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)