feat(fusion_plating): quote-to-cash infra, notifications, wizards, Tier 1 plating features
Quote-to-cash PDF reports (portrait + landscape variants, 16 new actions): - Quotation / Sales Order, Work Order Traveller, Packing Slip, Bill of Lading, Certificate of Conformance (portrait added), Invoice, Payment Receipt - Shared fp_portrait_styles + fp_landscape_styles base templates Workflow gap fixes (fusion_plating_bridge_mrp): - Auto-assign recipe from SO coating config in MrpProduction.action_confirm - Auto-create draft CoC (fp.certificate) on MrpProduction.button_mark_done Notifications overhaul (fusion_plating_notifications v2.0): - Expanded TRIGGER_EVENTS to 7 (added quote_sent, mo_complete, shipped, payment_received) - Shared _dispatch method replaces three duplicated send helpers - Auto-attach PDF reports per template config (quote, SO, CoC, invoice, receipt, BoL) - Rebuilt 7 email templates with fusion_claims accent-bar design (info/success color-coded, theme-safe, 600px max-width) - New hooks: MrpProduction done, FpDelivery mark_delivered, AccountPayment post, SaleOrder action_quotation_send Wizards (fusion_plating_configurator): - fp.direct.order.wizard — skip quotation for repeat customers with PO in hand; optional new-revision drawing upload bumps fp.part.catalog revision and links new rev to the SO; creates + confirms the SO in one step - fp.part.catalog.import.wizard — 3-step CSV import with dry-run preview, tolerant parsing (customer by name/email/xmlid, human-readable selections), duplicate detection, create-missing-customers option, single transaction commit - Partner form stat buttons: Direct Order, Import Parts - CSV template download button Tier 1 practical plating features: - T1.1 Hydrogen bake window enforcement (fp.coating.config.requires_bake_relief, auto-create fusion.plating.bake.window on plating WO finish, FpDelivery lockout when window is open) - T1.2 Bath replenishment rules + pending suggestion queue (fusion.plating.bath.replenishment.rule + .suggestion, hook on bath log line create, operator Apply / Dismiss actions) - T1.3 Rack/fixture library (fusion.plating.rack with MTO counter, strip schedule, lifecycle: active → needs_strip → stripping → retired) - T1.4 Rework / strip-and-replate MOs (x_fc_is_rework, x_fc_original_production_id, Create Rework stat button on completed MOs) - T1.5 Parts location (x_fc_current_location computed on mrp.production — "In progress: Alkaline Clean" / "Queued: Bake Oven" / "Ready to Ship") Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'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',
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})',
|
||||
)
|
||||
|
||||
@@ -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'})
|
||||
117
fusion_plating/fusion_plating/models/fp_rack.py
Normal file
117
fusion_plating/fusion_plating/models/fp_rack.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
@@ -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>
|
||||
@@ -43,6 +43,24 @@
|
||||
action="action_fp_tank"
|
||||
sequence="30"/>
|
||||
|
||||
<menuitem id="menu_fp_racks"
|
||||
name="Racks & 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"
|
||||
|
||||
132
fusion_plating/fusion_plating/views/fp_rack_views.xml
Normal file
132
fusion_plating/fusion_plating/views/fp_rack_views.xml
Normal 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 & 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 & 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>
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — MRP Bridge',
|
||||
'version': '19.0.2.1.0',
|
||||
'version': '19.0.3.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||
'description': """
|
||||
@@ -39,6 +39,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_quality',
|
||||
'fusion_plating_logistics',
|
||||
'fusion_plating_batch',
|
||||
'fusion_plating_shopfloor',
|
||||
'fusion_plating_configurator',
|
||||
'mrp',
|
||||
'mrp_workorder',
|
||||
'mrp_account',
|
||||
|
||||
@@ -3,28 +3,43 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpDelivery(models.Model):
|
||||
"""Extend delivery to auto-update portal job when delivered.
|
||||
|
||||
GAP 5: Delivery marked "delivered" → portal job → "shipped"
|
||||
+ set actual_ship_date on the job.
|
||||
"""Extend delivery to auto-update portal job and block shipment
|
||||
when hydrogen embrittlement bake window isn't closed.
|
||||
"""
|
||||
_inherit = 'fusion.plating.delivery'
|
||||
|
||||
def action_mark_delivered(self):
|
||||
"""Override to cascade delivery completion to the portal job."""
|
||||
"""Override to cascade delivery completion to the portal job and
|
||||
enforce the bake-window lockout."""
|
||||
# --- Lockout: refuse to ship if any bake window for this job isn't complete
|
||||
BakeWindow = self.env.get('fusion.plating.bake.window')
|
||||
if BakeWindow is not None:
|
||||
for delivery in self:
|
||||
if not delivery.job_ref:
|
||||
continue
|
||||
open_windows = BakeWindow.search([
|
||||
('lot_ref', '=', delivery.job_ref),
|
||||
('state', 'not in', ('baked', 'scrapped')),
|
||||
])
|
||||
if open_windows:
|
||||
bad = open_windows[0]
|
||||
raise UserError(_(
|
||||
'Cannot mark delivery %s delivered — job %s has an open '
|
||||
'bake window (%s, state: %s). Complete the relief bake '
|
||||
'or mark it scrapped before shipping.'
|
||||
) % (delivery.name, delivery.job_ref, bad.name, bad.state))
|
||||
|
||||
res = super().action_mark_delivered()
|
||||
PortalJob = self.env['fusion.plating.portal.job']
|
||||
for delivery in self:
|
||||
if not delivery.job_ref:
|
||||
continue
|
||||
# Find the portal job by name/reference
|
||||
job = PortalJob.search(
|
||||
[('name', '=', delivery.job_ref)], limit=1,
|
||||
)
|
||||
job = PortalJob.search([('name', '=', delivery.job_ref)], limit=1)
|
||||
if not job:
|
||||
continue
|
||||
job.write({
|
||||
@@ -32,5 +47,5 @@ class FpDelivery(models.Model):
|
||||
'actual_ship_date': fields.Date.today(),
|
||||
'tracking_ref': delivery.name,
|
||||
})
|
||||
job.message_post(body='Parts shipped — delivery %s marked delivered.' % delivery.name)
|
||||
job.message_post(body=_('Parts shipped — delivery %s marked delivered.') % delivery.name)
|
||||
return res
|
||||
|
||||
@@ -49,11 +49,112 @@ class MrpProduction(models.Model):
|
||||
compute='_compute_override_count',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# T1.4 — Rework / strip-and-replate
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_is_rework = fields.Boolean(
|
||||
string='Rework Order',
|
||||
help='This MO is a rework (strip-and-replate) of a previous order.',
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_original_production_id = fields.Many2one(
|
||||
'mrp.production', string='Original MO',
|
||||
help='The manufacturing order this rework replaces.',
|
||||
)
|
||||
x_fc_rework_reason = fields.Text(string='Rework Reason')
|
||||
x_fc_rework_children_ids = fields.One2many(
|
||||
'mrp.production', 'x_fc_original_production_id',
|
||||
string='Rework MOs',
|
||||
)
|
||||
x_fc_rework_count = fields.Integer(
|
||||
string='Reworks', compute='_compute_rework_count',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# T1.5 — Parts location (computed from workorder progress)
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_current_location = fields.Char(
|
||||
string='Parts Location',
|
||||
compute='_compute_current_location',
|
||||
store=True,
|
||||
help='Where the parts physically are right now — the active work centre, '
|
||||
'or "Ready to Ship" when all work is done.',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_override_ids')
|
||||
def _compute_override_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_override_count = len(rec.x_fc_override_ids)
|
||||
|
||||
def _compute_rework_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_rework_count = len(rec.x_fc_rework_children_ids)
|
||||
|
||||
@api.depends(
|
||||
'workorder_ids.state',
|
||||
'workorder_ids.workcenter_id.name',
|
||||
'state',
|
||||
)
|
||||
def _compute_current_location(self):
|
||||
for mo in self:
|
||||
if mo.state == 'done':
|
||||
mo.x_fc_current_location = _('Ready to Ship')
|
||||
continue
|
||||
if mo.state == 'cancel':
|
||||
mo.x_fc_current_location = _('Cancelled')
|
||||
continue
|
||||
# Find the first WO that isn't done yet
|
||||
active = mo.workorder_ids.sorted('sequence').filtered(
|
||||
lambda w: w.state in ('pending', 'ready', 'progress')
|
||||
)[:1]
|
||||
if active:
|
||||
wo = active
|
||||
if wo.state == 'progress':
|
||||
mo.x_fc_current_location = _('In progress: %s') % wo.workcenter_id.name
|
||||
else:
|
||||
mo.x_fc_current_location = _('Queued: %s') % wo.workcenter_id.name
|
||||
else:
|
||||
mo.x_fc_current_location = _('Pending')
|
||||
|
||||
def action_view_reworks(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Rework MOs'),
|
||||
'res_model': 'mrp.production',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('x_fc_original_production_id', '=', self.id)],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_create_rework(self):
|
||||
"""Open a wizard — or just copy the MO with is_rework flag set."""
|
||||
self.ensure_one()
|
||||
if self.state != 'done':
|
||||
raise UserError(_('Rework can only be created from a completed MO.'))
|
||||
rework = self.copy({
|
||||
'name': False, # Let the sequence assign a fresh name
|
||||
'x_fc_is_rework': True,
|
||||
'x_fc_original_production_id': self.id,
|
||||
'x_fc_portal_job_id': False,
|
||||
'origin': self.origin, # Keep original SO link for billing
|
||||
})
|
||||
rework.message_post(
|
||||
body=_('Rework of MO %s — reason will be recorded in the '
|
||||
'rework reason field.') % self.name,
|
||||
)
|
||||
self.message_post(
|
||||
body=_('Rework MO %s created.') % rework.name,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Rework MO'),
|
||||
'res_model': 'mrp.production',
|
||||
'res_id': rework.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_configure_recipe_steps(self):
|
||||
"""Open the wizard to configure opt-in/out steps for this job."""
|
||||
self.ensure_one()
|
||||
@@ -175,6 +276,32 @@ class MrpProduction(models.Model):
|
||||
len(wo_vals_list), production.x_fc_recipe_id.name),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Recipe auto-assignment from SO coating config
|
||||
# ------------------------------------------------------------------
|
||||
def _auto_assign_recipe_from_so(self):
|
||||
"""If no recipe is set, pull the default recipe from the SO's
|
||||
coating config (fp.coating.config.recipe_id).
|
||||
"""
|
||||
for mo in self:
|
||||
if mo.x_fc_recipe_id:
|
||||
continue # Already set — respect planner's choice
|
||||
if not mo.origin:
|
||||
continue
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', mo.origin)], limit=1,
|
||||
)
|
||||
if not so or 'x_fc_coating_config_id' not in so._fields:
|
||||
continue
|
||||
coating = so.x_fc_coating_config_id
|
||||
if coating and coating.recipe_id:
|
||||
mo.x_fc_recipe_id = coating.recipe_id
|
||||
mo.message_post(
|
||||
body=_('Recipe "%s" auto-assigned from coating config "%s".') % (
|
||||
coating.recipe_id.name, coating.name,
|
||||
),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GAP 2: SO confirm → MO confirm → auto-create Portal Job + WOs
|
||||
# ------------------------------------------------------------------
|
||||
@@ -182,6 +309,9 @@ class MrpProduction(models.Model):
|
||||
"""Override to auto-create a portal job and generate work orders
|
||||
from the assigned recipe when the MO is confirmed.
|
||||
"""
|
||||
# Auto-assign recipe BEFORE super() so work-order generation sees it
|
||||
self._auto_assign_recipe_from_so()
|
||||
|
||||
res = super().action_confirm()
|
||||
PortalJob = self.env['fusion.plating.portal.job']
|
||||
for mo in self:
|
||||
@@ -219,28 +349,67 @@ class MrpProduction(models.Model):
|
||||
return res
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GAP 3+4: MO done → update portal job + auto-create delivery
|
||||
# GAP 3+4+5: MO done → portal job ready + delivery draft + CoC draft
|
||||
# ------------------------------------------------------------------
|
||||
def button_mark_done(self):
|
||||
"""Override to cascade MO completion to portal job and delivery."""
|
||||
"""Override to cascade MO completion to portal job, delivery,
|
||||
and an auto-generated draft Certificate of Conformance."""
|
||||
res = super().button_mark_done()
|
||||
Delivery = self.env.get('fusion.plating.delivery')
|
||||
Certificate = self.env.get('fp.certificate')
|
||||
for mo in self:
|
||||
job = mo.x_fc_portal_job_id
|
||||
if not job:
|
||||
continue
|
||||
# GAP 3: MO done → portal job ready_to_ship
|
||||
job.write({'state': 'ready_to_ship'})
|
||||
job.message_post(body='Manufacturing complete — ready to ship.')
|
||||
|
||||
# GAP 4: Auto-create delivery record
|
||||
if Delivery is None:
|
||||
continue
|
||||
partner = job.partner_id
|
||||
Delivery.create({
|
||||
'partner_id': partner.id,
|
||||
'job_ref': job.name,
|
||||
'source_facility_id': mo.x_fc_facility_id.id if mo.x_fc_facility_id else False,
|
||||
'state': 'draft',
|
||||
})
|
||||
# Portal job → ready_to_ship
|
||||
job.write({'state': 'ready_to_ship'})
|
||||
job.message_post(body=_('Manufacturing complete — ready to ship.'))
|
||||
|
||||
# Resolve SO for denormalized fields on the certificate
|
||||
so = False
|
||||
if mo.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', mo.origin)], limit=1,
|
||||
)
|
||||
|
||||
# Auto-create draft delivery record
|
||||
if Delivery is not None:
|
||||
Delivery.create({
|
||||
'partner_id': job.partner_id.id,
|
||||
'job_ref': job.name,
|
||||
'source_facility_id': (
|
||||
mo.x_fc_facility_id.id if mo.x_fc_facility_id else False
|
||||
),
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
# Auto-create draft Certificate of Conformance
|
||||
if Certificate is not None:
|
||||
# Skip if a CoC already exists for this MO
|
||||
existing = Certificate.search(
|
||||
[('production_id', '=', mo.id), ('certificate_type', '=', 'coc')],
|
||||
limit=1,
|
||||
)
|
||||
if not existing:
|
||||
coating = so.x_fc_coating_config_id if (
|
||||
so and 'x_fc_coating_config_id' in so._fields
|
||||
) else False
|
||||
cert_vals = {
|
||||
'certificate_type': 'coc',
|
||||
'partner_id': job.partner_id.id,
|
||||
'production_id': mo.id,
|
||||
'portal_job_id': job.id,
|
||||
'sale_order_id': so.id if so else False,
|
||||
'quantity_shipped': int(mo.product_qty),
|
||||
'po_number': so.x_fc_po_number if (
|
||||
so and 'x_fc_po_number' in so._fields
|
||||
) else False,
|
||||
'entech_wo_number': mo.name,
|
||||
'spec_reference': coating.spec_reference if coating else False,
|
||||
'process_description': coating.name if coating else False,
|
||||
'part_number': mo.product_id.default_code or '',
|
||||
'state': 'draft',
|
||||
}
|
||||
Certificate.create(cert_vals)
|
||||
return res
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class MrpWorkorder(models.Model):
|
||||
@@ -32,7 +32,12 @@ class MrpWorkorder(models.Model):
|
||||
x_fc_tank_id = fields.Many2one(
|
||||
'fusion.plating.tank', string='Tank',
|
||||
)
|
||||
x_fc_rack_ref = fields.Char(string='Rack / Fixture Ref')
|
||||
x_fc_rack_ref = fields.Char(string='Rack / Fixture Ref (legacy)')
|
||||
x_fc_rack_id = fields.Many2one(
|
||||
'fusion.plating.rack', string='Rack / Fixture',
|
||||
domain="[('state', '!=', 'retired')]",
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_thickness_target = fields.Float(string='Target Thickness')
|
||||
x_fc_thickness_uom = fields.Selection(
|
||||
[('mils', 'mils'), ('microns', '\u00b5m')],
|
||||
@@ -397,3 +402,58 @@ class MrpWorkorder(models.Model):
|
||||
'part_ref': n.part_ref or '',
|
||||
})
|
||||
return {'holds': holds, 'ncrs': ncrs}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# T1.1 — Bake window auto-create on plating WO finish
|
||||
# T1.3 — Rack MTO increment when a rack was used
|
||||
# ------------------------------------------------------------------
|
||||
def button_finish(self):
|
||||
"""Finish the WO, bump rack MTO, spawn bake window if required."""
|
||||
res = super().button_finish()
|
||||
for wo in self:
|
||||
if wo.x_fc_rack_id:
|
||||
wo.x_fc_rack_id._increment_mto(1.0)
|
||||
self._fp_spawn_bake_window_if_needed()
|
||||
return res
|
||||
|
||||
def _fp_spawn_bake_window_if_needed(self):
|
||||
"""Create a fusion.plating.bake.window record if the MO's coating
|
||||
config requires it and this WO was the plating step.
|
||||
"""
|
||||
BakeWindow = self.env.get('fusion.plating.bake.window')
|
||||
if BakeWindow is None:
|
||||
return
|
||||
for wo in self:
|
||||
if not wo.x_fc_bath_id:
|
||||
continue # Not a bath step
|
||||
so = wo.x_fc_sale_order_id
|
||||
coating = so.x_fc_coating_config_id if (
|
||||
so and 'x_fc_coating_config_id' in so._fields
|
||||
) else False
|
||||
if not coating or not getattr(coating, 'requires_bake_relief', False):
|
||||
continue
|
||||
# Only fire on the *plating* WO — the one whose bath's process
|
||||
# matches the coating config's process.
|
||||
if wo.x_fc_bath_id.process_type_id != coating.process_type_id:
|
||||
continue
|
||||
# De-dup: don't create a second window for the same lot
|
||||
existing = BakeWindow.search([
|
||||
('lot_ref', '=', wo.production_id.x_fc_portal_job_id.name or wo.production_id.name),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
BakeWindow.create({
|
||||
'bath_id': wo.x_fc_bath_id.id,
|
||||
'part_ref': wo.production_id.product_id.default_code or wo.production_id.product_id.name,
|
||||
'lot_ref': wo.production_id.x_fc_portal_job_id.name or wo.production_id.name,
|
||||
'customer_ref': wo.x_fc_customer_id.name or '',
|
||||
'quantity': int(wo.production_id.product_qty or 0),
|
||||
'window_hours': coating.bake_window_hours or 4.0,
|
||||
'plate_exit_time': fields.Datetime.now(),
|
||||
})
|
||||
wo.production_id.message_post(
|
||||
body=_(
|
||||
'Bake-window record created — relief bake must start '
|
||||
'within %s hours of plate exit.'
|
||||
) % (coating.bake_window_hours or 4.0)
|
||||
)
|
||||
|
||||
@@ -18,12 +18,23 @@
|
||||
<group>
|
||||
<field name="x_fc_customer_spec_id"/>
|
||||
<field name="x_fc_facility_id"/>
|
||||
<field name="x_fc_current_location" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_portal_job_id"/>
|
||||
<field name="x_fc_recipe_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Rework" name="rework"
|
||||
invisible="not x_fc_is_rework and not x_fc_original_production_id">
|
||||
<group>
|
||||
<field name="x_fc_is_rework" readonly="1"/>
|
||||
<field name="x_fc_original_production_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_rework_reason"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
@@ -33,6 +44,20 @@
|
||||
<field name="x_fc_override_count" widget="statinfo"
|
||||
string="Overrides"/>
|
||||
</button>
|
||||
<button name="action_view_reworks" type="object"
|
||||
class="oe_stat_button" icon="fa-recycle"
|
||||
invisible="x_fc_rework_count == 0">
|
||||
<field name="x_fc_rework_count" widget="statinfo"
|
||||
string="Reworks"/>
|
||||
</button>
|
||||
<button name="action_create_rework" type="object"
|
||||
class="oe_stat_button" icon="fa-refresh"
|
||||
invisible="state != 'done' or x_fc_is_rework"
|
||||
confirm="Create a rework MO from this completed order?">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Create Rework</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
</field>
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
<field name="x_fc_facility_id"/>
|
||||
<field name="x_fc_bath_id"/>
|
||||
<field name="x_fc_tank_id"/>
|
||||
<field name="x_fc_rack_id"/>
|
||||
<field name="x_fc_rack_ref"/>
|
||||
</group>
|
||||
<group string="Process Parameters">
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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 & 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>
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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': [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<filter name="filter_this_week" string="This Week"
|
||||
domain="[('sent_date', '>=', (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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
380
fusion_plating/fusion_plating_reports/report/report_fp_bol.xml
Normal file
380
fusion_plating/fusion_plating_reports/report/report_fp_bol.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
464
fusion_plating/fusion_plating_reports/report/report_fp_sale.xml
Normal file
464
fusion_plating/fusion_plating_reports/report/report_fp_sale.xml
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user