Split 49 modules/suites into independent git repos; untrack from monorepo
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled

Each top-level module/suite folder is now its own private repo on GitHub
(gsinghpal/<name>) and gitea (admin/<name>), with a fresh single initial
commit. The monorepo no longer tracks them (added to .gitignore + git rm
--cached); working-tree files are retained on disk and managed in their
own repos. The monorepo keeps shared root files (CLAUDE.md, docs/, scripts/,
tools/, AGENTS.md, WIP/obsolete dirs) and full history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-07 01:54:34 -04:00
parent 2a7b315e98
commit a66cdefc01
6740 changed files with 51 additions and 1277207 deletions

View File

@@ -1,47 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 2 of the native plating job model migration. Models are added
# task-by-task in Tasks 2.2 onwards.
from . import fp_job_workflow_state # Sub 14 - must load before fp_job (FK target)
from . import fp_job
from . import fp_job_sticker
from . import fp_job_step
from . import fp_job_masking
from . import fp_job_node_override
from . import fp_portal_job
from . import account_move
from . import sale_order
from . import sale_order_line
from . import res_users
# Phase 3 - parallel job/step links on dependent modules' models.
from . import fp_batch
from . import fp_quality_hold
from . import fp_certificate
from . import fp_thickness_reading
from . import fp_delivery
from . import fp_racking_inspection
from . import fp_receiving
# Phase 4 - light refactors batch B (notifications, KPI source tag).
from . import fp_notification_trigger
from . import fusion_plating_kpi_value
# Phase 5 - Job Margin report.
from . import report_fp_job_margin
# Phase 1 of MRP cut-out (Sub 11) - relocated from fusion_plating_bridge_mrp.
# (fp.qc.checklist.template lives in fusion_plating_quality; can't depend
# back on jobs without a cycle.)
from . import fp_job_consumption
# Multi-rack splitting at Racking (Phase 1) - jobs-side extension of
# fp.rack.load (core model in fusion_plating) + fp.job rollups.
from . import fp_job_rack
# fp.work.role, fp.operator.proficiency, fp_process_node inherit, and the
# hr.employee shop-roles inherit live in fusion_plating core so every
# downstream module (cgp, bridge_mrp residue, etc.) sees them without a
# transitive dep on jobs.

View File

@@ -1,150 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""account.move overrides for Fusion Plating:
1. Block direct creation of out_invoice / out_refund for ALL users
including administrators. The only legal entry points are:
* sale.order._create_invoices() - sets context fp_from_so_invoice=True
* manual create() with invoice_origin matching an existing sale.order.name
2. Once a customer move is created via a legitimate path, derive its
name from the SO's parent number (IN-30000 / IN-30000-02 for
invoices, CN-30000 / CN-30000-02 for credit notes). Per the
2026-05-12 parent-number hierarchy design.
3. On post, link the invoice back to its fp.job's portal job (mark
complete, stamp invoice_ref). Pre-existing behaviour, preserved.
"""
import logging
from odoo import api, models
from odoo.exceptions import UserError
from odoo.tools.translate import _
_logger = logging.getLogger(__name__)
CUSTOMER_TYPES = ('out_invoice', 'out_refund', 'out_receipt')
class AccountMove(models.Model):
_inherit = ['account.move', 'fp.parent.numbered.mixin']
# =================================================================
# Parent-numbered mixin hooks
# =================================================================
def _fp_parent_sale_order(self):
"""Find linked SO via SO context flag (set by _create_invoices),
or fall back to invoice_origin name match, then to the reversed
entry's SO (for the Add Credit Note path where invoice_origin
has copy=False and doesn't survive the move.copy())."""
so_id = self.env.context.get('fp_invoice_source_so_id')
if so_id:
so = self.env['sale.order'].browse(so_id).exists()
if so:
return so
if self.invoice_origin:
so = self.env['sale.order'].search(
[('name', '=', self.invoice_origin)], limit=1,
)
if so:
return so
# Reversal path: read the parent move's SO link so the credit
# note's name flows from the same parent number as the invoice
# it's reversing.
if self.reversed_entry_id:
parent_so = self.reversed_entry_id._fp_parent_sale_order()
if parent_so:
return parent_so
return self.env['sale.order']
def _fp_name_prefix(self):
return 'CN' if self.move_type == 'out_refund' else 'IN'
def _fp_parent_counter_field(self):
return 'x_fc_pn_cn_count' if self.move_type == 'out_refund' else 'x_fc_pn_invoice_count'
# =================================================================
# Create override: block off-flow + assign parent-derived name
# =================================================================
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
self._fp_validate_customer_invoice(vals)
moves = super().create(vals_list)
for mv in moves:
if mv.move_type in CUSTOMER_TYPES:
mv._fp_assign_parent_name()
return moves
@api.model
def _fp_validate_customer_invoice(self, vals):
"""Refuse out_invoice / out_refund / out_receipt creation that
didn't come through the SO workflow. Applies to ALL users
including admins."""
mtype = vals.get('move_type', 'entry')
if mtype not in CUSTOMER_TYPES:
return
if self.env.context.get('fp_from_so_invoice'):
return
origin = (vals.get('invoice_origin') or '').strip()
if origin and self.env['sale.order'].sudo().search_count(
[('name', '=', origin)]
):
return
# Credit-note / reversal path: Odoo's "Add Credit Note" wizard
# calls move.copy() with reversed_entry_id set in the defaults,
# but invoice_origin has copy=False on the standard field so
# it doesn't survive the copy. Allow reversals through as long
# as the reversed entry is itself a customer-facing move (which
# means it already went through this validator at original
# creation time - the audit trail is intact).
reversed_id = vals.get('reversed_entry_id')
if reversed_id:
parent = self.env['account.move'].sudo().browse(reversed_id)
if parent.exists() and parent.move_type in CUSTOMER_TYPES:
return
raise UserError(_(
'Customer invoices, credit notes, and receipts must be '
'created from a Sale Order. Open the originating SO and '
'use the Create Invoice / Add Credit Note action.\n\n'
'This rule applies to all users including administrators. '
'It is enforced to keep the parent-number audit trail '
'intact (see fusion_plating numbering policy).'
))
# =================================================================
# Post hook: link the invoice to its fp.job's portal job
# =================================================================
def action_post(self):
result = super().action_post()
for invoice in self.filtered(
lambda m: m.move_type in CUSTOMER_TYPES
):
invoice._fp_link_to_job()
return result
def _fp_link_to_job(self):
self.ensure_one()
if not self.invoice_origin:
return
Job = self.env['fp.job'].sudo()
SO = self.env['sale.order'].sudo()
so = SO.search([('name', '=', self.invoice_origin)], limit=1)
if not so:
return
job = Job.search([('sale_order_id', '=', so.id)], limit=1)
if not job or not job.portal_job_id:
return
portal = job.portal_job_id
if 'invoice_ref' in portal._fields:
portal.invoice_ref = self.name
# Recompute state via the central helper - it'll only land on
# 'complete' if the WO is actually done AND the shipment is
# delivered. Posting an invoice early no longer skips the floor.
if hasattr(portal, '_fp_recompute_portal_state'):
portal._fp_recompute_portal_state()
_logger.info(
'Invoice %s linked to fp.job %s portal %s',
self.name, job.name, portal.name,
)

View File

@@ -1,26 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 - parallel job/step links on fusion.plating.batch.
# The legacy workorder_id link to mrp.workorder stays in place.
from odoo import fields, models
class FusionPlatingBatch(models.Model):
_inherit = 'fusion.plating.batch'
x_fc_step_id = fields.Many2one(
'fp.job.step',
string='Plating Step',
index=True,
help='Native fp.job.step link. Coexists with the legacy '
'workorder_id link to mrp.workorder.',
)
x_fc_job_id = fields.Many2one(
'fp.job',
related='x_fc_step_id.job_id',
store=True,
string='Work Order',
)

View File

