Three coupled shop-floor corrections: - fp.job._compute_display_name: renders "Work Order # 00011" in form header, breadcrumbs, M2O dropdowns, and error messages. DB name stays as WH/JOB/00011 - existing chatter/cert/delivery references unchanged. - fp.job.step.button_finish: refuses if qty_at_step > 0 AND a downstream pending/ready step exists. Last runnable step is exempt (parts complete in place). Manager bypass via fp_skip_qty_gate=True context key. - fp.job.step.action_complete_one_to_next: new per-row button "Complete 1 -> Next" for streaming flow (large parts going one-by-one). Records move(qty=1) to next step; if drain takes qty_at_step to 0, auto-finishes source + auto-starts destination via existing action_finish_and_advance. - fp.job.step._fp_record_one_piece_auto_move: auto-move shim wired into action_finish_and_advance. qty=1 + downstream => silently record move(1). qty>1 + downstream => raise pointing at Complete 1 -> Next. Last step always allowed. - 16 new TestQtyGate tests covering gate / shim / auto-finish / last-step exemption / display rename / Move wizard zero-qty. Spec: docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md Plan: docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md 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 = 'Plating Job'
|
||
_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' → 'Work Order # 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 = _('Work Order # %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
|