Calculate the quantities to ship and to invoice for sale and purchase

This commit is contained in:
Cédric Krier
2026-03-27 19:32:32 +01:00
parent 905a2b20ba
commit a49aa67d73
14 changed files with 194 additions and 16 deletions
+23
View File
@@ -75,6 +75,22 @@ class Line(order_line_mixin('sale'), metaclass=PoolMeta):
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 @@ class LineComponent(
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()
+1
View File
@@ -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
+28 -4
View File
@@ -199,6 +199,7 @@ class Purchase(
('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 @@ class Purchase(
},
'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 @@ class Purchase(
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 @@ class Purchase(
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 @@ class Purchase(
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 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
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 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
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 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
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 @@ class Line(sequence_ordered(), ModelSQL, ModelView):
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)
@@ -4,13 +4,19 @@ Purchase Manual Invoice Scenario
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 @@ Get accounts::
>>> 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 @@ Purchase with manual invoice method::
'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 @@ Manually create an invoice::
'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
@@ -11,6 +11,7 @@ this repository contains the full copyright notices and license terms. -->
<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"/>
@@ -8,6 +8,7 @@ this repository contains the full copyright notices and license terms. -->
<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"/>
+1
View File
@@ -13,5 +13,6 @@ this repository contains the full copyright notices and license terms. -->
<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>
+1
View File
@@ -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
+58 -6
View File
@@ -256,6 +256,7 @@ class Sale(
('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 @@ class Sale(
('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 @@ class Sale(
},
'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 @@ class Sale(
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 @@ class Sale(
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 @@ class Sale(
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 @@ class SaleLine(TaxableMixin, sequence_ordered(), ModelSQL, ModelView):
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 @@ class SaleLine(TaxableMixin, sequence_ordered(), ModelSQL, ModelView):
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 @@ class SaleLine(TaxableMixin, sequence_ordered(), ModelSQL, ModelView):
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 @@ class SaleLine(TaxableMixin, sequence_ordered(), ModelSQL, ModelView):
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)
@@ -7,7 +7,10 @@ Imports::
>>> 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 @@ Get accounts::
>>> 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 @@ Sale with manual invoice method::
'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 @@ Manually create an invoice::
'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
@@ -53,6 +53,11 @@ Sale with manual shipment method::
'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 @@ Manually create a shipment::
'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
+2
View File
@@ -10,6 +10,8 @@ this repository contains the full copyright notices and license terms. -->
<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"/>
@@ -7,6 +7,8 @@ this repository contains the full copyright notices and license terms. -->
<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"/>
+2
View File
@@ -13,5 +13,7 @@ this repository contains the full copyright notices and license terms. -->
<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>