diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index d9713878..586e177f 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -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', ], diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py index c4a4eb43..59faad35 100644 --- a/fusion_plating/fusion_plating/models/__init__.py +++ b/fusion_plating/fusion_plating/models/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating/models/fp_bath_log_line.py b/fusion_plating/fusion_plating/models/fp_bath_log_line.py index 8a21077c..c8b31d2f 100644 --- a/fusion_plating/fusion_plating/models/fp_bath_log_line.py +++ b/fusion_plating/fusion_plating/models/fp_bath_log_line.py @@ -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})', + ) diff --git a/fusion_plating/fusion_plating/models/fp_bath_replenishment_rule.py b/fusion_plating/fusion_plating/models/fp_bath_replenishment_rule.py new file mode 100644 index 00000000..8aca863d --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_bath_replenishment_rule.py @@ -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'}) diff --git a/fusion_plating/fusion_plating/models/fp_rack.py b/fusion_plating/fusion_plating/models/fp_rack.py new file mode 100644 index 00000000..58402ec9 --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_rack.py @@ -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 diff --git a/fusion_plating/fusion_plating/security/ir.model.access.csv b/fusion_plating/fusion_plating/security/ir.model.access.csv index ebc7dcb7..c51caf68 100644 --- a/fusion_plating/fusion_plating/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating/security/ir.model.access.csv @@ -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 diff --git a/fusion_plating/fusion_plating/views/fp_bath_replenishment_views.xml b/fusion_plating/fusion_plating/views/fp_bath_replenishment_views.xml new file mode 100644 index 00000000..148ebbec --- /dev/null +++ b/fusion_plating/fusion_plating/views/fp_bath_replenishment_views.xml @@ -0,0 +1,151 @@ + + + + + + fp.replenishment.rule.list + fusion.plating.bath.replenishment.rule + + + + + + + + + + + + + + + + + + fp.replenishment.rule.form + fusion.plating.bath.replenishment.rule + +
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + Replenishment Rules + fusion.plating.bath.replenishment.rule + list,form + + + + + fp.replenishment.suggestion.list + fusion.plating.bath.replenishment.suggestion + + + + + + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml b/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml index 93ca6151..d7ac961c 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml +++ b/fusion_plating/fusion_plating_bridge_mrp/views/mrp_workorder_views.xml @@ -146,6 +146,7 @@ + diff --git a/fusion_plating/fusion_plating_configurator/__init__.py b/fusion_plating/fusion_plating_configurator/__init__.py index 2ea9535e..a6f36d1b 100644 --- a/fusion_plating/fusion_plating_configurator/__init__.py +++ b/fusion_plating/fusion_plating_configurator/__init__.py @@ -5,3 +5,4 @@ from . import controllers from . import models +from . import wizard diff --git a/fusion_plating/fusion_plating_configurator/__manifest__.py b/fusion_plating/fusion_plating_configurator/__manifest__.py index 321ec1e2..219cc93d 100644 --- a/fusion_plating/fusion_plating_configurator/__manifest__.py +++ b/fusion_plating/fusion_plating_configurator/__manifest__.py @@ -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', diff --git a/fusion_plating/fusion_plating_configurator/models/fp_coating_config.py b/fusion_plating/fusion_plating_configurator/models/fp_coating_config.py index c7a29f88..65824425 100644 --- a/fusion_plating/fusion_plating_configurator/models/fp_coating_config.py +++ b/fusion_plating/fusion_plating_configurator/models/fp_coating_config.py @@ -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) diff --git a/fusion_plating/fusion_plating_configurator/models/res_partner.py b/fusion_plating/fusion_plating_configurator/models/res_partner.py index 790d3840..3a7d9930 100644 --- a/fusion_plating/fusion_plating_configurator/models/res_partner.py +++ b/fusion_plating/fusion_plating_configurator/models/res_partner.py @@ -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}, + } diff --git a/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv b/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv index aab9eee1..b6cc8694 100644 --- a/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating_configurator/security/ir.model.access.csv @@ -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 diff --git a/fusion_plating/fusion_plating_configurator/views/fp_configurator_menu.xml b/fusion_plating/fusion_plating_configurator/views/fp_configurator_menu.xml index a1ba6ae0..684c44f2 100644 --- a/fusion_plating/fusion_plating_configurator/views/fp_configurator_menu.xml +++ b/fusion_plating/fusion_plating_configurator/views/fp_configurator_menu.xml @@ -28,6 +28,12 @@ action="action_fp_quotations" sequence="10"/> + + + + + + diff --git a/fusion_plating/fusion_plating_configurator/wizard/__init__.py b/fusion_plating/fusion_plating_configurator/wizard/__init__.py new file mode 100644 index 00000000..bc0d151d --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/wizard/__init__.py @@ -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 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 new file mode 100644 index 00000000..789aed23 --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard.py @@ -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', + } 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 new file mode 100644 index 00000000..b13914cb --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml @@ -0,0 +1,96 @@ + + + + + fp.direct.order.wizard.form + fp.direct.order.wizard + +
+ +
+

