diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py
index ea5da59..22acbd7 100755
--- a/modules/purchase_trade/__init__.py
+++ b/modules/purchase_trade/__init__.py
@@ -102,6 +102,7 @@ def register():
coffee.CoffeeCuppingCup,
coffee.CoffeeCuppingResult,
coffee.CoffeeCuppingResultLine,
+ coffee.CoffeeLabAnalysis,
coffee.PurchaseLine,
coffee.SaleLine,
duplicate.TradeCustomDuplicateStart,
diff --git a/modules/purchase_trade/coffee.py b/modules/purchase_trade/coffee.py
index f5d83da..56b7a10 100644
--- a/modules/purchase_trade/coffee.py
+++ b/modules/purchase_trade/coffee.py
@@ -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'
diff --git a/modules/purchase_trade/coffee.xml b/modules/purchase_trade/coffee.xml
index 2dbcdd2..1dddf7c 100644
--- a/modules/purchase_trade/coffee.xml
+++ b/modules/purchase_trade/coffee.xml
@@ -68,6 +68,16 @@ this repository contains the full copyright notices and license terms. -->
re-cup and rejected.
+re-cup et rejected.
+