430 lines
14 KiB
Python
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')
|