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) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.8.22.3',
|
'version': '19.0.8.22.4',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -1,24 +1,101 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
#
|
"""account.move overrides for Fusion Plating:
|
||||||
# When an invoice is posted, find the linked fp.job (via origin) and
|
|
||||||
# update the portal job state to 'complete' + stamp invoice_ref.
|
|
||||||
|
|
||||||
|
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
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CUSTOMER_TYPES = ('out_invoice', 'out_refund')
|
||||||
|
|
||||||
|
|
||||||
class AccountMove(models.Model):
|
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):
|
def action_post(self):
|
||||||
result = super().action_post()
|
result = super().action_post()
|
||||||
for invoice in self.filtered(
|
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()
|
invoice._fp_link_to_job()
|
||||||
return result
|
return result
|
||||||
@@ -28,7 +105,6 @@ class AccountMove(models.Model):
|
|||||||
if not self.invoice_origin:
|
if not self.invoice_origin:
|
||||||
return
|
return
|
||||||
Job = self.env['fp.job'].sudo()
|
Job = self.env['fp.job'].sudo()
|
||||||
# Walk SO -> fp.job
|
|
||||||
SO = self.env['sale.order'].sudo()
|
SO = self.env['sale.order'].sudo()
|
||||||
so = SO.search([('name', '=', self.invoice_origin)], limit=1)
|
so = SO.search([('name', '=', self.invoice_origin)], limit=1)
|
||||||
if not so:
|
if not so:
|
||||||
|
|||||||
@@ -296,6 +296,17 @@ class SaleOrder(models.Model):
|
|||||||
) % {'job': job.name, 'err': exc})
|
) % {'job': job.name, 'err': exc})
|
||||||
return result
|
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):
|
def _fp_resolve_recipe_for_line(self, line):
|
||||||
"""4-tier recipe resolution. Used BOTH for grouping (Task 6
|
"""4-tier recipe resolution. Used BOTH for grouping (Task 6
|
||||||
recipe-driven WO splits) AND for the per-job vals construction.
|
recipe-driven WO splits) AND for the per-job vals construction.
|
||||||
|
|||||||
Reference in New Issue
Block a user