diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py
index 9f0e81b..eff7daa 100755
--- a/modules/purchase_trade/__init__.py
+++ b/modules/purchase_trade/__init__.py
@@ -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,
diff --git a/modules/purchase_trade/coffee.py b/modules/purchase_trade/coffee.py
new file mode 100644
index 0000000..ecd8fce
--- /dev/null
+++ b/modules/purchase_trade/coffee.py
@@ -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')
diff --git a/modules/purchase_trade/coffee.xml b/modules/purchase_trade/coffee.xml
new file mode 100644
index 0000000..f21ac31
--- /dev/null
+++ b/modules/purchase_trade/coffee.xml
@@ -0,0 +1,136 @@
+
+
+
% rate: FR / EN
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.
diff --git a/modules/purchase_trade/docs/business/coffee.en.md b/modules/purchase_trade/docs/business/coffee.en.md
new file mode 100644
index 0000000..4e42b80
--- /dev/null
+++ b/modules/purchase_trade/docs/business/coffee.en.md
@@ -0,0 +1,74 @@
+
+
+# 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:
+
+coffee.sample
+purchase.line.coffee_samples
+sale.line.coffee_samples
+coffee_* fields on purchase.line and sale.line
+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
+Expired and Expiring soon domains;
+coffee.sample
+purchase.line.coffee_samples
+sale.line.coffee_samples
+coffee_* sur purchase.line et sale.line
+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
+Expired et Expiring soon;
+