Files
Odoo-Modules/fusion_plating/fusion_plating_jobs/models/sale_order.py
gsinghpal 091f98e1f9 changes
2026-05-18 22:33:23 -04:00

651 lines
28 KiB
Python

# -*- 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_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_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):
"""4-tier recipe resolution. Used BOTH for grouping (Task 6
recipe-driven WO splits) AND for the per-job vals construction.
Priority (most-specific first):
1. line.x_fc_process_variant_id — Sarah explicitly picked a
part-scoped variant on this order line. Always wins.
2. part.default_process_id — part's flagged default
variant. Customer-and-part-tuned recipe.
3. 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
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 '-'),
)
# 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',
}