fix(plating): self-heal work orders missing recipe/steps + repair existing

Root cause: jobs auto-created at SO confirm resolve the recipe once; if the
SO line's process variant is not set at that instant (new parts, or the
copy-from-quote path), the WO is created with no recipe and no steps, and
setting the recipe on the line afterward never propagates (idempotency
guard + no line-to-job sync).

Fix (fusion_plating_jobs 19.0.12.6.0):
- fp.job._fp_resync_recipe_from_so(): re-resolve recipe from the SO line(s)
  and build steps (mirrors action_confirm: generate, promote pending to
  ready, express overrides). Acts only on not-started jobs; idempotent.
- action_fp_resync_recipe_from_so(): "Re-sync Recipe from SO" header button.
- sale.order.line.write: when x_fc_process_variant_id changes, auto-heal the
  linked not-yet-started WO.

Verified on entech: WO-30071-01/02 healed (recipe + 8 steps each); auto-
propagation confirmed in a rolled-back transaction. Deployed in place
(fusion_plating_jobs is ahead of git); same change recorded here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-05 01:14:57 -04:00
parent 88e1e5e9bb
commit a6186120b2
4 changed files with 85 additions and 1 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating - Native Jobs',
'version': '19.0.12.5.0',
'version': '19.0.12.6.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model - replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -1385,6 +1385,70 @@ class FpJob(models.Model):
# - Drops work_role_id (not on fp.job.step yet - Task 2.6+)
# - Drops _fp_autofill_default_equipment (not yet on step)
# ------------------------------------------------------------------
def _fp_resync_recipe_from_so(self):
"""Re-resolve the recipe from this job's SO line(s) and build its
steps. Heals work orders created before the recipe was set on the
SO line (new parts, or the copy-from-quote path): once the
estimator sets the process variant on the line, the WO can pull it
in. Acts only on jobs with NO steps yet and not terminal, so
in-progress work is never disturbed. Idempotent. Returns True when
steps were generated.
"""
self.ensure_one()
if self.step_ids or self.state in ('done', 'cancelled'):
return False
so = self.sale_order_id
if not so:
return False
recipe = False
for line in self.sale_order_line_ids:
recipe = so._fp_resolve_recipe_for_line(line)
if recipe:
break
if not recipe:
return False
if self.recipe_id != recipe:
self.recipe_id = recipe.id
# Mirror action_confirm's post-recipe sequence so a healed WO
# matches a normally-confirmed one (steps, ready, express text).
self._generate_steps_from_recipe()
pending = self.step_ids.filtered(lambda s: s.state == 'pending')
if pending:
pending.write({'state': 'ready'})
if (self.recipe_id and self.step_ids
and 'x_fc_masking_enabled' in self.env['sale.order.line']._fields):
for sol in self.sale_order_line_ids:
if hasattr(sol, '_fp_apply_express_overrides_to_job'):
sol._fp_apply_express_overrides_to_job(self)
self.message_post(body=_(
'Recipe re-synced from the sale order (%(recipe)s); %(n)d '
'step(s) generated.'
) % {'recipe': recipe.display_name, 'n': len(self.step_ids)})
return True
def action_fp_resync_recipe_from_so(self):
"""Header button: re-pull the recipe from the SO and build steps
for work orders that came out empty because the recipe was set on
the SO line after the WO was created. Safe and idempotent.
"""
healed = self.env['fp.job']
for job in self:
if job._fp_resync_recipe_from_so():
healed |= job
if not healed:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'warning',
'message': _('Nothing to re-sync: no resolvable recipe '
'on the sale order, or the job already has '
'steps.'),
'sticky': False,
},
}
return True
def _generate_steps_from_recipe(self):
"""Generate fp.job.step records from the assigned recipe.

View File

@@ -25,6 +25,18 @@ class SaleOrderLine(models.Model):
for line in self:
old_qty_by_id[line.id] = line.product_uom_qty
result = super().write(vals)
# Recipe set/changed late on the line -> heal the linked WO that
# was created empty before the estimator picked the process. Only
# not-yet-started jobs (no steps) are touched.
if 'x_fc_process_variant_id' in vals:
Job = self.env['fp.job']
for line in self:
jobs = Job.search([
('sale_order_line_ids', 'in', line.id),
('state', 'not in', ('done', 'cancelled')),
])
for job in jobs.filtered(lambda j: not j.step_ids):
job.sudo()._fp_resync_recipe_from_so()
if 'product_uom_qty' not in vals:
return result
Job = self.env['fp.job']

View File

@@ -20,6 +20,14 @@
<field name="inherit_id" ref="fusion_plating.view_fp_job_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<!-- Heal a WO that was created before the recipe was set
on the SO line: re-pull the recipe and build steps.
Hidden once steps exist or the job is terminal. -->
<button name="action_fp_resync_recipe_from_so" type="object"
string="Re-sync Recipe from SO"
class="btn-secondary"
icon="fa-refresh"
invisible="state in ('done', 'cancelled') or step_ids"/>
<!-- Phase 1 - Tablet redesign. Opens the JobWorkspace OWL
client action focused on this WO. Primary entry point
for techs before the Landing kanban (Phase 3) ships;