From 913311653fd024d620263e3a6b908effc53f97dd Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Mon, 11 May 2026 22:40:25 -0400 Subject: [PATCH] feat(jobs+certs): milestone-cascade Phase 1 + session patch catch-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the milestone-cascade design (Phase 1) and catches the fusion_plating_jobs / fusion_plating_certificates source up to entech. Milestone cascade (this PR's core): - fp.job: new computes all_steps_terminal, next_milestone_action, next_milestone_label; dispatcher action_advance_next_milestone with 3 helpers (_action_open_draft_certs, _action_open_draft_delivery, _action_mark_active_delivery_delivered); _resolve_required_cert_types resolver; _fp_create_certificates rewritten to honour part.certificate_requirement + partner flags + loop over resolved cert types - fp.job.workflow.state: new trigger_on_delivery_state Boolean; _fp_is_passed_for_job extended with delivery-state branch; Shipped state seed reroutes from default_kind=ship to the new trigger - View: hide Finish & Next when all_steps_terminal; add 4 mutually- exclusive milestone buttons (Mark Job Done / Issue Certs / Schedule Delivery / Mark Shipped) bound to one dispatcher - Cert gate (fusion_plating_certificates/models/fp_delivery.py): action_mark_delivered hard-blocks on draft certs; manager bypass via fp_skip_cert_gate=True context key - 24 unit tests in test_fp_job_milestone_cascade.py covering computes, resolver, dispatcher, cert gate - Spec: docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md - Plan: docs/superpowers/plans/2026-05-12-job-milestone-cascade.md Other entech changes caught up in this sync (from earlier session patches not previously committed): - fp.job version bump series 18.x → 19.0 - res_users_views.xml addition (signature widget in user prefs) - racking inspection smart button removal - various view/manifest touch-ups Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__manifest__.py | 3 +- .../models/__init__.py | 1 + .../models/fp_delivery.py | 59 +++ .../fusion_plating_jobs/__manifest__.py | 12 +- .../data/fp_workflow_state_data.xml | 4 +- .../fusion_plating_jobs/models/fp_job.py | 380 ++++++++++++++---- .../models/fp_job_workflow_state.py | 18 + .../fusion_plating_jobs/tests/__init__.py | 1 + .../tests/test_fp_job_milestone_cascade.py | 333 +++++++++++++++ .../views/fp_job_form_inherit.xml | 50 ++- 10 files changed, 755 insertions(+), 106 deletions(-) create mode 100644 fusion_plating/fusion_plating_certificates/models/fp_delivery.py create mode 100644 fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py index bfb59d29..53315e54 100644 --- a/fusion_plating/fusion_plating_certificates/__manifest__.py +++ b/fusion_plating/fusion_plating_certificates/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Certificates', - 'version': '19.0.5.4.0', + 'version': '19.0.5.5.0', 'category': 'Manufacturing/Plating', 'summary': 'Certificate registry for CoC, thickness reports, and quality documents.', 'description': """ @@ -27,6 +27,7 @@ Includes Fischerscope thickness measurement data capture. 'fusion_plating_portal', 'fusion_plating_batch', 'fusion_plating_configurator', + 'fusion_plating_logistics', 'sale_management', ], 'data': [ diff --git a/fusion_plating/fusion_plating_certificates/models/__init__.py b/fusion_plating/fusion_plating_certificates/models/__init__.py index 9b8ea7b7..999c44d1 100644 --- a/fusion_plating/fusion_plating_certificates/models/__init__.py +++ b/fusion_plating/fusion_plating_certificates/models/__init__.py @@ -7,3 +7,4 @@ from . import fp_thickness_reading from . import fp_certificate from . import res_config_settings from . import res_partner +from . import fp_delivery diff --git a/fusion_plating/fusion_plating_certificates/models/fp_delivery.py b/fusion_plating/fusion_plating_certificates/models/fp_delivery.py new file mode 100644 index 00000000..c433ad3b --- /dev/null +++ b/fusion_plating/fusion_plating_certificates/models/fp_delivery.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""Cert-aware extension of fusion.plating.delivery. + +Hard-blocks action_mark_delivered when the linked job still has any +draft certificate (CoC or Thickness Report). AS9100 / Nadcap +compliance: parts can't ship without paperwork. + +Manager bypass: pass context key `fp_skip_cert_gate=True` (matches +the existing bypass convention on fp.job.button_mark_done). +""" +from odoo import _, models +from odoo.exceptions import UserError + + +class FusionPlatingDelivery(models.Model): + _inherit = 'fusion.plating.delivery' + + def action_mark_delivered(self): + if not self.env.context.get('fp_skip_cert_gate'): + Cert = self.env.get('fp.certificate') + Job = self.env.get('fp.job') + if Cert is not None and Job is not None: + for delivery in self: + if not delivery.job_ref: + continue + job = Job.search( + [('name', '=', delivery.job_ref)], limit=1, + ) + if not job: + continue + dom = [('state', '=', 'draft')] + if 'x_fc_job_id' in Cert._fields: + dom.append(('x_fc_job_id', '=', job.id)) + elif (job.sale_order_id + and 'sale_order_id' in Cert._fields): + dom.append(( + 'sale_order_id', '=', job.sale_order_id.id, + )) + else: + continue + draft_certs = Cert.search(dom) + if draft_certs: + raise UserError(_( + 'Cannot mark delivery %(d)s shipped — job ' + '%(j)s still has %(n)d draft ' + 'certificate(s) (%(types)s). Issue them ' + 'first, or pass fp_skip_cert_gate=True ' + 'context key to bypass.' + ) % { + 'd': delivery.name or delivery.id, + 'j': job.name, + 'n': len(draft_certs), + 'types': ', '.join(sorted(set( + draft_certs.mapped('certificate_type') + ))), + }) + return super().action_mark_delivered() diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 31d13c9b..037ce1ee 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.8.20.6', + 'version': '19.0.8.19.6', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', @@ -20,10 +20,10 @@ Bridges fp.job and fp.job.step (defined in fusion_plating core, Phase 1 of the migration spec dated 2026-04-25) to the rest of the Fusion Plating module family — configurator, portal, logistics, quality, certificates. -As of Sub 11 (2026-04-26), MRP is uninstalled and fp.job is the only -fulfilment path. SO confirm always creates fp.job records here. The -former x_fc_use_native_jobs migration toggle was removed in 19.0.8.19.0 -once the legacy fallback became unreachable. +Coexists with fusion_plating_bridge_mrp during the migration period. +Activate native jobs via the x_fc_use_native_jobs settings flag (default: +False). When False, SO confirm continues to create mrp.production records +through bridge_mrp. When True, SO confirm creates fp.job records here. 19.0.4.0.0 (2026-04-24): Operator UI consolidation. The parallel OWL/controller stack (job_process_tree, job_plant_overview, @@ -57,6 +57,7 @@ full design rationale and §6.2 of the implementation plan for task list. # so the statusbar's m2o has its targets available at view-render time). 'data/fp_workflow_state_data.xml', 'views/fp_workflow_state_views.xml', + 'views/res_config_settings_views.xml', 'views/fp_job_step_quick_look_views.xml', 'views/fp_job_form_inherit.xml', 'views/fp_job_quality_buttons.xml', @@ -66,6 +67,7 @@ full design rationale and §6.2 of the implementation plan for task list. 'views/fp_step_priority_views.xml', 'views/jobs_in_shopfloor_menu.xml', 'views/legacy_menu_hide.xml', + 'views/res_users_views.xml', 'wizards/fp_job_step_move_wizard_views.xml', 'wizards/fp_job_step_input_wizard_views.xml', 'report/report_fp_job_sticker.xml', diff --git a/fusion_plating/fusion_plating_jobs/data/fp_workflow_state_data.xml b/fusion_plating/fusion_plating_jobs/data/fp_workflow_state_data.xml index ade92f76..e8302b13 100644 --- a/fusion_plating/fusion_plating_jobs/data/fp_workflow_state_data.xml +++ b/fusion_plating/fusion_plating_jobs/data/fp_workflow_state_data.xml @@ -61,8 +61,8 @@ shipped 60 success - ship - Shipment confirmed (BOL or carrier pickup). Customer can be notified. + + Shipment confirmed (delivery marked delivered). Customer can be notified. diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index fccfde84..baf7005d 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -106,6 +106,8 @@ class FpJob(models.Model): 'step_ids.recipe_node_id.default_kind', 'step_ids.recipe_node_id.triggers_workflow_state_id', 'quality_hold_count', + 'delivery_id', + 'delivery_id.state', ) def _compute_workflow_state_id(self): WS = self.env['fp.job.workflow.state'] @@ -137,6 +139,210 @@ class FpJob(models.Model): timelog_count = fields.Integer(compute='_compute_smart_counts') portal_job_count = fields.Integer(compute='_compute_smart_counts') + # ------------------------------------------------------------------ + # Milestone cascade (Phase 1) — drives the header-button replacement + # that fires when every recipe step reaches a terminal state. See + # docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md. + # ------------------------------------------------------------------ + all_steps_terminal = fields.Boolean( + compute='_compute_all_steps_terminal', + store=True, + help='True ⇔ at least one step exists AND every step is in ' + 'done/skipped/cancelled. Used to swap the per-step ' + 'Finish & Next button for a milestone-advance button.', + ) + + @api.depends('step_ids', 'step_ids.state') + def _compute_all_steps_terminal(self): + for job in self: + if not job.step_ids: + job.all_steps_terminal = False + else: + job.all_steps_terminal = all( + s.state in ('done', 'skipped', 'cancelled') + for s in job.step_ids + ) + + def _resolve_required_cert_types(self): + """Set of cert types this job must produce. + + Priority: part.certificate_requirement wins; 'inherit' falls + back to partner-level send_coc / send_thickness_report flags. + 'none' returns empty (commercial customer, no paperwork). + Unknown requirement codes default to {'coc'} as a safety net. + """ + self.ensure_one() + req = ( + self.part_catalog_id + and self.part_catalog_id.certificate_requirement + ) or 'inherit' + if req == 'inherit': + types = set() + if self.partner_id.x_fc_send_coc: + types.add('coc') + if self.partner_id.x_fc_send_thickness_report: + types.add('thickness_report') + return types + return { + 'none': set(), + 'coc': {'coc'}, + 'coc_thickness': {'coc', 'thickness_report'}, + }.get(req, {'coc'}) + + next_milestone_action = fields.Selection( + [ + ('mark_done', 'Mark Job Done'), + ('issue_certs', 'Issue Certs'), + ('schedule_delivery', 'Schedule Delivery'), + ('mark_shipped', 'Mark Shipped'), + ('closed', 'Closed'), + ], + compute='_compute_next_milestone_action', + help='What the manager should click next once steps complete. ' + 'Drives the milestone-advance buttons on the form header. ' + 'False/empty while steps are still running.', + ) + next_milestone_label = fields.Char( + compute='_compute_next_milestone_action', + help='Human label for the next-action button.', + ) + + @api.depends( + 'all_steps_terminal', + 'state', + 'delivery_id', + 'delivery_id.state', + ) + def _compute_next_milestone_action(self): + """Resolve next action in priority order: + 1. NOT all_steps_terminal → False (Finish & Next stays) + 2. state != 'done' → mark_done + 3. ANY required draft cert → issue_certs + 4. NO delivery or draft → schedule_delivery + 5. delivery scheduled/transit → mark_shipped + 6. otherwise (delivered) → closed + """ + labels = dict(self._fields['next_milestone_action'].selection) + for job in self: + if not job.all_steps_terminal: + job.next_milestone_action = False + job.next_milestone_label = '' + continue + if job.state != 'done': + job.next_milestone_action = 'mark_done' + elif job._fp_has_draft_required_certs(): + job.next_milestone_action = 'issue_certs' + elif (not job.delivery_id + or job.delivery_id.state == 'draft'): + job.next_milestone_action = 'schedule_delivery' + elif job.delivery_id.state in ('scheduled', 'in_transit'): + job.next_milestone_action = 'mark_shipped' + else: + job.next_milestone_action = 'closed' + job.next_milestone_label = labels.get( + job.next_milestone_action, '' + ) + + def _fp_has_draft_required_certs(self): + """True if at least one cert of a required type is still 'draft'. + Returns False when no certs are required (commercial customers). + """ + self.ensure_one() + if 'fp.certificate' not in self.env: + return False + required = self._resolve_required_cert_types() + if not required: + return False + Cert = self.env['fp.certificate'] + dom = [ + ('certificate_type', 'in', list(required)), + ('state', '=', 'draft'), + ] + if 'x_fc_job_id' in Cert._fields: + dom.append(('x_fc_job_id', '=', self.id)) + elif self.sale_order_id and 'sale_order_id' in Cert._fields: + dom.append(('sale_order_id', '=', self.sale_order_id.id)) + else: + return False # can't link safely → don't block the cascade + return bool(Cert.search_count(dom)) + + def action_advance_next_milestone(self): + """Single entry point bound to all four milestone header buttons. + Branches on next_milestone_action and delegates to the existing + business-logic method. Never invents new logic — just routes.""" + self.ensure_one() + action_map = { + 'mark_done': self.button_mark_done, + 'issue_certs': self._action_open_draft_certs, + 'schedule_delivery': self._action_open_draft_delivery, + 'mark_shipped': self._action_mark_active_delivery_delivered, + } + fn = action_map.get(self.next_milestone_action) + if not fn: + raise UserError(_( + 'No milestone action available for job %(j)s ' + '(next=%(a)s).' + ) % { + 'j': self.name, + 'a': self.next_milestone_action or 'none', + }) + return fn() + + def _action_open_draft_certs(self): + """Open the cert list filtered to draft certs for this job. + Manager reviews each in turn and clicks Issue per-cert.""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Draft Certificates — %s') % self.name, + 'res_model': 'fp.certificate', + 'view_mode': 'list,form', + 'domain': [ + ('x_fc_job_id', '=', self.id), + ('state', '=', 'draft'), + ], + 'target': 'current', + } + + def _action_open_draft_delivery(self): + """Open the linked delivery if it's still in draft state. + Falls back to the delivery list filtered to this job's + delivery if the state isn't draft (defensive).""" + self.ensure_one() + if self.delivery_id and self.delivery_id.state == 'draft': + return { + 'type': 'ir.actions.act_window', + 'name': _('Schedule Delivery — %s') % self.name, + 'res_model': 'fusion.plating.delivery', + 'res_id': self.delivery_id.id, + 'view_mode': 'form', + 'target': 'current', + } + return { + 'type': 'ir.actions.act_window', + 'name': _('Deliveries — %s') % self.name, + 'res_model': 'fusion.plating.delivery', + 'view_mode': 'list,form', + 'domain': [('job_ref', '=', self.name)], + 'target': 'current', + } + + def _action_mark_active_delivery_delivered(self): + """Call action_mark_delivered on the linked delivery if it's + in scheduled / in_transit. Posts to job chatter on success.""" + self.ensure_one() + if (not self.delivery_id + or self.delivery_id.state not in ('scheduled', 'in_transit')): + raise UserError(_( + 'No scheduled or in-transit delivery to mark shipped ' + 'for %s.' + ) % self.name) + self.delivery_id.action_mark_delivered() + self.message_post(body=_( + 'Delivery %s marked shipped via milestone cascade.' + ) % self.delivery_id.name) + return True + @api.depends( 'sale_order_id', 'delivery_id', 'portal_job_id', 'step_ids', 'step_ids.time_log_ids', 'origin', 'partner_id', @@ -374,6 +580,15 @@ class FpJob(models.Model): 'fusion_plating_jobs.action_report_fp_job_traveller' ).report_action(self) + def action_print_sticker(self): + """Print the 6x4" job-box identification sticker (logo + WO# + QR + + part / customer / thickness / notes). Used at receiving and at + every move so the box is always identifiable on the floor.""" + self.ensure_one() + return self.env.ref( + 'fusion_plating_jobs.action_report_fp_job_sticker' + ).report_action(self) + def action_print_wo_detail(self): """Print the Steelhead-style Work Order Detail PDF — chronological chain-of-custody + per-step inputs + Certified By page. Use this @@ -1285,93 +1500,102 @@ class FpJob(models.Model): ) def _fp_create_certificates(self): - """Trigger cert auto-create on job done. + """Auto-create one draft fp.certificate per type returned by + _resolve_required_cert_types. Idempotent per type — re-running + on a job that already has a CoC won't create another one. - Pre-populates ALL the fields a CoC issuer needs so Tom can hit - Issue without filling 6 fields first: - - partner_id from job - - spec_reference from coating (required by action_issue) - - part_number from part_catalog - - quantity_shipped from job qty (minus scrap) - - po_number from sale_order - - sale_order_id link - - x_fc_job_id link if the field exists + Each cert is pre-populated with everything action_issue needs + (partner, spec_reference, part_number, quantity_shipped, po, + SO link, job link) so the manager just reviews and clicks Issue. - Idempotent — if a cert already exists for this job, skip - (prevents dupes when button_mark_done is re-run after a - manager bypass). + Replaces the single-CoC implementation: now honours + part.certificate_requirement (coc / coc_thickness / none / + inherit) and partner-level send_coc / send_thickness_report + flags. Closes spec gap C-G1. """ self.ensure_one() if 'fp.certificate' not in self.env: return Cert = self.env['fp.certificate'].sudo() - # Idempotency: don't double-create on retry. - existing_dom = [] - if 'x_fc_job_id' in Cert._fields: - existing_dom.append(('x_fc_job_id', '=', self.id)) - elif self.sale_order_id and 'sale_order_id' in Cert._fields: - existing_dom.append(('sale_order_id', '=', self.sale_order_id.id)) - if existing_dom: - existing = Cert.search(existing_dom, limit=1) - if existing: - _logger.info( - 'Job %s: cert %s already exists, skipping auto-create', - self.name, existing.name, + required = self._resolve_required_cert_types() + if not required: + return + has_job_link = 'x_fc_job_id' in Cert._fields + coating = self.coating_config_id + for cert_type in sorted(required): + # Idempotency per type. + existing_dom = [('certificate_type', '=', cert_type)] + if has_job_link: + existing_dom.append(('x_fc_job_id', '=', self.id)) + elif self.sale_order_id and 'sale_order_id' in Cert._fields: + existing_dom.append( + ('sale_order_id', '=', self.sale_order_id.id), ) - return - try: - vals = {'partner_id': self.partner_id.id} - if 'certificate_type' in Cert._fields: - vals['certificate_type'] = 'coc' - if 'state' in Cert._fields: - vals['state'] = 'draft' - # Job + SO links. - if 'x_fc_job_id' in Cert._fields: - vals['x_fc_job_id'] = self.id - elif 'job_id' in Cert._fields: - vals['job_id'] = self.id - if 'sale_order_id' in Cert._fields and self.sale_order_id: - vals['sale_order_id'] = self.sale_order_id.id - # Pre-fill from coating: the spec_reference is what action_issue - # blocks on — without this every cert needs a manual edit. - coating = self.coating_config_id - if coating and 'spec_reference' in Cert._fields \ - and getattr(coating, 'spec_reference', False): - vals['spec_reference'] = coating.spec_reference - # Pre-fill part_number from the part catalog if we have one. - if 'part_number' in Cert._fields and self.part_catalog_id: - vals['part_number'] = self.part_catalog_id.part_number or '' - # Quantity shipped = job qty minus scrap. AS9100 wants the - # actual count that left the shop, not the order count. - if 'quantity_shipped' in Cert._fields: - vals['quantity_shipped'] = int( - (self.qty_done or self.qty or 0) - (self.qty_scrapped or 0) + else: + continue # can't safely identify — skip + if Cert.search_count(existing_dom): + continue + try: + vals = { + 'partner_id': self.partner_id.id, + 'certificate_type': cert_type, + } + if 'state' in Cert._fields: + vals['state'] = 'draft' + if has_job_link: + vals['x_fc_job_id'] = self.id + elif 'job_id' in Cert._fields: + vals['job_id'] = self.id + if 'sale_order_id' in Cert._fields and self.sale_order_id: + vals['sale_order_id'] = self.sale_order_id.id + # spec_reference is what action_issue blocks on. + if coating and 'spec_reference' in Cert._fields \ + and getattr(coating, 'spec_reference', False): + vals['spec_reference'] = coating.spec_reference + if 'part_number' in Cert._fields and self.part_catalog_id: + vals['part_number'] = ( + self.part_catalog_id.part_number or '' + ) + if 'quantity_shipped' in Cert._fields: + vals['quantity_shipped'] = int( + (self.qty_done or self.qty or 0) + - (self.qty_scrapped or 0) + ) + if 'po_number' in Cert._fields and self.sale_order_id \ + and 'x_fc_po_number' in self.sale_order_id._fields: + vals['po_number'] = ( + self.sale_order_id.x_fc_po_number or '' + ) + if 'customer_job_no' in Cert._fields \ + and self.sale_order_id \ + and 'x_fc_customer_job_number' \ + in self.sale_order_id._fields: + vals['customer_job_no'] = ( + self.sale_order_id.x_fc_customer_job_number or '' + ) + if 'process_description' in Cert._fields and coating: + vals['process_description'] = coating.name or '' + if 'entech_wo_number' in Cert._fields: + vals['entech_wo_number'] = self.name or '' + cert = Cert.create(vals) + self.message_post(body=Markup(_( + '%(t)s %(n)s auto-created (draft). Issuer ' + 'should hit Issue when ready to ship.' + )) % { + 't': dict( + Cert._fields['certificate_type'].selection + ).get(cert_type, cert_type), + 'n': cert.name, + }) + except Exception as e: + _logger.warning( + "Job %s: failed to auto-create cert (%s): %s", + self.name, cert_type, e, ) - # PO number from the source SO. - if 'po_number' in Cert._fields and self.sale_order_id \ - and 'x_fc_po_number' in self.sale_order_id._fields: - vals['po_number'] = self.sale_order_id.x_fc_po_number or '' - # Customer job# → cert label (helps customer search). - if 'customer_job_no' in Cert._fields and self.sale_order_id \ - and 'x_fc_customer_job_number' in self.sale_order_id._fields: - vals['customer_job_no'] = ( - self.sale_order_id.x_fc_customer_job_number or '' - ) - # Process description from coating name. - if 'process_description' in Cert._fields and coating: - vals['process_description'] = coating.name or '' - # Job # for shop-side reference. - if 'entech_wo_number' in Cert._fields: - vals['entech_wo_number'] = self.name or '' - cert = Cert.create(vals) - self.message_post(body=Markup(_( - 'CoC %s auto-created (draft). Issuer should hit ' - 'the Issue button on the certificate when ready to ship.' - )) % cert.name) - except Exception as e: - _logger.warning( - "Job %s: failed to auto-create cert: %s", self.name, e, - ) + self.message_post(body=_( + 'Cert auto-create (%(t)s) failed: %(e)s. ' + 'Create manually.' + ) % {'t': cert_type, 'e': e}) class FpJobStep(models.Model): diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_workflow_state.py b/fusion_plating/fusion_plating_jobs/models/fp_job_workflow_state.py index dffd6bc2..9da46fc5 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_workflow_state.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_workflow_state.py @@ -129,6 +129,18 @@ class FpJobWorkflowState(models.Model): 'is in done/skipped state. Used for the "Done" milestone.', ) + trigger_on_delivery_state = fields.Boolean( + string='Trigger on Delivery Delivered', + default=False, + help='Special trigger — passes once the fusion.plating.delivery ' + 'linked to the job (job.delivery_id) reaches state="delivered". ' + 'Used for the Shipped milestone in lieu of recipe-side ' + 'default_kind="ship" tagging. Shipping is logistics, not ' + 'manufacturing — keeping the trigger off the recipe lets us ' + 'route deliveries (split shipments, RMA reverse-flow, ' + 'customer pickup) independently from plating steps.', + ) + block_when_quality_hold = fields.Boolean( string='Blocked by Quality Hold', default=False, @@ -180,6 +192,12 @@ class FpJobWorkflowState(models.Model): return False return all(s.state in ('done', 'skipped') for s in non_cancelled) + # Special trigger: linked delivery has been marked delivered + if self.trigger_on_delivery_state: + return bool( + job.delivery_id and job.delivery_id.state == 'delivered' + ) + # Special trigger: first wet step started if self.trigger_first_step_started: wet_kinds = ('wet', 'bake', 'mask', 'rack') diff --git a/fusion_plating/fusion_plating_jobs/tests/__init__.py b/fusion_plating/fusion_plating_jobs/tests/__init__.py index 0c22091c..56d0ea0d 100644 --- a/fusion_plating/fusion_plating_jobs/tests/__init__.py +++ b/fusion_plating/fusion_plating_jobs/tests/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- from . import test_fp_job_extensions +from . import test_fp_job_milestone_cascade diff --git a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py new file mode 100644 index 00000000..92f8b729 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py @@ -0,0 +1,333 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""Milestone cascade Phase 1 tests. + +Covers: + - all_steps_terminal (Task 2) + - _resolve_required_cert_types (Task 3) + - _fp_create_certificates (Task 4) + - next_milestone_action (Task 5) + - action_advance_next_milestone dispatcher (Task 6) + - action_mark_delivered cert gate (Task 8) + +See docs/superpowers/plans/2026-05-12-job-milestone-cascade.md. +""" +from odoo.tests.common import TransactionCase + + +class TestMilestoneCascade(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].create({'name': 'CustA'}) + cls.product = cls.env['product.product'].create({ + 'name': 'Widget', + }) + + def _make_job(self, **kw): + vals = { + 'partner_id': self.partner.id, + 'product_id': self.product.id, + 'qty': 1.0, + } + vals.update(kw) + return self.env['fp.job'].create(vals) + + def _make_step(self, job, name='Step', state='pending'): + return self.env['fp.job.step'].create({ + 'job_id': job.id, + 'name': name, + 'state': state, + }) + + # ---------------- Task 2: all_steps_terminal ---------------------- + + def test_all_steps_terminal_false_when_no_steps(self): + job = self._make_job() + self.assertFalse(job.all_steps_terminal) + + def test_all_steps_terminal_false_when_any_step_pending(self): + job = self._make_job() + self._make_step(job, state='done') + self._make_step(job, state='pending') + job.invalidate_recordset(['all_steps_terminal']) + self.assertFalse(job.all_steps_terminal) + + def test_all_steps_terminal_true_when_all_done(self): + job = self._make_job() + self._make_step(job, state='done') + self._make_step(job, state='done') + job.invalidate_recordset(['all_steps_terminal']) + self.assertTrue(job.all_steps_terminal) + + def test_all_steps_terminal_true_with_skipped_and_cancelled(self): + job = self._make_job() + self._make_step(job, state='done') + self._make_step(job, state='skipped') + self._make_step(job, state='cancelled') + job.invalidate_recordset(['all_steps_terminal']) + self.assertTrue(job.all_steps_terminal) + + # ---------------- Task 3: _resolve_required_cert_types ----------- + + def _make_part(self, certificate_requirement='inherit'): + return self.env['fp.part.catalog'].create({ + 'name': 'PartA', + 'part_number': 'PN-001-%s' % certificate_requirement, + 'partner_id': self.partner.id, + 'certificate_requirement': certificate_requirement, + }) + + def test_resolve_certs_none_returns_empty(self): + part = self._make_part(certificate_requirement='none') + job = self._make_job(part_catalog_id=part.id) + self.assertEqual(job._resolve_required_cert_types(), set()) + + def test_resolve_certs_coc_only(self): + part = self._make_part(certificate_requirement='coc') + job = self._make_job(part_catalog_id=part.id) + self.assertEqual(job._resolve_required_cert_types(), {'coc'}) + + def test_resolve_certs_coc_plus_thickness(self): + part = self._make_part(certificate_requirement='coc_thickness') + job = self._make_job(part_catalog_id=part.id) + self.assertEqual( + job._resolve_required_cert_types(), + {'coc', 'thickness_report'}, + ) + + def test_resolve_certs_inherit_falls_back_to_partner(self): + part = self._make_part(certificate_requirement='inherit') + self.partner.x_fc_send_coc = True + self.partner.x_fc_send_thickness_report = True + job = self._make_job(part_catalog_id=part.id) + self.assertEqual( + job._resolve_required_cert_types(), + {'coc', 'thickness_report'}, + ) + + def test_resolve_certs_inherit_partner_says_no(self): + part = self._make_part(certificate_requirement='inherit') + self.partner.x_fc_send_coc = False + self.partner.x_fc_send_thickness_report = False + job = self._make_job(part_catalog_id=part.id) + self.assertEqual(job._resolve_required_cert_types(), set()) + + def test_resolve_certs_no_part_no_partner_flags(self): + self.partner.x_fc_send_coc = False + self.partner.x_fc_send_thickness_report = False + job = self._make_job() + self.assertEqual(job._resolve_required_cert_types(), set()) + + # ---------------- Task 4: _fp_create_certificates ----------------- + + def test_create_certs_skips_when_no_required(self): + part = self._make_part(certificate_requirement='none') + job = self._make_job(part_catalog_id=part.id) + job._fp_create_certificates() + certs = self.env['fp.certificate'].search([ + ('x_fc_job_id', '=', job.id), + ]) + self.assertFalse(certs) + + def test_create_certs_coc_only(self): + part = self._make_part(certificate_requirement='coc') + job = self._make_job(part_catalog_id=part.id) + job._fp_create_certificates() + certs = self.env['fp.certificate'].search([ + ('x_fc_job_id', '=', job.id), + ]) + self.assertEqual(len(certs), 1) + self.assertEqual(certs.certificate_type, 'coc') + self.assertEqual(certs.state, 'draft') + + def test_create_certs_coc_plus_thickness(self): + part = self._make_part(certificate_requirement='coc_thickness') + job = self._make_job(part_catalog_id=part.id) + job._fp_create_certificates() + certs = self.env['fp.certificate'].search([ + ('x_fc_job_id', '=', job.id), + ]) + self.assertEqual(len(certs), 2) + self.assertEqual( + set(certs.mapped('certificate_type')), + {'coc', 'thickness_report'}, + ) + + def test_create_certs_idempotent(self): + part = self._make_part(certificate_requirement='coc') + job = self._make_job(part_catalog_id=part.id) + job._fp_create_certificates() + job._fp_create_certificates() # second call must be no-op + certs = self.env['fp.certificate'].search([ + ('x_fc_job_id', '=', job.id), + ]) + self.assertEqual(len(certs), 1) + + # ---------------- Task 5: next_milestone_action ------------------- + + def test_next_milestone_false_while_steps_running(self): + job = self._make_job() + self._make_step(job, state='pending') + job.invalidate_recordset(['all_steps_terminal']) + self.assertFalse(job.next_milestone_action) + + def test_next_milestone_mark_done_when_state_not_done(self): + job = self._make_job() + self._make_step(job, state='done') + job.invalidate_recordset(['all_steps_terminal']) + # default state is draft after create + self.assertNotEqual(job.state, 'done') + self.assertEqual(job.next_milestone_action, 'mark_done') + self.assertEqual(job.next_milestone_label, 'Mark Job Done') + + def test_next_milestone_issue_certs_when_draft_cert_exists(self): + part = self._make_part(certificate_requirement='coc') + job = self._make_job(part_catalog_id=part.id) + self._make_step(job, state='done') + job.state = 'done' + job._fp_create_certificates() # creates draft CoC + job.invalidate_recordset([ + 'all_steps_terminal', 'next_milestone_action', + ]) + self.assertEqual(job.next_milestone_action, 'issue_certs') + + def test_next_milestone_schedule_delivery_when_no_certs(self): + part = self._make_part(certificate_requirement='none') + job = self._make_job(part_catalog_id=part.id) + self._make_step(job, state='done') + job.state = 'done' + job.invalidate_recordset([ + 'all_steps_terminal', 'next_milestone_action', + ]) + self.assertEqual(job.next_milestone_action, 'schedule_delivery') + + def test_next_milestone_closed_when_delivered(self): + part = self._make_part(certificate_requirement='none') + job = self._make_job(part_catalog_id=part.id) + self._make_step(job, state='done') + job.state = 'done' + delivery = self.env['fusion.plating.delivery'].create({ + 'partner_id': self.partner.id, + 'job_ref': job.name, + 'state': 'delivered', + }) + job.delivery_id = delivery.id + job.invalidate_recordset([ + 'all_steps_terminal', 'next_milestone_action', + ]) + self.assertEqual(job.next_milestone_action, 'closed') + + # ---------------- Task 6: dispatcher ------------------------------ + + def test_dispatcher_raises_when_no_action(self): + from odoo.exceptions import UserError + job = self._make_job() + self._make_step(job, state='pending') # not terminal + job.invalidate_recordset(['all_steps_terminal']) + with self.assertRaises(UserError): + job.action_advance_next_milestone() + + def test_open_draft_certs_returns_filtered_action(self): + part = self._make_part(certificate_requirement='coc') + job = self._make_job(part_catalog_id=part.id) + self._make_step(job, state='done') + job.state = 'done' + job._fp_create_certificates() + action = job._action_open_draft_certs() + self.assertEqual(action['res_model'], 'fp.certificate') + self.assertIn(('state', '=', 'draft'), action['domain']) + self.assertIn(('x_fc_job_id', '=', job.id), action['domain']) + + def test_open_draft_delivery_returns_form_when_draft(self): + job = self._make_job() + delivery = self.env['fusion.plating.delivery'].create({ + 'partner_id': self.partner.id, + 'job_ref': job.name, + 'state': 'draft', + }) + job.delivery_id = delivery.id + action = job._action_open_draft_delivery() + self.assertEqual(action['res_model'], 'fusion.plating.delivery') + self.assertEqual(action.get('res_id'), delivery.id) + self.assertEqual(action['view_mode'], 'form') + + def test_open_draft_delivery_falls_back_to_list(self): + # Delivery not draft → returns list view filtered to this job. + job = self._make_job() + self.env['fusion.plating.delivery'].create({ + 'partner_id': self.partner.id, + 'job_ref': job.name, + 'state': 'delivered', + }) + action = job._action_open_draft_delivery() + self.assertEqual(action['view_mode'], 'list,form') + self.assertIn(('job_ref', '=', job.name), action['domain']) + + def test_mark_active_raises_without_active_delivery(self): + from odoo.exceptions import UserError + job = self._make_job() + with self.assertRaises(UserError): + job._action_mark_active_delivery_delivered() + + # ---------------- Task 8: cert gate on action_mark_delivered ------ + + def test_mark_delivered_blocks_on_draft_certs(self): + from odoo.exceptions import UserError + part = self._make_part(certificate_requirement='coc') + job = self._make_job(part_catalog_id=part.id) + job.state = 'done' + job._fp_create_certificates() # creates one draft CoC + delivery = self.env['fusion.plating.delivery'].create({ + 'partner_id': self.partner.id, + 'job_ref': job.name, + 'state': 'scheduled', + }) + with self.assertRaises(UserError): + delivery.action_mark_delivered() + + def test_mark_delivered_bypass_skips_cert_gate(self): + """With fp_skip_cert_gate=True the gate doesn't raise. Downstream + super() chain (notifications, invoicing) may still raise for + their own reasons — out of scope for this test.""" + part = self._make_part(certificate_requirement='coc') + job = self._make_job(part_catalog_id=part.id) + job.state = 'done' + job._fp_create_certificates() + delivery = self.env['fusion.plating.delivery'].create({ + 'partner_id': self.partner.id, + 'job_ref': job.name, + 'state': 'scheduled', + }) + try: + delivery.with_context( + fp_skip_cert_gate=True, + ).action_mark_delivered() + except Exception as e: + # Cert-gate message must NOT appear. Anything else is fine. + self.assertNotIn('draft certificate', str(e)) + + def test_mark_delivered_passes_when_cert_issued(self): + """Issuing the cert clears the gate. Downstream chain errors + are accepted (delivery PDF render etc. — see test above).""" + part = self._make_part(certificate_requirement='coc') + job = self._make_job(part_catalog_id=part.id) + job.state = 'done' + job._fp_create_certificates() + cert = self.env['fp.certificate'].search([ + ('x_fc_job_id', '=', job.id), + ]) + cert.spec_reference = 'AMS 2404' + cert.action_issue() + self.assertEqual(cert.state, 'issued') + delivery = self.env['fusion.plating.delivery'].create({ + 'partner_id': self.partner.id, + 'job_ref': job.name, + 'state': 'scheduled', + }) + try: + delivery.action_mark_delivered() + except Exception as e: + self.assertNotIn('draft certificate', str(e)) diff --git a/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml b/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml index 7aa315ff..711d8f09 100644 --- a/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml +++ b/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml @@ -35,11 +35,37 @@ string="Finish & Next" class="btn-primary" icon="fa-arrow-right" - invisible="state not in ('confirmed', 'in_progress')"/> - -