Files
Odoo-Modules/fusion_plating/fusion_plating/models/fp_job.py
gsinghpal 0d85063b5e feat(numbering): wire CoC/RCV/DLV/PU into parent-numbered mixin + rename counters
Per-model counter fields on sale.order renamed to x_fc_pn_*_count
to avoid collision with pre-existing compute fields of the same
short name in bridge_mrp / receiving / configurator (silent
compute-override was suppressing the storage). 4 child models
(fp.certificate, fp.receiving, fusion.plating.delivery,
fusion.plating.pickup.request) now derive names as PFX-<parent>
with -NN suffix from the 2nd onward.

fusion.plating.pickup.request gains a sale_order_id field
(optional) so pickups created against an SO get parent-derived
names, while standalone pickups (pre-SO) fall back to PU/YYYY/NNNN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:30:37 -04:00

469 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.job — native plating job model.
#
# Replaces mrp.production for plating. One record per shop-floor job.
# Header data lives here; per-operation detail on fp.job.step (Task 1.5).
# Recipe template (fusion.plating.process.node) is unchanged — this
# model just instantiates from it via fp.job.step.recipe_node_id.
#
# State machine:
# draft -> confirmed -> in_progress -> done
# | ^
# v |
# cancelled (rework reverts here)
# on_hold can be entered from confirmed or in_progress.
import pytz
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpJob(models.Model):
_name = 'fp.job'
def fp_format_local(self, dt, fmt='%Y-%m-%d %H:%M'):
"""Format a UTC datetime in the viewer's local timezone.
Used by report templates: QWeb's eval scope doesn't expose pytz
or format_datetime, but record methods are always callable, so
templates do `<span t-esc="job.fp_format_local(dt, '%H:%M')"/>`.
Resolution order matches the rest of the module: env.user.tz →
company.x_fc_default_tz → UTC.
"""
if not dt:
return ''
tz_name = (
self.env.user.tz
or ('x_fc_default_tz' in self.env.company._fields
and self.env.company.x_fc_default_tz)
or 'UTC'
)
try:
tz = pytz.timezone(tz_name)
except Exception:
tz = pytz.UTC
if dt.tzinfo is None:
dt = pytz.UTC.localize(dt)
return dt.astimezone(tz).strftime(fmt)
_description = 'Work Order'
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
# Sub 12d — state-aware sort. Active work bubbles to the top
# (in_progress → confirmed/draft → on_hold → done → cancelled),
# then high-priority first within each state, then nearest deadline.
# state_priority is a small stored compute below.
_order = 'state_priority asc, priority desc, date_deadline asc, id desc'
_rec_name = 'name'
name = fields.Char(
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
index=True,
)
@api.depends('name')
def _compute_display_name(self):
"""Reformat 'WH/JOB/00011''WO # 00011' for every
human-facing surface (form header, breadcrumbs, M2O dropdowns,
smart-button titles, error messages). The DB `name` is
unchanged so existing certs / deliveries / chatter references
don't break.
"""
for job in self:
if job.name and '/' in job.name:
suffix = job.name.rsplit('/', 1)[-1]
job.display_name = _('WO # %s') % suffix
else:
job.display_name = job.name or ''
state = fields.Selection(
[
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('in_progress', 'In Progress'),
('on_hold', 'On Hold'),
('done', 'Done'),
('cancelled', 'Cancelled'),
],
default='draft',
required=True,
tracking=True,
index=True,
)
# Sub 12d — drives the default sort so active jobs surface above
# closed jobs. Lower number = sorted earlier. Stored + indexed so
# SQL ORDER BY hits an index and doesn't recompute per row.
state_priority = fields.Integer(
string='State Priority',
compute='_compute_state_priority',
store=True,
index=True,
)
_STATE_SORT_RANK = {
'in_progress': 0,
'confirmed': 1,
'draft': 2,
'on_hold': 3,
'done': 4,
'cancelled': 5,
}
@api.depends('state')
def _compute_state_priority(self):
for rec in self:
rec.state_priority = self._STATE_SORT_RANK.get(rec.state, 9)
priority = fields.Selection(
[
('low', 'Low'),
('normal', 'Normal'),
('high', 'High'),
('rush', 'Rush'),
],
default='normal',
tracking=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Customer',
required=True,
tracking=True,
)
product_id = fields.Many2one('product.product', string='Reference Product')
qty = fields.Float(string='Quantity', required=True, default=1.0)
qty_done = fields.Float(string='Quantity Completed')
qty_scrapped = fields.Float(string='Quantity Scrapped')
date_deadline = fields.Datetime(string='Deadline', tracking=True)
date_planned_start = fields.Datetime(string='Planned Start')
date_started = fields.Datetime(string='Actual Start', readonly=True)
date_finished = fields.Datetime(string='Actual Finish', readonly=True)
origin = fields.Char(string='Source SO', help='Sale Order name for traceability.')
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
facility_id = fields.Many2one('fusion.plating.facility', string='Facility')
manager_id = fields.Many2one('res.users', string='Plating Manager')
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
required=True,
)
# ------------------------------------------------------------------
# Source / recipe / invoicing — core-safe (target models reachable
# via current depends: sale_management → sale → account, and our
# own fusion.plating.process.node).
#
# Plating-specific extensions (part_catalog_id, coating_config_id,
# customer_spec_id, portal_job_id, delivery_id, qc_check_id) are
# deferred to their owning modules via _inherit = 'fp.job' to avoid
# inverting the dependency graph. See spec §5.1.
# ------------------------------------------------------------------
sale_order_line_ids = fields.Many2many(
'sale.order.line',
'fp_job_sale_order_line_rel',
'job_id', 'line_id',
string='Source SO Lines',
)
recipe_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe',
domain=[('node_type', '=', 'recipe')],
)
start_at_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Start at Node',
help='Rework: start the job at this recipe node (skip earlier).',
)
invoice_ids = fields.Many2many(
'account.move',
'fp_job_account_move_rel',
'job_id', 'move_id',
string='Invoices',
)
# ------------------------------------------------------------------
# Cost rollup — actual_cost stays at 0 until Task 1.5 wires step
# time × work_centre.cost_per_hour. quoted_revenue is a manual entry
# for now (will be filled by the SO → job hook in Phase 2).
# ------------------------------------------------------------------
currency_id = fields.Many2one(
'res.currency',
required=True,
default=lambda self: self.env.company.currency_id,
)
quoted_revenue = fields.Monetary(
currency_field='currency_id',
help='From source SO.',
)
actual_cost = fields.Monetary(
currency_field='currency_id',
compute='_compute_costs', store=True,
)
margin = fields.Monetary(
currency_field='currency_id',
compute='_compute_costs', store=True,
)
margin_pct = fields.Float(
compute='_compute_costs', store=True,
)
@api.depends('quoted_revenue')
def _compute_costs(self):
"""Cost rollup for the job header.
TODO(Task 1.5): when fp.job.step lands, expand @api.depends to
include 'step_ids.cost_total' so actual_cost rolls up
step time × work_centre.cost_per_hour automatically.
"""
for job in self:
job.actual_cost = 0.0
job.margin = job.quoted_revenue - job.actual_cost
job.margin_pct = (
(job.margin / job.quoted_revenue * 100.0)
if job.quoted_revenue else 0.0
)
# ------------------------------------------------------------------
# current_location — operator-readable status string. Stub here;
# full "Queued: Bath 3" / "In progress: Oven A" rendering needs
# fp.job.step + fp.work.centre, which lands in Tasks 1.5/1.6.
# ------------------------------------------------------------------
current_location = fields.Char(
compute='_compute_current_location',
help='Human-readable: "Queued: Bath 3" / "In progress: Oven A" / "Ready to ship".',
)
def _compute_current_location(self):
for job in self:
if job.state == 'draft':
job.current_location = 'Not started'
elif job.state == 'cancelled':
job.current_location = 'Cancelled'
elif job.state == 'done':
job.current_location = 'Done'
else:
job.current_location = job.state.replace('_', ' ').title()
# ------------------------------------------------------------------
# Steps — One2many to fp.job.step (Task 1.5)
# ------------------------------------------------------------------
step_ids = fields.One2many(
'fp.job.step',
'job_id',
string='Steps',
)
# ===== Sub 12b — traveller header + active timer ========================
# Header counters mirror the paper traveller's "Qty Rec." / "VIS INSP."
# / "Rework" columns (screens 16-18). Sub 12c's traveller report pulls
# these into the printed header.
# ---- Customer references mirrored from sale.order ----------------
# Populated on SO confirm by _fp_auto_create_job. Without these
# mirrors, certs / deliveries / chatter would have to round-trip
# through job.sale_order_id.x_fc_* to find the customer's references,
# and offline-printed paperwork on the shop floor wouldn't show them.
x_fc_customer_job_number = fields.Char(
string='Customer Job #',
tracking=True,
help='Customer\'s own job/lot reference. Copied from the '
'sale order when the job is auto-created on SO confirm; '
'printed on the cert, traveller, and BoL.',
)
x_fc_po_number = fields.Char(
string='Customer PO #',
tracking=True,
help='Customer purchase order number. Mirrored from sale.order '
'so the shop floor and printed paperwork have it without '
'a round-trip to the SO.',
)
x_fc_rush_order = fields.Boolean(
string='Rush Order',
tracking=True,
help='High-priority flag mirrored from sale.order. Operators see '
'this on the queue / tablet at-a-glance — saves lifting the '
'job form to know it\'s rush.',
)
# ---- Scheduling targets mirrored from sale.order -----------------
# These are kept separate from the operational date_planned_start /
# date_deadline fields (which may be tweaked by scheduling logic) —
# this preserves the ORIGINAL customer-facing dates entered on the SO.
x_fc_internal_deadline = fields.Date(
string='Internal Deadline',
tracking=True,
help='Shop\'s internal target finish date, copied from sale.order. '
'Buffer ahead of the customer-facing deadline.',
)
x_fc_planned_start_date = fields.Date(
string='Planned Start (SO)',
tracking=True,
help='Customer-quoted planned start date copied from sale.order. '
'Different from date_planned_start (which scheduling logic '
'may adjust based on shop capacity).',
)
# ---- Operational notes mirrored from sale.order ------------------
x_fc_internal_note = fields.Html(
string='Internal Note',
tracking=True,
help='Shop-internal notes from the order. Not shown to customer.',
)
x_fc_external_note = fields.Html(
string='External Note',
tracking=True,
help='Customer-facing notes copied from the sale order. Printed '
'on traveller / BoL / cert.',
)
qty_received = fields.Integer(
string='Qty Received',
help='Paper traveller "Qty Rec." column.',
)
qty_visual_inspection_rejects = fields.Integer(
string='Visual Insp Rejects',
help='Paper traveller "VIS INSP." column.',
)
qty_rework = fields.Integer(
string='Qty Sent to Rework',
help='Paper traveller "Rework" column.',
)
special_requirements = fields.Text(
string='Special Requirements',
help='Long free-form spec text from customer; printed on the '
'traveller header (Sub 12c).',
)
active_timer_ids = fields.One2many(
'fp.job.step.timelog',
'job_id',
string='Active Timers',
domain=[('state', 'in', ('running', 'paused'))],
help='Sub 12b — used by tablet for live timer badges. Filtered '
'on state by Task 7\'s state field.',
)
move_ids = fields.One2many(
'fp.job.step.move', 'job_id',
string='Move Log',
)
# step_count + step_done_count are stored (drive list views / stat
# buttons in Task 1.8). step_progress_pct stays non-stored — it's a
# cheap derivative. Odoo flags as inconsistent when stored and
# non-stored fields share a compute method, so they get distinct
# methods below.
step_count = fields.Integer(compute='_compute_step_counts', store=True)
step_done_count = fields.Integer(compute='_compute_step_counts', store=True)
step_progress_pct = fields.Float(compute='_compute_step_progress_pct')
current_step_id = fields.Many2one(
'fp.job.step',
compute='_compute_current_step',
)
@api.depends('step_ids', 'step_ids.state')
def _compute_step_counts(self):
for job in self:
job.step_count = len(job.step_ids)
job.step_done_count = len(job.step_ids.filtered(lambda s: s.state == 'done'))
@api.depends('step_count', 'step_done_count')
def _compute_step_progress_pct(self):
for job in self:
job.step_progress_pct = (
(job.step_done_count / job.step_count * 100.0)
if job.step_count else 0.0
)
@api.depends('step_ids.state', 'step_ids.sequence')
def _compute_current_step(self):
for job in self:
in_prog = job.step_ids.filtered(lambda s: s.state == 'in_progress')
if in_prog:
job.current_step_id = in_prog.sorted('sequence')[:1]
continue
ready = job.step_ids.filtered(lambda s: s.state == 'ready')
if ready:
job.current_step_id = ready.sorted('sequence')[:1]
continue
job.current_step_id = False
# ------------------------------------------------------------------
# Parent-numbered mixin hooks (2026-05-12 numbering hierarchy)
# ------------------------------------------------------------------
def _fp_parent_sale_order(self):
return self.sale_order_id
def _fp_name_prefix(self):
return 'WO'
def _fp_parent_counter_field(self):
return 'x_fc_pn_wo_count'
@api.model_create_multi
def create(self, vals_list):
"""fp.job naming priority:
1. Caller-provided name (bulk SO-confirm path sets these explicitly).
2. Mixin parent-derived name (manual WO add to an existing SO).
3. Legacy fp.job sequence (standalone job, no SO link).
"""
# Pass A: fall back to legacy 'New' sentinel for records that
# don't get a parent-derived name. The mixin's post-create
# _fp_assign_parent_name() will override 'New' once the record
# exists if a parent SO is reachable.
for vals in vals_list:
if not vals.get('name'):
vals['name'] = _('New')
records = super().create(vals_list)
# Pass B: any record that came through with 'New' (no explicit
# name from the bulk SO path) tries the parent-derived path,
# falling back to the legacy sequence if there's no parent SO.
for rec in records:
if rec.name and rec.name != _('New') and rec.name != 'New':
continue # caller set an explicit name (e.g. bulk SO confirm)
if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fp.job') or _('New')
# Raw SQL — fp.job has no immutability guard yet in this
# task, but Task 11 will add one. Using SQL here keeps the
# fallback path consistent across all child models.
self.env.cr.execute(
"UPDATE fp_job SET name = %s WHERE id = %s",
(seq, rec.id),
)
rec.invalidate_recordset(['name'])
return records
# ------------------------------------------------------------------
# State machine — actions
# ------------------------------------------------------------------
# TODO(fp.job state-machine completeness): action_hold, action_resume,
# action_revert_to_confirmed (rework path) — to be added when shopfloor
# / rework workflows are wired up. For now, draft → confirmed and the
# cancel paths are the only enforced transitions; everything else is
# an explicit `state` write by privileged code.
def action_confirm(self):
for job in self:
if job.state != 'draft':
raise UserError(_(
"Job %s is in state '%s' - only draft jobs can be confirmed."
) % (job.name, job.state))
job.state = 'confirmed'
# Step auto-promote happens in the fusion_plating_jobs override
# AFTER _generate_steps_from_recipe runs — at this point step_ids
# is empty for any newly-confirmed job.
return True
def action_cancel(self):
for job in self:
if job.state == 'done':
raise UserError(_(
"Job %s is done — cannot cancel."
) % job.name)
if job.state == 'cancelled':
raise UserError(_(
"Job %s is already cancelled."
) % job.name)
job.state = 'cancelled'
return True