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). --> + + + + + + + + + + + + + + - - - + + +