Files
2026-03-14 09:42:12 +00:00

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]