Merge pull request 'feature/AS-sat-regimenes-2024' (#11) from feature/AS-sat-regimenes-2024 into main
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 6s
Build Producción & Push a Harbor / build (push) Has been skipped

Reviewed-on: #11
This commit is contained in:
2026-08-11 20:48:06 +00:00
11 changed files with 549 additions and 28 deletions

View File

@@ -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))
)

View File

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

View File

@@ -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.

View File

@@ -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:

View File

@@ -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),
]

View File

@@ -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``.

View 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) == []

View File

@@ -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,

View File

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

View File

@@ -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}

View File

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