This commit is contained in:
2026-06-29 20:34:23 +02:00
parent 5635856711
commit f9ade37eab
15 changed files with 1024 additions and 9 deletions

View File

@@ -37,6 +37,7 @@ from . import (
service,
invoice,
duplicate,
coffee,
)
def register():
@@ -94,6 +95,7 @@ def register():
configuration.Configuration,
pricing.ImportPricesStart,
pricing.ImportPricesResult,
coffee.CoffeeSample,
duplicate.TradeCustomDuplicateStart,
module='purchase_trade', type_='model')
Pool.register(
@@ -239,10 +241,11 @@ def register():
purchase.AssayUnit,
purchase.PayableRule,
purchase.PenaltyRule,
purchase.PenaltyRuleTier,
purchase.ConcentrateTerm,
backtoback.Backtoback,
dimension.AnalyticDimension,
purchase.PenaltyRuleTier,
purchase.ConcentrateTerm,
coffee.PurchaseLine,
backtoback.Backtoback,
dimension.AnalyticDimension,
dimension.AnalyticDimensionValue,
dimension.AnalyticDimensionAssignment,
weight_report.WeightReport,
@@ -306,9 +309,10 @@ def register():
sale.OpenPosition,
sale.Backtoback,
sale.AnalyticDimensionAssignment,
sale.PriceComposition,
sale.PremiumComposition,
module='sale', type_='model')
sale.PriceComposition,
sale.PremiumComposition,
coffee.SaleLine,
module='sale', type_='model')
Pool.register(
lot.LotShipping,
lot.LotPlanTransport,

View File

@@ -0,0 +1,429 @@
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import datetime
from decimal import Decimal
from trytond.model import ModelSQL, ModelView, Workflow, fields
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Eval
COFFEE_SAMPLE_TYPES = [
(None, ''),
('offer', 'Offer sample'),
('pre_shipment', 'Pre-shipment sample'),
('shipment', 'Shipment sample'),
('arrival', 'Arrival sample'),
('stock', 'Stock sample'),
('customer', 'Customer sample'),
('retained', 'Retained sample'),
]
COFFEE_PROCESS_TYPES = [
(None, ''),
('washed', 'Washed'),
('natural', 'Natural'),
('honey', 'Honey'),
('semi_washed', 'Semi-washed'),
('other', 'Other'),
]
class CoffeeSample(ModelSQL, ModelView, Workflow):
'Coffee Sample'
__name__ = 'coffee.sample'
reference = fields.Char('Reference')
direction = fields.Selection([
(None, ''),
('purchase', 'Purchase'),
('sale', 'Sale'),
], 'Direction', required=True)
sample_type = fields.Selection(
COFFEE_SAMPLE_TYPES, 'Sample Type', required=True)
state = fields.Selection([
(None, ''),
('requested', 'Requested'),
('received', 'Received'),
('sent_to_lab', 'Sent to lab'),
('under_review', 'Under review'),
('approved', 'Approved'),
('rejected', 'Rejected'),
('expired', 'Expired'),
('archived', 'Archived'),
], 'State', readonly=True, required=True)
purchase_line = fields.Many2One(
'purchase.line', 'Purchase Line', ondelete='CASCADE',
states={'invisible': Eval('direction') != 'purchase'},
depends=['direction'])
sale_line = fields.Many2One(
'sale.line', 'Sale Line', ondelete='CASCADE',
states={'invisible': Eval('direction') != 'sale'},
depends=['direction'])
lot = fields.Many2One('lot.lot', 'Lot')
product = fields.Function(
fields.Many2One('product.product', 'Product'), 'get_product')
party = fields.Function(
fields.Many2One('party.party', 'Party'), 'get_party')
requested_date = fields.Date('Requested Date')
received_date = fields.Date('Received Date')
sent_date = fields.Date('Sent Date')
review_date = fields.Date('Review Date')
decision_date = fields.Date('Decision Date')
shelf_life_days = fields.Integer('Shelf life (days)')
expiry_date = fields.Date('Expiry Date')
is_expired = fields.Function(
fields.Boolean('Expired'), 'get_is_expired',
searcher='search_is_expired')
expires_soon = fields.Function(
fields.Boolean('Expires soon'), 'get_expires_soon',
searcher='search_expires_soon')
quantity = fields.Numeric('Quantity', digits='unit')
unit = fields.Many2One('product.uom', 'Unit')
storage_location = fields.Char('Sample location')
delegated_to_lab = fields.Boolean('Delegated to lab')
lab = fields.Many2One(
'party.party', 'Lab',
states={'invisible': ~Eval('delegated_to_lab')},
depends=['delegated_to_lab'])
lab_due_date = fields.Date(
'Lab due date',
states={'invisible': ~Eval('delegated_to_lab')},
depends=['delegated_to_lab'])
aroma = fields.Numeric('Aroma', digits=(5, 2))
flavor = fields.Numeric('Flavor', digits=(5, 2))
aftertaste = fields.Numeric('Aftertaste', digits=(5, 2))
acidity = fields.Numeric('Acidity', digits=(5, 2))
body = fields.Numeric('Body', digits=(5, 2))
balance = fields.Numeric('Balance', digits=(5, 2))
sweetness = fields.Numeric('Sweetness', digits=(5, 2))
clean_cup = fields.Numeric('Clean cup', digits=(5, 2))
uniformity = fields.Numeric('Uniformity', digits=(5, 2))
overall = fields.Numeric('Overall', digits=(5, 2))
defects = fields.Numeric('Defects', digits=(5, 2))
total_score = fields.Function(
fields.Numeric('Cup score', digits=(5, 2)), 'get_total_score')
moisture = fields.Numeric('Moisture %', digits=(5, 2))
screen_size = fields.Char('Screen size')
defect_count = fields.Integer('Defects count')
notes = fields.Text('Notes')
decision_note = fields.Text('Decision note')
@classmethod
def __setup__(cls):
super().__setup__()
cls._transitions |= set((
('requested', 'received'),
('received', 'sent_to_lab'),
('received', 'under_review'),
('sent_to_lab', 'under_review'),
('under_review', 'approved'),
('under_review', 'rejected'),
('requested', 'expired'),
('received', 'expired'),
('sent_to_lab', 'expired'),
('under_review', 'expired'),
('approved', 'archived'),
('rejected', 'archived'),
('expired', 'archived'),
('archived', 'requested'),
))
cls._buttons.update({
'receive': {
'invisible': Eval('state') != 'requested',
'depends': ['state'],
},
'send_to_lab': {
'invisible': ~Eval('state').in_(['received']),
'depends': ['state'],
},
'review': {
'invisible': ~Eval('state').in_(['received', 'sent_to_lab']),
'depends': ['state'],
},
'approve': {
'invisible': Eval('state') != 'under_review',
'depends': ['state'],
},
'reject': {
'invisible': Eval('state') != 'under_review',
'depends': ['state'],
},
'expire': {
'invisible': Eval('state').in_(
['approved', 'rejected', 'expired', 'archived']),
'depends': ['state'],
},
'archive': {
'invisible': ~Eval('state').in_(
['approved', 'rejected', 'expired']),
'depends': ['state'],
},
'reset_to_requested': {
'invisible': Eval('state') != 'archived',
'depends': ['state'],
},
})
@staticmethod
def default_state():
return 'requested'
@staticmethod
def default_shelf_life_days():
return 90
@staticmethod
def default_requested_date():
return Pool().get('ir.date').today()
@classmethod
def default_direction(cls):
return 'purchase'
@classmethod
def create(cls, vlist):
for values in vlist:
cls._set_direction_from_line(values)
cls._set_default_expiry_date(values, force=True)
return super().create(vlist)
@classmethod
def write(cls, *args):
actions = iter(args)
args = []
for records, values in zip(actions, actions):
values = values.copy()
cls._set_direction_from_line(values)
if (
{'requested_date', 'received_date', 'shelf_life_days'}
& set(values)
and not any(
getattr(record, 'expiry_date', None)
for record in records)):
cls._set_default_expiry_date(values)
args.extend((records, values))
super().write(*args)
@classmethod
def _set_direction_from_line(cls, values):
if values.get('purchase_line') and not values.get('direction'):
values['direction'] = 'purchase'
elif values.get('sale_line') and not values.get('direction'):
values['direction'] = 'sale'
@classmethod
def _set_default_expiry_date(cls, values, force=False):
if values.get('expiry_date'):
return
if not force and not (
{'requested_date', 'received_date', 'shelf_life_days'}
& set(values)):
return
start = (
values.get('received_date')
or values.get('requested_date')
or cls.default_requested_date())
shelf_life_days = values.get('shelf_life_days')
if shelf_life_days is None:
shelf_life_days = cls.default_shelf_life_days()
if start and shelf_life_days:
values['expiry_date'] = (
start + datetime.timedelta(days=int(shelf_life_days)))
def get_product(self, name=None):
line = self.purchase_line or self.sale_line
product = getattr(line, 'product', None)
return product.id if product else None
def get_party(self, name=None):
if self.purchase_line and self.purchase_line.purchase:
party = self.purchase_line.purchase.party
return party.id if party else None
if self.sale_line and self.sale_line.sale:
party = self.sale_line.sale.party
return party.id if party else None
def _score_fields(self):
return [
'aroma', 'flavor', 'aftertaste', 'acidity', 'body', 'balance',
'sweetness', 'clean_cup', 'uniformity', 'overall']
def get_total_score(self, name=None):
total = Decimal(0)
for field_name in self._score_fields():
value = getattr(self, field_name, None)
if value is not None:
total += Decimal(str(value))
defects = getattr(self, 'defects', None)
if defects is not None:
total -= Decimal(str(defects))
return total
def get_is_expired(self, name=None):
if self.state == 'expired':
return True
today = Pool().get('ir.date').today()
return bool(
self.expiry_date and self.expiry_date < today
and self.state not in {'archived', 'rejected'})
def get_expires_soon(self, name=None):
today = Pool().get('ir.date').today()
limit = today + datetime.timedelta(days=14)
return bool(
self.expiry_date and today <= self.expiry_date <= limit
and self.state not in {'archived', 'expired', 'rejected'})
@classmethod
def search_is_expired(cls, name, clause):
today = Pool().get('ir.date').today()
positive = clause[2]
domain = ['OR',
('state', '=', 'expired'),
[
('expiry_date', '<', today),
('state', 'not in', ['archived', 'rejected']),
],
]
if positive:
return domain
return ['NOT', domain]
@classmethod
def search_expires_soon(cls, name, clause):
today = Pool().get('ir.date').today()
limit = today + datetime.timedelta(days=14)
domain = [
('expiry_date', '>=', today),
('expiry_date', '<=', limit),
('state', 'not in', ['archived', 'expired', 'rejected']),
]
if clause[2]:
return domain
return ['NOT', domain]
def get_rec_name(self, name):
if self.reference:
return self.reference
label = self.sample_type or 'sample'
if self.purchase_line and self.purchase_line.purchase:
return '%s - %s' % (label, self.purchase_line.purchase.rec_name)
if self.sale_line and self.sale_line.sale:
return '%s - %s' % (label, self.sale_line.sale.rec_name)
return label
@classmethod
@ModelView.button
@Workflow.transition('received')
def receive(cls, samples):
today = Pool().get('ir.date').today()
cls.write(
[sample for sample in samples if not sample.received_date],
{'received_date': today})
@classmethod
@ModelView.button
@Workflow.transition('sent_to_lab')
def send_to_lab(cls, samples):
today = Pool().get('ir.date').today()
cls.write(samples, {
'delegated_to_lab': True,
'sent_date': today,
})
@classmethod
@ModelView.button
@Workflow.transition('under_review')
def review(cls, samples):
today = Pool().get('ir.date').today()
cls.write(samples, {'review_date': today})
@classmethod
@ModelView.button
@Workflow.transition('approved')
def approve(cls, samples):
today = Pool().get('ir.date').today()
cls.write(samples, {'decision_date': today})
@classmethod
@ModelView.button
@Workflow.transition('rejected')
def reject(cls, samples):
today = Pool().get('ir.date').today()
cls.write(samples, {'decision_date': today})
@classmethod
@ModelView.button
@Workflow.transition('expired')
def expire(cls, samples):
pass
@classmethod
@ModelView.button
@Workflow.transition('archived')
def archive(cls, samples):
pass
@classmethod
@ModelView.button
@Workflow.transition('requested')
def reset_to_requested(cls, samples):
pass
class CoffeeLineMixin:
coffee_samples = fields.One2Many(
'coffee.sample', None, 'Coffee samples')
coffee_quality_status = fields.Function(fields.Selection([
(None, ''),
('none', 'No sample'),
('pending', 'Pending'),
('approved', 'Approved'),
('rejected', 'Rejected'),
('expired', 'Expired'),
], 'Coffee quality'), 'get_coffee_quality_status')
coffee_origin = fields.Char('Coffee origin')
coffee_process = fields.Selection(COFFEE_PROCESS_TYPES, 'Coffee process')
coffee_variety = fields.Char('Coffee variety')
coffee_crop_year = fields.Char('Crop year')
coffee_screen_size = fields.Char('Screen size')
coffee_moisture_max = fields.Numeric('Moisture max %', digits=(5, 2))
coffee_defect_max = fields.Integer('Defects max')
coffee_cup_score_min = fields.Numeric(
'Cup score min', digits=(5, 2))
def _coffee_samples(self):
return list(getattr(self, 'coffee_samples', None) or [])
def get_coffee_quality_status(self, name=None):
samples = self._coffee_samples()
if not samples:
return 'none'
if any(sample.state == 'rejected' for sample in samples):
return 'rejected'
if any(sample.state == 'expired' or sample.get_is_expired()
for sample in samples):
return 'expired'
if any(sample.state == 'approved' for sample in samples):
return 'approved'
return 'pending'
class PurchaseLine(CoffeeLineMixin, metaclass=PoolMeta):
__name__ = 'purchase.line'
coffee_samples = fields.One2Many(
'coffee.sample', 'purchase_line', 'Coffee samples')
class SaleLine(CoffeeLineMixin, metaclass=PoolMeta):
__name__ = 'sale.line'
coffee_samples = fields.One2Many(
'coffee.sample', 'sale_line', 'Coffee samples')

View File

@@ -0,0 +1,136 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tryton>
<data>
<record model="ir.ui.view" id="coffee_sample_view_tree">
<field name="model">coffee.sample</field>
<field name="type">tree</field>
<field name="name">coffee_sample_tree</field>
</record>
<record model="ir.ui.view" id="coffee_sample_view_form">
<field name="model">coffee.sample</field>
<field name="type">form</field>
<field name="name">coffee_sample_form</field>
</record>
<record model="ir.action.act_window" id="act_coffee_sample_form">
<field name="name">Coffee Samples</field>
<field name="res_model">coffee.sample</field>
<field name="search_value"></field>
</record>
<record model="ir.action.act_window.view" id="act_coffee_sample_form_view_tree">
<field name="sequence" eval="10"/>
<field name="view" ref="coffee_sample_view_tree"/>
<field name="act_window" ref="act_coffee_sample_form"/>
</record>
<record model="ir.action.act_window.view" id="act_coffee_sample_form_view_form">
<field name="sequence" eval="20"/>
<field name="view" ref="coffee_sample_view_form"/>
<field name="act_window" ref="act_coffee_sample_form"/>
</record>
<record model="ir.action.act_window.domain" id="act_coffee_sample_domain_pending">
<field name="name">Pending</field>
<field name="sequence" eval="10"/>
<field name="domain" eval="[('state', 'in', ['requested', 'received', 'sent_to_lab', 'under_review'])]" pyson="1"/>
<field name="count" eval="True"/>
<field name="act_window" ref="act_coffee_sample_form"/>
</record>
<record model="ir.action.act_window.domain" id="act_coffee_sample_domain_expiring">
<field name="name">Expiring soon</field>
<field name="sequence" eval="20"/>
<field name="domain" eval="[('expires_soon', '=', True)]" pyson="1"/>
<field name="count" eval="True"/>
<field name="act_window" ref="act_coffee_sample_form"/>
</record>
<record model="ir.action.act_window.domain" id="act_coffee_sample_domain_expired">
<field name="name">Expired</field>
<field name="sequence" eval="30"/>
<field name="domain" eval="[('is_expired', '=', True)]" pyson="1"/>
<field name="count" eval="True"/>
<field name="act_window" ref="act_coffee_sample_form"/>
</record>
<record model="ir.action.act_window.domain" id="act_coffee_sample_domain_approved">
<field name="name">Approved</field>
<field name="sequence" eval="40"/>
<field name="domain" eval="[('state', '=', 'approved')]" pyson="1"/>
<field name="count" eval="True"/>
<field name="act_window" ref="act_coffee_sample_form"/>
</record>
<record model="ir.action.act_window.domain" id="act_coffee_sample_domain_all">
<field name="name">All</field>
<field name="sequence" eval="9999"/>
<field name="domain"></field>
<field name="act_window" ref="act_coffee_sample_form"/>
</record>
<menuitem
name="Coffee Samples"
parent="purchase.menu_purchase"
sequence="18"
action="act_coffee_sample_form"
id="menu_coffee_sample_purchase"/>
<menuitem
name="Coffee Samples"
parent="sale.menu_sale"
sequence="18"
action="act_coffee_sample_form"
id="menu_coffee_sample_sale"/>
<record model="ir.model.button" id="coffee_sample_receive_button">
<field name="model">coffee.sample</field>
<field name="name">receive</field>
<field name="string">Receive</field>
</record>
<record model="ir.model.button" id="coffee_sample_send_to_lab_button">
<field name="model">coffee.sample</field>
<field name="name">send_to_lab</field>
<field name="string">Send to lab</field>
</record>
<record model="ir.model.button" id="coffee_sample_review_button">
<field name="model">coffee.sample</field>
<field name="name">review</field>
<field name="string">Review</field>
</record>
<record model="ir.model.button" id="coffee_sample_approve_button">
<field name="model">coffee.sample</field>
<field name="name">approve</field>
<field name="string">Approve</field>
</record>
<record model="ir.model.button" id="coffee_sample_reject_button">
<field name="model">coffee.sample</field>
<field name="name">reject</field>
<field name="string">Reject</field>
</record>
<record model="ir.model.button" id="coffee_sample_expire_button">
<field name="model">coffee.sample</field>
<field name="name">expire</field>
<field name="string">Expire</field>
</record>
<record model="ir.model.button" id="coffee_sample_archive_button">
<field name="model">coffee.sample</field>
<field name="name">archive</field>
<field name="string">Archive</field>
</record>
<record model="ir.model.button" id="coffee_sample_reset_to_requested_button">
<field name="model">coffee.sample</field>
<field name="name">reset_to_requested</field>
<field name="string">Reset</field>
</record>
<record model="ir.model.access" id="access_coffee_sample">
<field name="model">coffee.sample</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_delete" eval="True"/>
</record>
<record model="ir.model.access" id="access_coffee_sample_purchase">
<field name="model">coffee.sample</field>
<field name="group" ref="purchase.group_purchase"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_delete" eval="True"/>
</record>
</data>
</tryton>

View File

@@ -17,6 +17,8 @@ Statut: `migration partielle`
</li>
<li style="margin:0.38rem 0;">Pricing manuel, basis, premium, linked currency: <a href="pricing.md">pricing.md</a>
</li>
<li style="margin:0.38rem 0;">Trading cafe, echantillons, cupping, qualite: <a href="coffee.md">FR</a> / <a href="coffee.en.md">EN</a>
</li>
<li style="margin:0.38rem 0;">Fees, freight, lots effectifs, quantités, <code>% rate</code>: <a href="fees.md">FR</a> / <a href="fees.en.md">EN</a>
</li>
<li style="margin:0.38rem 0;">Valuation, PnL, MTM, derivatives: <a href="valuation-pnl-mtm.md">FR</a> / <a href="valuation-pnl-mtm.en.md">EN</a>
@@ -66,6 +68,8 @@ Statut: `migration partielle`
</li>
<li style="margin:0.38rem 0;"><code>BR-PT-PRI-003</code>: pricing manuel.
</li>
<li style="margin:0.38rem 0;"><code>BR-PT-COF-001</code>: echantillons cafe phase 1.
</li>
<li style="margin:0.38rem 0;"><code>BR-PT-FEE-001</code>: maritime freight depuis fee shipment.
</li>
<li style="margin:0.38rem 0;"><code>BR-PT-FEE-002</code>: lots effectifs des fees.

View File

@@ -0,0 +1,74 @@
<!-- Generated from docs_source/business by docs/tools/render_business_docs.py. -->
# Coffee trading - samples and quality
Mirror page: [coffee.md](coffee.md)
## BR-PT-COF-001 - Coffee sample phase 1
Status: active
Source: project decision on 2026-06-29
### Business rule
Coffee trading must track samples linked to purchase and sale lines.
Each sample stores:
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">a sample type: offer, pre-shipment, shipment, arrival, stock, customer or retained;
</li>
<li style="margin:0.38rem 0;">a purchase or sale direction;
</li>
<li style="margin:0.38rem 0;">a lifecycle: requested, received, sent to lab, under review, approved, rejected, expired, archived;
</li>
<li style="margin:0.38rem 0;">an expiry date computed from the request/receipt date and the sample shelf life;
</li>
<li style="margin:0.38rem 0;">a simple cupping score and physical quality values.
</li>
</ul>
`purchase.line` and `sale.line` store the target coffee quality specifications:
origin, process, variety, crop year, screen size, maximum moisture, maximum
defect count and minimum cup score.
### Developer notes
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Models/fields:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><code>coffee.sample</code>
</li>
<li style="margin:0.38rem 0;"><code>purchase.line.coffee_samples</code>
</li>
<li style="margin:0.38rem 0;"><code>sale.line.coffee_samples</code>
</li>
<li style="margin:0.38rem 0;"><code>coffee_*</code> fields on <code>purchase.line</code> and <code>sale.line</code>
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Files:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><code>modules/purchase_trade/coffee.py</code>
</li>
<li style="margin:0.38rem 0;"><code>modules/purchase_trade/coffee.xml</code>
</li>
<li style="margin:0.38rem 0;"><code>modules/purchase_trade/view/coffee_sample_*.xml</code>
</li>
<li style="margin:0.38rem 0;"><code>modules/purchase_trade/view/purchase_line_form.xml</code>
</li>
<li style="margin:0.38rem 0;"><code>modules/purchase_trade/view/sale_line_form.xml</code>
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Caveats:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">expiry is monitored through the <code>Expired</code> and <code>Expiring soon</code> domains;
</li>
<li style="margin:0.38rem 0;">phase 1 does not block shipment or contract validation;
</li>
<li style="margin:0.38rem 0;">lab delegation is a tracking field, not a complete lab workflow yet.
</li>
</ul>
</li>
</ul>

View File

@@ -0,0 +1,75 @@
<!-- Generated from docs_source/business by docs/tools/render_business_docs.py. -->
# Trading cafe - echantillons et qualite
Page miroir: [coffee.en.md](coffee.en.md)
## BR-PT-COF-001 - Phase 1 echantillons cafe
Statut: active
Source: decision projet du 2026-06-29
### Regle consultant
Le trading cafe doit pouvoir piloter les echantillons rattaches aux lignes
d'achat et de vente.
Chaque echantillon porte:
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">un type d&#x27;echantillon: offer, pre-shipment, shipment, arrival, stock, customer ou retained;
</li>
<li style="margin:0.38rem 0;">un sens purchase ou sale;
</li>
<li style="margin:0.38rem 0;">un cycle de vie: requested, received, sent to lab, under review, approved, rejected, expired, archived;
</li>
<li style="margin:0.38rem 0;">une date d&#x27;expiration calculee depuis la date de demande/reception et la duree de vie de l&#x27;echantillon;
</li>
<li style="margin:0.38rem 0;">une notation de cupping simple et des donnees de qualite physique.
</li>
</ul>
Les lignes `purchase.line` et `sale.line` portent les specifications qualite
cibles du cafe: origine, process, variete, crop year, screen size, humidite
maximum, nombre de defauts maximum et score cup minimum.
### Notes developpeur
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">Modeles/champs:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><code>coffee.sample</code>
</li>
<li style="margin:0.38rem 0;"><code>purchase.line.coffee_samples</code>
</li>
<li style="margin:0.38rem 0;"><code>sale.line.coffee_samples</code>
</li>
<li style="margin:0.38rem 0;">champs <code>coffee_*</code> sur <code>purchase.line</code> et <code>sale.line</code>
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Fichiers:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;"><code>modules/purchase_trade/coffee.py</code>
</li>
<li style="margin:0.38rem 0;"><code>modules/purchase_trade/coffee.xml</code>
</li>
<li style="margin:0.38rem 0;"><code>modules/purchase_trade/view/coffee_sample_*.xml</code>
</li>
<li style="margin:0.38rem 0;"><code>modules/purchase_trade/view/purchase_line_form.xml</code>
</li>
<li style="margin:0.38rem 0;"><code>modules/purchase_trade/view/sale_line_form.xml</code>
</li>
</ul>
</li>
<li style="margin:0.38rem 0;">Points de vigilance:
<ul style="margin:0.65rem 0 1rem 1.35rem; padding-left:1rem; list-style-type:circle;">
<li style="margin:0.38rem 0;">l&#x27;expiration est pilotee par les domaines <code>Expired</code> et <code>Expiring soon</code>;
</li>
<li style="margin:0.38rem 0;">aucun blocage shipment/validation n&#x27;est introduit en phase 1;
</li>
<li style="margin:0.38rem 0;">la delegation labo reste une information de suivi, sans workflow labo complet.
</li>
</ul>
</li>
</ul>

View File

@@ -9,6 +9,7 @@ Statut: `migration partielle`
- Matching, Create Contracts, back-to-back: [matching.md](matching.md)
- Shipments, controllers, SLA, weight reports: [shipments-execution.md](shipments-execution.md)
- Pricing manuel, basis, premium, linked currency: [pricing.md](pricing.md)
- Trading cafe, echantillons, cupping, qualite: [FR](coffee.md) / [EN](coffee.en.md)
- Fees, freight, lots effectifs, quantités, `% rate`: [FR](fees.md) / [EN](fees.en.md)
- Valuation, PnL, MTM, derivatives: [FR](valuation-pnl-mtm.md) / [EN](valuation-pnl-mtm.en.md)
- Factures provisoires/finales, padding: [invoicing.md](invoicing.md)
@@ -35,6 +36,7 @@ Statut: `migration partielle`
- `BR-PT-PRI-001`: premium dans priced et basis.
- `BR-PT-PRI-002`: linked currency.
- `BR-PT-PRI-003`: pricing manuel.
- `BR-PT-COF-001`: echantillons cafe phase 1.
- `BR-PT-FEE-001`: maritime freight depuis fee shipment.
- `BR-PT-FEE-002`: lots effectifs des fees.
- `BR-PT-FEE-003`: quantité du fee alignée sur les lots effectifs.

View File

@@ -0,0 +1,45 @@
# Coffee trading - samples and quality
Mirror page: [coffee.md](coffee.md)
## BR-PT-COF-001 - Coffee sample phase 1
Status: active
Source: project decision on 2026-06-29
### Business rule
Coffee trading must track samples linked to purchase and sale lines.
Each sample stores:
- a sample type: offer, pre-shipment, shipment, arrival, stock, customer or
retained;
- a purchase or sale direction;
- a lifecycle: requested, received, sent to lab, under review, approved,
rejected, expired, archived;
- an expiry date computed from the request/receipt date and the sample shelf
life;
- a simple cupping score and physical quality values.
`purchase.line` and `sale.line` store the target coffee quality specifications:
origin, process, variety, crop year, screen size, maximum moisture, maximum
defect count and minimum cup score.
### Developer notes
- Models/fields:
- `coffee.sample`
- `purchase.line.coffee_samples`
- `sale.line.coffee_samples`
- `coffee_*` fields on `purchase.line` and `sale.line`
- Files:
- `modules/purchase_trade/coffee.py`
- `modules/purchase_trade/coffee.xml`
- `modules/purchase_trade/view/coffee_sample_*.xml`
- `modules/purchase_trade/view/purchase_line_form.xml`
- `modules/purchase_trade/view/sale_line_form.xml`
- Caveats:
- expiry is monitored through the `Expired` and `Expiring soon` domains;
- phase 1 does not block shipment or contract validation;
- lab delegation is a tracking field, not a complete lab workflow yet.

View File

@@ -0,0 +1,47 @@
# Trading cafe - echantillons et qualite
Page miroir: [coffee.en.md](coffee.en.md)
## BR-PT-COF-001 - Phase 1 echantillons cafe
Statut: active
Source: decision projet du 2026-06-29
### Regle consultant
Le trading cafe doit pouvoir piloter les echantillons rattaches aux lignes
d'achat et de vente.
Chaque echantillon porte:
- un type d'echantillon: offer, pre-shipment, shipment, arrival, stock,
customer ou retained;
- un sens purchase ou sale;
- un cycle de vie: requested, received, sent to lab, under review, approved,
rejected, expired, archived;
- une date d'expiration calculee depuis la date de demande/reception et la
duree de vie de l'echantillon;
- une notation de cupping simple et des donnees de qualite physique.
Les lignes `purchase.line` et `sale.line` portent les specifications qualite
cibles du cafe: origine, process, variete, crop year, screen size, humidite
maximum, nombre de defauts maximum et score cup minimum.
### Notes developpeur
- Modeles/champs:
- `coffee.sample`
- `purchase.line.coffee_samples`
- `sale.line.coffee_samples`
- champs `coffee_*` sur `purchase.line` et `sale.line`
- Fichiers:
- `modules/purchase_trade/coffee.py`
- `modules/purchase_trade/coffee.xml`
- `modules/purchase_trade/view/coffee_sample_*.xml`
- `modules/purchase_trade/view/purchase_line_form.xml`
- `modules/purchase_trade/view/sale_line_form.xml`
- Points de vigilance:
- l'expiration est pilotee par les domaines `Expired` et `Expiring soon`;
- aucun blocage shipment/validation n'est introduit en phase 1;
- la delegation labo reste une information de suivi, sans workflow labo
complet.

View File

@@ -22,6 +22,7 @@ from trytond.modules.purchase_trade import stock as stock_module
from trytond.modules.purchase_trade import fee as fee_module
from trytond.modules.purchase_trade import pricing as pricing_module
from trytond.modules.purchase_trade import duplicate as duplicate_module
from trytond.modules.purchase_trade import coffee as coffee_module
from trytond.modules.purchase_trade import company_defaults
from trytond.modules.purchase_trade.service import ContractFactory
@@ -30,6 +31,45 @@ class PurchaseTradeTestCase(ModuleTestCase):
'Test purchase_trade module'
module = 'purchase_trade'
def test_coffee_sample_cup_score_sums_cupping_criteria(self):
'coffee sample cup score sums criteria and subtracts defects'
score_fields = [
'aroma', 'flavor', 'aftertaste', 'acidity', 'body', 'balance',
'sweetness', 'clean_cup', 'uniformity', 'overall']
sample = SimpleNamespace(
defects=Decimal('2.00'),
_score_fields=lambda: score_fields)
for field_name in score_fields:
setattr(sample, field_name, Decimal('8.00'))
self.assertEqual(
coffee_module.CoffeeSample.get_total_score(sample),
Decimal('78.00'))
def test_coffee_sample_default_expiry_date_uses_shelf_life(self):
'coffee sample expiry date defaults from request date and shelf life'
values = {
'requested_date': datetime.date(2026, 6, 29),
'shelf_life_days': 30,
}
coffee_module.CoffeeSample._set_default_expiry_date(
values, force=True)
self.assertEqual(
values['expiry_date'], datetime.date(2026, 7, 29))
@with_transaction()
def test_purchase_line_coffee_quality_status_uses_samples(self):
'purchase line coffee quality status follows linked samples'
PurchaseLine = Pool().get('purchase.line')
line = PurchaseLine()
line.coffee_samples = [
Mock(state='approved', get_is_expired=Mock(return_value=False))]
self.assertEqual(
line.get_coffee_quality_status(), 'approved')
def test_itsa_book_year_suffix_uses_april_fiscal_start(self):
'ITSA book year changes on April 1st'
self.assertEqual(

View File

@@ -15,6 +15,7 @@ depends:
risk
xml:
global_reporting.xml
coffee.xml
purchase.xml
contract_clause_reference.xml
sale.xml
@@ -26,8 +27,8 @@ xml:
lc.xml
optional.xml
dashboard.xml
fee.xml
payment_term.xml
fee.xml
payment_term.xml
purchase_prepayment.xml
open_position.xml
credit_risk.xml

View File

@@ -0,0 +1,101 @@
<?xml version="1.0"?>
<form col="4">
<label name="reference"/>
<field name="reference"/>
<label name="state"/>
<field name="state"/>
<newline/>
<button name="receive" string="Receive"/>
<button name="send_to_lab" string="Send to lab"/>
<button name="review" string="Review"/>
<button name="approve" string="Approve"/>
<button name="reject" string="Reject"/>
<button name="expire" string="Expire"/>
<button name="archive" string="Archive"/>
<button name="reset_to_requested" string="Reset"/>
<newline/>
<label name="direction"/>
<field name="direction"/>
<label name="sample_type"/>
<field name="sample_type"/>
<label name="purchase_line"/>
<field name="purchase_line"/>
<label name="sale_line"/>
<field name="sale_line"/>
<label name="lot"/>
<field name="lot"/>
<label name="product"/>
<field name="product"/>
<label name="party"/>
<field name="party"/>
<newline/>
<separator string="Lifecycle" id="lifecycle" colspan="4"/>
<label name="requested_date"/>
<field name="requested_date"/>
<label name="received_date"/>
<field name="received_date"/>
<label name="sent_date"/>
<field name="sent_date"/>
<label name="review_date"/>
<field name="review_date"/>
<label name="decision_date"/>
<field name="decision_date"/>
<label name="shelf_life_days"/>
<field name="shelf_life_days"/>
<label name="expiry_date"/>
<field name="expiry_date"/>
<label name="expires_soon"/>
<field name="expires_soon"/>
<newline/>
<separator string="Sample" id="sample" colspan="4"/>
<label name="quantity"/>
<field name="quantity"/>
<label name="unit"/>
<field name="unit"/>
<label name="storage_location"/>
<field name="storage_location"/>
<label name="delegated_to_lab"/>
<field name="delegated_to_lab"/>
<label name="lab"/>
<field name="lab"/>
<label name="lab_due_date"/>
<field name="lab_due_date"/>
<newline/>
<separator string="Cupping" id="cupping" colspan="4"/>
<label name="aroma"/>
<field name="aroma"/>
<label name="flavor"/>
<field name="flavor"/>
<label name="aftertaste"/>
<field name="aftertaste"/>
<label name="acidity"/>
<field name="acidity"/>
<label name="body"/>
<field name="body"/>
<label name="balance"/>
<field name="balance"/>
<label name="sweetness"/>
<field name="sweetness"/>
<label name="clean_cup"/>
<field name="clean_cup"/>
<label name="uniformity"/>
<field name="uniformity"/>
<label name="overall"/>
<field name="overall"/>
<label name="defects"/>
<field name="defects"/>
<label name="total_score"/>
<field name="total_score"/>
<newline/>
<separator string="Physical quality" id="physical_quality" colspan="4"/>
<label name="moisture"/>
<field name="moisture"/>
<label name="screen_size"/>
<field name="screen_size"/>
<label name="defect_count"/>
<field name="defect_count"/>
<newline/>
<separator string="Notes" id="notes" colspan="4"/>
<field name="notes" colspan="4"/>
<field name="decision_note" colspan="4"/>
</form>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0"?>
<tree>
<field name="reference"/>
<field name="direction"/>
<field name="sample_type"/>
<field name="state"/>
<field name="party"/>
<field name="product"/>
<field name="received_date"/>
<field name="expiry_date"/>
<field name="total_score"/>
<field name="lab"/>
</tree>

View File

@@ -134,6 +134,28 @@ this repository contains the full copyright notices and license terms. -->
</page>
</xpath>
<xpath expr="/form/notebook/page[@id='general']" position="after">
<page string="Coffee Quality" col="4" id="coffee_quality">
<label name="coffee_quality_status"/>
<field name="coffee_quality_status"/>
<label name="coffee_origin"/>
<field name="coffee_origin"/>
<label name="coffee_process"/>
<field name="coffee_process"/>
<label name="coffee_variety"/>
<field name="coffee_variety"/>
<label name="coffee_crop_year"/>
<field name="coffee_crop_year"/>
<label name="coffee_screen_size"/>
<field name="coffee_screen_size"/>
<label name="coffee_moisture_max"/>
<field name="coffee_moisture_max"/>
<label name="coffee_defect_max"/>
<field name="coffee_defect_max"/>
<label name="coffee_cup_score_min"/>
<field name="coffee_cup_score_min"/>
<newline/>
<field name="coffee_samples" colspan="4" mode="tree,form" view_ids="purchase_trade.coffee_sample_view_tree,purchase_trade.coffee_sample_view_form"/>
</page>
<page string="Premium composition" col="4" id="premium_composition">
<field name="premium_decomposition" colspan="4" mode="tree,form" view_ids="purchase_trade.premium_composition_view_tree,purchase_trade.premium_composition_view_form"/>
</page>

View File

@@ -127,6 +127,28 @@ this repository contains the full copyright notices and license terms. -->
</page>
</xpath>
<xpath expr="/form/notebook/page[@id='general']" position="after">
<page string="Coffee Quality" col="4" id="coffee_quality">
<label name="coffee_quality_status"/>
<field name="coffee_quality_status"/>
<label name="coffee_origin"/>
<field name="coffee_origin"/>
<label name="coffee_process"/>
<field name="coffee_process"/>
<label name="coffee_variety"/>
<field name="coffee_variety"/>
<label name="coffee_crop_year"/>
<field name="coffee_crop_year"/>
<label name="coffee_screen_size"/>
<field name="coffee_screen_size"/>
<label name="coffee_moisture_max"/>
<field name="coffee_moisture_max"/>
<label name="coffee_defect_max"/>
<field name="coffee_defect_max"/>
<label name="coffee_cup_score_min"/>
<field name="coffee_cup_score_min"/>
<newline/>
<field name="coffee_samples" colspan="4" mode="tree,form" view_ids="purchase_trade.coffee_sample_view_tree,purchase_trade.coffee_sample_view_form"/>
</page>
<page string="Fees" col="4" id="fees">
<button name="apply_default_fees" icon="tryton-launch"/>
<field name="fees" colspan="4" view_ids="purchase_trade.fee_view_tree_sequence"/>