Catálogos y selects:
- Incoterm como catálogo (nuevo catálogo global 'incoterm') en solicitud y modal
de conversión de oportunidad.
- Moneda como catálogo en Cotizaciones (nuevo/detalle) y Facturación.
- "Tipo de transporte" desde catálogo medio_transporte (antes lista fija).
- Cotizador: origen/destino como selects alineados a las rutas de los tarifarios
activos (endpoint /rate-locations), para que el costeo siempre encuentre ruta.
Bugs de la sesión:
- Direcciones no guardaban: DTO country String(2)→String(3) (ISO alfa-3); se
amplía accounts.country y se normaliza 'MX'→'MEX' (migración).
- Contacto de proveedor mal filtrado: contacts.ts ahora envía supplier_id.
- Selects ilegibles en modo oscuro: regla global select option en app.css.
- Formas de pago SAT a 2 dígitos (01/04/08) en catálogo y valores guardados.
- RelatedManager: editar direcciones/contactos/documentos (antes solo eliminar).
Oportunidades:
- Se quitan etapas Prospecto/Contactado del embudo semilla.
- Fechas separadas won_date/lost_date + motivo de pérdida, con modal al mover a
Ganada/Perdida (migración).
Facturación:
- Folio automático F{AAAA}-{MM}-{NNN} (next_folio entidad F, sin dirección).
- Moneda como catálogo.
UI:
- Giro "otro" habilita campo para especificar (accounts.industry_other, migración).
- Lista de contactos muestra a quién pertenece (cliente/prospecto/proveedor).
- Proveedores: países/puertos/aeropuertos/aduanas por catálogo (select + chips).
Migraciones reversibles (c2d3e4f5a6b7 ya existía; d3e4f5a6b7c8, e4f5a6b7c8d9).
Suite backend en verde (109). svelte-check sin errores nuevos.
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, 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)}"
|