details: https://code.tryton.org/tryton/commit/b8720f474575
branch: default
user: Cédric Krier <[email protected]>
date: Fri Mar 27 19:32:32 2026 +0100
description:
Calculate the quantities to ship and to invoice for sale and purchase
diffstat:
modules/product_kit/sale.py | 23 ++++
modules/purchase/CHANGELOG | 1 +
modules/purchase/purchase.py | 32 +++++-
modules/purchase/tests/scenario_purchase_manual_invoice.rst | 31 +++++-
modules/purchase/view/purchase_line_tree.xml | 1 +
modules/purchase/view/purchase_line_tree_sequence.xml | 1 +
modules/purchase/view/purchase_tree.xml | 1 +
modules/sale/CHANGELOG | 1 +
modules/sale/sale.py | 64 +++++++++++-
modules/sale/tests/scenario_sale_manual_invoice.rst | 27 +++++-
modules/sale/tests/scenario_sale_manual_shipment.rst | 22 ++++-
modules/sale/view/sale_line_tree.xml | 2 +
modules/sale/view/sale_line_tree_sequence.xml | 2 +
modules/sale/view/sale_tree.xml | 2 +
14 files changed, 194 insertions(+), 16 deletions(-)
diffs (565 lines):
diff -r 4d2bb29276c1 -r b8720f474575 modules/product_kit/sale.py
--- a/modules/product_kit/sale.py Wed Feb 25 11:37:37 2026 +0100
+++ b/modules/product_kit/sale.py Fri Mar 27 19:32:32 2026 +0100
@@ -75,6 +75,22 @@
def movable_types(cls):
return super().movable_types() + ['kit']
+ def set_quantities(self):
+ super().set_quantities()
+ if self.components:
+ def quantity(component):
+ quantity = component.quantity_to_ship
+ if component.fixed:
+ quantity *= component.quantity / self.quantity
+ else:
+ quantity *= component.quantity_ratio
+ return quantity
+ quantity_to_ship = max(quantity(c) for c in self.components)
+ if self.unit:
+ quantity_to_ship = self.unit.ceil(quantity_to_ship)
+ if quantity_to_ship > (self.quantity_to_ship or 0):
+ self.quantity_to_ship = quantity_to_ship
+
class LineComponent(
order_line_component_mixin('sale'), ComponentMixin,
@@ -96,6 +112,13 @@
def warehouse(self):
return self.line.warehouse
+ @property
+ def quantity_to_ship(self):
+ shipment_type = 'out' if self.quantity >= 0 else 'in'
+ return self.unit.round(
+ self._get_move_quantity(shipment_type)
+ - self._get_shipped_quantity(shipment_type))
+
def get_move(self, shipment_type):
from trytond.modules.sale.exceptions import PartyLocationError
pool = Pool()
diff -r 4d2bb29276c1 -r b8720f474575 modules/purchase/CHANGELOG
--- a/modules/purchase/CHANGELOG Wed Feb 25 11:37:37 2026 +0100
+++ b/modules/purchase/CHANGELOG Fri Mar 27 19:32:32 2026 +0100
@@ -1,3 +1,4 @@
+* Calculate the quantities to invoice for purchase
* Rename invoice method on shipment into on fulfillment
* Add support for Python 3.14
* Remove support for Python 3.9
diff -r 4d2bb29276c1 -r b8720f474575 modules/purchase/purchase.py
--- a/modules/purchase/purchase.py Wed Feb 25 11:37:37 2026 +0100
+++ b/modules/purchase/purchase.py Fri Mar 27 19:32:32 2026 +0100
@@ -199,6 +199,7 @@
('paid', 'Paid'),
('exception', 'Exception'),
], 'Invoice State', readonly=True, required=True, sort=False)
+ to_invoice = fields.Boolean("To Invoice", readonly=True)
invoices = fields.Function(fields.Many2Many(
'account.invoice', None, None, "Invoices"),
'get_invoices', searcher='search_invoices')
@@ -338,9 +339,10 @@
},
'manual_invoice': {
'invisible': (
- (Eval('invoice_method') != 'manual')
+ ~Eval('to_invoice', False)
+ | (Eval('invoice_method') != 'manual')
| ~Eval('state').in_(['processing', 'done'])),
- 'depends': ['invoice_method', 'state'],
+ 'depends': ['to_invoice', 'invoice_method', 'state'],
},
'handle_invoice_exception': {
'invisible': ((Eval('invoice_state') != 'exception')
@@ -761,6 +763,7 @@
default.setdefault('number', None)
default.setdefault('reference')
default.setdefault('invoice_state', 'none')
+ default.setdefault('to_invoice')
default.setdefault('invoices_ignored', None)
default.setdefault('shipment_state', 'none')
default.setdefault('purchase_date', None)
@@ -1060,6 +1063,7 @@
pool = Pool()
Line = pool.get('purchase.line')
lines = []
+ purchases_to_invoice = defaultdict(list)
invoice_states, shipment_states = defaultdict(list), defaultdict(list)
for purchase in purchases:
invoice_state = purchase.get_invoice_state()
@@ -1069,10 +1073,16 @@
if purchase.shipment_state != shipment_state:
shipment_states[shipment_state].append(purchase)
+ to_invoice = False
for line in purchase.line_lines:
- line.set_actual_quantity()
+ line.set_quantities()
+ to_invoice |= bool(line.quantity_to_invoice)
lines.append(line)
+ if purchase.to_invoice != to_invoice:
+ purchases_to_invoice[to_invoice].append(purchase)
+ for to_invoice, purchases in purchases_to_invoice.items():
+ cls.write(purchases, {'to_invoice': to_invoice})
for invoice_state, purchases in invoice_states.items():
cls.write(purchases, {'invoice_state': invoice_state})
cls.log(purchases, 'transition', f'invoice_state:{invoice_state}')
@@ -1188,6 +1198,11 @@
states={
'invisible': ((Eval('type') != 'line') | ~Eval('actual_quantity')),
})
+ quantity_to_invoice = fields.Float(
+ "Quantity to Invoice", digits='unit', readonly=True,
+ states={
+ 'invisible': ~Eval('quantity_to_invoice'),
+ })
unit = fields.Many2One('product.uom', 'Unit',
ondelete='RESTRICT',
states={
@@ -2069,7 +2084,7 @@
lambda l: samesign(self.quantity, l.quantity), lines)
return list(lines)
- def set_actual_quantity(self):
+ def set_quantities(self):
pool = Pool()
Uom = pool.get('product.uom')
if self.type != 'line':
@@ -2097,6 +2112,14 @@
if self.actual_quantity != actual_quantity:
self.actual_quantity = actual_quantity
+ quantity_to_invoice = (
+ self._get_invoice_line_quantity()
+ - self._get_invoiced_quantity())
+ if self.unit:
+ quantity_to_invoice = self.unit.round(quantity_to_invoice)
+ if self.quantity_to_invoice != quantity_to_invoice:
+ self.quantity_to_invoice = quantity_to_invoice
+
def get_rec_name(self, name):
pool = Pool()
Lang = pool.get('ir.lang')
@@ -2159,6 +2182,7 @@
default.setdefault('moves_recreated', None)
default.setdefault('invoice_lines', None)
default.setdefault('actual_quantity')
+ default.setdefault('quantity_to_invoice')
return super().copy(lines, default=default)
diff -r 4d2bb29276c1 -r b8720f474575
modules/purchase/tests/scenario_purchase_manual_invoice.rst
--- a/modules/purchase/tests/scenario_purchase_manual_invoice.rst Wed Feb
25 11:37:37 2026 +0100
+++ b/modules/purchase/tests/scenario_purchase_manual_invoice.rst Fri Mar
27 19:32:32 2026 +0100
@@ -4,13 +4,19 @@
Imports::
+ >>> import datetime as dt
>>> from decimal import Decimal
>>> from proteus import Model
- >>> from trytond.modules.account.tests.tools import create_chart,
get_accounts
+ >>> from trytond.modules.account.tests.tools import (
+ ... create_chart, create_fiscalyear, get_accounts)
+ >>> from trytond.modules.account_invoice.tests.tools import (
+ ... set_fiscalyear_invoice_sequences)
>>> from trytond.modules.company.tests.tools import create_company
>>> from trytond.tests.tools import activate_modules
+ >>> today = dt.date.today()
+
Activate modules::
>>> config = activate_modules('purchase', create_company, create_chart)
@@ -25,6 +31,11 @@
>>> accounts = get_accounts()
+Create fiscal year::
+
+ >>> fiscalyear = set_fiscalyear_invoice_sequences(create_fiscalyear())
+ >>> fiscalyear.click('create_period')
+
Create party::
>>> supplier = Party(name='Supplier')
@@ -67,6 +78,11 @@
'none'
>>> len(purchase.invoices)
0
+ >>> bool(purchase.to_invoice)
+ True
+ >>> line, = purchase.lines
+ >>> line.quantity_to_invoice
+ 10.0
Manually create an invoice::
@@ -75,16 +91,27 @@
'processing'
>>> purchase.invoice_state
'pending'
+ >>> bool(purchase.to_invoice)
+ False
+ >>> line, = purchase.lines
+ >>> line.quantity_to_invoice
+ 0.0
Change quantity on invoice and create a new invoice::
>>> invoice, = purchase.invoices
+ >>> invoice.invoice_date = today
>>> line, = invoice.lines
>>> line.quantity = 5
- >>> invoice.save()
+ >>> invoice.click('post')
+ >>> invoice.state
+ 'posted'
+ >>> purchase.reload()
>>> len(purchase.invoices)
1
+ >>> bool(purchase.to_invoice)
+ True
>>> purchase.click('manual_invoice')
>>> len(purchase.invoices)
2
diff -r 4d2bb29276c1 -r b8720f474575
modules/purchase/view/purchase_line_tree.xml
--- a/modules/purchase/view/purchase_line_tree.xml Wed Feb 25 11:37:37
2026 +0100
+++ b/modules/purchase/view/purchase_line_tree.xml Fri Mar 27 19:32:32
2026 +0100
@@ -11,6 +11,7 @@
<field name="summary" expand="1" optional="1"/>
<field name="actual_quantity" symbol="unit" optional="0"/>
<field name="quantity" symbol="unit" optional="0"/>
+ <field name="quantity_to_invoice" symbol="unit" optional="1"/>
<field name="unit_price"/>
<field name="amount"/>
<field name="moves_progress" string="Shipping" widget="progressbar"
optional="1"/>
diff -r 4d2bb29276c1 -r b8720f474575
modules/purchase/view/purchase_line_tree_sequence.xml
--- a/modules/purchase/view/purchase_line_tree_sequence.xml Wed Feb 25
11:37:37 2026 +0100
+++ b/modules/purchase/view/purchase_line_tree_sequence.xml Fri Mar 27
19:32:32 2026 +0100
@@ -8,6 +8,7 @@
<field name="product_supplier" expand="1" optional="1"/>
<field name="summary" expand="1" optional="1"/>
<field name="quantity" symbol="unit"/>
+ <field name="quantity_to_invoice" symbol="unit" optional="1"/>
<field name="unit_price"/>
<field name="taxes" optional="0"/>
<field name="amount"/>
diff -r 4d2bb29276c1 -r b8720f474575 modules/purchase/view/purchase_tree.xml
--- a/modules/purchase/view/purchase_tree.xml Wed Feb 25 11:37:37 2026 +0100
+++ b/modules/purchase/view/purchase_tree.xml Fri Mar 27 19:32:32 2026 +0100
@@ -13,5 +13,6 @@
<field name="description" expand="1" optional="1"/>
<field name="state"/>
<field name="invoice_state"/>
+ <field name="to_invoice" optional="1"/>
<field name="shipment_state"/>
</tree>
diff -r 4d2bb29276c1 -r b8720f474575 modules/sale/CHANGELOG
--- a/modules/sale/CHANGELOG Wed Feb 25 11:37:37 2026 +0100
+++ b/modules/sale/CHANGELOG Fri Mar 27 19:32:32 2026 +0100
@@ -1,3 +1,4 @@
+* Calculate the quantities to ship and to invoice for sale
* Rename invoice method on shipment into on fulfillment
* Add support for Python 3.14
* Remove support for Python 3.9
diff -r 4d2bb29276c1 -r b8720f474575 modules/sale/sale.py
--- a/modules/sale/sale.py Wed Feb 25 11:37:37 2026 +0100
+++ b/modules/sale/sale.py Fri Mar 27 19:32:32 2026 +0100
@@ -256,6 +256,7 @@
('paid', 'Paid'),
('exception', 'Exception'),
], 'Invoice State', readonly=True, required=True, sort=False)
+ to_invoice = fields.Boolean("To Invoice", readonly=True)
invoices = fields.Function(fields.Many2Many(
'account.invoice', None, None, "Invoices"),
'get_invoices', searcher='search_invoices')
@@ -288,6 +289,7 @@
('sent', 'Sent'),
('exception', 'Exception'),
], "Shipment State", readonly=True, required=True, sort=False)
+ to_ship = fields.Boolean("To Ship", readonly=True)
shipments = fields.Function(fields.Many2Many(
'stock.shipment.out', None, None, "Shipments"),
'get_shipments', searcher='search_shipments')
@@ -402,15 +404,17 @@
},
'manual_invoice': {
'invisible': (
- (Eval('invoice_method') != 'manual')
+ ~Eval('to_invoice', False)
+ | (Eval('invoice_method') != 'manual')
| ~Eval('state').in_(['processing', 'done'])),
- 'depends': ['invoice_method', 'state'],
+ 'depends': ['to_invoice', 'invoice_method', 'state'],
},
'manual_shipment': {
'invisible': (
- (Eval('shipment_method') != 'manual')
+ ~Eval('to_ship', False)
+ | (Eval('shipment_method') != 'manual')
| ~Eval('state').in_(['processing', 'done'])),
- 'depends': ['shipment_method', 'state'],
+ 'depends': ['to_ship', 'shipment_method', 'state'],
},
'handle_invoice_exception': {
'invisible': ((Eval('invoice_state') != 'exception')
@@ -900,8 +904,10 @@
default.setdefault('number', None)
default.setdefault('reference')
default.setdefault('invoice_state', 'none')
+ default.setdefault('to_invoice')
default.setdefault('invoices_ignored', None)
default.setdefault('shipment_state', 'none')
+ default.setdefault('to_ship')
default.setdefault('quotation_date')
default.setdefault('sale_date', None)
default.setdefault('quoted_by')
@@ -1250,6 +1256,7 @@
pool = Pool()
Line = pool.get('sale.line')
lines = []
+ sales_to_invoice, sales_to_ship = defaultdict(list), defaultdict(list)
invoice_states, shipment_states = defaultdict(list), defaultdict(list)
for sale in sales:
invoice_state = sale.get_invoice_state()
@@ -1259,10 +1266,21 @@
if sale.shipment_state != shipment_state:
shipment_states[shipment_state].append(sale)
+ to_invoice = to_ship = False
for line in sale.line_lines:
- line.set_actual_quantity()
+ line.set_quantities()
+ to_invoice |= bool(line.quantity_to_invoice)
+ to_ship |= bool(line.quantity_to_ship)
lines.append(line)
+ if sale.to_invoice != to_invoice:
+ sales_to_invoice[to_invoice].append(sale)
+ if sale.to_ship != to_ship:
+ sales_to_ship[to_ship].append(sale)
+ for to_invoice, sales in sales_to_invoice.items():
+ cls.write(sales, {'to_invoice': to_invoice})
+ for to_ship, sales in sales_to_ship.items():
+ cls.write(sales, {'to_ship': to_ship})
for invoice_state, sales in invoice_states.items():
cls.write(sales, {'invoice_state': invoice_state})
cls.log(sales, 'transition', f'invoice_state:{invoice_state}')
@@ -1396,6 +1414,16 @@
states={
'invisible': ((Eval('type') != 'line') | ~Eval('actual_quantity')),
})
+ quantity_to_ship = fields.Float(
+ "Quantity to Ship", digits='unit', readonly=True,
+ states={
+ 'invisible': ~Eval('quantity_to_ship'),
+ })
+ quantity_to_invoice = fields.Float(
+ "Quantity to Invoice", digits='unit', readonly=True,
+ states={
+ 'invisible': ~Eval('quantity_to_invoice'),
+ })
unit = fields.Many2One('product.uom', 'Unit', ondelete='RESTRICT',
states={
'required': Bool(Eval('product')),
@@ -2192,9 +2220,11 @@
invoice_lines.append(invoice_line)
return invoice_lines
- def set_actual_quantity(self):
+ def set_quantities(self):
pool = Pool()
Uom = pool.get('product.uom')
+ Move = pool.get('stock.move')
+
if self.type != 'line':
return
moved_quantity = 0
@@ -2220,6 +2250,26 @@
if self.actual_quantity != actual_quantity:
self.actual_quantity = actual_quantity
+ if self.product and self.product.type in Move.get_product_types():
+ shipment_type = 'out' if self.quantity >= 0 else 'in'
+ quantity_to_ship = (
+ self._get_move_quantity(shipment_type)
+ - self._get_shipped_quantity(shipment_type))
+ if self.unit:
+ quantity_to_ship = self.unit.round(quantity_to_ship)
+ else:
+ quantity_to_ship = None
+ if self.quantity_to_ship != quantity_to_ship:
+ self.quantity_to_ship = quantity_to_ship
+
+ quantity_to_invoice = (
+ self._get_invoice_line_quantity()
+ - self._get_invoiced_quantity())
+ if self.unit:
+ quantity_to_invoice = self.unit.round(quantity_to_invoice)
+ if self.quantity_to_invoice != quantity_to_invoice:
+ self.quantity_to_invoice = quantity_to_invoice
+
def get_rec_name(self, name):
pool = Pool()
Lang = pool.get('ir.lang')
@@ -2278,6 +2328,8 @@
default.setdefault('moves_recreated', None)
default.setdefault('invoice_lines', None)
default.setdefault('actual_quantity')
+ default.setdefault('quantity_to_invoice')
+ default.setdefault('quantity_to_ship')
return super().copy(lines, default=default)
diff -r 4d2bb29276c1 -r b8720f474575
modules/sale/tests/scenario_sale_manual_invoice.rst
--- a/modules/sale/tests/scenario_sale_manual_invoice.rst Wed Feb 25
11:37:37 2026 +0100
+++ b/modules/sale/tests/scenario_sale_manual_invoice.rst Fri Mar 27
19:32:32 2026 +0100
@@ -7,7 +7,10 @@
>>> from decimal import Decimal
>>> from proteus import Model
- >>> from trytond.modules.account.tests.tools import create_chart,
get_accounts
+ >>> from trytond.modules.account.tests.tools import (
+ ... create_chart, create_fiscalyear, get_accounts)
+ >>> from trytond.modules.account_invoice.tests.tools import (
+ ... set_fiscalyear_invoice_sequences)
>>> from trytond.modules.company.tests.tools import create_company
>>> from trytond.tests.tools import activate_modules
@@ -25,6 +28,11 @@
>>> accounts = get_accounts()
+Create fiscal year::
+
+ >>> fiscalyear = set_fiscalyear_invoice_sequences(create_fiscalyear())
+ >>> fiscalyear.click('create_period')
+
Create party::
>>> customer = Party(name="Customer")
@@ -67,6 +75,11 @@
'none'
>>> len(sale.invoices)
0
+ >>> bool(sale.to_invoice)
+ True
+ >>> line, = sale.lines
+ >>> line.quantity_to_invoice
+ 10.0
Manually create an invoice::
@@ -75,16 +88,26 @@
'processing'
>>> sale.invoice_state
'pending'
+ >>> bool(sale.to_invoice)
+ False
+ >>> line, = sale.lines
+ >>> line.quantity_to_invoice
+ 0.0
Change quantity on invoice and create a new invoice::
>>> invoice, = sale.invoices
>>> line, = invoice.lines
>>> line.quantity = 5
- >>> invoice.save()
+ >>> invoice.click('post')
+ >>> invoice.state
+ 'posted'
+ >>> sale.reload()
>>> len(sale.invoices)
1
+ >>> bool(sale.to_invoice)
+ True
>>> sale.click('manual_invoice')
>>> len(sale.invoices)
2
diff -r 4d2bb29276c1 -r b8720f474575
modules/sale/tests/scenario_sale_manual_shipment.rst
--- a/modules/sale/tests/scenario_sale_manual_shipment.rst Wed Feb 25
11:37:37 2026 +0100
+++ b/modules/sale/tests/scenario_sale_manual_shipment.rst Fri Mar 27
19:32:32 2026 +0100
@@ -53,6 +53,11 @@
'processing'
>>> len(sale.shipments)
0
+ >>> bool(sale.to_ship)
+ True
+ >>> line, = sale.lines
+ >>> line.quantity_to_ship
+ 10.0
Manually create a shipment::
@@ -61,16 +66,29 @@
'processing'
>>> sale.shipment_state
'waiting'
+ >>> bool(sale.to_ship)
+ False
+ >>> line, = sale.lines
+ >>> line.quantity_to_ship
+ 0.0
Change quantity on shipment and create a new shipment::
>>> shipment, = sale.shipments
- >>> move, = shipment.outgoing_moves
+ >>> move, = shipment.inventory_moves
>>> move.quantity = 5
- >>> shipment.save()
+ >>> shipment.click('assign_force')
+ >>> shipment.click('pick')
+ >>> shipment.click('pack')
+ >>> shipment.click('ship')
+ >>> shipment.state
+ 'shipped'
+ >>> sale.reload()
>>> len(sale.shipments)
1
+ >>> bool(sale.to_ship)
+ True
>>> sale.click('manual_shipment')
>>> len(sale.shipments)
2
diff -r 4d2bb29276c1 -r b8720f474575 modules/sale/view/sale_line_tree.xml
--- a/modules/sale/view/sale_line_tree.xml Wed Feb 25 11:37:37 2026 +0100
+++ b/modules/sale/view/sale_line_tree.xml Fri Mar 27 19:32:32 2026 +0100
@@ -10,6 +10,8 @@
<field name="summary" expand="1" optional="1"/>
<field name="actual_quantity" symbol="unit" optional="0"/>
<field name="quantity" symbol="unit" optional="0"/>
+ <field name="quantity_to_ship" symbol="unit" optional="1"/>
+ <field name="quantity_to_invoice" symbol="unit" optional="1"/>
<field name="unit_price"/>
<field name="amount"/>
<field name="moves_progress" string="Shipping" widget="progressbar"
optional="1"/>
diff -r 4d2bb29276c1 -r b8720f474575
modules/sale/view/sale_line_tree_sequence.xml
--- a/modules/sale/view/sale_line_tree_sequence.xml Wed Feb 25 11:37:37
2026 +0100
+++ b/modules/sale/view/sale_line_tree_sequence.xml Fri Mar 27 19:32:32
2026 +0100
@@ -7,6 +7,8 @@
<field name="product" expand="1" optional="0"/>
<field name="summary" expand="1" optional="1"/>
<field name="quantity" symbol="unit"/>
+ <field name="quantity_to_ship" symbol="unit" optional="1"/>
+ <field name="quantity_to_invoice" symbol="unit" optional="1"/>
<field name="unit_price"/>
<field name="taxes" optional="0"/>
<field name="amount"/>
diff -r 4d2bb29276c1 -r b8720f474575 modules/sale/view/sale_tree.xml
--- a/modules/sale/view/sale_tree.xml Wed Feb 25 11:37:37 2026 +0100
+++ b/modules/sale/view/sale_tree.xml Fri Mar 27 19:32:32 2026 +0100
@@ -13,5 +13,7 @@
<field name="description" expand="1" optional="1"/>
<field name="state"/>
<field name="invoice_state"/>
+ <field name="to_invoice" optional="1"/>
<field name="shipment_state"/>
+ <field name="to_ship" optional="1"/>
</tree>