This commit is contained in:
2026-06-29 22:24:51 +02:00
parent cad7f7235e
commit 6fedb92b1a
13 changed files with 843 additions and 73 deletions

View File

@@ -102,6 +102,7 @@ def register():
coffee.CoffeeCuppingCup,
coffee.CoffeeCuppingResult,
coffee.CoffeeCuppingResultLine,
coffee.CoffeeLabAnalysis,
coffee.PurchaseLine,
coffee.SaleLine,
duplicate.TradeCustomDuplicateStart,

View File

@@ -117,12 +117,39 @@ class CoffeeSample(Workflow, ModelSQL, ModelView):
'Lab due date',
states={'invisible': ~Eval('delegated_to_lab')},
depends=['delegated_to_lab'])
lab_analyses = fields.One2Many(
'coffee.lab.analysis', 'sample', 'Lab analyses')
lab_analysis_status = fields.Function(fields.Selection([
(None, ''),
('none', 'No lab analysis'),
('pending', 'Pending'),
('received', 'Received'),
('accepted', 'Accepted'),
('rejected', 'Rejected'),
], 'Lab analysis'), 'get_lab_analysis_status')
moisture = fields.Numeric('Moisture %', digits=(5, 2))
screen_size = fields.Char('Screen size')
defect_count = fields.Integer('Defects count')
cupping_lines = fields.One2Many(
'coffee.cupping.session.sample', 'sample', 'Cupping sessions')
cupping_average_score = fields.Function(
fields.Numeric('Cupping score', digits=(5, 2)),
'get_cupping_average_score')
quality_status = fields.Function(fields.Selection([
(None, ''),
('pending', 'Pending'),
('pass', 'Pass'),
('warning', 'Warning'),
('fail', 'Fail'),
], 'Quality status'), 'get_quality_status')
recommended_decision = fields.Function(fields.Selection([
(None, ''),
('pending', 'Pending'),
('approve', 'Approve'),
('re_cup', 'Re-cup'),
('reject', 'Reject'),
], 'Recommended decision'), 'get_recommended_decision')
notes = fields.Text('Notes')
decision_note = fields.Text('Decision note')
@@ -136,6 +163,10 @@ class CoffeeSample(Workflow, ModelSQL, ModelView):
('sent_to_lab', 'under_review'),
('under_review', 'approved'),
('under_review', 'rejected'),
('received', 'approved'),
('received', 'rejected'),
('sent_to_lab', 'approved'),
('sent_to_lab', 'rejected'),
('requested', 'expired'),
('received', 'expired'),
('sent_to_lab', 'expired'),
@@ -163,7 +194,13 @@ class CoffeeSample(Workflow, ModelSQL, ModelView):
'depends': ['state'],
},
'reject': {
'invisible': Eval('state') != 'under_review',
'invisible': ~Eval('state').in_([
'received', 'sent_to_lab', 'under_review']),
'depends': ['state'],
},
'evaluate_quality': {
'invisible': ~Eval('state').in_([
'received', 'sent_to_lab', 'under_review']),
'depends': ['state'],
},
'expire': {
@@ -269,6 +306,105 @@ class CoffeeSample(Workflow, ModelSQL, ModelView):
line = self.purchase_line or self.sale_line
return getattr(line, 'coffee_type', None)
def _coffee_line(self):
return self.purchase_line or self.sale_line
def _latest_lab_analysis(self):
analyses = [
analysis for analysis in self.lab_analyses or []
if analysis.state not in {'cancelled'}]
if not analyses:
return None
return sorted(
analyses,
key=lambda analysis: (
analysis.received_date or analysis.sent_date
or analysis.requested_date or datetime.date.min,
getattr(analysis, 'id', 0) or 0),
reverse=True)[0]
def get_lab_analysis_status(self, name=None):
analysis = self._latest_lab_analysis()
if not analysis:
return 'none'
if analysis.state in {'accepted', 'rejected'}:
return analysis.state
if analysis.state == 'received':
return 'received'
return 'pending'
def _cupping_scores(self):
scores = []
for line in self.cupping_lines or []:
score = line.get_average_score()
if score is not None:
scores.append(score)
return scores
def get_cupping_average_score(self, name=None):
scores = self._cupping_scores()
if not scores:
return None
return sum(scores, Decimal(0)) / Decimal(len(scores))
def _quality_values(self):
analysis = self._latest_lab_analysis()
return {
'moisture': (
getattr(analysis, 'moisture', None)
if analysis and analysis.moisture is not None
else self.moisture),
'defect_count': (
getattr(analysis, 'defect_count', None)
if analysis and analysis.defect_count is not None
else self.defect_count),
'cupping_score': (
getattr(analysis, 'cup_score', None)
if analysis and analysis.cup_score is not None
else self.get_cupping_average_score()),
}
def get_quality_status(self, name=None):
line = self._coffee_line()
values = self._quality_values()
checks = []
moisture_max = getattr(line, 'coffee_moisture_max', None)
if moisture_max is not None and values['moisture'] is not None:
checks.append(values['moisture'] <= moisture_max)
defect_max = getattr(line, 'coffee_defect_max', None)
if defect_max is not None and values['defect_count'] is not None:
checks.append(values['defect_count'] <= defect_max)
score_min = getattr(line, 'coffee_cup_score_min', None)
if score_min is not None and values['cupping_score'] is not None:
checks.append(values['cupping_score'] >= score_min)
rejected_cupping = any(
cupping.decision == 'rejected'
for cupping in self.cupping_lines or [])
recup_cupping = any(
cupping.decision == 're_cup'
for cupping in self.cupping_lines or [])
if rejected_cupping:
return 'fail'
if recup_cupping:
return 'warning'
if not checks:
return 'pending'
if all(checks):
return 'pass'
if any(check is False for check in checks):
return 'fail'
return 'warning'
def get_recommended_decision(self, name=None):
status = self.get_quality_status()
if status == 'pass':
return 'approve'
if status == 'fail':
return 'reject'
if status == 'warning':
return 're_cup'
return 'pending'
def get_is_expired(self, name=None):
if self.state == 'expired':
return True
@@ -336,10 +472,28 @@ class CoffeeSample(Workflow, ModelSQL, ModelView):
@Workflow.transition('sent_to_lab')
def send_to_lab(cls, samples):
today = Pool().get('ir.date').today()
LabAnalysis = Pool().get('coffee.lab.analysis')
cls.write(samples, {
'delegated_to_lab': True,
'sent_date': today,
})
values = []
for sample in samples:
if not sample.lab:
continue
active_analyses = [
analysis for analysis in sample.lab_analyses or []
if analysis.state in {'draft', 'sent', 'received'}]
if active_analyses:
continue
values.append({
'sample': sample.id,
'lab': sample.lab.id,
'requested_date': today,
'due_date': sample.lab_due_date,
})
if values:
LabAnalysis.create(values)
@classmethod
@ModelView.button
@@ -362,6 +516,34 @@ class CoffeeSample(Workflow, ModelSQL, ModelView):
today = Pool().get('ir.date').today()
cls.write(samples, {'decision_date': today})
@classmethod
@ModelView.button
def evaluate_quality(cls, samples):
today = Pool().get('ir.date').today()
to_approve = []
to_reject = []
to_review = []
for sample in samples:
decision = sample.get_recommended_decision()
if decision == 'approve':
to_approve.append(sample)
elif decision == 'reject':
to_reject.append(sample)
else:
to_review.append(sample)
if to_approve:
cls.write(to_approve, {
'state': 'approved',
'decision_date': today,
})
if to_reject:
cls.write(to_reject, {
'state': 'rejected',
'decision_date': today,
})
if to_review:
cls.write(to_review, {'state': 'under_review'})
@classmethod
@ModelView.button
@Workflow.transition('expired')
@@ -736,6 +918,159 @@ class CoffeeCuppingResultLine(ModelSQL, ModelView):
notes = fields.Text('Notes')
class CoffeeLabAnalysis(Workflow, ModelSQL, ModelView):
'Coffee Lab Analysis'
__name__ = 'coffee.lab.analysis'
sample = fields.Many2One(
'coffee.sample', 'Sample', required=True, ondelete='CASCADE')
lab = fields.Many2One('party.party', 'Lab', required=True)
state = fields.Selection([
('draft', 'Draft'),
('sent', 'Sent'),
('received', 'Received'),
('accepted', 'Accepted'),
('rejected', 'Rejected'),
('cancelled', 'Cancelled'),
], 'State', readonly=True, required=True)
requested_date = fields.Date('Requested Date')
sent_date = fields.Date('Sent Date')
due_date = fields.Date('Due Date')
received_date = fields.Date('Received Date')
report_reference = fields.Char('Report reference')
moisture = fields.Numeric('Moisture %', digits=(5, 2))
screen_size = fields.Char('Screen size')
defect_count = fields.Integer('Defects count')
cup_score = fields.Numeric('Cup score', digits=(5, 2))
compliant = fields.Function(
fields.Boolean('Compliant'), 'get_compliant')
result_summary = fields.Text('Result summary')
@classmethod
def __setup__(cls):
super().__setup__()
cls._transitions |= set((
('draft', 'sent'),
('sent', 'received'),
('received', 'accepted'),
('received', 'rejected'),
('draft', 'cancelled'),
('sent', 'cancelled'),
('received', 'cancelled'),
('cancelled', 'draft'),
))
cls._buttons.update({
'send': {
'invisible': Eval('state') != 'draft',
'depends': ['state'],
},
'receive': {
'invisible': Eval('state') != 'sent',
'depends': ['state'],
},
'accept': {
'invisible': Eval('state') != 'received',
'depends': ['state'],
},
'reject': {
'invisible': Eval('state') != 'received',
'depends': ['state'],
},
'cancel': {
'invisible': Eval('state').in_([
'accepted', 'rejected', 'cancelled']),
'depends': ['state'],
},
'draft': {
'invisible': Eval('state') != 'cancelled',
'depends': ['state'],
},
})
@staticmethod
def default_state():
return 'draft'
@staticmethod
def default_requested_date():
return Pool().get('ir.date').today()
def get_compliant(self, name=None):
if self.state == 'rejected':
return False
if self.state == 'accepted':
return True
sample = self.sample
if not sample:
return False
line = sample._coffee_line()
checks = []
moisture_max = getattr(line, 'coffee_moisture_max', None)
if moisture_max is not None and self.moisture is not None:
checks.append(self.moisture <= moisture_max)
defect_max = getattr(line, 'coffee_defect_max', None)
if defect_max is not None and self.defect_count is not None:
checks.append(self.defect_count <= defect_max)
score_min = getattr(line, 'coffee_cup_score_min', None)
if score_min is not None and self.cup_score is not None:
checks.append(self.cup_score >= score_min)
return bool(checks and all(checks))
@classmethod
@ModelView.button
@Workflow.transition('sent')
def send(cls, analyses):
today = Pool().get('ir.date').today()
cls.write([a for a in analyses if not a.sent_date], {
'sent_date': today,
})
samples = [analysis.sample for analysis in analyses if analysis.sample]
if samples:
Pool().get('coffee.sample').write(samples, {
'delegated_to_lab': True,
'sent_date': today,
})
@classmethod
@ModelView.button
@Workflow.transition('received')
def receive(cls, analyses):
today = Pool().get('ir.date').today()
cls.write([a for a in analyses if not a.received_date], {
'received_date': today,
})
samples = [analysis.sample for analysis in analyses if analysis.sample]
if samples:
Pool().get('coffee.sample').write(samples, {
'review_date': today,
'state': 'under_review',
})
@classmethod
@ModelView.button
@Workflow.transition('accepted')
def accept(cls, analyses):
pass
@classmethod
@ModelView.button
@Workflow.transition('rejected')
def reject(cls, analyses):
pass
@classmethod
@ModelView.button
@Workflow.transition('cancelled')
def cancel(cls, analyses):
pass
@classmethod
@ModelView.button
@Workflow.transition('draft')
def draft(cls, analyses):
pass
class CoffeeLineMixin:
active_coffee_compatibility = fields.Function(
fields.Boolean('Active coffee compatibility'),
@@ -749,6 +1084,7 @@ class CoffeeLineMixin:
('approved', 'Approved'),
('rejected', 'Rejected'),
('expired', 'Expired'),
('warning', 'Warning'),
], 'Coffee quality'), 'get_coffee_quality_status')
coffee_origin = fields.Char('Coffee origin')
coffee_type = fields.Selection(COFFEE_TYPES, 'Coffee type')
@@ -778,6 +1114,8 @@ class CoffeeLineMixin:
return 'expired'
if any(sample.state == 'approved' for sample in samples):
return 'approved'
if any(sample.get_quality_status() == 'warning' for sample in samples):
return 'warning'
return 'pending'

