Price composition + Pnl open matched
This commit is contained in:
494
modules/purchase_trade/docs/coffee_user_guide.html
Normal file
494
modules/purchase_trade/docs/coffee_user_guide.html
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user