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>

Reply via email to