trytond-farm/animal_group.py

531 lines
19 KiB
Python

# The COPYRIGHT file at the top level of this repository contains the full
# copyright notices and license terms.
from datetime import date, datetime
from decimal import Decimal
from trytond.model import ModelView, ModelSQL, fields
from trytond.pyson import Equal, Eval, Greater, Id, Not
from trytond.transaction import Transaction
from trytond.pool import Pool
from trytond.exceptions import UserError
from trytond.i18n import gettext
from .animal import AnimalMixin
class AnimalGroup(ModelSQL, ModelView, AnimalMixin):
'Group of Farm Animals'
__name__ = 'farm.animal.group'
specie = fields.Many2One('farm.specie', 'Specie', required=True,
states={
'readonly': True,
})
breed = fields.Many2One('farm.specie.breed', 'Breed', required=True,
domain=[('specie', '=', Eval('specie'))], depends=['specie'])
lot = fields.One2One('stock.lot-farm.animal.group', 'animal_group',
'lot', 'Lot', required=True, readonly=True,
domain=[('animal_type', '=', 'group')])
number = fields.Function(fields.Char('Number'),
'get_number', 'set_number')
locations = fields.Function(fields.Many2Many('stock.location', None, None,
'Current Locations'),
'get_locations', searcher='search_locations')
farms = fields.Function(fields.Many2Many('stock.location', None, None,
'Current Farms', domain=[
('type', '=', 'warehouse'),
],
help='Farms where this group can be found. It is used for access '
'management.'),
'get_locations', searcher='search_locations')
quantity = fields.Function(fields.Integer('Quantity'), 'get_quantity',
searcher='search_quantity')
origin = fields.Selection([
('purchased', 'Purchased'),
('raised', 'Raised'),
], 'Origin', required=True, readonly=True,
help='Raised means that this group was born in the farm. Otherwise, '
'it was purchased.')
arrival_date = fields.Date('Arrival Date', states={
'readonly': Greater(Eval('id', 0), 0),
}, depends=['id'],
help="The date this group arrived (if it was purchased) or when it "
"was born.")
purchase_shipment = fields.Many2One('stock.shipment.in',
'Purchase Shipment', readonly=True,
states={'invisible': Not(Equal(Eval('origin'), 'purchased'))},
depends=['origin'])
initial_location = fields.Many2One('stock.location', "Initial Location",
required=True, domain=[
('type', '=', 'storage'),
('silo', '=', False),
],
states={'readonly': Greater(Eval('id', 0), 0)}, depends=['id'],
context={'restrict_by_specie_animal_type': True},
help="The Location where the group was reached or where it was "
"allocated when it was purchased.\nIt is used as historical "
"information and to get Serial Number.")
initial_quantity = fields.Integer('Initial Quantity', required=True,
states={'readonly': Greater(Eval('id', 0), 0)}, depends=['id'],
help="The number of animals in group when it was reached or "
"purchased.\nIt is used as historical information and to create the "
"initial move.")
removal_date = fields.Date('Removal Date', readonly=True)
weights = fields.One2Many('farm.animal.group.weight', 'group',
'Weight Records', readonly=False, order=[('timestamp', 'DESC')])
current_weight = fields.Function(fields.Many2One(
'farm.animal.group.weight', 'Current Weight'),
'on_change_with_current_weight')
tags = fields.Many2Many('farm.animal.group-farm.tag', 'group', 'tag',
'Tags')
notes = fields.Text('Notes')
active = fields.Boolean('Active')
feed_unit_digits = fields.Function(fields.Integer('Feed Unit Digits'),
'get_unit_digits')
consumed_feed = fields.Function(
fields.Numeric('Consumed Feed per Animal (Kg)',
digits=(16, Eval('feed_unit_digits', 2)),
depends=['feed_unit_digits']),
'get_consumed_feed')
current_location = fields.Function(fields.Many2One('stock.location',
'Current Location'), 'get_current_location')
# # TODO: Extra
# 'type': fields.selection([('static','Static'),('dynamic','Dynamic')],
# help='Static = all-in, all-out. Dynamic = continuous flow')
# # Stages a dynamic group may be in.
# 'stage': fields.many2one('farm.animal.group.stage')
@classmethod
def __setup__(cls):
super(AnimalGroup, cls).__setup__()
cls._sql_constraints += [
# Comented because of breeding groups are initialized to 0
#('initial_quantity_positive', 'check (initial_quantity > 0)',
# 'In Groups, the initial quantity must be positive (greater or '
# 'equals 1)'),
]
@staticmethod
def default_specie():
return Transaction().context.get('specie')
@staticmethod
def default_breed():
pool = Pool()
Specie = pool.get('farm.specie')
context = Transaction().context
if 'specie' in context:
specie = Specie(context['specie'])
if len(specie.breeds) == 1:
return specie.breeds[0].id
@staticmethod
def default_origin():
return 'purchased'
@staticmethod
def default_arrival_date():
return date.today()
@staticmethod
def default_active():
return True
def get_rec_name(self, name):
name = self.lot.number
if not self.active:
name += ' (*)'
return name
def get_current_location(self, name):
if not self.locations:
return
return self.locations[0].id
@classmethod
def search_rec_name(cls, name, clause):
return [('lot.number',) + tuple(clause[1:])]
def get_number(self, name):
return self.lot.number
@classmethod
def set_number(cls, instances, name, value):
Lot = Pool().get('stock.lot')
lots = [group.lot for group in instances if group.lot]
if lots:
Lot.write(lots, {
'number': value,
})
@classmethod
def get_locations(cls, animal_groups, name):
pool = Pool()
Location = pool.get('stock.location')
Lot = pool.get('stock.lot')
warehouses = Location.search([
('type', '=', 'warehouse'),
])
if name == 'farms':
with Transaction().set_context(stock_skip_warehouse=True):
qbl = Lot.quantity_by_location(
[ag.lot for ag in animal_groups],
[w.id for w in warehouses],
quantity_domain=('quantity', '>', 0.0), with_childs=True)
else:
warehouse_locations = Location.search([
('parent', 'child_of',
[wh.storage_location.id for wh in warehouses]),
])
qbl = Lot.quantity_by_location([ag.lot for ag in animal_groups],
[l.id for l in warehouse_locations],
quantity_domain=('quantity', '>', 0.0))
res = {}
for animal_group in animal_groups:
ag_lot_id = animal_group.lot.id
res[animal_group.id] = [l for l in qbl.get(ag_lot_id, [])
if qbl[ag_lot_id][l] > 0.0]
return res
@classmethod
def search_locations(cls, name, domain=None):
pool = Pool()
Location = pool.get('stock.location')
Lot = pool.get('stock.lot')
Specie = pool.get('farm.specie')
if not domain:
return []
specie_id = cls.default_specie()
specie_warehouse_ids = None
if specie_id:
specie = Specie(specie_id)
if not specie.group_product:
return []
specie_warehouse_ids = [l.farm.id for l in specie.farm_lines
if l.has_group]
if name == 'farms':
if specie_warehouse_ids:
warehouses = Location.search([
('id', 'in', specie_warehouse_ids),
('type', '=', 'warehouse'),
('warehouse',) + tuple(domain[1:]),
])
else:
warehouses = Location.search([
('type', '=', 'warehouse'),
('warehouse',) + tuple(domain[1:]),
])
if not warehouses:
return []
with Transaction().set_context(stock_skip_warehouse=True):
qbl = Lot.quantity_by_location(None,
[w.id for w in warehouses],
quantity_domain=('quantity', '>', 0.0), with_childs=True)
else:
location_domain = Location.search_rec_name(Location._rec_name,
domain)
if specie_warehouse_ids:
location_domain.append(
('warehouse', 'in', specie_warehouse_ids))
warehouse_locations = Location.search(location_domain)
qbl = Lot.quantity_by_location(None,
[l.id for l in warehouse_locations],
quantity_domain=('quantity', '>', 0.0), with_childs=True)
# Lots that have any unit in any location
lot_ids = set(l for l in qbl
if qbl[l] and any(q > 0.0 for q in list(qbl[l].values())))
return [('lot', 'in', list(lot_ids))]
@classmethod
def get_quantity(cls, animal_groups, name):
"""
Returns the quantity of animals
If location_ids is provided in the context, then quantity only from
those locations is considered. In this case, the 'with_childs' context
value is also honoured just like in the stock module.
Otherwise, stock is computed for all warehouse location ids, and
computing its children (with_childs = True). However, if there's a
'farms' value in the context, only those warehouses are considered.
"""
Location = Pool().get('stock.location')
context = Transaction().context
location_ids = context.get('locations')
with_children = context.get('with_childs', False)
if not location_ids:
domain = [
('type', '=', 'warehouse'),
]
farm_ids = context.get('farms')
if farm_ids:
domain.append(('warehouse', 'in', farm_ids))
warehouses = Location.search(domain)
location_ids = [x.id for x in warehouses]
with_children = True
return cls.get_quantity_by_locations(animal_groups, location_ids,
with_children)
@classmethod
def get_quantity_by_locations(cls, animal_groups, locations,
with_children):
Lot = Pool().get('stock.lot')
ids = [x.id for x in animal_groups]
lots = [x.lot.id for x in animal_groups]
with Transaction().set_context({'locations': locations, 'with_childs':
with_children}):
quantities = [int(x.quantity) for x in Lot.browse(lots)]
return dict(zip(ids, quantities))
@classmethod
def search_quantity(cls, name, domain=None):
pool = Pool()
Lot = pool.get('stock.lot')
Specie = pool.get('farm.specie')
specie_id = cls.default_specie()
location_ids = Transaction().context.get('locations')
if specie_id and not location_ids:
specie = Specie(specie_id)
location_ids = [l.farm.storage_location.id
for l in specie.farm_lines if l.has_group]
lot_domain = Lot._search_quantity(name, location_ids, domain,
grouping=('product', 'lot'))
return [('lot', ) + tuple(t[1:]) if t[0] == 'id'
else ('lot.' + t[0], ) + tuple(t[1:]) for t in lot_domain]
@fields.depends('weights')
def on_change_with_current_weight(self, name=None):
if self.weights:
return self.weights[0].id
def get_consumed_feed(self, name):
pool = Pool()
FeedEvent = pool.get('farm.feed.event')
Uom = pool.get('product.uom')
now = datetime.now()
feed_events = FeedEvent.search([
('animal_type', '=', 'group'),
('animal_group', '=', self.id),
('state', 'in', ['provisional', 'validated']),
['OR', [
('start_date', '=', None),
('timestamp', '<=', now),
], [
('start_date', '<=', now.date()),
]],
])
kg, = Uom.search([('symbol', '=', 'kg')], limit=1)
consumed_feed = Decimal('0.0')
for event in feed_events:
if event.start_date and event.timestamp > now:
event_feed_quantity = (event.feed_quantity_animal_day *
(now.date() - event.start_date).days)
else:
event_feed_quantity = event.feed_quantity / event.quantity
# TODO: it uses compute_price() because quantity is a Decimal
# quantity in feed_product default uom. The method is not for
# this purpose but it works
consumed_feed = Uom.compute_price(kg, event_feed_quantity,
event.uom)
return consumed_feed
def check_in_location(self, location, timestamp, quantity=1):
Lot = Pool().get('stock.lot')
with Transaction().set_context(
locations=[location.id],
stock_date_end=timestamp.date()):
# Object must be reinstantiated in order for the context to take
# effect when computing lot's quantity.
lot = Lot(self.lot.id)
return lot.quantity >= quantity
def check_allowed_location(self, location, event_rec_name):
if not location.warehouse:
return
for farm_line in self.specie.farm_lines:
if farm_line.farm.id == location.warehouse.id:
if farm_line.has_group:
return
raise UserError(gettext('farm.invalid_group_destination',
event=event_rec_name,
group=self.rec_name,
location=location.rec_name,
))
@classmethod
def copy(cls, records, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.update({
'lot': False,
'farms': False,
'origin': False,
'arrival_date': False,
'purchase_shipment': False,
'removal_date': False,
'weights': False,
})
return super(AnimalGroup, cls).copy(records, default)
@classmethod
def create(cls, vlist):
pool = Pool()
Location = pool.get('stock.location')
Lot = pool.get('stock.lot')
context = Transaction().context
vlist = [x.copy() for x in vlist]
for vals in vlist:
if not vals.get('specie'):
vals['specie'] = context.get('specie')
if not vals.get('number'):
location = Location(vals['initial_location'])
vals['number'] = cls._calc_number(vals['specie'],
location.warehouse.id, vals)
if vals.get('lot'):
lot = Lot(vals['lot'])
Lot.write([lot], cls._get_lot_values(vals, False))
else:
new_lot, = Lot.create([cls._get_lot_values(vals, True)])
vals['lot'] = new_lot.id
new_groups = super(AnimalGroup, cls).create(vlist)
if not context.get('no_create_stock_move'):
cls._create_and_done_first_stock_move(new_groups)
return new_groups
@classmethod
def _calc_number(cls, specie_id, farm_id, vals):
pool = Pool()
FarmLine = pool.get('farm.specie.farm_line')
Location = pool.get('stock.location')
Specie = pool.get('farm.specie')
farm_lines = FarmLine.search([
('specie', '=', specie_id),
('farm', '=', farm_id),
('has_group', '=', True),
])
if not farm_lines:
raise UserError(gettext(
'farm.group_no_farm_specie_farm_line_available',
farm=Location(farm_id).rec_name if farm_id else '',
specie=Specie(specie_id).rec_name if specie_id else '-',
))
sequence = farm_lines[0].group_sequence
return sequence and sequence.get() or ''
@classmethod
def _get_lot_values(cls, group_vals, create):
"""
Prepare values to create the stock.lot for the new group.
group_vals: dictionary with values to create farm.animal.group
It returns a dictionary with values to create stock.lot
"""
pool = Pool()
Specie = pool.get('farm.specie')
if not group_vals:
return {}
specie = Specie(group_vals['specie'])
assert specie.group_product
group_product = specie.group_product.id
return {
'number': group_vals['number'],
'product': group_product,
'animal_type': 'group',
}
@classmethod
def delete(cls, groups):
pool = Pool()
Lot = pool.get('stock.lot')
lots = [g.lot for g in groups]
if lots:
Lot.write(lots, {'animal_group': None})
result = super(AnimalGroup, cls).delete(groups)
if lots:
Lot.delete(lots)
return result
class AnimalGroupTag(ModelSQL):
'Animal Group - Tag'
__name__ = 'farm.animal.group-farm.tag'
group = fields.Many2One('farm.animal.group', 'Group', ondelete='CASCADE',
required=True)
tag = fields.Many2One('farm.tag', 'Tag', ondelete='CASCADE', required=True)
class AnimalGroupWeight(ModelSQL, ModelView):
'Farm Animal Group Weight Record'
__name__ = 'farm.animal.group.weight'
_order = ('timestamp', 'DESC')
group = fields.Many2One('farm.animal.group', 'Group', required=True,
ondelete='CASCADE')
timestamp = fields.DateTime('Date & Time', required=True)
quantity = fields.Integer('Number of individuals', required=True)
uom = fields.Many2One('product.uom', "UoM", required=True,
domain=[('category', '=', Id('product', 'uom_cat_weight'))])
unit_digits = fields.Function(fields.Integer('Unit Digits'),
'on_change_with_unit_digits')
weight = fields.Numeric('Weight',
digits=(16, Eval('unit_digits', 2)),
required=True,
depends=['unit_digits'])
@staticmethod
def default_timestamp():
return datetime.now()
@staticmethod
def default_uom():
return Pool().get('ir.model.data').get_id('product', 'uom_kilogram')
@staticmethod
def default_unit_digits():
return 2
def get_rec_name(self, name):
return '%s %s (%s)' % (self.weight, self.uom.symbol, self.timestamp)
@classmethod
def search_rec_name(cls, name, clause):
operand = clause[2]
if isinstance(operand, str):
# May be None
operand = operand.replace('%', '')
try:
operand = Decimal(operand)
except:
return [('weight', '=', 0)]
operator = clause[1]
operator = operator.replace('ilike', '=').replace('like', '=')
return [('weight', operator, operand)]
@fields.depends('uom')
def on_change_with_unit_digits(self, name=None):
if self.uom:
return self.uom.digits
return 2