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 @@ + + + + + + coffee.sample + tree + coffee_sample_tree + + + coffee.sample + form + coffee_sample_form + + + Coffee Samples + coffee.sample + + + + + + + + + + + + + + Pending + + + + + + + Expiring soon + + + + + + + Expired + + + + + + + Approved + + + + + + + All + + + + + + + + + + coffee.sample + receive + Receive + + + coffee.sample + send_to_lab + Send to lab + + + coffee.sample + review + Review + + + coffee.sample + approve + Approve + + + coffee.sample + reject + Reject + + + coffee.sample + expire + Expire + + + coffee.sample + archive + Archive + + + coffee.sample + reset_to_requested + Reset + + + + coffee.sample + + + + + + + coffee.sample + + + + + + + + diff --git a/modules/purchase_trade/docs/business/INDEX.md b/modules/purchase_trade/docs/business/INDEX.md index 0d2a858..1662f29 100644 --- a/modules/purchase_trade/docs/business/INDEX.md +++ b/modules/purchase_trade/docs/business/INDEX.md @@ -17,6 +17,8 @@ Statut: `migration partielle`
  • Pricing manuel, basis, premium, linked currency: pricing.md
  • +
  • Trading cafe, echantillons, cupping, qualite: FR / EN +
  • Fees, freight, lots effectifs, quantités, % rate: FR / EN
  • Valuation, PnL, MTM, derivatives: FR / EN @@ -66,6 +68,8 @@ Statut: `migration partielle`
  • 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: + + + +`purchase.line` and `sale.line` store the target coffee quality specifications: +origin, process, variety, crop year, screen size, maximum moisture, maximum +defect count and minimum cup score. + +### Developer notes + + diff --git a/modules/purchase_trade/docs/business/coffee.md b/modules/purchase_trade/docs/business/coffee.md new file mode 100644 index 0000000..97956d1 --- /dev/null +++ b/modules/purchase_trade/docs/business/coffee.md @@ -0,0 +1,75 @@ + + +# Trading cafe - echantillons et qualite + +Page miroir: [coffee.en.md](coffee.en.md) + +## BR-PT-COF-001 - Phase 1 echantillons cafe + +Statut: active +Source: decision projet du 2026-06-29 + +### Regle consultant + +Le trading cafe doit pouvoir piloter les echantillons rattaches aux lignes +d'achat et de vente. + +Chaque echantillon porte: + + + +Les lignes `purchase.line` et `sale.line` portent les specifications qualite +cibles du cafe: origine, process, variete, crop year, screen size, humidite +maximum, nombre de defauts maximum et score cup minimum. + +### Notes developpeur + + diff --git a/modules/purchase_trade/docs_source/business/INDEX.md b/modules/purchase_trade/docs_source/business/INDEX.md index dbf51ca..69e38dc 100644 --- a/modules/purchase_trade/docs_source/business/INDEX.md +++ b/modules/purchase_trade/docs_source/business/INDEX.md @@ -9,6 +9,7 @@ Statut: `migration partielle` - Matching, Create Contracts, back-to-back: [matching.md](matching.md) - Shipments, controllers, SLA, weight reports: [shipments-execution.md](shipments-execution.md) - Pricing manuel, basis, premium, linked currency: [pricing.md](pricing.md) +- Trading cafe, echantillons, cupping, qualite: [FR](coffee.md) / [EN](coffee.en.md) - Fees, freight, lots effectifs, quantités, `% rate`: [FR](fees.md) / [EN](fees.en.md) - Valuation, PnL, MTM, derivatives: [FR](valuation-pnl-mtm.md) / [EN](valuation-pnl-mtm.en.md) - Factures provisoires/finales, padding: [invoicing.md](invoicing.md) @@ -35,6 +36,7 @@ Statut: `migration partielle` - `BR-PT-PRI-001`: premium dans priced et basis. - `BR-PT-PRI-002`: linked currency. - `BR-PT-PRI-003`: pricing manuel. +- `BR-PT-COF-001`: echantillons cafe phase 1. - `BR-PT-FEE-001`: maritime freight depuis fee shipment. - `BR-PT-FEE-002`: lots effectifs des fees. - `BR-PT-FEE-003`: quantité du fee alignée sur les lots effectifs. diff --git a/modules/purchase_trade/docs_source/business/coffee.en.md b/modules/purchase_trade/docs_source/business/coffee.en.md new file mode 100644 index 0000000..c7808ad --- /dev/null +++ b/modules/purchase_trade/docs_source/business/coffee.en.md @@ -0,0 +1,45 @@ +# Coffee trading - samples and quality + +Mirror page: [coffee.md](coffee.md) + +## BR-PT-COF-001 - Coffee sample phase 1 + +Status: active +Source: project decision on 2026-06-29 + +### Business rule + +Coffee trading must track samples linked to purchase and sale lines. + +Each sample stores: + +- a sample type: offer, pre-shipment, shipment, arrival, stock, customer or + retained; +- a purchase or sale direction; +- a lifecycle: requested, received, sent to lab, under review, approved, + rejected, expired, archived; +- an expiry date computed from the request/receipt date and the sample shelf + life; +- a simple cupping score and physical quality values. + +`purchase.line` and `sale.line` store the target coffee quality specifications: +origin, process, variety, crop year, screen size, maximum moisture, maximum +defect count and minimum cup score. + +### Developer notes + +- Models/fields: + - `coffee.sample` + - `purchase.line.coffee_samples` + - `sale.line.coffee_samples` + - `coffee_*` fields on `purchase.line` and `sale.line` +- Files: + - `modules/purchase_trade/coffee.py` + - `modules/purchase_trade/coffee.xml` + - `modules/purchase_trade/view/coffee_sample_*.xml` + - `modules/purchase_trade/view/purchase_line_form.xml` + - `modules/purchase_trade/view/sale_line_form.xml` +- Caveats: + - expiry is monitored through the `Expired` and `Expiring soon` domains; + - phase 1 does not block shipment or contract validation; + - lab delegation is a tracking field, not a complete lab workflow yet. diff --git a/modules/purchase_trade/docs_source/business/coffee.md b/modules/purchase_trade/docs_source/business/coffee.md new file mode 100644 index 0000000..79bd2cc --- /dev/null +++ b/modules/purchase_trade/docs_source/business/coffee.md @@ -0,0 +1,47 @@ +# Trading cafe - echantillons et qualite + +Page miroir: [coffee.en.md](coffee.en.md) + +## BR-PT-COF-001 - Phase 1 echantillons cafe + +Statut: active +Source: decision projet du 2026-06-29 + +### Regle consultant + +Le trading cafe doit pouvoir piloter les echantillons rattaches aux lignes +d'achat et de vente. + +Chaque echantillon porte: + +- un type d'echantillon: offer, pre-shipment, shipment, arrival, stock, + customer ou retained; +- un sens purchase ou sale; +- un cycle de vie: requested, received, sent to lab, under review, approved, + rejected, expired, archived; +- une date d'expiration calculee depuis la date de demande/reception et la + duree de vie de l'echantillon; +- une notation de cupping simple et des donnees de qualite physique. + +Les lignes `purchase.line` et `sale.line` portent les specifications qualite +cibles du cafe: origine, process, variete, crop year, screen size, humidite +maximum, nombre de defauts maximum et score cup minimum. + +### Notes developpeur + +- Modeles/champs: + - `coffee.sample` + - `purchase.line.coffee_samples` + - `sale.line.coffee_samples` + - champs `coffee_*` sur `purchase.line` et `sale.line` +- Fichiers: + - `modules/purchase_trade/coffee.py` + - `modules/purchase_trade/coffee.xml` + - `modules/purchase_trade/view/coffee_sample_*.xml` + - `modules/purchase_trade/view/purchase_line_form.xml` + - `modules/purchase_trade/view/sale_line_form.xml` +- Points de vigilance: + - l'expiration est pilotee par les domaines `Expired` et `Expiring soon`; + - aucun blocage shipment/validation n'est introduit en phase 1; + - la delegation labo reste une information de suivi, sans workflow labo + complet. diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 1cd67b0..5093f38 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -22,6 +22,7 @@ from trytond.modules.purchase_trade import stock as stock_module from trytond.modules.purchase_trade import fee as fee_module from trytond.modules.purchase_trade import pricing as pricing_module from trytond.modules.purchase_trade import duplicate as duplicate_module +from trytond.modules.purchase_trade import coffee as coffee_module from trytond.modules.purchase_trade import company_defaults from trytond.modules.purchase_trade.service import ContractFactory @@ -30,6 +31,45 @@ class PurchaseTradeTestCase(ModuleTestCase): 'Test purchase_trade module' module = 'purchase_trade' + def test_coffee_sample_cup_score_sums_cupping_criteria(self): + 'coffee sample cup score sums criteria and subtracts defects' + score_fields = [ + 'aroma', 'flavor', 'aftertaste', 'acidity', 'body', 'balance', + 'sweetness', 'clean_cup', 'uniformity', 'overall'] + sample = SimpleNamespace( + defects=Decimal('2.00'), + _score_fields=lambda: score_fields) + for field_name in score_fields: + setattr(sample, field_name, Decimal('8.00')) + + self.assertEqual( + coffee_module.CoffeeSample.get_total_score(sample), + Decimal('78.00')) + + def test_coffee_sample_default_expiry_date_uses_shelf_life(self): + 'coffee sample expiry date defaults from request date and shelf life' + values = { + 'requested_date': datetime.date(2026, 6, 29), + 'shelf_life_days': 30, + } + + coffee_module.CoffeeSample._set_default_expiry_date( + values, force=True) + + self.assertEqual( + values['expiry_date'], datetime.date(2026, 7, 29)) + + @with_transaction() + def test_purchase_line_coffee_quality_status_uses_samples(self): + 'purchase line coffee quality status follows linked samples' + PurchaseLine = Pool().get('purchase.line') + line = PurchaseLine() + line.coffee_samples = [ + Mock(state='approved', get_is_expired=Mock(return_value=False))] + + self.assertEqual( + line.get_coffee_quality_status(), 'approved') + def test_itsa_book_year_suffix_uses_april_fiscal_start(self): 'ITSA book year changes on April 1st' self.assertEqual( diff --git a/modules/purchase_trade/tryton.cfg b/modules/purchase_trade/tryton.cfg index 1f2c3d7..72593a1 100755 --- a/modules/purchase_trade/tryton.cfg +++ b/modules/purchase_trade/tryton.cfg @@ -15,6 +15,7 @@ depends: risk xml: global_reporting.xml + coffee.xml purchase.xml contract_clause_reference.xml sale.xml @@ -26,8 +27,8 @@ xml: lc.xml optional.xml dashboard.xml - fee.xml - payment_term.xml + fee.xml + payment_term.xml purchase_prepayment.xml open_position.xml credit_risk.xml diff --git a/modules/purchase_trade/view/coffee_sample_form.xml b/modules/purchase_trade/view/coffee_sample_form.xml new file mode 100644 index 0000000..655bb13 --- /dev/null +++ b/modules/purchase_trade/view/coffee_sample_form.xml @@ -0,0 +1,101 @@ + +
    +