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:
@@ -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': """
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user