Files
tradon/modules/purchase_trade/coffee.py
2026-06-29 20:34:23 +02:00

430 lines
14 KiB
Python

# 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')