From 335dc2488eb65c1a27bdf9de94cf41ea0d70fb58 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 24 Apr 2026 21:44:28 -0400 Subject: [PATCH] feat(jobs): add core-safe extension fields to fp.job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope was reduced from spec's full §5.1 list because 6 of the 10 plating-specific fields point to models in dependent modules (configurator, quality, portal, logistics, bridge_mrp). Adding those Many2ones in core would invert the dependency graph. They move to their owning modules via _inherit = 'fp.job' and get re-bundled by fusion_plating_jobs in Phase 2. This commit lands the core-safe subset: - sale_order_line_ids (sale_management is in core depends) - recipe_id, start_at_node_id (process.node is in core) - invoice_ids (account is reachable via sale_management → sale) - Cost rollup: quoted_revenue / actual_cost / margin / margin_pct with placeholder compute (actual_cost = 0 until Task 1.5 wires fp.job.step.cost_total) - current_location stub (full Bath/Oven rendering in Task 1.6) Tests cover the cost-rollup math and the current_location stub. Spec §5.1 has been re-tabulated with explicit 'Module' column. Manifest 19.0.8.2.1 → 19.0.8.3.0. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_plating/fusion_plating/__manifest__.py | 2 +- .../fusion_plating/models/fp_job.py | 91 +++++++++++++++++++ .../tests/test_fp_job_state_machine.py | 24 +++++ 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index b9e282ab..0b3116b7 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.8.2.1', + 'version': '19.0.8.3.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/models/fp_job.py b/fusion_plating/fusion_plating/models/fp_job.py index d1305640..54efbc38 100644 --- a/fusion_plating/fusion_plating/models/fp_job.py +++ b/fusion_plating/fusion_plating/models/fp_job.py @@ -82,6 +82,97 @@ class FpJob(models.Model): 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', + 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') + # NOTE: when fp.job.step lands in Task 1.5, this dependency expands + # to include 'step_ids.cost_total'. For now actual_cost is always 0. + def _compute_costs(self): + 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() + @api.model_create_multi def create(self, vals_list): for vals in vals_list: diff --git a/fusion_plating/fusion_plating/tests/test_fp_job_state_machine.py b/fusion_plating/fusion_plating/tests/test_fp_job_state_machine.py index 47a9429e..4c06d7b2 100644 --- a/fusion_plating/fusion_plating/tests/test_fp_job_state_machine.py +++ b/fusion_plating/fusion_plating/tests/test_fp_job_state_machine.py @@ -62,3 +62,27 @@ class TestFpJobStateMachine(TransactionCase): job.action_cancel() with self.assertRaises(UserError): job.action_cancel() + + def test_current_location_for_draft(self): + job = self._make_job() + self.assertEqual(job.current_location, 'Not started') + + def test_current_location_for_done(self): + job = self._make_job() + # Force state to 'done' (no public action yet) + job.state = 'done' + # Recompute — Odoo's compute is auto on read + self.assertEqual(job.current_location, 'Done') + + def test_margin_zero_when_no_revenue(self): + job = self._make_job() + self.assertEqual(job.actual_cost, 0.0) + self.assertEqual(job.margin, 0.0) + self.assertEqual(job.margin_pct, 0.0) + + def test_margin_with_revenue(self): + job = self._make_job(quoted_revenue=1000.0) + self.assertEqual(job.quoted_revenue, 1000.0) + self.assertEqual(job.actual_cost, 0.0) + self.assertEqual(job.margin, 1000.0) + self.assertEqual(job.margin_pct, 100.0)