Files
Odoo-Modules/fusion_plating/fusion_plating/models/fp_job.py
gsinghpal cd2584d6ee ui(rename): "Plating Job" -> "Work Order" / display "WO # 01368"
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>
2026-05-12 08:22:09 -04:00

373 lines
14 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']
# 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