From f2947c9f86169da1f36c589e6b32033ac8035d7c Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Mon, 29 Jun 2026 21:16:42 +0200 Subject: [PATCH] Coffee --- modules/purchase_trade/__init__.py | 6 + modules/purchase_trade/coffee.py | 422 ++++++++++++++++-- modules/purchase_trade/coffee.xml | 208 ++++++++- modules/purchase_trade/configuration.py | 42 +- .../purchase_trade/docs/business/coffee.en.md | 46 +- .../purchase_trade/docs/business/coffee.md | 46 +- .../docs_source/business/coffee.en.md | 31 +- .../docs_source/business/coffee.md | 34 +- modules/purchase_trade/tests/test_module.py | 57 ++- .../view/coffee_cupping_criterion_form.xml | 17 + .../view/coffee_cupping_criterion_tree.xml | 9 + .../view/coffee_cupping_cup_form.xml | 18 + .../view/coffee_cupping_cup_tree.xml | 9 + .../view/coffee_cupping_result_form.xml | 18 + .../view/coffee_cupping_result_line_tree.xml | 7 + .../view/coffee_cupping_result_tree.xml | 8 + .../view/coffee_cupping_session_form.xml | 31 ++ .../coffee_cupping_session_sample_form.xml | 26 ++ .../coffee_cupping_session_sample_tree.xml | 12 + .../view/coffee_cupping_session_tree.xml | 11 + .../view/coffee_sample_form.xml | 34 +- .../view/coffee_sample_tree.xml | 3 +- .../view/configuration_form.xml | 2 + .../view/purchase_line_form.xml | 6 +- .../purchase_trade/view/sale_line_form.xml | 6 +- 25 files changed, 1015 insertions(+), 94 deletions(-) create mode 100644 modules/purchase_trade/view/coffee_cupping_criterion_form.xml create mode 100644 modules/purchase_trade/view/coffee_cupping_criterion_tree.xml create mode 100644 modules/purchase_trade/view/coffee_cupping_cup_form.xml create mode 100644 modules/purchase_trade/view/coffee_cupping_cup_tree.xml create mode 100644 modules/purchase_trade/view/coffee_cupping_result_form.xml create mode 100644 modules/purchase_trade/view/coffee_cupping_result_line_tree.xml create mode 100644 modules/purchase_trade/view/coffee_cupping_result_tree.xml create mode 100644 modules/purchase_trade/view/coffee_cupping_session_form.xml create mode 100644 modules/purchase_trade/view/coffee_cupping_session_sample_form.xml create mode 100644 modules/purchase_trade/view/coffee_cupping_session_sample_tree.xml create mode 100644 modules/purchase_trade/view/coffee_cupping_session_tree.xml diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py index eff7daa..1dbd875 100755 --- a/modules/purchase_trade/__init__.py +++ b/modules/purchase_trade/__init__.py @@ -96,6 +96,12 @@ def register(): pricing.ImportPricesStart, pricing.ImportPricesResult, coffee.CoffeeSample, + coffee.CoffeeCuppingCriterion, + coffee.CoffeeCuppingSession, + coffee.CoffeeCuppingSessionSample, + coffee.CoffeeCuppingCup, + coffee.CoffeeCuppingResult, + coffee.CoffeeCuppingResultLine, duplicate.TradeCustomDuplicateStart, module='purchase_trade', type_='model') Pool.register( diff --git a/modules/purchase_trade/coffee.py b/modules/purchase_trade/coffee.py index ecd8fce..878115c 100644 --- a/modules/purchase_trade/coffee.py +++ b/modules/purchase_trade/coffee.py @@ -6,6 +6,7 @@ from decimal import Decimal from trytond.model import ModelSQL, ModelView, Workflow, fields from trytond.pool import Pool, PoolMeta from trytond.pyson import Eval +from trytond.exceptions import UserError COFFEE_SAMPLE_TYPES = [ @@ -28,6 +29,25 @@ COFFEE_PROCESS_TYPES = [ ('other', 'Other'), ] +COFFEE_TYPES = [ + (None, ''), + ('arabica', 'Arabica'), + ('robusta', 'Robusta'), + ('blend', 'Blend'), + ('other', 'Other'), +] + + +def coffee_compatibility_active(): + try: + Configuration = Pool().get('purchase_trade.configuration') + configurations = Configuration.search([], limit=1) + except Exception: + return False + return bool( + configurations + and configurations[0].active_coffee_compatibility) + class CoffeeSample(ModelSQL, ModelView, Workflow): 'Coffee Sample' @@ -66,6 +86,10 @@ class CoffeeSample(ModelSQL, ModelView, Workflow): fields.Many2One('product.product', 'Product'), 'get_product') party = fields.Function( fields.Many2One('party.party', 'Party'), 'get_party') + public_origin = fields.Function( + fields.Char('Origin'), 'get_public_origin') + public_coffee_type = fields.Function( + fields.Selection(COFFEE_TYPES, 'Coffee type'), 'get_public_coffee_type') requested_date = fields.Date('Requested Date') received_date = fields.Date('Received Date') @@ -94,23 +118,11 @@ class CoffeeSample(ModelSQL, ModelView, Workflow): 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') + cupping_lines = fields.One2Many( + 'coffee.cupping.session.sample', 'sample', 'Cupping sessions') notes = fields.Text('Notes') decision_note = fields.Text('Decision note') @@ -249,21 +261,13 @@ class CoffeeSample(ModelSQL, ModelView, Workflow): 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_public_origin(self, name=None): + line = self.purchase_line or self.sale_line + return getattr(line, 'coffee_origin', None) - 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_public_coffee_type(self, name=None): + line = self.purchase_line or self.sale_line + return getattr(line, 'coffee_type', None) def get_is_expired(self, name=None): if self.state == 'expired': @@ -377,7 +381,365 @@ class CoffeeSample(ModelSQL, ModelView, Workflow): pass +class CoffeeCuppingCriterion(ModelSQL, ModelView): + 'Coffee Cupping Criterion' + __name__ = 'coffee.cupping.criterion' + + name = fields.Char('Name', required=True) + sequence = fields.Integer('Sequence') + active = fields.Boolean('Active') + kind = fields.Selection([ + ('score', 'Score'), + ('defect', 'Defect'), + ('text', 'Text'), + ], 'Kind', required=True) + max_score = fields.Numeric('Max score', digits=(5, 2)) + subtract_from_score = fields.Boolean('Subtract from score') + description = fields.Text('Description') + + @staticmethod + def default_sequence(): + return 10 + + @staticmethod + def default_active(): + return True + + @staticmethod + def default_kind(): + return 'score' + + @staticmethod + def default_max_score(): + return Decimal('10.00') + + +class CoffeeCuppingSession(ModelSQL, ModelView, Workflow): + 'Coffee Cupping Session' + __name__ = 'coffee.cupping.session' + + name = fields.Char('Name', required=True) + date = fields.Date('Date') + responsible = fields.Many2One('party.party', 'Responsible') + location = fields.Char('Location') + cup_capacity = fields.Integer('Cup capacity') + cups_per_sample = fields.Integer('Cups per sample') + state = fields.Selection([ + ('draft', 'Draft'), + ('prepared', 'Prepared'), + ('in_progress', 'In progress'), + ('done', 'Done'), + ('cancelled', 'Cancelled'), + ], 'State', readonly=True, required=True) + lines = fields.One2Many( + 'coffee.cupping.session.sample', 'session', 'Samples') + total_cups = fields.Function( + fields.Integer('Total cups'), 'get_total_cups') + remaining_cups = fields.Function( + fields.Integer('Remaining cups'), 'get_remaining_cups') + + @classmethod + def __setup__(cls): + super().__setup__() + cls._transitions |= set(( + ('draft', 'prepared'), + ('prepared', 'in_progress'), + ('in_progress', 'done'), + ('draft', 'cancelled'), + ('prepared', 'cancelled'), + ('in_progress', 'cancelled'), + ('cancelled', 'draft'), + )) + cls._buttons.update({ + 'prepare': { + 'invisible': Eval('state') != 'draft', + 'depends': ['state'], + }, + 'start': { + 'invisible': Eval('state') != 'prepared', + 'depends': ['state'], + }, + 'done': { + 'invisible': Eval('state') != 'in_progress', + 'depends': ['state'], + }, + 'cancel': { + 'invisible': Eval('state').in_(['done', 'cancelled']), + 'depends': ['state'], + }, + 'draft': { + 'invisible': Eval('state') != 'cancelled', + 'depends': ['state'], + }, + }) + + @staticmethod + def default_state(): + return 'draft' + + @staticmethod + def default_date(): + return Pool().get('ir.date').today() + + @staticmethod + def default_cup_capacity(): + return 24 + + @staticmethod + def default_cups_per_sample(): + return 3 + + @classmethod + def _total_requested_cups(cls, lines): + total = 0 + for line in lines: + session = getattr(line, 'session', None) + total += ( + line.cups_per_sample + or getattr(session, 'cups_per_sample', 0) + or 0) + return total + + def get_total_cups(self, name=None): + return self._total_requested_cups(self.lines or []) + + def get_remaining_cups(self, name=None): + capacity = self.cup_capacity or 0 + return capacity - self.get_total_cups() + + @staticmethod + def _coffee_type_order(coffee_type): + return { + 'arabica': 10, + 'blend': 20, + 'other': 30, + 'robusta': 40, + }.get(coffee_type, 30) + + @classmethod + def _line_sort_key(cls, line): + sample = getattr(line, 'sample', None) + coffee_type = sample.get_public_coffee_type() if sample else None + origin = sample.get_public_origin() if sample else '' + return ( + cls._coffee_type_order(coffee_type), + origin or '', + getattr(sample, 'reference', '') or '', + getattr(sample, 'id', 0) or 0, + ) + + @classmethod + @ModelView.button + @Workflow.transition('prepared') + def prepare(cls, sessions): + Cup = Pool().get('coffee.cupping.cup') + Line = Pool().get('coffee.cupping.session.sample') + for session in sessions: + lines = list(session.lines or []) + total_cups = cls._total_requested_cups(lines) + if session.cup_capacity and total_cups > session.cup_capacity: + raise UserError( + 'The cupping session exceeds the room cup capacity.') + for sequence, line in enumerate( + sorted(lines, key=cls._line_sort_key), 1): + if line.sequence != sequence: + Line.write([line], {'sequence': sequence}) + existing_cups = list(line.cups or []) + missing = (line.cups_per_sample or session.cups_per_sample or 0) + missing -= len(existing_cups) + if missing <= 0: + continue + values = [] + start = len(existing_cups) + 1 + for cup_no in range(start, start + missing): + values.append({ + 'session_sample': line.id, + 'cup_no': cup_no, + 'blind_code': 'S%02d-C%d' % (sequence, cup_no), + }) + Cup.create(values) + + @classmethod + @ModelView.button + @Workflow.transition('in_progress') + def start(cls, sessions): + pass + + @classmethod + @ModelView.button + @Workflow.transition('done') + def done(cls, sessions): + pass + + @classmethod + @ModelView.button + @Workflow.transition('cancelled') + def cancel(cls, sessions): + pass + + @classmethod + @ModelView.button + @Workflow.transition('draft') + def draft(cls, sessions): + pass + + +class CoffeeCuppingSessionSample(ModelSQL, ModelView): + 'Coffee Cupping Session Sample' + __name__ = 'coffee.cupping.session.sample' + + session = fields.Many2One( + 'coffee.cupping.session', 'Session', required=True, + ondelete='CASCADE') + sample = fields.Many2One( + 'coffee.sample', 'Sample', required=True, ondelete='CASCADE') + sequence = fields.Integer('Order') + cups_per_sample = fields.Integer('Cups') + decision = fields.Selection([ + ('pending', 'Pending'), + ('approved', 'Approved'), + ('re_cup', 'Re-cup'), + ('rejected', 'Rejected'), + ], 'Decision', required=True) + decision_note = fields.Text('Decision note') + public_origin = fields.Function( + fields.Char('Origin'), 'get_public_origin') + public_coffee_type = fields.Function( + fields.Selection(COFFEE_TYPES, 'Coffee type'), 'get_public_coffee_type') + average_score = fields.Function( + fields.Numeric('Average score', digits=(5, 2)), 'get_average_score') + defect_count = fields.Function( + fields.Integer('Defect reports'), 'get_defect_count') + result_count = fields.Function( + fields.Integer('Results'), 'get_result_count') + cups = fields.One2Many( + 'coffee.cupping.cup', 'session_sample', 'Cups') + + @staticmethod + def default_decision(): + return 'pending' + + def get_public_origin(self, name=None): + return self.sample.get_public_origin() if self.sample else None + + def get_public_coffee_type(self, name=None): + return self.sample.get_public_coffee_type() if self.sample else None + + def _results(self): + results = [] + for cup in self.cups or []: + results.extend(cup.results or []) + return results + + def get_average_score(self, name=None): + scores = [ + result.get_total_score() + for result in self._results() + if result.get_total_score() is not None] + if not scores: + return None + return sum(scores, Decimal(0)) / Decimal(len(scores)) + + def get_defect_count(self, name=None): + return sum(1 for result in self._results() if result.has_defect) + + def get_result_count(self, name=None): + return len(self._results()) + + +class CoffeeCuppingCup(ModelSQL, ModelView): + 'Coffee Cupping Cup' + __name__ = 'coffee.cupping.cup' + + session_sample = fields.Many2One( + 'coffee.cupping.session.sample', 'Session sample', + required=True, ondelete='CASCADE') + cup_no = fields.Integer('Cup') + blind_code = fields.Char('Blind code', required=True) + public_origin = fields.Function( + fields.Char('Origin'), 'get_public_origin') + public_coffee_type = fields.Function( + fields.Selection(COFFEE_TYPES, 'Coffee type'), 'get_public_coffee_type') + average_score = fields.Function( + fields.Numeric('Average score', digits=(5, 2)), 'get_average_score') + defect_count = fields.Function( + fields.Integer('Defect reports'), 'get_defect_count') + results = fields.One2Many( + 'coffee.cupping.result', 'cup', 'Results') + + def get_public_origin(self, name=None): + if self.session_sample: + return self.session_sample.get_public_origin() + + def get_public_coffee_type(self, name=None): + if self.session_sample: + return self.session_sample.get_public_coffee_type() + + def get_average_score(self, name=None): + scores = [ + result.get_total_score() + for result in self.results or [] + if result.get_total_score() is not None] + if not scores: + return None + return sum(scores, Decimal(0)) / Decimal(len(scores)) + + def get_defect_count(self, name=None): + return sum(1 for result in self.results or [] if result.has_defect) + + +class CoffeeCuppingResult(ModelSQL, ModelView): + 'Coffee Cupping Result' + __name__ = 'coffee.cupping.result' + + cup = fields.Many2One( + 'coffee.cupping.cup', 'Cup', required=True, ondelete='CASCADE') + cupper = fields.Many2One('party.party', 'Cupper', required=True) + date = fields.Date('Date') + has_defect = fields.Boolean('Defect found') + total_score = fields.Function( + fields.Numeric('Total score', digits=(5, 2)), 'get_total_score') + lines = fields.One2Many( + 'coffee.cupping.result.line', 'result', 'Criteria') + notes = fields.Text('Notes') + + @staticmethod + def default_date(): + return Pool().get('ir.date').today() + + def get_total_score(self, name=None): + total = Decimal(0) + has_score = False + for line in self.lines or []: + score = line.score + if score is None: + continue + has_score = True + if line.criterion and line.criterion.subtract_from_score: + total -= Decimal(str(score)) + else: + total += Decimal(str(score)) + return total if has_score else None + + +class CoffeeCuppingResultLine(ModelSQL, ModelView): + 'Coffee Cupping Result Line' + __name__ = 'coffee.cupping.result.line' + + result = fields.Many2One( + 'coffee.cupping.result', 'Result', required=True, + ondelete='CASCADE') + criterion = fields.Many2One( + 'coffee.cupping.criterion', 'Criterion', required=True) + score = fields.Numeric('Score', digits=(5, 2)) + defect_found = fields.Boolean('Defect found') + notes = fields.Text('Notes') + + class CoffeeLineMixin: + active_coffee_compatibility = fields.Function( + fields.Boolean('Active coffee compatibility'), + 'on_change_with_active_coffee_compatibility') coffee_samples = fields.One2Many( 'coffee.sample', None, 'Coffee samples') coffee_quality_status = fields.Function(fields.Selection([ @@ -389,6 +751,7 @@ class CoffeeLineMixin: ('expired', 'Expired'), ], 'Coffee quality'), 'get_coffee_quality_status') coffee_origin = fields.Char('Coffee origin') + coffee_type = fields.Selection(COFFEE_TYPES, 'Coffee type') coffee_process = fields.Selection(COFFEE_PROCESS_TYPES, 'Coffee process') coffee_variety = fields.Char('Coffee variety') coffee_crop_year = fields.Char('Crop year') @@ -401,6 +764,9 @@ class CoffeeLineMixin: def _coffee_samples(self): return list(getattr(self, 'coffee_samples', None) or []) + def on_change_with_active_coffee_compatibility(self, name=None): + return coffee_compatibility_active() + def get_coffee_quality_status(self, name=None): samples = self._coffee_samples() if not samples: diff --git a/modules/purchase_trade/coffee.xml b/modules/purchase_trade/coffee.xml index f21ac31..2dbcdd2 100644 --- a/modules/purchase_trade/coffee.xml +++ b/modules/purchase_trade/coffee.xml @@ -13,10 +13,65 @@ this repository contains the full copyright notices and license terms. --> form coffee_sample_form + + coffee.cupping.criterion + tree + coffee_cupping_criterion_tree + + + coffee.cupping.criterion + form + coffee_cupping_criterion_form + + + coffee.cupping.session + tree + coffee_cupping_session_tree + + + coffee.cupping.session + form + coffee_cupping_session_form + + + coffee.cupping.session.sample + tree + coffee_cupping_session_sample_tree + + + coffee.cupping.session.sample + form + coffee_cupping_session_sample_form + + + coffee.cupping.cup + tree + coffee_cupping_cup_tree + + + coffee.cupping.cup + form + coffee_cupping_cup_form + + + coffee.cupping.result + tree + coffee_cupping_result_tree + + + coffee.cupping.result + form + coffee_cupping_result_form + + + coffee.cupping.result.line + tree + coffee_cupping_result_line_tree + + Coffee Samples coffee.sample - @@ -63,18 +118,80 @@ this repository contains the full copyright notices and license terms. --> - - + + Cupping Sessions + coffee.cupping.session + + + + + + + + + + + + + + Cupping Criteria + coffee.cupping.criterion + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + coffee.sample @@ -116,6 +233,31 @@ this repository contains the full copyright notices and license terms. --> reset_to_requested Reset + + coffee.cupping.session + prepare + Prepare + + + coffee.cupping.session + start + Start + + + coffee.cupping.session + done + Done + + + coffee.cupping.session + cancel + Cancel + + + coffee.cupping.session + draft + Draft + coffee.sample @@ -124,9 +266,43 @@ this repository contains the full copyright notices and license terms. --> - - coffee.sample - + + coffee.cupping.criterion + + + + + + + coffee.cupping.session + + + + + + + coffee.cupping.session.sample + + + + + + + coffee.cupping.cup + + + + + + + coffee.cupping.result + + + + + + + coffee.cupping.result.line diff --git a/modules/purchase_trade/configuration.py b/modules/purchase_trade/configuration.py index f664668..d0d33ce 100644 --- a/modules/purchase_trade/configuration.py +++ b/modules/purchase_trade/configuration.py @@ -107,8 +107,20 @@ class Configuration(ModelSingleton, ModelSQL, ModelView): ('shipment_packing_list_report_label', 'purchase_trade', 'report_shipment_in_packing_list', 'Packing List'), ) + _COFFEE_MENU_XML_IDS = ( + 'menu_coffee_purchase', + 'menu_coffee_sample_purchase', + 'menu_coffee_cupping_session_purchase', + 'menu_coffee_cupping_criterion_purchase', + 'menu_coffee_sale', + 'menu_coffee_sample_sale', + 'menu_coffee_cupping_session_sale', + 'menu_coffee_cupping_criterion_sale', + ) pricing_rule = fields.Text("Pricing Rule") + active_coffee_compatibility = fields.Boolean( + "Active coffee compatibility") sale_report_template = fields.Char("Sale Template") sale_report_label = fields.Char("Sale Menu Label") sale_commission_report_template = fields.Char( @@ -149,16 +161,23 @@ class Configuration(ModelSingleton, ModelSQL, ModelView): shipment_packing_list_report_label = fields.Char( "Packing List Menu Label") + @classmethod + def default_active_coffee_compatibility(cls): + return False + @classmethod def create(cls, vlist): records = super().create(vlist) cls._sync_report_labels(records) + cls._sync_coffee_menu_visibility(records) return records @classmethod def write(cls, *args): super().write(*args) - cls._sync_report_labels(sum(args[::2], [])) + records = sum(args[::2], []) + cls._sync_report_labels(records) + cls._sync_coffee_menu_visibility(records) @classmethod def _sync_report_labels(cls, records): @@ -179,3 +198,24 @@ class Configuration(ModelSingleton, ModelSQL, ModelView): if to_write: with Transaction().set_user(0): ActionReport.write(*to_write) + + @classmethod + def _sync_coffee_menu_visibility(cls, records): + if not records: + return + pool = Pool() + ModelData = pool.get('ir.model.data') + Menu = pool.get('ir.ui.menu') + active = any( + bool(record.active_coffee_compatibility) + for record in records) + menus = [] + for xml_id in cls._COFFEE_MENU_XML_IDS: + try: + menu_id = ModelData.get_id('purchase_trade', xml_id) + except Exception: + continue + menus.append(Menu(menu_id)) + if menus: + with Transaction().set_user(0): + Menu.write(menus, {'active': active}) diff --git a/modules/purchase_trade/docs/business/coffee.en.md b/modules/purchase_trade/docs/business/coffee.en.md index 4e42b80..22f2635 100644 --- a/modules/purchase_trade/docs/business/coffee.en.md +++ b/modules/purchase_trade/docs/business/coffee.en.md @@ -24,7 +24,31 @@ Each sample stores:
  • an expiry date computed from the request/receipt date and the sample shelf life;
  • -
  • a simple cupping score and physical quality values. +
  • physical quality values and links to cupping sessions. +
  • + + +Coffee compatibility is disabled by default and must be enabled on +`purchase_trade.configuration` with the `Active coffee compatibility` field. +While the option is disabled, coffee menus and coffee quality pages on lines +must not be visible. + +Cupping results are entered through sessions: + +
      +
    • cupping properties/criteria are configurable; +
    • +
    • a session defines the room cup capacity and the number of cups per sample; +
    • +
    • each sample added to the session generates several blind-coded cups; +
    • +
    • multiple cuppers can enter a result for each cup; +
    • +
    • cuppers see only the origin and coffee type, not the contract or supplier; +
    • +
    • the preparation order proposes arabicas first, blends/other coffees next and robustas at the end of the session; +
    • +
    • the final decision remains global on the session sample: approved, rejected or re-cup.
    @@ -39,6 +63,20 @@ defect count and minimum cup score.
    • coffee.sample
    • +
    • coffee.cupping.criterion +
    • +
    • coffee.cupping.session +
    • +
    • coffee.cupping.session.sample +
    • +
    • coffee.cupping.cup +
    • +
    • coffee.cupping.result +
    • +
    • coffee.cupping.result.line +
    • +
    • purchase_trade.configuration.active_coffee_compatibility +
    • purchase.line.coffee_samples
    • sale.line.coffee_samples @@ -55,6 +93,8 @@ defect count and minimum cup score.
    • modules/purchase_trade/view/coffee_sample_*.xml
    • +
    • modules/purchase_trade/view/coffee_cupping_*.xml +
    • modules/purchase_trade/view/purchase_line_form.xml
    • modules/purchase_trade/view/sale_line_form.xml @@ -67,7 +107,9 @@ defect count and minimum cup score.
    • phase 1 does not block shipment or contract validation;
    • -
    • lab delegation is a tracking field, not a complete lab workflow yet. +
    • 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.
    diff --git a/modules/purchase_trade/docs/business/coffee.md b/modules/purchase_trade/docs/business/coffee.md index 97956d1..4f7a415 100644 --- a/modules/purchase_trade/docs/business/coffee.md +++ b/modules/purchase_trade/docs/business/coffee.md @@ -25,7 +25,31 @@ Chaque echantillon porte:
  • 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. +
  • des donnees de qualite physique et des liens vers les seances de cupping. +
  • + + +La compatibilite cafe est inactive par defaut et doit etre activee dans la +configuration `purchase_trade.configuration` avec le champ `Active coffee +compatibility`. Tant que l'option est inactive, les menus cafe et les pages +qualite cafe des lignes ne doivent pas etre visibles. + +Les resultats de cupping sont saisis dans des seances: + +
      +
    • les proprietes/criteres de cupping sont parametrables; +
    • +
    • une seance definit une capacite de salle en nombre de tasses et un nombre de tasses par echantillon; +
    • +
    • chaque echantillon inscrit a la seance genere plusieurs tasses avec un code aveugle; +
    • +
    • plusieurs personnes peuvent saisir un resultat par tasse; +
    • +
    • les degustateurs voient uniquement l'origine et le type de cafe, pas le contrat ni le fournisseur; +
    • +
    • l'ordre de preparation propose les arabicas avant les blends/autres cafes et garde les robustas en fin de seance; +
    • +
    • la decision finale reste globale a l'echantillon de seance: approved, rejected ou re-cup.
    @@ -40,6 +64,20 @@ maximum, nombre de defauts maximum et score cup minimum.
    • coffee.sample
    • +
    • coffee.cupping.criterion +
    • +
    • coffee.cupping.session +
    • +
    • coffee.cupping.session.sample +
    • +
    • coffee.cupping.cup +
    • +
    • coffee.cupping.result +
    • +
    • coffee.cupping.result.line +
    • +
    • purchase_trade.configuration.active_coffee_compatibility +
    • purchase.line.coffee_samples
    • sale.line.coffee_samples @@ -56,6 +94,8 @@ maximum, nombre de defauts maximum et score cup minimum.
    • modules/purchase_trade/view/coffee_sample_*.xml
    • +
    • modules/purchase_trade/view/coffee_cupping_*.xml +
    • modules/purchase_trade/view/purchase_line_form.xml
    • modules/purchase_trade/view/sale_line_form.xml @@ -68,7 +108,9 @@ maximum, nombre de defauts maximum et score cup minimum.
    • aucun blocage shipment/validation n'est introduit en phase 1;
    • -
    • la delegation labo reste une information de suivi, sans workflow labo complet. +
    • la delegation labo reste une information de suivi, sans workflow labo complet; +
    • +
    • les menus cafe sont inactifs par defaut et synchronises quand la configuration est sauvegardee.
    diff --git a/modules/purchase_trade/docs_source/business/coffee.en.md b/modules/purchase_trade/docs_source/business/coffee.en.md index c7808ad..e4e21b3 100644 --- a/modules/purchase_trade/docs_source/business/coffee.en.md +++ b/modules/purchase_trade/docs_source/business/coffee.en.md @@ -20,7 +20,24 @@ Each sample stores: 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. +- physical quality values and links to cupping sessions. + +Coffee compatibility is disabled by default and must be enabled on +`purchase_trade.configuration` with the `Active coffee compatibility` field. +While the option is disabled, coffee menus and coffee quality pages on lines +must not be visible. + +Cupping results are entered through sessions: + +- cupping properties/criteria are configurable; +- a session defines the room cup capacity and the number of cups per sample; +- each sample added to the session generates several blind-coded cups; +- multiple cuppers can enter a result for each cup; +- cuppers see only the origin and coffee type, not the contract or supplier; +- the preparation order proposes arabicas first, blends/other coffees next and + robustas at the end of the session; +- the final decision remains global on the session sample: approved, rejected + or re-cup. `purchase.line` and `sale.line` store the target coffee quality specifications: origin, process, variety, crop year, screen size, maximum moisture, maximum @@ -30,6 +47,13 @@ defect count and minimum cup score. - Models/fields: - `coffee.sample` + - `coffee.cupping.criterion` + - `coffee.cupping.session` + - `coffee.cupping.session.sample` + - `coffee.cupping.cup` + - `coffee.cupping.result` + - `coffee.cupping.result.line` + - `purchase_trade.configuration.active_coffee_compatibility` - `purchase.line.coffee_samples` - `sale.line.coffee_samples` - `coffee_*` fields on `purchase.line` and `sale.line` @@ -37,9 +61,12 @@ defect count and minimum cup score. - `modules/purchase_trade/coffee.py` - `modules/purchase_trade/coffee.xml` - `modules/purchase_trade/view/coffee_sample_*.xml` + - `modules/purchase_trade/view/coffee_cupping_*.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. + - 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. diff --git a/modules/purchase_trade/docs_source/business/coffee.md b/modules/purchase_trade/docs_source/business/coffee.md index 79bd2cc..5b0bba5 100644 --- a/modules/purchase_trade/docs_source/business/coffee.md +++ b/modules/purchase_trade/docs_source/business/coffee.md @@ -21,7 +21,27 @@ Chaque echantillon porte: 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. +- des donnees de qualite physique et des liens vers les seances de cupping. + +La compatibilite cafe est inactive par defaut et doit etre activee dans la +configuration `purchase_trade.configuration` avec le champ `Active coffee +compatibility`. Tant que l'option est inactive, les menus cafe et les pages +qualite cafe des lignes ne doivent pas etre visibles. + +Les resultats de cupping sont saisis dans des seances: + +- les proprietes/criteres de cupping sont parametrables; +- une seance definit une capacite de salle en nombre de tasses et un nombre de + tasses par echantillon; +- chaque echantillon inscrit a la seance genere plusieurs tasses avec un code + aveugle; +- plusieurs personnes peuvent saisir un resultat par tasse; +- les degustateurs voient uniquement l'origine et le type de cafe, pas le + contrat ni le fournisseur; +- l'ordre de preparation propose les arabicas avant les blends/autres cafes et + garde les robustas en fin de seance; +- la decision finale reste globale a l'echantillon de seance: approved, + rejected ou re-cup. Les lignes `purchase.line` et `sale.line` portent les specifications qualite cibles du cafe: origine, process, variete, crop year, screen size, humidite @@ -31,6 +51,13 @@ maximum, nombre de defauts maximum et score cup minimum. - Modeles/champs: - `coffee.sample` + - `coffee.cupping.criterion` + - `coffee.cupping.session` + - `coffee.cupping.session.sample` + - `coffee.cupping.cup` + - `coffee.cupping.result` + - `coffee.cupping.result.line` + - `purchase_trade.configuration.active_coffee_compatibility` - `purchase.line.coffee_samples` - `sale.line.coffee_samples` - champs `coffee_*` sur `purchase.line` et `sale.line` @@ -38,10 +65,13 @@ maximum, nombre de defauts maximum et score cup minimum. - `modules/purchase_trade/coffee.py` - `modules/purchase_trade/coffee.xml` - `modules/purchase_trade/view/coffee_sample_*.xml` + - `modules/purchase_trade/view/coffee_cupping_*.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. + complet; + - les menus cafe sont inactifs par defaut et synchronises quand la + configuration est sauvegardee. diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 5093f38..9d1529b 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -31,20 +31,53 @@ 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')) + def test_coffee_cupping_result_score_sums_configured_criteria(self): + 'coffee cupping result score sums criteria and subtracts defects' + score = SimpleNamespace(subtract_from_score=False) + defect = SimpleNamespace(subtract_from_score=True) + result = SimpleNamespace(lines=[ + SimpleNamespace(score=Decimal('8.00'), criterion=score), + SimpleNamespace(score=Decimal('8.25'), criterion=score), + SimpleNamespace(score=Decimal('2.00'), criterion=defect), + ]) self.assertEqual( - coffee_module.CoffeeSample.get_total_score(sample), - Decimal('78.00')) + coffee_module.CoffeeCuppingResult.get_total_score(result), + Decimal('14.25')) + + def test_coffee_cupping_session_counts_requested_cups(self): + 'coffee cupping session capacity is based on sample cups' + session = SimpleNamespace(cups_per_sample=3) + lines = [ + SimpleNamespace(cups_per_sample=None, session=session), + SimpleNamespace(cups_per_sample=4, session=session), + ] + + self.assertEqual( + coffee_module.CoffeeCuppingSession._total_requested_cups(lines), + 7) + + def test_coffee_cupping_session_orders_arabica_before_robusta(self): + 'coffee cupping session tasting order keeps robusta last' + arabica = SimpleNamespace( + id=2, + reference='A', + get_public_origin=lambda: 'Colombia', + get_public_coffee_type=lambda: 'arabica') + robusta = SimpleNamespace( + id=1, + reference='R', + get_public_origin=lambda: 'Vietnam', + get_public_coffee_type=lambda: 'robusta') + lines = [ + SimpleNamespace(sample=robusta), + SimpleNamespace(sample=arabica), + ] + + ordered = sorted( + lines, key=coffee_module.CoffeeCuppingSession._line_sort_key) + + self.assertEqual([line.sample.reference for line in ordered], ['A', 'R']) def test_coffee_sample_default_expiry_date_uses_shelf_life(self): 'coffee sample expiry date defaults from request date and shelf life' diff --git a/modules/purchase_trade/view/coffee_cupping_criterion_form.xml b/modules/purchase_trade/view/coffee_cupping_criterion_form.xml new file mode 100644 index 0000000..5e14793 --- /dev/null +++ b/modules/purchase_trade/view/coffee_cupping_criterion_form.xml @@ -0,0 +1,17 @@ + +
    +