feat(jobs): add core-safe extension fields to fp.job

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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-24 21:44:28 -04:00
parent b45a134aa4
commit 335dc2488e
3 changed files with 116 additions and 1 deletions

View File

@@ -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': """

View File

@@ -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:

View File

@@ -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)