586 lines
21 KiB
Python
586 lines
21 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 as dt
|
|
from collections import defaultdict
|
|
from itertools import groupby
|
|
|
|
from trytond.model import Index, Model, ModelSQL, ModelView, Workflow, fields
|
|
from trytond.modules.company.model import (
|
|
employee_field, reset_employee, set_employee)
|
|
from trytond.pool import Pool, PoolMeta
|
|
from trytond.pyson import Eval
|
|
from trytond.transaction import Transaction
|
|
from trytond.wizard import Button, StateAction, StateView, Wizard
|
|
|
|
|
|
class QuantityEarlyPlan(Workflow, ModelSQL, ModelView):
|
|
__name__ = 'stock.quantity.early_plan'
|
|
|
|
company = fields.Many2One(
|
|
'company.company', "Company", required=True)
|
|
origin = fields.Reference(
|
|
"Origin", 'get_origins', required=True,
|
|
domain={
|
|
'stock.move': [
|
|
('company', '=', Eval('company', -1)),
|
|
],
|
|
'stock.shipment.out': [
|
|
('company', '=', Eval('company', -1)),
|
|
],
|
|
'stock.shipment.in.return': [
|
|
('company', '=', Eval('company', -1)),
|
|
],
|
|
'stock.shipment.internal': [
|
|
('company', '=', Eval('company', -1)),
|
|
],
|
|
})
|
|
planned_date = fields.Function(
|
|
fields.Date("Planned Date"),
|
|
'on_change_with_planned_date')
|
|
early_quantity = fields.Float(
|
|
"Early Quantity", readonly=True,
|
|
states={
|
|
'invisible': True,
|
|
})
|
|
early_date = fields.Date(
|
|
"Early Date", readonly=True,
|
|
states={
|
|
'invisible': True,
|
|
})
|
|
earlier_date = fields.Function(
|
|
fields.Date("Earlier Date"), 'get_earlier_date')
|
|
earliest_date = fields.Function(
|
|
fields.Date("Earliest Date"), 'get_earliest_date')
|
|
earliest_percentage = fields.Function(
|
|
fields.Float(
|
|
"Earliest Percentage", digits=(1, 4),
|
|
states={
|
|
'invisible': ~Eval('earliest_date'),
|
|
}),
|
|
'get_earliest_percentage')
|
|
warehouse = fields.Function(
|
|
fields.Many2One('stock.location', "Warehouse"),
|
|
'on_change_with_warehouse')
|
|
moves = fields.Function(
|
|
fields.Many2Many(
|
|
'stock.quantity.early_plan', None, None, "Moves",
|
|
states={
|
|
'invisible': ~Eval('moves'),
|
|
}),
|
|
'get_moves')
|
|
|
|
processed_by = employee_field("Processed by", states=['processing'])
|
|
closed_by = employee_field("Closed by", states=['closed'])
|
|
ignored_by = employee_field("Ignored by", states=['ignored'])
|
|
|
|
state = fields.Selection([
|
|
('open', "Open"),
|
|
('processing', "Processing"),
|
|
('closed', "Closed"),
|
|
('ignored', "Ignored"),
|
|
], "State", required=True, readonly=True, sort=False)
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
t = cls.__table__()
|
|
cls._sql_indexes.add(
|
|
Index(
|
|
t,
|
|
(t.state, Index.Equality(cardinality='low')),
|
|
where=t.state.in_(['open', 'processing'])))
|
|
cls._transitions |= {
|
|
('open', 'processing'),
|
|
('open', 'ignored'),
|
|
('processing', 'closed'),
|
|
('processing', 'open'),
|
|
('processing', 'ignored'),
|
|
('ignored', 'open'),
|
|
}
|
|
cls._buttons.update({
|
|
'open': {
|
|
'invisible': ~Eval('state').in_(['processing', 'ignored']),
|
|
'depends': ['state'],
|
|
},
|
|
'process': {
|
|
'invisible': Eval('state') != 'open',
|
|
'depends': ['state'],
|
|
},
|
|
'close': {
|
|
'invisible': Eval('state') != 'processing',
|
|
'depends': ['state'],
|
|
},
|
|
'ignore': {
|
|
'invisible': ~Eval('state').in_(['open', 'processing']),
|
|
'depends': ['state'],
|
|
},
|
|
})
|
|
|
|
@classmethod
|
|
def get_origins(cls):
|
|
pool = Pool()
|
|
Model = pool.get('ir.model')
|
|
get_name = Model.get_name
|
|
models = cls._get_origins()
|
|
return [(m, get_name(m)) for m in models]
|
|
|
|
@classmethod
|
|
def _get_origins(cls):
|
|
"Return a list of Model names for origin Reference"
|
|
return [
|
|
'stock.move',
|
|
'stock.shipment.out',
|
|
'stock.shipment.in.return',
|
|
'stock.shipment.internal',
|
|
]
|
|
|
|
@fields.depends('origin')
|
|
def on_change_with_planned_date(self, name=None):
|
|
if isinstance(self.origin, Model) and self.origin.id >= 0:
|
|
return self.origin.planned_date
|
|
|
|
def get_earlier_date(self, name):
|
|
return self._get_dates(max)
|
|
|
|
def get_earliest_date(self, name):
|
|
return self._get_dates(min)
|
|
|
|
@property
|
|
def _allow_partial_moves(self):
|
|
"Allow to early planning without all moves"
|
|
return True
|
|
|
|
def _get_dates(self, aggregate):
|
|
pool = Pool()
|
|
Move = pool.get('stock.move')
|
|
if isinstance(self.origin, Move) and self.origin.id >= 0:
|
|
if (aggregate == max
|
|
and self.early_quantity != self.origin.internal_quantity):
|
|
return self.origin.planned_date
|
|
else:
|
|
return self.early_date
|
|
elif (not self._allow_partial_moves
|
|
and any(not m.early_date for m in self.moves)):
|
|
return self.planned_date
|
|
else:
|
|
return aggregate(
|
|
filter(None, (m._get_dates(aggregate) for m in self.moves)),
|
|
default=self.planned_date)
|
|
|
|
@property
|
|
def _early_quantity(self):
|
|
if isinstance(self.origin, Move) and self.origin.id >= 0:
|
|
if self.early_quantity is not None:
|
|
return self.early_quantity
|
|
else:
|
|
return self.origin.internal_quantity
|
|
|
|
def get_earliest_percentage(self, name):
|
|
pool = Pool()
|
|
Move = pool.get('stock.move')
|
|
if isinstance(self.origin, Move) and self.origin.id >= 0:
|
|
return round(
|
|
self._early_quantity / self.origin.internal_quantity, 4)
|
|
else:
|
|
date = self._get_dates(min)
|
|
total = sum(m.origin.internal_quantity for m in self.moves)
|
|
quantity = sum(
|
|
m._early_quantity for m in self.moves
|
|
if (m.early_date or m.planned_date or dt.date.max) <= date)
|
|
if total:
|
|
return round(quantity / total, 4)
|
|
else:
|
|
return 1
|
|
|
|
@classmethod
|
|
def default_warehouse(cls):
|
|
pool = Pool()
|
|
Location = pool.get('stock.location')
|
|
return Location.get_default_warehouse()
|
|
|
|
@fields.depends('origin')
|
|
def on_change_with_warehouse(self, name=None):
|
|
pool = Pool()
|
|
Move = pool.get('stock.move')
|
|
if isinstance(self.origin, Move) and self.origin.id >= 0:
|
|
return self.origin.from_location.warehouse
|
|
elif (isinstance(self.origin, Model) and self.origin.id >= 0
|
|
and hasattr(self.origin, 'warehouse')):
|
|
return self.origin.warehouse
|
|
|
|
def get_moves(self, name):
|
|
pool = Pool()
|
|
ShipmentOut = pool.get('stock.shipment.out')
|
|
ShipmentInReturn = pool.get('stock.shipment.in.return')
|
|
ShipmentInternal = pool.get('stock.shipment.internal')
|
|
moves = []
|
|
if isinstance(self.origin, (
|
|
ShipmentOut, ShipmentInReturn, ShipmentInternal)):
|
|
for move in self.origin.moves:
|
|
moves.extend([p.id for p in move.quantity_early_plans])
|
|
return moves
|
|
|
|
@classmethod
|
|
def default_state(cls):
|
|
return 'open'
|
|
|
|
def get_rec_name(self, name):
|
|
return (self.origin.rec_name if isinstance(self.origin, Model)
|
|
else '(%s)' % self.id)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('open')
|
|
@reset_employee('processed_by', 'ignored_by')
|
|
def open(cls, plans):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('processing')
|
|
@set_employee('processed_by')
|
|
def process(cls, plans):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('closed')
|
|
@set_employee('closed_by')
|
|
def close(cls, plans):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('ignored')
|
|
@set_employee('ignored_by')
|
|
def ignore(cls, plans):
|
|
pass
|
|
|
|
@classmethod
|
|
def generate_plans(cls, warehouses=None, company=None):
|
|
"""
|
|
For each outgoing move it creates an early plan and a plan for its
|
|
shipment.
|
|
|
|
If warehouses is specified it searches only for moves from them.
|
|
"""
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
Location = pool.get('stock.location')
|
|
Move = pool.get('stock.move')
|
|
User = pool.get('res.user')
|
|
|
|
if warehouses is None:
|
|
warehouses = Location.search([
|
|
('type', '=', 'warehouse'),
|
|
])
|
|
if company is None:
|
|
company = User(Transaction().user).company
|
|
|
|
with Transaction().set_context(company=company.id):
|
|
today = Date.today()
|
|
|
|
# Do not keep former plan as the may no more be valid
|
|
opens = cls.search([
|
|
('company', '=', company.id),
|
|
('state', '=', 'open'),
|
|
])
|
|
opens = [
|
|
p for p in opens if p.warehouse in warehouses or not p.warehouse]
|
|
cls.delete(opens)
|
|
|
|
plans = {}
|
|
for plan in cls.search([
|
|
('company', '=', company.id),
|
|
('state', 'in', ['processing', 'ignored']),
|
|
]):
|
|
if plan.warehouse not in warehouses:
|
|
continue
|
|
plans[plan.origin] = plan
|
|
|
|
for warehouse in warehouses:
|
|
moves = Move.search([
|
|
('company', '=', company.id),
|
|
('from_location', 'child_of', [warehouse.id], 'parent'),
|
|
('to_location', 'not child_of', [warehouse.id], 'parent'),
|
|
('planned_date', '>', today),
|
|
('state', '=', 'draft'),
|
|
],
|
|
order=[('product.id', 'ASC'), ('planned_date', 'ASC')])
|
|
|
|
for product, moves in groupby(moves, lambda m: m.product):
|
|
for move in moves:
|
|
earlier_date, quantity = cls._get_earlier_date(
|
|
move, warehouse)
|
|
plan = cls._add(move, plans)
|
|
if earlier_date < move.planned_date:
|
|
plan.early_date = earlier_date
|
|
else:
|
|
plan.early_date = None
|
|
plan.early_quantity = quantity
|
|
|
|
for parent in cls._parents(move):
|
|
cls._add(parent, plans)
|
|
cls.save(plans.values())
|
|
|
|
to_delete = []
|
|
for plan in cls.browse(plans.values()):
|
|
if (plan.state == 'open'
|
|
and not isinstance(plan.origin, Move)
|
|
and plan.earliest_date == plan.planned_date):
|
|
to_delete.append(plan)
|
|
cls.delete(to_delete)
|
|
|
|
# Update early date based on internal incoming requests
|
|
for warehouse in warehouses:
|
|
product_plans = cls.search([
|
|
('company', '=', company.id),
|
|
('state', '=', 'open'),
|
|
('origin', 'like', 'stock.move,%'),
|
|
],
|
|
order=[('early_date', 'ASC NULLS LAST')])
|
|
|
|
in_plans = cls.search([
|
|
('company', '=', company.id),
|
|
('state', 'in', ['open', 'processing']),
|
|
cls._incoming_domain(),
|
|
])
|
|
product2in = defaultdict(lambda: defaultdict(list))
|
|
for plan in in_plans:
|
|
products = defaultdict(int)
|
|
for product, quantity in plan._incoming_quantities(warehouse):
|
|
products[product] += quantity
|
|
for product, quantity in products.items():
|
|
product2in[product][plan.planned_date].append(
|
|
(quantity, plan))
|
|
|
|
to_save = []
|
|
products = set()
|
|
for product_plan in product_plans:
|
|
if product_plan.warehouse != warehouse:
|
|
continue
|
|
product = product_plan.origin.product
|
|
quantity = product_plan.origin.internal_quantity
|
|
plans = product2in[product][product_plan.early_date]
|
|
plans = cls._pick_incoming(quantity, plans)
|
|
if plans:
|
|
incoming_products = {p
|
|
for pl in plans
|
|
for p, q in pl._incoming_quantities(warehouse)}
|
|
if incoming_products & products:
|
|
cls.save(to_save)
|
|
del to_save[:]
|
|
products.clear()
|
|
|
|
earlier_date = max(p.earlier_date for p in plans)
|
|
|
|
if (not product_plan.early_date
|
|
or product_plan.early_date > earlier_date):
|
|
product_plan.early_date = earlier_date
|
|
to_save.append(product_plan)
|
|
products.add(product)
|
|
cls.save(to_save)
|
|
|
|
@classmethod
|
|
def _get_earlier_date(cls, move, warehouse):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
ProductQuantitiesByWarehouse = pool.get(
|
|
'stock.product_quantities_warehouse')
|
|
product = move.product
|
|
with Transaction().set_context(company=move.company.id):
|
|
today = Date.today()
|
|
|
|
quantity = move.internal_quantity
|
|
if product.consumable:
|
|
return today, quantity
|
|
|
|
with Transaction().set_context(
|
|
product=product.id,
|
|
warehouse=warehouse.id,
|
|
stock_skip_warehouse=False,
|
|
):
|
|
product_quantities = (
|
|
ProductQuantitiesByWarehouse.search([
|
|
('date', '>=', today),
|
|
('date', '<=', move.planned_date),
|
|
],
|
|
order=[('date', 'DESC')]))
|
|
future_product_quantities = (
|
|
ProductQuantitiesByWarehouse.search([
|
|
('date', '>=', move.planned_date),
|
|
]))
|
|
min_future_product_quantity = min(
|
|
p.quantity for p in future_product_quantities)
|
|
earlier_date = move.planned_date
|
|
if product_quantities and product_quantities[0].quantity >= 0:
|
|
assert product_quantities[0].date == move.planned_date
|
|
if product_quantities[0].quantity > -quantity:
|
|
# The new date must left the same available
|
|
# quantity for other moves at the current date
|
|
min_quantity = (
|
|
product_quantities[0].quantity
|
|
+ move.internal_quantity)
|
|
quantity = min(min_quantity, move.internal_quantity)
|
|
if min_future_product_quantity > 0:
|
|
# The remaining quantities can be used
|
|
min_quantity -= min_future_product_quantity
|
|
if quantity >= 0:
|
|
for product_quantity in product_quantities[1:]:
|
|
if product_quantity.quantity < min_quantity:
|
|
if earlier_date == move.planned_date:
|
|
# Not found earlier date,
|
|
# try with the first smaller quantity
|
|
quantity = min(
|
|
quantity, product_quantity.quantity)
|
|
min_quantity = min(
|
|
product_quantity.quantity,
|
|
move.internal_quantity)
|
|
else:
|
|
break
|
|
earlier_date = product_quantity.date
|
|
return earlier_date, quantity
|
|
|
|
@classmethod
|
|
def _add(cls, origin, plans):
|
|
if origin in plans:
|
|
plan = plans[origin]
|
|
else:
|
|
plan = plans[origin] = cls(
|
|
company=origin.company,
|
|
origin=origin)
|
|
return plan
|
|
|
|
@classmethod
|
|
def _parents(cls, move):
|
|
if move.shipment:
|
|
yield move.shipment
|
|
|
|
@classmethod
|
|
def _incoming_domain(cls):
|
|
return ['OR',
|
|
('origin.state', '=', 'request', 'stock.shipment.internal'),
|
|
]
|
|
|
|
def _incoming_quantities(self, warehouse):
|
|
pool = Pool()
|
|
ShipmentInternal = pool.get('stock.shipment.internal')
|
|
if isinstance(self.origin, ShipmentInternal):
|
|
shipment = self.origin
|
|
if (shipment.to_location.warehouse == warehouse
|
|
or shipment.from_location.warehouse != warehouse):
|
|
for move in shipment.moves:
|
|
if move.to_location.warehouse == warehouse:
|
|
yield move.product, move.internal_quantity
|
|
|
|
@classmethod
|
|
def _pick_incoming(cls, quantity, plans):
|
|
plans = [p for q, p in plans if q >= quantity]
|
|
plans.sort(key=lambda p: p.earlier_date)
|
|
return plans
|
|
|
|
|
|
class QuantityEarlyPlanProduction(metaclass=PoolMeta):
|
|
__name__ = 'stock.quantity.early_plan'
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
cls.origin.domain['production'] = [
|
|
('company', '=', Eval('company', -1)),
|
|
]
|
|
|
|
@classmethod
|
|
def _get_origins(cls):
|
|
return super()._get_origins() + ['production']
|
|
|
|
@property
|
|
def _allow_partial_moves(self):
|
|
pool = Pool()
|
|
Production = pool.get('production')
|
|
allow = super()._allow_partial_moves
|
|
if (isinstance(self.origin, Production)
|
|
and any(not m.early_date for m in self.moves)):
|
|
allow = False
|
|
return allow
|
|
|
|
def get_moves(self, name):
|
|
pool = Pool()
|
|
Production = pool.get('production')
|
|
moves = super().get_moves(name)
|
|
if isinstance(self.origin, Production):
|
|
for move in self.origin.inputs + self.origin.outputs:
|
|
moves.extend([p.id for p in move.quantity_early_plans])
|
|
return moves
|
|
|
|
@classmethod
|
|
def _parents(cls, move):
|
|
yield from super()._parents(move)
|
|
if move.production_input:
|
|
yield move.production_input
|
|
if move.production_output:
|
|
yield move.production_output
|
|
|
|
@classmethod
|
|
def _incoming_domain(cls):
|
|
return super()._incoming_domain() + [
|
|
('origin.state', '=', 'request', 'production'),
|
|
]
|
|
|
|
def _incoming_quantities(self, warehouse):
|
|
pool = Pool()
|
|
Production = pool.get('production')
|
|
yield from super()._incoming_quantities(warehouse)
|
|
if isinstance(self.origin, Production):
|
|
production = self.origin
|
|
if production.warehouse == warehouse:
|
|
for output in production.outputs:
|
|
yield output.product, output.internal_quantity
|
|
|
|
|
|
class Move(metaclass=PoolMeta):
|
|
__name__ = 'stock.move'
|
|
|
|
quantity_early_plans = fields.One2Many(
|
|
'stock.quantity.early_plan', 'origin',
|
|
"Quantity Early Plans", readonly=True,
|
|
order=[('early_date', 'ASC NULLS LAST')])
|
|
|
|
|
|
class QuantityEarlyPlanGenerate(Wizard):
|
|
__name__ = 'stock.quantity.early_plan.generate'
|
|
start = StateView(
|
|
'stock.quantity.early_plan.generate.start',
|
|
'stock_quantity_early_planning'
|
|
'.quantity_early_plan_generate_start_view_form', [
|
|
Button("Cancel", 'end', 'tryton-cancel'),
|
|
Button("Generate", 'generate', 'tryton-ok', default=True),
|
|
])
|
|
generate = StateAction(
|
|
'stock_quantity_early_planning.act_quantity_early_plan_form')
|
|
|
|
def transition_generate(self):
|
|
pool = Pool()
|
|
QuantityEarlyPlan = pool.get('stock.quantity.early_plan')
|
|
QuantityEarlyPlan.generate_plans(
|
|
warehouses=self.start.warehouses or None)
|
|
return 'end'
|
|
|
|
|
|
class QuantityEarlyPlanGenerateStart(ModelView):
|
|
__name__ = 'stock.quantity.early_plan.generate.start'
|
|
warehouses = fields.Many2Many(
|
|
'stock.location', None, None, "Warehouses",
|
|
domain=[
|
|
('type', '=', 'warehouse'),
|
|
],
|
|
help="If empty all warehouses are used.")
|
|
|
|
@classmethod
|
|
def default_warehouses(cls):
|
|
pool = Pool()
|
|
Location = pool.get('stock.location')
|
|
warehouse = Location.get_default_warehouse()
|
|
if warehouse:
|
|
return [warehouse]
|