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>
This commit is contained in:
gsinghpal
2026-05-23 00:43:44 -04:00
parent e4000374ca
commit efaf16dffb
2 changed files with 69 additions and 37 deletions

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: