From 99d5d2a84994e9d84a1e588883ad2f5549b29fd2 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Tue, 30 Jun 2026 18:27:40 +0200 Subject: [PATCH] Price composition + Pnl open matched --- .../docs/coffee_user_guide.html | 494 ++++++++++++++++++ modules/purchase_trade/invoice.py | 85 ++- modules/purchase_trade/purchase.py | 2 +- modules/purchase_trade/sale.py | 7 +- modules/purchase_trade/tests/test_module.py | 138 ++++- modules/purchase_trade/valuation.py | 62 ++- 6 files changed, 765 insertions(+), 23 deletions(-) create mode 100644 modules/purchase_trade/docs/coffee_user_guide.html diff --git a/modules/purchase_trade/docs/coffee_user_guide.html b/modules/purchase_trade/docs/coffee_user_guide.html new file mode 100644 index 0000000..16dba1b --- /dev/null +++ b/modules/purchase_trade/docs/coffee_user_guide.html @@ -0,0 +1,494 @@ + + + + + + Guide utilisateur - Fonctionnalites coffee + + + +
+

Guide utilisateur - Fonctionnalites coffee

+

+ Ce guide explique comment activer et utiliser les extensions coffee du CTRM : + qualite cafe sur lignes d'achat et de vente, gestion des echantillons, + analyses laboratoire, sessions de cupping multi-tasses et decision qualite. +

+

Version documentee : implementation purchase_trade coffee, 30/06/2026.

+
+ +
+ + +
+

1. Activation du mode coffee

+

+ Les fonctionnalites coffee sont optionnelles. Elles n'apparaissent que si + l'option est activee dans la configuration trade. +

+
    +
  1. Ouvrir la configuration du module trade.
  2. +
  3. Aller dans le bloc Trade options.
  4. +
  5. Cocher Active coffee compatibility.
  6. +
  7. Enregistrer.
  8. +
+

Quand l'option est activee, les menus suivants deviennent disponibles sous Purchase et Sale :

+

+ Coffee / Samples + Coffee / Lab Analyses + Coffee / Cupping Sessions + Coffee / Cupping Criteria +

+
+ Quand l'option est desactivee, les menus Coffee et l'onglet Coffee Quality + des lignes purchase/sale sont retires de la vue. Les clients non coffee ne + voient donc pas les champs coffee. +
+
+ +
+

2. Onglet Coffee Quality sur les lignes purchase/sale

+

+ Sur une ligne d'achat ou une ligne de vente, l'onglet + Coffee Quality sert a definir le profil attendu + du cafe et les seuils de controle. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ZoneChamps principauxUtilisation
Resume qualiteCoffee quality, Coffee origin, Coffee type, Coffee processIdentifier l'origine et le type de cafe visibles dans les process d'echantillon et de cupping.
Identite cafeVariety, Crop year, Screen sizeCompleter les caracteristiques commerciales ou physiques du cafe cible.
Objectifs qualiteMoisture max %, Defects max, Cup score minDefinir les seuils utilises pour evaluer l'echantillon ou l'analyse labo.
Coffee samplesListe des echantillons lies a la ligneCreer ou consulter les echantillons rattaches au contrat d'achat ou de vente.
+

+ Le champ Coffee quality est calcule a partir des echantillons + rattaches a la ligne. Il peut indiquer notamment : aucun echantillon, en + attente, approuve, rejete, expire ou warning. +

+
+ +
+

3. Gestion des echantillons

+

+ Un echantillon peut etre cree depuis l'onglet Coffee Quality d'une ligne, + ou depuis le menu Coffee / Samples. Il peut etre + lie a une ligne purchase ou sale, a un lot, a un labo et a des resultats + de cupping. +

+ +

Informations a saisir

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChampRole
ReferenceIdentifiant interne ou externe de l'echantillon.
DirectionPurchase ou Sale. Si l'echantillon est cree depuis une ligne, la direction est deduite.
Sample TypeOffer, Pre-shipment, Shipment, Arrival, Stock, Customer ou Retained.
Requested Date / Received DateSuivi du cycle de vie. La date de demande est renseignee par defaut avec la date du jour.
Shelf life / Expiry DateDuree de vie de l'echantillon. Par defaut : 90 jours. La date d'expiration est calculee automatiquement si elle n'est pas saisie.
Delegated to lab, Lab, Lab Due DatePermet d'envoyer l'echantillon a un laboratoire et de suivre son delai.
Moisture, Screen size, Defects countResultats qualite saisis directement sur l'echantillon si l'analyse n'est pas deleguee.
+ +

