Compare commits

...

5 Commits

Author SHA1 Message Date
gsinghpal
27e12dd544 chore(shopfloor): register fp_rpc.js asset + bump to 19.0.30.2.0 (P6.3.6)
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
Adds the Phase 6.3 fpRpc wrapper to the web.assets_backend bundle.
Placed before its consumers so the `import { fpRpc } from "./services/fp_rpc"`
calls in job_workspace, shopfloor_landing, manager_dashboard, and
hold_composer resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:47:51 -04:00
gsinghpal
5f03080374 feat(shopfloor): switch action-path RPCs to fpRpc + wire plant_overview/move_card (P6.3.5)
JobWorkspace, ShopfloorLanding, ManagerDashboard, and the embedded
FpHoldComposer now call fpRpc() for write-path endpoints (start/finish
step, hold create, sign-off, milestone advance, work-centre move,
assign-worker, assign-tank, manager takeover). fpRpc auto-injects
tablet_tech_id from the tech_store so the server can rebind env via
env_for_tablet_tech() and credit the right user.

Read-path RPCs (workspace/load, landing/kanban, manager/overview,
manager/funnel, manager/approval_inbox, manager/at_risk, shopfloor/scan)
stay as plain rpc() — no audit benefit, no need for the extra plumbing.

Also wires tablet_tech_id into /fp/shopfloor/plant_overview/move_card
which I missed in P6.3.3 — surfaced when grepping JS for write callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:47:20 -04:00
gsinghpal
efaf16dffb feat(shopfloor): propagate tablet_tech_id to shopfloor + manager action endpoints (P6.3.3 + P6.3.4)
10 endpoints in shopfloor_controller (log_chemistry, start_bake, end_bake,
start_wo, stop_wo, bump_qty_done, bump_qty_scrapped, log_thickness_reading,
quality_hold, mark_gate) and 3 in manager_controller (assign_worker,
assign_tank, take_over) now accept a `tablet_tech_id` kwarg. Each rebinds
env via env_for_tablet_tech() so writes carry the correct uid even when
the OS session belongs to the persistent tablet user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:43:44 -04:00
gsinghpal
e4000374ca feat(fusion_plating_shopfloor): wire tablet_tech_id into workspace endpoints (P6.3.2)
hold, sign_off, advance_milestone each accept tablet_tech_id and
rebind env via env_for_tablet_tech. Writes (Hold.create, button_finish,
action_advance_next_milestone) now carry the tech-of-record's uid.
load endpoint is read-only and untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:37:58 -04:00
gsinghpal
fee4219703 feat(fusion_plating_shopfloor): fpRpc wrapper + env_for_tablet_tech helper (P6.3.1)
Client-side fpRpc() is a drop-in for rpc() that automatically injects
tablet_tech_id from the tech_store into every action call. Read-only
endpoints can keep using plain rpc().

Server-side env_for_tablet_tech(env, tablet_tech_id) returns an env
scoped via with_user() when the id is a valid active user; otherwise
returns the original env unchanged. Controllers call this at the top
of action methods so all subsequent writes carry the right uid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:37:02 -04:00
11 changed files with 198 additions and 58 deletions

View File

