first commit
This commit is contained in:
585
modules/stock_quantity_early_planning/stock.py
Normal file
585
modules/stock_quantity_early_planning/stock.py
Normal file
@@ -0,0 +1,585 @@
|
||||
# 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]
|
||||
Reference in New Issue
Block a user