"""Modèles du module Matériel & Produits d'entretien. Les quantités sont toujours exprimées dans l'unité de référence du produit et stockées en DECIMAL. Les mouvements constituent l'historique immuable ; les soldes par lot/emplacement sont des projections verrouillées pendant les opérations transactionnelles. """ from datetime import datetime, timezone from decimal import Decimal from ...extensions import db def utcnow(): return datetime.now(timezone.utc) class ProductCategory(db.Model): __tablename__ = "cleaning_product_categories" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(120), nullable=False, unique=True) description = db.Column(db.Text) is_active = db.Column(db.Boolean, nullable=False, default=True) products = db.relationship("ProductGeneric", back_populates="category") class ProductGeneric(db.Model): __tablename__ = "cleaning_products_generic" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(200), nullable=False, unique=True) description = db.Column(db.Text) category_id = db.Column(db.Integer, db.ForeignKey("cleaning_product_categories.id"), index=True) product_type = db.Column(db.String(40), nullable=False, default="consommable") reference_unit = db.Column(db.String(30), nullable=False, default="unité") is_active = db.Column(db.Boolean, nullable=False, default=True) stock_minimum = db.Column(db.Numeric(14, 6), nullable=False, default=0) stock_security = db.Column(db.Numeric(14, 6), nullable=False, default=0) forecast_daily_quantity = db.Column(db.Numeric(14, 6), nullable=False, default=0) display_order = db.Column(db.Integer, nullable=False, default=0) usage_zones = db.Column(db.String(500)) request_enabled = db.Column(db.Boolean, nullable=False, default=True) allowed_distribution_modes = db.Column(db.String(255), nullable=False, default="standard") created_at = db.Column(db.DateTime, nullable=False, default=utcnow) updated_at = db.Column(db.DateTime, nullable=False, default=utcnow, onupdate=utcnow) category = db.relationship("ProductCategory", back_populates="products") commercial_products = db.relationship("CommercialProduct", back_populates="generic_product", cascade="all, delete-orphan") packagings = db.relationship("ProductPackaging", back_populates="generic_product", cascade="all, delete-orphan") lots = db.relationship("StockLot", back_populates="generic_product") @property def stock_total(self): return sum((b.quantity for lot in self.lots for b in lot.balances), Decimal("0")) class CommercialProduct(db.Model): __tablename__ = "cleaning_commercial_products" id = db.Column(db.Integer, primary_key=True) generic_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id"), nullable=False, index=True) commercial_name = db.Column(db.String(200), nullable=False) brand = db.Column(db.String(120)) manufacturer_id = db.Column(db.Integer, db.ForeignKey("companies.id"), index=True) supplier_id = db.Column(db.Integer, db.ForeignKey("companies.id"), index=True) manufacturer_reference = db.Column(db.String(120)) supplier_reference = db.Column(db.String(120)) barcode = db.Column(db.String(80), index=True) valid_from = db.Column(db.Date) valid_to = db.Column(db.Date) is_active = db.Column(db.Boolean, nullable=False, default=True) photo_path = db.Column(db.String(500)) dosage_information = db.Column(db.Text) is_concentrated = db.Column(db.Boolean, nullable=False, default=False) after_opening_days = db.Column(db.Integer) safety_notes = db.Column(db.Text) pictograms = db.Column(db.String(500)) warning_statement = db.Column(db.String(120)) hazard_statements = db.Column(db.Text) precautionary_statements = db.Column(db.Text) required_ppe = db.Column(db.String(500)) storage_conditions = db.Column(db.Text) incompatibilities = db.Column(db.Text) created_at = db.Column(db.DateTime, nullable=False, default=utcnow) generic_product = db.relationship("ProductGeneric", back_populates="commercial_products") manufacturer = db.relationship("Company", foreign_keys=[manufacturer_id]) supplier = db.relationship("Company", foreign_keys=[supplier_id]) lots = db.relationship("StockLot", back_populates="commercial_product") packagings = db.relationship("ProductPackaging", back_populates="commercial_product") documents = db.relationship("ProductDocument", back_populates="commercial_product", cascade="all, delete-orphan") class ProductPackaging(db.Model): __tablename__ = "cleaning_product_packagings" id = db.Column(db.Integer, primary_key=True) generic_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id"), nullable=False, index=True) commercial_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_commercial_products.id"), index=True) name = db.Column(db.String(160), nullable=False) purchase_unit = db.Column(db.String(40), nullable=False, default="conditionnement") units_per_package = db.Column(db.Numeric(14, 6), nullable=False, default=1) reference_quantity_per_package = db.Column(db.Numeric(14, 6), nullable=False) is_active = db.Column(db.Boolean, nullable=False, default=True) is_current = db.Column(db.Boolean, nullable=False, default=True) generic_product = db.relationship("ProductGeneric", back_populates="packagings") commercial_product = db.relationship("CommercialProduct", back_populates="packagings") class StockLocation(db.Model): __tablename__ = "cleaning_stock_locations" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(160), nullable=False, unique=True) building_id = db.Column(db.Integer, db.ForeignKey("buildings.id"), index=True) zone_id = db.Column(db.Integer, db.ForeignKey("zones.id"), index=True) room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), index=True) is_active = db.Column(db.Boolean, nullable=False, default=True) notes = db.Column(db.Text) balances = db.relationship("StockLotBalance", back_populates="location") class StockLot(db.Model): __tablename__ = "cleaning_stock_lots" id = db.Column(db.Integer, primary_key=True) generic_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id"), nullable=False, index=True) commercial_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_commercial_products.id"), nullable=False, index=True) lot_number = db.Column(db.String(120), nullable=False, index=True) received_at = db.Column(db.Date, nullable=False) manufactured_at = db.Column(db.Date) expiry_date = db.Column(db.Date, index=True) quantity_received = db.Column(db.Numeric(14, 6), nullable=False) opened_at = db.Column(db.Date) after_opening_days = db.Column(db.Integer) status = db.Column(db.String(30), nullable=False, default="disponible", index=True) notes = db.Column(db.Text) generic_product = db.relationship("ProductGeneric", back_populates="lots") commercial_product = db.relationship("CommercialProduct", back_populates="lots") balances = db.relationship("StockLotBalance", back_populates="lot", cascade="all, delete-orphan") movements = db.relationship("StockMovement", back_populates="lot") class StockLotBalance(db.Model): __tablename__ = "cleaning_stock_lot_balances" __table_args__ = (db.UniqueConstraint("lot_id", "location_id", name="uq_cleaning_lot_location"),) id = db.Column(db.Integer, primary_key=True) lot_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_lots.id", ondelete="RESTRICT"), nullable=False, index=True) location_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_locations.id", ondelete="RESTRICT"), nullable=False, index=True) quantity = db.Column(db.Numeric(14, 6), nullable=False, default=0) lot = db.relationship("StockLot", back_populates="balances") location = db.relationship("StockLocation", back_populates="balances") class StockMovement(db.Model): __tablename__ = "cleaning_stock_movements" id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) generic_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id"), nullable=False, index=True) commercial_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_commercial_products.id"), index=True) lot_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_lots.id"), index=True) movement_type = db.Column(db.String(30), nullable=False, index=True) quantity_reference = db.Column(db.Numeric(14, 6), nullable=False) source_location_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_locations.id"), index=True) destination_location_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_locations.id"), index=True) staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), index=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), index=True) comment = db.Column(db.Text) dilution_ratio = db.Column(db.Numeric(8, 5)) created_at = db.Column(db.DateTime, nullable=False, default=utcnow, index=True) lot = db.relationship("StockLot", back_populates="movements") generic_product = db.relationship("ProductGeneric") commercial_product = db.relationship("CommercialProduct") source_location = db.relationship("StockLocation", foreign_keys=[source_location_id]) destination_location = db.relationship("StockLocation", foreign_keys=[destination_location_id]) staff = db.relationship("Staff") user = db.relationship("User") class MaterialAssignment(db.Model): __tablename__ = "cleaning_material_assignments" id = db.Column(db.Integer, primary_key=True) equipment_id = db.Column(db.Integer, db.ForeignKey("equipments.id", ondelete="RESTRICT"), nullable=False, index=True) staff_id = db.Column(db.Integer, db.ForeignKey("staff.id"), index=True) zone_id = db.Column(db.Integer, db.ForeignKey("zones.id"), index=True) room_id = db.Column(db.Integer, db.ForeignKey("rooms.id"), index=True) status = db.Column(db.String(30), nullable=False, default="affecte") assigned_at = db.Column(db.DateTime, nullable=False, default=utcnow) returned_at = db.Column(db.DateTime) notes = db.Column(db.Text) equipment = db.relationship("Equipment") staff = db.relationship("Staff") zone = db.relationship("Zone") room = db.relationship("Room") class ReusableContainer(db.Model): __tablename__ = "cleaning_reusable_containers" id = db.Column(db.Integer, primary_key=True) identifier = db.Column(db.String(80), nullable=False, unique=True) container_type = db.Column(db.String(80), nullable=False) volume_liters = db.Column(db.Numeric(12, 6), nullable=False) dedicated_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id")) current_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id")) staff_id = db.Column(db.Integer, db.ForeignKey("staff.id")) zone_id = db.Column(db.Integer, db.ForeignKey("zones.id")) state = db.Column(db.String(30), nullable=False, default="disponible") last_filled_at = db.Column(db.DateTime) dedicated_product = db.relationship("ProductGeneric", foreign_keys=[dedicated_product_id]) current_product = db.relationship("ProductGeneric", foreign_keys=[current_product_id]) staff = db.relationship("Staff") zone = db.relationship("Zone") fills = db.relationship("ContainerFill", back_populates="container", cascade="all, delete-orphan") class ContainerFill(db.Model): __tablename__ = "cleaning_container_fills" id = db.Column(db.Integer, primary_key=True) container_id = db.Column(db.Integer, db.ForeignKey("cleaning_reusable_containers.id", ondelete="RESTRICT"), nullable=False, index=True) movement_id = db.Column(db.BigInteger, db.ForeignKey("cleaning_stock_movements.id", ondelete="RESTRICT"), nullable=False) volume_liters = db.Column(db.Numeric(12, 6), nullable=False) dilution_ratio = db.Column(db.Numeric(8, 5)) prepared_at = db.Column(db.DateTime, nullable=False, default=utcnow) container = db.relationship("ReusableContainer", back_populates="fills") movement = db.relationship("StockMovement") class ProductDocument(db.Model): __tablename__ = "cleaning_product_documents" id = db.Column(db.Integer, primary_key=True) commercial_product_id = db.Column(db.Integer, db.ForeignKey("cleaning_commercial_products.id", ondelete="RESTRICT"), nullable=False, index=True) document_type = db.Column(db.String(30), nullable=False, default="fds") title = db.Column(db.String(255), nullable=False) filename = db.Column(db.String(255), nullable=False) filepath = db.Column(db.String(500), nullable=False) version = db.Column(db.String(50)) published_at = db.Column(db.Date) uploaded_at = db.Column(db.DateTime, nullable=False, default=utcnow) uploaded_by_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")) is_current = db.Column(db.Boolean, nullable=False, default=True) commercial_product = db.relationship("CommercialProduct", back_populates="documents") uploaded_by = db.relationship("User") class RequestProfile(db.Model): __tablename__ = "cleaning_request_profiles" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(160), nullable=False, unique=True) description = db.Column(db.Text) is_active = db.Column(db.Boolean, nullable=False, default=True) items = db.relationship("RequestProfileItem", back_populates="profile", cascade="all, delete-orphan", order_by="RequestProfileItem.display_order") class RequestProfileItem(db.Model): __tablename__ = "cleaning_request_profile_items" id = db.Column(db.Integer, primary_key=True) profile_id = db.Column(db.Integer, db.ForeignKey("cleaning_request_profiles.id", ondelete="CASCADE"), nullable=False, index=True) product_id = db.Column(db.Integer, db.ForeignKey("cleaning_products_generic.id", ondelete="RESTRICT"), nullable=False, index=True) display_order = db.Column(db.Integer, nullable=False, default=0) profile = db.relationship("RequestProfile", back_populates="items") product = db.relationship("ProductGeneric") class StockInventory(db.Model): __tablename__ = "cleaning_stock_inventories" id = db.Column(db.Integer, primary_key=True) location_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_locations.id"), nullable=False, index=True) counted_at = db.Column(db.DateTime, nullable=False, default=utcnow) status = db.Column(db.String(20), nullable=False, default="brouillon") user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL")) notes = db.Column(db.Text) location = db.relationship("StockLocation") user = db.relationship("User") lines = db.relationship("StockInventoryLine", back_populates="inventory", cascade="all, delete-orphan") class StockInventoryLine(db.Model): __tablename__ = "cleaning_stock_inventory_lines" id = db.Column(db.Integer, primary_key=True) inventory_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_inventories.id", ondelete="CASCADE"), nullable=False, index=True) lot_id = db.Column(db.Integer, db.ForeignKey("cleaning_stock_lots.id", ondelete="RESTRICT"), nullable=False) theoretical_quantity = db.Column(db.Numeric(14, 6), nullable=False) counted_quantity = db.Column(db.Numeric(14, 6), nullable=False) inventory = db.relationship("StockInventory", back_populates="lines") lot = db.relationship("StockLot") class CleaningForecastConfig(db.Model): __tablename__ = "cleaning_forecast_configs" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(120), nullable=False, default="Configuration générale") normal_coefficient = db.Column(db.Numeric(8, 5), nullable=False, default=1) permanence_coefficient = db.Column(db.Numeric(8, 5), nullable=False, default=Decimal("0.4")) closed_coefficient = db.Column(db.Numeric(8, 5), nullable=False, default=0) exceptional_coefficient = db.Column(db.Numeric(8, 5), nullable=False, default=1) is_active = db.Column(db.Boolean, nullable=False, default=True)