@@ -166,6 +166,19 @@ These modules have **source code in this repo** but are **intentionally NOT inst
| `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. | | `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. |
| `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. | | `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. |
## Shop-floor action endpoints — credit the correct tech via `tablet_tech_id`
The tablet sits on a long-lived "shopfloor service" Odoo session shared by many techs. The actual tech-of-record is established via the PIN unlock (Phase 6); their id lives in the OWL `fp_shopfloor_tech_store` service and is sent as `tablet_tech_id` on every action RPC.
When writing a NEW shop-floor controller endpoint that **writes** (creates a record, calls a `button_*` method, posts to chatter):
1. Add `tablet_tech_id=None` as a kwarg on the route handler.
2. At the top, call: `env = env_for_tablet_tech(request.env, tablet_tech_id)` (from `fusion_plating_shopfloor/controllers/_tablet_audit.py`).
3. Use `env` (not `request.env`) for all subsequent writes. `env.with_user(...)` is applied internally so `create_uid` / `write_uid` / chatter authorship carry the right uid.
4. Read-only endpoints (load / kanban / funnel / overview) don't need this — leave them as `request.env`.
On the client side: use `fpRpc()` from `services/fp_rpc.js` (drop-in for `rpc()`) for action calls. It auto-injects `tablet_tech_id`. Read calls can keep using plain `rpc()`.
If `tablet_tech_id` is missing or invalid, `env_for_tablet_tech` falls back to the session uid — old callers and pre-Phase-6.3 endpoints continue working.
## Removing menus/records — Odoo does NOT auto-delete orphans ## Removing menus/records — Odoo does NOT auto-delete orphans
Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`: Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`:
```xml ```xml

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Shop Floor', 'name': 'Fusion Plating — Shop Floor',
'version': '19.0.30.1.0', 'version': '19.0.30.2.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.', 'first-piece inspection gates.',
@@ -85,6 +85,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
# ---- Phase 6.2 tablet PIN gate ---- # ---- Phase 6.2 tablet PIN gate ----
'fusion_plating_shopfloor/static/src/js/services/tech_store.js', 'fusion_plating_shopfloor/static/src/js/services/tech_store.js',
'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js', 'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js',
# Phase 6.3 — fpRpc wrapper. MUST load before any consumer
# (job_workspace, shopfloor_landing, manager_dashboard,
# hold_composer) so `import { fpRpc }` resolves.
'fusion_plating_shopfloor/static/src/js/services/fp_rpc.js',
'fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss', 'fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss',
'fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml', 'fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml',
'fusion_plating_shopfloor/static/src/js/components/pin_pad.js', 'fusion_plating_shopfloor/static/src/js/components/pin_pad.js',

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Helper for audit-credit propagation (Phase 6.3 tablet redesign).
Controllers that accept an optional `tablet_tech_id` kwarg use this
helper to switch their `env` to the tech-of-record before performing
writes. The result: chatter posts + create_uid/write_uid carry the
unlocked tech's identity, not the tablet's persistent session user.
"""
import logging
_logger = logging.getLogger(__name__)
def env_for_tablet_tech(env, tablet_tech_id):
"""Return an env scoped to `tablet_tech_id` if it's a valid user;
otherwise return the original env unchanged.
Validation: the user must exist and be active. We deliberately do
NOT cross-check that they actually unlocked recently — the OWL
component is the source of truth for "who's at the tablet right
now", and the only path that produces a tablet_tech_id is a
successful /fp/tablet/unlock followed by an active session in the
OWL tech_store.
"""
if not tablet_tech_id:
return env
try:
tech_id = int(tablet_tech_id)
except (TypeError, ValueError):
return env
User = env['res.users'].sudo()
tech = User.browse(tech_id)
if not tech.exists() or not tech.active:
_logger.warning(
"tablet_tech_id %s invalid (not found or inactive); "
"falling back to session uid %s",
tablet_tech_id, env.uid,
)
return env
return env(user=tech_id)

View File

@@ -25,6 +25,8 @@ from odoo import fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_format from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.http import request from odoo.http import request
from ._tablet_audit import env_for_tablet_tech
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -387,7 +389,8 @@ class FpManagerDashboardController(http.Controller):
# Assign a worker to a step # Assign a worker to a step
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user') @http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user')
def assign_worker(self, step_id=None, user_id=None, workorder_id=None, **kwargs): def assign_worker(self, step_id=None, user_id=None, workorder_id=None,
tablet_tech_id=None, **kwargs):
"""Assign an operator to a step. ``step_id`` is the canonical """Assign an operator to a step. ``step_id`` is the canonical
kwarg; ``workorder_id`` is accepted as a deprecated alias for kwarg; ``workorder_id`` is accepted as a deprecated alias for
one release so any caller we missed doesn't break. one release so any caller we missed doesn't break.
@@ -400,7 +403,8 @@ class FpManagerDashboardController(http.Controller):
step_id = workorder_id step_id = workorder_id
if not step_id: if not step_id:
return {'ok': False, 'error': 'step_id required'} return {'ok': False, 'error': 'step_id required'}
step = request.env['fp.job.step'].browse(int(step_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = env['fp.job.step'].browse(int(step_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': 'Step not found.'} return {'ok': False, 'error': 'Step not found.'}
step.assigned_user_id = int(user_id) if user_id else False step.assigned_user_id = int(user_id) if user_id else False
@@ -415,7 +419,8 @@ class FpManagerDashboardController(http.Controller):
# Reassign or swap tank on a step # Reassign or swap tank on a step
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user') @http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user')
def assign_tank(self, step_id=None, tank_id=None, workorder_id=None, **kwargs): def assign_tank(self, step_id=None, tank_id=None, workorder_id=None,
tablet_tech_id=None, **kwargs):
"""Swap the tank on a step. ``step_id`` is the canonical kwarg; """Swap the tank on a step. ``step_id`` is the canonical kwarg;
``workorder_id`` is accepted as a deprecated alias. ``workorder_id`` is accepted as a deprecated alias.
""" """
@@ -427,7 +432,8 @@ class FpManagerDashboardController(http.Controller):
step_id = workorder_id step_id = workorder_id
if not step_id: if not step_id:
return {'ok': False, 'error': 'step_id required'} return {'ok': False, 'error': 'step_id required'}
step = request.env['fp.job.step'].browse(int(step_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = env['fp.job.step'].browse(int(step_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': 'Step not found.'} return {'ok': False, 'error': 'Step not found.'}
step.tank_id = int(tank_id) if tank_id else False step.tank_id = int(tank_id) if tank_id else False
@@ -442,7 +448,8 @@ class FpManagerDashboardController(http.Controller):
# Manager takes over a step (no-show coverage) # Manager takes over a step (no-show coverage)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route('/fp/manager/take_over', type='jsonrpc', auth='user') @http.route('/fp/manager/take_over', type='jsonrpc', auth='user')
def take_over(self, step_id=None, workorder_id=None, **kwargs): def take_over(self, step_id=None, workorder_id=None,
tablet_tech_id=None, **kwargs):
"""Manager takes over a step. ``step_id`` is the canonical kwarg; """Manager takes over a step. ``step_id`` is the canonical kwarg;
``workorder_id`` is accepted as a deprecated alias. ``workorder_id`` is accepted as a deprecated alias.
""" """
@@ -454,10 +461,11 @@ class FpManagerDashboardController(http.Controller):
step_id = workorder_id step_id = workorder_id
if not step_id: if not step_id:
return {'ok': False, 'error': 'step_id required'} return {'ok': False, 'error': 'step_id required'}
step = request.env['fp.job.step'].browse(int(step_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = env['fp.job.step'].browse(int(step_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': 'Step not found.'} return {'ok': False, 'error': 'Step not found.'}
user = request.env.user user = env.user
previous = step.assigned_user_id.name or '' previous = step.assigned_user_id.name or ''
step.assigned_user_id = user.id step.assigned_user_id = user.id
step.message_post( step.message_post(

View File

@@ -20,6 +20,8 @@ from odoo.addons.fusion_plating.models.fp_tz import (
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.http import request from odoo.http import request
from ._tablet_audit import env_for_tablet_tech
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -255,11 +257,13 @@ class FpShopfloorController(http.Controller):
# Quick chemistry log from the tablet # Quick chemistry log from the tablet
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@http.route('/fp/shopfloor/log_chemistry', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/log_chemistry', type='jsonrpc', auth='user')
def log_chemistry(self, bath_id, readings, shift=None, notes=None): def log_chemistry(self, bath_id, readings, shift=None, notes=None,
tablet_tech_id=None):
"""Create a fusion.plating.bath.log with one line per reading.""" """Create a fusion.plating.bath.log with one line per reading."""
env = env_for_tablet_tech(request.env, tablet_tech_id)
if not bath_id: if not bath_id:
raise UserError("bath_id required") raise UserError("bath_id required")
bath = request.env['fusion.plating.bath'].browse(int(bath_id)) bath = env['fusion.plating.bath'].browse(int(bath_id))
if not bath.exists(): if not bath.exists():
raise UserError(f"Bath {bath_id} not found") raise UserError(f"Bath {bath_id} not found")
@@ -274,7 +278,7 @@ class FpShopfloorController(http.Controller):
'value': float(value) if value not in (None, '') else 0.0, 'value': float(value) if value not in (None, '') else 0.0,
})) }))
log = request.env['fusion.plating.bath.log'].create({ log = env['fusion.plating.bath.log'].create({
'bath_id': bath.id, 'bath_id': bath.id,
'shift': shift or False, 'shift': shift or False,
'notes': notes or False, 'notes': notes or False,
@@ -291,10 +295,11 @@ class FpShopfloorController(http.Controller):
# Bake window controls # Bake window controls
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@http.route('/fp/shopfloor/start_bake', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/start_bake', type='jsonrpc', auth='user')
def start_bake(self, bake_window_id, oven_id=None): def start_bake(self, bake_window_id, oven_id=None, tablet_tech_id=None):
# action_start_bake raises UserError for S6 missed_window. Wrap # action_start_bake raises UserError for S6 missed_window. Wrap
# the same way as start_wo so operator gets a clean flash. # the same way as start_wo so operator gets a clean flash.
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
bw = env['fusion.plating.bake.window'].browse(int(bake_window_id))
if not bw.exists(): if not bw.exists():
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'} return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
if oven_id: if oven_id:
@@ -306,12 +311,13 @@ class FpShopfloorController(http.Controller):
return { return {
'ok': True, 'ok': True,
'state': bw.state, 'state': bw.state,
'bake_start_time': fp_format(request.env, bw.bake_start_time), 'bake_start_time': fp_format(env, bw.bake_start_time),
} }
@http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user')
def end_bake(self, bake_window_id): def end_bake(self, bake_window_id, tablet_tech_id=None):
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
bw = env['fusion.plating.bake.window'].browse(int(bake_window_id))
if not bw.exists(): if not bw.exists():
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'} return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
try: try:
@@ -321,7 +327,7 @@ class FpShopfloorController(http.Controller):
return { return {
'ok': True, 'ok': True,
'state': bw.state, 'state': bw.state,
'bake_end_time': fp_format(request.env, bw.bake_end_time), 'bake_end_time': fp_format(env, bw.bake_end_time),
'bake_duration_hours': bw.bake_duration_hours, 'bake_duration_hours': bw.bake_duration_hours,
} }
@@ -340,8 +346,15 @@ class FpShopfloorController(http.Controller):
step = request.env['fp.job.step'].browse(int(sid)) step = request.env['fp.job.step'].browse(int(sid))
return step if step.exists() else False return step if step.exists() else False
def _resolve_step_in_env(self, env, step_id=None, workorder_id=None):
sid = step_id if step_id else workorder_id
if not sid:
return False
step = env['fp.job.step'].browse(int(sid))
return step if step.exists() else False
@http.route('/fp/shopfloor/start_wo', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/start_wo', type='jsonrpc', auth='user')
def start_wo(self, workorder_id=None, step_id=None): def start_wo(self, workorder_id=None, step_id=None, tablet_tech_id=None):
"""Start the timer on a fp.job.step (called from the tablet). """Start the timer on a fp.job.step (called from the tablet).
button_start() can raise UserError for any guarded condition button_start() can raise UserError for any guarded condition
@@ -350,7 +363,8 @@ class FpShopfloorController(http.Controller):
the explicit state check, so the tablet flashes a clean toast the explicit state check, so the tablet flashes a clean toast
instead of popping a stack-trace dialog at the operator. instead of popping a stack-trace dialog at the operator.
""" """
step = self._resolve_step(step_id, workorder_id) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = self._resolve_step_in_env(env, step_id, workorder_id)
if not step: if not step:
return {'ok': False, 'error': 'Step not found'} return {'ok': False, 'error': 'Step not found'}
if not _step_can_start(step): if not _step_can_start(step):
@@ -369,7 +383,8 @@ class FpShopfloorController(http.Controller):
} }
@http.route('/fp/shopfloor/stop_wo', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/stop_wo', type='jsonrpc', auth='user')
def stop_wo(self, workorder_id=None, step_id=None, finish=False): def stop_wo(self, workorder_id=None, step_id=None, finish=False,
tablet_tech_id=None):
"""Finish the timer on a fp.job.step. """Finish the timer on a fp.job.step.
finish=True calls button_finish(); other values are no-ops for finish=True calls button_finish(); other values are no-ops for
@@ -380,7 +395,8 @@ class FpShopfloorController(http.Controller):
not provided). Wrapped same as start_wo so the operator gets a not provided). Wrapped same as start_wo so the operator gets a
clean flash, not a stack-trace dialog. clean flash, not a stack-trace dialog.
""" """
step = self._resolve_step(step_id, workorder_id) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = self._resolve_step_in_env(env, step_id, workorder_id)
if not step: if not step:
return {'ok': False, 'error': 'Step not found'} return {'ok': False, 'error': 'Step not found'}
if finish: if finish:
@@ -409,11 +425,12 @@ class FpShopfloorController(http.Controller):
# both with a single tap. Scrap auto-spawns a hold via fp.job.write # both with a single tap. Scrap auto-spawns a hold via fp.job.write
# (S17 hook, no extra wiring needed here). # (S17 hook, no extra wiring needed here).
@http.route('/fp/shopfloor/bump_qty_done', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/bump_qty_done', type='jsonrpc', auth='user')
def bump_qty_done(self, job_id, delta=1): def bump_qty_done(self, job_id, delta=1, tablet_tech_id=None):
"""Increment job.qty_done by `delta` (defaults to +1). """Increment job.qty_done by `delta` (defaults to +1).
Returns the new totals so the tablet can update without a full refresh. Returns the new totals so the tablet can update without a full refresh.
""" """
job = request.env['fp.job'].browse(int(job_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': 'Job not found'} return {'ok': False, 'error': 'Job not found'}
try: try:
@@ -433,13 +450,15 @@ class FpShopfloorController(http.Controller):
} }
@http.route('/fp/shopfloor/bump_qty_scrapped', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/bump_qty_scrapped', type='jsonrpc', auth='user')
def bump_qty_scrapped(self, job_id, delta=1, reason=None): def bump_qty_scrapped(self, job_id, delta=1, reason=None,
tablet_tech_id=None):
"""Increment job.qty_scrapped by `delta`. The S17 write-hook on """Increment job.qty_scrapped by `delta`. The S17 write-hook on
fp.job auto-spawns a fusion.plating.quality.hold for the delta; fp.job auto-spawns a fusion.plating.quality.hold for the delta;
the operator can edit the description on that hold later. the operator can edit the description on that hold later.
`reason` is optional — passed through to the hold's description. `reason` is optional — passed through to the hold's description.
""" """
job = request.env['fp.job'].browse(int(job_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': 'Job not found'} return {'ok': False, 'error': 'Job not found'}
try: try:
@@ -470,20 +489,22 @@ class FpShopfloorController(http.Controller):
position_label=None, reading_number=None, position_label=None, reading_number=None,
equipment_model=None, calibration_std_ref=None, equipment_model=None, calibration_std_ref=None,
microscope_image=None, microscope_image=None,
microscope_image_filename=None): microscope_image_filename=None,
tablet_tech_id=None):
"""Record a single Fischerscope reading against a job. """Record a single Fischerscope reading against a job.
`job_id` is the canonical kwarg; `production_id` is accepted as an `job_id` is the canonical kwarg; `production_id` is accepted as an
alias for older clients. The reading auto-links to an existing alias for older clients. The reading auto-links to an existing
CoC certificate for the job when one exists. CoC certificate for the job when one exists.
""" """
Reading = request.env.get('fp.thickness.reading') env = env_for_tablet_tech(request.env, tablet_tech_id)
Reading = env.get('fp.thickness.reading')
if Reading is None: if Reading is None:
return {'ok': False, 'error': 'Certificates module not installed'} return {'ok': False, 'error': 'Certificates module not installed'}
target_id = job_id or production_id target_id = job_id or production_id
if not target_id: if not target_id:
return {'ok': False, 'error': 'job_id required'} return {'ok': False, 'error': 'job_id required'}
job = request.env['fp.job'].browse(int(target_id)) job = env['fp.job'].browse(int(target_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': f'Job {target_id} not found'} return {'ok': False, 'error': f'Job {target_id} not found'}
@@ -508,7 +529,7 @@ class FpShopfloorController(http.Controller):
'ni_percent': float(ni_percent or 0.0), 'ni_percent': float(ni_percent or 0.0),
'p_percent': float(p_percent or 0.0), 'p_percent': float(p_percent or 0.0),
'position_label': position_label or '', 'position_label': position_label or '',
'operator_id': request.env.user.id, 'operator_id': env.user.id,
} }
if equipment_model: if equipment_model:
@@ -516,7 +537,7 @@ class FpShopfloorController(http.Controller):
if calibration_std_ref: if calibration_std_ref:
vals['calibration_std_ref'] = calibration_std_ref vals['calibration_std_ref'] = calibration_std_ref
if microscope_image: if microscope_image:
att = request.env['ir.attachment'].create({ att = env['ir.attachment'].create({
'name': microscope_image_filename or f'thickness_{reading_number}.jpg', 'name': microscope_image_filename or f'thickness_{reading_number}.jpg',
'datas': microscope_image, 'datas': microscope_image,
'res_model': 'fp.thickness.reading', 'res_model': 'fp.thickness.reading',
@@ -525,7 +546,7 @@ class FpShopfloorController(http.Controller):
vals['microscope_image_id'] = att.id vals['microscope_image_id'] = att.id
# Auto-link to an existing CoC if there is one for this job. # Auto-link to an existing CoC if there is one for this job.
Cert = request.env.get('fp.certificate') Cert = env.get('fp.certificate')
if Cert is not None: if Cert is not None:
if 'x_fc_job_id' in Cert._fields: if 'x_fc_job_id' in Cert._fields:
cert_field = 'x_fc_job_id' cert_field = 'x_fc_job_id'
@@ -557,7 +578,8 @@ class FpShopfloorController(http.Controller):
part_ref=None, qty_on_hold=0, qty_original=0, part_ref=None, qty_on_hold=0, qty_original=0,
hold_reason='other', description=None, hold_reason='other', description=None,
mark_for_scrap=False, facility_id=None, mark_for_scrap=False, facility_id=None,
work_center_id=None, current_process_node=None): work_center_id=None, current_process_node=None,
tablet_tech_id=None):
"""Create a quality hold record, splitting qty from the original lot. """Create a quality hold record, splitting qty from the original lot.
The hold is linked to the fp.job and (when provided) the The hold is linked to the fp.job and (when provided) the
@@ -566,7 +588,8 @@ class FpShopfloorController(http.Controller):
if not qty_on_hold or int(qty_on_hold) <= 0: if not qty_on_hold or int(qty_on_hold) <= 0:
raise UserError("qty_on_hold must be a positive integer.") raise UserError("qty_on_hold must be a positive integer.")
Hold = request.env['fusion.plating.quality.hold'] env = env_for_tablet_tech(request.env, tablet_tech_id)
Hold = env['fusion.plating.quality.hold']
vals = { vals = {
'part_ref': part_ref or '', 'part_ref': part_ref or '',
@@ -583,7 +606,7 @@ class FpShopfloorController(http.Controller):
if work_center_id: if work_center_id:
vals['work_center_id'] = int(work_center_id) vals['work_center_id'] = int(work_center_id)
if portal_job_id: if portal_job_id:
pj = request.env['fusion.plating.portal.job'].browse( pj = env['fusion.plating.portal.job'].browse(
int(portal_job_id), int(portal_job_id),
) )
if pj.exists(): if pj.exists():
@@ -594,7 +617,7 @@ class FpShopfloorController(http.Controller):
# via fusion_plating_jobs (Phase 3) as `x_fc_job_id` / `x_fc_step_id`. # via fusion_plating_jobs (Phase 3) as `x_fc_job_id` / `x_fc_step_id`.
step_target_id = step_id or workorder_id step_target_id = step_id or workorder_id
if step_target_id: if step_target_id:
step = request.env['fp.job.step'].browse(int(step_target_id)) step = env['fp.job.step'].browse(int(step_target_id))
if step.exists(): if step.exists():
if 'x_fc_step_id' in Hold._fields: if 'x_fc_step_id' in Hold._fields:
vals['x_fc_step_id'] = step.id vals['x_fc_step_id'] = step.id
@@ -605,7 +628,7 @@ class FpShopfloorController(http.Controller):
# set it through the step. # set it through the step.
if (job_id and 'x_fc_job_id' in Hold._fields if (job_id and 'x_fc_job_id' in Hold._fields
and not vals.get('x_fc_job_id')): and not vals.get('x_fc_job_id')):
j = request.env['fp.job'].browse(int(job_id)) j = env['fp.job'].browse(int(job_id))
if j.exists(): if j.exists():
vals['x_fc_job_id'] = j.id vals['x_fc_job_id'] = j.id
@@ -995,8 +1018,9 @@ class FpShopfloorController(http.Controller):
# Mark a first-piece gate result from the tablet # Mark a first-piece gate result from the tablet
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user')
def mark_gate(self, gate_id, result): def mark_gate(self, gate_id, result, tablet_tech_id=None):
gate = request.env['fusion.plating.first.piece.gate'].browse(int(gate_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
gate = env['fusion.plating.first.piece.gate'].browse(int(gate_id))
if not gate.exists(): if not gate.exists():
return {'ok': False, 'error': 'Gate not found.'} return {'ok': False, 'error': 'Gate not found.'}
try: try:
@@ -1084,21 +1108,23 @@ class FpShopfloorController(http.Controller):
@http.route('/fp/shopfloor/plant_overview/move_card', @http.route('/fp/shopfloor/plant_overview/move_card',
type='jsonrpc', auth='user') type='jsonrpc', auth='user')
def plant_overview_move_card(self, card_id, source_model=None, def plant_overview_move_card(self, card_id, source_model=None,
target_workcenter_id=None): target_workcenter_id=None,
tablet_tech_id=None):
"""Move a step card to a different work centre (drag & drop). """Move a step card to a different work centre (drag & drop).
`source_model` is accepted for backward compatibility but ignored — `source_model` is accepted for backward compatibility but ignored —
Plant Overview now only ever serves fp.job.step cards. A target Plant Overview now only ever serves fp.job.step cards. A target
of 0 / falsy clears the work centre. of 0 / falsy clears the work centre.
""" """
Step = request.env['fp.job.step'] env = env_for_tablet_tech(request.env, tablet_tech_id)
Step = env['fp.job.step']
step = Step.browse(int(card_id)) step = Step.browse(int(card_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': f'Step {card_id} not found.'} return {'ok': False, 'error': f'Step {card_id} not found.'}
wc_id = int(target_workcenter_id) if target_workcenter_id else False wc_id = int(target_workcenter_id) if target_workcenter_id else False
if wc_id: if wc_id:
wc = request.env['fp.work.centre'].browse(wc_id) wc = env['fp.work.centre'].browse(wc_id)
if not wc.exists(): if not wc.exists():
return {'ok': False, return {'ok': False,
'error': f'Work centre {target_workcenter_id} not found.'} 'error': f'Work centre {target_workcenter_id} not found.'}
@@ -1108,7 +1134,7 @@ class FpShopfloorController(http.Controller):
_logger.info( _logger.info(
'Plant Overview: moved step %s (%s) → WC %s by uid %s', 'Plant Overview: moved step %s (%s) → WC %s by uid %s',
step.id, step.name, wc_id or 'unassigned', step.id, step.name, wc_id or 'unassigned',
request.env.uid, env.uid,
) )
except Exception as exc: except Exception as exc:
_logger.exception('Plant Overview move_card failed') _logger.exception('Plant Overview move_card failed')

View File

@@ -23,6 +23,8 @@ from odoo import fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_format from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.http import request from odoo.http import request
from ._tablet_audit import env_for_tablet_tech
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -190,8 +192,8 @@ class FpWorkspaceController(http.Controller):
@http.route('/fp/workspace/hold', type='jsonrpc', auth='user') @http.route('/fp/workspace/hold', type='jsonrpc', auth='user')
def hold(self, job_id, reason='other', qty_on_hold=1, description='', def hold(self, job_id, reason='other', qty_on_hold=1, description='',
part_ref='', step_id=None, mark_for_scrap=False, part_ref='', step_id=None, mark_for_scrap=False,
photo_data=None, photo_filename=None): photo_data=None, photo_filename=None, tablet_tech_id=None):
env = request.env env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id)) job = env['fp.job'].browse(int(job_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': f'Job {job_id} not found'} return {'ok': False, 'error': f'Job {job_id} not found'}
@@ -253,8 +255,8 @@ class FpWorkspaceController(http.Controller):
# /fp/workspace/sign_off — capture signature + finish step atomically # /fp/workspace/sign_off — capture signature + finish step atomically
# ====================================================================== # ======================================================================
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user') @http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
def sign_off(self, step_id, signature_data_uri): def sign_off(self, step_id, signature_data_uri, tablet_tech_id=None):
env = request.env env = env_for_tablet_tech(request.env, tablet_tech_id)
sig = (signature_data_uri or '').strip() sig = (signature_data_uri or '').strip()
if not sig: if not sig:
_logger.warning("workspace/sign_off: empty signature for step %s", step_id) _logger.warning("workspace/sign_off: empty signature for step %s", step_id)
@@ -302,8 +304,8 @@ class FpWorkspaceController(http.Controller):
# /fp/workspace/advance_milestone — fire next_milestone_action # /fp/workspace/advance_milestone — fire next_milestone_action
# ====================================================================== # ======================================================================
@http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user') @http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
def advance_milestone(self, job_id): def advance_milestone(self, job_id, tablet_tech_id=None):
env = request.env env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id)) job = env['fp.job'].browse(int(job_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': f'Job {job_id} not found'} return {'ok': False, 'error': f'Job {job_id} not found'}

View File

@@ -14,7 +14,7 @@
import { Component, useState } from "@odoo/owl"; import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog"; import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc"; import { fpRpc } from "../services/fp_rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
// Hold reasons kept here so the picker doesn't need a server roundtrip. // Hold reasons kept here so the picker doesn't need a server roundtrip.
@@ -75,7 +75,7 @@ export class FpHoldComposer extends Component {
} }
this.state.submitting = true; this.state.submitting = true;
try { try {
const res = await rpc("/fp/workspace/hold", { const res = await fpRpc("/fp/workspace/hold", {
job_id: this.props.jobId, job_id: this.props.jobId,
step_id: this.props.stepId || null, step_id: this.props.stepId || null,
part_ref: this.props.partRef || "", part_ref: this.props.partRef || "",

View File

@@ -20,6 +20,7 @@
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc"; import { rpc } from "@web/core/network/rpc";
import { fpRpc } from "./services/fp_rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { WorkflowChip } from "./components/workflow_chip"; import { WorkflowChip } from "./components/workflow_chip";
import { GateViz } from "./components/gate_viz"; import { GateViz } from "./components/gate_viz";
@@ -116,7 +117,7 @@ export class FpJobWorkspace extends Component {
// ---- Step actions ------------------------------------------------------ // ---- Step actions ------------------------------------------------------
async onStartStep(stepId) { async onStartStep(stepId) {
try { try {
const res = await rpc("/fp/shopfloor/start_wo", { workorder_id: stepId }); const res = await fpRpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
if (res && res.ok) { if (res && res.ok) {
this.notification.add("Step started.", { type: "success" }); this.notification.add("Step started.", { type: "success" });
await this.refresh(); await this.refresh();
@@ -135,7 +136,7 @@ export class FpJobWorkspace extends Component {
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`, contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
onSubmit: async (dataUri) => { onSubmit: async (dataUri) => {
try { try {
const res = await rpc("/fp/workspace/sign_off", { const res = await fpRpc("/fp/workspace/sign_off", {
step_id: step.id, step_id: step.id,
signature_data_uri: dataUri, signature_data_uri: dataUri,
}); });
@@ -154,7 +155,7 @@ export class FpJobWorkspace extends Component {
} }
// Plain finish — no signature required // Plain finish — no signature required
try { try {
const res = await rpc("/fp/shopfloor/stop_wo", { const res = await fpRpc("/fp/shopfloor/stop_wo", {
workorder_id: step.id, finish: true, workorder_id: step.id, finish: true,
}); });
if (res && res.ok) { if (res && res.ok) {
@@ -201,7 +202,7 @@ export class FpJobWorkspace extends Component {
async onAdvanceMilestone() { async onAdvanceMilestone() {
try { try {
const res = await rpc("/fp/workspace/advance_milestone", { const res = await fpRpc("/fp/workspace/advance_milestone", {
job_id: this.state.jobId, job_id: this.state.jobId,
}); });
if (res && res.ok) { if (res && res.ok) {

View File

@@ -15,6 +15,7 @@
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc"; import { rpc } from "@web/core/network/rpc";
import { fpRpc } from "./services/fp_rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner"; import { QrScanner } from "./qr_scanner";
import { FpTabletLock } from "./tablet_lock"; import { FpTabletLock } from "./tablet_lock";
@@ -208,7 +209,7 @@ export class ManagerDashboard extends Component {
async onAssignWorker(step, userIdRaw) { async onAssignWorker(step, userIdRaw) {
const userId = parseInt(userIdRaw) || null; const userId = parseInt(userIdRaw) || null;
try { try {
const res = await rpc("/fp/manager/assign_worker", { const res = await fpRpc("/fp/manager/assign_worker", {
step_id: step.id, user_id: userId, step_id: step.id, user_id: userId,
}); });
if (res && res.ok) { if (res && res.ok) {
@@ -226,7 +227,7 @@ export class ManagerDashboard extends Component {
async onAssignTank(step, tankIdRaw) { async onAssignTank(step, tankIdRaw) {
const tankId = parseInt(tankIdRaw) || null; const tankId = parseInt(tankIdRaw) || null;
try { try {
const res = await rpc("/fp/manager/assign_tank", { const res = await fpRpc("/fp/manager/assign_tank", {
step_id: step.id, tank_id: tankId, step_id: step.id, tank_id: tankId,
}); });
if (res && res.ok) { if (res && res.ok) {
@@ -243,7 +244,7 @@ export class ManagerDashboard extends Component {
async onTakeOver(step) { async onTakeOver(step) {
try { try {
const res = await rpc("/fp/manager/take_over", { const res = await fpRpc("/fp/manager/take_over", {
step_id: step.id, step_id: step.id,
}); });
if (res && res.ok) { if (res && res.ok) {

View File

@@ -0,0 +1,42 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — fpRpc() wrapper
//
// Drop-in replacement for the standard `rpc()` import. Automatically
// injects the current tablet_tech_id from the tech_store into every
// call, so server-side endpoints can attribute the action to the right
// user via env.with_user() (see env_for_tablet_tech in
// controllers/_tablet_audit.py).
//
// USE for any RPC that WRITES (start step, finish step, hold create,
// sign-off, milestone advance). For read-only loads (kanban, workspace
// load, manager funnel), plain rpc() is fine.
//
// Example:
// import { fpRpc } from "../services/fp_rpc";
// await fpRpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
//
// =============================================================================
import { rpc as baseRpc } from "@web/core/network/rpc";
function _getTechStore() {
// Lazy-resolve via the global debug API — avoids circular service init
try {
const env = odoo.__WOWL_DEBUG__?.root?.env;
if (env && env.services && env.services.fp_shopfloor_tech_store) {
return env.services.fp_shopfloor_tech_store;
}
} catch (e) {
// ignore
}
return null;
}
export function fpRpc(url, params = {}) {
const techStore = _getTechStore();
if (techStore && techStore.currentTechId) {
params = { ...params, tablet_tech_id: techStore.currentTechId };
}
return baseRpc(url, params);
}

View File

@@ -20,6 +20,7 @@
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc"; import { rpc } from "@web/core/network/rpc";
import { fpRpc } from "./services/fp_rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner"; import { QrScanner } from "./qr_scanner";
import { FpKanbanCard } from "./components/kanban_card"; import { FpKanbanCard } from "./components/kanban_card";
@@ -254,7 +255,7 @@ export class FpShopfloorLanding extends Component {
this._movesInFlight += 1; this._movesInFlight += 1;
this._lastDropAt = Date.now(); this._lastDropAt = Date.now();
try { try {
const res = await rpc("/fp/shopfloor/plant_overview/move_card", { const res = await fpRpc("/fp/shopfloor/plant_overview/move_card", {
card_id: dragged.id, card_id: dragged.id,
target_workcenter_id: col.work_center_id, target_workcenter_id: col.work_center_id,
}); });