Al timbrar con éxito, el CFDI transmitido al PAC y el que contestó se encolan hacia
el expediente electrónico. El expediente ya existe: nace con la oportunidad y la
factura lo hereda vía fin.invoices.case_id.
Reusa el carril que ya estaba armado (outbox transaccional + worker con reintentos);
este es su primer consumidor en producción.
Decisiones:
- efc_tipo = factura_venta. La lista de tipos está duplicada a mano en este repo y en
EFC (TIPOS_DOCUMENTO_CRM), y una clave que solo exista de este lado se rechaza allá.
Los tres archivos se distinguen por nombre: FAC-*.pdf, CFDI-<uuid>-envio.xml,
CFDI-<uuid>-respuesta.xml.
- Dos kinds (cfdi_request / cfdi_response) y no uno: la guarda de idempotencia es
(source_table, source_id, kind) y los dos XML comparten source_id —el id del
intento—, así que un kind común dejaría el par a medias en silencio.
- delete_local=False, a diferencia de los documentos que sube el usuario: el XML
timbrado es el comprobante fiscal y el CRM lo sirve por /stamp/xml-url.
- Solo el intento que obtuvo timbre. Los rechazos quedan en el CRM y se consultan por
/stamp/attempts/{id}/xml-url.
El encolado va en la transacción del timbre y el despacho después del commit. Nada de
esto puede propagar: un CFDI ya válido ante el SAT no se cae porque EFC esté apagado.
Ajusta test_fin_sat_catalogs al nuevo conteo de regímenes (19 -> 22) y fija ahí que el
616 es de persona física.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
216 lines
8.2 KiB
Python
216 lines
8.2 KiB
Python
"""Entrega al expediente de EFC de los dos XML del timbrado.
|
|
|
|
Lo que se fija aquí es el encolado: qué se manda, con qué tipo, y —lo más frágil— que los DOS
|
|
XML del mismo timbre lleguen. Comparten ``source_id`` (el id del intento), así que un solo
|
|
``kind`` para ambos haría que la guarda de idempotencia se comiera el segundo.
|
|
|
|
No se prueba la subida a EFC: eso es ``deliver_file_row``, ya cubierto en las pruebas del outbox.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from api.v1.modules.crm.cases import service as cases_service
|
|
from api.v1.modules.crm.expediente_gateway.models import (
|
|
FILE_KIND_CFDI_REQUEST,
|
|
FILE_KIND_CFDI_RESPONSE,
|
|
SOURCE_FIN_INVOICE_STAMPS,
|
|
STATUS_SENT,
|
|
EfcFileOutbox,
|
|
)
|
|
from api.v1.modules.fin.invoices.models import Invoice
|
|
from api.v1.modules.fin.stamping import service as stamping
|
|
from api.v1.modules.fin.stamping.models import STATUS_STAMPED, InvoiceStamp
|
|
from tests.conftest import COMPANY_ID, TENANT_ID
|
|
|
|
UUID = "11111111-2222-3333-4444-555555555555"
|
|
KEY_REQUEST = "tenants/1/companies/1/fin-invoices/7/stamps/attempts/3-request.xml"
|
|
KEY_RESPONSE = "tenants/1/companies/1/fin-invoices/7/stamps/attempts/3-response.xml"
|
|
|
|
|
|
@pytest.fixture()
|
|
def efc_encendido(monkeypatch):
|
|
from core.config import settings
|
|
|
|
monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False)
|
|
return settings
|
|
|
|
|
|
def _expediente(db):
|
|
"""Expediente como el que nace con la oportunidad, sin tocar el carril hacia EFC."""
|
|
from api.v1.modules.crm.expediente_gateway import service as gateway
|
|
|
|
# ``create_case`` replica el expediente a EFC; aquí solo interesa la fila local.
|
|
original = gateway.replicate_expediente_best_effort
|
|
gateway.replicate_expediente_best_effort = lambda *a, **k: None
|
|
try:
|
|
case = cases_service.create_case(
|
|
db, TENANT_ID, COMPANY_ID, title="Importación de prueba", user_id="user-1"
|
|
)
|
|
finally:
|
|
gateway.replicate_expediente_best_effort = original
|
|
db.commit()
|
|
return case
|
|
|
|
|
|
def _factura_timbrada(db, *, case_id, request_key=KEY_REQUEST, response_key=KEY_RESPONSE):
|
|
invoice = Invoice(
|
|
reference="FAC-001", case_id=case_id, currency="MXN", status="emitida",
|
|
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
|
)
|
|
db.add(invoice)
|
|
db.flush()
|
|
stamp = InvoiceStamp(
|
|
invoice_id=invoice.id, mode="pruebas", status=STATUS_STAMPED, uuid=UUID,
|
|
request_xml_file_key=request_key, response_xml_file_key=response_key,
|
|
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
|
)
|
|
db.add(stamp)
|
|
db.flush()
|
|
return invoice, stamp
|
|
|
|
|
|
def _filas(db):
|
|
return db.query(EfcFileOutbox).order_by(EfcFileOutbox.id).all()
|
|
|
|
|
|
# ── El caso normal ───────────────────────────────────────────────────────────
|
|
|
|
def test_encola_los_dos_xml_del_timbre(db, efc_encendido):
|
|
case = _expediente(db)
|
|
invoice, stamp = _factura_timbrada(db, case_id=case.id)
|
|
|
|
ids = stamping._encolar_xml_al_expediente(db, invoice, stamp)
|
|
db.commit()
|
|
|
|
assert len(ids) == 2
|
|
filas = _filas(db)
|
|
assert [f.kind for f in filas] == [FILE_KIND_CFDI_REQUEST, FILE_KIND_CFDI_RESPONSE]
|
|
assert [f.s3_key for f in filas] == [KEY_REQUEST, KEY_RESPONSE]
|
|
assert [f.file_name for f in filas] == [
|
|
f"CFDI-{UUID}-envio.xml",
|
|
f"CFDI-{UUID}-respuesta.xml",
|
|
]
|
|
for fila in filas:
|
|
assert fila.expediente_ref == case.id
|
|
assert fila.source_table == SOURCE_FIN_INVOICE_STAMPS
|
|
assert fila.source_id == stamp.id
|
|
assert fila.content_type == "application/xml"
|
|
assert fila.efc_tipo == "factura_venta"
|
|
|
|
|
|
def test_el_tipo_es_uno_que_efc_acepta(db, efc_encendido):
|
|
"""El tipo viaja al catálogo GLOBAL de EFC: una clave inventada lo contamina."""
|
|
from api.v1.modules.crm.expediente_gateway.doc_types import is_valid_doc_type
|
|
|
|
assert is_valid_doc_type(stamping._EFC_TIPO_CFDI)
|
|
|
|
|
|
def test_no_borra_la_copia_local(db, efc_encendido):
|
|
"""El XML timbrado es el comprobante fiscal y el CRM lo sirve por /stamp/xml-url.
|
|
|
|
Con ``delete_local=True`` la entrega borraría el objeto de MinIO y esos endpoints quedarían
|
|
apuntando a algo inexistente.
|
|
"""
|
|
case = _expediente(db)
|
|
invoice, stamp = _factura_timbrada(db, case_id=case.id)
|
|
|
|
stamping._encolar_xml_al_expediente(db, invoice, stamp)
|
|
db.commit()
|
|
|
|
assert [f.delete_local for f in _filas(db)] == [False, False]
|
|
|
|
|
|
def test_referencias_distintas_por_xml(db, efc_encendido):
|
|
"""``crm_document_ref`` es la idempotencia del lado de EFC: un UNIQUE por (org, ref).
|
|
|
|
Si los dos XML compartieran la referencia, EFC devolvería el primero al entregar el segundo
|
|
y el expediente se quedaría con la mitad del par.
|
|
"""
|
|
case = _expediente(db)
|
|
invoice, stamp = _factura_timbrada(db, case_id=case.id)
|
|
|
|
stamping._encolar_xml_al_expediente(db, invoice, stamp)
|
|
db.commit()
|
|
|
|
refs = [f.crm_document_ref for f in _filas(db)]
|
|
assert refs == [f"CFDIREQ-{COMPANY_ID}-{stamp.id}", f"CFDIRES-{COMPANY_ID}-{stamp.id}"]
|
|
assert len(set(refs)) == 2
|
|
|
|
|
|
# ── La colisión de kinds ─────────────────────────────────────────────────────
|
|
|
|
def test_el_xml_ya_entregado_no_bloquea_al_otro(db, efc_encendido):
|
|
"""La razón de que los kinds sean dos y no uno.
|
|
|
|
La guarda es ``(source_table, source_id, kind)`` y los dos XML comparten source_id. Con un
|
|
kind común, tener el de envío en 'sent' haría que el de respuesta no se encolara nunca — y
|
|
el expediente se quedaría a medias sin un solo error visible.
|
|
"""
|
|
case = _expediente(db)
|
|
invoice, stamp = _factura_timbrada(db, case_id=case.id)
|
|
|
|
db.add(
|
|
EfcFileOutbox(
|
|
kind=FILE_KIND_CFDI_REQUEST, s3_key=KEY_REQUEST, file_name="x.xml",
|
|
content_type="application/xml", efc_tipo="factura_venta",
|
|
source_table=SOURCE_FIN_INVOICE_STAMPS, source_id=stamp.id,
|
|
expediente_ref=case.id, status=STATUS_SENT, delete_local=False,
|
|
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
stamping._encolar_xml_al_expediente(db, invoice, stamp)
|
|
db.commit()
|
|
|
|
nuevas = [f for f in _filas(db) if f.status != STATUS_SENT]
|
|
assert [f.kind for f in nuevas] == [FILE_KIND_CFDI_RESPONSE]
|
|
|
|
|
|
# ── Los casos en que no hay nada que entregar ────────────────────────────────
|
|
|
|
def test_factura_sin_expediente_no_encola(db, efc_encendido):
|
|
"""Una factura capturada fuera del ciclo comercial no tiene expediente. No es un error."""
|
|
invoice, stamp = _factura_timbrada(db, case_id=None)
|
|
|
|
assert stamping._encolar_xml_al_expediente(db, invoice, stamp) == []
|
|
assert _filas(db) == []
|
|
|
|
|
|
def test_sin_xml_guardado_se_encola_solo_el_que_existe(db, efc_encendido):
|
|
"""Si el almacenamiento falló al guardar el intento, no hay objeto que subir."""
|
|
case = _expediente(db)
|
|
invoice, stamp = _factura_timbrada(db, case_id=case.id, response_key=None)
|
|
|
|
ids = stamping._encolar_xml_al_expediente(db, invoice, stamp)
|
|
db.commit()
|
|
|
|
assert len(ids) == 1
|
|
assert [f.kind for f in _filas(db)] == [FILE_KIND_CFDI_REQUEST]
|
|
|
|
|
|
def test_un_fallo_del_carril_no_tumba_el_timbre(db, efc_encendido, monkeypatch):
|
|
"""El CFDI ya es válido ante el SAT: nada del carril hacia EFC puede propagar."""
|
|
from api.v1.modules.crm.expediente_gateway import service as gateway
|
|
|
|
def _explota(*_a, **_k):
|
|
raise RuntimeError("EFC en llamas")
|
|
|
|
monkeypatch.setattr(gateway, "enqueue_file_best_effort", _explota)
|
|
case = _expediente(db)
|
|
invoice, stamp = _factura_timbrada(db, case_id=case.id)
|
|
|
|
assert stamping._encolar_xml_al_expediente(db, invoice, stamp) == []
|
|
|
|
|
|
def test_con_efc_apagado_no_encola(db, monkeypatch):
|
|
"""Sin ``EFC_API_URL`` el carril es un no-op y el timbrado sigue igual."""
|
|
from core.config import settings
|
|
|
|
monkeypatch.setattr(settings, "EFC_API_URL", "", raising=False)
|
|
case = _expediente(db)
|
|
invoice, stamp = _factura_timbrada(db, case_id=case.id)
|
|
|
|
assert stamping._encolar_xml_al_expediente(db, invoice, stamp) == []
|
|
assert _filas(db) == []
|