From 765a0a4c82cb02ed73dbce7d1c86592ab099c50f Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 12 May 2026 13:21:09 -0400 Subject: [PATCH] feat(numbering): block direct invoice creation + wire account.move into mixin Customer invoices (out_invoice / out_refund) can only be created via sale.order._create_invoices() or with an invoice_origin matching an existing SO. Applies to ALL users including admins. Once created, the move's name is derived from the SO's parent number: IN-30000, IN-30000-02, CN-30000, ... Pre-existing portal-job link on action_post() preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fusion_plating_jobs/__manifest__.py | 2 +- .../models/account_move.py | 90 +++++++++++++++++-- .../fusion_plating_jobs/models/sale_order.py | 11 +++ 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index a3e15bb9..dee9bbee 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.8.22.3', + 'version': '19.0.8.22.4', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/models/account_move.py b/fusion_plating/fusion_plating_jobs/models/account_move.py index 33d3f973..25fd0dd1 100644 --- a/fusion_plating/fusion_plating_jobs/models/account_move.py +++ b/fusion_plating/fusion_plating_jobs/models/account_move.py @@ -1,24 +1,101 @@ # -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) -# -# When an invoice is posted, find the linked fp.job (via origin) and -# update the portal job state to 'complete' + stamp invoice_ref. +"""account.move overrides for Fusion Plating: +1. Block direct creation of out_invoice / out_refund for ALL users + including administrators. The only legal entry points are: + * sale.order._create_invoices() — sets context fp_from_so_invoice=True + * manual create() with invoice_origin matching an existing sale.order.name + +2. Once a customer move is created via a legitimate path, derive its + name from the SO's parent number (IN-30000 / IN-30000-02 for + invoices, CN-30000 / CN-30000-02 for credit notes). Per the + 2026-05-12 parent-number hierarchy design. + +3. On post, link the invoice back to its fp.job's portal job (mark + complete, stamp invoice_ref). Pre-existing behaviour, preserved. +""" import logging -from odoo import models +from odoo import api, models +from odoo.exceptions import UserError +from odoo.tools.translate import _ _logger = logging.getLogger(__name__) +CUSTOMER_TYPES = ('out_invoice', 'out_refund') + class AccountMove(models.Model): - _inherit = 'account.move' + _inherit = ['account.move', 'fp.parent.numbered.mixin'] + # ================================================================= + # Parent-numbered mixin hooks + # ================================================================= + def _fp_parent_sale_order(self): + """Find linked SO via SO context flag (set by _create_invoices), + or fall back to invoice_origin name match.""" + so_id = self.env.context.get('fp_invoice_source_so_id') + if so_id: + so = self.env['sale.order'].browse(so_id).exists() + if so: + return so + if self.invoice_origin: + return self.env['sale.order'].search( + [('name', '=', self.invoice_origin)], limit=1, + ) + return self.env['sale.order'] + + def _fp_name_prefix(self): + return 'CN' if self.move_type == 'out_refund' else 'IN' + + def _fp_parent_counter_field(self): + return 'x_fc_cn_count' if self.move_type == 'out_refund' else 'x_fc_invoice_count' + + # ================================================================= + # Create override: block off-flow + assign parent-derived name + # ================================================================= + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + self._fp_validate_customer_invoice(vals) + moves = super().create(vals_list) + for mv in moves: + if mv.move_type in CUSTOMER_TYPES: + mv._fp_assign_parent_name() + return moves + + @api.model + def _fp_validate_customer_invoice(self, vals): + """Refuse out_invoice / out_refund creation that didn't come + through the SO workflow. Applies to ALL users including admins.""" + mtype = vals.get('move_type', 'entry') + if mtype not in CUSTOMER_TYPES: + return + if self.env.context.get('fp_from_so_invoice'): + return + origin = (vals.get('invoice_origin') or '').strip() + if origin and self.env['sale.order'].sudo().search_count( + [('name', '=', origin)] + ): + return + raise UserError(_( + 'Customer invoices and credit notes must be created from a ' + 'Sale Order. Open the originating SO and use the Create ' + 'Invoice / Add Credit Note action.\n\n' + 'This rule applies to all users including administrators. ' + 'It is enforced to keep the parent-number audit trail ' + 'intact (see fusion_plating numbering policy).' + )) + + # ================================================================= + # Post hook: link the invoice to its fp.job's portal job + # ================================================================= def action_post(self): result = super().action_post() for invoice in self.filtered( - lambda m: m.move_type in ('out_invoice', 'out_refund') + lambda m: m.move_type in CUSTOMER_TYPES ): invoice._fp_link_to_job() return result @@ -28,7 +105,6 @@ class AccountMove(models.Model): if not self.invoice_origin: return Job = self.env['fp.job'].sudo() - # Walk SO -> fp.job SO = self.env['sale.order'].sudo() so = SO.search([('name', '=', self.invoice_origin)], limit=1) if not so: diff --git a/fusion_plating/fusion_plating_jobs/models/sale_order.py b/fusion_plating/fusion_plating_jobs/models/sale_order.py index c333cddf..591db981 100644 --- a/fusion_plating/fusion_plating_jobs/models/sale_order.py +++ b/fusion_plating/fusion_plating_jobs/models/sale_order.py @@ -296,6 +296,17 @@ class SaleOrder(models.Model): ) % {'job': job.name, 'err': exc}) return result + def _create_invoices(self, grouped=False, final=False, date=None): + """Set fp_from_so_invoice=True so account.move.create() allows + the customer-invoice creation (the direct-creation block is + bypassed via this context flag). Also lets the parent-numbered + mixin find the originating SO without depending on invoice_origin. + """ + return super(SaleOrder, self.with_context( + fp_from_so_invoice=True, + fp_invoice_source_so_id=self.id if len(self) == 1 else False, + ))._create_invoices(grouped=grouped, final=final, date=date) + def _fp_resolve_recipe_for_line(self, line): """4-tier recipe resolution. Used BOTH for grouping (Task 6 recipe-driven WO splits) AND for the per-job vals construction.