diff --git a/fusion_plating/fusion_plating/models/fp_operator_certification.py b/fusion_plating/fusion_plating/models/fp_operator_certification.py
index 89b51afd..e575053b 100644
--- a/fusion_plating/fusion_plating/models/fp_operator_certification.py
+++ b/fusion_plating/fusion_plating/models/fp_operator_certification.py
@@ -45,20 +45,17 @@ class FpOperatorCertification(models.Model):
'ir.attachment', string='Training Record',
)
notes = fields.Text(string='Notes')
+ revoked = fields.Boolean(string='Revoked', tracking=True)
+ revoked_reason = fields.Text(string='Revoked Reason')
state = fields.Selection(
[('active', 'Active'),
('expired', 'Expired'),
('revoked', 'Revoked')],
- string='Status', default='active', required=True,
- compute='_compute_state', store=True, readonly=False, tracking=True,
+ string='Status',
+ compute='_compute_state', store=True, tracking=True,
+ # NOT readonly=False — this is purely derived from revoked + expires_date
+ # so the nightly recompute never fights with manual edits.
)
- revoked_reason = fields.Text(string='Revoked Reason')
-
- _sql_constraints = [
- ('fp_operator_cert_unique',
- 'unique(employee_id, process_type_id, state)',
- 'An operator cannot hold two active certifications for the same process.'),
- ]
@api.depends('employee_id', 'process_type_id')
def _compute_name(self):
@@ -68,33 +65,64 @@ class FpOperatorCertification(models.Model):
else:
rec.name = ''
- @api.depends('expires_date')
+ @api.depends('expires_date', 'revoked')
def _compute_state(self):
today = fields.Date.today()
for rec in self:
- if rec.state == 'revoked':
+ if rec.revoked:
+ rec.state = 'revoked'
+ elif rec.expires_date and rec.expires_date < today:
+ rec.state = 'expired'
+ else:
+ rec.state = 'active'
+
+ @api.constrains('employee_id', 'process_type_id', 'revoked', 'expires_date')
+ def _check_single_active(self):
+ """At most one active certification per (employee, process_type)."""
+ today = fields.Date.today()
+ for rec in self:
+ if rec.revoked:
continue
if rec.expires_date and rec.expires_date < today:
- rec.state = 'expired'
- elif rec.state != 'active':
- rec.state = 'active'
+ continue
+ # This record is active — look for another active sibling
+ dupes = self.search_count([
+ ('id', '!=', rec.id),
+ ('employee_id', '=', rec.employee_id.id),
+ ('process_type_id', '=', rec.process_type_id.id),
+ ('revoked', '=', False),
+ '|', ('expires_date', '=', False),
+ ('expires_date', '>=', today),
+ ])
+ if dupes:
+ from odoo.exceptions import ValidationError
+ raise ValidationError(_(
+ 'Operator %s already has an active certification for "%s". '
+ 'Revoke or expire the existing one before adding another.'
+ ) % (rec.employee_id.name, rec.process_type_id.name))
def action_revoke(self):
for rec in self:
- rec.state = 'revoked'
+ rec.revoked = True
rec.message_post(body=_('Certification revoked.'))
@api.model
def has_active_cert(self, employee_id, process_type_id):
- """Utility — True if this employee holds a current certification
- for this process type (or one of its ancestors in the category tree).
+ """Utility — True if this employee holds a current certification.
+
+ Checks revoked + expires_date directly instead of the computed
+ `state` column, so even a certification that expired yesterday
+ is caught immediately (no wait for nightly recompute).
"""
if not employee_id or not process_type_id:
return False
+ today = fields.Date.today()
return bool(self.search_count([
('employee_id', '=', employee_id),
('process_type_id', '=', process_type_id),
- ('state', '=', 'active'),
+ ('revoked', '=', False),
+ '|', ('expires_date', '=', False),
+ ('expires_date', '>=', today),
]))
diff --git a/fusion_plating/fusion_plating/models/res_company.py b/fusion_plating/fusion_plating/models/res_company.py
index 60260f1b..b1ac517c 100644
--- a/fusion_plating/fusion_plating/models/res_company.py
+++ b/fusion_plating/fusion_plating/models/res_company.py
@@ -28,3 +28,38 @@ class ResCompany(models.Model):
def _compute_x_fc_facility_count(self):
for rec in self:
rec.x_fc_facility_count = len(rec.x_fc_facility_ids)
+
+ # =====================================================================
+ # CoC / Certificate report settings
+ # =====================================================================
+ x_fc_owner_user_id = fields.Many2one(
+ 'res.users',
+ string='Certificate Owner (Default Signer)',
+ help='Quality manager / owner whose signature appears on Certificates '
+ 'of Conformance by default. Signature is pulled from their linked '
+ 'HR Employee record.',
+ )
+ x_fc_coc_signature_override = fields.Binary(
+ string='Signature Override Image',
+ help='Optional. Upload a pre-scanned signature image to use on '
+ 'Certificates of Conformance. Overrides the Owner user\'s '
+ 'employee signature when set. Useful if the owner doesn\'t have '
+ 'an HR record or wants a different signature for plating certs.',
+ )
+
+ # --- Accreditation logos shown in CoC header ---
+ x_fc_nadcap_logo = fields.Binary(string='Nadcap Logo')
+ x_fc_nadcap_active = fields.Boolean(
+ string='Nadcap Accredited',
+ help='Show the Nadcap logo on certificates.',
+ )
+ x_fc_as9100_logo = fields.Binary(string='AS9100 / ISO 9001 Logo')
+ x_fc_as9100_active = fields.Boolean(
+ string='AS9100 / ISO 9001 Certified',
+ help='Show the AS9100 / ISO 9001 logo on certificates.',
+ )
+ x_fc_cgp_logo = fields.Binary(string='Controlled Goods Program Logo')
+ x_fc_cgp_active = fields.Boolean(
+ string='CGP Registered',
+ help='Show the Controlled Goods Program logo on certificates.',
+ )
diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py
index eedf8225..7c169dd7 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py
@@ -465,16 +465,21 @@ class MrpProduction(models.Model):
[('name', '=', mo.origin)], limit=1,
)
- # Auto-create draft delivery record
+ # Auto-create draft delivery record (idempotent — skip if one
+ # already exists for this job_ref)
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',
- })
+ existing_delivery = Delivery.search(
+ [('job_ref', '=', job.name)], limit=1,
+ )
+ if not existing_delivery:
+ 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:
diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py
index ee97ebe0..c0588623 100644
--- a/fusion_plating/fusion_plating_certificates/__manifest__.py
+++ b/fusion_plating/fusion_plating_certificates/__manifest__.py
@@ -33,6 +33,7 @@ Includes Fischerscope thickness measurement data capture.
'data': [
'security/ir.model.access.csv',
'data/fp_certificate_sequence_data.xml',
+ 'views/res_config_settings_views.xml',
'views/fp_certificate_views.xml',
'views/fp_certificates_menu.xml',
],
diff --git a/fusion_plating/fusion_plating_certificates/models/__init__.py b/fusion_plating/fusion_plating_certificates/models/__init__.py
index 0881ff43..7a788c39 100644
--- a/fusion_plating/fusion_plating_certificates/models/__init__.py
+++ b/fusion_plating/fusion_plating_certificates/models/__init__.py
@@ -5,3 +5,4 @@
from . import fp_thickness_reading
from . import fp_certificate
+from . import res_config_settings
diff --git a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py
index 1ee26a5d..ab9a6dac 100644
--- a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py
+++ b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py
@@ -45,6 +45,20 @@ class FpCertificate(models.Model):
po_number = fields.Char(string='Customer PO #')
entech_wo_number = fields.Char(string='Entech WO #')
quantity_shipped = fields.Integer(string='Qty Shipped')
+ nc_quantity = fields.Integer(
+ string='NC Qty',
+ help='Non-conforming quantity — parts that failed inspection / rework.',
+ )
+ customer_job_no = fields.Char(
+ string='Customer Job No.',
+ help="Customer's internal job / traveler reference.",
+ )
+ contact_partner_id = fields.Many2one(
+ 'res.partner', string='Customer Contact',
+ domain="[('parent_id', '=', partner_id)]",
+ help="Specific contact person at the customer for this certificate. "
+ 'Their name, email, and phone are printed on the CoC.',
+ )
issued_by_id = fields.Many2one(
'res.users', string='Issued By', default=lambda self: self.env.user,
)
@@ -73,6 +87,8 @@ class FpCertificate(models.Model):
@api.depends('production_id')
def _compute_batch_ids(self):
Batch = self.env.get('fusion.plating.batch')
+ Bath = self.env['fusion.plating.bath']
+ empty_batch = self.env['fusion.plating.batch']
for rec in self:
if Batch is not None and rec.production_id:
batches = Batch.search([
@@ -82,9 +98,9 @@ class FpCertificate(models.Model):
rec.batch_count = len(batches)
rec.bath_ids = batches.mapped('bath_id')
else:
- rec.batch_ids = False
+ rec.batch_ids = empty_batch
rec.batch_count = 0
- rec.bath_ids = False
+ rec.bath_ids = Bath
state = fields.Selection(
[('draft', 'Draft'), ('issued', 'Issued'), ('voided', 'Voided')],
string='Status', default='draft', tracking=True, required=True,
diff --git a/fusion_plating/fusion_plating_certificates/models/res_config_settings.py b/fusion_plating/fusion_plating_certificates/models/res_config_settings.py
new file mode 100644
index 00000000..781a21ff
--- /dev/null
+++ b/fusion_plating/fusion_plating_certificates/models/res_config_settings.py
@@ -0,0 +1,38 @@
+# -*- 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 fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ """Expose Fusion Plating CoC settings on the standard Settings page
+ so they show up under a Fusion Plating section that the owner can
+ edit like any other Odoo settings."""
+ _inherit = 'res.config.settings'
+
+ x_fc_owner_user_id = fields.Many2one(
+ related='company_id.x_fc_owner_user_id', readonly=False,
+ )
+ x_fc_coc_signature_override = fields.Binary(
+ related='company_id.x_fc_coc_signature_override', readonly=False,
+ )
+ x_fc_nadcap_logo = fields.Binary(
+ related='company_id.x_fc_nadcap_logo', readonly=False,
+ )
+ x_fc_nadcap_active = fields.Boolean(
+ related='company_id.x_fc_nadcap_active', readonly=False,
+ )
+ x_fc_as9100_logo = fields.Binary(
+ related='company_id.x_fc_as9100_logo', readonly=False,
+ )
+ x_fc_as9100_active = fields.Boolean(
+ related='company_id.x_fc_as9100_active', readonly=False,
+ )
+ x_fc_cgp_logo = fields.Binary(
+ related='company_id.x_fc_cgp_logo', readonly=False,
+ )
+ x_fc_cgp_active = fields.Boolean(
+ related='company_id.x_fc_cgp_active', readonly=False,
+ )
diff --git a/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml b/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
index b2f845b2..affb7bc8 100644
--- a/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
+++ b/fusion_plating/fusion_plating_certificates/views/fp_certificate_views.xml
@@ -80,9 +80,14 @@
+
+
+
diff --git a/fusion_plating/fusion_plating_certificates/views/fp_certificates_menu.xml b/fusion_plating/fusion_plating_certificates/views/fp_certificates_menu.xml
index 77023bc8..7cb3c0b9 100644
--- a/fusion_plating/fusion_plating_certificates/views/fp_certificates_menu.xml
+++ b/fusion_plating/fusion_plating_certificates/views/fp_certificates_menu.xml
@@ -41,4 +41,12 @@
action="action_fp_certificate_thickness"
sequence="30"/>
+
+
+
diff --git a/fusion_plating/fusion_plating_certificates/views/res_config_settings_views.xml b/fusion_plating/fusion_plating_certificates/views/res_config_settings_views.xml
new file mode 100644
index 00000000..3d4f6c8a
--- /dev/null
+++ b/fusion_plating/fusion_plating_certificates/views/res_config_settings_views.xml
@@ -0,0 +1,70 @@
+
+
+
+
+ res.config.settings.view.form.fusion.plating
+ res.config.settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fusion Plating Settings
+ res.config.settings
+ form
+ current
+ {'module': 'fusion_plating'}
+
+
+
diff --git a/fusion_plating/fusion_plating_configurator/models/sale_order.py b/fusion_plating/fusion_plating_configurator/models/sale_order.py
index 89a67163..ed2fd64f 100644
--- a/fusion_plating/fusion_plating_configurator/models/sale_order.py
+++ b/fusion_plating/fusion_plating_configurator/models/sale_order.py
@@ -35,6 +35,17 @@ class SaleOrder(models.Model):
)
x_fc_deposit_percent = fields.Float(string='Deposit %',
help='Deposit percentage if strategy is Deposit.')
+ x_fc_progress_initial_percent = fields.Float(
+ string='Progress — Initial %',
+ default=50.0,
+ help='First-phase percentage for Progress Billing strategy. '
+ 'Billed on SO confirmation; remainder billed on delivery.',
+ )
+ x_fc_final_invoice_id = fields.Many2one(
+ 'account.move', string='Final Invoice', copy=False, readonly=True,
+ help='Final invoice auto-created on delivery for Progress Billing / '
+ 'Net Terms strategies.',
+ )
x_fc_rush_order = fields.Boolean(string='Rush Order', tracking=True)
x_fc_delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'), ('shipping_partner', 'Shipping Partner'),
diff --git a/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml b/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml
index d637a032..8f9cb29f 100644
--- a/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml
+++ b/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml
@@ -70,6 +70,10 @@
+
+
diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py
index eeef915c..bbee93b1 100644
--- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py
+++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py
@@ -80,6 +80,9 @@ class FpDirectOrderWizard(models.TransientModel):
string='Invoice Strategy',
)
deposit_percent = fields.Float(string='Deposit %')
+ progress_initial_percent = fields.Float(
+ string='Progress — Initial %', default=50.0,
+ )
notes = fields.Text(string='Internal Notes')
@@ -184,6 +187,7 @@ class FpDirectOrderWizard(models.TransientModel):
'x_fc_po_received': True,
'x_fc_invoice_strategy': self.invoice_strategy,
'x_fc_deposit_percent': self.deposit_percent,
+ 'x_fc_progress_initial_percent': self.progress_initial_percent,
'origin': 'Direct Order',
'note': self.notes or False,
'order_line': [(0, 0, {
diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
index b13914cb..09247010 100644
--- a/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
+++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml
@@ -67,6 +67,8 @@
+
diff --git a/fusion_plating/fusion_plating_invoicing/__manifest__.py b/fusion_plating/fusion_plating_invoicing/__manifest__.py
index 233d44b1..f2cd2e5e 100644
--- a/fusion_plating/fusion_plating_invoicing/__manifest__.py
+++ b/fusion_plating/fusion_plating_invoicing/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Invoicing',
- 'version': '19.0.1.0.0',
+ 'version': '19.0.2.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
'description': """
@@ -29,6 +29,7 @@ Provides:
'currency': 'CAD',
'depends': [
'fusion_plating_configurator',
+ 'fusion_plating_logistics',
'sale_management',
'account',
],
diff --git a/fusion_plating/fusion_plating_invoicing/models/__init__.py b/fusion_plating/fusion_plating_invoicing/models/__init__.py
index ec78ddf6..586baab3 100644
--- a/fusion_plating/fusion_plating_invoicing/models/__init__.py
+++ b/fusion_plating/fusion_plating_invoicing/models/__init__.py
@@ -7,3 +7,4 @@ from . import fp_invoice_strategy_default
from . import res_partner
from . import sale_order
from . import account_move
+from . import fp_delivery
diff --git a/fusion_plating/fusion_plating_invoicing/models/fp_delivery.py b/fusion_plating/fusion_plating_invoicing/models/fp_delivery.py
new file mode 100644
index 00000000..50e99d26
--- /dev/null
+++ b/fusion_plating/fusion_plating_invoicing/models/fp_delivery.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# 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 FpDelivery(models.Model):
+ """Fire the 'final balance' invoice for Progress Billing / Net Terms
+ when a delivery is marked delivered.
+ """
+ _inherit = 'fusion.plating.delivery'
+
+ def action_mark_delivered(self):
+ res = super().action_mark_delivered()
+ SaleOrder = self.env['sale.order']
+ MrpProduction = self.env['mrp.production']
+ for delivery in self:
+ # Resolve the sale order via delivery.job_ref → MO.name → MO.origin
+ so = False
+ if delivery.job_ref:
+ mo = MrpProduction.search(
+ [('name', '=', delivery.job_ref)], limit=1,
+ )
+ if mo and mo.origin:
+ so = SaleOrder.search(
+ [('name', '=', mo.origin)], limit=1,
+ )
+ if not so:
+ # Fallback: find by partner + recently-confirmed with matching strategy
+ continue
+ strategy = so.x_fc_invoice_strategy
+ if strategy not in ('progress', 'net_terms'):
+ continue
+ # Skip if already billed in full
+ if so.invoice_status == 'invoiced':
+ continue
+ so._create_final_balance_invoice()
+ return res
diff --git a/fusion_plating/fusion_plating_invoicing/models/sale_order.py b/fusion_plating/fusion_plating_invoicing/models/sale_order.py
index dcd52878..3072df17 100644
--- a/fusion_plating/fusion_plating_invoicing/models/sale_order.py
+++ b/fusion_plating/fusion_plating_invoicing/models/sale_order.py
@@ -43,7 +43,6 @@ class SaleOrder(models.Model):
) % (order.partner_id.name,
order.partner_id.x_fc_account_hold_reason or 'No reason specified'))
else:
- # Manager gets a warning in chatter but can proceed
order.message_post(
body=_(
'Warning: Customer "%s" is on account hold (reason: %s). '
@@ -54,35 +53,45 @@ class SaleOrder(models.Model):
res = super().action_confirm()
- # --- Invoice strategy automation ---
+ # --- Invoice strategy automation (on confirm) ---
for order in self:
strategy = order.x_fc_invoice_strategy
if not strategy:
continue
-
if strategy == 'deposit' and order.x_fc_deposit_percent:
order._create_deposit_invoice()
elif strategy == 'cod_prepay':
order._create_full_invoice()
+ elif strategy == 'progress' and order.x_fc_progress_initial_percent:
+ order._create_progress_initial_invoice()
+ # 'net_terms' — no action on confirm; invoiced when delivery is marked delivered
return res
+ # ------------------------------------------------------------------
+ # Strategy implementations
+ # ------------------------------------------------------------------
def _create_deposit_invoice(self):
- """Create a deposit (down payment) invoice for the deposit percentage."""
+ """Deposit strategy: down-payment invoice for the deposit %."""
self.ensure_one()
percent = self.x_fc_deposit_percent
if not percent or percent <= 0:
return
-
try:
- # Use Odoo's standard down payment mechanism
- wizard = self.env['sale.advance.payment.inv'].create({
+ # The wizard's sale_order_ids default reads active_ids AT CREATE
+ # time — context must be set on .with_context(), not on the
+ # subsequent create_invoices() call.
+ wizard = self.env['sale.advance.payment.inv'].with_context(
+ active_ids=self.ids,
+ active_model='sale.order',
+ active_id=self.id,
+ ).create({
'advance_payment_method': 'percentage',
'amount': percent,
})
- wizard.with_context(active_ids=self.ids, active_model='sale.order').create_invoices()
+ wizard.create_invoices()
self.message_post(
- body=_('Deposit invoice (%.0f%%) created automatically — strategy: Deposit.') % percent,
+ body=_('Deposit invoice (%.0f%%) created — strategy: Deposit.') % percent,
)
except Exception as e:
_logger.warning('Failed to create deposit invoice for SO %s: %s', self.name, e)
@@ -91,16 +100,83 @@ class SaleOrder(models.Model):
)
def _create_full_invoice(self):
- """Create a full invoice immediately (COD/Prepay strategy)."""
+ """COD / Prepay: invoice the entire order immediately."""
self.ensure_one()
try:
invoices = self._create_invoices()
if invoices:
self.message_post(
- body=_('Full invoice created automatically — strategy: COD / Prepay.'),
+ body=_('Full invoice created — strategy: COD / Prepay.'),
)
except Exception as e:
_logger.warning('Failed to create COD invoice for SO %s: %s', self.name, e)
self.message_post(
body=_('Failed to auto-create invoice: %s. Create manually.') % str(e),
)
+
+ def _create_progress_initial_invoice(self):
+ """Progress Billing — first invoice at SO confirm.
+
+ Uses Odoo's down-payment mechanism to bill the initial percentage.
+ The remainder is billed on delivery via `_create_final_balance_invoice`.
+ """
+ self.ensure_one()
+ percent = self.x_fc_progress_initial_percent
+ if not percent or percent <= 0:
+ return
+ try:
+ wizard = self.env['sale.advance.payment.inv'].with_context(
+ active_ids=self.ids,
+ active_model='sale.order',
+ active_id=self.id,
+ ).create({
+ 'advance_payment_method': 'percentage',
+ 'amount': percent,
+ })
+ wizard.create_invoices()
+ self.message_post(
+ body=_(
+ 'Progress invoice — initial %.0f%% created — strategy: Progress Billing. '
+ 'Final balance will be invoiced on delivery.'
+ ) % percent,
+ )
+ except Exception as e:
+ _logger.warning('Failed progress-initial invoice for SO %s: %s', self.name, e)
+ self.message_post(
+ body=_('Failed to auto-create progress invoice: %s') % str(e),
+ )
+
+ def _create_final_balance_invoice(self):
+ """Create the closing invoice for Progress Billing / Net Terms.
+
+ Called when delivery is marked delivered. Uses the standard
+ `_create_invoices()` method which bills the remainder (net of any
+ previously-posted down payments).
+ """
+ self.ensure_one()
+ if self.x_fc_final_invoice_id:
+ return self.x_fc_final_invoice_id # Already invoiced — don't double
+ if self.invoice_status == 'invoiced':
+ return False # Nothing more to bill
+ try:
+ invoices = self._create_invoices(final=True)
+ if invoices:
+ self.x_fc_final_invoice_id = invoices[:1].id
+ strategy_label = dict(
+ self._fields['x_fc_invoice_strategy'].selection
+ ).get(self.x_fc_invoice_strategy, self.x_fc_invoice_strategy)
+ self.message_post(
+ body=_(
+ 'Final invoice created on delivery — strategy: %s.'
+ ) % strategy_label,
+ )
+ return invoices
+ except Exception as e:
+ _logger.warning('Failed final invoice for SO %s: %s', self.name, e)
+ self.message_post(
+ body=_(
+ 'Failed to auto-create final invoice: %s. '
+ 'Create manually from the SO.'
+ ) % str(e),
+ )
+ return False
diff --git a/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py b/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py
index 78291903..2808af6a 100644
--- a/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py
+++ b/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py
@@ -190,13 +190,9 @@ class FpNotificationTemplate(models.Model):
_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:
+ # Both attach_quotation and attach_sale_order point at the same
+ # report today — render once to avoid double attachment.
+ if (self.attach_quotation or self.attach_sale_order) and sale_order:
att = _render_report(
'fusion_plating_reports.action_report_fp_sale_portrait', sale_order,
)
diff --git a/fusion_plating/fusion_plating_reports/report/report_actions.xml b/fusion_plating/fusion_plating_reports/report/report_actions.xml
index 7ee38b32..0f0f679b 100644
--- a/fusion_plating/fusion_plating_reports/report/report_actions.xml
+++ b/fusion_plating/fusion_plating_reports/report/report_actions.xml
@@ -38,7 +38,7 @@
-
+
Certificate of Conformance (Portrait)
fusion.plating.portal.job
@@ -50,6 +50,34 @@
report
+
+
+
+
+ Certificate of Conformance (English)
+ fp.certificate
+ qweb-pdf
+ fusion_plating_reports.report_coc_en
+ fusion_plating_reports.report_coc_en
+ 'CoC EN - %s' % object.name
+
+ report
+
+
+
+
+
+
+ Certificat de Conformité (Français)
+ fp.certificate
+ qweb-pdf
+ fusion_plating_reports.report_coc_fr
+ fusion_plating_reports.report_coc_fr
+ 'CoC FR - %s' % object.name
+
+ report
+
+
diff --git a/fusion_plating/fusion_plating_reports/report/report_base_styles.xml b/fusion_plating/fusion_plating_reports/report/report_base_styles.xml
index f96e0c81..5392a780 100644
--- a/fusion_plating/fusion_plating_reports/report/report_base_styles.xml
+++ b/fusion_plating/fusion_plating_reports/report/report_base_styles.xml
@@ -3,18 +3,30 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
+
Shared CSS for all Fusion Plating reports (portrait + landscape).
+
+ The primary colour is driven by the active company's
+ res.company.primary_color field (Settings → Company → Report Layout),
+ falling back to #1d1f1e when the company has no brand colour set.
+
+ To keep section-header markup concise in individual report files,
+ a utility class `.fp-header-primary` is exposed — apply that class
+ to any `
` or ` | ` that should render as a primary-coloured
+ section banner (e.g. CARGO DESCRIPTION, PAYMENT DETAILS).
-->
+
+
+
+
+
+
+
+
+ Certificate of Conformance
+ Certificat de Conformité
+
+
+
+
+
+
+
+
+
+
+ ,
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+ |
+
+ Customer Name:
+ Nom du client :
+
+
+
+ Customer Address:
+ Adresse du client :
+
+
+
+
+ ,
+
+
+ |
+
+
+
+ Contact Name:
+ Nom du contact :
+
+
+
+ Customer Email:
+ Courriel :
+
+
+
+ Customer Phone:
+ Téléphone :
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+ |
+ Date of Certification
+ Date du certificat
+ |
+
+ Generated By
+ Créé par
+ |
+
+ Work Order #
+ Bon de travail
+ |
+
+
+
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+ Quantities
+ Quantités
+
+
+
+
+ |
+ Part Number / Line Item
+ No. de pièce / Ligne
+ |
+
+ Process
+ Procédé
+ |
+
+ Customer PO
+ Bon de commande
+ |
+
+ Shipped
+ Expédié
+ |
+
+ NC Qty
+ Qté NC
+ |
+
+ Customer Job No.
+ Bon de travail client
+ |
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+ |
+
+ Certified By:
+ Certifié par :
+
+
+ ![]()
+
+
+ Name:
+ Nom :
+
+
+ |
+
+
+ Certification Statement
+ Énoncé de conformité
+
+ This is to certify that the items listed herein have
+ been processed, inspected and tested in accordance
+ with your Purchase Order, drawings and specification
+ requirements. All chemistry used in this order is
+ Made in Canada. There is no Mercury used in the
+ processing of this order.
+
+
+ Ceci est pour certifier que les articles inscrits ont
+ été procédés, inspectés et mis à l'essai selon votre
+ bon de commande, dessins et spécifications. Tous les
+ produits chimiques utilisés dans cette commande sont
+ fabriqués au Canada. Il n'y a pas de mercure dans les
+ procédés de fabrication de cette commande.
+
+
+ |
+
+
+
+
+
+
+
+
+ Cert Created At:
+
+ · Fusion Plating by Nexa Systems
+
+
+ Certificat créé le :
+
+ · Fusion Plating par Nexa Systems
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -18,13 +358,10 @@
-
Certificate of Conformance —
-
-
-
-
-
-
- | CUSTOMER |
-
-
-
- | Name |
- |
-
-
- | Address |
-
-
- |
-
-
- | Tracking Reference |
- |
-
-
-
-
-
-
-
-
- | PROCESSES APPLIED |
-
-
-
- |
-
-
- ,
-
- |
-
-
-
-
-
-
-
- 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.
-
-
-
-
-
-
-
-
-
-
-
-
- Quality Manager (Signature)
-
-
-
-
-
@@ -125,9 +387,9 @@
-
-
-
+
+
+
@@ -139,8 +401,6 @@
Certificate of Conformance
-
-
| JOB REF |
@@ -149,7 +409,6 @@
RECEIVED |
SHIP DATE |
TRACKING REF |
- STATUS |
|
@@ -158,76 +417,8 @@
|
|
|
- |
-
-
-
-
- | CUSTOMER DETAILS |
-
-
-
- | Name |
- |
-
-
- | Address |
-
-
- |
-
-
-
-
-
-
-
- | PROCESSES APPLIED |
-
-
- |
-
-
- ,
-
- |
-
-
-
-
-
- | CERTIFICATION |
- |
- 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.
- |
-
-
-
-
-
-
-
-
-
-
-
- |
- Quality Manager Signature: ___________________________
- |
-
- Date: ___________________________
- |
-
-
-
diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_bol.xml b/fusion_plating/fusion_plating_reports/report/report_fp_bol.xml
index 5f7cd789..3317984d 100644
--- a/fusion_plating/fusion_plating_reports/report/report_fp_bol.xml
+++ b/fusion_plating/fusion_plating_reports/report/report_fp_bol.xml
@@ -111,7 +111,7 @@
- | CARGO DESCRIPTION |
+
| PACKAGES |
@@ -283,7 +283,7 @@
- | CARGO DESCRIPTION |
+
| PACKAGES |
diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_invoice.xml b/fusion_plating/fusion_plating_reports/report/report_fp_invoice.xml
index 5c46e820..7e4cf7b5 100644
--- a/fusion_plating/fusion_plating_reports/report/report_fp_invoice.xml
+++ b/fusion_plating/fusion_plating_reports/report/report_fp_invoice.xml
@@ -266,7 +266,9 @@
-
+
+
+
@@ -275,7 +277,7 @@
| QTY |
UOM |
UNIT PRICE |
- DISCOUNT |
+ DISCOUNT |
TAXES |
AMOUNT |
@@ -283,10 +285,10 @@
- |
+ |
- |
+ |
@@ -305,7 +307,7 @@
|
|
-
+ |
%
-
|
diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_receipt.xml b/fusion_plating/fusion_plating_reports/report/report_fp_receipt.xml
index 485cfd2b..74af18a3 100644
--- a/fusion_plating/fusion_plating_reports/report/report_fp_receipt.xml
+++ b/fusion_plating/fusion_plating_reports/report/report_fp_receipt.xml
@@ -59,7 +59,7 @@
- | PAYMENT DETAILS |
+
@@ -93,7 +93,7 @@
- | APPLIED TO INVOICES |
+
| INVOICE # |
@@ -220,7 +220,7 @@
- | APPLIED TO INVOICES |
+
| INVOICE # |
diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_sale.xml b/fusion_plating/fusion_plating_reports/report/report_fp_sale.xml
index 5acf564a..03d6ae71 100644
--- a/fusion_plating/fusion_plating_reports/report/report_fp_sale.xml
+++ b/fusion_plating/fusion_plating_reports/report/report_fp_sale.xml
@@ -131,7 +131,7 @@
|
- |
+ |
|
@@ -327,7 +327,9 @@
-
+
+
+
@@ -336,7 +338,7 @@
| QTY |
UOM |
UNIT PRICE |
- DISCOUNT |
+ DISCOUNT |
TAXES |
AMOUNT |
@@ -344,10 +346,10 @@
- |
+ |
- |
+ |
@@ -362,11 +364,11 @@
|
|
- |
+ |
|
-
+ |
%
-
|
diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_work_order.xml b/fusion_plating/fusion_plating_reports/report/report_fp_work_order.xml
index ee0ed7b4..4c3683ce 100644
--- a/fusion_plating/fusion_plating_reports/report/report_fp_work_order.xml
+++ b/fusion_plating/fusion_plating_reports/report/report_fp_work_order.xml
@@ -86,7 +86,7 @@
- | PROCESS PARAMETERS |
+
@@ -135,7 +135,7 @@
- | OPERATION INSTRUCTIONS |
+
|
@@ -172,7 +172,7 @@
- | OPERATOR SIGN-OFF |
+
| OPERATOR |
@@ -272,7 +272,7 @@
- | PROCESS PARAMETERS |
+
@@ -314,7 +314,7 @@
- | CHEMISTRY TARGETS |
+
| PARAM |
@@ -342,7 +342,7 @@
- | OPERATION INSTRUCTIONS |
+
|
@@ -354,7 +354,7 @@
- | OPERATOR SIGN-OFF |
+
| OPERATOR |
|