@@ -1,282 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# 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 api, fields, models
class FpCertificate(models.Model):
_inherit = 'fp.certificate'
x_fc_job_id = fields.Many2one(
'fp.job',
string='Work Order',
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',
'x_fc_local_thickness_pdf')
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'
# Cert-local upload wins over QC-side PDF (matches the
# merge resolution order in fp_certificate.py).
if rec.x_fc_local_thickness_pdf:
if rec.state == 'issued' and rec.attachment_id:
status = 'merged'
else:
status = 'pending'
elif 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',
}
# ---- Parse-on-upload for the cert-form Fischerscope field (2026-05-28)
# The Issue Certs wizard parses .doc/.docx/RTF Fischerscope exports into
# readings + metadata + microscope image. Dropping the same file straight
# onto the cert form's x_fc_local_thickness_pdf field did nothing - it
# just stored the bytes. These hooks give the form the SAME behaviour as
# the wizard: on save, a non-PDF upload is parsed and relocated to the
# evidence field (a real PDF is left in place to merge as page 2).
@api.model_create_multi
def create(self, vals_list):
recs = super().create(vals_list)
for rec in recs:
if (rec.x_fc_local_thickness_pdf
and not self.env.context.get('fp_skip_thickness_parse')):
rec._fp_parse_local_thickness_upload()
return recs
def write(self, vals):
res = super().write(vals)
if (vals.get('x_fc_local_thickness_pdf')
and not self.env.context.get('fp_skip_thickness_parse')):
for rec in self:
rec._fp_parse_local_thickness_upload()
return res
def _fp_parse_local_thickness_upload(self):
"""Parse a Fischerscope .doc/.docx/RTF dropped on the cert form's
x_fc_local_thickness_pdf field, exactly like the Issue Certs wizard:
extract readings → thickness_reading_ids, header metadata →
x_fc_thickness_* fields, microscope image → x_fc_thickness_image_id,
then relocate the non-PDF source to x_fc_local_thickness_evidence_id
and clear the PDF field (so the page-2 merge doesn't choke on it).
A real PDF is left in place - it merges as page 2 of the CoC on
Issue and carries no parseable readings. Unknown non-PDF types are
left untouched.
"""
import base64
from datetime import datetime
self.ensure_one()
if not self.x_fc_local_thickness_pdf:
return
try:
raw = base64.b64decode(self.x_fc_local_thickness_pdf)
except Exception:
return
# Real PDF → leave it (merges as page 2). XDAL 600 names RTF files
# ".doc"; detect by magic bytes, not extension (see CLAUDE.md).
if raw[:4] == b'%PDF':
return
name = (self.x_fc_local_thickness_pdf_filename or '').lower()
is_rtf = raw[:5] == b'{\\rtf'
is_docx = name.endswith('.docx')
if not (is_rtf or is_docx):
return # unknown non-PDF - don't guess
from ..wizards.fp_cert_issue_wizard import (
_fp_parse_fischerscope_rtf, _fp_parse_fischerscope_docx,
_fp_extract_rtf_images, _fp_pick_microscope_image,
)
parsed = (_fp_parse_fischerscope_rtf(raw) if is_rtf
else _fp_parse_fischerscope_docx(raw))
vals = {}
for fname, fval in (
('x_fc_thickness_operator', parsed.get('operator')),
('x_fc_thickness_product', parsed.get('product')),
('x_fc_thickness_directory', parsed.get('directory')),
('x_fc_thickness_application', parsed.get('application')),
('x_fc_thickness_measuring_time_sec',
parsed.get('measuring_time_sec') or 0),
('x_fc_thickness_equipment',
parsed.get('equipment') or 'Fischerscope XDAL 600'),
('x_fc_thickness_source_filename',
self.x_fc_local_thickness_pdf_filename or ''),
):
if fname in self._fields and fval:
vals[fname] = fval
date_str = (parsed.get('date_str') or '').strip()
time_str = (parsed.get('time_str') or '').strip()
if date_str and 'x_fc_thickness_datetime' in self._fields:
combined = ('%s %s' % (date_str, time_str)).strip()
for fmt in (
'%m/%d/%Y %I:%M:%S %p', '%m/%d/%Y %I:%M %p',
'%m/%d/%Y %H:%M:%S', '%m/%d/%Y %H:%M', '%m/%d/%Y',
):
try:
vals['x_fc_thickness_datetime'] = datetime.strptime(
combined, fmt,
)
break
except ValueError:
continue
# Readings - replace any existing set with the freshly-parsed rows
# (the uploaded report is authoritative for this cert).
readings = parsed.get('readings') or []
Reading = self.env.get('fp.thickness.reading')
if readings and Reading is not None:
calibration = parsed.get('calibration') or ''
cmds = [(5, 0, 0)]
for i, (nip, ni, p) in enumerate(readings):
rvals = {'nip_mils': nip, 'ni_percent': ni, 'p_percent': p}
if 'reading_number' in Reading._fields:
rvals['reading_number'] = i + 1
if calibration and 'calibration_std_ref' in Reading._fields:
rvals['calibration_std_ref'] = calibration
cmds.append((0, 0, rvals))
vals['thickness_reading_ids'] = cmds
# Relocate the non-PDF source to the evidence slot + clear the PDF
# field (mirrors the wizard's non-PDF end state).
att = self.env['ir.attachment'].sudo().create({
'name': self.x_fc_local_thickness_pdf_filename or 'fischerscope-report',
'type': 'binary',
'datas': self.x_fc_local_thickness_pdf,
'res_model': self._name,
'res_id': self.id,
})
if 'x_fc_local_thickness_evidence_id' in self._fields:
vals['x_fc_local_thickness_evidence_id'] = att.id
vals['x_fc_local_thickness_pdf'] = False
vals['x_fc_local_thickness_pdf_filename'] = False
# Microscope image (RTF only - .docx images need a different path).
if is_rtf and 'x_fc_thickness_image_id' in self._fields:
try:
pngs = _fp_extract_rtf_images(raw)
img_bytes, img_w, img_h = _fp_pick_microscope_image(pngs)
if img_bytes:
img_att = self.env['ir.attachment'].sudo().create({
'name': '%s-microscope.png' % (
(self.x_fc_local_thickness_pdf_filename
or 'fischerscope').rsplit('.', 1)[0]
),
'type': 'binary',
'datas': base64.b64encode(img_bytes),
'mimetype': 'image/png',
'res_model': self._name,
'res_id': self.id,
})
vals['x_fc_thickness_image_id'] = img_att.id
except Exception:
pass
self.with_context(fp_skip_thickness_parse=True).write(vals)
from markupsafe import Markup
from odoo import _
self.message_post(body=Markup(_(
'Fischerscope file <b>%s</b> parsed from the cert form: '
'%d reading(s) extracted.'
)) % (
self.x_fc_thickness_source_filename or name or 'unnamed',
len(readings),
))

View File

@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 - parallel job link on fusion.plating.delivery.
# Coexists with the legacy job_ref Char.
from odoo import fields, models
class FusionPlatingDelivery(models.Model):
_inherit = 'fusion.plating.delivery'
x_fc_job_id = fields.Many2one(
'fp.job',
string='Work Order',
index=True,
help='Native fp.job link. Coexists with the legacy job_ref Char.',
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,104 +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.
#
# Phase 1 (Sub 11) - relocated from fusion_plating_bridge_mrp.
# MRP-flavoured fields (production_id, workorder_id) replaced by their
# native fp.job / fp.job.step equivalents.
from odoo import api, fields, models, _
class FpJobConsumption(models.Model):
"""A single consumable drawdown charged to a plating job.
Sources include bath replenishment applied against a job, masking tape
rolls, PPE, nickel salts - anything that has a cost and should roll
into job costing.
Kept deliberately lightweight: one row per event, cost derived from
`product.standard_price` at log time (snapshot, not reactive).
"""
_name = 'fp.job.consumption'
_description = 'Fusion Plating - Job Consumption'
_order = 'logged_date desc, id desc'
job_id = fields.Many2one(
'fp.job', string='Work Order',
required=True, ondelete='cascade', index=True,
)
step_id = fields.Many2one(
'fp.job.step', string='Job Step',
domain="[('job_id', '=', job_id)]",
ondelete='set null',
)
product_id = fields.Many2one(
'product.product', string='Product', required=True,
domain="[('sale_ok', '=', False)]",
)
product_name = fields.Char(
string='Product Name (snapshot)',
help='Free-text product label if no inventory product is linked.',
)
quantity = fields.Float(string='Quantity', required=True, digits=(12, 3))
uom_id = fields.Many2one(
'uom.uom', string='UoM',
)
currency_id = fields.Many2one(
'res.currency', required=True,
default=lambda self: self.env.company.currency_id,
)
unit_cost = fields.Monetary(
string='Unit Cost (snapshot)', currency_field='currency_id',
help='Taken from product.standard_price at log time.',
)
total_cost = fields.Monetary(
string='Total Cost', currency_field='currency_id',
compute='_compute_total_cost', store=True,
)
logged_date = fields.Datetime(
string='Logged', default=fields.Datetime.now,
)
logged_by_id = fields.Many2one(
'res.users', string='Logged By', default=lambda self: self.env.user,
)
source = fields.Selection(
[('replenishment', 'Bath Replenishment'),
('masking', 'Masking Material'),
('ppe', 'PPE / Consumables'),
('chemistry', 'Process Chemistry'),
('other', 'Other')],
string='Source', default='other', required=True,
)
replenishment_id = fields.Many2one(
'fusion.plating.bath.replenishment.suggestion',
string='Replenishment Suggestion',
ondelete='set null',
)
notes = fields.Char(string='Notes')
@api.depends('quantity', 'unit_cost')
def _compute_total_cost(self):
for rec in self:
rec.total_cost = round((rec.quantity or 0) * (rec.unit_cost or 0), 2)
@api.depends('product_id', 'product_name', 'quantity', 'job_id')
def _compute_display_name(self):
for rec in self:
label = rec.product_id.display_name or rec.product_name or 'Consumption'
qty = ('%g' % rec.quantity) if rec.quantity else ''
job = rec.job_id.name or ''
bits = [label]
if qty:
bits.append('×' + qty)
if job:
bits.append('(%s)' % job)
rec.display_name = ' '.join(bits)
@api.onchange('product_id')
def _onchange_product(self):
if self.product_id:
self.product_name = self.product_id.display_name
self.unit_cost = self.product_id.standard_price or 0.0
self.uom_id = self.product_id.uom_id or False

View File

@@ -1,36 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
"""Masking reference attachments - captured at Express order entry, surfaced
on the job's masking step (operator workstation) and rolled up to the job
form (office). Populated by sale.order.line._fp_apply_express_overrides_to_job.
"""
from odoo import api, fields, models
class FpJobStep(models.Model):
_inherit = 'fp.job.step'
x_fc_masking_attachment_ids = fields.Many2many(
'ir.attachment',
'fp_job_step_masking_att_rel', 'step_id', 'attachment_id',
string='Masking Reference(s)',
help='Reference image(s)/PDF(s) of what to mask, attached at order '
'entry (Express) and shown to the operator on the masking step.',
)
class FpJob(models.Model):
_inherit = 'fp.job'
x_fc_masking_attachment_ids = fields.Many2many(
'ir.attachment',
compute='_compute_masking_attachment_ids',
string='Masking References',
help='All masking reference files across this job\'s masking steps.',
)
@api.depends('step_ids.x_fc_masking_attachment_ids')
def _compute_masking_attachment_ids(self):
for job in self:
atts = job.step_ids.mapped('x_fc_masking_attachment_ids')
job.x_fc_masking_attachment_ids = [(6, 0, atts.ids)]

View File

@@ -1,49 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.job.node.override - per-job opt-in/out decisions for opt_in/opt_out
# recipe nodes. Mirrors fusion.plating.job.node.override from bridge_mrp,
# but bound to fp.job instead of mrp.production.
#
# bridge_mrp keeps its version alive so legacy MO-flow keeps working.
# Both coexist during the migration period.
from odoo import api, fields, models
class FpJobNodeOverride(models.Model):
_name = 'fp.job.node.override'
_description = 'Work Order Recipe Node Override'
_order = 'job_id, node_id'
job_id = fields.Many2one(
'fp.job',
required=True,
ondelete='cascade',
index=True,
)
node_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe Node',
required=True,
domain="[('opt_in_out', 'in', ('opt_in', 'opt_out'))]",
)
included = fields.Boolean(
string='Included',
default=True,
help='When True, this opt-in/out node is included in step generation.',
)
@api.depends('job_id', 'node_id', 'included')
def _compute_display_name(self):
for rec in self:
job = rec.job_id.display_name or '(no job)'
node = rec.node_id.display_name or '(no node)'
tag = 'included' if rec.included else 'excluded'
rec.display_name = '%s · %s [%s]' % (job, node, tag)
_unique_job_node = models.Constraint(
'unique(job_id, node_id)',
'A job can only have one override per recipe node.',
)

