feat(ops,fin,crm): reglas de negocio del PDF (decisiones, cierre, facturación, continuidad, RBAC)

Cierra los huecos de la auditoría contra "SOFTWARE PARA AGENTES DE CARGA":

- ops (Diag. 2/3): bitácora con puntos de decisión (kind=decision) y ciclo de
  corrección (parent_event_id/attempt) para ¿Cut Off? y ¿despacho autorizado?
  (R-E-05/13, R-I-06). Reprogramación de salida (previous_etd, R-E-06). Hitos
  operativos completos export/import. Cierre operativo con costos finales
  (close_shipment, R-E-22).
- fin (Diag. 4): facturación con gate por cierre operativo y sin duplicar
  (R-F-01), costos de operación arrastrados (ops_cost_total, R-F-02), envío con
  PDF generado y guardado en MinIO (send_invoice + pdf.py sin dependencias,
  R-F-05) y revisión del cliente (en_revision_cliente + aprobación, R-F-06).
- crm (Diag. 1): opportunity_id enlaza embudo→RFQ (R-C-02), contacto como etapa
  (first_contact_at, R-C-04), re-cotización (clone_quote + reopen, R-C-12).
- transversal: catálogo de Incoterms y participantes/actores incl. autoridad
  aduanera (R-T-01/10), enforcement de permisos por carril (RBAC) con roles
  sembrados y dependencias dev-safe (R-T-07).
