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',
'version': '19.0.1.0.0',
'version': '19.0.2.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -90,6 +90,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_facility_views.xml',
'views/fp_bath_views.xml',
'views/fp_process_node_views.xml',
'views/fp_rack_views.xml',
'views/fp_bath_replenishment_views.xml',
'views/fp_menu.xml',
'data/fp_recipe_enp_alum_basic.xml',
],

View File

@@ -12,5 +12,7 @@ from . import fp_bath
from . import fp_bath_log
from . import fp_bath_log_line
from . import fp_bath_parameter
from . import fp_bath_replenishment_rule
from . import fp_process_node
from . import fp_rack
from . import res_company

View File

@@ -112,3 +112,47 @@ class FpBathLogLine(models.Model):
rec.status = 'warning'
else:
rec.status = 'ok'
# ------------------------------------------------------------------
# T1.2 — Auto-suggest replenishment on every log line
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
lines = super().create(vals_list)
lines._spawn_replenishment_suggestions()
return lines
def _spawn_replenishment_suggestions(self):
"""For every out-of-spec reading, run the matching replenishment
rule and create a pending suggestion the operator can apply."""
Rule = self.env['fusion.plating.bath.replenishment.rule']
Suggestion = self.env['fusion.plating.bath.replenishment.suggestion']
for line in self:
if not line.parameter_id or not line.log_id.bath_id:
continue
bath = line.log_id.bath_id
rules = Rule._find_rules(bath, line.parameter_id.id)
for rule in rules:
dose = rule._compute_dose(
line.value, line.target_min, line.target_max, bath.volume,
)
if dose <= 0:
continue
Suggestion.create({
'bath_id': bath.id,
'log_line_id': line.id,
'rule_id': rule.id,
'parameter_id': line.parameter_id.id,
'current_value': line.value,
'target_min': line.target_min,
'target_max': line.target_max,
'product_name': rule.product_name,
'dose_amount': dose,
'dose_uom': rule.dose_uom,
'state': 'pending',
})
bath.message_post(
body=f'Replenishment suggested: add {dose} {rule.dose_uom} '
f'of {rule.product_name} ({line.parameter_id.name} '
f'reading: {line.value})',
)

View File

@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpBathReplenishmentRule(models.Model):
"""Linear replenishment rule: when a chemistry reading drifts outside
target, calculate how much replenisher to add.
The formula is deliberately simple:
dose = deficit × bath.volume × dose_rate
where deficit = (target_min value) for below_min rules
or = (value target_max) for above_max rules.
Shops wanting non-linear or piecewise rules can extend this model.
"""
_name = 'fusion.plating.bath.replenishment.rule'
_description = 'Fusion Plating — Replenishment Rule'
_order = 'process_type_id, parameter_id'
name = fields.Char(string='Rule Name', required=True)
process_type_id = fields.Many2one(
'fusion.plating.process.type', string='Process Type',
help='If set, this rule applies to every bath running this process. '
'Leave blank and set bath_id for a bath-specific rule.',
)
bath_id = fields.Many2one(
'fusion.plating.bath', string='Specific Bath',
help='Narrow the rule to a single bath (overrides process-level rule).',
)
parameter_id = fields.Many2one(
'fusion.plating.bath.parameter', string='Parameter', required=True,
)
trigger = fields.Selection(
[('below_min', 'Reading Below Target Min'),
('above_max', 'Reading Above Target Max')],
string='Trigger', required=True, default='below_min',
)
product_name = fields.Char(
string='Replenisher Name', required=True,
help='Human-readable chemical name, e.g. "Nickel Sulfamate 30% — Grade A"',
)
product_id = fields.Many2one(
'product.product', string='Product (Inventory)',
help='Optional link to an inventory product for consumption tracking.',
)
dose_rate = fields.Float(
string='Dose Rate', required=True, digits=(12, 4),
help='Amount of replenisher per unit of parameter deficit per gallon '
'of bath volume. E.g. 0.5 means "add 0.5 mL per (g/L deficit) per gallon".',
)
dose_uom = fields.Selection(
[('ml', 'mL'), ('oz', 'fl oz'), ('g', 'g'), ('lb', 'lb'), ('l', 'L')],
string='Dose UoM', required=True, default='ml',
)
min_dose = fields.Float(
string='Minimum Dose', default=0.0,
help='Do not suggest doses below this (useful to avoid noise).',
)
max_dose = fields.Float(
string='Safety Cap', default=0.0,
help='Cap the suggested dose. 0 = no cap.',
)
notes = fields.Text(string='Operator Notes')
active = fields.Boolean(default=True)
@api.model
def _find_rules(self, bath, parameter_id):
"""Return rules applicable to this (bath, parameter). Bath-specific
rules take precedence over process-level ones.
"""
bath_rule = self.search([
('bath_id', '=', bath.id),
('parameter_id', '=', parameter_id),
('active', '=', True),
])
if bath_rule:
return bath_rule
return self.search([
('bath_id', '=', False),
('process_type_id', '=', bath.process_type_id.id),
('parameter_id', '=', parameter_id),
('active', '=', True),
])
def _compute_dose(self, value, target_min, target_max, bath_volume):
"""Return a dose amount for this rule given the reading context.
Returns 0.0 if the trigger doesn't apply.
"""
self.ensure_one()
deficit = 0.0
if self.trigger == 'below_min' and target_min and value < target_min:
deficit = target_min - value
elif self.trigger == 'above_max' and target_max and value > target_max:
deficit = value - target_max
if deficit <= 0:
return 0.0
dose = deficit * (bath_volume or 1.0) * self.dose_rate
if self.min_dose and dose < self.min_dose:
return 0.0
if self.max_dose and dose > self.max_dose:
dose = self.max_dose
return round(dose, 3)
class FpBathReplenishmentSuggestion(models.Model):
"""One suggestion generated from a bath-log reading. Operators mark
them applied or dismissed once the dose has been added."""
_name = 'fusion.plating.bath.replenishment.suggestion'
_description = 'Fusion Plating — Replenishment Suggestion'
_inherit = ['mail.thread']
_order = 'create_date desc, id desc'
bath_id = fields.Many2one(
'fusion.plating.bath', string='Bath', required=True, ondelete='cascade',
)
log_line_id = fields.Many2one(
'fusion.plating.bath.log.line', string='Triggering Reading',
ondelete='cascade',
)
rule_id = fields.Many2one(
'fusion.plating.bath.replenishment.rule', string='Rule',
ondelete='set null',
)
parameter_id = fields.Many2one(
'fusion.plating.bath.parameter', string='Parameter', required=True,
)
current_value = fields.Float(string='Current Reading', digits=(12, 4))
target_min = fields.Float(string='Target Min', digits=(12, 4))
target_max = fields.Float(string='Target Max', digits=(12, 4))
product_name = fields.Char(string='Replenisher', required=True)
dose_amount = fields.Float(string='Suggested Dose', digits=(12, 3))
dose_uom = fields.Selection(
[('ml', 'mL'), ('oz', 'fl oz'), ('g', 'g'), ('lb', 'lb'), ('l', 'L')],
string='UoM', required=True, default='ml',
)
state = fields.Selection(
[('pending', 'Pending'), ('applied', 'Applied'), ('dismissed', 'Dismissed')],
default='pending', tracking=True,
)
applied_at = fields.Datetime(readonly=True)
applied_by_id = fields.Many2one('res.users', readonly=True)
def action_apply(self):
for rec in self:
rec.write({
'state': 'applied',
'applied_at': fields.Datetime.now(),
'applied_by_id': self.env.user.id,
})
rec.bath_id.message_post(
body=f'Replenishment applied: {rec.dose_amount} {rec.dose_uom} '
f'of {rec.product_name} (parameter: {rec.parameter_id.name})'
)
def action_dismiss(self):
self.write({'state': 'dismissed'})

View File

@@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models, _
class FpRack(models.Model):
"""Plating rack / barrel / fixture.
Racks carry parts through baths and accumulate nickel themselves over
time. Once the rack's metal turnover (MTO) count exceeds the strip
interval, the rack must be stripped before re-use to avoid bald spots
on parts.
"""
_name = 'fusion.plating.rack'
_description = 'Fusion Plating — Rack / Fixture'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'facility_id, rack_type, name'
name = fields.Char(string='Rack ID', required=True, tracking=True)
rack_type = fields.Selection(
[('rack', 'Rack'), ('barrel', 'Barrel'),
('fixture', 'Fixture'), ('basket', 'Basket')],
string='Type', required=True, default='rack',
)
facility_id = fields.Many2one(
'fusion.plating.facility', string='Facility', required=True, tracking=True,
)
company_id = fields.Many2one(
'res.company', related='facility_id.company_id', store=True, readonly=True,
)
capacity = fields.Integer(
string='Capacity (parts)',
help='Max parts per load. Used for batch planning.',
)
contact_points = fields.Integer(
string='Contact Points',
help='Number of clips/tips that touch parts. Wear points for re-stripping.',
)
# --- Wear tracking ---
mto_count = fields.Float(
string='MTO (current)', default=0.0, tracking=True,
help='Metal turnover accumulated since last strip.',
)
strip_interval_mto = fields.Float(
string='Strip After (MTO)', default=3.0,
help='When MTO crosses this value, rack needs stripping.',
)
last_stripped_date = fields.Datetime(string='Last Stripped', tracking=True)
last_stripped_by_id = fields.Many2one(
'res.users', string='Stripped By', tracking=True,
)
strips_count = fields.Integer(string='Total Strips', default=0, readonly=True)
state = fields.Selection(
[('active', 'Active'),
('needs_strip', 'Needs Strip'),
('stripping', 'Stripping'),
('retired', 'Retired')],
string='Status', default='active', required=True, tracking=True,
compute='_compute_state', store=True, readonly=False,
)
status_color = fields.Integer(compute='_compute_status_color')
notes = fields.Html(string='Notes')
active = fields.Boolean(default=True)
_sql_constraints = [
('fp_rack_facility_name_uniq', 'unique(facility_id, name)',
'Rack ID must be unique per facility.'),
]
# ------------------------------------------------------------------
# Computes
# ------------------------------------------------------------------
@api.depends('mto_count', 'strip_interval_mto')
def _compute_state(self):
for rec in self:
if rec.state in ('stripping', 'retired'):
continue # Manually set — don't override
if rec.strip_interval_mto and rec.mto_count >= rec.strip_interval_mto:
rec.state = 'needs_strip'
elif rec.state != 'active':
rec.state = 'active'
@api.depends('state')
def _compute_status_color(self):
mapping = {'active': 4, 'needs_strip': 3, 'stripping': 2, 'retired': 10}
for rec in self:
rec.status_color = mapping.get(rec.state, 0)
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def action_start_strip(self):
self.write({'state': 'stripping'})
def action_mark_stripped(self):
for rec in self:
rec.write({
'state': 'active',
'mto_count': 0.0,
'last_stripped_date': fields.Datetime.now(),
'last_stripped_by_id': self.env.user.id,
'strips_count': rec.strips_count + 1,
})
rec.message_post(body=_('Rack stripped and returned to service.'))
def action_retire(self):
self.write({'state': 'retired', 'active': False})
def _increment_mto(self, delta=1.0):
"""Add `delta` to the rack's MTO count. Called by the WO finish hook."""
for rec in self:
rec.mto_count = (rec.mto_count or 0.0) + delta

View File

@@ -32,3 +32,12 @@ access_fp_process_node_manager,fp.process.node.manager,model_fusion_plating_proc
access_fp_process_node_input_operator,fp.process.node.input.operator,model_fusion_plating_process_node_input,group_fusion_plating_operator,1,0,0,0
access_fp_process_node_input_supervisor,fp.process.node.input.supervisor,model_fusion_plating_process_node_input,group_fusion_plating_supervisor,1,1,1,0
access_fp_process_node_input_manager,fp.process.node.input.manager,model_fusion_plating_process_node_input,group_fusion_plating_manager,1,1,1,1
access_fp_rack_operator,fp.rack.operator,model_fusion_plating_rack,group_fusion_plating_operator,1,1,0,0
access_fp_rack_supervisor,fp.rack.supervisor,model_fusion_plating_rack,group_fusion_plating_supervisor,1,1,1,0
access_fp_rack_manager,fp.rack.manager,model_fusion_plating_rack,group_fusion_plating_manager,1,1,1,1
access_fp_replenishment_rule_operator,fp.replenishment.rule.operator,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_operator,1,0,0,0
access_fp_replenishment_rule_supervisor,fp.replenishment.rule.supervisor,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_supervisor,1,1,1,0
access_fp_replenishment_rule_manager,fp.replenishment.rule.manager,model_fusion_plating_bath_replenishment_rule,group_fusion_plating_manager,1,1,1,1
access_fp_replenishment_suggestion_operator,fp.replenishment.suggestion.operator,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_operator,1,1,1,0
access_fp_replenishment_suggestion_supervisor,fp.replenishment.suggestion.supervisor,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_supervisor,1,1,1,0
access_fp_replenishment_suggestion_manager,fp.replenishment.suggestion.manager,model_fusion_plating_bath_replenishment_suggestion,group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
32 access_fp_process_node_input_operator fp.process.node.input.operator model_fusion_plating_process_node_input group_fusion_plating_operator 1 0 0 0
33 access_fp_process_node_input_supervisor fp.process.node.input.supervisor model_fusion_plating_process_node_input group_fusion_plating_supervisor 1 1 1 0
34 access_fp_process_node_input_manager fp.process.node.input.manager model_fusion_plating_process_node_input group_fusion_plating_manager 1 1 1 1
35 access_fp_rack_operator fp.rack.operator model_fusion_plating_rack group_fusion_plating_operator 1 1 0 0
36 access_fp_rack_supervisor fp.rack.supervisor model_fusion_plating_rack group_fusion_plating_supervisor 1 1 1 0
37 access_fp_rack_manager fp.rack.manager model_fusion_plating_rack group_fusion_plating_manager 1 1 1 1
38 access_fp_replenishment_rule_operator fp.replenishment.rule.operator model_fusion_plating_bath_replenishment_rule group_fusion_plating_operator 1 0 0 0
39 access_fp_replenishment_rule_supervisor fp.replenishment.rule.supervisor model_fusion_plating_bath_replenishment_rule group_fusion_plating_supervisor 1 1 1 0
40 access_fp_replenishment_rule_manager fp.replenishment.rule.manager model_fusion_plating_bath_replenishment_rule group_fusion_plating_manager 1 1 1 1
41 access_fp_replenishment_suggestion_operator fp.replenishment.suggestion.operator model_fusion_plating_bath_replenishment_suggestion group_fusion_plating_operator 1 1 1 0
42 access_fp_replenishment_suggestion_supervisor fp.replenishment.suggestion.supervisor model_fusion_plating_bath_replenishment_suggestion group_fusion_plating_supervisor 1 1 1 0
43 access_fp_replenishment_suggestion_manager fp.replenishment.suggestion.manager model_fusion_plating_bath_replenishment_suggestion group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,151 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===== Rule List ===== -->
<record id="view_fp_replenishment_rule_list" model="ir.ui.view">
<field name="name">fp.replenishment.rule.list</field>
<field name="model">fusion.plating.bath.replenishment.rule</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="process_type_id"/>
<field name="bath_id"/>
<field name="parameter_id"/>
<field name="trigger"/>
<field name="product_name"/>
<field name="dose_rate"/>
<field name="dose_uom"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Rule Form ===== -->
<record id="view_fp_replenishment_rule_form" model="ir.ui.view">
<field name="name">fp.replenishment.rule.form</field>
<field name="model">fusion.plating.bath.replenishment.rule</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_title">
<h1><field name="name"/></h1>
</div>
<group>
<group string="Scope">
<field name="process_type_id"/>
<field name="bath_id"/>
<field name="parameter_id"/>
<field name="trigger"/>
</group>
<group string="Dose">
<field name="product_name"/>
<field name="product_id"/>
<field name="dose_rate"/>
<field name="dose_uom"/>
<field name="min_dose"/>
<field name="max_dose"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2"/>
</group>
<group>
<field name="active" widget="boolean_toggle"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fp_replenishment_rule" model="ir.actions.act_window">
<field name="name">Replenishment Rules</field>
<field name="res_model">fusion.plating.bath.replenishment.rule</field>
<field name="view_mode">list,form</field>
</record>
<!-- ===== Suggestion List ===== -->
<record id="view_fp_replenishment_suggestion_list" model="ir.ui.view">
<field name="name">fp.replenishment.suggestion.list</field>
<field name="model">fusion.plating.bath.replenishment.suggestion</field>
<field name="arch" type="xml">
<list decoration-info="state == 'pending'"
decoration-muted="state in ('applied','dismissed')"
default_order="create_date desc">
<field name="create_date" optional="show"/>
<field name="bath_id"/>
<field name="parameter_id"/>
<field name="current_value"/>
<field name="target_min"/>
<field name="target_max"/>
<field name="product_name"/>
<field name="dose_amount"/>
<field name="dose_uom"/>
<field name="state" widget="badge"
decoration-info="state == 'pending'"
decoration-success="state == 'applied'"
decoration-muted="state == 'dismissed'"/>
<button name="action_apply" type="object"
string="Apply" class="btn-primary"
invisible="state != 'pending'" icon="fa-check"/>
<button name="action_dismiss" type="object"
string="Dismiss"
invisible="state != 'pending'" icon="fa-times"/>
</list>
</field>
</record>
<!-- ===== Suggestion Form ===== -->
<record id="view_fp_replenishment_suggestion_form" model="ir.ui.view">
<field name="name">fp.replenishment.suggestion.form</field>
<field name="model">fusion.plating.bath.replenishment.suggestion</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_apply" string="Apply"
type="object" class="btn-primary"
invisible="state != 'pending'"/>
<button name="action_dismiss" string="Dismiss"
type="object" class="btn-secondary"
invisible="state != 'pending'"/>
<field name="state" widget="statusbar"
statusbar_visible="pending,applied"/>
</header>
<sheet>
<group>
<group string="Context">
<field name="bath_id"/>
<field name="parameter_id"/>
<field name="log_line_id"/>
<field name="rule_id"/>
</group>
<group string="Reading vs Target">
<field name="current_value"/>
<field name="target_min"/>
<field name="target_max"/>
</group>
</group>
<group string="Suggested Dose">
<group>
<field name="product_name"/>
<field name="dose_amount"/>
<field name="dose_uom"/>
</group>
<group>
<field name="applied_at"/>
<field name="applied_by_id"/>
</group>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_fp_replenishment_suggestion" model="ir.actions.act_window">
<field name="name">Replenishment Suggestions</field>
<field name="res_model">fusion.plating.bath.replenishment.suggestion</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_pending': 1}</field>
</record>
</odoo>

View File