View File

@@ -1,166 +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.
#
# Multi-rack splitting at Racking - Phase 1 jobs-module extension.
# Core models live in fusion_plating/models/fp_rack_load.py. This file owns
# everything that touches jobs-module fields (fp.job.step.area_kind,
# fp.job.part_catalog_id) and the racking-step detection (_fp_is_racking_step).
# Spec/plan: docs/superpowers/{specs,plans}/2026-06-03-racking-multi-rack-*.md
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class FpRackLoad(models.Model):
_inherit = 'fp.rack.load'
current_area_kind = fields.Char(
string='Current Area', compute='_compute_current_area_kind', store=True)
@api.depends('current_step_id.area_kind')
def _compute_current_area_kind(self):
for load in self:
load.current_area_kind = load.current_step_id.area_kind or False
# ------------------------------------------------------------------
# Racking-step resolution + the "total parts available to rack"
# ------------------------------------------------------------------
@api.model
def _fp_racking_step_for(self, job):
# Detect the racking step by area_kind == 'racking' (the corrected
# classification), NOT _fp_is_racking_step() - the latter keys off the
# step's kind, and de-racking steps are frequently mis-tagged
# kind='racking' in the data, which would wrongly match De-Racking.
return job.step_ids.filtered(lambda s: s.area_kind == 'racking')[:1]
@api.model
def _fp_racking_total(self, job):
step = self._fp_racking_step_for(job)
if step and step.qty_at_step:
return int(step.qty_at_step)
return int(job.qty or 0)
@api.model
def _fp_job_loads(self, job):
"""Active (not unracked/cancelled) loads carrying this job's parts."""
return self.search([
('line_ids.job_id', '=', job.id),
('state', 'in', ('loading', 'loaded', 'running')),
], order='id')
# ------------------------------------------------------------------
# Division API (operator's split + manual override)
# ------------------------------------------------------------------
@api.model
def _fp_split_job(self, job, n):
"""(Re)create n loads for `job`, equal split of the racking total.
Drops existing unmoved 'loading' loads first. Moved/assigned loads are
left alone (can't re-split parts that already advanced)."""
total = self._fp_racking_total(job)
self._fp_job_loads(job).filtered(
lambda l: l.state == 'loading' and not l.current_step_id).unlink()
qtys = self._fp_equal_split(total, max(int(n), 1))
loads = self.browse()
for q in qtys:
loads |= self.create({
'line_ids': [(0, 0, {'job_id': job.id, 'qty': q})],
})
return loads
@api.model
def _fp_ensure_seeded(self, job):
"""Default state: one rack carrying all the parts."""
if not self._fp_job_loads(job):
self._fp_split_job(job, 1)
return self._fp_job_loads(job)
@api.model
def _fp_add_rack(self, job):
return self._fp_split_job(job, len(self._fp_job_loads(job)) + 1)
@api.model
def _fp_divide_equally(self, job):
return self._fp_split_job(job, max(len(self._fp_job_loads(job)), 1))
def _fp_set_qty(self, qty):
"""Manual override of a single load's quantity (must not exceed the
job's available parts across all its loads)."""
self.ensure_one()
line = self.line_ids[:1]
if not line:
raise UserError(_('This rack has no work order line.'))
job = line.job_id
total = self._fp_racking_total(job)
other = sum((self._fp_job_loads(job) - self).mapped('qty_total'))
if other + int(qty) > total:
raise UserError(
_('Assigned %(a)s exceeds the %(t)s parts available to rack.')
% {'a': other + int(qty), 't': total})
line.qty = int(qty)
def _fp_remove_rack(self):
self.ensure_one()
if self.current_step_id:
raise UserError(_('Cannot remove a rack that has already moved.'))
self.unlink()
# ------------------------------------------------------------------
# Independent movement + de-racking
# ------------------------------------------------------------------
def _fp_advance_to(self, to_step):
"""Move these rack-loads to `to_step`: one move row per line (per WO),
carrying the rack + line qty, then update position/state."""
Move = self.env['fp.job.step.move']
for load in self:
from_step = load.current_step_id
for line in load.line_ids:
Move.create({
'job_id': line.job_id.id,
'from_step_id': from_step.id if from_step else False,
'to_step_id': to_step.id,
'qty_moved': line.qty,
'rack_id': load.rack_id.id if load.rack_id else False,
'transfer_type': 'step',
'moved_by_user_id': self.env.user.id,
})
load.current_step_id = to_step
load.state = 'running'
def _fp_unrack(self):
"""De-Racking: free the physical rack; each line's parts continue in
its own job's flow (the per-line moves already attributed qty)."""
for load in self:
load.state = 'unracked'
if load.rack_id:
load.rack_id.racking_state = 'empty'
class FpRackLoadLine(models.Model):
_inherit = 'fp.rack.load.line'
part_catalog_id = fields.Many2one(
related='job_id.part_catalog_id', store=True, string='Part')
class FpJob(models.Model):
_inherit = 'fp.job'
rack_load_line_ids = fields.One2many(
'fp.rack.load.line', 'job_id', string='Rack Loads')
qty_racked = fields.Integer(
string='Parts Racked', compute='_compute_qty_racked')
qty_unracked = fields.Integer(
string='Parts Unassigned', compute='_compute_qty_racked')
@api.depends('rack_load_line_ids.qty', 'rack_load_line_ids.load_id.state')
def _compute_qty_racked(self):
Load = self.env['fp.rack.load']
for job in self:
active = job.rack_load_line_ids.filtered(
lambda l: l.load_id.state in ('loading', 'loaded', 'running'))
job.qty_racked = sum(active.mapped('qty'))
total = Load._fp_racking_total(job)
job.qty_unracked = max(total - job.qty_racked, 0)

File diff suppressed because it is too large Load Diff

View File

