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