feat(fusion_plating): quote-to-cash infra, notifications, wizards, Tier 1 plating features

Quote-to-cash PDF reports (portrait + landscape variants, 16 new actions):
- Quotation / Sales Order, Work Order Traveller, Packing Slip, Bill of Lading,
  Certificate of Conformance (portrait added), Invoice, Payment Receipt
- Shared fp_portrait_styles + fp_landscape_styles base templates

Workflow gap fixes (fusion_plating_bridge_mrp):
- Auto-assign recipe from SO coating config in MrpProduction.action_confirm
- Auto-create draft CoC (fp.certificate) on MrpProduction.button_mark_done

Notifications overhaul (fusion_plating_notifications v2.0):
- Expanded TRIGGER_EVENTS to 7 (added quote_sent, mo_complete, shipped, payment_received)
- Shared _dispatch method replaces three duplicated send helpers
- Auto-attach PDF reports per template config (quote, SO, CoC, invoice, receipt, BoL)
- Rebuilt 7 email templates with fusion_claims accent-bar design
  (info/success color-coded, theme-safe, 600px max-width)
- New hooks: MrpProduction done, FpDelivery mark_delivered, AccountPayment post,
  SaleOrder action_quotation_send

Wizards (fusion_plating_configurator):
- fp.direct.order.wizard — skip quotation for repeat customers with PO in hand;
  optional new-revision drawing upload bumps fp.part.catalog revision and links
  new rev to the SO; creates + confirms the SO in one step
- fp.part.catalog.import.wizard — 3-step CSV import with dry-run preview,
  tolerant parsing (customer by name/email/xmlid, human-readable selections),
  duplicate detection, create-missing-customers option, single transaction commit
- Partner form stat buttons: Direct Order, Import Parts
- CSV template download button

Tier 1 practical plating features:
- T1.1 Hydrogen bake window enforcement (fp.coating.config.requires_bake_relief,
  auto-create fusion.plating.bake.window on plating WO finish, FpDelivery lockout
  when window is open)
- T1.2 Bath replenishment rules + pending suggestion queue
  (fusion.plating.bath.replenishment.rule + .suggestion, hook on bath log line
  create, operator Apply / Dismiss actions)
- T1.3 Rack/fixture library (fusion.plating.rack with MTO counter, strip
  schedule, lifecycle: active → needs_strip → stripping → retired)
- T1.4 Rework / strip-and-replate MOs (x_fc_is_rework, x_fc_original_production_id,
  Create Rework stat button on completed MOs)
- T1.5 Parts location (x_fc_current_location computed on mrp.production —
  "In progress: Alkaline Clean" / "Queued: Bake Oven" / "Ready to Ship")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-16 23:41:12 -04:00
parent 7c7ef06057
commit d3dd6376a6
51 changed files with 5231 additions and 197 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — MRP Bridge',
'version': '19.0.2.1.0',
'version': '19.0.3.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """
@@ -39,6 +39,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_quality',
'fusion_plating_logistics',
'fusion_plating_batch',
'fusion_plating_shopfloor',
'fusion_plating_configurator',
'mrp',
'mrp_workorder',
'mrp_account',

View File