View File

@@ -68,6 +68,16 @@ this repository contains the full copyright notices and license terms. -->
<field name="type">tree</field>
<field name="name">coffee_cupping_result_line_tree</field>
</record>
<record model="ir.ui.view" id="coffee_lab_analysis_view_tree">
<field name="model">coffee.lab.analysis</field>
<field name="type">tree</field>
<field name="name">coffee_lab_analysis_tree</field>
</record>
<record model="ir.ui.view" id="coffee_lab_analysis_view_form">
<field name="model">coffee.lab.analysis</field>
<field name="type">form</field>
<field name="name">coffee_lab_analysis_form</field>
</record>
<record model="ir.action.act_window" id="act_coffee_sample_form">
<field name="name">Coffee Samples</field>
@@ -147,11 +157,28 @@ this repository contains the full copyright notices and license terms. -->
<field name="view" ref="coffee_cupping_criterion_view_form"/>
<field name="act_window" ref="act_coffee_cupping_criterion_form"/>
</record>
<record model="ir.action.act_window" id="act_coffee_lab_analysis_form">
<field name="name">Lab Analyses</field>
<field name="res_model">coffee.lab.analysis</field>
</record>
<record model="ir.action.act_window.view" id="act_coffee_lab_analysis_form_view_tree">
<field name="sequence" eval="10"/>
<field name="view" ref="coffee_lab_analysis_view_tree"/>
<field name="act_window" ref="act_coffee_lab_analysis_form"/>
</record>
<record model="ir.action.act_window.view" id="act_coffee_lab_analysis_form_view_form">
<field name="sequence" eval="20"/>
<field name="view" ref="coffee_lab_analysis_view_form"/>
<field name="act_window" ref="act_coffee_lab_analysis_form"/>
</record>
<menuitem name="Coffee" parent="purchase.menu_purchase" sequence="18"
id="menu_coffee_purchase"/>
<menuitem name="Samples" parent="menu_coffee_purchase" sequence="10"
action="act_coffee_sample_form" id="menu_coffee_sample_purchase"/>
<menuitem name="Lab Analyses" parent="menu_coffee_purchase"
sequence="15" action="act_coffee_lab_analysis_form"
id="menu_coffee_lab_analysis_purchase"/>
<menuitem name="Cupping Sessions" parent="menu_coffee_purchase"
sequence="20" action="act_coffee_cupping_session_form"
id="menu_coffee_cupping_session_purchase"/>
@@ -162,6 +189,9 @@ this repository contains the full copyright notices and license terms. -->
id="menu_coffee_sale"/>
<menuitem name="Samples" parent="menu_coffee_sale" sequence="10"
action="act_coffee_sample_form" id="menu_coffee_sample_sale"/>
<menuitem name="Lab Analyses" parent="menu_coffee_sale"
sequence="15" action="act_coffee_lab_analysis_form"
id="menu_coffee_lab_analysis_sale"/>
<menuitem name="Cupping Sessions" parent="menu_coffee_sale"
sequence="20" action="act_coffee_cupping_session_form"
id="menu_coffee_cupping_session_sale"/>
@@ -174,6 +204,9 @@ this repository contains the full copyright notices and license terms. -->
<record model="ir.ui.menu" id="menu_coffee_sample_purchase">
<field name="active" eval="False"/>
</record>
<record model="ir.ui.menu" id="menu_coffee_lab_analysis_purchase">
<field name="active" eval="False"/>
</record>
<record model="ir.ui.menu" id="menu_coffee_cupping_session_purchase">
<field name="active" eval="False"/>
</record>
@@ -186,6 +219,9 @@ this repository contains the full copyright notices and license terms. -->
<record model="ir.ui.menu" id="menu_coffee_sample_sale">
<field name="active" eval="False"/>
</record>
<record model="ir.ui.menu" id="menu_coffee_lab_analysis_sale">
<field name="active" eval="False"/>
</record>
<record model="ir.ui.menu" id="menu_coffee_cupping_session_sale">
<field name="active" eval="False"/>
</record>
@@ -218,6 +254,11 @@ this repository contains the full copyright notices and license terms. -->
<field name="name">reject</field>
<field name="string">Reject</field>
</record>
<record model="ir.model.button" id="coffee_sample_evaluate_quality_button">
<field name="model">coffee.sample</field>
<field name="name">evaluate_quality</field>
<field name="string">Evaluate quality</field>
</record>
<record model="ir.model.button" id="coffee_sample_expire_button">
<field name="model">coffee.sample</field>
<field name="name">expire</field>
@@ -258,6 +299,36 @@ this repository contains the full copyright notices and license terms. -->
<field name="name">draft</field>
<field name="string">Draft</field>
</record>
<record model="ir.model.button" id="coffee_lab_analysis_send_button">
<field name="model">coffee.lab.analysis</field>
<field name="name">send</field>
<field name="string">Send</field>
</record>
<record model="ir.model.button" id="coffee_lab_analysis_receive_button">
<field name="model">coffee.lab.analysis</field>
<field name="name">receive</field>
<field name="string">Receive</field>
</record>
<record model="ir.model.button" id="coffee_lab_analysis_accept_button">
<field name="model">coffee.lab.analysis</field>
<field name="name">accept</field>
<field name="string">Accept</field>
</record>
<record model="ir.model.button" id="coffee_lab_analysis_reject_button">
<field name="model">coffee.lab.analysis</field>
<field name="name">reject</field>
<field name="string">Reject</field>
</record>
<record model="ir.model.button" id="coffee_lab_analysis_cancel_button">
<field name="model">coffee.lab.analysis</field>
<field name="name">cancel</field>
<field name="string">Cancel</field>
</record>
<record model="ir.model.button" id="coffee_lab_analysis_draft_button">
<field name="model">coffee.lab.analysis</field>
<field name="name">draft</field>
<field name="string">Draft</field>
</record>
<record model="ir.model.access" id="access_coffee_sample">
<field name="model">coffee.sample</field>
@@ -308,5 +379,12 @@ this repository contains the full copyright notices and license terms. -->
<field name="perm_create" eval="True"/>
<field name="perm_delete" eval="True"/>
</record>
<record model="ir.model.access" id="access_coffee_lab_analysis">
<field name="model">coffee.lab.analysis</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>
</data>
</tryton>

