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 ` - - - + + + + + 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ 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: +

+
    +
  1. Open the linked Plating Job (smart + button above)
  2. +
  3. Click into the auto-spawned Quality + Check
  4. +
  5. Go to the Thickness Report tab + and upload the PDF from the Fischerscope + / XDAL 600 export
  6. +
  7. Pass the QC, then come back here and + click Issue
  8. +
+
+
+

+ + 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.

', + }) + print(f' ✓ Created RMA {rma.name} (state={rma.state})') + assert rma.state == 'draft', f'Expected draft, got {rma.state}' + + # 2. Authorise + rma.action_authorise() + assert rma.state == 'authorised' + print(f' ✓ Authorised — state={rma.state}, qr_code present={bool(rma.qr_code)}') + + # 3. Mark shipped + rma.action_mark_shipped_to_us() + assert rma.state == 'shipped_to_us' + print(f' ✓ Customer shipped — state={rma.state}') + + # 4. Auto-receive via fp.receiving create. The RMA-link create-hook + # walks the receiving from draft → counted → staged → closed in one + # shot so the SO's x_fc_receiving_status flips to "received". + receiving = e['fp.receiving'].create({ + 'sale_order_id': so.id, + 'rma_id': rma.id, + 'box_count_in': 1, + 'expected_qty': 5, + 'received_qty': 5, + }) + print(f' ✓ Created fp.receiving {receiving.name} → RMA state={rma.state}, recv state={receiving.state}, SO status={so.x_fc_receiving_status}') + assert rma.state == 'received', f'Expected received, got {rma.state}' + assert receiving.state == 'closed', f'Expected receiving closed, got {receiving.state}' + assert so.x_fc_receiving_status == 'received', \ + f'Expected SO status received, got {so.x_fc_receiving_status}' + + # 5. Verify auto-spawn fired + assert rma.linked_ncr_ids, 'Auto-NCR was not spawned' + assert rma.linked_hold_ids, 'Auto-Hold was not spawned' + ncr = rma.linked_ncr_ids[0] + hold = rma.linked_hold_ids[0] + print(f' ✓ Auto-spawned NCR {ncr.name} + Hold {hold.name}') + + # 6. Set resolution + triage + rma.resolution_type = 'rework' + rma.action_triage_complete() + assert rma.state == 'triaged' + print(f' ✓ Triage complete — state={rma.state}, resolution=rework') + + # 7. Start resolving + rma.action_start_resolving() + assert rma.state == 'resolving' + print(f' ✓ Resolving — state={rma.state}') + + # 8. NCR walk: open → containment → root cause → close + ncr.action_open() + ncr.action_containment() + ncr.containment = '

Smoke-test: parts segregated for re-rack.

' + ncr.action_disposition() + ncr.disposition = 'rework' + ncr.root_cause = '

Smoke-test: rack contact loss during transit.

' + + # 9. Spawn CAPA from NCR (uses severity gate — high passes) + spawn_action = ncr.action_spawn_capa() + capa = e['fusion.plating.capa'].browse(spawn_action.get('res_id')) + print(f' ✓ Spawned CAPA {capa.name} from NCR') + assert capa.ncr_id == ncr, 'CAPA not linked to NCR' + + # 10. Walk CAPA: analysis → implementation → verification → effective + capa.action_start_analysis() + capa.root_cause_analysis = '

Smoke-test: 5 Whys → packaging gap.

' + capa.action_start_implementation() + capa.action_plan = '

Smoke-test: revise packaging SOP.

' + capa.action_start_verification() + capa.effectiveness_notes = '

Smoke-test: 30 days no recurrence.

' + capa.action_mark_effective() + print(f' ✓ CAPA marked effective — state={capa.state}') + assert capa.state == 'effective' + + # 11. Close NCR + ncr.action_close() + assert ncr.state == 'closed' + print(f' ✓ NCR closed — state={ncr.state}') + + # 11b. Release the auto-spawned Hold (rework path) so the RMA close + # gate doesn't block. action_close on RMA refuses if any Hold is + # still on_hold or under_review. + hold.action_send_to_rework() + print(f' ✓ Hold sent to rework — state={hold.state}') + + # 12. Resolve RMA (will spawn replacement job for rework) + rma.action_resolve() + print(f' ✓ RMA resolved — state={rma.state}, replacement_job={rma.replacement_job_id.name if rma.replacement_job_id else None}') + assert rma.state == 'resolved' + + # 13. Close RMA + rma.action_close() + assert rma.state == 'closed' + print(f' ✓ RMA closed — state={rma.state}') + + # 14. Stage_id sync sanity + print(f' ✓ NCR stage_id={ncr.stage_id.name if ncr.stage_id else "(none)"}') + print(f' ✓ RMA stage_id={rma.stage_id.name if rma.stage_id else "(none)"}') + + # 15. Counts smoke (read directly — controller needs http context). + open_holds = e['fusion.plating.quality.hold'].search_count([ + ('state', 'in', ('on_hold', 'under_review')), + ]) + open_ncrs = e['fusion.plating.ncr'].search_count([ + ('state', 'in', ('open', 'containment', 'disposition')), + ]) + open_rmas = e['fusion.plating.rma'].search_count([ + ('state', 'not in', ('closed', 'cancelled')), + ]) + print(f' ✓ Dashboard counts (post-test): holds={open_holds}, ncrs={open_ncrs}, rmas={open_rmas}') + + e.cr.commit() + print('--- Sub 12 smoke test PASSED ---') + + +smoke() diff --git a/fusion_plating/fusion_plating_quality/security/ir.model.access.csv b/fusion_plating/fusion_plating_quality/security/ir.model.access.csv index 829c95a7..d0bcc759 100644 --- a/fusion_plating/fusion_plating_quality/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating_quality/security/ir.model.access.csv @@ -44,3 +44,21 @@ access_fp_qc_template_manager,fp.qc.checklist.template.manager,model_fp_qc_check access_fp_qc_template_line_operator,fp.qc.checklist.template.line.operator,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_operator,1,0,0,0 access_fp_qc_template_line_supervisor,fp.qc.checklist.template.line.supervisor,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_qc_template_line_manager,fp.qc.checklist.template.line.manager,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_rma_operator,fusion.plating.rma.operator,model_fusion_plating_rma,fusion_plating.group_fusion_plating_operator,1,0,0,0 +access_fp_rma_supervisor,fusion.plating.rma.supervisor,model_fusion_plating_rma,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 +access_fp_rma_manager,fusion.plating.rma.manager,model_fusion_plating_rma,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_quality_tag_user,fp.quality.tag.user,model_fp_quality_tag,base.group_user,1,0,0,0 +access_fp_quality_tag_supervisor,fp.quality.tag.supervisor,model_fp_quality_tag,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 +access_fp_quality_tag_manager,fp.quality.tag.manager,model_fp_quality_tag,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_quality_reason_user,fp.quality.reason.user,model_fp_quality_reason,base.group_user,1,0,0,0 +access_fp_quality_reason_supervisor,fp.quality.reason.supervisor,model_fp_quality_reason,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 +access_fp_quality_reason_manager,fp.quality.reason.manager,model_fp_quality_reason,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_quality_team_user,fp.quality.team.user,model_fp_quality_team,base.group_user,1,0,0,0 +access_fp_quality_team_supervisor,fp.quality.team.supervisor,model_fp_quality_team,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 +access_fp_quality_team_manager,fp.quality.team.manager,model_fp_quality_team,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_quality_alert_stage_user,fp.quality.alert.stage.user,model_fp_quality_alert_stage,base.group_user,1,0,0,0 +access_fp_quality_alert_stage_supervisor,fp.quality.alert.stage.supervisor,model_fp_quality_alert_stage,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 +access_fp_quality_alert_stage_manager,fp.quality.alert.stage.manager,model_fp_quality_alert_stage,fusion_plating.group_fusion_plating_manager,1,1,1,1 +access_fp_quality_point_user,fp.quality.point.user,model_fp_quality_point,base.group_user,1,0,0,0 +access_fp_quality_point_supervisor,fp.quality.point.supervisor,model_fp_quality_point,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 +access_fp_quality_point_manager,fp.quality.point.manager,model_fp_quality_point,fusion_plating.group_fusion_plating_manager,1,1,1,1 diff --git a/fusion_plating/fusion_plating_quality/static/src/js/fp_quality_dashboard.js b/fusion_plating/fusion_plating_quality/static/src/js/fp_quality_dashboard.js new file mode 100644 index 00000000..e2293336 --- /dev/null +++ b/fusion_plating/fusion_plating_quality/static/src/js/fp_quality_dashboard.js @@ -0,0 +1,95 @@ +/** @odoo-module **/ + +// Sub 12 Phase D — Unified Quality Dashboard. +// Five tabs (Holds / Checks / NCRs / CAPAs / RMAs) backed by their list +// kanbans, with a header summary card showing open + overdue counts. +// Each tab embeds the corresponding model's kanban via an action service +// switch. The header counters refresh on tab switch and on a 60-second +// poll. + +import { Component, useState, onWillStart, onMounted, onWillUnmount } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { rpc } from "@web/core/network/rpc"; + +const TABS = [ + { id: "holds", label: "Holds", model: "fusion.plating.quality.hold", group: "state", domain: [["state", "in", ["on_hold", "under_review"]]] }, + { id: "checks", label: "Checks", model: "fusion.plating.quality.check", group: "state", domain: [] }, + { id: "ncrs", label: "NCRs", model: "fusion.plating.ncr", group: "stage_id", domain: [["state", "!=", "closed"]] }, + { id: "capas", label: "CAPAs", model: "fusion.plating.capa", group: "state", domain: [["state", "not in", ["closed", "effective"]]] }, + { id: "rmas", label: "RMAs", model: "fusion.plating.rma", group: "stage_id", domain: [["state", "not in", ["closed", "cancelled"]]] }, +]; + +export class FpQualityDashboard extends Component { + static template = "fusion_plating_quality.FpQualityDashboard"; + static props = ["*"]; + + setup() { + this.action = useService("action"); + this.state = useState({ + activeTab: "ncrs", + counts: TABS.reduce((acc, t) => ({ ...acc, [t.id]: { open: 0, overdue: 0 } }), {}), + }); + + onWillStart(async () => { + await this._refreshCounts(); + }); + onMounted(() => { + this._poll = setInterval(() => this._refreshCounts(), 60000); + }); + onWillUnmount(() => { + if (this._poll) clearInterval(this._poll); + }); + } + + async _refreshCounts() { + try { + const result = await rpc("/fp/quality/dashboard/counts"); + if (result && typeof result === "object") { + for (const tab of TABS) { + if (result[tab.id]) { + this.state.counts[tab.id] = result[tab.id]; + } + } + } + } catch (e) { + // Best-effort; leave counts at zero on RPC failure. + console.warn("FpQualityDashboard: count refresh failed", e); + } + } + + selectTab(id) { + this.state.activeTab = id; + } + + async openTab(tab) { + // Open the model's full kanban view in the main app area. + await this.action.doAction({ + type: "ir.actions.act_window", + name: tab.label, + res_model: tab.model, + view_mode: "kanban,list,form", + views: [[false, "kanban"], [false, "list"], [false, "form"]], + domain: tab.domain, + context: { group_by: tab.group }, + }); + } + + get tabs() { + return TABS; + } + + get totalOpen() { + return TABS.reduce( + (sum, t) => sum + (this.state.counts[t.id]?.open || 0), 0, + ); + } + + get totalOverdue() { + return TABS.reduce( + (sum, t) => sum + (this.state.counts[t.id]?.overdue || 0), 0, + ); + } +} + +registry.category("actions").add("fp_quality_dashboard", FpQualityDashboard); diff --git a/fusion_plating/fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss b/fusion_plating/fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss new file mode 100644 index 00000000..8c632671 --- /dev/null +++ b/fusion_plating/fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss @@ -0,0 +1,57 @@ +// Sub 12 Phase D — Unified Quality Dashboard styling. +// Reuses the shopfloor SCSS tokens ($fp-page, $fp-card, $fp-border, +// $fp-ink, $fp-accent, etc.) — they are bundled before us via the +// fusion_plating_shopfloor dep, so no @import is needed. + +.o_fp_quality_dashboard { + background-color: $fp-page; + min-height: 100%; + + .o_fp_card { + background-color: $fp-card; + border: 1px solid $fp-border; + border-radius: 8px; + } + + .o_fp_qd_summary { + min-width: 220px; + } + + .o_fp_qd_tile { + cursor: pointer; + min-width: 130px; + text-align: left; + transition: transform 0.08s ease-in-out, box-shadow 0.08s ease-in-out; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); + } + + &.o_fp_qd_active { + border: 2px solid $fp-accent; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + } + } + + .o_fp_qd_metric_label { + font-size: 0.85em; + color: $fp-ink-mute; + font-weight: 500; + } + + .o_fp_qd_metric_value { + font-size: 1.6em; + font-weight: 700; + color: $fp-ink; + line-height: 1.1; + } + + .o_fp_qd_metric_sub { + margin-top: 0.25em; + } + + .o_fp_qd_panel { + min-height: 200px; + } +} diff --git a/fusion_plating/fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml b/fusion_plating/fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml new file mode 100644 index 00000000..39b0f8af --- /dev/null +++ b/fusion_plating/fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml @@ -0,0 +1,65 @@ + + + + +
+
+
+

Quality Overview

+
+
+
Open across all 5
+
+
+
+
Overdue
+
+
+
+
+ + + +
+ +
+ +
+
+
+

+
+ open + + — overdue + +
+
+ +
+

+ Click "Open Kanban" to drill into the full + board with stage / state grouping, + drag-and-drop, and the standard filters. +

+
+
+
+
+
+ +
diff --git a/fusion_plating/fusion_plating_quality/views/fp_menu.xml b/fusion_plating/fusion_plating_quality/views/fp_menu.xml index c8ccfccb..b169ad41 100644 --- a/fusion_plating/fusion_plating_quality/views/fp_menu.xml +++ b/fusion_plating/fusion_plating_quality/views/fp_menu.xml @@ -31,6 +31,12 @@ action="action_fp_capa" sequence="20"/> + + + + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_quality/views/fp_quality_categorisation_views.xml b/fusion_plating/fusion_plating_quality/views/fp_quality_categorisation_views.xml new file mode 100644 index 00000000..5aff6901 --- /dev/null +++ b/fusion_plating/fusion_plating_quality/views/fp_quality_categorisation_views.xml @@ -0,0 +1,164 @@ + + + + + + + fp.quality.tag.list + fp.quality.tag + + + + + + + + + + + Quality Tags + fp.quality.tag + list + + + + + fp.quality.reason.list + fp.quality.reason + + + + + + + + + + + fp.quality.reason.form + fp.quality.reason + +
+ + + + + + + + +
+
+
+ + Quality Reasons + fp.quality.reason + list,form + + + + + fp.quality.team.list + fp.quality.team + + + + + + + + + + + + fp.quality.team.form + fp.quality.team + +
+ +
+
+ + + + + + + + + + + + + + + +
+ + +
+
+ + Quality Teams + fp.quality.team + list,form + + + + + fp.quality.alert.stage.list + fp.quality.alert.stage + + + + + + + + + + + + Quality Stages + fp.quality.alert.stage + list + +

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.

+
+
+ + + + + + + +
diff --git a/fusion_plating/fusion_plating_quality/views/fp_quality_dashboard_views.xml b/fusion_plating/fusion_plating_quality/views/fp_quality_dashboard_views.xml new file mode 100644 index 00000000..a977915f --- /dev/null +++ b/fusion_plating/fusion_plating_quality/views/fp_quality_dashboard_views.xml @@ -0,0 +1,23 @@ + + + + + + Quality Dashboard + fp_quality_dashboard + current + + + + + diff --git a/fusion_plating/fusion_plating_quality/views/fp_quality_point_views.xml b/fusion_plating/fusion_plating_quality/views/fp_quality_point_views.xml new file mode 100644 index 00000000..4bf44c6b --- /dev/null +++ b/fusion_plating/fusion_plating_quality/views/fp_quality_point_views.xml @@ -0,0 +1,132 @@ + + + + + + fp.quality.point.list + fp.quality.point + + + + + + + + + + + + + + + + fp.quality.point.form + fp.quality.point + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + fp.quality.point.search + fp.quality.point + + + + + + + + + + + + + + + + + + + Quality Points + fp.quality.point + list,form + + {'search_default_active_only': 1} + +

Define a Quality Point

+

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.

+
+
+ + + +
diff --git a/fusion_plating/fusion_plating_quality/views/fp_quality_smart_button_views.xml b/fusion_plating/fusion_plating_quality/views/fp_quality_smart_button_views.xml new file mode 100644 index 00000000..1576c00c --- /dev/null +++ b/fusion_plating/fusion_plating_quality/views/fp_quality_smart_button_views.xml @@ -0,0 +1,101 @@ + + + + + + + + + sale.order.form.quality.buttons + sale.order + + + + + + + + + + + + + + + res.partner.form.quality.button + res.partner + + + + + + + + + + + fp.ncr.form.spawn.capa + fusion.plating.ncr + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + fp.rma.kanban + fusion.plating.rma + + + + + + + + + + + + +
+
+ + + + +
+
+ +
+
+ +
+
+ Returned + +
+
+ NCRs / Holds + + / + + +
+
+
+
+
+
+
+ + + + fp.rma.search + fusion.plating.rma + + + + + + + + + + + + + + + + + + + + + + + + + + RMAs + fusion.plating.rma + kanban,list,form + + {'search_default_open': 1} + +

+ Open the first Return Material Authorisation +

+

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"/> +
+ +

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 + fusion.plating.ncr + qweb-pdf + fusion_plating_reports.report_fp_8d_doc + fusion_plating_reports.report_fp_8d_doc + + report + '8D-' + (object.name or '').replace('/', '-') + + + diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_bol.xml b/fusion_plating/fusion_plating_reports/report/report_fp_bol.xml index d492de76..eca29685 100644 --- a/fusion_plating/fusion_plating_reports/report/report_fp_bol.xml +++ b/fusion_plating/fusion_plating_reports/report/report_fp_bol.xml @@ -116,10 +116,12 @@ - - - + + + + @@ -134,22 +136,21 @@ - - + +
- 1 + 1 - - +
- + @@ -327,10 +328,11 @@
- - - + + + + @@ -346,22 +348,21 @@ - - + +
- 1 + 1 - - +
- + diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_job_traveller.xml b/fusion_plating/fusion_plating_reports/report/report_fp_job_traveller.xml index 9cfcb75d..dc8c81d5 100644 --- a/fusion_plating/fusion_plating_reports/report/report_fp_job_traveller.xml +++ b/fusion_plating/fusion_plating_reports/report/report_fp_job_traveller.xml @@ -508,8 +508,10 @@