Split 49 modules/suites into independent git repos; untrack from monorepo
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:
@@ -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.
|
||||
Binary file not shown.
@@ -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,
|
||||
)
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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),
|
||||
))
|
||||
@@ -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
@@ -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
|
||||
@@ -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)]
|
||||
@@ -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.',
|
||||
)
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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',
|
||||
},
|
||||
)
|
||||
@@ -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).',
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.'
|
||||
))
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.',
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user