From ea4782565a728d44145bde1c2a59096cc4eca66e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Tue, 6 Aug 2024 08:24:46 +0200 Subject: [PATCH] Store internal measurements for final states of shipments This prevents the searcher to compute the sum for all the shipments with a join to all the stock moves. Closes #13438 --- modules/stock_shipment_measurements/CHANGELOG | 1 + modules/stock_shipment_measurements/stock.py | 228 ++++++++++++++++-- .../tests/test_module.py | 25 +- 3 files changed, 237 insertions(+), 17 deletions(-) diff --git a/modules/stock_shipment_measurements/CHANGELOG b/modules/stock_shipment_measurements/CHANGELOG index 4afddd8031..5629729323 100644 --- a/modules/stock_shipment_measurements/CHANGELOG +++ b/modules/stock_shipment_measurements/CHANGELOG @@ -1,3 +1,4 @@ +* Store internal measurements for final states of shipments Version 7.2.0 - 2024-04-29 -------------------------- diff --git a/modules/stock_shipment_measurements/stock.py b/modules/stock_shipment_measurements/stock.py index f3c40875ab..e47badeeeb 100644 --- a/modules/stock_shipment_measurements/stock.py +++ b/modules/stock_shipment_measurements/stock.py @@ -1,12 +1,15 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. from collections import defaultdict +from functools import wraps from itertools import groupby +from sql import Literal, Null from sql.aggregate import Sum +from sql.conditionals import Coalesce from sql.operators import Concat -from trytond.model import ModelSQL, fields +from trytond.model import Index, ModelSQL, ModelView, Workflow, fields from trytond.modules.company.model import CompanyValueMixin from trytond.pool import Pool, PoolMeta from trytond.pyson import Bool, Eval, Id @@ -180,6 +183,7 @@ class MeasurementsMixin(object): }, help="The total weight of the record's moves."), 'get_measurements', searcher='search_measurements') + internal_weight = fields.Float("Internal Weight", readonly=True) weight_uom = fields.Function( fields.Many2One( 'product.uom', "Weight UoM", @@ -193,12 +197,55 @@ class MeasurementsMixin(object): }, help="The total volume of the record's moves."), 'get_measurements', searcher='search_measurements') + internal_volume = fields.Float("Internal Volume", readonly=True) volume_uom = fields.Function( fields.Many2One( 'product.uom', "Volume UoM", help="The Unit of Measure of volume."), 'get_measurements_uom') + @classmethod + def __setup__(cls): + super().__setup__() + + t = cls.__table__() + states = cls._measurements_states() + if states: + cls._sql_indexes.update({ + Index( + t, + (t.id, Index.Range()), + where=(~t.state.in_(states) + | (t.internal_weight == Null) + | (t.internal_volume == Null))), + Index(t, + (t.internal_weight, Index.Range()), + where=(t.state.in_(states) + & (t.internal_weight != Null) + & (t.internal_volume != Null))), + Index(t, + (t.internal_volume, Index.Range()), + where=(t.state.in_(states) + & (t.internal_weight != Null) + & (t.internal_volume != Null))), + }) + + @classmethod + def __register__(cls, module): + table_h = cls.__table_handler__(module) + set_measurements = ( + not table_h.column_exist('internal_weight') + or not table_h.column_exist('internal_volume')) + + super().__register__(module) + + if set_measurements: + cls.set_measurements() + + @classmethod + def _measurements_states(cls): + return [] + @classmethod def get_measurements_uom(cls, shipments, name): pool = Pool() @@ -212,6 +259,50 @@ class MeasurementsMixin(object): uoms[shipment.id] = uom.id return uoms + @classmethod + def set_measurements(cls, shipments=None): + pool = Pool() + Location = pool.get('stock.location') + Move = pool.get('stock.move') + + cursor = Transaction().connection.cursor() + table = cls.__table__() + move = Move.__table__() + location = Location.__table__() + + if not (states := cls._measurements_states()): + return + + query = (table.update( + [table.internal_weight, table.internal_volume], + [move + .join(location, + condition=cls._measurements_location_condition( + table, move, location)) + .select( + Sum(Coalesce(move.internal_weight, 0)), + where=cls._measurements_move_condition(table, move)), + move + .join(location, + condition=cls._measurements_location_condition( + table, move, location)) + .select( + Sum(Coalesce(move.internal_volume, 0)), + where=cls._measurements_move_condition(table, move))], + where=table.state.in_(states))) + + if shipments: + where = query.where + for sub_shipments in grouped_slice(shipments): + query.where = ( + where & reduce_ids(table.id, map(int, sub_shipments))) + cursor.execute(*query) + else: + cursor.execute(*query) + + if shipments: + cls.write(shipments, {}) + @classmethod def get_measurements(cls, shipments, names): pool = Pool() @@ -238,23 +329,35 @@ class MeasurementsMixin(object): table, move, location) ).select( table.id, - Sum(move.internal_weight), - Sum(move.internal_volume), + Sum(Coalesce(move.internal_weight, 0)), + Sum(Coalesce(move.internal_volume, 0)), group_by=[table.id]) - id2shipment = {s.id: s for s in shipments} - for sub_shipments in grouped_slice(shipments): + weights, volumes = {}, {} + states = cls._measurements_states() + for sub_shipments in grouped_slice( + (s for s in shipments + if s.state not in states + or s.internal_weight is None + or s.internal_volume is None)): query.where = reduce_ids( table.id, [s.id for s in sub_shipments]) cursor.execute(*query) for id_, weight, volume in cursor: - shipment = id2shipment[id_] - if 'weight' in names: - measurements['weight'][id_] = Uom.compute_qty( - kg, weight, shipment.weight_uom) - if 'volume' in names: - measurements['volume'][id_] = Uom.compute_qty( - liter, volume, shipment.volume_uom) + weights[id_] = weight + volumes[id_] = volume + + for shipment in shipments: + if 'weight' in names: + weight = weights.get( + shipment.id, shipment.internal_weight) + measurements['weight'][shipment.id] = Uom.compute_qty( + kg, weight, shipment.weight_uom) + if 'volume' in names: + volume = volumes.get( + shipment.id, shipment.internal_volume) + measurements['volume'][shipment.id] = Uom.compute_qty( + liter, volume, shipment.volume_uom) return measurements @classmethod @@ -276,7 +379,8 @@ class MeasurementsMixin(object): Operator = fields.SQL_OPERATORS[operator] if name == 'weight': - measurement = Sum(move.internal_weight) + measurement = Sum(Coalesce(move.internal_weight, 0)) + column = table.internal_weight if value is not None: kg = Uom(ModelData.get_id('product', 'uom_kilogram')) if isinstance(value, (int, float)): @@ -287,7 +391,8 @@ class MeasurementsMixin(object): if v is not None else v for v in value] else: - measurement = Sum(move.internal_volume) + measurement = Sum(Coalesce(move.internal_volume, 0)) + column = table.internal_volume if value is not None: liter = Uom(ModelData.get_id('product', 'uom_liter')) if isinstance(value, (int, float)): @@ -298,6 +403,11 @@ class MeasurementsMixin(object): if v is not None else v for v in value] + if states := cls._measurements_states(): + state_clause = table.state.in_(states) + else: + state_clause = Literal(False) + query = table.join( move, type_='LEFT', condition=cls._measurements_move_condition(table, move) @@ -307,8 +417,17 @@ class MeasurementsMixin(object): table, move, location) ).select( table.id, + where=~state_clause + | (table.internal_weight == Null) + | (table.internal_volume == Null), group_by=[table.id], having=Operator(measurement, value)) + query |= table.select( + table.id, + where=state_clause + & (table.internal_weight != Null) + & (table.internal_volume != Null) + & Operator(column, value)) return [('id', 'in', query)] @classmethod @@ -320,27 +439,76 @@ class MeasurementsMixin(object): raise NotImplementedError +def set_measurements(func): + @wraps(func) + def wrapper(cls, shipments, *args, **kwargs): + result = func(cls, shipments, *args, **kwargs) + cls.set_measurements(shipments) + return result + return wrapper + + class ShipmentIn(MeasurementsMixin, object, metaclass=PoolMeta): __name__ = 'stock.shipment.in' + @classmethod + def _measurements_states(cls): + return super()._measurements_states() + ['done', 'cancelled'] + @classmethod def _measurements_location_condition(cls, shipment, move, location): return ( (move.from_location == location.id) & (location.type == 'supplier')) + @classmethod + @ModelView.button + @set_measurements + @Workflow.transition('done') + def do(cls, shipments): + super().do(shipments) + + @classmethod + @ModelView.button + @set_measurements + @Workflow.transition('cancelled') + def cancel(cls, shipments): + super().cancel(shipments) + class ShipmentInReturn(MeasurementsMixin, object, metaclass=PoolMeta): __name__ = 'stock.shipment.in.return' + @classmethod + def _measurements_states(cls): + return super()._measurements_states() + ['done', 'cancelled'] + @classmethod def _measurements_location_condition(cls, shipment, move, location): return move.from_location == location.id + @classmethod + @ModelView.button + @set_measurements + @Workflow.transition('done') + def do(cls, shipments): + super().do(shipments) + + @classmethod + @ModelView.button + @set_measurements + @Workflow.transition('cancelled') + def cancel(cls, shipments): + super().cancel(shipments) + class ShipmentOut(MeasurementsMixin, object, metaclass=PoolMeta): __name__ = 'stock.shipment.out' + @classmethod + def _measurements_states(cls): + return super()._measurements_states() + ['done', 'cancelled'] + @classmethod def _measurements_location_condition(cls, shipment, move, location): return ( @@ -364,16 +532,48 @@ class ShipmentOut(MeasurementsMixin, object, metaclass=PoolMeta): round=False) return weight + @classmethod + @ModelView.button + @set_measurements + @Workflow.transition('done') + def do(cls, shipments): + super().do(shipments) + + @classmethod + @ModelView.button + @set_measurements + @Workflow.transition('cancelled') + def cancel(cls, shipments): + super().cancel(shipments) + class ShipmentOutReturn(MeasurementsMixin, object, metaclass=PoolMeta): __name__ = 'stock.shipment.out.return' + @classmethod + def _measurements_states(cls): + return super()._measurements_states() + ['done', 'cancelled'] + @classmethod def _measurements_location_condition(cls, shipment, move, location): return ( (move.from_location == location.id) & (location.type == 'customer')) + @classmethod + @ModelView.button + @set_measurements + @Workflow.transition('done') + def do(cls, shipments): + super().do(shipments) + + @classmethod + @ModelView.button + @set_measurements + @Workflow.transition('cancelled') + def cancel(cls, shipments): + super().cancel(shipments) + # TODO ShipmentInternal diff --git a/modules/stock_shipment_measurements/tests/test_module.py b/modules/stock_shipment_measurements/tests/test_module.py index 528fb53b27..a481d1bedd 100644 --- a/modules/stock_shipment_measurements/tests/test_module.py +++ b/modules/stock_shipment_measurements/tests/test_module.py @@ -130,7 +130,6 @@ class StockShipmentMeasurementsTestCase(CompanyTestMixin, ModuleTestCase): kg, = Uom.search([('name', '=', 'Kilogram')]) liter, = Uom.search([('name', '=', 'Liter')]) customer, = Location.search([('code', '=', 'CUS')]) - storage, = Location.search([('code', '=', 'STO')]) party = Party(name='Customer') party.addresses = [{}] party.save() @@ -166,7 +165,7 @@ class StockShipmentMeasurementsTestCase(CompanyTestMixin, ModuleTestCase): product=product, unit=kg, quantity=10, - from_location=storage, + from_location=shipment.warehouse.output_location, to_location=customer, company=company, unit_price=Decimal(1), @@ -179,6 +178,7 @@ class StockShipmentMeasurementsTestCase(CompanyTestMixin, ModuleTestCase): self.assertEqual(shipment.volume, 2) Shipment.wait([shipment]) + Shipment.pick([shipment]) # with inventory moves self.assertEqual(shipment.weight, 10) @@ -207,7 +207,7 @@ class StockShipmentMeasurementsTestCase(CompanyTestMixin, ModuleTestCase): package = Package() package.type = package_type package.shipment = shipment - package.moves = shipment.moves + package.moves = shipment.outgoing_moves package.parent = package_root package.save() @@ -223,5 +223,24 @@ class StockShipmentMeasurementsTestCase(CompanyTestMixin, ModuleTestCase): self.assertEqual(package.total_weight, 10) self.assertEqual(package.total_volume, 2) + Shipment.pack([shipment]) + Shipment.do([shipment]) + + self.assertEqual(shipment.internal_weight, 10) + self.assertEqual(shipment.internal_volume, 2) + self.assertEqual(shipment.weight, 10) + self.assertEqual(shipment.volume, 2) + + for clause, result in [ + ([('weight', '=', 10)], [shipment]), + ([('weight', '=', 5)], []), + ([('weight', 'in', [10, 5])], [shipment]), + ([('volume', '=', 2)], [shipment]), + ([('volume', '=', 3)], []), + ([('volume', 'in', [2, 3])], [shipment]), + ]: + msg = 'clause: %s' % clause + self.assertEqual(Shipment.search(clause), result, msg=msg) + del ModuleTestCase