New Direct Order

+

+ Skip the quotation stage — create a confirmed order + when the customer has already sent a PO. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + + New Direct Order + fp.direct.order.wizard + form + new + + +
diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_part_catalog_import_wizard.py b/fusion_plating/fusion_plating_configurator/wizard/fp_part_catalog_import_wizard.py new file mode 100644 index 00000000..796c5592 --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_part_catalog_import_wizard.py @@ -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'
' + f'

Import Preview

' + f'

' + f'{len(all_rows)} rows parsed · ' + f'{self.valid_rows} valid · ' + f'{self.duplicate_rows} duplicates · ' + f'{self.error_rows} errors' + f'

' + ) + if errors: + pieces.append('

Errors (first 10)

') + pieces.append('') + pieces.append( + '' + '' + '' + '' + '' + '' + ) + 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'' + f'' + f'' + f'' + f'' + f'' + ) + pieces.append('
RowIssuePart #Customer
{e["row"]}{msg}{pn}{cust}
') + if duplicates: + pieces.append( + f'

' + f'{len(duplicates)} duplicate rows will be skipped.

' + ) + pieces.append('
') + return ''.join(pieces) diff --git a/fusion_plating/fusion_plating_configurator/wizard/fp_part_catalog_import_wizard_views.xml b/fusion_plating/fusion_plating_configurator/wizard/fp_part_catalog_import_wizard_views.xml new file mode 100644 index 00000000..890f1e22 --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/wizard/fp_part_catalog_import_wizard_views.xml @@ -0,0 +1,125 @@ + + + + + fp.part.catalog.import.wizard.form + fp.part.catalog.import.wizard + +
+ + + +
+ +
+

Import Parts from CSV

+

+ Bulk-load part catalog entries. The wizard validates + every row before writing — nothing is imported until + you approve the preview. +

+
+ + + + + + + + + + + + + + + +
+
+
+
+ + +
+ +
+

Preview Import

+
+ + + + + + + + + + + +
+
+
+
+ + +
+ +
+

Import Complete

+

+ + parts created. +

+
+
+
+
+
+ + +
+
+ + + Import Parts from CSV + fp.part.catalog.import.wizard + form + new + + +
diff --git a/fusion_plating/fusion_plating_notifications/__manifest__.py b/fusion_plating/fusion_plating_notifications/__manifest__.py index c94db0b7..d711c6cb 100644 --- a/fusion_plating/fusion_plating_notifications/__manifest__.py +++ b/fusion_plating/fusion_plating_notifications/__manifest__.py @@ -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': [ diff --git a/fusion_plating/fusion_plating_notifications/data/fp_notification_template_data.xml b/fusion_plating/fusion_plating_notifications/data/fp_notification_template_data.xml index 72a78437..76cad532 100644 --- a/fusion_plating/fusion_plating_notifications/data/fp_notification_template_data.xml +++ b/fusion_plating/fusion_plating_notifications/data/fp_notification_template_data.xml @@ -1,11 +1,20 @@ + + Quotation Sent + quote_sent + + + + + Order Confirmation so_confirmed + @@ -15,6 +24,22 @@ + + Manufacturing Complete + mo_complete + + + + + + Shipped / Delivered + shipped + + + + + + Invoice Posted invoice_posted @@ -23,4 +48,12 @@ + + Payment Received + payment_received + + + + + diff --git a/fusion_plating/fusion_plating_notifications/data/mail_template_data.xml b/fusion_plating/fusion_plating_notifications/data/mail_template_data.xml index d90a791f..fe7178d3 100644 --- a/fusion_plating/fusion_plating_notifications/data/mail_template_data.xml +++ b/fusion_plating/fusion_plating_notifications/data/mail_template_data.xml @@ -1,51 +1,404 @@ + + + + + + FP: Quotation Sent + + Quotation {{ object.name }} — EN Technologies + {{ (object.company_id.email or user.email) }} + {{ object.partner_id.email }} + + +
+
+
+ EN Technologies +
+

