diff --git a/modules/product_kit/sale.py b/modules/product_kit/sale.py
index 213fe5812b..d1fc6f7183 100644
--- a/modules/product_kit/sale.py
+++ b/modules/product_kit/sale.py
@@ -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()
diff --git a/modules/purchase/CHANGELOG b/modules/purchase/CHANGELOG
index 9a36c0b6cf..466a5b338a 100644
--- a/modules/purchase/CHANGELOG
+++ b/modules/purchase/CHANGELOG
@@ -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 --git a/modules/purchase/purchase.py b/modules/purchase/purchase.py
index a8b300bca0..2719f0334f 100644
--- a/modules/purchase/purchase.py
+++ b/modules/purchase/purchase.py
@@ -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)
diff --git a/modules/purchase/tests/scenario_purchase_manual_invoice.rst b/modules/purchase/tests/scenario_purchase_manual_invoice.rst
index 7fe09eb2f4..1f36ea999e 100644
--- a/modules/purchase/tests/scenario_purchase_manual_invoice.rst
+++ b/modules/purchase/tests/scenario_purchase_manual_invoice.rst
@@ -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
diff --git a/modules/purchase/view/purchase_line_tree.xml b/modules/purchase/view/purchase_line_tree.xml
index a524918a36..50a87e5fd4 100644
--- a/modules/purchase/view/purchase_line_tree.xml
+++ b/modules/purchase/view/purchase_line_tree.xml
@@ -11,6 +11,7 @@ this repository contains the full copyright notices and license terms. -->
+
diff --git a/modules/purchase/view/purchase_line_tree_sequence.xml b/modules/purchase/view/purchase_line_tree_sequence.xml
index 4be45f4e54..898fca8cae 100644
--- a/modules/purchase/view/purchase_line_tree_sequence.xml
+++ b/modules/purchase/view/purchase_line_tree_sequence.xml
@@ -8,6 +8,7 @@ this repository contains the full copyright notices and license terms. -->
+
diff --git a/modules/purchase/view/purchase_tree.xml b/modules/purchase/view/purchase_tree.xml
index 77cde61ac4..2985899ed6 100644
--- a/modules/purchase/view/purchase_tree.xml
+++ b/modules/purchase/view/purchase_tree.xml
@@ -13,5 +13,6 @@ this repository contains the full copyright notices and license terms. -->
+
diff --git a/modules/sale/CHANGELOG b/modules/sale/CHANGELOG
index ce7d5e16a0..2dfad02cda 100644
--- a/modules/sale/CHANGELOG
+++ b/modules/sale/CHANGELOG
@@ -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 --git a/modules/sale/sale.py b/modules/sale/sale.py
index 76a0e938f8..2b115f5e60 100644
--- a/modules/sale/sale.py
+++ b/modules/sale/sale.py
@@ -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)
diff --git a/modules/sale/tests/scenario_sale_manual_invoice.rst b/modules/sale/tests/scenario_sale_manual_invoice.rst
index 46c1e50724..7f587d09be 100644
--- a/modules/sale/tests/scenario_sale_manual_invoice.rst
+++ b/modules/sale/tests/scenario_sale_manual_invoice.rst
@@ -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
diff --git a/modules/sale/tests/scenario_sale_manual_shipment.rst b/modules/sale/tests/scenario_sale_manual_shipment.rst
index 89e4a27853..410a5d51c4 100644
--- a/modules/sale/tests/scenario_sale_manual_shipment.rst
+++ b/modules/sale/tests/scenario_sale_manual_shipment.rst
@@ -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
diff --git a/modules/sale/view/sale_line_tree.xml b/modules/sale/view/sale_line_tree.xml
index c83a1994ce..2d4b2765b0 100644
--- a/modules/sale/view/sale_line_tree.xml
+++ b/modules/sale/view/sale_line_tree.xml
@@ -10,6 +10,8 @@ this repository contains the full copyright notices and license terms. -->
+
+
diff --git a/modules/sale/view/sale_line_tree_sequence.xml b/modules/sale/view/sale_line_tree_sequence.xml
index cd963ded52..469a9e3334 100644
--- a/modules/sale/view/sale_line_tree_sequence.xml
+++ b/modules/sale/view/sale_line_tree_sequence.xml
@@ -7,6 +7,8 @@ this repository contains the full copyright notices and license terms. -->
+
+
diff --git a/modules/sale/view/sale_tree.xml b/modules/sale/view/sale_tree.xml
index a86d53accc..3d6d066587 100644
--- a/modules/sale/view/sale_tree.xml
+++ b/modules/sale/view/sale_tree.xml
@@ -13,5 +13,7 @@ this repository contains the full copyright notices and license terms. -->
+
+