@@ -1,113 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
"""Display helpers for the redesigned job stickers (Internal = Layout A,
one per job; External = Layout B, one per box).
Keeps the QWeb templates thin: all field resolution, the customer
short-code (shop-floor "secrecy cover"), em-dash/smart-quote cleanup for
the entech wkhtmltopdf font, and the length-tiered notes font size live
here in Python.
"""
from odoo import models
def _clean(text):
"""Strip the glyphs entech's wkhtmltopdf font mojibakes."""
if not text:
return ''
t = str(text)
for a, b in ((u'\u2014', '-'), (u'\u2013', '-'), (u'', "'"),
(u'', "'"), (u'', '"'), (u'', '"'),
(u'', '...'),
# Degree symbols: the masculine-ordinal 'º' (U+00BA) operators
# type for "375ºF", the real degree '°' (U+00B0), and the ring
# '˚' ALL mojibake to "°"/"º" through this sticker's lightweight
# html_container path (no .article UTF-8 wrapper - and adding one
# blows up the dpi=96 mm layout). Strip to clean ASCII: "375F".
(u'º', ''), (u'°', ''), (u'˚', '')):
t = t.replace(a, b)
return t.strip()
class FpJob(models.Model):
_inherit = 'fp.job'
def _fp_sticker_shortcode(self, partner):
"""ABC Manufacturing Inc -> 'ABC-MANU'. First 3 of word[0] + first 4
of word[1] (alnum-only), uppercase. Single word -> first 3."""
name = (partner.name or '') if partner else ''
words = [''.join(c for c in w if c.isalnum()) for w in name.split()]
words = [w for w in words if w]
if len(words) >= 2:
return (words[0][:3] + '-' + words[1][:4]).upper()
if words:
return words[0][:3].upper()
return name or '-'
def _fp_note_pt(self, text):
"""Length-tiered notes font (pt) so long instructions stay on one
label. Mirrors the approved mockups."""
n = len(text or '')
if n <= 180:
return 11.0
if n <= 320:
return 10.0
if n <= 520:
return 9.0
return 8.5
def _fp_sticker_data(self):
"""Resolved display values for the job sticker (both variants)."""
self.ensure_one()
job = self
line = job.sale_order_line_ids[:1] if 'sale_order_line_ids' in job._fields \
else job.env['sale.order.line']
part = (('part_catalog_id' in job._fields and job.part_catalog_id)
or (line and 'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id)
or False)
so = job.sale_order_id
rev = ''
if part and getattr(part, 'revision', False):
rev = (part.revision or '').strip()
if rev.lower().startswith('rev '):
rev = rev[4:].strip()
due = job.date_deadline or (so and so.commitment_date) or False
due_s = due.strftime('%b %d %Y') if due else ''
thk = ''
if line and 'x_fc_thickness_range' in line._fields and line.x_fc_thickness_range:
thk = _clean(line.x_fc_thickness_range)
q = job.qty or 0
qty = int(q) if float(q).is_integer() else q
return {
'wo': job.name or '',
'part': ((part.part_number if part and getattr(part, 'part_number', False)
else (part.name if part else '')) or ''),
'rev': rev,
'customer': self._fp_sticker_shortcode(job.partner_id),
'customer_full': job.partner_id.name or '',
'po': (so and getattr(so, 'x_fc_po_number', False)) or '',
'qty': qty,
'due': due_s,
'thk': thk,
# Real thickness present (has a digit) - drives the prominent
# THICKNESS banner; skips empty / 'N/A' / '-' placeholders.
'has_thk': bool(thk and any(c.isdigit() for c in thk)),
'mask': bool(line and 'x_fc_masking_enabled' in line._fields and line.x_fc_masking_enabled),
'bake': _clean(line.x_fc_bake_instructions) if (line and 'x_fc_bake_instructions' in line._fields) else '',
'internal_notes': _clean(line.x_fc_internal_description) if (line and 'x_fc_internal_description' in line._fields) else '',
'customer_notes': _clean(line.name) if line else '',
}
def _fp_sticker_boxes(self):
"""The job's tracked boxes (External sticker prints one label each).
Empty recordset when none yet - the template falls back to 1/1."""
self.ensure_one()
if self.sale_order_id and 'fp.box' in self.env:
return self.env['fp.box'].sudo().search(
[('sale_order_id', '=', self.sale_order_id.id)], order='box_number')
return self.env['fp.box'] if 'fp.box' in self.env else self.browse()

View File

@@ -1,315 +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.
"""Sub 14 - Configurable workflow status bar.
Each job carries a workflow_state_id that auto-advances along a
shop-configurable sequence of milestones (Draft → Confirmed → Received
→ In Progress → Inspected → Shipped → Done - by default).
Recipe authors tag specific recipe steps as "this step's completion
triggers workflow state X" via process_node.triggers_workflow_state_id.
The default mapping is by step.default_kind (so out-of-the-box recipes
just work), with per-recipe override on each operation node.
Why this lives in fusion_plating_jobs (not core):
* It depends on fp.job.step which is implemented here
* Recipe-side trigger fields are added via _inherit on
fusion.plating.process.node (also here, in fp_job.py)
The catalog seed lives in data/fp_workflow_state_data.xml and ships
the 7 default milestones. Settings UI lets shops add more.
"""
from odoo import _, api, fields, models
class FpJobWorkflowState(models.Model):
_name = 'fp.job.workflow.state'
_description = 'Fusion Plating - Job Workflow State (status bar milestone)'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'sequence, id'
_rec_name = 'name'
name = fields.Char(
string='State Name',
required=True,
translate=True,
tracking=True,
help='Operator-facing label shown in the job status bar '
'(e.g. "Received", "Inspected", "Shipped").',
)
code = fields.Char(
string='Code',
required=True,
tracking=True,
help='Stable identifier - used by code/migrations to reference '
'this state without depending on the (translatable) name. '
'Lowercase snake_case (e.g. "received", "inspected").',
)
sequence = fields.Integer(
string='Sequence',
default=10,
required=True,
tracking=True,
help='Position of this state on the bar (left to right). '
'10-spacing convention so authors can insert new states '
'between existing ones without renumbering.',
)
color = fields.Selection(
[
('grey', 'Grey'),
('blue', 'Blue'),
('cyan', 'Cyan'),
('yellow', 'Yellow'),
('orange', 'Orange'),
('green', 'Green'),
('success', 'Success Green'),
('danger', 'Danger Red'),
('purple', 'Purple'),
],
string='Color',
default='grey',
help='Status pill colour on the bar.',
)
is_initial = fields.Boolean(
string='Initial State',
default=False,
help='Marks this as the starting state for new jobs. Only one '
'state should be marked initial.',
)
is_terminal = fields.Boolean(
string='Terminal State',
default=False,
help='Marks this as the final state. The bar stops advancing '
'once a job reaches it. Only one state should be marked '
'terminal.',
)
active = fields.Boolean(default=True)
description = fields.Text(
string='Description',
help='Internal notes on what this milestone represents and '
'when it should fire. Not shown to operators.',
)
# ---- Trigger conditions --------------------------------------------------
#
# A state is "passed" when ALL recipe steps matching its trigger
# conditions are in done/skipped/cancelled. Two ways to define
# which steps trigger:
# 1. trigger_default_kinds - match on recipe step's default_kind
# Selection. Easiest path - covers standard recipes that use
# the curated kind values (receiving, final_inspect, ship, etc.)
# 2. Per-recipe-node override via
# fusion.plating.process.node.triggers_workflow_state_id
# (defined in fp_job.py). Wins over default_kind matching.
trigger_default_kinds = fields.Char(
string='Trigger Default Kinds',
help='Comma-separated list of step.default_kind values. When the '
'last recipe step matching any of these kinds is finished, '
'the state passes. Example: "receiving,inspect" for a '
'"Received" state. Leave blank if you only want to use '
'per-recipe-node overrides.',
)
trigger_first_step_started = fields.Boolean(
string='Trigger on First Step Started',
default=False,
help='Special trigger - passes as soon as the first wet step '
'(or any step with kind not in inspection/admin) starts. '
'Used for the "In Progress" milestone.',
)
trigger_all_steps_done = fields.Boolean(
string='Trigger on All Steps Done',
default=False,
help='Special trigger - passes when every non-cancelled step '
'is in done/skipped state. Used for the "Done" milestone.',
)
trigger_on_delivery_state = fields.Boolean(
string='Trigger on Delivery Delivered',
default=False,
help='Special trigger - passes once the fusion.plating.delivery '
'linked to the job (job.delivery_id) reaches state="delivered". '
'Used for the Shipped milestone in lieu of recipe-side '
'default_kind="ship" tagging. Shipping is logistics, not '
'manufacturing - keeping the trigger off the recipe lets us '
'route deliveries (split shipments, RMA reverse-flow, '
'customer pickup) independently from plating steps.',
)
trigger_on_job_state = fields.Selection(
[
('confirmed', 'Job Confirmed'),
('in_progress', 'Job In Progress'),
('done', 'Job Done'),
],
string='Trigger on Job State',
help='State-driven trigger: this milestone passes when '
'fp.job.state reaches AT LEAST the chosen value. Fallback '
'for jobs whose recipes do not tag steps with default_kind '
'(so default_kind-driven triggers cannot fire). Order: '
'draft < confirmed < in_progress/on_hold < done. '
'Use for Confirmed ("confirmed") and optionally as a '
'safety-net for Done ("done").',
)
trigger_on_parts_received = fields.Boolean(
string='Trigger on Parts Received',
default=False,
help='Special trigger - passes once the job\'s sale order has '
'a receiving status of "partial" or "received" (set by '
'the fp.receiving inbound logistics flow). Used by the '
'Received milestone in lieu of recipe-side '
'default_kind="receiving" tagging. Receiving is a pre-'
'recipe activity (parts physically arrive on the dock) - '
'keeping the trigger off recipe steps means shops without '
'a "Receiving" step in their recipe still see the bar '
'advance correctly.',
)
block_when_quality_hold = fields.Boolean(
string='Blocked by Quality Hold',
default=False,
help='If True, this state will NOT pass while there is an open '
'quality hold on the job. Used for the "Inspected" '
'milestone - you can finish the inspection step but the '
'state stays at the previous milestone until the NCR clears.',
)
_sql_constraints = [
('fp_workflow_state_code_uniq', 'unique(code)',
'Workflow state code must be unique.'),
]
# NOTE: no display_name override on purpose. Earlier draft computed
# "Name [code]" so admin pages could disambiguate at a glance, but
# that string bled into the operator status bar (every pill rendered
# as "Received [received]"). The admin list view shows code as its
# own column, so we don't need it baked into display_name.
# ---- Trigger evaluation --------------------------------------------------
def _fp_kinds_set(self):
"""Parse trigger_default_kinds into a set of kind strings."""
self.ensure_one()
if not self.trigger_default_kinds:
return set()
return {
k.strip() for k in self.trigger_default_kinds.split(',')
if k.strip()
}
def _fp_is_passed_for_job(self, job):
"""Return True if this state's trigger conditions are met by
the given fp.job. Called from the job's compute method.
"""
self.ensure_one()
# Initial state - always passed (every job starts here)
if self.is_initial:
return True
Step = self.env['fp.job.step']
steps = job.step_ids
# Special trigger: all steps done
if self.trigger_all_steps_done:
non_cancelled = steps.filtered(lambda s: s.state != 'cancelled')
if not non_cancelled:
return False
return all(s.state in ('done', 'skipped') for s in non_cancelled)
# Special trigger: linked delivery has been marked delivered
if self.trigger_on_delivery_state:
return bool(
job.delivery_id and job.delivery_id.state == 'delivered'
)
# Special trigger: parts physically received (pre-recipe).
if self.trigger_on_parts_received:
so = job.sale_order_id
if (so and 'x_fc_receiving_status' in so._fields
and so.x_fc_receiving_status
in ('partial', 'received')):
return True
return False
# Special trigger: job.state reached ("at least" semantics).
if self.trigger_on_job_state:
order = {
'draft': 0,
'confirmed': 1,
'in_progress': 2,
'on_hold': 2,
'done': 3,
}
required = order.get(self.trigger_on_job_state, -1)
actual = order.get(job.state, -1)
return required >= 0 and actual >= required
# Special trigger: first wet step started.
# For tagged recipes (any step has kind in wet/bake/mask/rack),
# use strict kind-based check. For untagged recipes (all steps
# are kind='other'), fall back to "any step started" so the
# milestone fires regardless of recipe authoring quality.
if self.trigger_first_step_started:
wet_kinds = ('wet', 'bake', 'mask', 'rack')
has_kind_tagging = any(s.kind in wet_kinds for s in steps)
if has_kind_tagging:
production_started = any(
s.state in ('in_progress', 'paused', 'done')
and (s.kind in wet_kinds)
for s in steps
)
else:
# Untagged recipe - any started step counts as
# "production has started".
production_started = any(
s.state in ('in_progress', 'paused', 'done')
for s in steps
)
return production_started
# Standard trigger: ALL recipe steps matching the trigger
# (default_kind in our list OR per-node override pointing at
# us) must be in a terminal state.
kinds = self._fp_kinds_set()
matching_steps = steps.filtered(
lambda s: (
# Per-node override wins
(s.recipe_node_id
and s.recipe_node_id.triggers_workflow_state_id
and s.recipe_node_id.triggers_workflow_state_id.id == self.id)
or
# Default-kind match
(kinds and s.recipe_node_id
and s.recipe_node_id.default_kind in kinds)
)
)
if not matching_steps:
# Nothing matches - this state can't pass for this recipe.
# Treat as not-passed so the bar stays at the previous state.
return False
# Every matching step must be terminal
if not all(
s.state in ('done', 'skipped', 'cancelled')
for s in matching_steps
):
return False
# Quality-hold gate (optional)
if self.block_when_quality_hold:
QH = self.env.get('fusion.plating.quality.hold')
if QH is not None:
open_holds = QH.search_count([
('x_fc_job_id', '=', job.id),
('state', 'not in', ('closed', 'cancelled')),
])
if open_holds:
return False
return True

View File

@@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Adds 'job_confirmed' and 'job_complete' trigger events to the
# fp.notification.template selection. Fired from fp.job lifecycle
# hooks (action_confirm, button_mark_done).
#
# bridge_mrp's existing 'mo_confirmed' / 'mo_complete' triggers
# stay alive for the legacy MO flow.
from odoo import fields, models
class FpNotificationTemplate(models.Model):
_inherit = 'fp.notification.template'
trigger_event = fields.Selection(
selection_add=[
('job_confirmed', 'Plating Job Confirmed'),
('job_complete', 'Plating Job Complete'),
],
ondelete={
'job_confirmed': 'cascade',
'job_complete': 'cascade',
},
)

View File

@@ -1,21 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Add a back-reference from fusion.plating.portal.job to the native
# fp.job. Coexists with any future x_fc_production_id (legacy
# mrp.production link) added by bridge_mrp.
from odoo import fields, models
class FusionPlatingPortalJob(models.Model):
_inherit = 'fusion.plating.portal.job'
x_fc_job_id = fields.Many2one(
'fp.job',
string='Work Order',
index=True,
help='Native fp.job link. Coexists with x_fc_production_id (legacy '
'mrp.production link).',
)

View File

@@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 - parallel job/step links on fusion.plating.quality.hold.
# Coexists with bridge_mrp's existing production_id link.
from odoo import fields, models
class FusionPlatingQualityHold(models.Model):
_inherit = 'fusion.plating.quality.hold'
x_fc_job_id = fields.Many2one(
'fp.job',
string='Work Order',
index=True,
help="Native fp.job link. Coexists with bridge_mrp's production_id "
"link.",
)
x_fc_step_id = fields.Many2one(
'fp.job.step',
string='Plating Step',
index=True,
)

View File

@@ -1,56 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 / Phase 9 - native-job link on fp.racking.inspection.
# Coexists with the legacy production_id (mrp.production) link; either
# (or both) may be set.
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class FpRackingInspection(models.Model):
_inherit = 'fp.racking.inspection'
# x_fc_job_id is declared in the base receiving module so its views
# can reference it. We add help/depends here.
@api.depends('x_fc_job_id.name', 'partner_id.name')
def _compute_name(self):
for rec in self:
if rec.x_fc_job_id:
rec.name = _('Inspection - %s') % rec.x_fc_job_id.name
else:
rec.name = _('Racking Inspection')
@api.depends('x_fc_job_id.sale_order_id')
def _compute_sale_order(self):
for rec in self:
so = (rec.x_fc_job_id.sale_order_id
if rec.x_fc_job_id and rec.x_fc_job_id.sale_order_id
else False)
rec.sale_order_id = so or False
rec.partner_id = so.partner_id if so else False
@api.constrains('x_fc_job_id')
def _check_link_present(self):
for rec in self:
if not rec.x_fc_job_id:
raise ValidationError(_(
'Racking inspection must reference a plating job.'
))
@api.constrains('x_fc_job_id')
def _check_job_unique(self):
for rec in self:
if not rec.x_fc_job_id:
continue
dup = self.search_count([
('x_fc_job_id', '=', rec.x_fc_job_id.id),
('id', '!=', rec.id),
])
if dup:
raise ValidationError(_(
'Only one racking inspection per plating job.'
))

View File

@@ -1,99 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Adds the Work Order smart button + header action to fp.receiving so
# the receiving form mirrors the SO's WO entry point. Button appears
# once the receiving is closed and stays until every linked fp.job
# reaches state='done'.
from odoo import _, fields, models
from odoo.exceptions import UserError
class FpReceiving(models.Model):
_inherit = 'fp.receiving'
x_fc_fp_job_count = fields.Integer(
string='Work Orders',
compute='_compute_fp_job_count',
)
x_fc_show_work_order_btn = fields.Boolean(
string='Show Work Order Button',
compute='_compute_show_work_order_btn',
help='True once this receiving is closed and at least one linked '
'work order is still open (state != done). Hidden again '
'when every job is done.',
)
def _compute_fp_job_count(self):
Job = self.env['fp.job'].sudo()
for rec in self:
if rec.sale_order_id:
rec.x_fc_fp_job_count = Job.search_count(
[('sale_order_id', '=', rec.sale_order_id.id)]
)
else:
rec.x_fc_fp_job_count = 0
def _compute_show_work_order_btn(self):
Job = self.env['fp.job'].sudo()
for rec in self:
if rec.state != 'closed' or not rec.sale_order_id:
rec.x_fc_show_work_order_btn = False
continue
jobs = Job.search([('sale_order_id', '=', rec.sale_order_id.id)])
rec.x_fc_show_work_order_btn = bool(jobs) and any(
j.state != 'done' for j in jobs
)
def action_view_fp_jobs(self):
"""Open the work order(s) linked to this receiving's sale order."""
self.ensure_one()
if not self.sale_order_id:
return False
jobs = self.env['fp.job'].search([
('sale_order_id', '=', self.sale_order_id.id),
])
action = {
'type': 'ir.actions.act_window',
'name': _('Work Orders'),
'res_model': 'fp.job',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.sale_order_id.id)],
'context': {'default_sale_order_id': self.sale_order_id.id},
}
if len(jobs) == 1:
action.update({'view_mode': 'form', 'res_id': jobs.id})
return action
# ---- Sticker printing from the Receiving screen (2026-06-04) ----------
# Both stickers loop the SO's boxes (one label per box). Pass a SINGLE
# work order: the box loop is sale-order-scoped, so feeding every job
# would reprint each box label once per job. One job → exactly one label
# per box. Falls back to a single 1/1 label when no boxes exist yet.
def _fp_sticker_jobs(self):
self.ensure_one()
if not self.sale_order_id:
return self.env['fp.job']
return self.env['fp.job'].sudo().search(
[('sale_order_id', '=', self.sale_order_id.id)], order='id', limit=1)
def _fp_print_sticker(self, xmlid):
self.ensure_one()
jobs = self._fp_sticker_jobs()
if not jobs:
raise UserError(_(
'No work order exists for this receiving yet - create the '
'Work Order before printing stickers.'))
return self.env.ref(xmlid).report_action(jobs)
def action_print_external_sticker(self):
"""Customer (external) box sticker(s) for this receiving's WO."""
return self._fp_print_sticker(
'fusion_plating_jobs.action_report_fp_job_sticker')
def action_print_internal_sticker(self):
"""Shop (internal) box sticker(s) - same layout, internal notes."""
return self._fp_print_sticker(
'fusion_plating_jobs.action_report_fp_job_sticker_internal')

