174 lines
8.1 KiB
Python
174 lines
8.1 KiB
Python
|
|
"""Opérations transactionnelles et FEFO du stock d'entretien."""
|
||
|
|
from datetime import date
|
||
|
|
from decimal import Decimal, ROUND_HALF_UP
|
||
|
|
|
||
|
|
from sqlalchemy import or_, case
|
||
|
|
|
||
|
|
from ...extensions import db
|
||
|
|
from ...core.models import (
|
||
|
|
ProductGeneric, CommercialProduct, ProductPackaging, StockLocation,
|
||
|
|
StockLot, StockLotBalance, StockMovement, ReusableContainer, ContainerFill,
|
||
|
|
)
|
||
|
|
|
||
|
|
Q = Decimal("0.000001")
|
||
|
|
|
||
|
|
|
||
|
|
class StockError(ValueError):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def dec(value):
|
||
|
|
try:
|
||
|
|
return Decimal(str(value)).quantize(Q, rounding=ROUND_HALF_UP)
|
||
|
|
except Exception as exc:
|
||
|
|
raise StockError("Quantité invalide") from exc
|
||
|
|
|
||
|
|
|
||
|
|
def _balance(lot_id, location_id, create=False):
|
||
|
|
query = StockLotBalance.query.filter_by(lot_id=lot_id, location_id=location_id)
|
||
|
|
balance = query.with_for_update().first()
|
||
|
|
if not balance and create:
|
||
|
|
balance = StockLotBalance(lot_id=lot_id, location_id=location_id, quantity=0)
|
||
|
|
db.session.add(balance)
|
||
|
|
db.session.flush()
|
||
|
|
return balance
|
||
|
|
|
||
|
|
|
||
|
|
def _movement(**kwargs):
|
||
|
|
movement = StockMovement(**kwargs)
|
||
|
|
db.session.add(movement)
|
||
|
|
return movement
|
||
|
|
|
||
|
|
|
||
|
|
def receive_stock(*, generic_product_id, commercial_product_id, packaging_id, packages,
|
||
|
|
lot_number, received_at, location_id, user_id=None, expiry_date=None,
|
||
|
|
manufactured_at=None, comment=None):
|
||
|
|
"""Réceptionne un nombre de conditionnements et crée le lot + mouvement."""
|
||
|
|
packages = dec(packages)
|
||
|
|
if packages <= 0:
|
||
|
|
raise StockError("La quantité reçue doit être positive")
|
||
|
|
packaging = ProductPackaging.query.get(packaging_id)
|
||
|
|
if not packaging or packaging.generic_product_id != generic_product_id:
|
||
|
|
raise StockError("Conditionnement inconnu pour ce produit")
|
||
|
|
if packaging.commercial_product_id and packaging.commercial_product_id != commercial_product_id:
|
||
|
|
raise StockError("Conditionnement associé à une autre référence")
|
||
|
|
quantity = (packages * dec(packaging.reference_quantity_per_package)).quantize(Q)
|
||
|
|
lot = StockLot(generic_product_id=generic_product_id, commercial_product_id=commercial_product_id,
|
||
|
|
lot_number=(lot_number or "").strip() or "Sans numéro", received_at=received_at,
|
||
|
|
manufactured_at=manufactured_at, expiry_date=expiry_date,
|
||
|
|
quantity_received=quantity)
|
||
|
|
db.session.add(lot)
|
||
|
|
db.session.flush()
|
||
|
|
balance = _balance(lot.id, location_id, create=True)
|
||
|
|
balance.quantity = quantity
|
||
|
|
_movement(generic_product_id=generic_product_id, commercial_product_id=commercial_product_id,
|
||
|
|
lot_id=lot.id, movement_type="reception_fournisseur", quantity_reference=quantity,
|
||
|
|
destination_location_id=location_id, user_id=user_id, comment=comment)
|
||
|
|
return lot
|
||
|
|
|
||
|
|
|
||
|
|
def _usable_lots(generic_product_id, location_id, today=None):
|
||
|
|
today = today or date.today()
|
||
|
|
return (StockLot.query.join(StockLotBalance)
|
||
|
|
.filter(StockLot.generic_product_id == generic_product_id,
|
||
|
|
StockLotBalance.location_id == location_id,
|
||
|
|
StockLotBalance.quantity > 0,
|
||
|
|
StockLot.status.notin_(("bloqué", "detruit", "expire", "epuise")),
|
||
|
|
or_(StockLot.expiry_date.is_(None), StockLot.expiry_date >= today))
|
||
|
|
.order_by(case((StockLot.expiry_date.is_(None), 1), else_=0), StockLot.expiry_date, StockLot.received_at, StockLot.id)
|
||
|
|
.with_for_update().all())
|
||
|
|
|
||
|
|
|
||
|
|
def issue_stock(*, generic_product_id, quantity, source_location_id, user_id=None,
|
||
|
|
staff_id=None, movement_type="sortie_agent", comment=None, today=None):
|
||
|
|
"""Sortie FEFO atomique. Retourne les mouvements créés."""
|
||
|
|
requested = dec(quantity)
|
||
|
|
if requested <= 0:
|
||
|
|
raise StockError("La quantité doit être positive")
|
||
|
|
remaining = requested
|
||
|
|
movements = []
|
||
|
|
for lot in _usable_lots(generic_product_id, source_location_id, today=today):
|
||
|
|
balance = _balance(lot.id, source_location_id)
|
||
|
|
available = dec(balance.quantity)
|
||
|
|
taken = min(available, remaining)
|
||
|
|
if taken <= 0:
|
||
|
|
continue
|
||
|
|
balance.quantity = available - taken
|
||
|
|
if balance.quantity == 0 and all(dec(b.quantity) == 0 for b in lot.balances):
|
||
|
|
lot.status = "epuise"
|
||
|
|
movements.append(_movement(generic_product_id=generic_product_id,
|
||
|
|
commercial_product_id=lot.commercial_product_id, lot_id=lot.id,
|
||
|
|
movement_type=movement_type, quantity_reference=-taken,
|
||
|
|
source_location_id=source_location_id, staff_id=staff_id, user_id=user_id, comment=comment))
|
||
|
|
remaining -= taken
|
||
|
|
if remaining <= 0:
|
||
|
|
break
|
||
|
|
if remaining > 0:
|
||
|
|
raise StockError(f"Stock disponible insuffisant (manque {remaining} unité(s) de référence)")
|
||
|
|
return movements
|
||
|
|
|
||
|
|
|
||
|
|
def transfer_stock(*, generic_product_id, quantity, source_location_id, destination_location_id,
|
||
|
|
user_id=None, comment=None):
|
||
|
|
if source_location_id == destination_location_id:
|
||
|
|
raise StockError("Les emplacements source et destination doivent être différents")
|
||
|
|
requested = dec(quantity)
|
||
|
|
if requested <= 0:
|
||
|
|
raise StockError("La quantité doit être positive")
|
||
|
|
remaining = requested
|
||
|
|
total = Decimal("0")
|
||
|
|
for lot in _usable_lots(generic_product_id, source_location_id):
|
||
|
|
source = _balance(lot.id, source_location_id)
|
||
|
|
taken = min(dec(source.quantity), remaining)
|
||
|
|
if taken <= 0:
|
||
|
|
continue
|
||
|
|
source.quantity = dec(source.quantity) - taken
|
||
|
|
destination = _balance(lot.id, destination_location_id, create=True)
|
||
|
|
destination.quantity = dec(destination.quantity) + taken
|
||
|
|
_movement(generic_product_id=generic_product_id,
|
||
|
|
commercial_product_id=lot.commercial_product_id, lot_id=lot.id,
|
||
|
|
movement_type="transfert", quantity_reference=-taken,
|
||
|
|
source_location_id=source_location_id, destination_location_id=destination_location_id,
|
||
|
|
user_id=user_id, comment=comment)
|
||
|
|
total += taken
|
||
|
|
remaining -= taken
|
||
|
|
if remaining <= 0:
|
||
|
|
break
|
||
|
|
if remaining > 0:
|
||
|
|
raise StockError(f"Stock disponible insuffisant (manque {remaining} unité(s) de référence)")
|
||
|
|
return total
|
||
|
|
|
||
|
|
|
||
|
|
def transvasement(*, generic_product_id, quantity_liters, source_location_id,
|
||
|
|
container_id=None, user_id=None, staff_id=None, comment=None):
|
||
|
|
movements = issue_stock(generic_product_id=generic_product_id, quantity=quantity_liters,
|
||
|
|
source_location_id=source_location_id, user_id=user_id,
|
||
|
|
staff_id=staff_id, movement_type="transvasement", comment=comment)
|
||
|
|
total = sum((-dec(m.quantity_reference) for m in movements), Decimal("0"))
|
||
|
|
if container_id:
|
||
|
|
container = ReusableContainer.query.get(container_id)
|
||
|
|
if not container:
|
||
|
|
raise StockError("Flacon inconnu")
|
||
|
|
if container.dedicated_product_id and container.dedicated_product_id != generic_product_id:
|
||
|
|
raise StockError("Ce flacon est dédié à un autre produit")
|
||
|
|
if total > dec(container.volume_liters):
|
||
|
|
raise StockError("Le volume dépasse la capacité du flacon")
|
||
|
|
container.current_product_id = generic_product_id
|
||
|
|
container.last_filled_at = db.func.now()
|
||
|
|
db.session.flush()
|
||
|
|
db.session.add(ContainerFill(container_id=container.id, movement_id=movements[0].id,
|
||
|
|
volume_liters=total))
|
||
|
|
return movements
|
||
|
|
|
||
|
|
|
||
|
|
def dilute(*, generic_product_id, total_liters, ratio_percent, source_location_id,
|
||
|
|
user_id=None, staff_id=None, container_id=None, comment=None):
|
||
|
|
total = dec(total_liters)
|
||
|
|
ratio = dec(ratio_percent)
|
||
|
|
if total <= 0 or ratio <= 0 or ratio >= 100:
|
||
|
|
raise StockError("Volume et taux de dilution invalides")
|
||
|
|
concentrate = (total * ratio / Decimal("100")).quantize(Q)
|
||
|
|
return transvasement(generic_product_id=generic_product_id, quantity_liters=concentrate,
|
||
|
|
source_location_id=source_location_id, container_id=container_id,
|
||
|
|
user_id=user_id, staff_id=staff_id, comment=f"Dilution {ratio}%{(' — ' + comment) if comment else ''}")
|