Quotation Ready

+

+ Hi , your quotation is attached for review. +

+ + + + + + + + + + + + + + + + + +
DetailValue
Quote Reference
Valid Until
Total
+
+ Next steps: Reply with a PO number or signed copy to accept. Once we receive your parts, we'll confirm receipt and begin production. +
+
+ Best regards,
+
+ EN Technologies Inc. +
+
+ This is an automated notification from EN Technologies production system. +
+
+
+
+ + + + FP: Order Confirmation - Order Confirmation — {{ object.name }} + Order Confirmed — {{ object.name }} {{ (object.company_id.email or user.email) }} {{ object.partner_id.email }} - -

Dear {{ object.partner_id.name }},

-

Your order {{ object.name }} has been confirmed.

-

We will notify you when your parts have been received at our facility.

-

Thank you for your business.

-

— EN Technologies Inc.

-
+ +
+
+
+ EN Technologies +
+

Order Confirmed

+

+ Thank you, . We have your order and will notify you the moment your parts arrive at our facility. +

+ + + + + + + + + + + + + + + + + + + + + +
OrderDetail
Reference
Customer PO
Order Date
Total
+
+ What's next: Ship your parts to our facility. We'll inspect on arrival, run the process, and keep you posted at each milestone. +
+
+ Best regards,
+
+ EN Technologies Inc. +
+
+ This is an automated notification from EN Technologies production system. +
+
+
+ + + FP: Parts Received - - Parts Received — {{ object.name }} + + Parts Received — {{ object.sale_order_id.name or object.name }} {{ (object.sale_order_id.company_id.email or user.email) }} {{ object.partner_id.email }} - -

Dear {{ object.partner_id.name }},

-

We have received your parts for order {{ object.sale_order_id.name }}.

-

Quantity received: {{ object.received_qty }}

-

Your parts are now in our production queue. We will keep you updated on progress.

-

— EN Technologies Inc.

-
+ +
+
+
+ EN Technologies +
+

Parts Received

+

+ Good news, . Your parts have arrived at our facility and passed incoming inspection. +

+ + + + + + + + + + + + + + + + + +
DetailValue
Sale Order
Receiving Reference
Qty Received
+
+ Next step: Your job has entered our production queue. Expect a manufacturing-complete email once the job is through processing. +
+
+ Best regards,
+
+ EN Technologies Inc. +
+
+ This is an automated notification from EN Technologies production system. +
+
+
+ + + + + FP: Manufacturing Complete + + Job Complete — {{ object.x_fc_portal_job_id.name or object.name }} + {{ (object.company_id.email or user.email) }} + {{ object.x_fc_portal_job_id.partner_id.email }} + + +
+
+
+ EN Technologies +
+

Manufacturing Complete — Ready to Ship

+

+ Hi , your job has cleared production and quality. We are preparing it for shipment. +

+ + + + + + + + + + + + + + + + + +
DetailValue
Job Reference
Sale Order
Quantity
+
+ Next: Your Certificate of Conformance will be issued with the shipment. Delivery scheduling to follow. +
+
+ Best regards,
+
+ EN Technologies Inc. +
+
+ This is an automated notification from EN Technologies production system. +
+
+
+
+ + + + + + FP: Shipped / Delivered + + Shipped — {{ object.job_ref or object.name }} + {{ (object.company_id.email or user.email) }} + {{ object.partner_id.email }} + + +
+
+
+ EN Technologies +
+