View File

@@ -1,22 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 - parallel job/step links on fp.thickness.reading.
from odoo import fields, models
class FpThicknessReading(models.Model):
_inherit = 'fp.thickness.reading'
x_fc_job_id = fields.Many2one(
'fp.job',
string='Work Order',
index=True,
)
x_fc_step_id = fields.Many2one(
'fp.job.step',
string='Plating Step',
index=True,
)

View File

@@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Tags KPI values by source: 'mrp' (legacy bridge_mrp rollups) vs
# 'jobs' (native fp.job rollups). Lets Phase 9 / Phase 10 dashboards
# show both side-by-side or filter to one.
from odoo import fields, models
class FusionPlatingKpiValue(models.Model):
_inherit = 'fusion.plating.kpi.value'
x_fc_source = fields.Selection(
[
('mrp', 'MRP (legacy)'),
('jobs', 'Native Jobs'),
],
string='Data Source',
default='mrp',
index=True,
help='Which data path produced this KPI value. Phase 9+ '
'rollups from fp.job/fp.job.step set this to jobs.',
)

View File

@@ -1,60 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Native fp.job margin report - replaces report_wo_margin which binds
# to mrp.production. Uses fp.job.step.cost_total (already computed in
# Phase 1: duration_actual / 60 * cost_per_hour).
from odoo import api, models
class ReportFpJobMargin(models.AbstractModel):
# Odoo looks up the report's data model via report.<report_name>.
# The action's report_name is `fusion_plating_jobs.report_fp_job_margin_template`,
# so this MUST be `report.fusion_plating_jobs.report_fp_job_margin_template`.
# Pre-2026-05-12 the model name was missing the `_template` suffix,
# which silently caused _get_report_values to never fire and the
# template rendered with no `rows` -> blank PDF. The t-field error
# was masking this because it crashed earlier; once t-field was
# swapped to t-esc the blank-render surfaced.
_name = 'report.fusion_plating_jobs.report_fp_job_margin_template'
_description = 'Work Order Margin Report'
@api.model
def _get_report_values(self, docids, data=None):
Job = self.env['fp.job']
jobs = Job.browse(docids)
rows = []
for job in jobs:
step_rows = []
total_labour = 0.0
total_minutes = 0.0
for step in job.step_ids.sorted('sequence'):
step_rows.append({
'sequence': step.sequence,
'name': step.name,
'work_centre': step.work_centre_id.name if step.work_centre_id else '-',
'duration_expected': step.duration_expected,
'duration_actual': step.duration_actual,
'rate': step.cost_per_hour,
'cost': step.cost_total,
})
total_labour += step.cost_total
total_minutes += step.duration_actual
rows.append({
'job': job,
'steps': step_rows,
'total_minutes': total_minutes,
'total_labour': total_labour,
'quoted_revenue': job.quoted_revenue,
'actual_cost': job.actual_cost,
'margin': job.margin,
'margin_pct': job.margin_pct,
})
return {
'doc_ids': docids,
'doc_model': 'fp.job',
'docs': jobs,
'rows': rows,
}