Cycle de vie de l'echantillon

+

+ Les boutons de workflow permettent de faire avancer l'echantillon : +

+

+ Receive + Send to lab + Review + Approve + Reject + Evaluate quality + Expire + Archive + Reset +

+
    +
  • Receive passe l'echantillon en recu et renseigne la date de reception si elle est vide.
  • +
  • Send to lab marque l'echantillon comme delegue au labo et cree une analyse labo si un laboratoire est renseigne.
  • +
  • Review passe l'echantillon en revue qualite.
  • +
  • Approve ou Reject enregistrent la decision et la date de decision.
  • +
  • Evaluate quality applique automatiquement la decision recommandee selon les seuils et resultats disponibles.
  • +
  • Expire force l'etat expire, puis Archive permet de sortir l'echantillon du suivi actif.
  • +
+ +

Filtres utiles

+

La liste des echantillons contient des domaines prets a l'emploi :

+

+ Pending + Expiring soon + Expired + Approved + All +

+
+ Un echantillon est considere comme arrivant a expiration lorsque son + expiry date est dans les 14 prochains jours. Un echantillon expire reste + signale tant qu'il n'est pas archive ou rejete. +
+
+ +
+

4. Analyses laboratoire

+

+ Les analyses labo servent a deleguer le controle d'un echantillon a un + laboratoire externe ou interne. Elles sont accessibles depuis + Coffee / Lab Analyses ou depuis l'echantillon. +

+

Creation

+
    +
  1. Ouvrir l'echantillon.
  2. +
  3. Renseigner le champ Lab et, si necessaire, Lab Due Date.
  4. +
  5. Cliquer sur Send to lab.
  6. +
+

+ Le systeme cree une analyse labo active si aucune analyse active n'existe + deja pour cet echantillon. +

+ +

Suivi de l'analyse

+ + + + + + + + + + + + + + + + + + + + + + + + + +
BoutonEffet
SendRenseigne la date d'envoi et marque l'echantillon comme delegue au laboratoire.
ReceiveRenseigne la date de reception, puis place l'echantillon en revue qualite.
Accept / RejectValide ou rejette l'analyse labo.
Cancel / DraftAnnule ou remet l'analyse en brouillon.
+

+ Le champ Compliant est calcule en comparant les resultats + labo aux seuils de la ligne coffee : moisture max, defects max et cup + score min. +

+
+ +
+

5. Sessions de cupping

+

+ Une session de cupping permet de preparer une degustation en aveugle avec + plusieurs echantillons, plusieurs tasses par echantillon et plusieurs + personnes degustatrices. +

+ +

Parametrer les criteres de cupping

+

+ Dans Coffee / Cupping Criteria, creer les + criteres a noter : nom, sequence, type de critere, score maximum et + indication si le score doit etre soustrait. Ces criteres sont utilises + dans les lignes de resultat de cupping. +

+ +

Creer une session

+
    +
  1. Ouvrir Coffee / Cupping Sessions.
  2. +
  3. Creer une nouvelle session avec un nom, une date, un responsable et une salle.
  4. +
  5. Renseigner la capacite de la salle en nombre de tasses. Par defaut : 24.
  6. +
  7. Renseigner le nombre de tasses par echantillon. Par defaut : 3.
  8. +
  9. Ajouter les echantillons a deguster dans les lignes de session.
  10. +
+

+ Les compteurs Total cups et Remaining cups + indiquent le nombre de tasses prevues et la capacite restante. +

+ +

Preparer l'ordre et les codes aveugles

+

+ Le bouton Prepare controle la capacite de la salle, trie les + echantillons et genere les tasses manquantes. +

+
    +
  • L'ordre propose commence par les cafes les plus doux : arabica, puis blend/other, puis robusta.
  • +
  • Le tri tient aussi compte de l'origine et de la reference echantillon.
  • +
  • Chaque tasse recoit un code aveugle de type S01-C1, S01-C2, etc.
  • +
  • Les degustateurs voient l'origine et le type de cafe, mais pas le contrat ni le fournisseur.
  • +
+
+ Si le nombre total de tasses depasse la capacite de la salle, le bouton + Prepare bloque la preparation et affiche une erreur. +
+ +

Saisir les resultats

+
    +
  1. Passer la session en Start.
  2. +
  3. Ouvrir chaque tasse generee.
  4. +
  5. Ajouter un resultat par cupper/personne degustatrice.
  6. +
  7. Dans chaque resultat, saisir les lignes de criteres, les notes et cocher Defect found si un defaut est detecte.
  8. +
  9. Lorsque la session est terminee, utiliser Done.
  10. +
