feat(promote-customer-spec): Phase E — final removal of coating + treatment

DELETED entirely (model + view + ACL + data file + menu):
- fp.coating.config (configurator)
- fp.treatment (configurator + seeded data)
- fp.coating.thickness (configurator) — replaced by fp.recipe.thickness in Phase A
- fp.customer.price.list (configurator) — coating-keyed, no replacement

Field deletions:
- sale.order.x_fc_coating_config_id
- sale.order.line.x_fc_coating_config_id + x_fc_treatment_ids
- account.move.line.x_fc_coating_config_id
- fp.part.catalog.x_fc_default_coating_config_id + x_fc_default_treatment_ids
- fp.job.coating_config_id
- fp.pricing.rule.coating_config_id
- fp.quality.point.coating_config_ids
- fp.direct.order.line.coating_config_id + treatment_ids
- fp.sale.description.template.coating_config_id

Refactored:
- fp.quote.configurator.coating_config_id → recipe_id (now points at
  fusion.plating.process.node, the actual recipe). All compute, onchange,
  and matcher logic updated to use recipe directly. Quality inherit
  extends matcher with spec-tier scoring.
- fp.job._fp_create_certificates now reads spec from job.customer_spec_id
  and formats spec_reference as "code Rev rev". Same for thickness
  source — bake fields read from recipe_root (Phase A).
- fp.job.step.button_finish bake-window auto-spawn reads bake settings
  from recipe_root instead of coating.
- fp.certificate auto-fill spec_min_mils/max_mils from recipe (Phase A
  thickness fields) instead of coating.
- jobs/sale_order.py: job creation reads x_fc_customer_spec_id from
  line, drops coating refs and the legacy header-coating fallback.
- Wizards drop coating + treatment fields and refs.
- Configurator views drop x_fc_coating_config_id + x_fc_treatment_ids
  fields entirely. Quality inherits re-anchor on stable fields
  (x_fc_part_catalog_id, x_fc_internal_description, default_process_id,
  process_variant_id, substrate_material) so they keep working.
- Reports drop coating fallback elifs; print recipe / spec.
- Tablet payload drops coating_config_id from job.read fields.

Skipped (deferred to backlog):
- fusion_plating_bridge_mrp — module is uninstalled per Sub 11; source
  files retain coating refs but no runtime impact.
- fusion_plating_portal — circular dep (portal → quality → certs →
  portal). Customer-facing portal coating picker stays for now;
  promote-spec polish is a separate sub-project.

Verification: grep for "coating_config_id|fp.coating.config|
fp.treatment|fp.coating.thickness" in live (non-bridge_mrp,
non-portal, non-script, non-test) Python/XML/CSV returns 3 hits,
all in module / class docstrings explaining Phase E history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-15 02:00:41 -04:00
parent e0eacc2530
commit d891002c84
54 changed files with 233 additions and 1283 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.9.1.0',
'version': '19.0.10.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
@@ -39,7 +39,7 @@ full design rationale and §6.2 of the implementation plan for task list.
'fusion_plating', # fp.job, fp.job.step, fp.work.centre
'fusion_plating_batch', # fusion.plating.batch (Phase 3)
'fusion_plating_certificates', # fp.certificate, fp.thickness.reading
'fusion_plating_configurator', # fp.part.catalog, fp.coating.config
'fusion_plating_configurator', # fp.part.catalog
'fusion_plating_kpi', # fusion.plating.kpi.value (Phase 4)
'fusion_plating_logistics', # fusion.plating.delivery
'fusion_plating_notifications', # fp.notification.template (Phase 4)

View File