@@ -3,28 +3,43 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
from odoo import _, fields, models
from odoo.exceptions import UserError
class FpDelivery(models.Model):
"""Extend delivery to auto-update portal job when delivered.
GAP 5: Delivery marked "delivered" → portal job → "shipped"
+ set actual_ship_date on the job.
"""Extend delivery to auto-update portal job and block shipment
when hydrogen embrittlement bake window isn't closed.
"""
_inherit = 'fusion.plating.delivery'
def action_mark_delivered(self):
"""Override to cascade delivery completion to the portal job."""
"""Override to cascade delivery completion to the portal job and
enforce the bake-window lockout."""
# --- Lockout: refuse to ship if any bake window for this job isn't complete
BakeWindow = self.env.get('fusion.plating.bake.window')
if BakeWindow is not None:
for delivery in self:
if not delivery.job_ref:
continue
open_windows = BakeWindow.search([
('lot_ref', '=', delivery.job_ref),
('state', 'not in', ('baked', 'scrapped')),
])
if open_windows:
bad = open_windows[0]
raise UserError(_(
'Cannot mark delivery %s delivered — job %s has an open '
'bake window (%s, state: %s). Complete the relief bake '
'or mark it scrapped before shipping.'
) % (delivery.name, delivery.job_ref, bad.name, bad.state))
res = super().action_mark_delivered()
PortalJob = self.env['fusion.plating.portal.job']
for delivery in self:
if not delivery.job_ref:
continue
# Find the portal job by name/reference
job = PortalJob.search(
[('name', '=', delivery.job_ref)], limit=1,
)
job = PortalJob.search([('name', '=', delivery.job_ref)], limit=1)
if not job:
continue
job.write({
@@ -32,5 +47,5 @@ class FpDelivery(models.Model):
'actual_ship_date': fields.Date.today(),
'tracking_ref': delivery.name,
})
job.message_post(body='Parts shipped — delivery %s marked delivered.' % delivery.name)
job.message_post(body=_('Parts shipped — delivery %s marked delivered.') % delivery.name)
return res

View File

@@ -49,11 +49,112 @@ class MrpProduction(models.Model):
compute='_compute_override_count',
)
# ------------------------------------------------------------------
# T1.4 — Rework / strip-and-replate
# ------------------------------------------------------------------
x_fc_is_rework = fields.Boolean(
string='Rework Order',
help='This MO is a rework (strip-and-replate) of a previous order.',
tracking=True,
)
x_fc_original_production_id = fields.Many2one(
'mrp.production', string='Original MO',
help='The manufacturing order this rework replaces.',
)
x_fc_rework_reason = fields.Text(string='Rework Reason')
x_fc_rework_children_ids = fields.One2many(
'mrp.production', 'x_fc_original_production_id',
string='Rework MOs',
)
x_fc_rework_count = fields.Integer(
string='Reworks', compute='_compute_rework_count',
)
# ------------------------------------------------------------------
# T1.5 — Parts location (computed from workorder progress)
# ------------------------------------------------------------------
x_fc_current_location = fields.Char(
string='Parts Location',
compute='_compute_current_location',
store=True,
help='Where the parts physically are right now — the active work centre, '
'or "Ready to Ship" when all work is done.',
)
@api.depends('x_fc_override_ids')
def _compute_override_count(self):
for rec in self:
rec.x_fc_override_count = len(rec.x_fc_override_ids)
def _compute_rework_count(self):
for rec in self:
rec.x_fc_rework_count = len(rec.x_fc_rework_children_ids)
@api.depends(
'workorder_ids.state',
'workorder_ids.workcenter_id.name',
'state',
)
def _compute_current_location(self):
for mo in self:
if mo.state == 'done':
mo.x_fc_current_location = _('Ready to Ship')
continue
if mo.state == 'cancel':
mo.x_fc_current_location = _('Cancelled')
continue
# Find the first WO that isn't done yet
active = mo.workorder_ids.sorted('sequence').filtered(
lambda w: w.state in ('pending', 'ready', 'progress')
)[:1]
if active:
wo = active
if wo.state == 'progress':
mo.x_fc_current_location = _('In progress: %s') % wo.workcenter_id.name
else:
mo.x_fc_current_location = _('Queued: %s') % wo.workcenter_id.name
else:
mo.x_fc_current_location = _('Pending')
def action_view_reworks(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Rework MOs'),
'res_model': 'mrp.production',
'view_mode': 'list,form',
'domain': [('x_fc_original_production_id', '=', self.id)],
'target': 'current',
}
def action_create_rework(self):
"""Open a wizard — or just copy the MO with is_rework flag set."""
self.ensure_one()
if self.state != 'done':
raise UserError(_('Rework can only be created from a completed MO.'))
rework = self.copy({
'name': False, # Let the sequence assign a fresh name
'x_fc_is_rework': True,
'x_fc_original_production_id': self.id,
'x_fc_portal_job_id': False,
'origin': self.origin, # Keep original SO link for billing
})
rework.message_post(
body=_('Rework of MO %s — reason will be recorded in the '
'rework reason field.') % self.name,
)
self.message_post(
body=_('Rework MO %s created.') % rework.name,
)
return {
'type': 'ir.actions.act_window',
'name': _('Rework MO'),
'res_model': 'mrp.production',
'res_id': rework.id,
'view_mode': 'form',
'target': 'current',
}
def action_configure_recipe_steps(self):
"""Open the wizard to configure opt-in/out steps for this job."""
self.ensure_one()
@@ -175,6 +276,32 @@ class MrpProduction(models.Model):
len(wo_vals_list), production.x_fc_recipe_id.name),
)
# ------------------------------------------------------------------
# Recipe auto-assignment from SO coating config
# ------------------------------------------------------------------
def _auto_assign_recipe_from_so(self):
"""If no recipe is set, pull the default recipe from the SO's
coating config (fp.coating.config.recipe_id).
"""
for mo in self:
if mo.x_fc_recipe_id:
continue # Already set — respect planner's choice
if not mo.origin:
continue
so = self.env['sale.order'].search(
[('name', '=', mo.origin)], limit=1,
)
if not so or 'x_fc_coating_config_id' not in so._fields:
continue
coating = so.x_fc_coating_config_id
if coating and coating.recipe_id:
mo.x_fc_recipe_id = coating.recipe_id
mo.message_post(
body=_('Recipe "%s" auto-assigned from coating config "%s".') % (
coating.recipe_id.name, coating.name,
),
)
# ------------------------------------------------------------------
# GAP 2: SO confirm → MO confirm → auto-create Portal Job + WOs
# ------------------------------------------------------------------
@@ -182,6 +309,9 @@ class MrpProduction(models.Model):
"""Override to auto-create a portal job and generate work orders
from the assigned recipe when the MO is confirmed.
"""
# Auto-assign recipe BEFORE super() so work-order generation sees it
self._auto_assign_recipe_from_so()
res = super().action_confirm()
PortalJob = self.env['fusion.plating.portal.job']
for mo in self:
@@ -219,28 +349,67 @@ class MrpProduction(models.Model):
return res
# ------------------------------------------------------------------
# GAP 3+4: MO done → update portal job + auto-create delivery
# GAP 3+4+5: MO done → portal job ready + delivery draft + CoC draft
# ------------------------------------------------------------------
def button_mark_done(self):
"""Override to cascade MO completion to portal job and delivery."""
"""Override to cascade MO completion to portal job, delivery,
and an auto-generated draft Certificate of Conformance."""
res = super().button_mark_done()
Delivery = self.env.get('fusion.plating.delivery')
Certificate = self.env.get('fp.certificate')
for mo in self:
job = mo.x_fc_portal_job_id
if not job:
continue
# GAP 3: MO done → portal job ready_to_ship
job.write({'state': 'ready_to_ship'})
job.message_post(body='Manufacturing complete — ready to ship.')
# GAP 4: Auto-create delivery record
if Delivery is None:
continue
partner = job.partner_id
Delivery.create({
'partner_id': partner.id,
'job_ref': job.name,
'source_facility_id': mo.x_fc_facility_id.id if mo.x_fc_facility_id else False,
'state': 'draft',
})
# Portal job → ready_to_ship
job.write({'state': 'ready_to_ship'})
job.message_post(body=_('Manufacturing complete — ready to ship.'))
# Resolve SO for denormalized fields on the certificate
so = False
if mo.origin:
so = self.env['sale.order'].search(
[('name', '=', mo.origin)], limit=1,
)
# Auto-create draft delivery record
if Delivery is not None:
Delivery.create({
'partner_id': job.partner_id.id,
'job_ref': job.name,
'source_facility_id': (
mo.x_fc_facility_id.id if mo.x_fc_facility_id else False
),
'state': 'draft',
})
# Auto-create draft Certificate of Conformance
if Certificate is not None:
# Skip if a CoC already exists for this MO
existing = Certificate.search(
[('production_id', '=', mo.id), ('certificate_type', '=', 'coc')],
limit=1,
)
if not existing:
coating = so.x_fc_coating_config_id if (
so and 'x_fc_coating_config_id' in so._fields
) else False
cert_vals = {
'certificate_type': 'coc',
'partner_id': job.partner_id.id,
'production_id': mo.id,
'portal_job_id': job.id,
'sale_order_id': so.id if so else False,
'quantity_shipped': int(mo.product_qty),
'po_number': so.x_fc_po_number if (
so and 'x_fc_po_number' in so._fields
) else False,
'entech_wo_number': mo.name,
'spec_reference': coating.spec_reference if coating else False,
'process_description': coating.name if coating else False,
'part_number': mo.product_id.default_code or '',
'state': 'draft',
}
Certificate.create(cert_vals)
return res

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
from odoo import _, api, fields, models
class MrpWorkorder(models.Model):
@@ -32,7 +32,12 @@ class MrpWorkorder(models.Model):
x_fc_tank_id = fields.Many2one(
'fusion.plating.tank', string='Tank',
)
x_fc_rack_ref = fields.Char(string='Rack / Fixture Ref')
x_fc_rack_ref = fields.Char(string='Rack / Fixture Ref (legacy)')
x_fc_rack_id = fields.Many2one(
'fusion.plating.rack', string='Rack / Fixture',
domain="[('state', '!=', 'retired')]",
tracking=True,
)
x_fc_thickness_target = fields.Float(string='Target Thickness')
x_fc_thickness_uom = fields.Selection(
[('mils', 'mils'), ('microns', '\u00b5m')],
@@ -397,3 +402,58 @@ class MrpWorkorder(models.Model):
'part_ref': n.part_ref or '',
})
return {'holds': holds, 'ncrs': ncrs}
# ------------------------------------------------------------------
# T1.1 — Bake window auto-create on plating WO finish
# T1.3 — Rack MTO increment when a rack was used
# ------------------------------------------------------------------
def button_finish(self):
"""Finish the WO, bump rack MTO, spawn bake window if required."""
res = super().button_finish()
for wo in self:
if wo.x_fc_rack_id:
wo.x_fc_rack_id._increment_mto(1.0)
self._fp_spawn_bake_window_if_needed()
return res
def _fp_spawn_bake_window_if_needed(self):
"""Create a fusion.plating.bake.window record if the MO's coating
config requires it and this WO was the plating step.
"""
BakeWindow = self.env.get('fusion.plating.bake.window')
if BakeWindow is None:
return
for wo in self:
if not wo.x_fc_bath_id:
continue # Not a bath step
so = wo.x_fc_sale_order_id
coating = so.x_fc_coating_config_id if (
so and 'x_fc_coating_config_id' in so._fields
) else False
if not coating or not getattr(coating, 'requires_bake_relief', False):
continue
# Only fire on the *plating* WO — the one whose bath's process
# matches the coating config's process.
if wo.x_fc_bath_id.process_type_id != coating.process_type_id:
continue
# De-dup: don't create a second window for the same lot
existing = BakeWindow.search([
('lot_ref', '=', wo.production_id.x_fc_portal_job_id.name or wo.production_id.name),
], limit=1)
if existing:
continue
BakeWindow.create({
'bath_id': wo.x_fc_bath_id.id,
'part_ref': wo.production_id.product_id.default_code or wo.production_id.product_id.name,
'lot_ref': wo.production_id.x_fc_portal_job_id.name or wo.production_id.name,
'customer_ref': wo.x_fc_customer_id.name or '',
'quantity': int(wo.production_id.product_qty or 0),
'window_hours': coating.bake_window_hours or 4.0,
'plate_exit_time': fields.Datetime.now(),
})
wo.production_id.message_post(
body=_(
'Bake-window record created — relief bake must start '
'within %s hours of plate exit.'
) % (coating.bake_window_hours or 4.0)
)

View File

@@ -18,12 +18,23 @@
<group>
<field name="x_fc_customer_spec_id"/>
<field name="x_fc_facility_id"/>
<field name="x_fc_current_location" readonly="1"/>
</group>
<group>
<field name="x_fc_portal_job_id"/>
<field name="x_fc_recipe_id"/>
</group>
</group>
<group string="Rework" name="rework"
invisible="not x_fc_is_rework and not x_fc_original_production_id">
<group>
<field name="x_fc_is_rework" readonly="1"/>
<field name="x_fc_original_production_id" readonly="1"/>
</group>
<group>
<field name="x_fc_rework_reason"/>
</group>
</group>
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
@@ -33,6 +44,20 @@
<field name="x_fc_override_count" widget="statinfo"
string="Overrides"/>
</button>
<button name="action_view_reworks" type="object"
class="oe_stat_button" icon="fa-recycle"
invisible="x_fc_rework_count == 0">
<field name="x_fc_rework_count" widget="statinfo"
string="Reworks"/>
</button>
<button name="action_create_rework" type="object"
class="oe_stat_button" icon="fa-refresh"
invisible="state != 'done' or x_fc_is_rework"
confirm="Create a rework MO from this completed order?">
<div class="o_stat_info">
<span class="o_stat_text">Create Rework</span>
</div>
</button>
</xpath>
</field>

View File

@@ -146,6 +146,7 @@
<field name="x_fc_facility_id"/>
<field name="x_fc_bath_id"/>
<field name="x_fc_tank_id"/>
<field name="x_fc_rack_id"/>
<field name="x_fc_rack_ref"/>
</group>
<group string="Process Parameters">