View File

@@ -1,49 +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 api, fields, models
class ResUsers(models.Model):
_inherit = 'res.users'
x_fc_initials = fields.Char(
string='Plating Initials',
help='Operator / inspector initials used to pre-fill signature '
'and "Reviewer Initials" style prompts in the Record Inputs '
'dialog. Editable in the dialog itself - when the user types '
'a different value and saves, it persists here for every '
'future job and step.',
)
x_fc_signature_image = fields.Binary(
string='Plating Signature',
attachment=True,
help='Drawn or uploaded signature image. Used in WO detail and '
'certificate reports for any signature-type prompt this user '
'signed off on; falls back to typed initials when blank. '
'Capture it once in user preferences; it stamps every '
'future sign-off automatically.',
)
@api.model
def _fp_default_initials(self):
"""Best-effort initials derived from the user's display name.
Used as a fallback when ``x_fc_initials`` is empty so the
operator still gets a sensible pre-fill on their first run.
E.g. "John Doe" -> "JD", "Mary Anne Smith" -> "MAS".
"""
name = (self.name or '').strip()
if not name:
return ''
return ''.join(
piece[0] for piece in name.split() if piece
).upper()[:6]
def fp_get_initials(self):
"""Resolve the user's initials for the dialog: stored override
first, fall back to the auto-derived value from their name."""
self.ensure_one()
return self.x_fc_initials or self._fp_default_initials()

View File

