Add counter to controller

This commit is contained in:
2026-04-06 09:03:10 +02:00
parent 1757075f2b
commit 199b8aec12
4 changed files with 201 additions and 29 deletions

View File

@@ -291,6 +291,27 @@ Owner technique: `a completer`
- les lignes existantes ne sont pas modifiees retroactivement - les lignes existantes ne sont pas modifiees retroactivement
- Priorite: - Priorite:
- `importante` - `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: - Resultat attendu:
- apres creation du lot virtuel, si aucun matching purchase n'existe: - apres creation du lot virtuel, si aucun matching purchase n'existe:
- appeler `Valuation.generate_from_sale_line(line)` - appeler `Valuation.generate_from_sale_line(line)`

View File

@@ -7,17 +7,71 @@ from decimal import getcontext, Decimal, ROUND_HALF_UP
from sql import Table from sql import Table
from trytond.pyson import Bool, Eval, Id, If from trytond.pyson import Bool, Eval, Id, If
class PartyExecution(ModelSQL,ModelView): class PartyExecution(ModelSQL,ModelView):
"Party Execution" "Party Execution"
__name__ = 'party.execution' __name__ = 'party.execution'
party = fields.Many2One('party.party',"Party") party = fields.Many2One('party.party',"Party")
area = fields.Many2One('country.region',"Area") area = fields.Many2One('country.region',"Area")
percent = fields.Numeric("% targeted") percent = fields.Numeric("% targeted")
achieved_percent = fields.Function(fields.Numeric("% achieved"),'get_percent') achieved_percent = fields.Function(fields.Numeric("% achieved"),'get_percent')
def get_percent(self,name): @staticmethod
return 2 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): class PartyExecutionSla(ModelSQL,ModelView):
"Party Execution Sla" "Party Execution Sla"
@@ -55,8 +109,8 @@ class PartyExecutionPlace(ModelSQL,ModelView):
'readonly': Eval('mode') != 'ppack', 'readonly': Eval('mode') != 'ppack',
}) })
class Party(metaclass=PoolMeta): class Party(metaclass=PoolMeta):
__name__ = 'party.party' __name__ = 'party.party'
tol_min = fields.Numeric("Tol - in %") tol_min = fields.Numeric("Tol - in %")
tol_max = fields.Numeric("Tol + in %") tol_max = fields.Numeric("Tol + in %")
@@ -65,13 +119,25 @@ class Party(metaclass=PoolMeta):
origin =fields.Char("Origin") origin =fields.Char("Origin")
execution = fields.One2Many('party.execution','party',"") execution = fields.One2Many('party.execution','party',"")
sla = fields.One2Many('party.execution.sla','party', "Sla") sla = fields.One2Many('party.execution.sla','party', "Sla")
initial = fields.Char("Initials") initial = fields.Char("Initials")
def IsAvailableForControl(self,sh): def IsAvailableForControl(self,sh):
return True return True
def get_sla_cost(self,location): def get_controller_execution_priority(self, shipment):
if self.sla: 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: for sla in self.sla:
SlaPlace = Pool().get('party.execution.place') SlaPlace = Pool().get('party.execution.place')
sp = SlaPlace.search([('pes','=', sla.id),('location','=',location)]) sp = SlaPlace.search([('pes','=', sla.id),('location','=',location)])
@@ -111,4 +177,4 @@ class Party(metaclass=PoolMeta):
pc.category = cat[0].id pc.category = cat[0].id
PartyCategory.save([pc]) PartyCategory.save([pc])
return p return p

View File

@@ -487,16 +487,26 @@ class ShipmentIn(metaclass=PoolMeta):
fee.price = price fee.price = price
Fee.save([fee]) Fee.save([fee])
def get_controller(self): def get_controller(self):
ControllerCategory = Pool().get('party.category') ControllerCategory = Pool().get('party.category')
PartyCategory = Pool().get('party.party-party.category') PartyCategory = Pool().get('party.party-party.category')
cc = ControllerCategory.search(['name','=','CONTROLLER']) cc = ControllerCategory.search(['name','=','CONTROLLER'])
if cc: if cc:
cc = cc[0] cc = cc[0]
controllers = PartyCategory.search(['category','=',cc.id]) controllers = PartyCategory.search(['category','=',cc.id])
for c in controllers: prioritized = []
if c.party.IsAvailableForControl(self): for c in controllers:
return c.party 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): def get_instructions_html(self,inv_date,inv_nb):
vessel = self.vessel.vessel_name if self.vessel else "" vessel = self.vessel.vessel_name if self.vessel else ""

View File

@@ -184,6 +184,81 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual( self.assertEqual(
PurchaseLine.default_pricing_rule(), 'Default pricing rule') 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): def test_sale_report_multi_line_helpers_aggregate_all_lines(self):
'sale report helpers aggregate quantity, price lines and shipment periods' 'sale report helpers aggregate quantity, price lines and shipment periods'
Sale = Pool().get('sale.sale') Sale = Pool().get('sale.sale')