+

+ Les scores moyens, le nombre de resultats et le nombre de defauts sont + consolides au niveau de la tasse puis de l'echantillon de session. +

+
+ +
+

6. Decisions qualite

+

+ La decision finale reste une decision utilisateur, mais le systeme calcule + des indicateurs pour aider le responsable qualite ou origine. +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
IndicateurCalcul / logique
Quality statusCompare les resultats disponibles aux seuils de la ligne. Un rejet de cupping entraine un fail. Une demande de re-cup entraine un warning.
Recommended decisionPass = approve, fail = reject, warning = re-cup, sinon pending.
Cupping average scoreMoyenne des scores consolides sur les resultats de cupping de l'echantillon.
Lab analysis statusAucun, pending, received, accepted ou rejected selon la derniere analyse labo active.
+

+ Sur une ligne purchase ou sale, le statut Coffee Quality remonte une + synthese de tous les echantillons de la ligne. Un echantillon rejete ou + expire remonte donc directement sur la ligne. +

+
+ +
+

7. Routine quotidienne recommandee

+
    +
  1. Consulter Coffee / Samples / Expiring soon pour traiter les echantillons proches de l'expiration.
  2. +
  3. Consulter Coffee / Samples / Pending pour suivre les echantillons demandes, recus, envoyes au labo ou en revue.
  4. +
  5. Verifier les analyses labo en attente dans Coffee / Lab Analyses.
  6. +
  7. Preparer les prochaines sessions dans Coffee / Cupping Sessions en respectant la capacite de la salle.
  8. +
  9. Apres cupping ou retour labo, utiliser Evaluate quality sur les echantillons pour proposer l'approbation, le rejet ou la re-degustation.
  10. +
+
+ Le flux le plus simple est : creer l'echantillon depuis la ligne, + recevoir l'echantillon, envoyer au labo ou en cupping, saisir les + resultats, evaluer la qualite, puis approuver ou rejeter. +
+
+ +
+

8. Points d'attention

+
    +
  • Le mode coffee doit etre active dans la configuration pour afficher les menus et l'onglet ligne.
  • +
  • Les seuils qualite sont portes par la ligne purchase/sale. Sans seuil renseigne, la decision automatique reste en attente.
  • +
  • Le bouton Send to lab cree une analyse seulement si un laboratoire est renseigne sur l'echantillon.
  • +
  • Les informations visibles en cupping sont limitees a l'origine et au type de cafe pour conserver la logique de degustation en aveugle.
  • +
  • Le responsable peut forcer une decision manuelle via Approve, Reject ou la decision de ligne de session, meme si les indicateurs servent d'aide.
  • +