View File

@@ -110,10 +110,12 @@ class Configuration(ModelSingleton, ModelSQL, ModelView):
_COFFEE_MENU_XML_IDS = (
'menu_coffee_purchase',
'menu_coffee_sample_purchase',
'menu_coffee_lab_analysis_purchase',
'menu_coffee_cupping_session_purchase',
'menu_coffee_cupping_criterion_purchase',
'menu_coffee_sale',
'menu_coffee_sample_sale',
'menu_coffee_lab_analysis_sale',
'menu_coffee_cupping_session_sale',
'menu_coffee_cupping_criterion_sale',
)

View File

@@ -114,3 +114,57 @@ defect count and minimum cup score.
</ul>
</li>
</ul>
## BR-PT-COF-002 - Phase 2 lab delegation
Status: active
Source: project decision on 2026-06-29
### Business rule
A coffee sample can be delegated to a laboratory. The delegation creates and
tracks lab analyses linked to the sample:
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">analysis lifecycle: draft, sent, received, accepted, rejected, cancelled;
</li>
<li style="margin:0.38rem 0;">request, sent, due and received dates;
</li>
<li style="margin:0.38rem 0;">lab report reference;
</li>
<li style="margin:0.38rem 0;">physical and organoleptic results: moisture, screen size, defect count and cup score;
</li>
<li style="margin:0.38rem 0;">compliance check against the target purchase/sale line thresholds.
</li>
</ul>
The sample `Send to lab` button creates an active lab request when a lab is set
and no open analysis already exists.
## BR-PT-COF-003 - Phase 3 consolidated quality decision
Status: active
Source: project decision on 2026-06-29
### Business rule
The quality decision of a sample is consolidated from:
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">the target line quality thresholds;
</li>
<li style="margin:0.38rem 0;">the latest non-cancelled lab analysis;
</li>
<li style="margin:0.38rem 0;">the average cupping results;
</li>
<li style="margin:0.38rem 0;">cupping session decisions, including <code>re-cup</code> and <code>rejected</code>.
</li>
</ul>
The sample exposes a quality status (`pending`, `pass`, `warning`, `fail`) and
a recommended decision (`approve`, `re-cup`, `reject`). The `Evaluate quality`
button applies the recommendation: approval, rejection or review when the
sample must be cupped again.
These features remain under `Active coffee compatibility` and do not add a
cross-flow blocking rule for non-coffee flows.

