Solicitud de servicio:
- Campos del documento maestro de cotización (ruta estructurada por país,
mercancía, dimensiones/bultos, FCL/LCL, servicios adicionales, notas).
- Origen/Destino seleccionables por catálogo de país (seed ya poblado).
- Validación de contacto asociado (422 si no existe).
Ciclo Oportunidad -> Solicitud -> Cotización -> Operación:
- Dirección impo/expo se captura en la Oportunidad y se hereda al ciclo.
- Conversión Oportunidad->Solicitud idempotente con back-link.
- Endpoint Solicitud->Cotización; "Ambas" genera 2 cotizaciones (FCL/LCL).
- Liberación a Operaciones confirma IMPO/EXPO (prefijado) y siembra los hitos.
- Fecha de la cotización (issue_date) por defecto hoy, editable y en el PDF.
Folios auto-generados {LETRA}{AAAA}-{MM}-{NNN}-{DIR} para Oportunidad (O),
Solicitud (S), Cotización (C) y Operación (OP); consecutivo mensual por
compañía y entidad (crm.folio_counters + helper next_folio con bloqueo de fila).
Catálogos: 9 nuevos (tipo_operacion, medio_transporte, tipo_servicio, prioridad,
tipo_mercancia, unidad_medida, tipo_embalaje, servicio_adicional, tipo_documento).
Migración b1c2d3e4f5a6 reversible (upgrade->downgrade->upgrade verificado en PG).
25 pruebas unitarias nuevas (folios, catálogos, solicitudes, cotizaciones,
embarques); suite completa en verde (101 pruebas).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
93 lines
3.4 KiB
Python
93 lines
3.4 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.
|
|
ENTITIES = ("O", "S", "C", "OP")
|
|
# 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,
|
|
) -> 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.
|
|
"""
|
|
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}"
|
|
return f"{entity}{period}-{sequence}-{direction_suffix(direction)}"
|