@@ -48,15 +48,12 @@ class FpJob(models.Model):
string='Part',
ondelete='restrict',
)
coating_config_id = fields.Many2one(
'fp.coating.config',
string='Coating Configuration',
ondelete='restrict',
)
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Customer Spec',
string='Specification',
ondelete='set null',
help='Customer / industry spec the job ships under. Auto-filled '
'from the SO line at job creation.',
)
portal_job_id = fields.Many2one(
'fusion.plating.portal.job',
@@ -996,29 +993,28 @@ class FpJob(models.Model):
if node.estimated_duration:
vals['dwell_time_minutes'] = node.estimated_duration
# Pull thickness target from the coating config when
# this is a plating step (matched by node name keyword).
coating = job.coating_config_id
# Pull thickness target from the recipe root when this
# is a plating step (matched by node name keyword).
# Recipe-root carries thickness fields post-promote-spec.
recipe_root = job.recipe_id
name_l = (node.name or '').lower()
is_plating_node = (
'plat' in name_l or 'nickel' in name_l
or 'chrome' in name_l or 'anodiz' in name_l
)
if coating and is_plating_node:
if recipe_root and is_plating_node:
if (
'thickness_max' in coating._fields
and coating.thickness_max
'thickness_max' in recipe_root._fields
and recipe_root.thickness_max
):
vals['thickness_target'] = coating.thickness_max
vals['thickness_target'] = recipe_root.thickness_max
if (
'thickness_uom' in coating._fields
and coating.thickness_uom
'thickness_uom' in recipe_root._fields
and recipe_root.thickness_uom
):
# fp.coating.config uses long-form uom names
# (mils / microns / inches); fp.job.step uses
# short codes (mil / um / inch). Map between
# them. Unknown values fall through to the
# step's default ('um').
# Recipe uses long-form uom names (mils /
# microns / inches); fp.job.step uses short
# codes (mil / um / inch). Map between them.
_UOM_MAP = {
'mils': 'mil',
'mil': 'mil',
@@ -1029,7 +1025,7 @@ class FpJob(models.Model):
'inch': 'inch',
'in': 'inch',
}
mapped = _UOM_MAP.get(coating.thickness_uom)
mapped = _UOM_MAP.get(recipe_root.thickness_uom)
if mapped:
vals['thickness_uom'] = mapped
@@ -1546,7 +1542,9 @@ class FpJob(models.Model):
if not required:
return
has_job_link = 'x_fc_job_id' in Cert._fields
coating = self.coating_config_id
# Spec drives the cert spec_reference. The customer.spec was
# auto-filled onto the job at confirm time (sale_order.py).
spec = self.customer_spec_id
for cert_type in sorted(required):
# Idempotency per type.
existing_dom = [('certificate_type', '=', cert_type)]
@@ -1574,9 +1572,16 @@ class FpJob(models.Model):
if 'sale_order_id' in Cert._fields and self.sale_order_id:
vals['sale_order_id'] = self.sale_order_id.id
# spec_reference is what action_issue blocks on.
if coating and 'spec_reference' in Cert._fields \
and getattr(coating, 'spec_reference', False):
vals['spec_reference'] = coating.spec_reference
# Format spec.code + revision for the cert text.
if spec and 'spec_reference' in Cert._fields:
ref = spec.code or ''
if spec.revision:
ref = (f'{ref} Rev {spec.revision}'
if ref else f'Rev {spec.revision}')
if ref:
vals['spec_reference'] = ref
if 'customer_spec_id' in Cert._fields:
vals['customer_spec_id'] = spec.id
if 'part_number' in Cert._fields and self.part_catalog_id:
vals['part_number'] = (
self.part_catalog_id.part_number or ''

View File

@@ -474,8 +474,9 @@ class FpJobStep(models.Model):
def button_finish(self):
"""Override to:
1) Auto-spawn a bake.window when a wet plating step finishes
on a coating that requires hydrogen-embrittlement relief
(AS9100 / Nadcap compliance);
on a recipe that requires hydrogen-embrittlement relief
(AS9100 / Nadcap compliance). Bake fields live on the
recipe root post-promote-customer-spec.
2) Post a chatter warning when duration_actual exceeds 1.5×
duration_expected — silent overruns are a red flag for
scheduling and costing.
@@ -499,12 +500,11 @@ class FpJobStep(models.Model):
'estimate too tight.'
)) % (step.name, ratio, step.duration_expected,
step.duration_actual))
coating = step.job_id.coating_config_id \
if 'coating_config_id' in step.job_id._fields else False
if not coating:
recipe_root = step.job_id.recipe_id
if not recipe_root:
continue
requires = getattr(coating, 'requires_bake_relief', False)
window_hrs = getattr(coating, 'bake_window_hours', 0.0)
requires = getattr(recipe_root, 'requires_bake_relief', False)
window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
if not requires or not window_hrs:
continue
# Trigger only on the actual plating-out step. We want

View File

@@ -339,11 +339,8 @@ class SaleOrder(models.Model):
1. line.x_fc_process_variant_id — Sarah explicitly picked a
part-scoped variant on this order line. Always wins.
2. part.default_process_id — part's flagged default
variant. Customer-and-part-tuned recipe; must beat any
generic coating template.
3. coating.recipe_id — coating-config recipe
(generic template fallback).
4. part.recipe_id — legacy fallback.
variant. Customer-and-part-tuned recipe.
3. part.recipe_id — legacy fallback.
Returns the recipe record or an empty recordset.
"""
Node = self.env['fusion.plating.process.node']
@@ -352,11 +349,6 @@ class SaleOrder(models.Model):
) or False
if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False
coating = (
'x_fc_coating_config_id' in line._fields and line.x_fc_coating_config_id
) or False
if not coating and 'x_fc_coating_config_id' in self._fields:
coating = self.x_fc_coating_config_id or False
picked = (
'x_fc_process_variant_id' in line._fields
and line.x_fc_process_variant_id
@@ -365,8 +357,6 @@ class SaleOrder(models.Model):
return picked
if part and 'default_process_id' in part._fields and part.default_process_id:
return part.default_process_id
if coating and 'recipe_id' in coating._fields and coating.recipe_id:
return coating.recipe_id
if part and 'recipe_id' in part._fields and part.recipe_id:
return part.recipe_id
return Node
@@ -389,22 +379,22 @@ class SaleOrder(models.Model):
if existing:
return
# Find plating lines (those with a part_catalog_id or coating_config_id)
# Find plating lines (those with a part_catalog_id or
# customer_spec_id).
plating_lines = self.order_line.filtered(
lambda l: (
('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)
or ('x_fc_coating_config_id' in l._fields and l.x_fc_coating_config_id)
or ('x_fc_customer_spec_id' in l._fields and l.x_fc_customer_spec_id)
)
)
# Fallback: legacy/configurator SOs that carry part+coating on the
# header but not on the line. Treat the entire order as one
# plating line so the planner gets an fp.job to work against.
# Fallback: SOs that carry part on the header but not on the
# line. Treat the entire order as one plating job so the planner
# gets an fp.job to work against.
if not plating_lines and self.order_line and (
('x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id)
or ('x_fc_coating_config_id' in self._fields and self.x_fc_coating_config_id)
'x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id
):
_logger.info(
'SO %s: no line-level part/coating but header carries one — '
'SO %s: no line-level part but header carries one — '
'treating all lines as a single plating job.', self.name,
)
plating_lines = self.order_line
@@ -412,13 +402,12 @@ class SaleOrder(models.Model):
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return
# Group by (recipe, part, coating, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Same compliance reasoning
# as part_id + coating_id: bundling lines with different thicknesses
# or different serials under one WO would carry the first line's
# values onto the cert + sticker — silent mis-attestation. Sub 5
# added thickness_id + serial_id; this extends the grouping logic
# to honour them. No-recipe lines still get their own group each.
# Group by (recipe, part, spec, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Bundling lines with
# different specs / thicknesses / serials under one WO would
# carry the first line's values onto the cert + sticker —
# silent mis-attestation. No-recipe lines still get their own
# group each.
groups = {}
unrecipe_idx = 0
for line in plating_lines:
@@ -427,9 +416,9 @@ class SaleOrder(models.Model):
'x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id.id
) or False
coating_id = (
'x_fc_coating_config_id' in line._fields
and line.x_fc_coating_config_id.id
spec_id = (
'x_fc_customer_spec_id' in line._fields
and line.x_fc_customer_spec_id.id
) or False
thickness_id = (
'x_fc_thickness_id' in line._fields
@@ -440,7 +429,7 @@ class SaleOrder(models.Model):
and line.x_fc_serial_id.id
) or False
if recipe:
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
key = (recipe.id, part_id, spec_id, thickness_id, serial_id)
else:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)
@@ -465,11 +454,6 @@ class SaleOrder(models.Model):
and first_line.x_fc_part_catalog_id
or False
)
coating = (
'x_fc_coating_config_id' in first_line._fields
and first_line.x_fc_coating_config_id
or False
)
customer_spec = (
'x_fc_customer_spec_id' in first_line._fields
and first_line.x_fc_customer_spec_id
@@ -477,8 +461,6 @@ class SaleOrder(models.Model):
)
if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False
if not coating and 'x_fc_coating_config_id' in self._fields:
coating = self.x_fc_coating_config_id or False
recipe = self._fp_resolve_recipe_for_line(first_line)
vals = {
@@ -492,8 +474,6 @@ class SaleOrder(models.Model):
}
if part:
vals['part_catalog_id'] = part.id
if coating:
vals['coating_config_id'] = coating.id
if customer_spec:
vals['customer_spec_id'] = customer_spec.id
if recipe:

View File

@@ -56,7 +56,6 @@
<t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
<t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/>
<t t-set="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/>
@@ -99,7 +98,6 @@
<t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
<t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/>
<t t-set="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/>

View File

@@ -203,9 +203,6 @@
<t t-if="'customer_spec_id' in job._fields and job.customer_spec_id">
<span t-esc="job.customer_spec_id.display_name"/>
</t>
<t t-elif="'coating_config_id' in job._fields and job.coating_config_id">
<span t-esc="job.coating_config_id.name"/>
</t>
</td>
</tr>
</table>

View File

@@ -95,7 +95,7 @@
</xpath>
<xpath expr="//field[@name='product_id']" position="after">
<field name="part_catalog_id" string="Part"/>
<field name="coating_config_id" string="Coating"/>
<field name="customer_spec_id" string="Specification"/>
<field name="recipe_id" string="Process Recipe"/>
</xpath>
<!-- Show qty completed alongside total so the partial-qty