From 199b8aec1219eb46d0cfb53e9ddb3c676afb2287 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Mon, 6 Apr 2026 09:03:10 +0200 Subject: [PATCH] Add counter to controller --- modules/purchase_trade/docs/business-rules.md | 21 ++++ modules/purchase_trade/party.py | 104 ++++++++++++++---- modules/purchase_trade/stock.py | 30 +++-- modules/purchase_trade/tests/test_module.py | 75 +++++++++++++ 4 files changed, 201 insertions(+), 29 deletions(-) diff --git a/modules/purchase_trade/docs/business-rules.md b/modules/purchase_trade/docs/business-rules.md index 2515b24..eb1c3fb 100644 --- a/modules/purchase_trade/docs/business-rules.md +++ b/modules/purchase_trade/docs/business-rules.md @@ -291,6 +291,27 @@ Owner technique: `a completer` - les lignes existantes ne sont pas modifiees retroactivement - Priorite: - `importante` + +### BR-PT-014 - L'affectation d'un controller doit suivre l'ecart a l'objectif regional + +- Intent: repartir les controllers selon les cibles definies dans l'onglet + `Execution` des `party.party`. +- Description: + - chaque ligne `party.execution` fixe une cible `% targeted` pour un + controller sur une `country.region` + - le `% achieved` est calcule a partir des `stock.shipment.in` deja affectes + a un controller dans cette zone + - la zone d'un shipment est determinee par `shipment.to_location.country` + - une region parente couvre aussi ses sous-regions +- Resultat attendu: + - pour une ligne `party.execution`, `achieved_percent` = + `shipments de la zone avec ce controller / shipments controles de la zone` + - lors d'un choix automatique de controller, la priorite va a la regle dont + l'ecart `targeted - achieved` est le plus eleve + - un controller a `80%` cible et `40%` reel doit donc passer avant un + controller a `50%` cible et `45%` reel sur la meme zone +- Priorite: + - `importante` - Resultat attendu: - apres creation du lot virtuel, si aucun matching purchase n'existe: - appeler `Valuation.generate_from_sale_line(line)` diff --git a/modules/purchase_trade/party.py b/modules/purchase_trade/party.py index 124313a..e51c6e8 100755 --- a/modules/purchase_trade/party.py +++ b/modules/purchase_trade/party.py @@ -7,17 +7,71 @@ from decimal import getcontext, Decimal, ROUND_HALF_UP from sql import Table from trytond.pyson import Bool, Eval, Id, If -class PartyExecution(ModelSQL,ModelView): - "Party Execution" - __name__ = 'party.execution' +class PartyExecution(ModelSQL,ModelView): + "Party Execution" + __name__ = 'party.execution' party = fields.Many2One('party.party',"Party") - area = fields.Many2One('country.region',"Area") - percent = fields.Numeric("% targeted") - achieved_percent = fields.Function(fields.Numeric("% achieved"),'get_percent') - - def get_percent(self,name): - return 2 + area = fields.Many2One('country.region',"Area") + percent = fields.Numeric("% targeted") + achieved_percent = fields.Function(fields.Numeric("% achieved"),'get_percent') + + @staticmethod + def _to_decimal(value): + if value is None: + return Decimal('0') + if isinstance(value, Decimal): + return value + return Decimal(str(value)) + + @classmethod + def _round_percent(cls, value): + return cls._to_decimal(value).quantize( + Decimal('0.01'), rounding=ROUND_HALF_UP) + + def matches_country(self, country): + if not self.area or not country or not getattr(country, 'region', None): + return False + region = country.region + while region: + if region.id == self.area.id: + return True + region = getattr(region, 'parent', None) + return False + + def matches_shipment(self, shipment): + location = getattr(shipment, 'to_location', None) + country = getattr(location, 'country', None) + return self.matches_country(country) + + @classmethod + def compute_achieved_percent_for(cls, party, area): + if not party or not area: + return Decimal('0') + Shipment = Pool().get('stock.shipment.in') + shipments = Shipment.search([ + ('controller', '!=', None), + ('to_location.country', '!=', None), + ('to_location.country.region', 'child_of', [area.id]), + ]) + total = len(shipments) + if not total: + return Decimal('0') + achieved = sum( + 1 for shipment in shipments + if shipment.controller and shipment.controller.id == party.id) + return cls._round_percent( + (Decimal(achieved) * Decimal('100')) / Decimal(total)) + + def compute_achieved_percent(self): + return self.__class__.compute_achieved_percent_for( + self.party, self.area) + + def get_target_gap(self): + return self._to_decimal(self.percent) - self.compute_achieved_percent() + + def get_percent(self,name): + return self.compute_achieved_percent() class PartyExecutionSla(ModelSQL,ModelView): "Party Execution Sla" @@ -55,8 +109,8 @@ class PartyExecutionPlace(ModelSQL,ModelView): 'readonly': Eval('mode') != 'ppack', }) -class Party(metaclass=PoolMeta): - __name__ = 'party.party' +class Party(metaclass=PoolMeta): + __name__ = 'party.party' tol_min = fields.Numeric("Tol - in %") tol_max = fields.Numeric("Tol + in %") @@ -65,13 +119,25 @@ class Party(metaclass=PoolMeta): origin =fields.Char("Origin") execution = fields.One2Many('party.execution','party',"") sla = fields.One2Many('party.execution.sla','party', "Sla") - initial = fields.Char("Initials") - - def IsAvailableForControl(self,sh): - return True - - def get_sla_cost(self,location): - if self.sla: + initial = fields.Char("Initials") + + def IsAvailableForControl(self,sh): + return True + + def get_controller_execution_priority(self, shipment): + best_rule = None + best_gap = None + for execution in self.execution or []: + if not execution.matches_shipment(shipment): + continue + gap = execution.get_target_gap() + if best_gap is None or gap > best_gap: + best_gap = gap + best_rule = execution + return best_gap, best_rule + + def get_sla_cost(self,location): + if self.sla: for sla in self.sla: SlaPlace = Pool().get('party.execution.place') sp = SlaPlace.search([('pes','=', sla.id),('location','=',location)]) @@ -111,4 +177,4 @@ class Party(metaclass=PoolMeta): pc.category = cat[0].id PartyCategory.save([pc]) return p - \ No newline at end of file + diff --git a/modules/purchase_trade/stock.py b/modules/purchase_trade/stock.py index 9607399..432f2de 100755 --- a/modules/purchase_trade/stock.py +++ b/modules/purchase_trade/stock.py @@ -487,16 +487,26 @@ class ShipmentIn(metaclass=PoolMeta): fee.price = price Fee.save([fee]) - def get_controller(self): - ControllerCategory = Pool().get('party.category') - PartyCategory = Pool().get('party.party-party.category') - cc = ControllerCategory.search(['name','=','CONTROLLER']) - if cc: - cc = cc[0] - controllers = PartyCategory.search(['category','=',cc.id]) - for c in controllers: - if c.party.IsAvailableForControl(self): - return c.party + def get_controller(self): + ControllerCategory = Pool().get('party.category') + PartyCategory = Pool().get('party.party-party.category') + cc = ControllerCategory.search(['name','=','CONTROLLER']) + if cc: + cc = cc[0] + controllers = PartyCategory.search(['category','=',cc.id]) + prioritized = [] + for c in controllers: + if not c.party.IsAvailableForControl(self): + continue + gap, rule = c.party.get_controller_execution_priority(self) + prioritized.append(( + 1 if rule else 0, + gap if gap is not None else Decimal('-999999'), + c.party, + )) + if prioritized: + prioritized.sort(key=lambda item: (item[0], item[1]), reverse=True) + return prioritized[0][2] def get_instructions_html(self,inv_date,inv_nb): vessel = self.vessel.vessel_name if self.vessel else "" diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 699249c..ae8e15c 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -184,6 +184,81 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual( PurchaseLine.default_pricing_rule(), 'Default pricing rule') + def test_party_execution_achieved_percent_uses_real_area_statistics(self): + 'party execution achieved percent reflects the controller share in its area' + PartyExecution = Pool().get('party.execution') + execution = PartyExecution() + execution.party = Mock(id=1) + execution.area = Mock(id=10) + + shipments = [ + Mock(controller=Mock(id=1)), + Mock(controller=Mock(id=2)), + Mock(controller=Mock(id=1)), + Mock(controller=Mock(id=2)), + Mock(controller=Mock(id=1)), + ] + shipment_model = Mock() + shipment_model.search.return_value = shipments + + with patch( + 'trytond.modules.purchase_trade.party.Pool' + ) as PoolMock: + PoolMock.return_value.get.return_value = shipment_model + + self.assertEqual( + execution.get_percent('achieved_percent'), + Decimal('60.00')) + + def test_get_controller_prioritizes_controller_farthest_from_target(self): + 'shipment controller selection prioritizes the most under-target rule' + Shipment = Pool().get('stock.shipment.in') + Party = Pool().get('party.party') + PartyExecution = Pool().get('party.execution') + + shipment = Shipment() + shipment.to_location = Mock( + country=Mock(region=Mock(id=20, parent=Mock(id=10, parent=None)))) + + party_a = Party() + party_a.id = 1 + rule_a = PartyExecution() + rule_a.party = party_a + rule_a.area = Mock(id=10) + rule_a.percent = Decimal('80') + rule_a.compute_achieved_percent = Mock(return_value=Decimal('40')) + party_a.execution = [rule_a] + + party_b = Party() + party_b.id = 2 + rule_b = PartyExecution() + rule_b.party = party_b + rule_b.area = Mock(id=10) + rule_b.percent = Decimal('50') + rule_b.compute_achieved_percent = Mock(return_value=Decimal('45')) + party_b.execution = [rule_b] + + category_model = Mock() + category_model.search.return_value = [Mock(id=99)] + party_category_model = Mock() + party_category_model.search.return_value = [ + Mock(party=party_b), + Mock(party=party_a), + ] + + with patch( + 'trytond.modules.purchase_trade.stock.Pool' + ) as PoolMock: + def get_model(name): + return { + 'party.category': category_model, + 'party.party-party.category': party_category_model, + }[name] + + PoolMock.return_value.get.side_effect = get_model + + self.assertIs(shipment.get_controller(), party_a) + def test_sale_report_multi_line_helpers_aggregate_all_lines(self): 'sale report helpers aggregate quantity, price lines and shipment periods' Sale = Pool().get('sale.sale')