@@ -43,6 +43,24 @@
action="action_fp_tank"
sequence="30"/>
<menuitem id="menu_fp_racks"
name="Racks &amp; Fixtures"
parent="menu_fp_operations"
action="action_fp_rack"
sequence="35"/>
<menuitem id="menu_fp_replenishment_suggestions"
name="Replenishment Suggestions"
parent="menu_fp_operations"
action="action_fp_replenishment_suggestion"
sequence="40"/>
<menuitem id="menu_fp_replenishment_rules"
name="Replenishment Rules"
parent="menu_fp_config"
action="action_fp_replenishment_rule"
sequence="55"/>
<!-- ===== CONFIGURATION ===== -->
<menuitem id="menu_fp_config"
name="Configuration"

View File

@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===== List ===== -->
<record id="view_fp_rack_list" model="ir.ui.view">
<field name="name">fusion.plating.rack.list</field>
<field name="model">fusion.plating.rack</field>
<field name="arch" type="xml">
<list decoration-danger="state == 'needs_strip'"
decoration-warning="state == 'stripping'"
decoration-muted="state == 'retired'">
<field name="name"/>
<field name="rack_type"/>
<field name="facility_id"/>
<field name="capacity"/>
<field name="mto_count"/>
<field name="strip_interval_mto"/>
<field name="last_stripped_date"/>
<field name="strips_count"/>
<field name="state" widget="badge"
decoration-success="state == 'active'"
decoration-danger="state == 'needs_strip'"
decoration-warning="state == 'stripping'"
decoration-muted="state == 'retired'"/>
</list>
</field>
</record>
<!-- ===== Form ===== -->
<record id="view_fp_rack_form" model="ir.ui.view">
<field name="name">fusion.plating.rack.form</field>
<field name="model">fusion.plating.rack</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_start_strip"
string="Start Strip"
type="object" class="btn-primary"
invisible="state != 'needs_strip'"/>
<button name="action_mark_stripped"
string="Mark Stripped"
type="object" class="btn-primary"
invisible="state != 'stripping'"/>
<button name="action_retire"
string="Retire"
type="object" class="btn-secondary"
invisible="state == 'retired'"
confirm="Retire this rack permanently?"/>
<field name="state" widget="statusbar"
statusbar_visible="active,needs_strip,stripping"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. RACK-014"/></h1>
</div>
<group>
<group string="Identity">
<field name="rack_type"/>
<field name="facility_id"/>
<field name="capacity"/>
<field name="contact_points"/>
</group>
<group string="Wear &amp; Strip">
<field name="mto_count"/>
<field name="strip_interval_mto"/>
<field name="last_stripped_date"/>
<field name="last_stripped_by_id"/>
<field name="strips_count"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ===== Kanban ===== -->
<record id="view_fp_rack_kanban" model="ir.ui.view">
<field name="name">fusion.plating.rack.kanban</field>
<field name="model">fusion.plating.rack</field>
<field name="arch" type="xml">
<kanban default_group_by="state">
<field name="status_color"/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_card oe_kanban_global_click oe_kanban_color_#{record.status_color.raw_value}">
<strong><field name="name"/></strong>
<div><field name="rack_type"/><field name="facility_id"/></div>
<div>MTO: <field name="mto_count"/> / <field name="strip_interval_mto"/></div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===== Search ===== -->
<record id="view_fp_rack_search" model="ir.ui.view">
<field name="name">fusion.plating.rack.search</field>
<field name="model">fusion.plating.rack</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="facility_id"/>
<filter name="needs_strip" string="Needs Strip"
domain="[('state', '=', 'needs_strip')]"/>
<filter name="active" string="Active"
domain="[('state', '=', 'active')]"/>
<separator/>
<group>
<filter name="group_facility" string="Facility"
context="{'group_by': 'facility_id'}"/>
<filter name="group_type" string="Type"
context="{'group_by': 'rack_type'}"/>
<filter name="group_state" string="Status"
context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_rack" model="ir.actions.act_window">
<field name="name">Racks &amp; Fixtures</field>
<field name="res_model">fusion.plating.rack</field>
<field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="view_fp_rack_search"/>
</record>
</odoo>

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">

View File

@@ -5,3 +5,4 @@
from . import controllers
from . import models
from . import wizard

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.1.0.0',
'version': '19.0.2.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -39,6 +39,8 @@ Provides:
'security/ir.model.access.csv',
'data/fp_configurator_sequence_data.xml',
'data/fp_treatment_data.xml',
'wizard/fp_direct_order_wizard_views.xml',
'wizard/fp_part_catalog_import_wizard_views.xml',
'views/fp_treatment_views.xml',
'views/fp_part_catalog_views.xml',
'views/fp_coating_config_views.xml',

View File

@@ -51,6 +51,27 @@ class FpCoatingConfig(models.Model):
'fp.treatment', 'fp_coating_config_post_treatment_rel', 'config_id', 'treatment_id',
string='Post-Treatments', domain="[('treatment_type', '=', 'post')]",
)
# ---- Hydrogen embrittlement relief (AMS 2759/9) ----
requires_bake_relief = fields.Boolean(
string='Requires Bake Relief',
help='Hydrogen embrittlement relief bake required (high-strength steel, '
'Rockwell C ≥ 31). When set, finishing the plating WO auto-creates '
'a bake window record and blocks shipment until bake is complete.',
)
bake_window_hours = fields.Float(
string='Bake Window (hours)', default=4.0,
help='Maximum time between plate exit and bake start. Typically 4h per AMS 2759/9.',
)
bake_temperature = fields.Float(
string='Bake Temperature (°F)', default=375.0,
help='Relief bake temperature. Typical: 375°F for steel ≥ HRC 40.',
)
bake_duration_hours = fields.Float(
string='Bake Duration (hours)', default=23.0,
help='Minimum bake hold time at temperature. Typical: 23h.',
)
sequence = fields.Integer(string='Sequence', default=10)
description = fields.Text(string='Description')
active = fields.Boolean(string='Active', default=True)

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
from odoo import api, fields, models, _
class ResPartner(models.Model):
@@ -35,3 +35,27 @@ class ResPartner(models.Model):
'context': {'default_partner_id': self.id},
'target': 'current',
}
def action_fp_import_parts(self):
"""Open the CSV import wizard with this partner pre-selected."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Import Parts from CSV'),
'res_model': 'fp.part.catalog.import.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_partner_id': self.id},
}
def action_fp_new_direct_order(self):
"""Open the Direct Order wizard with this partner pre-selected."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('New Direct Order'),
'res_model': 'fp.direct.order.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_partner_id': self.id},
}

View File

@@ -17,3 +17,7 @@ access_fp_pricing_surcharge_manager,fp.pricing.complexity.surcharge.manager,mode
access_fp_quote_configurator_operator,fp.quote.configurator.operator,model_fp_quote_configurator,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_quote_configurator_estimator,fp.quote.configurator.estimator,model_fp_quote_configurator,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_quote_configurator_manager,fp.quote.configurator.manager,model_fp_quote_configurator,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_direct_order_wizard_estimator,fp.direct.order.wizard.estimator,model_fp_direct_order_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_direct_order_wizard_manager,fp.direct.order.wizard.manager,model_fp_direct_order_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_import_wizard_estimator,fp.part.catalog.import.wizard.estimator,model_fp_part_catalog_import_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_part_import_wizard_manager,fp.part.catalog.import.wizard.manager,model_fp_part_catalog_import_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
17 access_fp_quote_configurator_operator fp.quote.configurator.operator model_fp_quote_configurator fusion_plating.group_fusion_plating_operator 1 0 0 0
18 access_fp_quote_configurator_estimator fp.quote.configurator.estimator model_fp_quote_configurator fusion_plating_configurator.group_fp_estimator 1 1 1 0
19 access_fp_quote_configurator_manager fp.quote.configurator.manager model_fp_quote_configurator fusion_plating.group_fusion_plating_manager 1 1 1 1
20 access_fp_direct_order_wizard_estimator fp.direct.order.wizard.estimator model_fp_direct_order_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
21 access_fp_direct_order_wizard_manager fp.direct.order.wizard.manager model_fp_direct_order_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
22 access_fp_part_import_wizard_estimator fp.part.catalog.import.wizard.estimator model_fp_part_catalog_import_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
23 access_fp_part_import_wizard_manager fp.part.catalog.import.wizard.manager model_fp_part_catalog_import_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -28,6 +28,12 @@
action="action_fp_quotations"
sequence="10"/>
<menuitem id="menu_fp_direct_order"
name="New Direct Order"
parent="menu_fp_sales"
action="action_fp_direct_order_wizard"
sequence="15"/>
<menuitem id="menu_fp_sale_orders"
name="Sale Orders"
parent="menu_fp_sales"
@@ -46,6 +52,12 @@
action="action_fp_part_catalog"
sequence="40"/>
<menuitem id="menu_fp_part_catalog_import"
name="Import Parts (CSV)"
parent="menu_fp_sales"
action="action_fp_part_catalog_import_wizard"
sequence="45"/>
<!-- ===== CONFIGURATOR submenu ===== -->
<menuitem id="menu_fp_configurator"
name="Configurator"

View File

@@ -15,6 +15,24 @@
invisible="x_fc_part_count == 0">
<field name="x_fc_part_count" widget="statinfo" string="Parts"/>
</button>
<button name="action_fp_new_direct_order"
type="object"
class="oe_stat_button"
icon="fa-shopping-cart"
invisible="customer_rank == 0">
<div class="o_stat_info">
<span class="o_stat_text">Direct Order</span>
</div>
</button>
<button name="action_fp_import_parts"
type="object"
class="oe_stat_button"
icon="fa-upload"
invisible="customer_rank == 0">
<div class="o_stat_info">
<span class="o_stat_text">Import Parts</span>
</div>
</button>
</xpath>
<xpath expr="//page[@name='sales_purchases']" position="after">

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_direct_order_wizard
from . import fp_part_catalog_import_wizard

View File

