details: https://code.tryton.org/tryton/commit/78badbeff5b4
branch: default
user: Cédric Krier <[email protected]>
date: Sat Mar 28 17:50:12 2026 +0100
description:
Add support for selling with excise number using excise tax to
calculate price list
diffstat:
modules/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 +
modules/account_stock_eu_excise/tests/scenario_account_stock_eu_excise_sale.rst
| 143 ++++++++
modules/account_stock_eu_excise/tests/test_module.py
| 4 +-
modules/account_stock_eu_excise/tryton.cfg
| 15 +
modules/account_stock_eu_excise/view/product_price_list_line_form.xml
| 11 +
modules/account_stock_eu_excise/view/product_price_list_line_list.xml
| 9 +
modules/account_stock_eu_excise/view/sale_line_list.xml
| 8 +
modules/account_stock_eu_excise/view/sale_sale_form.xml
| 10 +
12 files changed, 453 insertions(+), 1 deletions(-)
diffs (550 lines):
diff -r f0e6ce9424a0 -r 78badbeff5b4
modules/account_stock_eu_excise/doc/design.rst
--- a/modules/account_stock_eu_excise/doc/design.rst Mon Apr 07 19:13:55
2025 +0200
+++ b/modules/account_stock_eu_excise/doc/design.rst Sat Mar 28 17:50:12
2026 +0100
@@ -119,6 +119,19 @@
.. |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 <model-account.stock.eu.excise.tax>` and duty
+suspension.
+
+.. seealso::
+
+ The Price List concept is introduced by the :doc:`Product Price List Module
+ <product_price_list:index>`.
.. _concept-stock.location.warehouse:
@@ -160,3 +173,17 @@
The `Stock Move <stock:model-stock.move>` concept is introduced by the
:doc:`Stock Module <stock:index>`.
+
+.. _model-sale.sale:
+
+Sale
+====
+
+When the *Account Stock EU Excise Module* is activated, sales gain new
+properties for storing the `Excise Number <model-party.identifier>` of the
+customer and calculating the duty amount.
+
+.. seealso::
+
+ The `Sale <sale:model-sale.sale>` concept is introduced by the :doc:`Sale
+ Module <sale:index>`.
diff -r f0e6ce9424a0 -r 78badbeff5b4 modules/account_stock_eu_excise/product.py
--- a/modules/account_stock_eu_excise/product.py Mon Apr 07 19:13:55
2025 +0200
+++ b/modules/account_stock_eu_excise/product.py Sat Mar 28 17:50:12
2026 +0100
@@ -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 @@
@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 -r f0e6ce9424a0 -r 78badbeff5b4 modules/account_stock_eu_excise/product.xml
--- a/modules/account_stock_eu_excise/product.xml Mon Apr 07 19:13:55
2025 +0200
+++ b/modules/account_stock_eu_excise/product.xml Sat Mar 28 17:50:12
2026 +0100
@@ -85,4 +85,24 @@
<field name="name">product-account_stock_eu_excise_tax_form</field>
</record>
</data>
+ <data depends="product_price_list">
+ <record model="ir.ui.view" id="product_price_list_line_view_form">
+ <field name="model">product.price_list.line</field>
+ <field name="inherit"
ref="product_price_list.price_list_line_view_form"/>
+ <field name="name">product_price_list_line_form</field>
+ </record>
+
+ <record model="ir.ui.view" id="product_price_list_line_view_list">
+ <field name="model">product.price_list.line</field>
+ <field name="inherit"
ref="product_price_list.price_list_line_view_tree"/>
+ <field name="name">product_price_list_line_list</field>
+ </record>
+
+ <record model="ir.ui.view"
id="product_price_list_line_view_list_sequence">
+ <field name="model">product.price_list.line</field>
+ <field name="inherit"
ref="product_price_list.price_list_line_view_tree_sequence"/>
+ <field name="name">product_price_list_line_list</field>
+ </record>
+
+ </data>
</tryton>
diff -r f0e6ce9424a0 -r 78badbeff5b4 modules/account_stock_eu_excise/sale.py
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/modules/account_stock_eu_excise/sale.py Sat Mar 28 17:50:12 2026 +0100
@@ -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 -r f0e6ce9424a0 -r 78badbeff5b4 modules/account_stock_eu_excise/sale.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/modules/account_stock_eu_excise/sale.xml Sat Mar 28 17:50:12 2026 +0100
@@ -0,0 +1,18 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<tryton>
+ <data depends="sale">
+ <record model="ir.ui.view" id="sale_sale_view_form">
+ <field name="model">sale.sale</field>
+ <field name="inherit" ref="sale.sale_view_form"/>
+ <field name="name">sale_sale_form</field>
+ </record>
+
+ <record model="ir.ui.view" id="sale_line_view_list">
+ <field name="model">sale.line</field>
+ <field name="inherit" ref="sale.sale_line_view_tree_sequence"/>
+ <field name="name">sale_line_list</field>
+ </record>
+ </data>
+</tryton>
diff -r f0e6ce9424a0 -r 78badbeff5b4
modules/account_stock_eu_excise/tests/scenario_account_stock_eu_excise_sale.rst
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++
b/modules/account_stock_eu_excise/tests/scenario_account_stock_eu_excise_sale.rst
Sat Mar 28 17:50:12 2026 +0100
@@ -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 -r f0e6ce9424a0 -r 78badbeff5b4
modules/account_stock_eu_excise/tests/test_module.py
--- a/modules/account_stock_eu_excise/tests/test_module.py Mon Apr 07
19:13:55 2025 +0200
+++ b/modules/account_stock_eu_excise/tests/test_module.py Sat Mar 28
17:50:12 2026 +0100
@@ -8,7 +8,9 @@
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 -r f0e6ce9424a0 -r 78badbeff5b4 modules/account_stock_eu_excise/tryton.cfg
--- a/modules/account_stock_eu_excise/tryton.cfg Mon Apr 07 19:13:55
2025 +0200
+++ b/modules/account_stock_eu_excise/tryton.cfg Sat Mar 28 17:50:12
2026 +0100
@@ -12,11 +12,16 @@
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 @@
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 -r f0e6ce9424a0 -r 78badbeff5b4
modules/account_stock_eu_excise/view/product_price_list_line_form.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/modules/account_stock_eu_excise/view/product_price_list_line_form.xml
Sat Mar 28 17:50:12 2026 +0100
@@ -0,0 +1,11 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<data>
+ <xpath expr="//field[@name='product']" position="after">
+ <label name="eu_excise_tax"/>
+ <field name="eu_excise_tax"/>
+ <label name="eu_excise_duty"/>
+ <field name="eu_excise_duty"/>
+ </xpath>
+</data>
diff -r f0e6ce9424a0 -r 78badbeff5b4
modules/account_stock_eu_excise/view/product_price_list_line_list.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/modules/account_stock_eu_excise/view/product_price_list_line_list.xml
Sat Mar 28 17:50:12 2026 +0100
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<data>
+ <xpath expr="//field[@name='product']" position="after">
+ <field name="eu_excise_tax" expand="1" optional="1"/>
+ <field name="eu_excise_duty" optional="1"/>
+ </xpath>
+</data>
diff -r f0e6ce9424a0 -r 78badbeff5b4
modules/account_stock_eu_excise/view/sale_line_list.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/modules/account_stock_eu_excise/view/sale_line_list.xml Sat Mar 28
17:50:12 2026 +0100
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<data>
+ <xpath expr="//field[@name='taxes']" position="after">
+ <field name="eu_excise_duty_amount" optional="1"/>
+ </xpath>
+</data>
diff -r f0e6ce9424a0 -r 78badbeff5b4
modules/account_stock_eu_excise/view/sale_sale_form.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/modules/account_stock_eu_excise/view/sale_sale_form.xml Sat Mar 28
17:50:12 2026 +0100
@@ -0,0 +1,10 @@
+<?xml version="1.0"?>
+<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
+this repository contains the full copyright notices and license terms. -->
+<data>
+ <xpath expr="//field[@name='shipment_address']" position="after">
+ <label name="eu_excise_number"/>
+ <field name="eu_excise_number"/>
+ <newline/>
+ </xpath>
+</data>