diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md
index 594771d2..33b85fc6 100644
--- a/fusion_plating/CLAUDE.md
+++ b/fusion_plating/CLAUDE.md
@@ -100,6 +100,87 @@ These modules have **source code in this repo** but are **intentionally NOT inst
- SCSS class prefix: `o_fp_*` (shopfloor: `o_fp_po_*`, `o_fp_pt_*`; recipes: `o_fp_recipe_*`)
- Monetary fields: always pair with `currency_id` field on the same model
+## Smart Buttons — Anatomy + Conventions
+
+Smart buttons sit in the `
` at the top of a form view. Every smart button MUST follow this canonical pattern so the row stays visually consistent — icon on top, count in the middle, label on the bottom.
+
+### Canonical button shape
+
+```xml
+
+```
+
+What each piece does:
+- `name=` — the Python method called on click (an `action_view_X` returning a window action dict).
+- `class="oe_stat_button"` — REQUIRED. Without it the button doesn't get the stat-box styling and renders as a plain action button.
+- `icon=` — Font Awesome 4 (`fa-cogs`, `fa-truck`, `fa-list-alt`, `fa-th-large`, etc.). Pick one that telegraphs the target model.
+- `` — REQUIRED for the count-on-top label-below format. Don't use `string="Foo"` on the `
+
+
+
+ invisible="x_fc_distinct_part_count < 2">
+
+
+ invisible="not x_fc_has_wo_group_tag">
+
+
diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py
index b15dc4a9..88b5a11b 100644
--- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py
+++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_line.py
@@ -116,11 +116,61 @@ class FpDirectOrderLine(models.Model):
@api.onchange('part_catalog_id')
def _onchange_part_clears_variant(self):
- """Clear variant pick when the part changes (variants are part-scoped)."""
+ """Clear variant pick when the part changes (variants are part-scoped).
+
+ Pre-fill coating + treatments from the part's saved defaults so
+ Sarah doesn't re-pick the same coating every repeat customer.
+ Defaults only apply when the line currently has no coating set
+ — editing an existing line with a chosen coating doesn't get
+ clobbered.
+
+ For BRAND-NEW parts (no defaults saved yet) auto-tick
+ `push_to_defaults` so Sarah's first coating pick gets persisted
+ back to the part. Without this Sarah has to remember to tick the
+ toggle herself, and the second order doesn't pre-fill.
+ Returns a warning popup explaining what's happening.
+ """
+ warning = None
for rec in self:
+ # Variant clear (original behaviour).
if (rec.process_variant_id
and rec.process_variant_id.part_catalog_id != rec.part_catalog_id):
rec.process_variant_id = False
+ if not rec.part_catalog_id:
+ continue
+ part = rec.part_catalog_id
+ has_default_coating = bool(getattr(
+ part, 'x_fc_default_coating_config_id', False))
+ has_default_treatments = bool(getattr(
+ part, 'x_fc_default_treatment_ids', False))
+ # Pre-fill default coating if the line is empty.
+ if not rec.coating_config_id and has_default_coating:
+ rec.coating_config_id = part.x_fc_default_coating_config_id
+ # Pre-fill default treatments if any are configured.
+ if not rec.treatment_ids and has_default_treatments:
+ rec.treatment_ids = [(6, 0, part.x_fc_default_treatment_ids.ids)]
+ # New-part auto-suggest: if neither default exists, this is
+ # likely a first-time use of the part. Auto-tick the
+ # push_to_defaults toggle so whatever Sarah picks becomes
+ # the saved default — surface a warning popup so she knows.
+ # `is_one_off` always wins (operator opted out of catalog
+ # persistence), so don't auto-tick in that case.
+ if (not has_default_coating
+ and not has_default_treatments
+ and not rec.is_one_off
+ and not rec.push_to_defaults):
+ rec.push_to_defaults = True
+ warning = {
+ 'title': _('First-Time Part — Defaults Will Be Saved'),
+ 'message': _(
+ '%(part)s has no saved coating / treatments. '
+ 'The coating + treatments you pick on this line '
+ 'will be saved as the part\'s defaults so the '
+ 'next order auto-fills them. Untick "Save as '
+ 'Default" on the line if you don\'t want this.'
+ ) % {'part': part.display_name or part.part_number or '(part)'},
+ }
+ return {'warning': warning} if warning else None
# ---- Qty / price ----
quantity = fields.Integer(string='Qty', default=1, required=True)
diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py
index 9f4160da..a3487504 100644
--- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py
+++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py
@@ -209,37 +209,63 @@ class FpDirectOrderWizard(models.Model):
# ---- Onchange ----
@api.onchange('partner_id')
def _onchange_partner_id(self):
- """Seed invoice defaults + addresses + payment terms when customer changes."""
- if self.partner_id and 'x_fc_default_invoice_strategy' in self.partner_id._fields:
- self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
- self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
- if self.partner_id:
- addrs = self.partner_id.address_get(['invoice', 'delivery'])
- self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
- self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
- # Seed payment terms: customer's invoice-strategy default wins;
- # fallback to partner.property_payment_term_id.
- term = False
- isd = self.env['fp.invoice.strategy.default'].search(
- [('partner_id', '=', self.partner_id.id)], limit=1,
- )
- if isd and isd.payment_term_id:
- term = isd.payment_term_id
- # Also seed strategy from the same record if not already set.
- if not self.invoice_strategy:
- self.invoice_strategy = isd.default_strategy
- if not self.deposit_percent:
- self.deposit_percent = isd.default_deposit_percent or 0.0
- if not term and self.partner_id.property_payment_term_id:
- term = self.partner_id.property_payment_term_id
- self.payment_term_id = term or False
- else:
+ """Seed invoice defaults + addresses + payment terms when customer
+ changes. Also surface an account-hold warning so Sarah doesn't
+ build a full quote for a customer she can't ship to.
+ """
+ if not self.partner_id:
self.partner_invoice_id = False
self.partner_shipping_id = False
self.payment_term_id = False
+ self._apply_strategy_payment_term()
+ return
+
+ # Legacy partner-field defaults (pre-Sub-5).
+ if 'x_fc_default_invoice_strategy' in self.partner_id._fields:
+ self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
+ self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
+
+ # Addresses.
+ addrs = self.partner_id.address_get(['invoice', 'delivery'])
+ self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
+ self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
+
+ # Per-customer invoice strategy default (fp.invoice.strategy.default).
+ # Pull strategy + deposit even when payment_term_id is empty — the
+ # previous condition `if isd and isd.payment_term_id` silently
+ # skipped the strategy fill for net-terms customers without
+ # explicit terms configured.
+ isd = self.env['fp.invoice.strategy.default'].search(
+ [('partner_id', '=', self.partner_id.id)], limit=1,
+ )
+ term = False
+ if isd:
+ if not self.invoice_strategy:
+ self.invoice_strategy = isd.default_strategy
+ if not self.deposit_percent:
+ self.deposit_percent = isd.default_deposit_percent or 0.0
+ term = isd.payment_term_id
+ if not term and self.partner_id.property_payment_term_id:
+ term = self.partner_id.property_payment_term_id
+ self.payment_term_id = term or False
+
# Re-apply strategy → terms mapping after partner switch.
self._apply_strategy_payment_term()
+ # Account-hold early warning. Hard block lives in action_confirm
+ # but Sarah deserves to know NOW before she builds 5 lines.
+ if getattr(self.partner_id, 'x_fc_account_hold', False):
+ return {
+ 'warning': {
+ 'title': _('Customer on Account Hold'),
+ 'message': _(
+ '%s is currently on account hold. You can still '
+ 'build the quotation, but it cannot be confirmed '
+ 'until the hold is cleared by accounting.'
+ ) % self.partner_id.display_name,
+ }
+ }
+
@api.onchange('invoice_strategy')
def _onchange_invoice_strategy(self):
"""Map the strategy onto sensible payment terms."""
@@ -247,12 +273,15 @@ class FpDirectOrderWizard(models.Model):
def _apply_strategy_payment_term(self):
"""Mapping rule:
- - cod_prepay → Immediate Payment (Odoo's stock term)
- - deposit / progress / net_terms → keep what the partner default
- already gave us; if blank, leave it blank so the user can pick.
- Never overwrites an explicit user choice for non-COD strategies —
- only fills in when payment_term_id is empty.
+ - cod_prepay → Immediate Payment
+ - net_terms / deposit / progress → fall back to a 30-day
+ term when nothing is set. Without ANY payment term Odoo
+ blocks invoice posting, which silently strands SOs at the
+ invoicing step. Better to default to net-30 and let the
+ estimator override if the customer's terms are different.
+ Never overwrites an explicit user choice — only fills the gap.
"""
+ Pt = self.env['account.payment.term']
for rec in self:
if rec.invoice_strategy == 'cod_prepay':
immediate = rec.env.ref(
@@ -261,6 +290,20 @@ class FpDirectOrderWizard(models.Model):
)
if immediate:
rec.payment_term_id = immediate.id
+ elif rec.invoice_strategy in ('net_terms', 'deposit', 'progress') \
+ and not rec.payment_term_id:
+ # Try canonical Net-30, then any term named "30 Days",
+ # then any term at all as last-ditch.
+ term = rec.env.ref(
+ 'account.account_payment_term_30days',
+ raise_if_not_found=False,
+ )
+ if not term:
+ term = Pt.search([('name', 'ilike', '30 Days')], limit=1)
+ if not term:
+ term = Pt.search([], limit=1)
+ if term:
+ rec.payment_term_id = term.id
# ---- Actions ----
@api.model
@@ -351,6 +394,17 @@ class FpDirectOrderWizard(models.Model):
raise UserError(_('Pick a customer before confirming.'))
if not self.line_ids:
raise UserError(_('Add at least one part line before confirming.'))
+ # Account-hold hard block — same policy as sale.order.action_confirm
+ # but enforced earlier so the wizard doesn't waste Sarah's time.
+ # Manager override allowed via context key fp_skip_account_hold=True.
+ if (getattr(self.partner_id, 'x_fc_account_hold', False)
+ and not self.env.context.get('fp_skip_account_hold')
+ and not self.env.user.has_group(
+ 'fusion_plating.group_fusion_plating_manager')):
+ raise UserError(_(
+ 'Customer %s is on account hold. Have a manager clear the '
+ 'hold (or override) before creating the order.'
+ ) % self.partner_id.display_name)
# Accept EITHER a PO (document + number) OR the PO Pending
# flag. Customers who haven't sent paperwork yet use Pending;
diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
index ef1e9987..65eb11b6 100644
--- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
+++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
@@ -141,9 +141,9 @@
+ options="{'no_quick_create': True}"/>
+
+
+
+ Fusion Plating: Nudge stale paused steps
+
+ code
+ model._cron_nudge_stale_paused(threshold_hours=24)
+ 1
+ days
+
+
+
+
+
+ Fusion Plating: Nudge stale in-progress steps
+
+ code
+ model._cron_nudge_stale_in_progress(threshold_hours=8)
+ 1
+ hours
+
+
+
diff --git a/fusion_plating/fusion_plating_jobs/models/__init__.py b/fusion_plating/fusion_plating_jobs/models/__init__.py
index e55c08d4..cbeadacc 100644
--- a/fusion_plating/fusion_plating_jobs/models/__init__.py
+++ b/fusion_plating/fusion_plating_jobs/models/__init__.py
@@ -12,6 +12,7 @@ from . import fp_portal_job
from . import account_move
from . import res_config_settings
from . import sale_order
+from . import sale_order_line
# Phase 3 — parallel job/step links on dependent modules' models.
from . import fp_batch
diff --git a/fusion_plating/fusion_plating_jobs/models/fp_certificate.py b/fusion_plating/fusion_plating_jobs/models/fp_certificate.py
index 2b15b08d..33bd07cb 100644
--- a/fusion_plating/fusion_plating_jobs/models/fp_certificate.py
+++ b/fusion_plating/fusion_plating_jobs/models/fp_certificate.py
@@ -4,8 +4,14 @@
#
# Phase 3 — parallel job link on fp.certificate.
# Coexists with bridge_mrp's production_id link.
+#
+# v19.0.6.20.0 — surface the Fischerscope PDF on the cert form so
+# operators can SEE that the thickness report will be (or has been)
+# merged into the CoC. The merge logic itself lives in
+# fusion_plating_certificates/models/fp_certificate.py — this file
+# only adds the human-readable indicators.
-from odoo import fields, models
+from odoo import api, fields, models
class FpCertificate(models.Model):
@@ -17,3 +23,95 @@ class FpCertificate(models.Model):
index=True,
help="Native fp.job link. Coexists with bridge_mrp's production_id.",
)
+
+ # ---- Fischerscope thickness-PDF visibility (S19) ---------------------
+ # These three fields are computed from the linked job's QC checks so
+ # the cert form can show the operator BEFORE issuing whether a
+ # Fischerscope report is on file and will be appended as page 2.
+ x_fc_thickness_qc_id = fields.Many2one(
+ 'fusion.plating.quality.check',
+ string='Linked QC (Thickness)',
+ compute='_compute_fischer_visibility',
+ help='Quality check on the linked plating job that has a '
+ 'Fischerscope / XDAL 600 thickness PDF uploaded. Used to '
+ 'merge that PDF into the CoC on Issue.',
+ )
+ x_fc_thickness_pdf_id = fields.Many2one(
+ 'ir.attachment',
+ string='Fischerscope PDF',
+ compute='_compute_fischer_visibility',
+ help='Thickness report PDF that will be appended as page 2 of '
+ 'the CoC when the certificate is issued.',
+ )
+ x_fc_thickness_status = fields.Selection(
+ [
+ ('none', 'No PDF Uploaded'),
+ ('pending', 'Will Append on Issue'),
+ ('merged', 'Merged into CoC'),
+ ],
+ string='Thickness Report',
+ compute='_compute_fischer_visibility',
+ help='none = QC has no Fischerscope upload · '
+ 'pending = will be appended when Issue is clicked · '
+ 'merged = already in the issued CoC PDF',
+ )
+
+ @api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id')
+ def _compute_fischer_visibility(self):
+ QC = self.env.get('fusion.plating.quality.check')
+ empty_qc = self.env['fusion.plating.quality.check'] if QC is not None else None
+ empty_att = self.env['ir.attachment']
+ for rec in self:
+ qc = empty_qc
+ pdf = empty_att
+ status = 'none'
+ if QC is not None and rec.x_fc_job_id:
+ # Same lookup the merge method uses — passed-first,
+ # then any QC with a PDF.
+ qc = QC.sudo().search([
+ ('job_id', '=', rec.x_fc_job_id.id),
+ ('state', '=', 'passed'),
+ ('thickness_report_pdf_id', '!=', False),
+ ], order='completed_at desc', limit=1)
+ if not qc:
+ qc = QC.sudo().search([
+ ('job_id', '=', rec.x_fc_job_id.id),
+ ('thickness_report_pdf_id', '!=', False),
+ ], order='create_date desc', limit=1)
+ if qc and qc.thickness_report_pdf_id:
+ pdf = qc.thickness_report_pdf_id
+ if rec.state == 'issued' and rec.attachment_id:
+ status = 'merged'
+ else:
+ status = 'pending'
+ rec.x_fc_thickness_qc_id = qc or empty_qc
+ rec.x_fc_thickness_pdf_id = pdf or empty_att
+ rec.x_fc_thickness_status = status
+
+ def action_view_thickness_qc(self):
+ """Smart-button target — open the linked QC for inspection."""
+ self.ensure_one()
+ if not self.x_fc_thickness_qc_id:
+ return False
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_thickness_qc_id.name,
+ 'res_model': 'fusion.plating.quality.check',
+ 'res_id': self.x_fc_thickness_qc_id.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
+
+ def action_open_job(self):
+ """Smart-button target — open the linked plating job."""
+ self.ensure_one()
+ if not self.x_fc_job_id:
+ return False
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': self.x_fc_job_id.name,
+ 'res_model': 'fp.job',
+ 'res_id': self.x_fc_job_id.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py
index bfe93f06..66e15504 100644
--- a/fusion_plating/fusion_plating_jobs/models/fp_job.py
+++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py
@@ -14,7 +14,7 @@ import logging
from markupsafe import Markup
-from odoo import api, fields, models
+from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
@@ -263,6 +263,95 @@ class FpJob(models.Model):
'name': self.portal_job_id.name,
}
+ def write(self, vals):
+ """Write hook: when qty_scrapped INCREASES, auto-spawn a
+ fusion.plating.quality.hold for the scrapped delta. AS9100 /
+ Nadcap need a disposition record per scrap event — without
+ this the operator silently bumps qty_scrapped, no paper trail,
+ auditor can't reconstruct what happened.
+
+ Idempotent per write: one hold per increase event. Operator
+ fills hold_reason + description on the spawned record.
+ """
+ from markupsafe import Markup as _Markup
+ scrap_deltas = {}
+ if 'qty_scrapped' in vals:
+ new = vals['qty_scrapped'] or 0
+ for job in self:
+ old = job.qty_scrapped or 0
+ if new > old:
+ scrap_deltas[job.id] = (old, new)
+ result = super().write(vals)
+ if not scrap_deltas:
+ return result
+ Hold = (self.env['fusion.plating.quality.hold']
+ if 'fusion.plating.quality.hold' in self.env else None)
+ if Hold is None:
+ return result
+ Facility = self.env['fusion.plating.facility']
+ for job in self:
+ if job.id not in scrap_deltas:
+ continue
+ old, new = scrap_deltas[job.id]
+ delta = new - old
+ facility = job.facility_id or Facility.search([
+ ('company_id', '=', job.company_id.id),
+ ], limit=1) or Facility.search([], limit=1)
+ part_ref = (
+ job.part_catalog_id.part_number if job.part_catalog_id
+ else job.product_id.default_code or job.name
+ )
+ try:
+ hold = Hold.create({
+ 'job_id': job.id,
+ 'part_ref': (part_ref or job.name)[:64],
+ 'qty_on_hold': int(delta),
+ 'qty_original': int(job.qty or 0),
+ 'mark_for_scrap': True,
+ 'hold_reason': 'other',
+ 'description': _(
+ 'Auto-spawned from job %s scrap update by %s: '
+ 'qty_scrapped went from %g to %g (delta %g). '
+ 'OPERATOR: replace this text with the actual '
+ 'reason (drop / contamination / out-of-spec / etc).'
+ ) % (job.name, self.env.user.name, old, new, delta),
+ 'facility_id': facility.id if facility else False,
+ })
+ job.message_post(body=_Markup(_(
+ '⚠️ Scrap auto-Hold spawned: %s for %g part(s). '
+ 'Operator must update description with the cause.'
+ )) % (hold.name, delta))
+ except Exception as e:
+ _logger.warning(
+ 'Job %s: failed to auto-spawn scrap hold: %s',
+ job.name, e,
+ )
+ return result
+
+ def action_sync_qty_from_so(self):
+ """Pull the SO qty into the job's qty field after a mid-job
+ SO line edit. Posts chatter so the audit trail captures who
+ synced + what the previous value was.
+
+ Manual action because qty changes mid-job have physical-world
+ consequences (rack more parts, stop early, scrap excess) — the
+ supervisor must explicitly acknowledge by clicking the button.
+ """
+ from markupsafe import Markup
+ for job in self:
+ if not job.sale_order_id:
+ continue
+ so_qty = sum(job.sale_order_id.order_line.mapped('product_uom_qty'))
+ old = job.qty
+ if abs(old - so_qty) < 0.0001:
+ continue
+ job.qty = so_qty
+ job.message_post(body=Markup(_(
+ 'Job qty synced from SO by %s: %g → %g (Δ %+g). '
+ 'Operator: confirm physical scope matches.'
+ )) % (self.env.user.name, old, so_qty, so_qty - old))
+ return True
+
# ------------------------------------------------------------------
# Recipe → fp.job.step generation (Task 2.4)
#
@@ -523,6 +612,15 @@ class FpJob(models.Model):
# short-circuits when steps already exist.
if job.recipe_id and not job.step_ids:
job._generate_steps_from_recipe()
+ # Promote freshly-generated 'pending' steps to 'ready' so the
+ # operator has a Start button when they open the job. Without
+ # this the floor stalls — every step is parked in pending with
+ # no UI affordance to move it forward.
+ pending_steps = job.step_ids.filtered(
+ lambda s: s.state == 'pending'
+ )
+ if pending_steps:
+ pending_steps.write({'state': 'ready'})
job._fp_create_portal_job()
job._fp_create_qc_check_if_needed()
job._fp_create_racking_inspection()
@@ -576,13 +674,13 @@ class FpJob(models.Model):
self.portal_job_id = portal.id
def _fp_create_qc_check_if_needed(self):
- """If customer has x_fc_requires_qc=True, create a QC check.
+ """If customer has x_fc_requires_qc=True, spawn a QC check via
+ the canonical fp.quality.check.create_for_job() entry point.
- The fusion.plating.quality.check model lives in
- fusion_plating_bridge_mrp; we runtime-detect it to avoid a
- depends-on-bridge_mrp cycle. If the model isn't registered, log
- a warning and skip — bridge_mrp can be installed later without
- breaking this flow.
+ Sub 11 — model relocated from bridge_mrp to fusion_plating_quality.
+ create_for_job resolves the template (customer-specific or default),
+ clones every template line, returns an existing record if one is
+ already open, and posts a chatter trail.
"""
self.ensure_one()
partner = self.partner_id
@@ -593,31 +691,13 @@ class FpJob(models.Model):
if not wants_qc:
return
if 'fusion.plating.quality.check' not in self.env:
- _logger.warning(
- "Job %s: customer wants QC but fusion.plating.quality.check "
- "model not registered (bridge_mrp deferral).", self.name,
- )
return
- QC = self.env['fusion.plating.quality.check'].sudo()
- # Try to create with the most likely required fields. If the
- # model has a different schema than expected, this may need
- # adjustment when bridge_mrp's QC model lands here.
+ QC = self.env['fusion.plating.quality.check']
try:
- qc_vals = {
- 'partner_id': partner.id,
- 'state': 'pending',
- }
- # Try the new field name first; fallback to mrp-bound.
- if 'job_id' in QC._fields:
- qc_vals['job_id'] = self.id
- elif 'production_id' in QC._fields:
- # bridge_mrp's QC binds to production. We can't fill that
- # from here — leave it null and let a manual link happen.
- pass
- QC.create(qc_vals)
+ QC.create_for_job(self)
except Exception as e:
_logger.warning(
- "Job %s: failed to create QC check: %s", self.name, e,
+ "Job %s: create_for_job failed: %s", self.name, e,
)
# ------------------------------------------------------------------
@@ -626,12 +706,22 @@ class FpJob(models.Model):
def button_mark_done(self):
"""Transition the job to 'done' and trigger downstream side effects.
+ - Blocks if any step is not done/skipped (manager bypass via
+ context key `fp_skip_step_gate=True`). Compliance: AS9100 /
+ Nadcap require evidence that every recipe step ran. Without
+ this guard an operator could close a job with zero work.
+ - Blocks if customer requires QC and the QC check isn't passed
+ (manager bypass via context key `fp_skip_qc_gate=True`)
- Sets state='done', date_finished=now
- Auto-creates a draft fusion.plating.delivery
- Triggers certificate auto-generation (best-effort)
"""
# During migration, side-effects are skipped — see action_confirm.
skip_side_effects = self.env.context.get('fp_jobs_migration')
+ skip_qc_gate = self.env.context.get('fp_skip_qc_gate')
+ skip_step_gate = self.env.context.get('fp_skip_step_gate')
+ QC = self.env['fusion.plating.quality.check'] \
+ if 'fusion.plating.quality.check' in self.env else None
for job in self:
if job.state == 'done':
continue
@@ -639,6 +729,105 @@ class FpJob(models.Model):
raise UserError(
"Job %s is cancelled — cannot mark done." % job.name
)
+ # Step-completion gate: every step must be done (or explicitly
+ # skipped, once button_skip is implemented). Without this
+ # guard operators can close a recipe-driven job with zero
+ # actual work logged. Manager bypass via context.
+ if not skip_step_gate and job.step_ids:
+ # `skipped` and `cancelled` count as terminal — operator
+ # explicitly opted those out (skipped) or killed them
+ # (cancelled). Only steps still in pending/ready/in_progress/
+ # paused block job close.
+ undone = job.step_ids.filtered(
+ lambda s: s.state not in ('done', 'skipped', 'cancelled')
+ )
+ if undone:
+ raise UserError(_(
+ "Job %s cannot be marked Done — %d/%d step(s) "
+ "are not finished:\n %s\n\nWalk each step on "
+ "the tablet (or skip / cancel opt-in steps)."
+ ) % (
+ job.name, len(undone), len(job.step_ids),
+ '\n '.join(
+ f'#{s.sequence} {s.name} ({s.state})'
+ for s in undone[:5]
+ ),
+ ))
+ # Bake-window gate (compliance — AS9100 / Nadcap): if any
+ # auto-spawned bake.window is still awaiting_bake OR
+ # bake_in_progress, the bake hasn't been documented and
+ # parts cannot ship. Without this guard a careless
+ # operator closes the job, parts ship, three weeks later
+ # a field failure surfaces and the auditor asks for the
+ # bake record that doesn't exist. Manager bypass via
+ # fp_skip_bake_gate=True for documented customer deviation.
+ skip_bake_gate = self.env.context.get('fp_skip_bake_gate')
+ BW = (self.env['fusion.plating.bake.window']
+ if 'fusion.plating.bake.window' in self.env else None)
+ if not skip_bake_gate and BW is not None:
+ pending_bw = BW.sudo().search([
+ ('part_ref', '=', job.name),
+ ('state', 'in', ('awaiting_bake', 'bake_in_progress')),
+ ])
+ if pending_bw:
+ raise UserError(_(
+ "Job %s cannot be marked Done — bake window "
+ "still pending:\n %s\n\nBake hydrogen "
+ "embrittlement relief on the parts (start + "
+ "end the bake on the bake.window record), then "
+ "close the job. Manager override available for "
+ "documented customer deviation."
+ ) % (
+ job.name,
+ '\n '.join(
+ f'{bw.name} (state={bw.state}, '
+ f'required_by={bw.bake_required_by})'
+ for bw in pending_bw[:5]
+ ),
+ ))
+ # Qty reconciliation gate: qty_done + qty_scrapped must
+ # equal qty when the job closes. Without this an operator
+ # can ship "5 of 5" while only 4 are actually plated +
+ # 1 contaminated, with no record of the missing piece.
+ # Manager bypass via fp_skip_qty_reconcile=True (e.g. when
+ # qty tracking truly doesn't apply).
+ skip_qty_gate = self.env.context.get('fp_skip_qty_reconcile')
+ if not skip_qty_gate and job.qty:
+ accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
+ if abs(accounted - job.qty) > 0.0001:
+ raise UserError(_(
+ "Job %s qty mismatch — ordered %g, but qty_done "
+ "(%g) + qty_scrapped (%g) = %g. Update Quantity "
+ "Completed and Quantity Scrapped on the job "
+ "header so they sum to %g before closing."
+ ) % (
+ job.name, job.qty, job.qty_done or 0,
+ job.qty_scrapped or 0, accounted, job.qty,
+ ))
+ # QC gate: customers flagged x_fc_requires_qc must have a
+ # passed QC before the job closes. AS9100 / Nadcap compliance.
+ if QC and not skip_qc_gate \
+ and 'x_fc_requires_qc' in job.partner_id._fields \
+ and job.partner_id.x_fc_requires_qc:
+ blocking_qc = QC.search([
+ ('job_id', '=', job.id),
+ ('state', 'not in', ('passed',)),
+ ], order='create_date desc', limit=1)
+ if blocking_qc:
+ raise UserError(_(
+ "Job %s cannot be marked Done — QC check %s is in "
+ "state '%s'. Pass the QC checklist first, or have "
+ "a manager override via the bypass button."
+ ) % (job.name, blocking_qc.name, blocking_qc.state))
+ # No QC at all? Spawn one now (idempotent) and require
+ # the operator to walk it before retrying.
+ no_qc = not QC.search_count([('job_id', '=', job.id)])
+ if no_qc:
+ QC.create_for_job(job)
+ raise UserError(_(
+ "Job %s requires QC. A new check has been created — "
+ "complete it before marking the job Done."
+ ) % job.name)
job.state = 'done'
job.date_finished = fields.Datetime.now()
if not skip_side_effects:
@@ -682,33 +871,31 @@ class FpJob(models.Model):
)
def _fp_create_delivery(self):
- """Create a draft fusion.plating.delivery linked to this job."""
+ """Create a draft fusion.plating.delivery linked to this job.
+
+ Sets BOTH x_fc_job_id (Many2one — strong link) AND job_ref
+ (Char — soft reference). Downstream code is split: smart-button
+ navigation reads x_fc_job_id, but the box-parity check, RMA
+ refund auto-link, and the legacy notification dispatch all
+ look up by job_ref. Setting both ends keeps every consumer
+ happy.
+ """
self.ensure_one()
if self.delivery_id:
return
Delivery = self.env['fusion.plating.delivery'].sudo()
- # Verify the model has a job link field. The current delivery
- # model uses `job_ref` (Char) as a soft reference; some forks
- # may add `x_fc_job_id` (Many2one).
+ vals = {'partner_id': self.partner_id.id}
if 'x_fc_job_id' in Delivery._fields:
- ref_field = 'x_fc_job_id'
- ref_value = self.id
- elif 'job_ref' in Delivery._fields:
- ref_field = 'job_ref'
- ref_value = self.name
- else:
+ vals['x_fc_job_id'] = self.id
+ if 'job_ref' in Delivery._fields:
+ vals['job_ref'] = self.name
+ if 'x_fc_job_id' not in Delivery._fields \
+ and 'job_ref' not in Delivery._fields:
_logger.warning(
"Job %s: fusion.plating.delivery has no job link field; "
"delivery created without job back-reference.", self.name,
)
- ref_field = None
- ref_value = None
try:
- vals = {
- 'partner_id': self.partner_id.id,
- }
- if ref_field:
- vals[ref_field] = ref_value
delivery = Delivery.create(vals)
self.delivery_id = delivery.id
except Exception as e:
@@ -719,29 +906,87 @@ class FpJob(models.Model):
def _fp_create_certificates(self):
"""Trigger cert auto-create on job done.
- Best-effort: if fp.certificate has the right fields, create a
- draft CoC. Otherwise log + skip.
+ 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
+
+ Idempotent — if a cert already exists for this job, skip
+ (prevents dupes when button_mark_done is re-run after a
+ manager bypass).
"""
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,
+ )
+ return
try:
- vals = {
- 'partner_id': self.partner_id.id,
- }
+ 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'
- # Add job link if Cert has the field
+ # 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
- elif 'sale_order_id' in Cert._fields and self.sale_order_id:
+ if 'sale_order_id' in Cert._fields and self.sale_order_id:
vals['sale_order_id'] = self.sale_order_id.id
- Cert.create(vals)
+ # 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)
+ )
+ # 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,
diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py
index 9cc7e5b4..97edec20 100644
--- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py
+++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py
@@ -6,20 +6,55 @@
# fusion_plating core's fp.job.step shipped as NotImplementedError
# placeholders. Per spec §5.2 state machine.
-from odoo import _, fields, models
+import logging
+import re
+
+from markupsafe import Markup
+
+from odoo import _, api, fields, models
from odoo.exceptions import UserError
+_logger = logging.getLogger(__name__)
+
class FpJobStep(models.Model):
_inherit = 'fp.job.step'
def button_start(self):
- """Override — soft gate when parts haven't been received yet.
+ """Override — soft gate when parts haven't been received yet,
+ plus hard predecessor gate for steps flagged
+ requires_predecessor_done by the recipe author.
- Doesn't block (parts could be in-transit late, manager wants
- the shop to start prep regardless), but posts a chatter warning
- on the job so the audit trail captures premature starts.
+ Receiving check is soft (logs to chatter) — manager wants the
+ shop to start prep regardless when parts are in-transit late.
+
+ Predecessor check IS hard-blocking — if the recipe author
+ marked this step as serial-required, every earlier-sequence
+ step must be terminal (done / skipped / cancelled) before
+ Start fires. Manager bypass via fp_skip_predecessor_check=True.
"""
+ skip_pred = self.env.context.get('fp_skip_predecessor_check')
+ for step in self:
+ if not step.requires_predecessor_done or skip_pred:
+ continue
+ blocking = step.job_id.step_ids.filtered(
+ lambda s: s.sequence < step.sequence and s.state not in (
+ 'done', 'skipped', 'cancelled',
+ )
+ )
+ if blocking:
+ raise UserError(_(
+ "Step '%s' requires predecessors done first. "
+ "Blocking earlier step(s):\n %s\n\nFinish or skip "
+ "those before starting this one (manager can "
+ "override via context fp_skip_predecessor_check=True)."
+ ) % (
+ step.name,
+ '\n '.join(
+ f'#{s.sequence} {s.name} ({s.state})'
+ for s in blocking[:5]
+ ),
+ ))
result = super().button_start()
for step in self:
so = step.job_id.sale_order_id
@@ -88,3 +123,293 @@ class FpJobStep(models.Model):
) % step.name)
step.state = 'cancelled'
return True
+
+ def write(self, vals):
+ """Post a chatter trail on the parent JOB whenever an active
+ step gets reassigned. The step itself already tracks
+ assigned_user_id (tracking=True) but supervisors don't open
+ each step's chatter — they read the job. Without a job-level
+ post the takeover is invisible.
+
+ Only fires for steps in active states (in_progress / paused)
+ so creating a draft job + assigning a step to someone doesn't
+ spam the job chatter. Comparing to the OLD assignment so we
+ don't post on the initial set-from-False either.
+ """
+ post_for = []
+ if 'assigned_user_id' in vals:
+ new_uid = vals['assigned_user_id']
+ for step in self:
+ if step.state not in ('in_progress', 'paused'):
+ continue
+ old_uid = step.assigned_user_id.id
+ if not old_uid:
+ continue
+ if new_uid == old_uid:
+ continue
+ post_for.append((step, old_uid, new_uid))
+ result = super().write(vals)
+ Users = self.env['res.users']
+ for step, old_uid, new_uid in post_for:
+ old_name = Users.browse(old_uid).name if old_uid else '(unassigned)'
+ new_name = Users.browse(new_uid).name if new_uid else '(unassigned)'
+ step.job_id.message_post(body=Markup(_(
+ 'Step %s reassigned from %s to %s '
+ '(state=%s) by %s.'
+ )) % (step.name, old_name, new_name, step.state,
+ self.env.user.name))
+ return result
+
+ @api.model
+ def _cron_nudge_stale_paused(self, threshold_hours=24):
+ """Daily nudge for steps stuck in `paused` longer than threshold."""
+ return self._cron_nudge_stale_steps(
+ states=('paused',),
+ threshold_hours=threshold_hours,
+ label='paused',
+ )
+
+ @api.model
+ def _cron_nudge_stale_in_progress(self, threshold_hours=8):
+ """Cron nudge for steps stuck in `in_progress` longer than
+ threshold. Default 8 hours — operator started, walked away,
+ timelog accumulating phantom hours.
+ """
+ return self._cron_nudge_stale_steps(
+ states=('in_progress',),
+ threshold_hours=threshold_hours,
+ label='in-progress',
+ )
+
+ @api.model
+ def _cron_nudge_stale_steps(self, states=('paused',),
+ threshold_hours=24, label='stale'):
+ """Generic stale-step nudger.
+
+ Finds every fp.job.step in any of `states` with date_started
+ older than N hours. Schedules a 'todo' mail.activity on the
+ parent job for the job's manager_id (falls back to the user
+ who started the step). Idempotent — won't double-schedule if
+ an open activity with the same summary already exists.
+ """
+ from datetime import timedelta as _td
+ cutoff = fields.Datetime.now() - _td(hours=threshold_hours)
+ stale = self.search([
+ ('state', 'in', list(states)),
+ ('date_started', '<', cutoff),
+ ('date_started', '!=', False),
+ ])
+ Activity = self.env['mail.activity']
+ ActivityType = self.env.ref(
+ 'mail.mail_activity_data_todo', raise_if_not_found=False,
+ )
+ nudged_count = 0
+ for step in stale:
+ job = step.job_id
+ assignee = (job.manager_id or step.assigned_user_id
+ or step.started_by_user_id or self.env.user)
+ summary = _('Stale %s step: %s') % (label, step.name)
+ existing = Activity.search([
+ ('res_model', '=', job._name),
+ ('res_id', '=', job.id),
+ ('summary', '=', summary),
+ ], limit=1)
+ if existing:
+ continue
+ age_h = (fields.Datetime.now() - step.date_started).total_seconds() / 3600.0
+ note = _(
+ 'Step "%(step)s" on job %(job)s has been in %(label)s state for '
+ '%(hours).1f hours (since %(start)s). Investigate: operator '
+ 'reassignment, equipment failure, or finish + close out.'
+ ) % {
+ 'step': step.name, 'job': job.name, 'label': label,
+ 'hours': age_h, 'start': step.date_started,
+ }
+ vals = {
+ 'res_model_id': self.env['ir.model']._get(job._name).id,
+ 'res_id': job.id,
+ 'summary': summary,
+ 'note': note,
+ 'user_id': assignee.id,
+ 'date_deadline': fields.Date.context_today(self),
+ }
+ if ActivityType:
+ vals['activity_type_id'] = ActivityType.id
+ Activity.create(vals)
+ nudged_count += 1
+ job.message_post(body=Markup(_(
+ 'Stale %s step: %s has been idle %.1f hours. '
+ 'Activity created for %s.'
+ )) % (label, step.name, age_h, assignee.name))
+ if nudged_count:
+ _logger.info(
+ 'fp.job.step stale-%s cron: nudged %d step(s)',
+ label, nudged_count,
+ )
+ return nudged_count
+
+ def action_abort_for_retry(self, reason=None, new_tank_id=None,
+ new_bath_id=None):
+ """Abort an in_progress / paused step so the operator can restart
+ it (typically after an equipment failure mid-step).
+
+ Closes the open timelog (preserves the partial-work record on
+ the audit trail), posts a clear chatter event on the JOB
+ explaining why + which tank, optionally moves the step to a
+ different tank/bath, and resets the step to `ready` so the
+ operator can hit Start again.
+
+ Without this method the operator's only options are
+ button_cancel (kills the step entirely) or
+ pause → write tank → start (no failure audit).
+ """
+ if not reason:
+ reason = _('Equipment failure / abort for retry')
+ for step in self:
+ if step.state not in ('in_progress', 'paused'):
+ raise UserError(_(
+ "Step '%s' is in state '%s' — only in_progress / "
+ "paused steps can be aborted for retry."
+ ) % (step.name, step.state))
+ old_tank = step.tank_id.display_name or '(no tank set)'
+ old_bath = step.bath_id.display_name or '(no bath set)'
+ now = fields.Datetime.now()
+ open_logs = step.time_log_ids.filtered(
+ lambda l: not l.date_finished
+ )
+ if open_logs:
+ open_logs.write({'date_finished': now})
+ partial_min = sum(step.time_log_ids.mapped('duration_minutes'))
+ change_msg = ''
+ if new_tank_id:
+ step.tank_id = new_tank_id
+ change_msg += ' -> tank %s' % step.tank_id.display_name
+ if new_bath_id:
+ step.bath_id = new_bath_id
+ change_msg += ' -> bath %s' % step.bath_id.display_name
+ step.state = 'ready'
+ step.duration_actual = partial_min
+ step.job_id.message_post(body=Markup(_(
+ '⚠️ Step %s aborted for retry by %s. '
+ 'Reason: %s '
+ 'Equipment: tank=%s, bath=%s%s '
+ 'Partial work captured: %.2f min in %d timelog(s). '
+ 'Step is back in ready state — operator can '
+ 'restart when the issue is resolved.'
+ )) % (
+ step.name, self.env.user.name, reason,
+ old_tank, old_bath, change_msg, partial_min,
+ len(step.time_log_ids),
+ ))
+ return True
+
+ def action_recompute_duration_from_timelogs(self):
+ """Re-sum duration_actual from the step's timelog rows.
+
+ Use case: supervisor adjusts a timelog row (back-date a forgotten
+ click, fix wrong operator, delete a stale entry that was left
+ open over a shift change) and needs the step's duration_actual
+ to reflect the corrected reality. Without this, edits to time_log_ids
+ rows don't propagate into duration_actual (which is set once
+ by button_finish).
+
+ Posts the before/after to chatter for audit.
+ """
+ for step in self:
+ old = step.duration_actual or 0.0
+ new = sum(step.time_log_ids.mapped('duration_minutes'))
+ step.duration_actual = new
+ if abs(old - new) > 0.001:
+ step.job_id.message_post(body=Markup(_(
+ 'Step %s duration recomputed from timelog rows: '
+ '%.2f min → %.2f min (Δ %+.2f). Recomputed by %s.'
+ )) % (step.name, old, new, new - old, self.env.user.name))
+ return True
+
+ def button_finish(self):
+ """Override to:
+ 1) Auto-spawn a bake.window when a wet plating step finishes
+ on a coating that requires hydrogen-embrittlement relief
+ (AS9100 / Nadcap compliance);
+ 2) Post a chatter warning when duration_actual exceeds 1.5×
+ duration_expected — silent overruns are a red flag for
+ scheduling and costing.
+
+ Both actions are idempotent and never block the finish itself.
+ """
+ result = super().button_finish()
+ BW = self.env['fusion.plating.bake.window']
+ Bath = self.env['fusion.plating.bath']
+ for step in self:
+ if step.state != 'done':
+ continue
+ # Duration-overrun chatter alert.
+ if step.duration_expected and step.duration_actual:
+ ratio = step.duration_actual / step.duration_expected
+ if ratio >= 1.5:
+ step.job_id.message_post(body=Markup(_(
+ '⚠️ Step "%s" ran %.1fx expected — '
+ 'expected %.0f min, actual %.0f min. Investigate: '
+ 'equipment issue, training gap, or recipe time '
+ 'estimate too tight.'
+ )) % (step.name, ratio, step.duration_expected,
+ step.duration_actual))
+ coating = step.job_id.coating_config_id \
+ if 'coating_config_id' in step.job_id._fields else False
+ if not coating:
+ continue
+ requires = getattr(coating, 'requires_bake_relief', False)
+ window_hrs = getattr(coating, 'bake_window_hours', 0.0)
+ if not requires or not window_hrs:
+ continue
+ # Trigger only on the actual plating-out step. We want
+ # exactly ONE bake.window per job (not one per step that
+ # happens to have "plate" in the name). Heuristic:
+ # - step.kind == 'wet' (clean, recipe-authored signal); OR
+ # - the step name contains "plating" as a word
+ # Explicit excludes: inspection / bake / mask / rack steps
+ # whose names might happen to mention plating in passing
+ # (e.g. "Post-plate Inspection").
+ name_l = (step.name or '').lower()
+ kind_match = step.kind == 'wet'
+ name_match = bool(re.search(r'\bplating\b', name_l))
+ excluded = any(kw in name_l for kw in (
+ 'inspect', 'inspection', 'bake', 'mask', 'rack',
+ ))
+ if (not kind_match and not name_match) or excluded:
+ continue
+ # Idempotency — only one bake.window per (job, step).
+ existing = BW.sudo().search([
+ ('part_ref', '=', step.job_id.name),
+ ('lot_ref', '=', f'step-{step.id}'),
+ ], limit=1)
+ if existing:
+ continue
+ # Pick a bath: step.bath_id wins; fall back to the first
+ # active bath in the facility (best-effort — operator can
+ # correct on the bake.window record).
+ bath = step.bath_id or Bath.sudo().search(
+ [('facility_id', '=', step.facility_id.id)], limit=1,
+ ) if step.facility_id else False
+ if not bath:
+ bath = Bath.sudo().search([], limit=1)
+ if not bath:
+ _logger.warning(
+ 'Step %s: bake-window auto-spawn skipped — no bath '
+ 'configured.', step.name,
+ )
+ continue
+ bw = BW.sudo().create({
+ 'bath_id': bath.id,
+ 'plate_exit_time': step.date_finished or fields.Datetime.now(),
+ 'window_hours': window_hrs,
+ 'part_ref': step.job_id.name,
+ 'lot_ref': f'step-{step.id}',
+ 'customer_ref': step.job_id.partner_id.display_name or '',
+ 'quantity': int(step.job_id.qty or 0),
+ })
+ step.job_id.message_post(body=Markup(_(
+ 'Bake window %s auto-created — %.1fh window from '
+ 'plate exit. Required by %s.'
+ )) % (bw.name, window_hrs, bw.bake_required_by))
+ return result
diff --git a/fusion_plating/fusion_plating_jobs/models/sale_order.py b/fusion_plating/fusion_plating_jobs/models/sale_order.py
index 070687c2..79f7fb78 100644
--- a/fusion_plating/fusion_plating_jobs/models/sale_order.py
+++ b/fusion_plating/fusion_plating_jobs/models/sale_order.py
@@ -25,6 +25,14 @@ class SaleOrder(models.Model):
string='Plating Jobs',
compute='_compute_fp_job_count',
)
+ x_fc_fp_certificate_count = fields.Integer(
+ string='Certificates',
+ compute='_compute_fp_certificate_count',
+ help='Number of fp.certificate records issued (or draft) against '
+ 'this sale order. Surfaced as a smart button so Sarah/Tom '
+ 'can jump straight from the SO to the cert without having '
+ 'to drill through the linked Plating Job first.',
+ )
# ------------------------------------------------------------------
# Phase 4 (Sub 11) — workflow-stage field + assigned-manager field
@@ -66,6 +74,13 @@ class SaleOrder(models.Model):
[('sale_order_id', '=', so.id)]
)
+ def _compute_fp_certificate_count(self):
+ Cert = self.env['fp.certificate'].sudo()
+ for so in self:
+ so.x_fc_fp_certificate_count = Cert.search_count(
+ [('sale_order_id', '=', so.id)]
+ )
+
def _compute_workflow_stage(self):
"""Native-jobs override — walks fp.job state instead of mrp.production.
@@ -162,6 +177,28 @@ class SaleOrder(models.Model):
})
return action
+ def action_view_fp_certificates(self):
+ """Smart-button target — open the certificate(s) linked to this
+ SO. One cert → form view; many → list view filtered to this SO."""
+ self.ensure_one()
+ certs = self.env['fp.certificate'].search([
+ ('sale_order_id', '=', self.id),
+ ])
+ action = {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Certificates'),
+ 'res_model': 'fp.certificate',
+ 'view_mode': 'list,form',
+ 'domain': [('sale_order_id', '=', self.id)],
+ 'context': {
+ 'default_sale_order_id': self.id,
+ 'default_partner_id': self.partner_id.id,
+ },
+ }
+ if len(certs) == 1:
+ action.update({'view_mode': 'form', 'res_id': certs.id})
+ return action
+
def action_confirm(self):
result = super().action_confirm()
# Only run when the native flag is on
@@ -209,6 +246,18 @@ class SaleOrder(models.Model):
or ('x_fc_coating_config_id' in l._fields and l.x_fc_coating_config_id)
)
)
+ # Fallback: legacy/configurator SOs that carry part+coating on the
+ # header but not on the line. Treat the entire order as one
+ # plating line so the planner gets an fp.job to work against.
+ if not plating_lines and self.order_line and (
+ ('x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id)
+ or ('x_fc_coating_config_id' in self._fields and self.x_fc_coating_config_id)
+ ):
+ _logger.info(
+ 'SO %s: no line-level part/coating but header carries one — '
+ 'treating all lines as a single plating job.', self.name,
+ )
+ plating_lines = self.order_line
if not plating_lines:
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return
@@ -239,13 +288,38 @@ class SaleOrder(models.Model):
and first_line.x_fc_coating_config_id
or False
)
- # Recipe lookup: from coating, fallback to part
+ # Header fallback for legacy/configurator SOs that put part +
+ # coating on the SO header instead of the line.
+ if not part and 'x_fc_part_catalog_id' in self._fields:
+ part = self.x_fc_part_catalog_id or False
+ if not coating and 'x_fc_coating_config_id' in self._fields:
+ coating = self.x_fc_coating_config_id or False
+ # Recipe lookup priority:
+ # 1. line.x_fc_process_variant_id — Sarah explicitly picked
+ # a part-scoped variant on this order line. Always wins.
+ # 2. coating.recipe_id — coating-config recipe.
+ # 3. part.default_process_id — part's flagged default.
+ # 4. part.recipe_id — legacy fallback.
+ #
+ # If multiple lines in the same WO group have different
+ # variants we use the FIRST line's variant (consistent with
+ # everything else in this loop using `first_line`).
recipe = False
- if coating and 'recipe_id' in coating._fields and coating.recipe_id:
+ picked_variant = (
+ 'x_fc_process_variant_id' in first_line._fields
+ and first_line.x_fc_process_variant_id
+ or False
+ )
+ if picked_variant:
+ recipe = picked_variant
+ if not recipe and coating and 'recipe_id' in coating._fields \
+ and coating.recipe_id:
recipe = coating.recipe_id
- if not recipe and part and 'default_process_id' in part._fields and part.default_process_id:
+ if not recipe and part and 'default_process_id' in part._fields \
+ and part.default_process_id:
recipe = part.default_process_id
- if not recipe and part and 'recipe_id' in part._fields and part.recipe_id:
+ if not recipe and part and 'recipe_id' in part._fields \
+ and part.recipe_id:
recipe = part.recipe_id
vals = {
diff --git a/fusion_plating/fusion_plating_jobs/models/sale_order_line.py b/fusion_plating/fusion_plating_jobs/models/sale_order_line.py
new file mode 100644
index 00000000..374662d1
--- /dev/null
+++ b/fusion_plating/fusion_plating_jobs/models/sale_order_line.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+#
+# Mid-job qty drift guard. When Sarah edits an SO line's qty after a
+# fp.job has been spawned and started, the job's qty does NOT auto-
+# update (intentionally — Carlos may already be plating). But without
+# a warning the qty drift is silent and bills go out wrong. This
+# write-override posts chatter on every active linked job so operators
+# see the change immediately, AND offers a "Sync qty from SO" action
+# on the job for the supervisor to apply.
+
+from markupsafe import Markup
+
+from odoo import _, models
+
+
+class SaleOrderLine(models.Model):
+ _inherit = 'sale.order.line'
+
+ def write(self, vals):
+ # Detect qty changes BEFORE the write so we can compare.
+ old_qty_by_id = {}
+ if 'product_uom_qty' in vals:
+ for line in self:
+ old_qty_by_id[line.id] = line.product_uom_qty
+ result = super().write(vals)
+ if 'product_uom_qty' not in vals:
+ return result
+ Job = self.env['fp.job']
+ for line in self:
+ new_qty = line.product_uom_qty
+ old_qty = old_qty_by_id.get(line.id, new_qty)
+ if old_qty == new_qty:
+ continue
+ jobs = Job.search([
+ ('sale_order_id', '=', line.order_id.id),
+ ('state', 'not in', ('draft', 'cancelled', 'done')),
+ ])
+ for job in jobs:
+ job.message_post(body=Markup(_(
+ '⚠️ SO qty changed mid-job by %(user)s. '
+ 'SO line %(name)s went from %(old)g to %(new)g. '
+ 'Job qty is still %(jobqty)g — operator '
+ 'must manually adjust scope (start more racks or '
+ 'stop early) and the supervisor should hit '
+ 'Sync qty from SO on the job header to '
+ 'reconcile.'
+ )) % {
+ 'user': self.env.user.name,
+ 'name': line.name[:60] if line.name else '(unnamed)',
+ 'old': old_qty,
+ 'new': new_qty,
+ 'jobqty': job.qty,
+ })
+ return result
diff --git a/fusion_plating/fusion_plating_jobs/views/fp_certificate_views.xml b/fusion_plating/fusion_plating_jobs/views/fp_certificate_views.xml
new file mode 100644
index 00000000..1906ffac
--- /dev/null
+++ b/fusion_plating/fusion_plating_jobs/views/fp_certificate_views.xml
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ fp.certificate.form.inherit.jobs
+ fp.certificate
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fischerscope
+
+
+
+
+
+
+
+
+
+ Fischerscope thickness PDF is on file.
+ It will be automatically appended as page 2 of
+ the CoC when you click Issue.
+
+
+
+ Fischerscope thickness report merged.
+ The issued CoC PDF includes the Fischerscope report
+ as page 2 — open the Certificate PDF tab to verify.
+
+
+
+ No Fischerscope PDF on the linked QC.
+ If this customer expects an XRF report with the CoC,
+ have the operator upload the Fischerscope PDF on the
+ QC check before issuing.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No Fischerscope thickness PDF has been
+ uploaded on the linked QC yet. The CoC will
+ be issued without an appended thickness
+ report. To attach one:
+
+
+
Open the linked Plating Job (smart
+ button above)
+
Click into the auto-spawned Quality
+ Check
+
Go to the Thickness Report tab
+ and upload the PDF from the Fischerscope
+ / XDAL 600 export
+
Pass the QC, then come back here and
+ click Issue
+
+
+
+
+
+ Click Issue in the header
+ and the Fischerscope PDF above will be
+ merged into page 2 of the CoC.
+
+
+
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_jobs/views/fp_job_quality_buttons.xml b/fusion_plating/fusion_plating_jobs/views/fp_job_quality_buttons.xml
new file mode 100644
index 00000000..c2d8aded
--- /dev/null
+++ b/fusion_plating/fusion_plating_jobs/views/fp_job_quality_buttons.xml
@@ -0,0 +1,43 @@
+
+
+
+
+ fp.job.form.quality.buttons
+ fp.job
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_jobs/views/legacy_menu_hide.xml b/fusion_plating/fusion_plating_jobs/views/legacy_menu_hide.xml
index f7293ff5..6d20bae3 100644
--- a/fusion_plating/fusion_plating_jobs/views/legacy_menu_hide.xml
+++ b/fusion_plating/fusion_plating_jobs/views/legacy_menu_hide.xml
@@ -23,10 +23,7 @@
-
-
-
-
+
diff --git a/fusion_plating/fusion_plating_jobs/views/sale_order_views.xml b/fusion_plating/fusion_plating_jobs/views/sale_order_views.xml
index 1a51a03b..4e5c94f9 100644
--- a/fusion_plating/fusion_plating_jobs/views/sale_order_views.xml
+++ b/fusion_plating/fusion_plating_jobs/views/sale_order_views.xml
@@ -23,6 +23,14 @@
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating_logistics/__manifest__.py b/fusion_plating/fusion_plating_logistics/__manifest__.py
index ceeb7479..7dc44d75 100644
--- a/fusion_plating/fusion_plating_logistics/__manifest__.py
+++ b/fusion_plating/fusion_plating_logistics/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Logistics',
- 'version': '19.0.3.0.0',
+ 'version': '19.0.3.1.0',
'category': 'Manufacturing/Plating',
'summary': (
'Pickup & delivery for plating shops: vehicle master, driver '
diff --git a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py
index 33cfcc8e..8cc12c67 100644
--- a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py
+++ b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py
@@ -251,17 +251,16 @@ class FpDelivery(models.Model):
self.ensure_one()
if not self.x_fc_box_count_out:
return
- Receiving = self.env.get('fp.receiving')
- if Receiving is None:
+ if 'fp.receiving' not in self.env:
return
- # Resolve SO via job_ref → MO.origin → SO.name
+ # Sub 11 — resolve SO via job_ref → fp.job.origin → SO.name.
so_name = False
- if self.job_ref:
- mo = self.env['mrp.production'].search(
+ if self.job_ref and 'fp.job' in self.env:
+ job = self.env['fp.job'].sudo().search(
[('name', '=', self.job_ref)], limit=1,
)
- if mo and mo.origin:
- so_name = mo.origin
+ if job and job.origin:
+ so_name = job.origin
if not so_name:
return
so = self.env['sale.order'].search(
diff --git a/fusion_plating/fusion_plating_notifications/__manifest__.py b/fusion_plating/fusion_plating_notifications/__manifest__.py
index 943ca8b7..f3714da0 100644
--- a/fusion_plating/fusion_plating_notifications/__manifest__.py
+++ b/fusion_plating/fusion_plating_notifications/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Notifications',
- 'version': '19.0.6.0.0',
+ 'version': '19.0.6.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
'author': 'Nexa Systems Inc.',
diff --git a/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py b/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py
index 6e1a954f..92a89e36 100644
--- a/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py
+++ b/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py
@@ -13,11 +13,16 @@ TRIGGER_EVENTS = [
('quote_sent', 'Quotation Sent'),
('so_confirmed', 'Order Confirmed'),
('parts_received', 'Parts Received'),
- ('mo_complete', 'Manufacturing Complete'),
+ ('mo_complete', 'Manufacturing Complete'), # legacy, fired by mrp; kept for back-compat
+ ('job_confirmed', 'Plating Job Confirmed'), # Sub 11 — fp.job lifecycle
+ ('job_complete', 'Plating Job Complete'), # Sub 11 — fp.job.button_mark_done
('shipped', 'Shipped / Delivered'),
('invoice_posted', 'Invoice Posted'),
('payment_received', 'Payment Received'),
('deposit_created', 'Deposit Required'),
+ ('rma_authorised', 'RMA Authorised'), # Sub 12 — RMA lifecycle
+ ('rma_received', 'RMA Parts Received'),
+ ('rma_resolved', 'RMA Resolved'),
]
# Sub 6 — map each trigger event to a communication stream. Contacts on
@@ -29,10 +34,15 @@ FP_TRIGGER_STREAM = {
'so_confirmed': 'quotes_so',
'parts_received': 'quotes_so',
'mo_complete': 'qc',
+ 'job_confirmed': 'qc',
+ 'job_complete': 'qc',
'shipped': 'certs',
'invoice_posted': 'invoices',
'payment_received': 'invoices',
'deposit_created': 'invoices',
+ 'rma_authorised': 'qc',
+ 'rma_received': 'qc',
+ 'rma_resolved': 'qc',
}
@@ -117,6 +127,9 @@ class FpNotificationTemplate(models.Model):
)
elif partner.email:
recipient_emails = [partner.email]
+ # Filter out falsy entries — sub-contacts may have no email and the
+ # resolver returns False/None for them. Joining with bool blows up.
+ recipient_emails = [e for e in (recipient_emails or []) if e]
recipient_str = ', '.join(recipient_emails)
email_values = {}
diff --git a/fusion_plating/fusion_plating_notifications/models/mrp_production.py b/fusion_plating/fusion_plating_notifications/models/mrp_production.py
deleted file mode 100644
index 2ecaf768..00000000
--- a/fusion_plating/fusion_plating_notifications/models/mrp_production.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2026 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Plating product family.
-
-from odoo import models
-
-
-class MrpProduction(models.Model):
- _inherit = 'mrp.production'
-
- def button_mark_done(self):
- res = super().button_mark_done()
- Dispatch = self.env['fp.notification.template']
- for mo in self:
- partner = False
- so = False
- if mo.x_fc_portal_job_id:
- partner = mo.x_fc_portal_job_id.partner_id
- if mo.origin:
- so = self.env['sale.order'].search(
- [('name', '=', mo.origin)], limit=1,
- )
- if so and not partner:
- partner = so.partner_id
- if not partner:
- continue
- Dispatch._dispatch(
- 'mo_complete', mo, partner, sale_order=so,
- )
- return res
diff --git a/fusion_plating/fusion_plating_quality/__manifest__.py b/fusion_plating/fusion_plating_quality/__manifest__.py
index 824a7535..b1685747 100644
--- a/fusion_plating/fusion_plating_quality/__manifest__.py
+++ b/fusion_plating/fusion_plating_quality/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
- 'version': '19.0.3.0.0',
+ 'version': '19.0.4.7.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',
@@ -69,6 +69,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_configurator',
'fusion_plating_certificates', # fp.thickness.reading link from QC
'fusion_plating_shopfloor', # _fp_shopfloor_tokens.scss for QC tablet
+ 'fusion_plating_receiving', # rma_id on fp.receiving (Sub 12 Phase A)
+ # NB: deliberately NOT depending on fusion_plating_jobs — it depends
+ # on us already (extends fusion.plating.quality.hold). Many2one('fp.job')
+ # on fp.rma is resolved by the registry once jobs loads after us.
'mail',
],
'data': [
@@ -76,6 +80,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'security/ir.model.access.csv',
'data/fp_sequence_data.xml',
'data/fp_quality_hold_sequence_data.xml',
+ 'data/fp_rma_sequence.xml',
+ 'data/fp_quality_categorisation_data.xml',
'data/fp_qc_data.xml',
'views/fp_qc_template_views.xml',
'views/fp_quality_hold_views.xml',
@@ -93,6 +99,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_contract_review_views.xml',
'views/fp_part_catalog_views.xml',
'views/fp_quality_check_views.xml',
+ 'views/fp_rma_views.xml',
+ 'views/fp_quality_categorisation_views.xml',
+ 'views/fp_quality_point_views.xml',
+ 'views/fp_quality_smart_button_views.xml',
+ 'views/fp_quality_dashboard_views.xml',
'reports/fp_contract_review_report.xml',
'reports/fp_contract_review_template.xml',
'views/fp_menu.xml',
@@ -107,6 +118,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_quality/static/src/scss/fp_qc_checklist.scss',
'fusion_plating_quality/static/src/xml/fp_qc_checklist.xml',
'fusion_plating_quality/static/src/js/fp_qc_checklist.js',
+ # Sub 12 Phase D — Unified Quality Dashboard.
+ 'fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss',
+ 'fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml',
+ 'fusion_plating_quality/static/src/js/fp_quality_dashboard.js',
],
},
'installable': True,
diff --git a/fusion_plating/fusion_plating_quality/controllers/__init__.py b/fusion_plating/fusion_plating_quality/controllers/__init__.py
index 829f2c34..e3fa1667 100644
--- a/fusion_plating/fusion_plating_quality/controllers/__init__.py
+++ b/fusion_plating/fusion_plating_quality/controllers/__init__.py
@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import fp_qc_controller
+from . import fp_quality_dashboard
diff --git a/fusion_plating/fusion_plating_quality/controllers/fp_quality_dashboard.py b/fusion_plating/fusion_plating_quality/controllers/fp_quality_dashboard.py
new file mode 100644
index 00000000..c0db9af7
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/controllers/fp_quality_dashboard.py
@@ -0,0 +1,90 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 12 Phase D — counts endpoint for the Unified Quality Dashboard.
+
+from odoo import fields, http
+from odoo.http import request
+
+
+class FpQualityDashboardController(http.Controller):
+
+ @http.route('/fp/quality/dashboard/counts',
+ type='jsonrpc', auth='user', methods=['POST'])
+ def counts(self):
+ """Return per-tab open + overdue counts for the dashboard.
+
+ "Overdue" definition:
+ - Hold: state='on_hold' for > 3 days
+ - Check: state='pending' for > 1 day
+ - NCR: state in (open, containment, disposition) AND reported >7d
+ - CAPA: due_date < today AND state not in (effective, closed)
+ - RMA: state='received' for > 5 days (triage past due) OR
+ state in (authorised, shipped_to_us) for > 14 days
+ """
+ env = request.env
+ today = fields.Date.context_today(env.user)
+ now = fields.Datetime.now()
+
+ Hold = env['fusion.plating.quality.hold']
+ Check = env['fusion.plating.quality.check']
+ Ncr = env['fusion.plating.ncr']
+ Capa = env['fusion.plating.capa']
+ Rma = env['fusion.plating.rma']
+
+ d3 = fields.Datetime.subtract(now, days=3)
+ d1 = fields.Datetime.subtract(now, days=1)
+ d7 = fields.Datetime.subtract(now, days=7)
+ d5 = fields.Datetime.subtract(now, days=5)
+ d14 = fields.Datetime.subtract(now, days=14)
+
+ return {
+ 'holds': {
+ 'open': Hold.search_count(
+ [('state', 'in', ('on_hold', 'under_review'))]),
+ 'overdue': Hold.search_count([
+ ('state', 'in', ('on_hold', 'under_review')),
+ ('create_date', '<', d3),
+ ]),
+ },
+ 'checks': {
+ 'open': Check.search_count([('state', '=', 'pending')]),
+ 'overdue': Check.search_count([
+ ('state', '=', 'pending'),
+ ('create_date', '<', d1),
+ ]),
+ },
+ 'ncrs': {
+ 'open': Ncr.search_count([
+ ('state', 'in', ('open', 'containment', 'disposition')),
+ ]),
+ 'overdue': Ncr.search_count([
+ ('state', 'in', ('open', 'containment', 'disposition')),
+ ('reported_date', '<', d7),
+ ]),
+ },
+ 'capas': {
+ 'open': Capa.search_count([
+ ('state', 'not in', ('effective', 'closed')),
+ ]),
+ 'overdue': Capa.search_count([
+ ('state', 'not in', ('effective', 'closed')),
+ ('due_date', '<', today),
+ ('due_date', '!=', False),
+ ]),
+ },
+ 'rmas': {
+ 'open': Rma.search_count([
+ ('state', 'not in', ('closed', 'cancelled')),
+ ]),
+ 'overdue': Rma.search_count([
+ '|',
+ '&', ('state', '=', 'received'),
+ ('create_date', '<', d5),
+ '&', ('state', 'in', ('authorised', 'shipped_to_us')),
+ ('create_date', '<', d14),
+ ]),
+ },
+ }
diff --git a/fusion_plating/fusion_plating_quality/data/fp_quality_categorisation_data.xml b/fusion_plating/fusion_plating_quality/data/fp_quality_categorisation_data.xml
new file mode 100644
index 00000000..d7acb2eb
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/data/fp_quality_categorisation_data.xml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+ New
+ new
+ 10
+
+
+ Investigating
+ investigating
+ 20
+
+
+ Containment
+ containment
+ 30
+
+
+ Disposition
+ disposition
+ 40
+
+
+ Awaiting Sign-off
+ awaiting_signoff
+ 50
+
+
+ Closed
+ closed
+ 60
+
+
+
+ Cancelled
+ cancelled
+ 70
+
+
+
+
+
+ Customer Complaint
+ 2
+
+
+ Thickness
+ 3
+
+
+ Appearance
+ 4
+
+
+ Adhesion
+ 5
+
+
+ Corrosion
+ 1
+
+
+ Repeat Offender
+ 1
+ Same customer + part has had > 2 issues in 90 days.
+
+
+ Audit Finding
+ 6
+
+
+ First-Off Inspection
+ 7
+
+
+
+
+ Bath Chemistry Drift
+ process
+ Concentration, pH, or temperature outside spec window.
+
+
+ Bath Contamination
+ process
+
+
+ Temperature Excursion
+ process
+
+
+ Inbound Material Defect
+ supplier
+
+
+ Out-of-Calibration Equipment
+ equipment
+
+
+ Rectifier / Power Supply Issue
+ equipment
+
+
+ Mis-load / Mis-rack
+ human
+
+
+ Training Gap
+ human
+
+
+ Recipe Step Skipped
+ human
+
+
+ Customer Part Defect
+ material
+
+
+
+
+ Quality Assurance
+ 10
+ Default quality team. Assign every new NCR/RMA here unless the issue clearly belongs to a process-specific team.
+
+
+
diff --git a/fusion_plating/fusion_plating_quality/data/fp_rma_sequence.xml b/fusion_plating/fusion_plating_quality/data/fp_rma_sequence.xml
new file mode 100644
index 00000000..9294fc4f
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/data/fp_rma_sequence.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Fusion Plating: RMA
+ fusion.plating.rma
+ RMA/%(year)s/
+ 4
+
+
+
+
diff --git a/fusion_plating/fusion_plating_quality/models/__init__.py b/fusion_plating/fusion_plating_quality/models/__init__.py
index 57ee7633..c7322d38 100644
--- a/fusion_plating/fusion_plating_quality/models/__init__.py
+++ b/fusion_plating/fusion_plating_quality/models/__init__.py
@@ -23,3 +23,23 @@ from . import fp_part_catalog
from . import fp_qc_template
from . import fp_thickness_reading
from . import fp_quality_check
+
+# Sub 12 Phase A — native RMA + the inverse fields it hangs off existing
+# quality and receiving models.
+from . import fp_rma
+from . import fp_rma_links
+
+# Sub 12 Phase B — categorisation primitives + cross-model link fields.
+from . import fp_quality_tag
+from . import fp_quality_reason
+from . import fp_quality_team
+from . import fp_quality_alert_stage
+from . import fp_quality_categorisation_links
+
+# Sub 12 Phase C — trigger-based quality points.
+from . import fp_quality_point
+from . import fp_quality_point_hooks
+
+# Sub 12 Phase D — smart-button counts + cross-creation actions.
+from . import fp_quality_smart_buttons
+from . import fp_quality_cross_creation
diff --git a/fusion_plating/fusion_plating_quality/models/fp_part_catalog.py b/fusion_plating/fusion_plating_quality/models/fp_part_catalog.py
index 80bb9c03..5f33facb 100644
--- a/fusion_plating/fusion_plating_quality/models/fp_part_catalog.py
+++ b/fusion_plating/fusion_plating_quality/models/fp_part_catalog.py
@@ -44,28 +44,29 @@ class FpPartCatalog(models.Model):
# ---- Computes ------------------------------------------------------------
def _compute_has_confirmed_mo(self):
- """True if this part is referenced by at least one non-draft MO.
+ """True if this part is referenced by at least one live fp.job.
- Trace: fp.part.catalog → sale.order.line (x_fc_part_catalog_id)
- → sale.order → mrp.production (via origin name match).
- Cheap: two bounded search_counts. Kept store=False so MO state
- changes don't write-amplify through every part record.
+ Sub 11 — replaced mrp.production lookup with fp.job. Trace:
+ fp.part.catalog → sale.order.line (x_fc_part_catalog_id) →
+ sale.order → fp.job (via origin name match).
"""
SO = self.env['sale.order']
- MO = self.env['mrp.production']
- live_states = ('confirmed', 'progress', 'to_close', 'done')
+ live_states = ('confirmed', 'in_progress', 'on_hold', 'done')
+ for part in self:
+ part.x_fc_has_confirmed_mo = False
+ if 'fp.job' not in self.env:
+ return
+ Job = self.env['fp.job']
for part in self:
if not part.id:
- part.x_fc_has_confirmed_mo = False
continue
so_names = SO.search([
('order_line.x_fc_part_catalog_id', '=', part.id),
('state', 'in', ('sale', 'done')),
]).mapped('name')
if not so_names:
- part.x_fc_has_confirmed_mo = False
continue
- part.x_fc_has_confirmed_mo = bool(MO.search_count([
+ part.x_fc_has_confirmed_mo = bool(Job.search_count([
('origin', 'in', so_names),
('state', 'in', live_states),
]))
diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_alert_stage.py b/fusion_plating/fusion_plating_quality/models/fp_quality_alert_stage.py
new file mode 100644
index 00000000..2342eff4
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_quality_alert_stage.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 12 Phase B — quality alert stage.
+#
+# Shared kanban-stage namespace used by both NCR and RMA. Each model has
+# its own state Selection (state machine guards) AND a stage_id Many2one
+# (kanban-draggable). The two stay in sync — see fp_quality_categorisation_links.
+
+from odoo import fields, models
+
+
+class FpQualityAlertStage(models.Model):
+ _name = 'fp.quality.alert.stage'
+ _description = 'Fusion Plating — Quality Alert Stage'
+ _order = 'sequence, id'
+
+ name = fields.Char(required=True, translate=True)
+ sequence = fields.Integer(default=10, index=True)
+ fold = fields.Boolean(
+ string='Fold by Default',
+ help='If checked the stage is collapsed by default in kanban views.',
+ )
+ code = fields.Char(
+ string='Code',
+ index=True,
+ help='Stable machine identifier used by the state ↔ stage_id sync. '
+ 'Examples: new / investigating / containment / disposition / '
+ 'awaiting_signoff / closed / cancelled.',
+ )
+ description = fields.Text(translate=True)
+ active = fields.Boolean(default=True)
+
+ _sql_constraints = [
+ ('code_uniq', 'unique(code)', 'A stage with that code already exists.'),
+ ]
diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_categorisation_links.py b/fusion_plating/fusion_plating_quality/models/fp_quality_categorisation_links.py
new file mode 100644
index 00000000..e4863278
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_quality_categorisation_links.py
@@ -0,0 +1,153 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 12 Phase B — categorisation field extensions.
+#
+# Adds the cross-cutting tag_ids / reason_id / team_id fields to all five
+# quality records (NCR, CAPA, Hold, Check, RMA). Adds stage_id (kanban
+# stage) to NCR + RMA with state ↔ stage_id sync.
+
+import logging
+
+from odoo import api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+# ----- helper mapping (keeps stage codes consistent across models) -----
+NCR_STATE_TO_STAGE_CODE = {
+ 'draft': 'new',
+ 'open': 'investigating',
+ 'containment': 'containment',
+ 'disposition': 'disposition',
+ 'closed': 'closed',
+}
+NCR_STAGE_CODE_TO_STATE = {v: k for k, v in NCR_STATE_TO_STAGE_CODE.items()}
+
+RMA_STATE_TO_STAGE_CODE = {
+ 'draft': 'new',
+ 'authorised': 'investigating',
+ 'shipped_to_us': 'investigating',
+ 'received': 'containment',
+ 'triaged': 'disposition',
+ 'resolving': 'disposition',
+ 'resolved': 'awaiting_signoff',
+ 'closed': 'closed',
+ 'cancelled': 'cancelled',
+}
+
+
+def _stage_for_code(env, code):
+ if not code:
+ return env['fp.quality.alert.stage']
+ return env['fp.quality.alert.stage'].sudo().search(
+ [('code', '=', code)], limit=1,
+ )
+
+
+# ============================================================ NCR ===
+class FpNcrCategorisation(models.Model):
+ _inherit = 'fusion.plating.ncr'
+
+ tag_ids = fields.Many2many(
+ 'fp.quality.tag', 'fp_ncr_tag_rel', 'ncr_id', 'tag_id',
+ string='Tags',
+ )
+ reason_id = fields.Many2one('fp.quality.reason', string='Root-Cause Reason')
+ team_id = fields.Many2one('fp.quality.team', string='Quality Team',
+ tracking=True)
+ stage_id = fields.Many2one(
+ 'fp.quality.alert.stage', string='Stage',
+ compute='_compute_stage_id', inverse='_inverse_stage_id',
+ store=True, tracking=True, group_expand='_read_group_stage_ids',
+ )
+
+ @api.depends('state')
+ def _compute_stage_id(self):
+ for rec in self:
+ code = NCR_STATE_TO_STAGE_CODE.get(rec.state)
+ rec.stage_id = _stage_for_code(self.env, code) if code else False
+
+ def _inverse_stage_id(self):
+ for rec in self:
+ if not rec.stage_id or not rec.stage_id.code:
+ continue
+ new_state = NCR_STAGE_CODE_TO_STATE.get(rec.stage_id.code)
+ if new_state and new_state != rec.state:
+ # Use direct write to avoid the action_close UserError
+ # guards — kanban drag is an explicit user intent.
+ super(FpNcrCategorisation, rec).write({'state': new_state})
+
+ @api.model
+ def _read_group_stage_ids(self, stages, domain):
+ return self.env['fp.quality.alert.stage'].sudo().search([])
+
+
+# ============================================================ CAPA ===
+class FpCapaCategorisation(models.Model):
+ _inherit = 'fusion.plating.capa'
+
+ tag_ids = fields.Many2many(
+ 'fp.quality.tag', 'fp_capa_tag_rel', 'capa_id', 'tag_id',
+ string='Tags',
+ )
+ reason_id = fields.Many2one('fp.quality.reason', string='Root-Cause Reason')
+ team_id = fields.Many2one('fp.quality.team', string='Quality Team')
+
+
+# ============================================================ HOLD ===
+class FpQualityHoldCategorisation(models.Model):
+ _inherit = 'fusion.plating.quality.hold'
+
+ tag_ids = fields.Many2many(
+ 'fp.quality.tag', 'fp_hold_tag_rel', 'hold_id', 'tag_id',
+ string='Tags',
+ )
+ reason_id = fields.Many2one('fp.quality.reason', string='Root-Cause Reason')
+ team_id = fields.Many2one('fp.quality.team', string='Quality Team')
+
+
+# =========================================================== CHECK ===
+class FpQualityCheckCategorisation(models.Model):
+ _inherit = 'fusion.plating.quality.check'
+
+ tag_ids = fields.Many2many(
+ 'fp.quality.tag', 'fp_check_tag_rel', 'check_id', 'tag_id',
+ string='Tags',
+ )
+ reason_id = fields.Many2one('fp.quality.reason', string='Failure Reason')
+ team_id = fields.Many2one('fp.quality.team', string='Quality Team')
+
+
+# ============================================================ RMA ===
+class FpRmaCategorisation(models.Model):
+ _inherit = 'fusion.plating.rma'
+
+ tag_ids = fields.Many2many(
+ 'fp.quality.tag', 'fp_rma_tag_rel', 'rma_id', 'tag_id',
+ string='Tags',
+ )
+ reason_id = fields.Many2one('fp.quality.reason', string='Root-Cause Reason')
+ team_id = fields.Many2one('fp.quality.team', string='Quality Team',
+ tracking=True)
+ stage_id = fields.Many2one(
+ 'fp.quality.alert.stage', string='Stage',
+ compute='_compute_stage_id', store=True, tracking=True,
+ group_expand='_read_group_stage_ids',
+ help='Computed from state. RMA state machine has guards (use the '
+ 'lifecycle buttons for valid transitions); the stage field is '
+ 'read-mostly here so the unified Quality Dashboard can group '
+ 'NCR + RMA cards in one kanban.',
+ )
+
+ @api.depends('state')
+ def _compute_stage_id(self):
+ for rec in self:
+ code = RMA_STATE_TO_STAGE_CODE.get(rec.state)
+ rec.stage_id = _stage_for_code(self.env, code) if code else False
+
+ @api.model
+ def _read_group_stage_ids(self, stages, domain):
+ return self.env['fp.quality.alert.stage'].sudo().search([])
diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_cross_creation.py b/fusion_plating/fusion_plating_quality/models/fp_quality_cross_creation.py
new file mode 100644
index 00000000..b44e41da
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_quality_cross_creation.py
@@ -0,0 +1,157 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 12 Phase D — cross-creation actions and CAPA closure-loop linkage.
+#
+# - NCR.action_spawn_capa: creates a draft CAPA pre-filled from the NCR.
+# - CAPA.action_mark_not_effective override: auto-creates a follow-up NCR
+# linked back to the original NCR. Closes the loop "we said we fixed it
+# but it happened again."
+
+import logging
+
+from markupsafe import Markup
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class FpNcrCrossCreation(models.Model):
+ _inherit = 'fusion.plating.ncr'
+
+ def action_spawn_capa(self):
+ """Create a draft CAPA pre-filled from this NCR. Visible from form
+ when state ∈ {disposition, closed} and severity ≥ medium (gating
+ lives in the view; this method is a helper)."""
+ self.ensure_one()
+ Capa = self.env['fusion.plating.capa']
+ existing = Capa.search([('ncr_id', '=', self.id)], limit=1)
+ if existing:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.capa',
+ 'view_mode': 'form',
+ 'res_id': existing.id,
+ }
+ capa = Capa.create({
+ 'ncr_id': self.id,
+ 'description': self.description,
+ 'type': 'corrective',
+ 'state': 'draft',
+ 'team_id': self.team_id.id if self.team_id else False,
+ 'reason_id': self.reason_id.id if self.reason_id else False,
+ })
+ self.message_post(
+ body=Markup('Spawned CAPA %s from this NCR.') % capa.name,
+ message_type='comment',
+ subtype_xmlid='mail.mt_note',
+ )
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.capa',
+ 'view_mode': 'form',
+ 'res_id': capa.id,
+ }
+
+
+class FpCapaCrossCreation(models.Model):
+ _inherit = 'fusion.plating.capa'
+
+ follow_up_ncr_id = fields.Many2one(
+ 'fusion.plating.ncr', string='Follow-up NCR',
+ ondelete='set null',
+ help='When effectiveness verification fails, a new NCR is auto-spawned '
+ 'linked back to the original. This field tracks that follow-up.',
+ )
+
+ def action_mark_not_effective(self):
+ """Override to auto-spawn a follow-up NCR linked to the original.
+
+ Closes the closed-loop CAPA discipline: if a fix didn't work, the
+ next NCR gets a clear lineage back to the failed CAPA, so root-
+ cause analysis can dig deeper next time.
+ """
+ super().action_mark_not_effective()
+ Ncr = self.env['fusion.plating.ncr']
+ for rec in self:
+ if rec.follow_up_ncr_id:
+ continue
+ if not rec.ncr_id:
+ _logger.info(
+ 'CAPA %s marked not_effective but has no source NCR; '
+ 'no follow-up NCR created.', rec.name,
+ )
+ continue
+ src = rec.ncr_id
+ ncr = Ncr.create({
+ 'facility_id': src.facility_id.id,
+ 'source': src.source,
+ 'severity': src.severity,
+ 'part_ref': src.part_ref,
+ 'quantity_affected': src.quantity_affected,
+ 'customer_partner_id': src.customer_partner_id.id,
+ 'bath_id': src.bath_id.id if src.bath_id else False,
+ 'description': Markup(
+ '
Follow-up NCR auto-created from CAPA %s '
+ '(verification failed).
'
+ ) % rec.name,
+ 'team_id': rec.team_id.id if rec.team_id else False,
+ 'reason_id': rec.reason_id.id if rec.reason_id else False,
+ 'tag_ids': [(6, 0, src.tag_ids.ids)],
+ })
+ rec.follow_up_ncr_id = ncr.id
+ rec.message_post(
+ body=Markup(
+ 'Effectiveness verification failed. Spawned follow-up '
+ 'NCR %s for re-investigation.'
+ ) % ncr.name,
+ message_type='comment',
+ subtype_xmlid='mail.mt_note',
+ )
+ ncr.message_post(
+ body=Markup(
+ 'Auto-created from CAPA %s after effectiveness '
+ 'verification failed. Original NCR was %s.'
+ ) % (rec.name, src.name),
+ message_type='comment',
+ subtype_xmlid='mail.mt_note',
+ )
+
+ def action_verify_effectiveness(self):
+ """Schedule a follow-up activity on the originating NCR.
+
+ Used from the CAPA form to remind the QA team to come back and
+ confirm the corrective action actually held.
+ """
+ from datetime import timedelta
+ self.ensure_one()
+ if not self.ncr_id:
+ raise UserError(_(
+ 'CAPA %s has no source NCR — verification activity '
+ 'cannot be scheduled.'
+ ) % self.display_name)
+ deadline = fields.Date.context_today(self) + timedelta(days=30)
+ self.ncr_id.activity_schedule(
+ 'mail.mail_activity_data_todo',
+ date_deadline=deadline,
+ summary=_('Verify CAPA %s effectiveness') % self.name,
+ note=_(
+ 'Confirm that the corrective action from CAPA %s is still '
+ 'holding. If issue recurs, mark CAPA as Not Effective '
+ '(auto-spawns a follow-up NCR).'
+ ) % self.name,
+ user_id=(self.owner_id.id if self.owner_id else self.env.user.id),
+ )
+ self.message_post(
+ body=Markup(
+ 'Verification activity scheduled on source NCR %s '
+ '(due %s).'
+ ) % (self.ncr_id.name, deadline),
+ message_type='comment',
+ subtype_xmlid='mail.mt_note',
+ )
+ return True
diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_point.py b/fusion_plating/fusion_plating_quality/models/fp_quality_point.py
new file mode 100644
index 00000000..f4ef7df7
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_quality_point.py
@@ -0,0 +1,199 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 12 Phase C — trigger-based quality points.
+#
+# Replaces the old "customer.x_fc_requires_qc + customer.x_fc_qc_template_id"
+# direct binding. Now an admin defines fp.quality.point rules with filters
+# (partner / part / coating / step kind) and a trigger event; matching
+# records spawn fusion.plating.quality.check rows from the chosen template.
+
+import logging
+
+from odoo import _, api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+TRIGGER_TYPES = [
+ ('manual', 'Manual'),
+ ('so_confirmed', 'Sale Order Confirmed'),
+ ('receiving_done', 'Receiving Closed'),
+ ('job_confirmed', 'Job Confirmed'),
+ ('job_step_done', 'Job Step Finished'),
+ ('job_done', 'Job Completed'),
+]
+
+
+STEP_KINDS = [
+ ('wet', 'Wet Process'),
+ ('bake', 'Bake / Cure'),
+ ('inspect', 'Inspection'),
+ ('mask', 'Masking'),
+ ('post', 'Post-Treatment'),
+ ('other', 'Other'),
+]
+
+
+class FpQualityPoint(models.Model):
+ _name = 'fp.quality.point'
+ _description = 'Fusion Plating — Quality Point'
+ _inherit = ['mail.thread']
+ _order = 'sequence, name'
+
+ name = fields.Char(required=True, translate=True, tracking=True)
+ sequence = fields.Integer(default=10)
+ active = fields.Boolean(default=True, tracking=True)
+ description = fields.Text(translate=True)
+
+ trigger_type = fields.Selection(
+ TRIGGER_TYPES, string='Trigger', required=True,
+ default='job_confirmed', tracking=True,
+ help='When this point fires. "manual" never auto-fires.',
+ )
+
+ # ----- Filters (all optional; empty == match all) -----
+ partner_ids = fields.Many2many(
+ 'res.partner', 'fp_quality_point_partner_rel',
+ 'point_id', 'partner_id', string='Customers',
+ )
+ part_catalog_ids = fields.Many2many(
+ 'fp.part.catalog', 'fp_quality_point_part_rel',
+ 'point_id', 'part_id', string='Parts',
+ )
+ coating_config_ids = fields.Many2many(
+ 'fp.coating.config', 'fp_quality_point_coating_rel',
+ 'point_id', 'coating_id', string='Coatings',
+ )
+ step_kind = fields.Selection(STEP_KINDS, string='Step Kind')
+
+ template_id = fields.Many2one(
+ 'fp.qc.checklist.template', string='Checklist Template',
+ required=True, ondelete='restrict',
+ )
+ assignee_user_id = fields.Many2one(
+ 'res.users', string='Default Inspector',
+ help='If set, the auto-spawned QC check is pre-assigned here.',
+ )
+ team_id = fields.Many2one('fp.quality.team', string='Quality Team')
+ tag_ids = fields.Many2many(
+ 'fp.quality.tag', 'fp_quality_point_tag_rel',
+ 'point_id', 'tag_id', string='Tags',
+ )
+
+ # Stats
+ spawn_count = fields.Integer(
+ string='Checks Spawned', compute='_compute_spawn_count',
+ )
+
+ @api.depends('template_id')
+ def _compute_spawn_count(self):
+ Check = self.env['fusion.plating.quality.check']
+ for rec in self:
+ if not rec.template_id:
+ rec.spawn_count = 0
+ continue
+ rec.spawn_count = Check.search_count([
+ ('template_id', '=', rec.template_id.id),
+ ])
+
+ # ------------------------------------------------------------------
+ # Matching + spawning
+ # ------------------------------------------------------------------
+ def _matches(self, partner=None, part=None, coating=None, step=None):
+ """Return True if this point's filters all pass against the supplied
+ context. Empty filter == match anything.
+ """
+ self.ensure_one()
+ if self.partner_ids and (not partner or partner not in self.partner_ids):
+ return False
+ if self.part_catalog_ids and (
+ not part or part not in self.part_catalog_ids):
+ return False
+ if self.coating_config_ids and (
+ not coating or coating not in self.coating_config_ids):
+ return False
+ if self.step_kind and step and getattr(step, 'kind', None) \
+ and step.kind != self.step_kind:
+ return False
+ return True
+
+ @api.model
+ def _find_matching(self, trigger, partner=None, part=None, coating=None,
+ step=None):
+ """Return active points whose trigger + filters match the context."""
+ candidates = self.search([
+ ('active', '=', True),
+ ('trigger_type', '=', trigger),
+ ])
+ return candidates.filtered(lambda p: p._matches(
+ partner=partner, part=part, coating=coating, step=step,
+ ))
+
+ def _spawn_check_for(self, source, partner=None, job=None, step=None):
+ """Create a fusion.plating.quality.check from this point's template.
+
+ Idempotent per (point, source): if a check already exists with the
+ same template_id and the same job/step binding, no new one is
+ created (returns the existing one).
+ """
+ self.ensure_one()
+ Check = self.env['fusion.plating.quality.check']
+ if not self.template_id:
+ _logger.warning(
+ 'fp.quality.point %s: no template_id set, skipping spawn.',
+ self.name,
+ )
+ return False
+
+ domain = [('template_id', '=', self.template_id.id)]
+ if job:
+ domain.append(('job_id', '=', job.id))
+ if step and 'step_id' in Check._fields:
+ domain.append(('step_id', '=', step.id))
+ existing = Check.search(domain, limit=1)
+ if existing:
+ return existing
+
+ vals = {
+ 'template_id': self.template_id.id,
+ }
+ # Best-effort field bindings — survives schema variations.
+ if 'partner_id' in Check._fields and partner:
+ vals['partner_id'] = partner.id
+ if 'job_id' in Check._fields and job:
+ vals['job_id'] = job.id
+ if 'step_id' in Check._fields and step:
+ vals['step_id'] = step.id
+ if 'state' in Check._fields:
+ vals['state'] = 'pending'
+ if 'inspector_id' in Check._fields and self.assignee_user_id:
+ vals['inspector_id'] = self.assignee_user_id.id
+ if 'team_id' in Check._fields and self.team_id:
+ vals['team_id'] = self.team_id.id
+ if 'tag_ids' in Check._fields and self.tag_ids:
+ vals['tag_ids'] = [(6, 0, self.tag_ids.ids)]
+ try:
+ return Check.create(vals)
+ except Exception as e:
+ _logger.warning(
+ 'fp.quality.point %s: spawn failed for %s — %s',
+ self.name, source.display_name if source else '?', e,
+ )
+ return False
+
+ def action_spawn_manual(self):
+ """Manual fire — present from the form view button. No source ctx."""
+ for rec in self:
+ rec._spawn_check_for(source=rec)
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': _('Quality Point fired'),
+ 'message': _('Spawned %s check(s).') % len(self),
+ 'type': 'success',
+ },
+ }
diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_point_hooks.py b/fusion_plating/fusion_plating_quality/models/fp_quality_point_hooks.py
new file mode 100644
index 00000000..264bb818
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_quality_point_hooks.py
@@ -0,0 +1,135 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 12 Phase C — trigger-hook overrides on receiving / job / step / SO.
+# Each hook walks fp.quality.point with the matching trigger_type and
+# spawns a quality check for every match. Best-effort: failures are
+# logged but never block the underlying state transition.
+
+import logging
+
+from odoo import api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+# ============================================================ RECEIVING ===
+class FpReceivingPointHook(models.Model):
+ _inherit = 'fp.receiving'
+
+ def write(self, vals):
+ """When state flips to closed, fire receiving_done points."""
+ prev_states = {rec.id: rec.state for rec in self}
+ result = super().write(vals)
+ if 'state' not in vals or vals.get('state') != 'closed':
+ return result
+ Point = self.env['fp.quality.point']
+ for rec in self:
+ if prev_states.get(rec.id) == 'closed':
+ continue
+ partner = rec.partner_id
+ points = Point._find_matching(
+ trigger='receiving_done', partner=partner,
+ )
+ for point in points:
+ point._spawn_check_for(
+ source=rec, partner=partner,
+ )
+ return result
+
+
+# ================================================================== SO ===
+class SaleOrderPointHook(models.Model):
+ _inherit = 'sale.order'
+
+ def action_confirm(self):
+ result = super().action_confirm()
+ Point = self.env['fp.quality.point']
+ for so in self:
+ partner = so.partner_id
+ # Walk lines for part / coating context.
+ parts = so.order_line.mapped('x_fc_part_catalog_id') \
+ if 'x_fc_part_catalog_id' in so.order_line._fields else False
+ coatings = so.order_line.mapped('x_fc_coating_config_id') \
+ if 'x_fc_coating_config_id' in so.order_line._fields else False
+ points = Point._find_matching(
+ trigger='so_confirmed', partner=partner,
+ )
+ for point in points:
+ # Filter by part / coating intersection if the point cares.
+ if point.part_catalog_ids and parts and \
+ not (point.part_catalog_ids & parts):
+ continue
+ if point.coating_config_ids and coatings and \
+ not (point.coating_config_ids & coatings):
+ continue
+ point._spawn_check_for(source=so, partner=partner)
+ return result
+
+
+# ================================================================ JOB ===
+class FpJobPointHook(models.Model):
+ _inherit = 'fp.job'
+
+ def action_confirm(self):
+ result = super().action_confirm()
+ Point = self.env['fp.quality.point']
+ for job in self:
+ partner = job.partner_id
+ part = getattr(job, 'part_catalog_id', False) or False
+ coating = getattr(job, 'coating_config_id', False) or False
+ points = Point._find_matching(
+ trigger='job_confirmed', partner=partner,
+ part=part or None, coating=coating or None,
+ )
+ for point in points:
+ point._spawn_check_for(
+ source=job, partner=partner, job=job,
+ )
+ return result
+
+ def button_mark_done(self):
+ result = super().button_mark_done()
+ Point = self.env['fp.quality.point']
+ for job in self:
+ if job.state != 'done':
+ continue
+ partner = job.partner_id
+ part = getattr(job, 'part_catalog_id', False) or False
+ coating = getattr(job, 'coating_config_id', False) or False
+ points = Point._find_matching(
+ trigger='job_done', partner=partner,
+ part=part or None, coating=coating or None,
+ )
+ for point in points:
+ point._spawn_check_for(
+ source=job, partner=partner, job=job,
+ )
+ return result
+
+
+# =========================================================== JOB STEP ===
+class FpJobStepPointHook(models.Model):
+ _inherit = 'fp.job.step'
+
+ def button_finish(self):
+ result = super().button_finish()
+ Point = self.env['fp.quality.point']
+ for step in self:
+ if step.state != 'done':
+ continue
+ job = step.job_id
+ partner = job.partner_id if job else False
+ part = getattr(job, 'part_catalog_id', False) or False
+ coating = getattr(job, 'coating_config_id', False) or False
+ points = Point._find_matching(
+ trigger='job_step_done', partner=partner,
+ part=part or None, coating=coating or None, step=step,
+ )
+ for point in points:
+ point._spawn_check_for(
+ source=step, partner=partner, job=job, step=step,
+ )
+ return result
diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_reason.py b/fusion_plating/fusion_plating_quality/models/fp_quality_reason.py
new file mode 100644
index 00000000..ff34c0f3
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_quality_reason.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 12 Phase B — quality reason (root-cause classification library).
+
+from odoo import fields, models
+
+
+class FpQualityReason(models.Model):
+ _name = 'fp.quality.reason'
+ _description = 'Fusion Plating — Quality Reason'
+ _order = 'category, name'
+
+ name = fields.Char(required=True, translate=True)
+ description = fields.Text(translate=True)
+ category = fields.Selection(
+ [
+ ('process', 'Process'),
+ ('supplier', 'Supplier / Material Inbound'),
+ ('equipment', 'Equipment / Calibration'),
+ ('human', 'Human Error / Training'),
+ ('material', 'Material Defect'),
+ ('other', 'Other'),
+ ],
+ string='Category',
+ default='process',
+ required=True,
+ )
+ active = fields.Boolean(default=True)
+
+ _sql_constraints = [
+ ('name_category_uniq', 'unique(name, category)',
+ 'A reason with that name + category combination already exists.'),
+ ]
diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_smart_buttons.py b/fusion_plating/fusion_plating_quality/models/fp_quality_smart_buttons.py
new file mode 100644
index 00000000..dbc4bf8c
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_quality_smart_buttons.py
@@ -0,0 +1,261 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 12 Phase D — smart-button counts on fp.job, sale.order, res.partner.
+#
+# Each parent record gets badge counts for: Holds, Checks, NCRs, CAPAs,
+# RMAs. Counts always render (zero is acceptable). Action methods open
+# the relevant kanban filtered to that record.
+
+from odoo import _, api, fields, models
+
+
+# ============================================================ FP.JOB ===
+class FpJobQualitySmart(models.Model):
+ _inherit = 'fp.job'
+
+ fp_qc_hold_count = fields.Integer(
+ compute='_compute_fp_quality_counts', string='Holds',
+ )
+ fp_qc_check_count = fields.Integer(
+ compute='_compute_fp_quality_counts', string='Checks',
+ )
+ fp_qc_ncr_count = fields.Integer(
+ compute='_compute_fp_quality_counts', string='NCRs',
+ )
+ fp_qc_capa_count = fields.Integer(
+ compute='_compute_fp_quality_counts', string='CAPAs',
+ )
+ fp_qc_rma_count = fields.Integer(
+ compute='_compute_fp_quality_counts', string='RMAs',
+ )
+
+ def _compute_fp_quality_counts(self):
+ Hold = self.env['fusion.plating.quality.hold']
+ Check = self.env['fusion.plating.quality.check']
+ Ncr = self.env['fusion.plating.ncr']
+ Capa = self.env['fusion.plating.capa']
+ Rma = self.env['fusion.plating.rma']
+ for job in self:
+ job.fp_qc_hold_count = Hold.search_count(
+ [('job_id', '=', job.id)])
+ job.fp_qc_check_count = Check.search_count(
+ [('job_id', '=', job.id)])
+ ncr_ids = []
+ capa_ids = []
+ rma_ids = []
+ if job.sale_order_id:
+ rma_ids = Rma.search(
+ [('sale_order_id', '=', job.sale_order_id.id)]).ids
+ if rma_ids:
+ ncr_ids = Ncr.search([('rma_id', 'in', rma_ids)]).ids
+ if job.partner_id:
+ ncr_ids = list(set(ncr_ids + Ncr.search([
+ ('customer_partner_id', '=', job.partner_id.id),
+ ]).ids))
+ if ncr_ids:
+ capa_ids = Capa.search([('ncr_id', 'in', ncr_ids)]).ids
+ job.fp_qc_ncr_count = len(ncr_ids)
+ job.fp_qc_capa_count = len(capa_ids)
+ job.fp_qc_rma_count = len(rma_ids)
+
+ def action_view_fp_holds(self):
+ self.ensure_one()
+ return {
+ 'name': _('Holds'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.quality.hold',
+ 'view_mode': 'list,form',
+ 'domain': [('job_id', '=', self.id)],
+ 'context': {'default_job_id': self.id},
+ }
+
+ def action_view_fp_checks(self):
+ self.ensure_one()
+ return {
+ 'name': _('Quality Checks'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.quality.check',
+ 'view_mode': 'list,form',
+ 'domain': [('job_id', '=', self.id)],
+ 'context': {'default_job_id': self.id},
+ }
+
+ def action_view_fp_ncrs(self):
+ self.ensure_one()
+ domain = [('customer_partner_id', '=', self.partner_id.id)]
+ return {
+ 'name': _('NCRs'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.ncr',
+ 'view_mode': 'kanban,list,form',
+ 'domain': domain,
+ 'context': {'default_customer_partner_id': self.partner_id.id},
+ }
+
+ def action_view_fp_capas(self):
+ self.ensure_one()
+ Ncr = self.env['fusion.plating.ncr']
+ ncr_ids = Ncr.search([
+ ('customer_partner_id', '=', self.partner_id.id),
+ ]).ids
+ return {
+ 'name': _('CAPAs'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.capa',
+ 'view_mode': 'list,form',
+ 'domain': [('ncr_id', 'in', ncr_ids)],
+ }
+
+ def action_view_fp_rmas(self):
+ self.ensure_one()
+ domain = [('partner_id', '=', self.partner_id.id)]
+ if self.sale_order_id:
+ domain = ['|', ('sale_order_id', '=', self.sale_order_id.id),
+ ('partner_id', '=', self.partner_id.id)]
+ return {
+ 'name': _('RMAs'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.rma',
+ 'view_mode': 'kanban,list,form',
+ 'domain': domain,
+ 'context': {'default_partner_id': self.partner_id.id},
+ }
+
+
+# ============================================================== SO ===
+class SaleOrderQualitySmart(models.Model):
+ _inherit = 'sale.order'
+
+ fp_qc_hold_count = fields.Integer(
+ compute='_compute_fp_qc_counts', string='Holds',
+ )
+ fp_qc_check_count = fields.Integer(
+ compute='_compute_fp_qc_counts', string='Checks',
+ )
+ fp_qc_ncr_count_so = fields.Integer(
+ compute='_compute_fp_qc_counts', string='NCRs',
+ )
+ fp_qc_capa_count = fields.Integer(
+ compute='_compute_fp_qc_counts', string='CAPAs',
+ )
+ fp_qc_rma_count = fields.Integer(
+ compute='_compute_fp_qc_counts', string='RMAs',
+ )
+
+ def _compute_fp_qc_counts(self):
+ Hold = self.env['fusion.plating.quality.hold']
+ Check = self.env['fusion.plating.quality.check']
+ Ncr = self.env['fusion.plating.ncr']
+ Capa = self.env['fusion.plating.capa']
+ Rma = self.env['fusion.plating.rma']
+ Job = self.env['fp.job']
+ for so in self:
+ job_ids = Job.search([('sale_order_id', '=', so.id)]).ids
+ so.fp_qc_hold_count = Hold.search_count(
+ [('job_id', 'in', job_ids)]) if job_ids else 0
+ so.fp_qc_check_count = Check.search_count(
+ [('job_id', 'in', job_ids)]) if job_ids else 0
+ rma_ids = Rma.search([('sale_order_id', '=', so.id)]).ids
+ so.fp_qc_rma_count = len(rma_ids)
+ ncr_ids = []
+ if rma_ids:
+ ncr_ids = Ncr.search([('rma_id', 'in', rma_ids)]).ids
+ if so.partner_id:
+ ncr_ids = list(set(ncr_ids + Ncr.search([
+ ('customer_partner_id', '=', so.partner_id.id),
+ ]).ids))
+ so.fp_qc_ncr_count_so = len(ncr_ids)
+ so.fp_qc_capa_count = Capa.search_count(
+ [('ncr_id', 'in', ncr_ids)]) if ncr_ids else 0
+
+ def action_view_fp_holds(self):
+ self.ensure_one()
+ Job = self.env['fp.job']
+ job_ids = Job.search([('sale_order_id', '=', self.id)]).ids
+ return {
+ 'name': _('Holds'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.quality.hold',
+ 'view_mode': 'list,form',
+ 'domain': [('job_id', 'in', job_ids)],
+ }
+
+ def action_view_fp_checks(self):
+ self.ensure_one()
+ Job = self.env['fp.job']
+ job_ids = Job.search([('sale_order_id', '=', self.id)]).ids
+ return {
+ 'name': _('Quality Checks'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.quality.check',
+ 'view_mode': 'list,form',
+ 'domain': [('job_id', 'in', job_ids)],
+ }
+
+ def action_view_fp_ncrs_so(self):
+ self.ensure_one()
+ return {
+ 'name': _('NCRs'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.ncr',
+ 'view_mode': 'kanban,list,form',
+ 'domain': [('customer_partner_id', '=', self.partner_id.id)],
+ }
+
+ def action_view_fp_capas(self):
+ self.ensure_one()
+ Ncr = self.env['fusion.plating.ncr']
+ ncr_ids = Ncr.search([
+ ('customer_partner_id', '=', self.partner_id.id),
+ ]).ids
+ return {
+ 'name': _('CAPAs'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.capa',
+ 'view_mode': 'list,form',
+ 'domain': [('ncr_id', 'in', ncr_ids)],
+ }
+
+ def action_view_fp_rmas(self):
+ self.ensure_one()
+ return {
+ 'name': _('RMAs'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.rma',
+ 'view_mode': 'kanban,list,form',
+ 'domain': [('sale_order_id', '=', self.id)],
+ 'context': {
+ 'default_partner_id': self.partner_id.id,
+ 'default_sale_order_id': self.id,
+ },
+ }
+
+
+# ====================================================== RES.PARTNER ===
+class ResPartnerQualitySmart(models.Model):
+ _inherit = 'res.partner'
+
+ fp_qc_quality_history_count = fields.Integer(
+ compute='_compute_fp_qc_history_count', string='Quality History',
+ )
+
+ def _compute_fp_qc_history_count(self):
+ Ncr = self.env['fusion.plating.ncr']
+ Rma = self.env['fusion.plating.rma']
+ for partner in self:
+ partner.fp_qc_quality_history_count = (
+ Ncr.search_count([('customer_partner_id', '=', partner.id)])
+ + Rma.search_count([('partner_id', '=', partner.id)])
+ )
+
+ def action_view_fp_quality_history(self):
+ self.ensure_one()
+ return {
+ 'name': _('Quality History — %s') % self.display_name,
+ 'type': 'ir.actions.client',
+ 'tag': 'fp_quality_dashboard',
+ 'context': {'default_partner_id': self.id},
+ }
diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_tag.py b/fusion_plating/fusion_plating_quality/models/fp_quality_tag.py
new file mode 100644
index 00000000..9ceb9767
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_quality_tag.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 12 Phase B — quality tag.
+#
+# Cross-cutting tag library reused by NCR, CAPA, Hold, Check, RMA.
+
+from odoo import fields, models
+
+
+class FpQualityTag(models.Model):
+ _name = 'fp.quality.tag'
+ _description = 'Fusion Plating — Quality Tag'
+ _order = 'name'
+
+ name = fields.Char(required=True, translate=True)
+ color = fields.Integer(string='Colour Index', default=0)
+ description = fields.Char(string='Description', translate=True)
+ active = fields.Boolean(default=True)
+
+ _sql_constraints = [
+ ('name_uniq', 'unique(name)', 'A tag with that name already exists.'),
+ ]
diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_team.py b/fusion_plating/fusion_plating_quality/models/fp_quality_team.py
new file mode 100644
index 00000000..a7533bbe
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_quality_team.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 12 Phase B — quality team.
+#
+# Dedicated team model rather than reusing res.groups, per Sub 12 locked
+# decision: teams need their own kanban grouping + per-team escalation
+# chains.
+
+from odoo import fields, models
+
+
+class FpQualityTeam(models.Model):
+ _name = 'fp.quality.team'
+ _description = 'Fusion Plating — Quality Team'
+ _order = 'sequence, name'
+ _inherit = ['mail.thread']
+
+ name = fields.Char(required=True, tracking=True, translate=True)
+ sequence = fields.Integer(default=10)
+ color = fields.Integer(string='Colour Index', default=0)
+ description = fields.Text(translate=True)
+ lead_user_id = fields.Many2one(
+ 'res.users', string='Team Lead',
+ tracking=True,
+ help='Owns escalations and weekly review of open NCRs/RMAs.',
+ )
+ member_ids = fields.Many2many(
+ 'res.users', 'fp_quality_team_user_rel', 'team_id', 'user_id',
+ string='Members',
+ )
+ escalation_user_id = fields.Many2one(
+ 'res.users', string='Escalation Manager',
+ tracking=True,
+ help='Notified when team owns a record that misses its deadline.',
+ )
+ active = fields.Boolean(default=True)
+
+ _sql_constraints = [
+ ('name_uniq', 'unique(name)', 'A team with that name already exists.'),
+ ]
diff --git a/fusion_plating/fusion_plating_quality/models/fp_rma.py b/fusion_plating/fusion_plating_quality/models/fp_rma.py
new file mode 100644
index 00000000..d032966d
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_rma.py
@@ -0,0 +1,775 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# fp.rma — Return Material Authorisation.
+#
+# Sub 12 Phase A. Internal-only RMA workflow that ties customer returns to
+# the existing NCR / CAPA / Hold stack. Portal submission is deferred to a
+# future sub-project; for now an internal user opens the RMA on behalf of
+# the customer.
+#
+# Lifecycle:
+# draft -> authorised -> shipped_to_us -> received -> triaged ->
+# resolving -> resolved -> closed
+# \
+# -> cancelled (manager only, any state)
+#
+# Auto-spawn rules at the `received` transition (driven by fp.receiving):
+# - if auto_spawn_ncr (default True) -> create fusion.plating.ncr
+# - if auto_spawn_hold (default True) -> create fusion.plating.quality.hold
+# A manager can flip either toggle off before saving the RMA.
+
+import base64
+import io
+import logging
+
+from markupsafe import Markup
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class FpRma(models.Model):
+ _name = 'fusion.plating.rma'
+ _description = 'Fusion Plating — Return Material Authorisation'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+ _order = 'create_date desc, id desc'
+ _rec_name = 'name'
+
+ name = fields.Char(
+ string='Reference',
+ required=True,
+ copy=False,
+ readonly=True,
+ default=lambda self: self._default_name(),
+ tracking=True,
+ )
+ state = fields.Selection(
+ [
+ ('draft', 'Draft'),
+ ('authorised', 'Authorised'),
+ ('shipped_to_us', 'Customer Shipped'),
+ ('received', 'Received at Shop'),
+ ('triaged', 'Triaged'),
+ ('resolving', 'Resolving'),
+ ('resolved', 'Resolved'),
+ ('closed', 'Closed'),
+ ('cancelled', 'Cancelled'),
+ ],
+ string='Status',
+ default='draft',
+ required=True,
+ tracking=True,
+ index=True,
+ )
+
+ # ------------------------------------------------------------------
+ # Customer + originating order
+ # ------------------------------------------------------------------
+ partner_id = fields.Many2one(
+ 'res.partner', string='Customer',
+ required=True, tracking=True,
+ domain=[('customer_rank', '>', 0)],
+ )
+ sale_order_id = fields.Many2one(
+ 'sale.order', string='Original Sale Order',
+ required=True, tracking=True,
+ domain="[('partner_id', '=', partner_id)]",
+ help='The order being returned. Required so cert/part/coating '
+ 'context follows the return through triage and resolution.',
+ )
+ sale_order_line_ids = fields.Many2many(
+ 'sale.order.line', 'fp_rma_sol_rel', 'rma_id', 'sol_id',
+ string='Returned Lines',
+ domain="[('order_id', '=', sale_order_id)]",
+ help='Subset of the original SO lines that the customer is '
+ 'returning. Used to pull part/cert context.',
+ )
+ original_job_ids = fields.Many2many(
+ 'fp.job', string='Original Jobs',
+ compute='_compute_original_job_ids', store=False,
+ help='Jobs derived from the SO. Navigation-only.',
+ )
+ company_id = fields.Many2one(
+ 'res.company', default=lambda self: self.env.company,
+ readonly=True,
+ )
+
+ # ------------------------------------------------------------------
+ # Why and how bad
+ # ------------------------------------------------------------------
+ trigger_source = fields.Selection(
+ [
+ ('customer_complaint', 'Customer Complaint'),
+ ('qc_fail_post_ship', 'Post-Shipment QC Failure'),
+ ('inspection_post_delivery', 'Customer Inspection Post-Delivery'),
+ ('other', 'Other'),
+ ],
+ string='Trigger',
+ default='customer_complaint',
+ required=True,
+ tracking=True,
+ )
+ severity = fields.Selection(
+ [
+ ('low', 'Low'),
+ ('medium', 'Medium'),
+ ('high', 'High'),
+ ('critical', 'Critical'),
+ ],
+ string='Severity',
+ default='medium',
+ required=True,
+ tracking=True,
+ )
+ complaint_description = fields.Html(
+ string='Customer Complaint',
+ help='What the customer reported.',
+ )
+ triage_findings = fields.Html(
+ string='Triage Findings',
+ help='What we found on inspection after receiving the parts.',
+ )
+
+ # ------------------------------------------------------------------
+ # Resolution
+ # ------------------------------------------------------------------
+ resolution_type = fields.Selection(
+ [
+ ('replace', 'Replace'),
+ ('rework', 'Rework'),
+ ('refund', 'Refund'),
+ ('scrap', 'Scrap'),
+ ],
+ string='Resolution',
+ tracking=True,
+ )
+ resolution_notes = fields.Html(string='Resolution Notes')
+ replacement_job_id = fields.Many2one(
+ 'fp.job', string='Replacement Job',
+ ondelete='set null',
+ help='New plating job created for replace/rework resolutions.',
+ )
+ refund_invoice_id = fields.Many2one(
+ 'account.move', string='Refund / Credit Note',
+ ondelete='set null',
+ domain="[('move_type', '=', 'out_refund')]",
+ )
+
+ # ------------------------------------------------------------------
+ # Inbound logistics
+ # ------------------------------------------------------------------
+ inbound_receiving_id = fields.Many2one(
+ 'fp.receiving', string='Inbound Receiving',
+ ondelete='set null',
+ help='Receiving record auto-created when the carrier delivers '
+ 'the returned parts.',
+ )
+ inbound_picking_id = fields.Many2one(
+ 'stock.picking', string='Inbound Picking',
+ ondelete='set null',
+ )
+ qty_returned = fields.Integer(
+ string='Qty Returned', tracking=True,
+ help='Total units the customer is returning per the authorisation.',
+ )
+ qty_received = fields.Integer(
+ string='Qty Received', tracking=True,
+ help='Counted on receipt at our dock.',
+ )
+ customer_tracking = fields.Char(
+ string='Customer Tracking #',
+ help='Outbound tracking from the customer back to us.',
+ )
+ our_tracking = fields.Char(
+ string='Our Tracking #',
+ help='Tracking number for the replacement / return shipment '
+ 'from our shop.',
+ )
+ carrier_id = fields.Many2one('delivery.carrier', string='Carrier')
+
+ # ------------------------------------------------------------------
+ # QR + auto-spawn toggles
+ # ------------------------------------------------------------------
+ qr_code = fields.Binary(
+ string='QR Code', compute='_compute_qr_code', store=False,
+ help='Encodes /fp/rma/ for the customer authorisation PDF.',
+ )
+ auto_spawn_ncr = fields.Boolean(
+ string='Auto-create NCR on Receipt',
+ default=True, tracking=True,
+ help='When the carrier delivers the returned parts and an '
+ 'fp.receiving is created against this RMA, an NCR is '
+ 'spawned automatically. Manager can toggle off — the '
+ 'change is tracked on the chatter for audit.',
+ )
+ auto_spawn_hold = fields.Boolean(
+ string='Auto-place Hold on Receipt',
+ default=True, tracking=True,
+ help='Same trigger as auto_spawn_ncr but creates an '
+ 'fusion.plating.quality.hold for the returned qty.',
+ )
+
+ # ------------------------------------------------------------------
+ # Linked records (cross-domain)
+ # ------------------------------------------------------------------
+ linked_ncr_ids = fields.One2many(
+ 'fusion.plating.ncr', 'rma_id', string='NCRs',
+ )
+ linked_hold_ids = fields.One2many(
+ 'fusion.plating.quality.hold', 'rma_id', string='Holds',
+ )
+ linked_capa_ids = fields.Many2many(
+ 'fusion.plating.capa', string='CAPAs',
+ compute='_compute_linked_capa_ids', store=False,
+ )
+
+ ncr_count = fields.Integer(compute='_compute_link_counts')
+ hold_count = fields.Integer(compute='_compute_link_counts')
+ capa_count = fields.Integer(compute='_compute_link_counts')
+
+ active = fields.Boolean(default=True)
+
+ # ------------------------------------------------------------------
+ # Phase B placeholders (categorisation) — added now so views won't
+ # break when Phase B lands. Kept as M2O/M2M to models added later.
+ # ------------------------------------------------------------------
+ # tag_ids, reason_id, team_id, stage_id are added in Phase B.
+
+ # ------------------------------------------------------------------
+ # Defaults / create / name
+ # ------------------------------------------------------------------
+ @api.model
+ def _default_name(self):
+ seq = self.env['ir.sequence'].next_by_code('fusion.plating.rma')
+ return seq or '/'
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if not vals.get('name') or vals.get('name') == '/':
+ vals['name'] = self._default_name()
+ return super().create(vals_list)
+
+ # ------------------------------------------------------------------
+ # Computes
+ # ------------------------------------------------------------------
+ @api.depends('sale_order_id', 'sale_order_line_ids')
+ def _compute_original_job_ids(self):
+ Job = self.env['fp.job']
+ for rec in self:
+ if not rec.sale_order_id:
+ rec.original_job_ids = False
+ continue
+ rec.original_job_ids = Job.search([
+ ('sale_order_id', '=', rec.sale_order_id.id),
+ ])
+
+ @api.depends('linked_ncr_ids.capa_ids')
+ def _compute_linked_capa_ids(self):
+ for rec in self:
+ rec.linked_capa_ids = rec.linked_ncr_ids.mapped('capa_ids')
+
+ @api.depends(
+ 'linked_ncr_ids', 'linked_hold_ids', 'linked_capa_ids',
+ )
+ def _compute_link_counts(self):
+ for rec in self:
+ rec.ncr_count = len(rec.linked_ncr_ids)
+ rec.hold_count = len(rec.linked_hold_ids)
+ rec.capa_count = len(rec.linked_capa_ids)
+
+ @api.depends('name')
+ def _compute_qr_code(self):
+ try:
+ import qrcode
+ except ImportError:
+ for rec in self:
+ rec.qr_code = False
+ return
+ base = self.env['ir.config_parameter'].sudo().get_param(
+ 'web.base.url', '',
+ )
+ for rec in self:
+ if not rec.id:
+ rec.qr_code = False
+ continue
+ url = f'{base}/fp/rma/{rec.id}'
+ buf = io.BytesIO()
+ img = qrcode.make(url)
+ img.save(buf, format='PNG')
+ rec.qr_code = base64.b64encode(buf.getvalue())
+
+ # ------------------------------------------------------------------
+ # Lifecycle actions
+ # ------------------------------------------------------------------
+ def action_authorise(self):
+ for rec in self:
+ if not rec.sale_order_line_ids:
+ raise UserError(_(
+ 'Select at least one returned line on RMA %s before '
+ 'authorising.'
+ ) % rec.display_name)
+ if rec.qty_returned <= 0:
+ raise UserError(_(
+ 'RMA %s needs a returned quantity > 0 before '
+ 'authorising.'
+ ) % rec.display_name)
+ rec.state = 'authorised'
+ rec._post_state_message('Authorised')
+ rec._fire_rma_notification('rma_authorised')
+
+ def action_mark_shipped_to_us(self):
+ for rec in self:
+ if rec.state != 'authorised':
+ raise UserError(_(
+ 'RMA %s must be Authorised before marking it as '
+ 'shipped by the customer.'
+ ) % rec.display_name)
+ rec.state = 'shipped_to_us'
+ rec._post_state_message('Customer Shipped')
+
+ def action_mark_received(self):
+ """Manual fallback when an inbound fp.receiving was not auto-linked."""
+ for rec in self:
+ if rec.state not in ('authorised', 'shipped_to_us'):
+ raise UserError(_(
+ 'RMA %s must be Authorised or Shipped before being '
+ 'marked Received.'
+ ) % rec.display_name)
+ rec._enter_received_state(receiving=False)
+
+ def _enter_received_state(self, receiving=None):
+ """Common receive-side hook. Called either:
+ - from action_mark_received (manual)
+ - from fp.receiving.create override when rma_id was set
+ Flips state to `received` and (optionally) spawns NCR + Hold per
+ the auto_spawn_* toggles. Idempotent — re-entry on an already-
+ received RMA is a no-op (no double-spawn on ORM retry / split
+ deliveries).
+ """
+ for rec in self:
+ if rec.state == 'received':
+ continue
+ rec.state = 'received'
+ spawned = []
+ if rec.auto_spawn_ncr:
+ ncr = rec._spawn_ncr()
+ if ncr:
+ spawned.append(_('NCR %s') % ncr.name)
+ if rec.auto_spawn_hold:
+ hold = rec._spawn_hold()
+ if hold:
+ spawned.append(_('Hold %s') % hold.name)
+ label = 'Received'
+ if spawned:
+ label += ' — auto-spawned ' + ', '.join(spawned)
+ rec._post_state_message(label)
+ # Customer notification: parts arrived at the shop.
+ rec._fire_rma_notification('rma_received')
+
+ def _spawn_ncr(self):
+ self.ensure_one()
+ Ncr = self.env['fusion.plating.ncr']
+ # Idempotency: if an NCR for this RMA already exists, return it.
+ existing = Ncr.search([('rma_id', '=', self.id)], limit=1)
+ if existing:
+ return existing
+ partner = self.partner_id
+ # Pull a facility — prefer the partner's company facility, fall
+ # back to the first active facility.
+ Facility = self.env['fusion.plating.facility']
+ facility = (
+ Facility.search([('company_id', '=', self.company_id.id)], limit=1)
+ or Facility.search([], limit=1)
+ )
+ if not facility:
+ _logger.warning(
+ 'RMA %s: no fusion.plating.facility found, NCR spawn '
+ 'skipped', self.name,
+ )
+ return False
+ part_ref = ', '.join(
+ self.sale_order_line_ids.mapped('product_id.default_code') or []
+ ) or self.sale_order_line_ids[:1].product_id.display_name or '/'
+ complaint = self.complaint_description or ''
+ body = (
+ Markup('
RMA %s — auto-created from customer return.
') % self.name
+ + Markup(complaint or '
(no description)
')
+ )
+ ncr = Ncr.create({
+ 'facility_id': facility.id,
+ 'source': 'customer',
+ 'severity': self.severity or 'medium',
+ 'part_ref': part_ref[:64],
+ 'quantity_affected': self.qty_received or self.qty_returned or 0,
+ 'description': body,
+ 'customer_partner_id': partner.id,
+ 'rma_id': self.id,
+ })
+ return ncr
+
+ def _spawn_hold(self):
+ self.ensure_one()
+ Hold = self.env['fusion.plating.quality.hold']
+ # Idempotency: one auto-Hold per RMA.
+ existing = Hold.search([('rma_id', '=', self.id)], limit=1)
+ if existing:
+ return existing
+ Facility = self.env['fusion.plating.facility']
+ facility = (
+ Facility.search([('company_id', '=', self.company_id.id)], limit=1)
+ or Facility.search([], limit=1)
+ )
+ part_ref = (
+ self.sale_order_line_ids[:1].product_id.default_code
+ or self.sale_order_line_ids[:1].product_id.display_name
+ or self.name
+ )
+ hold = Hold.create({
+ 'part_ref': part_ref[:64],
+ 'qty_on_hold': self.qty_received or self.qty_returned or 0,
+ 'qty_original': self.qty_returned or 0,
+ 'hold_reason': 'customer_complaint',
+ 'description': (
+ f'Auto-created from RMA {self.name}. '
+ f'Returned parts on hold pending triage.'
+ ),
+ 'facility_id': facility.id if facility else False,
+ 'rma_id': self.id,
+ })
+ return hold
+
+ def action_triage_complete(self):
+ for rec in self:
+ if rec.state != 'received':
+ raise UserError(_(
+ 'RMA %s must be Received before triage can be '
+ 'completed.'
+ ) % rec.display_name)
+ if not rec.resolution_type:
+ raise UserError(_(
+ 'Set a Resolution (replace / rework / refund / scrap) '
+ 'on RMA %s before completing triage.'
+ ) % rec.display_name)
+ rec.state = 'triaged'
+ rec._post_state_message('Triaged')
+
+ def action_start_resolving(self):
+ for rec in self:
+ if rec.state != 'triaged':
+ raise UserError(_(
+ 'RMA %s must be Triaged before resolution work can '
+ 'start.'
+ ) % rec.display_name)
+ rec.state = 'resolving'
+ rec._post_state_message('Resolving')
+
+ def action_resolve(self):
+ """Trigger resolution-specific side-effects then flip to resolved.
+
+ For replace/rework/scrap: spawn the side-effect, flip state.
+ For refund: open the credit-note wizard. State stays at
+ `resolving` until the wizard runs and the accountant links the
+ credit note via action_link_refund (or the AccountMove write
+ hook auto-links by invoice_origin).
+ """
+ for rec in self:
+ if rec.state not in ('triaged', 'resolving'):
+ raise UserError(_(
+ 'RMA %s must be Triaged or Resolving before being '
+ 'marked Resolved.'
+ ) % rec.display_name)
+ # Refund path needs a wizard return — handle separately.
+ refund_recs = self.filtered(lambda r: r.resolution_type == 'refund')
+ if len(refund_recs) > 1:
+ raise UserError(_(
+ 'Resolve refund RMAs one at a time so the credit-note '
+ 'wizard can be filled in.'
+ ))
+ if refund_recs:
+ return refund_recs._resolve_refund()
+ # Non-refund paths: fire side-effect then flip state.
+ for rec in self:
+ handler = {
+ 'replace': rec._resolve_replace,
+ 'rework': rec._resolve_rework,
+ 'scrap': rec._resolve_scrap,
+ }.get(rec.resolution_type)
+ if not handler:
+ raise UserError(_(
+ 'No handler for resolution type "%s" on RMA %s.'
+ ) % (rec.resolution_type, rec.display_name))
+ handler()
+ rec.state = 'resolved'
+ rec._post_state_message(
+ f'Resolved ({rec.resolution_type})',
+ )
+ rec._fire_rma_notification('rma_resolved')
+
+ def _resolve_replace(self):
+ return self._spawn_replacement_job(reason='replace')
+
+ def _resolve_rework(self):
+ return self._spawn_replacement_job(reason='rework')
+
+ def _spawn_replacement_job(self, reason='replace'):
+ self.ensure_one()
+ Job = self.env['fp.job']
+ if self.replacement_job_id:
+ return self.replacement_job_id
+ first = self.original_job_ids[:1]
+ if not first:
+ _logger.info(
+ 'RMA %s: no originating fp.job to clone; creating bare '
+ 'replacement job.', self.name,
+ )
+ new_job = Job.create({
+ 'partner_id': self.partner_id.id,
+ 'sale_order_id': self.sale_order_id.id,
+ 'origin': self.sale_order_id.name or self.name,
+ 'qty': self.qty_returned or 1,
+ })
+ else:
+ new_job = first.copy({
+ 'origin': f'{self.name} (RMA {reason})',
+ 'qty': self.qty_returned or first.qty,
+ 'state': 'draft',
+ })
+ # Drop cloned-from-source steps and regenerate from the
+ # recipe so the rework starts fresh (every step pending,
+ # no inherited timelogs / actuals / completion flags).
+ if hasattr(new_job, 'step_ids') and new_job.step_ids:
+ new_job.step_ids.unlink()
+ if hasattr(new_job, '_generate_steps_from_recipe') \
+ and new_job.recipe_id:
+ new_job._generate_steps_from_recipe()
+ self.replacement_job_id = new_job.id
+ # Auto-confirm so the portal mirror, racking inspection and
+ # 'job_confirmed' notification all fire — same as a normal job.
+ if hasattr(new_job, 'action_confirm') and new_job.state == 'draft':
+ try:
+ new_job.action_confirm()
+ except Exception as e:
+ _logger.warning(
+ 'RMA %s: replacement job %s auto-confirm failed (%s); '
+ 'leaving in draft.', self.name, new_job.name, e,
+ )
+ return new_job
+
+ def _resolve_refund(self):
+ self.ensure_one()
+ if self.refund_invoice_id:
+ return self.refund_invoice_id
+ # Open the standard refund wizard pre-filled to the original SO.
+ # We don't auto-confirm — accountant verifies amounts first.
+ invoices = self.env['account.move'].search([
+ ('invoice_origin', '=', self.sale_order_id.name),
+ ('move_type', '=', 'out_invoice'),
+ ], limit=1)
+ if not invoices:
+ raise UserError(_(
+ 'RMA %s: no posted invoice found for SO %s — cannot '
+ 'create a credit note automatically. Issue refund '
+ 'manually.'
+ ) % (self.display_name, self.sale_order_id.name))
+ return {
+ 'name': _('Credit Note'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move.reversal',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'active_model': 'account.move',
+ 'active_ids': invoices.ids,
+ 'default_reason': f'RMA {self.name}',
+ 'default_journal_id': invoices.journal_id.id,
+ },
+ }
+
+ def _resolve_scrap(self):
+ # NB: spec calls for an fp.job.consumption row with source='rma_scrap'
+ # but fp.job.consumption requires product_id and there's no curated
+ # "scrap" product yet. Phase E will surface scrap via the Monthly
+ # Quality Summary report instead. For now, just narrate.
+ self.ensure_one()
+ qty = self.qty_received or self.qty_returned or 0
+ self.message_post(
+ body=Markup(
+ 'Resolution: scrap. %s units written off via RMA %s.'
+ ) % (qty, self.name),
+ message_type='comment',
+ subtype_xmlid='mail.mt_note',
+ )
+ for ncr in self.linked_ncr_ids:
+ ncr.message_post(
+ body=Markup('Resolution: scrap via RMA %s.') % self.name,
+ message_type='comment',
+ subtype_xmlid='mail.mt_note',
+ )
+
+ def action_close(self):
+ for rec in self:
+ if rec.state != 'resolved':
+ raise UserError(_(
+ 'RMA %s must be Resolved before it can be closed.'
+ ) % rec.display_name)
+ open_ncrs = rec.linked_ncr_ids.filtered(
+ lambda n: n.state != 'closed'
+ )
+ if open_ncrs:
+ raise UserError(_(
+ 'RMA %s has open NCRs (%s). Close the NCRs first.'
+ ) % (
+ rec.display_name,
+ ', '.join(open_ncrs.mapped('name')),
+ ))
+ open_holds = rec.linked_hold_ids.filtered(
+ lambda h: h.state in ('on_hold', 'under_review')
+ )
+ if open_holds:
+ raise UserError(_(
+ 'RMA %s still has active Holds (%s). Release, scrap, '
+ 'or send to rework before closing the RMA.'
+ ) % (
+ rec.display_name,
+ ', '.join(open_holds.mapped('name')),
+ ))
+ rec.state = 'closed'
+ rec._post_state_message('Closed')
+
+ def _fire_rma_notification(self, event):
+ """Best-effort notification dispatch via fp.notification.template.
+
+ Silently skips if fusion_plating_notifications is absent or no
+ template is configured for this trigger event. Failures never
+ block the RMA state machine.
+ """
+ if 'fp.notification.template' not in self.env:
+ return
+ Tpl = self.env['fp.notification.template'].sudo()
+ for rec in self:
+ partner = rec.partner_id
+ if not partner:
+ continue
+ try:
+ Tpl._dispatch(
+ event, rec, partner, sale_order=rec.sale_order_id,
+ )
+ except Exception as e:
+ _logger.warning(
+ 'RMA %s: notification %s failed: %s',
+ rec.name, event, e,
+ )
+
+ def action_cancel(self):
+ is_manager = self.env.user.has_group(
+ 'fusion_plating.group_fusion_plating_manager'
+ )
+ if not is_manager:
+ raise UserError(_(
+ 'Only Plating Managers can cancel an RMA.'
+ ))
+ for rec in self:
+ if rec.state == 'closed':
+ raise UserError(_(
+ 'RMA %s is already closed and cannot be cancelled.'
+ ) % rec.display_name)
+ rec.state = 'cancelled'
+ rec._post_state_message('Cancelled')
+
+ # ------------------------------------------------------------------
+ # Smart-button actions
+ # ------------------------------------------------------------------
+ def action_view_ncrs(self):
+ self.ensure_one()
+ return {
+ 'name': _('NCRs'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.ncr',
+ 'view_mode': 'list,form',
+ 'domain': [('rma_id', '=', self.id)],
+ 'context': {
+ 'default_rma_id': self.id,
+ 'default_customer_partner_id': self.partner_id.id,
+ 'default_source': 'customer',
+ },
+ }
+
+ def action_view_holds(self):
+ self.ensure_one()
+ return {
+ 'name': _('Holds'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.quality.hold',
+ 'view_mode': 'list,form',
+ 'domain': [('rma_id', '=', self.id)],
+ 'context': {'default_rma_id': self.id},
+ }
+
+ def action_view_capas(self):
+ self.ensure_one()
+ return {
+ 'name': _('CAPAs'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.plating.capa',
+ 'view_mode': 'list,form',
+ 'domain': [('id', 'in', self.linked_capa_ids.ids)],
+ }
+
+ def action_view_sale_order(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'sale.order',
+ 'view_mode': 'form',
+ 'res_id': self.sale_order_id.id,
+ }
+
+ def action_view_replacement_job(self):
+ self.ensure_one()
+ if not self.replacement_job_id:
+ return False
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fp.job',
+ 'view_mode': 'form',
+ 'res_id': self.replacement_job_id.id,
+ }
+
+ def action_view_refund(self):
+ self.ensure_one()
+ if not self.refund_invoice_id:
+ return False
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move',
+ 'view_mode': 'form',
+ 'res_id': self.refund_invoice_id.id,
+ }
+
+ def action_view_inbound_receiving(self):
+ self.ensure_one()
+ if not self.inbound_receiving_id:
+ return False
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fp.receiving',
+ 'view_mode': 'form',
+ 'res_id': self.inbound_receiving_id.id,
+ }
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+ def _post_state_message(self, label):
+ for rec in self:
+ rec.message_post(
+ body=Markup('RMA status changed to %s.') % label,
+ message_type='comment',
+ subtype_xmlid='mail.mt_note',
+ )
diff --git a/fusion_plating/fusion_plating_quality/models/fp_rma_links.py b/fusion_plating/fusion_plating_quality/models/fp_rma_links.py
new file mode 100644
index 00000000..7fea255e
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/models/fp_rma_links.py
@@ -0,0 +1,156 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 12 Phase A. Inverse Many2one fields on NCR, Hold and fp.receiving so
+# RMA can hang One2many counterparts off them. Plus a tiny override on
+# fp.receiving.create to flip a linked RMA into the `received` state and
+# trigger the auto-spawn rules.
+
+import logging
+
+from markupsafe import Markup
+
+from odoo import api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class FpNcrRmaLink(models.Model):
+ _inherit = 'fusion.plating.ncr'
+
+ rma_id = fields.Many2one(
+ 'fusion.plating.rma', string='RMA',
+ ondelete='set null', index=True,
+ help='Return that triggered this NCR (auto-set by RMA receive).',
+ )
+
+
+class FpQualityHoldRmaLink(models.Model):
+ _inherit = 'fusion.plating.quality.hold'
+
+ rma_id = fields.Many2one(
+ 'fusion.plating.rma', string='RMA',
+ ondelete='set null', index=True,
+ help='Return that placed these parts on hold.',
+ )
+
+
+class FpReceivingRmaLink(models.Model):
+ _inherit = 'fp.receiving'
+
+ rma_id = fields.Many2one(
+ 'fusion.plating.rma', string='Linked RMA',
+ ondelete='set null', index=True,
+ help='If set, this receiving is the inbound for a customer return. '
+ 'When created, it transitions the RMA to `received` and may '
+ 'auto-spawn an NCR + Hold per the RMA toggles.',
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ records = super().create(vals_list)
+ # Walk new records, mirror back to RMA, walk the receiving's own
+ # state machine (draft → counted → staged → closed) so the linked
+ # SO's x_fc_receiving_status updates, then fire the RMA receive
+ # hook. Without this the receiving sat at draft and the SO read
+ # 'not_received' even though the parts were physically at the shop.
+ for rec in records:
+ if not rec.rma_id:
+ continue
+ rma = rec.rma_id.sudo()
+ # Mirror inbound link both ways.
+ if not rma.inbound_receiving_id:
+ rma.inbound_receiving_id = rec.id
+ if rma.state in ('authorised', 'shipped_to_us'):
+ # Use received_qty as qty_received fallback if not set.
+ if not rma.qty_received and rec.received_qty:
+ rma.qty_received = rec.received_qty
+ # Walk the receiving's lifecycle to closed so SO status
+ # updates. RMA receipts don't have a multi-day racking
+ # delay (parts are already plated and being inspected for
+ # the complaint, not racked for fresh plating), so we
+ # fast-forward all three transitions in one shot.
+ rec.sudo()._fp_rma_fast_close()
+ rma._enter_received_state(receiving=rec)
+ else:
+ _logger.info(
+ 'RMA %s linked to fp.receiving %s but state %s does '
+ 'not trigger auto-receive hook.',
+ rma.name, rec.name, rma.state,
+ )
+ return records
+
+ def _fp_rma_fast_close(self):
+ """Walk an RMA-bound receiving from draft to closed in one call.
+
+ For RMA returns, the receiving's box-count → racking → close walk
+ is purely administrative — the parts are already plated and the
+ operator opens them on triage, not on intake. Fast-forwarding
+ here keeps the SO's x_fc_receiving_status accurate without
+ forcing the receiver to click three buttons in sequence.
+ """
+ for rec in self:
+ if not rec.box_count_in:
+ # Best-effort default: 1 box if unknown. Real qty lives on
+ # the RMA's qty_returned / qty_received.
+ rec.box_count_in = 1
+ if rec.state == 'draft':
+ rec.action_mark_counted()
+ if rec.state == 'counted':
+ rec.action_mark_staged()
+ if rec.state == 'staged':
+ rec.action_close()
+
+
+class AccountMoveRmaLink(models.Model):
+ """Auto-link a credit note back to its RMA when the accountant
+ confirms the reversal wizard. Looks up by invoice_origin matching
+ an RMA's sale_order_id.name, scoped to RMAs in `resolving` state
+ with resolution_type='refund' and no refund_invoice_id yet.
+
+ Also flips the RMA from `resolving` to `resolved` once the credit
+ note is linked — mirrors the auto-progression for replace/rework
+ paths so the RMA doesn't get stuck after a refund.
+ """
+ _inherit = 'account.move'
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ moves = super().create(vals_list)
+ moves._fp_link_to_rma()
+ return moves
+
+ def write(self, vals):
+ result = super().write(vals)
+ if 'state' in vals and vals.get('state') == 'posted':
+ self._fp_link_to_rma()
+ return result
+
+ def _fp_link_to_rma(self):
+ Rma = self.env['fusion.plating.rma'].sudo()
+ for move in self:
+ if move.move_type != 'out_refund':
+ continue
+ if not move.invoice_origin:
+ continue
+ candidate = Rma.search([
+ ('sale_order_id.name', '=', move.invoice_origin),
+ ('resolution_type', '=', 'refund'),
+ ('refund_invoice_id', '=', False),
+ ('state', 'in', ('resolving', 'triaged')),
+ ], limit=1)
+ if not candidate:
+ continue
+ candidate.refund_invoice_id = move.id
+ candidate.state = 'resolved'
+ candidate.message_post(
+ body=Markup(
+ 'Refund credit note %s linked back to this RMA. '
+ 'Marked Resolved.'
+ ) % move.name,
+ message_type='comment',
+ subtype_xmlid='mail.mt_note',
+ )
+ candidate._fire_rma_notification('rma_resolved')
diff --git a/fusion_plating/fusion_plating_quality/scripts/battle_test.py b/fusion_plating/fusion_plating_quality/scripts/battle_test.py
new file mode 100644
index 00000000..aba9b71b
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/battle_test.py
@@ -0,0 +1,210 @@
+# Battle test — real shop failure modes.
+#
+# This is the "what if my operator is sloppy / forgetful / lazy" suite.
+# We document what the system does TODAY, then identify what's missing.
+#
+# Persona shorthand:
+# Carlos = operator
+# Mike = second operator
+# Bob = supervisor / manager (admin in this DB)
+
+import time
+from datetime import timedelta
+from odoo import fields
+
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+
+def make_job(po_suffix):
+ w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': f'PO-BT-{po_suffix}',
+ 'invoice_strategy': 'net_terms',
+ })
+ w._onchange_partner_id()
+ Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+ })
+ r = w.action_create_order()
+ so = env['sale.order'].browse(r['res_id'])
+ so.action_confirm()
+ return env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+
+
+# ====================================================================== 1
+print('='*72)
+print('SCENARIO 1 — Carlos forgot to click Start. Realizes 2 hours later.')
+print('='*72)
+job = make_job('S1-' + fields.Datetime.now().strftime('%H%M%S'))
+step = job.step_ids.sorted('sequence')[0]
+print(f' Setup: {job.name}, step "{step.name}" state={step.state}')
+print(f' Reality: Carlos started masking 2h ago but forgot to click.')
+print(f' Now he clicks Start, then immediately Finish.')
+step.button_start()
+step.button_finish()
+print(f' Result: state={step.state}, duration_actual={step.duration_actual:.4f} min')
+print(f' → Lost 2h of clock time. NO way to back-date date_started without admin SQL.')
+print(f' → date_started field is readonly=True on the form.')
+print(f' GAP: No "Adjust Time" affordance for forgetful operators.')
+
+# ====================================================================== 2
+print()
+print('='*72)
+print('SCENARIO 2 — Carlos finished step physically. Forgot Finish. Went home.')
+print('='*72)
+job = make_job('S2-' + fields.Datetime.now().strftime('%H%M%S'))
+step = job.step_ids.sorted('sequence')[0]
+step.button_start()
+print(f' Carlos starts {step.name} at {step.date_started}')
+print(f' ... 12 hours later Mike notices the step is still in_progress ...')
+# Simulate the time gap by setting started 12h ago
+step.write({'date_started': fields.Datetime.now() - timedelta(hours=12)})
+# Mike taps Finish now
+step.button_finish()
+print(f' Mike clicks Finish: duration_actual = {step.duration_actual:.1f} min')
+print(f' Reality was probably 30 min. System recorded {step.duration_actual:.0f} min.')
+print(f' Cost rollup is wildly wrong: cost_total = ${step.cost_total or 0:.2f}')
+print(f' GAP: No way to retroactively correct the timelog interval.')
+
+# ====================================================================== 3
+print()
+print('='*72)
+print('SCENARIO 3 — Two operators tap Start on the same step.')
+print('='*72)
+job = make_job('S3-' + fields.Datetime.now().strftime('%H%M%S'))
+step = job.step_ids.sorted('sequence')[0]
+step.button_start()
+print(f' Carlos clicks Start → state={step.state}, '
+ f'open logs={len(step.time_log_ids.filtered(lambda l: not l.date_finished))}')
+try:
+ # Mike "logs in as himself" then taps Start on the same step
+ step.button_start()
+ open_logs = step.time_log_ids.filtered(lambda l: not l.date_finished)
+ print(f' Mike clicks Start → state={step.state}, open logs={len(open_logs)}')
+ if len(open_logs) >= 2:
+ print(f' ❌ TWO open timelogs created. duration_actual will double-count.')
+except Exception as e:
+ print(f' ✓ Blocked: {e}')
+
+# ====================================================================== 4
+print()
+print('='*72)
+print('SCENARIO 4 — Operator finishes step #6 before #5 is started.')
+print('='*72)
+job = make_job('S4-' + fields.Datetime.now().strftime('%H%M%S'))
+steps = job.step_ids.sorted('sequence')
+step5 = steps[4]
+step6 = steps[5]
+print(f' Step #5: {step5.name} state={step5.state}')
+print(f' Step #6: {step6.name} state={step6.state}')
+try:
+ step6.button_start()
+ print(f' ❌ Allowed start of step #6 while step #5 still ready')
+ step6.button_finish()
+ print(f' Step #6 done. Step #5 still: {step5.state}')
+except Exception as e:
+ print(f' Blocked: {str(e)[:80]}')
+print(f' GAP: No predecessor enforcement. Steps are independent.')
+print(f' REALITY: This may be intentional (parallel work in different tanks).')
+print(f' But there\'s no "force serial" flag for steps that MUST be in order.')
+
+# ====================================================================== 5
+print()
+print('='*72)
+print('SCENARIO 5 — Job stuck mid-process. Manager wants to take over.')
+print('='*72)
+job = make_job('S5-' + fields.Datetime.now().strftime('%H%M%S'))
+step = job.step_ids.sorted('sequence')[0]
+step.write({'assigned_user_id': env.user.id})
+step.button_start()
+print(f' Step assigned to Carlos, in progress.')
+print(f' Carlos is on vacation. Bob needs to reassign + finish.')
+print(f' Bob views step → assigned_user_id={step.assigned_user_id.name}')
+# Can Bob reassign?
+try:
+ step.write({'assigned_user_id': env.user.id})
+ print(f' ✓ Bob reassigned step (write to assigned_user_id allowed)')
+except Exception as e:
+ print(f' ❌ Reassign blocked: {e}')
+# Bob finishes
+step.button_finish()
+print(f' Bob finishes: state={step.state}, finished_by={step.finished_by_user_id.name}')
+
+# ====================================================================== 6
+print()
+print('='*72)
+print('SCENARIO 6 — Bake window expired (operator at lunch). Override?')
+print('='*72)
+BW = env['fusion.plating.bake.window']
+Bath = env['fusion.plating.bath']
+bath = Bath.search([], limit=1)
+expired = BW.create({
+ 'bath_id': bath.id,
+ 'plate_exit_time': fields.Datetime.now() - timedelta(hours=10),
+ 'window_hours': 4.0,
+ 'part_ref': 'BT-EXPIRED',
+ 'quantity': 5,
+})
+# Cron updates state if past required_by
+BW._cron_update_states()
+expired.invalidate_recordset()
+print(f' Bake window {expired.name}: state={expired.state}, '
+ f'required_by={expired.bake_required_by} (10h ago)')
+# Try to start_bake on a missed_window
+try:
+ expired.action_start_bake()
+ print(f' ⚠️ action_start_bake worked even on missed_window: state={expired.state}')
+ print(f' GAP: No guard against starting bake after missing window. Should require manager override.')
+except Exception as e:
+ print(f' ✓ Blocked: {str(e)[:80]}')
+
+# ====================================================================== 7
+print()
+print('='*72)
+print('SCENARIO 7 — Operator clocks 6 hours on a step expected to take 30 min.')
+print('='*72)
+job = make_job('S7-' + fields.Datetime.now().strftime('%H%M%S'))
+step = job.step_ids.sorted('sequence')[0]
+step.duration_expected = 30 # 30 min
+step.button_start()
+# Simulate 6h elapsed
+step.write({'date_started': fields.Datetime.now() - timedelta(hours=6)})
+step.button_finish()
+ratio = (step.duration_actual / step.duration_expected) if step.duration_expected else 0
+print(f' duration_expected={step.duration_expected} min, duration_actual={step.duration_actual:.0f} min')
+print(f' Ratio: {ratio:.1f}x expected')
+print(f' GAP: System silently accepted 12x overrun. No alert, no chatter post.')
+
+# ====================================================================== 8
+print()
+print('='*72)
+print('SCENARIO 8 — Operator did 4 of 5 parts. 1 contaminated. Qty drift.')
+print('='*72)
+job = make_job('S8-' + fields.Datetime.now().strftime('%H%M%S'))
+print(f' Job qty={job.qty}, qty_done={job.qty_done}, qty_scrapped={job.qty_scrapped}')
+# Operator finishes all steps
+for s in job.step_ids.sorted('sequence'):
+ if s.state in ('pending', 'ready'):
+ s.button_start()
+ if s.state == 'in_progress':
+ s.button_finish()
+# Try to mark done — qty_done is still 0
+try:
+ job.button_mark_done()
+ print(f' Job done: qty_done={job.qty_done}, qty_scrapped={job.qty_scrapped}')
+ print(f' ⚠️ System lets job close with qty_done=0 even though qty=5')
+ print(f' GAP: No reconciliation between qty + qty_done + qty_scrapped at close.')
+except Exception as e:
+ print(f' Blocked: {str(e)[:80]}')
+
+env.cr.commit()
+print()
+print('== Battle test complete ==')
diff --git a/fusion_plating/fusion_plating_quality/scripts/battle_test_v2.py b/fusion_plating/fusion_plating_quality/scripts/battle_test_v2.py
new file mode 100644
index 00000000..0c0eda20
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/battle_test_v2.py
@@ -0,0 +1,150 @@
+# Battle test v2 — re-verify after fixes for: bake-window override,
+# duration overrun chatter, qty reconciliation, recompute-duration.
+
+from datetime import timedelta
+from odoo import fields
+
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+
+def make_job(po_suffix):
+ w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': f'PO-BT2-{po_suffix}',
+ 'invoice_strategy': 'net_terms',
+ })
+ w._onchange_partner_id()
+ Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+ })
+ r = w.action_create_order()
+ so = env['sale.order'].browse(r['res_id'])
+ so.action_confirm()
+ return env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+
+
+# ====================================================================== Fix 1
+print('='*72)
+print('FIX 1 — Bake-window: missed_window blocks, manager override allowed + audited')
+print('='*72)
+BW = env['fusion.plating.bake.window']
+Bath = env['fusion.plating.bath']
+expired = BW.create({
+ 'bath_id': Bath.search([], limit=1).id,
+ 'plate_exit_time': fields.Datetime.now() - timedelta(hours=10),
+ 'window_hours': 4.0,
+ 'part_ref': 'BT2-EXPIRED',
+ 'quantity': 5,
+})
+BW._cron_update_states()
+expired.invalidate_recordset()
+print(f' Window {expired.name} state: {expired.state}')
+
+# Naive operator (no override) — should fail
+try:
+ expired.action_start_bake()
+ print(f' ❌ start_bake worked without override')
+except Exception as e:
+ print(f' ✓ Blocked: {str(e)[:120]}')
+
+# Manager override
+try:
+ expired.action_force_start_missed()
+ print(f' ✓ Manager override succeeded: state={expired.state}')
+ # Check chatter
+ msgs = expired.message_ids.filtered(lambda m: 'OVERRIDE' in (m.body or ''))
+ print(f' ✓ Chatter audit: {len(msgs)} OVERRIDE message logged')
+except Exception as e:
+ print(f' ❌ Override failed: {e}')
+
+# ====================================================================== Fix 2
+print()
+print('='*72)
+print('FIX 2 — Duration overrun: > 1.5x expected posts chatter warning')
+print('='*72)
+job = make_job('F2-' + fields.Datetime.now().strftime('%H%M%S'))
+step = job.step_ids.sorted('sequence')[0]
+step.duration_expected = 30 # 30 min expected
+step.button_start()
+# Force a 6h elapsed via timelog backdate
+step.write({'date_started': fields.Datetime.now() - timedelta(hours=6)})
+# Update the open timelog to start 6h ago too
+open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
+open_log.write({'date_started': fields.Datetime.now() - timedelta(hours=6)})
+step.button_finish()
+print(f' duration_expected={step.duration_expected:.0f} min, '
+ f'duration_actual={step.duration_actual:.0f} min, '
+ f'ratio={step.duration_actual/step.duration_expected:.1f}x')
+overrun_msgs = job.message_ids.filtered(lambda m: 'expected' in (m.body or ''))
+print(f' Chatter overrun warnings on job: {len(overrun_msgs)}')
+if overrun_msgs:
+ print(f' ✓ Posted: {overrun_msgs[0].body[:100]}...')
+
+# ====================================================================== Fix 3
+print()
+print('='*72)
+print('FIX 3 — Qty reconciliation: job mark-done blocks if qty mismatch')
+print('='*72)
+job = make_job('F3-' + fields.Datetime.now().strftime('%H%M%S'))
+for s in job.step_ids.sorted('sequence'):
+ if s.state in ('pending', 'ready'):
+ s.button_start()
+ if s.state == 'in_progress':
+ s.button_finish()
+print(f' Job qty={job.qty}, qty_done={job.qty_done}, qty_scrapped={job.qty_scrapped}')
+
+# Try mark done with qty_done = 0
+try:
+ job.button_mark_done()
+ print(f' ❌ Job closed with qty_done=0!')
+except Exception as e:
+ print(f' ✓ Blocked: {str(e)[:160]}')
+
+# Set qty_done = 4, qty_scrapped = 1, retry
+job.qty_done = 4
+job.qty_scrapped = 1
+print(f' Update: qty_done=4, qty_scrapped=1 (sums to qty=5)')
+try:
+ job.button_mark_done()
+ print(f' ✓ Closed with reconciled qty: state={job.state}')
+except Exception as e:
+ print(f' ❌ Still blocked: {e}')
+
+# ====================================================================== Fix 4
+print()
+print('='*72)
+print('FIX 4 — Supervisor edits timelog → Recompute Duration action picks it up')
+print('='*72)
+job = make_job('F4-' + fields.Datetime.now().strftime('%H%M%S'))
+step = job.step_ids.sorted('sequence')[0]
+step.button_start()
+import time as _t
+_t.sleep(1)
+step.button_finish()
+print(f' Initial: duration_actual={step.duration_actual:.4f} min, '
+ f'logs={len(step.time_log_ids)}')
+
+# Bob backdates the timelog (operator forgot to start; was actually 30 min)
+log = step.time_log_ids[0]
+real_start = log.date_finished - timedelta(minutes=30)
+log.write({'date_started': real_start})
+print(f' Bob backdates log: started 30 min before finish')
+print(f' log.duration_minutes (auto): {log.duration_minutes:.2f} min')
+print(f' step.duration_actual STILL stale: {step.duration_actual:.2f} min')
+
+# Apply recompute
+step.action_recompute_duration_from_timelogs()
+print(f' After Recompute: duration_actual={step.duration_actual:.2f} min')
+recompute_msgs = job.message_ids.filtered(lambda m: 'recomputed' in (m.body or '').lower())
+print(f' Chatter audit: {len(recompute_msgs)} recompute entry logged')
+
+env.cr.commit()
+print()
+print('== Battle test v2 complete ==')
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s10_stale_paused.py b/fusion_plating/fusion_plating_quality/scripts/bt_s10_stale_paused.py
new file mode 100644
index 00000000..8d4b31cb
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s10_stale_paused.py
@@ -0,0 +1,82 @@
+# Scenario 10 — Carlos paused for lunch. Got pulled to another job. Step
+# is now sitting in 'paused' state for 3 days. No alert. Costing is wrong
+# (the open timelog row was already closed at pause, but the step shows
+# zero progress).
+#
+# Real shop pattern: this happens daily — interruptions, shift change,
+# operator pulled to rush job.
+#
+# What we want:
+# 1. A way to find ALL steps stuck in 'paused' beyond a threshold
+# 2. An automatic activity / chatter nudge to the supervisor
+
+from datetime import timedelta
+from odoo import fields
+
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-S10-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+step = job.step_ids.sorted('sequence')[0]
+
+# Carlos starts → pauses → walks away
+step.button_start()
+step.button_pause()
+print(f'[Carlos] Started + paused step "{step.name}" (state={step.state})')
+
+# Simulate 3 days passing — backdate the pause by setting date_started
+step.date_started = fields.Datetime.now() - timedelta(days=3)
+print(f' Pretending it has been paused 3 days')
+
+# Today: how would a manager find this?
+print()
+print('=== Manager finds stale paused steps ===')
+Step = env['fp.job.step']
+all_paused = Step.search([('state', '=', 'paused')])
+print(f' Total paused steps in DB: {len(all_paused)}')
+print(f' Stale-paused (date_started > 1 day ago, state=paused):')
+
+cutoff = fields.Datetime.now() - timedelta(days=1)
+stale = Step.search([
+ ('state', '=', 'paused'),
+ ('date_started', '<', cutoff),
+])
+print(f' found {len(stale)} via search_count')
+for s in stale[:5]:
+ age = (fields.Datetime.now() - s.date_started).days
+ print(f' - {s.job_id.name} step "{s.name}": paused {age}d, '
+ f'assigned={s.assigned_user_id.name or "(no one)"}')
+
+# Is there a cron / activity nudge?
+crons = env['ir.cron'].search([('name', 'ilike', 'pause')])
+print()
+print(f' Crons matching "pause": {len(crons)}')
+
+activities = env['mail.activity'].search([
+ ('res_model', '=', 'fp.job.step'),
+ ('summary', 'ilike', 'paused'),
+])
+print(f' Activities about paused steps: {len(activities)}')
+print()
+if not activities and not crons:
+ print(' ❌ GAP: stale-paused steps live forever silently. No nudge.')
+
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s11_equipment_fail.py b/fusion_plating/fusion_plating_quality/scripts/bt_s11_equipment_fail.py
new file mode 100644
index 00000000..2d450a56
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s11_equipment_fail.py
@@ -0,0 +1,110 @@
+# Scenario 11 — Carlos plating step #4 in tank 3. 8 minutes in, the
+# rectifier dies. Parts come out half-plated. Carlos needs to:
+# 1. Abort the current step (parts not finished — but partial work
+# already happened)
+# 2. Switch to backup tank 5
+# 3. Restart the step there
+#
+# What does the system support today?
+#
+# Fields on fp.job.step that exist:
+# - state machine: pending/ready/in_progress/paused/done/skipped/cancelled
+# - bath_id, tank_id (the tank picked at start)
+# - time_log_ids
+#
+# Operator's options today:
+# A) button_cancel → state=cancelled, but then step shows as cancelled
+# and won't be replayed. Not what we want — we WANT a retry.
+# B) button_finish + open NCR manually + create a new step manually?
+# Way too much paperwork.
+# C) button_pause + change tank_id + button_start → preserves history
+# but doesn't capture WHY (equipment failure)
+#
+# Real shop need:
+# - "Abort + restart" action that:
+# 1. Closes the current timelog (capturing the partial time)
+# 2. Resets state to ready
+# 3. Lets operator pick a new tank/bath
+# 4. Posts chatter on the JOB explaining (equipment failure → tank)
+# 5. Optionally fires an NCR / Maintenance request
+
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-S11-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+
+# Pick the plating step
+plating = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
+if not plating:
+ plating = job.step_ids.sorted('sequence')[3:4]
+
+# Walk earlier steps to done
+for s in job.step_ids.sorted('sequence'):
+ if s == plating:
+ break
+ if s.state in ('pending', 'ready'):
+ s.button_start()
+ if s.state == 'in_progress':
+ s.button_finish()
+
+print(f' [Carlos] About to start: {plating.name}')
+Tank = env['fusion.plating.tank']
+Bath = env['fusion.plating.bath']
+tanks = Tank.search([], limit=2)
+if len(tanks) < 2:
+ print(f' ⚠️ Need 2+ tanks for the test, only have {len(tanks)}')
+else:
+ tank3, tank5 = tanks[0], tanks[1]
+ plating.write({'tank_id': tank3.id})
+ print(f' Initial tank: {plating.tank_id.name}')
+
+plating.button_start()
+print(f' Started → state={plating.state}, started_by={plating.started_by_user_id.name}')
+print(f' Open timelog rows: {len(plating.time_log_ids)}')
+
+print()
+print(f' ⚡ 8 MINUTES LATER: Rectifier dies on tank {plating.tank_id.name}')
+print(f' Carlos needs to abort and restart on backup tank.')
+print()
+
+# Today's options:
+print(f' Today\'s options the operator has:')
+print(f' A) button_cancel → step becomes cancelled (job stuck — no replay)')
+print(f' B) button_pause + write tank_id + button_start (no failure record)')
+print(f' C) ???')
+print()
+
+# Try option B (the workaround)
+print(f' Trying option B (pause → change tank → resume):')
+plating.button_pause()
+print(f' Paused: state={plating.state}, logs={len(plating.time_log_ids)}')
+if len(tanks) >= 2:
+ plating.write({'tank_id': tanks[1].id})
+ print(f' Changed tank to: {plating.tank_id.name}')
+plating.button_start()
+print(f' Resumed: state={plating.state}, logs={len(plating.time_log_ids)}')
+print()
+print(f' ❌ GAP: NO RECORD of WHY the tank change happened.')
+print(f' ❌ GAP: Workaround works but loses the equipment-failure event.')
+print(f' ❌ GAP: No automatic Maintenance Request / NCR creation for the failed equipment.')
+
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s11_verify.py b/fusion_plating/fusion_plating_quality/scripts/bt_s11_verify.py
new file mode 100644
index 00000000..3f5d5795
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s11_verify.py
@@ -0,0 +1,82 @@
+# Verify action_abort_for_retry on a fresh job.
+
+import time
+from odoo import fields
+
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+Tank = env['fusion.plating.tank']
+tanks = Tank.search([], limit=2)
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-S11V-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+
+step = job.step_ids.sorted('sequence')[3] # plating
+step.tank_id = tanks[0].id
+step.button_start()
+print(f' [Carlos] Started {step.name} on tank {step.tank_id.name}')
+time.sleep(2)
+
+# Equipment fails
+print(f' ⚡ Rectifier dies on tank {step.tank_id.name}')
+print()
+
+before_msgs = len(job.message_ids)
+
+step.action_abort_for_retry(
+ reason='Rectifier #3 tripped breaker; sparking on bus bar',
+ new_tank_id=tanks[1].id if len(tanks) > 1 else False,
+)
+
+print(f' After abort:')
+print(f' state={step.state}')
+print(f' tank_id={step.tank_id.name}')
+print(f' duration_actual (partial work)={step.duration_actual:.4f} min')
+print(f' timelogs={len(step.time_log_ids)}, all closed: '
+ f'{all(l.date_finished for l in step.time_log_ids)}')
+print()
+
+after_msgs = len(job.message_ids)
+print(f' Job chatter: {before_msgs} → {after_msgs} (delta {after_msgs - before_msgs})')
+abort_msg = job.message_ids[0]
+print(f' Latest message:')
+print(f' {abort_msg.body[:300]}...')
+
+# Operator restarts on the new tank
+print()
+print(f' [Carlos] Restarts the step on the new tank')
+step.button_start()
+time.sleep(2)
+step.button_finish()
+print(f' Final state={step.state}, total duration_actual={step.duration_actual:.4f} min')
+print(f' Total timelogs={len(step.time_log_ids)} (1 from abort + 1 from retry)')
+
+# Failure case: try to abort a step in 'ready' state
+print()
+print(f' Failure test: try abort on a ready (not in_progress) step')
+ready_step = job.step_ids.filtered(lambda s: s.state == 'ready')[:1]
+if ready_step:
+ try:
+ ready_step.action_abort_for_retry(reason='test')
+ print(f' ❌ Allowed abort on ready step')
+ except Exception as e:
+ print(f' ✓ Blocked: {str(e)[:100]}')
+
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s12_qty_drift.py b/fusion_plating/fusion_plating_quality/scripts/bt_s12_qty_drift.py
new file mode 100644
index 00000000..09a6a6d3
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s12_qty_drift.py
@@ -0,0 +1,76 @@
+# Scenario 12 — Sarah enters SO qty=5. Job spawns with qty=5. Carlos
+# starts step 1. Customer calls — they want 8 instead of 5. Sarah edits
+# the SO line from 5 to 8.
+#
+# Question: does the job pick up the change?
+# Reality: a stale qty on the job means Carlos plates 5 (per his router)
+# but invoice goes for 8 (per the SO).
+#
+# OR Sarah can't edit a confirmed-SO line (Odoo standard locks it),
+# in which case Sarah cancels + reorders, and we have ANOTHER problem.
+
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+# Build SO with qty=5
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-S12-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+sol = so.order_line[:1]
+print(f' Initial: SO line qty={sol.product_uom_qty}, job qty={job.qty}')
+
+# Carlos starts the first step
+step = job.step_ids.sorted('sequence')[0]
+step.button_start()
+print(f' Carlos started step "{step.name}" (state={step.state})')
+print()
+
+# Customer calls — wants 8 not 5
+print(f' 📞 Customer: "Make it 8 instead of 5"')
+print(f' Sarah edits SO line qty from 5 to 8...')
+try:
+ sol.product_uom_qty = 8
+ print(f' Edit succeeded: SO line qty={sol.product_uom_qty}')
+except Exception as e:
+ print(f' ❌ Edit blocked: {e}')
+
+# Did the job qty propagate?
+job.invalidate_recordset()
+print(f' Job qty AFTER SO edit: {job.qty}')
+print()
+
+if job.qty != sol.product_uom_qty:
+ print(f' ❌ GAP: Job qty stale ({job.qty}) vs SO line qty ({sol.product_uom_qty}).')
+ print(f' Carlos will plate {job.qty} parts. Invoice ships for {sol.product_uom_qty}.')
+ print(f' No automatic resync, no warning.')
+else:
+ print(f' ✓ Job qty auto-updated.')
+
+# Try the reverse — what if Sarah tries to LOWER the qty?
+print()
+print(f' Customer changes mind: now wants 3 instead of 8')
+try:
+ sol.product_uom_qty = 3
+ job.invalidate_recordset()
+ print(f' SO line qty={sol.product_uom_qty}, job qty={job.qty}')
+except Exception as e:
+ print(f' ❌ Blocked: {e}')
+
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s12_verify.py b/fusion_plating/fusion_plating_quality/scripts/bt_s12_verify.py
new file mode 100644
index 00000000..58160970
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s12_verify.py
@@ -0,0 +1,59 @@
+# Verify mid-job qty change posts chatter + sync action works.
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-S12V-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+sol = so.order_line[:1]
+job.step_ids.sorted('sequence')[0].button_start()
+print(f' Initial: SO={sol.product_uom_qty}, job.qty={job.qty}')
+
+before_msgs = len(job.message_ids)
+print()
+print(f' Sarah edits SO line qty 5 → 8 mid-job')
+sol.product_uom_qty = 8
+job.invalidate_recordset()
+after_msgs = len(job.message_ids)
+print(f' Job chatter: {before_msgs} → {after_msgs} (delta {after_msgs - before_msgs})')
+warn = job.message_ids.filtered(lambda m: 'qty changed mid-job' in (m.body or ''))
+print(f' Warning messages on job: {len(warn)}')
+if warn:
+ print(f' ✓ Chatter warning posted')
+print(f' Job.qty still: {job.qty} (unchanged — supervisor must explicitly sync)')
+
+print()
+print(f' Bob clicks "Sync qty from SO" on the job')
+job.action_sync_qty_from_so()
+print(f' Job.qty after sync: {job.qty} (expect 8)')
+sync_msgs = job.message_ids.filtered(lambda m: 'synced from SO' in (m.body or ''))
+print(f' Sync chatter messages: {len(sync_msgs)}')
+print()
+
+# Now what about LOWER qty
+print(f' Customer reduces to 3...')
+sol.product_uom_qty = 3
+job.invalidate_recordset()
+warn2 = len(job.message_ids.filtered(lambda m: 'qty changed mid-job' in (m.body or '')))
+print(f' Warnings now: {warn2}')
+job.action_sync_qty_from_so()
+print(f' After sync: job.qty={job.qty}')
+
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s13_verify.py b/fusion_plating/fusion_plating_quality/scripts/bt_s13_verify.py
new file mode 100644
index 00000000..ee0bd371
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s13_verify.py
@@ -0,0 +1,93 @@
+# Verify shopfloor scan + tablet_overview now expose step instructions.
+from odoo.tests.common import HOST
+from odoo import fields
+
+# Build a job with instructions on a step
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-S13-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+
+# Add detailed instructions to the plating step
+plating = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
+if not plating:
+ plating = job.step_ids.sorted('sequence')[3:4]
+plating.instructions = (
+ '
Plating bath checklist:
'
+ '
Verify nickel concentration is 4.0–5.5 g/L (Fischerscope reading)
'
+ '
pH must be 4.4–4.8 — adjust with ammonium hydroxide if needed
'
+ '
Bath temp 88–93°C, agitation ON
'
+ '
Dwell 45 minutes for 25 µm coating; longer for thicker
'
+ '
Rinse for 60s before next station
'
+)
+plating.thickness_target = 25.0
+plating.thickness_uom = 'um'
+plating.dwell_time_minutes = 45.0
+plating.bake_setpoint_temp = 0 # not a bake step
+
+print(f' Step "{plating.name}":')
+print(f' instructions length: {len(plating.instructions or "")} chars')
+print(f' thickness_target: {plating.thickness_target} {plating.thickness_uom}')
+print()
+
+# Now simulate scan endpoint via the controller
+from odoo.addons.fusion_plating_shopfloor.controllers import shopfloor_controller as sc
+print(f' Tablet operator scans the step QR code (simulating /fp/shopfloor/scan)')
+# Build a fake request env
+from odoo.http import request as _req
+# Call the underlying logic directly
+# Find code prefix used
+print(f' Step code: {plating.id}, name: {plating.name}')
+
+# Direct call to the scan response builder (no http) — easier approach:
+# The scan endpoint builds the dict inline. Verify by replicating its code path.
+step = plating
+payload = {
+ 'ok': True, 'model': 'fp.job.step',
+ 'id': step.id, 'name': step.name, 'state': step.state,
+ 'duration_actual': step.duration_actual,
+ 'duration_expected': step.duration_expected,
+ 'job_name': step.job_id.name or '',
+ 'product_name': step.job_id.product_id.display_name or '',
+ 'instructions': step.instructions or '',
+ 'thickness_target': step.thickness_target or 0,
+ 'thickness_uom': step.thickness_uom or '',
+ 'dwell_time_minutes': step.dwell_time_minutes or 0,
+ 'bake_setpoint_temp': step.bake_setpoint_temp or 0,
+}
+print(f' Scan payload now includes:')
+print(f' instructions: {len(payload["instructions"])} chars')
+print(f' thickness_target: {payload["thickness_target"]} {payload["thickness_uom"]}')
+print(f' dwell_time_minutes: {payload["dwell_time_minutes"]}')
+print(f' duration_expected: {payload["duration_expected"]}')
+
+# Tablet overview check via JSONRPC
+# We'll just check the controller method directly
+print()
+print(f' Tablet overview payload (simulate /fp/shopfloor/tablet_overview):')
+# Just verify the field is in _step_payload by introspection
+import inspect
+src = inspect.getsource(sc.FpShopfloorController)
+print(f' _step_payload includes "instructions"? {"instructions" in src and "step.instructions" in src}')
+print(f' _step_payload includes "thickness_target"? {"step.thickness_target" in src}')
+print(f' _step_payload includes "dwell_time_minutes"? {"step.dwell_time_minutes" in src}')
+
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s14_predecessor.py b/fusion_plating/fusion_plating_quality/scripts/bt_s14_predecessor.py
new file mode 100644
index 00000000..6f4126bf
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s14_predecessor.py
@@ -0,0 +1,63 @@
+# Scenario 14 — Recipe author wants step "Plating" to be hard-blocked
+# until step "Acid Etch" finishes. (Real reason: passivation layer
+# starts forming on bare metal in seconds; if Plating starts before
+# acid etch is done, adhesion fails.)
+#
+# Today the system allows ANY step to start any time. Out-of-order is
+# allowed for parallel work — but for SERIAL-MUST steps, there's no
+# enforcement. We need an opt-in flag the recipe author can set per
+# step: requires_predecessor_done.
+
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-S14-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+
+steps = job.step_ids.sorted('sequence')
+print(f' Job {job.name} steps:')
+for i, s in enumerate(steps[:6]):
+ print(f' #{i+1} ({s.sequence}): {s.name} state={s.state}')
+
+# Today: skip step #1, #2, #3, jump to step #4 (plating).
+print()
+print(' [Operator] Tries to skip earlier steps and start plating directly:')
+plating = steps.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
+if plating:
+ try:
+ plating.button_start()
+ print(f' state={plating.state}')
+ print(f' ❌ NO PREDECESSOR CHECK. Plating started while Masking/Racking still ready.')
+ except Exception as e:
+ print(f' Blocked: {str(e)[:80]}')
+
+# Check if requires_predecessor_done field exists
+rec_step = plating.recipe_node_id if plating else False
+fields_on_node = list(env['fusion.plating.process.node']._fields.keys())
+print()
+print(f' Looking for requires_predecessor_done field on fp.process.node:')
+print(f' Found: {"requires_predecessor_done" in fields_on_node}')
+print(f' Looking for requires_predecessor_done field on fp.job.step:')
+print(f' Found: {"requires_predecessor_done" in env["fp.job.step"]._fields}')
+print()
+print(f' ❌ GAP: No way for the recipe author to mark a step as serial-required.')
+
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s14_verify.py b/fusion_plating/fusion_plating_quality/scripts/bt_s14_verify.py
new file mode 100644
index 00000000..4b60d587
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s14_verify.py
@@ -0,0 +1,91 @@
+# Verify predecessor enforcement
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-S14V-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+
+# Find plating step + flag its recipe node as serial-required
+plating = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
+if plating and plating.recipe_node_id:
+ plating.recipe_node_id.requires_predecessor_done = True
+ print(f' Recipe author flagged "{plating.name}" requires_predecessor_done')
+ plating.invalidate_recordset()
+ print(f' Step picks it up via related: {plating.requires_predecessor_done}')
+
+# Try to start plating with earlier steps still ready
+print()
+print(f' [Operator] Tries to start plating WITHOUT finishing earlier steps:')
+try:
+ plating.button_start()
+ print(f' ❌ Allowed early start! state={plating.state}')
+except Exception as e:
+ print(f' ✓ Blocked: {str(e)[:200]}')
+
+# Walk earlier steps to done
+print()
+print(f' [Operator] Walks earlier steps to done:')
+for s in job.step_ids.sorted('sequence'):
+ if s == plating:
+ break
+ if s.state in ('pending', 'ready'):
+ s.button_start()
+ if s.state == 'in_progress':
+ s.button_finish()
+print(f' Earlier steps now: {set(job.step_ids.filtered(lambda x: x.sequence < plating.sequence).mapped("state"))}')
+
+# Try plating again
+print()
+print(f' [Operator] Tries plating again after earlier steps done:')
+try:
+ plating.button_start()
+ print(f' ✓ Allowed: state={plating.state}')
+except Exception as e:
+ print(f' ❌ Still blocked: {e}')
+
+# Test manager bypass via context
+print()
+print(f' Test manager bypass on a fresh job:')
+w2 = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-S14B-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w2._onchange_partner_id()
+Line.create({
+ 'wizard_id': w2.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+})
+r2 = w2.action_create_order()
+so2 = env['sale.order'].browse(r2['res_id'])
+so2.action_confirm()
+job2 = env['fp.job'].search([('sale_order_id', '=', so2.id)], limit=1)
+plating2 = job2.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
+# (the recipe_node already has requires_predecessor_done=True from earlier write)
+print(f' Plating step requires_predecessor_done: {plating2.requires_predecessor_done}')
+try:
+ plating2.with_context(fp_skip_predecessor_check=True).button_start()
+ print(f' ✓ Manager bypass: state={plating2.state}')
+except Exception as e:
+ print(f' ❌ Bypass failed: {e}')
+
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s15_bake_close.py b/fusion_plating/fusion_plating_quality/scripts/bt_s15_bake_close.py
new file mode 100644
index 00000000..cef42297
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s15_bake_close.py
@@ -0,0 +1,72 @@
+# Scenario 15 — Job has a coating that requires hydrogen embrittlement
+# bake. Operator finishes plating step → bake.window auto-spawns
+# (state=awaiting_bake). Operator finishes the rest of the steps and
+# clicks Mark Done on the job — but never started the bake.
+#
+# Today: job closes done. Customer ships parts. Field failure 3 weeks
+# later. AS9100 auditor: "Show me the bake record for lot X." There's
+# no bake record. NCR + customer credit hit.
+#
+# Want: button_mark_done blocks if any linked bake.window is in state
+# awaiting_bake or bake_in_progress. Manager bypass for one-off
+# deviations.
+
+import time
+from odoo import fields
+
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+Coating = env['fp.coating.config']
+target = P.browse(2529)
+coating = Coating.search([('requires_bake_relief', '=', True)], limit=1)
+part = Part.create({
+ 'partner_id': target.id,
+ 'part_number': 'S15-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'revision': 'A',
+ 'substrate_material': 'steel',
+ 'x_fc_default_coating_config_id': coating.id,
+})
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-S15-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': coating.id,
+ 'quantity': 5, 'unit_price': 30.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+
+# Walk all steps to done — the plating step will spawn a bake.window
+for s in job.step_ids.sorted('sequence'):
+ if s.state in ('pending', 'ready'):
+ s.button_start()
+ if s.state == 'in_progress':
+ s.button_finish()
+
+job.qty_done = 5 # satisfy reconciliation gate
+print(f' Job {job.name}: all steps done')
+
+BW = env['fusion.plating.bake.window']
+bws = BW.search([('part_ref', '=', job.name)])
+print(f' Bake windows linked to job: {len(bws)}')
+for bw in bws:
+ print(f' {bw.name}: state={bw.state}, required_by={bw.bake_required_by}')
+
+print()
+print(f' [Operator — careless] Clicks Mark Done WITHOUT starting bake')
+try:
+ job.button_mark_done()
+ print(f' ❌ Job closed with bake awaiting! state={job.state}')
+ print(f' COMPLIANCE BOMB — no bake record but parts ship.')
+except Exception as e:
+ print(f' ✓ Blocked: {str(e)[:200]}')
+
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s16_phantom_inprogress.py b/fusion_plating/fusion_plating_quality/scripts/bt_s16_phantom_inprogress.py
new file mode 100644
index 00000000..8f894a6b
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s16_phantom_inprogress.py
@@ -0,0 +1,67 @@
+# Scenario 16 — Carlos clicked Start on a step. Got pulled to a rush
+# job. Forgot to come back. The original step is still in_progress 8
+# hours later. The open timelog row is accumulating phantom time. Cost
+# rollup is wrong. Manager has no nudge.
+#
+# Mirror of S10 (stale-paused) but for in_progress.
+
+from datetime import timedelta
+from odoo import fields
+
+# Find existing stale in_progress steps in DB to test against
+Step = env['fp.job.step']
+cutoff = fields.Datetime.now() - timedelta(hours=8)
+stale = Step.search([
+ ('state', '=', 'in_progress'),
+ ('date_started', '<', cutoff),
+ ('date_started', '!=', False),
+])
+print(f' Total in_progress steps started > 8h ago: {len(stale)}')
+for s in stale[:5]:
+ age = (fields.Datetime.now() - s.date_started).total_seconds() / 3600.0
+ print(f' {s.job_id.name} step "{s.name}": in_progress {age:.1f}h, '
+ f'started_by={s.started_by_user_id.name or "(none)"}')
+
+if not stale:
+ print(f' Building one synthetic case...')
+ W = env['fp.direct.order.wizard']
+ Line = env['fp.direct.order.line']
+ P = env['res.partner']
+ Part = env['fp.part.catalog']
+ target = P.browse(2529)
+ part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+ w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-S16-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+ })
+ w._onchange_partner_id()
+ Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+ })
+ r = w.action_create_order()
+ so = env['sale.order'].browse(r['res_id'])
+ so.action_confirm()
+ job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+ s = job.step_ids.sorted('sequence')[0]
+ s.button_start()
+ s.write({'date_started': fields.Datetime.now() - timedelta(hours=10)})
+ open_log = s.time_log_ids.filtered(lambda l: not l.date_finished)
+ if open_log:
+ open_log.write({'date_started': fields.Datetime.now() - timedelta(hours=10)})
+ print(f' Created stale: {s.job_id.name} step "{s.name}"')
+
+# Look for cron / activity
+crons = env['ir.cron'].search([
+ ('name', 'ilike', 'in_progress'), ('name', 'ilike', 'stale'),
+])
+print()
+print(f' Crons matching stale-in_progress: {len(crons)}')
+acts = env['mail.activity'].search([('summary', 'like', 'Stale in-progress%')])
+print(f' Activities about stale in_progress: {len(acts)}')
+
+if not crons and not acts:
+ print(f' ❌ GAP: no nudge for phantom in_progress steps either.')
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s17_scrap_ncr.py b/fusion_plating/fusion_plating_quality/scripts/bt_s17_scrap_ncr.py
new file mode 100644
index 00000000..5b8dde9a
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s17_scrap_ncr.py
@@ -0,0 +1,60 @@
+# Scenario 17 — Mid-job Carlos drops 2 parts (out of 5). Sets
+# qty_scrapped from 0 → 2. With my qty-reconciliation gate, he MUST
+# update this for the job to close — but there's no NCR / hold record
+# explaining WHY 2 parts went away.
+#
+# Real shop: every scrap event is investigated. Material cost lost,
+# customer not told (because the qty_done went down, not the order
+# qty), and the AS9100 audit asks "where's the disposition record for
+# scrapped parts?"
+#
+# Want: when qty_scrapped increases on fp.job, auto-create a
+# fusion.plating.quality.hold + post chatter for the operator to
+# document the cause.
+
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-S17-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+
+# Carlos starts working
+job.step_ids.sorted('sequence')[0].button_start()
+
+# Count holds linked before
+Hold = env['fusion.plating.quality.hold']
+holds_before = Hold.search_count([('part_ref', '=', part.part_number)])
+print(f' Holds for {part.part_number} before scrap: {holds_before}')
+
+# Drop 2 parts
+print(f' [Carlos] Drops 2 parts. Updates qty_scrapped 0 → 2')
+job.qty_scrapped = 2
+
+holds_after = Hold.search_count([('part_ref', '=', part.part_number)])
+print(f' Holds for {part.part_number} after scrap: {holds_after}')
+
+if holds_after > holds_before:
+ print(f' ✓ Auto-Hold spawned')
+else:
+ print(f' ❌ GAP: qty_scrapped went up but NO hold/NCR auto-created.')
+ print(f' No record of what happened. AS9100 auditor unhappy.')
+
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s18_cert_flow.py b/fusion_plating/fusion_plating_quality/scripts/bt_s18_cert_flow.py
new file mode 100644
index 00000000..d273987f
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s18_cert_flow.py
@@ -0,0 +1,121 @@
+# Scenario 18 — Certificate flow simulation.
+# Persona: Sarah (CSR) → Carlos (operator) → Tom (shipper)
+# Goal: complete cert issuance from SO entry to customer email.
+# Track every gap.
+
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+Coating = env['fp.coating.config']
+target = P.browse(2529)
+coating = Coating.search([('spec_reference', '!=', False)], limit=1) \
+ or Coating.search([], limit=1)
+part = Part.search([('x_fc_default_coating_config_id', '=', coating.id)], limit=1) \
+ or Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+print(f' Coating: {coating.name}, spec_reference={coating.spec_reference}')
+
+# Build the SO + walk the full flow
+import base64
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-CERT-001',
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': coating.id,
+ 'quantity': 5, 'unit_price': 25.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+print(f' Job {job.name} confirmed, qty=5')
+
+# Walk all steps to done
+for s in job.step_ids.sorted('sequence'):
+ if s.state in ('pending', 'ready'):
+ s.button_start()
+ if s.state == 'in_progress':
+ s.button_finish()
+
+# Set qty_done so reconciliation gate passes
+job.qty_done = 5
+
+# Bake-window if any
+BW = env['fusion.plating.bake.window']
+bws = BW.search([('part_ref', '=', job.name), ('state', '!=', 'baked')])
+for bw in bws:
+ bw.action_start_bake()
+ bw.action_end_bake()
+
+# Mark done
+print(f' [Carlos] Mark Done')
+job.button_mark_done()
+print(f' job.state = {job.state}')
+
+# CHECK: was a certificate auto-spawned?
+Cert = env['fp.certificate']
+certs = Cert.search([('sale_order_id', '=', so.id)])
+if not certs:
+ # try x_fc_job_id link
+ certs = Cert.search([]) # last resort
+print()
+print(f' Certificates for this SO: {len(certs)}')
+for c in certs[:3]:
+ print(f' {c.name}: state={c.state}, type={c.certificate_type}')
+ print(f' partner_id: {c.partner_id.name}')
+ print(f' spec_reference: {c.spec_reference!r}')
+ print(f' part_number: {c.part_number!r}')
+ print(f' quantity_shipped: {c.quantity_shipped}')
+ print(f' po_number: {c.po_number!r}')
+ print(f' attachment_id: {c.attachment_id.name if c.attachment_id else None}')
+
+if not certs:
+ print(f' ❌ GAP: no cert auto-created!')
+ raise SystemExit
+
+cert = certs[0]
+
+# DISCOVERABILITY — would Tom find the cert from the job form?
+print()
+print(f' [Tom] Looking at the job form, smart-button row:')
+print(f' job.certificate_count = {getattr(job, "certificate_count", "no field")}')
+print(f' Smart button visible? (depends on certificate_count > 0)')
+
+# Try to issue
+print()
+print(f' [Tom] Clicks Issue on the certificate:')
+try:
+ cert.action_issue()
+ print(f' ✓ Issued: state={cert.state}')
+except Exception as e:
+ print(f' ❌ Blocked: {str(e)[:200]}')
+
+# If blocked due to spec_reference, fix and retry
+if cert.state == 'draft' and not cert.spec_reference:
+ print()
+ print(f' [Tom] Manually fills spec_reference (workflow gap — should auto-fill from coating)')
+ cert.spec_reference = coating.spec_reference or 'AMS 2404'
+ try:
+ cert.action_issue()
+ print(f' ✓ Issued after manual fix: state={cert.state}')
+ except Exception as e:
+ print(f' ❌ Still blocked: {str(e)[:200]}')
+
+# Try Send to Customer
+print()
+print(f' [Tom] Clicks Send to Customer:')
+print(f' cert.attachment_id = {cert.attachment_id.name if cert.attachment_id else "(none — PDF not generated!)"}')
+try:
+ act = cert.action_send_to_customer()
+ print(f' Composer opens. Default attachments: '
+ f'{act.get("context", {}).get("default_attachment_ids", "(none)")}')
+except Exception as e:
+ print(f' ❌ {e}')
+
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s19_fischer_merge.py b/fusion_plating/fusion_plating_quality/scripts/bt_s19_fischer_merge.py
new file mode 100644
index 00000000..b9cfea5b
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s19_fischer_merge.py
@@ -0,0 +1,155 @@
+# Scenario 19 — Fischerscope thickness report PDF appended to CoC.
+#
+# Goal: when QC has a thickness_report_pdf_id uploaded by the operator
+# on the tablet, action_issue should produce a multi-page CoC with the
+# Fischerscope PDF as page 2+.
+
+import base64
+from odoo import fields
+
+# Build a fresh job that requires QC + Fischerscope PDF
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+Tpl = env['fp.qc.checklist.template']
+TplLine = env['fp.qc.checklist.template.line']
+QC = env['fusion.plating.quality.check']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+Coating = env['fp.coating.config']
+
+target = P.browse(2529)
+target.x_fc_requires_qc = True
+coating = Coating.search([('spec_reference', '!=', False)], limit=1)
+part = Part.search([('x_fc_default_coating_config_id', '=', coating.id)], limit=1) \
+ or Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+# Make sure default QC template requires Fischerscope PDF
+default_tpl = Tpl.search([('partner_id', '=', False), ('active', '=', True)], limit=1)
+if default_tpl:
+ default_tpl.require_thickness_report_pdf = True
+print(f' Using QC template: {default_tpl.name} (requires PDF)')
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-FISCHER-001',
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': coating.id,
+ 'quantity': 5, 'unit_price': 30.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+
+# Find the auto-spawned QC
+qc = QC.search([('job_id', '=', job.id)], limit=1)
+if not qc:
+ qc = QC.create_for_job(job)
+print(f' QC: {qc.name}, lines={len(qc.line_ids)}')
+
+# Operator uploads a fake "Fischerscope" PDF to the QC
+# Use a real minimal PDF so the merge actually parses
+minimal_pdf = (
+ b'%PDF-1.4\n'
+ b'1 0 obj<>endobj\n'
+ b'2 0 obj<>endobj\n'
+ b'3 0 obj<>>>>>>>endobj\n'
+ b'4 0 obj<>stream\n'
+ b'BT /F1 14 Tf 100 700 Td (FISCHERSCOPE THICKNESS REPORT) Tj '
+ b'0 -30 Td (Mean: 25.3 um Std: 1.2) Tj ET\n'
+ b'endstream endobj\n'
+ b'xref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n'
+ b'0000000054 00000 n \n0000000097 00000 n \n0000000189 00000 n \n'
+ b'trailer<>\nstartxref\n330\n%%EOF\n'
+)
+att = env['ir.attachment'].create({
+ 'name': 'fischer_test.pdf',
+ 'datas': base64.b64encode(minimal_pdf),
+ 'mimetype': 'application/pdf',
+ 'type': 'binary',
+})
+qc.thickness_report_pdf_id = att.id
+print(f' Uploaded Fischerscope PDF: {qc.thickness_report_pdf_id.name} '
+ f'({len(minimal_pdf)} bytes)')
+
+# Walk QC lines + pass
+for ln in qc.line_ids:
+ ln.result = 'pass'
+qc.action_pass()
+print(f' QC state: {qc.state}')
+
+# Walk job to done
+for s in job.step_ids.sorted('sequence'):
+ if s.state in ('pending', 'ready'):
+ s.button_start()
+ if s.state == 'in_progress':
+ s.button_finish()
+job.qty_done = 5
+# Bake window
+BW = env['fusion.plating.bake.window']
+for bw in BW.search([('part_ref', '=', job.name), ('state', '!=', 'baked')]):
+ bw.action_start_bake()
+ bw.action_end_bake()
+job.button_mark_done()
+print(f' Job done')
+
+# Fetch the auto-spawned cert
+Cert = env['fp.certificate']
+cert = Cert.search([('x_fc_job_id', '=', job.id)], limit=1)
+print()
+print(f' Cert: {cert.name}, state={cert.state}')
+
+# v19.0.6.20.0 — new UI visibility fields (S19 Phase 2). Assert the
+# operator would see "Will Append on Issue" badge BEFORE clicking Issue.
+print(f' x_fc_thickness_status (pre-Issue): {cert.x_fc_thickness_status!r}')
+print(f' x_fc_thickness_qc_id: {cert.x_fc_thickness_qc_id.name if cert.x_fc_thickness_qc_id else "(none)"}')
+print(f' x_fc_thickness_pdf_id: {cert.x_fc_thickness_pdf_id.name if cert.x_fc_thickness_pdf_id else "(none)"}')
+if cert.x_fc_thickness_status == 'pending':
+ print(f' ✓ UI banner WILL show "Fischerscope thickness PDF is on file"')
+elif cert.x_fc_thickness_status == 'none':
+ print(f' ❌ UI says no PDF — merge would not run on Issue')
+else:
+ print(f' ⚠️ unexpected status: {cert.x_fc_thickness_status}')
+
+# Issue the cert — should render CoC + merge Fischerscope as page 2
+cert.action_issue()
+cert.invalidate_recordset(['x_fc_thickness_status', 'x_fc_thickness_qc_id', 'x_fc_thickness_pdf_id'])
+print(f' After Issue: state={cert.state}')
+print(f' x_fc_thickness_status (post-Issue): {cert.x_fc_thickness_status!r}')
+if cert.x_fc_thickness_status == 'merged':
+ print(f' ✓ UI banner shows "Fischerscope thickness report merged"')
+else:
+ print(f' ❌ UI status not flipping to merged: {cert.x_fc_thickness_status}')
+print(f' attachment_id: {cert.attachment_id.name if cert.attachment_id else "(none)"}')
+if cert.attachment_id:
+ pdf_bytes = base64.b64decode(cert.attachment_id.datas)
+ print(f' Total PDF size: {len(pdf_bytes)} bytes')
+ # Quick page count via pypdf
+ import io
+ try:
+ from pypdf import PdfReader
+ except ImportError:
+ from PyPDF2 import PdfReader
+ try:
+ reader = PdfReader(io.BytesIO(pdf_bytes))
+ print(f' Page count: {len(reader.pages)}')
+ if len(reader.pages) >= 2:
+ print(f' ✓ CoC + Fischerscope merged (multi-page)')
+ else:
+ print(f' ❌ Only 1 page — merge did not run')
+ except Exception as e:
+ print(f' ⚠️ couldn\'t parse output PDF: {e}')
+
+# Look for chatter audit
+msgs = cert.message_ids.filtered(lambda m: 'fischerscope' in (m.body or '').lower())
+print(f' Chatter mentions Fischerscope: {len(msgs)}')
+for m in msgs[:2]:
+ print(f' - {m.body[:120]}')
+
+target.x_fc_requires_qc = False
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s9_reassign.py b/fusion_plating/fusion_plating_quality/scripts/bt_s9_reassign.py
new file mode 100644
index 00000000..1974d20e
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/bt_s9_reassign.py
@@ -0,0 +1,65 @@
+# Scenario 9 — Carlos starts step, Bob (supervisor) reassigns to Mike.
+# Verify chatter audit trail.
+
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-S9-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+step = job.step_ids.sorted('sequence')[0]
+
+# Pretend Carlos owns this step.
+step.assigned_user_id = env.user.id
+step.button_start()
+print(f'[Carlos] Started step "{step.name}" (state={step.state})')
+print(f' assigned_user_id: {step.assigned_user_id.name}')
+
+# Count chatter messages on the JOB before Bob reassigns.
+before_count = len(job.message_ids)
+print(f' Job chatter messages before reassign: {before_count}')
+
+# Bob reassigns to "another user" — for the test we just write to ourselves
+# to simulate, but the field write IS the operation.
+print()
+print('[Bob] Reassigning step to a different operator...')
+# Use a different user if available.
+other = env['res.users'].search([('id', '!=', env.user.id), ('share', '=', False)], limit=1)
+if not other:
+ other = env.user # fallback — at least the write fires
+step.assigned_user_id = other.id
+step.invalidate_recordset()
+job.invalidate_recordset()
+
+after_count = len(job.message_ids)
+print(f' After reassign: assigned_user_id={step.assigned_user_id.name}')
+print(f' Job chatter messages: {after_count} (delta: {after_count - before_count})')
+
+reassign_msgs = job.message_ids.filtered(
+ lambda m: 'reassign' in (m.body or '').lower()
+)
+print(f' Reassign-flagged chatter posts: {len(reassign_msgs)}')
+
+if reassign_msgs:
+ print(f' ✓ Audit trail captured')
+else:
+ print(f' ❌ GAP: silent reassignment, no chatter trail')
+
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/e2e_persona_test.py b/fusion_plating/fusion_plating_quality/scripts/e2e_persona_test.py
new file mode 100644
index 00000000..aeacadc5
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/e2e_persona_test.py
@@ -0,0 +1,395 @@
+# -*- coding: utf-8 -*-
+# E2E persona walk — order entry from start to finish.
+#
+# Personas:
+# Sarah — Customer Service Rep
+# Mike — Receiver
+# Carlos — Plating Operator
+# Lisa — QC Inspector
+# Tom — Shipper
+# Jane — Accounting
+#
+# This script fills every visible-to-operator field per step, walks the
+# workflow with no shortcuts, asserts the data is sane after each phase,
+# and prints what's actually visible in each form view.
+
+import logging
+import traceback
+from datetime import date, timedelta
+
+_log = logging.getLogger(__name__)
+
+
+def section(title):
+ print(f'\n{"="*72}\n{title}\n{"="*72}')
+
+
+def step(persona, msg):
+ print(f' [{persona:>7}] {msg}')
+
+
+def fail(persona, msg):
+ print(f' [{persona:>7}] ❌ {msg}')
+
+
+def find(persona, msg):
+ print(f' [{persona:>7}] 🔍 GAP: {msg}')
+
+
+def e2e(env):
+ findings = []
+
+ # ----- pick a real partner with a recipe-able product -----
+ section('SETUP — pick a customer + a part already in the catalog')
+ Partner = env['res.partner']
+ Part = env['fp.part.catalog']
+ Coating = env['fp.coating.config']
+ partner = Partner.search([
+ ('customer_rank', '>', 0),
+ ('x_fc_account_hold', '=', False),
+ ], limit=1)
+ if not partner:
+ partner = Partner.search([('customer_rank', '>', 0)], limit=1)
+ part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1) \
+ or Part.search([], limit=1)
+ coating = part.x_fc_default_coating_config_id \
+ if part.x_fc_default_coating_config_id \
+ else Coating.search([], limit=1)
+ step('Sarah', f'Customer: {partner.display_name} (id={partner.id})')
+ step('Sarah', f'Part: {part.part_number or part.name} rev {part.revision or "?"} (id={part.id})')
+ step('Sarah', f'Coating: {coating.display_name if coating else "NONE"} (id={coating.id if coating else 0})')
+ if not coating:
+ findings.append('No fp.coating.config found in DB — cannot create realistic SO')
+ return findings
+
+ # ----- Sarah builds a sale order -----
+ section('PHASE 1 — Sarah (CSR) creates the sale order')
+ SO = env['sale.order']
+ SOL = env['sale.order.line']
+ so_vals = {
+ 'partner_id': partner.id,
+ 'x_fc_po_number': f'PO-E2E-{date.today():%y%m%d}',
+ 'x_fc_customer_job_number': 'CUSTJOB-001',
+ 'x_fc_contact_phone': '+1-555-0100',
+ 'x_fc_ship_via': 'Customer pickup',
+ 'x_fc_planned_start_date': date.today() + timedelta(days=2),
+ 'x_fc_internal_deadline': date.today() + timedelta(days=10),
+ 'commitment_date': date.today() + timedelta(days=14),
+ 'x_fc_invoice_strategy': 'net_terms',
+ 'x_fc_delivery_method': 'shipping_partner',
+ 'x_fc_rush_order': False,
+ 'x_fc_is_blanket_order': False,
+ 'x_fc_internal_note': 'E2E test SO — full persona walk.',
+ 'x_fc_external_note': 'Standard plating per spec.',
+ }
+ so = SO.create(so_vals)
+ step('Sarah', f'Created SO {so.name} (id={so.id})')
+
+ # add a line — fill the part / coating / treatment fields
+ product = env['product.product'].search([('sale_ok', '=', True)], limit=1)
+ if not product:
+ findings.append('No saleable product available for SO line')
+ return findings
+ line_vals = {
+ 'order_id': so.id,
+ 'product_id': product.id,
+ 'product_uom_qty': 25,
+ 'name': f'{part.part_number or part.name} — Plating per coating spec',
+ 'x_fc_part_catalog_id': part.id,
+ 'x_fc_coating_config_id': coating.id,
+ 'x_fc_internal_description': 'Process via standard recipe; bake ASAP.',
+ 'x_fc_job_number': 'INTJOB-001',
+ }
+ line = SOL.create(line_vals)
+ step('Sarah', f'Added line: {line.product_uom_qty} × {line.name[:40]}')
+
+ # confirm — does account hold block?
+ if partner.x_fc_account_hold:
+ find('Sarah', 'Customer is on account hold; SO confirm should block (or warn)')
+ try:
+ so.action_confirm()
+ step('Sarah', f'SO confirmed → state={so.state}')
+ except Exception as e:
+ fail('Sarah', f'SO confirm raised: {e}')
+ findings.append(f'SO confirm failure: {e}')
+ return findings
+
+ # ----- side effects: fp.job created? receiving created? -----
+ Job = env['fp.job']
+ Receiving = env['fp.receiving']
+ PortalJob = env['fusion.plating.portal.job']
+ jobs = Job.search([('sale_order_id', '=', so.id)])
+ receivings = Receiving.search([('sale_order_id', '=', so.id)])
+ portal_jobs = PortalJob.search([('x_fc_job_id', 'in', jobs.ids)])
+ step('Sarah', f'After confirm: {len(jobs)} fp.job, {len(receivings)} fp.receiving, {len(portal_jobs)} portal.job')
+ if not jobs:
+ find('Sarah', 'NO fp.job auto-created on SO confirm! Operator has nothing to work.')
+ findings.append('SO confirm did not auto-spawn fp.job')
+ if not receivings:
+ find('Sarah', 'NO fp.receiving auto-created on SO confirm! Receiver has nothing to track.')
+ findings.append('SO confirm did not auto-spawn fp.receiving')
+ if jobs and not portal_jobs:
+ find('Sarah', 'fp.job exists but no portal.job mirror — customer can\'t track on portal.')
+ findings.append('Portal job mirror missing post-confirm')
+
+ # smart-button visibility check
+ so._compute_smart_button_visibility()
+ so._compute_fp_qc_counts()
+ step('Sarah', f'SO smart buttons: BOM Items visible? {so.x_fc_distinct_part_count >= 2} (count={so.x_fc_distinct_part_count}); '
+ f'By Job Group visible? {so.x_fc_has_wo_group_tag}; '
+ f'NCRs visible? {so.fp_qc_ncr_count_so > 0} (count={so.fp_qc_ncr_count_so})')
+
+ # ----- Mike receives parts -----
+ section('PHASE 2 — Mike (Receiver) processes inbound parts')
+ receiving = receivings[:1]
+ if not receiving:
+ receiving = Receiving.create({
+ 'sale_order_id': so.id,
+ 'expected_qty': 25,
+ })
+ step('Mike', f'Manually created receiving {receiving.name} (auto-create did not fire)')
+ find('Mike', 'Had to manually create receiving — auto-create from SO confirm is missing')
+ findings.append('Auto-receiving on SO confirm not wired')
+ else:
+ step('Mike', f'Found auto-created receiving {receiving.name} (state={receiving.state})')
+
+ # operator fills carrier + box count
+ receiving.write({
+ 'carrier_name': 'Purolator Ground',
+ 'carrier_tracking': 'PUR-1Z9999E2E',
+ 'box_count_in': 3,
+ 'received_qty': 25,
+ })
+ step('Mike', f'Set box_count_in={receiving.box_count_in}, carrier={receiving.carrier_name}')
+
+ # walk the state machine: draft → counted → staged → closed
+ try:
+ receiving.action_mark_counted()
+ step('Mike', f'Marked Counted → state={receiving.state}, SO status={so.x_fc_receiving_status}')
+ assert receiving.state == 'counted'
+ assert so.x_fc_receiving_status == 'partial', f'Expected partial after Counted, got {so.x_fc_receiving_status}'
+ except AssertionError as e:
+ fail('Mike', str(e))
+ findings.append(f'Receiving status mismatch after Counted: {e}')
+ except Exception as e:
+ fail('Mike', f'action_mark_counted failed: {e}')
+ findings.append(f'action_mark_counted: {e}')
+
+ try:
+ receiving.action_mark_staged()
+ step('Mike', f'Marked Staged → state={receiving.state}, SO status={so.x_fc_receiving_status}')
+ assert receiving.state == 'staged'
+ assert so.x_fc_receiving_status == 'partial'
+ except Exception as e:
+ fail('Mike', f'action_mark_staged failed: {e}')
+ findings.append(f'action_mark_staged: {e}')
+
+ try:
+ receiving.action_close()
+ step('Mike', f'Closed receiving → state={receiving.state}, SO status={so.x_fc_receiving_status}')
+ assert receiving.state == 'closed'
+ assert so.x_fc_receiving_status == 'received'
+ except Exception as e:
+ fail('Mike', f'action_close failed: {e}')
+ findings.append(f'receiving action_close: {e}')
+
+ # racking inspection should exist
+ if 'fp.racking.inspection' in env:
+ Inspection = env['fp.racking.inspection']
+ racks = Inspection.search([('sale_order_id', '=', so.id)])
+ step('Mike', f'Racking inspections for this SO: {len(racks)}')
+ if not racks:
+ find('Mike', 'Racking inspection NOT auto-created — racking crew has nothing to walk.')
+ findings.append('No racking inspection auto-created post-confirm')
+
+ # ----- Carlos works the plating job -----
+ section('PHASE 3 — Carlos (Operator) walks the plating job')
+ if not jobs:
+ fail('Carlos', 'No job to work — SO confirm did not spawn one. Skipping phase.')
+ else:
+ job = jobs[0]
+ step('Carlos', f'Job {job.name}: state={job.state}, qty={job.qty}, deadline={job.date_deadline}')
+ step('Carlos', f'Steps: {len(job.step_ids)} — recipe={job.recipe_id.name or "(none)"}')
+ if not job.step_ids:
+ find('Carlos', f'Job has zero steps! Recipe not assigned or not generated. Recipe field: {job.recipe_id}')
+ findings.append('Job confirmed with zero steps')
+
+ if job.step_ids:
+ first_step = job.step_ids.sorted('sequence')[0]
+ step('Carlos', f'Starting step {first_step.sequence}: {first_step.name}')
+ try:
+ first_step.button_start()
+ step('Carlos', f'After start: state={first_step.state}, started_by={first_step.started_by_user_id.name if first_step.started_by_user_id else "(none)"}')
+ except Exception as e:
+ fail('Carlos', f'button_start failed: {e}')
+ findings.append(f'step button_start: {e}')
+
+ try:
+ first_step.button_finish()
+ step('Carlos', f'After finish: state={first_step.state}, duration_actual={first_step.duration_actual}')
+ except Exception as e:
+ fail('Carlos', f'button_finish failed: {e}')
+ findings.append(f'step button_finish: {e}')
+
+ # walk the rest at warp speed
+ for s in job.step_ids.sorted('sequence')[1:]:
+ try:
+ if s.state == 'pending':
+ s.button_start()
+ if s.state == 'in_progress':
+ s.button_finish()
+ except Exception as e:
+ fail('Carlos', f'step {s.name} walk: {e}')
+ findings.append(f'step walk {s.name}: {e}')
+ done_count = len(job.step_ids.filtered(lambda st: st.state == 'done'))
+ step('Carlos', f'Walked {done_count}/{len(job.step_ids)} steps to done')
+
+ # try to mark job done — should hit QC gate if customer requires QC
+ wants_qc = 'x_fc_requires_qc' in partner._fields and partner.x_fc_requires_qc
+ step('Carlos', f'Customer requires QC? {wants_qc}')
+ try:
+ job.button_mark_done()
+ step('Carlos', f'Job done → state={job.state}, finished={job.date_finished}')
+ except Exception as e:
+ if wants_qc:
+ step('Carlos', f'(Expected) QC gate fired: {str(e)[:120]}')
+ else:
+ fail('Carlos', f'button_mark_done unexpectedly failed: {e}')
+ findings.append(f'button_mark_done: {e}')
+
+ # ----- Lisa runs QC -----
+ section('PHASE 4 — Lisa (QC) walks the checklist (if any)')
+ QC = env['fusion.plating.quality.check']
+ qcs = QC.search([('job_id', 'in', jobs.ids)]) if jobs else QC.browse()
+ step('Lisa', f'QC checks for this job: {len(qcs)}')
+ if jobs and 'x_fc_requires_qc' in partner._fields and partner.x_fc_requires_qc and not qcs:
+ find('Lisa', 'Customer requires QC but no QC check auto-spawned!')
+ findings.append('QC gate fired but no check spawned')
+ for qc in qcs:
+ step('Lisa', f'QC {qc.name}: state={qc.state}, lines={len(qc.line_ids)}')
+ # try to pass it
+ for ln in qc.line_ids:
+ try:
+ ln.write({'result': 'pass'})
+ except Exception:
+ pass
+ try:
+ qc.action_pass()
+ step('Lisa', f'After action_pass: state={qc.state}')
+ except Exception as e:
+ fail('Lisa', f'action_pass failed: {e}')
+ findings.append(f'qc action_pass: {e}')
+
+ # retry job done if blocked
+ if jobs:
+ job = jobs[0]
+ if job.state != 'done':
+ try:
+ job.button_mark_done()
+ step('Lisa', f'Job marked done after QC pass → state={job.state}')
+ except Exception as e:
+ fail('Lisa', f'Job still blocked: {e}')
+ findings.append(f'Job blocked post-QC: {e}')
+
+ # ----- Tom ships -----
+ section('PHASE 5 — Tom (Shipper) prepares the delivery')
+ Delivery = env['fusion.plating.delivery']
+ deliveries = Delivery.search([
+ '|', ('job_ref', 'in', jobs.mapped('name') if jobs else []),
+ ('x_fc_job_id', 'in', jobs.ids) if jobs else (False, False, False),
+ ]) if jobs else Delivery.browse()
+ step('Tom', f'Deliveries linked to this job: {len(deliveries)}')
+ if jobs and jobs[0].state == 'done' and not deliveries:
+ find('Tom', 'Job is done but NO delivery auto-created!')
+ findings.append('Delivery auto-create on job done missing')
+ for delivery in deliveries:
+ method = (
+ getattr(delivery, 'x_fc_delivery_method', None)
+ or getattr(delivery, 'delivery_method', None)
+ or '(no method field)'
+ )
+ step('Tom', f'Delivery {delivery.name}: state={delivery.state}, method={method}')
+ try:
+ if hasattr(delivery, 'action_schedule') and delivery.state == 'draft':
+ delivery.action_schedule()
+ step('Tom', f'Scheduled → state={delivery.state}')
+ except Exception as e:
+ fail('Tom', f'schedule: {e}')
+
+ # certificates
+ Cert = env['fp.certificate']
+ certs = Cert.search([('x_fc_job_id', 'in', jobs.ids)]) if jobs else Cert.browse()
+ step('Tom', f'Certificates for this job: {len(certs)}')
+ if jobs and jobs[0].state == 'done' and not certs:
+ find('Tom', 'Job done but NO certificate auto-generated.')
+ findings.append('Certificate auto-create missing')
+
+ # ----- Jane invoices -----
+ section('PHASE 6 — Jane (Accounting) creates and posts invoice')
+ invoices_before = env['account.move'].search_count([
+ ('invoice_origin', '=', so.name),
+ ])
+ try:
+ if so.invoice_status == 'to invoice':
+ inv_action = so._create_invoices()
+ step('Jane', f'Invoiced — {invoices_before} → {env["account.move"].search_count([("invoice_origin","=",so.name)])} moves')
+ else:
+ step('Jane', f'invoice_status={so.invoice_status} (nothing to invoice)')
+ except Exception as e:
+ fail('Jane', f'_create_invoices failed: {e}')
+ findings.append(f'invoice creation: {e}')
+
+ # ----- common-sense edge case sweeps -----
+ section('PHASE 7 — common-sense edge case sweeps')
+
+ # smart-button results: do they actually return non-empty data?
+ section_name = ' smart-button result probes'
+ print(section_name)
+ if jobs:
+ job = jobs[0]
+ for action in ('action_view_fp_holds', 'action_view_fp_checks',
+ 'action_view_fp_ncrs', 'action_view_fp_capas',
+ 'action_view_fp_rmas'):
+ try:
+ act = getattr(job, action)()
+ domain = act.get('domain') or []
+ model = act.get('res_model')
+ count = env[model].search_count(domain) if model else 0
+ step('audit', f'{action}: model={model}, domain count={count}')
+ except Exception as e:
+ fail('audit', f'{action}: {e}')
+ findings.append(f'{action}: {e}')
+
+ # SO smart-buttons
+ for action in ('action_view_fp_holds', 'action_view_fp_checks',
+ 'action_view_fp_ncrs_so', 'action_view_fp_capas',
+ 'action_view_fp_rmas'):
+ try:
+ act = getattr(so, action)()
+ domain = act.get('domain') or []
+ model = act.get('res_model')
+ count = env[model].search_count(domain) if model else 0
+ step('audit', f'SO {action}: model={model}, domain count={count}')
+ except Exception as e:
+ fail('audit', f'SO {action}: {e}')
+ findings.append(f'SO {action}: {e}')
+
+ # final summary
+ section('SUMMARY')
+ if findings:
+ print(f' ❌ {len(findings)} finding(s):')
+ for i, f in enumerate(findings, 1):
+ print(f' {i}. {f}')
+ else:
+ print(' ✅ No findings — workflow is clean end-to-end.')
+
+ env.cr.commit()
+ return findings
+
+
+# entry-point: env injected by odoo-shell
+try:
+ findings = e2e(env) # noqa
+except Exception as e:
+ print('FATAL:', e)
+ traceback.print_exc()
diff --git a/fusion_plating/fusion_plating_quality/scripts/step1_verify.py b/fusion_plating/fusion_plating_quality/scripts/step1_verify.py
new file mode 100644
index 00000000..3f4aa328
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step1_verify.py
@@ -0,0 +1,60 @@
+# Step 1 verification — Direct Order wizard onchange + hold guard fixes.
+W = env['fp.direct.order.wizard']
+ISD = env['fp.invoice.strategy.default']
+P = env['res.partner']
+target = P.browse(2529) # 2CM INNOVATIVE
+
+print('Test 1 — customer with NO invoice strategy default:')
+ISD.search([('partner_id', '=', target.id)]).unlink()
+w = W.new({'partner_id': target.id})
+w._onchange_partner_id()
+print(f' invoice_strategy={w.invoice_strategy}, payment_term={w.payment_term_id.name if w.payment_term_id else None}')
+
+print('\nTest 2 — customer WITH strategy default but NO payment_term:')
+isd = ISD.create({'partner_id': target.id, 'default_strategy': 'net_terms'})
+w = W.new({'partner_id': target.id})
+w._onchange_partner_id()
+print(f' invoice_strategy={w.invoice_strategy} (expect: net_terms)')
+print(f' payment_term={w.payment_term_id.name if w.payment_term_id else None}')
+isd.unlink()
+
+print('\nTest 3 — customer with strategy + deposit + payment_term:')
+pt = env['account.payment.term'].search([], limit=1)
+isd = ISD.create({
+ 'partner_id': target.id, 'default_strategy': 'deposit',
+ 'default_deposit_percent': 50.0, 'payment_term_id': pt.id,
+})
+w = W.new({'partner_id': target.id})
+w._onchange_partner_id()
+print(f' invoice_strategy={w.invoice_strategy} (expect: deposit)')
+print(f' deposit_percent={w.deposit_percent} (expect: 50.0)')
+print(f' payment_term={w.payment_term_id.name} (expect: {pt.name})')
+isd.unlink()
+
+print('\nTest 4 — account-hold warning fires on partner change:')
+target.x_fc_account_hold = True
+w = W.new({'partner_id': target.id})
+result = w._onchange_partner_id()
+warning = (result or {}).get('warning')
+print(f' warning title: {warning.get("title") if warning else None}')
+print(f' warning msg: {(warning.get("message") or "")[:100] if warning else None}')
+
+print('\nTest 5 — account-hold blocks action_create_order:')
+w = W.create({'partner_id': target.id, 'po_pending': True})
+# add one line so the line check passes
+part = env['fp.part.catalog'].search([], limit=1)
+coating = env['fp.coating.config'].search([], limit=1)
+env['fp.direct.order.line'].create({
+ 'wizard_id': w.id,
+ 'part_catalog_id': part.id,
+ 'coating_config_id': coating.id,
+ 'quantity': 1,
+ 'unit_price': 10.0,
+})
+try:
+ w.action_create_order()
+ print(' ❌ HELD CUSTOMER CREATED ORDER — guard failed')
+except Exception as e:
+ print(f' ✓ blocked: {str(e)[:120]}')
+target.x_fc_account_hold = False
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/step2_verify.py b/fusion_plating/fusion_plating_quality/scripts/step2_verify.py
new file mode 100644
index 00000000..5882e817
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step2_verify.py
@@ -0,0 +1,37 @@
+# Step 2 verification — picking a part on the wizard line pre-fills coating + treatments.
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+Part = env['fp.part.catalog']
+Coating = env['fp.coating.config']
+Treat = env['fp.treatment']
+P = env['res.partner']
+
+target = P.browse(2529)
+# Pick a part that has a default coating + treatments configured.
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+if not part:
+ # Build a synthetic one for the test.
+ coating = Coating.search([], limit=1)
+ treats = Treat.search([], limit=2)
+ part = Part.search([], limit=1)
+ part.x_fc_default_coating_config_id = coating.id
+ part.x_fc_default_treatment_ids = [(6, 0, treats.ids)]
+ print(f'Set up part {part.part_number}: default coating={coating.name}, treatments={treats.mapped("name")}')
+
+print(f'Part: {part.part_number} rev {part.revision}')
+print(f' default coating: {part.x_fc_default_coating_config_id.name if part.x_fc_default_coating_config_id else None}')
+print(f' default treatments: {part.x_fc_default_treatment_ids.mapped("name") if part.x_fc_default_treatment_ids else None}')
+
+# Build a wizard, add an empty line, simulate Sarah picking the part.
+w = W.create({'partner_id': target.id})
+w._onchange_partner_id()
+ln = Line.new({'wizard_id': w.id})
+ln.part_catalog_id = part
+ln._onchange_part_clears_variant()
+print()
+print(f'After picking part on line:')
+print(f' coating_config_id: {ln.coating_config_id.name if ln.coating_config_id else None}')
+print(f' treatment_ids: {ln.treatment_ids.mapped("name") if ln.treatment_ids else None}')
+print(f' Pre-fill worked? {bool(ln.coating_config_id) and bool(ln.treatment_ids)}')
+
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/step3_verify.py b/fusion_plating/fusion_plating_quality/scripts/step3_verify.py
new file mode 100644
index 00000000..e2c4c09c
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step3_verify.py
@@ -0,0 +1,107 @@
+# Step 3 — Sarah hits "Create Order" in wizard, then confirms SO.
+# Watch every side-effect: SO state, fp.job auto-spawn, fp.receiving
+# auto-spawn, fp.racking.inspection, portal.job mirror, QC check.
+
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+
+target = P.browse(2529) # 2CM INNOVATIVE
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+# Build the wizard exactly the way Sarah would after Step 1+2 fixes.
+w = W.create({
+ 'partner_id': target.id,
+ 'po_number': 'PO-STEP3-001',
+ 'po_pending': False,
+ 'customer_job_number': 'CUSTJOB-STEP3',
+ 'planned_start_date': fields.Date.today(),
+ 'customer_deadline': fields.Date.add(fields.Date.today(), days=14),
+ 'invoice_strategy': 'net_terms',
+ 'delivery_method': 'shipping_partner',
+ 'po_attachment_file': b'fake-pdf-bytes',
+ 'po_attachment_filename': 'po.pdf',
+})
+w._onchange_partner_id()
+print(f'[Sarah] Created wizard {w.name} for {target.display_name}')
+
+ln = Line.new({'wizard_id': w.id})
+ln.part_catalog_id = part
+ln._onchange_part_clears_variant()
+ln_vals = ln._convert_to_write({n: ln[n] for n in ln._fields})
+ln_vals.update({
+ 'wizard_id': w.id,
+ 'quantity': 25,
+ 'unit_price': 12.50,
+ 'line_description': 'EN plating per part default coating',
+ 'internal_description': 'Standard recipe; bake within 4h.',
+})
+real_line = Line.create(ln_vals)
+print(f'[Sarah] Added line: part={real_line.part_catalog_id.part_number}, '
+ f'coating={real_line.coating_config_id.name}, qty={real_line.quantity}')
+
+# Hit Create Order.
+print('[Sarah] Clicking "Create Order"...')
+result = w.action_create_order()
+so_id = (result or {}).get('res_id')
+SO = env['sale.order']
+so = SO.browse(so_id) if so_id else SO.search(
+ [('x_fc_po_number', '=', 'PO-STEP3-001')], order='id desc', limit=1,
+)
+print(f' -> SO created: {so.name} (state={so.state})')
+
+# Now confirm the SO (Sarah does this from the SO form, not the wizard).
+print('[Sarah] Confirming SO...')
+try:
+ so.action_confirm()
+ print(f' -> SO state={so.state}, x_fc_receiving_status={so.x_fc_receiving_status}')
+except Exception as e:
+ print(f' ❌ confirm failed: {e}')
+ env.cr.rollback()
+ raise SystemExit
+
+# Verify side-effects.
+print()
+print('=== Side effects of SO confirm ===')
+
+Job = env['fp.job']
+jobs = Job.search([('sale_order_id', '=', so.id)])
+print(f' fp.job auto-spawn: {len(jobs)} job(s)')
+for j in jobs:
+ print(f' {j.name}: state={j.state}, qty={j.qty}, recipe={j.recipe_id.name or "(no recipe)"}, steps={len(j.step_ids)}')
+
+Receiving = env['fp.receiving']
+receivings = Receiving.search([('sale_order_id', '=', so.id)])
+print(f' fp.receiving auto-spawn: {len(receivings)} record(s)')
+for r in receivings:
+ print(f' {r.name}: state={r.state}, expected_qty={r.expected_qty}')
+
+if 'fp.racking.inspection' in env:
+ Inspection = env['fp.racking.inspection']
+ racks = Inspection.search([('sale_order_id', '=', so.id)])
+ print(f' fp.racking.inspection auto-spawn: {len(racks)} record(s)')
+ for ri in racks:
+ print(f' {ri.name}: state={ri.state if "state" in ri._fields else "?"}, x_fc_job_id={ri.x_fc_job_id.name if ri.x_fc_job_id else None}')
+
+PortalJob = env['fusion.plating.portal.job']
+portal_jobs = PortalJob.search([('x_fc_job_id', 'in', jobs.ids)])
+print(f' portal.job mirror: {len(portal_jobs)} record(s)')
+for pj in portal_jobs:
+ print(f' {pj.name}: state={pj.state}')
+
+QC = env['fusion.plating.quality.check']
+qcs = QC.search([('job_id', 'in', jobs.ids)])
+print(f' QC checks: {len(qcs)} (customer x_fc_requires_qc={getattr(target, "x_fc_requires_qc", "NOFIELD")})')
+for qc in qcs:
+ print(f' {qc.name}: state={qc.state}, lines={len(qc.line_ids)}')
+
+# x_fc_receiving_status check
+print()
+print(f' SO x_fc_receiving_status (post-confirm, no receipt yet): {so.x_fc_receiving_status}')
+print(f' Expected: not_received (parts haven\'t arrived)')
+
+env.cr.commit()
+print()
+print(f'== Step 3 complete. SO ID for next steps: {so.id} ==')
diff --git a/fusion_plating/fusion_plating_quality/scripts/step4_verify.py b/fusion_plating/fusion_plating_quality/scripts/step4_verify.py
new file mode 100644
index 00000000..1e57b19d
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step4_verify.py
@@ -0,0 +1,91 @@
+# Step 4 — Mike receives parts. Walk the receiving form, fill every
+# visible field, walk the state machine, verify SO status updates at
+# every transition.
+
+so = env['sale.order'].browse(423)
+recv = env['fp.receiving'].search([('sale_order_id', '=', so.id)], limit=1)
+print(f'[Mike] Looking at receiving {recv.name}: state={recv.state}, expected_qty={recv.expected_qty}')
+
+# Mike sees the form. What's required vs optional?
+print()
+print('Visible fields on the receiving form (per fp_receiving_views.xml):')
+print(f' sale_order_id: {recv.sale_order_id.name} (readonly via related)')
+print(f' partner_id: {recv.partner_id.name} (related)')
+print(f' po_number: {recv.po_number}')
+print(f' box_count_in: {recv.box_count_in} <-- Mike must set this')
+print(f' expected_qty: {recv.expected_qty}')
+print(f' received_qty: {recv.received_qty} <-- defaults to expected_qty per Sub 8')
+print(f' qty_match: {recv.qty_match}')
+print(f' carrier_name: {recv.carrier_name} <-- Mike fills this')
+print(f' carrier_tracking: {recv.carrier_tracking} <-- Mike fills this')
+
+# Mike fills the carrier + tracking + counts the boxes.
+print()
+print('[Mike] Filling carrier + tracking + box count...')
+recv.write({
+ 'carrier_name': 'Purolator Ground',
+ 'carrier_tracking': 'PUR-1Z9999E2E',
+ 'box_count_in': 3,
+ 'received_qty': 25, # all 25 arrived
+ 'notes': '
Truck arrived 10am. Boxes look clean.
',
+})
+print(f' recv.qty_match = {recv.qty_match} (expected vs received)')
+print(f' SO status BEFORE marking counted: {so.x_fc_receiving_status}')
+
+# Click "Mark Counted"
+print()
+print('[Mike] Clicks "Mark Counted"')
+try:
+ recv.action_mark_counted()
+ print(f' recv.state = {recv.state}')
+ print(f' recv.received_by_id = {recv.received_by_id.name}')
+ print(f' SO status AFTER mark counted: {so.x_fc_receiving_status}')
+ print(f' Expected: partial (boxes on dock, racking pending)')
+ assert so.x_fc_receiving_status == 'partial', 'SO status should be partial!'
+ print(' ✓ SO status correctly flipped to partial')
+except Exception as e:
+ print(f' ❌ {e}')
+
+# Click "Mark Staged"
+print()
+print('[Mike] Clicks "Mark Staged"')
+try:
+ recv.action_mark_staged()
+ print(f' recv.state = {recv.state}')
+ print(f' SO status: {so.x_fc_receiving_status} (should still be partial)')
+ assert so.x_fc_receiving_status == 'partial'
+ print(' ✓ Still partial — racking not done yet')
+except Exception as e:
+ print(f' ❌ {e}')
+
+# Mike clicks the new "Racking Inspections" smart button (Round 2 fix)
+print()
+print('[Mike] Clicks the "Racking Inspections" smart button')
+try:
+ act = recv.action_view_racking_inspections()
+ Inspection = env['fp.racking.inspection']
+ racks = Inspection.search(act.get('domain') or [])
+ print(f' Smart-button opens model={act.get("res_model")}, finds {len(racks)} inspection(s)')
+ for ri in racks:
+ print(f' {ri.name}: state={ri.state if "state" in ri._fields else "?"}, x_fc_job_id={ri.x_fc_job_id.name if ri.x_fc_job_id else None}')
+except Exception as e:
+ print(f' ❌ {e}')
+
+# At this point Mike's done — racking crew takes over.
+# But the receiving stays at "staged" until racking crew finishes
+# inspection and someone clicks "Close" on the receiving.
+# Let's pretend racking is done and close the receiving.
+print()
+print('[Mike] (or shop manager) Clicks "Close Receiving" once racking is done')
+try:
+ recv.action_close()
+ print(f' recv.state = {recv.state}')
+ print(f' SO status AFTER close: {so.x_fc_receiving_status}')
+ assert so.x_fc_receiving_status == 'received'
+ print(' ✓ SO status correctly flipped to received')
+except Exception as e:
+ print(f' ❌ {e}')
+
+print()
+print(f'== Step 4 complete. SO {so.name} status={so.x_fc_receiving_status}, recv {recv.name} state={recv.state} ==')
+env.cr.commit()
diff --git a/fusion_plating/fusion_plating_quality/scripts/step5_verify.py b/fusion_plating/fusion_plating_quality/scripts/step5_verify.py
new file mode 100644
index 00000000..65d83831
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step5_verify.py
@@ -0,0 +1,99 @@
+# Step 5 — Carlos walks the plating job. Test BOTH paths:
+# A) Try to mark_done with steps still ready → must be blocked
+# B) Walk every step → mark_done succeeds
+
+# Build a fresh SO + job (don't reuse 423 — its job is already done).
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-STEP5-001',
+ 'planned_start_date': fields.Date.today(),
+ 'customer_deadline': fields.Date.add(fields.Date.today(), days=14),
+ 'invoice_strategy': 'net_terms',
+ 'delivery_method': 'shipping_partner',
+})
+w._onchange_partner_id()
+ln = Line.new({'wizard_id': w.id})
+ln.part_catalog_id = part
+ln._onchange_part_clears_variant()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': ln.coating_config_id.id,
+ 'quantity': 10, 'unit_price': 15.0,
+})
+result = w.action_create_order()
+so = env['sale.order'].browse(result['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+print(f'[Carlos] Fresh job {job.name} for SO {so.name}')
+print(f' Steps: {len(job.step_ids)}, all in state: {set(job.step_ids.mapped("state"))}')
+
+# Path A: try mark_done without walking steps.
+print()
+print('[Carlos] Try Mark Done WITHOUT walking any step (compliance test):')
+try:
+ job.button_mark_done()
+ print(' ❌ JOB CLOSED WITH ZERO STEPS WALKED — guard failed')
+except Exception as e:
+ print(f' ✓ blocked: {str(e)[:200]}')
+
+# Path B: walk every step then mark_done.
+print()
+print('[Carlos] Walk every step, then Mark Done:')
+for s in job.step_ids.sorted('sequence'):
+ if s.state in ('pending', 'ready'):
+ s.button_start()
+ if s.state == 'in_progress':
+ s.button_finish()
+done_count = len(job.step_ids.filtered(lambda s: s.state == 'done'))
+print(f' walked {done_count}/{len(job.step_ids)} to done')
+
+try:
+ job.button_mark_done()
+ print(f' ✓ Job marked done — state={job.state}, finished={job.date_finished}')
+except Exception as e:
+ print(f' ❌ Mark Done failed AFTER walking: {e}')
+
+# Verify side effects on this job too.
+Delivery = env['fusion.plating.delivery']
+deliveries = Delivery.search([('x_fc_job_id', '=', job.id)])
+Cert = env['fp.certificate']
+certs = Cert.search([('x_fc_job_id', '=', job.id)])
+print(f' Side effects: {len(deliveries)} delivery, {len(certs)} certificate')
+
+# Path C: manager bypass (admin is a manager).
+print()
+print('[Mgr] Test manager bypass via context fp_skip_step_gate=True')
+w2 = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-STEP5-002',
+ 'invoice_strategy': 'net_terms',
+})
+w2._onchange_partner_id()
+Line.create({
+ 'wizard_id': w2.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 15.0,
+})
+r2 = w2.action_create_order()
+so2 = env['sale.order'].browse(r2['res_id'])
+so2.action_confirm()
+job2 = env['fp.job'].search([('sale_order_id', '=', so2.id)], limit=1)
+print(f' Created fresh job {job2.name} with {len(job2.step_ids)} unworked steps')
+try:
+ job2.with_context(fp_skip_step_gate=True).button_mark_done()
+ print(f' ✓ Manager bypass worked — job state={job2.state}')
+except Exception as e:
+ print(f' ❌ Bypass failed: {e}')
+
+env.cr.commit()
+print()
+print('== Step 5 complete ==')
diff --git a/fusion_plating/fusion_plating_quality/scripts/step6_verify.py b/fusion_plating/fusion_plating_quality/scripts/step6_verify.py
new file mode 100644
index 00000000..33d2916d
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step6_verify.py
@@ -0,0 +1,103 @@
+# Step 6 — Lisa walks the QC checklist for a customer that requires QC.
+# Test:
+# A) Customer requires QC but no template configured → spawn fails gracefully?
+# B) Customer requires QC + template configured → check auto-spawns on confirm
+# C) Lisa walks the checklist, marks lines, action_pass
+# D) Job mark_done now lets through
+
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+Tpl = env['fp.qc.checklist.template']
+TplLine = env['fp.qc.checklist.template.line']
+QC = env['fusion.plating.quality.check']
+
+# Find or create a QC template (default, no partner_id) for the test.
+default_tpl = Tpl.search([('partner_id', '=', False), ('active', '=', True)], limit=1)
+if not default_tpl:
+ default_tpl = Tpl.create({
+ 'name': 'Default QC Template (E2E)',
+ 'active': True,
+ })
+ TplLine.create({'template_id': default_tpl.id, 'sequence': 10, 'name': 'Visual inspection — appearance'})
+ TplLine.create({'template_id': default_tpl.id, 'sequence': 20, 'name': 'Thickness measurement (Fischerscope)'})
+ TplLine.create({'template_id': default_tpl.id, 'sequence': 30, 'name': 'Tape adhesion test'})
+ print(f'[setup] Created default QC template: {default_tpl.name} ({len(default_tpl.line_ids)} lines)')
+else:
+ print(f'[setup] Using existing default QC template: {default_tpl.name}')
+
+# Mark our test customer as requires_qc.
+target = P.browse(2529)
+target.x_fc_requires_qc = True
+print(f'[setup] Set {target.display_name}.x_fc_requires_qc = True')
+
+# Build a fresh SO + job for QC test.
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-STEP6-001',
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 20.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+print(f'[Sarah] Confirmed SO {so.name} → job {job.name}')
+
+# Did QC auto-spawn?
+qcs = QC.search([('job_id', '=', job.id)])
+print(f'[Lisa] QC checks auto-spawned: {len(qcs)}')
+for qc in qcs:
+ print(f' {qc.name}: state={qc.state}, lines={len(qc.line_ids)}, partner_id={qc.partner_id.name}')
+
+if not qcs:
+ print(' ❌ Customer requires QC but no check spawned!')
+ raise SystemExit
+
+qc = qcs[0]
+
+# Lisa walks every checklist line.
+print()
+print('[Lisa] Walks the checklist:')
+for ln in qc.line_ids.sorted('sequence'):
+ print(f' - {ln.name}: result before={ln.result}')
+ ln.result = 'pass'
+ ln.notes = 'OK on inspection'
+
+# Try to action_pass.
+print()
+print('[Lisa] Clicks "Pass":')
+try:
+ qc.action_pass()
+ print(f' ✓ QC state={qc.state}, overall_result={qc.overall_result}')
+except Exception as e:
+ print(f' ❌ {e}')
+
+# Now job mark_done should work (steps need to be walked first).
+print()
+print('[Carlos+Lisa] Walking steps then marking job done:')
+for s in job.step_ids.sorted('sequence'):
+ if s.state in ('pending', 'ready'):
+ s.button_start()
+ if s.state == 'in_progress':
+ s.button_finish()
+try:
+ job.button_mark_done()
+ print(f' ✓ Job done — state={job.state}')
+except Exception as e:
+ print(f' ❌ Job mark_done blocked: {e}')
+
+# Reset partner flag for test independence.
+target.x_fc_requires_qc = False
+
+env.cr.commit()
+print()
+print('== Step 6 complete ==')
diff --git a/fusion_plating/fusion_plating_quality/scripts/step7_verify.py b/fusion_plating/fusion_plating_quality/scripts/step7_verify.py
new file mode 100644
index 00000000..5613bcb1
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step7_verify.py
@@ -0,0 +1,93 @@
+# Step 7 — Tom (Shipper) walks the delivery from draft to delivered.
+# Test:
+# A) Delivery exists post-job-done — what fields visible? what state?
+# B) Try action_start_route without driver → must block
+# C) Assign driver + vehicle + box count, schedule
+# D) Try action_mark_delivered without POD → must block
+# E) Capture POD, mark delivered, verify cert + chain of custody
+
+so = env['sale.order'].browse(423) # Step 3's SO
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+Delivery = env['fusion.plating.delivery']
+delivery = Delivery.search([('x_fc_job_id', '=', job.id)], limit=1)
+print(f'[Tom] Looking at delivery {delivery.name}')
+print()
+
+print('Visible header on delivery form:')
+print(f' partner_id: {delivery.partner_id.name}')
+print(f' delivery_address_id: {delivery.delivery_address_id.name if delivery.delivery_address_id else None}')
+print(f' contact_name: {delivery.contact_name}')
+print(f' contact_phone: {delivery.contact_phone}')
+print(f' job_ref: {delivery.job_ref}')
+print(f' state: {delivery.state}')
+print(f' scheduled_date: {delivery.scheduled_date}')
+print(f' assigned_driver_id: {delivery.assigned_driver_id.name if delivery.assigned_driver_id else None}')
+print(f' vehicle_id: {delivery.vehicle_id.name if delivery.vehicle_id else None}')
+print(f' source_facility_id: {delivery.source_facility_id.name if delivery.source_facility_id else None}')
+print(f' tdg_required: {delivery.tdg_required}')
+print(f' pod_id: {delivery.pod_id.name if delivery.pod_id else None}')
+
+# Tom schedules.
+print()
+print('[Tom] Clicks "Schedule"')
+delivery.action_schedule()
+print(f' state={delivery.state}')
+
+# Tom tries to start route WITHOUT assigning a driver.
+print()
+print('[Tom] Tries Start Route without driver:')
+try:
+ delivery.action_start_route()
+ print(' ❌ Got past driver gate without assignment!')
+except Exception as e:
+ print(f' ✓ blocked: {str(e)[:120]}')
+
+# Assign a driver (any user).
+driver = env.user
+delivery.assigned_driver_id = driver.id
+delivery.x_fc_box_count_out = 3
+print()
+print(f'[Tom] Assigned driver: {driver.name}, box_count_out=3')
+
+# Now start route.
+print()
+print('[Tom] Clicks Start Route:')
+try:
+ delivery.action_start_route()
+ print(f' state={delivery.state}')
+ print(f' custody events: {delivery.custody_event_count}')
+except Exception as e:
+ print(f' ❌ {e}')
+
+# Tom tries to mark delivered without POD.
+print()
+print('[Tom] Tries Mark Delivered without POD:')
+try:
+ delivery.action_mark_delivered()
+ print(' ❌ Got past POD gate without capture!')
+except Exception as e:
+ print(f' ✓ blocked: {str(e)[:120]}')
+
+# Tom captures POD.
+POD = env['fusion.plating.proof.of.delivery']
+pod = POD.create({
+ 'delivery_id': delivery.id,
+ 'recipient_name': 'Mark at receiving',
+})
+delivery.pod_id = pod.id
+print()
+print(f'[Tom] Captured POD: {pod.name}, recipient="{pod.recipient_name}"')
+
+# Mark delivered.
+print()
+print('[Tom] Clicks Mark Delivered:')
+try:
+ delivery.action_mark_delivered()
+ print(f' state={delivery.state}, delivered_at={delivery.delivered_at}')
+ print(f' custody events: {delivery.custody_event_count}')
+except Exception as e:
+ print(f' ❌ {e}')
+
+env.cr.commit()
+print()
+print('== Step 7 complete ==')
diff --git a/fusion_plating/fusion_plating_quality/scripts/step8_reverify.py b/fusion_plating/fusion_plating_quality/scripts/step8_reverify.py
new file mode 100644
index 00000000..f9d44e4a
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step8_reverify.py
@@ -0,0 +1,58 @@
+# Step 8 re-verify — fresh SO with net_terms strategy should now get
+# Net-30 payment term auto-filled, and the invoice should post.
+
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+Part = env['fp.part.catalog']
+P = env['res.partner']
+
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-STEP8RV-001',
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+w._onchange_invoice_strategy() # also fires _apply_strategy_payment_term
+print(f'[Sarah] After invoice_strategy=net_terms, payment_term_id={w.payment_term_id.name if w.payment_term_id else None}')
+
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 4, 'unit_price': 30.0,
+})
+r = w.action_create_order()
+so = env['sale.order'].browse(r['res_id'])
+print(f'[Sarah] SO {so.name} created, payment_term_id={so.payment_term_id.name if so.payment_term_id else None}')
+so.action_confirm()
+print(f'[Sarah] Confirmed → state={so.state}')
+
+# Walk job to done so it's invoiceable.
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+for s in job.step_ids.sorted('sequence'):
+ if s.state in ('pending', 'ready'):
+ s.button_start()
+ if s.state == 'in_progress':
+ s.button_finish()
+job.button_mark_done()
+print(f'[Carlos] Job {job.name} done')
+
+# Jane invoices.
+print()
+print('[Jane] Creating + posting invoice:')
+result = so._create_invoices()
+inv = env['account.move'].search([('invoice_origin', '=', so.name)], order='id desc', limit=1)
+print(f' Invoice {inv.name or "(unnamed draft)"}: state={inv.state}, payment_term={inv.invoice_payment_term_id.name if inv.invoice_payment_term_id else None}')
+try:
+ inv.action_post()
+ print(f' ✓ Posted: state={inv.state}, payment_state={inv.payment_state}')
+ print(f' ✓ Invoice name: {inv.name}, due date: {inv.invoice_date_due}')
+except Exception as e:
+ print(f' ❌ {e}')
+
+env.cr.commit()
+print()
+print('== Step 8 re-verify complete ==')
diff --git a/fusion_plating/fusion_plating_quality/scripts/step8_verify.py b/fusion_plating/fusion_plating_quality/scripts/step8_verify.py
new file mode 100644
index 00000000..a093117a
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step8_verify.py
@@ -0,0 +1,76 @@
+# Step 8 — Jane creates the invoice for the completed SO and posts it.
+# Test:
+# A) SO has invoice_status = 'to invoice' after delivery
+# B) Jane creates the invoice
+# C) Invoice draft has correct lines, taxes, payment terms
+# D) Jane posts → invoice posted, account moves balanced
+# E) Notification fires (best-effort)
+
+so = env['sale.order'].browse(423)
+print(f'[Jane] Looking at SO {so.name}')
+print(f' state: {so.state}')
+print(f' invoice_status: {so.invoice_status}')
+print(f' amount_total: {so.amount_total} {so.currency_id.symbol}')
+print(f' payment_term_id: {so.payment_term_id.name if so.payment_term_id else None}')
+print(f' x_fc_invoice_strategy: {so.x_fc_invoice_strategy}')
+print(f' partner.account hold? {getattr(so.partner_id, "x_fc_account_hold", False)}')
+
+# What's already invoiced?
+existing = env['account.move'].search([
+ ('invoice_origin', '=', so.name),
+])
+print(f' Existing invoices for this SO: {len(existing)}')
+for inv in existing:
+ print(f' {inv.name}: state={inv.state}, type={inv.move_type}, amount={inv.amount_total}')
+
+# Path A: create invoices.
+print()
+print('[Jane] Clicks "Create Invoice"')
+if so.invoice_status == 'to invoice':
+ try:
+ result = so._create_invoices()
+ new_invs = env['account.move'].search([
+ ('invoice_origin', '=', so.name), ('id', 'not in', existing.ids),
+ ])
+ print(f' Created {len(new_invs)} new invoice(s)')
+ for inv in new_invs:
+ print(f' {inv.name}: state={inv.state}, lines={len(inv.invoice_line_ids)}')
+ for ln in inv.invoice_line_ids:
+ print(f' - {ln.name[:50]}: qty={ln.quantity}, price={ln.price_unit}, subtotal={ln.price_subtotal}')
+ except Exception as e:
+ print(f' ❌ {e}')
+else:
+ print(f' Skipped — invoice_status={so.invoice_status} (nothing to invoice)')
+ new_invs = env['account.move'].browse()
+
+# Path B: post.
+if new_invs:
+ inv = new_invs[0]
+ print()
+ print(f'[Jane] Posting invoice {inv.name}:')
+ try:
+ inv.action_post()
+ print(f' ✓ state={inv.state}')
+ print(f' payment_state={inv.payment_state}')
+ except Exception as e:
+ print(f' ❌ {e}')
+
+# Verify the SO progress: invoice_status should now show 'invoiced'
+print()
+print(f'[Jane] After posting:')
+print(f' SO invoice_status: {so.invoice_status}')
+print(f' Outstanding receivables on partner: {sum(env["account.move"].search([("partner_id", "=", so.partner_id.id), ("move_type", "=", "out_invoice"), ("state", "=", "posted"), ("payment_state", "in", ("not_paid", "partial"))]).mapped("amount_residual"))}')
+
+# Notification check.
+print()
+print(f'[Jane] Notification logs for this SO/invoice:')
+NotifLog = env['fp.notification.log'] if 'fp.notification.log' in env else None
+if NotifLog and new_invs:
+ logs = NotifLog.search([('source_record_id', 'in', new_invs.ids)])
+ print(f' {len(logs)} notification log(s)')
+ for lg in logs:
+ print(f' {lg.trigger_event} → {lg.partner_id.name if lg.partner_id else "(no partner)"} sent_at={lg.sent_at if "sent_at" in lg._fields else "?"}')
+
+env.cr.commit()
+print()
+print('== Step 8 complete ==')
diff --git a/fusion_plating/fusion_plating_quality/scripts/step_auto_push_defaults.py b/fusion_plating/fusion_plating_quality/scripts/step_auto_push_defaults.py
new file mode 100644
index 00000000..60e0e6aa
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step_auto_push_defaults.py
@@ -0,0 +1,138 @@
+# Verify the auto-push-to-defaults behaviour.
+#
+# Four scenarios:
+# A) Brand-new part (no defaults) → push_to_defaults auto-ticks +
+# warning popup fires
+# B) Existing part WITH defaults → push_to_defaults stays False (no
+# surprise overwrite)
+# C) Brand-new part flagged is_one_off → push_to_defaults stays False
+# D) End-to-end: enter order with new part → confirm → second order
+# with same part auto-pre-fills coating + treatments
+
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+Part = env['fp.part.catalog']
+Coating = env['fp.coating.config']
+Treat = env['fp.treatment']
+P = env['res.partner']
+
+target = P.browse(2529)
+coating = Coating.search([], limit=1)
+treats = Treat.search([], limit=2)
+
+# ====================================================================== A
+print('='*72)
+print('Scenario A — Brand-new part (no defaults)')
+print('='*72)
+fresh = Part.create({
+ 'partner_id': target.id,
+ 'part_number': 'AUTODEF-A-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'revision': 'A',
+ 'name': 'Fresh part for auto-default test',
+})
+w = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
+w._onchange_partner_id()
+ln = Line.new({'wizard_id': w.id})
+ln.part_catalog_id = fresh
+result = ln._onchange_part_clears_variant()
+print(f' push_to_defaults after onchange: {ln.push_to_defaults} (expect True)')
+print(f' is_one_off: {ln.is_one_off}')
+print(f' warning fired? {bool(result and result.get("warning"))}')
+if result and result.get('warning'):
+ w_msg = result['warning']
+ print(f' title: {w_msg["title"]}')
+ print(f' message: {w_msg["message"][:90]}...')
+
+# ====================================================================== B
+print()
+print('='*72)
+print('Scenario B — Existing part WITH defaults already set')
+print('='*72)
+existing = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+print(f' Using part: {existing.display_name} (default coating={existing.x_fc_default_coating_config_id.name})')
+w2 = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
+w2._onchange_partner_id()
+ln2 = Line.new({'wizard_id': w2.id})
+ln2.part_catalog_id = existing
+result2 = ln2._onchange_part_clears_variant()
+print(f' push_to_defaults after onchange: {ln2.push_to_defaults} (expect False — defaults already exist)')
+print(f' pre-filled coating: {ln2.coating_config_id.name if ln2.coating_config_id else "(none)"}')
+print(f' warning fired? {bool(result2 and result2.get("warning"))} (expect False)')
+
+# ====================================================================== C
+print()
+print('='*72)
+print('Scenario C — Brand-new part flagged is_one_off (don\'t persist)')
+print('='*72)
+fresh3 = Part.create({
+ 'partner_id': target.id,
+ 'part_number': 'AUTODEF-C-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'revision': 'A',
+})
+w3 = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
+w3._onchange_partner_id()
+ln3 = Line.new({'wizard_id': w3.id, 'is_one_off': True})
+ln3.part_catalog_id = fresh3
+result3 = ln3._onchange_part_clears_variant()
+print(f' push_to_defaults after onchange: {ln3.push_to_defaults} (expect False — is_one_off blocks)')
+print(f' warning fired? {bool(result3 and result3.get("warning"))} (expect False)')
+
+# ====================================================================== D
+print()
+print('='*72)
+print('Scenario D — End-to-end: order #1 saves defaults, order #2 pre-fills')
+print('='*72)
+fresh_d = Part.create({
+ 'partner_id': target.id,
+ 'part_number': 'AUTODEF-D-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'revision': 'A',
+})
+print(f' Created fresh part: {fresh_d.part_number}')
+
+# ORDER #1
+w_d = W.create({'partner_id': target.id, 'po_pending': True, 'po_number': 'PO-AUTO-D-1', 'invoice_strategy': 'net_terms'})
+w_d._onchange_partner_id()
+ln_d = Line.new({'wizard_id': w_d.id})
+ln_d.part_catalog_id = fresh_d
+ln_d._onchange_part_clears_variant()
+print(f' Order #1 line — auto-ticked push_to_defaults: {ln_d.push_to_defaults}')
+# Sarah picks the coating + treatments she wants
+saved = Line.create({
+ 'wizard_id': w_d.id, 'part_catalog_id': fresh_d.id,
+ 'coating_config_id': coating.id,
+ 'treatment_ids': [(6, 0, treats.ids)],
+ 'push_to_defaults': True,
+ 'quantity': 5, 'unit_price': 12.0,
+})
+print(f' Sarah picked coating={coating.name}, treatments={treats.mapped("name")}')
+
+# Confirm
+result = w_d.action_create_order()
+print(f' Order created: {env["sale.order"].browse(result["res_id"]).name}')
+
+# Re-fetch the part
+fresh_d.invalidate_recordset()
+print(f' Part defaults after order #1:')
+print(f' x_fc_default_coating_config_id: {fresh_d.x_fc_default_coating_config_id.name if fresh_d.x_fc_default_coating_config_id else "(none)"}')
+print(f' x_fc_default_treatment_ids: {fresh_d.x_fc_default_treatment_ids.mapped("name") if fresh_d.x_fc_default_treatment_ids else "(none)"}')
+
+# ORDER #2 — Sarah picks the same part again
+print()
+print(' Order #2 — Sarah picks the same part:')
+w_d2 = W.create({'partner_id': target.id, 'po_pending': True, 'invoice_strategy': 'net_terms'})
+w_d2._onchange_partner_id()
+ln_d2 = Line.new({'wizard_id': w_d2.id})
+ln_d2.part_catalog_id = fresh_d
+ln_d2._onchange_part_clears_variant()
+print(f' Pre-filled coating: {ln_d2.coating_config_id.name if ln_d2.coating_config_id else "(none)"}')
+print(f' Pre-filled treatments: {ln_d2.treatment_ids.mapped("name") if ln_d2.treatment_ids else "(none)"}')
+print(f' push_to_defaults: {ln_d2.push_to_defaults} (expect False — defaults exist)')
+if ln_d2.coating_config_id == coating:
+ print(f' ✓ Order #2 correctly auto-filled from order #1\'s saved defaults')
+else:
+ print(f' ❌ Order #2 did NOT pre-fill from order #1\'s defaults')
+
+env.cr.commit()
+print()
+print('== Auto-default test complete ==')
diff --git a/fusion_plating/fusion_plating_quality/scripts/step_internal_full.py b/fusion_plating/fusion_plating_quality/scripts/step_internal_full.py
new file mode 100644
index 00000000..d20e626f
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step_internal_full.py
@@ -0,0 +1,171 @@
+# Comprehensive internal-process walk.
+#
+# Phases:
+# A) Pause / resume — multiple intervals merge into duration_actual
+# B) Skip an opt-in step
+# C) Skipped steps don't block job mark-done
+# D) Wet plating step finish auto-spawns bake.window with right window_hours
+# E) Bake-window state evolves (awaiting_bake → bake_in_progress → baked)
+# F) Failure: try to start a step already done
+
+import time
+from datetime import timedelta
+from odoo import fields
+
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+Coating = env['fp.coating.config']
+target = P.browse(2529)
+
+# Find or build a coating that requires bake relief.
+coating = Coating.search([('requires_bake_relief', '=', True)], limit=1)
+if not coating:
+ coating = Coating.search([], limit=1)
+ coating.requires_bake_relief = True
+ coating.bake_window_hours = 4.0
+ coating.bake_temperature = 375.0
+ coating.bake_temperature_uom = 'F'
+ coating.bake_duration_hours = 4.0
+ print(f'[setup] Configured {coating.name} to require bake relief (4h window @ 375°F for 4h)')
+else:
+ print(f'[setup] Using existing bake-required coating: {coating.name} ({coating.bake_window_hours}h window)')
+
+# Build a part using this coating as default.
+part = Part.create({
+ 'partner_id': target.id,
+ 'part_number': 'INT-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'revision': 'A',
+ 'name': 'Internal-process test bracket',
+ 'substrate_material': 'steel',
+ 'x_fc_default_coating_config_id': coating.id,
+})
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-INT-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+ln = Line.new({'wizard_id': w.id})
+ln.part_catalog_id = part
+ln._onchange_part_clears_variant()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': coating.id,
+ 'quantity': 5, 'unit_price': 22.0,
+})
+result = w.action_create_order()
+so = env['sale.order'].browse(result['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+print(f'[setup] Job {job.name} with {len(job.step_ids)} steps')
+
+# ====================================================================== A
+print()
+print('='*72)
+print('A — Pause + resume on a step. Multiple intervals must merge.')
+print('='*72)
+masking = job.step_ids.sorted('sequence')[0]
+masking.button_start()
+print(f' start → state={masking.state}, open logs={len(masking.time_log_ids)}')
+time.sleep(2)
+masking.button_pause()
+print(f' pause → state={masking.state}, logs={len(masking.time_log_ids)}, '
+ f'log[0]={masking.time_log_ids[0].duration_minutes:.3f} min')
+time.sleep(2)
+masking.button_start() # resume
+print(f' resume → state={masking.state}, logs={len(masking.time_log_ids)}')
+time.sleep(2)
+masking.button_finish()
+print(f' finish → state={masking.state}, logs={len(masking.time_log_ids)}')
+total = sum(masking.time_log_ids.mapped('duration_minutes'))
+print(f' duration_actual={masking.duration_actual:.3f} min (sum of logs={total:.3f} min)')
+if abs(masking.duration_actual - total) < 0.001:
+ print(f' ✓ Pause/resume merged correctly')
+else:
+ print(f' ❌ Mismatch')
+
+# ====================================================================== B
+print()
+print('='*72)
+print('B — Skip an opt-in step')
+print('='*72)
+racking = job.step_ids.sorted('sequence')[1]
+print(f' Step: {racking.name} state={racking.state}')
+racking.button_skip()
+print(f' After Skip: state={racking.state}')
+if racking.state == 'skipped':
+ print(f' ✓ Skip works')
+
+# ====================================================================== C — walk rest, then mark-done
+print()
+print('='*72)
+print('C — Walk remaining steps (some will spawn bake-window). Mark job done.')
+print('='*72)
+spawn_count_before = env['fusion.plating.bake.window'].search_count([])
+for s in job.step_ids.sorted('sequence'):
+ if s.state in ('done', 'skipped', 'cancelled'):
+ continue
+ if s.state in ('pending', 'ready'):
+ s.button_start()
+ if s.state == 'in_progress':
+ s.button_finish()
+spawn_count_after = env['fusion.plating.bake.window'].search_count([])
+created_bw = spawn_count_after - spawn_count_before
+print(f' Walked all remaining steps to done')
+print(f' Bake windows spawned during walk: {created_bw}')
+
+bws = env['fusion.plating.bake.window'].search([('part_ref', '=', job.name)])
+for bw in bws:
+ print(f' {bw.name}: state={bw.state}, plate_exit={bw.plate_exit_time}, required_by={bw.bake_required_by}, time_remaining={bw.time_remaining_display}')
+
+# ====================================================================== D — try to mark job done
+print()
+print('='*72)
+print('D — Mark job done (skipped+done steps both count as terminal)')
+print('='*72)
+try:
+ job.button_mark_done()
+ print(f' ✓ Job done — state={job.state}')
+except Exception as e:
+ print(f' ❌ {e}')
+
+# ====================================================================== E — bake-window lifecycle
+if bws:
+ bw = bws[0]
+ print()
+ print('='*72)
+ print('E — Bake-window lifecycle: start → end')
+ print('='*72)
+ print(f' Before start: state={bw.state}, color={bw.status_color}')
+ bw.action_start_bake()
+ print(f' After start_bake: state={bw.state}, bake_start={bw.bake_start_time}, color={bw.status_color}')
+ time.sleep(1)
+ bw.action_end_bake()
+ print(f' After end_bake: state={bw.state}, bake_end={bw.bake_end_time}, duration_h={bw.bake_duration_hours:.4f}')
+
+# ====================================================================== F — failure: start a done step
+print()
+print('='*72)
+print('F — Failure paths')
+print('='*72)
+done_step = job.step_ids.filtered(lambda s: s.state == 'done')[:1]
+if done_step:
+ try:
+ done_step.button_start()
+ print(f' ❌ Allowed re-start of a done step')
+ except Exception as e:
+ print(f' ✓ Blocked: {str(e)[:80]}')
+
+# Try to skip an already-done step
+try:
+ done_step.button_skip()
+ print(f' ❌ Allowed skip of done step')
+except Exception as e:
+ print(f' ✓ Blocked: {str(e)[:80]}')
+
+env.cr.commit()
+print()
+print('== Internal-process walk complete ==')
diff --git a/fusion_plating/fusion_plating_quality/scripts/step_internal_process.py b/fusion_plating/fusion_plating_quality/scripts/step_internal_process.py
new file mode 100644
index 00000000..a9551ae5
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step_internal_process.py
@@ -0,0 +1,146 @@
+# Internal-process walk — test time tracking, pause, skip, bake-window
+# auto-spawn, duration overrun. Persona: Carlos (operator) walking the
+# tablet station for a real plating job.
+#
+# Goals:
+# 1) Time tracking captures every start/stop interval correctly
+# 2) Multiple intervals (start/finish/start/finish) sum to duration_actual
+# 3) Pause / resume flow works (currently NOT implemented — gap to fix)
+# 4) Skip flow works for opt-in steps (currently NOT implemented)
+# 5) Wet plating step finishing auto-spawns a bake.window when the
+# coating requires hydrogen embrittlement relief
+# 6) Bake-window state machine reflects elapsed time
+
+import time
+from datetime import timedelta
+from odoo import fields
+
+# Set up a fresh job to walk.
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+P = env['res.partner']
+Part = env['fp.part.catalog']
+target = P.browse(2529)
+part = Part.search([('x_fc_default_coating_config_id', '!=', False)], limit=1)
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-INTERNAL-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+ln = Line.new({'wizard_id': w.id})
+ln.part_catalog_id = part
+ln._onchange_part_clears_variant()
+Line.create({
+ 'wizard_id': w.id, 'part_catalog_id': part.id,
+ 'coating_config_id': part.x_fc_default_coating_config_id.id,
+ 'quantity': 5, 'unit_price': 18.0,
+})
+result = w.action_create_order()
+so = env['sale.order'].browse(result['res_id'])
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+print(f'[setup] Fresh job {job.name} with {len(job.step_ids)} steps')
+
+# ====================================================================== STEP 1
+print()
+print('='*72)
+print('STEP 1 — Carlos opens the first step on the tablet, clicks Start')
+print('='*72)
+first = job.step_ids.sorted('sequence')[0]
+print(f' Step: {first.name} (kind={first.kind}, state={first.state})')
+print(f' duration_expected: {first.duration_expected} min')
+
+before = fields.Datetime.now()
+first.button_start()
+print(f' After Start: state={first.state}, date_started={first.date_started}, started_by={first.started_by_user_id.name}')
+print(f' Open time-log rows: {len(first.time_log_ids.filtered(lambda l: not l.date_finished))}')
+
+# ====================================================================== STEP 2
+print()
+print('='*72)
+print('STEP 2 — Carlos works for 6 seconds, then clicks Finish')
+print('='*72)
+time.sleep(6)
+first.button_finish()
+print(f' After Finish: state={first.state}, date_finished={first.date_finished}')
+print(f' Time-log rows: {len(first.time_log_ids)}')
+for log in first.time_log_ids:
+ print(f' - {log.user_id.name} {log.date_started} → {log.date_finished or "OPEN"} = {log.duration_minutes:.3f} min')
+print(f' duration_actual: {first.duration_actual:.3f} min')
+print(f' ✓ Single interval captured cleanly')
+
+# ====================================================================== STEP 3
+print()
+print('='*72)
+print('STEP 3 — Test pause/resume on the next step (currently NotImplementedError)')
+print('='*72)
+second = job.step_ids.sorted('sequence')[1]
+second.button_start()
+print(f' Started step: {second.name} (state={second.state})')
+print(f' Carlos now needs a smoke break — clicks Pause')
+try:
+ second.button_pause()
+ print(f' ✓ Paused: state={second.state}, open timelog={len(second.time_log_ids.filtered(lambda l: not l.date_finished))}')
+except NotImplementedError as e:
+ print(f' ❌ button_pause not implemented: {e}')
+except Exception as e:
+ print(f' ❌ {type(e).__name__}: {e}')
+
+# ====================================================================== STEP 4
+print()
+print('='*72)
+print('STEP 4 — Test skip (currently NotImplementedError)')
+print('='*72)
+third = job.step_ids.sorted('sequence')[2]
+print(f' Step: {third.name}, state={third.state}')
+print(f' Planner wants to skip this opt-in step')
+try:
+ third.button_skip()
+ print(f' ✓ Skipped: state={third.state}')
+except NotImplementedError as e:
+ print(f' ❌ button_skip not implemented: {e}')
+except Exception as e:
+ print(f' ❌ {type(e).__name__}: {e}')
+
+# ====================================================================== STEP 5
+print()
+print('='*72)
+print('STEP 5 — Wet plating step finishes, does a bake.window auto-spawn?')
+print('='*72)
+# Find a step with kind='wet' (or use step #4 as plating analog)
+wet_step = job.step_ids.filtered(lambda s: 'plating' in (s.name or '').lower())[:1]
+if not wet_step:
+ wet_step = job.step_ids.sorted('sequence')[3:4]
+print(f' Using as plating step: {wet_step.name} (kind={wet_step.kind})')
+
+coating = job.coating_config_id
+print(f' Coating: {coating.name}')
+print(f' coating.requires_bake_relief: {coating.requires_bake_relief}')
+print(f' coating.bake_window_hours: {coating.bake_window_hours}')
+
+# Count bake.window before
+BW = env['fusion.plating.bake.window']
+bw_before = BW.search_count([('part_ref', '=', job.name)])
+print(f' Bake windows for this job BEFORE finish: {bw_before}')
+
+# Skip if currently in_progress (it is — paused step #2 still open)
+if wet_step.state in ('pending', 'ready'):
+ wet_step.button_start()
+if wet_step.state == 'in_progress':
+ wet_step.button_finish()
+print(f' After Finish: state={wet_step.state}')
+
+bw_after = BW.search_count([('part_ref', '=', job.name)])
+print(f' Bake windows for this job AFTER finish: {bw_after}')
+if coating.requires_bake_relief and bw_after == bw_before:
+ print(f' ❌ Coating requires bake relief BUT no bake.window was auto-created!')
+elif not coating.requires_bake_relief:
+ print(f' (coating doesn\'t require bake relief — auto-spawn would skip anyway)')
+else:
+ print(f' ✓ Bake window spawned')
+
+env.cr.commit()
+print()
+print('== Walk complete ==')
diff --git a/fusion_plating/fusion_plating_quality/scripts/step_new_part_from_wizard.py b/fusion_plating/fusion_plating_quality/scripts/step_new_part_from_wizard.py
new file mode 100644
index 00000000..efb07ba9
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step_new_part_from_wizard.py
@@ -0,0 +1,158 @@
+# Walk: Sarah opens Direct Order, creates a brand-new part inline, attaches a process.
+#
+# Personas:
+# Sarah (CSR) — driving the wizard
+#
+# What we're testing:
+# 1) Wizard now allows creating a new part (no_quick_create lets the
+# "Create and edit..." popup through)
+# 2) Sarah enters a brand-new part number for the customer
+# 3) Sarah picks coating + treatments
+# 4) Variant dropdown is empty for the brand-new part (no variants yet)
+# 5) On confirm, the part is saved to catalog + the SO line links to it
+# 6) The job uses the coating's recipe as fallback (no variant means
+# coating.recipe_id wins)
+# 7) Sarah can THEN go to the part form, hit Compose, attach 1+ variants,
+# and the next order can pick one
+
+from odoo import fields
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+Part = env['fp.part.catalog']
+Coating = env['fp.coating.config']
+P = env['res.partner']
+
+target = P.browse(2529) # Cyclone Manufacturing
+default_coating = Coating.search([], limit=1)
+print(f'[Sarah] Customer: {target.display_name}')
+print(f'[Sarah] Picking coating: {default_coating.name}')
+print()
+
+# ====================================================================== STEP 2
+print('='*72)
+print('STEP 2 — Sarah opens wizard, hits "Create and edit..." on Part field')
+print('='*72)
+
+w = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-NEWPART-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+print(f'[Sarah] Wizard {w.name} created.')
+
+# In the UI, Sarah types a new part number → dropdown shows nothing →
+# clicks "Create and edit..." → popup opens with partner pre-filled →
+# fills part_number + name + revision (default A) → saves.
+# Programmatic equivalent: just create the part directly.
+new_part = Part.create({
+ 'partner_id': target.id,
+ 'part_number': 'NEW-INLINE-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'revision': 'A',
+ 'name': 'Inline-created bracket',
+ 'substrate_material': 'aluminium',
+})
+print(f'[Sarah] Filled popup → created part: {new_part.display_name}')
+print(f' partner_id correctly set: {new_part.partner_id.name}')
+print(f' part_number: {new_part.part_number}')
+print(f' revision: {new_part.revision}')
+print(f' default_process_id: {new_part.default_process_id.name if new_part.default_process_id else "(none — no variants composed yet)"}')
+print(f' process_variant_count: {new_part.process_variant_count}')
+
+# Now Sarah adds a line with the new part.
+ln = Line.new({'wizard_id': w.id})
+ln.part_catalog_id = new_part
+ln._onchange_part_clears_variant()
+print()
+print(f'[Sarah] Adds line with new part:')
+print(f' Pre-filled coating: {ln.coating_config_id.name if ln.coating_config_id else "(none — new part has no defaults)"}')
+print(f' Pre-filled treatments: {ln.treatment_ids.mapped("name") if ln.treatment_ids else "(none)"}')
+
+# Sarah picks coating manually (since new part has no defaults).
+print(f'[Sarah] Manually picks coating: {default_coating.name}')
+
+# Save the line.
+real_line = Line.create({
+ 'wizard_id': w.id,
+ 'part_catalog_id': new_part.id,
+ 'coating_config_id': default_coating.id,
+ 'quantity': 8,
+ 'unit_price': 18.0,
+})
+
+# Check variant dropdown availability
+print()
+print(f'[Sarah] Variant dropdown for new part:')
+print(f' Available variants: {len(new_part.process_variant_ids)} (expect 0 — none composed yet)')
+print(f' → Sarah leaves variant blank; coating.recipe_id will drive job')
+
+# ====================================================================== STEP 3
+print()
+print('='*72)
+print('STEP 3 — Sarah confirms order, verify part landed in catalog + job uses coating recipe')
+print('='*72)
+result = w.action_create_order()
+so = env['sale.order'].browse(result['res_id'])
+print(f'[Sarah] SO {so.name} created.')
+
+# Confirm
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+print(f'[Sarah] Confirmed → job {job.name}')
+print(f' job.partner_id: {job.partner_id.name}')
+print(f' job.part_catalog_id: {job.part_catalog_id.display_name}')
+print(f' job.coating_config_id: {job.coating_config_id.name}')
+print(f' job.recipe_id: {job.recipe_id.name if job.recipe_id else "(none)"}')
+print(f' → Coating recipe used as fallback (correct, no variant picked)')
+
+# Verify part is in catalog
+print()
+fetched = Part.search([('part_number', '=', new_part.part_number)], limit=1)
+print(f' Part survives in catalog: {fetched.display_name} (id={fetched.id})')
+
+# ====================================================================== STEP 4
+print()
+print('='*72)
+print('STEP 4 — Bob attaches a variant to the new part (compose flow)')
+print('='*72)
+from odoo.addons.fusion_plating_configurator.controllers.fp_part_composer_controller \
+ import _clone_subtree
+Node = env['fusion.plating.process.node']
+template = Node.search([
+ ('node_type', '=', 'recipe'),
+ ('parent_id', '=', False),
+ ('part_catalog_id', '=', False),
+], limit=1)
+v1 = _clone_subtree(env, template, new_part, parent=False)
+v1.variant_label = 'Standard'
+v1.is_default_variant = True
+new_part.default_process_id = v1.id
+print(f'[Bob] Composed 1 variant: "{v1.variant_label}" (root id={v1.id})')
+
+new_part.invalidate_recordset()
+print(f' process_variant_count now: {new_part.process_variant_count}')
+print(f' default_process_id: {new_part.default_process_id.name}')
+
+# Now Sarah enters a SECOND order — this time variant dropdown should show "Standard"
+print()
+print('='*72)
+print('STEP 5 — Sarah enters a follow-up order; variant dropdown should now show "Standard"')
+print('='*72)
+w2 = W.create({
+ 'partner_id': target.id, 'po_pending': True,
+ 'po_number': 'PO-NEWPART-2-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'invoice_strategy': 'net_terms',
+})
+w2._onchange_partner_id()
+ln2 = Line.new({'wizard_id': w2.id})
+ln2.part_catalog_id = new_part
+ln2._onchange_part_clears_variant()
+print(f'[Sarah] Picked the same part again. Variant dropdown:')
+for v in new_part.process_variant_ids:
+ flag = '★' if v.is_default_variant else ' '
+ print(f' {flag} {v.variant_label or v.name}')
+print(f' Pre-filled coating: {ln2.coating_config_id.name if ln2.coating_config_id else "(none)"}')
+
+env.cr.commit()
+print()
+print('== Walk complete ==')
diff --git a/fusion_plating/fusion_plating_quality/scripts/step_part_variants.py b/fusion_plating/fusion_plating_quality/scripts/step_part_variants.py
new file mode 100644
index 00000000..eafa502a
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/step_part_variants.py
@@ -0,0 +1,190 @@
+# Walk part creation + 4 process variants step by step.
+# Personas:
+# Bob (Estimator) — owns the part catalog, designs process variants
+# Sarah (CSR) — picks a variant on order entry
+#
+# Goal: prove that
+# 1) Bob can create a part
+# 2) Bob can attach 4 distinct process variants via the Composer flow
+# 3) One is flagged default; switching default works
+# 4) Sarah opens a Direct Order, picks the part — variant dropdown lists ALL FOUR
+# 5) Sarah picks a non-default variant; the SO + job actually use it
+
+from odoo import fields
+from odoo.addons.fusion_plating_configurator.controllers.fp_part_composer_controller \
+ import _list_variants, _clone_subtree
+
+P = env['res.partner']
+Part = env['fp.part.catalog']
+Coating = env['fp.coating.config']
+Treat = env['fp.treatment']
+Node = env['fusion.plating.process.node']
+Tpl = Node # template recipes are also fp.process.node records
+
+# ====================================================================== STEP 2
+print('='*72)
+print('STEP 2 — Bob creates a brand-new part')
+print('='*72)
+target_partner = P.browse(2529) # 2CM INNOVATIVE
+default_coating = Coating.search([], limit=1)
+default_treats = Treat.search([], limit=2)
+part = Part.create({
+ 'partner_id': target_partner.id,
+ 'part_number': 'E2E-VAR-' + fields.Datetime.now().strftime('%H%M%S'),
+ 'revision': 'A',
+ 'name': 'E2E variant test bracket',
+ 'substrate_material': 'aluminium',
+ 'surface_area': 12.5,
+ 'surface_area_uom': 'sq_in',
+ 'weight': 0.45,
+ 'complexity': 'simple',
+ 'masking_zones': 1,
+ 'x_fc_default_coating_config_id': default_coating.id,
+ 'x_fc_default_treatment_ids': [(6, 0, default_treats.ids)],
+})
+print(f'[Bob] Created part: {part.display_name} (id={part.id})')
+print(f' default coating: {part.x_fc_default_coating_config_id.name}')
+print(f' default treatments: {default_treats.mapped("name")}')
+print(f' process_variant_count (BEFORE adding any): {part.process_variant_count}')
+
+# Find a shared template recipe to clone from. Templates = fp.process.node
+# records with node_type='recipe', parent_id=False, part_catalog_id=False.
+template = Node.search([
+ ('node_type', '=', 'recipe'),
+ ('parent_id', '=', False),
+ ('part_catalog_id', '=', False),
+], limit=1)
+if not template:
+ print(' ❌ No shared template recipes available — cannot continue!')
+ raise SystemExit
+print(f'[Bob] Will clone from shared template: {template.name} ({len(template.child_ids)} root children)')
+
+# ====================================================================== STEP 3
+print()
+print('='*72)
+print('STEP 3 — Bob adds variant #1: Standard Production')
+print('='*72)
+v1 = _clone_subtree(env, template, part, parent=False)
+v1.variant_label = 'Standard Production'
+v1.is_default_variant = True
+part.default_process_id = v1.id
+print(f'[Bob] Created variant: {v1.variant_label} (root node id={v1.id}, name="{v1.name}")')
+print(f' is_default: {v1.is_default_variant}')
+print(f' child nodes cloned: {len(v1.child_ids)}')
+
+# ====================================================================== STEP 4
+print()
+print('='*72)
+print('STEP 4 — Bob adds variant #2: Aerospace Cert (AS9100)')
+print('='*72)
+v2 = _clone_subtree(env, template, part, parent=False)
+v2.variant_label = 'Aerospace Cert (AS9100)'
+print(f'[Bob] Created variant: {v2.variant_label} (root id={v2.id})')
+print(f' is_default: {v2.is_default_variant} (correct — first one stays default)')
+
+# ====================================================================== STEP 5
+print()
+print('='*72)
+print('STEP 5 — Bob adds variant #3: Quick-turn (no bake)')
+print('='*72)
+v3 = _clone_subtree(env, template, part, parent=False)
+v3.variant_label = 'Quick-turn (no bake)'
+print(f'[Bob] Created variant: {v3.variant_label} (root id={v3.id})')
+
+# ====================================================================== STEP 6
+print()
+print('='*72)
+print('STEP 6 — Bob adds variant #4: Heavy build (wear)')
+print('='*72)
+v4 = _clone_subtree(env, template, part, parent=False)
+v4.variant_label = 'Heavy build (wear)'
+print(f'[Bob] Created variant: {v4.variant_label} (root id={v4.id})')
+
+# Refresh the part and inspect what the form would show.
+part.invalidate_recordset()
+print()
+print(f'[Bob] After 4 adds — part {part.display_name}:')
+print(f' process_variant_count: {part.process_variant_count}')
+print(f' default_process_id: {part.default_process_id.name if part.default_process_id else None}')
+print(f' Variants list (per Composer endpoint /fp/part/composer/state):')
+for entry in _list_variants(part):
+ flag = '★ default' if entry['is_default'] else ' '
+ print(f' {flag} id={entry["id"]:>5} "{entry["label"]}" — {entry["node_count"]} nodes')
+
+# ====================================================================== STEP 7
+print()
+print('='*72)
+print('STEP 7 — Sarah enters a Direct Order, picks the part, picks a variant')
+print('='*72)
+W = env['fp.direct.order.wizard']
+Line = env['fp.direct.order.line']
+w = W.create({
+ 'partner_id': target_partner.id, 'po_pending': True,
+ 'po_number': 'PO-VARTEST-001',
+ 'invoice_strategy': 'net_terms',
+})
+w._onchange_partner_id()
+
+# Sarah adds a line, picks the part. Onchange should pre-fill default coating.
+ln = Line.new({'wizard_id': w.id})
+ln.part_catalog_id = part
+ln._onchange_part_clears_variant()
+print(f'[Sarah] Picked part {part.part_number}.')
+print(f' Pre-filled coating: {ln.coating_config_id.name if ln.coating_config_id else "(none)"}')
+print(f' Pre-filled treatments: {ln.treatment_ids.mapped("name") if ln.treatment_ids else "(none)"}')
+
+# What variants would the dropdown show? Inspect process_variant_id field domain.
+print()
+print(f'[Sarah] Looking at the Variant dropdown on the line:')
+# Domain on x_fc_process_variant_id (defined on sale.order.line) is part-scoped.
+# For the wizard line it's process_variant_id with the same domain.
+visible_variants = part.process_variant_ids
+print(f' Domain: part_scoped (id, child_of, ...). Visible variants: {len(visible_variants)}')
+for v in visible_variants:
+ flag = '★' if v.is_default_variant else ' '
+ print(f' {flag} {v.variant_label or v.name} (id={v.id})')
+
+# Sarah picks variant #3 (Quick-turn).
+ln.process_variant_id = v3
+print()
+print(f'[Sarah] Picked variant: {ln.process_variant_id.variant_label}')
+
+# Persist via Line.create with the chosen variant.
+new_line = Line.create({
+ 'wizard_id': w.id,
+ 'part_catalog_id': part.id,
+ 'coating_config_id': default_coating.id,
+ 'process_variant_id': v3.id,
+ 'quantity': 5,
+ 'unit_price': 25.0,
+})
+print(f' Saved line: process_variant_id={new_line.process_variant_id.variant_label}')
+
+# ====================================================================== STEP 8
+print()
+print('='*72)
+print('STEP 8 — Confirm SO; verify the JOB uses variant #3, not the default')
+print('='*72)
+result = w.action_create_order()
+so = env['sale.order'].browse(result['res_id'])
+print(f'[Sarah] SO created: {so.name}')
+
+# Inspect the SO line's variant.
+sol = so.order_line[:1]
+print(f' SO line process_variant_id: {sol.x_fc_process_variant_id.variant_label if sol.x_fc_process_variant_id else "(none)"}')
+
+# Confirm the SO.
+so.action_confirm()
+job = env['fp.job'].search([('sale_order_id', '=', so.id)], limit=1)
+print(f' Job created: {job.name}')
+print(f' Job recipe_id: {job.recipe_id.name if job.recipe_id else "(none)"}')
+print(f' EXPECTED: recipe_id should match variant #3 root (id={v3.id}, name="{v3.name}")')
+print(f' ACTUAL: recipe_id={job.recipe_id.id} (name="{job.recipe_id.name}")')
+if job.recipe_id.id == v3.id:
+ print(f' ✓ Job correctly inherited the picked variant')
+else:
+ print(f' ❌ Job did NOT use the picked variant! Recipe is {job.recipe_id.name}, expected {v3.name}')
+
+env.cr.commit()
+print()
+print('== Walk complete ==')
diff --git a/fusion_plating/fusion_plating_quality/scripts/sub12_e2e_walkthrough.py b/fusion_plating/fusion_plating_quality/scripts/sub12_e2e_walkthrough.py
new file mode 100644
index 00000000..ec806594
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/sub12_e2e_walkthrough.py
@@ -0,0 +1,399 @@
+# -*- coding: utf-8 -*-
+# End-to-end order walkthrough — simulates each role on the shop floor.
+#
+# Run via odoo-shell:
+# echo 'exec(open("/mnt/extra-addons/custom/fusion_plating_quality/scripts/sub12_e2e_walkthrough.py").read())' \
+# | /usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http
+#
+# Each step prints what the employee would see / type. Failures and
+# missing affordances are printed with [GAP] tags.
+
+import logging
+
+_log = logging.getLogger(__name__)
+GAPS = []
+
+
+def gap(role, where, msg):
+ GAPS.append((role, where, msg))
+ print(f' [GAP] {role} @ {where}: {msg}')
+
+
+def walk():
+ e = env # noqa -- env injected by odoo-shell
+ print('====================== E2E ORDER WALKTHROUGH ======================')
+
+ # ------------------------------------------------------------------
+ # ROLE: Sales / Estimator — open Plating > Sales > Quotations
+ # ------------------------------------------------------------------
+ print('\n[ROLE: Estimator] Plating > Sales > Quotations > New Quote')
+
+ # 1. Pick or create a customer
+ Partner = e['res.partner']
+ customer = Partner.search([('customer_rank', '>', 0)], limit=1)
+ if not customer:
+ gap('Estimator', 'res.partner', 'No customers in DB at all')
+ return
+ print(f' Customer chosen: {customer.display_name} (id={customer.id})')
+
+ # 2. Pick a part from the catalog (or create on the fly)
+ Part = e.get('fp.part.catalog') or (
+ e['fp.part.catalog'] if 'fp.part.catalog' in e else None
+ )
+ if Part is None or 'fp.part.catalog' not in e:
+ gap('Estimator', 'fp.part.catalog', 'Part catalog model missing')
+ return
+ Part = e['fp.part.catalog']
+ part = Part.search([], limit=1)
+ if not part:
+ gap('Estimator', 'fp.part.catalog',
+ 'No parts in catalog — estimator has nothing to quote against')
+ return
+ print(f' Part chosen: {part.display_name} '
+ f'(part#={getattr(part, "part_number", "?")} '
+ f'rev={getattr(part, "revision", "?")})')
+
+ # 2a. Required-field walk (Sub 2 made part_number + revision required)
+ for f in ('part_number', 'revision', 'name'):
+ if f not in part._fields:
+ gap('Estimator', f'fp.part.catalog.{f}', 'field missing')
+ elif not part[f]:
+ gap('Estimator', f'fp.part.catalog.{f}',
+ f'value blank on existing record')
+
+ # 3. Pick a coating config
+ if 'fp.coating.config' not in e:
+ gap('Estimator', 'fp.coating.config', 'coating config model missing')
+ return
+ coating = e['fp.coating.config'].search([], limit=1)
+ if not coating:
+ gap('Estimator', 'fp.coating.config',
+ 'No coating configs defined — estimator cannot configure quote')
+ else:
+ print(f' Coating chosen: {coating.display_name}')
+
+ # 4. Try to create a quote configurator session (the "New Quote" wizard)
+ if 'fp.quote.configurator' not in e:
+ gap('Estimator', 'fp.quote.configurator', 'configurator model missing')
+ return
+ Configurator = e['fp.quote.configurator']
+ cfg_vals = {
+ 'partner_id': customer.id,
+ }
+ if 'part_catalog_id' in Configurator._fields and part:
+ cfg_vals['part_catalog_id'] = part.id
+ if 'coating_config_id' in Configurator._fields and coating:
+ cfg_vals['coating_config_id'] = coating.id
+ try:
+ cfg = Configurator.create(cfg_vals)
+ print(f' ✓ Configurator session created: {cfg.display_name}')
+ except Exception as ex:
+ gap('Estimator', 'fp.quote.configurator.create', str(ex))
+ return
+
+ # 4a. Try the "Create Quotation" path — what action confirms the SO?
+ so = False
+ for meth in ('action_create_quotation', 'action_promote_to_direct_order',
+ 'action_create_sale_order', 'action_generate_quote'):
+ if hasattr(cfg, meth):
+ try:
+ result = getattr(cfg, meth)()
+ so = (
+ e['sale.order'].browse(result.get('res_id'))
+ if isinstance(result, dict) and result.get('res_id')
+ else (cfg.x_fc_sale_order_id if 'x_fc_sale_order_id' in cfg._fields else False)
+ )
+ print(f' ✓ Quote created via {meth}: '
+ f'{so.name if so else "(no SO returned)"}')
+ break
+ except Exception as ex:
+ gap('Estimator', f'configurator.{meth}', str(ex))
+ if not so:
+ # Fall back: create SO directly and see if the configurator workflow is wired.
+ gap('Estimator', 'configurator',
+ 'No working "create quote" action found on the configurator '
+ '— estimator has no button to make a quote')
+ # Manual SO creation for the rest of the walkthrough
+ SO = e['sale.order']
+ try:
+ so = SO.create({
+ 'partner_id': customer.id,
+ 'order_line': [(0, 0, {
+ 'product_id': (
+ e['product.product'].search(
+ [('sale_ok', '=', True)], limit=1).id
+ ),
+ 'product_uom_qty': 10,
+ })],
+ })
+ print(f' Fallback: hand-created SO {so.name}')
+ except Exception as ex:
+ gap('Estimator', 'sale.order.create (fallback)', str(ex))
+ return
+
+ # 5. Customer-facing fields on the SO line
+ if so.order_line:
+ line = so.order_line[0]
+ for f in ('x_fc_internal_description', 'x_fc_part_catalog_id',
+ 'x_fc_coating_config_id', 'x_fc_thickness_id',
+ 'x_fc_serial_id', 'x_fc_job_number'):
+ if f not in line._fields:
+ gap('Estimator', f'sale.order.line.{f}',
+ 'expected field missing')
+ print(f' SO header fields: po={so.x_fc_po_number or "(blank)"}, '
+ f'invoice_strategy={so.x_fc_invoice_strategy}, '
+ f'rush={so.x_fc_rush_order}')
+
+ # ------------------------------------------------------------------
+ # ROLE: Estimator confirms the quote → SO
+ # ------------------------------------------------------------------
+ print('\n[ROLE: Estimator] Click Confirm on the quote')
+ # Estimator types in the customer PO# (real flow: paste from email)
+ if 'x_fc_po_number' in so._fields and not so.x_fc_po_number:
+ so.x_fc_po_number = 'TEST-PO-E2E-001'
+ print(f' Set x_fc_po_number=TEST-PO-E2E-001 on {so.name}')
+ if so.state == 'draft':
+ try:
+ so.action_confirm()
+ print(f' ✓ SO confirmed — state={so.state}')
+ except Exception as ex:
+ gap('Estimator', 'sale.order.action_confirm', str(ex))
+ return
+ else:
+ print(f' SO already in state {so.state}')
+
+ # 5a. Confirm side-effects fired
+ Job = e['fp.job']
+ jobs = Job.search([('sale_order_id', '=', so.id)])
+ if not jobs:
+ gap('Planner', 'fp.job auto-create',
+ 'No fp.job auto-created on SO confirm — planner has nothing '
+ 'to plan against')
+ else:
+ print(f' ✓ {len(jobs)} fp.job(s) created: '
+ f'{", ".join(jobs.mapped("name"))}')
+
+ # 5b. Receiving record auto-created?
+ Recv = e['fp.receiving']
+ receivings = Recv.search([('sale_order_id', '=', so.id)])
+ if not receivings:
+ gap('Receiver', 'fp.receiving auto-create',
+ 'No fp.receiving auto-created on SO confirm — receiver has '
+ 'nothing to count against')
+ else:
+ print(f' ✓ Receiving record(s): {", ".join(receivings.mapped("name"))}')
+
+ # 5c. Racking inspection auto-created on job confirm?
+ Insp = e['fp.racking.inspection']
+ insps = Insp.search([('sale_order_id', '=', so.id)])
+ if not insps and jobs:
+ gap('Racker', 'fp.racking.inspection auto-create',
+ 'jobs exist but no racking inspection — racker walks empty')
+ elif insps:
+ print(f' ✓ Racking inspection(s): '
+ f'{", ".join(insps.mapped("name"))}')
+
+ # 5d. Portal job mirror auto-created?
+ PJ = e['fusion.plating.portal.job']
+ pjs = PJ.search([('partner_id', '=', customer.id)],
+ order='id desc', limit=2)
+ if pjs:
+ print(f' ✓ Portal job(s) for customer: '
+ f'{", ".join(pjs.mapped("name"))}')
+ else:
+ gap('Portal', 'portal job auto-create',
+ 'No portal.job mirror — customer sees nothing on portal')
+
+ # ------------------------------------------------------------------
+ # ROLE: Receiver — Plating > Receiving > All Receiving
+ # ------------------------------------------------------------------
+ print('\n[ROLE: Receiver] Open the receiving record, count boxes')
+ if receivings:
+ r = receivings[0]
+ if 'box_count_in' not in r._fields:
+ gap('Receiver', 'fp.receiving.box_count_in', 'field missing')
+ else:
+ r.box_count_in = 3
+ print(f' Set box_count_in=3 on {r.name}')
+ if hasattr(r, 'action_mark_counted'):
+ try:
+ r.action_mark_counted()
+ print(f' ✓ Marked counted — state={r.state}')
+ except Exception as ex:
+ gap('Receiver', 'action_mark_counted', str(ex))
+ else:
+ gap('Receiver', 'fp.receiving',
+ 'no action_mark_counted button')
+ if hasattr(r, 'action_mark_staged'):
+ try:
+ r.action_mark_staged()
+ print(f' ✓ Marked staged — state={r.state}')
+ except Exception as ex:
+ gap('Receiver', 'action_mark_staged', str(ex))
+ # Smart button to racking inspection?
+ if 'racking_inspection_count' in r._fields:
+ print(f' ✓ Receiving form shows '
+ f'{r.racking_inspection_count} racking inspection(s)')
+ else:
+ gap('Receiver', 'fp.receiving.racking_inspection_count',
+ 'no smart button; receiver navigates manually')
+
+ # ------------------------------------------------------------------
+ # ROLE: Racking Crew — open the linked racking inspection
+ # ------------------------------------------------------------------
+ print('\n[ROLE: Racker] Open the racking inspection from receiving smart button')
+ if insps:
+ insp = insps[0]
+ # Real fields are line_count / ok_count / flagged_count (not "parts_*")
+ for f in ('line_count', 'ok_count', 'flagged_count', 'has_variance'):
+ if f not in insp._fields:
+ gap('Racker', f'fp.racking.inspection.{f}',
+ f'expected field missing')
+ # Real workflow: draft → inspecting (action_start) → done (action_complete)
+ if hasattr(insp, 'action_start'):
+ try:
+ insp.action_start()
+ print(f' ✓ Inspection started — state={insp.state}')
+ except Exception as ex:
+ gap('Racker', 'racking_inspection.action_start', str(ex))
+ if hasattr(insp, 'action_complete'):
+ try:
+ insp.action_complete()
+ print(f' ✓ Inspection completed — state={insp.state}')
+ except Exception as ex:
+ gap('Racker', 'racking_inspection.action_complete', str(ex))
+
+ # ------------------------------------------------------------------
+ # ROLE: Operator — runs the plating job step-by-step
+ # ------------------------------------------------------------------
+ print('\n[ROLE: Operator] Open the job, run each step')
+ if jobs:
+ job = jobs[0]
+ steps = job.step_ids.sorted('sequence')
+ if not steps:
+ gap('Operator', 'fp.job.step_ids',
+ 'job has no steps — recipe not generated')
+ else:
+ print(f' Job {job.name} has {len(steps)} steps')
+ ran = 0
+ for step in steps[:3]: # walk the first 3
+ if step.state in ('ready', 'paused') and hasattr(step, 'button_start'):
+ try:
+ step.button_start()
+ step.button_finish()
+ ran += 1
+ except Exception as ex:
+ gap('Operator', f'step.{step.name}', str(ex))
+ else:
+ gap('Operator', f'step.{step.name}',
+ f"state={step.state} — operator can't start it")
+ print(f' ✓ Ran {ran} of 3 first steps')
+
+ # ------------------------------------------------------------------
+ # ROLE: Inspector — walk the QC checklist if customer requires QC
+ # ------------------------------------------------------------------
+ print('\n[ROLE: Inspector] Look for an open QC check on the job')
+ QC = e['fusion.plating.quality.check']
+ if jobs:
+ job = jobs[0]
+ # Customer might not be flagged x_fc_requires_qc — flip it for the test.
+ wants = ('x_fc_requires_qc' in customer._fields
+ and customer.x_fc_requires_qc)
+ print(f' Customer requires QC: {wants}')
+ if wants:
+ check = QC.search([('job_id', '=', job.id)], limit=1)
+ if not check:
+ gap('Inspector', 'QC.create_for_job',
+ 'customer wants QC but no check was auto-spawned on confirm')
+ else:
+ print(f' ✓ QC check found: {check.name}')
+
+ # ------------------------------------------------------------------
+ # ROLE: Operator — try to mark job done (will hit QC gate if applicable)
+ # ------------------------------------------------------------------
+ print('\n[ROLE: Operator] Click Mark Done on the job')
+ if jobs:
+ job = jobs[0]
+ # Move all steps to done first so the job CAN be done
+ for step in job.step_ids:
+ if step.state in ('pending', 'in_progress'):
+ if step.state == 'pending' and hasattr(step, 'button_start'):
+ try:
+ step.button_start()
+ except Exception:
+ pass
+ if hasattr(step, 'button_finish'):
+ try:
+ step.button_finish()
+ except Exception:
+ pass
+ try:
+ job.with_context(fp_skip_qc_gate=True).button_mark_done()
+ print(f' ✓ Job marked done (with QC bypass) — state={job.state}')
+ except Exception as ex:
+ gap('Operator', 'fp.job.button_mark_done', str(ex))
+
+ # 5e. Delivery auto-created on done?
+ Del = e.get('fusion.plating.delivery') or (
+ e['fusion.plating.delivery'] if 'fusion.plating.delivery' in e else None
+ )
+ Del = e['fusion.plating.delivery'] if 'fusion.plating.delivery' in e else None
+ if Del is not None and jobs:
+ deliveries = Del.search([], order='id desc', limit=3)
+ print(f' Latest deliveries on system: '
+ f'{", ".join(deliveries.mapped("name") or ["(none)"])}')
+
+ # ------------------------------------------------------------------
+ # ROLE: Driver — picks up the delivery
+ # ------------------------------------------------------------------
+ print('\n[ROLE: Driver] Find the linked fusion.plating.delivery')
+ if Del is not None and jobs:
+ d = Del.search([('job_id', '=', jobs[0].id) if 'job_id' in Del._fields
+ else ('id', '=', 0)], limit=1)
+ if d:
+ print(f' ✓ Delivery {d.name} state={d.state}')
+ if hasattr(d, 'action_mark_delivered'):
+ try:
+ d.action_mark_delivered()
+ print(f' ✓ Marked delivered — state={d.state}')
+ except Exception as ex:
+ gap('Driver', 'delivery.action_mark_delivered', str(ex))
+ else:
+ print(' No delivery linked to job — checking by SO')
+
+ # ------------------------------------------------------------------
+ # ROLE: Accountant — invoice the SO
+ # ------------------------------------------------------------------
+ print('\n[ROLE: Accountant] Generate invoice')
+ print(f' invoice_status={so.invoice_status}')
+ if so and so.invoice_status == 'to invoice':
+ try:
+ so._create_invoices()
+ invs = e['account.move'].search(
+ [('invoice_origin', '=', so.name)])
+ print(f' ✓ Invoice(s) created: '
+ f'{", ".join(invs.mapped("name") or ["(none yet)"])}')
+ except Exception as ex:
+ gap('Accountant', 'sale.order._create_invoices', str(ex))
+ elif so.invoice_status == 'no':
+ # qty_delivered is 0 — service products invoice on ordered qty by
+ # default. If "no" persists, the SO has no invoiceable lines yet
+ # (e.g. delivered_qty=0 + invoice_policy='delivery').
+ print(f' Note: SO not yet invoiceable (qty_delivered=0). '
+ f'Set invoice_policy=order on plating service products to '
+ f'invoice immediately on confirm.')
+
+ # ------------------------------------------------------------------
+ # SUMMARY
+ # ------------------------------------------------------------------
+ print('\n=========================== SUMMARY ===========================')
+ if not GAPS:
+ print('NO GAPS FOUND — workflow walked end-to-end clean')
+ else:
+ print(f'{len(GAPS)} GAP(S) FOUND:')
+ for role, where, msg in GAPS:
+ print(f' - [{role}] {where} :: {msg}')
+ e.cr.commit()
+
+
+walk()
diff --git a/fusion_plating/fusion_plating_quality/scripts/sub12_smoke_test.py b/fusion_plating/fusion_plating_quality/scripts/sub12_smoke_test.py
new file mode 100644
index 00000000..57dee143
--- /dev/null
+++ b/fusion_plating/fusion_plating_quality/scripts/sub12_smoke_test.py
@@ -0,0 +1,156 @@
+# -*- coding: utf-8 -*-
+# Sub 12 Phase F — end-to-end smoke test.
+#
+# Run via odoo-shell:
+# /usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin
+# >>> exec(open('/mnt/extra-addons/custom/fusion_plating_quality/scripts/sub12_smoke_test.py').read())
+#
+# Walks the full Sub 12 lifecycle and asserts at each step.
+
+import logging
+
+_log = logging.getLogger(__name__)
+
+
+def _resolve_test_partner(env):
+ """Pick a partner with at least one sale order so the RMA can bind."""
+ so = env['sale.order'].search([('state', 'in', ('sale', 'done'))], limit=1)
+ if not so:
+ raise RuntimeError('No confirmed sale.order found — seed one first.')
+ return so.partner_id, so
+
+
+def smoke():
+ e = env # noqa -- env injected by odoo-shell
+ print('--- Sub 12 smoke test ---')
+
+ # Reset any stale half-completed test artefacts so the script is idempotent.
+ e['fusion.plating.rma'].search([('name', 'like', 'RMA/SUB12-SMOKE-%')]).unlink()
+
+ partner, so = _resolve_test_partner(e)
+ print(f' Using partner: {partner.display_name} (SO {so.name})')
+
+ # 1. Create RMA (draft)
+ rma = e['fusion.plating.rma'].create({
+ 'partner_id': partner.id,
+ 'sale_order_id': so.id,
+ 'sale_order_line_ids': [(6, 0, so.order_line[:1].ids)] if so.order_line else False,
+ 'trigger_source': 'customer_complaint',
+ 'severity': 'high',
+ 'qty_returned': 5,
+ 'complaint_description': '
Smoke-test RMA. Auto-issued for Sub 12 verification.
Shared kanban-stage namespace for NCR + RMA. Codes are
+ referenced by the state ↔ stage_id sync in
+ fp_quality_categorisation_links.py — don't rename codes
+ without checking that file.
Quality points are auto-fired rules that spawn QC checks
+ when receiving closes, jobs are confirmed, steps finish, jobs
+ complete, or sale orders are confirmed. Use filters (customer,
+ part, coating, step kind) to scope each point.
RMAs track customer returns, inspection on receipt, root-cause
+ triage, and resolution (replace / rework / refund / scrap). They
+ auto-spawn NCRs and Holds when the parts arrive at the shop.
+
+
+
+
diff --git a/fusion_plating/fusion_plating_receiving/__manifest__.py b/fusion_plating/fusion_plating_receiving/__manifest__.py
index 03121e1a..fc18a34b 100644
--- a/fusion_plating/fusion_plating_receiving/__manifest__.py
+++ b/fusion_plating/fusion_plating_receiving/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Receiving & Inspection',
- 'version': '19.0.3.3.0',
+ 'version': '19.0.3.5.0',
'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """
diff --git a/fusion_plating/fusion_plating_receiving/models/__init__.py b/fusion_plating/fusion_plating_receiving/models/__init__.py
index 19a2ff86..30ac79b8 100644
--- a/fusion_plating/fusion_plating_receiving/models/__init__.py
+++ b/fusion_plating/fusion_plating_receiving/models/__init__.py
@@ -7,6 +7,5 @@ from . import fp_receiving_damage
from . import fp_receiving_line
from . import fp_receiving
from . import fp_racking_inspection
+from . import fp_receiving_racking_link
from . import sale_order
-# Phase 6 (Sub 11) — mrp_production hook retired.
-# from . import mrp_production
diff --git a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py
index c70759b5..ca125ca9 100644
--- a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py
+++ b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py
@@ -125,6 +125,7 @@ class FpReceiving(models.Model):
rec.state = 'counted'
rec.received_by_id = self.env.user
rec.received_date = fields.Datetime.now()
+ rec._update_so_receiving_status()
rec.message_post(body=_(
'%(user)s counted %(n)d box(es) at receiving.'
) % {'user': self.env.user.name, 'n': rec.box_count_in})
@@ -219,12 +220,28 @@ class FpReceiving(models.Model):
rec.message_post(body=_('Discrepancy resolved.'))
def _update_so_receiving_status(self):
- """Update the linked sale order's receiving status."""
+ """Update the linked sale order's receiving status.
+
+ Sub 8 maps the new box-count-only states (`counted`, `staged`,
+ `closed`) onto the SO's `x_fc_receiving_status`:
+ - draft -> not_received (no rows or just-created)
+ - counted / staged -> partial (boxes on dock, parts not yet
+ racked / inspected)
+ - closed -> received (all boxes opened, racking done)
+ Legacy states (inspecting / accepted / discrepancy / resolved) keep
+ their original mapping for back-compat with pre-Sub-8 records.
+ """
for rec in self:
- if rec.sale_order_id:
- if rec.state in ('accepted', 'resolved'):
- rec.sale_order_id.x_fc_receiving_status = 'received'
- elif rec.state == 'discrepancy':
- rec.sale_order_id.x_fc_receiving_status = 'partial'
- elif rec.state == 'inspecting':
- rec.sale_order_id.x_fc_receiving_status = 'partial'
+ if not rec.sale_order_id:
+ continue
+ if rec.state == 'closed':
+ rec.sale_order_id.x_fc_receiving_status = 'received'
+ elif rec.state in ('counted', 'staged'):
+ rec.sale_order_id.x_fc_receiving_status = 'partial'
+ # Legacy states preserved.
+ elif rec.state in ('accepted', 'resolved'):
+ rec.sale_order_id.x_fc_receiving_status = 'received'
+ elif rec.state in ('discrepancy', 'inspecting'):
+ rec.sale_order_id.x_fc_receiving_status = 'partial'
+ elif rec.state == 'draft':
+ rec.sale_order_id.x_fc_receiving_status = 'not_received'
diff --git a/fusion_plating/fusion_plating_receiving/models/fp_receiving_racking_link.py b/fusion_plating/fusion_plating_receiving/models/fp_receiving_racking_link.py
new file mode 100644
index 00000000..1afa1487
--- /dev/null
+++ b/fusion_plating/fusion_plating_receiving/models/fp_receiving_racking_link.py
@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+#
+# Sub 12 audit fix — discoverable handoff from fp.receiving (boxes
+# counted) to fp.racking.inspection (parts inspected by the racking
+# crew). The racking inspection is auto-created on fp.job.action_confirm
+# but until now there was no smart-button on the receiving form to find
+# it — racking crew had to navigate via a separate menu.
+
+from odoo import _, fields, models
+
+
+class FpReceivingRackingLink(models.Model):
+ _inherit = 'fp.receiving'
+
+ racking_inspection_count = fields.Integer(
+ string='Racking Inspections', compute='_compute_racking_inspection_count',
+ )
+
+ def _compute_racking_inspection_count(self):
+ Inspection = self.env['fp.racking.inspection'] \
+ if 'fp.racking.inspection' in self.env else None
+ for rec in self:
+ if Inspection is None or not rec.sale_order_id:
+ rec.racking_inspection_count = 0
+ continue
+ rec.racking_inspection_count = Inspection.search_count([
+ ('sale_order_id', '=', rec.sale_order_id.id),
+ ])
+
+ def action_view_racking_inspections(self):
+ """Open the racking inspection(s) for this receiving's SO. If
+ none exists yet, default-create context lets the user spawn one
+ with the SO context pre-filled.
+ """
+ self.ensure_one()
+ Inspection = self.env['fp.racking.inspection']
+ domain = [('sale_order_id', '=', self.sale_order_id.id)] \
+ if self.sale_order_id else []
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Racking Inspections'),
+ 'res_model': 'fp.racking.inspection',
+ 'view_mode': 'list,form',
+ 'domain': domain,
+ 'context': {
+ 'default_sale_order_id': self.sale_order_id.id
+ if self.sale_order_id else False,
+ },
+ }
diff --git a/fusion_plating/fusion_plating_receiving/models/mrp_production.py b/fusion_plating/fusion_plating_receiving/models/mrp_production.py
deleted file mode 100644
index e2fc38f1..00000000
--- a/fusion_plating/fusion_plating_receiving/models/mrp_production.py
+++ /dev/null
@@ -1,55 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2026 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Plating product family.
-
-import logging
-
-from odoo import models, _
-
-_logger = logging.getLogger(__name__)
-
-
-class MrpProduction(models.Model):
- _inherit = 'mrp.production'
-
- def action_confirm(self):
- """Soft gate: warn if parts haven't been received yet.
-
- Checks the linked sale order's receiving status. If parts
- are not yet received, logs a warning. This is informational
- only -- it does not block confirmation. The gate is soft
- because handshake deals and urgent jobs need flexibility.
- """
- for production in self:
- so = production._get_source_sale_order()
- if so and so.x_fc_receiving_status in ('not_received', False):
- _logger.warning(
- 'MO %s: parts not yet received for SO %s (receiving status: %s). '
- 'Proceeding with confirmation.',
- production.name, so.name, so.x_fc_receiving_status,
- )
- production.message_post(
- body=_(
- 'Warning: Parts not yet received for sale order '
- '%s. '
- 'Manufacturing confirmed without receiving verification.'
- ) % (so.id, so.name),
- )
- return super().action_confirm()
-
- def _get_source_sale_order(self):
- """Find the sale order linked to this MO via origin field."""
- self.ensure_one()
- if not self.origin:
- return False
- # origin may contain SO name like "S00001" or configurator ref "CFG-00001"
- so = self.env['sale.order'].search(
- [('name', '=', self.origin)], limit=1,
- )
- if not so:
- # Try matching by origin containing the SO name
- so = self.env['sale.order'].search(
- [('name', 'ilike', self.origin)], limit=1,
- )
- return so or False
diff --git a/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml b/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml
index 9d08c812..98acc7c2 100644
--- a/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml
+++ b/fusion_plating/fusion_plating_receiving/views/fp_receiving_views.xml
@@ -76,13 +76,24 @@
statusbar_visible="draft,counted,staged,closed"/>
+
+
+
+
+
Receiving = box count only.
Count the boxes the truck dropped off, set the number
below, and stage them for racking. The racking crew
- opens the boxes and inspects each part — see
- Plating → Operations → Racking Inspection.
+ opens the boxes and inspects each part — click
+ Racking Inspections above to jump
+ straight to the open inspection for this SO.
diff --git a/fusion_plating/fusion_plating_reports/__manifest__.py b/fusion_plating/fusion_plating_reports/__manifest__.py
index 59abc1f2..cfa98a26 100644
--- a/fusion_plating/fusion_plating_reports/__manifest__.py
+++ b/fusion_plating/fusion_plating_reports/__manifest__.py
@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
- 'version': '19.0.8.0.0',
+ 'version': '19.0.9.1.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [
@@ -11,14 +11,19 @@
'sale_pdf_quote_builder',
'account',
'stock',
- 'mrp',
+ # 'mrp' dep dropped post-Sub 11 (MRP cutout). Plating uses fp.job
+ # exclusively now. Re-introducing this dep silently pulls mrp +
+ # cascade back to `installed` on any -u base rescan.
'fusion_plating',
'fusion_plating_quality',
'fusion_plating_compliance',
'fusion_plating_safety',
'fusion_plating_portal',
'fusion_plating_configurator',
- 'fusion_plating_jobs',
+ # NB: fusion_plating_jobs intentionally NOT depended on. Jobs depends
+ # on us (uses report_fp_wo_sticker_inner). Adding the reverse dep
+ # creates a cycle. Our only fp.job touchpoint is wo_scan.py which
+ # uses runtime env.get('fp.job') — safe without the manifest dep.
'fusion_plating_logistics',
],
'data': [
@@ -48,6 +53,10 @@
'report/report_fp_bol.xml',
'report/report_fp_invoice.xml',
'report/report_fp_receipt.xml',
+ # Sub 12 Phase E — quality/RMA reports.
+ 'report/report_fp_rma_authorisation.xml',
+ 'report/report_fp_8d.xml',
+ 'report/report_fp_quality_monthly.xml',
# Hide Odoo's default reports from the Print menu wherever FP
# ships an equivalent (loaded last so it overrides any earlier
# binding declarations from base modules).
diff --git a/fusion_plating/fusion_plating_reports/data/fp_hide_default_reports.xml b/fusion_plating/fusion_plating_reports/data/fp_hide_default_reports.xml
index 454af898..330b4fd6 100644
--- a/fusion_plating/fusion_plating_reports/data/fp_hide_default_reports.xml
+++ b/fusion_plating/fusion_plating_reports/data/fp_hide_default_reports.xml
@@ -64,14 +64,8 @@
action
-
-
-
- action
-
+
-
-
-
-
-
-
-
-
-
+
+
diff --git a/fusion_plating/fusion_plating_reports/models/__init__.py b/fusion_plating/fusion_plating_reports/models/__init__.py
index 58b57c14..36fa87d2 100644
--- a/fusion_plating/fusion_plating_reports/models/__init__.py
+++ b/fusion_plating/fusion_plating_reports/models/__init__.py
@@ -5,3 +5,4 @@
from . import ir_actions_report
from . import report_wo_margin
+from . import report_fp_quality_monthly
diff --git a/fusion_plating/fusion_plating_reports/models/report_fp_quality_monthly.py b/fusion_plating/fusion_plating_reports/models/report_fp_quality_monthly.py
new file mode 100644
index 00000000..7736a3f3
--- /dev/null
+++ b/fusion_plating/fusion_plating_reports/models/report_fp_quality_monthly.py
@@ -0,0 +1,182 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+#
+# Sub 12 Phase E — backing data computation for the Monthly Quality
+# Summary PDF.
+
+from datetime import timedelta
+
+from odoo import api, fields, models
+
+
+class ReportFpQualityMonthly(models.AbstractModel):
+ _name = 'report.fusion_plating_reports.report_fp_quality_monthly_doc'
+ _description = 'Monthly Quality Summary — Backing'
+
+ @api.model
+ def _get_report_values(self, docids, data=None):
+ Company = self.env['res.company']
+ # Default to the user's current company when called from a menu
+ # action with no record selection (docids will be False/None/[]).
+ companies = Company.browse(docids) if docids else self.env.company
+ today = fields.Date.context_today(self.env.user)
+ period_start = today.replace(day=1)
+ period_label = (
+ f'{period_start.strftime("%B %Y")} '
+ f'(running through {today.strftime("%Y-%m-%d")})'
+ )
+
+ Hold = self.env['fusion.plating.quality.hold']
+ Check = self.env['fusion.plating.quality.check']
+ Ncr = self.env['fusion.plating.ncr']
+ Capa = self.env['fusion.plating.capa']
+ Rma = self.env['fusion.plating.rma'] \
+ if 'fusion.plating.rma' in self.env else None
+
+ def _bytype(model, label, opened_field, closed_field, open_dom,
+ overdue_dom):
+ if model is None:
+ return {
+ 'label': label, 'opened': 0, 'closed': 0,
+ 'open_total': 0, 'overdue': 0,
+ }
+ opened = model.search_count([
+ (opened_field, '>=', period_start),
+ ])
+ closed = (
+ model.search_count([(closed_field, '>=', period_start)])
+ if closed_field else 0
+ )
+ return {
+ 'label': label,
+ 'opened': opened,
+ 'closed': closed,
+ 'open_total': model.search_count(open_dom),
+ 'overdue': model.search_count(overdue_dom),
+ }
+
+ cutoff_3d = fields.Datetime.subtract(fields.Datetime.now(), days=3)
+ cutoff_7d = fields.Datetime.subtract(fields.Datetime.now(), days=7)
+ cutoff_5d = fields.Datetime.subtract(fields.Datetime.now(), days=5)
+ cutoff_14d = fields.Datetime.subtract(fields.Datetime.now(), days=14)
+
+ by_type = [
+ _bytype(
+ Hold, 'Quality Holds',
+ 'create_date', None,
+ [('state', 'in', ('on_hold', 'under_review'))],
+ [('state', 'in', ('on_hold', 'under_review')),
+ ('create_date', '<', cutoff_3d)],
+ ),
+ _bytype(
+ Check, 'QC Checks',
+ 'create_date', None,
+ [('state', '=', 'pending')] if 'state' in Check._fields else [],
+ [],
+ ),
+ _bytype(
+ Ncr, 'Non-Conformance Reports',
+ 'reported_date', 'closed_date',
+ [('state', 'in', ('open', 'containment', 'disposition'))],
+ [('state', 'in', ('open', 'containment', 'disposition')),
+ ('reported_date', '<', cutoff_7d)],
+ ),
+ _bytype(
+ Capa, 'CAPAs',
+ 'create_date', None,
+ [('state', 'not in', ('effective', 'closed'))],
+ [('state', 'not in', ('effective', 'closed')),
+ ('due_date', '<', today),
+ ('due_date', '!=', False)],
+ ),
+ ]
+ if Rma is not None:
+ by_type.append(_bytype(
+ Rma, 'RMAs',
+ 'create_date', None,
+ [('state', 'not in', ('closed', 'cancelled'))],
+ ['|',
+ '&', ('state', '=', 'received'),
+ ('create_date', '<', cutoff_5d),
+ '&', ('state', 'in', ('authorised', 'shipped_to_us')),
+ ('create_date', '<', cutoff_14d)],
+ ))
+
+ # NCR severity
+ ncr_severity = []
+ for sev_code, sev_label in [
+ ('critical', 'Critical'), ('high', 'High'),
+ ('medium', 'Medium'), ('low', 'Low'),
+ ]:
+ ncr_severity.append({
+ 'label': sev_label,
+ 'count': Ncr.search_count([
+ ('severity', '=', sev_code),
+ ('reported_date', '>=', period_start),
+ ]),
+ })
+
+ # CAPA effectiveness
+ closed_in_period = Capa.search_count([
+ ('state', 'in', ('effective', 'closed', 'not_effective')),
+ ('verification_date', '>=', period_start),
+ ])
+ effective = Capa.search_count([
+ ('state', '=', 'effective'),
+ ('verification_date', '>=', period_start),
+ ])
+ not_effective = Capa.search_count([
+ ('state', '=', 'not_effective'),
+ ('verification_date', '>=', period_start),
+ ])
+ rate_pct = (
+ int(round(100.0 * effective / closed_in_period))
+ if closed_in_period else 0
+ )
+
+ # Repeat customers (≥3 NCRs in last 90 days)
+ cutoff_90d = today - timedelta(days=90)
+ # Odoo 19 — use _read_group with aggregates=['__count'].
+ groups = self.env['fusion.plating.ncr']._read_group(
+ domain=[('reported_date', '>=', cutoff_90d),
+ ('customer_partner_id', '!=', False)],
+ groupby=['customer_partner_id'],
+ aggregates=['__count'],
+ )
+ repeat_customers = []
+ for partner, count in groups:
+ if count < 3:
+ continue
+ rma_count = (
+ Rma.search_count([
+ ('partner_id', '=', partner.id),
+ ('state', 'not in', ('closed', 'cancelled')),
+ ]) if Rma else 0
+ )
+ repeat_customers.append({
+ 'name': partner.display_name,
+ 'ncr_count': count,
+ 'rma_count': rma_count,
+ })
+ repeat_customers.sort(key=lambda r: r['ncr_count'], reverse=True)
+
+ return {
+ 'doc_ids': companies.ids,
+ 'doc_model': 'res.company',
+ 'docs': companies,
+ 'data': {
+ 'period_label': period_label,
+ 'generated_at': fields.Datetime.now().strftime('%Y-%m-%d %H:%M'),
+ 'by_type': by_type,
+ 'ncr_severity': ncr_severity,
+ 'capa': {
+ 'closed': closed_in_period,
+ 'effective': effective,
+ 'not_effective': not_effective,
+ 'rate_pct': rate_pct,
+ },
+ 'repeat_customers': repeat_customers,
+ },
+ }
diff --git a/fusion_plating/fusion_plating_reports/models/report_wo_margin.py b/fusion_plating/fusion_plating_reports/models/report_wo_margin.py
index 61006017..4b25343d 100644
--- a/fusion_plating/fusion_plating_reports/models/report_wo_margin.py
+++ b/fusion_plating/fusion_plating_reports/models/report_wo_margin.py
@@ -96,6 +96,12 @@ class ReportWoMargin(models.AbstractModel):
# ------------------------------------------------------------------
@api.model
def _get_report_values(self, docids, data=None):
+ # Sub 11 — MRP gone. The report is bound to fusion_plating_reports.action_report_wo_margin
+ # which itself was uninstalled. Returning empty docs keeps the
+ # AbstractModel safe to import (its sister fp.job report
+ # report_fp_job_margin owns the live margin path now).
+ if 'mrp.production' not in self.env:
+ return {'doc_ids': [], 'doc_model': 'mrp.production', 'docs': []}
productions = self.env['mrp.production'].browse(docids)
docs = []
for mo in productions:
diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_8d.xml b/fusion_plating/fusion_plating_reports/report/report_fp_8d.xml
new file mode 100644
index 00000000..4a312258
--- /dev/null
+++ b/fusion_plating/fusion_plating_reports/report/report_fp_8d.xml
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
8D Report
+
+ NCR:
+ CAPA:
+ Issued:
+
+
+
+
+
+
D1 — Team
+
+
Lead
+
Team
+
Members
+
+
+
+
+
+
D2 — Problem Description
+
+
+
Severity
+
Source
+
Customer
+
Part / Lot
+
Qty Affected
+
+
+
+
+
+
+
+
+
+
D3 — Containment Action
+
+
+
+
+
+
+
+
D4 — Root Cause Analysis
+
+
+
Classified Reason
()
+
CAPA Reason
+
+
+
+
+ Root cause not yet documented.
+
+
+
+
+
+
+
D5 — Permanent Corrective Action
+
+
+ No corrective action plan recorded — open a CAPA from the NCR to populate this section.
+
+
+
+
+
+
D6 — Implement & Verify
+
+
+
CAPA State
+
Owner
+
Due
+
Verification
+
Effective
+
+
+
+
+
No CAPA opened — implementation tracking unavailable.
+
+
+
+
+
+
D7 — Prevent Recurrence
+
+
+
+
+
+ Refer to D5 — corrective action plan covers preventive measures.
+
+ No preventive actions recorded. Open a Preventive-type CAPA to track recurrence-prevention measures separately.
+
Print this authorisation and include it with your shipment.
+
Pack returned parts in their original boxes (we ship back in the same boxes per shop policy).
+
Mark each box clearly with the RMA number .
+
Ship to the address below — pre-paid carrier of your choice.
+
Send your tracking number to your account contact so we can monitor the return.
+
+
Return Address:
+
+ EN Technologies
+ Receiving — RMA
+ [shop street address]
+ [city, province, postal code]
+
+
+
+
+
+
+ Scan to track this RMA
+
+
+
+
+
+
+
+ This authorisation is valid for 60 days from the issue date. Returns received without an RMA number will not be processed. Questions? Reply to the email this PDF was attached to or call your account contact.
+
+
+
+
+
+
+
+
+ RMA Authorisation
+ fusion.plating.rma
+ qweb-pdf
+ fusion_plating_reports.report_fp_rma_authorisation_doc
+ fusion_plating_reports.report_fp_rma_authorisation_doc
+
+ report
+ 'RMA-' + (object.name or '').replace('/', '-')
+
+
+
diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py
index 53744a4f..e28779f1 100644
--- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py
+++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
- 'version': '19.0.24.0.0',
+ 'version': '19.0.24.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py
index 2ceb1a10..9ada5a3f 100644
--- a/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py
+++ b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py
@@ -208,8 +208,17 @@ class FpShopfloorController(http.Controller):
'name': step.name,
'state': step.state,
'duration_actual': step.duration_actual,
+ 'duration_expected': step.duration_expected,
'job_name': step.job_id.name or '',
'product_name': step.job_id.product_id.display_name or '',
+ # Recipe-author instructions surfaced to the operator on
+ # scan. Without this the operator never sees bake
+ # setpoints, masking patterns, dwell times, etc.
+ 'instructions': step.instructions or '',
+ 'thickness_target': step.thickness_target or 0,
+ 'thickness_uom': step.thickness_uom or '',
+ 'dwell_time_minutes': step.dwell_time_minutes or 0,
+ 'bake_setpoint_temp': step.bake_setpoint_temp or 0,
}
# FP-QC: → directly into the mobile checklist screen
@@ -1083,6 +1092,15 @@ class FpShopfloorController(http.Controller):
'duration_display': _dur_disp(step.duration_actual or 0),
'duration_expected_display': _dur_disp(step.duration_expected or 0),
'missing_for_release': '',
+ # Operator-facing recipe guidance — without this the
+ # tablet UI has no way to surface bake setpoints,
+ # masking patterns, dwell times, etc.
+ 'instructions': step.instructions or '',
+ 'thickness_target': step.thickness_target or 0,
+ 'thickness_uom': step.thickness_uom or '',
+ 'dwell_time_minutes': step.dwell_time_minutes or 0,
+ 'bake_setpoint_temp': step.bake_setpoint_temp or 0,
+ 'requires_signoff': bool(step.requires_signoff),
}
attached_step_ids = set()
diff --git a/fusion_plating/fusion_plating_shopfloor/models/fp_bake_window.py b/fusion_plating/fusion_plating_shopfloor/models/fp_bake_window.py
index 3fdd4e80..35623c15 100644
--- a/fusion_plating/fusion_plating_shopfloor/models/fp_bake_window.py
+++ b/fusion_plating/fusion_plating_shopfloor/models/fp_bake_window.py
@@ -5,7 +5,8 @@
from datetime import timedelta
-from odoo import api, fields, models
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
class FpBakeWindow(models.Model):
@@ -247,12 +248,48 @@ class FpBakeWindow(models.Model):
# Actions
# ==========================================================================
def action_start_bake(self):
+ """Move into bake_in_progress.
+
+ Hard guard: cannot start a bake on a missed_window record without
+ manager override (context `fp_skip_missed_window=True`). AS9100 /
+ Nadcap can't be retroactively documented — starting a bake after
+ the window means the parts are likely scrap. The override exists
+ for the rare case the customer accepts a deviation in writing;
+ every override posts to chatter so the audit trail is intact.
+ """
+ skip = self.env.context.get('fp_skip_missed_window')
+ is_manager = self.env.user.has_group(
+ 'fusion_plating.group_fusion_plating_manager'
+ )
for rec in self:
+ if rec.state == 'missed_window':
+ if not skip or not is_manager:
+ raise UserError(_(
+ 'Bake window %s has expired (required by %s). '
+ 'A manager must override via the "Force Start "'
+ '(missed window)" action — the override is '
+ 'logged on chatter for audit. Otherwise the '
+ 'parts must be scrapped.'
+ ) % (rec.name, rec.bake_required_by))
+ rec.message_post(body=_(
+ 'MANAGER OVERRIDE: bake started after missed window. '
+ 'Window required by %s — actual start %s. Customer '
+ 'deviation must be on file.'
+ ) % (rec.bake_required_by, fields.Datetime.now()))
rec.write({
'state': 'bake_in_progress',
'bake_start_time': fields.Datetime.now(),
})
+ def action_force_start_missed(self):
+ """Manager-only: force-start a bake on a missed_window record.
+
+ Just calls action_start_bake with the override context. Exists
+ as a separate button so the form view can guard visibility on
+ manager group.
+ """
+ return self.with_context(fp_skip_missed_window=True).action_start_bake()
+
def action_end_bake(self):
for rec in self:
vals = {