+
+
+ + diff --git a/modules/purchase_trade/invoice.py b/modules/purchase_trade/invoice.py index e8804a8..5623516 100644 --- a/modules/purchase_trade/invoice.py +++ b/modules/purchase_trade/invoice.py @@ -355,9 +355,9 @@ class Invoice(metaclass=PoolMeta): @staticmethod def _format_report_number(value, digits='0.0000', keep_trailing_decimal=False, - strip_trailing_zeros=True): + strip_trailing_zeros=True, use_grouping=False): value = Decimal(str(value or 0)).quantize(Decimal(digits)) - text = format(value, 'f') + text = format(value, ',f' if use_grouping else 'f') if strip_trailing_zeros: text = text.rstrip('0').rstrip('.') if keep_trailing_decimal and '.' not in text: @@ -1910,6 +1910,9 @@ class Invoice(metaclass=PoolMeta): return details def _get_report_price_composition_rows(self): + rows = self._get_report_invoice_price_composition_rows() + if rows: + return rows sale = self._get_report_sale() if sale and getattr(sale, 'report_price_composition_rows', None): return sale.report_price_composition_rows @@ -1920,6 +1923,84 @@ class Invoice(metaclass=PoolMeta): return sale.report_price_composition_rows return [] + def _get_report_invoice_price_composition_rows(self): + rows = [] + for invoice_line in self._get_report_invoice_lines(): + sale_line = getattr(invoice_line, 'origin', None) + compositions = list( + getattr(sale_line, 'price_composition', []) or []) + if not compositions: + continue + quantity = self._get_report_price_composition_quantity( + invoice_line, sale_line) + currency = getattr(invoice_line, 'currency', None) + sale = getattr(sale_line, 'sale', None) + currency = currency or getattr(sale, 'currency', None) + currency_name = ( + currency.rec_name.upper() + if currency and getattr(currency, 'rec_name', None) else '') + missing = [ + composition for composition in compositions + if getattr(composition, 'price', None) in (None, '')] + known_total = sum( + Decimal(str(getattr(composition, 'price', 0) or 0)) * quantity + for composition in compositions + if getattr(composition, 'price', None) not in (None, '')) + residual = None + if len(missing) == 1: + residual = ( + self._get_report_price_composition_invoice_line_amount( + invoice_line, sale_line, quantity) + - known_total) + for composition in compositions: + component = getattr(composition, 'component', '') or '' + price = getattr(composition, 'price', None) + if price in (None, ''): + if composition is missing[0] and residual is not None: + amount = residual + else: + continue + else: + amount = Decimal(str(price or 0)) * quantity + label = ' '.join( + part for part in [ + component, + 'value in', + currency_name, + ] if part) + if label: + rows.append({ + 'label': label, + 'amount': amount, + 'amount_text': self._format_report_number( + amount, digits='0.01', + strip_trailing_zeros=False, + use_grouping=True), + }) + return rows + + def _get_report_price_composition_quantity(self, invoice_line, sale_line): + quantity = self._get_report_invoice_line_weights(invoice_line)[0] + return self._convert_report_quantity( + quantity, + self._get_report_invoice_line_unit(invoice_line), + getattr(sale_line, 'unit', None), + ) + + def _get_report_price_composition_invoice_line_amount( + self, invoice_line, sale_line, quantity): + amount = getattr(invoice_line, 'amount', None) + if amount not in (None, ''): + return Decimal(str(amount or 0)) + unit_price = getattr(invoice_line, 'unit_price', None) + if unit_price not in (None, ''): + return Decimal(str(unit_price or 0)) * quantity + unit_price = Decimal(str(getattr(sale_line, 'unit_price', 0) or 0)) + premium_getter = getattr(sale_line, '_get_premium_price', None) + premium = premium_getter() if premium_getter else getattr( + sale_line, 'premium', 0) + return quantity * (unit_price + Decimal(str(premium or 0))) + @property def report_positive_rate_lines(self): sale = self._get_report_sale() diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py index 2ed0876..a908c96 100755 --- a/modules/purchase_trade/purchase.py +++ b/modules/purchase_trade/purchase.py @@ -1202,7 +1202,7 @@ class PriceComposition(ModelSQL,ModelView): line = fields.Many2One('purchase.line',"Purchase line") component = fields.Char("Component") - price = fields.Numeric("Price") + price = fields.Numeric("Unit Price") class PremiumComposition(ModelSQL, ModelView): "Premium Composition" diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index 835d520..c8c74b9 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -1256,8 +1256,9 @@ class Sale(metaclass=PoolMeta): missing = [ composition for composition in compositions if getattr(composition, 'price', None) in (None, '')] + quantity = self._get_report_line_quantity(line) known_total = sum( - Decimal(str(getattr(composition, 'price', 0) or 0)) + Decimal(str(getattr(composition, 'price', 0) or 0)) * quantity for composition in compositions if getattr(composition, 'price', None) not in (None, '')) residual = None @@ -1280,7 +1281,9 @@ class Sale(metaclass=PoolMeta): currency_name, ] if part) if label: - amount = Decimal(str(price or 0)) + amount = Decimal(str(price or 0)) * quantity + if missing and composition is missing[0] and residual is not None: + amount = residual lines.append({ 'label': label, 'amount': amount, diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index b096e3c..eec9e30 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -2554,6 +2554,90 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(values[0]['type'], 'sale fee') self.assertEqual(values[0]['lot'], sale_lot.id) + def test_purchase_open_fee_pnl_splits_by_matched_sale_lotqt(self): + 'purchase open fee pnl keeps one segment per matched sale lot.qt' + Valuation = Pool().get('valuation.valuation') + currency = Mock(id=1) + unit = Mock(id=2) + product = Mock(id=3, name='Broker commission') + supplier = Mock(id=4) + purchase = Mock(id=5, currency=currency, company=Mock(currency=currency)) + line = Mock(id=6, purchase=purchase, unit=unit, finished=False) + purchase_lot = Mock(id=7, sale_line=None, lot_type='virtual') + purchase_lot.get_current_quantity_converted.return_value = Decimal('60') + line.lots = [purchase_lot] + line.get_matched_lines.return_value = [] + sales = [Mock(id=20 + index) for index in range(3)] + sale_lines = [ + Mock(id=30 + index, sale=sales[index], finished=False) + for index in range(3)] + sale_lots = [ + Mock(id=40 + index, sale_line=sale_lines[index]) + for index in range(3)] + lot_qts = [ + Mock( + id=50, + lot_s=sale_lots[0], + lot_quantity=Decimal('10'), + lot_unit=unit, + lot_shipment_in=None), + Mock( + id=51, + lot_s=sale_lots[1], + lot_quantity=Decimal('20'), + lot_unit=unit, + lot_shipment_in=None), + Mock( + id=52, + lot_s=sale_lots[2], + lot_quantity=Decimal('30'), + lot_unit=unit, + lot_shipment_in=None), + ] + fee = Mock( + id=60, + line=line, + sale_line=None, + shipment_in=None, + shipment_out=None, + shipment_internal=None, + product=product, + supplier=supplier, + type='budgeted', + p_r='pay', + mode='perqt', + price=Decimal('2'), + currency=currency, + unit=unit, + ) + fee.get_price_per_qt.return_value = Decimal('2') + fee_lots = Mock() + fee_lots.search.return_value = [Mock(fee=fee)] + lot_qt_model = Mock() + lot_qt_model.search.return_value = lot_qts + + with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock: + PoolMock.return_value.get.side_effect = lambda name: { + 'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 6, 30))), + 'currency.currency': Mock(), + 'fee.lots': fee_lots, + 'lot.qt': lot_qt_model, + }[name] + + values = Valuation.create_pnl_fee_from_line(line) + + self.assertEqual(len(values), 3) + self.assertEqual( + [value['sale_line'] for value in values], + [sale_line.id for sale_line in sale_lines]) + self.assertEqual( + [value['quantity'] for value in values], + [Decimal('10.00000'), Decimal('20.00000'), Decimal('30.00000')]) + self.assertEqual( + [value['amount'] for value in values], + [Decimal('-20.00'), Decimal('-40.00'), Decimal('-60.00')]) + self.assertEqual(len(Valuation._dedupe_values(values)), 3) + def test_sale_open_fee_pnl_splits_by_shipment_lotqt(self): 'sale open fee pnl is split between shipped and unshipped lot.qt' Valuation = Pool().get('valuation.valuation') @@ -6345,8 +6429,10 @@ class PurchaseTradeTestCase(ModuleTestCase): line.linked_currency = None line.linked_unit = None line.unit = Mock(rec_name='MT') + line.quantity = Decimal('25') line.unit_price = Decimal('2525') - line.amount = Decimal('62493.75') + line.amount = Decimal('63750') + line.lots = [] line.price_composition = [ Mock(component='FOB', price=Decimal('2500')), Mock(component='FREIGHT', price=None), @@ -6367,13 +6453,55 @@ class PurchaseTradeTestCase(ModuleTestCase): [ { 'label': 'FOB value in EUR', - 'amount': Decimal('2500'), - 'amount_text': '2,500.00', + 'amount': Decimal('62500'), + 'amount_text': '62,500.00', }, { 'label': 'FREIGHT value in EUR', - 'amount': Decimal('59993.75'), - 'amount_text': '59,993.75', + 'amount': Decimal('1250'), + 'amount_text': '1,250.00', + }, + ]) + + def test_invoice_report_melya_price_composition_uses_invoice_quantity(self): + 'invoice_melya price composition applies the invoiced quantity' + Invoice = Pool().get('account.invoice') + unit = Mock(id=1, rec_name='MT') + sale = Mock(currency=Mock(rec_name='EUR'), lines=[]) + sale_line = Mock( + sale=sale, + unit=unit, + amount=Decimal('63750'), + price_composition=[ + Mock(component='FOB', price=Decimal('2500')), + Mock(component='FREIGHT', price=None), + ]) + invoice_line = Mock( + type='line', + origin=sale_line, + unit=unit, + quantity=Decimal('20'), + amount=Decimal('51000'), + currency=Mock(rec_name='EUR'), + invoice=None, + lot=None, + lot_weight_snapshots=[]) + invoice = Invoice() + invoice.lines = [invoice_line] + invoice.sales = [sale] + + self.assertEqual( + invoice.report_melya_rate_rows, + [ + { + 'label': 'FOB value in EUR', + 'amount': Decimal('50000'), + 'amount_text': '50,000.00', + }, + { + 'label': 'FREIGHT value in EUR', + 'amount': Decimal('1000'), + 'amount_text': '1,000.00', }, ]) diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py index fe7840d..75e2de5 100644 --- a/modules/purchase_trade/valuation.py +++ b/modules/purchase_trade/valuation.py @@ -251,9 +251,12 @@ class ValuationBase(ModelSQL): @classmethod def _fee_lot_segments(cls, lot, line, LotQt): if getattr(lot, 'lot_type', None) == 'physic': + sale_lot = lot if getattr(lot, 'sale_line', None) else None return [{ 'quantity': None, 'share': None, + 'sale_lot': sale_lot, + 'sale_line': getattr(sale_lot, 'sale_line', None), 'shipment_in': cls._lot_shipment_in_id(lot), }] @@ -262,6 +265,8 @@ class ValuationBase(ModelSQL): return [{ 'quantity': None, 'share': None, + 'sale_lot': None, + 'sale_line': None, 'shipment_in': cls._lot_shipment_in_id(lot), }] @@ -283,6 +288,8 @@ class ValuationBase(ModelSQL): return [{ 'quantity': None, 'share': None, + 'sale_lot': getattr(lot, 'sale_line', None) and lot or None, + 'sale_line': getattr(lot, 'sale_line', None), 'shipment_in': cls._lot_shipment_in_id(lot), }] @@ -292,9 +299,16 @@ class ValuationBase(ModelSQL): segments = [] for lqt, quantity in zip(lqts, quantities): + sale_lot = getattr(lqt, 'lot_s', None) + sale_line = getattr(sale_lot, 'sale_line', None) + if getattr(lot, 'sale_line', None): + sale_lot = lot + sale_line = lot.sale_line segments.append({ 'quantity': quantity, 'share': (quantity / total) if total else None, + 'sale_lot': sale_lot, + 'sale_line': sale_line, 'shipment_in': cls._lotqt_shipment_in_id(lqt), }) return segments @@ -1279,23 +1293,39 @@ class ValuationBase(ModelSQL): segment for segment in segments if segment.get('shipment_in') == shipment_id] if selected: - quantities = [ - Decimal(str(segment['quantity'])) - for segment in selected - if segment.get('quantity') is not None] - shares = [ - Decimal(str(segment['share'])) - for segment in selected - if segment.get('share') is not None] + grouped = {} + for segment in selected: + sale_line = segment.get('sale_line') + sale_lot = segment.get('sale_lot') + key = cls._record_id(sale_line) if sale_line else None + grouped.setdefault(key, { + 'quantities': [], + 'shares': [], + 'sale_lot': sale_lot, + 'sale_line': sale_line, + }) + if segment.get('quantity') is not None: + grouped[key]['quantities'].append( + Decimal(str(segment['quantity']))) + if segment.get('share') is not None: + grouped[key]['shares'].append( + Decimal(str(segment['share']))) return [{ 'quantity': ( - sum(quantities, Decimal(0)) if quantities else None), - 'share': sum(shares, Decimal(0)) if shares else None, + sum(data['quantities'], Decimal(0)) + if data['quantities'] else None), + 'share': ( + sum(data['shares'], Decimal(0)) + if data['shares'] else None), + 'sale_lot': data['sale_lot'], + 'sale_line': data['sale_line'], 'shipment_in': shipment_id, - }] + } for data in grouped.values()] return [{ 'quantity': None, 'share': None, + 'sale_lot': None, + 'sale_line': None, 'shipment_in': shipment_id, }] @@ -1379,6 +1409,8 @@ class ValuationBase(ModelSQL): cls._check_fee_required_fields(sf) sign = -1 if sf.p_r == 'pay' else 1 for segment in cls._fee_segments_for_fee(sf, lot, line, LotQt): + segment_sale_line = ( + segment.get('sale_line') or matched_sale_line) qty = cls._fee_segment_quantity(sf, lot, segment) price, amount = cls._fee_segment_amount( sf, lot, qty, sign, segment) @@ -1390,8 +1422,12 @@ class ValuationBase(ModelSQL): amount, sf.currency, line.purchase.company.currency) fee_lines.append({ 'lot': lot.id, - 'sale': matched_sale_line.sale.id if matched_sale_line else None, - 'sale_line': matched_sale_line.id if matched_sale_line else None, + 'sale': ( + segment_sale_line.sale.id + if segment_sale_line else None), + 'sale_line': ( + segment_sale_line.id + if segment_sale_line else None), 'purchase': line.purchase.id, 'line': line.id, 'shipment_in': segment.get('shipment_in'),