Your Parts Have Shipped

+

+ Hi , your order has left our facility. Certificate of Conformance and Bill of Lading are attached for your records. +

+ + + + + + + + + + + + + + + + + + + + + +
ShipmentDetail
Delivery Ref
Job Reference
Delivered
Driver
+
+ Next: Your invoice will follow shortly. Please inspect your parts on receipt and contact us with any questions. +
+
+ Best regards,
+
+ EN Technologies Inc. +
+
+ This is an automated notification from EN Technologies production system. +
+
+
+
+ + + + FP: Invoice Notification Invoice {{ object.name }} — EN Technologies {{ (object.company_id.email or user.email) }} {{ object.partner_id.email }} - -

Dear {{ object.partner_id.name }},

-

Please find your invoice {{ object.name }} for amount {{ object.amount_total }}.

-

Thank you for your business.

-

— EN Technologies Inc.

-
+ +
+
+
+ EN Technologies +
+

Invoice Ready

+

+ Hi , please find invoice attached. +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
InvoiceDetail
Invoice Number
Source Order
Invoice Date
Due Date
Amount Due
+
+ Payment: Please remit by the due date. Reference invoice number on your payment. +
+
+ Best regards,
+
+ EN Technologies Inc. +
+
+ This is an automated notification from EN Technologies production system. +
+
+
+
+ + + + + + FP: Payment Received + + Payment Received — {{ object.name }} + {{ (object.company_id.email or user.email) }} + {{ object.partner_id.email }} + + +
+
+
+ EN Technologies +
+

Payment Received — Thank You

+

+ Hi , we've received your payment. Your receipt is attached. +