View File

@@ -115,3 +115,57 @@ maximum, nombre de defauts maximum et score cup minimum.
</ul>
</li>
</ul>
## BR-PT-COF-002 - Phase 2 delegation labo
Statut: active
Source: decision projet du 2026-06-29
### Regle consultant
Un echantillon cafe peut etre delegue a un laboratoire. La delegation cree et
pilote des analyses labo rattachees a l'echantillon:
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">cycle de vie analyse: draft, sent, received, accepted, rejected, cancelled;
</li>
<li style="margin:0.38rem 0;">dates de demande, envoi, echeance et reception;
</li>
<li style="margin:0.38rem 0;">reference de rapport labo;
</li>
<li style="margin:0.38rem 0;">resultats physiques et organoleptiques: humidite, screen size, nombre de defauts, cup score;
</li>
<li style="margin:0.38rem 0;">controle de conformite par rapport aux seuils de la ligne purchase/sale.
</li>
</ul>
Le bouton `Send to lab` de l'echantillon cree une demande labo active si un
labo est renseigne et s'il n'existe pas deja une analyse ouverte.
## BR-PT-COF-003 - Phase 3 decision qualite consolidee
Statut: active
Source: decision projet du 2026-06-29
### Regle consultant
La decision qualite d'un echantillon est consolidee depuis:
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">les seuils qualite de la ligne cible;
</li>
<li style="margin:0.38rem 0;">la derniere analyse labo non annulee;
</li>
<li style="margin:0.38rem 0;">les resultats moyens de cupping;
</li>
<li style="margin:0.38rem 0;">les decisions de seance de cupping, dont <code>re-cup</code> et <code>rejected</code>.
</li>
</ul>
L'echantillon expose un statut qualite (`pending`, `pass`, `warning`, `fail`)
et une decision recommandee (`approve`, `re-cup`, `reject`). Le bouton
`Evaluate quality` applique la recommandation: approbation, rejet ou retour en
revue si l'echantillon doit etre re-deguste.
Ces fonctions restent sous `Active coffee compatibility` et n'ajoutent pas de
blocage transverse sur les flux non cafe.