@@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class FpDirectOrderWizard(models.TransientModel):
"""Direct order entry for repeat customers.
Skips the quotation stage when the customer has already sent a PO.
Creates a sale.order and calls action_confirm() in one step.
Optionally bumps the part catalog revision when a new drawing is uploaded.
"""
_name = 'fp.direct.order.wizard'
_description = 'Fusion Plating — Direct Order Entry'
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True,
domain="[('customer_rank', '>', 0)]",
)
# Part selection
part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part', required=True,
domain="[('partner_id', '=', partner_id), ('is_latest_revision', '=', True)]",
)
part_number = fields.Char(related='part_catalog_id.part_number', readonly=True)
current_revision = fields.Char(related='part_catalog_id.revision', readonly=True)
surface_area = fields.Float(
related='part_catalog_id.surface_area', readonly=True, digits=(12, 4),
)
surface_area_uom = fields.Selection(
related='part_catalog_id.surface_area_uom', readonly=True,
)
# Revision upload (optional — creates a new revision of the part)
create_new_revision = fields.Boolean(
string='This is a New Revision',
help='Check if the customer sent an updated drawing or 3D model. '
'A new part revision will be created and linked to this order.',
)
new_drawing_file = fields.Binary(
string='New Drawing / 3D Model',
help='STEP, STL, IGES, or PDF. Used when creating a new revision.',
)
new_drawing_filename = fields.Char(string='Filename')
revision_note = fields.Char(
string='Revision Note', help='What changed in this revision?',
)
# Order details
coating_config_id = fields.Many2one(
'fp.coating.config', string='Coating', required=True,
)
quantity = fields.Integer(string='Quantity', required=True, default=1)
unit_price = fields.Float(
string='Unit Price', digits=(12, 2),
help='Negotiated price per part. Leave blank to set later.',
)
rush_order = fields.Boolean(string='Rush Order')
delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'),
('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup')],
string='Delivery Method',
)
# PO (required — that's what makes this a "direct" order)
po_number = fields.Char(string='Customer PO #', required=True)
po_attachment_file = fields.Binary(string='PO Document', required=True)
po_attachment_filename = fields.Char(string='PO Filename')
# Invoice strategy (pulled from partner default if set)
invoice_strategy = fields.Selection(
[('deposit', 'Deposit'), ('progress', 'Progress Billing'),
('net_terms', 'Net Terms'), ('cod_prepay', 'COD / Prepay')],
string='Invoice Strategy',
)
deposit_percent = fields.Float(string='Deposit %')
notes = fields.Text(string='Internal Notes')
@api.onchange('partner_id')
def _onchange_partner_id(self):
"""Reset part selection when customer changes + pull invoice defaults."""
self.part_catalog_id = False
if self.partner_id and 'x_fc_default_invoice_strategy' in self.partner_id._fields:
self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
def action_create_order(self):
"""Create and confirm the sale order, optionally bumping part revision."""
self.ensure_one()
if self.create_new_revision and not self.new_drawing_file:
raise UserError(_(
'Please upload the new drawing when creating a new revision.'
))
if self.quantity <= 0:
raise UserError(_('Quantity must be positive.'))
# 1. Optional: create a new part revision from the uploaded drawing
part = self.part_catalog_id
if self.create_new_revision:
drawing_att = self.env['ir.attachment'].create({
'name': self.new_drawing_filename or 'drawing.pdf',
'datas': self.new_drawing_file,
'res_model': 'fp.part.catalog',
'res_id': part.id,
})
# action_create_revision returns an action dict; we keep the part
part.action_create_revision()
new_rev = self.env['fp.part.catalog'].search(
[('parent_part_id', '=', (part.parent_part_id or part).id),
('is_latest_revision', '=', True)],
limit=1, order='revision_number desc',
)
if new_rev:
new_rev.write({
'revision_note': self.revision_note or False,
})
# Attach drawing/model based on extension
fname = (self.new_drawing_filename or '').lower()
if fname.endswith(('.step', '.stp', '.stl', '.iges', '.igs', '.brep', '.brp')):
new_rev.model_attachment_id = drawing_att.id
else:
new_rev.drawing_attachment_ids = [(4, drawing_att.id)]
part = new_rev
# 2. Save the PO attachment
po_att = self.env['ir.attachment'].create({
'name': self.po_attachment_filename or 'po.pdf',
'datas': self.po_attachment_file,
'mimetype': 'application/pdf',
})
# 3. Find or create the generic plating service product (same as configurator)
product = self.env['product.product'].search(
[('default_code', '=', 'FP-SERVICE')], limit=1,
)
if not product:
product = self.env['product.product'].create({
'name': 'Plating Service',
'default_code': 'FP-SERVICE',
'type': 'service',
'list_price': 0,
'sale_ok': True,
'purchase_ok': False,
})
line_desc = '%s%s Rev %s (x%d)' % (
self.coating_config_id.name,
part.name,
part.revision or part.revision_number,
self.quantity,
)
so_vals = {
'partner_id': self.partner_id.id,
'x_fc_part_catalog_id': part.id,
'x_fc_coating_config_id': self.coating_config_id.id,
'x_fc_rush_order': self.rush_order,
'x_fc_delivery_method': self.delivery_method,
'x_fc_po_number': self.po_number,
'x_fc_po_attachment_id': po_att.id,
'x_fc_po_received': True,
'x_fc_invoice_strategy': self.invoice_strategy,
'x_fc_deposit_percent': self.deposit_percent,
'origin': 'Direct Order',
'note': self.notes or False,
'order_line': [(0, 0, {
'product_id': product.id,
'name': line_desc,
'product_uom_qty': self.quantity,
'price_unit': self.unit_price or 0.0,
})],
}
so = self.env['sale.order'].create(so_vals)
# Immediately confirm — skips quote/send step entirely
so.action_confirm()
so.message_post(
body=_(
'Direct order created from PO %s. Quotation stage skipped.'
) % self.po_number,
)
return {
'type': 'ir.actions.act_window',
'name': _('Sale Order'),
'res_model': 'sale.order',
'res_id': so.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fp_direct_order_wizard_form" model="ir.ui.view">
<field name="name">fp.direct.order.wizard.form</field>
<field name="model">fp.direct.order.wizard</field>
<field name="arch" type="xml">
<form string="Direct Order Entry">
<sheet>
<div class="oe_title">
<h1>New Direct Order</h1>
<p class="text-muted">
Skip the quotation stage — create a confirmed order
when the customer has already sent a PO.
</p>
</div>
<group>
<group string="Customer">
<field name="partner_id" options="{'no_create_edit': True}"/>
</group>
<group string="Purchase Order">
<field name="po_number"/>
<field name="po_attachment_file" filename="po_attachment_filename"/>
<field name="po_attachment_filename" invisible="1"/>
</group>
</group>
<group string="Part">
<group>
<field name="part_catalog_id"
options="{'no_create_edit': True}"
context="{'default_partner_id': partner_id}"/>
<field name="part_number" invisible="not part_catalog_id"/>
<field name="current_revision" invisible="not part_catalog_id"/>
</group>
<group>
<field name="surface_area" invisible="not part_catalog_id"/>
<field name="surface_area_uom" invisible="not part_catalog_id"/>
</group>
</group>
<group string="New Revision (optional)">
<field name="create_new_revision"/>
<field name="new_drawing_file"
filename="new_drawing_filename"
invisible="not create_new_revision"
required="create_new_revision"/>
<field name="new_drawing_filename" invisible="1"/>
<field name="revision_note" invisible="not create_new_revision"/>
</group>
<group>
<group string="Order">
<field name="coating_config_id"/>
<field name="quantity"/>
<field name="unit_price"/>
</group>
<group string="Fulfilment">
<field name="rush_order"/>
<field name="delivery_method"/>
</group>
</group>
<group string="Invoicing">
<group>
<field name="invoice_strategy"/>
<field name="deposit_percent"
invisible="invoice_strategy != 'deposit'"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2" placeholder="Internal notes..."/>
</group>
</sheet>
<footer>
<button name="action_create_order"
type="object"
string="Create &amp; Confirm Order"
class="btn-primary"/>
<button string="Cancel" special="cancel" class="btn-secondary"/>
</footer>
</form>
</field>
</record>
<record id="action_fp_direct_order_wizard" model="ir.actions.act_window">
<field name="name">New Direct Order</field>
<field name="res_model">fp.direct.order.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,501 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import base64
import csv
import io
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------
# CSV column spec — order matters for the downloadable template
# ---------------------------------------------------------------------
CSV_COLUMNS = [
'part_number', # required
'name', # required
'customer', # required unless wizard.partner_id set
'revision',
'revision_number',
'substrate_material',
'surface_area',
'surface_area_uom',
'complexity',
'weight',
'dimensions_length',
'dimensions_width',
'dimensions_height',
'masking_zones',
'masking_description',
'has_blind_holes',
'has_recesses',
'has_threads',
'notes',
]
# Map human-friendly inputs to field-key values
SUBSTRATE_ALIASES = {
'aluminium': 'aluminium', 'aluminum': 'aluminium', 'al': 'aluminium',
'steel': 'steel', 'carbon steel': 'steel', 'cs': 'steel',
'stainless': 'stainless', 'stainless steel': 'stainless', 'ss': 'stainless',
'copper': 'copper', 'cu': 'copper',
'titanium': 'titanium', 'ti': 'titanium',
'other': 'other', '': 'steel',
}
UOM_ALIASES = {
'sq_in': 'sq_in', 'sq in': 'sq_in', 'sqin': 'sq_in', 'in2': 'sq_in', 'in²': 'sq_in',
'sq_ft': 'sq_ft', 'sq ft': 'sq_ft', 'sqft': 'sq_ft', 'ft2': 'sq_ft',
'sq_cm': 'sq_cm', 'sq cm': 'sq_cm', 'sqcm': 'sq_cm', 'cm2': 'sq_cm',
'sq_m': 'sq_m', 'sq m': 'sq_m', 'sqm': 'sq_m', 'm2': 'sq_m',
'': 'sq_in',
}
COMPLEXITY_ALIASES = {
'simple': 'simple', '1': 'simple', 'low': 'simple',
'moderate': 'moderate', '2': 'moderate', 'medium': 'moderate', 'med': 'moderate',
'complex': 'complex', '3': 'complex', 'high': 'complex',
'very_complex': 'very_complex', 'very complex': 'very_complex', '4': 'very_complex',
'': 'simple',
}
TRUE_VALUES = {'1', 'true', 'yes', 'y', 't'}
def _to_float(v, default=0.0):
if v is None or v == '':
return default
try:
return float(str(v).replace(',', '').strip())
except (ValueError, TypeError):
return default
def _to_int(v, default=0):
if v is None or v == '':
return default
try:
return int(float(str(v).replace(',', '').strip()))
except (ValueError, TypeError):
return default
def _to_bool(v):
if v is None or v == '':
return False
return str(v).strip().lower() in TRUE_VALUES
class FpPartCatalogImportWizard(models.TransientModel):
"""Two-step CSV import for the part catalog.
Step 1 (draft): user uploads CSV and clicks Preview.
Step 2 (preview): wizard shows row counts, first-10 errors, and an
Import button. User can fix and re-upload, or commit.
"""
_name = 'fp.part.catalog.import.wizard'
_description = 'Fusion Plating — Part Catalog CSV Import'
state = fields.Selection(
[('draft', 'Draft'), ('preview', 'Preview'), ('done', 'Done')],
default='draft',
)
csv_file = fields.Binary(string='CSV File', required=True)
csv_filename = fields.Char(string='Filename')
partner_id = fields.Many2one(
'res.partner', string='Default Customer',
domain="[('customer_rank', '>', 0)]",
help='Optional. If set, rows without a customer column use this. '
'If the CSV has a customer column, values there win.',
)
create_missing_customers = fields.Boolean(
string='Create Missing Customers',
help='If a customer name in the CSV does not exist, create it as a '
'new contact with customer_rank=1.',
)
skip_existing = fields.Boolean(
string='Skip Duplicates',
default=True,
help='When (customer, part_number) already exists, skip that row '
'instead of erroring.',
)
# Preview / result counters
total_rows = fields.Integer(readonly=True)
valid_rows = fields.Integer(readonly=True)
duplicate_rows = fields.Integer(readonly=True)
error_rows = fields.Integer(readonly=True)
created_count = fields.Integer(readonly=True)
preview_html = fields.Html(readonly=True)
# Hidden: stash the parsed rows between preview and import
parsed_rows_json = fields.Text()
# ---------------------------------------------------------------
# Actions
# ---------------------------------------------------------------
def action_download_template(self):
"""Return a minimal CSV template with just the header row."""
self.ensure_one()
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(CSV_COLUMNS)
# One example row for clarity
writer.writerow([
'PN-12345', 'Widget A', 'Acme Corp', 'Rev A', '1',
'steel', '12.5', 'sq_in', 'moderate', '0.4',
'50', '30', '20', '2', 'Mask threaded holes',
'no', 'no', 'yes', 'Example row — delete before import',
])
data = buf.getvalue().encode('utf-8')
att = self.env['ir.attachment'].create({
'name': 'fp_part_catalog_template.csv',
'type': 'binary',
'datas': base64.b64encode(data),
'mimetype': 'text/csv',
'res_model': self._name,
'res_id': self.id,
})
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{att.id}?download=true',
'target': 'self',
}
def action_preview(self):
"""Parse + validate the CSV. Does not write anything."""
self.ensure_one()
rows, parse_errors = self._parse_csv()
errors = list(parse_errors)
valid_rows = []
duplicates = []
Partner = self.env['res.partner']
Part = self.env['fp.part.catalog']
for i, row in enumerate(rows, start=2): # start=2 → row 1 is header
row_err = []
part_number = (row.get('part_number') or '').strip()
name = (row.get('name') or '').strip()
if not part_number:
row_err.append('part_number is required')
if not name:
row_err.append('name is required')
# Resolve customer
customer_raw = (row.get('customer') or '').strip()
partner = False
if customer_raw:
partner = self._find_partner(customer_raw)
if not partner and not self.create_missing_customers:
row_err.append(f'customer "{customer_raw}" not found')
elif self.partner_id:
partner = self.partner_id
else:
row_err.append(
'customer is required (column empty and no default set)'
)
# Normalise selection fields
substrate = SUBSTRATE_ALIASES.get(
(row.get('substrate_material') or '').strip().lower()
)
if substrate is None:
row_err.append(
f'substrate_material "{row.get("substrate_material")}" '
'not recognised'
)
substrate = 'steel'
uom = UOM_ALIASES.get(
(row.get('surface_area_uom') or '').strip().lower()
)
if uom is None:
row_err.append(
f'surface_area_uom "{row.get("surface_area_uom")}" '
'not recognised'
)
uom = 'sq_in'
complexity = COMPLEXITY_ALIASES.get(
(row.get('complexity') or '').strip().lower()
)
if complexity is None:
row_err.append(
f'complexity "{row.get("complexity")}" not recognised'
)
complexity = 'simple'
# Check duplicate if the customer resolved
is_duplicate = False
if partner and part_number:
existing = Part.search([
('partner_id', '=', partner.id),
('part_number', '=', part_number),
], limit=1)
if existing:
is_duplicate = True
if not self.skip_existing:
row_err.append(
f'part ({partner.name}, {part_number}) already exists'
)
if row_err:
errors.append({'row': i, 'errors': row_err, 'data': row})
continue
if is_duplicate:
duplicates.append({'row': i, 'customer': partner.name, 'part_number': part_number})
continue
# Build the prepared vals (no partner id yet — may need creating)
valid_rows.append({
'row': i,
'customer_raw': customer_raw,
'partner_id': partner.id if partner else None,
'vals': {
'part_number': part_number,
'name': name,
'revision': (row.get('revision') or '').strip() or False,
'revision_number': _to_int(row.get('revision_number'), 1),
'substrate_material': substrate,
'surface_area': _to_float(row.get('surface_area')),
'surface_area_uom': uom,
'complexity': complexity,
'weight': _to_float(row.get('weight')),
'dimensions_length': _to_float(row.get('dimensions_length')),
'dimensions_width': _to_float(row.get('dimensions_width')),
'dimensions_height': _to_float(row.get('dimensions_height')),
'masking_zones': _to_int(row.get('masking_zones')),
'masking_description': (row.get('masking_description') or '').strip() or False,
'has_blind_holes': _to_bool(row.get('has_blind_holes')),
'has_recesses': _to_bool(row.get('has_recesses')),
'has_threads': _to_bool(row.get('has_threads')),
'notes': (row.get('notes') or '').strip() or False,
'is_latest_revision': True,
},
})
# Stash parsed rows for the import step
import json
self.parsed_rows_json = json.dumps(valid_rows)
# Write counters
self.total_rows = len(rows)
self.valid_rows = len(valid_rows)
self.duplicate_rows = len(duplicates)
self.error_rows = len(errors)
self.preview_html = self._build_preview_html(errors, duplicates, rows)
self.state = 'preview'
return {
'type': 'ir.actions.act_window',
'name': _('Import Preview'),
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_import(self):
"""Commit the valid rows. Called from the Preview screen."""
self.ensure_one()
if self.state != 'preview':
raise UserError(_('Run Preview first.'))
import json
try:
valid_rows = json.loads(self.parsed_rows_json or '[]')
except ValueError:
raise UserError(_('Preview data lost — please Preview again.'))
Partner = self.env['res.partner']
Part = self.env['fp.part.catalog']
created = 0
# Resolve/create customers once per name, then create parts in bulk
partner_cache = {}
for row in valid_rows:
pid = row.get('partner_id')
if pid:
continue
name = row['customer_raw']
if name in partner_cache:
row['partner_id'] = partner_cache[name]
continue
partner = self._find_partner(name)
if not partner and self.create_missing_customers:
partner = Partner.create({
'name': name,
'customer_rank': 1,
})
if partner:
partner_cache[name] = partner.id
row['partner_id'] = partner.id
batch = []
for row in valid_rows:
if not row.get('partner_id'):
continue # Shouldn't happen after preview, but guard anyway
vals = dict(row['vals'])
vals['partner_id'] = row['partner_id']
batch.append(vals)
if batch:
Part.create(batch)
created = len(batch)
self.created_count = created
self.state = 'done'
return {
'type': 'ir.actions.act_window',
'name': _('Import Complete'),
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_open_imported_parts(self):
"""Open the part catalog filtered to the customer (if one was set)."""
self.ensure_one()
domain = [('is_latest_revision', '=', True)]
if self.partner_id:
domain.append(('partner_id', '=', self.partner_id.id))
return {
'type': 'ir.actions.act_window',
'name': _('Part Catalog'),
'res_model': 'fp.part.catalog',
'view_mode': 'list,form',
'domain': domain,
'target': 'current',
}
# ---------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------
def _parse_csv(self):
"""Decode the binary upload and yield dict rows."""
self.ensure_one()
if not self.csv_file:
raise UserError(_('Upload a CSV file first.'))
try:
raw = base64.b64decode(self.csv_file)
# Try utf-8 with BOM, fall back to latin-1
try:
text = raw.decode('utf-8-sig')
except UnicodeDecodeError:
text = raw.decode('latin-1')
except Exception as exc:
raise UserError(_('Could not decode the file: %s') % exc)
# Sniff delimiter (support comma / semicolon / tab)
try:
sample = text[:4096]
dialect = csv.Sniffer().sniff(sample, delimiters=',;\t')
except csv.Error:
dialect = csv.excel
reader = csv.DictReader(io.StringIO(text), dialect=dialect)
# Normalise header names: lowercase + underscores
def _norm(h):
return (h or '').strip().lower().replace(' ', '_')
reader.fieldnames = [_norm(h) for h in (reader.fieldnames or [])]
missing = [c for c in ('part_number', 'name') if c not in reader.fieldnames]
parse_errors = []
if missing:
parse_errors.append({
'row': 1,
'errors': [f'missing required columns: {", ".join(missing)}'],
'data': {},
})
return [], parse_errors
rows = []
for raw_row in reader:
# Keys already normalised; strip values
rows.append({k: (v.strip() if isinstance(v, str) else v)
for k, v in raw_row.items()})
return rows, parse_errors
def _find_partner(self, raw):
"""Match partner by external ID, email, or name (case-insensitive)."""
self.ensure_one()
Partner = self.env['res.partner']
raw = raw.strip()
if not raw:
return False
# External ID (module.xmlid)
if '.' in raw and ' ' not in raw:
try:
p = self.env.ref(raw, raise_if_not_found=False)
if p and p._name == 'res.partner':
return p
except Exception:
pass
# Email
if '@' in raw:
p = Partner.search([('email', '=ilike', raw)], limit=1)
if p:
return p
# Name (case-insensitive exact, then loose)
p = Partner.search([('name', '=ilike', raw)], limit=1)
if p:
return p
p = Partner.search([('name', 'ilike', raw)], limit=1)
return p or False
def _build_preview_html(self, errors, duplicates, all_rows):
"""Render a compact summary for the preview screen."""
pieces = []
pieces.append(
f'<div style="font-family: sans-serif;">'
f'<h3 style="margin:0 0 8px 0;">Import Preview</h3>'
f'<p style="margin:0 0 16px 0;">'
f'<b>{len(all_rows)}</b> rows parsed · '
f'<span style="color:#2e7d32;"><b>{self.valid_rows}</b> valid</span> · '
f'<span style="color:#f57f17;"><b>{self.duplicate_rows}</b> duplicates</span> · '
f'<span style="color:#c62828;"><b>{self.error_rows}</b> errors</span>'
f'</p>'
)
if errors:
pieces.append('<h4 style="margin:16px 0 8px 0;">Errors (first 10)</h4>')
pieces.append('<table style="width:100%;border-collapse:collapse;font-size:12px;">')
pieces.append(
'<tr style="background:#f5f5f5;">'
'<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;">Row</th>'
'<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;">Issue</th>'
'<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;">Part #</th>'
'<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;">Customer</th>'
'</tr>'
)
for e in errors[:10]:
pn = (e.get('data') or {}).get('part_number', '')
cust = (e.get('data') or {}).get('customer', '')
for msg in e['errors']:
pieces.append(
f'<tr>'
f'<td style="padding:4px 8px;border-bottom:1px solid #eee;">{e["row"]}</td>'
f'<td style="padding:4px 8px;border-bottom:1px solid #eee;color:#c62828;">{msg}</td>'
f'<td style="padding:4px 8px;border-bottom:1px solid #eee;">{pn}</td>'
f'<td style="padding:4px 8px;border-bottom:1px solid #eee;">{cust}</td>'
f'</tr>'
)
pieces.append('</table>')
if duplicates:
pieces.append(
f'<p style="margin:12px 0 0 0;color:#f57f17;font-size:12px;">'
f'{len(duplicates)} duplicate rows will be skipped.</p>'
)
pieces.append('</div>')
return ''.join(pieces)

View File

@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fp_part_catalog_import_wizard_form" model="ir.ui.view">
<field name="name">fp.part.catalog.import.wizard.form</field>
<field name="model">fp.part.catalog.import.wizard</field>
<field name="arch" type="xml">
<form string="Part Catalog CSV Import">
<field name="state" invisible="1"/>
<!-- ========== STEP 1: Upload ========== -->
<div invisible="state != 'draft'">
<sheet>
<div class="oe_title">
<h1>Import Parts from CSV</h1>
<p class="text-muted">
Bulk-load part catalog entries. The wizard validates
every row before writing — nothing is imported until
you approve the preview.
</p>
</div>
<group>
<group>
<field name="csv_file" filename="csv_filename"/>
<field name="csv_filename" invisible="1"/>
</group>
<group>
<field name="partner_id"
options="{'no_create_edit': True}"/>
<field name="create_missing_customers"/>
<field name="skip_existing"/>
</group>
</group>
<div class="alert alert-info" role="alert">
<strong>CSV format:</strong>
<code>part_number</code>, <code>name</code>, <code>customer</code>,
<code>revision</code>, <code>substrate_material</code>,
<code>surface_area</code>, <code>surface_area_uom</code>,
<code>complexity</code>, <code>weight</code>,
dimensions, masking, flags. The importer accepts
readable values — e.g. "Stainless Steel" maps to
<code>stainless</code>, "sq in" to <code>sq_in</code>.
</div>
</sheet>
<footer>
<button name="action_download_template"
type="object"
string="Download CSV Template"
class="btn-secondary"/>
<button name="action_preview"
type="object"
string="Preview"
class="btn-primary"
invisible="not csv_file"/>
<button string="Cancel" special="cancel"/>
</footer>
</div>
<!-- ========== STEP 2: Preview ========== -->
<div invisible="state != 'preview'">
<sheet>
<div class="oe_title">
<h1>Preview Import</h1>
</div>
<group>
<group>
<field name="total_rows" string="Rows Parsed"/>
<field name="valid_rows" string="Valid"/>
</group>
<group>
<field name="duplicate_rows" string="Duplicates (skipped)"/>
<field name="error_rows" string="Errors"/>
</group>
</group>
<field name="preview_html" nolabel="1" widget="html" readonly="1"/>
</sheet>
<footer>
<button name="action_import"
type="object"
string="Import Valid Rows"
class="btn-primary"
invisible="valid_rows == 0"/>
<button name="action_preview"
type="object"
string="Re-Preview"
class="btn-secondary"/>
<button string="Cancel" special="cancel"/>
</footer>
</div>
<!-- ========== STEP 3: Done ========== -->
<div invisible="state != 'done'">
<sheet>
<div class="oe_title">
<h1>Import Complete</h1>
<p class="text-muted">
<field name="created_count" readonly="1" nolabel="1" class="oe_inline"/>
parts created.
</p>
</div>
</sheet>
<footer>
<button name="action_open_imported_parts"
type="object"
string="View Parts"
class="btn-primary"/>
<button string="Close" special="cancel"/>
</footer>
</div>
</form>
</field>
</record>
<record id="action_fp_part_catalog_import_wizard" model="ir.actions.act_window">
<field name="name">Import Parts from CSV</field>
<field name="res_model">fp.part.catalog.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -5,16 +5,9 @@
{
'name': 'Fusion Plating — Notifications',
'version': '19.0.1.0.0',
'version': '19.0.2.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Auto-email notifications at workflow milestones with configurable templates and audit log.',
'description': """
Fusion Plating — Notifications
================================
Automated email notifications triggered at key workflow events:
SO confirmation, parts received, invoice posted, and more.
""",
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
@@ -26,8 +19,12 @@ SO confirmation, parts received, invoice posted, and more.
'fusion_plating_configurator',
'fusion_plating_receiving',
'fusion_plating_invoicing',
'fusion_plating_bridge_mrp',
'fusion_plating_logistics',
'fusion_plating_reports',
'sale_management',
'account',
'mrp',
'mail',
],
'data': [

View File

@@ -1,11 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="fp_notif_quote_sent" model="fp.notification.template">
<field name="name">Quotation Sent</field>
<field name="trigger_event">quote_sent</field>
<field name="mail_template_id" ref="fp_mail_template_quote_sent"/>
<field name="active" eval="True"/>
<field name="attach_quotation" eval="True"/>
</record>
<record id="fp_notif_so_confirmed" model="fp.notification.template">
<field name="name">Order Confirmation</field>
<field name="trigger_event">so_confirmed</field>
<field name="mail_template_id" ref="fp_mail_template_so_confirmed"/>
<field name="active" eval="True"/>
<field name="attach_sale_order" eval="True"/>
</record>
<record id="fp_notif_parts_received" model="fp.notification.template">
@@ -15,6 +24,22 @@
<field name="active" eval="True"/>
</record>
<record id="fp_notif_mo_complete" model="fp.notification.template">
<field name="name">Manufacturing Complete</field>
<field name="trigger_event">mo_complete</field>
<field name="mail_template_id" ref="fp_mail_template_mo_complete"/>
<field name="active" eval="True"/>
</record>
<record id="fp_notif_shipped" model="fp.notification.template">
<field name="name">Shipped / Delivered</field>
<field name="trigger_event">shipped</field>
<field name="mail_template_id" ref="fp_mail_template_shipped"/>
<field name="active" eval="True"/>
<field name="attach_coc" eval="True"/>
<field name="attach_bol" eval="True"/>
</record>
<record id="fp_notif_invoice_posted" model="fp.notification.template">
<field name="name">Invoice Posted</field>
<field name="trigger_event">invoice_posted</field>
@@ -23,4 +48,12 @@
<field name="attach_invoice" eval="True"/>
</record>
<record id="fp_notif_payment_received" model="fp.notification.template">
<field name="name">Payment Received</field>
<field name="trigger_event">payment_received</field>
<field name="mail_template_id" ref="fp_mail_template_payment_received"/>
<field name="active" eval="True"/>
<field name="attach_receipt" eval="True"/>
</record>
</odoo>

View File

@@ -1,51 +1,404 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Mail templates use the Fusion-Claims accent-bar design:
- 4 px accent bar in status colour (info / success / attention / urgent)
- 600 px max-width, centred, Segoe UI stack
- Theme-safe: opacity + rgba grey, no fixed backgrounds
-->
<odoo noupdate="1">
<!-- ============================================================= -->
<!-- 1. Quote Sent (Info, #2B6CB0) -->
<!-- ============================================================= -->
<record id="fp_mail_template_quote_sent" model="mail.template">
<field name="name">FP: Quotation Sent</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">Quotation {{ object.name }} — EN Technologies</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
EN Technologies
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Quotation Ready</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
Hi <t t-out="object.partner_id.name or ''"/>, your quotation is attached for review.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Value</th>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Quote Reference</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.name"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
<td style="padding: 8px 4px;">Valid Until</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.validity_date or 'On request'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;"><strong>Total</strong></td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><strong><t t-out="object.currency_id.symbol or ''"/><t t-out="'{:,.2f}'.format(object.amount_total)"/></strong></td>
</tr>
</table>
<div style="border-left: 3px solid #2B6CB0; padding: 12px 16px; margin: 20px 0; font-size: 14px;">
<strong>Next steps:</strong> Reply with a PO number or signed copy to accept. Once we receive your parts, we'll confirm receipt and begin production.
</div>
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
</div>
</div>
</field>
</record>
<!-- ============================================================= -->
<!-- 2. Sales Order Confirmed (Success, #38a169) -->
<!-- ============================================================= -->
<record id="fp_mail_template_so_confirmed" model="mail.template">
<field name="name">FP: Order Confirmation</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">Order Confirmation — {{ object.name }}</field>
<field name="subject">Order Confirmed — {{ object.name }}</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="body_html" type="html">
<p>Dear {{ object.partner_id.name }},</p>
<p>Your order <strong>{{ object.name }}</strong> has been confirmed.</p>
<p>We will notify you when your parts have been received at our facility.</p>
<p>Thank you for your business.</p>
<p>— EN Technologies Inc.</p>
</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
EN Technologies
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Order Confirmed</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
Thank you, <t t-out="object.partner_id.name or ''"/>. We have your order and will notify you the moment your parts arrive at our facility.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Order</th>
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Reference</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.name"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
<td style="padding: 8px 4px;">Customer PO</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.x_fc_po_number or '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Order Date</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.date_order.strftime('%b %d, %Y') if object.date_order else '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
<td style="padding: 8px 4px;"><strong>Total</strong></td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><strong><t t-out="object.currency_id.symbol or ''"/><t t-out="'{:,.2f}'.format(object.amount_total)"/></strong></td>
</tr>
</table>
<div style="border-left: 3px solid #38a169; padding: 12px 16px; margin: 20px 0; font-size: 14px;">
<strong>What's next:</strong> Ship your parts to our facility. We'll inspect on arrival, run the process, and keep you posted at each milestone.
</div>
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
</div>
</div>
</field>
</record>
<!-- ============================================================= -->
<!-- 3. Parts Received (Info, #2B6CB0) -->
<!-- ============================================================= -->
<record id="fp_mail_template_parts_received" model="mail.template">
<field name="name">FP: Parts Received</field>
<field name="model_id" eval="env['ir.model']._get_id('fp.receiving')"/>
<field name="subject">Parts Received — {{ object.name }}</field>
<field name="model_id" ref="fusion_plating_receiving.model_fp_receiving"/>
<field name="subject">Parts Received — {{ object.sale_order_id.name or object.name }}</field>
<field name="email_from">{{ (object.sale_order_id.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="body_html" type="html">
<p>Dear {{ object.partner_id.name }},</p>
<p>We have received your parts for order <strong>{{ object.sale_order_id.name }}</strong>.</p>
<p>Quantity received: {{ object.received_qty }}</p>
<p>Your parts are now in our production queue. We will keep you updated on progress.</p>
<p>— EN Technologies Inc.</p>
</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
EN Technologies
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Parts Received</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
Good news, <t t-out="object.partner_id.name or ''"/>. Your parts have arrived at our facility and passed incoming inspection.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Value</th>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Sale Order</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.sale_order_id.name or '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
<td style="padding: 8px 4px;">Receiving Reference</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.name"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Qty Received</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.received_qty"/></td>
</tr>
</table>
<div style="border-left: 3px solid #2B6CB0; padding: 12px 16px; margin: 20px 0; font-size: 14px;">
<strong>Next step:</strong> Your job has entered our production queue. Expect a manufacturing-complete email once the job is through processing.
</div>
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
</div>
</div>
</field>
</record>
<!-- ============================================================= -->
<!-- 4. Manufacturing Complete (Info, #2B6CB0) -->
<!-- ============================================================= -->
<record id="fp_mail_template_mo_complete" model="mail.template">
<field name="name">FP: Manufacturing Complete</field>
<field name="model_id" ref="mrp.model_mrp_production"/>
<field name="subject">Job Complete — {{ object.x_fc_portal_job_id.name or object.name }}</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.x_fc_portal_job_id.partner_id.email }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
EN Technologies
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Manufacturing Complete — Ready to Ship</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
Hi <t t-out="object.x_fc_portal_job_id.partner_id.name or ''"/>, your job has cleared production and quality. We are preparing it for shipment.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Value</th>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Job Reference</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.x_fc_portal_job_id.name or object.name"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
<td style="padding: 8px 4px;">Sale Order</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.origin or '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Quantity</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="int(object.product_qty)"/></td>
</tr>
</table>
<div style="border-left: 3px solid #2B6CB0; padding: 12px 16px; margin: 20px 0; font-size: 14px;">
<strong>Next:</strong> Your Certificate of Conformance will be issued with the shipment. Delivery scheduling to follow.
</div>
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
</div>
</div>
</field>
</record>
<!-- ============================================================= -->
<!-- 5. Shipped / Delivered (Success, #38a169) -->
<!-- ============================================================= -->
<record id="fp_mail_template_shipped" model="mail.template">
<field name="name">FP: Shipped / Delivered</field>
<field name="model_id" ref="fusion_plating_logistics.model_fusion_plating_delivery"/>
<field name="subject">Shipped — {{ object.job_ref or object.name }}</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
EN Technologies
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your Parts Have Shipped</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
Hi <t t-out="object.partner_id.name or ''"/>, your order has left our facility. Certificate of Conformance and Bill of Lading are attached for your records.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Shipment</th>
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Delivery Ref</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.name"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
<td style="padding: 8px 4px;">Job Reference</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.job_ref or '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Delivered</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.delivered_at.strftime('%b %d, %Y %H:%M') if object.delivered_at else '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
<td style="padding: 8px 4px;">Driver</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.assigned_driver_id.name or '—'"/></td>
</tr>
</table>
<div style="border-left: 3px solid #38a169; padding: 12px 16px; margin: 20px 0; font-size: 14px;">
<strong>Next:</strong> Your invoice will follow shortly. Please inspect your parts on receipt and contact us with any questions.
</div>
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
</div>
</div>
</field>
</record>
<!-- ============================================================= -->
<!-- 6. Invoice Posted (Info, #2B6CB0) -->
<!-- ============================================================= -->
<record id="fp_mail_template_invoice_posted" model="mail.template">
<field name="name">FP: Invoice Notification</field>
<field name="model_id" ref="account.model_account_move"/>
<field name="subject">Invoice {{ object.name }} — EN Technologies</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="body_html" type="html">
<p>Dear {{ object.partner_id.name }},</p>
<p>Please find your invoice <strong>{{ object.name }}</strong> for amount <strong>{{ object.amount_total }}</strong>.</p>
<p>Thank you for your business.</p>
<p>— EN Technologies Inc.</p>
</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
EN Technologies
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Invoice Ready</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
Hi <t t-out="object.partner_id.name or ''"/>, please find invoice <t t-out="object.name"/> attached.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Invoice</th>
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Invoice Number</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.name"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
<td style="padding: 8px 4px;">Source Order</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.invoice_origin or '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Invoice Date</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.invoice_date.strftime('%b %d, %Y') if object.invoice_date else '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
<td style="padding: 8px 4px;">Due Date</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.invoice_date_due.strftime('%b %d, %Y') if object.invoice_date_due else '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;"><strong>Amount Due</strong></td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><strong><t t-out="object.currency_id.symbol or ''"/><t t-out="'{:,.2f}'.format(object.amount_residual or object.amount_total)"/></strong></td>
</tr>
</table>
<div style="border-left: 3px solid #2B6CB0; padding: 12px 16px; margin: 20px 0; font-size: 14px;">
<strong>Payment:</strong> Please remit by the due date. Reference invoice number <t t-out="object.name"/> on your payment.
</div>
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
</div>
</div>
</field>
</record>
<!-- ============================================================= -->
<!-- 7. Payment Received (Success, #38a169) -->
<!-- ============================================================= -->
<record id="fp_mail_template_payment_received" model="mail.template">
<field name="name">FP: Payment Received</field>
<field name="model_id" ref="account.model_account_payment"/>
<field name="subject">Payment Received — {{ object.name }}</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
EN Technologies
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Payment Received — Thank You</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
Hi <t t-out="object.partner_id.name or ''"/>, we've received your payment. Your receipt is attached.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Payment</th>
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Receipt Number</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.name"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
<td style="padding: 8px 4px;">Payment Method</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.payment_method_line_id.name or '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Payment Date</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.date.strftime('%b %d, %Y') if object.date else '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
<td style="padding: 8px 4px;"><strong>Amount</strong></td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><strong><t t-out="object.currency_id.symbol or ''"/><t t-out="'{:,.2f}'.format(object.amount)"/></strong></td>
</tr>
</table>
<div style="border-left: 3px solid #38a169; padding: 12px 16px; margin: 20px 0; font-size: 14px;">
Your account balance has been updated. We appreciate your business.
</div>
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
</div>
</div>
</field>
</record>
</odoo>

View File

@@ -8,3 +8,6 @@ from . import fp_notification_log
from . import sale_order
from . import fp_receiving
from . import account_move
from . import account_payment
from . import mrp_production
from . import fp_delivery

View File

@@ -3,56 +3,24 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = 'account.move'
def action_post(self):
res = super().action_post()
Dispatch = self.env['fp.notification.template']
for move in self:
if move.move_type == 'out_invoice' and move.partner_id:
# Find linked SO
so = False
if move.invoice_origin:
so = self.env['sale.order'].search(
[('name', '=', move.invoice_origin)], limit=1,
)
self._send_fp_notification(
'invoice_posted', move, move.partner_id, sale_order=so,
if move.move_type != 'out_invoice' or not move.partner_id:
continue
so = False
if move.invoice_origin:
so = self.env['sale.order'].search(
[('name', '=', move.invoice_origin)], limit=1,
)
Dispatch._dispatch(
'invoice_posted', move, move.partner_id, sale_order=so,
)
return res
def _send_fp_notification(self, trigger_event, record, partner, sale_order=None):
"""Send a notification email and log it."""
template = self.env['fp.notification.template'].search(
[('trigger_event', '=', trigger_event), ('active', '=', True)], limit=1,
)
if not template or not template.mail_template_id:
return
try:
template.mail_template_id.send_mail(record.id, force_send=False)
self.env['fp.notification.log'].create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'status': 'sent',
})
except Exception as e:
_logger.warning('FP notification failed (%s): %s', trigger_event, e)
self.env['fp.notification.log'].create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'status': 'failed',
'error_message': str(e),
})

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import models
class AccountPayment(models.Model):
_inherit = 'account.payment'
def action_post(self):
res = super().action_post()
Dispatch = self.env['fp.notification.template']
for pay in self:
# Only customer receipts (inbound payments from customers)
if pay.payment_type != 'inbound' or not pay.partner_id:
continue
if pay.partner_type != 'customer':
continue
so = False
inv = pay.reconciled_invoice_ids[:1]
if inv and inv.invoice_origin:
so = self.env['sale.order'].search(
[('name', '=', inv.invoice_origin)], limit=1,
)
Dispatch._dispatch(
'payment_received', pay, pay.partner_id, sale_order=so,
)
return res

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import models
class FpDelivery(models.Model):
_inherit = 'fusion.plating.delivery'
def action_mark_delivered(self):
res = super().action_mark_delivered()
Dispatch = self.env['fp.notification.template']
for rec in self:
if not rec.partner_id:
continue
so = False
if rec.job_ref:
# Delivery's job_ref is the MO name; find the SO via MO origin.
mo = self.env['mrp.production'].search(
[('name', '=', rec.job_ref)], limit=1,
)
if mo and mo.origin:
so = self.env['sale.order'].search(
[('name', '=', mo.origin)], limit=1,
)
Dispatch._dispatch(
'shipped', rec, rec.partner_id, sale_order=so,
)
return res

View File

@@ -5,15 +5,7 @@
from odoo import fields, models
TRIGGER_EVENTS = [
('so_confirmed', 'Order Confirmed'),
('parts_received', 'Parts Received'),
('mo_complete', 'Manufacturing Complete'),
('shipment', 'Shipment (Carrier)'),
('delivery', 'Delivery (Local)'),
('invoice_posted', 'Invoice Posted'),
('deposit_created', 'Deposit Required'),
]
from .fp_notification_template import TRIGGER_EVENTS
class FpNotificationLog(models.Model):

View File

@@ -3,15 +3,20 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
TRIGGER_EVENTS = [
('quote_sent', 'Quotation Sent'),
('so_confirmed', 'Order Confirmed'),
('parts_received', 'Parts Received'),
('mo_complete', 'Manufacturing Complete'),
('shipment', 'Shipment (Carrier)'),
('delivery', 'Delivery (Local)'),
('shipped', 'Shipped / Delivered'),
('invoice_posted', 'Invoice Posted'),
('payment_received', 'Payment Received'),
('deposit_created', 'Deposit Required'),
]
@@ -35,10 +40,14 @@ class FpNotificationTemplate(models.Model):
help='The Odoo mail template used to render and send the email.',
)
active = fields.Boolean(string='Active', default=True)
attach_quotation = fields.Boolean(string='Attach Quotation PDF')
attach_sale_order = fields.Boolean(string='Attach Sales Order PDF')
attach_coc = fields.Boolean(string='Attach CoC')
attach_thickness_report = fields.Boolean(string='Attach Thickness Report')
attach_invoice = fields.Boolean(string='Attach Invoice')
attach_receipt = fields.Boolean(string='Attach Payment Receipt')
attach_packing_list = fields.Boolean(string='Attach Packing List')
attach_bol = fields.Boolean(string='Attach Bill of Lading')
attach_pod = fields.Boolean(string='Attach Proof of Delivery')
cc_internal_ids = fields.Many2many(
'res.users', 'fp_notification_template_cc_rel',
@@ -49,3 +58,172 @@ class FpNotificationTemplate(models.Model):
('fp_notification_trigger_uniq', 'unique(trigger_event)',
'Only one notification template per trigger event.'),
]
# ------------------------------------------------------------------
# Central dispatch helper — called from every hook.
# ------------------------------------------------------------------
@api.model
def _dispatch(self, trigger_event, record, partner=None, sale_order=None,
extra_attachment_ids=None):
"""Look up the template for this trigger, render it, and send.
Also logs the attempt in fp.notification.log.
"""
template = self.search(
[('trigger_event', '=', trigger_event), ('active', '=', True)],
limit=1,
)
if not template or not template.mail_template_id:
return
partner = partner or getattr(record, 'partner_id', False)
# Build attachment list from template config
attachment_ids = list(extra_attachment_ids or [])
attachment_names = []
for att_id in template._collect_attachments(record):
attachment_ids.append(att_id)
if attachment_ids:
attachment_names = self.env['ir.attachment'].browse(attachment_ids).mapped('name')
Log = self.env['fp.notification.log']
try:
mail_id = template.mail_template_id.send_mail(
record.id,
force_send=False,
email_values={'attachment_ids': [(6, 0, attachment_ids)]} if attachment_ids else None,
)
Log.create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'attachment_names': ', '.join(attachment_names) if attachment_names else '',
'status': 'sent',
'mail_mail_id': mail_id,
})
except Exception as exc:
_logger.warning('FP notification failed (%s): %s', trigger_event, exc)
Log.create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'status': 'failed',
'error_message': str(exc),
})
def _collect_attachments(self, record):
"""Return a list of ir.attachment ids to attach to the email based
on the template's attach_* flags and the record's context.
"""
self.ensure_one()
Attachment = self.env['ir.attachment']
ids = []
# Resolve related records (MO, portal job, SO) from `record`
portal_job = None
production = None
sale_order = None
invoice = None
delivery = None
payment = None
model = record._name
if model == 'sale.order':
sale_order = record
portal_job = self.env['fusion.plating.portal.job'].search(
[('name', 'in', record.mapped('picking_ids.origin'))], limit=1,
) or None
elif model == 'account.move':
invoice = record
if record.invoice_origin:
sale_order = self.env['sale.order'].search(
[('name', '=', record.invoice_origin)], limit=1,
) or None
elif model == 'account.payment':
payment = record
invoice = record.reconciled_invoice_ids[:1]
if invoice and invoice.invoice_origin:
sale_order = self.env['sale.order'].search(
[('name', '=', invoice.invoice_origin)], limit=1,
) or None
elif model == 'mrp.production':
production = record
portal_job = record.x_fc_portal_job_id
if record.origin:
sale_order = self.env['sale.order'].search(
[('name', '=', record.origin)], limit=1,
) or None
elif model == 'fusion.plating.delivery':
delivery = record
if record.job_ref:
portal_job = self.env['fusion.plating.portal.job'].search(
[('name', '=', record.job_ref)], limit=1,
) or None
elif model == 'fp.receiving':
sale_order = record.sale_order_id
def _render_report(xmlid, rec):
"""Render a PDF report and return an attachment id."""
if not rec:
return None
try:
report = self.env.ref(xmlid, raise_if_not_found=False)
if not report:
return None
pdf_bytes, _fmt = self.env['ir.actions.report']._render_qweb_pdf(
xmlid, res_ids=rec.ids,
)
import base64
att = Attachment.create({
'name': f'{report.name} - {rec.display_name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_bytes),
'mimetype': 'application/pdf',
'res_model': rec._name,
'res_id': rec.id,
})
return att.id
except Exception as exc:
_logger.warning('Failed to render %s: %s', xmlid, exc)
return None
if self.attach_quotation and sale_order:
att = _render_report(
'fusion_plating_reports.action_report_fp_sale_portrait', sale_order,
)
if att:
ids.append(att)
if self.attach_sale_order and sale_order:
att = _render_report(
'fusion_plating_reports.action_report_fp_sale_portrait', sale_order,
)
if att:
ids.append(att)
if self.attach_coc and portal_job:
att = _render_report(
'fusion_plating_reports.action_report_coc', portal_job,
)
if att:
ids.append(att)
if self.attach_invoice and invoice:
att = _render_report(
'fusion_plating_reports.action_report_fp_invoice_portrait', invoice,
)
if att:
ids.append(att)
if self.attach_receipt and payment:
att = _render_report(
'fusion_plating_reports.action_report_fp_receipt_portrait', payment,
)
if att:
ids.append(att)
if self.attach_bol and delivery:
att = _render_report(
'fusion_plating_reports.action_report_fp_bol_portrait', delivery,
)
if att:
ids.append(att)
return ids

View File

@@ -3,50 +3,18 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class FpReceiving(models.Model):
_inherit = 'fp.receiving'
def action_accept(self):
res = super().action_accept()
Dispatch = self.env['fp.notification.template']
for rec in self:
self._send_fp_notification(
Dispatch._dispatch(
'parts_received', rec, rec.partner_id,
sale_order=rec.sale_order_id,
)
return res
def _send_fp_notification(self, trigger_event, record, partner, sale_order=None):
"""Send a notification email and log it."""
template = self.env['fp.notification.template'].search(
[('trigger_event', '=', trigger_event), ('active', '=', True)], limit=1,
)
if not template or not template.mail_template_id:
return
try:
template.mail_template_id.send_mail(record.id, force_send=False)
self.env['fp.notification.log'].create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'status': 'sent',
})
except Exception as e:
_logger.warning('FP notification failed (%s): %s', trigger_event, e)
self.env['fp.notification.log'].create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'status': 'failed',
'error_message': str(e),
})

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import models
class MrpProduction(models.Model):
_inherit = 'mrp.production'
def button_mark_done(self):
res = super().button_mark_done()
Dispatch = self.env['fp.notification.template']
for mo in self:
partner = False
so = False
if mo.x_fc_portal_job_id:
partner = mo.x_fc_portal_job_id.partner_id
if mo.origin:
so = self.env['sale.order'].search(
[('name', '=', mo.origin)], limit=1,
)
if so and not partner:
partner = so.partner_id
if not partner:
continue
Dispatch._dispatch(
'mo_complete', mo, partner, sale_order=so,
)
return res

View File

@@ -3,49 +3,27 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
res = super().action_confirm()
def action_quotation_send(self):
"""Fire the quote_sent trigger when a quotation is emailed."""
res = super().action_quotation_send()
Dispatch = self.env['fp.notification.template']
for order in self:
self._send_fp_notification(
'so_confirmed', order, order.partner_id, sale_order=order,
Dispatch._dispatch(
'quote_sent', order, order.partner_id, sale_order=order,
)
return res
def _send_fp_notification(self, trigger_event, record, partner, sale_order=None):
"""Send a notification email and log it."""
template = self.env['fp.notification.template'].search(
[('trigger_event', '=', trigger_event), ('active', '=', True)], limit=1,
)
if not template or not template.mail_template_id:
return
try:
template.mail_template_id.send_mail(record.id, force_send=False)
self.env['fp.notification.log'].create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'status': 'sent',
})
except Exception as e:
_logger.warning('FP notification failed (%s): %s', trigger_event, e)
self.env['fp.notification.log'].create({
'template_id': template.id,
'trigger_event': trigger_event,
'sale_order_id': sale_order.id if sale_order else False,
'partner_id': partner.id if partner else False,
'recipient_email': partner.email if partner else '',
'status': 'failed',
'error_message': str(e),
})
def action_confirm(self):
res = super().action_confirm()
Dispatch = self.env['fp.notification.template']
for order in self:
Dispatch._dispatch(
'so_confirmed', order, order.partner_id, sale_order=order,
)
return res

