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>

Reply via email to