View File

@@ -70,3 +70,45 @@ defect count and minimum cup score.
- lab delegation is a tracking field, not a complete lab workflow yet;
- coffee menus are inactive by default and synchronized when the
configuration is saved.
## BR-PT-COF-002 - Phase 2 lab delegation
Status: active
Source: project decision on 2026-06-29
### Business rule
A coffee sample can be delegated to a laboratory. The delegation creates and
tracks lab analyses linked to the sample:
- analysis lifecycle: draft, sent, received, accepted, rejected, cancelled;
- request, sent, due and received dates;
- lab report reference;
- physical and organoleptic results: moisture, screen size, defect count and
cup score;
- compliance check against the target purchase/sale line thresholds.
The sample `Send to lab` button creates an active lab request when a lab is set
and no open analysis already exists.
## BR-PT-COF-003 - Phase 3 consolidated quality decision
Status: active
Source: project decision on 2026-06-29
### Business rule
The quality decision of a sample is consolidated from:
- the target line quality thresholds;
- the latest non-cancelled lab analysis;
- the average cupping results;
- cupping session decisions, including `re-cup` and `rejected`.
The sample exposes a quality status (`pending`, `pass`, `warning`, `fail`) and
a recommended decision (`approve`, `re-cup`, `reject`). The `Evaluate quality`
button applies the recommendation: approval, rejection or review when the
sample must be cupped again.
These features remain under `Active coffee compatibility` and do not add a
cross-flow blocking rule for non-coffee flows.

