Price composition + Pnl open matched

This commit is contained in:
2026-06-30 18:27:40 +02:00
parent c0ac8fac68
commit 99d5d2a849
6 changed files with 765 additions and 23 deletions

View File

@@ -0,0 +1,494 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Guide utilisateur - Fonctionnalites coffee</title>
<style>
:root {
--ink: #17333a;
--muted: #66797d;
--line: #d9e3e1;
--panel: #f6faf9;
--accent: #0d7180;
--accent-soft: #e8f4f3;
--warn: #8a5a00;
--bad: #8a1f17;
--good: #126a38;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
color: var(--ink);
background: #fff;
line-height: 1.55;
}
header {
padding: 36px 44px 26px;
background: #f1f7f6;
border-bottom: 1px solid var(--line);
}
main {
max-width: 1120px;
padding: 28px 44px 56px;
}
h1 {
margin: 0 0 8px;
font-size: 32px;
line-height: 1.15;
}
h2 {
margin: 34px 0 12px;
padding-bottom: 8px;
font-size: 22px;
border-bottom: 1px solid var(--line);
}
h3 {
margin: 24px 0 8px;
font-size: 17px;
}
p { margin: 8px 0 12px; }
ul, ol { margin: 8px 0 16px 24px; padding: 0; }
li { margin: 5px 0; }
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0 20px;
font-size: 14px;
}
th, td {
border: 1px solid var(--line);
padding: 9px 10px;
vertical-align: top;
text-align: left;
}
th {
background: var(--panel);
color: #24484f;
}
code {
padding: 1px 4px;
border-radius: 4px;
background: var(--accent-soft);
color: #064d58;
font-family: Consolas, Monaco, monospace;
font-size: 0.95em;
}
.lead {
max-width: 860px;
color: var(--muted);
font-size: 16px;
}
.toc {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
margin: 22px 0 4px;
}
.toc a {
display: block;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--accent);
text-decoration: none;
background: #fff;
}
.note, .warning, .success {
margin: 14px 0;
padding: 12px 14px;
border-left: 4px solid var(--accent);
background: var(--panel);
}
.warning {
border-left-color: var(--warn);
background: #fff8e8;
}
.success {
border-left-color: var(--good);
background: #ecf8f1;
}
.badge {
display: inline-block;
margin: 0 4px 4px 0;
padding: 2px 7px;
border-radius: 999px;
background: var(--accent-soft);
color: #075662;
font-size: 12px;
font-weight: bold;
}
.path {
font-weight: bold;
color: #0a5965;
}
.small { color: var(--muted); font-size: 13px; }
@media print {
header, main { padding-left: 22px; padding-right: 22px; }
.toc a { break-inside: avoid; }
}
</style>
</head>
<body>
<header>
<h1>Guide utilisateur - Fonctionnalites coffee</h1>
<p class="lead">
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.
</p>
<p class="small">Version documentee : implementation purchase_trade coffee, 30/06/2026.</p>
</header>
<main>
<nav class="toc" aria-label="Sommaire">
<a href="#activation">1. Activation du mode coffee</a>
<a href="#line">2. Onglet Coffee Quality</a>
<a href="#samples">3. Echantillons</a>
<a href="#lab">4. Analyses laboratoire</a>
<a href="#cupping">5. Sessions de cupping</a>
<a href="#decisions">6. Decisions qualite</a>
<a href="#daily">7. Routine quotidienne</a>
<a href="#limits">8. Points d'attention</a>
</nav>
<section id="activation">
<h2>1. Activation du mode coffee</h2>
<p>
Les fonctionnalites coffee sont optionnelles. Elles n'apparaissent que si
l'option est activee dans la configuration trade.
</p>
<ol>
<li>Ouvrir la configuration du module trade.</li>
<li>Aller dans le bloc <span class="path">Trade options</span>.</li>
<li>Cocher <code>Active coffee compatibility</code>.</li>
<li>Enregistrer.</li>
</ol>
<p>Quand l'option est activee, les menus suivants deviennent disponibles sous Purchase et Sale :</p>
<p>
<span class="badge">Coffee / Samples</span>
<span class="badge">Coffee / Lab Analyses</span>
<span class="badge">Coffee / Cupping Sessions</span>
<span class="badge">Coffee / Cupping Criteria</span>
</p>
<div class="note">
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.
</div>
</section>
<section id="line">
<h2>2. Onglet Coffee Quality sur les lignes purchase/sale</h2>
<p>
Sur une ligne d'achat ou une ligne de vente, l'onglet
<span class="path">Coffee Quality</span> sert a definir le profil attendu
du cafe et les seuils de controle.
</p>
<table>
<thead>
<tr>
<th>Zone</th>
<th>Champs principaux</th>
<th>Utilisation</th>
</tr>
</thead>
<tbody>
<tr>
<td>Resume qualite</td>
<td>Coffee quality, Coffee origin, Coffee type, Coffee process</td>
<td>Identifier l'origine et le type de cafe visibles dans les process d'echantillon et de cupping.</td>
</tr>
<tr>
<td>Identite cafe</td>
<td>Variety, Crop year, Screen size</td>
<td>Completer les caracteristiques commerciales ou physiques du cafe cible.</td>
</tr>
<tr>
<td>Objectifs qualite</td>
<td>Moisture max %, Defects max, Cup score min</td>
<td>Definir les seuils utilises pour evaluer l'echantillon ou l'analyse labo.</td>
</tr>
<tr>
<td>Coffee samples</td>
<td>Liste des echantillons lies a la ligne</td>
<td>Creer ou consulter les echantillons rattaches au contrat d'achat ou de vente.</td>
</tr>
</tbody>
</table>
<p>
Le champ <code>Coffee quality</code> est calcule a partir des echantillons
rattaches a la ligne. Il peut indiquer notamment : aucun echantillon, en
attente, approuve, rejete, expire ou warning.
</p>
</section>
<section id="samples">
<h2>3. Gestion des echantillons</h2>
<p>
Un echantillon peut etre cree depuis l'onglet Coffee Quality d'une ligne,
ou depuis le menu <span class="path">Coffee / Samples</span>. Il peut etre
lie a une ligne purchase ou sale, a un lot, a un labo et a des resultats
de cupping.
</p>
<h3>Informations a saisir</h3>
<table>
<thead>
<tr>
<th>Champ</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td>Reference</td>
<td>Identifiant interne ou externe de l'echantillon.</td>
</tr>
<tr>
<td>Direction</td>
<td>Purchase ou Sale. Si l'echantillon est cree depuis une ligne, la direction est deduite.</td>
</tr>
<tr>
<td>Sample Type</td>
<td>Offer, Pre-shipment, Shipment, Arrival, Stock, Customer ou Retained.</td>
</tr>
<tr>
<td>Requested Date / Received Date</td>
<td>Suivi du cycle de vie. La date de demande est renseignee par defaut avec la date du jour.</td>
</tr>
<tr>
<td>Shelf life / Expiry Date</td>
<td>Duree de vie de l'echantillon. Par defaut : 90 jours. La date d'expiration est calculee automatiquement si elle n'est pas saisie.</td>
</tr>
<tr>
<td>Delegated to lab, Lab, Lab Due Date</td>
<td>Permet d'envoyer l'echantillon a un laboratoire et de suivre son delai.</td>
</tr>
<tr>
<td>Moisture, Screen size, Defects count</td>
<td>Resultats qualite saisis directement sur l'echantillon si l'analyse n'est pas deleguee.</td>
</tr>
</tbody>
</table>
<h3>Cycle de vie de l'echantillon</h3>
<p>
Les boutons de workflow permettent de faire avancer l'echantillon :
</p>
<p>
<span class="badge">Receive</span>
<span class="badge">Send to lab</span>
<span class="badge">Review</span>
<span class="badge">Approve</span>
<span class="badge">Reject</span>
<span class="badge">Evaluate quality</span>
<span class="badge">Expire</span>
<span class="badge">Archive</span>
<span class="badge">Reset</span>
</p>
<ul>
<li><code>Receive</code> passe l'echantillon en recu et renseigne la date de reception si elle est vide.</li>
<li><code>Send to lab</code> marque l'echantillon comme delegue au labo et cree une analyse labo si un laboratoire est renseigne.</li>
<li><code>Review</code> passe l'echantillon en revue qualite.</li>
<li><code>Approve</code> ou <code>Reject</code> enregistrent la decision et la date de decision.</li>
<li><code>Evaluate quality</code> applique automatiquement la decision recommandee selon les seuils et resultats disponibles.</li>
<li><code>Expire</code> force l'etat expire, puis <code>Archive</code> permet de sortir l'echantillon du suivi actif.</li>
</ul>
<h3>Filtres utiles</h3>
<p>La liste des echantillons contient des domaines prets a l'emploi :</p>
<p>
<span class="badge">Pending</span>
<span class="badge">Expiring soon</span>
<span class="badge">Expired</span>
<span class="badge">Approved</span>
<span class="badge">All</span>
</p>
<div class="warning">
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.
</div>
</section>
<section id="lab">
<h2>4. Analyses laboratoire</h2>
<p>
Les analyses labo servent a deleguer le controle d'un echantillon a un
laboratoire externe ou interne. Elles sont accessibles depuis
<span class="path">Coffee / Lab Analyses</span> ou depuis l'echantillon.
</p>
<h3>Creation</h3>
<ol>
<li>Ouvrir l'echantillon.</li>
<li>Renseigner le champ <code>Lab</code> et, si necessaire, <code>Lab Due Date</code>.</li>
<li>Cliquer sur <code>Send to lab</code>.</li>
</ol>
<p>
Le systeme cree une analyse labo active si aucune analyse active n'existe
deja pour cet echantillon.
</p>
<h3>Suivi de l'analyse</h3>
<table>
<thead>
<tr>
<th>Bouton</th>
<th>Effet</th>
</tr>
</thead>
<tbody>
<tr>
<td>Send</td>
<td>Renseigne la date d'envoi et marque l'echantillon comme delegue au laboratoire.</td>
</tr>
<tr>
<td>Receive</td>
<td>Renseigne la date de reception, puis place l'echantillon en revue qualite.</td>
</tr>
<tr>
<td>Accept / Reject</td>
<td>Valide ou rejette l'analyse labo.</td>
</tr>
<tr>
<td>Cancel / Draft</td>
<td>Annule ou remet l'analyse en brouillon.</td>
</tr>
</tbody>
</table>
<p>
Le champ <code>Compliant</code> est calcule en comparant les resultats
labo aux seuils de la ligne coffee : moisture max, defects max et cup
score min.
</p>
</section>
<section id="cupping">
<h2>5. Sessions de cupping</h2>
<p>
Une session de cupping permet de preparer une degustation en aveugle avec
plusieurs echantillons, plusieurs tasses par echantillon et plusieurs
personnes degustatrices.
</p>
<h3>Parametrer les criteres de cupping</h3>
<p>
Dans <span class="path">Coffee / Cupping Criteria</span>, 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.
</p>
<h3>Creer une session</h3>
<ol>
<li>Ouvrir <span class="path">Coffee / Cupping Sessions</span>.</li>
<li>Creer une nouvelle session avec un nom, une date, un responsable et une salle.</li>
<li>Renseigner la capacite de la salle en nombre de tasses. Par defaut : 24.</li>
<li>Renseigner le nombre de tasses par echantillon. Par defaut : 3.</li>
<li>Ajouter les echantillons a deguster dans les lignes de session.</li>
</ol>
<p>
Les compteurs <code>Total cups</code> et <code>Remaining cups</code>
indiquent le nombre de tasses prevues et la capacite restante.
</p>
<h3>Preparer l'ordre et les codes aveugles</h3>
<p>
Le bouton <code>Prepare</code> controle la capacite de la salle, trie les
echantillons et genere les tasses manquantes.
</p>
<ul>
<li>L'ordre propose commence par les cafes les plus doux : arabica, puis blend/other, puis robusta.</li>
<li>Le tri tient aussi compte de l'origine et de la reference echantillon.</li>
<li>Chaque tasse recoit un code aveugle de type <code>S01-C1</code>, <code>S01-C2</code>, etc.</li>
<li>Les degustateurs voient l'origine et le type de cafe, mais pas le contrat ni le fournisseur.</li>
</ul>
<div class="warning">
Si le nombre total de tasses depasse la capacite de la salle, le bouton
Prepare bloque la preparation et affiche une erreur.
</div>
<h3>Saisir les resultats</h3>
<ol>
<li>Passer la session en <code>Start</code>.</li>
<li>Ouvrir chaque tasse generee.</li>
<li>Ajouter un resultat par cupper/personne degustatrice.</li>
<li>Dans chaque resultat, saisir les lignes de criteres, les notes et cocher <code>Defect found</code> si un defaut est detecte.</li>
<li>Lorsque la session est terminee, utiliser <code>Done</code>.</li>
</ol>
<p>
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.
</p>
</section>
<section id="decisions">
<h2>6. Decisions qualite</h2>
<p>
La decision finale reste une decision utilisateur, mais le systeme calcule
des indicateurs pour aider le responsable qualite ou origine.
</p>
<table>
<thead>
<tr>
<th>Indicateur</th>
<th>Calcul / logique</th>
</tr>
</thead>
<tbody>
<tr>
<td>Quality status</td>
<td>Compare les resultats disponibles aux seuils de la ligne. Un rejet de cupping entraine un fail. Une demande de re-cup entraine un warning.</td>
</tr>
<tr>
<td>Recommended decision</td>
<td>Pass = approve, fail = reject, warning = re-cup, sinon pending.</td>
</tr>
<tr>
<td>Cupping average score</td>
<td>Moyenne des scores consolides sur les resultats de cupping de l'echantillon.</td>
</tr>
<tr>
<td>Lab analysis status</td>
<td>Aucun, pending, received, accepted ou rejected selon la derniere analyse labo active.</td>
</tr>
</tbody>
</table>
<p>
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.
</p>
</section>
<section id="daily">
<h2>7. Routine quotidienne recommandee</h2>
<ol>
<li>Consulter <span class="path">Coffee / Samples / Expiring soon</span> pour traiter les echantillons proches de l'expiration.</li>
<li>Consulter <span class="path">Coffee / Samples / Pending</span> pour suivre les echantillons demandes, recus, envoyes au labo ou en revue.</li>
<li>Verifier les analyses labo en attente dans <span class="path">Coffee / Lab Analyses</span>.</li>
<li>Preparer les prochaines sessions dans <span class="path">Coffee / Cupping Sessions</span> en respectant la capacite de la salle.</li>
<li>Apres cupping ou retour labo, utiliser <code>Evaluate quality</code> sur les echantillons pour proposer l'approbation, le rejet ou la re-degustation.</li>
</ol>
<div class="success">
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.
</div>
</section>
<section id="limits">
<h2>8. Points d'attention</h2>
<ul>
<li>Le mode coffee doit etre active dans la configuration pour afficher les menus et l'onglet ligne.</li>
<li>Les seuils qualite sont portes par la ligne purchase/sale. Sans seuil renseigne, la decision automatique reste en attente.</li>
<li>Le bouton <code>Send to lab</code> cree une analyse seulement si un laboratoire est renseigne sur l'echantillon.</li>
<li>Les informations visibles en cupping sont limitees a l'origine et au type de cafe pour conserver la logique de degustation en aveugle.</li>
<li>Le responsable peut forcer une decision manuelle via Approve, Reject ou la decision de ligne de session, meme si les indicateurs servent d'aide.</li>
</ul>
</section>
</main>
</body>
</html>

View File

@@ -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()

View File

@@ -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"

View File

@@ -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,

View File

@@ -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',
},
])

View File

@@ -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'),