Files
CRM_AGENTES_CARGA/backend/tests/test_compliance.py
Aduanasoft e79705e6e3 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>
2026-07-15 08:46:53 -06:00

170 lines
8.4 KiB
Python

"""Pruebas de las reglas de negocio agregadas para cumplir el PDF de agente de carga.
Cubren: puntos de decisión y ciclo de corrección de la bitácora (R-E-13/14, R-I-06/07),
cierre operativo y gate de facturación (R-E-22 / R-F-01), envío y revisión de factura
(R-F-05/06) y continuidad comercial (R-C-02/04/12) + catálogo de Incoterms (R-T-10).
"""
from decimal import Decimal
import pytest
from fastapi import HTTPException
from api.v1.modules.crm.accounts import service as accounts_service
from api.v1.modules.crm.accounts.dto import AccountCreate
from api.v1.modules.crm.opportunities import service as opps_service
from api.v1.modules.crm.opportunities.dto import OpportunityCreate
from api.v1.modules.crm.quotes import service as quotes_service
from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate
from api.v1.modules.crm.service_requests import service as sr_service
from api.v1.modules.crm.service_requests.dto import (
ServiceRequestContactInput,
ServiceRequestCreate,
ServiceRequestFromOpportunityInput,
)
from api.v1.modules.fin.invoices import service as inv_service
from api.v1.modules.fin.invoices.dto import InvoiceClientReviewInput, InvoiceCreate, InvoiceItemCreate
from api.v1.modules.ops.shipments import service as ops_service
from api.v1.modules.ops.shipments.dto import (
ShipmentCloseInput,
ShipmentCreate,
ShipmentEventDecisionInput,
ShipmentRescheduleInput,
)
T, C = 1, 1
def _shipment(db, op_type="exportacion"):
return ops_service.create_shipment(db, ShipmentCreate(reference="EMB-T", operation_type=op_type), T, C)
# ---------- Bitácora: decisiones y ciclo de corrección ----------
def test_seed_milestones_include_decision_points(db):
sh = _shipment(db, "exportacion")
events = ops_service.seed_default_milestones(db, sh.id, T, C)
decisions = [e for e in events if e.kind == "decision"]
assert any(e.event_type == "decision_cutoff" for e in decisions)
assert any(e.event_type == "decision_despacho_exportacion" for e in decisions)
def test_decision_autorizado_completa(db):
sh = _shipment(db, "importacion")
events = ops_service.seed_default_milestones(db, sh.id, T, C)
decision = next(e for e in events if e.kind == "decision")
updated = ops_service.decide_shipment_event(db, decision.id, ShipmentEventDecisionInput(outcome="autorizado"), T, C)
assert updated.status == "completado" and updated.outcome == "autorizado"
def test_decision_rechazado_abre_correccion(db):
sh = _shipment(db, "importacion")
events = ops_service.seed_default_milestones(db, sh.id, T, C)
before = len(ops_service.get_shipment_events(db, T, C, sh.id))
decision = next(e for e in events if e.kind == "decision")
ops_service.decide_shipment_event(db, decision.id, ShipmentEventDecisionInput(outcome="rechazado", notes="Docs incompletos"), T, C)
after = ops_service.get_shipment_events(db, T, C, sh.id)
assert len(after) == before + 1 # se creó el hito de corrección
correction = next(e for e in after if e.parent_event_id == decision.id)
assert correction.status == "en_correccion" and correction.attempt == 2
def test_complete_on_decision_falla(db):
sh = _shipment(db, "importacion")
events = ops_service.seed_default_milestones(db, sh.id, T, C)
decision = next(e for e in events if e.kind == "decision")
with pytest.raises(HTTPException) as exc:
ops_service.complete_shipment_event(db, decision.id, T, C)
assert exc.value.status_code == 422
# ---------- Cierre operativo y gate de facturación ----------
def test_close_blocked_with_pending_decision(db):
sh = _shipment(db, "importacion")
ops_service.seed_default_milestones(db, sh.id, T, C) # deja decisiones pendientes
with pytest.raises(HTTPException) as exc:
ops_service.close_shipment(db, sh.id, ShipmentCloseInput(actual_cost_total=Decimal("100")), T, C)
assert exc.value.status_code == 409
def test_generate_invoice_requires_closed_shipment(db):
sh = _shipment(db, "exportacion") # abierta, sin cerrar
with pytest.raises(HTTPException) as exc:
inv_service.generate_from_shipment(db, sh.id, T, C)
assert exc.value.status_code == 409
# Tras cerrar (sin hitos → sin decisiones pendientes) sí factura
ops_service.close_shipment(db, sh.id, ShipmentCloseInput(actual_cost_total=Decimal("500"), cost_currency="MXN"), T, C)
inv = inv_service.generate_from_shipment(db, sh.id, T, C)
assert inv.shipment_id == sh.id
def test_reschedule_keeps_previous_etd(db):
from datetime import date
sh = ops_service.create_shipment(db, ShipmentCreate(reference="EMB-R", operation_type="exportacion", etd=date(2026, 1, 10)), T, C)
ops_service.reschedule_departure(db, sh.id, ShipmentRescheduleInput(etd=date(2026, 1, 20), reason="Cut Off perdido"), T, C)
sh = ops_service.get_shipment(db, sh.id, T, C)
assert str(sh.previous_etd) == "2026-01-10" and str(sh.etd) == "2026-01-20"
# ---------- Envío y revisión de factura ----------
def test_send_invoice_generates_pdf_and_reviews(db, monkeypatch):
stored = {}
monkeypatch.setattr("core.storage_s3.put_object_bytes", lambda key, body, content_type="": stored.update({"key": key, "len": len(body)}))
acc = accounts_service.create_account(db, AccountCreate(name="Cliente PDF"), T, C)
inv = inv_service.create_invoice(db, InvoiceCreate(reference="F-PDF", account_id=acc.id, tax_rate=Decimal("16")), T, C)
inv_service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1, unit_amount=1000), T, C)
inv_service.emit_invoice(db, inv.id, T, C)
inv = inv_service.send_invoice(db, inv.id, T, C)
assert inv.status == "enviada" and inv.pdf_file_key and stored["len"] > 0
# Revisión del cliente: aprobada
inv = inv_service.mark_client_review(db, inv.id, T, C)
assert inv.status == "en_revision_cliente"
inv = inv_service.client_review_decision(db, inv.id, InvoiceClientReviewInput(approved=True, notes="OK"), T, C)
assert inv.status == "enviada" and inv.client_approved is True and inv.client_reviewed_at is not None
# ---------- Continuidad comercial ----------
def test_register_contact_sets_stage(db):
acc = accounts_service.create_account(db, AccountCreate(name="Prospecto"), T, C)
sr = sr_service.create_service_request(db, ServiceRequestCreate(account_id=acc.id, operation_type="exportacion"), T, C)
sr = sr_service.register_contact(db, sr.id, ServiceRequestContactInput(notes="Primer contacto"), T, C)
assert sr.status == "contacto" and sr.first_contact_at is not None
def test_service_request_from_opportunity_links_back(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente Op"), T, C)
opp = opps_service.create_opportunity(db, OpportunityCreate(name="Oportunidad X", account_id=acc.id), T, C)
sr = sr_service.create_from_opportunity(
db, opp.id, ServiceRequestFromOpportunityInput(operation_type="importacion"), T, C
)
assert sr.opportunity_id == opp.id and sr.account_id == acc.id
def test_clone_quote_reopens_request(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente Q"), T, C)
sr = sr_service.create_service_request(db, ServiceRequestCreate(account_id=acc.id, operation_type="exportacion"), T, C)
q = quotes_service.create_quote(db, QuoteCreate(reference="COT-1", account_id=acc.id, service_request_id=sr.id, currency="USD"), T, C)
quotes_service.create_quote_item(db, QuoteItemCreate(quote_id=q.id, concept="flete_internacional", quantity=1, unit_cost=100, unit_sale=200), T, C)
quotes_service.reject_quote(db, q.id, T, C)
clone = quotes_service.clone_quote(db, q.id, T, C)
assert clone.id != q.id and clone.status == "borrador"
items = quotes_service.get_quote_items(db, clone.id, T, C)
assert len(items) == 1
sr = sr_service.get_service_request(db, sr.id, T, C)
assert sr.status == "en_analisis" # reabierta para re-cotizar
def test_incoterm_catalog_validation(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente Inc"), T, C)
with pytest.raises(HTTPException) as exc:
sr_service.create_service_request(
db, ServiceRequestCreate(account_id=acc.id, operation_type="exportacion", incoterm="XXX"), T, C
)
assert exc.value.status_code == 422
ok = sr_service.create_service_request(
db, ServiceRequestCreate(account_id=acc.id, operation_type="exportacion", incoterm="FOB"), T, C
)
assert ok.incoterm == "FOB"