@@ -1,692 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# sale.order.action_confirm hook - creates fp.job records on confirm.
# Sub 11 (2026-04-26) removed MRP entirely; fp.job is the only fulfilment
# path. The former x_fc_use_native_jobs migration toggle was dropped in
# 19.0.8.19.0 once the legacy bridge_mrp fallback became unreachable.
import logging
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
x_fc_fp_job_count = fields.Integer(
string='Work Orders',
compute='_compute_fp_job_count',
)
x_fc_show_work_order_btn = fields.Boolean(
string='Show Work Order Header Button',
compute='_compute_show_work_order_btn',
help='True once any receiving record on this SO has closed and '
'at least one work order is still open (state != done). '
'Hidden again when every WO is done.',
)
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.',
)
# ------------------------------------------------------------------
# Parent-number hierarchy (2026-05-12 design)
# See docs/superpowers/specs/2026-05-12-parent-number-hierarchy-design.md
# ------------------------------------------------------------------
x_fc_parent_number = fields.Integer(
string='Parent Number',
readonly=True,
copy=False,
index=True,
help='Set on confirm. Drives every linked document\'s name '
'(WO-NNN, IN-NNN, CoC-NNN, ...). Immutable post-assignment.',
)
x_fc_quote_ref = fields.Char(
string='Originally Quoted As',
readonly=True,
copy=False,
help='The quote-stage name (e.g. Q202605-200). Preserved when '
'the SO is renamed on confirm.',
)
# Per-model counters - monotonic, never decrement. Source of truth
# for the next sibling's x_fc_doc_index. Updated via row-locked SQL
# in fp.parent.numbered.mixin so concurrent creates can't drift.
#
# Naming: `x_fc_pn_*_count` - the `pn_` infix distinguishes our
# storage counters from pre-existing compute fields (e.g. the
# `x_fc_delivery_count` compute in bridge_mrp, `x_fc_ncr_count`
# in configurator, `x_fc_receiving_count` in fp_receiving) which
# are surface counters for smart buttons. Distinct names avoid
# the silent compute-override that made Tasks 3+9 fail until 9.5.
x_fc_pn_wo_count = fields.Integer(string='Parent: WO Count', readonly=True, copy=False, default=0)
x_fc_pn_invoice_count = fields.Integer(string='Parent: Invoice Count', readonly=True, copy=False, default=0)
x_fc_pn_cn_count = fields.Integer(string='Parent: Credit Note Count', readonly=True, copy=False, default=0)
x_fc_pn_cert_count = fields.Integer(string='Parent: Certificate Count', readonly=True, copy=False, default=0)
x_fc_pn_delivery_count = fields.Integer(string='Parent: Delivery Count', readonly=True, copy=False, default=0)
x_fc_pn_receiving_count = fields.Integer(string='Parent: Receiving Count', readonly=True, copy=False, default=0)
x_fc_pn_pickup_count = fields.Integer(string='Parent: Pickup Count', readonly=True, copy=False, default=0)
x_fc_pn_ncr_count = fields.Integer(string='Parent: NCR Count', readonly=True, copy=False, default=0)
x_fc_pn_capa_count = fields.Integer(string='Parent: CAPA Count', readonly=True, copy=False, default=0)
x_fc_pn_hold_count = fields.Integer(string='Parent: Hold Count', readonly=True, copy=False, default=0)
x_fc_pn_rma_count = fields.Integer(string='Parent: RMA Count', readonly=True, copy=False, default=0)
# ------------------------------------------------------------------
# Phase 4 (Sub 11) - workflow-stage field + assigned-manager field
# relocated from fusion_plating_bridge_mrp. Field re-declared with
# the same selection + compute pointer; jobs is now the source of
# truth so Phase 5 can delete bridge_mrp without losing the field.
# ------------------------------------------------------------------
x_fc_workflow_stage = fields.Selection(
[
('draft', 'Quote'),
('awaiting_parts', 'Parts'),
('inspecting', 'Inspecting'),
('accept_parts', 'Accept'),
('assign_work', 'Assign'),
('in_production', 'Production'),
('ready_to_ship', 'Ready'),
('shipped', 'Shipped'),
('invoicing', 'Invoicing'),
('paid', 'Paid'),
('complete', 'Done'),
('cancelled', 'Cancelled'),
],
compute='_compute_workflow_stage',
string='Workflow Stage',
help='Current position in the SO → Ship → Invoice workflow. '
'Drives which next-step button is shown on the SO header.',
)
x_fc_assigned_manager_id = fields.Many2one(
'res.users', string='Assigned Manager',
help='The manager responsible for this job. Set when the job '
'is confirmed (falls back to the salesperson).',
tracking=True,
)
def _compute_fp_job_count(self):
Job = self.env['fp.job'].sudo()
for so in self:
so.x_fc_fp_job_count = Job.search_count(
[('sale_order_id', '=', so.id)]
)
def _compute_show_work_order_btn(self):
Job = self.env['fp.job'].sudo()
Recv = self.env.get('fp.receiving')
for so in self:
if Recv is None:
so.x_fc_show_work_order_btn = False
continue
has_closed_recv = bool(Recv.sudo().search_count([
('sale_order_id', '=', so.id),
('state', '=', 'closed'),
]))
if not has_closed_recv:
so.x_fc_show_work_order_btn = False
continue
jobs = Job.search([('sale_order_id', '=', so.id)])
so.x_fc_show_work_order_btn = bool(jobs) and any(
j.state != 'done' for j in jobs
)
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):
"""Walk fp.job state to derive the SO workflow banner."""
Job = self.env['fp.job']
Delivery = self.env.get('fusion.plating.delivery')
for so in self:
if so.state == 'cancel':
so.x_fc_workflow_stage = 'cancelled'
continue
if so.state in ('draft', 'sent'):
so.x_fc_workflow_stage = 'draft'
continue
jobs = Job.search([('sale_order_id', '=', so.id)])
all_jobs_done = bool(jobs) and all(
j.state == 'done' for j in jobs
)
shipped = False
if Delivery is not None and jobs:
if 'x_fc_job_id' in Delivery._fields:
shipped = bool(Delivery.search_count([
('x_fc_job_id', 'in', jobs.ids),
('state', '=', 'delivered'),
]))
posted_invoices = so.invoice_ids.filtered(
lambda i: i.state == 'posted'
)
has_posted_invoice = bool(posted_invoices)
all_paid = has_posted_invoice and all(
i.payment_state in ('paid', 'in_payment')
for i in posted_invoices
)
if shipped and all_paid:
so.x_fc_workflow_stage = 'complete'
continue
if all_paid and not shipped:
so.x_fc_workflow_stage = 'paid'
continue
if shipped and has_posted_invoice:
so.x_fc_workflow_stage = 'invoicing'
continue
if shipped:
so.x_fc_workflow_stage = 'shipped'
continue
if all_jobs_done:
so.x_fc_workflow_stage = 'ready_to_ship'
continue
recv_status = so.x_fc_receiving_status or 'not_received'
if recv_status == 'not_received':
so.x_fc_workflow_stage = 'awaiting_parts'
continue
if recv_status == 'partial':
so.x_fc_workflow_stage = 'awaiting_parts'
continue
if recv_status == 'received':
# Sub 8: 'received' is the terminal receiving state (no
# more separate 'inspected'). Parts are on the floor;
# inspection happens inside the recipe's racking step.
if not so.x_fc_assigned_manager_id and not jobs:
so.x_fc_workflow_stage = 'assign_work'
continue
so.x_fc_workflow_stage = 'in_production'
continue
so.x_fc_workflow_stage = (
'in_production' if jobs else 'awaiting_parts'
)
def action_view_fp_jobs(self):
self.ensure_one()
jobs = self.env['fp.job'].search([('sale_order_id', '=', self.id)])
action = {
'type': 'ir.actions.act_window',
'name': _('Plating Jobs'),
'res_model': 'fp.job',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
if len(jobs) == 1:
action.update({
'view_mode': 'form',
'res_id': jobs.id,
})
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
# ------------------------------------------------------------------
# Parent-number hierarchy - quote naming on create
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
"""Draw Q-YYYYMM-N from fp.quote.number when no explicit name.
The drawn name is also stashed in x_fc_quote_ref so it survives
the confirm-time rename to SO-<parent_number>. If the caller
passed an explicit name we preserve that AND mirror it into
x_fc_quote_ref (covers data migration, restore, etc.).
"""
Seq = self.env['ir.sequence']
for vals in vals_list:
existing = vals.get('name')
if not existing or existing == _('New') or existing == 'New':
quote_name = Seq.next_by_code('fp.quote.number')
if quote_name:
vals['name'] = quote_name
vals.setdefault('x_fc_quote_ref', quote_name)
elif not vals.get('x_fc_quote_ref'):
vals['x_fc_quote_ref'] = existing
return super().create(vals_list)
def action_confirm(self):
"""Assign parent number + rename Q-…-N to SO-<parent>, then run
the standard confirm (which kicks off WO creation).
Parent number is drawn from fp.parent.number; the quote name
was already saved to x_fc_quote_ref on create() so it survives
the rename. Idempotent - if x_fc_parent_number is already set,
the rename is skipped (re-confirm scenarios)."""
Seq = self.env['ir.sequence']
for so in self:
if so.x_fc_parent_number:
continue
parent = Seq.next_by_code('fp.parent.number')
if not parent:
raise UserError(_(
'Sequence fp.parent.number is missing. Reinstall '
'fusion_plating to restore it.'
))
parent_int = int(parent)
old_name = so.name
# fp_allow_name_rename whitelists this single legitimate
# rename path through the immutability write() guard
# (added in Task 11).
so.with_context(fp_allow_name_rename=True).write({
'name': f'SO-{parent_int}',
'x_fc_parent_number': parent_int,
})
so.message_post(body=Markup(_(
'Confirmed quote <strong>%s</strong> as <strong>%s</strong>.'
)) % (old_name, so.name))
result = super().action_confirm()
for so in self:
so._fp_auto_create_job()
# Auto-confirm any draft jobs we just created so steps
# generate immediately (no manager click required).
# Best-effort: an exception in side-effects shouldn't
# block the SO confirm itself.
draft_jobs = self.env['fp.job'].sudo().search([
('sale_order_id', '=', so.id),
('state', '=', 'draft'),
])
for job in draft_jobs:
try:
job.action_confirm()
except Exception as exc:
so.message_post(body=_(
'Auto-confirm of fp.job %(job)s failed: %(err)s. '
'Confirm manually from the job form.'
) % {'job': job.name, 'err': exc})
return result
def _create_invoices(self, grouped=False, final=False, date=None):
"""Set fp_from_so_invoice=True so account.move.create() allows
the customer-invoice creation (the direct-creation block is
bypassed via this context flag). Also lets the parent-numbered
mixin find the originating SO without depending on invoice_origin.
"""
return super(SaleOrder, self.with_context(
fp_from_so_invoice=True,
fp_invoice_source_so_id=self.id if len(self) == 1 else False,
))._create_invoices(grouped=grouped, final=final, date=date)
def unlink(self):
"""Spec §6.2 - confirmed SOs are part of the compliance audit
trail and cannot be deleted. Cancellation must go through the
state machine instead. Draft SOs (no parent_number assigned
yet) remain freely deletable per Odoo standard. Applies to
all users including administrators."""
for so in self:
if so.x_fc_parent_number:
raise UserError(_(
'Sale Order "%(name)s" cannot be deleted - it has '
'been confirmed (parent number %(parent)s issued) '
'and is part of the compliance audit trail. Cancel '
'it instead. This rule applies to all users '
'including administrators.'
) % {'name': so.display_name, 'parent': so.x_fc_parent_number})
return super().unlink()
def _fp_resolve_recipe_for_line(self, line):
"""Recipe resolution with Express-Orders SO header fallback.
Priority (most-specific first):
1. line.x_fc_process_variant_id - explicit per-line variant
(always wins; this is where G3 propagation lands a value).
2. self.x_fc_material_process - Express Orders order-level
recipe. Catches the case where G3 propagation failed to
reach the line but the header has the recipe.
3. part.default_process_id - part's flagged default
variant. Customer-and-part-tuned recipe.
4. part.recipe_id - legacy fallback.
Returns the recipe record or an empty recordset.
"""
Node = self.env['fusion.plating.process.node']
part = (
'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id
) or False
if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False
picked = (
'x_fc_process_variant_id' in line._fields
and line.x_fc_process_variant_id
) or False
if picked:
return picked
# Express Orders header recipe (2026-05-27 fallback)
if 'x_fc_material_process' in self._fields and self.x_fc_material_process:
return self.x_fc_material_process
if part and 'default_process_id' in part._fields and part.default_process_id:
return part.default_process_id
if part and 'recipe_id' in part._fields and part.recipe_id:
return part.recipe_id
return Node
def _fp_auto_create_job(self):
"""Create fp.job(s) from the SO's plating lines.
2026-05-12 parent-number rewrite: lines are grouped by resolved
recipe id (NOT by x_fc_wo_group_tag). If 1 group → one WO named
WO-<parent> (bare). If N>1 groups → N WOs named WO-<parent>-01,
WO-<parent>-02, ..., ordered by min line sequence so suffixes
mirror SO display order. WO names are then immutable; later
manual additions to the SO get the next index via the mixin.
"""
self.ensure_one()
Job = self.env['fp.job'].sudo()
# Idempotency: skip if a job already references this SO
existing = Job.search([('sale_order_id', '=', self.id)], limit=1)
if existing:
return
# Find plating lines (those with a part_catalog_id or
# customer_spec_id).
plating_lines = self.order_line.filtered(
lambda l: (
('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)
or ('x_fc_customer_spec_id' in l._fields and l.x_fc_customer_spec_id)
)
)
# Fallback: SOs that carry part on the header but not on the
# line. Treat the entire order as one plating job 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
):
_logger.info(
'SO %s: no line-level part 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
# Group by (recipe, part, spec, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Bundling lines with
# different specs / thicknesses / serials under one WO would
# carry the first line's values onto the cert + sticker -
# silent mis-attestation. No-recipe lines still get their own
# group each.
groups = {}
unrecipe_idx = 0
for line in plating_lines:
recipe = self._fp_resolve_recipe_for_line(line)
part_id = (
'x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id.id
) or False
spec_id = (
'x_fc_customer_spec_id' in line._fields
and line.x_fc_customer_spec_id.id
) or False
thickness_key = (
'x_fc_thickness_range' in line._fields
and (line.x_fc_thickness_range or '').strip()
) or False
serial_id = (
'x_fc_serial_id' in line._fields
and line.x_fc_serial_id.id
) or False
if recipe:
key = (recipe.id, part_id, spec_id, thickness_key, serial_id)
else:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)
groups[key] = groups.get(key, self.env['sale.order.line']) | line
# Order groups by min line sequence so dash-suffixes mirror SO
# display order. Deterministic regardless of dict iteration order.
ordered_keys = sorted(
groups.keys(),
key=lambda k: min(groups[k].mapped('sequence') or [0]),
)
n_groups = len(ordered_keys)
parent = self.x_fc_parent_number # set by action_confirm earlier
# Create a job per group
for idx, key in enumerate(ordered_keys, start=1):
lines = groups[key]
first_line = lines[0]
qty = sum(lines.mapped('product_uom_qty'))
part = (
'x_fc_part_catalog_id' in first_line._fields
and first_line.x_fc_part_catalog_id
or False
)
customer_spec = (
'x_fc_customer_spec_id' in first_line._fields
and first_line.x_fc_customer_spec_id
or False
)
if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False
recipe = self._fp_resolve_recipe_for_line(first_line)
vals = {
'partner_id': self.partner_id.id,
'product_id': first_line.product_id.id if first_line.product_id else False,
'qty': qty,
'origin': self.name,
'sale_order_id': self.id,
'sale_order_line_ids': [(6, 0, lines.ids)],
'date_deadline': self.commitment_date or self.date_order,
}
if part:
vals['part_catalog_id'] = part.id
if customer_spec:
vals['customer_spec_id'] = customer_spec.id
if recipe:
vals['recipe_id'] = recipe.id
# Customer references - mirror onto the job so the shop floor
# has them without round-tripping to the SO.
if 'x_fc_customer_job_number' in self._fields \
and self.x_fc_customer_job_number:
vals['x_fc_customer_job_number'] = self.x_fc_customer_job_number
if 'x_fc_po_number' in self._fields and self.x_fc_po_number:
vals['x_fc_po_number'] = self.x_fc_po_number
if 'x_fc_rush_order' in self._fields:
vals['x_fc_rush_order'] = bool(self.x_fc_rush_order)
# Scheduling targets - mirror the SO's customer-facing dates.
if 'x_fc_internal_deadline' in self._fields \
and self.x_fc_internal_deadline:
vals['x_fc_internal_deadline'] = self.x_fc_internal_deadline
if 'x_fc_planned_start_date' in self._fields \
and self.x_fc_planned_start_date:
vals['x_fc_planned_start_date'] = self.x_fc_planned_start_date
# Operational notes - mirror so the shop has them on the WO.
if 'x_fc_internal_note' in self._fields \
and self.x_fc_internal_note:
vals['x_fc_internal_note'] = self.x_fc_internal_note
if 'x_fc_external_note' in self._fields \
and self.x_fc_external_note:
vals['x_fc_external_note'] = self.x_fc_external_note
# Customer spec / facility / manager - copy from SO if present
if 'x_fc_customer_spec_id' in self._fields and self.x_fc_customer_spec_id:
vals['customer_spec_id'] = self.x_fc_customer_spec_id.id
if 'x_fc_facility_id' in self._fields and self.x_fc_facility_id:
vals['facility_id'] = self.x_fc_facility_id.id
if 'x_fc_manager_id' in self._fields and self.x_fc_manager_id:
vals['manager_id'] = self.x_fc_manager_id.id
# Quoted revenue: sum line totals
vals['quoted_revenue'] = sum(lines.mapped('price_subtotal'))
# Parent-number naming (2026-05-12). Bare for the single-group
# case; zero-padded -NN suffix when multiple recipes split the
# SO into multiple WOs. Set explicitly so fp.job.create() skips
# its own naming fallback.
if parent:
if n_groups == 1:
vals['name'] = f'WO-{parent}'
vals['x_fc_doc_index'] = 1
else:
vals['name'] = f'WO-{parent}-{idx:02d}' if idx <= 99 else f'WO-{parent}-{idx}'
vals['x_fc_doc_index'] = idx
job = Job.create(vals)
_logger.info(
'SO %s: created fp.job %s (qty=%s, recipe=%s)',
self.name, job.name, qty, (recipe.name if recipe else '-'),
)
# Express Orders (2026-05-26) - apply per-line masking + bake
# overrides to the new job. This runs BEFORE step generation
# (which happens in fp.job.action_confirm) so the override rows
# are in place when _generate_steps_from_recipe reads override_map.
# Step.instructions writes are deferred to a second pass after
# step gen - see fp.job.action_confirm override.
if job.recipe_id and 'x_fc_masking_enabled' in self.env['sale.order.line']._fields:
for sol in lines:
if hasattr(sol, '_fp_apply_express_overrides_to_job'):
sol._fp_apply_express_overrides_to_job(job)
# Bump SO counter to reflect the bulk creation. Future manual
# WO additions pick up from here via the mixin standard path.
if parent and n_groups:
self.env.cr.execute(
"UPDATE sale_order SET x_fc_pn_wo_count = %s WHERE id = %s",
(n_groups, self.id),
)
self.invalidate_recordset(['x_fc_pn_wo_count'])
return True
# ------------------------------------------------------------------
# Phase 4 (Sub 11) - workflow stage action buttons.
# Native versions of bridge_mrp's action_fp_* methods. Drop the
# mrp.production lookups; talk to fp.job and fp.receiving directly.
# ------------------------------------------------------------------
def action_fp_mark_inspected(self):
"""Flip open receivings from draft → inspecting."""
self.ensure_one()
Recv = self.env.get('fp.receiving')
if Recv is None:
return False
for rec in Recv.search([('sale_order_id', '=', self.id)]):
if rec.state == 'draft':
rec.state = 'inspecting'
self.message_post(body=_('Parts marked as inspecting.'))
return True
def action_fp_accept_parts(self):
"""Mark receiving complete; flip SO receiving status to received.
Sub 8 (2026-04-22) moved inspection out of receiving and into the
recipe's racking step. Receiving's terminal state is now 'closed'
(or legacy 'accepted'), which maps to SO status 'received'. The
old 'inspected' SO status no longer exists.
"""
self.ensure_one()
Recv = self.env.get('fp.receiving')
if Recv is None:
return False
for rec in Recv.search([('sale_order_id', '=', self.id)]):
# Push receiving to its terminal state - 'closed' is the
# post-Sub-8 terminal; 'accepted' kept as a legacy fallback
# only for old records still in pre-Sub-8 states.
if rec.state in ('draft', 'counted', 'staged'):
rec.state = 'closed'
elif rec.state in ('inspecting',):
rec.state = 'accepted'
if 'x_fc_receiving_status' in self._fields:
self.x_fc_receiving_status = 'received'
self.message_post(body=_('Parts accepted - ready to assign manager.'))
return True
def action_fp_assign_to_me(self):
"""Manager claims the SO and confirms its draft fp.jobs."""
self.ensure_one()
user = self.env.user
self.x_fc_assigned_manager_id = user.id
Job = self.env['fp.job']
jobs = Job.search([
('sale_order_id', '=', self.id),
('state', '=', 'draft'),
])
for job in jobs:
try:
job.action_confirm()
except Exception as exc:
self.message_post(body=_(
'Auto-confirm of fp.job %s failed: %s'
) % (job.name, exc))
if 'manager_id' in job._fields and not job.manager_id:
job.manager_id = user.id
self.message_post(
body=Markup(_(
'Job assigned to <b>%s</b>. %d plating job(s) released to the floor.'
)) % (user.name, len(jobs)),
)
return True
def action_fp_mark_shipped(self):
"""Mark linked deliveries delivered (triggers auto-invoice)."""
self.ensure_one()
Delivery = self.env.get('fusion.plating.delivery')
if Delivery is None:
return False
Job = self.env['fp.job']
jobs = Job.search([('sale_order_id', '=', self.id)])
deliveries = Delivery.browse([])
if 'x_fc_job_id' in Delivery._fields:
deliveries = Delivery.search([
('x_fc_job_id', 'in', jobs.ids),
('state', '!=', 'delivered'),
])
for dlv in deliveries:
dlv.action_mark_delivered()
self.message_post(
body=_(
'%d delivery record(s) marked delivered. '
'Invoice flow triggered per invoice strategy.'
) % len(deliveries),
)
return True
def action_fp_open_shop_floor(self):
"""Jump to the Plant Overview filtered to this SO's jobs."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_plant_overview',
'name': _('Shop Floor - %s') % self.name,
'target': 'current',
}

View File

@@ -1,68 +0,0 @@
# -*- 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)
# Recipe set/changed late on the line -> heal the linked WO that
# was created empty before the estimator picked the process. Only
# not-yet-started jobs (no steps) are touched.
if 'x_fc_process_variant_id' in vals:
Job = self.env['fp.job']
for line in self:
jobs = Job.search([
('sale_order_line_ids', 'in', line.id),
('state', 'not in', ('done', 'cancelled')),
])
for job in jobs.filtered(lambda j: not j.step_ids):
job.sudo()._fp_resync_recipe_from_so()
if 'product_uom_qty' not in vals:
return result
Job = self.env['fp.job']
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(_(
'⚠️ <b>SO qty changed mid-job</b> by %(user)s. '
'SO line %(name)s went from %(old)g to %(new)g. '
'Job qty is still <b>%(jobqty)g</b> - operator '
'must manually adjust scope (start more racks or '
'stop early) and the supervisor should hit '
'<b>Sync qty from SO</b> 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