268 lines
10 KiB
Python
268 lines
10 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.
|
||
|
||
from odoo import _, api, fields, models
|
||
from odoo.exceptions import UserError
|
||
|
||
|
||
class FpJob(models.Model):
|
||
_name = 'fp.job'
|
||
_description = 'Plating Job'
|
||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||
_order = '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,
|
||
)
|
||
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,
|
||
)
|
||
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',
|
||
)
|
||
# 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
|