gmao/app_new/cleaning/services/stock.py

174 lines
8.1 KiB
Python
Raw Permalink Normal View History

"""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 ''}")