# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) """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 api, models from odoo.exceptions import UserError from odoo.tools.translate import _ _logger = logging.getLogger(__name__) CUSTOMER_TYPES = ('out_invoice', 'out_refund', 'out_receipt') class AccountMove(models.Model): _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, then to the reversed entry's SO (for the Add Credit Note path where invoice_origin has copy=False and doesn't survive the move.copy()).""" 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: so = self.env['sale.order'].search( [('name', '=', self.invoice_origin)], limit=1, ) if so: return so # Reversal path: read the parent move's SO link so the credit # note's name flows from the same parent number as the invoice # it's reversing. if self.reversed_entry_id: parent_so = self.reversed_entry_id._fp_parent_sale_order() if parent_so: return parent_so 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_pn_cn_count' if self.move_type == 'out_refund' else 'x_fc_pn_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 / out_receipt 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 # Credit-note / reversal path: Odoo's "Add Credit Note" wizard # calls move.copy() with reversed_entry_id set in the defaults, # but invoice_origin has copy=False on the standard field so # it doesn't survive the copy. Allow reversals through as long # as the reversed entry is itself a customer-facing move (which # means it already went through this validator at original # creation time — the audit trail is intact). reversed_id = vals.get('reversed_entry_id') if reversed_id: parent = self.env['account.move'].sudo().browse(reversed_id) if parent.exists() and parent.move_type in CUSTOMER_TYPES: return raise UserError(_( 'Customer invoices, credit notes, and receipts 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 CUSTOMER_TYPES ): invoice._fp_link_to_job() return result def _fp_link_to_job(self): self.ensure_one() if not self.invoice_origin: return Job = self.env['fp.job'].sudo() SO = self.env['sale.order'].sudo() so = SO.search([('name', '=', self.invoice_origin)], limit=1) if not so: return job = Job.search([('sale_order_id', '=', so.id)], limit=1) if not job or not job.portal_job_id: return portal = job.portal_job_id if 'invoice_ref' in portal._fields: portal.invoice_ref = self.name # Recompute state via the central helper — it'll only land on # 'complete' if the WO is actually done AND the shipment is # delivered. Posting an invoice early no longer skips the floor. if hasattr(portal, '_fp_recompute_portal_state'): portal._fp_recompute_portal_state() _logger.info( 'Invoice %s linked to fp.job %s portal %s', self.name, job.name, portal.name, )