Merge pull request 'feature/AS-sat-regimenes-2024' (#11) from feature/AS-sat-regimenes-2024 into main
Reviewed-on: #11
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"""Claves de c_RegimenFiscal con vigencia 2024: 628, 629 y 630.
|
||||
|
||||
El catálogo se había sembrado con las 19 claves vigentes hasta 2022 y quedaron fuera las
|
||||
tres que el SAT publicó con vigencia a partir del 01-01-2024 (628 Hidrocarburos, 629
|
||||
Regímenes Fiscales Preferentes y Empresas Multinacionales, 630 Enajenación de acciones en
|
||||
bolsa de valores). Sin ellas, el select de régimen del receptor no puede representar a un
|
||||
contribuyente en esos regímenes y el timbrado quedaría con una clave incorrecta.
|
||||
|
||||
``sync_catalogs`` es idempotente y hace upsert: inserta las tres claves nuevas y refresca
|
||||
descripción y banderas de las que ya existen, sin tocar el resto de los catálogos.
|
||||
|
||||
No se agrega la clave 609 (Consolidación): su vigencia terminó el 31-12-2019 y el catálogo
|
||||
solo lleva claves vigentes.
|
||||
|
||||
Revision ID: k6l7m8n9o0p1
|
||||
Revises: j5k6l7m8n9o0
|
||||
Create Date: 2026-08-11 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs
|
||||
|
||||
revision: str = "k6l7m8n9o0p1"
|
||||
down_revision: Union[str, None] = "j5k6l7m8n9o0"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_NUEVAS = ("628", "629", "630")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
sync_catalogs(op.get_bind())
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# No se borran las filas: ``crm.accounts.tax_regime_id`` y
|
||||
# ``fin.issuer_settings.tax_regime_id`` las referencian por FK, y un CFDI ya timbrado
|
||||
# con una de estas claves debe seguir siendo legible. Se desactivan, que es el mismo
|
||||
# criterio que usa el catálogo para una clave retirada por el SAT.
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE sat.tax_regimes SET is_active = false, updated_at = now() "
|
||||
"WHERE code IN :codes"
|
||||
).bindparams(sa.bindparam("codes", value=_NUEVAS, expanding=True))
|
||||
)
|
||||
@@ -25,6 +25,12 @@ KIND_COMPLETAR = "completar"
|
||||
|
||||
# Tipos del outbox de ARCHIVOS (efc_file_outbox).
|
||||
FILE_KIND_DOCUMENTO = "documento"
|
||||
# Los dos XML del timbrado. Son kinds SEPARADOS y no un solo 'cfdi', porque la guarda de
|
||||
# idempotencia es (source_table, source_id, kind): los dos XML de un mismo timbre comparten
|
||||
# source_id —el id del intento—, así que con un kind común la entrega del segundo se saltaría
|
||||
# para siempre en cuanto el primero quedara 'sent'.
|
||||
FILE_KIND_CFDI_REQUEST = "cfdi_request"
|
||||
FILE_KIND_CFDI_RESPONSE = "cfdi_response"
|
||||
|
||||
# Tablas de origen posibles de un archivo. El CRM tiene DOS tablas de documentos con secuencias
|
||||
# independientes, así que `source_id` por sí solo es ambiguo: crm.documents.id = 5 y
|
||||
@@ -32,6 +38,9 @@ FILE_KIND_DOCUMENTO = "documento"
|
||||
SOURCE_CRM_DOCUMENTS = "crm.documents"
|
||||
SOURCE_OPS_SHIPMENT_DOCUMENTS = "ops.shipment_documents"
|
||||
SOURCE_FIN_INVOICES = "fin.invoices"
|
||||
# El origen de los XML del timbrado es el INTENTO (fin.invoice_stamps), no la factura: una
|
||||
# factura puede acumular varios intentos y el par enviado/recibido pertenece a uno concreto.
|
||||
SOURCE_FIN_INVOICE_STAMPS = "fin.invoice_stamps"
|
||||
|
||||
# Estados (columna status).
|
||||
STATUS_PENDING = "pending"
|
||||
|
||||
@@ -420,6 +420,19 @@ def _dispatch_file_delivery(outbox_id: int, tenant_id: int, company_id: int) ->
|
||||
)
|
||||
|
||||
|
||||
def dispatch_file_delivery(outbox_id: int, tenant_id: int, company_id: int) -> None:
|
||||
"""Despacha la entrega de una fila ya COMMITEADA del outbox de archivos.
|
||||
|
||||
Está separado de ``enqueue_file_best_effort`` porque esa función no commitea: la fila viaja en
|
||||
la transacción de quien la origina, y despachar antes del commit haría que el worker buscara
|
||||
una fila que todavía no existe. El orden es siempre encolar → commit → despachar.
|
||||
|
||||
No despachar no pierde nada: ``sweep_file_outbox`` recoge lo que quede en ``pending``. Esto
|
||||
solo acelera la entrega del caso normal.
|
||||
"""
|
||||
_dispatch_file_delivery(outbox_id, tenant_id, company_id)
|
||||
|
||||
|
||||
def deliver_file_row(db: Session, row: EfcFileOutbox, client: Optional[EfcClient] = None) -> None:
|
||||
"""Sube el archivo de ``row.s3_key`` al expediente de EFC y, si ``delete_local``, borra la copia.
|
||||
|
||||
|
||||
@@ -17,6 +17,26 @@ router = APIRouter()
|
||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB
|
||||
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
# Extensiones que el CRM acepta subir. Es una ALLOWLIST y no una lista de vetados: lo segundo
|
||||
# deja pasar todo lo que nadie pensó en prohibir.
|
||||
EXTENSIONES_PERMITIDAS = frozenset({
|
||||
# Documentos
|
||||
"pdf", "xml", "csv", "txt", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods",
|
||||
# Imágenes (fotos de maniobras, sellos, evidencias)
|
||||
"jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff",
|
||||
# Paquetes (juegos de documentos de un embarque)
|
||||
"zip", "rar", "7z",
|
||||
# Correo, que en comercio exterior se archiva como evidencia
|
||||
"msg", "eml",
|
||||
})
|
||||
|
||||
# Prefijos —ya dentro de ``tenants/{tid}/companies/{cid}/``— que son documentos del CRM. El
|
||||
# alcance de estos endpoints es «los archivos que el CRM subió», NO todo el almacén de la
|
||||
# company: ahí conviven los certificados de la FIEL y del CSD, los CFDI, los CSV de importación
|
||||
# y el branding, que nada tienen que ver con el permiso de módulo ``crm.access``.
|
||||
_PREFIJOS_DOCUMENTOS = ("crm-docs/",)
|
||||
_PATRON_DOCUMENTOS = re.compile(r"^expedientes/\d+/documents/")
|
||||
|
||||
|
||||
def _safe_filename(name: str | None) -> str:
|
||||
base = (name or "archivo").strip().replace(" ", "_")
|
||||
@@ -24,6 +44,41 @@ def _safe_filename(name: str | None) -> str:
|
||||
return base[:120]
|
||||
|
||||
|
||||
def validar_extension(name: str | None) -> None:
|
||||
"""Rechaza lo que no esté en la allowlist. Un archivo sin extensión tampoco pasa.
|
||||
|
||||
Se valida el NOMBRE y no el ``content_type``: el segundo lo pone el navegador y quien sube
|
||||
el archivo lo controla, así que no es una comprobación.
|
||||
"""
|
||||
_, punto, extension = (name or "").rpartition(".")
|
||||
if not punto or extension.lower() not in EXTENSIONES_PERMITIDAS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Ese tipo de archivo no está permitido.",
|
||||
)
|
||||
|
||||
|
||||
def _validar_alcance(key: str, tenant_id: int, company_id: int) -> None:
|
||||
"""Comprueba que la key sea un documento del CRM de ESTE tenant y company.
|
||||
|
||||
Son dos guardas y la segunda no reemplaza a la primera. El aislamiento por
|
||||
tenant/company evita leer el almacén de otro cliente; el alcance por prefijo evita que el
|
||||
permiso de módulo del CRM sirva para firmar un objeto que pertenece a otro módulo.
|
||||
"""
|
||||
prefijo = f"tenants/{tenant_id}/companies/{company_id}/"
|
||||
if not key.startswith(prefijo):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance"
|
||||
)
|
||||
|
||||
relativa = key[len(prefijo):]
|
||||
if relativa.startswith(_PREFIJOS_DOCUMENTOS) or _PATRON_DOCUMENTOS.match(relativa):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/uploads")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
@@ -31,6 +86,7 @@ async def upload_file(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
validar_extension(file.filename)
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(
|
||||
@@ -55,11 +111,7 @@ def get_upload_url(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
# Un archivo solo puede consultarse dentro de su propio tenant/company (aislamiento).
|
||||
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
|
||||
if not key.startswith(prefix):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
|
||||
_validar_alcance(key, current_user["tenant_id"], company_id)
|
||||
return {"url": presigned_get_url(key)}
|
||||
|
||||
|
||||
@@ -72,11 +124,12 @@ def download_file(
|
||||
"""Transmite el archivo por el backend (sin exponer MinIO al navegador).
|
||||
|
||||
Evita el bug de la URL prefirmada que apunta al host interno ``minio:9000``.
|
||||
|
||||
Mismo alcance que ``/uploads/url``, y por la misma razón: este endpoint entrega los BYTES,
|
||||
así que dejarlo más abierto que el que solo firma una URL sería la puerta grande al lado de
|
||||
la que se acaba de cerrar.
|
||||
"""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
|
||||
if not key.startswith(prefix):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
|
||||
_validar_alcance(key, current_user["tenant_id"], company_id)
|
||||
try:
|
||||
data = get_object_bytes(key)
|
||||
except Exception:
|
||||
|
||||
@@ -86,6 +86,10 @@ TAX_REGIMES: list[dict] = [
|
||||
_regime("624", "Coordinados", False, True),
|
||||
_regime("625", "Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas", True, False),
|
||||
_regime("626", "Régimen Simplificado de Confianza", True, True),
|
||||
# Claves publicadas por el SAT con vigencia a partir del 01-01-2024.
|
||||
_regime("628", "Hidrocarburos", False, True),
|
||||
_regime("629", "De los Regímenes Fiscales Preferentes y de las Empresas Multinacionales", True, False),
|
||||
_regime("630", "Enajenación de acciones en bolsa de valores", True, False),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ logger = logging.getLogger(__name__)
|
||||
CFDI_NS = "http://www.sat.gob.mx/cfd/4"
|
||||
TFD_NS = "http://www.sat.gob.mx/TimbreFiscalDigital"
|
||||
|
||||
# Tipo con el que los XML del timbrado entran al expediente de EFC. Se reusa el de la factura en
|
||||
# vez de inventar uno: 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 del CFDI —PDF, XML enviado y XML recibido— se distinguen por su nombre de archivo.
|
||||
_EFC_TIPO_CFDI = "factura_venta"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# Lectura
|
||||
@@ -455,11 +461,119 @@ def stamp_invoice(
|
||||
# timbre ni provocar un retimbrado. Se guarda el registro sin la clave y se anota.
|
||||
stamp.error_message = f"Timbrado correcto, pero no se pudo guardar el XML: {exc}"
|
||||
|
||||
# Las filas del outbox van en ESTA transacción, junto con el timbre: así no puede quedar un
|
||||
# CFDI timbrado sin su intención de entrega al expediente, ni al revés.
|
||||
pendientes = _encolar_xml_al_expediente(db, invoice, stamp)
|
||||
|
||||
db.commit()
|
||||
db.refresh(stamp)
|
||||
|
||||
# Después del commit: el worker necesita encontrar las filas ya existentes.
|
||||
_despachar_xml_al_expediente(pendientes, tenant_id, company_id)
|
||||
return stamp
|
||||
|
||||
|
||||
def _encolar_xml_al_expediente(db: Session, invoice: Invoice, stamp: InvoiceStamp) -> list[int]:
|
||||
"""Encola hacia el expediente de EFC el XML transmitido al PAC y el que contestó.
|
||||
|
||||
Devuelve los ids de las filas del outbox, para despacharlas después del commit.
|
||||
|
||||
Se entrega el par del intento que SÍ obtuvo timbre; los rechazados quedan en el CRM y se
|
||||
consultan por ``/stamp/attempts/{id}/xml-url``. Un expediente fiscal con los comprobantes que
|
||||
el PAC rechazó no aporta respaldo, solo ruido.
|
||||
|
||||
``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``. El corte directo que borra la
|
||||
copia local aplica a un documento cuya única razón de existir es vivir en el expediente; aquí
|
||||
dejaría esos endpoints apuntando a un objeto inexistente.
|
||||
|
||||
Best-effort de punta a punta: si EFC está apagado o el encolado falla, el timbre ya es válido
|
||||
ante el SAT y no puede caerse por esto. Por eso NUNCA propaga: ``enqueue_file_best_effort`` ya
|
||||
se protege con un SAVEPOINT, pero todo lo que rodea a la llamada —resolver el expediente,
|
||||
armar los nombres— también tiene que ser incapaz de tumbar un CFDI ya timbrado.
|
||||
"""
|
||||
try:
|
||||
return _encolar_xml_al_expediente_inner(db, invoice, stamp)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"timbrado: falló el encolado de los XML del intento %s hacia EFC; el timbre no se toca",
|
||||
stamp.id,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def _encolar_xml_al_expediente_inner(
|
||||
db: Session, invoice: Invoice, stamp: InvoiceStamp
|
||||
) -> list[int]:
|
||||
"""Cuerpo de ``_encolar_xml_al_expediente``; ver ahí el contrato y el porqué."""
|
||||
from ...crm.expediente_gateway import service as gateway # noqa: PLC0415
|
||||
from ...crm.expediente_gateway.doc_types import is_valid_doc_type # noqa: PLC0415
|
||||
from ...crm.expediente_gateway.models import ( # noqa: PLC0415
|
||||
FILE_KIND_CFDI_REQUEST,
|
||||
FILE_KIND_CFDI_RESPONSE,
|
||||
SOURCE_FIN_INVOICE_STAMPS,
|
||||
)
|
||||
|
||||
# El expediente nace con la oportunidad y se hereda vía ``case_id``. Una factura suelta —
|
||||
# capturada sin pasar por el ciclo comercial— no tiene a dónde entregar, y eso no es un error.
|
||||
if not invoice.case_id:
|
||||
logger.info(
|
||||
"timbrado: la factura %s no tiene expediente (case_id nulo); no se entregan los XML a EFC",
|
||||
invoice.id,
|
||||
)
|
||||
return []
|
||||
|
||||
# El tipo viaja al catálogo GLOBAL de EFC, compartido por todas las organizaciones. Se valida
|
||||
# contra el set cerrado para no crear ahí un tipo basura que nadie limpia después.
|
||||
if not is_valid_doc_type(_EFC_TIPO_CFDI):
|
||||
logger.error(
|
||||
"timbrado: %r no está en el catálogo de tipos que EFC acepta; no se entregan los XML",
|
||||
_EFC_TIPO_CFDI,
|
||||
)
|
||||
return []
|
||||
|
||||
partes = (
|
||||
(FILE_KIND_CFDI_REQUEST, stamp.request_xml_file_key, "envio", "CFDIREQ"),
|
||||
(FILE_KIND_CFDI_RESPONSE, stamp.response_xml_file_key, "respuesta", "CFDIRES"),
|
||||
)
|
||||
|
||||
filas: list[int] = []
|
||||
for kind, s3_key, sufijo, prefijo_ref in partes:
|
||||
if not s3_key:
|
||||
# El almacenamiento falló al guardar el intento: no hay objeto que entregar.
|
||||
logger.warning(
|
||||
"timbrado: el intento %s no tiene XML de %s guardado; no se entrega a EFC",
|
||||
stamp.id, sufijo,
|
||||
)
|
||||
continue
|
||||
row = gateway.enqueue_file_best_effort(
|
||||
db,
|
||||
kind=kind,
|
||||
s3_key=s3_key,
|
||||
file_name=f"CFDI-{stamp.uuid}-{sufijo}.xml",
|
||||
content_type="application/xml",
|
||||
efc_tipo=_EFC_TIPO_CFDI,
|
||||
source_table=SOURCE_FIN_INVOICE_STAMPS,
|
||||
source_id=stamp.id,
|
||||
crm_document_ref=f"{prefijo_ref}-{stamp.company_id}-{stamp.id}",
|
||||
expediente_ref=invoice.case_id,
|
||||
tenant_id=stamp.tenant_id,
|
||||
company_id=stamp.company_id,
|
||||
delete_local=False,
|
||||
)
|
||||
if row is not None:
|
||||
filas.append(row.id)
|
||||
return filas
|
||||
|
||||
|
||||
def _despachar_xml_al_expediente(outbox_ids: list[int], tenant_id: int, company_id: int) -> None:
|
||||
"""Despacha las filas ya commiteadas. Lo que no se despache lo recoge el sweep del beat."""
|
||||
from ...crm.expediente_gateway import service as gateway # noqa: PLC0415
|
||||
|
||||
for outbox_id in outbox_ids:
|
||||
gateway.dispatch_file_delivery(outbox_id, tenant_id, company_id)
|
||||
|
||||
|
||||
def _store_attempt_xml(stamp: InvoiceStamp, sent: bytes, received: str) -> None:
|
||||
"""Guarda el par enviado/recibido del intento y anota sus claves en ``stamp``.
|
||||
|
||||
|
||||
215
backend/tests/test_efc_cfdi_xml.py
Normal file
215
backend/tests/test_efc_cfdi_xml.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""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) == []
|
||||
@@ -20,7 +20,7 @@ from api.v1.modules.crm.expediente_gateway.models import (
|
||||
EfcFileOutbox,
|
||||
EfcSyncOutbox,
|
||||
)
|
||||
from api.v1.modules.crm.expedientes import service as expedientes_service
|
||||
from api.v1.modules.crm.cases.models import Case
|
||||
from api.v1.modules.crm.service_requests import service as sr_service
|
||||
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
|
||||
from core.efc_client import EfcClientError
|
||||
@@ -49,17 +49,28 @@ def efc_apagado(monkeypatch):
|
||||
return settings
|
||||
|
||||
|
||||
def _expediente(db):
|
||||
solicitud = sr_service.create_service_request(
|
||||
def _solicitud(db):
|
||||
return sr_service.create_service_request(
|
||||
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
||||
)
|
||||
return expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
|
||||
|
||||
def _expediente(db):
|
||||
"""El expediente que nace al crear una solicitud.
|
||||
|
||||
El expediente es ``crm.cases``: nace con la oportunidad o la solicitud y se hereda por
|
||||
``case_id``. Se llega a él por esa liga, que es la que existe hoy — no hay una tabla de
|
||||
expedientes con ``service_request_id`` propio.
|
||||
"""
|
||||
solicitud = _solicitud(db)
|
||||
assert solicitud.case_id is not None, "crear una solicitud debe mintear su expediente"
|
||||
return db.query(Case).filter(Case.id == solicitud.case_id).one()
|
||||
|
||||
|
||||
def _fila_sync(db, expediente, **kwargs):
|
||||
row = EfcSyncOutbox(
|
||||
kind=kwargs.pop("kind", KIND_EXPEDIENTE),
|
||||
payload=kwargs.pop("payload", {"folio": expediente.folio}),
|
||||
payload=kwargs.pop("payload", {"folio": expediente.reference}),
|
||||
expediente_ref=expediente.id,
|
||||
status=kwargs.pop("status", STATUS_PENDING),
|
||||
tenant_id=kwargs.pop("tenant_id", TENANT_ID),
|
||||
@@ -352,7 +363,7 @@ def test_con_efc_encendido_crear_una_solicitud_encola_su_expediente(db, efc_ence
|
||||
assert len(filas) == 1
|
||||
assert filas[0].kind == KIND_EXPEDIENTE
|
||||
assert filas[0].status == STATUS_PENDING
|
||||
assert filas[0].payload["folio"] == expediente.folio
|
||||
assert filas[0].payload["folio"] == expediente.reference
|
||||
assert filas[0].payload["storage_token"] == expediente.efc_storage_token
|
||||
|
||||
|
||||
@@ -363,21 +374,27 @@ def test_si_el_encolado_revienta_la_operacion_local_no_se_rompe(db, efc_encendid
|
||||
|
||||
monkeypatch.setattr(gateway, "_expediente_ya_encolado", _revienta)
|
||||
|
||||
solicitud = sr_service.create_service_request(
|
||||
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
||||
)
|
||||
solicitud = _solicitud(db)
|
||||
|
||||
assert solicitud.id is not None
|
||||
assert expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID) is not None
|
||||
# La solicitud sigue en pie CON su expediente: el fallo del carril no deshace ninguno de los dos.
|
||||
assert solicitud.case_id is not None
|
||||
assert db.query(Case).filter(Case.id == solicitud.case_id).one_or_none() is not None
|
||||
|
||||
|
||||
def test_no_se_encola_dos_veces_el_mismo_expediente(db, efc_encendido):
|
||||
"""Primera guarda: ``ensure`` es idempotente y no debe generar una segunda réplica."""
|
||||
expediente = _expediente(db)
|
||||
solicitud_id = expediente.service_request_id
|
||||
"""Primera guarda de idempotencia: replicar el mismo expediente no genera una segunda fila.
|
||||
|
||||
expedientes_service.ensure_expediente(db, solicitud_id, TENANT_ID, COMPANY_ID, "user-1")
|
||||
expedientes_service.ensure_expediente(db, solicitud_id, TENANT_ID, COMPANY_ID, "user-1")
|
||||
Se ejercita ``replicate_expediente_best_effort`` directamente, que es el punto de entrada del
|
||||
carril y donde vive la guarda ``_expediente_ya_encolado``. Antes esto entraba por un
|
||||
``ensure_expediente`` del módulo de expedientes, que ya no existe; el invariante es el mismo.
|
||||
"""
|
||||
expediente = _expediente(db)
|
||||
|
||||
# La primera réplica ya la hizo create_case al mintear el expediente.
|
||||
gateway.replicate_expediente_best_effort(db, expediente)
|
||||
gateway.replicate_expediente_best_effort(db, expediente)
|
||||
db.commit()
|
||||
|
||||
filas = db.query(EfcSyncOutbox).filter(
|
||||
EfcSyncOutbox.expediente_ref == expediente.id,
|
||||
|
||||
@@ -68,7 +68,7 @@ def _concept_payload(db, code: str = "FLETE-MAR", ps_code: str = "78101600") ->
|
||||
# ---------- Catálogos del SAT: lectura ----------
|
||||
|
||||
CATALOG_EXPECTATIONS = [
|
||||
("tax-regimes", 19, "601"),
|
||||
("tax-regimes", 22, "601"),
|
||||
("taxes", 3, "002"),
|
||||
("payment-forms", 22, "03"),
|
||||
("units-of-measure", 21, "H87"),
|
||||
@@ -111,6 +111,15 @@ def test_tax_regimes_person_type_excludes_individual_only(client):
|
||||
fisica_codes = [r["code"] for r in fisica]
|
||||
assert "605" in fisica_codes and "601" not in fisica_codes
|
||||
|
||||
# Claves con vigencia 2024: el 628 es solo de personas morales y el 630 solo de físicas.
|
||||
assert "628" in codes and "628" not in fisica_codes
|
||||
assert "630" in fisica_codes and "630" not in codes
|
||||
|
||||
# 616 «Sin obligaciones fiscales» es de persona física en el catálogo del SAT: una cuenta
|
||||
# marcada como moral NO debe ofrecerlo. Se fija porque es justo lo que se reporta como
|
||||
# «falta el 616» cuando en realidad está bien filtrado.
|
||||
assert "616" in fisica_codes and "616" not in codes
|
||||
|
||||
|
||||
def test_products_services_limit_caps_results(client):
|
||||
assert len(client.get("/fin/catalogs/products-services", params={"limit": 3}).json()) == 3
|
||||
|
||||
@@ -18,7 +18,7 @@ from api.v1.modules.crm.expediente_gateway.models import (
|
||||
STATUS_SENT,
|
||||
EfcFileOutbox,
|
||||
)
|
||||
from api.v1.modules.crm.expedientes import service as expedientes_service
|
||||
from api.v1.modules.crm.cases.models import Case
|
||||
from api.v1.modules.crm.service_requests import service as sr_service
|
||||
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
|
||||
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||
@@ -40,7 +40,8 @@ def entorno(db, monkeypatch):
|
||||
solicitud = sr_service.create_service_request(
|
||||
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
||||
)
|
||||
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
# El expediente es ``crm.cases`` y se llega a él por la liga ``case_id`` de la solicitud.
|
||||
expediente = db.query(Case).filter(Case.id == solicitud.case_id).one()
|
||||
return {"db": db, "expediente": expediente}
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ almacén de la company».
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.v1.modules.crm.uploads.routes import get_upload_url, validar_extension
|
||||
from api.v1.modules.crm.uploads.routes import download_file, get_upload_url, validar_extension
|
||||
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||
|
||||
USUARIO = {"tenant_id": TENANT_ID, "sub": "user-1"}
|
||||
@@ -83,3 +83,40 @@ def test_la_allowlist_de_extensiones_rechaza_lo_ejecutable():
|
||||
def test_la_allowlist_acepta_los_formatos_de_documento():
|
||||
for nombre in ("guia.pdf", "factura.XML", "foto.JPG", "hoja.xlsx", "carta.docx", "paquete.zip"):
|
||||
validar_extension(nombre) # no lanza
|
||||
|
||||
|
||||
# ── /uploads/download: el mismo alcance ──────────────────────────────────────
|
||||
#
|
||||
# El endpoint que entrega los BYTES no puede ser más permisivo que el que solo firma una URL:
|
||||
# sería la puerta grande justo al lado de la que se cerró.
|
||||
|
||||
@pytest.fixture()
|
||||
def _sin_descarga(monkeypatch):
|
||||
import api.v1.modules.crm.uploads.routes as uploads
|
||||
|
||||
monkeypatch.setattr(uploads, "get_object_bytes", lambda key: b"contenido")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sufijo",
|
||||
[
|
||||
"certificates/fiel_20260101.key",
|
||||
"invoices/9/cove/cove.xml",
|
||||
"branding/logo.png",
|
||||
],
|
||||
)
|
||||
def test_la_descarga_tampoco_alcanza_fuera_de_los_documentos(sufijo, _sin_descarga):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
download_file(PREFIJO + sufijo, COMPANY_ID, USUARIO)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_la_descarga_si_entrega_un_documento_del_crm(_sin_descarga):
|
||||
resp = download_file(PREFIJO + "crm-docs/abc123/contrato.pdf", COMPANY_ID, USUARIO)
|
||||
assert resp.body == b"contenido"
|
||||
|
||||
|
||||
def test_la_descarga_no_alcanza_otro_tenant(_sin_descarga):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
download_file("tenants/999/companies/1/crm-docs/a/b.pdf", COMPANY_ID, USUARIO)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
Reference in New Issue
Block a user