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. --> + +