- Tabla crm.cases (expediente) con folio EXP2026-08-001 (next_folio entidad EXP,
sin dirección). Nace al crear la Oportunidad y se hereda vía case_id a
solicitud → cotización → operación → factura. advance_stage solo avanza.
- case_id (FK a crm.cases) en crm.opportunities/service_requests/quotes,
ops.shipments y fin.invoices; propagación en sus create_*. Migración
d4e5f6a7b8c9 reversible.
- Endpoints GET /v1/crm/cases, /cases/{id}, /cases/by-ref/{ref} con timeline
(historia completa para UI y otros sistemas).
- Frontend: casesAPI, ruta /dashboard/crm/expedientes (lista + timeline vertical),
chip "📁 Expediente" en solicitud/cotización, "Expedientes" en el sidebar.
- Consecutivo de folios sin tope (soporta >10,000,000/mes).
- 4 pruebas de expediente (minteo, propagación, timeline, no-retroceso). Suite en verde (113).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
97 lines
3.6 KiB
Python
97 lines
3.6 KiB
Python
"""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, EXP = expediente; sin dirección).
|
|
ENTITIES = ("O", "S", "C", "OP", "F", "EXP")
|
|
# 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)}"
|