"""Folios auto-generados del ciclo comercial (Oportunidad → Solicitud → Cotización → Operación). Formato: ``{LETRA}{AAAA}-{MM}-{NNN}-{DIR}`` (ej. ``O2025-08-001-E``): - LETRA: entidad — ``O`` Oportunidad, ``S`` Solicitud, ``C`` Cotización, ``OP`` Operación/Embarque. - ``AAAA-MM``: año-mes de creación. - ``NNN``: consecutivo **mensual** por compañía y por entidad (reinicia cada mes). - ``DIR``: ``I`` importación / ``E`` exportación (``X`` si aún no se define la dirección). El consecutivo se toma de ``crm.folio_counters`` con bloqueo de fila para evitar duplicados por concurrencia. En SQLite (pruebas) el ``FOR UPDATE`` se ignora sin error; la unicidad la garantiza el índice único (tenant, company, entity, period). """ from __future__ import annotations from datetime import date from sqlalchemy import Integer, String, UniqueConstraint, text from sqlalchemy.orm import Mapped, mapped_column from api.v1.common.base_models import BaseTimestampMixin, TenantScopedMixin from core.database import Base # Entidades válidas y su letra de folio (F = factura, sin dirección impo/expo). ENTITIES = ("O", "S", "C", "OP", "F") # Mapa dirección de operación → sufijo del folio. _DIRECTION_SUFFIX = {"importacion": "I", "exportacion": "E"} class FolioCounter(Base, TenantScopedMixin, BaseTimestampMixin): """Consecutivo mensual por compañía y entidad para armar los folios del ciclo.""" __tablename__ = "folio_counters" __table_args__ = ( UniqueConstraint( "tenant_id", "company_id", "entity", "period", name="uq_crm_folio_counters_scope" ), {"schema": "crm"}, ) id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) entity: Mapped[str] = mapped_column(String(4), nullable=False) # O | S | C | OP period: Mapped[str] = mapped_column(String(7), nullable=False) # 'AAAA-MM' last_number: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) def direction_suffix(direction: str | None) -> str: """Devuelve la letra de dirección del folio (I/E) o 'X' si no está definida.""" return _DIRECTION_SUFFIX.get(direction or "", "X") def next_folio( db, tenant_id: int, company_id: int, entity: str, direction: str | None, on_date: date | None = None, with_direction: bool = True, ) -> str: """Genera el siguiente folio de una entidad, incrementando su consecutivo mensual. Reserva el número dentro de la transacción activa (no hace commit): el ``create_*`` que lo invoca es quien confirma junto con la fila recién creada. ``with_direction=False`` omite el sufijo I/E (p. ej. facturas → ``F2026-08-001``). """ if entity not in ENTITIES: raise ValueError(f"Entidad de folio inválida: {entity!r}") on_date = on_date or date.today() period = on_date.strftime("%Y-%m") counter = ( db.query(FolioCounter) .filter( FolioCounter.tenant_id == tenant_id, FolioCounter.company_id == company_id, FolioCounter.entity == entity, FolioCounter.period == period, ) .with_for_update() .first() ) if counter is None: counter = FolioCounter( tenant_id=tenant_id, company_id=company_id, entity=entity, period=period, last_number=0 ) db.add(counter) db.flush() counter.last_number = (counter.last_number or 0) + 1 db.flush() sequence = f"{counter.last_number:03d}" if not with_direction: return f"{entity}{period}-{sequence}" return f"{entity}{period}-{sequence}-{direction_suffix(direction)}"