# -*- 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 ``. 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