View File

@@ -78,7 +78,7 @@
<filter name="filter_this_week" string="This Week"
domain="[('sent_date', '&gt;=', (context_today() - datetime.timedelta(days=context_today().weekday())).strftime('%Y-%m-%d'))]"/>
<separator/>
<group expand="1">
<group>
<filter name="group_trigger" string="Trigger Event"
context="{'group_by': 'trigger_event'}"/>
<filter name="group_status" string="Status"

View File

@@ -42,12 +42,16 @@
</group>
<group string="Attachments">
<group>
<field name="attach_quotation"/>
<field name="attach_sale_order"/>
<field name="attach_coc"/>
<field name="attach_thickness_report"/>
<field name="attach_invoice"/>
<field name="attach_receipt"/>
</group>
<group>
<field name="attach_packing_list"/>
<field name="attach_bol"/>
<field name="attach_pod"/>
</group>
</group>

View File

@@ -3,21 +3,28 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.1.0.0',
'version': '19.0.2.0.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for all Fusion Plating models: CoC, NCR, CAPA, bath logs, calibration, and more.',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [
'sale',
'account',
'stock',
'mrp',
'fusion_plating',
'fusion_plating_quality',
'fusion_plating_compliance',
'fusion_plating_safety',
'fusion_plating_portal',
'fusion_plating_configurator',
'fusion_plating_bridge_mrp',
'fusion_plating_logistics',
],
'data': [
'security/ir.model.access.csv',
'report/report_base_styles.xml',
'report/report_actions.xml',
# Quality + compliance reports
'report/report_coc.xml',
'report/report_ncr.xml',
'report/report_capa.xml',
@@ -30,6 +37,13 @@
'report/report_waste_manifest.xml',
'report/report_discharge_sample.xml',
'report/report_wo_margin.xml',
# Quote-to-cash reports (portrait + landscape)
'report/report_fp_sale.xml',
'report/report_fp_work_order.xml',
'report/report_fp_packing_slip.xml',
'report/report_fp_bol.xml',
'report/report_fp_invoice.xml',
'report/report_fp_receipt.xml',
],
'installable': True,
'application': False,

