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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user