Add support for selling with excise number using excise tax to calculate price list

This commit is contained in:
Cédric Krier
2026-03-28 17:50:12 +01:00
parent c71b23f817
commit 06a21c6354
12 changed files with 453 additions and 1 deletions
@@ -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 <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 type of excise duty that applies.
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>`.
@@ -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")
@@ -85,4 +85,24 @@ this repository contains the full copyright notices and license terms. -->
<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>
+166
View File
@@ -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
+18
View File
@@ -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>
@@ -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)
@@ -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
@@ -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
@@ -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>
@@ -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>
@@ -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>
@@ -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>