Files
tryton/modules/sale_supply/sale.py
T
Cédric Krier 9818cab01d Do not rely on assigned quantity to supply on stock first
Closes #13279
(grafted from 9f8439bce62dff9e6f59b9cf1b0851c58171d9a4)

--HG--
branch : 7.0
2024-05-24 15:20:43 +02:00

359 lines
13 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.
from collections import defaultdict
from itertools import groupby
from operator import attrgetter
from trytond.model import Model, fields
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Eval
from trytond.transaction import Transaction
class Sale(metaclass=PoolMeta):
__name__ = 'sale.sale'
def is_done(self):
done = super(Sale, self).is_done()
if done:
if any(l.supply_state == 'requested'
for l in self.lines if l.supply_on_sale):
return False
return done
@classmethod
def _process_shipment(cls, sales):
pool = Pool()
Product = pool.get('product.product')
product_quantities = defaultdict(float)
for (company, warehouse), sub_sales in groupby(
filter(attrgetter('warehouse'), sales),
key=attrgetter('company', 'warehouse')):
sub_sales = list(sub_sales)
products = {
l.product for s in sub_sales for l in s.lines
if l.product and l.product.supply_on_sale == 'stock_first'}
locations = [warehouse.id]
with Transaction().set_context(
company=company.id, locations=locations,
stock_assign=True,
stock_date_end=None):
product_quantities.update(
(p, p.forecast_quantity)
for p in Product.browse(products))
# purchase requests must be created before shipments to get
# information about requests during the shipments creation like the
# supplier
cls._process_supply(sub_sales, product_quantities)
product_quantities.clear()
super()._process_shipment(sales)
@classmethod
def _process_supply(cls, sales, product_quantities):
pool = Pool()
Line = pool.get('sale.line')
Move = pool.get('stock.move')
PurchaseRequest = pool.get('purchase.request')
ShipmentOut = pool.get('stock.shipment.out')
requests, lines = [], []
moves_to_draft, shipments_to_wait = [], set()
for sale in sales:
reqs, lns = sale.create_purchase_requests(product_quantities)
requests.extend(reqs)
lines.extend(lns)
PurchaseRequest.save(requests)
Line.save(lines)
for sale in sales:
moves, shipments = sale.create_move_from_supply()
moves_to_draft.extend(moves)
shipments_to_wait.update(shipments)
shipments_to_wait = ShipmentOut.browse(list(shipments_to_wait))
Move.draft(moves_to_draft)
ShipmentOut.wait(shipments_to_wait)
def create_purchase_requests(self, product_quantities):
requests, lines = [], []
for line in self.lines:
request = line.get_purchase_request(product_quantities)
if not request:
continue
requests.append(request)
assert not line.purchase_request
line.purchase_request = request
lines.append(line)
return requests, lines
def create_move_from_supply(self):
'Set to draft move linked to supply'
pool = Pool()
ShipmentOut = pool.get('stock.shipment.out')
moves = []
for line in self.lines:
if (not line.has_supply
or line.supply_state in {'supplied', 'cancelled'}):
for move in line.moves:
if move.state == 'staging':
moves.append(move)
shipments = {m.shipment for m in moves
if isinstance(m.shipment, ShipmentOut)}
return moves, shipments
class Line(metaclass=PoolMeta):
__name__ = 'sale.line'
purchase_request = fields.Many2One('purchase.request', 'Purchase Request',
ondelete='SET NULL', readonly=True)
supply_state = fields.Function(fields.Selection([
('', ""),
('requested', "Requested"),
('supplied', "Supplied"),
('cancelled', "Cancelled"),
], "Supply State",
states={
'invisible': ~Eval('supply_state'),
}), 'get_supply_state')
@property
def has_supply(self):
return bool(self.purchase_request)
def get_supply_state(self, name):
if self.purchase_request is not None:
if self.purchase_request.state == 'cancelled':
return 'cancelled'
else:
purchase_line = self.purchase_request.purchase_line
if purchase_line is not None:
purchase = purchase_line.purchase
if purchase.state in {'processing', 'done'}:
return 'supplied'
return 'requested'
return ''
@classmethod
def copy(cls, lines, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('purchase_request', None)
return super().copy(lines, default=default)
@property
def supply_on_sale(self):
"Returns True if the sale line has to be supply by purchase request"
if (self.type != 'line'
or self.sale.shipment_method == 'manual'
or not self.product
or self.quantity <= 0
or any(m.state not in ['staging', 'cancelled']
for m in self.moves)):
return False
return bool(self.product.supply_on_sale)
@property
def ready_for_supply(self):
if self.sale.shipment_method == 'invoice':
# Ensure to create the request for the maximum paid
invoice_skips = (
set(self.sale.invoices_ignored)
| set(self.sale.invoices_recreated))
invoice_lines = [
l for l in self.invoice_lines
if l.invoice not in invoice_skips]
if (not invoice_lines
or any(
(not l.invoice) or l.invoice.state != 'paid'
for l in invoice_lines)):
return False
return True
def get_move(self, shipment_type):
move = super().get_move(shipment_type)
if (move
and shipment_type == 'out'
and self.has_supply):
if self.supply_state == 'requested':
move.state = 'staging'
return move
def _get_move_quantity(self, shipment_type):
quantity = super()._get_move_quantity(shipment_type)
if self.supply_on_sale and not self.ready_for_supply:
quantity = 0
return quantity
def _get_purchase_request_product_supplier_pattern(self):
return {
'company': self.sale.company.id,
}
def get_purchase_request(self, product_quantities):
"""Return purchase request for the sale line
depending on the product quantities"""
pool = Pool()
Uom = pool.get('product.uom')
Request = pool.get('purchase.request')
if (not self.supply_on_sale
or self.purchase_request
or not self.ready_for_supply
or not self.product.purchasable):
return
product = self.product
quantity = self._get_move_quantity('out')
if product.supply_on_sale == 'stock_first':
available_qty = product_quantities[product]
available_qty = Uom.compute_qty(
product.default_uom, available_qty, self.unit,
round=False)
if available_qty > 0:
product_quantities[product] -= Uom.compute_qty(
self.unit, quantity, product.default_uom, round=False)
return
supplier, purchase_date = Request.find_best_supplier(product,
self.shipping_date,
**self._get_purchase_request_product_supplier_pattern())
unit = product.purchase_uom or product.default_uom
quantity = Uom.compute_qty(self.unit, quantity, unit)
return Request(
product=product,
party=supplier,
quantity=quantity,
unit=unit,
computed_quantity=quantity,
computed_unit=unit,
purchase_date=purchase_date,
supply_date=self.shipping_date,
company=self.sale.company,
warehouse=self.warehouse,
origin=self.sale,
)
def assign_supplied(self, quantities, grouping=('product',)):
'''
Assign supplied move
location_quantities will be updated according to assigned
quantities.
'''
pool = Pool()
Uom = pool.get('product.uom')
Move = pool.get('stock.move')
ShipmentOut = pool.get('stock.shipment.out')
Location = pool.get('stock.location')
if self.supply_state != 'supplied':
return
def get_key(move, location_id):
key = (location_id,)
for field in grouping:
value = getattr(move, field)
if isinstance(value, Model):
value = value.id
key += (value,)
return key
def get_values(key, location_name):
yield location_name, key[0]
for field, value in zip(grouping, key[1:]):
if value is not None and '.' not in field:
yield field, value
def match(key, pattern):
for k, p in zip(key, pattern):
if p is None or k == p:
continue
else:
return False
else:
return True
moves = set()
for move in self.moves:
shipment = move.shipment
if isinstance(shipment, ShipmentOut):
if shipment.warehouse_storage == shipment.warehouse_output:
inventory_moves = shipment.outgoing_moves
else:
inventory_moves = shipment.inventory_moves
for inv_move in inventory_moves:
if inv_move.product == self.product:
moves.add(inv_move)
child_locations = {}
to_write = []
to_assign = []
for move in moves:
if move.state != 'draft':
continue
childs = child_locations.get(move.from_location)
if childs is None:
childs = Location.search([
('parent', 'child_of', [move.from_location.id]),
('type', '!=', 'view'),
])
child_locations[move.from_location] = childs
# Prevent picking from the destination location
try:
childs.remove(move.to_location)
except ValueError:
pass
# Try first to pick from source location
try:
childs.remove(move.from_location)
childs.insert(0, move.from_location)
except ValueError:
# from_location may be a view
pass
qties_converted = []
for key, quantity in quantities.items():
move_key = get_key(move, key[0])
if match(key, move_key):
qty = Uom.compute_qty(
move.product.default_uom, quantity, move.unit,
round=False)
qties_converted.append((key, qty))
location_qties = move.sort_quantities(
qties_converted, childs, grouping)
to_pick = move.pick_product(location_qties)
picked_qties = sum(qty for _, qty in to_pick)
if picked_qties < move.quantity:
first = False
Move.write([move], {
'quantity': move.quantity - picked_qties,
})
else:
first = True
for key, qty in to_pick:
values = dict(get_values(key, 'from_location'))
values['quantity'] = move.unit.round(qty)
if first:
to_write.extend([[move], values])
to_assign.append(move)
first = False
else:
with Transaction().set_context(_stock_move_split=True):
to_assign.extend(Move.copy([move], default=values))
qty_default_uom = Uom.compute_qty(
move.unit, qty, move.product.default_uom, round=False)
quantities[key] -= qty_default_uom
if to_write:
Move.write(*to_write)
if to_assign:
Move.assign(to_assign)