Standardise user-facing terminology across 5 modules (27 files):
- display_name compute: 'Work Order # 01368' -> 'WO # 01368'
- _description on 5 models: Plating Job{," Step"," Step Time Log"," Margin Report"," Recipe Node Override"} -> Work Order equivalents
- field labels (string=...) on 13 Many2one / One2many fields
across fp.batch, fp.thickness_reading, fp.quality.hold,
fp.job_consumption, fp.portal.job, fp.certificate, fp.delivery,
fp.quality.check, fp.racking.inspection, res.partner, sale.order
- XML view labels: action names, list/form/search strings,
portal template names, dashboard tile titles
What's deliberately preserved:
- DB model name 'fp.job' (technical identifier — used by
sale_order.x_fc_plating_job_ids and all comodel refs)
- Module name 'fusion_plating_jobs' (directory / import path)
- Settings -> Apps display label 'Fusion Plating Jobs' (module
identity for Odoo's app picker)
- 'Use Native Plating Jobs' migration toggle (internal mechanism
flag, not user-facing terminology)
Verified on entech: WH/JOB/01368 now displays as 'WO # 01368'
everywhere humans look (form header, breadcrumbs, M2O dropdowns,
error messages, smart-button titles).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
373 lines
14 KiB
Python
373 lines
14 KiB
Python
# -*- 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']
|
||
# 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.
|
||
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
|
||
|
||
@api.model_create_multi
|
||
def create(self, vals_list):
|
||
for vals in vals_list:
|
||
if vals.get('name', _('New')) == _('New'):
|
||
vals['name'] = self.env['ir.sequence'].next_by_code('fp.job') or _('New')
|
||
return super().create(vals_list)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 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
|