View File

@@ -75,3 +75,45 @@ maximum, nombre de defauts maximum et score cup minimum.
complet;
- les menus cafe sont inactifs par defaut et synchronises quand la
configuration est sauvegardee.
## BR-PT-COF-002 - Phase 2 delegation labo
Statut: active
Source: decision projet du 2026-06-29
### Regle consultant
Un echantillon cafe peut etre delegue a un laboratoire. La delegation cree et
pilote des analyses labo rattachees a l'echantillon:
- cycle de vie analyse: draft, sent, received, accepted, rejected, cancelled;
- dates de demande, envoi, echeance et reception;
- reference de rapport labo;
- resultats physiques et organoleptiques: humidite, screen size, nombre de
defauts, cup score;
- controle de conformite par rapport aux seuils de la ligne purchase/sale.
Le bouton `Send to lab` de l'echantillon cree une demande labo active si un
labo est renseigne et s'il n'existe pas deja une analyse ouverte.
## BR-PT-COF-003 - Phase 3 decision qualite consolidee
Statut: active
Source: decision projet du 2026-06-29
### Regle consultant
La decision qualite d'un echantillon est consolidee depuis:
- les seuils qualite de la ligne cible;
- la derniere analyse labo non annulee;
- les resultats moyens de cupping;
- les decisions de seance de cupping, dont `re-cup` et `rejected`.
L'echantillon expose un statut qualite (`pending`, `pass`, `warning`, `fail`)
et une decision recommandee (`approve`, `re-cup`, `reject`). Le bouton
`Evaluate quality` applique la recommandation: approbation, rejet ou retour en
revue si l'echantillon doit etre re-deguste.
Ces fonctions restent sous `Active coffee compatibility` et n'ajoutent pas de
blocage transverse sur les flux non cafe.