View File

@@ -24,10 +24,10 @@
</record>
<!-- ============================================================= -->
<!-- 1. Certificate of Conformance (Portal Job) -->
<!-- 1. Certificate of Conformance (Portal Job) — Landscape -->
<!-- ============================================================= -->
<record id="action_report_coc" model="ir.actions.report">
<field name="name">Certificate of Conformance</field>
<field name="name">Certificate of Conformance (Landscape)</field>
<field name="model">fusion.plating.portal.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_coc</field>
@@ -38,6 +38,18 @@
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- Certificate of Conformance — Portrait -->
<record id="action_report_coc_portrait" model="ir.actions.report">
<field name="name">Certificate of Conformance (Portrait)</field>
<field name="model">fusion.plating.portal.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_coc_portrait</field>
<field name="report_file">fusion_plating_reports.report_coc_portrait</field>
<field name="print_report_name">'CoC - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_portal.model_fusion_plating_portal_job"/>
<field name="binding_type">report</field>
</record>
<!-- ============================================================= -->
<!-- 2. Non-Conformance Report -->
<!-- ============================================================= -->
@@ -202,4 +214,160 @@
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 13. Quotation / Sales Order (Portrait + Landscape) -->
<!-- ============================================================= -->
<record id="action_report_fp_sale_portrait" model="ir.actions.report">
<field name="name">Quotation / Order (Portrait)</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_sale_portrait</field>
<field name="report_file">fusion_plating_reports.report_fp_sale_portrait</field>
<field name="print_report_name">(object.state in ('draft', 'sent') and 'Quotation - %s' % object.name) or 'Order - %s' % object.name</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
</record>
<record id="action_report_fp_sale_landscape" model="ir.actions.report">
<field name="name">Quotation / Order (Landscape)</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_sale_landscape</field>
<field name="report_file">fusion_plating_reports.report_fp_sale_landscape</field>
<field name="print_report_name">(object.state in ('draft', 'sent') and 'Quotation - %s' % object.name) or 'Order - %s' % object.name</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 14. Work Order Traveller (Portrait + Landscape) -->
<!-- ============================================================= -->
<record id="action_report_fp_work_order_portrait" model="ir.actions.report">
<field name="name">Work Order Traveller (Portrait)</field>
<field name="model">mrp.workorder</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_work_order_portrait</field>
<field name="report_file">fusion_plating_reports.report_fp_work_order_portrait</field>
<field name="print_report_name">'WO - %s' % object.name</field>
<field name="binding_model_id" ref="mrp.model_mrp_workorder"/>
<field name="binding_type">report</field>
</record>
<record id="action_report_fp_work_order_landscape" model="ir.actions.report">
<field name="name">Work Order Traveller (Landscape)</field>
<field name="model">mrp.workorder</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_work_order_landscape</field>
<field name="report_file">fusion_plating_reports.report_fp_work_order_landscape</field>
<field name="print_report_name">'WO - %s' % object.name</field>
<field name="binding_model_id" ref="mrp.model_mrp_workorder"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 15. Packing Slip (Portrait + Landscape) -->
<!-- ============================================================= -->
<record id="action_report_fp_packing_slip_portrait" model="ir.actions.report">
<field name="name">Packing Slip (Portrait)</field>
<field name="model">stock.picking</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_portrait</field>
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_portrait</field>
<field name="print_report_name">'Packing Slip - %s' % object.name</field>
<field name="binding_model_id" ref="stock.model_stock_picking"/>
<field name="binding_type">report</field>
</record>
<record id="action_report_fp_packing_slip_landscape" model="ir.actions.report">
<field name="name">Packing Slip (Landscape)</field>
<field name="model">stock.picking</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_landscape</field>
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_landscape</field>
<field name="print_report_name">'Packing Slip - %s' % object.name</field>
<field name="binding_model_id" ref="stock.model_stock_picking"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 16. Bill of Lading (Portrait + Landscape) -->
<!-- ============================================================= -->
<record id="action_report_fp_bol_portrait" model="ir.actions.report">
<field name="name">Bill of Lading (Portrait)</field>
<field name="model">fusion.plating.delivery</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_bol_portrait</field>
<field name="report_file">fusion_plating_reports.report_fp_bol_portrait</field>
<field name="print_report_name">'BoL - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_logistics.model_fusion_plating_delivery"/>
<field name="binding_type">report</field>
</record>
<record id="action_report_fp_bol_landscape" model="ir.actions.report">
<field name="name">Bill of Lading (Landscape)</field>
<field name="model">fusion.plating.delivery</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_bol_landscape</field>
<field name="report_file">fusion_plating_reports.report_fp_bol_landscape</field>
<field name="print_report_name">'BoL - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_logistics.model_fusion_plating_delivery"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 17. Invoice (Portrait + Landscape) -->
<!-- ============================================================= -->
<record id="action_report_fp_invoice_portrait" model="ir.actions.report">
<field name="name">Invoice — Plating (Portrait)</field>
<field name="model">account.move</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_invoice_portrait</field>
<field name="report_file">fusion_plating_reports.report_fp_invoice_portrait</field>
<field name="print_report_name">'Invoice - %s' % (object.name or '')</field>
<field name="binding_model_id" ref="account.model_account_move"/>
<field name="binding_type">report</field>
</record>
<record id="action_report_fp_invoice_landscape" model="ir.actions.report">
<field name="name">Invoice — Plating (Landscape)</field>
<field name="model">account.move</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_invoice_landscape</field>
<field name="report_file">fusion_plating_reports.report_fp_invoice_landscape</field>
<field name="print_report_name">'Invoice - %s' % (object.name or '')</field>
<field name="binding_model_id" ref="account.model_account_move"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
<!-- ============================================================= -->
<!-- 18. Payment Receipt (Portrait + Landscape) -->
<!-- ============================================================= -->
<record id="action_report_fp_receipt_portrait" model="ir.actions.report">
<field name="name">Payment Receipt (Portrait)</field>
<field name="model">account.payment</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_receipt_portrait</field>
<field name="report_file">fusion_plating_reports.report_fp_receipt_portrait</field>
<field name="print_report_name">'Receipt - %s' % (object.name or '')</field>
<field name="binding_model_id" ref="account.model_account_payment"/>
<field name="binding_type">report</field>
</record>
<record id="action_report_fp_receipt_landscape" model="ir.actions.report">
<field name="name">Payment Receipt (Landscape)</field>
<field name="model">account.payment</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_receipt_landscape</field>
<field name="report_file">fusion_plating_reports.report_fp_receipt_landscape</field>
<field name="print_report_name">'Receipt - %s' % (object.name or '')</field>
<field name="binding_model_id" ref="account.model_account_payment"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
</record>
</odoo>

View File