- Migración d5e6f7a8b9c0 con downgrade. Seed extendido. 70 tests (12 nuevos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aduanasoft
2026-07-15 08:46:53 -06:00
parent 116d2e7f5a
commit e79705e6e3
26 changed files with 1407 additions and 68 deletions

View File

@@ -0,0 +1,37 @@
"""Catálogos de referencia del dominio (Incoterms y actores/participantes).
Se centralizan aquí para administrarlos en un solo lugar y validarlos desde el
levantamiento de requerimientos (R-T-10) y modelar los participantes del proceso
(R-T-01), incluyendo la autoridad aduanera.
"""
# Incoterms 2020 (R-T-10)
INCOTERMS: list[dict] = [
{"code": "EXW", "name": "Ex Works — En fábrica"},
{"code": "FCA", "name": "Free Carrier — Franco transportista"},
{"code": "FAS", "name": "Free Alongside Ship — Franco al costado del buque"},
{"code": "FOB", "name": "Free On Board — Franco a bordo"},
{"code": "CFR", "name": "Cost and Freight — Costo y flete"},
{"code": "CIF", "name": "Cost, Insurance and Freight — Costo, seguro y flete"},
{"code": "CPT", "name": "Carriage Paid To — Transporte pagado hasta"},
{"code": "CIP", "name": "Carriage and Insurance Paid To — Transporte y seguro pagados hasta"},
{"code": "DAP", "name": "Delivered At Place — Entregado en lugar"},
{"code": "DPU", "name": "Delivered At Place Unloaded — Entregado en lugar descargado"},
{"code": "DDP", "name": "Delivered Duty Paid — Entregado con derechos pagados"},
]
INCOTERM_CODES: set[str] = {i["code"] for i in INCOTERMS}
# Roles/actores del proceso (R-T-01). Los actores externos se administran como
# proveedores (crm.suppliers) vía su clasificación; el cliente/prospecto como cuenta.
PARTICIPANT_ROLES: list[dict] = [
{"code": "exportador", "label": "Exportador", "source": "account"},
{"code": "importador", "label": "Importador", "source": "account"},
{"code": "agente_carga", "label": "Agente de carga", "source": "supplier"},
{"code": "agente_aduanal", "label": "Agente aduanal", "source": "supplier"},
{"code": "naviera", "label": "Naviera", "source": "supplier"},
{"code": "aerolinea", "label": "Aerolínea", "source": "supplier"},
{"code": "transportista_terrestre", "label": "Transportista terrestre", "source": "supplier"},
{"code": "agente_corresponsal", "label": "Agente corresponsal", "source": "supplier"},
{"code": "autoridad_aduanera", "label": "Autoridad aduanera", "source": "supplier"},
]

View File

@@ -0,0 +1,91 @@
"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10)."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from ..accounts.models import Account
from ..suppliers.models import Supplier
from .data import INCOTERMS, PARTICIPANT_ROLES
router = APIRouter()
@router.get("/catalogs/incoterms")
def list_incoterms(
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
):
"""Catálogo de Incoterms 2020 (R-T-10)."""
return INCOTERMS
@router.get("/catalogs/participant-roles")
def list_participant_roles(
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
):
"""Catálogo de roles/actores del proceso, incluida la autoridad aduanera (R-T-01)."""
return PARTICIPANT_ROLES
@router.get("/participants")
def list_participants(
company_id: int = Query(..., description="Company ID"),
role: str | None = Query(None, description="Filtra por rol/clasificación"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Vista unificada de participantes del proceso: clientes/prospectos (cuentas) y
actores externos (proveedores por clasificación), en un solo catálogo (R-T-01)."""
tenant_id = current_user["tenant_id"]
result: list[dict] = []
accounts = (
db.query(Account)
.filter(
Account.tenant_id == tenant_id,
Account.company_id == company_id,
Account.deleted_at.is_(None),
)
.all()
)
for acc in accounts:
acc_role = getattr(acc, "record_type", None) or "cliente"
if role and role not in (acc_role, "exportador", "importador"):
# Las cuentas representan exportador/importador/cliente; sólo se omiten
# cuando el filtro pide explícitamente un rol de proveedor.
if role not in ("exportador", "importador", "cliente", "prospecto"):
continue
result.append({
"id": acc.id,
"source": "account",
"name": acc.name,
"role": acc_role,
"roles": [acc_role],
})
suppliers = (
db.query(Supplier)
.filter(
Supplier.tenant_id == tenant_id,
Supplier.company_id == company_id,
Supplier.deleted_at.is_(None),
)
.all()
)
for sup in suppliers:
classifications = sup.classifications or []
if role and role not in classifications:
continue
result.append({
"id": sup.id,
"source": "supplier",
"name": sup.name,
"role": classifications[0] if classifications else "proveedor",
"roles": classifications,
})
return result

View File

@@ -98,6 +98,17 @@ def reject_quote(
return service.reject_quote(db, quote_id, current_user["tenant_id"], company_id)
@router.post("/quotes/{quote_id}/clone", response_model=QuoteResponse, status_code=status.HTTP_201_CREATED)
def clone_quote(
quote_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Clona la cotización como borrador para re-cotizar (R-C-12)."""
return service.clone_quote(db, quote_id, current_user["tenant_id"], company_id, _user_id(current_user))
@router.delete("/quotes/{quote_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_quote(
quote_id: int,

View File

@@ -158,6 +158,56 @@ def reject_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) ->
return quote
def clone_quote(
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None
) -> Quote:
"""Clona una cotización (y sus conceptos) como borrador para re-cotizar (R-C-12).
Si la cotización origen fue rechazada, reabre su solicitud a 'en_analisis' para
cerrar el ciclo de reintento del Diagrama 1.
"""
src = get_quote(db, quote_id, tenant_id, company_id)
new_quote = Quote(
reference=(f"{src.reference}-R" if src.reference else None),
service_request_id=src.service_request_id,
account_id=src.account_id,
currency=src.currency,
status="borrador",
valid_until=src.valid_until,
notes=src.notes,
terms=src.terms,
owner_user_id=src.owner_user_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(new_quote)
db.flush()
src_items = (
db.query(QuoteItem)
.filter(QuoteItem.quote_id == src.id, QuoteItem.deleted_at.is_(None))
.all()
)
for it in src_items:
db.add(QuoteItem(
quote_id=new_quote.id, concept=it.concept, description=it.description,
supplier_id=it.supplier_id, quantity=it.quantity, unit_cost=it.unit_cost,
unit_sale=it.unit_sale, currency=it.currency,
tenant_id=tenant_id, company_id=company_id,
))
db.flush()
_recompute_totals(db, new_quote)
# Reabre la solicitud origen para el ciclo de re-cotización
if src.service_request_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == src.service_request_id).first()
if sr and sr.status in ("rechazada", "cotizada"):
sr.status = "en_analisis"
db.commit()
db.refresh(new_quote)
return new_quote
# ----- Quote items -----
def get_quote_items(db: Session, quote_id: int, tenant_id: int, company_id: int) -> list[QuoteItem]:

View File

@@ -5,12 +5,15 @@ Importar este módulo también registra los permisos del CRM (side-effect de
``permissions``), siguiendo el patrón del ``PermissionRegistry``.
"""
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from api.v1.modules.core.permissions.dependencies import PermissionChecker
from . import permissions # noqa: F401 (side-effect: registra permisos del CRM)
from .accounts.routes import router as accounts_router
from .activities.routes import router as activities_router
from .addresses.routes import router as addresses_router
from .catalogs.routes import router as catalogs_router
from .contacts.routes import router as contacts_router
from .documents.routes import router as documents_router
from .leads.routes import router as leads_router
@@ -22,7 +25,10 @@ from .service_requests.routes import router as service_requests_router
from .suppliers.routes import router as suppliers_router
from .uploads.routes import router as uploads_router
router = APIRouter()
# Enforcement por área/carril (R-T-07): se exige el permiso crm.access para tocar
# cualquier endpoint del módulo. En desarrollo el usuario se auto-bootstrapea a
# super_admin (ver PermissionChecker) para no bloquear el entorno.
router = APIRouter(dependencies=[Depends(PermissionChecker(["crm.access"]))])
router.include_router(accounts_router)
router.include_router(suppliers_router)
@@ -36,4 +42,5 @@ router.include_router(pipelines_router)
router.include_router(opportunities_router)
router.include_router(activities_router)
router.include_router(metrics_router)
router.include_router(catalogs_router)
router.include_router(uploads_router)

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field
class ServiceRequestBase(BaseModel):
reference: str | None = Field(None, max_length=40)
account_id: int | None = None
opportunity_id: int | None = None
operation_type: str = Field(..., max_length=20) # importacion | exportacion
transport_mode: str | None = Field(None, max_length=20)
service_type: str | None = Field(None, max_length=20)
@@ -31,9 +32,26 @@ class ServiceRequestCreate(ServiceRequestBase):
pass
class ServiceRequestContactInput(BaseModel):
"""Registro del contacto al cliente como etapa del flujo comercial (R-C-04)."""
notes: str | None = None
class ServiceRequestFromOpportunityInput(BaseModel):
"""Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02)."""
operation_type: str = Field(..., max_length=20) # importacion | exportacion
transport_mode: str | None = Field(None, max_length=20)
service_type: str | None = Field(None, max_length=20)
incoterm: str | None = Field(None, max_length=10)
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
notes: str | None = None
class ServiceRequestUpdate(BaseModel):
reference: str | None = Field(None, max_length=40)
account_id: int | None = None
opportunity_id: int | None = None
operation_type: str | None = Field(None, max_length=20)
transport_mode: str | None = Field(None, max_length=20)
service_type: str | None = Field(None, max_length=20)
@@ -58,6 +76,8 @@ class ServiceRequestResponse(ServiceRequestBase):
model_config = ConfigDict(from_attributes=True)
id: int
first_contact_at: datetime | None = None
first_contact_notes: str | None = None
tenant_id: int
company_id: int
created_by: str | None = None

View File

@@ -1,6 +1,6 @@
from datetime import date
from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -22,6 +22,10 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
# Oportunidad de origen: enlaza el embudo (primer contacto) con la cadena RFQ→cotización (R-C-02)
opportunity_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.opportunities.id"), nullable=True, index=True
)
# importacion | exportacion
operation_type: Mapped[str] = mapped_column(String(20), nullable=False)
# maritimo | aereo | terrestre | ferroviario | multimodal
@@ -43,7 +47,10 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
Integer, ForeignKey("crm.suppliers.id"), nullable=True
)
requirements: Mapped[str | None] = mapped_column(Text, nullable=True) # otros requerimientos
# nueva | en_analisis | cotizada | aceptada | rechazada | liberada
# Contacto al cliente como etapa del flujo comercial (Diagrama 1, paso 2 — R-C-04)
first_contact_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
first_contact_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# nueva | contacto | en_analisis | cotizada | aceptada | rechazada | liberada
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'nueva'"), index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)

View File

@@ -9,7 +9,9 @@ from .dto import (
RateRequestCreate,
RateRequestResponse,
RateRequestUpdate,
ServiceRequestContactInput,
ServiceRequestCreate,
ServiceRequestFromOpportunityInput,
ServiceRequestResponse,
ServiceRequestUpdate,
)
@@ -71,6 +73,44 @@ def update_service_request(
return service.update_service_request(db, request_id, payload, tenant_id, company_id, _user_id(current_user))
@router.post("/service-requests/from-opportunity", response_model=ServiceRequestResponse, status_code=status.HTTP_201_CREATED)
def create_from_opportunity(
payload: ServiceRequestFromOpportunityInput,
opportunity_id: int = Query(..., description="Oportunidad a convertir en solicitud"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Convierte una oportunidad del embudo en solicitud/RFQ enlazada (R-C-02)."""
tenant_id = current_user["tenant_id"]
return service.create_from_opportunity(db, opportunity_id, payload, tenant_id, company_id, _user_id(current_user))
@router.post("/service-requests/{request_id}/contact", response_model=ServiceRequestResponse)
def register_contact(
request_id: int,
payload: ServiceRequestContactInput,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Registra el contacto al cliente como etapa del flujo comercial (R-C-04)."""
tenant_id = current_user["tenant_id"]
return service.register_contact(db, request_id, payload, tenant_id, company_id, _user_id(current_user))
@router.post("/service-requests/{request_id}/requote", response_model=ServiceRequestResponse)
def reopen_for_requote(
request_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Reabre una solicitud rechazada para volver a cotizar (R-C-12)."""
tenant_id = current_user["tenant_id"]
return service.reopen_for_requote(db, request_id, tenant_id, company_id, _user_id(current_user))
@router.delete("/service-requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_service_request(
request_id: int,

View File

@@ -4,8 +4,17 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..catalogs.data import INCOTERM_CODES
from ..opportunities.models import Opportunity
from ..suppliers.models import Supplier
from .dto import RateRequestCreate, RateRequestUpdate, ServiceRequestCreate, ServiceRequestUpdate
from .dto import (
RateRequestCreate,
RateRequestUpdate,
ServiceRequestContactInput,
ServiceRequestCreate,
ServiceRequestFromOpportunityInput,
ServiceRequestUpdate,
)
from .models import RateRequest, ServiceRequest
@@ -30,6 +39,14 @@ def _validate_request_refs(db: Session, data: dict, tenant_id: int, company_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El cliente asociado no existe")
if not _exists(db, Supplier, data.get("destination_agent_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El agente en destino no existe")
if not _exists(db, Opportunity, data.get("opportunity_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="La oportunidad asociada no existe")
incoterm = data.get("incoterm")
if incoterm and incoterm not in INCOTERM_CODES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Incoterm inválido: usa uno del catálogo ({', '.join(sorted(INCOTERM_CODES))})",
)
# ----- Service requests (RFQ) -----
@@ -112,6 +129,83 @@ def delete_service_request(db: Session, request_id: int, tenant_id: int, company
db.commit()
def register_contact(
db: Session, request_id: int, payload: ServiceRequestContactInput, tenant_id: int, company_id: int,
user_id: str | None = None,
) -> ServiceRequest:
"""Registra el contacto al cliente como etapa del flujo comercial (R-C-04)."""
obj = get_service_request(db, request_id, tenant_id, company_id)
obj.first_contact_at = datetime.now(timezone.utc)
obj.first_contact_notes = payload.notes
if obj.status == "nueva":
obj.status = "contacto"
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def create_from_opportunity(
db: Session, opportunity_id: int, payload: ServiceRequestFromOpportunityInput,
tenant_id: int, company_id: int, user_id: str | None = None,
) -> ServiceRequest:
"""Convierte una oportunidad del embudo en una solicitud/RFQ enlazada (R-C-02).
Da continuidad al hilo comercial: el embudo (primer contacto) queda ligado a la
cadena RFQ → cotización → embarque vía ``opportunity_id``.
"""
opp = (
db.query(Opportunity)
.filter(
Opportunity.id == opportunity_id,
Opportunity.tenant_id == tenant_id,
Opportunity.company_id == company_id,
Opportunity.deleted_at.is_(None),
)
.first()
)
if not opp:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Oportunidad no encontrada")
obj = ServiceRequest(
account_id=opp.account_id,
opportunity_id=opp.id,
operation_type=payload.operation_type,
transport_mode=payload.transport_mode,
service_type=payload.service_type,
incoterm=payload.incoterm,
origin=payload.origin,
destination=payload.destination,
status="nueva",
notes=payload.notes,
owner_user_id=opp.owner_user_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def reopen_for_requote(
db: Session, request_id: int, tenant_id: int, company_id: int, user_id: str | None = None
) -> ServiceRequest:
"""Reabre una solicitud rechazada para volver a cotizar (R-C-12)."""
obj = get_service_request(db, request_id, tenant_id, company_id)
if obj.status not in ("rechazada", "cotizada"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Solo una solicitud rechazada o cotizada puede reabrirse para re-cotizar",
)
obj.status = "en_analisis"
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
# ----- Rate requests -----
def get_rate_requests(