"""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"