+ + + + + + + + + + + + + + + + + + + + + +
PaymentDetail
Receipt Number
Payment Method
Payment Date
Amount
+
+ Your account balance has been updated. We appreciate your business. +
+
+ Best regards,
+
+ EN Technologies Inc. +
+
+ This is an automated notification from EN Technologies production system. +
+
+
diff --git a/fusion_plating/fusion_plating_notifications/models/__init__.py b/fusion_plating/fusion_plating_notifications/models/__init__.py index e42f630a..9d4bad49 100644 --- a/fusion_plating/fusion_plating_notifications/models/__init__.py +++ b/fusion_plating/fusion_plating_notifications/models/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating_notifications/models/account_move.py b/fusion_plating/fusion_plating_notifications/models/account_move.py index 509b0ecd..7721a459 100644 --- a/fusion_plating/fusion_plating_notifications/models/account_move.py +++ b/fusion_plating/fusion_plating_notifications/models/account_move.py @@ -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), - }) diff --git a/fusion_plating/fusion_plating_notifications/models/account_payment.py b/fusion_plating/fusion_plating_notifications/models/account_payment.py new file mode 100644 index 00000000..0873f768 --- /dev/null +++ b/fusion_plating/fusion_plating_notifications/models/account_payment.py @@ -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 diff --git a/fusion_plating/fusion_plating_notifications/models/fp_delivery.py b/fusion_plating/fusion_plating_notifications/models/fp_delivery.py new file mode 100644 index 00000000..f70529fe --- /dev/null +++ b/fusion_plating/fusion_plating_notifications/models/fp_delivery.py @@ -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 diff --git a/fusion_plating/fusion_plating_notifications/models/fp_notification_log.py b/fusion_plating/fusion_plating_notifications/models/fp_notification_log.py index c3eef91a..de914468 100644 --- a/fusion_plating/fusion_plating_notifications/models/fp_notification_log.py +++ b/fusion_plating/fusion_plating_notifications/models/fp_notification_log.py @@ -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): 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 af06829a..78291903 100644 --- a/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py +++ b/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py @@ -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 diff --git a/fusion_plating/fusion_plating_notifications/models/fp_receiving.py b/fusion_plating/fusion_plating_notifications/models/fp_receiving.py index d692f5d1..fbaf851c 100644 --- a/fusion_plating/fusion_plating_notifications/models/fp_receiving.py +++ b/fusion_plating/fusion_plating_notifications/models/fp_receiving.py @@ -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), - }) diff --git a/fusion_plating/fusion_plating_notifications/models/mrp_production.py b/fusion_plating/fusion_plating_notifications/models/mrp_production.py new file mode 100644 index 00000000..2ecaf768 --- /dev/null +++ b/fusion_plating/fusion_plating_notifications/models/mrp_production.py @@ -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 diff --git a/fusion_plating/fusion_plating_notifications/models/sale_order.py b/fusion_plating/fusion_plating_notifications/models/sale_order.py index 72002c92..75b3b9d0 100644 --- a/fusion_plating/fusion_plating_notifications/models/sale_order.py +++ b/fusion_plating/fusion_plating_notifications/models/sale_order.py @@ -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 diff --git a/fusion_plating/fusion_plating_notifications/views/fp_notification_log_views.xml b/fusion_plating/fusion_plating_notifications/views/fp_notification_log_views.xml index b710694c..9a35d8c9 100644 --- a/fusion_plating/fusion_plating_notifications/views/fp_notification_log_views.xml +++ b/fusion_plating/fusion_plating_notifications/views/fp_notification_log_views.xml @@ -78,7 +78,7 @@ - + + + + + diff --git a/fusion_plating/fusion_plating_reports/__manifest__.py b/fusion_plating/fusion_plating_reports/__manifest__.py index 5a6c78c1..3925d0e6 100644 --- a/fusion_plating/fusion_plating_reports/__manifest__.py +++ b/fusion_plating/fusion_plating_reports/__manifest__.py @@ -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, diff --git a/fusion_plating/fusion_plating_reports/report/report_actions.xml b/fusion_plating/fusion_plating_reports/report/report_actions.xml index 3668e055..7ee38b32 100644 --- a/fusion_plating/fusion_plating_reports/report/report_actions.xml +++ b/fusion_plating/fusion_plating_reports/report/report_actions.xml @@ -24,10 +24,10 @@
- + - Certificate of Conformance + Certificate of Conformance (Landscape) fusion.plating.portal.job qweb-pdf fusion_plating_reports.report_coc @@ -38,6 +38,18 @@ + + + Certificate of Conformance (Portrait) + fusion.plating.portal.job + qweb-pdf + fusion_plating_reports.report_coc_portrait + fusion_plating_reports.report_coc_portrait + 'CoC - %s' % object.name + + report + + @@ -202,4 +214,160 @@ report + + + + + + Quotation / Order (Portrait) + sale.order + qweb-pdf + fusion_plating_reports.report_fp_sale_portrait + fusion_plating_reports.report_fp_sale_portrait + (object.state in ('draft', 'sent') and 'Quotation - %s' % object.name) or 'Order - %s' % object.name + + report + + + + Quotation / Order (Landscape) + sale.order + qweb-pdf + fusion_plating_reports.report_fp_sale_landscape + fusion_plating_reports.report_fp_sale_landscape + (object.state in ('draft', 'sent') and 'Quotation - %s' % object.name) or 'Order - %s' % object.name + + report + + + + + + + + Work Order Traveller (Portrait) + mrp.workorder + qweb-pdf + fusion_plating_reports.report_fp_work_order_portrait + fusion_plating_reports.report_fp_work_order_portrait + 'WO - %s' % object.name + + report + + + + Work Order Traveller (Landscape) + mrp.workorder + qweb-pdf + fusion_plating_reports.report_fp_work_order_landscape + fusion_plating_reports.report_fp_work_order_landscape + 'WO - %s' % object.name + + report + + + + + + + + Packing Slip (Portrait) + stock.picking + qweb-pdf + fusion_plating_reports.report_fp_packing_slip_portrait + fusion_plating_reports.report_fp_packing_slip_portrait + 'Packing Slip - %s' % object.name + + report + + + + Packing Slip (Landscape) + stock.picking + qweb-pdf + fusion_plating_reports.report_fp_packing_slip_landscape + fusion_plating_reports.report_fp_packing_slip_landscape + 'Packing Slip - %s' % object.name + + report + + + + + + + + Bill of Lading (Portrait) + fusion.plating.delivery + qweb-pdf + fusion_plating_reports.report_fp_bol_portrait + fusion_plating_reports.report_fp_bol_portrait + 'BoL - %s' % object.name + + report + + + + Bill of Lading (Landscape) + fusion.plating.delivery + qweb-pdf + fusion_plating_reports.report_fp_bol_landscape + fusion_plating_reports.report_fp_bol_landscape + 'BoL - %s' % object.name + + report + + + + + + + + Invoice — Plating (Portrait) + account.move + qweb-pdf + fusion_plating_reports.report_fp_invoice_portrait + fusion_plating_reports.report_fp_invoice_portrait + 'Invoice - %s' % (object.name or '') + + report + + + + Invoice — Plating (Landscape) + account.move + qweb-pdf + fusion_plating_reports.report_fp_invoice_landscape + fusion_plating_reports.report_fp_invoice_landscape + 'Invoice - %s' % (object.name or '') + + report + + + + + + + + Payment Receipt (Portrait) + account.payment + qweb-pdf + fusion_plating_reports.report_fp_receipt_portrait + fusion_plating_reports.report_fp_receipt_portrait + 'Receipt - %s' % (object.name or '') + + report + + + + Payment Receipt (Landscape) + account.payment + qweb-pdf + fusion_plating_reports.report_fp_receipt_landscape + fusion_plating_reports.report_fp_receipt_landscape + 'Receipt - %s' % (object.name or '') + + 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 7b4ad721..f96e0c81 100644 --- a/fusion_plating/fusion_plating_reports/report/report_base_styles.xml +++ b/fusion_plating/fusion_plating_reports/report/report_base_styles.xml @@ -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). --> + + + + + + + + diff --git a/fusion_plating/fusion_plating_reports/report/report_coc.xml b/fusion_plating/fusion_plating_reports/report/report_coc.xml index 8d0b368b..e0faedb8 100644 --- a/fusion_plating/fusion_plating_reports/report/report_coc.xml +++ b/fusion_plating/fusion_plating_reports/report/report_coc.xml @@ -2,9 +2,132 @@ + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_bol.xml b/fusion_plating/fusion_plating_reports/report/report_fp_bol.xml new file mode 100644 index 00000000..5f7cd789 --- /dev/null +++ b/fusion_plating/fusion_plating_reports/report/report_fp_bol.xml @@ -0,0 +1,380 @@ + + + + + + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_invoice.xml b/fusion_plating/fusion_plating_reports/report/report_fp_invoice.xml new file mode 100644 index 00000000..5c46e820 --- /dev/null +++ b/fusion_plating/fusion_plating_reports/report/report_fp_invoice.xml @@ -0,0 +1,420 @@ + + + + + + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_packing_slip.xml b/fusion_plating/fusion_plating_reports/report/report_fp_packing_slip.xml new file mode 100644 index 00000000..b2586602 --- /dev/null +++ b/fusion_plating/fusion_plating_reports/report/report_fp_packing_slip.xml @@ -0,0 +1,280 @@ + + + + + + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_receipt.xml b/fusion_plating/fusion_plating_reports/report/report_fp_receipt.xml new file mode 100644 index 00000000..485cfd2b --- /dev/null +++ b/fusion_plating/fusion_plating_reports/report/report_fp_receipt.xml @@ -0,0 +1,282 @@ + + + + + + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_sale.xml b/fusion_plating/fusion_plating_reports/report/report_fp_sale.xml new file mode 100644 index 00000000..5acf564a --- /dev/null +++ b/fusion_plating/fusion_plating_reports/report/report_fp_sale.xml @@ -0,0 +1,464 @@ + + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000..ee0ed7b4 --- /dev/null +++ b/fusion_plating/fusion_plating_reports/report/report_fp_work_order.xml @@ -0,0 +1,380 @@ + + + + + + + + + + + + + + +