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

@@ -25,11 +25,14 @@ from api.v1.modules.crm.pipelines.models import Pipeline, PipelineStage
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
from api.v1.modules.crm.service_requests.models import ServiceRequest
from api.v1.modules.crm.suppliers.models import Supplier
from api.v1.modules.ops.shipments.models import Shipment, ShipmentDocument
from api.v1.modules.ops.shipments.models import Shipment, ShipmentDocument, ShipmentEvent
from api.v1.modules.ops.shipments import service as shipments_service
from api.v1.modules.ops.shipments.dto import ShipmentCloseInput, ShipmentEventDecisionInput
from api.v1.modules.fin.invoices.models import Invoice
from api.v1.modules.fin.invoices import service as invoices_service
from api.v1.modules.fin.invoices.dto import PaymentCreate
from api.v1.modules.core.permissions.models import CompanyRole, Permission, RolePermission
from api.v1.modules.core.permissions.service import PermissionService
from core.database import CoreSessionLocal
# Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID
@@ -347,17 +350,81 @@ def seed_invoicing_and_events(db) -> None:
print("• Sin embarque; se omite facturación")
return
# Bitácora de hitos (según tipo de operación)
shipments_service.seed_default_milestones(db, shipment.id, TENANT_ID, COMPANY_ID)
# Bitácora de hitos (según tipo de operación, incluye puntos de decisión)
events = shipments_service.seed_default_milestones(db, shipment.id, TENANT_ID, COMPANY_ID)
# Factura generada desde el embarque (toma conceptos de la cotización)
# Resolver los puntos de decisión como autorizados y completar los hitos simples,
# para poder cerrar operativamente el embarque.
for ev in events:
if ev.kind == "decision":
shipments_service.decide_shipment_event(
db, ev.id, ShipmentEventDecisionInput(outcome="autorizado"), TENANT_ID, COMPANY_ID
)
else:
shipments_service.complete_shipment_event(db, ev.id, TENANT_ID, COMPANY_ID)
# Cierre operativo con costos finales (dispara la facturación — R-F-01/R-E-22)
shipments_service.close_shipment(
db, shipment.id,
ShipmentCloseInput(actual_cost_total=Decimal("2350"), cost_currency="USD", notes="Cierre demo"),
TENANT_ID, COMPANY_ID,
)
# Factura generada desde el embarque cerrado (toma conceptos de la cotización)
invoice = invoices_service.generate_from_shipment(db, shipment.id, TENANT_ID, COMPANY_ID)
invoices_service.emit_invoice(db, invoice.id, TENANT_ID, COMPANY_ID)
invoices_service.create_payment(
db, PaymentCreate(invoice_id=invoice.id, amount=Decimal("1000"), method="transferencia", reference="SPEI-001"),
TENANT_ID, COMPANY_ID,
)
print("Factura emitida desde el embarque con pago parcial + hitos de la bitácora")
print("Embarque cerrado, factura emitida con pago parcial + bitácora con decisiones resueltas")
# Carriles del proceso (R-T-07): a qué módulos/acciones puede acceder cada rol.
# clave = (code, nombre); valor = función que decide si un permiso pertenece al rol.
def _carril_roles() -> dict:
return {
("ventas", "Ventas"): lambda p: p.module == "crm" or p in {"ops.access", "ops.shipment.view"},
("operaciones", "Operaciones"): lambda p: p.module == "ops"
or p in {"crm.access", "crm.account.view", "crm.quote.view", "crm.service_request.view", "fin.access", "fin.invoice.view"},
("facturacion", "Facturación"): lambda p: p.module == "fin"
or p in {"ops.access", "ops.shipment.view", "crm.access", "crm.account.view"},
("consulta", "Consulta"): lambda p: p.endswith(".access") or p.endswith(".view"),
}
def seed_carril_roles(db) -> None:
"""Crea los roles por carril del proceso con sus permisos (R-T-07). Idempotente."""
# Poblar el catálogo de permisos desde el registry de código
PermissionService(db).sync_permissions()
all_perms = db.query(Permission).filter(Permission.is_active == True).all() # noqa: E712
created = 0
for (code, name), belongs in _carril_roles().items():
role = (
db.query(CompanyRole)
.filter(CompanyRole.company_id == COMPANY_ID, CompanyRole.tenant_id == TENANT_ID, CompanyRole.code == code)
.first()
)
if not role:
role = CompanyRole(
company_id=COMPANY_ID, tenant_id=TENANT_ID, name=name, code=code,
description=f"Rol de carril: {name}", is_active=True,
)
db.add(role)
db.flush()
created += 1
existing_perm_ids = {
pid for (pid,) in db.query(RolePermission.permission_id).filter(RolePermission.company_role_id == role.id)
}
for perm in all_perms:
if belongs(perm.code) and perm.id not in existing_perm_ids:
db.add(RolePermission(
company_role_id=role.id, permission_id=perm.id,
tenant_id=TENANT_ID, company_id=COMPANY_ID,
))
db.commit()
print(f"✓ Roles por carril sembrados (nuevos: {created}) — Ventas, Operaciones, Facturación, Consulta")
def main() -> None:
@@ -369,6 +436,7 @@ def main() -> None:
seed_suppliers_and_related(db)
seed_commercial_and_ops(db)
seed_invoicing_and_events(db)
seed_carril_roles(db)
print("\nSeed CRM completado.")
finally:
db.close()