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