View File

@@ -79,6 +79,54 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual([line.sample.reference for line in ordered], ['A', 'R'])
def test_coffee_lab_analysis_compliance_uses_line_targets(self):
'coffee lab analysis compliance checks sample line targets'
line = SimpleNamespace(
coffee_moisture_max=Decimal('12.50'),
coffee_defect_max=8,
coffee_cup_score_min=Decimal('82.00'))
sample = SimpleNamespace(_coffee_line=lambda: line)
analysis = SimpleNamespace(
state='received',
sample=sample,
moisture=Decimal('12.00'),
defect_count=6,
cup_score=Decimal('84.00'))
self.assertTrue(
coffee_module.CoffeeLabAnalysis.get_compliant(analysis))
def test_coffee_sample_quality_recommends_reject_on_failed_targets(self):
'coffee sample quality recommendation rejects failed target values'
line = SimpleNamespace(
coffee_moisture_max=Decimal('12.50'),
coffee_defect_max=8,
coffee_cup_score_min=Decimal('82.00'))
sample = SimpleNamespace(
purchase_line=line,
sale_line=None,
moisture=Decimal('13.00'),
defect_count=6,
lab_analyses=[],
cupping_lines=[],
get_cupping_average_score=lambda: Decimal('84.00'),
_coffee_line=lambda: line,
_latest_lab_analysis=lambda: None,
_quality_values=lambda: {
'moisture': Decimal('13.00'),
'defect_count': 6,
'cupping_score': Decimal('84.00'),
})
sample.get_quality_status = (
lambda: coffee_module.CoffeeSample.get_quality_status(sample))
self.assertEqual(
coffee_module.CoffeeSample.get_quality_status(sample),
'fail')
self.assertEqual(
coffee_module.CoffeeSample.get_recommended_decision(sample),
'reject')
def test_coffee_sample_default_expiry_date_uses_shelf_life(self):
'coffee sample expiry date defaults from request date and shelf life'
values = {

View File

@@ -0,0 +1,55 @@
<?xml version="1.0"?>
<form col="12" col_widths="1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr">
<group id="lab_summary" colspan="12" col="8" panel="summary"
xalign="0" yalign="0"
col_widths="min-content,1fr,min-content,1fr,min-content,1fr,min-content,1fr">
<label name="sample"/>
<field name="sample"/>
<label name="lab"/>
<field name="lab"/>
<label name="state"/>
<field name="state"/>
<label name="compliant"/>
<field name="compliant"/>
</group>
<button name="send" string="Send"/>
<button name="receive" string="Receive"/>
<button name="accept" string="Accept"/>
<button name="reject" string="Reject"/>
<button name="cancel" string="Cancel"/>
<button name="draft" string="Draft"/>
<newline/>
<group id="lab_dates" string="Dates"
colspan="6" col="4" panel="card" icon="tryton-date"
xalign="0" yalign="0"
col_widths="min-content,1fr,min-content,1fr">
<label name="requested_date"/>
<field name="requested_date"/>
<label name="sent_date"/>
<field name="sent_date"/>
<label name="due_date"/>
<field name="due_date"/>
<label name="received_date"/>
<field name="received_date"/>
</group>
<group id="lab_results" string="Results"
colspan="6" col="4" panel="card" icon="tryton-note"
xalign="0" yalign="0"
col_widths="min-content,1fr,min-content,1fr">
<label name="report_reference"/>
<field name="report_reference"/>
<label name="moisture"/>
<field name="moisture" width="120" xexpand="0" xfill="0"/>
<label name="screen_size"/>
<field name="screen_size"/>
<label name="defect_count"/>
<field name="defect_count" width="120" xexpand="0" xfill="0"/>
<label name="cup_score"/>
<field name="cup_score" width="120" xexpand="0" xfill="0"/>
</group>
<group id="lab_notes" string="Summary"
colspan="12" col="1" panel="card" icon="tryton-note"
xalign="0" yalign="0" col_widths="1fr">
<field name="result_summary"/>
</group>
</form>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0"?>
<tree>
<field name="sample"/>
<field name="lab"/>
<field name="state"/>
<field name="sent_date"/>
<field name="due_date"/>
<field name="received_date"/>
<field name="report_reference"/>
<field name="moisture"/>
<field name="defect_count"/>
<field name="cup_score"/>
<field name="compliant"/>
</tree>

View File

@@ -1,83 +1,121 @@
<?xml version="1.0"?>
<form col="4">
<label name="reference"/>
<field name="reference"/>
<label name="state"/>
<field name="state"/>
<newline/>
<form col="12" col_widths="1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr">
<group id="sample_summary" colspan="12" col="8" panel="summary"
xalign="0" yalign="0"
col_widths="min-content,1fr,min-content,1fr,min-content,1fr,min-content,1fr">
<label name="reference"/>
<field name="reference"/>
<label name="state"/>
<field name="state"/>
<label name="quality_status"/>
<field name="quality_status"/>
<label name="recommended_decision"/>
<field name="recommended_decision"/>
</group>
<button name="receive" string="Receive"/>
<button name="send_to_lab" string="Send to lab"/>
<button name="review" string="Review"/>
<button name="evaluate_quality" string="Evaluate quality"/>
<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"/>
<label name="public_origin"/>
<field name="public_origin"/>
<label name="public_coffee_type"/>
<field name="public_coffee_type"/>
<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="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="Cupping sessions" id="cupping" colspan="4"/>
<field name="cupping_lines" colspan="4" mode="tree"
view_ids="purchase_trade.coffee_cupping_session_sample_view_tree"/>
<newline/>
<separator string="Notes" id="notes" colspan="4"/>
<field name="notes" colspan="4"/>
<field name="decision_note" colspan="4"/>
<group id="sample_identity" string="Sample identity"
colspan="6" col="4" panel="card" icon="tryton-public"
xalign="0" yalign="0"
col_widths="min-content,1fr,min-content,1fr">
<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"/>
<label name="public_origin"/>
<field name="public_origin"/>
<label name="public_coffee_type"/>
<field name="public_coffee_type"/>
</group>
<group id="sample_lifecycle" string="Lifecycle"
colspan="6" col="4" panel="card" icon="tryton-date"
xalign="0" yalign="0"
col_widths="min-content,1fr,min-content,1fr">
<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"/>
</group>
<group id="sample_storage" string="Sample storage"
colspan="6" col="4" panel="card" icon="tryton-archive"
xalign="0" yalign="0"
col_widths="min-content,1fr,min-content,1fr">
<label name="quantity"/>
<field name="quantity"/>
<label name="unit"/>
<field name="unit"/>
<label name="storage_location"/>
<field name="storage_location"/>
</group>
<group id="sample_quality" string="Quality values"
colspan="6" col="4" panel="card" icon="tryton-note"
xalign="0" yalign="0"
col_widths="min-content,1fr,min-content,1fr">
<label name="moisture"/>
<field name="moisture" width="120" xexpand="0" xfill="0"/>
<label name="screen_size"/>
<field name="screen_size"/>
<label name="defect_count"/>
<field name="defect_count" width="120" xexpand="0" xfill="0"/>
<label name="cupping_average_score"/>
<field name="cupping_average_score" width="120" xexpand="0" xfill="0"/>
<label name="lab_analysis_status"/>
<field name="lab_analysis_status"/>
</group>
<group id="sample_lab" string="Lab delegation"
colspan="12" col="4" panel="card" icon="tryton-list"
xalign="0" yalign="0"
col_widths="min-content,1fr,min-content,1fr">
<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/>
<field name="lab_analyses" colspan="4" mode="tree,form"
view_ids="purchase_trade.coffee_lab_analysis_view_tree,purchase_trade.coffee_lab_analysis_view_form"/>
</group>
<group id="sample_cupping" string="Cupping sessions"
colspan="12" col="1" panel="card" icon="tryton-list"
xalign="0" yalign="0" col_widths="1fr">
<field name="cupping_lines" colspan="1" mode="tree"
view_ids="purchase_trade.coffee_cupping_session_sample_view_tree"/>
</group>
<group id="sample_notes" string="Notes"
colspan="12" col="1" panel="card" icon="tryton-note"
xalign="0" yalign="0" col_widths="1fr">
<field name="notes"/>
<field name="decision_note"/>
</group>
</form>

View File

@@ -4,11 +4,15 @@
<field name="direction"/>
<field name="sample_type"/>
<field name="state"/>
<field name="quality_status"/>
<field name="recommended_decision"/>
<field name="party"/>
<field name="product"/>
<field name="public_origin"/>
<field name="public_coffee_type"/>
<field name="received_date"/>
<field name="expiry_date"/>
<field name="lab_analysis_status"/>
<field name="cupping_average_score"/>
<field name="lab"/>
</tree>