From 06a21c635430d05c5de6c5f2bddde33b5d95d8a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 28 Mar 2026 17:50:12 +0100 Subject: [PATCH] Add support for selling with excise number using excise tax to calculate price list --- .../account_stock_eu_excise/doc/design.rst | 27 +++ modules/account_stock_eu_excise/product.py | 23 +++ modules/account_stock_eu_excise/product.xml | 20 +++ modules/account_stock_eu_excise/sale.py | 166 ++++++++++++++++++ modules/account_stock_eu_excise/sale.xml | 18 ++ .../scenario_account_stock_eu_excise_sale.rst | 143 +++++++++++++++ .../tests/test_module.py | 4 +- modules/account_stock_eu_excise/tryton.cfg | 15 ++ .../view/product_price_list_line_form.xml | 11 ++ .../view/product_price_list_line_list.xml | 9 + .../view/sale_line_list.xml | 8 + .../view/sale_sale_form.xml | 10 ++ 12 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 modules/account_stock_eu_excise/sale.py create mode 100644 modules/account_stock_eu_excise/sale.xml create mode 100644 modules/account_stock_eu_excise/tests/scenario_account_stock_eu_excise_sale.rst create mode 100644 modules/account_stock_eu_excise/view/product_price_list_line_form.xml create mode 100644 modules/account_stock_eu_excise/view/product_price_list_line_list.xml create mode 100644 modules/account_stock_eu_excise/view/sale_line_list.xml create mode 100644 modules/account_stock_eu_excise/view/sale_sale_form.xml diff --git a/modules/account_stock_eu_excise/doc/design.rst b/modules/account_stock_eu_excise/doc/design.rst index caa5dcc087..1bc013cf55 100644 --- a/modules/account_stock_eu_excise/doc/design.rst +++ b/modules/account_stock_eu_excise/doc/design.rst @@ -119,6 +119,19 @@ The excise code stores the codes from the `System for Exchange of Excise Data .. |Products --> Configuration --> Excise Codes| replace:: :menuselection:`Products --> Configuration --> Excise Codes` __ https://demo.tryton.org/model/product.eu.excise_code +.. _model-product.price_list: + +Price List +========== + +When the *Account Stock EU Excise Module* is activated, the price list gains +new criteria based on `Excise Tax ` and duty +suspension. + +.. seealso:: + + The Price List concept is introduced by the :doc:`Product Price List Module + `. .. _concept-stock.location.warehouse: @@ -160,3 +173,17 @@ the type of excise duty that applies. The `Stock Move ` concept is introduced by the :doc:`Stock Module `. + +.. _model-sale.sale: + +Sale +==== + +When the *Account Stock EU Excise Module* is activated, sales gain new +properties for storing the `Excise Number ` of the +customer and calculating the duty amount. + +.. seealso:: + + The `Sale ` concept is introduced by the :doc:`Sale + Module `. diff --git a/modules/account_stock_eu_excise/product.py b/modules/account_stock_eu_excise/product.py index ca38a9c0ba..b30311a1df 100644 --- a/modules/account_stock_eu_excise/product.py +++ b/modules/account_stock_eu_excise/product.py @@ -4,6 +4,7 @@ from trytond.model import DeactivableMixin, ModelSQL, ModelView, Unique, fields from trytond.pool import PoolMeta from trytond.pyson import Bool, Eval, If +from trytond.transaction import Transaction class Template(metaclass=PoolMeta): @@ -105,3 +106,25 @@ class Product_EUExciseTax(ModelSQL, ModelView): @classmethod def search_rec_name(cls, name, clause): return [('excise_tax.rec_name', *clause[1:])] + + +class PriceList(metaclass=PoolMeta): + __name__ = 'product.price_list' + + def compute(self, product, quantity, uom, pattern=None): + context = Transaction().context + pattern = pattern.copy() if pattern is not None else {} + pattern.setdefault('eu_excise_tax', context.get('eu_excise_tax')) + pattern.setdefault('eu_excise_duty', context.get('eu_excise_duty')) + return super().compute(product, quantity, uom, pattern=pattern) + + +class PriceListLine(metaclass=PoolMeta): + __name__ = 'product.price_list.line' + + eu_excise_tax = fields.Many2One( + 'account.stock.eu.excise.tax', "Excise Tax") + eu_excise_duty = fields.Selection([ + (None, ""), + ('suspension', "Suspension"), + ], "Excise Duty Suspension") diff --git a/modules/account_stock_eu_excise/product.xml b/modules/account_stock_eu_excise/product.xml index 39993fe775..df7cb0aaed 100644 --- a/modules/account_stock_eu_excise/product.xml +++ b/modules/account_stock_eu_excise/product.xml @@ -85,4 +85,24 @@ this repository contains the full copyright notices and license terms. --> product-account_stock_eu_excise_tax_form + + + product.price_list.line + + product_price_list_line_form + + + + product.price_list.line + + product_price_list_line_list + + + + product.price_list.line + + product_price_list_line_list + + + diff --git a/modules/account_stock_eu_excise/sale.py b/modules/account_stock_eu_excise/sale.py new file mode 100644 index 0000000000..fd5a8a3721 --- /dev/null +++ b/modules/account_stock_eu_excise/sale.py @@ -0,0 +1,166 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +from decimal import Decimal + +from trytond.model import fields +from trytond.modules.currency.fields import Monetary +from trytond.pool import Pool, PoolMeta +from trytond.pyson import Eval, If +from trytond.transaction import Transaction + +from .party import IDENTIFIER_TYPES + + +class Sale(metaclass=PoolMeta): + __name__ = 'sale.sale' + + warehouse_eu_excise_number = fields.Function( + fields.Many2One('party.identifier', "Warehouse Excise Number"), + 'on_change_with_warehouse_eu_excise_number') + eu_excise_number = fields.Many2One( + 'party.identifier', "Excise Number", + domain=[ + ('type', 'in', Eval('eu_excise_types', [])), + ('party', '=', If(Eval('shipment_party', -1), + Eval('shipment_party', -1), + Eval('party', -1))), + ('address', '=', Eval('shipment_address', -1)), + ], + states={ + 'invisible': ~Eval('warehouse_eu_excise_number'), + 'readonly': (Eval('state') != 'draft') | Eval('lines', [0]), + }) + eu_excise_types = fields.Function( + fields.MultiSelection(IDENTIFIER_TYPES, "Excise Types"), + 'on_change_with_eu_excise_types') + eu_excise_duty_amount = fields.Function(Monetary( + "Excise Duty Amount", digits='currency', currency='currency'), + 'get_eu_excise_duty_amount') + + @classmethod + def __setup__(cls): + super().__setup__() + cls.warehouse.states['readonly'] = ( + cls.warehouse.states.get('readonly', False) + | Eval('eu_excise_number')) + + @fields.depends(methods=['_clear_eu_excise_number']) + def on_change_party(self): + try: + super().on_change_party() + except AttributeError: + pass + self._clear_eu_excise_number() + + @fields.depends(methods=['_clear_eu_excise_number']) + def on_change_shipment_party(self): + try: + super().on_change_shipment_party() + except AttributeError: + pass + self._clear_eu_excise_number() + + @fields.depends('party', 'shipment_party', 'eu_excise_number') + def _clear_eu_excise_number(self): + if (self.eu_excise_number + and self.eu_excise_number.party != ( + self.shipment_party or self.party)): + self.eu_excise_number = None + + @fields.depends('warehouse', 'company') + def on_change_with_warehouse_eu_excise_number(self, name=None): + if self.warehouse: + return self.warehouse.get_eu_excise_number(self.company) + + @fields.depends('warehouse_eu_excise_number') + def on_change_with_eu_excise_types(self, name=None): + types = set() + if self.warehouse_eu_excise_number: + types.add('eu_excise') + types.add(self.warehouse_eu_excise_number.type) + return list(types) + + def get_eu_excise_duty_amount(self, name): + return sum( + (l.eu_excise_duty_amount for l in self.lines + if l.eu_excise_duty_amount is not None), + Decimal(0)) + + def _get_shipment_sale(self, Shipment, key): + shipment = super()._get_shipment_sale(Shipment, key) + shipment.eu_excise_number = self.eu_excise_number + return shipment + + def _get_shipment_grouping_fields(self, shipment): + return super()._get_shipment_grouping_fields(shipment) | { + 'eu_excise_number'} + + +class Line(metaclass=PoolMeta): + __name__ = 'sale.line' + + eu_excise_duty_amount = fields.Function(Monetary( + "Excise Duty Amount", digits='currency', currency='currency'), + 'get_eu_excise_duty_amount') + + def get_eu_excise_duty_amount(self, name): + pool = Pool() + Date = pool.get('ir.date') + Currency = pool.get('currency.currency') + + amount = None + if self.product and self.eu_excise_duty != 'suspension': + country = None + if (self.warehouse + and self.warehouse.address + and self.warehouse.address.country): + country = self.warehouse.address.country + if eu_excise_tax := self.product.get_eu_excise_tax(country): + if not (date := self.sale.sale_date): + with Transaction().set_context( + company=self.sale.company.id): + date = Date.today() + if tax_rate := eu_excise_tax.get_tax_rate({ + 'date': date, + }): + amount = tax_rate.compute( + self.product, self.quantity, self.unit) + if amount is not None: + with Transaction().set_context(date=date): + amount = Currency.compute( + eu_excise_tax.currency, amount, self.currency) + return amount + + @property + @fields.depends( + 'sale', + '_parent_sale.warehouse', + '_parent_sale.warehouse_eu_excise_number', + '_parent_sale.eu_excise_number', + 'product') + def eu_excise_duty(self): + if (self.sale + and self.sale.warehouse_eu_excise_number + and self.sale.eu_excise_number + and self.product): + warehouse_eu_excise_number = self.sale.warehouse_eu_excise_number + eu_excise_number = self.sale.eu_excise_number + if (warehouse_eu_excise_number.is_excise_product(self.product) + and eu_excise_number.is_excise_product(self.product)): + return 'suspension' + + @fields.depends( + 'sale', '_parent_sale.warehouse', 'product', + methods=['eu_excise_duty']) + def _get_context_sale_price(self): + context = super()._get_context_sale_price() + context['eu_excise_duty'] = self.eu_excise_duty + country = None + if (self.warehouse + and self.warehouse.address + and self.warehouse.address.country): + country = self.warehouse.address.country + if eu_excise_tax := self.product.get_eu_excise_tax(country): + context['eu_excise_tax'] = eu_excise_tax.id + return context diff --git a/modules/account_stock_eu_excise/sale.xml b/modules/account_stock_eu_excise/sale.xml new file mode 100644 index 0000000000..7252531cf2 --- /dev/null +++ b/modules/account_stock_eu_excise/sale.xml @@ -0,0 +1,18 @@ + + + + + + sale.sale + + sale_sale_form + + + + sale.line + + sale_line_list + + + diff --git a/modules/account_stock_eu_excise/tests/scenario_account_stock_eu_excise_sale.rst b/modules/account_stock_eu_excise/tests/scenario_account_stock_eu_excise_sale.rst new file mode 100644 index 0000000000..4839c47631 --- /dev/null +++ b/modules/account_stock_eu_excise/tests/scenario_account_stock_eu_excise_sale.rst @@ -0,0 +1,143 @@ +===================================== +Account Stock EU Excise Sale Scenario +===================================== + +Imports:: + + >>> from decimal import Decimal + + >>> from proteus import Model + >>> from trytond.modules.company.tests.tools import create_company, get_company + >>> from trytond.modules.currency.tests.tools import get_currency + >>> from trytond.tests.tools import activate_modules, assertEqual + +Activate modules:: + + >>> config = activate_modules( + ... ['account_stock_eu_excise', 'sale', 'sale_price_list'], + ... create_company) + + >>> Country = Model.get('country.country') + >>> ExciseCode = Model.get('product.eu.excise_code') + >>> ExciseTax = Model.get('account.stock.eu.excise.tax') + >>> Location = Model.get('stock.location') + >>> Party = Model.get('party.party') + >>> PriceList = Model.get('product.price_list') + >>> ProductTemplate = Model.get('product.template') + >>> ProductUom = Model.get('product.uom') + >>> Sale = Model.get('sale.sale') + +Get company:: + + >>> company = get_company() + +Setup tax warehouse:: + + >>> liter, = ProductUom.find([('name', '=', "Liter")]) + >>> france = Country(code='FR', name='France') + >>> france.save() + + >>> excise_code = ExciseCode(code='W200') + >>> excise_code.save() + + >>> company_party = company.party + >>> company_address, = company_party.addresses + >>> company_address.country = france + >>> excise_number = company_party.identifiers.new(type='eu_excise') + >>> excise_number.address = company_address + >>> excise_number.code = "LU00000987ABC" + >>> excise_number.eu_excise_codes.append(ExciseCode(excise_code.id)) + >>> company_party.save() + >>> excise_number, = company_party.identifiers + + >>> warehouse, = Location.find([('code', '=', 'WH')]) + >>> warehouse.address = company_address + >>> wh_excise_number = warehouse.eu_excise_numbers.new() + >>> wh_excise_number.eu_excise_number = excise_number + >>> warehouse.save() + + >>> excise_tax = ExciseTax(code='TAX') + >>> excise_tax.quantity = 'measurement_volume' + >>> excise_tax.uom = liter + >>> excise_tax.country = france + >>> excise_tax.currency = get_currency('EUR') + >>> tax_rate = excise_tax.tax_rates.new() + >>> tax_rate.formula = 'quantity * 5' + >>> excise_tax.save() + +Create a customer:: + + >>> customer = Party(name="Customer") + >>> customer.save() + >>> customer_excise_number = customer.identifiers.new(type='eu_excise') + >>> customer_excise_number.address, = customer.addresses + >>> customer_excise_number.code = "LU00000987DEF" + >>> customer_excise_number.eu_excise_codes.append(ExciseCode(excise_code.id)) + >>> customer.save() + >>> customer_excise_number, = customer.identifiers + +Create a product:: + + >>> template = ProductTemplate() + >>> template.name = "Wine" + >>> template.default_uom = liter + >>> template.type = 'goods' + >>> template.list_price = Decimal('20.0000') + >>> template.eu_excise_code = excise_code + >>> template.salable = True + >>> _ = template.eu_excise_taxes.new(excise_tax=excise_tax) + >>> template.save() + >>> product, = template.products + +Create a price list:: + + >>> price_list = PriceList(name="Price", price='list_price') + + >>> price_list_line = price_list.lines.new() + >>> price_list_line.eu_excise_tax = excise_tax + >>> price_list_line.eu_excise_duty = 'suspension' + >>> price_list_line.formula = 'unit_price * .9' + + >>> price_list_line = price_list.lines.new() + >>> price_list_line.eu_excise_tax = excise_tax + >>> price_list_line.eu_excise_duty = None + >>> price_list_line.formula = 'unit_price' + + >>> price_list.save() + +Create a sale without suspension:: + + >>> sale = Sale(party=customer, price_list=price_list) + >>> sale.invoice_method = 'manual' + >>> line = sale.lines.new() + >>> line.product = product + >>> line.unit_price + Decimal('20.0000') + >>> line.quantity = 1 + >>> sale.save() + >>> sale.eu_excise_duty_amount + Decimal('2.50') + +Create a sale with suspension:: + + >>> sale = Sale(party=customer, price_list=price_list) + >>> sale.invoice_method = 'manual' + >>> sale.eu_excise_number = customer_excise_number + >>> line = sale.lines.new() + >>> line.product = product + >>> line.unit_price + Decimal('18.0000') + >>> line.quantity = 1 + >>> sale.save() + >>> sale.eu_excise_duty_amount + Decimal('0') + +Check excise number is passed to shipment:: + + >>> sale.click('quote') + >>> sale.click('confirm') + >>> sale.state + 'processing' + + >>> shipment, = sale.shipments + >>> assertEqual(shipment.eu_excise_number, customer_excise_number) diff --git a/modules/account_stock_eu_excise/tests/test_module.py b/modules/account_stock_eu_excise/tests/test_module.py index bc59f95b00..44033db13b 100644 --- a/modules/account_stock_eu_excise/tests/test_module.py +++ b/modules/account_stock_eu_excise/tests/test_module.py @@ -8,7 +8,9 @@ from trytond.tests.test_tryton import ModuleTestCase class AccountStockEuExciseTestCase(CompanyTestMixin, ModuleTestCase): "Test Account Stock Eu Excise module" module = 'account_stock_eu_excise' - extras = ['product_measurements', 'production'] + extras = [ + 'product_price_list', 'production', 'sale', 'sale_shipment_grouping', + 'sale_price_list'] del ModuleTestCase diff --git a/modules/account_stock_eu_excise/tryton.cfg b/modules/account_stock_eu_excise/tryton.cfg index 983fab7d5b..46e8eb6dac 100644 --- a/modules/account_stock_eu_excise/tryton.cfg +++ b/modules/account_stock_eu_excise/tryton.cfg @@ -12,11 +12,16 @@ depends: product_measurements stock extras_depend: + product_price_list production + sale + sale_shipment_grouping + sale_price_list xml: party.xml product.xml stock.xml + sale.xml account_stock_eu.xml message.xml @@ -45,6 +50,16 @@ model: account_stock_eu.ExciseDeclarationProduct account_stock_eu.ExciseDeclarationProductLine +[register product_price_list] +model: + product.PriceList + product.PriceListLine + [register production] model: account_stock_eu.ExciseDeclarationProductLine_Production + +[register sale] +model: + sale.Sale + sale.Line diff --git a/modules/account_stock_eu_excise/view/product_price_list_line_form.xml b/modules/account_stock_eu_excise/view/product_price_list_line_form.xml new file mode 100644 index 0000000000..a7b66825f8 --- /dev/null +++ b/modules/account_stock_eu_excise/view/product_price_list_line_form.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/modules/account_stock_eu_excise/view/product_price_list_line_list.xml b/modules/account_stock_eu_excise/view/product_price_list_line_list.xml new file mode 100644 index 0000000000..1ca5053113 --- /dev/null +++ b/modules/account_stock_eu_excise/view/product_price_list_line_list.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/modules/account_stock_eu_excise/view/sale_line_list.xml b/modules/account_stock_eu_excise/view/sale_line_list.xml new file mode 100644 index 0000000000..8b26a1bfe1 --- /dev/null +++ b/modules/account_stock_eu_excise/view/sale_line_list.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/modules/account_stock_eu_excise/view/sale_sale_form.xml b/modules/account_stock_eu_excise/view/sale_sale_form.xml new file mode 100644 index 0000000000..e079a37176 --- /dev/null +++ b/modules/account_stock_eu_excise/view/sale_sale_form.xml @@ -0,0 +1,10 @@ + + + + + +