@@ -3,12 +3,48 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Shared landscape CSS for all Fusion Plating reports.
Shared CSS for all Fusion Plating reports (portrait + landscape).
-->
<odoo>
<!-- ============================================================= -->
<!-- Portrait Styles -->
<!-- ============================================================= -->
<template id="fp_portrait_styles">
<style>
.fp-report { font-family: Arial, sans-serif; font-size: 10pt; color: #000; }
.fp-report table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fp-report table.bordered, .fp-report table.bordered th, .fp-report table.bordered td { border: 1px solid #000; }
.fp-report th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; text-align: center; font-size: 9pt; }
.fp-report td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
.fp-report .text-center { text-align: center; }
.fp-report .text-end { text-align: right; }
.fp-report .text-start { text-align: left; }
.fp-report .adp-bg { background-color: #e3f2fd; }
.fp-report .client-bg { background-color: #fff3e0; }
.fp-report .section-row { background-color: #f0f0f0; font-weight: bold; }
.fp-report .note-row { font-style: italic; color: #555; font-size: 9pt; }
.fp-report h4 { color: #0066a1; margin: 0 0 15px 0; font-size: 16pt; }
.fp-report .totals-table { border: 1px solid #000; border-collapse: collapse; }
.fp-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
.fp-report .info-header { background-color: #f5f5f5; color: #333; }
.fp-report .adp-header { background-color: #e3f2fd; color: #333; }
.fp-report .highlight-box { border: 2px solid #0066a1; background-color: #eaf2f8; padding: 10px; margin: 10px 0; }
.fp-report .paid-stamp { color: #28a745; font-size: 36pt; font-weight: bold; border: 4px solid #28a745; padding: 10px 20px; transform: rotate(-8deg); display: inline-block; }
.fp-report .status-ok { color: #2e7d32; font-weight: bold; }
.fp-report .status-warning { color: #f57f17; font-weight: bold; }
.fp-report .status-fail { color: #c62828; font-weight: bold; }
.fp-report .sig-box { border: 1px solid #000; padding: 12px; min-height: 70px; }
.fp-report .sig-line { border-bottom: 1px solid #000; min-height: 28px; }
.fp-report .small-muted { font-size: 8pt; color: #666; }
</style>
</template>
<!-- ============================================================= -->
<!-- Landscape Styles -->
<!-- ============================================================= -->
<template id="fp_landscape_styles">
<style>
.fp-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
.fp-landscape { font-family: Arial, sans-serif; font-size: 11pt; color: #000; }
.fp-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
.fp-landscape table.bordered, .fp-landscape table.bordered th, .fp-landscape table.bordered td { border: 1px solid #000; }
.fp-landscape th { background-color: #0066a1; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
@@ -19,15 +55,20 @@
.fp-landscape .adp-bg { background-color: #e3f2fd; }
.fp-landscape .client-bg { background-color: #fff3e0; }
.fp-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
.fp-landscape .note-row { font-style: italic; }
.fp-landscape .note-row { font-style: italic; color: #555; }
.fp-landscape h2 { color: #0066a1; margin: 10px 0; font-size: 18pt; }
.fp-landscape .info-table td { padding: 8px 12px; font-size: 11pt; }
.fp-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
.fp-landscape .totals-table { border: 1px solid #000; }
.fp-landscape .totals-table td { border: 1px solid #000; padding: 8px 12px; font-size: 11pt; }
.fp-landscape .highlight-box { border: 2px solid #0066a1; background-color: #eaf2f8; padding: 10px; margin: 10px 0; }
.fp-landscape .paid-stamp { color: #28a745; font-size: 42pt; font-weight: bold; border: 4px solid #28a745; padding: 10px 20px; transform: rotate(-8deg); display: inline-block; }
.fp-landscape .status-ok { color: #2e7d32; font-weight: bold; }
.fp-landscape .status-warning { color: #f57f17; font-weight: bold; }
.fp-landscape .status-fail { color: #c62828; font-weight: bold; }
.fp-landscape .sig-box { border: 1px solid #000; padding: 12px; min-height: 70px; }
.fp-landscape .sig-line { border-bottom: 1px solid #000; min-height: 28px; }
.fp-landscape .small-muted { font-size: 9pt; color: #666; }
</style>
</template>
</odoo>

View File

@@ -2,9 +2,132 @@
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Certificate of Conformance — Portal Job
Certificate of Conformance — Portal Job (Portrait + Landscape).
The original `report_coc` id is kept as the landscape variant so existing
bindings keep working; a new `report_coc_portrait` variant is added.
-->
<odoo>
<!-- ============================================================= -->
<!-- PORTRAIT -->
<!-- ============================================================= -->
<template id="report_coc_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<div class="page">
<h4>
Certificate of Conformance —
<span t-field="doc.name"/>
</h4>
<!-- Job info -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 25%;">JOB REF</th>
<th class="info-header" style="width: 25%;">QTY</th>
<th class="info-header" style="width: 25%;">RECEIVED</th>
<th class="info-header" style="width: 25%;">SHIP DATE</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.name"/></td>
<td class="text-center"><span t-field="doc.quantity"/></td>
<td class="text-center"><span t-field="doc.received_date" t-options="{'widget': 'date'}"/></td>
<td class="text-center"><span t-field="doc.actual_ship_date" t-options="{'widget': 'date'}"/></td>
</tr>
</tbody>
</table>
<!-- Customer block -->
<table class="bordered">
<thead>
<tr><th colspan="2" style="background-color: #0066a1; color: white;">CUSTOMER</th></tr>
</thead>
<tbody>
<tr>
<td style="width: 30%; background-color: #f5f5f5; font-weight: bold;">Name</td>
<td><span t-field="doc.partner_id.name"/></td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Address</td>
<td>
<span t-field="doc.partner_id" t-options="{'widget': 'contact', 'fields': ['address'], 'no_marker': True}"/>
</td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Tracking Reference</td>
<td><span t-esc="doc.tracking_ref or '-'"/></td>
</tr>
</tbody>
</table>
<!-- Processes -->
<t t-if="doc.process_type_ids">
<table class="bordered">
<thead>
<tr><th style="background-color: #0066a1; color: white;">PROCESSES APPLIED</th></tr>
</thead>
<tbody>
<tr>
<td>
<t t-foreach="doc.process_type_ids" t-as="pt">
<span t-out="pt.name"/>
<t t-if="not pt_last">, </t>
</t>
</td>
</tr>
</tbody>
</table>
</t>
<!-- Certification statement -->
<div class="highlight-box">
This certifies that the above items were processed in accordance with
applicable specifications and meet all requirements as stated in the
purchase order. All work was performed in compliance with the quality
management system.
</div>
<!-- Notes -->
<t t-if="doc.notes">
<table class="bordered">
<tr class="section-row"><td>NOTES</td></tr>
<tr><td><t t-out="doc.notes"/></td></tr>
</table>
</t>
<!-- Signature block -->
<div class="row" style="margin-top: 30px;">
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Quality Manager (Signature)</div>
</div>
</div>
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Date</div>
</div>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
<!-- ============================================================= -->
<!-- LANDSCAPE (legacy id `report_coc` kept for existing bindings) -->
<!-- ============================================================= -->
<template id="report_coc">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
@@ -111,4 +234,5 @@
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,380 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Fusion Plating — Bill of Lading (Portrait + Landscape).
Binds to fusion.plating.delivery. Includes shipper, consignee, carrier,
cargo description, special instructions, and sign-off lines.
-->
<odoo>
<!-- ============================================================= -->
<!-- PORTRAIT -->
<!-- ============================================================= -->
<template id="report_fp_bol_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<div class="page">
<h4 class="text-center" style="text-align: center;">
BILL OF LADING
</h4>
<div class="text-center" style="text-align: center; margin-bottom: 10px;">
<strong>BoL #: <span t-field="doc.name"/></strong>
</div>
<!-- Shipper / Consignee -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">SHIPPER</th>
<th style="width: 50%;">CONSIGNEE</th>
</tr>
</thead>
<tbody>
<tr>
<td style="height: 90px;">
<strong><span t-field="doc.company_id.name"/></strong><br/>
<t t-if="doc.source_facility_id">
<em t-field="doc.source_facility_id.name"/><br/>
</t>
<div t-field="doc.company_id.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone', 'email'], 'no_marker': True}"/>
</td>
<td style="height: 90px;">
<strong><span t-field="doc.partner_id.name"/></strong><br/>
<t t-set="dest" t-value="doc.delivery_address_id or doc.partner_id"/>
<div t-field="dest"
t-options="{'widget': 'contact', 'fields': ['address', 'phone'], 'no_marker': True}"/>
<t t-if="doc.contact_name">
<strong>Attn: </strong><span t-field="doc.contact_name"/><br/>
</t>
<t t-if="doc.contact_phone">
<strong>Phone: </strong><span t-field="doc.contact_phone"/>
</t>
</td>
</tr>
</tbody>
</table>
<!-- Shipment info -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 33%;">SHIP DATE</th>
<th class="info-header" style="width: 33%;">DRIVER</th>
<th class="info-header" style="width: 34%;">VEHICLE</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.scheduled_date" t-options="{'widget': 'date'}"/></td>
<td class="text-center">
<t t-if="doc.assigned_driver_id">
<span t-field="doc.assigned_driver_id.name"/>
</t>
<t t-else="">-</t>
</td>
<td class="text-center">
<t t-if="doc.vehicle_id">
<span t-field="doc.vehicle_id"/>
</t>
<t t-else="">-</t>
</td>
</tr>
</tbody>
</table>
<!-- Job ref / TDG -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 50%;">JOB REFERENCE</th>
<th class="info-header" style="width: 50%;">DANGEROUS GOODS (TDG)</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-esc="doc.job_ref or '-'"/></td>
<td class="text-center">
<span t-if="doc.tdg_required" class="status-warning">TDG REQUIRED</span>
<span t-else="" class="status-ok">No TDG</span>
</td>
</tr>
</tbody>
</table>
<!-- Cargo description -->
<table class="bordered">
<thead>
<tr>
<th colspan="4" style="background-color: #0066a1; color: white;">CARGO DESCRIPTION</th>
</tr>
<tr>
<th style="width: 12%;">PACKAGES</th>
<th class="text-start" style="width: 58%;">DESCRIPTION OF GOODS</th>
<th style="width: 15%;">WEIGHT</th>
<th style="width: 15%;">CLASS</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center">1</td>
<td>
Plated parts — Job <span t-esc="doc.job_ref or doc.name"/>
<t t-if="doc.notes">
<br/><span t-field="doc.notes"/>
</t>
</td>
<td class="text-center"></td>
<td class="text-center">
<span t-if="doc.tdg_required">TDG</span>
<span t-else="">NON-HAZ</span>
</td>
</tr>
</tbody>
</table>
<!-- Certificate + Packing list refs -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 50%;">CoC ATTACHED</th>
<th class="info-header" style="width: 50%;">PACKING LIST</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center">
<span t-if="doc.coc_attachment_id" class="status-ok">✓ Attached</span>
<span t-else=""></span>
</td>
<td class="text-center">
<span t-if="doc.packing_list_attachment_id" class="status-ok">✓ Attached</span>
<span t-else=""></span>
</td>
</tr>
</tbody>
</table>
<!-- Certification statement -->
<div class="highlight-box" style="margin-top: 10px;">
This is to certify that the above-named materials are properly classified,
packaged, marked, and labelled, and are in proper condition for transportation
according to the applicable regulations of the Department of Transportation.
</div>
<!-- Sign off -->
<div class="row" style="margin-top: 20px;">
<div class="col-4">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Shipper (Signature / Date)</div>
</div>
</div>
<div class="col-4">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Carrier / Driver (Signature / Date)</div>
</div>
</div>
<div class="col-4">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Consignee (Signature / Date)</div>
</div>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
<!-- ============================================================= -->
<!-- LANDSCAPE -->
<!-- ============================================================= -->
<template id="report_fp_bol_landscape">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<div class="fp-landscape">
<div class="page">
<h2 style="text-align: center;">BILL OF LADING</h2>
<div class="text-center" style="text-align: center; margin-bottom: 10px;">
<strong>BoL #: <span t-field="doc.name"/></strong>
</div>
<!-- Shipper / Consignee -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">SHIPPER</th>
<th style="width: 50%;">CONSIGNEE</th>
</tr>
</thead>
<tbody>
<tr>
<td style="height: 100px; font-size: 12pt;">
<strong><span t-field="doc.company_id.name"/></strong><br/>
<t t-if="doc.source_facility_id">
<em t-field="doc.source_facility_id.name"/><br/>
</t>
<div t-field="doc.company_id.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone', 'email'], 'no_marker': True}"/>
</td>
<td style="height: 100px; font-size: 12pt;">
<strong><span t-field="doc.partner_id.name"/></strong><br/>
<t t-set="dest" t-value="doc.delivery_address_id or doc.partner_id"/>
<div t-field="dest"
t-options="{'widget': 'contact', 'fields': ['address', 'phone'], 'no_marker': True}"/>
<t t-if="doc.contact_name">
<strong>Attn: </strong><span t-field="doc.contact_name"/><br/>
</t>
<t t-if="doc.contact_phone">
<strong>Phone: </strong><span t-field="doc.contact_phone"/>
</t>
</td>
</tr>
</tbody>
</table>
<!-- Shipment info (wide) -->
<table class="bordered info-table">
<thead>
<tr>
<th>SHIP DATE</th>
<th>DRIVER</th>
<th>VEHICLE</th>
<th>JOB REFERENCE</th>
<th>TDG</th>
<th>STATUS</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.scheduled_date" t-options="{'widget': 'date'}"/></td>
<td class="text-center">
<t t-if="doc.assigned_driver_id"><span t-field="doc.assigned_driver_id.name"/></t>
<t t-else="">-</t>
</td>
<td class="text-center">
<t t-if="doc.vehicle_id"><span t-field="doc.vehicle_id"/></t>
<t t-else="">-</t>
</td>
<td class="text-center"><span t-esc="doc.job_ref or '-'"/></td>
<td class="text-center">
<span t-if="doc.tdg_required" class="status-warning">REQUIRED</span>
<span t-else="" class="status-ok">None</span>
</td>
<td class="text-center"><span t-field="doc.state"/></td>
</tr>
</tbody>
</table>
<!-- Cargo description -->
<table class="bordered">
<thead>
<tr>
<th colspan="6" style="background-color: #0066a1; color: white;">CARGO DESCRIPTION</th>
</tr>
<tr>
<th style="width: 10%;">PACKAGES</th>
<th class="text-start" style="width: 40%;">DESCRIPTION OF GOODS</th>
<th style="width: 12%;">QTY</th>
<th style="width: 12%;">WEIGHT</th>
<th style="width: 12%;">CLASS</th>
<th style="width: 14%;">SPECIAL HANDLING</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center">1</td>
<td>
Plated parts — Job <span t-esc="doc.job_ref or doc.name"/>
<t t-if="doc.notes">
<br/><span t-field="doc.notes"/>
</t>
</td>
<td class="text-center"></td>
<td class="text-center"></td>
<td class="text-center">
<span t-if="doc.tdg_required">TDG</span>
<span t-else="">NON-HAZ</span>
</td>
<td class="text-center">
<span t-if="doc.tdg_required" class="status-warning">TDG HANDLING</span>
<span t-else="">Standard</span>
</td>
</tr>
</tbody>
</table>
<!-- Attachment refs -->
<table class="bordered info-table">
<thead>
<tr>
<th>CoC</th>
<th>PACKING LIST</th>
<th>CUSTODY EVENTS</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center">
<span t-if="doc.coc_attachment_id" class="status-ok">✓ Attached</span>
<span t-else=""></span>
</td>
<td class="text-center">
<span t-if="doc.packing_list_attachment_id" class="status-ok">✓ Attached</span>
<span t-else=""></span>
</td>
<td class="text-center"><span t-field="doc.custody_event_count"/></td>
</tr>
</tbody>
</table>
<!-- Certification -->
<div class="highlight-box" style="margin-top: 10px;">
This is to certify that the above-named materials are properly classified,
packaged, marked, and labelled, and are in proper condition for transportation
according to the applicable regulations of the Department of Transportation.
</div>
<!-- Sign off -->
<div class="row" style="margin-top: 20px;">
<div class="col-4">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Shipper (Signature / Date)</div>
</div>
</div>
<div class="col-4">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Carrier / Driver (Signature / Date)</div>
</div>
</div>
<div class="col-4">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Consignee (Signature / Date)</div>
</div>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,420 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Fusion Plating — Invoice (Portrait + Landscape).
Binds to account.move. Includes invoice strategy, deposit context,
payment details, and amount due.
-->
<odoo>
<!-- ============================================================= -->
<!-- PORTRAIT -->
<!-- ============================================================= -->
<template id="report_fp_invoice_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<div class="page">
<h4>
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice </span>
<span t-elif="doc.move_type == 'out_invoice' and doc.state == 'draft'">Draft Invoice </span>
<span t-elif="doc.move_type == 'out_refund'">Credit Note </span>
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill </span>
<span t-field="doc.name"/>
</h4>
<!-- Billing / Shipping -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">BILLING ADDRESS</th>
<th style="width: 50%;">DELIVERY ADDRESS</th>
</tr>
</thead>
<tbody>
<tr>
<td style="height: 70px;">
<div t-field="doc.partner_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address', 'phone', 'email'], 'no_marker': True}"/>
</td>
<td style="height: 70px;">
<t t-if="doc.partner_shipping_id">
<div t-field="doc.partner_shipping_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address', 'phone'], 'no_marker': True}"/>
</t>
<t t-else="">
<div t-field="doc.partner_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address'], 'no_marker': True}"/>
</t>
</td>
</tr>
</tbody>
</table>
<!-- Invoice info -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 25%;">INVOICE DATE</th>
<th class="info-header" style="width: 25%;">DUE DATE</th>
<th class="info-header" style="width: 25%;">SOURCE</th>
<th class="info-header" style="width: 25%;">SALES REP</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.invoice_date"/></td>
<td class="text-center"><span t-field="doc.invoice_date_due"/></td>
<td class="text-center"><span t-field="doc.invoice_origin"/></td>
<td class="text-center"><span t-field="doc.invoice_user_id"/></td>
</tr>
</tbody>
</table>
<!-- Lines -->
<table class="bordered">
<thead>
<tr>
<th style="width: 12%;">SKU</th>
<th class="text-start" style="width: 40%;">DESCRIPTION</th>
<th style="width: 8%;">QTY</th>
<th style="width: 8%;">UOM</th>
<th style="width: 12%;">UNIT PRICE</th>
<th style="width: 8%;">TAXES</th>
<th style="width: 12%;">AMOUNT</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.invoice_line_ids" t-as="line">
<t t-if="line.display_type == 'line_section'">
<tr class="section-row"><td colspan="7"><strong t-field="line.name"/></td></tr>
</t>
<t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td colspan="7"><span t-field="line.name"/></td></tr>
</t>
<t t-elif="not line.display_type">
<tr>
<td class="text-center"><span t-esc="line.product_id.default_code or ''"/></td>
<td>
<t t-set="clean_name" t-value="line.name"/>
<t t-if="line.name and '] ' in line.name">
<t t-set="clean_name" t-value="line.name.split('] ', 1)[1]"/>
</t>
<span t-esc="clean_name"/>
</td>
<td class="text-center">
<span t-esc="int(line.quantity) if line.quantity == int(line.quantity) else line.quantity"/>
</td>
<td class="text-center"><span t-field="line.product_uom_id"/></td>
<td class="text-end">
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
<td class="text-center">
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
</td>
<td class="text-end">
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
<!-- Terms + Totals -->
<div class="row" style="margin-top: 15px;">
<div class="col-6">
<t t-if="doc.invoice_payment_term_id.note">
<strong>Payment Terms:</strong><br/>
<span t-field="doc.invoice_payment_term_id.note"/>
</t>
<t t-if="doc.payment_reference">
<div style="margin-top: 10px;">
<strong>Payment Reference:</strong>
<span t-field="doc.payment_reference"/>
</div>
</t>
</div>
<div class="col-6" style="text-align: right;">
<table class="totals-table" style="width: auto; margin-left: auto;">
<tr>
<td style="min-width: 150px;">Subtotal</td>
<td class="text-end" style="min-width: 110px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Taxes</td>
<td class="text-end">
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr style="background-color: #eaf2f8;">
<td><strong>Grand Total</strong></td>
<td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
<tr>
<td><strong>Amount Due</strong></td>
<td class="text-end"><strong>
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
</t>
</table>
</div>
</div>
<!-- Paid stamp -->
<t t-if="doc.payment_state == 'paid'">
<div style="margin-top: 15px; text-align: center;">
<span class="paid-stamp">PAID</span>
</div>
</t>
<!-- Notes -->
<t t-if="doc.narration">
<div style="margin-top: 15px;">
<strong>Notes:</strong>
<div t-field="doc.narration"/>
</div>
</t>
</div>
</div>
</t>
</t>
</t>
</template>
<!-- ============================================================= -->
<!-- LANDSCAPE -->
<!-- ============================================================= -->
<template id="report_fp_invoice_landscape">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<div class="fp-landscape">
<div class="page">
<h2 style="text-align: left;">
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice </span>
<span t-elif="doc.move_type == 'out_invoice' and doc.state == 'draft'">Draft Invoice </span>
<span t-elif="doc.move_type == 'out_refund'">Credit Note </span>
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill </span>
<span t-field="doc.name"/>
</h2>
<!-- Billing / Shipping -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">BILLING ADDRESS</th>
<th style="width: 50%;">DELIVERY ADDRESS</th>
</tr>
</thead>
<tbody>
<tr>
<td style="height: 70px; font-size: 12pt;">
<div t-field="doc.partner_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address', 'phone', 'email'], 'no_marker': True}"/>
</td>
<td style="height: 70px; font-size: 12pt;">
<t t-if="doc.partner_shipping_id">
<div t-field="doc.partner_shipping_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address', 'phone'], 'no_marker': True}"/>
</t>
<t t-else="">
<div t-field="doc.partner_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address'], 'no_marker': True}"/>
</t>
</td>
</tr>
</tbody>
</table>
<!-- Invoice info (wide) -->
<table class="bordered info-table">
<thead>
<tr>
<th>INVOICE DATE</th>
<th>DUE DATE</th>
<th>SOURCE</th>
<th>SALES REP</th>
<th>PAYMENT REF</th>
<th>CURRENCY</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.invoice_date"/></td>
<td class="text-center"><span t-field="doc.invoice_date_due"/></td>
<td class="text-center"><span t-field="doc.invoice_origin"/></td>
<td class="text-center"><span t-field="doc.invoice_user_id"/></td>
<td class="text-center"><span t-esc="doc.payment_reference or '-'"/></td>
<td class="text-center"><span t-field="doc.currency_id.name"/></td>
</tr>
</tbody>
</table>
<!-- Lines -->
<table class="bordered">
<thead>
<tr>
<th style="width: 10%;">SKU</th>
<th class="text-start" style="width: 32%;">DESCRIPTION</th>
<th style="width: 8%;">QTY</th>
<th style="width: 8%;">UOM</th>
<th style="width: 12%;">UNIT PRICE</th>
<th style="width: 10%;">DISCOUNT</th>
<th style="width: 10%;">TAXES</th>
<th style="width: 10%;">AMOUNT</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.invoice_line_ids" t-as="line">
<t t-if="line.display_type == 'line_section'">
<tr class="section-row"><td colspan="8"><strong t-field="line.name"/></td></tr>
</t>
<t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td colspan="8"><span t-field="line.name"/></td></tr>
</t>
<t t-elif="not line.display_type">
<tr>
<td class="text-center"><span t-esc="line.product_id.default_code or ''"/></td>
<td>
<t t-set="clean_name" t-value="line.name"/>
<t t-if="line.name and '] ' in line.name">
<t t-set="clean_name" t-value="line.name.split('] ', 1)[1]"/>
</t>
<span t-esc="clean_name"/>
</td>
<td class="text-center">
<span t-esc="int(line.quantity) if line.quantity == int(line.quantity) else line.quantity"/>
</td>
<td class="text-center"><span t-field="line.product_uom_id"/></td>
<td class="text-end">
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
<td class="text-center">
<t t-if="line.discount"><span t-esc="line.discount"/>%</t>
<t t-else="">-</t>
</td>
<td class="text-center">
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
</td>
<td class="text-end">
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
<!-- Terms + Totals -->
<div class="row" style="margin-top: 15px;">
<div class="col-7">
<t t-if="doc.invoice_payment_term_id.note">
<strong>Payment Terms:</strong><br/>
<span t-field="doc.invoice_payment_term_id.note"/>
</t>
</div>
<div class="col-5" style="text-align: right;">
<table class="totals-table" style="width: auto; margin-left: auto;">
<tr>
<td style="min-width: 200px;">Subtotal</td>
<td class="text-end" style="min-width: 150px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Taxes</td>
<td class="text-end">
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr style="background-color: #eaf2f8;">
<td><strong>Grand Total</strong></td>
<td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
<tr>
<td><strong>Amount Due</strong></td>
<td class="text-end"><strong>
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
</t>
</table>
</div>
</div>
<!-- Paid stamp -->
<t t-if="doc.payment_state == 'paid'">
<div style="margin-top: 15px; text-align: center;">
<span class="paid-stamp">PAID</span>
</div>
</t>
<!-- Payment history -->
<t t-if="doc.payment_state != 'invoicing_legacy'">
<t t-set="payments_vals" t-value="doc.sudo().invoice_payments_widget and doc.sudo().invoice_payments_widget.get('content') or []"/>
<t t-if="payments_vals">
<table class="bordered" style="margin-top: 15px;">
<thead>
<tr>
<th colspan="3" style="background-color: #28a745; color: white;">
<t t-if="doc.payment_state == 'paid'">✓ PAYMENT DETAILS — PAID IN FULL</t>
<t t-elif="doc.payment_state == 'partial'">PAYMENT DETAILS — PARTIALLY PAID</t>
<t t-else="">PAYMENT DETAILS</t>
</th>
</tr>
<tr style="background-color: #f5f5f5;">
<th style="width: 30%;">Date</th>
<th style="width: 40%;">Payment Method</th>
<th style="width: 30%;" class="text-end">Amount</th>
</tr>
</thead>
<tbody>
<t t-foreach="payments_vals" t-as="pv">
<tr t-if="not pv.get('is_exchange')">
<td><span t-out="pv.get('date')" t-options='{"widget": "date"}'/></td>
<td><span t-out="pv.get('payment_method_name') or '-'"/></td>
<td class="text-end">
<span t-out="pv.get('amount')" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</t>
</tbody>
</table>
</t>
</t>
<!-- Notes -->
<t t-if="doc.narration">
<div style="margin-top: 15px;">
<strong>Notes:</strong>
<div t-field="doc.narration"/>
</div>
</t>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,280 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Fusion Plating — Packing Slip / Shipping Confirmation (Portrait + Landscape).
Binds to stock.picking. Shows parts, quantities, lot/serial tracking,
and a receiver sign-off.
-->
<odoo>
<!-- ============================================================= -->
<!-- PORTRAIT -->
<!-- ============================================================= -->
<template id="report_fp_packing_slip_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<div class="page">
<h4>
Packing Slip —
<span t-field="doc.name"/>
</h4>
<!-- From / To -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">FROM</th>
<th style="width: 50%;">SHIP TO</th>
</tr>
</thead>
<tbody>
<tr>
<td style="height: 80px;">
<strong><span t-field="doc.company_id.name"/></strong><br/>
<div t-field="doc.company_id.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone', 'email'], 'no_marker': True}"/>
</td>
<td style="height: 80px;">
<strong><span t-field="doc.partner_id.name"/></strong><br/>
<div t-field="doc.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone'], 'no_marker': True}"/>
</td>
</tr>
</tbody>
</table>
<!-- Shipment info -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 25%;">SHIP DATE</th>
<th class="info-header" style="width: 25%;">SOURCE</th>
<th class="info-header" style="width: 25%;">OPERATION</th>
<th class="info-header" style="width: 25%;">CARRIER</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.scheduled_date" t-options="{'widget': 'date'}"/></td>
<td class="text-center"><span t-esc="doc.origin or '-'"/></td>
<td class="text-center"><span t-field="doc.picking_type_id"/></td>
<td class="text-center">
<t t-if="'carrier_id' in doc._fields and doc.carrier_id">
<span t-field="doc.carrier_id"/>
</t>
<t t-else="">-</t>
</td>
</tr>
</tbody>
</table>
<!-- Products -->
<table class="bordered">
<thead>
<tr>
<th style="width: 12%;">SKU</th>
<th class="text-start" style="width: 44%;">PRODUCT</th>
<th style="width: 12%;">QTY</th>
<th style="width: 10%;">UOM</th>
<th style="width: 22%;">LOT / SERIAL</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.move_ids_without_package" t-as="move">
<tr>
<td class="text-center"><span t-esc="move.product_id.default_code or ''"/></td>
<td><span t-field="move.product_id"/></td>
<td class="text-center">
<span t-esc="int(move.quantity) if move.quantity == int(move.quantity) else move.quantity"/>
</td>
<td class="text-center"><span t-field="move.product_uom"/></td>
<td>
<t t-foreach="move.move_line_ids" t-as="ml">
<t t-if="ml.lot_id">
<span t-field="ml.lot_id.name"/><br/>
</t>
</t>
</td>
</tr>
</t>
</tbody>
</table>
<!-- Notes -->
<t t-if="doc.note">
<div style="margin-top: 10px;">
<strong>Notes:</strong>
<div t-field="doc.note"/>
</div>
</t>
<!-- Sign off -->
<div class="row" style="margin-top: 30px;">
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Shipper (Signature / Date)</div>
</div>
</div>
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Receiver (Signature / Date)</div>
</div>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
<!-- ============================================================= -->
<!-- LANDSCAPE -->
<!-- ============================================================= -->
<template id="report_fp_packing_slip_landscape">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<div class="fp-landscape">
<div class="page">
<h2 style="text-align: left;">
Packing Slip —
<span t-field="doc.name"/>
</h2>
<!-- From / To -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">FROM</th>
<th style="width: 50%;">SHIP TO</th>
</tr>
</thead>
<tbody>
<tr>
<td style="height: 80px; font-size: 12pt;">
<strong><span t-field="doc.company_id.name"/></strong><br/>
<div t-field="doc.company_id.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone', 'email'], 'no_marker': True}"/>
</td>
<td style="height: 80px; font-size: 12pt;">
<strong><span t-field="doc.partner_id.name"/></strong><br/>
<div t-field="doc.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone'], 'no_marker': True}"/>
</td>
</tr>
</tbody>
</table>
<!-- Shipment info -->
<table class="bordered info-table">
<thead>
<tr>
<th>SHIP DATE</th>
<th>SOURCE</th>
<th>OPERATION</th>
<th>CARRIER</th>
<th>TRACKING REF</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.scheduled_date" t-options="{'widget': 'date'}"/></td>
<td class="text-center"><span t-esc="doc.origin or '-'"/></td>
<td class="text-center"><span t-field="doc.picking_type_id"/></td>
<td class="text-center">
<t t-if="'carrier_id' in doc._fields and doc.carrier_id">
<span t-field="doc.carrier_id"/>
</t>
<t t-else="">-</t>
</td>
<td class="text-center">
<t t-if="'carrier_tracking_ref' in doc._fields">
<span t-esc="doc.carrier_tracking_ref or '-'"/>
</t>
<t t-else="">-</t>
</td>
</tr>
</tbody>
</table>
<!-- Products -->
<table class="bordered">
<thead>
<tr>
<th style="width: 10%;">SKU</th>
<th class="text-start" style="width: 34%;">PRODUCT</th>
<th style="width: 10%;">ORDERED</th>
<th style="width: 10%;">DONE</th>
<th style="width: 8%;">UOM</th>
<th style="width: 14%;">LOT / SERIAL</th>
<th style="width: 14%;">NOTES</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.move_ids_without_package" t-as="move">
<tr>
<td class="text-center"><span t-esc="move.product_id.default_code or ''"/></td>
<td><span t-field="move.product_id"/></td>
<td class="text-center">
<span t-esc="int(move.product_uom_qty) if move.product_uom_qty == int(move.product_uom_qty) else move.product_uom_qty"/>
</td>
<td class="text-center">
<span t-esc="int(move.quantity) if move.quantity == int(move.quantity) else move.quantity"/>
</td>
<td class="text-center"><span t-field="move.product_uom"/></td>
<td>
<t t-foreach="move.move_line_ids" t-as="ml">
<t t-if="ml.lot_id">
<span t-field="ml.lot_id.name"/><br/>
</t>
</t>
</td>
<td/>
</tr>
</t>
</tbody>
</table>
<!-- Notes -->
<t t-if="doc.note">
<div style="margin-top: 10px;">
<strong>Notes:</strong>
<div t-field="doc.note"/>
</div>
</t>
<!-- Sign off -->
<div class="row" style="margin-top: 30px;">
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Shipper (Signature / Date)</div>
</div>
</div>
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Receiver (Signature / Date)</div>
</div>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,282 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Fusion Plating — Payment Receipt (Portrait + Landscape).
Binds to account.payment. Shows amount paid, method, reference,
applied invoices, and a "PAID" stamp.
-->
<odoo>
<!-- ============================================================= -->
<!-- PORTRAIT -->
<!-- ============================================================= -->
<template id="report_fp_receipt_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<div class="page">
<h4 style="text-align: center;">
PAYMENT RECEIPT
</h4>
<div class="text-center" style="text-align: center; margin-bottom: 15px;">
<strong>Receipt #: <span t-field="doc.name"/></strong>
</div>
<!-- Paid stamp -->
<div class="text-center" style="text-align: center; margin: 20px 0;">
<span class="paid-stamp">✓ RECEIVED</span>
</div>
<!-- From / Received by -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">RECEIVED FROM</th>
<th style="width: 50%;">RECEIVED BY</th>
</tr>
</thead>
<tbody>
<tr>
<td style="height: 70px;">
<strong><span t-field="doc.partner_id.name"/></strong><br/>
<div t-field="doc.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone', 'email'], 'no_marker': True}"/>
</td>
<td style="height: 70px;">
<strong><span t-field="doc.company_id.name"/></strong><br/>
<div t-field="doc.company_id.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone', 'email'], 'no_marker': True}"/>
</td>
</tr>
</tbody>
</table>
<!-- Payment details -->
<table class="bordered">
<thead>
<tr>
<th colspan="2" style="background-color: #0066a1; color: white;">PAYMENT DETAILS</th>
</tr>
</thead>
<tbody>
<tr>
<td style="width: 40%; background-color: #f5f5f5; font-weight: bold;">Payment Date</td>
<td><span t-field="doc.date" t-options="{'widget': 'date'}"/></td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Payment Method</td>
<td><span t-field="doc.payment_method_line_id"/></td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Journal</td>
<td><span t-field="doc.journal_id"/></td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Reference / Memo</td>
<td><span t-esc="doc.ref or doc.memo or '-'"/></td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Amount</td>
<td style="font-size: 14pt;"><strong>
<span t-field="doc.amount" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
</tbody>
</table>
<!-- Applied invoices -->
<t t-if="doc.reconciled_invoice_ids">
<table class="bordered">
<thead>
<tr>
<th colspan="3" style="background-color: #0066a1; color: white;">APPLIED TO INVOICES</th>
</tr>
<tr>
<th style="width: 34%;">INVOICE #</th>
<th style="width: 33%;">DATE</th>
<th style="width: 33%;">AMOUNT</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.reconciled_invoice_ids" t-as="inv">
<tr>
<td class="text-center"><span t-field="inv.name"/></td>
<td class="text-center"><span t-field="inv.invoice_date"/></td>
<td class="text-end">
<span t-field="inv.amount_total" t-options='{"widget": "monetary", "display_currency": inv.currency_id}'/>
</td>
</tr>
</t>
</tbody>
</table>
</t>
<!-- Thank you -->
<div class="highlight-box" style="margin-top: 20px; text-align: center;">
Thank you for your payment. This receipt confirms that payment has
been received in full for the amount stated above.
</div>
<!-- Sign off -->
<div class="row" style="margin-top: 30px;">
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Received by (Signature)</div>
</div>
</div>
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Date</div>
</div>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
<!-- ============================================================= -->
<!-- LANDSCAPE -->
<!-- ============================================================= -->
<template id="report_fp_receipt_landscape">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<div class="fp-landscape">
<div class="page">
<h2 style="text-align: center;">PAYMENT RECEIPT</h2>
<div class="text-center" style="text-align: center; margin-bottom: 15px;">
<strong>Receipt #: <span t-field="doc.name"/></strong>
</div>
<!-- Paid stamp -->
<div class="text-center" style="text-align: center; margin: 20px 0;">
<span class="paid-stamp">✓ RECEIVED</span>
</div>
<!-- From / Received by -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">RECEIVED FROM</th>
<th style="width: 50%;">RECEIVED BY</th>
</tr>
</thead>
<tbody>
<tr>
<td style="height: 80px; font-size: 12pt;">
<strong><span t-field="doc.partner_id.name"/></strong><br/>
<div t-field="doc.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone', 'email'], 'no_marker': True}"/>
</td>
<td style="height: 80px; font-size: 12pt;">
<strong><span t-field="doc.company_id.name"/></strong><br/>
<div t-field="doc.company_id.partner_id"
t-options="{'widget': 'contact', 'fields': ['address', 'phone', 'email'], 'no_marker': True}"/>
</td>
</tr>
</tbody>
</table>
<!-- Payment details (wide) -->
<table class="bordered info-table">
<thead>
<tr>
<th>PAYMENT DATE</th>
<th>METHOD</th>
<th>JOURNAL</th>
<th>REFERENCE</th>
<th>CURRENCY</th>
<th>AMOUNT</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.date" t-options="{'widget': 'date'}"/></td>
<td class="text-center"><span t-field="doc.payment_method_line_id"/></td>
<td class="text-center"><span t-field="doc.journal_id"/></td>
<td class="text-center"><span t-esc="doc.ref or doc.memo or '-'"/></td>
<td class="text-center"><span t-field="doc.currency_id.name"/></td>
<td class="text-end" style="font-size: 13pt;"><strong>
<span t-field="doc.amount" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
</tbody>
</table>
<!-- Applied invoices -->
<t t-if="doc.reconciled_invoice_ids">
<table class="bordered">
<thead>
<tr>
<th colspan="5" style="background-color: #0066a1; color: white;">APPLIED TO INVOICES</th>
</tr>
<tr>
<th style="width: 20%;">INVOICE #</th>
<th style="width: 20%;">DATE</th>
<th style="width: 20%;">DUE DATE</th>
<th style="width: 20%;">TOTAL</th>
<th style="width: 20%;">PAYMENT STATE</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.reconciled_invoice_ids" t-as="inv">
<tr>
<td class="text-center"><span t-field="inv.name"/></td>
<td class="text-center"><span t-field="inv.invoice_date"/></td>
<td class="text-center"><span t-field="inv.invoice_date_due"/></td>
<td class="text-end">
<span t-field="inv.amount_total" t-options='{"widget": "monetary", "display_currency": inv.currency_id}'/>
</td>
<td class="text-center">
<span t-if="inv.payment_state == 'paid'" class="status-ok">Paid</span>
<span t-elif="inv.payment_state == 'partial'" class="status-warning">Partial</span>
<span t-else=""><span t-field="inv.payment_state"/></span>
</td>
</tr>
</t>
</tbody>
</table>
</t>
<!-- Thank you -->
<div class="highlight-box" style="margin-top: 20px; text-align: center;">
Thank you for your payment. This receipt confirms that payment has
been received in full for the amount stated above.
</div>
<!-- Sign off -->
<div class="row" style="margin-top: 30px;">
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Received by (Signature)</div>
</div>
</div>
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Date</div>
</div>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,464 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Fusion Plating — Quotation / Sales Order (Portrait + Landscape)
Renders the same sale.order with a title that flips between
"Quotation" (draft/sent) and "Sales Order" (confirmed/done).
-->
<odoo>
<!-- ============================================================= -->
<!-- PORTRAIT -->
<!-- ============================================================= -->
<template id="report_fp_sale_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<div class="page">
<!-- Title -->
<h4>
<span t-if="doc.state in ['draft','sent']">Quotation </span>
<span t-else="">Sales Order </span>
<span t-field="doc.name"/>
</h4>
<!-- Billing / Shipping -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">BILLING ADDRESS</th>
<th style="width: 50%;">SHIPPING ADDRESS</th>
</tr>
</thead>
<tbody>
<tr>
<td style="height: 70px;">
<div t-field="doc.partner_invoice_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address', 'phone', 'email'], 'no_marker': True}"/>
</td>
<td style="height: 70px;">
<div t-field="doc.partner_shipping_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address', 'phone'], 'no_marker': True}"/>
</td>
</tr>
</tbody>
</table>
<!-- Order info -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 20%;">ORDER DATE</th>
<th class="info-header" style="width: 20%;">EXPIRATION</th>
<th class="info-header" style="width: 20%;">SALESPERSON</th>
<th class="info-header" style="width: 20%;">CUSTOMER PO #</th>
<th class="info-header" style="width: 20%;">RUSH</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.date_order" t-options="{'widget': 'date'}"/></td>
<td class="text-center"><span t-field="doc.validity_date"/></td>
<td class="text-center"><span t-field="doc.user_id"/></td>
<td class="text-center"><span t-esc="doc.x_fc_po_number or '-'"/></td>
<td class="text-center">
<span t-if="doc.x_fc_rush_order" class="status-warning">RUSH</span>
<span t-else="">Standard</span>
</td>
</tr>
</tbody>
</table>
<!-- Plating info -->
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_coating_config_id or doc.x_fc_delivery_method">
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 34%;">PART</th>
<th class="info-header" style="width: 33%;">COATING CONFIG</th>
<th class="info-header" style="width: 33%;">DELIVERY METHOD</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td>
<td class="text-center"><span t-field="doc.x_fc_coating_config_id"/></td>
<td class="text-center">
<t t-set="dm" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '-')"/>
<span t-esc="dm"/>
</td>
</tr>
</tbody>
</table>
</t>
<!-- Order lines -->
<table class="bordered">
<thead>
<tr>
<th style="width: 10%;">SKU</th>
<th class="text-start" style="width: 40%;">DESCRIPTION</th>
<th style="width: 8%;">QTY</th>
<th style="width: 8%;">UOM</th>
<th style="width: 12%;">UNIT PRICE</th>
<th style="width: 10%;">TAXES</th>
<th style="width: 12%;">AMOUNT</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.order_line" t-as="line">
<t t-if="line.display_type == 'line_section'">
<tr class="section-row"><td colspan="7"><strong t-field="line.name"/></td></tr>
</t>
<t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td colspan="7"><span t-field="line.name"/></td></tr>
</t>
<t t-elif="not line.display_type">
<tr>
<td class="text-center"><span t-esc="line.product_id.default_code or ''"/></td>
<td>
<t t-set="clean_name" t-value="line.name"/>
<t t-if="line.name and '] ' in line.name">
<t t-set="clean_name" t-value="line.name.split('] ', 1)[1]"/>
</t>
<span t-esc="clean_name"/>
</td>
<td class="text-center">
<span t-esc="int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty"/>
</td>
<td class="text-center"><span t-field="line.product_uom"/></td>
<td class="text-end">
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
<td class="text-center">
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
</td>
<td class="text-end">
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
<!-- Terms + Totals -->
<div class="row" style="margin-top: 15px;">
<div class="col-6">
<t t-if="doc.payment_term_id.note">
<strong>Payment Terms:</strong><br/>
<span t-field="doc.payment_term_id.note"/>
</t>
<t t-if="doc.x_fc_invoice_strategy">
<div style="margin-top: 10px;">
<strong>Invoice Strategy: </strong>
<t t-set="inv_strat" t-value="dict(doc._fields['x_fc_invoice_strategy'].selection).get(doc.x_fc_invoice_strategy, '-')"/>
<span t-esc="inv_strat"/>
<t t-if="doc.x_fc_invoice_strategy == 'deposit' and doc.x_fc_deposit_percent">
(<span t-esc="doc.x_fc_deposit_percent"/>%)
</t>
</div>
</t>
</div>
<div class="col-6" style="text-align: right;">
<table class="totals-table" style="width: auto; margin-left: auto;">
<tr>
<td style="min-width: 150px;">Subtotal</td>
<td class="text-end" style="min-width: 110px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Taxes</td>
<td class="text-end">
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr style="background-color: #eaf2f8;">
<td><strong>Grand Total</strong></td>
<td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
</table>
</div>
</div>
<!-- Terms and Conditions -->
<t t-if="doc.note">
<div style="margin-top: 15px;">
<strong>Terms and Conditions:</strong>
<div t-field="doc.note"/>
</div>
</t>
<!-- Signature -->
<div class="row" style="margin-top: 25px;">
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Customer Acceptance (Signature / Date)</div>
</div>
</div>
<div class="col-6">
<div class="sig-box">
<t t-if="doc.signature">
<img t-att-src="image_data_uri(doc.signature)" style="max-height: 3cm; max-width: 8cm;"/><br/>
<span t-field="doc.signed_by"/>
</t>
<t t-else="">
<div class="sig-line"/>
</t>
<div class="small-muted">Authorized Representative</div>
</div>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
<!-- ============================================================= -->
<!-- LANDSCAPE -->
<!-- ============================================================= -->
<template id="report_fp_sale_landscape">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<div class="fp-landscape">
<div class="page">
<!-- Title -->
<h2 style="text-align: left;">
<span t-if="doc.state in ['draft','sent']">Quotation </span>
<span t-else="">Sales Order </span>
<span t-field="doc.name"/>
</h2>
<!-- Billing / Shipping -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">BILLING ADDRESS</th>
<th style="width: 50%;">SHIPPING ADDRESS</th>
</tr>
</thead>
<tbody>
<tr>
<td style="height: 70px; font-size: 12pt;">
<div t-field="doc.partner_invoice_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address', 'phone', 'email'], 'no_marker': True}"/>
</td>
<td style="height: 70px; font-size: 12pt;">
<div t-field="doc.partner_shipping_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address', 'phone'], 'no_marker': True}"/>
</td>
</tr>
</tbody>
</table>
<!-- Order info (wide) -->
<table class="bordered info-table">
<thead>
<tr>
<th>ORDER DATE</th>
<th>EXPIRATION</th>
<th>SALESPERSON</th>
<th>CUSTOMER PO #</th>
<th>DELIVERY METHOD</th>
<th>RUSH</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.date_order" t-options="{'widget': 'date'}"/></td>
<td class="text-center"><span t-field="doc.validity_date"/></td>
<td class="text-center"><span t-field="doc.user_id"/></td>
<td class="text-center"><span t-esc="doc.x_fc_po_number or '-'"/></td>
<td class="text-center">
<t t-set="dm" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '-')"/>
<span t-esc="dm"/>
</td>
<td class="text-center">
<span t-if="doc.x_fc_rush_order" class="status-warning">RUSH</span>
<span t-else="">Standard</span>
</td>
</tr>
</tbody>
</table>
<!-- Plating details -->
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_coating_config_id">
<table class="bordered info-table">
<thead>
<tr>
<th>PART CATALOG</th>
<th>COATING CONFIGURATION</th>
<th>INVOICE STRATEGY</th>
<th>DEPOSIT %</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td>
<td class="text-center"><span t-field="doc.x_fc_coating_config_id"/></td>
<td class="text-center">
<t t-set="inv_strat" t-value="dict(doc._fields['x_fc_invoice_strategy'].selection).get(doc.x_fc_invoice_strategy, '-')"/>
<span t-esc="inv_strat"/>
</td>
<td class="text-center">
<t t-if="doc.x_fc_deposit_percent">
<span t-esc="doc.x_fc_deposit_percent"/>%
</t>
<t t-else="">-</t>
</td>
</tr>
</tbody>
</table>
</t>
<!-- Order lines -->
<table class="bordered">
<thead>
<tr>
<th style="width: 10%;">SKU</th>
<th class="text-start" style="width: 32%;">DESCRIPTION</th>
<th style="width: 8%;">QTY</th>
<th style="width: 8%;">UOM</th>
<th style="width: 12%;">UNIT PRICE</th>
<th style="width: 10%;">DISCOUNT</th>
<th style="width: 10%;">TAXES</th>
<th style="width: 10%;">AMOUNT</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.order_line" t-as="line">
<t t-if="line.display_type == 'line_section'">
<tr class="section-row"><td colspan="8"><strong t-field="line.name"/></td></tr>
</t>
<t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td colspan="8"><span t-field="line.name"/></td></tr>
</t>
<t t-elif="not line.display_type">
<tr>
<td class="text-center"><span t-esc="line.product_id.default_code or ''"/></td>
<td>
<t t-set="clean_name" t-value="line.name"/>
<t t-if="line.name and '] ' in line.name">
<t t-set="clean_name" t-value="line.name.split('] ', 1)[1]"/>
</t>
<span t-esc="clean_name"/>
</td>
<td class="text-center">
<span t-esc="int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty"/>
</td>
<td class="text-center"><span t-field="line.product_uom"/></td>
<td class="text-end">
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
<td class="text-center">
<t t-if="line.discount"><span t-esc="line.discount"/>%</t>
<t t-else="">-</t>
</td>
<td class="text-center">
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
</td>
<td class="text-end">
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
<!-- Terms + Totals -->
<div class="row" style="margin-top: 15px;">
<div class="col-7">
<t t-if="doc.payment_term_id.note">
<strong>Payment Terms:</strong><br/>
<span t-field="doc.payment_term_id.note"/>
</t>
<t t-if="doc.x_fc_invoice_strategy">
<div style="margin-top: 10px;">
<strong>Invoice Strategy: </strong>
<t t-set="inv_strat" t-value="dict(doc._fields['x_fc_invoice_strategy'].selection).get(doc.x_fc_invoice_strategy, '-')"/>
<span t-esc="inv_strat"/>
<t t-if="doc.x_fc_invoice_strategy == 'deposit' and doc.x_fc_deposit_percent">
(<span t-esc="doc.x_fc_deposit_percent"/>%)
</t>
</div>
</t>
</div>
<div class="col-5" style="text-align: right;">
<table class="totals-table" style="width: auto; margin-left: auto;">
<tr>
<td style="min-width: 200px;">Subtotal</td>
<td class="text-end" style="min-width: 150px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Taxes</td>
<td class="text-end">
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr style="background-color: #eaf2f8;">
<td><strong>Grand Total</strong></td>
<td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
</table>
</div>
</div>
<!-- Terms and Conditions -->
<t t-if="doc.note">
<div style="margin-top: 15px;">
<strong>Terms and Conditions:</strong>
<div t-field="doc.note"/>
</div>
</t>
<!-- Signature block -->
<div class="row" style="margin-top: 25px;">
<div class="col-6">
<div class="sig-box">
<div class="sig-line"/>
<div class="small-muted">Customer Acceptance (Signature / Date)</div>
</div>
</div>
<div class="col-6">
<div class="sig-box">
<t t-if="doc.signature">
<img t-att-src="image_data_uri(doc.signature)" style="max-height: 3cm; max-width: 8cm;"/><br/>
<span t-field="doc.signed_by"/>
</t>
<t t-else="">
<div class="sig-line"/>
</t>
<div class="small-muted">Authorized Representative</div>
</div>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,380 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Fusion Plating — Work Order traveller (Portrait + Landscape).
Printed shop-floor sheet with step info, bath/tank, chemistry targets,
and sign-off rows.
-->
<odoo>
<!-- ============================================================= -->
<!-- PORTRAIT -->
<!-- ============================================================= -->
<template id="report_fp_work_order_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<div class="page">
<h4>
Work Order Traveller —
<span t-field="doc.name"/>
</h4>
<!-- Header info -->
<table class="bordered">
<thead>
<tr>
<th class="info-header">MO #</th>
<th class="info-header">STEP</th>
<th class="info-header">WORK CENTRE</th>
<th class="info-header">PRIORITY</th>
<th class="info-header">STATE</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.production_id.name"/></td>
<td class="text-center"><span t-esc="doc.x_fc_step_display or '-'"/></td>
<td class="text-center"><span t-field="doc.workcenter_id"/></td>
<td class="text-center">
<t t-set="prio" t-value="dict(doc._fields['x_fc_priority'].selection).get(doc.x_fc_priority, 'Normal')"/>
<span t-esc="prio"/>
</td>
<td class="text-center"><span t-field="doc.state"/></td>
</tr>
</tbody>
</table>
<!-- Customer / Part -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 50%;">CUSTOMER</th>
<th class="info-header" style="width: 50%;">PRODUCT</th>
</tr>
</thead>
<tbody>
<tr>
<td><span t-field="doc.x_fc_customer_id"/></td>
<td><span t-field="doc.product_id"/></td>
</tr>
</tbody>
</table>
<!-- Sale / Portal links -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 33%;">SALE ORDER</th>
<th class="info-header" style="width: 34%;">PORTAL JOB</th>
<th class="info-header" style="width: 33%;">QTY TO PRODUCE</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-esc="doc.x_fc_sale_order_name or '-'"/></td>
<td class="text-center"><span t-field="doc.x_fc_portal_job_id"/></td>
<td class="text-center"><span t-field="doc.qty_production"/></td>
</tr>
</tbody>
</table>
<!-- Process parameters -->
<table class="bordered">
<thead>
<tr><th colspan="2" style="background-color: #0066a1; color: white;">PROCESS PARAMETERS</th></tr>
</thead>
<tbody>
<tr>
<td style="width: 35%; background-color: #f5f5f5; font-weight: bold;">Bath</td>
<td><span t-field="doc.x_fc_bath_id"/></td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Tank</td>
<td><span t-field="doc.x_fc_tank_id"/></td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Rack / Fixture Ref</td>
<td><span t-esc="doc.x_fc_rack_ref or '-'"/></td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Target Thickness</td>
<td>
<t t-if="doc.x_fc_thickness_target">
<span t-esc="doc.x_fc_thickness_target"/>
<span t-esc="dict(doc._fields['x_fc_thickness_uom'].selection).get(doc.x_fc_thickness_uom, '')"/>
</t>
<t t-else="">-</t>
</td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Dwell Time</td>
<td>
<t t-if="doc.x_fc_dwell_time_minutes">
<span t-esc="doc.x_fc_dwell_time_minutes"/> min
</t>
<t t-else="">-</t>
</td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Facility</td>
<td><span t-field="doc.x_fc_facility_id"/></td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Expected Duration</td>
<td><span t-esc="doc.duration_expected"/> min</td>
</tr>
</tbody>
</table>
<!-- Operation instructions -->
<t t-if="doc.operation_id and doc.operation_id.note">
<table class="bordered">
<thead>
<tr><th style="background-color: #0066a1; color: white;">OPERATION INSTRUCTIONS</th></tr>
</thead>
<tbody>
<tr><td><div t-field="doc.operation_id.note"/></td></tr>
</tbody>
</table>
</t>
<!-- Bath chemistry targets snapshot -->
<t t-if="doc.x_fc_bath_id and doc.x_fc_bath_id.target_line_ids">
<table class="bordered">
<thead>
<tr>
<th>PARAMETER</th>
<th>MIN</th>
<th>MAX</th>
<th>UOM</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.x_fc_bath_id.target_line_ids" t-as="p">
<tr>
<td><span t-field="p.parameter_id"/></td>
<td class="text-center"><span t-esc="p.target_min or '-'"/></td>
<td class="text-center"><span t-esc="p.target_max or '-'"/></td>
<td class="text-center"><span t-esc="p.uom or '-'"/></td>
</tr>
</t>
</tbody>
</table>
</t>
<!-- Sign-off -->
<div style="margin-top: 20px;">
<table class="bordered">
<thead>
<tr>
<th colspan="3" style="background-color: #0066a1; color: white;">OPERATOR SIGN-OFF</th>
</tr>
<tr>
<th style="width: 40%;">OPERATOR</th>
<th style="width: 30%;">DATE / TIME</th>
<th style="width: 30%;">INITIALS</th>
</tr>
</thead>
<tbody>
<tr><td class="sig-line"/><td class="sig-line"/><td class="sig-line"/></tr>
<tr><td class="sig-line"/><td class="sig-line"/><td class="sig-line"/></tr>
</tbody>
</table>
</div>
<!-- Notes -->
<div style="margin-top: 15px;">
<strong>Notes / Observations:</strong>
<div style="border: 1px solid #000; min-height: 80px; margin-top: 5px;"/>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
<!-- ============================================================= -->
<!-- LANDSCAPE -->
<!-- ============================================================= -->
<template id="report_fp_work_order_landscape">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<div class="fp-landscape">
<div class="page">
<h2 style="text-align: left;">
Work Order Traveller —
<span t-field="doc.name"/>
</h2>
<!-- Header info (wide) -->
<table class="bordered info-table">
<thead>
<tr>
<th>MO #</th>
<th>STEP</th>
<th>WORK CENTRE</th>
<th>FACILITY</th>
<th>PRIORITY</th>
<th>STATE</th>
<th>EXPECTED DURATION</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.production_id.name"/></td>
<td class="text-center"><span t-esc="doc.x_fc_step_display or '-'"/></td>
<td class="text-center"><span t-field="doc.workcenter_id"/></td>
<td class="text-center"><span t-field="doc.x_fc_facility_id"/></td>
<td class="text-center">
<t t-set="prio" t-value="dict(doc._fields['x_fc_priority'].selection).get(doc.x_fc_priority, 'Normal')"/>
<span t-esc="prio"/>
</td>
<td class="text-center"><span t-field="doc.state"/></td>
<td class="text-center"><span t-esc="doc.duration_expected"/> min</td>
</tr>
</tbody>
</table>
<!-- Customer / Links -->
<table class="bordered info-table">
<thead>
<tr>
<th>CUSTOMER</th>
<th>PRODUCT</th>
<th>SALE ORDER</th>
<th>PORTAL JOB</th>
<th>QTY</th>
</tr>
</thead>
<tbody>
<tr>
<td><span t-field="doc.x_fc_customer_id"/></td>
<td><span t-field="doc.product_id"/></td>
<td class="text-center"><span t-esc="doc.x_fc_sale_order_name or '-'"/></td>
<td class="text-center"><span t-field="doc.x_fc_portal_job_id"/></td>
<td class="text-center"><span t-field="doc.qty_production"/></td>
</tr>
</tbody>
</table>
<!-- Process Parameters / Chemistry Targets side-by-side -->
<div class="row">
<div class="col-6">
<table class="bordered">
<thead>
<tr><th colspan="2" style="background-color: #0066a1; color: white;">PROCESS PARAMETERS</th></tr>
</thead>
<tbody>
<tr>
<td style="width: 40%; background-color: #f5f5f5; font-weight: bold;">Bath</td>
<td><span t-field="doc.x_fc_bath_id"/></td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Tank</td>
<td><span t-field="doc.x_fc_tank_id"/></td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Rack / Fixture</td>
<td><span t-esc="doc.x_fc_rack_ref or '-'"/></td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Target Thickness</td>
<td>
<t t-if="doc.x_fc_thickness_target">
<span t-esc="doc.x_fc_thickness_target"/>
<span t-esc="dict(doc._fields['x_fc_thickness_uom'].selection).get(doc.x_fc_thickness_uom, '')"/>
</t>
<t t-else="">-</t>
</td>
</tr>
<tr>
<td style="background-color: #f5f5f5; font-weight: bold;">Dwell Time</td>
<td>
<t t-if="doc.x_fc_dwell_time_minutes">
<span t-esc="doc.x_fc_dwell_time_minutes"/> min
</t>
<t t-else="">-</t>
</td>
</tr>
</tbody>
</table>
</div>
<div class="col-6">
<t t-if="doc.x_fc_bath_id and doc.x_fc_bath_id.target_line_ids">
<table class="bordered">
<thead>
<tr>
<th colspan="4" style="background-color: #0066a1; color: white;">CHEMISTRY TARGETS</th>
</tr>
<tr>
<th>PARAM</th>
<th>MIN</th>
<th>MAX</th>
<th>UOM</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.x_fc_bath_id.target_line_ids" t-as="p">
<tr>
<td><span t-field="p.parameter_id"/></td>
<td class="text-center"><span t-esc="p.target_min or '-'"/></td>
<td class="text-center"><span t-esc="p.target_max or '-'"/></td>
<td class="text-center"><span t-esc="p.uom or '-'"/></td>
</tr>
</t>
</tbody>
</table>
</t>
</div>
</div>
<!-- Operation instructions -->
<t t-if="doc.operation_id and doc.operation_id.note">
<table class="bordered">
<thead>
<tr><th style="background-color: #0066a1; color: white;">OPERATION INSTRUCTIONS</th></tr>
</thead>
<tbody>
<tr><td><div t-field="doc.operation_id.note"/></td></tr>
</tbody>
</table>
</t>
<!-- Sign-off -->
<table class="bordered" style="margin-top: 15px;">
<thead>
<tr>
<th colspan="5" style="background-color: #0066a1; color: white;">OPERATOR SIGN-OFF</th>
</tr>
<tr>
<th style="width: 25%;">OPERATOR</th>
<th style="width: 15%;">DATE</th>
<th style="width: 15%;">TIME IN</th>
<th style="width: 15%;">TIME OUT</th>
<th style="width: 30%;">INITIALS / NOTES</th>
</tr>
</thead>
<tbody>
<tr><td class="sig-line"/><td class="sig-line"/><td class="sig-line"/><td class="sig-line"/><td class="sig-line"/></tr>
<tr><td class="sig-line"/><td class="sig-line"/><td class="sig-line"/><td class="sig-line"/><td class="sig-line"/></tr>
</tbody>
</table>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>