45 Commits

Author SHA1 Message Date
5708e12d7b Merge pull request 'feature/AS-herencia-datos-facturacion' (#12) from feature/AS-herencia-datos-facturacion into main
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 5s
Build Producción & Push a Harbor / build (push) Has been skipped
Reviewed-on: #12
2026-08-11 20:52:54 +00:00
abdf1ce790 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
2026-08-11 20:48:06 +00:00
f9258bad05 test(crm): deja la suite del backend corriendo en CI
CI ejecuta `pytest tests/` sin exclusiones y con `set -e`. En main, cuatro módulos no
coleccionaban, así que pytest se interrumpía y **no corría ni una prueba del backend** —
verificado contra main: "Interrupted: 4 errors during collection". Los tres primeros ya se
arreglaron en esta rama; aquí va el cuarto y las dos fallas que quedaban.

test_efc_entrega_documento.py: recupera sus 13 pruebas. Importaba crm.expedientes y creaba el
Document con expediente_id / efc_sync_state / efc_document_ref, columnas que no existen —eran
del expediente paralelo que 5c4df59 descartó—. El valor de estas pruebas está en
deliver_file_row (ensure-then-upload, corte directo, idempotencia por crm_document_ref), que
trabaja contra la fila del outbox y no necesita esas columnas. Las tres aserciones sobre el
estado del documento se reenfocan a lo que sí es observable: el acuse y el diagnóstico viven en
la fila del outbox, y del documento se comprueba lo único que _marcar_documento_entregado sí
persiste — que suelta su file_key al confirmar, y que NO lo suelta cuando la entrega falla.

test_contrato_efc.py: sus dos pruebas quedan skipped con el motivo completo. Afirman un API
/expedientes/* de nueve endpoints que este repo no implementa, y un DocumentResponse sin
file_key/file_url con columnas efc_*. No son arreglos de una línea: el contrato es la mitad de
un acuerdo que EFC afirma contra una copia idéntica, el frontend usa file_key para descargar, y
las columnas no existen. Se marca PENDIENTE DECISIÓN en vez de dejar CI rojo tapando el resto.

Resultado: 354 pruebas corriendo, 2 skipped, cero fallas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 14:32:49 -05:00
db4f3b1d53 fix(fin): el CFDI declaraba la descripción truncada del concepto
La partida guarda el mismo texto en dos columnas: `concept`, de 60 caracteres, que es la
que lee el PDF, y `description`, de 255, que es la que el CFDI prefiere
(`it.description or it.concept`). Al heredar de un concepto del catálogo solo se llenaba la
primera, así que el comprobante declaraba el texto cortado a 60 aunque el catálogo lo
tuviera entero: "Flete marítimo internacional puerta a puerta con seguro de c".

Ahora se heredan las dos, recortada y completa. Lo capturado a mano sigue mandando.

El PDF imprimía `concept — description`, que con las dos heredadas habría repetido el
texto —una vez cortado y otra entero—. `etiqueta_partida` lo resuelve por prefijo: cuando
description empieza con concept imprime solo la larga, y cuando son textos distintos
—una clave genérica más el detalle que alguien escribió— sigue imprimiendo los dos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 14:20:46 -05:00
5a62c31d93 feat(fin): la partida hereda todo lo configurado en el concepto
Al elegir un concepto del catálogo, la partida ya heredaba sus claves del SAT, pero eso
pasaba en silencio dentro del backend: en pantalla los tres selects se quedaban en
"Selecciona…" aunque el concepto los tuviera configurados, y no había forma de ver qué
impuesto iba a aplicar antes de guardar.

Backend:
- unit_amount se hereda del unit_price del concepto. Estaba prellenado SOLO por el
  formulario web, así que cualquier otro cliente de la API tenía que teclearlo. Requiere
  que unit_amount sea opcional en InvoiceItemCreate: con el default 0 de InvoiceItemBase
  siempre llegaba valor y el service no podía distinguir "no lo capturó" de "capturó 0".
  Un 0 explícito se respeta — una partida de cortesía es una decisión, no un campo vacío.

Frontend:
- Al elegir el concepto se copian precio y las tres claves del SAT a los campos, para que
  se vean antes de guardar. Elegir una partida genérica las limpia, en vez de arrastrar las
  del concepto anterior a algo que no las tiene.
- Se muestra el impuesto que se aplicará, resuelto con la misma precedencia del backend:
  el del concepto si lo define, el % de la factura si no, y "no causa impuesto" cuando el
  objeto de impuesto no es 02.

Lo que se cambie en el formulario sigue mandando sobre el catálogo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 14:11:38 -05:00
1f8fdf2866 feat(fin): el IVA se calcula por partida, no con un % global
El impuesto vivía en dos planos que podían divergir: el dinero salía de invoices.tax_rate
aplicado al subtotal completo, y el CFDI sumaba los impuestos de cada partida. Una partida
que no causa IVA se lo cobraba igual, y con una retención capturada la factura pedía 1160
mientras el comprobante declaraba 1060 — cobranza persiguiendo un adeudo inexistente.

Ahora fin.invoice_item_taxes es la fuente del impuesto y _recompute la lee:
total = subtotal + trasladado - retenido, la misma composición del comprobante. Se agrega
withheld_amount, porque sin guardarlo el total no cuadraba con subtotal + tax_amount y nada
en la fila lo explicaba.

NINGUNA factura existente cambia de total. El cálculo se versiona con taxes_per_item: las
nuevas nacen en true, las 9 que ya existían quedaron en false con la fórmula que las emitió.
Backfillear habría exigido poner tax_object_id='02' en partidas que nadie clasificó —
inventar una afirmación fiscal — y _recompute corre desde create_payment, así que un pago
meses después le habría bajado el total, dejado saldo negativo, marcado 'pagada' y pisado su
paid_at. El rollback es un UPDATE.

Conceptos que no causan IVA: fin.concepts gana impuesto, tasa y tipo de factor por defecto,
que la partida hereda como ya heredaba las claves fiscales. Exento (ObjetoImp 02 +
TipoFactor Exento) y tasa 0% son distintos y ahora los dos son expresables; el 0% era
incapturable, el rate==0 borraba el traslado y el timbrado fallaba pidiendo el desglose.

Redondeo: manda el comprobante. subtotal = Σ round(qty × precio) por renglón, no round(Σ),
y tax_amount es la suma de los importes ya materializados, todo ROUND_HALF_UP con el mismo
`cents` que usa el builder. El PAC valida que SubTotal sea la suma de los Importe.

Trampas que el cambio cerró:
- _build_data construía TaxLine sin factor: un exento se habría timbrado como gravado al 0%,
  un CFDI incorrecto que el PAC acepta.
- CfdiData.transferred no excluía Exento mientras _add_totals sí: una fila exenta con importe
  dejaba el XML inconsistente consigo mismo.
- El guard de captura manual era heurístico (retención o impuesto != IVA), así que un IVA al
  8% capturado volvía al 16% por cambiarle la cantidad a la partida. Ahora is_manual es un
  hecho registrado.
- delete_item dejaba los impuestos vivos: cobro fantasma de una partida que ya no existe.
- set_item_tax y delete_item_tax no recalculaban la factura.
- El PDF imprimía "IVA (16%)" y no mostraba retenciones. Ahora desglosa por
  (impuesto, factor, tasa) con el mismo criterio del comprobante, y los exentos se listan con
  su base y sin importe: es lo que explica por qué el total no es subtotal × 1.16.

stamp_invoice verifica que invoice.total sea el del comprobante antes de sellar, y falla en
vez de corregir: el timbrado es donde el dinero se vuelve irreversible y recalcular ahí
cambiaría montos sin que nadie lo vea.

Cuota queda fuera con 422 explícito: su importe es cuota × cantidad, no base × tasa.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:37:26 -05:00
dbda0b5755 feat(fin): la factura hereda los datos de facturación del cliente
Forma de pago, método de pago y moneda se capturaban a mano en cada factura aunque ya
vivieran en la ficha del cliente — y son justo las claves que detienen el timbrado en
validación si faltan. Ni el alta manual ni generate_from_shipment las prellenaban.

- _inherit_account_billing espeja _resolve_item_concept, el patrón de herencia que ya
  usa el módulo: lo explícito manda sobre la ficha, y se completa sin borrar. Si la
  ficha trae texto que no resuelve a una clave del SAT no se asigna nada, así que
  cambiar de cliente nunca vacía un dato ya capturado.
- find_by_code traduce el texto del Account a id de catálogo. Normaliza ('3' -> '03',
  'pue' -> 'PUE') y devuelve None sin lanzar: una ficha mal capturada no puede impedir
  facturar, el faltante lo reporta el timbrado junto al resto.
- Se invoca al crear, al cambiar de cliente (re-herencia) y en generate_from_shipment,
  donde la moneda del embarque gana sobre la de la ficha: es la que se coteó y operó.

Incluye el candado de inmutabilidad con timbre, que la herencia hacía necesario: había
un solo campo protegido (stamping_mode) y todo lo demás de una factura ya timbrada era
editable — cliente, folio, moneda, partidas e impuestos — con lo que la factura y su
CFDI podían contar cosas distintas. _reject_if_stamped generaliza esa guarda sobre una
lista cerrada de campos del comprobante, y sin lista en partidas e impuestos. Cobrar y
anotar siguen permitidos: no alteran el CFDI. send_invoice deja de regenerar el PDF de
una factura timbrada, que reescribía en MinIO el documento que el cliente ya recibió.

Dos cosas que la herencia obligaba a arreglar:

1. currency y tax_rate tenían default no nulo en el DTO y el frontend sembraba
   {currency:'MXN', tax_rate:16}, así que el backend nunca podía distinguir "no lo
   eligió" de "eligió eso" y la herencia habría sido código muerto. Ahora son
   opcionales; un None se retira del payload para que mande el default de la columna.
2. saveHeader mandaba el objeto completo, con lo que al cambiar de cliente el PATCH
   llevaba las claves del cliente anterior. Ahora manda solo el delta.

Se agrega fin.invoices.exchange_rate: heredar una moneda distinta de MXN producía
facturas no timbrables en silencio, porque _build_data pasaba exchange_rate=None
siempre y el validador lo exige. La validación sigue siendo del builder, que acumula
todos los faltantes juntos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:15:20 -05:00
16aca537be test(crm): revive las pruebas del carril EFC que no coleccionaban
test_efc_outbox.py y test_gateway_rutas.py importaban crm.expedientes, el módulo del
expediente paralelo que 5c4df59 descartó al rebasar sobre crm.cases. Con el
ModuleNotFoundError, pytest ni siquiera las coleccionaba: 28 pruebas de la máquina de
reintentos y del tablero de ops llevaban sin correr, justo las del carril que se está
extendiendo.

Se traducen al modelo vigente preservando cada invariante:

- el expediente es crm.cases y se llega por la liga case_id de la solicitud, en vez de
  find_by_service_request;
- el folio es Case.reference, no .folio;
- la idempotencia que se probaba vía ensure_expediente ahora se ejercita en
  replicate_expediente_best_effort, que es donde vive la guarda _expediente_ya_encolado.

No se relaja ninguna aserción.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:21:01 -05:00
a1793de2f2 fix(crm): restringe /uploads/url y /uploads/download a documentos del CRM
tests/test_uploads_alcance.py llegó en el rebase del carril EFC (5c4df59) sin el código
de producción que lo acompañaba: venía de la rama del expediente paralelo que se
descartó. El módulo no importaba —`validar_extension` no existe— así que la suite
nunca corrió y el hueco quedó invisible.

El hueco: ambos endpoints solo comprobaban que la key empezara con
tenants/{tid}/companies/{cid}/. Con el permiso de módulo crm.access eso alcanzaba para
firmar o descargar CUALQUIER objeto de la company, incluidos certificates/*.key — la
llave privada del CSD con la que se sellan los CFDI.

- _validar_alcance: además del aislamiento por tenant/company, la key tiene que ser un
  documento del CRM (crm-docs/ o expedientes/{n}/documents/). Se aplica a los dos
  endpoints: el que entrega bytes no puede ser más laxo que el que firma una URL.
- validar_extension: allowlist de extensiones en la subida, no lista de vetados.

Verificado que el frontend solo pasa file_key de documentos a estos endpoints
(uploads.ts, sus dos únicos llamadores). Se agregan 5 casos para /uploads/download,
que la prueba original no cubría.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:20:49 -05:00
81c237d852 feat(fin): entrega al expediente EFC los XML enviado y recibido del timbrado
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>
2026-08-11 11:55:05 -05:00
57745af9b5 feat(fin): agrega las claves 628, 629 y 630 a c_RegimenFiscal
El catálogo sat.tax_regimes tenía 19 claves: las vigentes hasta 2022. Faltaban las
tres que el SAT publicó con vigencia 01-01-2024, así que un receptor en esos
regímenes no se podía representar y el timbrado habría quedado con clave incorrecta.

- 628 Hidrocarburos (moral)
- 629 De los Regímenes Fiscales Preferentes y de las Empresas Multinacionales (física)
- 630 Enajenación de acciones en bolsa de valores (física)

La migración reusa sync_catalogs (upsert idempotente). El downgrade desactiva las
claves en vez de borrarlas: crm.accounts y fin.issuer_settings las referencian por FK
y un CFDI ya timbrado debe seguir siendo legible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:38:31 -05:00
4f3bb81607 Merge pull request 'feature/AS-timbrado-cfdi-ingreso' (#10) from feature/AS-timbrado-cfdi-ingreso 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: #10
2026-08-11 15:09:01 +00:00
c99cf2c7e9 Merge remote-tracking branch 'origin/main' into feature/AS-timbrado-cfdi-ingreso
Segunda integración de main: trae el carril hacia EFC y, vía el PR #9, la resolución del
merge anterior que ya se había hecho en esta rama.

Dos conflictos:

- core/config.py: adiciones en el mismo punto. Se conservan los dos bloques, con el de EFC
  antes del del PAC para que el diff de esta rama contra main sea sólo lo añadido.
- La migración de catálogos SAT: las dos ramas la renumeraron a g1h2i3j4k5l6 y sólo difería
  su down_revision. Se toma la de main —c5d6e7f8a9b0, el carril EFC—, que es la publicada.
  Cabeza única: j5k6l7m8n9o0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:07:16 -05:00
c195c05c0d Merge pull request 'chore/merge-main-en-cumplimiento-pdf' (#9) from chore/merge-main-en-cumplimiento-pdf 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: #9
2026-08-11 14:59:39 +00:00
061b48d30f Merge remote-tracking branch 'origin/main' into feature/crm-cumplimiento-pdf
Tres conflictos en frontend, todos por adiciones en el mismo punto:

- crm/types.ts y sidebar/modules.ts: se conservan las dos partes.
- crm/AccountFields.svelte: main pasó toda la pestaña fiscal a selects de crmCatalogs.
  Régimen fiscal y uso de CFDI se quedan con los catálogos del SAT (tax_regime_id /
  cfdi_use_id), que son las claves que viajan en el CFDI y que el PAC valida contra
  c_RegimenFiscal y c_UsoCFDI; el catálogo configurable del CRM no las garantiza. Método
  de pago, forma de pago y moneda sí toman la versión de main.

Las tres resoluciones son idénticas a las de feature/AS-timbrado-cfdi-ingreso, donde este
mismo merge ya se resolvió y se verificó, para que las dos ramas no diverjan de criterio.

Además, un choque que git no detecta: las dos ramas salieron de d5e6f7a8b9c0 y crearon una
migración con el mismo id, e6f7a8b9c0d1 —catálogos SAT aquí, catalog_items del CRM en
main—. Los archivos se llaman distinto, así que el merge pasa limpio y el problema sólo
aparece al arrancar Alembic, con la revisión duplicada y dos cabezas. Se renumera la de
facturación a g1h2i3j4k5l6 y se encadena detrás del carril EFC (c5d6e7f8a9b0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:42:24 -05:00
7c0a04fb3b fix(db): renumera las migraciones de facturación al integrar main
Las dos ramas salieron de d5e6f7a8b9c0 sin verse y crearon una migración con el mismo
identificador, e6f7a8b9c0d1: catálogos SAT aquí, catalog_items del CRM en main. Git no lo
detecta porque los archivos tienen nombres distintos, pero Alembic avisaba de que la
revisión estaba presente más de una vez y quedaban dos cabezas, con lo que `upgrade head`
falla.

Se renumera la de facturación —no la del CRM, que ya está en la rama compartida y a la que
e7f8a9b0c1d2 apunta por id— y se encadena detrás del expediente (d4e5f6a7b8c9). La historia
vuelve a ser lineal con una sola cabeza: j5k6l7m8n9o0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:24:50 -05:00
e32c9f110e Merge pull request 'T2026-08-047 feat(fin): catálogos SAT, conceptos de facturación y datos fiscales del emisor' (#5) from feature/AS-catalogos-sat-facturacion into feature/crm-cumplimiento-pdf
Reviewed-on: #5
2026-08-11 14:16:08 +00:00
926a75c5f8 Merge pull request 'feat(crm): carril emisor hacia EFC sobre el expediente existente (T2026-08-046)' (#6) from feature/T2026-08-046-carril-efc into main
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 4s
Build Producción & Push a Harbor / build (push) Has been skipped
Reviewed-on: #6
2026-08-11 14:15:53 +00:00
21ea958f33 Merge remote-tracking branch 'origin/main' into feature/AS-timbrado-cfdi-ingreso
# Conflicts:
#	frontend/src/lib/api/crm/types.ts
#	frontend/src/lib/components/crm/AccountFields.svelte
#	frontend/src/lib/components/sidebar/modules.ts
2026-08-11 09:12:09 -05:00
6e208876f7 feat(fin): timbrado de CFDI 4.0 de ingreso con Comercio Digital
Cierra el ciclo de la factura: construcción del comprobante, sellado con el CSD de la
empresa emisora y transmisión al PAC.

- cfdi_builder: XML 4.0 de ingreso en el orden de atributos del XSD, del que depende la
  cadena original y con ella el sello. Todo el dinero con Decimal.
- sealer: cadena original vía el XSLT oficial del SAT y firma con la llave del CSD.
- pac_comercio_digital: cliente de timbrarV5. Conserva el código y el saldo de folios que
  el legado leía en una variable que descartaba (CFDI.cs:19324-19336).
- csd_service y core/crypto: CSD por empresa, con la contraseña cifrada en la base. Antes
  el certificado había que dejarlo a mano en el almacenamiento y su contraseña era una
  variable de entorno global, lo que no funciona con varias empresas emisoras.
- Cada intento —también los rechazados— guarda el XML que se transmitió y el que contestó
  el PAC: sin ese par no hay forma de reconstruir un rechazo cuando termina la petición.

La declaración XML se escribe a mano con comillas dobles. lxml la emite con comillas
simples, que es XML válido, pero Comercio Digital compara la cadena literal version="1.0"
y responde 642 "la versión del XML no es 1.0".

El modo (pruebas o producción) sale de invoices.stamping_mode y no se puede pasar por la
API: es lo único que separa un timbre de prueba de un CFDI con validez fiscal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:07:05 -05:00
61af5c80fe chore(deploy): las ocho variables del carril hacia EFC en produccion
El compose de produccion no declaraba ninguna EFC_*, asi que el carril nacia muerto
alla: `EfcClient.is_configured` era False y todo el enganche hacia no-op en silencio.

Van en los TRES servicios y no solo en el backend, porque cada uno hace una parte:

  - backend       encola al crear el expediente y despacha la entrega inmediata;
  - celery_worker EJECUTA la entrega (`deliver_outbox_row` / `deliver_file_outbox_row`).
                  Si solo el backend las tuviera, el encolado se veria perfecto y nada
                  se entregaria nunca -- es exactamente el modo de fallo que ya nos
                  costo un diagnostico hoy, cuando el worker no tenia registradas las
                  tareas del carril;
  - celery_beat   dispara los tres barridos, que son la red que atrapa lo que el
                  despacho inmediato no alcanzo. Sin el, una caida de EFC deja la cola
                  detenida para siempre.

Se declaran las ocho aunque seis queden vacias por default. No es simetria: una
variable no declarada en el compose NO llega al contenedor, asi que editarla en el .env
no surte efecto y el sintoma parece un problema de red. Ya paso con EFC_API_VERIFY_SSL
en el compose de desarrollo, que solo pasa dos de las ocho.

EFC_API_URL vacia = carril apagado, y es el default a proposito: desplegar este cambio
no enciende nada. Encenderlo exige EFC_API_URL y una EFC_API_KEY identica a la
CRM_INTEGRATION_API_KEY del lado de EFC, cuyo permiso es fail-closed.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 16:23:38 -06:00
192a2d9d89 fix(crm): el worker no podia entregar, y el carril encolaba lo que EFC rechaza siempre
Tres defectos que solo aparecieron al probar con los dos sistemas cableados. Ninguno lo
habrian encontrado las pruebas: usan SQLite con su propio registro de modelos y no
ejercitan el proceso del worker.

1. REGISTRO DE MODELOS EN EL WORKER. Celery no carga la app: importa el modulo de la
   tarea y nada mas. SQLAlchemy resuelve las ForeignKey por nombre de tabla contra su
   registro global, asi que sin la clase del otro extremo importada la configuracion de
   mappers moria con "Foreign key associated with column 'cases.account_id' could not
   find table 'crm.accounts'" y la tarea con PendingRollbackError.
   El sintoma era cruel: la fila del outbox se quedaba en pending con attempts=0 y SIN
   last_error --el fallo ocurre antes de poder registrarlo--, asi que el carril se veia
   encolando bien y no entregaba nunca. En la app web no pasa porque main.py monta todos
   los routers. Se importan los cuatro modelos del juego minimo verificado con
   configure_mappers() en un proceso limpio.

2. LA MIGRACION NO RELLENABA LAS FILAS PREVIAS. Los expedientes creados antes del carril
   quedaban con efc_storage_token NULL; el barrido de reconciliacion los encolaba, EFC los
   rechazaba con {'storage_token': ['This field may not be null.']} y agotaban sus 8
   intentos hasta failed. Ruido permanente por un dato derivable. La migracion ahora
   rellena 'CRM-'||company_id||'-'||reference, con la misma condicion de longitud que la
   guarda de storage_token: lo que no cabe en los 25 de pedimento_app se queda NULL a
   proposito, porque un token recortado apuntaria a la carpeta de otro expediente.

3. SIN GUARDA DE FOLIO NULO. crm.cases.reference es nullable, y ni el encolado ni el
   barrido de huecos lo comprobaban. Ahora los dos saltan lo que no tiene folio o token,
   y el encolado lo avisa en WARNING: es una omision silenciosa --el expediente vive en el
   CRM y sus documentos no llegaran a EFC-- y merece dejar rastro.

Verificado de punta a punta. Los TRES expedientes del CRM estan en EFC, leido desde EFC:

  EXP2026-08-001 -> CRM-2-EXP2026-08-001  provisional
  EXP2026-08-002 -> CRM-2-EXP2026-08-002  provisional
  EXP2026-08-003 -> CRM-2-EXP2026-08-003  provisional

y los tres en LINKED con su outbox en sent. El 001 nacio antes del enganche y se recupero
por el camino del relleno + reintento, que es el que usaria una persona desde el tablero.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:44:12 -06:00
be45f950b8 fix(crm): registra las tareas del carril en Celery, sin lo cual nada se drenaba
Delta de core/celery_app.py que se me quedo fuera al portar el carril. El sintoma era
enganoso: el encolado se veia perfecto -- fila en crm.efc_sync_outbox, status pending,
sin error -- pero el worker rechazaba la entrega con "Received unregistered task of
type 'expediente_gateway.deliver_outbox_row'" y la fila se quedaba en pending con 0
intentos PARA SIEMPRE. Ni el despacho inmediato ni el barrido existian.

  - include: api.v1.modules.crm.expediente_gateway.tasks
  - beat: sweep_outbox y sweep_file_outbox cada 120 s, sweep_expediente_gaps cada
    300 s. Los intervalos son los del carril de referencia de Anexo22. El reintento
    NO es exponencial a proposito: el backoff corto vive en el cliente HTTP y el
    largo es este barrido de intervalo fijo.

Verificado de punta a punta con los dos sistemas cableados. Desde EFC, no desde el
CRM:

  pedimento_app : CRM-2-EXP2026-08-002        (el storage_token del CRM)
  patente/aduana/clave_pedimento/regimen: None  <- provisional de verdad
  pedimento_expediente: estado=provisional, crm_expediente_id=3, folio=EXP2026-08-002
  organizacion  : Aduanasoft (hub_tenant_slug=aduanasoft, is_verified=True)
  licencia      : 5 GB, asi que la subida no falla por cuota

El resolver mapeo tenant 11 -> organizacion por slug, que es el puente 1:1 acordado.
Las cinco tareas quedan registradas en el worker y los tres barridos en el beat.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:27:01 -06:00
bc50a7d099 feat(crm): el expediente pide su pedimento provisional a EFC al nacer el folio
Primera mitad del pegamento del carril (fase 7). El reflejo en EFC se pide en
create_case, en el instante en que se mintea el folio, porque el folio es la llave
con la que las dos mitades se reconocen: EFC no recibe ids del CRM como handle.

  - crm.cases nace con efc_storage_token = CRM-{company}-{folio}, fijado al nacer y
    nunca reescrito: es la carpeta de MinIO del lado de EFC, y que sea inmutable es
    lo que permite completar el provisional con la data aduanera real sin mover un
    solo archivo.
  - replicate_expediente_best_effort corre en la MISMA transaccion que el
    expediente. Con EFC_API_URL vacia es no-op; si el encolado o el despacho fallan
    no se propaga el error y el barrido del beat recoge lo pendiente. Un sistema de
    terceros caido no puede romper un alta.
  - El import del carril es diferido para no acoplar el arranque del modulo del
    expediente, que es de otra rama, a la integracion.
  - Se registra el tablero de ops del carril (outbox, metricas, reintento manual)
    en el router del CRM.

test_el_formato_del_folio_es_el_del_contrato se re-apunta a crm/common/folios.py y
queda VERDE: afirma contra la implementacion real que el folio del CRM tiene la forma
que EFC espera, que era el riesgo de haber rebasado sobre otro expediente.

Verificado en la base: create_case produce EXP2026-08-002 con storage_token
CRM-2-EXP2026-08-002 y link_state PENDING, 0 filas encoladas por carril apagado, y la
transaccion reversada NO deja hueco en el contador -- el with_for_update de
crm/common/folios.py revierte limpio.

PENDIENTE de la fase 7: documentos (subida de un paso, proxy de descarga, listado y
desvinculacion) y los 8 archivos del frontend. Por eso siguen rojas
test_efc_outbox, test_gateway_rutas, test_efc_entrega_documento, test_uploads_alcance
y dos de test_contrato_efc.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:04:12 -06:00
d1fcd236e4 fix(core): el bootstrap de permisos colgaba el rol de un tenant inexistente
Al entrar por primera vez a una compañia, /permissions/me creaba el rol super_admin
con `tenant_id = self.db.info.get(RLS_TENANT_KEY) or 1`. La sesion de ese request no
trae contexto RLS, asi que caia en el respaldo: tenant_id = 1. En una instalacion
real ese tenant no existe -- aqui son 11 y 17 -- y el INSERT moria con
ForeignKeyViolation sobre company_roles_tenant_id_fkey.

El fallo era silencioso hacia afuera: el except del bootstrap lo registraba como
ERROR CRITICO y devolvia False, pero /permissions/me seguia respondiendo 200 con la
lista de permisos VACIA. En pantalla se leia "No tienes permisos para realizar esta
accion", que manda a revisar roles en vez de la base. Toda la API respondia 403.

Dos arreglos:

  - `_resolve_tenant_id_for_company` tenia el respaldo sin implementar (`pass` con un
    comentario de plantilla), asi que devolvia None siempre que faltara el contexto
    RLS. Con esa funcion se scopean las consultas de sus CINCO llamadores, o sea que
    la lectura de permisos tampoco resolvia. Ahora consulta a76.company, la tabla de
    companias del CRM, con SQL crudo igual que seed_crm.py.

  - bootstrap_super_admin toma el tenant de la COMPANIA, con consulta directa y no
    por el helper: el helper prefiere el contexto RLS, que es el tenant del REQUEST y
    puede no ser el de la compania. Para leer permisos esa preferencia esta bien y
    ahorra una consulta en el camino caliente; para escribir una fila atada por FK a
    a76.company y a core.tenants a la vez, no -- si difirieran, el rol naceria
    cruzado entre dos tenants. Si la compania no existe, aborta sin crear nada en vez
    de inventar un tenant.

Verificado contra la base: bootstrap devuelve True en las dos companias del usuario,
70 permisos en cada una, y las filas quedan consistentes (rol de company 1 -> tenant
17, company 2 -> tenant 11). Suite sin regresion: 151 pasan.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 10:58:56 -06:00
5c4df590d4 feat(crm): carril hacia EFC montado sobre el expediente existente (crm.cases)
Rebase del lado emisor de T2026-08-046 sobre esta rama. La entrega anterior partia
de feature/crm-cumplimiento-pdf (16-jul), 40 commits atras, y por eso construyo un
expediente PARALELO -- crm.expedientes con su propio generador de folio y su propia
migracion -- que duplicaba el que ya existe aqui. Dos expedientes y dos secuencias
peleando por el mismo namespace EXP no se fusionan; se tira el nuestro.

La estructura del expediente es de esta rama y no se toca: crm.cases es el
expediente, su folio vive en `reference` y el consecutivo lo reserva
crm/common/folios.py con bloqueo de fila. Nuestro aporte es SOLO la conexion:

  - crm.cases gana seis columnas efc_* (espejo de EFC, nunca el handle) y nada mas;
  - crm.efc_sync_outbox y crm.efc_file_outbox, el outbox transaccional, con
    expediente_ref -> crm.cases.id;
  - core/efc_client.py y crm/expediente_gateway/ (outbox, reintentos, barridos),
    clonados del gateway Anexo22 -> EFC que ya corre en produccion;
  - las ocho variables EFC_* en config. EFC_API_URL vacia = carril apagado.

Verificado contra la base real: next_folio(...,'EXP',None,with_direction=False)
devuelve EXP2026-08-001, identico al formato que el contrato con EFC exige, y
storage_token da CRM-{company}-{folio} de 22 caracteres sobre los 25 de
pedimento_app.

Se corrige un error del docstring de storage_token: decia que cabian companies de
7 digitos y son 6 (4+7+1+14 = 26 > 25). Ahora valida y falla ruidosamente en vez de
entregar un token recortado, que apuntaria a la carpeta de otro expediente y
mezclaria documentos en silencio.

El revision id de la migracion tirada (e6f7a8b9c0d1) chocaba con crm_catalog_items
de esta rama: dos migraciones distintas con el mismo id habrian roto alembic al
fusionar. La nueva es c5d6e7f8a9b0, aditiva sobre d4e5f6a7b8c9.

PENDIENTE: falta el pegamento que invocaba el carril desde los flujos de la app
(alta del provisional al mintear el folio, subida de documento -> outbox, rutas en
el router y UI). Por eso test_efc_outbox, test_gateway_rutas y tres casos de
test_contrato_efc todavia no colectan. El carril no esta cableado al router, asi
que la app funciona igual: backend y frontend responden 200.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 10:35:44 -06:00
dfb3c8a08d chore(dev): puertos del CRM sin choque con EFC y overrides locales fuera del repo
El 8000 del host lo ocupa EFC_backend_dev, asi que el backend del stack E2E se
mueve a 3468 (vecino del 3467 de prod). Dentro del contenedor sigue siendo 8000:
gunicorn, healthcheck e INTERNAL_API_URL son de la red interna y no cambian.
El 5173 NO se mueve: es la redirect URI registrada en Workspace.

SUNRISE/, automatizacion/ y docker-compose.dev.yml quedan fuera del control de
versiones: son el corredor de la corrida autonoma y el override de dev de cada
maquina, no configuracion compartida.

Ref: T2026-08-046

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 10:19:09 -06:00
f4ef6a037d feat(fin,crm): catálogo c_UsoCFDI y claves fiscales del receptor
Cierra las decisiones pendientes 1 y 5. Agrega sat.cfdi_uses con su endpoint de
solo lectura y amarra la ficha del cliente a los catálogos del SAT con
crm.accounts.tax_regime_id y cfdi_use_id.

Las columnas de texto libre tax_regime y cfdi_use se conservan intactas: la
migración hace un backfill conservador que solo resuelve lo inequívoco (la clave
del catálogo, o la descripción exacta sin distinguir mayúsculas ni espacios) y
deja en NULL lo que no case, porque deducir el régimen de un receptor a partir
de texto libre provoca CFDI rechazados. La UI muestra el texto anterior junto al
selector para que el usuario elija la clave que corresponde.

El selector de régimen se acota al tipo de persona de la cuenta, y el service
valida ambas claves contra el catálogo.

sync_catalogs ahora omite los catálogos cuya tabla todavía no existe: al correr
el historial desde cero, la migración anterior la invoca antes de que se creen
los catálogos agregados después.

Las claves de c_UsoCFDI quedan pendientes de validación con el área Fiscal antes
de producción, igual que el subset de c_ClaveProdServ; no se cargaron las
banderas de persona física/moral ni la compatibilidad por régimen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:50:26 -05:00
15717314fd feat(fin): la partida hereda las claves del SAT de su concepto
Cierra la decisión pendiente 7. create_item ya no copia solo la descripción del
concepto: también hereda product_service_id, unit_of_measure_id y tax_object_id
cuando el cliente no los envía, para que la partida capturada por catálogo quede
completa para el CFDI. Lo que el cliente sí manda gana sobre el catálogo, para
poder facturar con una unidad distinta a la del concepto.

update_item pasa por la misma resolución cuando cambia concept_id: revalida que
el concepto sea de la empresa (antes el PATCH no lo validaba y admitía apuntar a
un concepto de otro tenant) y vuelve a heredar del concepto nuevo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:50:12 -05:00
ce09e0d30a feat(fin): partidas de factura capturadas desde el catálogo de conceptos
El selector de concepto de la partida deja de ser una lista fija en el código y
se alimenta del catálogo de conceptos de la empresa: al elegir uno se manda
concept_id y el backend copia la descripción a la columna de texto libre que
consume el PDF. Si el concepto trae precio unitario, se precarga en la partida.

Las claves genéricas anteriores quedan en un segundo grupo del mismo selector,
marcadas como "sin clave del SAT", para no bloquear a las empresas que aún no
tienen catálogo; si está vacío se enlaza al alta de conceptos.

El listado de partidas etiqueta con la clave y descripción del catálogo cuando
la partida lo referencia, y cae al texto libre para las facturas anteriores.

Los tipos de Invoice e InvoiceItem se completan con las claves fiscales que el
backend ya devuelve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:24:50 -05:00
ae0664e987 refactor(fin): conceptos con páginas dedicadas en vez de modal
Sustituye el diálogo de alta/edición por el patrón que ya usa el CRM para
proveedores y cuentas: la lista solo lista, y el alta y la edición viven en
/dashboard/fin/conceptos/nuevo y /dashboard/fin/conceptos/[id].

Los campos del formulario se extraen a $lib/components/fin/ConceptFields.svelte
para que ambas pantallas compartan el combobox de clave ProdServ y los selects
de unidad y objeto de impuesto. El 409 del backend por clave ya asignada se
sigue mostrando junto al campo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:10:57 -05:00
cb2acb11fc test(fin): cobertura de catálogos, conceptos y emisor
Backend: los 8 endpoints de catálogo responden 200 con las semillas exactas y
filtran por búsqueda; tax-regimes acota por tipo de persona; ninguna ruta de
catálogo acepta escritura (405); sync_catalogs es idempotente. CRUD de
conceptos, conflicto 409 por clave ProdServ repetida en la misma empresa,
la misma clave permitida en otra empresa, la baja lógica liberándola,
aislamiento multi-tenant, upsert del emisor sin duplicar filas y RFC inválido
rechazado. También que una partida con concept_id hereda la descripción y que
las facturas sin claves del SAT siguen listándose y generando PDF.

El fixture de pruebas siembra los catálogos con la misma función que usa la
migración, sobre el schema sat mapeado a SQLite.

Frontend: prueba del cacheo del cliente de catálogos.

RFC dummy XAXX010101000 en todas las pruebas: sin datos reales.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:58:09 -05:00
5a112a0171 feat(fin): pantallas de conceptos y datos fiscales del emisor
Clientes API por dominio para los catálogos del SAT, conceptos y emisor. Los
catálogos se cachean en un Map del módulo tras la primera carga: son fijos y no
cambian durante la sesión.

Pantalla de conceptos (/dashboard/fin/conceptos) con tabla, buscador, filtro de
activos y alta/edición en diálogo. La clave de producto/servicio se elige con un
combobox que consulta el catálogo a partir de 2 caracteres, y el 409 del backend
por clave ya asignada se muestra junto al campo.

Sección de configuración fiscal (/dashboard/settings/facturacion) con razón
social, RFC (misma validación que el backend), régimen fiscal y CP. Si el GET
responde 404 se abre en modo alta, no como error; el guardar se deshabilita sin
fin.settings.edit.

Todo en Svelte 5 con runes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:58:09 -05:00
8d9db3505d feat(fin): amarre de facturas y partidas a catálogos SAT
fin.invoices gana tipo de comprobante, forma y método de pago y CP de
expedición; fin.invoice_items gana concepto de catálogo y las claves ProdServ,
unidad y objeto de impuesto. Todas nullable: las facturas ya emitidas no las
tienen y siguen funcionando igual (listado, detalle, PDF, envío).

La columna de texto libre invoice_items.concept se conserva obligatoria porque
la consume el PDF actual; al capturar por catálogo, el service hereda ahí la
descripción del concepto cuando el cliente no la envía.

Nueva tabla fin.invoice_item_taxes para el detalle de impuestos trasladados y
retenidos por partida. No interviene en el cálculo de subtotal/IVA/total, que
sigue saliendo de invoices.tax_rate.

Incluye la migración e6f7a8b9c0d1 (crea el schema sat, siembra los catálogos con
sync_catalogs y monta las tablas e índices nuevos) y registra los permisos
fin.concept.* y fin.settings.{view,edit}.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:57:51 -05:00
b8b8311ece feat(fin): datos fiscales del emisor por empresa
fin.issuer_settings guarda la identidad fiscal con la que la empresa emite
CFDI: razón social, RFC, régimen fiscal y CP del lugar de expedición.

Una sola configuración vigente por empresa, garantizada con índice único
parcial; el guardado es un upsert (GET + PUT, sin DELETE). El RFC se valida con
la expresión oficial y se normaliza a mayúsculas sin espacios antes de aplicar
la restricción de longitud.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:57:39 -05:00
9cf142add6 feat(fin): CRUD de conceptos con relación 1:1 a clave ProdServ
fin.concepts es el catálogo de conceptos facturables de cada empresa, ligado a
una clave de producto/servicio del SAT. La relación es 1:1 por empresa: si dos
conceptos compartieran la misma clave, al timbrar no habría forma de saber qué
descripción corresponde.

La unicidad se garantiza por índice único parcial (WHERE deleted_at IS NULL) y
se valida además en el service para devolver 409 con mensaje en español en vez
de un IntegrityError crudo. La baja lógica libera la clave y el código.

Las respuestas traen los objetos del catálogo ya resueltos (selectin) para que
el frontend no dispare N+1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:57:39 -05:00
e24435c74b feat(fin): catálogos SAT en schema sat con seeds idempotentes
Agrega los 8 catálogos oficiales del SAT (c_RegimenFiscal, c_Impuesto,
c_FormaPago, c_ClaveUnidad, c_ClaveProdServ, c_TipoDeComprobante, c_MetodoPago
y c_ObjetoImp) como tablas globales de solo lectura en el schema sat: sin
tenant_id, sin CRUD y sin baja física (las claves retiradas se desactivan para
no romper los CFDI históricos).

Las semillas viven en catalogs/seed_data.py, no dentro de una migración, para
que corregir un dato del catálogo no exija escribir una migración nueva.
sync_catalogs() hace upsert por clave: inserta lo que falta, actualiza
descripción y banderas, y nunca borra.

El subset de c_ClaveProdServ (11 claves de logística) queda pendiente de
validación con el área Fiscal antes de producción.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:57:26 -05:00
Ernesto Herrera
afe659e56a feat(crm): Expediente — referencia única de trazabilidad del trámite (Fase D)
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 11s
Build Producción & Push a Harbor / build (push) Has been skipped
Aduanasoft/CRM_AGENTES_CARGA/pipeline/head There was a failure building this commit
- Tabla crm.cases (expediente) con folio EXP2026-08-001 (next_folio entidad EXP,
  sin dirección). Nace al crear la Oportunidad y se hereda vía case_id a
  solicitud → cotización → operación → factura. advance_stage solo avanza.
- case_id (FK a crm.cases) en crm.opportunities/service_requests/quotes,
  ops.shipments y fin.invoices; propagación en sus create_*. Migración
  d4e5f6a7b8c9 reversible.
- Endpoints GET /v1/crm/cases, /cases/{id}, /cases/by-ref/{ref} con timeline
  (historia completa para UI y otros sistemas).
- Frontend: casesAPI, ruta /dashboard/crm/expedientes (lista + timeline vertical),
  chip "📁 Expediente" en solicitud/cotización, "Expedientes" en el sidebar.
- Consecutivo de folios sin tope (soporta >10,000,000/mes).
- 4 pruebas de expediente (minteo, propagación, timeline, no-retroceso). Suite en verde (113).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:58:51 -06:00
Ernesto Herrera
b47dc542f2 feat(crm): Cotizaciones/Cotizador vinculados (Fase B)
- Lista de cotizaciones muestra la Solicitud referenciada (QuoteResponse enriquecido
  con service_request_reference; columna con enlace a la solicitud).
- Cotizador vinculable a una solicitud (?service_request_id=) → prellena modo
  (mapper transport+load→RateMode), ruta, peso/dimensiones, etc.
- Cotizador vinculable a una cotización (?quote_id=) → botón "Agregar" por opción
  que crea el concepto de flete + cargos en la cotización y regresa a ella.
- Botón "Cotizador" en el detalle de la cotización (entrada vinculada).

Suite de cotizaciones en verde. svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:43:53 -06:00
Ernesto Herrera
8431132b10 feat(crm): Prospecto — medio de contacto preferido + fix visualización de documentos (Fase C)
- Prospecto (lead): se conserva "Origen" y se agrega "Medio de contacto preferido"
  (catálogo medio_contacto). Backend leads.preferred_contact_method + migración
  f0a1b2c3d4e5 reversible.
- Bug documentos: endpoint proxy GET /v1/crm/uploads/download transmite el archivo
  por el backend (valida aislamiento tenant/company) — evita la URL prefirmada al
  host interno minio:9000. RelatedManager.openDoc usa blob→objectURL.

Suite backend en verde (109). svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:37:30 -06:00
Ernesto Herrera
8c7aeef1a6 feat(crm): refinamientos de Solicitud (Fase A del PDF 07-ago)
- Fecha de solicitud automática (hoy) editable al crear.
- Modalidad de carga dependiente del transporte: marítimo→FCL/LCL/Ambas,
  aéreo→Aérea (autoselección), terrestre→FTL/LTL, ferroviario/multimodal sin modalidad.
- Ciudad y Puerto/Aeropuerto dependientes del país (catálogos por parent_code) con
  respaldo de texto; el campo Puerto/Aeropuerto une ambos catálogos.
- Agente en destino filtrado a proveedores clasificados corresponsal/aduanal.
- Volumen SIEMPRE en m³ (conversión desde dimensiones según unidad de medida).
- P/Vol aéreo etiquetado con unidad (kg) y honra la unidad de medida.
- Moneda visible junto al valor de la mercancía.
- Lista de solicitudes con columna Cliente + filtro por cliente + búsqueda por nombre.
- Formulario de tarifa: campo "Válida hasta" (calendario).
- Catálogos globales ciudad/puerto/aeropuerto por país (seed_locations, extensible).
- Nuevos load types FTL/LTL. Suite backend en verde (109).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:25:20 -06:00
Ernesto Herrera
9c46f5bf3c feat(crm): ajustes de la sesión doc 2 (catálogos, bugs, oportunidades, facturación, UI)
Catálogos y selects:
- Incoterm como catálogo (nuevo catálogo global 'incoterm') en solicitud y modal
  de conversión de oportunidad.
- Moneda como catálogo en Cotizaciones (nuevo/detalle) y Facturación.
- "Tipo de transporte" desde catálogo medio_transporte (antes lista fija).
- Cotizador: origen/destino como selects alineados a las rutas de los tarifarios
  activos (endpoint /rate-locations), para que el costeo siempre encuentre ruta.

Bugs de la sesión:
- Direcciones no guardaban: DTO country String(2)→String(3) (ISO alfa-3); se
  amplía accounts.country y se normaliza 'MX'→'MEX' (migración).
- Contacto de proveedor mal filtrado: contacts.ts ahora envía supplier_id.
- Selects ilegibles en modo oscuro: regla global select option en app.css.
- Formas de pago SAT a 2 dígitos (01/04/08) en catálogo y valores guardados.
- RelatedManager: editar direcciones/contactos/documentos (antes solo eliminar).

Oportunidades:
- Se quitan etapas Prospecto/Contactado del embudo semilla.
- Fechas separadas won_date/lost_date + motivo de pérdida, con modal al mover a
  Ganada/Perdida (migración).

Facturación:
- Folio automático F{AAAA}-{MM}-{NNN} (next_folio entidad F, sin dirección).
- Moneda como catálogo.

UI:
- Giro "otro" habilita campo para especificar (accounts.industry_other, migración).
- Lista de contactos muestra a quién pertenece (cliente/prospecto/proveedor).
- Proveedores: países/puertos/aeropuertos/aduanas por catálogo (select + chips).

Migraciones reversibles (c2d3e4f5a6b7 ya existía; d3e4f5a6b7c8, e4f5a6b7c8d9).
Suite backend en verde (109). svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 08:07:58 -06:00
Ernesto Herrera
f1e6fba75d feat(crm): cotización aérea con peso/volumen (P/Vol) operacional
- Modalidad AÉREO (4ª opción en "¿Cómo desea cotizar?"): en la solicitud
  muestra la sección aérea con el cálculo en vivo P/Vol = (L×A×H cm × bultos)/6000
  y el peso a cobrar = max(peso bruto, P/Vol). Fija el transporte en aéreo.
- Utilidad compartida crm/common/pricing.py (air_volumetric_kg / air_chargeable_kg,
  factor internacional 6000).
- Motor de costeo (rates): la rama aérea usa el P/Vol por dimensiones si vienen
  (CostRequest ahora acepta length/width/height_cm); respaldo m³×167 cuando no.
- Cotizador: captura por dimensiones (L×A×H + bultos) en modo aéreo y muestra el P/Vol.
- Solicitud→Cotización: si es AÉREO, siembra el concepto de flete con cantidad =
  peso a cobrar (P/Vol) para capturar la tarifa por kg.
- Pruebas: test_pricing (ejemplo del doc → 720; max bruto/volumétrico) + cotización
  aérea desde solicitud. Suite en verde (108).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 07:30:25 -06:00
Ernesto Herrera
36e98ee976 feat(crm): costo estimado por servicio adicional, folios visibles y sidebar por flujo
- Solicitud: al marcar un servicio adicional se habilita su costo estimado
  (columna JSON additional_service_costs). Al cotizar, cada servicio marcado se
  siembra como concepto de la cotización con ese costo de partida (costo=venta).
- Folios visibles: se muestran en la tarjeta de Oportunidad del kanban y se aclara
  en el formulario que el folio se asigna al guardar (las listas ya lo mostraban).
- Sidebar CRM reordenado por flujo comercial (captación → embudo → solicitud →
  cotización → catálogos de apoyo).
- Migración c2d3e4f5a6b7 aditiva y reversible. Suite backend en verde (102).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 07:00:42 -06:00
Ernesto Herrera
915bdd19fe feat(crm): ampliar solicitud de servicio y encadenar el ciclo comercial
Solicitud de servicio:
- Campos del documento maestro de cotización (ruta estructurada por país,
  mercancía, dimensiones/bultos, FCL/LCL, servicios adicionales, notas).
- Origen/Destino seleccionables por catálogo de país (seed ya poblado).
- Validación de contacto asociado (422 si no existe).

Ciclo Oportunidad -> Solicitud -> Cotización -> Operación:
- Dirección impo/expo se captura en la Oportunidad y se hereda al ciclo.
- Conversión Oportunidad->Solicitud idempotente con back-link.
- Endpoint Solicitud->Cotización; "Ambas" genera 2 cotizaciones (FCL/LCL).
- Liberación a Operaciones confirma IMPO/EXPO (prefijado) y siembra los hitos.
- Fecha de la cotización (issue_date) por defecto hoy, editable y en el PDF.

Folios auto-generados {LETRA}{AAAA}-{MM}-{NNN}-{DIR} para Oportunidad (O),
Solicitud (S), Cotización (C) y Operación (OP); consecutivo mensual por
compañía y entidad (crm.folio_counters + helper next_folio con bloqueo de fila).

Catálogos: 9 nuevos (tipo_operacion, medio_transporte, tipo_servicio, prioridad,
tipo_mercancia, unidad_medida, tipo_embalaje, servicio_adicional, tipo_documento).

Migración b1c2d3e4f5a6 reversible (upgrade->downgrade->upgrade verificado en PG).
25 pruebas unitarias nuevas (folios, catálogos, solicitudes, cotizaciones,
embarques); suite completa en verde (101 pruebas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 17:51:40 -06:00
175 changed files with 17779 additions and 246 deletions

View File

@@ -112,3 +112,27 @@ SYNC_SECRET_TOKEN=change-this-sync-token-in-production
# Lista de spokes (Solo si es HUB y desea retransmitir a otros - Opcional)
SPOKE_URLS=""
# ==================================
# PAC COMERCIO DIGITAL (timbrado CFDI)
# ==================================
# El modo de timbrado se decide POR FACTURA, en fin.invoices.stamping_mode.
# Esta variable solo fija con qué valor nacen las facturas que no lo especifican.
# pruebas -> pruebas.comercio-digital.mx | produccion -> ws.comercio-digital.mx
# Una factura en "produccion" emite un CFDI con validez fiscal real ante el SAT.
PAC_DEFAULT_MODE=pruebas
# Credenciales del web service (headers usrws / pwdws). NO commitear valores reales:
# van en .env, que está en .gitignore.
PAC_USER=
PAC_PASSWORD=
# Opcional: correo al que el PAC notifica el comprobante (header email).
PAC_NOTIFICATION_EMAIL=
# Clave maestra que cifra las contraseñas de los CSD en la base de datos. OBLIGATORIA para
# poder cargar certificados desde Configuración de Facturación. Generarla con:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Si se cambia, las contraseñas ya guardadas dejan de poder descifrarse y hay que recargar
# los certificados.
CSD_ENCRYPTION_KEY=
# LEGADO: contraseña global del CSD. Sólo se usa como respaldo si una empresa no tiene la
# suya guardada. Lo correcto es cargar el CSD por empresa desde la interfaz.
CSD_PASSWORD=

5
.gitignore vendored
View File

@@ -84,3 +84,8 @@ celerybeat-schedule.*
celerybeat.pid
backend/api/v1/modules/reports/generated/
docker-compose.override.yml
SUNRISE/
# Corredor de la corrida autonoma continua: vive local, no se versiona.
automatizacion/
docker-compose.dev.yml

View File

@@ -0,0 +1,158 @@
"""Campos del documento maestro de cotización en la solicitud + folios del ciclo comercial
Revision ID: b1c2d3e4f5a6
Revises: a0b1c2d3e4f5
Create Date: 2026-08-03 00:00:00.000000
Amplía crm.service_requests con los campos que exige el documento maestro de
cotización, agrega los back-links y la dirección impo/expo del ciclo
Oportunidad→Solicitud→Cotización→Operación, y crea crm.folio_counters para los
folios auto-generados ({LETRA}{AAAA}-{MM}-{NNN}-{DIR}).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b1c2d3e4f5a6"
down_revision: Union[str, None] = "a0b1c2d3e4f5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
# Columnas nuevas de crm.service_requests (nombre, tipo, kwargs).
_SR_COLUMNS = [
("contact_id", sa.Integer(), {}),
("request_date", sa.Date(), {}),
("currency", sa.String(length=3), {}),
("priority", sa.String(length=20), {}),
("origin_country", sa.String(length=3), {}),
("origin_city", sa.String(length=120), {}),
("origin_port", sa.String(length=20), {}),
("destination_country", sa.String(length=3), {}),
("destination_city", sa.String(length=120), {}),
("destination_port", sa.String(length=20), {}),
("pickup_location", sa.String(length=255), {}),
("delivery_location", sa.String(length=255), {}),
("estimated_shipment_date", sa.Date(), {}),
("cargo_value", sa.Numeric(14, 2), {}),
("insurance_required", sa.Boolean(), {"server_default": sa.text("false")}),
("hs_code", sa.String(length=20), {}),
("goods_origin_country", sa.String(length=3), {}),
("hazardous_imo", sa.Boolean(), {"server_default": sa.text("false")}),
("refrigerated", sa.Boolean(), {"server_default": sa.text("false")}),
("stackable", sa.Boolean(), {"server_default": sa.text("false")}),
("pieces_count", sa.Integer(), {}),
("boxes_count", sa.Integer(), {}),
("pallets_count", sa.Integer(), {}),
("net_weight", sa.Numeric(14, 3), {}),
("length_cm", sa.Numeric(10, 2), {}),
("width_cm", sa.Numeric(10, 2), {}),
("height_cm", sa.Numeric(10, 2), {}),
("measurement_unit", sa.String(length=20), {}),
("container_count", sa.Integer(), {}),
("packaging_type", sa.String(length=20), {}),
("oversized", sa.Boolean(), {"server_default": sa.text("false")}),
("weight_per_pallet", sa.Numeric(14, 3), {}),
("volume_per_pallet", sa.Numeric(14, 3), {}),
("additional_services", sa.JSON(), {}),
("payment_method", sa.String(length=20), {}),
("client_notes", sa.Text(), {}),
("internal_notes", sa.Text(), {}),
]
def upgrade() -> None:
# ----- crm.service_requests: campos del documento maestro de cotización -----
for name, col_type, kwargs in _SR_COLUMNS:
nullable = "server_default" not in kwargs # los boolean quedan NOT NULL con default false
op.add_column(
"service_requests",
sa.Column(name, col_type, nullable=nullable, **kwargs),
schema=SCHEMA,
)
op.create_foreign_key(
"fk_crm_service_requests_contact_id", "service_requests", "contacts",
["contact_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA,
)
op.create_index(
"ix_crm_service_requests_contact_id", "service_requests", ["contact_id"], schema=SCHEMA
)
# ----- crm.documents: adjuntos de una solicitud -----
op.add_column(
"documents", sa.Column("service_request_id", sa.Integer(), nullable=True), schema=SCHEMA
)
op.create_foreign_key(
"fk_crm_documents_service_request_id", "documents", "service_requests",
["service_request_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA,
)
op.create_index(
"ix_crm_documents_service_request_id", "documents", ["service_request_id"], schema=SCHEMA
)
# ----- crm.opportunities: dirección impo/expo + folio + back-link a la solicitud -----
op.add_column("opportunities", sa.Column("operation_type", sa.String(length=20), nullable=True), schema=SCHEMA)
op.add_column("opportunities", sa.Column("reference", sa.String(length=40), nullable=True), schema=SCHEMA)
op.add_column(
"opportunities",
sa.Column("converted_service_request_id", sa.Integer(), nullable=True),
schema=SCHEMA,
)
op.create_foreign_key(
"fk_crm_opportunities_converted_sr", "opportunities", "service_requests",
["converted_service_request_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA,
)
op.create_index(
"ix_crm_opportunities_reference", "opportunities", ["reference"], schema=SCHEMA
)
# ----- crm.quotes: variante FCL/LCL para la comparación "Ambas" -----
op.add_column("quotes", sa.Column("load_type", sa.String(length=10), nullable=True), schema=SCHEMA)
# ----- crm.folio_counters: consecutivo mensual por compañía y entidad -----
op.create_table(
"folio_counters",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("entity", sa.String(length=4), nullable=False),
sa.Column("period", sa.String(length=7), nullable=False),
sa.Column("last_number", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.UniqueConstraint(
"tenant_id", "company_id", "entity", "period", name="uq_crm_folio_counters_scope"
),
schema=SCHEMA,
)
op.create_index("ix_crm_folio_counters_id", "folio_counters", ["id"], schema=SCHEMA)
op.create_index("ix_crm_folio_counters_tenant_id", "folio_counters", ["tenant_id"], schema=SCHEMA)
op.create_index("ix_crm_folio_counters_company_id", "folio_counters", ["company_id"], schema=SCHEMA)
def downgrade() -> None:
op.drop_index("ix_crm_folio_counters_company_id", table_name="folio_counters", schema=SCHEMA)
op.drop_index("ix_crm_folio_counters_tenant_id", table_name="folio_counters", schema=SCHEMA)
op.drop_index("ix_crm_folio_counters_id", table_name="folio_counters", schema=SCHEMA)
op.drop_table("folio_counters", schema=SCHEMA)
op.drop_column("quotes", "load_type", schema=SCHEMA)
op.drop_index("ix_crm_opportunities_reference", table_name="opportunities", schema=SCHEMA)
op.drop_constraint("fk_crm_opportunities_converted_sr", "opportunities", schema=SCHEMA, type_="foreignkey")
op.drop_column("opportunities", "converted_service_request_id", schema=SCHEMA)
op.drop_column("opportunities", "reference", schema=SCHEMA)
op.drop_column("opportunities", "operation_type", schema=SCHEMA)
op.drop_index("ix_crm_documents_service_request_id", table_name="documents", schema=SCHEMA)
op.drop_constraint("fk_crm_documents_service_request_id", "documents", schema=SCHEMA, type_="foreignkey")
op.drop_column("documents", "service_request_id", schema=SCHEMA)
op.drop_index("ix_crm_service_requests_contact_id", table_name="service_requests", schema=SCHEMA)
op.drop_constraint("fk_crm_service_requests_contact_id", "service_requests", schema=SCHEMA, type_="foreignkey")
for name, _col_type, _kwargs in reversed(_SR_COLUMNS):
op.drop_column("service_requests", name, schema=SCHEMA)

View File

@@ -0,0 +1,33 @@
"""Costo estimado por servicio adicional en la solicitud de servicio
Revision ID: c2d3e4f5a6b7
Revises: b1c2d3e4f5a6
Create Date: 2026-08-04 00:00:00.000000
Agrega crm.service_requests.additional_service_costs (JSON: {codigo_servicio: costo})
para capturar el costo estimado de cada servicio adicional marcado; ese costo se
usa como punto de partida al sembrar los conceptos de la cotización.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "c2d3e4f5a6b7"
down_revision: Union[str, None] = "b1c2d3e4f5a6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column(
"service_requests",
sa.Column("additional_service_costs", sa.JSON(), nullable=True),
schema=SCHEMA,
)
def downgrade() -> None:
op.drop_column("service_requests", "additional_service_costs", schema=SCHEMA)

View File

@@ -0,0 +1,180 @@
"""Carril CRM -> EFC: columnas efc_* en crm.cases + outbox transaccional (dos tablas).
El expediente NO se crea aquí: ya existe como ``crm.cases`` (ver d4e5f6a7b8c9), con su folio
en ``reference`` y su consecutivo en ``crm.folio_counters``. Esta migración solo le cuelga lo
que el carril hacia EFC necesita y crea el outbox. Es aditiva a propósito: la estructura del
expediente es de quien la definió, nosotros aportamos la conexión.
Las seis columnas ``efc_*`` son un ESPEJO de lo que hay en EFC, nunca el handle. El handle con
el que el CRM habla de un expediente es su ``id`` y su ``reference``: ``efc_pedimento_id`` es
un caché de la resolución y el ``pedimento_app`` del lado de EFC es mutable —se reescribe al
completar el provisional—, así que apoyarse en él rompería en cuanto llegue la data real.
``efc_storage_token`` es INMUTABLE una vez asignado: es la carpeta de MinIO donde EFC guarda
los objetos de este expediente. Que no cambie nunca es lo que permite completar el pedimento
sin mover un solo archivo.
``efc_link_state`` nace en 'PENDING' para las filas que ya existen. Eso es deliberado: al
encender el carril, el barrido de respaldo replica el histórico. Mientras ``EFC_API_URL`` esté
vacía el carril está apagado y no se encola nada.
Revision ID: c5d6e7f8a9b0
Revises: d4e5f6a7b8c9
Create Date: 2026-08-10 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "c5d6e7f8a9b0"
down_revision: Union[str, None] = "d4e5f6a7b8c9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ---------- crm.cases: el espejo de EFC ----------
op.add_column("cases", sa.Column("efc_organizacion_id", sa.String(length=36), nullable=True), schema="crm")
op.add_column("cases", sa.Column("efc_pedimento_id", sa.String(length=36), nullable=True), schema="crm")
op.add_column("cases", sa.Column("efc_storage_token", sa.String(length=25), nullable=True), schema="crm")
op.add_column(
"cases",
# PENDING | LINKED | FAILED
sa.Column("efc_link_state", sa.String(length=20), nullable=False, server_default=sa.text("'PENDING'")),
schema="crm",
)
# El diagnóstico se guarda en la fila para que se vea en la ficha del expediente, sin
# obligar a nadie a ir a los logs del worker.
op.add_column("cases", sa.Column("efc_error_code", sa.String(length=60), nullable=True), schema="crm")
op.add_column("cases", sa.Column("efc_error_detail", sa.Text(), nullable=True), schema="crm")
op.create_index("ix_crm_cases_efc_link_state", "cases", ["efc_link_state"], schema="crm")
# Relleno del token para los expedientes que ya existían. Sin esto, el barrido de
# reconciliación los encola, EFC los rechaza con
# {'storage_token': ['This field may not be null.']} y agotan sus 8 intentos hasta
# quedar en `failed`: ruido permanente por un dato que se podía derivar.
#
# La condición de longitud replica la guarda de storage_token(): en los 25 caracteres de
# `pedimento_app` caben hasta 6 dígitos de company. Lo que no cabe se queda NULL a
# propósito y el encolado lo salta avisando, porque un token recortado apuntaría a la
# carpeta de otro expediente y mezclaría documentos en silencio.
#
# `reference IS NOT NULL` porque el folio es nullable en crm.cases: un expediente sin
# folio no tiene con qué identificarse ante EFC.
op.execute(
"""
UPDATE crm.cases
SET efc_storage_token = 'CRM-' || company_id::text || '-' || reference
WHERE efc_storage_token IS NULL
AND reference IS NOT NULL
AND length('CRM-' || company_id::text || '-' || reference) <= 25
"""
)
# ---------- crm.efc_sync_outbox: metadatos (alta del provisional y completado) ----------
op.create_table(
"efc_sync_outbox",
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
sa.Column("kind", sa.String(length=20), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("expediente_ref", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=10), nullable=False, server_default=sa.text("'pending'")),
sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("sent_at", sa.DateTime(), nullable=True),
sa.Column("efc_pedimento_id", sa.String(length=36), nullable=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
schema="crm",
)
op.create_index("ix_crm_efc_sync_outbox_status", "efc_sync_outbox", ["status"], schema="crm")
op.create_index("ix_crm_efc_sync_outbox_kind_status", "efc_sync_outbox", ["kind", "status"], schema="crm")
op.create_index("ix_crm_efc_sync_outbox_expediente_ref", "efc_sync_outbox", ["expediente_ref"], schema="crm")
op.create_index("ix_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", ["tenant_id"], schema="crm")
op.create_index("ix_crm_efc_sync_outbox_company_id", "efc_sync_outbox", ["company_id"], schema="crm")
op.create_foreign_key(
"fk_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", "tenants",
["tenant_id"], ["id"], source_schema="crm", referent_schema="core",
)
# ---------- crm.efc_file_outbox: archivos ----------
op.create_table(
"efc_file_outbox",
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
sa.Column("kind", sa.String(length=30), nullable=False),
sa.Column("s3_key", sa.String(length=1024), nullable=False),
sa.Column("file_name", sa.String(length=255), nullable=False),
sa.Column("content_type", sa.String(length=100), nullable=True),
sa.Column("efc_tipo", sa.String(length=40), nullable=False),
# La pareja (tabla, id) desambigua entre las DOS secuencias de documentos del CRM:
# crm.documents.id = 5 y ops.shipment_documents.id = 5 coexisten.
sa.Column("source_table", sa.String(length=30), nullable=False),
sa.Column("source_id", sa.Integer(), nullable=True),
sa.Column("crm_document_ref", sa.String(length=64), nullable=True),
sa.Column("expediente_ref", sa.Integer(), nullable=False),
sa.Column("delete_local", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("status", sa.String(length=10), nullable=False, server_default=sa.text("'pending'")),
sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("sent_at", sa.DateTime(), nullable=True),
sa.Column("efc_document_id", sa.String(length=36), nullable=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
schema="crm",
)
op.create_index("ix_crm_efc_file_outbox_status", "efc_file_outbox", ["status"], schema="crm")
op.create_index("ix_crm_efc_file_outbox_kind_status", "efc_file_outbox", ["kind", "status"], schema="crm")
# Índice de la guarda _ya_entregado, que es lo que se consulta en cada encolado.
op.create_index("ix_crm_efc_file_outbox_source", "efc_file_outbox", ["source_table", "source_id"], schema="crm")
op.create_index("ix_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", ["expediente_ref"], schema="crm")
op.create_index("ix_crm_efc_file_outbox_tenant_id", "efc_file_outbox", ["tenant_id"], schema="crm")
op.create_index("ix_crm_efc_file_outbox_company_id", "efc_file_outbox", ["company_id"], schema="crm")
op.create_foreign_key(
"fk_crm_efc_file_outbox_tenant_id", "efc_file_outbox", "tenants",
["tenant_id"], ["id"], source_schema="crm", referent_schema="core",
)
# expediente_ref -> crm.cases.id. El nombre de la columna conserva el término del dominio:
# crm.cases ES el expediente (así lo nombra su propio docstring), y el carril, EFC y el
# ticket hablan de expedientes. Renombrarlo a case_ref solo movería la ambigüedad de sitio.
op.create_foreign_key(
"fk_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", "cases",
["expediente_ref"], ["id"], source_schema="crm", referent_schema="crm",
)
def downgrade() -> None:
op.drop_constraint("fk_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", schema="crm", type_="foreignkey")
op.drop_constraint("fk_crm_efc_file_outbox_tenant_id", "efc_file_outbox", schema="crm", type_="foreignkey")
op.drop_index("ix_crm_efc_file_outbox_company_id", table_name="efc_file_outbox", schema="crm")
op.drop_index("ix_crm_efc_file_outbox_tenant_id", table_name="efc_file_outbox", schema="crm")
op.drop_index("ix_crm_efc_file_outbox_expediente_ref", table_name="efc_file_outbox", schema="crm")
op.drop_index("ix_crm_efc_file_outbox_source", table_name="efc_file_outbox", schema="crm")
op.drop_index("ix_crm_efc_file_outbox_kind_status", table_name="efc_file_outbox", schema="crm")
op.drop_index("ix_crm_efc_file_outbox_status", table_name="efc_file_outbox", schema="crm")
op.drop_table("efc_file_outbox", schema="crm")
op.drop_constraint("fk_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", schema="crm", type_="foreignkey")
op.drop_index("ix_crm_efc_sync_outbox_company_id", table_name="efc_sync_outbox", schema="crm")
op.drop_index("ix_crm_efc_sync_outbox_tenant_id", table_name="efc_sync_outbox", schema="crm")
op.drop_index("ix_crm_efc_sync_outbox_expediente_ref", table_name="efc_sync_outbox", schema="crm")
op.drop_index("ix_crm_efc_sync_outbox_kind_status", table_name="efc_sync_outbox", schema="crm")
op.drop_index("ix_crm_efc_sync_outbox_status", table_name="efc_sync_outbox", schema="crm")
op.drop_table("efc_sync_outbox", schema="crm")
op.drop_index("ix_crm_cases_efc_link_state", table_name="cases", schema="crm")
op.drop_column("cases", "efc_error_detail", schema="crm")
op.drop_column("cases", "efc_error_code", schema="crm")
op.drop_column("cases", "efc_link_state", schema="crm")
op.drop_column("cases", "efc_storage_token", schema="crm")
op.drop_column("cases", "efc_pedimento_id", schema="crm")
op.drop_column("cases", "efc_organizacion_id", schema="crm")

View File

@@ -0,0 +1,56 @@
"""Ajustes de sesión: país ISO-3 en accounts, giro "otro" y formas de pago SAT a 2 dígitos
Revision ID: d3e4f5a6b7c8
Revises: c2d3e4f5a6b7
Create Date: 2026-08-04 01:00:00.000000
- crm.accounts.country String(2)→String(3) (ISO alfa-3, alineado a catálogo pais).
- crm.accounts.industry_other (especificar cuando el giro es "otro").
- Normaliza formas de pago SAT de 1 dígito a 2 (01, 02, …) en el catálogo y en
los valores guardados en accounts/suppliers; y país 'MX''MEX'.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "d3e4f5a6b7c8"
down_revision: Union[str, None] = "c2d3e4f5a6b7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
# País a ISO alfa-3 en accounts (addresses ya es String(3)).
# Primero se amplía la columna; luego se normaliza el dato (evita truncamiento).
op.alter_column(
"accounts", "country", schema=SCHEMA,
existing_type=sa.String(length=2), type_=sa.String(length=3),
existing_nullable=True, server_default=sa.text("'MEX'"),
)
op.execute("UPDATE crm.accounts SET country = 'MEX' WHERE country = 'MX'")
op.execute("UPDATE crm.addresses SET country = 'MEX' WHERE country = 'MX'")
# Giro "otro" — campo para especificar
op.add_column("accounts", sa.Column("industry_other", sa.String(length=120), nullable=True), schema=SCHEMA)
# Formas de pago SAT: 1 dígito → 2 dígitos (catálogo + valores guardados)
op.execute(
"UPDATE crm.catalog_items SET code = lpad(code, 2, '0') "
"WHERE catalog = 'forma_pago' AND char_length(code) = 1"
)
op.execute("UPDATE crm.accounts SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1")
op.execute("UPDATE crm.suppliers SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1")
def downgrade() -> None:
op.drop_column("accounts", "industry_other", schema=SCHEMA)
# Regresar país a String(2) sin truncar filas existentes
op.execute("UPDATE crm.accounts SET country = 'MX' WHERE country = 'MEX'")
op.alter_column(
"accounts", "country", schema=SCHEMA,
existing_type=sa.String(length=3), type_=sa.String(length=2),
existing_nullable=True, server_default=sa.text("'MX'"),
)
# La normalización de formas de pago no se revierte (evita romper códigos multi-dígito).

View File

@@ -0,0 +1,75 @@
"""Expediente (crm.cases) + case_id en el ciclo comercial
Revision ID: d4e5f6a7b8c9
Revises: f0a1b2c3d4e5
Create Date: 2026-08-07 02:00:00.000000
Crea crm.cases (expediente, hilo maestro con folio EXP...) y agrega case_id a
crm.opportunities/service_requests/quotes, ops.shipments y fin.invoices.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "d4e5f6a7b8c9"
down_revision: Union[str, None] = "f0a1b2c3d4e5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# (schema, tabla) donde se agrega case_id
_CASE_FK_TABLES = [
("crm", "opportunities"),
("crm", "service_requests"),
("crm", "quotes"),
("ops", "shipments"),
("fin", "invoices"),
]
def upgrade() -> None:
op.create_table(
"cases",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reference", sa.String(length=40), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("title", sa.String(length=255), nullable=True),
sa.Column("stage", sa.String(length=20), nullable=False, server_default=sa.text("'oportunidad'")),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'abierto'")),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
schema="crm",
)
op.create_index("ix_crm_cases_id", "cases", ["id"], schema="crm")
op.create_index("ix_crm_cases_reference", "cases", ["reference"], schema="crm")
op.create_index("ix_crm_cases_tenant_id", "cases", ["tenant_id"], schema="crm")
op.create_index("ix_crm_cases_company_id", "cases", ["company_id"], schema="crm")
op.create_index("ix_crm_cases_account_id", "cases", ["account_id"], schema="crm")
op.create_index("ix_crm_cases_status", "cases", ["status"], schema="crm")
for schema, table in _CASE_FK_TABLES:
op.add_column(table, sa.Column("case_id", sa.Integer(), nullable=True), schema=schema)
op.create_index(f"ix_{schema}_{table}_case_id", table, ["case_id"], schema=schema)
op.create_foreign_key(
f"fk_{schema}_{table}_case_id", table, "cases",
["case_id"], ["id"], source_schema=schema, referent_schema="crm",
)
def downgrade() -> None:
for schema, table in _CASE_FK_TABLES:
op.drop_constraint(f"fk_{schema}_{table}_case_id", table, schema=schema, type_="foreignkey")
op.drop_index(f"ix_{schema}_{table}_case_id", table_name=table, schema=schema)
op.drop_column(table, "case_id", schema=schema)
for idx in ("status", "account_id", "company_id", "tenant_id", "reference", "id"):
op.drop_index(f"ix_crm_cases_{idx}", table_name="cases", schema="crm")
op.drop_table("cases", schema="crm")

View File

@@ -0,0 +1,30 @@
"""Fechas separadas de ganada/perdida en la oportunidad
Revision ID: e4f5a6b7c8d9
Revises: d3e4f5a6b7c8
Create Date: 2026-08-04 02:00:00.000000
Agrega crm.opportunities.won_date y lost_date (fechas de cierre separadas,
editables) además de closed_at y lost_reason ya existentes.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "e4f5a6b7c8d9"
down_revision: Union[str, None] = "d3e4f5a6b7c8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column("opportunities", sa.Column("won_date", sa.Date(), nullable=True), schema=SCHEMA)
op.add_column("opportunities", sa.Column("lost_date", sa.Date(), nullable=True), schema=SCHEMA)
def downgrade() -> None:
op.drop_column("opportunities", "lost_date", schema=SCHEMA)
op.drop_column("opportunities", "won_date", schema=SCHEMA)

View File

@@ -0,0 +1,25 @@
"""Medio de contacto preferido en el prospecto (lead)
Revision ID: f0a1b2c3d4e5
Revises: e4f5a6b7c8d9
Create Date: 2026-08-07 01:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "f0a1b2c3d4e5"
down_revision: Union[str, None] = "e4f5a6b7c8d9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column("leads", sa.Column("preferred_contact_method", sa.String(length=20), nullable=True), schema=SCHEMA)
def downgrade() -> None:
op.drop_column("leads", "preferred_contact_method", schema=SCHEMA)

View File

@@ -0,0 +1,88 @@
"""Catálogo c_UsoCFDI y claves fiscales del receptor en crm.accounts.
Cierra las decisiones pendientes 1 y 5 del ticket de catálogos SAT: agrega
``sat.cfdi_uses`` y amarra el régimen fiscal y el uso de CFDI de la cuenta a los
catálogos, conservando las columnas de texto libre que ya existían.
Re-encadenada al integrar main: esta rama y la del CRM habían salido las dos de
d5e6f7a8b9c0, y con dos cabezas ``alembic upgrade head`` falla. La historia queda lineal,
con las migraciones de facturación detrás de las del CRM.
Revision ID: f7a8b9c0d1e2
Revises: g1h2i3j4k5l6
Create Date: 2026-08-07 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 = "f7a8b9c0d1e2"
down_revision: Union[str, None] = "g1h2i3j4k5l6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ---------- sat.cfdi_uses ----------
op.create_table(
"cfdi_uses",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("code", sa.String(length=4), nullable=False),
sa.Column("description", sa.String(length=500), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.PrimaryKeyConstraint("id"),
schema="sat",
)
op.create_index("ix_sat_cfdi_uses_id", "cfdi_uses", ["id"], schema="sat")
op.create_index("ix_sat_cfdi_uses_code", "cfdi_uses", ["code"], unique=True, schema="sat")
# sync_catalogs es idempotente: siembra c_UsoCFDI y deja intactos los catálogos
# que ya sembró la migración anterior.
sync_catalogs(op.get_bind())
# ---------- crm.accounts: claves fiscales del receptor ----------
# Nullables: las cuentas existentes solo tienen el texto libre.
op.add_column("accounts", sa.Column("tax_regime_id", sa.Integer(), nullable=True), schema="crm")
op.add_column("accounts", sa.Column("cfdi_use_id", sa.Integer(), nullable=True), schema="crm")
op.create_foreign_key(
"fk_crm_accounts_tax_regime_id", "accounts", "tax_regimes",
["tax_regime_id"], ["id"], source_schema="crm", referent_schema="sat",
)
op.create_foreign_key(
"fk_crm_accounts_cfdi_use_id", "accounts", "cfdi_uses",
["cfdi_use_id"], ["id"], source_schema="crm", referent_schema="sat",
)
# Backfill conservador: solo resuelve lo inequívoco. Se compara el texto libre
# contra la clave del catálogo (p. ej. "601", "G03") y contra la descripción
# exacta, sin distinguir mayúsculas ni espacios sobrantes. Lo que no case así se
# queda en NULL para que lo revise el usuario: adivinar el régimen de un receptor
# a partir de texto libre provoca CFDI rechazados.
for column, catalog in [("tax_regime", "tax_regimes"), ("cfdi_use", "cfdi_uses")]:
op.execute(
f"""
UPDATE crm.accounts AS a
SET {column}_id = c.id
FROM sat.{catalog} AS c
WHERE a.{column}_id IS NULL
AND a.{column} IS NOT NULL
AND (
upper(btrim(a.{column})) = upper(c.code)
OR upper(btrim(a.{column})) = upper(c.description)
)
"""
)
def downgrade() -> None:
op.drop_constraint("fk_crm_accounts_cfdi_use_id", "accounts", schema="crm", type_="foreignkey")
op.drop_constraint("fk_crm_accounts_tax_regime_id", "accounts", schema="crm", type_="foreignkey")
op.drop_column("accounts", "cfdi_use_id", schema="crm")
op.drop_column("accounts", "tax_regime_id", schema="crm")
op.drop_table("cfdi_uses", schema="sat")

View File

@@ -0,0 +1,274 @@
"""Catálogos SAT (schema sat), conceptos de facturación, datos fiscales del emisor
y amarre de facturas y partidas a los catálogos.
Renumerada al integrar main: nació como e6f7a8b9c0d1, el mismo identificador que la
migración de catálogos del CRM, porque ambas ramas salieron de d5e6f7a8b9c0 sin verse. Se
renumera ésta y no la del CRM porque aquélla ya está en main y hay otra migración que la
referencia por id.
Revision ID: g1h2i3j4k5l6
Revises: c5d6e7f8a9b0
Create Date: 2026-08-07 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 = "g1h2i3j4k5l6"
down_revision: Union[str, None] = "c5d6e7f8a9b0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Índices únicos parciales: la baja lógica (deleted_at) libera la clave.
_ALIVE = "deleted_at IS NULL"
# Catálogos del SAT: (tabla, longitud de code, columnas propias del catálogo).
_SAT_CATALOGS: list[tuple[str, int, list[sa.Column]]] = [
("tax_regimes", 3, [
sa.Column("applies_to_individual", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("applies_to_legal_entity", sa.Boolean(), nullable=False, server_default=sa.text("false")),
]),
("taxes", 3, [
sa.Column("is_withholding", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("is_transferred", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("is_local", sa.Boolean(), nullable=False, server_default=sa.text("false")),
]),
("payment_forms", 2, []),
("units_of_measure", 20, [
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("symbol", sa.String(length=20), nullable=True),
]),
("products_services", 8, []),
("voucher_types", 1, []),
("payment_methods", 3, []),
("tax_objects", 2, []),
]
# units_of_measure guarda el nombre corto aparte, así que su description es opcional.
_NULLABLE_DESCRIPTION = {"units_of_measure"}
def _timestamp_columns(with_soft_delete: bool) -> list[sa.Column]:
columns = [
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
]
if with_soft_delete:
columns.append(sa.Column("deleted_at", sa.DateTime(), nullable=True))
return columns
def upgrade() -> None:
# ---------- Schema y catálogos globales del SAT ----------
op.execute("CREATE SCHEMA IF NOT EXISTS sat")
for table, code_length, extra_columns in _SAT_CATALOGS:
op.create_table(
table,
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("code", sa.String(length=code_length), nullable=False),
sa.Column(
"description",
sa.String(length=500),
nullable=table in _NULLABLE_DESCRIPTION,
),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
*extra_columns,
*_timestamp_columns(with_soft_delete=False),
sa.PrimaryKeyConstraint("id"),
schema="sat",
)
op.create_index(f"ix_sat_{table}_id", table, ["id"], schema="sat")
# La clave oficial del SAT es única dentro de su catálogo.
op.create_index(f"ix_sat_{table}_code", table, ["code"], unique=True, schema="sat")
# Semillas de los catálogos (idempotente: puede volver a correrse sin duplicar).
sync_catalogs(op.get_bind())
# ---------- fin.concepts ----------
op.create_table(
"concepts",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("code", sa.String(length=40), nullable=False),
sa.Column("description", sa.String(length=500), nullable=False),
sa.Column("product_service_id", sa.Integer(), nullable=False),
sa.Column("unit_of_measure_id", sa.Integer(), nullable=True),
sa.Column("tax_object_id", sa.Integer(), nullable=True),
sa.Column("unit_price", sa.Numeric(precision=14, scale=2), nullable=True),
sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'MXN'")),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
*_timestamp_columns(with_soft_delete=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_concepts_tenant_id"),
sa.ForeignKeyConstraint(
["product_service_id"], ["sat.products_services.id"], name="fk_fin_concepts_product_service_id"
),
sa.ForeignKeyConstraint(
["unit_of_measure_id"], ["sat.units_of_measure.id"], name="fk_fin_concepts_unit_of_measure_id"
),
sa.ForeignKeyConstraint(
["tax_object_id"], ["sat.tax_objects.id"], name="fk_fin_concepts_tax_object_id"
),
schema="fin",
)
op.create_index("ix_fin_concepts_id", "concepts", ["id"], schema="fin")
op.create_index("ix_fin_concepts_tenant_id", "concepts", ["tenant_id"], schema="fin")
op.create_index("ix_fin_concepts_company_id", "concepts", ["company_id"], schema="fin")
op.create_index("ix_fin_concepts_product_service_id", "concepts", ["product_service_id"], schema="fin")
# La clave interna del concepto es única por empresa.
op.create_index(
"uq_fin_concepts_code", "concepts", ["tenant_id", "company_id", "code"],
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
)
# Relación 1:1 con c_ClaveProdServ: una clave del SAT no puede repetirse entre
# los conceptos vigentes de la misma empresa.
op.create_index(
"uq_fin_concepts_product_service", "concepts", ["tenant_id", "company_id", "product_service_id"],
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
)
# ---------- fin.issuer_settings ----------
op.create_table(
"issuer_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("legal_name", sa.String(length=255), nullable=False),
sa.Column("rfc", sa.String(length=13), nullable=False),
sa.Column("tax_regime_id", sa.Integer(), nullable=False),
sa.Column("zip_code", sa.String(length=5), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
*_timestamp_columns(with_soft_delete=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_issuer_settings_tenant_id"),
sa.ForeignKeyConstraint(
["tax_regime_id"], ["sat.tax_regimes.id"], name="fk_fin_issuer_settings_tax_regime_id"
),
schema="fin",
)
op.create_index("ix_fin_issuer_settings_id", "issuer_settings", ["id"], schema="fin")
op.create_index("ix_fin_issuer_settings_tenant_id", "issuer_settings", ["tenant_id"], schema="fin")
op.create_index("ix_fin_issuer_settings_company_id", "issuer_settings", ["company_id"], schema="fin")
op.create_index("ix_fin_issuer_settings_tax_regime_id", "issuer_settings", ["tax_regime_id"], schema="fin")
# Una sola configuración fiscal vigente por empresa.
op.create_index(
"uq_fin_issuer_settings_company", "issuer_settings", ["tenant_id", "company_id"],
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
)
# ---------- fin.invoice_item_taxes ----------
op.create_table(
"invoice_item_taxes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("invoice_item_id", sa.Integer(), nullable=False),
sa.Column("tax_id", sa.Integer(), nullable=False),
sa.Column("is_withholding", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("rate", sa.Numeric(precision=8, scale=6), nullable=True),
sa.Column("amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
*_timestamp_columns(with_soft_delete=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_invoice_item_taxes_tenant_id"),
sa.ForeignKeyConstraint(
["invoice_item_id"], ["fin.invoice_items.id"], name="fk_fin_invoice_item_taxes_invoice_item_id"
),
sa.ForeignKeyConstraint(["tax_id"], ["sat.taxes.id"], name="fk_fin_invoice_item_taxes_tax_id"),
schema="fin",
)
op.create_index("ix_fin_invoice_item_taxes_id", "invoice_item_taxes", ["id"], schema="fin")
op.create_index("ix_fin_invoice_item_taxes_tenant_id", "invoice_item_taxes", ["tenant_id"], schema="fin")
op.create_index("ix_fin_invoice_item_taxes_company_id", "invoice_item_taxes", ["company_id"], schema="fin")
op.create_index(
"ix_fin_invoice_item_taxes_invoice_item_id", "invoice_item_taxes", ["invoice_item_id"], schema="fin"
)
# Un mismo impuesto no puede declararse dos veces con el mismo rol en la partida.
op.create_index(
"uq_fin_invoice_item_taxes", "invoice_item_taxes", ["invoice_item_id", "tax_id", "is_withholding"],
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
)
# ---------- fin.invoices: claves fiscales del comprobante ----------
# Todas nullable: las facturas ya emitidas no tienen estos datos.
op.add_column("invoices", sa.Column("voucher_type_id", sa.Integer(), nullable=True), schema="fin")
op.add_column("invoices", sa.Column("payment_form_id", sa.Integer(), nullable=True), schema="fin")
op.add_column("invoices", sa.Column("payment_method_id", sa.Integer(), nullable=True), schema="fin")
op.add_column("invoices", sa.Column("expedition_zip_code", sa.String(length=5), nullable=True), schema="fin")
op.create_foreign_key(
"fk_fin_invoices_voucher_type_id", "invoices", "voucher_types",
["voucher_type_id"], ["id"], source_schema="fin", referent_schema="sat",
)
op.create_foreign_key(
"fk_fin_invoices_payment_form_id", "invoices", "payment_forms",
["payment_form_id"], ["id"], source_schema="fin", referent_schema="sat",
)
op.create_foreign_key(
"fk_fin_invoices_payment_method_id", "invoices", "payment_methods",
["payment_method_id"], ["id"], source_schema="fin", referent_schema="sat",
)
# ---------- fin.invoice_items: claves fiscales de la partida ----------
# La columna de texto libre `concept` se conserva intacta y obligatoria: la usa el
# PDF actual de la factura.
op.add_column("invoice_items", sa.Column("concept_id", sa.Integer(), nullable=True), schema="fin")
op.add_column("invoice_items", sa.Column("product_service_id", sa.Integer(), nullable=True), schema="fin")
op.add_column("invoice_items", sa.Column("unit_of_measure_id", sa.Integer(), nullable=True), schema="fin")
op.add_column("invoice_items", sa.Column("tax_object_id", sa.Integer(), nullable=True), schema="fin")
op.create_index("ix_fin_invoice_items_concept_id", "invoice_items", ["concept_id"], schema="fin")
op.create_foreign_key(
"fk_fin_invoice_items_concept_id", "invoice_items", "concepts",
["concept_id"], ["id"], source_schema="fin", referent_schema="fin",
)
op.create_foreign_key(
"fk_fin_invoice_items_product_service_id", "invoice_items", "products_services",
["product_service_id"], ["id"], source_schema="fin", referent_schema="sat",
)
op.create_foreign_key(
"fk_fin_invoice_items_unit_of_measure_id", "invoice_items", "units_of_measure",
["unit_of_measure_id"], ["id"], source_schema="fin", referent_schema="sat",
)
op.create_foreign_key(
"fk_fin_invoice_items_tax_object_id", "invoice_items", "tax_objects",
["tax_object_id"], ["id"], source_schema="fin", referent_schema="sat",
)
def downgrade() -> None:
# fin.invoice_items
for constraint in (
"fk_fin_invoice_items_tax_object_id",
"fk_fin_invoice_items_unit_of_measure_id",
"fk_fin_invoice_items_product_service_id",
"fk_fin_invoice_items_concept_id",
):
op.drop_constraint(constraint, "invoice_items", schema="fin", type_="foreignkey")
op.drop_index("ix_fin_invoice_items_concept_id", table_name="invoice_items", schema="fin")
for column in ("tax_object_id", "unit_of_measure_id", "product_service_id", "concept_id"):
op.drop_column("invoice_items", column, schema="fin")
# fin.invoices
for constraint in (
"fk_fin_invoices_payment_method_id",
"fk_fin_invoices_payment_form_id",
"fk_fin_invoices_voucher_type_id",
):
op.drop_constraint(constraint, "invoices", schema="fin", type_="foreignkey")
for column in ("expedition_zip_code", "payment_method_id", "payment_form_id", "voucher_type_id"):
op.drop_column("invoices", column, schema="fin")
# Tablas nuevas (los índices caen con la tabla).
op.drop_table("invoice_item_taxes", schema="fin")
op.drop_table("issuer_settings", schema="fin")
op.drop_table("concepts", schema="fin")
# Catálogos del SAT: se va el schema completo.
op.execute("DROP SCHEMA IF EXISTS sat CASCADE")

View File

@@ -0,0 +1,91 @@
"""Timbrado de CFDI: ``fin.invoice_stamps`` y ``fin.invoices.stamping_mode``.
Escrita a mano y no con ``--autogenerate``: el autogenerate de este proyecto arrastra
drift preexistente entre los modelos y la base (llaves foráneas de ``core``, cambios de
tipo en ``invite_tokens``), y generaba 1,516 operaciones ajenas a este ticket. Aquí van
sólo los dos cambios del timbrado.
Revision ID: h3i4j5k6l7m8
Revises: f7a8b9c0d1e2
Create Date: 2026-08-07 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "h3i4j5k6l7m8"
down_revision: Union[str, None] = "f7a8b9c0d1e2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ---------- fin.invoices: modo de timbrado por factura ----------
# NOT NULL con server_default: las facturas existentes quedan en 'pruebas', que es el
# valor seguro. Marcar como 'produccion' es siempre una decisión explícita.
op.add_column(
"invoices",
sa.Column(
"stamping_mode",
sa.String(length=12),
nullable=False,
server_default=sa.text("'pruebas'"),
),
schema="fin",
)
# ---------- fin.invoice_stamps ----------
op.create_table(
"invoice_stamps",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("invoice_id", sa.Integer(), nullable=False),
sa.Column("mode", sa.String(length=12), nullable=False),
sa.Column("status", sa.String(length=12), nullable=False, server_default=sa.text("'pendiente'")),
# Timbre Fiscal Digital
sa.Column("uuid", sa.String(length=36), nullable=True),
sa.Column("stamped_at", sa.DateTime(), nullable=True),
sa.Column("pac_rfc", sa.String(length=13), nullable=True),
sa.Column("sat_cert_number", sa.String(length=20), nullable=True),
sa.Column("sat_seal", sa.Text(), nullable=True),
sa.Column("cfd_seal", sa.Text(), nullable=True),
# Respuesta del PAC
sa.Column("pac_code", sa.Integer(), nullable=True),
sa.Column("pac_balance", sa.Integer(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("xml_file_key", sa.String(length=512), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["invoice_id"], ["fin.invoices.id"]),
sa.PrimaryKeyConstraint("id"),
schema="fin",
)
op.create_index("ix_fin_invoice_stamps_id", "invoice_stamps", ["id"], schema="fin")
op.create_index("ix_fin_invoice_stamps_invoice_id", "invoice_stamps", ["invoice_id"], schema="fin")
op.create_index("ix_fin_invoice_stamps_status", "invoice_stamps", ["status"], schema="fin")
op.create_index("ix_fin_invoice_stamps_uuid", "invoice_stamps", ["uuid"], schema="fin")
# Un UUID lo emite el SAT una sola vez. Parcial sobre uuid IS NOT NULL porque los intentos
# fallidos no traen UUID y colisionarían entre sí bajo un único convencional.
op.create_index(
"uq_fin_invoice_stamps_uuid",
"invoice_stamps",
["uuid"],
unique=True,
schema="fin",
postgresql_where=sa.text("uuid IS NOT NULL AND deleted_at IS NULL"),
)
def downgrade() -> None:
op.drop_index("uq_fin_invoice_stamps_uuid", table_name="invoice_stamps", schema="fin")
op.drop_index("ix_fin_invoice_stamps_uuid", table_name="invoice_stamps", schema="fin")
op.drop_index("ix_fin_invoice_stamps_status", table_name="invoice_stamps", schema="fin")
op.drop_index("ix_fin_invoice_stamps_invoice_id", table_name="invoice_stamps", schema="fin")
op.drop_index("ix_fin_invoice_stamps_id", table_name="invoice_stamps", schema="fin")
op.drop_table("invoice_stamps", schema="fin")
op.drop_column("invoices", "stamping_mode", schema="fin")

View File

@@ -0,0 +1,43 @@
"""CSD por empresa en ``fin.issuer_settings``.
Cierra el hueco de que el certificado de sello digital tuviera que dejarse a mano en el
almacenamiento y de que su contraseña fuera una variable de entorno global: con varias
empresas emisoras eso no funciona, porque cada una tiene su propio certificado.
La contraseña se guarda cifrada (``core.crypto``); la clave maestra vive en el entorno.
Escrita a mano, no con ``--autogenerate``: ver la nota de la migración h3i4j5k6l7m8.
Revision ID: i4j5k6l7m8n9
Revises: h3i4j5k6l7m8
Create Date: 2026-08-10 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "i4j5k6l7m8n9"
down_revision: Union[str, None] = "h3i4j5k6l7m8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_COLUMNAS = (
("csd_cer_file_key", sa.String(length=512)),
("csd_key_file_key", sa.String(length=512)),
("csd_password_enc", sa.Text()),
("csd_cert_number", sa.String(length=20)),
("csd_uploaded_at", sa.DateTime()),
)
def upgrade() -> None:
for nombre, tipo in _COLUMNAS:
op.add_column("issuer_settings", sa.Column(nombre, tipo, nullable=True), schema="fin")
def downgrade() -> None:
# Al revés, para que el orden de la tabla quede como estaba.
for nombre, _ in reversed(_COLUMNAS):
op.drop_column("issuer_settings", nombre, schema="fin")

View File

@@ -0,0 +1,40 @@
"""XML enviado y recibido de cada intento de timbrado, en ``fin.invoice_stamps``.
Hasta ahora sólo se guardaba el XML del timbrado exitoso, que es justo el caso en el que
menos falta hace. Cuando el PAC rechaza el comprobante no queda rastro de qué se le mandó
ni de qué contestó: el XML sellado vive en memoria durante la petición y desaparece con
ella, y el cuerpo de la respuesta también. Estas dos columnas apuntan al par enviado/recibido
que se guarda en el almacenamiento por cada intento.
Escrita a mano, no con ``--autogenerate``: ver la nota de la migración h3i4j5k6l7m8.
Revision ID: j5k6l7m8n9o0
Revises: i4j5k6l7m8n9
Create Date: 2026-08-11 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "j5k6l7m8n9o0"
down_revision: Union[str, None] = "i4j5k6l7m8n9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_COLUMNAS = (
("request_xml_file_key", sa.String(length=512)),
("response_xml_file_key", sa.String(length=512)),
)
def upgrade() -> None:
for nombre, tipo in _COLUMNAS:
op.add_column("invoice_stamps", sa.Column(nombre, tipo, nullable=True), schema="fin")
def downgrade() -> None:
# Al revés, para que el orden de la tabla quede como estaba.
for nombre, _ in reversed(_COLUMNAS):
op.drop_column("invoice_stamps", nombre, schema="fin")

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

@@ -0,0 +1,40 @@
"""Tipo de cambio de la factura (``fin.invoices.exchange_rate``).
La factura hereda la moneda de la ficha del cliente, y una factura en moneda distinta de MXN
**no se puede timbrar** sin tipo de cambio: ``CfdiData.validate`` lo exige y ``_build_data``
pasaba ``exchange_rate=None`` siempre, así que el campo no existía en ninguna parte. Un cliente
con ``currency='USD'`` producía facturas que fallaban al timbrar sin pista del porqué.
Nullable a propósito: en MXN no aplica y el CFDI no lleva ``TipoCambio``. La validación de
"falta el tipo de cambio" la sigue haciendo el builder, que acumula todos los faltantes y los
reporta juntos.
Escala 6: el SAT admite hasta seis decimales en ``TipoCambio``. El builder redondea a cuatro al
escribir el XML, que es una decisión previa suya y no se toca aquí.
Revision ID: l7m8n9o0p1q2
Revises: k6l7m8n9o0p1
Create Date: 2026-08-11 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "l7m8n9o0p1q2"
down_revision: Union[str, None] = "k6l7m8n9o0p1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"invoices",
sa.Column("exchange_rate", sa.Numeric(precision=14, scale=6), nullable=True),
schema="fin",
)
def downgrade() -> None:
op.drop_column("invoices", "exchange_rate", schema="fin")

View File

@@ -0,0 +1,163 @@
"""IVA por partida: ``taxes_per_item``, retenciones en el total, ``factor`` y defaults del concepto.
Hasta aquí el impuesto vivía en dos planos que podían divergir: el dinero salía de
``invoices.tax_rate`` aplicado al subtotal completo, y el CFDI sumaba los impuestos de cada
partida. Con una partida no objeto de impuesto la factura le cobraba IVA igual, y con una
retención capturada el total de la factura y el del comprobante no coincidían.
**No se reescribe ni una fila de las facturas existentes.** El cálculo se versiona con
``taxes_per_item``: las facturas nuevas nacen en ``true`` y usan la suma por partida; todas las
que ya existen quedan en ``false`` y conservan la fórmula con la que se emitieron.
La alternativa —backfillear ``invoice_item_taxes`` desde ``tax_rate``— se descartó por dos
razones. Obligaría a poner ``tax_object_id = '02'`` en partidas que nadie clasificó, que es
inventar una afirmación fiscal. Y ``_recompute`` no corre en la migración sino la próxima vez que
alguien toque la factura: registrar un pago meses después le bajaría el total, dejaría saldo
negativo, la marcaría 'pagada' y pisaría su ``paid_at``, sin que nada explicara por qué. El
rollback aquí es ``UPDATE fin.invoices SET taxes_per_item = false``.
Revision ID: m8n9o0p1q2r3
Revises: l7m8n9o0p1q2
Create Date: 2026-08-11 00:00:00.000000
"""
import logging
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "m8n9o0p1q2r3"
down_revision: Union[str, None] = "l7m8n9o0p1q2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
logger = logging.getLogger("alembic.runtime.migration")
def upgrade() -> None:
# ── fin.invoices ─────────────────────────────────────────────────────────────────────────
# server_default false: TODA factura existente queda con la fórmula vieja. Después se cambia
# el default a true para que las nuevas nazcan con el cálculo por partida.
op.add_column(
"invoices",
sa.Column("taxes_per_item", sa.Boolean(), nullable=False, server_default=sa.text("false")),
schema="fin",
)
op.alter_column("invoices", "taxes_per_item", server_default=sa.text("true"), schema="fin")
# Las retenciones restan del total y hasta ahora no se guardaban en ningún lado: el total no
# cuadraba con subtotal + tax_amount y nada en la fila explicaba el faltante.
op.add_column(
"invoices",
sa.Column("withheld_amount", sa.Numeric(14, 2), nullable=False, server_default=sa.text("0")),
schema="fin",
)
# ── fin.invoice_item_taxes ───────────────────────────────────────────────────────────────
# factor: c_TipoFactor. Sin esta columna un Exento es inexpresable — el builder ya sabe
# omitir TasaOCuota e Importe y excluirlo de los totales, pero nada podía pedírselo.
op.add_column(
"invoice_item_taxes",
sa.Column("factor", sa.String(7), nullable=False, server_default=sa.text("'Tasa'")),
schema="fin",
)
op.create_check_constraint(
"ck_fin_invoice_item_taxes_factor",
"invoice_item_taxes",
"factor IN ('Tasa', 'Cuota', 'Exento')",
schema="fin",
)
# is_manual reemplaza al heurístico que adivinaba la captura manual por la forma de la fila
# (una retención, o un impuesto distinto del IVA). Con IVA al 0% y Exento en el catálogo de
# conceptos ese heurístico deja de discriminar: un traslado de IVA capturado a mano es
# idéntico en forma a uno derivado.
op.add_column(
"invoice_item_taxes",
sa.Column("is_manual", sa.Boolean(), nullable=False, server_default=sa.text("false")),
schema="fin",
)
# ── fin.concepts: configuración fiscal por defecto ───────────────────────────────────────
# La tasa va como FRACCIÓN con 6 decimales (0.160000), igual que invoice_item_taxes.rate y
# que el TasaOCuota del XML — NO como el porcentaje de invoices.tax_rate (16.00). El tipo es
# idéntico al destino a propósito: convertir en el camino es la vía corta a un IVA del 1600%.
op.add_column("concepts", sa.Column("default_tax_id", sa.Integer(), nullable=True), schema="fin")
op.add_column("concepts", sa.Column("default_tax_rate", sa.Numeric(8, 6), nullable=True), schema="fin")
op.add_column("concepts", sa.Column("default_tax_factor", sa.String(7), nullable=True), schema="fin")
op.create_foreign_key(
"fk_fin_concepts_default_tax_id", "concepts", "taxes",
["default_tax_id"], ["id"], source_schema="fin", referent_schema="sat",
)
op.create_check_constraint(
"ck_fin_concepts_default_tax_factor",
"concepts",
"default_tax_factor IS NULL OR default_tax_factor IN ('Tasa', 'Cuota', 'Exento')",
schema="fin",
)
# Impide el estado medio capturado (impuesto sin factor, tasa sin impuesto) que después
# habría que adivinar en el service. Un Exento no lleva tasa; lo demás sí.
op.create_check_constraint(
"ck_fin_concepts_default_tax_coherente",
"concepts",
"(default_tax_id IS NULL AND default_tax_rate IS NULL AND default_tax_factor IS NULL)"
" OR (default_tax_id IS NOT NULL AND default_tax_factor IS NOT NULL"
" AND (default_tax_factor = 'Exento' OR default_tax_rate IS NOT NULL))",
schema="fin",
)
_reporta_facturas_afectadas()
def _reporta_facturas_afectadas() -> None:
"""Deja en la bitácora cuántas facturas se quedan con la fórmula vieja y por qué.
Solo lee y cuenta: no cambia nada. Es la constancia de que la migración no movió dinero, y
la lista de trabajo para quien decida pasar borradores al cálculo por partida.
"""
bind = op.get_bind()
if not bind.dialect.has_table(bind, "invoices", schema="fin"):
return
total = bind.execute(
sa.text("SELECT count(*) FROM fin.invoices WHERE deleted_at IS NULL")
).scalar()
# Facturas a las que la fórmula vieja les cobró IVA sobre partidas que no lo causan: es el
# bug que motiva el cambio. Se quedan como están (su total no se toca) y se listan para que
# Cobranza decida qué hacer con las que ya salieron al cliente.
con_iva_indebido = bind.execute(
sa.text(
"""
SELECT count(DISTINCT i.id)
FROM fin.invoices i
JOIN fin.invoice_items ii ON ii.invoice_id = i.id AND ii.deleted_at IS NULL
LEFT JOIN sat.tax_objects tobj ON tobj.id = ii.tax_object_id
WHERE i.deleted_at IS NULL
AND i.tax_rate > 0
AND (tobj.code IS NULL OR tobj.code <> '02')
"""
)
).scalar()
logger.info(
"IVA por partida: %s facturas existentes quedan en taxes_per_item=false y conservan su "
"total. De ellas, %s tienen partidas que no causan IVA y a las que la fórmula anterior "
"se lo cobró; su total NO se modifica.",
total, con_iva_indebido,
)
def downgrade() -> None:
op.drop_constraint("ck_fin_concepts_default_tax_coherente", "concepts", schema="fin", type_="check")
op.drop_constraint("ck_fin_concepts_default_tax_factor", "concepts", schema="fin", type_="check")
op.drop_constraint("fk_fin_concepts_default_tax_id", "concepts", schema="fin", type_="foreignkey")
op.drop_column("concepts", "default_tax_factor", schema="fin")
op.drop_column("concepts", "default_tax_rate", schema="fin")
op.drop_column("concepts", "default_tax_id", schema="fin")
op.drop_column("invoice_item_taxes", "is_manual", schema="fin")
op.drop_constraint("ck_fin_invoice_item_taxes_factor", "invoice_item_taxes", schema="fin", type_="check")
op.drop_column("invoice_item_taxes", "factor", schema="fin")
op.drop_column("invoices", "withheld_amount", schema="fin")
op.drop_column("invoices", "taxes_per_item", schema="fin")

View File

@@ -8,7 +8,7 @@ from datetime import datetime
from typing import Set, Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from sqlalchemy import and_, or_, text
from core.database import RLS_TENANT_KEY
from .cache import PermissionCache
@@ -51,8 +51,18 @@ class PermissionService:
return None
try:
# Sin modelo de compañía en la plantilla — implementa la consulta aquí.
pass
# La tabla de compañías del CRM es ``a76.company`` (esquema legado). No tiene modelo
# ORM en este proyecto, así que se consulta con SQL crudo, igual que ``seed_crm.py``.
#
# Sin este respaldo la función devolvía None siempre que la sesión no traía contexto
# RLS, y con ella se scopean las consultas de permisos de los cinco llamadores de
# abajo: el efecto neto era que nadie resolvía permisos.
row = self.db.execute(
text("SELECT tenant_id FROM a76.company WHERE id = :company_id"),
{"company_id": company_id},
).first()
if row is not None and row[0] is not None:
return int(row[0])
except Exception as exc:
logger.warning(
"resolve_tenant_id_for_company_failed",
@@ -470,8 +480,35 @@ class PermissionService:
sync_res = self.sync_permissions()
logger.info(f"Bootstrap: Sincronización completa. {sync_res.get('synced', 0)} nuevos, {sync_res.get('total_registered', 0)} totales.")
# 1. Obtener el tenant_id (implementa con tu modelo de compañía)
tenant_id = self.db.info.get(RLS_TENANT_KEY) or 1
# 1. tenant_id del rol: se toma de la COMPAÑÍA, que es su fuente autoritativa.
# ``company_roles`` referencia a la vez a ``a76.company`` y a ``core.tenants``, así
# que el tenant del rol tiene que ser el de su compañía o la fila queda cruzada
# entre dos tenants.
#
# Antes esto era ``self.db.info.get(RLS_TENANT_KEY) or 1``. Cuando la sesión no
# traía contexto RLS —justo el caso de ``/permissions/me`` en el primer acceso— el
# rol se creaba con ``tenant_id=1``; en una instalación real ese tenant no existe y
# el INSERT moría con ForeignKeyViolation. El bootstrap quedaba a medias, sin rol
# ni permisos, toda la API respondía 403 y ``/permissions/me`` seguía devolviendo
# 200 con la lista vacía: el fallo se leía en pantalla como "no tienes permisos"
# en lugar de como el error de configuración que era.
# Se consulta la compañía DIRECTAMENTE y no vía ``_resolve_tenant_id_for_company``:
# ese helper prefiere el contexto RLS, que es el tenant del REQUEST y puede no ser
# el de la compañía. Para leer permisos esa preferencia está bien y ahorra una
# consulta en el camino caliente; para escribir una fila atada por FK a las dos
# tablas, no: si difirieran, el rol nacería cruzado.
_row = self.db.execute(
text("SELECT tenant_id FROM a76.company WHERE id = :company_id"),
{"company_id": company_id},
).first()
if _row is None or _row[0] is None:
logger.error(
"Bootstrap: la compañía %s no existe en a76.company, no hay tenant al que "
"colgar el rol. Se aborta sin crear nada.",
company_id,
)
return False
tenant_id = int(_row[0])
# 2. Buscar si ya existe el rol "super_admin"
admin_role = self.db.query(CompanyRole).filter(

View File

@@ -13,6 +13,7 @@ class AccountBase(BaseModel):
record_type: str = Field("cliente", max_length=20) # cliente | prospecto
person_type: str | None = Field(None, max_length=10) # fisica | moral
industry: str | None = Field(None, max_length=120)
industry_other: str | None = Field(None, max_length=120)
account_type: str | None = Field(None, max_length=40)
status: str = Field("active", max_length=20) # active | inactive
# Comercial
@@ -27,6 +28,9 @@ class AccountBase(BaseModel):
# Fiscal
tax_regime: str | None = Field(None, max_length=120)
cfdi_use: str | None = Field(None, max_length=60)
# Claves contra los catálogos del SAT; sustituyen al texto libre de arriba al timbrar.
tax_regime_id: int | None = Field(None, description="c_RegimenFiscal del receptor")
cfdi_use_id: int | None = Field(None, description="c_UsoCFDI del receptor")
payment_method: str | None = Field(None, max_length=60)
payment_form: str | None = Field(None, max_length=60)
currency: str | None = Field(None, max_length=3)
@@ -38,7 +42,7 @@ class AccountBase(BaseModel):
address: str | None = None
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field("MX", max_length=2)
country: str | None = Field("MEX", max_length=3)
# Observaciones
notes: str | None = None
internal_notes: str | None = None
@@ -57,6 +61,7 @@ class AccountUpdate(BaseModel):
record_type: str | None = Field(None, max_length=20)
person_type: str | None = Field(None, max_length=10)
industry: str | None = Field(None, max_length=120)
industry_other: str | None = Field(None, max_length=120)
account_type: str | None = Field(None, max_length=40)
status: str | None = Field(None, max_length=20)
commercial_classification: str | None = Field(None, max_length=20)
@@ -69,6 +74,8 @@ class AccountUpdate(BaseModel):
commercial_observations: str | None = None
tax_regime: str | None = Field(None, max_length=120)
cfdi_use: str | None = Field(None, max_length=60)
tax_regime_id: int | None = None
cfdi_use_id: int | None = None
payment_method: str | None = Field(None, max_length=60)
payment_form: str | None = Field(None, max_length=60)
currency: str | None = Field(None, max_length=3)
@@ -79,7 +86,7 @@ class AccountUpdate(BaseModel):
address: str | None = None
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field(None, max_length=2)
country: str | None = Field(None, max_length=3)
notes: str | None = None
internal_notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)

View File

@@ -1,9 +1,10 @@
from decimal import Decimal
from sqlalchemy import Integer, Numeric, String, Text, text
from sqlalchemy import ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from api.v1.modules.fin.catalogs.models import CfdiUse, TaxRegime # noqa: F401 (resuelve las FK)
from core.database import Base
@@ -31,6 +32,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
# Tipo de persona: fisica | moral
person_type: Mapped[str | None] = mapped_column(String(10), nullable=True)
industry: Mapped[str | None] = mapped_column(String(120), nullable=True) # giro / industria
industry_other: Mapped[str | None] = mapped_column(String(120), nullable=True) # especificar cuando giro = "otro"
# Tipo operativo (immex | agencia_aduanal | importador | exportador | transportista | otro)
account_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
# Estatus: active | inactive
@@ -50,8 +52,17 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
commercial_observations: Mapped[str | None] = mapped_column(Text, nullable=True) # observaciones generales
# ----- Información fiscal -----
# Régimen fiscal y uso de CFDI en texto libre: se conservan como capturó el usuario
# para no perder lo ya registrado, pero lo que vale al timbrar son las FK de abajo.
tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True) # régimen fiscal
cfdi_use: Mapped[str | None] = mapped_column(String(60), nullable=True) # uso de CFDI
# Claves del receptor contra los catálogos del SAT (c_RegimenFiscal y c_UsoCFDI).
tax_regime_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("sat.tax_regimes.id"), nullable=True
)
cfdi_use_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("sat.cfdi_uses.id"), nullable=True
)
payment_method: Mapped[str | None] = mapped_column(String(60), nullable=True) # método de pago
payment_form: Mapped[str | None] = mapped_column(String(60), nullable=True) # forma de pago
currency: Mapped[str | None] = mapped_column(String(3), nullable=True) # moneda
@@ -65,7 +76,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
address: Mapped[str | None] = mapped_column(Text, nullable=True)
city: Mapped[str | None] = mapped_column(String(120), nullable=True)
state: Mapped[str | None] = mapped_column(String(120), nullable=True)
country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'"))
country: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'MEX'"))
# ----- Observaciones y auditoría -----
notes: Mapped[str | None] = mapped_column(Text, nullable=True) # comentarios generales

View File

@@ -3,10 +3,24 @@ from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from api.v1.modules.fin.catalogs.models import CfdiUse, TaxRegime
from .dto import AccountCreate, AccountUpdate
from .models import Account
def _validate_sat_refs(db: Session, data: dict) -> None:
"""Verifica las claves del SAT del receptor antes de guardar la cuenta."""
for field, model, msg in [
("tax_regime_id", TaxRegime, "El régimen fiscal indicado no existe en el catálogo del SAT"),
("cfdi_use_id", CfdiUse, "El uso de CFDI indicado no existe en el catálogo del SAT"),
]:
value = data.get(field)
if field in data and value is not None:
if db.query(model.id).filter(model.id == value).first() is None:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
def get_accounts(
db: Session,
tenant_id: int,
@@ -53,8 +67,10 @@ def get_account(db: Session, account_id: int, tenant_id: int, company_id: int) -
def create_account(
db: Session, payload: AccountCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Account:
data = payload.model_dump()
_validate_sat_refs(db, data)
account = Account(
**payload.model_dump(),
**data,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
@@ -75,7 +91,9 @@ def update_account(
user_id: str | None = None,
) -> Account:
account = get_account(db, account_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
data = payload.model_dump(exclude_unset=True)
_validate_sat_refs(db, data)
for field, value in data.items():
setattr(account, field, value)
account.updated_by = user_id
db.commit()

View File

@@ -14,7 +14,7 @@ class AddressBase(BaseModel):
postal_code: str | None = Field(None, max_length=10)
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field("MX", max_length=2)
country: str | None = Field("MEX", max_length=3) # ISO 3166-1 alfa-3 (alineado a catálogo pais)
reference_notes: str | None = None
is_primary: bool = False
@@ -32,7 +32,7 @@ class AddressUpdate(BaseModel):
postal_code: str | None = Field(None, max_length=10)
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field(None, max_length=2)
country: str | None = Field(None, max_length=3)
reference_notes: str | None = None
is_primary: bool | None = None

View File

@@ -0,0 +1,31 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class CaseResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
reference: str | None
account_id: int | None
title: str | None
stage: str
status: str
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
class CaseTimelineEvent(BaseModel):
kind: str # oportunidad | solicitud | cotizacion | operacion | factura
id: int
reference: str | None = None
status: str | None = None
created_at: datetime
url: str
class CaseWithTimeline(CaseResponse):
timeline: list[CaseTimelineEvent] = []

View File

@@ -0,0 +1,52 @@
from sqlalchemy import ForeignKey, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Case(Base, TenantScopedMixin, TimestampMixin):
"""Expediente: hilo maestro de un trámite (Oportunidad → Solicitud → Cotización →
Operación → Factura). Una sola referencia (``EXP…``) que agrupa toda la historia.
Nace al crear la Oportunidad y se hereda a las entidades siguientes vía ``case_id``.
"""
__tablename__ = "cases"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio EXP...
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Etapa más avanzada alcanzada: oportunidad|solicitud|cotizacion|operacion|facturacion|cerrado
stage: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'oportunidad'"))
# abierto | cerrado
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierto'"), index=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
# ── Espejo del carril hacia EFC (T2026-08-046) ──────────────────────────────────────────
# EFC es la fuente única de los documentos del expediente: cada expediente se refleja allá
# como un *pedimento provisional* y los archivos viven en su MinIO, no en el del CRM.
#
# Estas columnas son un ESPEJO, nunca el handle. El handle con el que el CRM habla de este
# expediente es su ``id`` y su ``reference``: ``efc_pedimento_id`` es un caché de la
# resolución, y del lado de EFC el ``pedimento_app`` es mutable —se reescribe al completar
# el provisional con la data aduanera real—, así que apoyarse en él rompería justo cuando
# llegue esa data. La liga vive en EFC, en la tabla desechable ``pedimento_expediente``.
efc_organizacion_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
efc_pedimento_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
# INMUTABLE una vez asignado: es la carpeta de MinIO donde EFC guarda los objetos de este
# expediente. Que no cambie nunca es lo que permite completar el pedimento sin mover ni un
# archivo.
efc_storage_token: Mapped[str | None] = mapped_column(String(25), nullable=True)
# PENDING | LINKED | FAILED
efc_link_state: Mapped[str] = mapped_column(
String(20), nullable=False, server_default=text("'PENDING'"), index=True
)
# El diagnóstico se guarda en la fila para que se vea en la ficha del expediente, sin
# obligar a nadie a ir a los logs del worker.
efc_error_code: Mapped[str | None] = mapped_column(String(60), nullable=True)
efc_error_detail: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import CaseResponse, CaseWithTimeline
router = APIRouter()
def _with_timeline(db, case) -> CaseWithTimeline:
data = CaseWithTimeline.model_validate(case)
data.timeline = service.build_timeline(db, case) # type: ignore[assignment]
return data
@router.get("/cases", response_model=list[CaseResponse])
def list_cases(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None),
account_id: int | None = Query(None),
stage: str | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.get_cases(db, current_user["tenant_id"], company_id, search, account_id, stage)
@router.get("/cases/by-ref/{reference}", response_model=CaseWithTimeline)
def get_case_by_ref(
reference: str,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Expediente + historia completa por su referencia (para UI y otros sistemas)."""
case = service.get_case_by_reference(db, reference, current_user["tenant_id"], company_id)
return _with_timeline(db, case)
@router.get("/cases/{case_id}", response_model=CaseWithTimeline)
def get_case(
case_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
case = service.get_case(db, case_id, current_user["tenant_id"], company_id)
return _with_timeline(db, case)

View File

@@ -0,0 +1,130 @@
"""Lógica del Expediente: minteo del folio, avance de etapa y armado del timeline."""
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..common.folios import next_folio
from .models import Case
# Orden de etapas (solo se avanza, nunca retrocede)
STAGE_ORDER = ["oportunidad", "solicitud", "cotizacion", "operacion", "facturacion", "cerrado"]
def create_case(
db: Session, tenant_id: int, company_id: int, *, account_id: int | None = None,
title: str | None = None, stage: str = "oportunidad", user_id: str | None = None,
) -> Case:
"""Mintea un expediente con folio EXP... (sin commit; lo confirma quien lo invoca)."""
case = Case(
reference=next_folio(db, tenant_id, company_id, "EXP", None, with_direction=False),
account_id=account_id, title=title, stage=stage, status="abierto",
tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id,
)
db.add(case)
db.flush()
# ── Carril hacia EFC (T2026-08-046) ──────────────────────────────────────────────────
# EFC es la fuente única de los documentos del expediente. El reflejo allá —un pedimento
# provisional— se pide AQUÍ, en el instante en que nace el folio, porque el folio es
# precisamente la llave con la que las dos mitades se reconocen.
#
# El token de almacenamiento se fija al nacer y NO cambia nunca: es la carpeta de MinIO
# del lado de EFC. Que sea inmutable es lo que permite completar el provisional con la
# data aduanera real sin mover un solo archivo.
#
# Todo es best-effort y va en la MISMA transacción que el expediente:
# - con ``EFC_API_URL`` vacía es un no-op y el expediente vive igual, solo en el CRM;
# - si el encolado o el despacho fallan, no se propaga el error: el barrido del beat
# recoge lo pendiente. Un sistema de terceros caído no puede romper un alta.
# El import es diferido para no acoplar el arranque del módulo del expediente al carril.
from ..expediente_gateway import service as gateway
from ..expediente_gateway.storage import storage_token
case.efc_storage_token = storage_token(company_id, case.reference)
db.flush()
gateway.replicate_expediente_best_effort(db, case)
return case
def advance_stage(db: Session, case_id: int | None, stage: str) -> None:
"""Avanza la etapa del expediente si la nueva es posterior a la actual."""
if not case_id or stage not in STAGE_ORDER:
return
case = db.query(Case).filter(Case.id == case_id).first()
if not case:
return
current = case.stage if case.stage in STAGE_ORDER else "oportunidad"
if STAGE_ORDER.index(stage) > STAGE_ORDER.index(current):
case.stage = stage
def get_cases(
db: Session, tenant_id: int, company_id: int, search: str | None = None,
account_id: int | None = None, stage: str | None = None,
) -> list[Case]:
q = db.query(Case).filter(
Case.tenant_id == tenant_id, Case.company_id == company_id, Case.deleted_at.is_(None),
)
if account_id is not None:
q = q.filter(Case.account_id == account_id)
if stage:
q = q.filter(Case.stage == stage)
if search:
q = q.filter(Case.reference.ilike(f"%{search}%"))
return q.order_by(Case.created_at.desc()).all()
def get_case(db: Session, case_id: int, tenant_id: int, company_id: int) -> Case:
obj = (
db.query(Case)
.filter(Case.id == case_id, Case.tenant_id == tenant_id, Case.company_id == company_id, Case.deleted_at.is_(None))
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado")
return obj
def get_case_by_reference(db: Session, reference: str, tenant_id: int, company_id: int) -> Case:
obj = (
db.query(Case)
.filter(Case.reference == reference, Case.tenant_id == tenant_id, Case.company_id == company_id,
Case.deleted_at.is_(None))
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado")
return obj
def build_timeline(db: Session, case: Case) -> list[dict]:
"""Devuelve la historia del expediente: todas las entidades ligadas por case_id,
en orden cronológico. Un único lookup para la UI y para otros sistemas."""
# Import local para evitar ciclos de importación entre módulos.
from ..opportunities.models import Opportunity
from ..quotes.models import Quote
from ..service_requests.models import ServiceRequest
from api.v1.modules.fin.invoices.models import Invoice
from api.v1.modules.ops.shipments.models import Shipment
events: list[dict] = []
specs = [
("oportunidad", Opportunity, "/dashboard/crm/oportunidades"),
("solicitud", ServiceRequest, "/dashboard/crm/solicitudes"),
("cotizacion", Quote, "/dashboard/crm/cotizaciones"),
("operacion", Shipment, "/dashboard/ops/embarques"),
("factura", Invoice, "/dashboard/fin/facturas"),
]
for kind, model, base_url in specs:
rows = db.query(model).filter(model.case_id == case.id, model.deleted_at.is_(None)).all()
for r in rows:
events.append({
"kind": kind,
"id": r.id,
"reference": getattr(r, "reference", None),
"status": getattr(r, "status", None),
"created_at": r.created_at,
"url": f"{base_url}/{r.id}",
})
events.sort(key=lambda e: e["created_at"])
return events

View File

@@ -766,3 +766,109 @@ TENANT_CATALOG_LABELS = {'servicio': 'Servicios que ofrece',
'puerto': 'Puertos donde opera',
'aeropuerto': 'Aeropuertos donde opera',
'aduana': 'Aduanas donde opera'}
# ---------------------------------------------------------------------------
# Catálogos del proceso comercial (Solicitud de servicio → Cotización).
# Alimentan los selects de la solicitud y del ciclo Oportunidad→Cotización.
# is_system = catálogos base que el cliente no puede borrar (sólo activar/desactivar).
# ---------------------------------------------------------------------------
GLOBAL_CATALOGS.update({
'tipo_operacion': {'label': 'Tipo de operación',
'is_system': True,
'items': [{'code': 'importacion', 'label': 'Importación'},
{'code': 'exportacion', 'label': 'Exportación'}]},
'medio_transporte': {'label': 'Medio de transporte',
'is_system': True,
'items': [{'code': 'maritimo', 'label': 'Marítimo'},
{'code': 'aereo', 'label': 'Aéreo'},
{'code': 'terrestre', 'label': 'Terrestre'},
{'code': 'ferroviario', 'label': 'Ferroviario'},
{'code': 'multimodal', 'label': 'Multimodal'}]},
'tipo_servicio': {'label': 'Tipo de servicio',
'is_system': True,
'items': [{'code': 'puerto_puerto', 'label': 'Puerto a puerto'},
{'code': 'puerto_puerta', 'label': 'Puerto a puerta'},
{'code': 'puerta_puerto', 'label': 'Puerta a puerto'},
{'code': 'puerta_puerta', 'label': 'Puerta a puerta'}]},
'prioridad': {'label': 'Prioridad',
'is_system': False,
'items': [{'code': 'baja', 'label': 'Baja'},
{'code': 'normal', 'label': 'Normal'},
{'code': 'alta', 'label': 'Alta'},
{'code': 'urgente', 'label': 'Urgente'}]},
'tipo_mercancia': {'label': 'Tipo de mercancía',
'is_system': False,
'items': [{'code': 'general', 'label': 'Carga general'},
{'code': 'perecedera', 'label': 'Perecedera'},
{'code': 'peligrosa', 'label': 'Peligrosa (IMO)'},
{'code': 'refrigerada', 'label': 'Refrigerada'},
{'code': 'granel', 'label': 'Granel'},
{'code': 'sobredimensionada', 'label': 'Sobredimensionada'},
{'code': 'valiosa', 'label': 'Valiosa'},
{'code': 'otro', 'label': 'Otro'}]},
'unidad_medida': {'label': 'Unidad de medida',
'is_system': False,
'items': [{'code': 'cm', 'label': 'Centímetros (cm)'},
{'code': 'm', 'label': 'Metros (m)'},
{'code': 'in', 'label': 'Pulgadas (in)'},
{'code': 'ft', 'label': 'Pies (ft)'},
{'code': 'kg', 'label': 'Kilogramos (kg)'},
{'code': 'lb', 'label': 'Libras (lb)'},
{'code': 'm3', 'label': 'Metros cúbicos (m³)'}]},
'tipo_embalaje': {'label': 'Tipo de embalaje',
'is_system': False,
'items': [{'code': 'caja', 'label': 'Caja'},
{'code': 'pallet', 'label': 'Pallet'},
{'code': 'tarima', 'label': 'Tarima'},
{'code': 'huacal', 'label': 'Huacal'},
{'code': 'saco', 'label': 'Saco'},
{'code': 'tambor', 'label': 'Tambor'},
{'code': 'rollo', 'label': 'Rollo'},
{'code': 'atado', 'label': 'Atado'},
{'code': 'granel', 'label': 'Granel'},
{'code': 'otro', 'label': 'Otro'}]},
'servicio_adicional': {'label': 'Servicios adicionales',
'is_system': False,
'items': [{'code': 'seguro', 'label': 'Seguro de la mercancía'},
{'code': 'despacho_aduanal', 'label': 'Despacho aduanal'},
{'code': 'transporte_terrestre', 'label': 'Transporte terrestre'},
{'code': 'almacenaje', 'label': 'Almacenaje'},
{'code': 'maniobras', 'label': 'Maniobras'},
{'code': 'custodia', 'label': 'Custodia'},
{'code': 'revalidacion', 'label': 'Revalidación'},
{'code': 'inspeccion', 'label': 'Inspección'},
{'code': 'otro', 'label': 'Otro'}]},
'tipo_documento': {'label': 'Tipo de documento',
'is_system': False,
'items': [{'code': 'factura_comercial', 'label': 'Factura comercial'},
{'code': 'packing_list', 'label': 'Packing list'},
{'code': 'certificado_origen', 'label': 'Certificado de origen'},
{'code': 'hoja_seguridad_msds', 'label': 'Hoja de seguridad (MSDS)'},
{'code': 'ficha_tecnica', 'label': 'Ficha técnica'},
{'code': 'carta_instrucciones', 'label': 'Carta de instrucciones'},
{'code': 'otro', 'label': 'Otro'}]},
'incoterm': {'label': 'Incoterm (2020)',
'is_system': True,
'items': [{'code': 'EXW', 'label': 'EXW — Ex Works (en fábrica)'},
{'code': 'FCA', 'label': 'FCA — Free Carrier (franco transportista)'},
{'code': 'FAS', 'label': 'FAS — Free Alongside Ship (franco al costado del buque)'},
{'code': 'FOB', 'label': 'FOB — Free On Board (franco a bordo)'},
{'code': 'CFR', 'label': 'CFR — Cost and Freight (costo y flete)'},
{'code': 'CIF', 'label': 'CIF — Cost, Insurance and Freight (costo, seguro y flete)'},
{'code': 'CPT', 'label': 'CPT — Carriage Paid To (transporte pagado hasta)'},
{'code': 'CIP', 'label': 'CIP — Carriage and Insurance Paid To (transporte y seguro pagados hasta)'},
{'code': 'DAP', 'label': 'DAP — Delivered At Place (entregado en lugar)'},
{'code': 'DPU', 'label': 'DPU — Delivered At Place Unloaded (entregado en lugar descargado)'},
{'code': 'DDP', 'label': 'DDP — Delivered Duty Paid (entregado con derechos pagados)'}]},
})
# Formas de pago SAT de un dígito → dos dígitos (01, 02, 03, 04, 05, 06, 08).
# El SAT exige dos posiciones; se corrige el catálogo base.
for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []):
if len(_fp['code']) == 1:
_fp['code'] = _fp['code'].zfill(2)
# Ubicaciones por país (ciudad/puerto/aeropuerto), dependientes de `pais`.
from .seed_locations import LOCATION_CATALOGS # noqa: E402
GLOBAL_CATALOGS.update(LOCATION_CATALOGS)

View File

@@ -0,0 +1,79 @@
"""Catálogos de ubicaciones por país: ciudad, puerto (UN/LOCODE), aeropuerto (IATA).
Dependientes de `pais` (`parent_catalog='pais'`, `parent_code=<ISO3>`). Curado a las
rutas de comercio más usadas (extensible: agregar países/nodos según tarifarios).
Los códigos de puerto/aeropuerto se alinean con los que usan las lanes del tarifario
para que el Cotizador encuentre ruta.
"""
# (ISO3, ciudades[(code,label)], puertos[(code,label)], aeropuertos[(code,label)])
_LOC = [
("MEX",
[("MX-CDMX", "Ciudad de México"), ("MX-GDL", "Guadalajara"), ("MX-MTY", "Monterrey"),
("MX-QRO", "Querétaro"), ("MX-TIJ", "Tijuana"), ("MX-VER", "Veracruz")],
[("MXZLO", "Manzanillo"), ("MXVER", "Veracruz"), ("MXATM", "Altamira"),
("MXLZC", "Lázaro Cárdenas"), ("MXPGO", "Progreso"), ("MXESE", "Ensenada")],
[("MEX", "AICM Ciudad de México"), ("NLU", "AIFA Santa Lucía"), ("GDL", "Guadalajara"),
("MTY", "Monterrey"), ("TIJ", "Tijuana"), ("CUN", "Cancún")]),
("USA",
[("US-LAX", "Los Ángeles"), ("US-NYC", "Nueva York"), ("US-HOU", "Houston"),
("US-CHI", "Chicago"), ("US-MIA", "Miami"), ("US-LRD", "Laredo")],
[("USLAX", "Los Angeles"), ("USLGB", "Long Beach"), ("USNYC", "Nueva York/NJ"),
("USHOU", "Houston"), ("USSAV", "Savannah"), ("USSEA", "Seattle"), ("USOAK", "Oakland")],
[("LAX", "Los Ángeles"), ("JFK", "Nueva York JFK"), ("ORD", "Chicago O'Hare"),
("MIA", "Miami"), ("DFW", "Dallas Fort Worth"), ("ATL", "Atlanta")]),
("CHN",
[("CN-SHA", "Shanghái"), ("CN-SZX", "Shenzhen"), ("CN-CAN", "Guangzhou"),
("CN-NGB", "Ningbo"), ("CN-TAO", "Qingdao"), ("CN-PEK", "Pekín")],
[("CNSHA", "Shanghái"), ("CNNGB", "Ningbo"), ("CNSZX", "Shenzhen"),
("CNTAO", "Qingdao"), ("CNCAN", "Guangzhou"), ("CNXMN", "Xiamen"), ("CNTXG", "Tianjin")],
[("PVG", "Shanghái Pudong"), ("PEK", "Pekín Capital"), ("CAN", "Guangzhou"),
("SZX", "Shenzhen"), ("HKG", "Hong Kong")]),
("DEU",
[("DE-HAM", "Hamburgo"), ("DE-FRA", "Fráncfort"), ("DE-MUC", "Múnich"), ("DE-BER", "Berlín")],
[("DEHAM", "Hamburgo"), ("DEBRV", "Bremerhaven")],
[("FRA", "Fráncfort"), ("MUC", "Múnich"), ("HAM", "Hamburgo")]),
("ESP",
[("ES-MAD", "Madrid"), ("ES-BCN", "Barcelona"), ("ES-VLC", "Valencia")],
[("ESVLC", "Valencia"), ("ESBCN", "Barcelona"), ("ESALG", "Algeciras")],
[("MAD", "Madrid Barajas"), ("BCN", "Barcelona")]),
("NLD",
[("NL-RTM", "Róterdam"), ("NL-AMS", "Ámsterdam")],
[("NLRTM", "Róterdam")],
[("AMS", "Ámsterdam Schiphol")]),
("BRA",
[("BR-SAO", "São Paulo"), ("BR-SSZ", "Santos"), ("BR-RIO", "Río de Janeiro")],
[("BRSSZ", "Santos"), ("BRPNG", "Paranaguá"), ("BRRIG", "Rio Grande")],
[("GRU", "São Paulo Guarulhos"), ("GIG", "Río de Janeiro")]),
("CAN",
[("CA-YVR", "Vancouver"), ("CA-YYZ", "Toronto"), ("CA-YMQ", "Montreal")],
[("CAVAN", "Vancouver"), ("CAMTR", "Montreal"), ("CAHAL", "Halifax")],
[("YVR", "Vancouver"), ("YYZ", "Toronto Pearson")]),
("JPN",
[("JP-TYO", "Tokio"), ("JP-OSA", "Osaka"), ("JP-YOK", "Yokohama")],
[("JPYOK", "Yokohama"), ("JPTYO", "Tokio"), ("JPNGO", "Nagoya"), ("JPKOB", "Kobe")],
[("NRT", "Tokio Narita"), ("HND", "Tokio Haneda"), ("KIX", "Osaka Kansai")]),
("KOR",
[("KR-SEL", "Seúl"), ("KR-PUS", "Busan")],
[("KRPUS", "Busan"), ("KRINC", "Incheon")],
[("ICN", "Seúl Incheon")]),
]
def _build() -> dict:
ciudad, puerto, aeropuerto = [], [], []
for iso3, cities, ports, airports in _LOC:
for code, label in cities:
ciudad.append({"code": code, "label": label, "parent_catalog": "pais", "parent_code": iso3})
for code, label in ports:
puerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3})
for code, label in airports:
aeropuerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3})
return {
"ciudad": {"label": "Ciudad", "is_system": True, "items": ciudad},
"puerto": {"label": "Puerto", "is_system": True, "items": puerto},
"aeropuerto": {"label": "Aeropuerto", "is_system": True, "items": aeropuerto},
}
LOCATION_CATALOGS = _build()

View File

@@ -0,0 +1,96 @@
"""Folios auto-generados del ciclo comercial (Oportunidad → Solicitud → Cotización → Operación).
Formato: ``{LETRA}{AAAA}-{MM}-{NNN}-{DIR}`` (ej. ``O2025-08-001-E``):
- LETRA: entidad — ``O`` Oportunidad, ``S`` Solicitud, ``C`` Cotización, ``OP`` Operación/Embarque.
- ``AAAA-MM``: año-mes de creación.
- ``NNN``: consecutivo **mensual** por compañía y por entidad (reinicia cada mes).
- ``DIR``: ``I`` importación / ``E`` exportación (``X`` si aún no se define la dirección).
El consecutivo se toma de ``crm.folio_counters`` con bloqueo de fila para evitar
duplicados por concurrencia. En SQLite (pruebas) el ``FOR UPDATE`` se ignora sin error;
la unicidad la garantiza el índice único (tenant, company, entity, period).
"""
from __future__ import annotations
from datetime import date
from sqlalchemy import Integer, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import BaseTimestampMixin, TenantScopedMixin
from core.database import Base
# Entidades válidas y su letra de folio (F = factura, EXP = expediente; sin dirección).
ENTITIES = ("O", "S", "C", "OP", "F", "EXP")
# Mapa dirección de operación → sufijo del folio.
_DIRECTION_SUFFIX = {"importacion": "I", "exportacion": "E"}
class FolioCounter(Base, TenantScopedMixin, BaseTimestampMixin):
"""Consecutivo mensual por compañía y entidad para armar los folios del ciclo."""
__tablename__ = "folio_counters"
__table_args__ = (
UniqueConstraint(
"tenant_id", "company_id", "entity", "period", name="uq_crm_folio_counters_scope"
),
{"schema": "crm"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
entity: Mapped[str] = mapped_column(String(4), nullable=False) # O | S | C | OP
period: Mapped[str] = mapped_column(String(7), nullable=False) # 'AAAA-MM'
last_number: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
def direction_suffix(direction: str | None) -> str:
"""Devuelve la letra de dirección del folio (I/E) o 'X' si no está definida."""
return _DIRECTION_SUFFIX.get(direction or "", "X")
def next_folio(
db,
tenant_id: int,
company_id: int,
entity: str,
direction: str | None,
on_date: date | None = None,
with_direction: bool = True,
) -> str:
"""Genera el siguiente folio de una entidad, incrementando su consecutivo mensual.
Reserva el número dentro de la transacción activa (no hace commit): el ``create_*``
que lo invoca es quien confirma junto con la fila recién creada. ``with_direction=False``
omite el sufijo I/E (p. ej. facturas → ``F2026-08-001``).
"""
if entity not in ENTITIES:
raise ValueError(f"Entidad de folio inválida: {entity!r}")
on_date = on_date or date.today()
period = on_date.strftime("%Y-%m")
counter = (
db.query(FolioCounter)
.filter(
FolioCounter.tenant_id == tenant_id,
FolioCounter.company_id == company_id,
FolioCounter.entity == entity,
FolioCounter.period == period,
)
.with_for_update()
.first()
)
if counter is None:
counter = FolioCounter(
tenant_id=tenant_id, company_id=company_id, entity=entity, period=period, last_number=0
)
db.add(counter)
db.flush()
counter.last_number = (counter.last_number or 0) + 1
db.flush()
sequence = f"{counter.last_number:03d}"
if not with_direction:
return f"{entity}{period}-{sequence}"
return f"{entity}{period}-{sequence}-{direction_suffix(direction)}"

View File

@@ -0,0 +1,37 @@
"""Cálculos de precio compartidos del proceso comercial.
Peso volumétrico / a cobrar de carga aérea (doc maestro de cotización):
P/Vol = (Largo_cm × Ancho_cm × Alto_cm × cantidad) / 6000
El peso a cobrar es el mayor entre el peso bruto y el P/Vol (estándar aéreo).
6000 cm³/kg es el factor internacional (equivale a ~167 kg/m³).
"""
from __future__ import annotations
from decimal import Decimal
# Factor internacional de peso volumétrico aéreo (cm³ por kg).
AIR_VOLUMETRIC_DIVISOR = Decimal("6000")
def _d(value) -> Decimal:
if value is None:
return Decimal(0)
return value if isinstance(value, Decimal) else Decimal(str(value))
def air_volumetric_kg(length_cm, width_cm, height_cm, qty=1) -> Decimal:
"""Peso volumétrico aéreo a partir de dimensiones (cm) y cantidad de bultos.
Devuelve 0 si falta alguna dimensión (no se puede calcular).
"""
length, width, height = _d(length_cm), _d(width_cm), _d(height_cm)
if length <= 0 or width <= 0 or height <= 0:
return Decimal(0)
quantity = _d(qty) if _d(qty) > 0 else Decimal(1)
return (length * width * height * quantity) / AIR_VOLUMETRIC_DIVISOR
def air_chargeable_kg(gross_kg, length_cm, width_cm, height_cm, qty=1) -> Decimal:
"""Peso a cobrar aéreo: max(peso bruto, peso volumétrico por dimensiones)."""
return max(_d(gross_kg), air_volumetric_kg(length_cm, width_cm, height_cm, qty))

View File

@@ -22,6 +22,10 @@ class Document(Base, TenantScopedMixin, TimestampMixin):
supplier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True
)
# Documento adjunto a una solicitud de servicio (factura, packing list, MSDS, etc.)
service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True
)
# constancia_fiscal | acta_constitutiva | identificacion | comprobante_domicilio |
# contrato | presentacion | certificacion | licencia | convenio | tarifario | otro
doc_type: Mapped[str] = mapped_column(String(60), nullable=False)

View File

@@ -0,0 +1,64 @@
"""Catálogo CERRADO de tipos de documento que EFC acepta del CRM.
Estas 22 claves son **exactamente** las de ``TIPOS_DOCUMENTO_CRM`` en
``api/record/views_integrations_crm.py`` de EFC. La lista está duplicada a mano en dos repos con
despliegue independiente, así que ``tests/test_doc_types_paridad.py`` la fija: si alguien agrega un
tipo de un solo lado, ese test se pone rojo antes de que un documento se rechace en producción.
Por qué es un conjunto cerrado y no texto libre, a diferencia del carril de Anexo22 —que manda el
tipo suelto y deja que EFC lo resuelva por nombre—: en el CRM ``doc_type`` es ``String(60)`` /
``String(30)`` **sin validación de backend**, los catálogos viven solo en TypeScript
(``frontend/src/lib/api/crm/format.ts``). Un typo crearía un ``DocumentType`` basura en el catálogo
**global** de EFC, que es compartido por todas las organizaciones y no se limpia solo.
Las tres fuentes del CRM y su origen:
- ``crm.documents`` → ``DOC_TYPES`` de ``format.ts``
- ``ops.shipment_documents`` → ``SHIPMENT_DOC_TYPES`` del mismo archivo
- ``fin.invoices`` → el PDF de factura (``factura_venta``)
``otro`` existe en las dos listas del CRM y significa lo mismo en ambas: es una sola entrada.
"""
# --- crm.documents ---------------------------------------------------------------------------
_TIPOS_DOCUMENTOS_CLIENTE = (
"constancia_fiscal",
"acta_constitutiva",
"identificacion",
"comprobante_domicilio",
"contrato",
"presentacion",
"certificacion",
"licencia",
"convenio",
"tarifario",
)
# --- ops.shipment_documents ------------------------------------------------------------------
_TIPOS_DOCUMENTOS_EMBARQUE = (
"MBL",
"HBL",
"MAWB",
"HAWB",
"CMR",
"factura_comercial",
"packing_list",
"carta_encomienda",
"carta_garantia",
"certificado_permiso",
)
# --- fin.invoices ----------------------------------------------------------------------------
_TIPOS_FACTURACION = ("factura_venta",)
# --- común a varias fuentes -------------------------------------------------------------------
_TIPOS_COMUNES = ("otro",)
EFC_DOC_TYPES: frozenset[str] = frozenset(
_TIPOS_DOCUMENTOS_CLIENTE + _TIPOS_DOCUMENTOS_EMBARQUE + _TIPOS_FACTURACION + _TIPOS_COMUNES
)
def is_valid_doc_type(doc_type: str | None) -> bool:
"""``True`` si EFC va a aceptar ese tipo. Se valida en el CRM para no gastar un viaje de red."""
return bool(doc_type) and doc_type in EFC_DOC_TYPES

View File

@@ -0,0 +1,140 @@
"""Outbox transaccional del carril CRM Agentes de Carga -> EFC.
DOS tablas separadas POR PROPÓSITO, igual que en el carril de referencia de Anexo22: una para los
expedientes (metadatos, JSON) y otra para los archivos (binarios que viven en MinIO y se referencian
por su ``s3_key``). Un worker de Celery las drena hacia EFC con reintentos.
**Diferencia con el original, y es necesaria:** aquí las filas se insertan en la MISMA transacción
que el expediente o el documento, porque el CRM es mono-base. En Anexo22 el outbox vivía en otra
base que el pedimento, y ese doble-commit es justamente lo que obligó a inventar el barrido de
huecos. Aquí el barrido se conserva —cubre lo creado antes de activar la integración y cualquier
crash— pero deja de ser el parche de una ventana estructural.
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
# Tipo de trabajo (columna kind) del outbox de EXPEDIENTES.
KIND_EXPEDIENTE = "expediente"
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
# ops.shipment_documents.id = 5 coexisten.
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"
STATUS_SENT = "sent"
STATUS_FAILED = "failed"
# Tope de reintentos antes de marcar 'failed' (reconciliación / reintento manual).
# Heredado del carril de Anexo22. Con barridos de 120 s son ~17 minutos de insistencia antes de
# rendirse y dejar la fila visible para que una persona la reintente a mano.
MAX_ATTEMPTS = 8
class EfcSyncOutbox(Base, TenantScopedMixin, TimestampMixin):
"""Cola de metadatos hacia EFC: crear el expediente provisional y completarlo."""
__tablename__ = "efc_sync_outbox"
__table_args__ = (
Index("ix_crm_efc_sync_outbox_status", "status"),
Index("ix_crm_efc_sync_outbox_kind_status", "kind", "status"),
{"schema": "crm"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
kind: Mapped[str] = mapped_column(String(20), nullable=False)
# Datos para construir el request a EFC (folio, storage_token, tenant slug, company, y la data
# aduanera si el kind es 'completar').
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
# id local del expediente (crm.cases.id) que originó la fila.
expediente_ref: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
# Ciclo de vida.
status: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text(f"'{STATUS_PENDING}'"))
attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Acuse de EFC al confirmar (trazabilidad).
efc_pedimento_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
class EfcFileOutbox(Base, TenantScopedMixin, TimestampMixin):
"""Cola de ARCHIVOS hacia EFC.
El binario vive en el MinIO del CRM (durable); esta fila referencia su ``s3_key`` y el expediente
destino. El worker lo sube a EFC y, con ``delete_local`` (corte directo), BORRA la copia local al
confirmar la entrega.
``delete_local`` **es el mecanismo de «EFC es la fuente única»**: "solo EFC" es el estado FINAL
(eventual), no el inmediato. Entre que el usuario sube el archivo y que EFC lo confirma, la copia
local es lo único que hay, y borrarla antes perdería el archivo si la entrega fallara.
``source_table`` es un añadido necesario sobre el original de Anexo22, que solo llevaba
``source_id``. El CRM tiene dos tablas de documentos con secuencias independientes, así que un
entero solo es ambiguo entre ellas. Es el mismo problema que Anexo22 resolvió con su mapa por
``kind``, y su comentario dice qué pasa si se ignora: un UPDATE con el id de otra tabla **vacía la
columna de un documento ajeno** que tuviera ese mismo entero — daño en el dato de otro, sin un
solo error visible. Un ``(kind, source_table)`` que no esté en el mapa **no toca nada**, en vez
de caer por omisión.
"""
__tablename__ = "efc_file_outbox"
__table_args__ = (
Index("ix_crm_efc_file_outbox_status", "status"),
Index("ix_crm_efc_file_outbox_kind_status", "kind", "status"),
Index("ix_crm_efc_file_outbox_source", "source_table", "source_id"),
{"schema": "crm"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
kind: Mapped[str] = mapped_column(String(30), nullable=False)
# Objeto en MinIO a subir + metadata para el upload a EFC.
s3_key: Mapped[str] = mapped_column(String(1024), nullable=False)
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
content_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
efc_tipo: Mapped[str] = mapped_column(String(40), nullable=False) # tipo de documento en EFC
# Origen: la pareja (tabla, id) desambigua entre las dos secuencias de documentos del CRM.
source_table: Mapped[str] = mapped_column(String(30), nullable=False)
source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# El handle autoritativo que viaja a EFC y garantiza la idempotencia del lado de allá.
crm_document_ref: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
expediente_ref: Mapped[int] = mapped_column(
Integer, ForeignKey("crm.cases.id"), nullable=False, index=True
)
delete_local: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
# Ciclo de vida.
status: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text(f"'{STATUS_PENDING}'"))
attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
efc_document_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)

View File

@@ -0,0 +1,58 @@
"""Endpoints de operación y observabilidad del carril CRM -> EFC.
Tablero mínimo para ver y reintentar la entrega de expedientes y documentos a EFC. Autenticado con
el auth normal del CRM y acotado por tenant/company, como el resto del módulo.
Montado bajo ``/v1/crm`` → ``/v1/crm/expediente-gateway/...``
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
router = APIRouter(prefix="/expediente-gateway", tags=["EFC Gateway (ops)"])
@router.get("/outbox")
def list_outbox(
company_id: int = Query(..., description="Company ID"),
tipo: str | None = Query(None, description="Filtrar por tabla: sync|file"),
status: str | None = Query(None, description="Filtrar por status: pending|sent|failed"),
limit: int = Query(100, ge=1, le=500),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Filas de los dos outbox, para ver los fallos y su ``last_error``."""
return service.list_outbox(db, current_user["tenant_id"], company_id, tipo, status, limit)
@router.post("/outbox/{outbox_id}/retry")
def retry_outbox(
outbox_id: int,
company_id: int = Query(..., description="Company ID"),
tipo: str = Query("file", description="Tabla de la fila: sync|file"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Reintento manual de una fila: la resetea a ``pending`` y la re-despacha.
Una fila inexistente devuelve **404 con mensaje específico**, no un 200 silencioso: el frontend
pinta el botón de reintento según lo que reciba, y un 200 le haría creer que la entrega volvió a
la cola cuando no hay nada que entregar.
"""
ok = service.retry_outbox_row(db, outbox_id, current_user["tenant_id"], company_id, tipo)
if not ok:
raise HTTPException(status_code=404, detail="Fila de outbox no encontrada")
return {"status": "requeued", "id": outbox_id}
@router.get("/metrics")
def metrics(
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Conteo de los dos outbox por status (pending/sent/failed) para monitoreo."""
return service.outbox_metrics(db, current_user["tenant_id"], company_id)

View File

@@ -0,0 +1,768 @@
"""Carril CRM Agentes de Carga -> EFC: encolado, entrega y reconciliación.
Clon del gateway de Anexo22 (``anexo22/.../pedimentos/pedimento_gateway/service.py``), que es el
carril de referencia ya en producción. Quien conozca uno debe poder leer el otro, así que la tabla
de equivalencias va aquí:
====================================== ======================================
Anexo22 CRM
====================================== ======================================
``replicate_pedimento_best_effort`` ``replicate_expediente_best_effort``
``_enqueue_pedimento_outbox`` ``_enqueue_expediente_outbox``
``_dispatch_delivery`` igual
``deliver_row`` / ``_deliver_pedimento`` ``deliver_row`` / ``_deliver_expediente``
``_register_failure`` **idéntico**
``_ya_entregado(source_id, kind)`` ``_ya_entregado(source_table, source_id, kind)``
``deliver_file_row`` **idéntico**, con ensure-then-upload y ``delete_local``
``_register_file_failure`` **idéntico**
``_resolve_org_id`` + ``_org_id_cache`` igual — dict módulo-global, por worker, sin invalidación
``list_outbox`` / ``retry_outbox_row`` / ``outbox_metrics`` igual, para las dos tablas
``find_pedimento_gaps`` ``find_expediente_gaps``
====================================== ======================================
**La máquina de reintentos tiene tres capas y las tres se conservan:**
1. En el cliente HTTP: 3 intentos, backoff lineal ``0.15 * (attempt + 1)``, corte seco en 4xx.
2. En el worker: ``deliver_row`` **nunca lanza**; registra el fallo en la propia fila.
3. En el beat: barridos cada 120 s que re-despachan lo ``pending``.
No hay ``autoretry_for``, ``retry_backoff`` ni ``max_retries`` en las tareas: duplicarían el
mecanismo que ya está en el cliente y en el barrido.
**Cuatro guardas de idempotencia**, en este orden:
1. ``_ya_entregado(source_table, source_id, kind)`` antes de encolar.
2. ``if row.status == STATUS_SENT: return`` al entrar a entregar.
3. El ``crm_document_ref`` que viaja con la subida: EFC devuelve 200 con el que ya existía.
4. El UNIQUE parcial del lado de EFC — la única que garantiza la base.
**Por qué ``find_expediente_gaps`` sigue aquí aunque el CRM sea mono-base.** En Anexo22 el outbox se
commitea aparte del pedimento (dos bases distintas) y ese doble-commit es lo que obligó a inventar el
barrido de huecos. Aquí la fila del outbox va en la MISMA transacción que el expediente, así que esa
ventana no existe. El barrido se conserva porque cubre otras dos cosas: los expedientes creados
**antes** de activar la integración, y cualquier crash. Queda escrito para que el siguiente que lo
lea no lo borre creyendo que es redundante.
"""
import logging
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.orm import Session
from core.config import settings
from core.database import scoped_core_db
from core.efc_client import EfcClient, EfcClientError, efc_client
from ..cases.models import Case
from .models import (
KIND_COMPLETAR,
KIND_EXPEDIENTE,
MAX_ATTEMPTS,
STATUS_FAILED,
STATUS_PENDING,
STATUS_SENT,
EfcFileOutbox,
EfcSyncOutbox,
)
logger = logging.getLogger(__name__)
# Cache de organización EFC por slug de tenant. Dict módulo-global: vive por worker y NO se
# invalida, igual que el del carril de Anexo22. Es correcto porque la organización de un tenant no
# cambia de id: el resolver de EFC es idempotente y devuelve siempre la misma. Si algún día pudiera
# cambiar, reiniciar el worker la vuelve a resolver.
_org_id_cache: dict[str, str] = {}
@contextmanager
def _savepoint(db: Session):
"""Aísla un encolado dentro de la transacción del usuario con un SAVEPOINT.
**Esto es lo único del encolado que NO se clona del carril de Anexo22, y la razón es de fondo.**
Allá el outbox vive en otra base que el pedimento, así que su ``except`` podía hacer
``db.rollback()`` sin consecuencias: revertía la sesión del outbox y la del pedimento ni se
enteraba.
Aquí el CRM es mono-base y el encolado corre DENTRO de la transacción del usuario. Un
``db.rollback()`` en el ``except`` se llevaría por delante la solicitud y el expediente que el
usuario acaba de crear — exactamente lo contrario de best-effort, y sin un solo error visible
para él. Con el SAVEPOINT, un fallo del encolado deshace **solo** la fila del outbox y la
operación local sigue en pie para que el llamador la commitee.
"""
nested = db.begin_nested()
try:
yield nested
except Exception:
nested.rollback()
raise
# ══ Expediente: encolado y entrega ══════════════════════════════════════════
def replicate_expediente_best_effort(db: Session, expediente: Case) -> None:
"""Encola la réplica del expediente a EFC y dispara la entrega inmediata.
Best-effort en todo: si EFC no está configurado, o si el encolado o el despacho fallan, **no se
propaga el error**. El expediente local ya existe y la operación del usuario no se puede romper
porque un sistema de terceros no conteste. El barrido periódico recoge lo que quede pendiente.
"""
if not settings.EFC_API_URL:
return
row = _enqueue_expediente_outbox(db, expediente)
if row is None:
return
_dispatch_delivery(row.id, row.tenant_id, row.company_id)
def _enqueue_expediente_outbox(db: Session, expediente: Case) -> Optional[EfcSyncOutbox]:
"""Inserta la fila de outbox del expediente. Devuelve ``None`` si falla, sin romper nada.
A diferencia del original, **no commitea**: el CRM es mono-base, así que la fila viaja en la
misma transacción que el expediente. Eso cierra de raíz la ventana del doble-commit que en
Anexo22 obligó a inventar el barrido de huecos.
"""
try:
if _expediente_ya_encolado(db, expediente.id):
return None
# Sin folio o sin token no hay nada que replicar: EFC exige los dos y responde
# {'storage_token': ['This field may not be null.']}, que NO es un fallo transitorio.
# Encolarlo de todos modos quemaría los 8 intentos para acabar en `failed`, ensuciando
# el tablero de ops con algo que ningún reintento puede arreglar.
#
# Pasa de verdad en dos casos: expedientes nacidos antes de que existiera el carril
# (los rellena la migración c5d6e7f8a9b0) y aquellos cuyo token no cabe en los 25
# caracteres de `pedimento_app`. Se avisa en WARNING porque es una omisión silenciosa:
# el expediente vive en el CRM y sus documentos nunca llegarán a EFC.
if not expediente.reference or not expediente.efc_storage_token:
logger.warning(
"expediente_gateway: expediente id=%s SIN replicar — folio=%r token=%r. "
"No se encola: EFC rechaza ambos nulos y el reintento no lo arregla.",
expediente.id, expediente.reference, expediente.efc_storage_token,
)
return None
# El slug del tenant NO se resuelve aquí: se rellena al ENTREGAR. Resolverlo ahora abriría
# una segunda sesión de base (``scoped_core_db``) dentro de la transacción del usuario, que
# es justo lo que el encolado debe evitar. Es además lo que hace el carril de referencia.
payload = {
"source": "crm",
"crm_company_id": expediente.company_id,
"crm_expediente_id": expediente.id,
"folio": expediente.reference,
"storage_token": expediente.efc_storage_token,
}
row = EfcSyncOutbox(
kind=KIND_EXPEDIENTE,
payload=payload,
expediente_ref=expediente.id,
status=STATUS_PENDING,
tenant_id=expediente.tenant_id,
company_id=expediente.company_id,
)
with _savepoint(db):
db.add(row)
db.flush()
return row
except Exception:
logger.warning(
"expediente_gateway: no se pudo encolar el expediente id=%s en el outbox",
getattr(expediente, "id", None), exc_info=True,
)
return None
def _expediente_ya_encolado(db: Session, expediente_id: int) -> bool:
"""¿Ya hay una fila viva de alta para este expediente? Evita encolar la misma réplica dos veces."""
return (
db.query(EfcSyncOutbox.id)
.filter(
EfcSyncOutbox.expediente_ref == expediente_id,
EfcSyncOutbox.kind == KIND_EXPEDIENTE,
EfcSyncOutbox.status.in_((STATUS_PENDING, STATUS_SENT)),
)
.first()
is not None
)
def enqueue_completar_best_effort(db: Session, expediente: Case, campos: dict) -> None:
"""Encola el completado del provisional en EFC con la data aduanera real."""
if not settings.EFC_API_URL:
return
try:
row = EfcSyncOutbox(
kind=KIND_COMPLETAR,
payload={
"source": "crm",
"crm_company_id": expediente.company_id,
"crm_expediente_id": expediente.id,
"folio": expediente.reference,
"pedimento": campos,
},
expediente_ref=expediente.id,
status=STATUS_PENDING,
tenant_id=expediente.tenant_id,
company_id=expediente.company_id,
)
with _savepoint(db):
db.add(row)
db.flush()
except Exception:
logger.warning(
"expediente_gateway: no se pudo encolar el completado del expediente id=%s",
getattr(expediente, "id", None), exc_info=True,
)
return
_dispatch_delivery(row.id, row.tenant_id, row.company_id)
def _dispatch_delivery(outbox_id: int, tenant_id: int, company_id: int) -> None:
"""Dispara la tarea de entrega propagando el contexto RLS por headers de Celery.
Best-effort: si el broker no responde, el barrido la recoge. Los headers son obligatorios —
``core/celery_app.py`` materializa el contexto de RLS a partir de ellos, y sin ellos la tarea
corre sin tenant y no ve nada.
"""
try:
from .tasks import deliver_outbox_row # import diferido: evita ciclo con celery_app
deliver_outbox_row.apply_async(
args=[outbox_id, tenant_id, company_id],
headers={"rls_tenant_id": str(tenant_id), "rls_company_id": str(company_id)},
)
except Exception:
logger.warning(
"expediente_gateway: no se pudo despachar la entrega outbox_id=%s (lo tomará el sweep)",
outbox_id, exc_info=True,
)
def deliver_row(db: Session, row: EfcSyncOutbox, client: Optional[EfcClient] = None) -> None:
"""Entrega una fila del outbox de expedientes a EFC. Actualiza estado y ``attempts``.
**No lanza nunca**: los fallos se registran en la propia fila para reconciliación. Un fallo no
puede matar al worker ni perder la intención de entregar.
"""
client = client or efc_client
if not client.is_configured:
logger.info("expediente_gateway: EFC no configurado; se deja pendiente row=%s", row.id)
return
if row.status == STATUS_SENT:
return
try:
if row.kind == KIND_EXPEDIENTE:
_deliver_expediente(db, row, client)
elif row.kind == KIND_COMPLETAR:
_deliver_completar(db, row, client)
else:
row.status = STATUS_FAILED
row.last_error = f"kind desconocido: {row.kind}"
db.commit()
except EfcClientError as exc:
_register_failure(db, row, exc, retryable=exc.retryable)
except Exception as exc: # noqa: BLE001 — cualquier fallo se registra, no rompe el worker
_register_failure(db, row, exc, retryable=True)
def _register_failure(db: Session, row: EfcSyncOutbox, exc: Exception, retryable: bool) -> None:
row.attempts = (row.attempts or 0) + 1
row.last_error = str(exc)[:2000]
if (not retryable) or row.attempts >= MAX_ATTEMPTS:
row.status = STATUS_FAILED
db.commit()
logger.warning(
"expediente_gateway: entrega falló row=%s attempts=%s retryable=%s status=%s: %s",
row.id, row.attempts, retryable, row.status, exc,
)
def _deliver_expediente(db: Session, row: EfcSyncOutbox, client: EfcClient) -> None:
payload = dict(row.payload or {})
org_id = _resolve_org_id(client, row.tenant_id)
payload["organizacion"] = {"efc_organizacion_id": org_id}
payload["crm_tenant_slug"] = _tenant_slug(row.tenant_id)[0] or ""
resp = client.ingest_expediente(payload)
efc = (resp or {}).get("efc") or {}
row.status = STATUS_SENT
row.sent_at = datetime.now(timezone.utc)
row.efc_pedimento_id = efc.get("pedimento_id")
_stamp_expediente_link(db, row.expediente_ref, org_id, efc.get("pedimento_id"))
db.commit()
logger.info(
"expediente_gateway: expediente replicado row=%s efc_pedimento_id=%s",
row.id, row.efc_pedimento_id,
)
def _deliver_completar(db: Session, row: EfcSyncOutbox, client: EfcClient) -> None:
payload = dict(row.payload or {})
org_id = _resolve_org_id(client, row.tenant_id)
payload["organizacion"] = {"efc_organizacion_id": org_id}
payload["crm_tenant_slug"] = _tenant_slug(row.tenant_id)[0] or ""
folio = payload.get("folio")
client.completar_expediente(folio, payload)
row.status = STATUS_SENT
row.sent_at = datetime.now(timezone.utc)
db.commit()
logger.info("expediente_gateway: expediente completado en EFC row=%s folio=%s", row.id, folio)
def _stamp_expediente_link(db: Session, expediente_id: Optional[int], org_id: str,
pedimento_id: Optional[str]) -> None:
"""Refleja en la fila del expediente que EFC ya lo tiene, para que la UI lo pinte.
Es un espejo, no un handle: el CRM sigue hablando de este expediente por su ``folio``. Se guarda
porque el proxy de descarga necesita el ``organizacion_id`` para preguntarle a EFC.
"""
if expediente_id is None:
return
expediente = db.query(Case).filter(Case.id == expediente_id).first()
if expediente is None:
return
expediente.efc_organizacion_id = org_id
if pedimento_id:
expediente.efc_pedimento_id = pedimento_id
expediente.efc_link_state = "LINKED"
expediente.efc_error_code = None
expediente.efc_error_detail = None
# ══ Archivos: encolado y entrega ════════════════════════════════════════════
def _ya_entregado(db: Session, source_table: str, source_id: int, kind: str) -> bool:
"""¿Este archivo ya se entregó al expediente? Evita re-encolar lo que ya está allá.
Sin esta guarda, un reintento encolaba otra entrega del mismo archivo — que además **falla al
leer el objeto local, porque la primera entrega ya lo borró** con ``delete_local``. Ruido en el
log y una fila del outbox condenada a ``failed``.
Lleva ``source_table`` además de ``source_id``, a diferencia del original: el CRM tiene dos
tablas de documentos con secuencias independientes, así que el id solo es ambiguo y esta guarda
se dispararía de más, saltándose la entrega de un documento distinto que casualmente comparte
entero.
"""
return (
db.query(EfcFileOutbox.id)
.filter(
EfcFileOutbox.source_table == source_table,
EfcFileOutbox.source_id == source_id,
EfcFileOutbox.kind == kind,
EfcFileOutbox.status == STATUS_SENT,
)
.first()
is not None
)
def enqueue_file_best_effort(
db: Session,
*,
kind: str,
s3_key: str,
file_name: str,
content_type: Optional[str],
efc_tipo: str,
source_table: str,
source_id: int,
crm_document_ref: str,
expediente_ref: int,
tenant_id: int,
company_id: int,
delete_local: bool = True,
) -> Optional[EfcFileOutbox]:
"""Encola un archivo hacia el expediente de EFC. Devuelve la fila, o ``None`` si no se encoló.
**No commitea**: la fila va en la misma transacción que el documento que la origina, de modo que
no puede existir un documento sin su intención de entrega ni al revés.
"""
if not settings.EFC_API_URL:
return None
if _ya_entregado(db, source_table, source_id, kind):
return None
try:
row = EfcFileOutbox(
kind=kind,
s3_key=s3_key,
file_name=file_name,
content_type=content_type,
efc_tipo=efc_tipo,
source_table=source_table,
source_id=source_id,
crm_document_ref=crm_document_ref,
expediente_ref=expediente_ref,
delete_local=delete_local,
status=STATUS_PENDING,
tenant_id=tenant_id,
company_id=company_id,
)
with _savepoint(db):
db.add(row)
db.flush()
return row
except Exception:
logger.warning(
"expediente_gateway: no se pudo encolar el archivo %s (%s:%s)",
s3_key, source_table, source_id, exc_info=True,
)
return None
def _dispatch_file_delivery(outbox_id: int, tenant_id: int, company_id: int) -> None:
try:
from .tasks import deliver_file_outbox_row # import diferido
deliver_file_outbox_row.apply_async(
args=[outbox_id, tenant_id, company_id],
headers={"rls_tenant_id": str(tenant_id), "rls_company_id": str(company_id)},
)
except Exception:
logger.warning(
"expediente_gateway: no se pudo despachar entrega de archivo outbox_id=%s (lo tomará el sweep)",
outbox_id, exc_info=True,
)
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.
**Ensure-then-upload**: si EFC contesta 404 ``expediente_no_encontrado``, la creación del
provisional puede venir en camino (el outbox de expedientes y el de archivos son colas
distintas), así que se asegura el expediente y se reintenta el upload **una** vez.
**No lanza nunca**: como ``deliver_row``, registra el fallo en la propia fila.
"""
client = client or efc_client
if not client.is_configured or row.status == STATUS_SENT:
return
try:
org_id = _resolve_org_id(client, row.tenant_id)
expediente = db.query(Case).filter(Case.id == row.expediente_ref).first()
if expediente is None:
raise EfcClientError(
f"expediente {row.expediente_ref} no encontrado para el archivo '{row.kind}'",
retryable=True,
)
from core.storage_s3 import get_object_bytes
content = get_object_bytes(row.s3_key)
ct = row.content_type or "application/octet-stream"
try:
resp = client.upload_documento(
org_id, row.company_id, expediente.id, row.efc_tipo,
row.file_name, content, ct, crm_document_ref=row.crm_document_ref,
)
except EfcClientError as exc:
if exc.status_code == 404 and exc.code == "expediente_no_encontrado":
# La creación del provisional puede venir en camino: se asegura y se reintenta UNA vez.
client.ingest_expediente({
"source": "crm",
"crm_tenant_slug": (_tenant_slug(row.tenant_id)[0] or ""),
"crm_company_id": row.company_id,
"crm_expediente_id": expediente.id,
"folio": expediente.reference,
"storage_token": expediente.efc_storage_token,
"organizacion": {"efc_organizacion_id": org_id},
})
resp = client.upload_documento(
org_id, row.company_id, expediente.id, row.efc_tipo,
row.file_name, content, ct, crm_document_ref=row.crm_document_ref,
)
else:
raise
doc_id = resp.get("id") if isinstance(resp, dict) else None
if row.delete_local:
try:
from core.storage_s3 import delete_object_if_exists
delete_object_if_exists(row.s3_key)
except Exception:
# Ya está en EFC: no poder borrar la copia local no invalida la entrega.
logger.warning(
"expediente_gateway: no se pudo borrar el archivo local %s (ya en EFC)",
row.s3_key, exc_info=True,
)
row.status = STATUS_SENT
row.sent_at = datetime.now(timezone.utc)
row.efc_document_id = doc_id
db.commit()
_marcar_documento_entregado(db, row, doc_id)
logger.info(
"expediente_gateway: archivo entregado row=%s kind=%s efc_document_id=%s",
row.id, row.kind, doc_id,
)
except EfcClientError as exc:
_register_file_failure(db, row, exc, exc.retryable)
except Exception as exc: # noqa: BLE001
_register_file_failure(db, row, exc, True)
def _register_file_failure(db: Session, row: EfcFileOutbox, exc: Exception, retryable: bool) -> None:
row.attempts = (row.attempts or 0) + 1
row.last_error = str(exc)[:2000]
if (not retryable) or row.attempts >= MAX_ATTEMPTS:
row.status = STATUS_FAILED
db.commit()
_marcar_documento_fallido(db, row, exc)
logger.warning(
"expediente_gateway: entrega de archivo falló row=%s attempts=%s status=%s: %s",
row.id, row.attempts, row.status, exc,
)
# El mapa (kind, source_table) -> modelo del documento de origen. Un par que NO esté aquí **no toca
# nada**, en vez de caer por omisión sobre una tabla cualquiera: escribir con el id de otra tabla
# vaciaría las columnas de un documento ajeno que tuviera ese mismo entero — daño en el dato de otro,
# sin un solo error visible.
def _modelo_de_origen(source_table: str):
if source_table == "crm.documents":
from ..documents.models import Document
return Document
if source_table == "ops.shipment_documents":
from api.v1.modules.ops.shipments.models import ShipmentDocument
return ShipmentDocument
return None
def _fila_de_origen(db: Session, row: EfcFileOutbox):
modelo = _modelo_de_origen(row.source_table)
if modelo is None or row.source_id is None:
return None
return (
db.query(modelo)
.filter(
modelo.id == row.source_id,
modelo.tenant_id == row.tenant_id,
modelo.company_id == row.company_id,
)
.first()
)
def _marcar_documento_entregado(db: Session, row: EfcFileOutbox, doc_id) -> None:
"""Cierra la entrega en la fila del documento: el badge de la UI pasa a «En expediente»."""
documento = _fila_de_origen(db, row)
if documento is None:
return
documento.efc_document_id = str(doc_id) if doc_id else None
documento.efc_sync_state = "SYNCED"
documento.efc_synced_at = datetime.now(timezone.utc)
documento.efc_error_code = None
documento.efc_error_detail = None
if row.delete_local:
# El objeto local ya no está: dejar la key apuntaría a algo inexistente y la descarga se
# ramificaría por el camino equivocado.
documento.file_key = None
db.commit()
def _marcar_documento_fallido(db: Session, row: EfcFileOutbox, exc: Exception) -> None:
"""Refleja el fallo en la fila del documento para que la ficha lo muestre sin ir a los logs."""
documento = _fila_de_origen(db, row)
if documento is None:
return
documento.efc_attempts = row.attempts
documento.efc_error_detail = str(exc)[:2000]
documento.efc_error_code = getattr(exc, "code", None)
if row.status == STATUS_FAILED:
documento.efc_sync_state = "FAILED"
db.commit()
# ══ Organización ════════════════════════════════════════════════════════════
def _resolve_org_id(client: EfcClient, tenant_id: int) -> str:
slug, name = _tenant_slug(tenant_id)
if not slug:
raise EfcClientError(
f"tenant {tenant_id} sin slug; no se puede resolver la organización EFC.",
retryable=False,
)
if slug in _org_id_cache:
return _org_id_cache[slug]
resp = client.resolve_organizacion(slug, name)
org_id = resp.get("id") if isinstance(resp, dict) else None
if not org_id:
raise EfcClientError("El resolver de organización de EFC no devolvió id.", retryable=True)
_org_id_cache[slug] = org_id
return org_id
def _tenant_slug(tenant_id: int) -> tuple[Optional[str], Optional[str]]:
from api.v1.modules.core.tenants.models import Tenant
with scoped_core_db(tenant_id=tenant_id) as db:
t = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if t is None:
return None, None
return t.slug, t.name
# ══ Tablero de ops ══════════════════════════════════════════════════════════
def _outbox_to_dict(r: EfcSyncOutbox) -> dict:
return {
"id": r.id,
"tabla": "sync",
"kind": r.kind,
"status": r.status,
"attempts": r.attempts,
"last_error": r.last_error,
"expediente_ref": r.expediente_ref,
"efc_pedimento_id": r.efc_pedimento_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
"sent_at": r.sent_at.isoformat() if r.sent_at else None,
}
def _file_outbox_to_dict(r: EfcFileOutbox) -> dict:
return {
"id": r.id,
"tabla": "file",
"kind": r.kind,
"status": r.status,
"attempts": r.attempts,
"last_error": r.last_error,
"expediente_ref": r.expediente_ref,
"file_name": r.file_name,
"efc_tipo": r.efc_tipo,
"source_table": r.source_table,
"source_id": r.source_id,
"crm_document_ref": r.crm_document_ref,
"efc_document_id": r.efc_document_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
"sent_at": r.sent_at.isoformat() if r.sent_at else None,
}
def list_outbox(db: Session, tenant_id: int, company_id: int, tipo: Optional[str] = None,
status: Optional[str] = None, limit: int = 100) -> list[dict]:
"""Lista filas de los DOS outbox para el tablero de ops. ``tipo`` ∈ ``sync`` | ``file``."""
salida: list[dict] = []
if tipo in (None, "", "sync"):
q = db.query(EfcSyncOutbox).filter(
EfcSyncOutbox.tenant_id == tenant_id, EfcSyncOutbox.company_id == company_id
)
if status:
q = q.filter(EfcSyncOutbox.status == status)
salida += [
_outbox_to_dict(r)
for r in q.order_by(EfcSyncOutbox.created_at.desc()).limit(limit).all()
]
if tipo in (None, "", "file"):
q = db.query(EfcFileOutbox).filter(
EfcFileOutbox.tenant_id == tenant_id, EfcFileOutbox.company_id == company_id
)
if status:
q = q.filter(EfcFileOutbox.status == status)
salida += [
_file_outbox_to_dict(r)
for r in q.order_by(EfcFileOutbox.created_at.desc()).limit(limit).all()
]
salida.sort(key=lambda d: (d.get("created_at") or ""), reverse=True)
return salida[:limit]
def retry_outbox_row(db: Session, outbox_id: int, tenant_id: int, company_id: int,
tipo: str = "file") -> bool:
"""Reintento manual: resetea la fila a ``pending`` (``attempts=0``) y la re-despacha.
Devuelve ``False`` si no existe para ese tenant/company — el llamador lo traduce a **404 con
mensaje específico**, no a un 200 silencioso: es contrato con el frontend, que pinta el botón
según lo que reciba.
"""
modelo = EfcSyncOutbox if tipo == "sync" else EfcFileOutbox
r = (
db.query(modelo)
.filter(modelo.id == outbox_id, modelo.tenant_id == tenant_id, modelo.company_id == company_id)
.first()
)
if r is None:
return False
r.status = STATUS_PENDING
r.attempts = 0
r.last_error = None
db.commit()
if tipo == "sync":
_dispatch_delivery(r.id, r.tenant_id, r.company_id)
else:
_reset_documento_pendiente(db, r)
_dispatch_file_delivery(r.id, r.tenant_id, r.company_id)
return True
def _reset_documento_pendiente(db: Session, row: EfcFileOutbox) -> None:
documento = _fila_de_origen(db, row)
if documento is None:
return
documento.efc_sync_state = "PENDING"
documento.efc_error_code = None
documento.efc_error_detail = None
db.commit()
def outbox_metrics(db: Session, tenant_id: int, company_id: int) -> dict:
"""Conteo de los dos outbox por status (monitoreo). Los conteos suman las dos tablas."""
from sqlalchemy import func
counts = {STATUS_PENDING: 0, STATUS_SENT: 0, STATUS_FAILED: 0}
for modelo in (EfcSyncOutbox, EfcFileOutbox):
rows = (
db.query(modelo.status, func.count())
.filter(modelo.tenant_id == tenant_id, modelo.company_id == company_id)
.group_by(modelo.status)
.all()
)
for estado, n in rows:
counts[estado] = counts.get(estado, 0) + n
return {
"pending": counts.get(STATUS_PENDING, 0),
"sent": counts.get(STATUS_SENT, 0),
"failed": counts.get(STATUS_FAILED, 0),
}
def find_expediente_gaps(db: Session, limit: int = 200) -> list:
"""Expedientes (no borrados) SIN ninguna fila de outbox que los referencie.
Nunca se encolaron: expedientes creados **antes** de activar la integración, o un crash. Se
re-encolan para no perder la réplica.
Los ``failed`` **no son huecos** —existen como fila, son visibles y reintentables desde el
tablero—, así que la fila los excluye por estar presente, no por su estado. Corre sin contexto
de tenant (beat); cada expediente lleva el suyo.
"""
from sqlalchemy import exists
ya_encolado = exists().where(EfcSyncOutbox.expediente_ref == Case.id)
return (
db.query(Case)
.filter(
Case.deleted_at.is_(None),
~ya_encolado,
# Mismo criterio que el encolado: lo que le falta folio o token no es un hueco
# recuperable, es algo que EFC rechazaría siempre. Sin este filtro la
# reconciliación los reencola cada 5 minutos para verlos fallar de nuevo.
Case.reference.isnot(None),
Case.efc_storage_token.isnot(None),
)
.order_by(Case.id.desc())
.limit(limit)
.all()
)

View File

@@ -0,0 +1,39 @@
"""La llave de almacenamiento del expediente en EFC.
Vive en el carril y no en el módulo del expediente a propósito: el expediente (``crm.cases``) es
del CRM y no sabe nada de EFC; esto es exclusivamente cómo EFC nombra su carpeta.
El generador de folios NO está aquí. Es ``crm/common/folios.py::next_folio``, que ya reserva el
consecutivo mensual por ``(tenant, company, entidad, periodo)`` con bloqueo de fila. El carril lo
consume, no lo reimplementa.
"""
from __future__ import annotations
# Longitud de ``Pedimento.pedimento_app`` en EFC (api/customs/models.py). El token se guarda ahí.
PEDIMENTO_APP_MAX = 25
def storage_token(company_id: int, folio: str) -> str:
"""``CRM-{company_id}-{folio}`` — la llave del pedimento provisional en EFC.
Empieza con letras, así que es imposible que colisione con la llave de un pedimento real, que
es ``^\\d{2}-\\d{2}-\\d{4}-\\d{7}$``. El ``company_id`` va dentro porque el puente con EFC es
tenant → organización 1:1 pero un tenant tiene N companies: sin él, dos companies del mismo
tenant generarían el mismo ``EXP2026-08-001`` y chocarían en el ``unique_together`` de EFC.
PRESUPUESTO DE CARACTERES: ``CRM-`` (4) + company + ``-`` (1) + ``EXP2026-08-001`` (14) = 19 +
los dígitos del company. En los 25 de ``pedimento_app`` caben hasta **6 dígitos** de company,
no 7 como decía la primera versión de este docstring: con 7 salen 26 y el insert del lado de
EFC reventaría. Un consecutivo de 4 dígitos (mes con más de 999 expedientes) gasta uno más.
Se valida en vez de truncar: un token recortado apuntaría a la carpeta de OTRO expediente y
los documentos se mezclarían en silencio, que es peor que fallar aquí.
"""
token = f"CRM-{company_id}-{folio}"
if len(token) > PEDIMENTO_APP_MAX:
raise ValueError(
f"storage_token de {len(token)} caracteres excede los {PEDIMENTO_APP_MAX} de "
f"pedimento_app en EFC: {token!r}. Revisa el largo del company_id o del consecutivo."
)
return token

View File

@@ -0,0 +1,143 @@
"""Tareas Celery del carril CRM Agentes de Carga -> EFC.
- ``deliver_outbox_row`` / ``sweep_outbox``: expedientes (alta del provisional y completado).
- ``deliver_file_outbox_row`` / ``sweep_file_outbox``: archivos.
- ``sweep_expediente_gaps``: reconciliación de expedientes que nunca se encolaron.
**La trampa de RLS, que es lo que más fácil se pasa por alto.** ``core/celery_app.py`` materializa el
contexto desde los headers ``rls_tenant_id`` / ``rls_company_id``. Por tanto:
- Las tareas **por fila** se despachan siempre con esos headers.
- Los **barridos corren sin contexto de tenant**: leen los ids pendientes con una sesión sin scope y
despachan una tarea hija por fila con sus propios headers. Si un barrido abriera una sesión con
scope e iterara, o no vería nada o se saltaría el aislamiento.
**Sin ``autoretry_for``, ``retry_backoff`` ni ``max_retries``**: duplicarían el mecanismo de
reintento que ya está en el cliente (3 intentos con backoff lineal) y en el barrido (cada 120 s
hasta ``MAX_ATTEMPTS``).
"""
import logging
from core.celery_app import celery_app
from core.config import settings
from core.database import scoped_core_db
from . import service
from .models import STATUS_PENDING, EfcFileOutbox, EfcSyncOutbox
# ── Registro de modelos: NO son imports decorativos, no los quites ──────────────────────
# El worker de Celery NO carga la app: importa este módulo y sus dependencias, y nada más.
# SQLAlchemy resuelve las ForeignKey por NOMBRE de tabla contra su registro global, así que
# si la clase del otro extremo nunca se importó, la configuración de mappers falla con
#
# Foreign key associated with column 'cases.account_id' could not find table 'crm.accounts'
#
# y la tarea muere con PendingRollbackError. El síntoma es cruel: la fila del outbox se
# queda en `pending` con attempts=0 y SIN last_error —porque el fallo ocurre antes de poder
# registrarlo—, así que el carril se ve encolando bien y no entrega nunca. En la app web no
# pasa: `main.py` monta todos los routers y con ellos se importan todos los modelos.
#
# El juego es el mínimo verificado con `configure_mappers()` en un proceso limpio:
# - accounts : cierra la FK cases.account_id, que es la que rompía;
# - documents y ops.shipments : las dos fuentes del outbox de archivos;
# - tenants : lo consulta el resolver de organización al entregar.
from api.v1.modules.core.tenants import models as _m_tenants # noqa: F401
from api.v1.modules.crm.accounts import models as _m_accounts # noqa: F401
from api.v1.modules.crm.documents import models as _m_documents # noqa: F401
from api.v1.modules.ops.shipments import models as _m_shipments # noqa: F401
logger = logging.getLogger(__name__)
# ── Expedientes ─────────────────────────────────────────────────────────────
@celery_app.task(name="expediente_gateway.deliver_outbox_row")
def deliver_outbox_row(outbox_id: int, tenant_id: int, company_id: int) -> None:
with scoped_core_db(tenant_id, company_id) as db:
row = db.query(EfcSyncOutbox).filter(EfcSyncOutbox.id == outbox_id).first()
if row is None:
logger.warning(
"expediente_gateway: outbox_id=%s no encontrado (tenant=%s)", outbox_id, tenant_id
)
return
service.deliver_row(db, row)
@celery_app.task(name="expediente_gateway.sweep_outbox")
def sweep_outbox(limit: int = 100) -> int:
"""Re-despacha filas pendientes de expediente. Sin contexto de tenant: cada fila lleva el suyo."""
with scoped_core_db() as db:
rows = (
db.query(EfcSyncOutbox.id, EfcSyncOutbox.tenant_id, EfcSyncOutbox.company_id)
.filter(EfcSyncOutbox.status == STATUS_PENDING)
.order_by(EfcSyncOutbox.created_at.asc())
.limit(limit)
.all()
)
for rid, tid, cid in rows:
deliver_outbox_row.apply_async(
args=[rid, tid, cid],
headers={"rls_tenant_id": str(tid), "rls_company_id": str(cid) if cid is not None else ""},
)
if rows:
logger.info("expediente_gateway: sweep (expedientes) re-despachó %s filas pendientes", len(rows))
return len(rows)
# ── Archivos ────────────────────────────────────────────────────────────────
@celery_app.task(name="expediente_gateway.deliver_file_outbox_row")
def deliver_file_outbox_row(outbox_id: int, tenant_id: int, company_id: int) -> None:
with scoped_core_db(tenant_id, company_id) as db:
row = db.query(EfcFileOutbox).filter(EfcFileOutbox.id == outbox_id).first()
if row is None:
logger.warning(
"expediente_gateway: file outbox_id=%s no encontrado (tenant=%s)", outbox_id, tenant_id
)
return
service.deliver_file_row(db, row)
@celery_app.task(name="expediente_gateway.sweep_file_outbox")
def sweep_file_outbox(limit: int = 100) -> int:
"""Re-despacha archivos pendientes (EFC o el broker caídos cuando el usuario subió el archivo)."""
with scoped_core_db() as db:
rows = (
db.query(EfcFileOutbox.id, EfcFileOutbox.tenant_id, EfcFileOutbox.company_id)
.filter(EfcFileOutbox.status == STATUS_PENDING)
.order_by(EfcFileOutbox.created_at.asc())
.limit(limit)
.all()
)
for rid, tid, cid in rows:
deliver_file_outbox_row.apply_async(
args=[rid, tid, cid],
headers={"rls_tenant_id": str(tid), "rls_company_id": str(cid) if cid is not None else ""},
)
if rows:
logger.info("expediente_gateway: sweep (archivos) re-despachó %s archivos pendientes", len(rows))
return len(rows)
# ── Reconciliación de huecos ────────────────────────────────────────────────
@celery_app.task(name="expediente_gateway.sweep_expediente_gaps")
def sweep_expediente_gaps(limit: int = 200) -> int:
"""Detecta expedientes que nunca se encolaron a EFC y los re-encola.
No-op si la integración está apagada.
"""
if not settings.EFC_API_URL:
return 0
n = 0
with scoped_core_db() as db:
gaps = service.find_expediente_gaps(db, limit=limit)
for expediente in gaps:
service.replicate_expediente_best_effort(db, expediente)
n += 1
if n:
db.commit()
if n:
logger.info("expediente_gateway: sweep de huecos re-encoló %s expedientes", n)
return n

View File

@@ -11,6 +11,7 @@ class LeadCreate(BaseModel):
phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
preferred_contact_method: str | None = Field(None, max_length=20)
status: str = Field("new", max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64)
@@ -24,6 +25,7 @@ class LeadUpdate(BaseModel):
phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
preferred_contact_method: str | None = Field(None, max_length=20)
status: str | None = Field(None, max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64)
@@ -50,6 +52,7 @@ class LeadResponse(BaseModel):
phone: str | None
company_name: str | None
source: str | None
preferred_contact_method: str | None = None
status: str
estimated_value: Decimal | None
owner_user_id: str | None

View File

@@ -19,6 +19,8 @@ class Lead(Base, TenantScopedMixin, TimestampMixin):
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Origen: web | referido | evento | llamada | email | otro
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
# Medio de contacto preferido (catálogo medio_contacto): llamada|correo|whatsapp|…
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Estado: new | contacted | qualified | unqualified | converted
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True)
estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)

View File

@@ -17,6 +17,7 @@ class OpportunityCreate(BaseModel):
source: str | None = Field(None, max_length=60)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
operation_type: str | None = Field(None, max_length=20) # importacion | exportacion
class OpportunityUpdate(BaseModel):
@@ -30,10 +31,13 @@ class OpportunityUpdate(BaseModel):
probability: int | None = Field(None, ge=0, le=100)
status: str | None = Field(None, max_length=20)
expected_close_date: date | None = None
won_date: date | None = None
lost_date: date | None = None
lost_reason: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
operation_type: str | None = Field(None, max_length=20)
class OpportunityMove(BaseModel):
@@ -57,10 +61,16 @@ class OpportunityResponse(BaseModel):
status: str
expected_close_date: date | None
closed_at: datetime | None
won_date: date | None = None
lost_date: date | None = None
lost_reason: str | None
source: str | None
owner_user_id: str | None
notes: str | None
operation_type: str | None = None
reference: str | None = None
case_id: int | None = None
converted_service_request_id: int | None = None
tenant_id: int
company_id: int
created_at: datetime

View File

@@ -34,7 +34,18 @@ class Opportunity(Base, TenantScopedMixin, TimestampMixin):
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'open'"), index=True)
expected_close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
won_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se ganó
lost_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se perdió
lost_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Dirección de la operación (importacion|exportacion): se hereda a Solicitud→Cotización→Embarque
operation_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio O...
# Expediente (hilo maestro del trámite); nace aquí y se hereda hacia abajo
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True)
# Solicitud generada al convertir la oportunidad (back-link idempotente)
converted_service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True
)

View File

@@ -1,9 +1,11 @@
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..cases import service as cases_service
from ..common.folios import next_folio
from ..contacts.models import Contact
from ..pipelines.models import Pipeline, PipelineStage
from .dto import OpportunityCreate, OpportunityUpdate
@@ -46,14 +48,20 @@ def _apply_stage_state(opportunity: Opportunity, stage: PipelineStage) -> None:
opportunity.status = "won"
opportunity.probability = 100
opportunity.closed_at = datetime.now(timezone.utc)
opportunity.won_date = opportunity.won_date or date.today()
opportunity.lost_date = None
elif stage.is_lost:
opportunity.status = "lost"
opportunity.probability = 0
opportunity.closed_at = datetime.now(timezone.utc)
opportunity.lost_date = opportunity.lost_date or date.today()
opportunity.won_date = None
else:
opportunity.status = "open"
opportunity.probability = stage.probability
opportunity.closed_at = None
opportunity.won_date = None
opportunity.lost_date = None
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
@@ -149,6 +157,15 @@ def create_opportunity(
if opportunity.stage_id is not None:
stage = _get_scoped_stage(db, opportunity.stage_id, tenant_id, company_id)
_apply_stage_state(opportunity, stage)
# Folio O... auto-generado (mensual). La dirección impo/expo se hereda al ciclo.
if not opportunity.reference:
opportunity.reference = next_folio(db, tenant_id, company_id, "O", opportunity.operation_type)
# Expediente: nace con la oportunidad y se hereda a solicitud/cotización/operación/factura
if not opportunity.case_id:
case = cases_service.create_case(
db, tenant_id, company_id, account_id=opportunity.account_id, title=opportunity.name, stage="oportunidad",
)
opportunity.case_id = case.id
db.add(opportunity)
db.commit()
db.refresh(opportunity)

View File

@@ -56,6 +56,7 @@ class QuoteBase(BaseModel):
service_request_id: int | None = None
account_id: int | None = None
currency: str = Field("USD", max_length=3)
load_type: str | None = Field(None, max_length=10) # FCL | LCL (variante de la comparación "Ambas")
issue_date: date | None = None
valid_until: date | None = None
notes: str | None = None
@@ -72,6 +73,7 @@ class QuoteUpdate(BaseModel):
service_request_id: int | None = None
account_id: int | None = None
currency: str | None = Field(None, max_length=3)
load_type: str | None = Field(None, max_length=10)
issue_date: date | None = None
valid_until: date | None = None
notes: str | None = None
@@ -83,6 +85,8 @@ class QuoteResponse(QuoteBase):
model_config = ConfigDict(from_attributes=True)
id: int
service_request_reference: str | None = None # folio de la solicitud referenciada
case_id: int | None = None
status: str
total_cost: Decimal
total_sale: Decimal

View File

@@ -15,6 +15,7 @@ class Quote(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True
)
@@ -22,6 +23,8 @@ class Quote(Base, TenantScopedMixin, TimestampMixin):
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'USD'"))
# Variante de carga cuando la solicitud es "Ambas": FCL | LCL (NULL si no aplica)
load_type: Mapped[str | None] = mapped_column(String(10), nullable=True)
# borrador | enviada | aceptada | rechazada
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"), index=True)
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)

View File

@@ -59,6 +59,14 @@ def set_logo_key(db: Session, tenant_id: int, company_id: int, file_key: str) ->
return obj
def _compose_place(city: str | None, country: str | None, port: str | None) -> str | None:
"""Arma 'Ciudad, PAÍS (Puerto)' con las partes que existan (ruta estructurada)."""
head = ", ".join(p for p in (city, country) if p)
if port:
head = f"{head} ({port})" if head else port
return head or None
def _company_row(db: Session, company_id: int) -> dict:
try:
row = db.execute(
@@ -139,7 +147,8 @@ def build_pdf_bytes(db: Session, quote: Quote, tenant_id: int, company_id: int)
route = [
("Operación", sr.operation_type), ("Modo", sr.transport_mode),
("Servicio", sr.service_type), ("Incoterm", sr.incoterm),
("Origen", sr.origin), ("Destino", sr.destination),
("Origen", sr.origin or _compose_place(sr.origin_city, sr.origin_country, sr.origin_port)),
("Destino", sr.destination or _compose_place(sr.destination_city, sr.destination_country, sr.destination_port)),
("Fecha requerida", sr.required_date.isoformat() if sr.required_date else None),
]

View File

@@ -110,6 +110,24 @@ def create_quote(
return service.create_quote(db, payload, tenant_id, company_id, _user_id(current_user))
@router.post(
"/quotes/from-service-request",
response_model=list[QuoteResponse],
status_code=status.HTTP_201_CREATED,
)
def create_quotes_from_service_request(
service_request_id: int = Query(..., description="Solicitud de servicio a cotizar"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Genera la(s) cotización(es) desde una solicitud. Si es 'Ambas' devuelve 2 (FCL/LCL)."""
tenant_id = current_user["tenant_id"]
return service.create_quotes_from_service_request(
db, service_request_id, tenant_id, company_id, _user_id(current_user)
)
@router.patch("/quotes/{quote_id}", response_model=QuoteResponse)
def update_quote(
quote_id: int,

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from decimal import Decimal
from fastapi import HTTPException, status
@@ -6,7 +6,11 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..service_requests.models import ServiceRequest
from ..cases import service as cases_service
from ..catalogs.models import CatalogItem
from ..common.folios import next_folio
from ..common.pricing import air_chargeable_kg
from ..service_requests.models import RateRequest, ServiceRequest
from ..suppliers.models import Supplier
from .dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate, QuoteUpdate
from .models import Quote, QuoteItem
@@ -70,7 +74,18 @@ def get_quotes(
query = query.filter(Quote.account_id == account_id)
if search:
query = query.filter(Quote.reference.ilike(f"%{search}%"))
return query.order_by(Quote.created_at.desc()).all()
quotes = query.order_by(Quote.created_at.desc()).all()
# Enriquecer con el folio de la solicitud referenciada (para verlo en la lista)
sr_ids = {q.service_request_id for q in quotes if q.service_request_id}
if sr_ids:
refs = dict(
db.query(ServiceRequest.id, ServiceRequest.reference)
.filter(ServiceRequest.id.in_(sr_ids))
.all()
)
for q in quotes:
q.service_request_reference = refs.get(q.service_request_id)
return quotes
def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
@@ -89,18 +104,142 @@ def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Qu
return obj
def _sr_direction(db: Session, service_request_id: int | None) -> str | None:
"""Dirección impo/expo heredada de la solicitud asociada (para el folio)."""
if not service_request_id:
return None
sr = db.query(ServiceRequest).filter(ServiceRequest.id == service_request_id).first()
return sr.operation_type if sr else None
def create_quote(
db: Session, payload: QuoteCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Quote:
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
obj = Quote(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
# Fecha de la cotización: por defecto hoy si no se capturó
if obj.issue_date is None:
obj.issue_date = date.today()
# Folio C... auto-generado (mensual), con la dirección heredada de la solicitud
if not obj.reference:
obj.reference = next_folio(db, tenant_id, company_id, "C", _sr_direction(db, obj.service_request_id))
# Expediente heredado de la solicitud
if obj.service_request_id and not obj.case_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == obj.service_request_id).first()
if sr:
obj.case_id = sr.case_id
cases_service.advance_stage(db, obj.case_id, "cotizacion")
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def create_quotes_from_service_request(
db: Session, service_request_id: int, tenant_id: int, company_id: int, user_id: str | None = None
) -> list[Quote]:
"""Genera cotización(es) a partir de una solicitud de servicio.
Si la solicitud es "Ambas" (FCL y LCL), genera **dos** cotizaciones (una por
variante) para comparar. Cada cotización toma su propio folio C... y hereda la
dirección impo/expo de la solicitud. Los conceptos se siembran desde las
solicitudes de tarifa (RateRequest) capturadas en la solicitud.
"""
sr = (
db.query(ServiceRequest)
.filter(
ServiceRequest.id == service_request_id,
ServiceRequest.tenant_id == tenant_id,
ServiceRequest.company_id == company_id,
ServiceRequest.deleted_at.is_(None),
)
.first()
)
if not sr:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud no encontrada")
variants = ["FCL", "LCL"] if (sr.load_type or "").upper() == "AMBAS" else [sr.load_type or None]
rate_requests = (
db.query(RateRequest)
.filter(
RateRequest.service_request_id == sr.id,
RateRequest.tenant_id == tenant_id,
RateRequest.company_id == company_id,
RateRequest.deleted_at.is_(None),
)
.all()
)
# Etiquetas legibles de los servicios adicionales (global + tenant) para los conceptos
service_labels = {
code: label
for code, label in db.query(CatalogItem.code, CatalogItem.label).filter(
CatalogItem.catalog == "servicio_adicional"
)
}
service_costs = sr.additional_service_costs or {}
created: list[Quote] = []
for variant in variants:
quote = Quote(
account_id=sr.account_id,
service_request_id=sr.id,
currency=sr.currency or "USD",
load_type=variant,
status="borrador",
issue_date=date.today(),
notes=sr.client_notes or sr.notes,
owner_user_id=sr.owner_user_id,
reference=next_folio(db, tenant_id, company_id, "C", sr.operation_type),
case_id=sr.case_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(quote)
db.flush()
for rr in rate_requests:
amount = rr.rate_amount if rr.rate_amount is not None else Decimal(0)
db.add(QuoteItem(
quote_id=quote.id, concept=rr.concept, description=rr.description,
supplier_id=rr.supplier_id, quantity=Decimal(1),
unit_cost=amount, unit_sale=amount, currency=rr.currency,
tenant_id=tenant_id, company_id=company_id,
))
# Servicios adicionales marcados en la solicitud → conceptos con su costo estimado
for code in (sr.additional_services or []):
amount = Decimal(str(service_costs.get(code) or 0))
db.add(QuoteItem(
quote_id=quote.id, concept=code[:60],
description=service_labels.get(code, "Servicio adicional"),
quantity=Decimal(1), unit_cost=amount, unit_sale=amount,
currency=sr.currency, tenant_id=tenant_id, company_id=company_id,
))
# Carga aérea: concepto de flete con el peso a cobrar (P/Vol) como cantidad,
# para que el ejecutivo capture la tarifa por kg.
if (variant or "").upper() == "AEREO":
chargeable = air_chargeable_kg(
sr.weight, sr.length_cm, sr.width_cm, sr.height_cm,
sr.pallets_count or sr.pieces_count or 1,
)
db.add(QuoteItem(
quote_id=quote.id, concept="flete_internacional",
description=f"Flete aéreo — peso a cobrar {chargeable.quantize(Decimal('0.01'))} kg (P/Vol)",
quantity=chargeable, unit_cost=Decimal(0), unit_sale=Decimal(0),
currency=sr.currency, tenant_id=tenant_id, company_id=company_id,
))
db.flush()
_recompute_totals(db, quote)
created.append(quote)
cases_service.advance_stage(db, sr.case_id, "cotizacion")
db.commit()
for quote in created:
db.refresh(quote)
return created
def update_quote(
db: Session, quote_id: int, payload: QuoteUpdate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Quote:

View File

@@ -146,6 +146,10 @@ class CostRequest(BaseModel):
on_date: date | None = None
gross_weight_kg: Decimal | None = None
volume_m3: Decimal | None = None
# Dimensiones (cm) para el peso volumétrico aéreo (P/Vol = L×A×H×cant / 6000)
length_cm: Decimal | None = None
width_cm: Decimal | None = None
height_cm: Decimal | None = None
equipment_type: str | None = None
quantity: int = 1
dangerous: bool = False

View File

@@ -255,3 +255,15 @@ def rate_quote(
tenant_id, _ = _ctx(current_user)
options = service.quote_cost(db, tenant_id, company_id, req)
return CostResult(request=req, options=options)
@cost_router.get("/rate-locations")
def rate_locations(
mode: str = Query(...),
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Orígenes/destinos cotizables (de los tarifarios activos) para alinear el cotizador."""
tenant_id, _ = _ctx(current_user)
return service.lane_locations(db, tenant_id, company_id, mode)

View File

@@ -20,9 +20,11 @@ from .dto import (
RateSheetCreate,
RateSheetUpdate,
)
from ..common.pricing import air_volumetric_kg
from .models import RateBreak, RateCharge, RateLane, RateSheet
# Factor volumétrico aéreo: 1 m³ = 167 kg (equivale a 6000 cm³/kg).
# Respaldo cuando solo se conoce el volumen en m³ (sin dimensiones cm).
AIR_VOLUMETRIC_FACTOR = Decimal("167")
@@ -477,6 +479,30 @@ def _apply_charges(db: Session, sheet: RateSheet, lane: RateLane, base: Decimal,
return lines
def lane_locations(db: Session, tenant_id: int, company_id: int, mode: str) -> dict[str, list[str]]:
"""Orígenes/destinos existentes en los tarifarios activos de un modo.
Alinea el cotizador con las rutas realmente cotizables (los códigos provienen
de las lanes, por lo que el costeo siempre encontrará ruta).
"""
sheets = _sheet_query(db, tenant_id, company_id).filter(
RateSheet.mode == mode, RateSheet.status == "activo",
).all()
origins: set[str] = set()
destinations: set[str] = set()
for sheet in sheets:
lanes = db.query(RateLane).filter(
RateLane.rate_sheet_id == sheet.id, RateLane.deleted_at.is_(None),
).all()
for lane in lanes:
origin = lane.origin or sheet.default_origin
if origin:
origins.add(origin)
if lane.destination:
destinations.add(lane.destination)
return {"origins": sorted(origins), "destinations": sorted(destinations)}
def quote_cost(db: Session, tenant_id: int, company_id: int, req: CostRequest) -> list[CostOption]:
on_date = req.on_date or date.today()
sheets = _sheet_query(db, tenant_id, company_id).filter(
@@ -518,11 +544,15 @@ def quote_cost(db: Session, tenant_id: int, company_id: int, req: CostRequest) -
base = max(base, lane.min_charge or Decimal(0))
detail = f"W/M {wm.quantize(Decimal('0.01'))}"
else: # aereo
chargeable = max(gross, _volumetric_kg(req.volume_m3))
# P/Vol por dimensiones (L×A×H×cant / 6000); si no hay dimensiones,
# respaldo con el volumen en m³ × 167.
vol_by_dims = air_volumetric_kg(req.length_cm, req.width_cm, req.height_cm, req.quantity)
volumetric = vol_by_dims if vol_by_dims > 0 else _volumetric_kg(req.volume_m3)
chargeable = max(gross, volumetric)
brks = breaks_of(db, lane.id)
base = _best_break_cost(brks, chargeable)
base = max(base, lane.min_charge or Decimal(0))
detail = f"facturable {chargeable.quantize(Decimal('0.01'))} kg"
detail = f"facturable {chargeable.quantize(Decimal('0.01'))} kg (P/Vol)"
charge_lines = _apply_charges(db, sheet, lane, base, chargeable, req.quantity, req.dangerous)
total = base + sum((c.amount for c in charge_lines), Decimal(0))

View File

@@ -13,9 +13,11 @@ from . import permissions # noqa: F401 (side-effect: registra permisos del CRM
from .accounts.routes import router as accounts_router
from .activities.routes import router as activities_router
from .addresses.routes import router as addresses_router
from .cases.routes import router as cases_router
from .catalogs.routes import router as catalogs_router
from .contacts.routes import router as contacts_router
from .documents.routes import router as documents_router
from .expediente_gateway.routes import router as expediente_gateway_router
from .leads.routes import router as leads_router
from .metrics.routes import router as metrics_router
from .opportunities.routes import router as opportunities_router
@@ -43,8 +45,13 @@ router.include_router(leads_router)
router.include_router(pipelines_router)
router.include_router(opportunities_router)
router.include_router(activities_router)
router.include_router(cases_router)
router.include_router(metrics_router)
router.include_router(catalogs_router)
router.include_router(uploads_router)
router.include_router(rates_router)
router.include_router(rates_cost_router)
# Tablero de operación del carril hacia EFC: cola pendiente, métricas y reintento manual.
# No es una ruta de negocio; existe para que una persona vea y desatore la entrega sin
# entrar a la base. Hereda el enforcement de crm.access del router agregador.
router.include_router(expediente_gateway_router)

View File

@@ -7,22 +7,66 @@ from pydantic import BaseModel, ConfigDict, Field
class ServiceRequestBase(BaseModel):
reference: str | None = Field(None, max_length=40)
account_id: int | None = None
contact_id: int | None = None
opportunity_id: int | None = None
operation_type: str = Field(..., max_length=20) # importacion | exportacion
transport_mode: str | None = Field(None, max_length=20)
service_type: str | None = Field(None, max_length=20)
incoterm: str | None = Field(None, max_length=10)
# Ruta legada (texto libre) — se conserva por compatibilidad
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
# Ruta estructurada (país por catálogo ISO; ciudad/puerto por catálogo o texto)
origin_country: str | None = Field(None, max_length=3)
origin_city: str | None = Field(None, max_length=120)
origin_port: str | None = Field(None, max_length=20)
destination_country: str | None = Field(None, max_length=3)
destination_city: str | None = Field(None, max_length=120)
destination_port: str | None = Field(None, max_length=20)
pickup_location: str | None = Field(None, max_length=255)
delivery_location: str | None = Field(None, max_length=255)
cargo_type: str | None = Field(None, max_length=120)
weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) # peso bruto
volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
load_type: str | None = Field(None, max_length=10)
load_type: str | None = Field(None, max_length=10) # FCL | LCL | AMBAS
container_equipment: str | None = Field(None, max_length=120)
container_count: int | None = Field(None, ge=0)
commodity: str | None = None
required_date: date | None = None
request_date: date | None = None
estimated_shipment_date: date | None = None
currency: str | None = Field(None, max_length=3)
priority: str | None = Field(None, max_length=20)
# Mercancía
cargo_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
insurance_required: bool = False
hs_code: str | None = Field(None, max_length=20)
goods_origin_country: str | None = Field(None, max_length=3)
hazardous_imo: bool = False
refrigerated: bool = False
stackable: bool = False
# Dimensiones y bultos
pieces_count: int | None = Field(None, ge=0)
boxes_count: int | None = Field(None, ge=0)
pallets_count: int | None = Field(None, ge=0)
net_weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
length_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
width_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
height_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
measurement_unit: str | None = Field(None, max_length=20)
# LCL
packaging_type: str | None = Field(None, max_length=20)
oversized: bool = False
weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
# Servicios adicionales (códigos del catálogo servicio_adicional) y pago
additional_services: list[str] | None = None
additional_service_costs: dict[str, float] | None = None # {codigo: costo estimado}
payment_method: str | None = Field(None, max_length=20)
destination_agent_id: int | None = None
requirements: str | None = None
client_notes: str | None = None
internal_notes: str | None = None
status: str = Field("nueva", max_length=20)
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
@@ -38,8 +82,12 @@ class ServiceRequestContactInput(BaseModel):
class ServiceRequestFromOpportunityInput(BaseModel):
"""Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02)."""
operation_type: str = Field(..., max_length=20) # importacion | exportacion
"""Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02).
La dirección impo/expo se hereda de la oportunidad; ``operation_type`` aquí es
solo un respaldo para oportunidades antiguas que no la tengan capturada.
"""
operation_type: str | None = Field(None, max_length=20) # importacion | exportacion
transport_mode: str | None = Field(None, max_length=20)
service_type: str | None = Field(None, max_length=20)
incoterm: str | None = Field(None, max_length=10)
@@ -51,6 +99,7 @@ class ServiceRequestFromOpportunityInput(BaseModel):
class ServiceRequestUpdate(BaseModel):
reference: str | None = Field(None, max_length=40)
account_id: int | None = None
contact_id: int | None = None
opportunity_id: int | None = None
operation_type: str | None = Field(None, max_length=20)
transport_mode: str | None = Field(None, max_length=20)
@@ -58,15 +107,52 @@ class ServiceRequestUpdate(BaseModel):
incoterm: str | None = Field(None, max_length=10)
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
origin_country: str | None = Field(None, max_length=3)
origin_city: str | None = Field(None, max_length=120)
origin_port: str | None = Field(None, max_length=20)
destination_country: str | None = Field(None, max_length=3)
destination_city: str | None = Field(None, max_length=120)
destination_port: str | None = Field(None, max_length=20)
pickup_location: str | None = Field(None, max_length=255)
delivery_location: str | None = Field(None, max_length=255)
cargo_type: str | None = Field(None, max_length=120)
weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
load_type: str | None = Field(None, max_length=10)
container_equipment: str | None = Field(None, max_length=120)
container_count: int | None = Field(None, ge=0)
commodity: str | None = None
required_date: date | None = None
request_date: date | None = None
estimated_shipment_date: date | None = None
currency: str | None = Field(None, max_length=3)
priority: str | None = Field(None, max_length=20)
cargo_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
insurance_required: bool | None = None
hs_code: str | None = Field(None, max_length=20)
goods_origin_country: str | None = Field(None, max_length=3)
hazardous_imo: bool | None = None
refrigerated: bool | None = None
stackable: bool | None = None
pieces_count: int | None = Field(None, ge=0)
boxes_count: int | None = Field(None, ge=0)
pallets_count: int | None = Field(None, ge=0)
net_weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
length_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
width_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
height_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2)
measurement_unit: str | None = Field(None, max_length=20)
packaging_type: str | None = Field(None, max_length=20)
oversized: bool | None = None
weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
additional_services: list[str] | None = None
additional_service_costs: dict[str, float] | None = None
payment_method: str | None = Field(None, max_length=20)
destination_agent_id: int | None = None
requirements: str | None = None
client_notes: str | None = None
internal_notes: str | None = None
status: str | None = Field(None, max_length=20)
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
@@ -76,6 +162,7 @@ class ServiceRequestResponse(ServiceRequestBase):
model_config = ConfigDict(from_attributes=True)
id: int
case_id: int | None = None
first_contact_at: datetime | None = None
first_contact_notes: str | None = None
tenant_id: int

View File

@@ -1,6 +1,6 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy import JSON, Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -19,6 +19,7 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
@@ -57,6 +58,57 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
# ----- Campos del documento maestro de cotización (T2026-08) -----
# Datos generales
contact_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.contacts.id"), nullable=True, index=True
)
request_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha de la solicitud
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
priority: Mapped[str | None] = mapped_column(String(20), nullable=True) # baja|normal|alta|urgente
# Ruta (país por catálogo ISO; ciudad/puerto por catálogo o texto libre)
origin_country: Mapped[str | None] = mapped_column(String(3), nullable=True)
origin_city: Mapped[str | None] = mapped_column(String(120), nullable=True)
origin_port: Mapped[str | None] = mapped_column(String(20), nullable=True)
destination_country: Mapped[str | None] = mapped_column(String(3), nullable=True)
destination_city: Mapped[str | None] = mapped_column(String(120), nullable=True)
destination_port: Mapped[str | None] = mapped_column(String(20), nullable=True)
pickup_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
delivery_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
estimated_shipment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
# Mercancía
cargo_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
insurance_required: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
hs_code: Mapped[str | None] = mapped_column(String(20), nullable=True) # fracción arancelaria
goods_origin_country: Mapped[str | None] = mapped_column(String(3), nullable=True) # país de origen de la mercancía
hazardous_imo: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
refrigerated: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
stackable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
# Dimensiones y bultos
pieces_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
boxes_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
pallets_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
net_weight: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) # peso neto (weight = bruto)
length_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
width_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
height_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
measurement_unit: Mapped[str | None] = mapped_column(String(20), nullable=True)
# FCL
container_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# LCL
packaging_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
oversized: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
weight_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
volume_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
# Servicios adicionales (lista de códigos del catálogo servicio_adicional) y pago
additional_services: Mapped[list | None] = mapped_column(JSON, nullable=True)
# Costo estimado por servicio adicional marcado: {codigo: costo}
additional_service_costs: Mapped[dict | None] = mapped_column(JSON, nullable=True)
payment_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Notas
client_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
internal_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class RateRequest(Base, TenantScopedMixin, TimestampMixin):
"""Solicitud de tarifa a un proveedor para una solicitud de servicio (Diagrama 1, paso 6)."""

View File

@@ -4,7 +4,10 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..cases import service as cases_service
from ..catalogs.data import INCOTERM_CODES
from ..common.folios import next_folio
from ..contacts.models import Contact
from ..opportunities.models import Opportunity
from ..suppliers.models import Supplier
from .dto import (
@@ -37,6 +40,8 @@ def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int
def _validate_request_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
if not _exists(db, Account, data.get("account_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El cliente asociado no existe")
if not _exists(db, Contact, data.get("contact_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El contacto asociado no existe")
if not _exists(db, Supplier, data.get("destination_agent_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El agente en destino no existe")
if not _exists(db, Opportunity, data.get("opportunity_id"), tenant_id, company_id):
@@ -103,6 +108,15 @@ def create_service_request(
data = payload.model_dump()
_validate_request_refs(db, data, tenant_id, company_id)
obj = ServiceRequest(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
# Folio S... auto-generado (mensual) si no viene uno explícito
if not obj.reference:
obj.reference = next_folio(db, tenant_id, company_id, "S", obj.operation_type)
# Expediente: normalmente nace en la oportunidad; si la solicitud es directa, se mintea aquí
if not obj.case_id:
case = cases_service.create_case(
db, tenant_id, company_id, account_id=obj.account_id, title=obj.reference, stage="solicitud", user_id=user_id,
)
obj.case_id = case.id
db.add(obj)
db.commit()
db.refresh(obj)
@@ -166,10 +180,24 @@ def create_from_opportunity(
)
if not opp:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Oportunidad no encontrada")
# Idempotente: si la oportunidad ya se convirtió, devuelve la misma solicitud
if opp.converted_service_request_id:
existing = get_service_request(db, opp.converted_service_request_id, tenant_id, company_id)
return existing
# La dirección impo/expo se hereda de la oportunidad (respaldo: el payload)
operation_type = opp.operation_type or payload.operation_type
if not operation_type:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Define la dirección (importación/exportación) en la oportunidad para convertirla",
)
obj = ServiceRequest(
account_id=opp.account_id,
contact_id=opp.contact_id,
opportunity_id=opp.id,
operation_type=payload.operation_type,
operation_type=operation_type,
transport_mode=payload.transport_mode,
service_type=payload.service_type,
incoterm=payload.incoterm,
@@ -178,12 +206,24 @@ def create_from_opportunity(
status="nueva",
notes=payload.notes,
owner_user_id=opp.owner_user_id,
reference=next_folio(db, tenant_id, company_id, "S", operation_type),
case_id=opp.case_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(obj)
db.flush()
# Expediente heredado de la oportunidad (fallback si la oportunidad es antigua sin expediente)
if not obj.case_id:
obj.case_id = cases_service.create_case(
db, tenant_id, company_id, account_id=opp.account_id, title=obj.reference, stage="solicitud", user_id=user_id,
).id
opp.case_id = obj.case_id
cases_service.advance_stage(db, obj.case_id, "solicitud")
# Back-link para cerrar el ciclo Oportunidad→Solicitud (y garantizar idempotencia)
opp.converted_service_request_id = obj.id
db.commit()
db.refresh(obj)
return obj

View File

@@ -7,16 +7,36 @@ pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran).
import re
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
from core.security import get_current_user
from core.storage_s3 import presigned_get_url, put_object_bytes
from core.storage_s3 import get_object_bytes, presigned_get_url, put_object_bytes
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,9 +111,32 @@ 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)}
@router.get("/uploads/download")
def download_file(
key: str = Query(..., description="Object key del archivo en el almacén"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
):
"""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.
"""
_validar_alcance(key, current_user["tenant_id"], company_id)
try:
data = get_object_bytes(key)
except Exception:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Archivo no encontrado")
filename = key.rsplit("/", 1)[-1]
return Response(
content=data,
media_type="application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)

View File

@@ -0,0 +1 @@
"""Catálogos oficiales del SAT (schema ``sat``): globales y de solo lectura."""

View File

@@ -0,0 +1,61 @@
"""Esquemas de respuesta de los catálogos del SAT (solo lectura)."""
from pydantic import BaseModel, ConfigDict
class SatCatalogItem(BaseModel):
"""Forma común de todo catálogo del SAT: clave + descripción."""
model_config = ConfigDict(from_attributes=True)
id: int
code: str
description: str
is_active: bool
class TaxRegimeResponse(SatCatalogItem):
"""``c_RegimenFiscal``: incluye a qué tipo de persona aplica el régimen."""
applies_to_individual: bool # persona física
applies_to_legal_entity: bool # persona moral
class TaxResponse(SatCatalogItem):
"""``c_Impuesto``: indica si el impuesto puede retenerse o trasladarse."""
is_withholding: bool
is_transferred: bool
is_local: bool
class UnitOfMeasureResponse(SatCatalogItem):
"""``c_ClaveUnidad``: nombre corto, símbolo y nota larga del catálogo."""
description: str | None = None
name: str
symbol: str | None = None
class PaymentFormResponse(SatCatalogItem):
"""``c_FormaPago``."""
class ProductServiceResponse(SatCatalogItem):
"""``c_ClaveProdServ``."""
class VoucherTypeResponse(SatCatalogItem):
"""``c_TipoDeComprobante``."""
class PaymentMethodResponse(SatCatalogItem):
"""``c_MetodoPago``."""
class TaxObjectResponse(SatCatalogItem):
"""``c_ObjetoImp``."""
class CfdiUseResponse(SatCatalogItem):
"""``c_UsoCFDI``."""

View File

@@ -0,0 +1,143 @@
"""Modelos de los catálogos oficiales del SAT — schema ``sat``.
Son catálogos **globales**: los publica el SAT, valen igual para cualquier tenant y
compañía, por eso no heredan ``TenantScopedMixin``. Tampoco se borran: cuando el SAT
retira una clave, el registro se marca ``is_active = false`` para que las facturas
históricas que la usan sigan resolviendo su descripción (de ahí que se use
``BaseTimestampMixin``, sin ``deleted_at``).
La API los expone únicamente en modo lectura; el alta y la actualización pasan por
``seed_data.sync_catalogs()``.
"""
from sqlalchemy import Boolean, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import BaseTimestampMixin
from core.database import Base
class SatCatalogMixin(BaseTimestampMixin):
"""Campos comunes a todo catálogo del SAT.
``code`` (la clave oficial) se declara en cada modelo porque su longitud
cambia de catálogo en catálogo.
"""
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
description: Mapped[str] = mapped_column(String(500), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
class TaxRegime(Base, SatCatalogMixin):
"""``c_RegimenFiscal`` — régimen fiscal del emisor y del receptor del CFDI.
Las banderas indican a qué tipo de persona aplica el régimen: una persona física
no puede declararse en el 601 (General de Ley Personas Morales) y viceversa.
"""
__tablename__ = "tax_regimes"
__table_args__ = {"schema": "sat"}
code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True)
applies_to_individual: Mapped[bool] = mapped_column( # persona física
Boolean, nullable=False, server_default=text("false")
)
applies_to_legal_entity: Mapped[bool] = mapped_column( # persona moral
Boolean, nullable=False, server_default=text("false")
)
class Tax(Base, SatCatalogMixin):
"""``c_Impuesto`` — impuestos federales que pueden trasladarse o retenerse."""
__tablename__ = "taxes"
__table_args__ = {"schema": "sat"}
code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True)
is_withholding: Mapped[bool] = mapped_column( # puede retenerse
Boolean, nullable=False, server_default=text("false")
)
is_transferred: Mapped[bool] = mapped_column( # puede trasladarse
Boolean, nullable=False, server_default=text("false")
)
# Los impuestos locales (ISH y similares) viajan en el complemento "Impuestos
# Locales" con claves ajenas a c_Impuesto; la bandera queda disponible para
# cuando el negocio defina ese catálogo.
is_local: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
class PaymentForm(Base, SatCatalogMixin):
"""``c_FormaPago`` — con qué se pagó (efectivo, transferencia, tarjeta…)."""
__tablename__ = "payment_forms"
__table_args__ = {"schema": "sat"}
code: Mapped[str] = mapped_column(String(2), nullable=False, unique=True, index=True)
class UnitOfMeasure(Base, SatCatalogMixin):
"""``c_ClaveUnidad`` — unidad de medida de la partida.
Único catálogo que separa nombre corto y definición: ``name`` es lo que se
muestra al capturar y ``description`` la nota larga del SAT, que puede venir
vacía.
"""
__tablename__ = "units_of_measure"
__table_args__ = {"schema": "sat"}
code: Mapped[str] = mapped_column(String(20), nullable=False, unique=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
symbol: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Se redeclara para permitir NULL: aquí la descripción es la nota del catálogo.
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
class ProductService(Base, SatCatalogMixin):
"""``c_ClaveProdServ`` — clave de producto o servicio de la partida."""
__tablename__ = "products_services"
__table_args__ = {"schema": "sat"}
code: Mapped[str] = mapped_column(String(8), nullable=False, unique=True, index=True)
class VoucherType(Base, SatCatalogMixin):
"""``c_TipoDeComprobante`` — I ingreso, E egreso, T traslado, N nómina, P pago."""
__tablename__ = "voucher_types"
__table_args__ = {"schema": "sat"}
code: Mapped[str] = mapped_column(String(1), nullable=False, unique=True, index=True)
class PaymentMethod(Base, SatCatalogMixin):
"""``c_MetodoPago`` — PUE (una sola exhibición) o PPD (parcialidades/diferido)."""
__tablename__ = "payment_methods"
__table_args__ = {"schema": "sat"}
code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True)
class TaxObject(Base, SatCatalogMixin):
"""``c_ObjetoImp`` — si la partida es o no objeto de impuesto."""
__tablename__ = "tax_objects"
__table_args__ = {"schema": "sat"}
code: Mapped[str] = mapped_column(String(2), nullable=False, unique=True, index=True)
class CfdiUse(Base, SatCatalogMixin):
"""``c_UsoCFDI`` — uso que el receptor le dará al comprobante.
Lo declara el receptor, no el emisor, y el SAT lo valida contra su régimen
fiscal: por eso vive en la ficha del cliente (``crm.accounts.cfdi_use_id``).
"""
__tablename__ = "cfdi_uses"
__table_args__ = {"schema": "sat"}
code: Mapped[str] = mapped_column(String(4), nullable=False, unique=True, index=True)

View File

@@ -0,0 +1,138 @@
"""Endpoints de los catálogos del SAT — **solo lectura**.
No se exponen POST/PUT/PATCH/DELETE a propósito: son catálogos fijos publicados por
el SAT y se mantienen con ``seed_data.sync_catalogs()``, no por API.
Nota: aunque los catálogos son globales, el router del módulo exige ``fin.access``,
permiso que se resuelve sobre una compañía; por eso las peticiones siguen llevando
``company_id`` en la query string.
"""
from typing import Literal
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import (
CfdiUseResponse,
PaymentFormResponse,
PaymentMethodResponse,
ProductServiceResponse,
TaxObjectResponse,
TaxRegimeResponse,
TaxResponse,
UnitOfMeasureResponse,
VoucherTypeResponse,
)
router = APIRouter()
_SEARCH = Query(None, description="Búsqueda por clave o descripción")
_ACTIVE_ONLY = Query(True, description="Solo claves vigentes")
@router.get("/catalogs/tax-regimes", response_model=list[TaxRegimeResponse])
def list_tax_regimes(
search: str | None = _SEARCH,
active_only: bool = _ACTIVE_ONLY,
person_type: Literal["fisica", "moral"] | None = Query(
None, description="Acota al régimen de persona física o moral"
),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""``c_RegimenFiscal`` — régimen fiscal del emisor/receptor del CFDI."""
return service.get_tax_regimes(db, search, active_only, person_type)
@router.get("/catalogs/taxes", response_model=list[TaxResponse])
def list_taxes(
search: str | None = _SEARCH,
active_only: bool = _ACTIVE_ONLY,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""``c_Impuesto`` — impuestos federales trasladados y retenidos."""
return service.get_taxes(db, search, active_only)
@router.get("/catalogs/payment-forms", response_model=list[PaymentFormResponse])
def list_payment_forms(
search: str | None = _SEARCH,
active_only: bool = _ACTIVE_ONLY,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""``c_FormaPago`` — medio con el que se liquidó el comprobante."""
return service.get_payment_forms(db, search, active_only)
@router.get("/catalogs/units-of-measure", response_model=list[UnitOfMeasureResponse])
def list_units_of_measure(
search: str | None = _SEARCH,
active_only: bool = _ACTIVE_ONLY,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""``c_ClaveUnidad`` — unidad de medida de la partida."""
return service.get_units_of_measure(db, search, active_only)
@router.get("/catalogs/products-services", response_model=list[ProductServiceResponse])
def list_products_services(
search: str | None = _SEARCH,
active_only: bool = _ACTIVE_ONLY,
limit: int = Query(50, ge=1, le=200, description="Máximo de claves devueltas"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""``c_ClaveProdServ`` — clave de producto/servicio; pensado para autocompletado."""
return service.get_products_services(db, search, active_only, limit)
@router.get("/catalogs/voucher-types", response_model=list[VoucherTypeResponse])
def list_voucher_types(
search: str | None = _SEARCH,
active_only: bool = _ACTIVE_ONLY,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""``c_TipoDeComprobante`` — ingreso, egreso, traslado, nómina o pago."""
return service.get_voucher_types(db, search, active_only)
@router.get("/catalogs/payment-methods", response_model=list[PaymentMethodResponse])
def list_payment_methods(
search: str | None = _SEARCH,
active_only: bool = _ACTIVE_ONLY,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""``c_MetodoPago`` — PUE o PPD."""
return service.get_payment_methods(db, search, active_only)
@router.get("/catalogs/tax-objects", response_model=list[TaxObjectResponse])
def list_tax_objects(
search: str | None = _SEARCH,
active_only: bool = _ACTIVE_ONLY,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""``c_ObjetoImp`` — si la partida es objeto de impuesto."""
return service.get_tax_objects(db, search, active_only)
@router.get("/catalogs/cfdi-uses", response_model=list[CfdiUseResponse])
def list_cfdi_uses(
search: str | None = _SEARCH,
active_only: bool = _ACTIVE_ONLY,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""``c_UsoCFDI`` — uso que el receptor le dará al comprobante."""
return service.get_cfdi_uses(db, search, active_only)

View File

@@ -0,0 +1,340 @@
"""Datos semilla de los catálogos del SAT y su sincronización idempotente.
Los catálogos viven aquí y no dentro de una migración concreta a propósito: cuando el
SAT corrige una descripción o publica una clave nueva, basta editar estas listas y
volver a correr :func:`sync_catalogs`, sin escribir una migración de esquema.
Las tablas se describen con ``sa.Table`` ligeros sobre un ``MetaData`` propio (no con
los modelos ORM) para que la migración pueda importar este módulo sin acoplarse a la
definición ORM, que sigue evolucionando.
"""
import sqlalchemy as sa
_metadata = sa.MetaData()
def _catalog_table(name: str, *extra_columns: sa.Column) -> sa.Table:
"""Tabla mínima de catálogo: las columnas que toca el upsert, nada más."""
return sa.Table(
name,
_metadata,
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("code", sa.String, nullable=False),
sa.Column("description", sa.String),
sa.Column("is_active", sa.Boolean),
*extra_columns,
schema="sat",
)
tax_regimes_table = _catalog_table(
"tax_regimes",
sa.Column("applies_to_individual", sa.Boolean),
sa.Column("applies_to_legal_entity", sa.Boolean),
)
taxes_table = _catalog_table(
"taxes",
sa.Column("is_withholding", sa.Boolean),
sa.Column("is_transferred", sa.Boolean),
sa.Column("is_local", sa.Boolean),
)
payment_forms_table = _catalog_table("payment_forms")
units_of_measure_table = _catalog_table(
"units_of_measure",
sa.Column("name", sa.String),
sa.Column("symbol", sa.String),
)
products_services_table = _catalog_table("products_services")
voucher_types_table = _catalog_table("voucher_types")
payment_methods_table = _catalog_table("payment_methods")
tax_objects_table = _catalog_table("tax_objects")
cfdi_uses_table = _catalog_table("cfdi_uses")
# ---------------------------------------------------------------------------
# c_RegimenFiscal (CFDI 4.0)
# ---------------------------------------------------------------------------
def _regime(code: str, description: str, individual: bool, legal_entity: bool) -> dict:
return {
"code": code,
"description": description,
"applies_to_individual": individual,
"applies_to_legal_entity": legal_entity,
"is_active": True,
}
TAX_REGIMES: list[dict] = [
_regime("601", "General de Ley Personas Morales", False, True),
_regime("603", "Personas Morales con Fines no Lucrativos", False, True),
_regime("605", "Sueldos y Salarios e Ingresos Asimilados a Salarios", True, False),
_regime("606", "Arrendamiento", True, False),
_regime("607", "Régimen de Enajenación o Adquisición de Bienes", True, False),
_regime("608", "Demás ingresos", True, False),
_regime("610", "Residentes en el Extranjero sin Establecimiento Permanente en México", True, True),
_regime("611", "Ingresos por Dividendos (socios y accionistas)", True, False),
_regime("612", "Personas Físicas con Actividades Empresariales y Profesionales", True, False),
_regime("614", "Ingresos por intereses", True, False),
_regime("615", "Régimen de los ingresos por obtención de premios", True, False),
_regime("616", "Sin obligaciones fiscales", True, False),
_regime("620", "Sociedades Cooperativas de Producción que optan por diferir sus ingresos", False, True),
_regime("621", "Incorporación Fiscal", True, False),
_regime("622", "Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras", False, True),
_regime("623", "Opcional para Grupos de Sociedades", False, True),
_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),
]
# ---------------------------------------------------------------------------
# c_Impuesto
# ---------------------------------------------------------------------------
# is_local queda en false para los tres: los impuestos locales (ISH y similares)
# se declaran en el complemento "Impuestos Locales" con claves que no pertenecen
# a c_Impuesto. No se siembran registros locales inventados.
TAXES: list[dict] = [
{"code": "001", "description": "ISR", "is_withholding": True, "is_transferred": False, "is_local": False, "is_active": True},
{"code": "002", "description": "IVA", "is_withholding": True, "is_transferred": True, "is_local": False, "is_active": True},
{"code": "003", "description": "IEPS", "is_withholding": True, "is_transferred": True, "is_local": False, "is_active": True},
]
# ---------------------------------------------------------------------------
# c_FormaPago
# ---------------------------------------------------------------------------
PAYMENT_FORMS: list[dict] = [
{"code": code, "description": description, "is_active": True}
for code, description in [
("01", "Efectivo"),
("02", "Cheque nominativo"),
("03", "Transferencia electrónica de fondos"),
("04", "Tarjeta de crédito"),
("05", "Monedero electrónico"),
("06", "Dinero electrónico"),
("08", "Vales de despensa"),
("12", "Dación en pago"),
("13", "Pago por subrogación"),
("14", "Pago por consignación"),
("15", "Condonación"),
("17", "Compensación"),
("23", "Novación"),
("24", "Confusión"),
("25", "Remisión de deuda"),
("26", "Prescripción o caducidad"),
("27", "A satisfacción del acreedor"),
("28", "Tarjeta de débito"),
("29", "Tarjeta de servicios"),
("30", "Aplicación de anticipos"),
("31", "Intermediario pagos"),
("99", "Por definir"),
]
]
# ---------------------------------------------------------------------------
# c_TipoDeComprobante
# ---------------------------------------------------------------------------
VOUCHER_TYPES: list[dict] = [
{"code": code, "description": description, "is_active": True}
for code, description in [
("I", "Ingreso"),
("E", "Egreso"),
("T", "Traslado"),
("N", "Nómina"),
("P", "Pago"),
]
]
# ---------------------------------------------------------------------------
# c_MetodoPago
# ---------------------------------------------------------------------------
PAYMENT_METHODS: list[dict] = [
{"code": "PUE", "description": "Pago en una sola exhibición", "is_active": True},
{"code": "PPD", "description": "Pago en parcialidades o diferido", "is_active": True},
]
# ---------------------------------------------------------------------------
# c_ObjetoImp
# ---------------------------------------------------------------------------
# Versiones posteriores del catálogo incorporan las claves 0507; no se siembran
# hasta que el área Fiscal confirme la versión vigente (ver PENDIENTE DECISIÓN).
TAX_OBJECTS: list[dict] = [
{"code": "01", "description": "No objeto de impuesto", "is_active": True},
{"code": "02", "description": "Sí objeto de impuesto", "is_active": True},
{"code": "03", "description": "Sí objeto del impuesto y no obligado al desglose", "is_active": True},
{"code": "04", "description": "Sí objeto del impuesto y no causa impuesto", "is_active": True},
]
# ---------------------------------------------------------------------------
# c_ClaveUnidad — subset operativo
# ---------------------------------------------------------------------------
# description queda en NULL: es la nota larga del catálogo, que aquí no aporta.
UNITS_OF_MEASURE: list[dict] = [
{"code": code, "name": name, "symbol": symbol, "description": None, "is_active": True}
for code, name, symbol in [
("H87", "Pieza", "pz"),
("E48", "Unidad de servicio", None),
("ACT", "Actividad", None),
("C62", "Uno", None),
("KGM", "Kilogramo", "kg"),
("TNE", "Tonelada métrica", "t"),
("GRM", "Gramo", "g"),
("LTR", "Litro", "l"),
("MTR", "Metro", "m"),
("MTK", "Metro cuadrado", ""),
("MTQ", "Metro cúbico", ""),
("KMT", "Kilómetro", "km"),
("CMT", "Centímetro", "cm"),
("DAY", "Día", "d"),
("HUR", "Hora", "h"),
("MON", "Mes", None),
("XBX", "Caja", None),
("XPK", "Paquete", None),
("XPX", "Paleta / tarima", None),
("XLT", "Lote", None),
("E51", "Trabajo", None),
]
]
# ---------------------------------------------------------------------------
# c_ClaveProdServ — subset de logística
# ---------------------------------------------------------------------------
# Subset inicial de c_ClaveProdServ para agente de carga — pendiente validación con
# área Fiscal antes de producción. El catálogo completo son ~52,000 claves; aquí solo
# se siembran las del giro. Si falta una clave para un caso de uso, se documenta como
# PENDIENTE DECISIÓN: no se deduce ni se inventa.
PRODUCTS_SERVICES: list[dict] = [
{"code": code, "description": description, "is_active": True}
for code, description in [
("78101500", "Transporte de carga por carretera"),
("78101600", "Transporte de carga marítimo"),
("78101700", "Transporte de carga por ferrocarril"),
("78101800", "Transporte de carga aérea"),
("78102200", "Servicios postales de paqueteo y courrier"),
("78121600", "Embalaje"),
("78131600", "Almacenaje"),
("78141500", "Servicios de planificación logística"),
("78141600", "Servicios de expedición de fletes"),
("84131500", "Seguros de vida, salud y accidentes / seguros de carga"),
("80101500", "Servicios de consultoría de negocios y administración corporativa"),
]
]
# ---------------------------------------------------------------------------
# c_UsoCFDI
# ---------------------------------------------------------------------------
# Catálogo del uso que el receptor da al comprobante. Se siembran clave y
# descripción; **no** se cargan las banderas de persona física/moral ni la
# compatibilidad por régimen fiscal, porque esa matriz cambia entre versiones del
# catálogo y equivocarla provoca rechazos al timbrar.
#
# Pendiente validación con área Fiscal antes de producción, igual que el subset de
# c_ClaveProdServ.
CFDI_USES: list[dict] = [
{"code": code, "description": description, "is_active": True}
for code, description in [
("G01", "Adquisición de mercancías"),
("G02", "Devoluciones, descuentos o bonificaciones"),
("G03", "Gastos en general"),
("I01", "Construcciones"),
("I02", "Mobiliario y equipo de oficina por inversiones"),
("I03", "Equipo de transporte"),
("I04", "Equipo de cómputo y accesorios"),
("I05", "Dados, troqueles, moldes, matrices y herramental"),
("I06", "Comunicaciones telefónicas"),
("I07", "Comunicaciones satelitales"),
("I08", "Otra maquinaria y equipo"),
("D01", "Honorarios médicos, dentales y gastos hospitalarios"),
("D02", "Gastos médicos por incapacidad o discapacidad"),
("D03", "Gastos funerales"),
("D04", "Donativos"),
("D05", "Intereses reales efectivamente pagados por créditos hipotecarios (casa habitación)"),
("D06", "Aportaciones voluntarias al SAR"),
("D07", "Primas por seguros de gastos médicos"),
("D08", "Gastos de transportación escolar obligatoria"),
("D09", "Depósitos en cuentas para el ahorro, primas que tengan como base planes de pensiones"),
("D10", "Pagos por servicios educativos (colegiaturas)"),
("S01", "Sin efectos fiscales"),
("CP01", "Pagos"),
("CN01", "Nómina"),
]
]
# Orden estable de sincronización: (tabla, filas).
CATALOGS: list[tuple[sa.Table, list[dict]]] = [
(tax_regimes_table, TAX_REGIMES),
(taxes_table, TAXES),
(payment_forms_table, PAYMENT_FORMS),
(units_of_measure_table, UNITS_OF_MEASURE),
(products_services_table, PRODUCTS_SERVICES),
(voucher_types_table, VOUCHER_TYPES),
(payment_methods_table, PAYMENT_METHODS),
(tax_objects_table, TAX_OBJECTS),
(cfdi_uses_table, CFDI_USES),
]
def sync_catalogs(connection) -> dict[str, int]:
"""Sincroniza los catálogos del SAT contra la base, de forma idempotente.
Inserta las claves que faltan y actualiza descripción y banderas de las que ya
existen. **Nunca borra**: una clave retirada por el SAT se desactiva a mano para
no romper los CFDI históricos que la referencian.
Devuelve un resumen ``{"sat.tabla": filas_insertadas}`` útil para la bitácora de
la migración.
Los catálogos cuya tabla todavía no existe se omiten: al correr el historial de
migraciones desde cero, una migración antigua invoca esta misma función cuando los
catálogos agregados después aún no se han creado. Cada uno se siembra en la
migración que lo crea.
Se usa contra el ``connection`` que da ``op.get_bind()`` en Alembic, o contra la
conexión de una sesión en pruebas.
"""
inspector = sa.inspect(connection)
# La inspección no aplica el schema_translate_map (las pruebas mapean sat -> None
# sobre SQLite), así que se resuelve el schema efectivo a mano.
schema_map = connection.get_execution_options().get("schema_translate_map") or {}
inserted: dict[str, int] = {}
for table, rows in CATALOGS:
effective_schema = schema_map.get(table.schema, table.schema)
if not inspector.has_table(table.name, schema=effective_schema):
continue
key = f"sat.{table.name}"
inserted[key] = 0
for row in rows:
existing = connection.execute(
sa.select(table.c.id).where(table.c.code == row["code"])
).scalar()
values = {k: v for k, v in row.items() if k != "code"}
if existing is None:
connection.execute(table.insert().values(code=row["code"], **values))
inserted[key] += 1
else:
connection.execute(
table.update().where(table.c.id == existing).values(**values)
)
return inserted

View File

@@ -0,0 +1,136 @@
"""Consultas de los catálogos del SAT.
Son globales (sin tenant_id / company_id) y de solo lectura: aquí no hay altas,
cambios ni bajas, únicamente búsqueda para llenar los selectores de captura.
"""
from sqlalchemy import or_
from sqlalchemy.orm import Session
from .models import (
CfdiUse,
PaymentForm,
PaymentMethod,
ProductService,
Tax,
TaxObject,
TaxRegime,
UnitOfMeasure,
VoucherType,
)
# Catálogos que además del código y la descripción buscan por nombre corto.
_SEARCHABLE_EXTRA_FIELDS = {UnitOfMeasure: ("name",)}
def find_by_code(db: Session, model, code: str | None, active_only: bool = True):
"""Resuelve una clave del SAT a su fila del catálogo. ``None`` si no hay coincidencia.
Existe para traducir a id los datos fiscales que el CRM guarda como TEXTO. En
``crm.accounts`` la forma y el método de pago son ``String(60)`` sin FK ni validación: lo
normal es que traigan la clave del SAT ('03', 'PUE'), porque la ficha se llena con el
``code`` de los catálogos del CRM, pero nada garantiza que no haya texto histórico como
'Transferencia'.
Devuelve ``None`` en vez de lanzar, y es la decisión importante: una ficha de cliente mal
capturada **no puede impedir crear una factura**. El faltante lo reporta la validación del
timbrado, que acumula todos los pendientes y los entrega juntos — el mismo criterio que
``stamping.service._code``.
``active_only`` por defecto: una clave que el SAT retiró no debe entrar en un comprobante
nuevo.
"""
limpio = (code or "").strip().upper()
if not limpio:
return None
# c_FormaPago son dos dígitos: una ficha con '3' en vez de '03' es la misma forma de pago.
if model is PaymentForm and limpio.isdigit():
limpio = limpio.zfill(2)
q = db.query(model).filter(model.code == limpio)
if active_only:
q = q.filter(model.is_active.is_(True))
return q.first()
def search_catalog(
db: Session,
model,
search: str | None = None,
active_only: bool = True,
limit: int | None = None,
) -> list:
"""Devuelve las claves de un catálogo, filtradas por texto libre.
``search`` compara contra la clave o la descripción sin distinguir mayúsculas.
"""
q = db.query(model)
if active_only:
q = q.filter(model.is_active.is_(True))
if search:
term = f"%{search.strip()}%"
fields = [model.code, model.description]
for extra in _SEARCHABLE_EXTRA_FIELDS.get(model, ()):
fields.append(getattr(model, extra))
q = q.filter(or_(*[f.ilike(term) for f in fields]))
q = q.order_by(model.code.asc())
if limit is not None:
q = q.limit(limit)
return q.all()
def get_tax_regimes(
db: Session,
search: str | None = None,
active_only: bool = True,
person_type: str | None = None,
) -> list[TaxRegime]:
"""``c_RegimenFiscal``, opcionalmente acotado al tipo de persona.
``person_type='fisica'`` deja solo los regímenes que puede usar una persona
física; ``'moral'``, los de persona moral.
"""
q = db.query(TaxRegime)
if active_only:
q = q.filter(TaxRegime.is_active.is_(True))
if search:
term = f"%{search.strip()}%"
q = q.filter(or_(TaxRegime.code.ilike(term), TaxRegime.description.ilike(term)))
if person_type == "fisica":
q = q.filter(TaxRegime.applies_to_individual.is_(True))
elif person_type == "moral":
q = q.filter(TaxRegime.applies_to_legal_entity.is_(True))
return q.order_by(TaxRegime.code.asc()).all()
def get_taxes(db: Session, search=None, active_only=True) -> list[Tax]:
return search_catalog(db, Tax, search, active_only)
def get_payment_forms(db: Session, search=None, active_only=True) -> list[PaymentForm]:
return search_catalog(db, PaymentForm, search, active_only)
def get_units_of_measure(db: Session, search=None, active_only=True) -> list[UnitOfMeasure]:
return search_catalog(db, UnitOfMeasure, search, active_only)
def get_products_services(db: Session, search=None, active_only=True, limit=50) -> list[ProductService]:
"""``c_ClaveProdServ``. Va paginado porque alimenta un autocompletado."""
return search_catalog(db, ProductService, search, active_only, limit=limit)
def get_voucher_types(db: Session, search=None, active_only=True) -> list[VoucherType]:
return search_catalog(db, VoucherType, search, active_only)
def get_payment_methods(db: Session, search=None, active_only=True) -> list[PaymentMethod]:
return search_catalog(db, PaymentMethod, search, active_only)
def get_tax_objects(db: Session, search=None, active_only=True) -> list[TaxObject]:
return search_catalog(db, TaxObject, search, active_only)
def get_cfdi_uses(db: Session, search=None, active_only=True) -> list[CfdiUse]:
return search_catalog(db, CfdiUse, search, active_only)

View File

@@ -0,0 +1 @@
"""Catálogo de conceptos de facturación por empresa."""

View File

@@ -0,0 +1,110 @@
"""Esquemas del catálogo de conceptos de facturación."""
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from ..catalogs.dto import (
ProductServiceResponse,
TaxObjectResponse,
TaxResponse,
UnitOfMeasureResponse,
)
# Configuración fiscal por defecto del concepto: es lo que permite tener conceptos que no
# causan IVA. La tasa va como FRACCIÓN (0.16), igual que en la partida y en el XML, NO como el
# porcentaje de la factura (16.00).
_FISCAL_DEFAULTS = ("default_tax_id", "default_tax_rate", "default_tax_factor")
class _FiscalDefaultsMixin(BaseModel):
"""Valida que la configuración fiscal esté completa o vacía, nunca a medias.
Espeja el CHECK de la base para que el error salga como un 422 legible en vez de un
IntegrityError, y para que el service no tenga que adivinar un estado medio capturado.
"""
@model_validator(mode="after")
def _valida_defaults_fiscales(self):
puestos = {c for c in _FISCAL_DEFAULTS if getattr(self, c, None) is not None}
if not puestos:
return self
if self.default_tax_id is None or self.default_tax_factor is None:
raise ValueError(
"La configuración fiscal del concepto necesita impuesto y tipo de factor"
)
if self.default_tax_factor == "Exento":
if self.default_tax_rate:
raise ValueError("Un concepto exento no lleva tasa")
elif self.default_tax_rate is None:
raise ValueError("Un concepto con factor Tasa necesita su tasa (0 para el 0%)")
return self
class ConceptBase(_FiscalDefaultsMixin):
code: str = Field(..., min_length=1, max_length=40, description="Clave interna del concepto")
description: str = Field(..., min_length=1, max_length=500)
product_service_id: int = Field(..., description="Clave ProdServ del SAT (1:1 por empresa)")
unit_of_measure_id: int | None = None
tax_object_id: int | None = None
# Impuesto por defecto del concepto. Si se define, la partida lo hereda y el % global de
# la factura deja de aplicarle. Exento y tasa 0% son distintos: el primero no se declara
# con TasaOCuota, el segundo sí.
default_tax_id: int | None = None
default_tax_rate: Decimal | None = Field(
None, ge=0, le=1, max_digits=8, decimal_places=6,
description="Fracción, no porcentaje: 0.16 es el 16%",
)
default_tax_factor: Literal["Tasa", "Exento"] | None = None
unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str = Field("MXN", min_length=3, max_length=3)
is_active: bool = True
notes: str | None = None
class ConceptCreate(ConceptBase):
pass
class ConceptUpdate(_FiscalDefaultsMixin):
"""Actualización parcial: solo se tocan los campos enviados."""
code: str | None = Field(None, min_length=1, max_length=40)
description: str | None = Field(None, min_length=1, max_length=500)
product_service_id: int | None = None
unit_of_measure_id: int | None = None
tax_object_id: int | None = None
# Impuesto por defecto del concepto. Si se define, la partida lo hereda y el % global de
# la factura deja de aplicarle. Exento y tasa 0% son distintos: el primero no se declara
# con TasaOCuota, el segundo sí.
default_tax_id: int | None = None
default_tax_rate: Decimal | None = Field(
None, ge=0, le=1, max_digits=8, decimal_places=6,
description="Fracción, no porcentaje: 0.16 es el 16%",
)
default_tax_factor: Literal["Tasa", "Exento"] | None = None
unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str | None = Field(None, min_length=3, max_length=3)
is_active: bool | None = None
notes: str | None = None
class ConceptResponse(ConceptBase):
"""Incluye los objetos del catálogo del SAT ya resueltos, para evitar N+1 en la UI."""
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
company_id: int
product_service: ProductServiceResponse | None = None
unit_of_measure: UnitOfMeasureResponse | None = None
tax_object: TaxObjectResponse | None = None
default_tax: TaxResponse | None = None
created_by: str | None = None
updated_by: str | None = None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,103 @@
"""Catálogo de conceptos de facturación — ``fin.concepts``.
A diferencia de los catálogos del SAT, este es **propio de cada empresa**: cada
concepto que la empresa factura (flete internacional, despacho, almacenaje…) se
registra una vez y queda amarrado a la clave de producto/servicio del SAT que le
corresponde.
La relación con ``sat.products_services`` es **1:1 por empresa**: si dos conceptos
compartieran la misma clave ProdServ, al timbrar no habría forma de saber cuál
descripción corresponde a la clave, así que la unicidad se garantiza por índice y se
valida además en el service para devolver un 409 con mensaje entendible.
"""
from sqlalchemy import (
Boolean,
CheckConstraint,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from ..catalogs.models import Tax, ProductService, TaxObject, UnitOfMeasure # noqa: F401 (resuelve las relaciones)
# Los índices son parciales (``WHERE deleted_at IS NULL``): un concepto dado de baja
# lógica libera su clave y su código para uno nuevo.
_ALIVE = text("deleted_at IS NULL")
class Concept(Base, TenantScopedMixin, TimestampMixin):
"""Concepto facturable de una empresa, ligado a una clave ProdServ del SAT."""
__tablename__ = "concepts"
__table_args__ = (
Index(
"uq_fin_concepts_code",
"tenant_id", "company_id", "code",
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
),
Index(
"uq_fin_concepts_product_service",
"tenant_id", "company_id", "product_service_id",
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
),
# Duplicados de la migración a propósito: las pruebas construyen el esquema con
# ``create_all``, así que sin esto validarían una base distinta de la de producción.
CheckConstraint(
"default_tax_factor IS NULL OR default_tax_factor IN ('Tasa', 'Cuota', 'Exento')",
name="ck_fin_concepts_default_tax_factor",
),
CheckConstraint(
"(default_tax_id IS NULL AND default_tax_rate IS NULL AND default_tax_factor IS NULL)"
" OR (default_tax_id IS NOT NULL AND default_tax_factor IS NOT NULL"
" AND (default_tax_factor = 'Exento' OR default_tax_rate IS NOT NULL))",
name="ck_fin_concepts_default_tax_coherente",
),
{"schema": "fin"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
code: Mapped[str] = mapped_column(String(40), nullable=False) # clave interna del concepto
description: Mapped[str] = mapped_column(String(500), nullable=False)
product_service_id: Mapped[int] = mapped_column(
Integer, ForeignKey("sat.products_services.id"), nullable=False, index=True
)
unit_of_measure_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("sat.units_of_measure.id"), nullable=True
)
tax_object_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("sat.tax_objects.id"), nullable=True
)
# ── Configuración fiscal por defecto ────────────────────────────────────────────────────
# La partida hereda de aquí su impuesto cuando el concepto lo define, y entonces el % global
# de la factura deja de aplicarle. Es lo que permite tener conceptos que no causan IVA:
# exentos (factor 'Exento') o a tasa 0% (factor 'Tasa' con tasa 0), que fiscalmente NO son lo
# mismo ni entre sí ni que un ObjetoImp 01 «no objeto de impuesto».
default_tax_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("sat.taxes.id"), nullable=True
)
# FRACCIÓN con 6 decimales (0.160000), igual que ``invoice_item_taxes.rate`` y que el
# TasaOCuota del XML. NO es el porcentaje de ``invoices.tax_rate`` (16.00): el tipo coincide
# con el destino justo para que la copia sea trivial y no haya un factor 100 en el camino.
default_tax_rate: Mapped[float | None] = mapped_column(Numeric(8, 6), nullable=True)
default_tax_factor: Mapped[str | None] = mapped_column(String(7), nullable=True)
unit_price: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Cargadas con selectinload para que el listado no dispare N+1 consultas.
product_service: Mapped["ProductService"] = relationship("ProductService", lazy="selectin")
unit_of_measure: Mapped["UnitOfMeasure | None"] = relationship("UnitOfMeasure", lazy="selectin")
tax_object: Mapped["TaxObject | None"] = relationship("TaxObject", lazy="selectin")
default_tax: Mapped["Tax | None"] = relationship("Tax", lazy="selectin")

View File

@@ -0,0 +1,96 @@
"""Endpoints del catálogo de conceptos de facturación (CRUD por empresa)."""
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from api.v1.modules.core.permissions.dependencies import PermissionChecker
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import ConceptCreate, ConceptResponse, ConceptUpdate
router = APIRouter()
def _uid(current_user: dict) -> str | None:
return current_user.get("sub") or current_user.get("id")
@router.get(
"/concepts",
response_model=list[ConceptResponse],
dependencies=[Depends(PermissionChecker(["fin.concept.view"]))],
)
def list_concepts(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None, description="Búsqueda por clave o descripción"),
active_only: bool | None = Query(None, description="Filtra por conceptos activos o inactivos"),
product_service_id: int | None = Query(None, description="Filtra por clave ProdServ del SAT"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.get_concepts(
db, current_user["tenant_id"], company_id, search, active_only, product_service_id
)
@router.get(
"/concepts/{concept_id}",
response_model=ConceptResponse,
dependencies=[Depends(PermissionChecker(["fin.concept.view"]))],
)
def get_concept(
concept_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.get_concept(db, concept_id, current_user["tenant_id"], company_id)
@router.post(
"/concepts",
response_model=ConceptResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(PermissionChecker(["fin.concept.create"]))],
)
def create_concept(
payload: ConceptCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.create_concept(db, payload, current_user["tenant_id"], company_id, _uid(current_user))
@router.patch(
"/concepts/{concept_id}",
response_model=ConceptResponse,
dependencies=[Depends(PermissionChecker(["fin.concept.edit"]))],
)
def update_concept(
concept_id: int,
payload: ConceptUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.update_concept(
db, concept_id, payload, current_user["tenant_id"], company_id, _uid(current_user)
)
@router.delete(
"/concepts/{concept_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(PermissionChecker(["fin.concept.delete"]))],
)
def delete_concept(
concept_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Baja lógica del concepto (``deleted_at``)."""
service.delete_concept(db, concept_id, current_user["tenant_id"], company_id)

View File

@@ -0,0 +1,159 @@
"""Lógica del catálogo de conceptos de facturación.
Todas las consultas filtran por ``tenant_id``, ``company_id`` y ``deleted_at IS NULL``:
el catálogo es privado de cada empresa dentro de cada tenant.
"""
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy import or_
from sqlalchemy.orm import Session
from ..catalogs.models import ProductService, Tax, TaxObject, UnitOfMeasure
from .dto import ConceptCreate, ConceptUpdate
from .models import Concept
def _check_sat_refs(db: Session, data: dict) -> None:
"""Verifica que las claves del SAT referidas existan antes de guardar."""
for field, model, msg in [
("product_service_id", ProductService, "La clave de producto/servicio del SAT no existe"),
("unit_of_measure_id", UnitOfMeasure, "La unidad de medida del SAT no existe"),
("tax_object_id", TaxObject, "El objeto de impuesto del SAT no existe"),
("default_tax_id", Tax, "El impuesto por defecto no existe en el catálogo del SAT"),
]:
value = data.get(field)
if field in data and value is not None:
if db.query(model.id).filter(model.id == value).first() is None:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
# El impuesto por defecto del concepto es un TRASLADO —lo que se le cobra al cliente—, así
# que tiene que ser trasladable. Un ISR aquí es un error de captura del catálogo, y atajarlo
# en el concepto evita que se propague a cada partida que lo use.
default_tax_id = data.get("default_tax_id")
if default_tax_id is not None:
tax = db.query(Tax).filter(Tax.id == default_tax_id).first()
if tax is not None and not tax.is_transferred:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"El impuesto {tax.code} ({tax.description}) no puede trasladarse",
)
def _check_unique(
db: Session,
tenant_id: int,
company_id: int,
code: str | None,
product_service_id: int | None,
exclude_id: int | None = None,
) -> None:
"""Aplica en el service las mismas reglas que los índices únicos parciales.
Sin esto el conflicto llegaría al cliente como un IntegrityError crudo; aquí se
traduce a un 409 con mensaje en español.
"""
base = db.query(Concept).filter(
Concept.tenant_id == tenant_id,
Concept.company_id == company_id,
Concept.deleted_at.is_(None),
)
if exclude_id is not None:
base = base.filter(Concept.id != exclude_id)
if code is not None and base.filter(Concept.code == code).first() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe un concepto con la clave '{code}' en esta empresa",
)
# Regla 1:1 — una clave ProdServ no puede repetirse entre conceptos de la empresa.
if product_service_id is not None and base.filter(
Concept.product_service_id == product_service_id
).first() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="La clave de producto/servicio del SAT ya está asignada a otro concepto de esta empresa",
)
def get_concepts(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
active_only: bool | None = None,
product_service_id: int | None = None,
) -> list[Concept]:
q = db.query(Concept).filter(
Concept.tenant_id == tenant_id,
Concept.company_id == company_id,
Concept.deleted_at.is_(None),
)
if active_only is not None:
q = q.filter(Concept.is_active.is_(active_only))
if product_service_id is not None:
q = q.filter(Concept.product_service_id == product_service_id)
if search:
term = f"%{search.strip()}%"
q = q.filter(or_(Concept.code.ilike(term), Concept.description.ilike(term)))
return q.order_by(Concept.code.asc()).all()
def get_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> Concept:
obj = db.query(Concept).filter(
Concept.id == concept_id,
Concept.tenant_id == tenant_id,
Concept.company_id == company_id,
Concept.deleted_at.is_(None),
).first()
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado")
return obj
def create_concept(
db: Session, payload: ConceptCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Concept:
data = payload.model_dump()
_check_sat_refs(db, data)
_check_unique(db, tenant_id, company_id, data["code"], data["product_service_id"])
obj = Concept(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_concept(
db: Session,
concept_id: int,
payload: ConceptUpdate,
tenant_id: int,
company_id: int,
user_id: str | None = None,
) -> Concept:
obj = get_concept(db, concept_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_check_sat_refs(db, data)
_check_unique(
db,
tenant_id,
company_id,
data.get("code"),
data.get("product_service_id"),
exclude_id=obj.id,
)
for field, value in data.items():
setattr(obj, field, value)
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def delete_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> None:
"""Baja lógica: libera la clave ProdServ y el código para un concepto nuevo."""
obj = get_concept(db, concept_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()

View File

@@ -1,7 +1,8 @@
from datetime import date, datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, computed_field
from pydantic import BaseModel, ConfigDict, Field, model_validator, computed_field
class InvoiceClientReviewInput(BaseModel):
@@ -10,7 +11,16 @@ class InvoiceClientReviewInput(BaseModel):
notes: str | None = None
class InvoiceItemBase(BaseModel):
class InvoiceItemSatFields(BaseModel):
"""Claves fiscales de la partida. Opcionales: las facturas previas no las tienen."""
concept_id: int | None = None
product_service_id: int | None = None
unit_of_measure_id: int | None = None
tax_object_id: int | None = None
class InvoiceItemBase(InvoiceItemSatFields):
concept: str = Field(..., max_length=60)
description: str | None = Field(None, max_length=255)
quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2)
@@ -19,9 +29,14 @@ class InvoiceItemBase(BaseModel):
class InvoiceItemCreate(InvoiceItemBase):
invoice_id: int
# Opcional solo si viene concept_id: el service copia la descripción del concepto.
concept: str | None = Field(None, max_length=60)
# Opcional para poder heredar el precio del concepto: con el default 0 de InvoiceItemBase
# siempre llegaría un valor y el service no podría distinguir "no lo capturó" de "capturó 0".
unit_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
class InvoiceItemUpdate(BaseModel):
class InvoiceItemUpdate(InvoiceItemSatFields):
concept: str | None = Field(None, max_length=60)
description: str | None = Field(None, max_length=255)
quantity: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2)
@@ -69,13 +84,27 @@ class InvoiceBase(BaseModel):
shipment_id: int | None = None
quote_id: int | None = None
account_id: int | None = None
currency: str = Field("MXN", max_length=3)
# Opcionales a propósito: con un default no nulo, ``model_dump()`` los incluiría siempre y
# el service no podría distinguir "no lo eligió" de "eligió eso" — con lo que la herencia de
# los datos del cliente nunca se activaría. Si ni el alta ni la ficha los traen, manda el
# server_default de la columna.
currency: str | None = Field(None, max_length=3)
# Tipo de cambio a MXN, obligatorio para timbrar si la moneda no es MXN.
exchange_rate: Decimal | None = Field(None, gt=0, max_digits=14, decimal_places=6)
issue_date: date | None = None
due_date: date | None = None
tax_rate: Decimal = Field(Decimal(0), ge=0, le=100, max_digits=5, decimal_places=2)
tax_rate: Decimal | None = Field(None, ge=0, le=100, max_digits=5, decimal_places=2)
bank_info: str | None = None
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
# ----- Claves fiscales del CFDI (opcionales mientras no se timbre) -----
voucher_type_id: int | None = None
payment_form_id: int | None = None
payment_method_id: int | None = None
expedition_zip_code: str | None = Field(None, max_length=5)
# Modo de timbrado de ESTA factura. 'produccion' emite un CFDI con validez fiscal real
# ante el SAT; por eso el default es 'pruebas' y subirlo es una decisión explícita.
stamping_mode: Literal["pruebas", "produccion"] = "pruebas"
class InvoiceCreate(InvoiceBase):
@@ -88,24 +117,40 @@ class InvoiceUpdate(BaseModel):
quote_id: int | None = None
account_id: int | None = None
currency: str | None = Field(None, max_length=3)
exchange_rate: Decimal | None = Field(None, gt=0, max_digits=14, decimal_places=6)
issue_date: date | None = None
due_date: date | None = None
tax_rate: Decimal | None = Field(None, ge=0, le=100, max_digits=5, decimal_places=2)
bank_info: str | None = None
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
voucher_type_id: int | None = None
payment_form_id: int | None = None
payment_method_id: int | None = None
expedition_zip_code: str | None = Field(None, max_length=5)
stamping_mode: Literal["pruebas", "produccion"] | None = None
class InvoiceResponse(InvoiceBase):
model_config = ConfigDict(from_attributes=True)
id: int
case_id: int | None = None
status: str
# Se redeclaran porque en InvoiceBase son opcionales para habilitar la herencia; en la
# respuesta corresponden a columnas NOT NULL y aflojarlas relajaría el contrato de salida.
currency: str
tax_rate: Decimal
subtotal: Decimal
tax_amount: Decimal
# Impuestos retenidos: restan del total, igual que en el comprobante. Sin exponerlos, el
# total no cuadraría con subtotal + tax_amount y nada explicaría la diferencia.
withheld_amount: Decimal
total: Decimal
paid_amount: Decimal
balance: Decimal
# false en las facturas anteriores al cálculo por partida: conservan la fórmula del % global.
taxes_per_item: bool
ops_cost_total: Decimal | None = None
sent_at: datetime | None = None
paid_at: datetime | None = None
@@ -119,3 +164,42 @@ class InvoiceResponse(InvoiceBase):
company_id: int
created_at: datetime
updated_at: datetime
class InvoiceItemTaxInput(BaseModel):
"""Alta o ajuste de un impuesto de la partida.
El importe no se recibe: se calcula de la base de la partida por la tasa, para que no
pueda quedar un desglose que no cuadre con el importe del concepto.
"""
tax_id: int
# Nula sólo para un exento, que no lleva TasaOCuota en el comprobante. Una tasa 0 SÍ es un
# valor válido y distinto: se declara con TasaOCuota="0.000000".
rate: Decimal | None = Field(None, ge=0, le=1, max_digits=8, decimal_places=6)
is_withholding: bool = False
# 'Cuota' queda fuera a propósito: su importe es cuota × cantidad, no base × tasa, y
# aceptarla sin esa fórmula daría importes plausibles y equivocados.
factor: Literal["Tasa", "Exento"] = "Tasa"
@model_validator(mode="after")
def _valida_tasa_contra_factor(self):
if self.factor == "Exento":
if self.rate:
raise ValueError("Un impuesto exento no lleva tasa")
elif self.rate is None:
raise ValueError("Un impuesto con factor Tasa requiere la tasa (0 para el 0%)")
return self
class InvoiceItemTaxResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
invoice_item_id: int
tax_id: int
is_withholding: bool
rate: Decimal | None = None
amount: Decimal
factor: str
is_manual: bool

View File

@@ -1,11 +1,34 @@
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy import (
Boolean,
CheckConstraint,
Date,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from ..catalogs.models import ( # noqa: F401 (registra los catálogos SAT referidos por las FK)
PaymentForm,
PaymentMethod,
ProductService,
Tax,
TaxObject,
UnitOfMeasure,
VoucherType,
)
from ..concepts.models import Concept # noqa: F401
class Invoice(Base, TenantScopedMixin, TimestampMixin):
"""Factura (Diagrama 4). Integra los costos de la operación para cobro al cliente."""
@@ -15,6 +38,7 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
shipment_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ops.shipments.id"), nullable=True, index=True
)
@@ -25,13 +49,24 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
# Tipo de cambio a MXN. Obligatorio para timbrar cuando la moneda no es MXN (lo exige
# c_Moneda del SAT vía CfdiData.validate); en MXN se queda en NULL y el CFDI no lo lleva.
exchange_rate: Mapped[float | None] = mapped_column(Numeric(14, 6), nullable=True)
# borrador | emitida | enviada | en_revision_cliente | pagada | cancelada
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"), index=True)
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
subtotal: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
tax_rate: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default=text("0")) # % IVA
# % de IVA POR DEFECTO de las partidas nuevas objeto de impuesto. Con taxes_per_item activo
# NO determina el total: el impuesto sale de las filas de invoice_item_taxes.
tax_rate: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default=text("0"))
tax_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
# Impuestos retenidos. Restan del total, igual que en el comprobante.
withheld_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
# Versiona el cálculo del impuesto. Las facturas nuevas nacen en true (suma por partida); las
# que existían antes del cambio quedaron en false y conservan la fórmula con la que se
# emitieron, para que su total no se mueva sola al registrarles un pago.
taxes_per_item: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
total: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
paid_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
balance: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
@@ -50,6 +85,25 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
# ----- Datos fiscales del CFDI (catálogos SAT) -----
# Nullables: las facturas emitidas antes de existir los catálogos no los tienen.
voucher_type_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("sat.voucher_types.id"), nullable=True
)
payment_form_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("sat.payment_forms.id"), nullable=True
)
payment_method_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("sat.payment_methods.id"), nullable=True
)
expedition_zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True)
# ----- Modo de timbrado (por factura, no por entorno) -----
# 'pruebas' | 'produccion'. Determina el host del PAC y, con él, si el comprobante tiene
# validez fiscal ante el SAT. Inmutable una vez que la factura tiene un timbre exitoso:
# cambiarlo después falsearía el registro de con qué intención se emitió.
stamping_mode: Mapped[str] = mapped_column(
String(12), nullable=False, server_default=text("'pruebas'")
)
class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
@@ -62,10 +116,72 @@ class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
invoice_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
)
# Texto libre histórico: lo consume el PDF actual y se conserva obligatorio.
concept: Mapped[str] = mapped_column(String(60), nullable=False)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1"))
unit_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
# ----- Datos fiscales de la partida (catálogos SAT) -----
concept_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("fin.concepts.id"), nullable=True, index=True
)
product_service_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("sat.products_services.id"), nullable=True
)
unit_of_measure_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("sat.units_of_measure.id"), nullable=True
)
tax_object_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("sat.tax_objects.id"), nullable=True
)
class InvoiceItemTax(Base, TenantScopedMixin, TimestampMixin):
"""Impuesto trasladado o retenido de una partida de la factura.
**Es la fuente del impuesto de la factura**, no solo detalle para el CFDI: cuando
``invoices.taxes_per_item`` está activo, ``tax_amount`` y ``withheld_amount`` son la suma de
estas filas y el total sale de ahí. Antes el dinero salía de ``invoices.tax_rate`` aplicado
al subtotal completo, y los dos planos podían divergir.
El índice único es por ``(invoice_item_id, tax_id, is_withholding)`` y **no incluye
``factor``**: un IVA trasladado sigue siendo uno solo por partida, y pasar de Tasa a Exento
es un UPDATE de esa fila, no una fila nueva.
"""
__tablename__ = "invoice_item_taxes"
__table_args__ = (
Index(
"uq_fin_invoice_item_taxes",
"invoice_item_id", "tax_id", "is_withholding",
unique=True,
postgresql_where=text("deleted_at IS NULL"),
sqlite_where=text("deleted_at IS NULL"),
),
# Declarado también aquí y no solo en la migración: las pruebas construyen el esquema con
# ``Base.metadata.create_all`` y sin esto validarían una base distinta de la de producción.
CheckConstraint(
"factor IN ('Tasa', 'Cuota', 'Exento')", name="ck_fin_invoice_item_taxes_factor"
),
{"schema": "fin"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
invoice_item_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fin.invoice_items.id"), nullable=False, index=True
)
tax_id: Mapped[int] = mapped_column(Integer, ForeignKey("sat.taxes.id"), nullable=False)
# false = trasladado (se cobra al cliente); true = retenido
is_withholding: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
rate: Mapped[float | None] = mapped_column(Numeric(8, 6), nullable=True) # p. ej. 0.160000
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
# c_TipoFactor. Un 'Exento' no lleva tasa ni importe en el XML y no suma a los totales; es
# distinto de una tasa 0%, que sí se declara con TasaOCuota="0.000000".
factor: Mapped[str] = mapped_column(String(7), nullable=False, server_default=text("'Tasa'"))
# true = lo capturó una persona por el endpoint de impuestos de la partida. La derivación
# automática no pisa lo manual, y esto lo registra como hecho en vez de inferirlo de la forma
# de la fila (que ya no distingue: un IVA al 0% derivado y uno capturado son idénticos).
is_manual: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
class Payment(Base, TenantScopedMixin, TimestampMixin):

View File

@@ -63,6 +63,8 @@ def _build_lines(
total,
paid,
balance,
tax_groups: Sequence[dict] | None = None,
withheld=0,
bank_info: str | None,
notes: str | None,
) -> list[tuple[str, int]]:
@@ -84,14 +86,20 @@ def _build_lines(
qty = Decimal(str(it.get("quantity") or 0))
unit = Decimal(str(it.get("unit_amount") or 0))
amount = (qty * unit).quantize(Decimal("0.01"))
label = concept if not desc else f"{concept}{desc}"
label = label[:42].ljust(42)
label = etiqueta_partida(concept, desc)[:42].ljust(42)
row = f"{qty:>5.2f} {label} {unit:>12,.2f} {amount:>12,.2f}"
L.append((row, 10))
L.append(("-" * 78, 10))
L.append(("", 11))
L.append((f"Subtotal: {_money(subtotal, currency)}", 11))
L.append((f"IVA ({Decimal(str(tax_rate or 0)):.2f}%): {_money(tax_amount, currency)}", 11))
# Un renglón por grupo (impuesto, factor, tasa), como los agrupa el comprobante. El % de
# la factura dejó de servir aquí: una factura puede mezclar tasas, o traer una partida
# exenta, y entonces no hay un único porcentaje que sea cierto.
for g in tax_groups or []:
L.append((_renglon_impuesto(g, currency), 11))
if not tax_groups and Decimal(str(tax_amount or 0)) != 0:
# Facturas con la fórmula anterior (un % global sobre el subtotal completo).
L.append((f"IVA ({Decimal(str(tax_rate or 0)):.2f}%): {_money(tax_amount, currency)}", 11))
L.append((f"Total: {_money(total, currency)}", 13))
L.append((f"Pagado: {_money(paid, currency)}", 11))
L.append((f"Saldo: {_money(balance, currency)}", 12))
@@ -108,6 +116,42 @@ def _build_lines(
return L
def etiqueta_partida(concept: str, description: str) -> str:
"""Cómo se lee la partida en el renglón del PDF.
Los dos campos vienen del mismo texto cuando la partida usa un concepto del catálogo:
``concept`` es la descripción recortada a 60 caracteres y ``description`` la completa.
Imprimir ambos repetiría el texto —una vez cortado y otra entero—, así que se detecta por
prefijo y se imprime sólo el largo.
Cuando son textos distintos —una clave genérica más el detalle que alguien escribió— se
imprimen los dos, que es lo que hacía siempre.
"""
if not description:
return concept
if not concept or description.startswith(concept):
return description
return f"{concept}{description}"
def _renglon_impuesto(grupo: dict, currency: str) -> str:
"""Un renglón del desglose de impuestos.
Los exentos se listan **con su base y sin importe**: es lo único que le explica al cliente por
qué el total no es el subtotal por 1.16, que es justo la pregunta que llega por teléfono. Las
retenciones van con signo negativo, porque restan del total igual que en el comprobante — antes
no aparecían en el PDF y la factura impresa pedía un importe distinto al del CFDI.
"""
nombre = str(grupo.get("nombre") or "Impuesto")
base = _money(grupo.get("base") or 0, currency)
if grupo.get("factor") == "Exento":
return f"{nombre} Exento (sobre {base}): —"
tasa = Decimal(str(grupo.get("rate") or 0)) * 100
importe = Decimal(str(grupo.get("amount") or 0))
etiqueta = f"Ret. {nombre}" if grupo.get("is_withholding") else nombre
signo = "-" if grupo.get("is_withholding") else ""
return f"{etiqueta} {tasa:.2f}% (sobre {base}): {signo}{_money(importe, currency)}"
def build_invoice_pdf(**kwargs) -> bytes:
"""Construye el PDF de la factura y devuelve los bytes."""
lines = _build_lines(**kwargs)

View File

@@ -4,12 +4,14 @@ from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from . import service, taxes_service
from .dto import (
InvoiceClientReviewInput,
InvoiceCreate,
InvoiceItemCreate,
InvoiceItemResponse,
InvoiceItemTaxInput,
InvoiceItemTaxResponse,
InvoiceItemUpdate,
InvoiceResponse,
InvoiceUpdate,
@@ -132,3 +134,26 @@ def create_payment(payload: PaymentCreate, company_id: int = Query(...), current
@router.delete("/payments/{payment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_payment(payment_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
service.delete_payment(db, payment_id, current_user["tenant_id"], company_id)
# ----- Impuestos por partida -----
# El traslado de IVA se deriva del % de la factura; estos endpoints son para ajustarlo
# (retenciones, tasas distintas) cuando el caso lo pide.
@router.get("/invoice-items/{item_id}/taxes", response_model=list[InvoiceItemTaxResponse])
def list_item_taxes(item_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return taxes_service.list_item_taxes(db, item_id, current_user["tenant_id"], company_id)
@router.put("/invoice-items/{item_id}/taxes", response_model=InvoiceItemTaxResponse)
def set_item_tax(item_id: int, payload: InvoiceItemTaxInput, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
"""Alta o ajuste. La combinación impuesto + traslado/retención es única por partida."""
return taxes_service.set_item_tax(
db, item_id, payload.tax_id, payload.rate, payload.is_withholding,
current_user["tenant_id"], company_id, factor=payload.factor,
)
@router.delete("/invoice-item-taxes/{tax_row_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item_tax(tax_row_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
taxes_service.delete_item_tax(db, tax_row_id, current_user["tenant_id"], company_id)

View File

@@ -1,14 +1,20 @@
import logging
from datetime import date, datetime, timezone
from decimal import Decimal
from decimal import ROUND_HALF_UP, Decimal
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.cases import service as cases_service
from api.v1.modules.crm.common.folios import next_folio
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
from api.v1.modules.ops.shipments.models import Shipment
from ..catalogs import service as catalogs_service
from ..catalogs.models import PaymentForm, PaymentMethod
from ..concepts.models import Concept
from .dto import (
InvoiceClientReviewInput,
InvoiceCreate,
@@ -17,9 +23,12 @@ from .dto import (
InvoiceUpdate,
PaymentCreate,
)
from .models import Invoice, InvoiceItem, Payment
from . import taxes_service
from .models import Invoice, InvoiceItem, InvoiceItemTax, Payment
from .pdf import build_invoice_pdf
logger = logging.getLogger(__name__)
def _exists(db: Session, model, _id, tenant_id, company_id) -> bool:
if _id is None:
@@ -32,6 +41,18 @@ def _exists(db: Session, model, _id, tenant_id, company_id) -> bool:
)
# Campos que el DTO acepta como nulos —para poder heredarlos del cliente— pero cuya columna es
# NOT NULL con server_default. Un None explícito tiene que retirarse del payload para que mande
# el default de la base, en vez de reventar en el flush.
_COLUMNAS_CON_DEFAULT = ("currency", "tax_rate")
def _drop_nulls_de_columnas_obligatorias(data: dict) -> None:
for campo in _COLUMNAS_CON_DEFAULT:
if campo in data and data[campo] is None:
data.pop(campo)
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
for field, model, msg in [
("account_id", Account, "El cliente asociado no existe"),
@@ -42,20 +63,70 @@ def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) ->
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
def _totales_por_partida(db: Session, invoice: Invoice) -> tuple[Decimal, Decimal, Decimal]:
"""``(subtotal, trasladado, retenido)`` sumando partida por partida.
Se agrega en Python y no en SQL por dos razones. El redondeo por renglón —que es el que hace
el comprobante— no se expresa igual en Postgres que en SQLite, donde corre la suite; y
``func.sum`` devuelve float bajo SQLite, que es justo lo que no se quiere tocando dinero.
Son unidades de partidas por factura, no miles.
"""
items = (
db.query(InvoiceItem)
.filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
.all()
)
subtotal = trasladado = retenido = Decimal("0.00")
for item in items:
subtotal += taxes_service.line_base(item)
for t in (
db.query(InvoiceItemTax)
.filter(
InvoiceItemTax.invoice_item_id == item.id,
InvoiceItemTax.deleted_at.is_(None),
)
.all()
):
# Los importes ya están en centavos: volver a redondear la suma no cambia nada y
# esconde de dónde salió la precisión. Un exento tiene importe 0 y no suma.
importe = Decimal(str(t.amount or 0))
if t.is_withholding:
retenido += importe
else:
trasladado += importe
return subtotal, trasladado, retenido
def _recompute(db: Session, invoice: Invoice) -> None:
subtotal = db.query(func.coalesce(func.sum(InvoiceItem.quantity * InvoiceItem.unit_amount), 0)).filter(
InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None)
).scalar()
"""Recalcula los totales de la factura y su estado de cobranza.
El impuesto sale de los impuestos de cada partida (``taxes_per_item``), que es lo que declara
el comprobante. Las facturas creadas antes de ese cambio conservan la fórmula del porcentaje
global: recalcularlas movería el total con el que se emitieron y el que ya vio el cliente.
"""
paid = db.query(func.coalesce(func.sum(Payment.amount), 0)).filter(
Payment.invoice_id == invoice.id, Payment.deleted_at.is_(None)
).scalar()
subtotal = Decimal(subtotal or 0)
rate = Decimal(invoice.tax_rate or 0)
tax = (subtotal * rate / Decimal(100)).quantize(Decimal("0.01"))
total = subtotal + tax
paid = Decimal(paid or 0)
paid = Decimal(str(paid or 0))
subtotal, trasladado, retenido = _totales_por_partida(db, invoice)
if invoice.taxes_per_item:
tax = trasladado
withheld = retenido
else:
# Fórmula histórica: el % global sobre el subtotal completo, con un solo redondeo. Las
# retenciones no se contemplaban y se dejan fuera para no mover el total de una factura
# vieja por un camino que no existía cuando se emitió.
tax = (subtotal * Decimal(str(invoice.tax_rate or 0)) / Decimal(100)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
withheld = Decimal("0.00")
total = subtotal + tax - withheld
invoice.subtotal = subtotal
invoice.tax_amount = tax
invoice.withheld_amount = withheld
invoice.total = total
invoice.paid_amount = paid
invoice.balance = total - paid
@@ -69,6 +140,11 @@ def _recompute(db: Session, invoice: Invoice) -> None:
invoice.paid_at = None
def recompute_invoice(db: Session, invoice: Invoice) -> None:
"""Punto de entrada público de ``_recompute``, para los módulos que mueven impuestos."""
_recompute(db, invoice)
# ----- Invoices -----
def get_invoices(db, tenant_id, company_id, search=None, inv_status=None, account_id=None) -> list[Invoice]:
@@ -94,7 +170,18 @@ def get_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=None) -> Invoice:
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
_inherit_account_billing(db, data, tenant_id, company_id)
_drop_nulls_de_columnas_obligatorias(data)
obj = Invoice(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
# Folio F... auto-generado (mensual) si no viene uno explícito
if not obj.reference:
obj.reference = next_folio(db, tenant_id, company_id, "F", None, with_direction=False)
# Expediente heredado del embarque (si la factura se genera de uno)
if obj.shipment_id and not obj.case_id:
sh = db.query(Shipment).filter(Shipment.id == obj.shipment_id).first()
if sh:
obj.case_id = sh.case_id
cases_service.advance_stage(db, obj.case_id, "facturacion")
db.add(obj)
db.flush()
_recompute(db, obj)
@@ -107,16 +194,90 @@ def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id
obj = get_invoice(db, invoice_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_validate_refs(db, data, tenant_id, company_id)
_reject_if_stamped(db, obj, tenant_id, company_id, data=data)
# Cambiar de cliente vuelve a heredar sus datos de facturación: facturar al cliente B con la
# forma de pago del cliente A es un error silencioso. Se re-hereda ANTES del setattr y sólo
# sobre lo que el PATCH no manda explícito, igual que update_item con el concepto.
if "account_id" in data:
for campo, _, _ in _ACCOUNT_INHERITED_BILLING:
data.setdefault(campo, None)
data.setdefault("currency", None)
_inherit_account_billing(db, data, tenant_id, company_id)
# Lo que no se pudo heredar se retira del PATCH para no pisar con NULL lo ya capturado.
for campo in ("currency", *(c for c, _, _ in _ACCOUNT_INHERITED_BILLING)):
if data.get(campo) is None:
data.pop(campo, None)
_drop_nulls_de_columnas_obligatorias(data)
for f, v in data.items():
setattr(obj, f, v)
obj.updated_by = user_id
db.flush()
_recompute(db, obj) # tax_rate pudo cambiar
if "tax_rate" in data:
# El % es el valor por defecto de las partidas cuyo impuesto se deriva: al cambiarlo se
# propaga a ésas. No toca las que tienen configuración fiscal de su concepto ni las
# capturadas a mano.
taxes_service.sync_invoice_taxes(db, obj)
_recompute(db, obj)
db.commit()
db.refresh(obj)
return obj
# Campos del comprobante que dejan de ser editables en cuanto la factura tiene timbre. Son los
# que el CFDI ya declaró ante el SAT: cambiarlos aquí haría que la factura y su comprobante
# contaran cosas distintas, y el comprobante es el que vale.
_INMUTABLES_CON_TIMBRE = (
"account_id",
"reference",
"currency",
"exchange_rate",
"issue_date",
"payment_form_id",
"payment_method_id",
"expedition_zip_code",
"voucher_type_id",
"stamping_mode",
"tax_rate",
)
def _reject_if_stamped(
db, obj: Invoice, tenant_id, company_id, data: dict | None = None, motivo: str | None = None
) -> None:
"""Rechaza con 409 la edición de una factura ya timbrada.
Con ``data`` sólo protege los campos de ``_INMUTABLES_CON_TIMBRE`` y únicamente cuando el
valor que llega es distinto del actual: guardar el encabezado sin tocarlos sigue permitido.
Sin ``data`` no admite nada, y así se usa desde las partidas y sus impuestos — el desglose
del comprobante no se corrige editándolo, se corrige cancelando y refacturando.
Cobrar NO pasa por aquí: registrar o borrar un pago no altera el CFDI.
"""
# Import diferido: stamping importa invoices, y al revés sería circular.
from ..stamping.service import get_stamp # noqa: PLC0415
if data is not None:
cambiados = [
campo
for campo in _INMUTABLES_CON_TIMBRE
if campo in data and data[campo] != getattr(obj, campo)
]
if not cambiados:
return
detalle = (
f"La factura ya está timbrada: no se puede cambiar {', '.join(cambiados)}. "
"El CFDI ya existe ante el SAT; para corregirlo hay que cancelarlo y refacturar."
)
else:
detalle = (
f"La factura ya está timbrada: {motivo or 'no admite cambios'}. El CFDI ya existe "
"ante el SAT; para corregirlo hay que cancelarlo y refacturar."
)
if get_stamp(db, obj.id, tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detalle)
def delete_invoice(db, invoice_id, tenant_id, company_id) -> None:
obj = get_invoice(db, invoice_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
@@ -139,6 +300,50 @@ def emit_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
return _set_status(db, invoice_id, tenant_id, company_id, "emitida", set_issue=True)
def _grupos_de_impuesto(db, invoice: Invoice) -> list[dict]:
"""Impuestos de la factura agrupados por ``(impuesto, factor, tasa)``, con su base.
Es el mismo criterio con el que el comprobante arma su nodo ``Impuestos``, y por eso el
desglose del PDF y el del CFDI dicen lo mismo. Vacío para las facturas con la fórmula
anterior: ahí el único desglose que existió fue el porcentaje global.
"""
if not invoice.taxes_per_item:
return []
from ..catalogs.models import Tax # noqa: PLC0415
grupos: dict[tuple, dict] = {}
items = (
db.query(InvoiceItem)
.filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
.all()
)
for item in items:
base = taxes_service.line_base(item)
for t in (
db.query(InvoiceItemTax)
.filter(InvoiceItemTax.invoice_item_id == item.id, InvoiceItemTax.deleted_at.is_(None))
.all()
):
tax = db.query(Tax).filter(Tax.id == t.tax_id).first()
clave = (t.tax_id, t.factor, str(t.rate or 0), bool(t.is_withholding))
g = grupos.setdefault(
clave,
{
"nombre": (tax.description if tax else "Impuesto"),
"factor": t.factor,
"rate": Decimal(str(t.rate or 0)),
"is_withholding": bool(t.is_withholding),
"base": Decimal("0.00"),
"amount": Decimal("0.00"),
},
)
g["base"] += base
g["amount"] += Decimal(str(t.amount or 0))
# Traslados primero y retenciones después, como se leen en un comprobante.
return sorted(grupos.values(), key=lambda g: (g["is_withholding"], g["nombre"]))
def _build_pdf_bytes(db, invoice: Invoice, tenant_id, company_id) -> bytes:
"""Arma los bytes del PDF de la factura a partir de sus datos y conceptos."""
items = get_items(db, invoice.id, tenant_id, company_id)
@@ -162,6 +367,8 @@ def _build_pdf_bytes(db, invoice: Invoice, tenant_id, company_id) -> bytes:
total=invoice.total,
paid=invoice.paid_amount,
balance=invoice.balance,
tax_groups=_grupos_de_impuesto(db, invoice),
withheld=invoice.withheld_amount,
bank_info=invoice.bank_info,
notes=invoice.notes,
)
@@ -180,10 +387,20 @@ def send_invoice(db, invoice_id, tenant_id, company_id, user_id=None) -> Invoice
if not obj.issue_date:
obj.issue_date = date.today()
db.flush()
pdf_bytes = _build_pdf_bytes(db, obj, tenant_id, company_id)
key = f"tenants/{tenant_id}/companies/{company_id}/fin-invoices/{obj.id}/factura-{obj.reference or obj.id}.pdf"
put_object_bytes(key, pdf_bytes, content_type="application/pdf")
obj.pdf_file_key = key
# Con timbre no se regenera el PDF: el documento que acompaña a un CFDI es el que se emitió
# con él. Regenerarlo sobre la misma llave de MinIO reescribiría lo que el cliente ya recibió,
# y con cualquier cambio posterior en la factura diría algo distinto del comprobante.
from ..stamping.service import get_stamp # noqa: PLC0415
ya_timbrada = get_stamp(db, obj.id, tenant_id, company_id) is not None
if not (ya_timbrada and obj.pdf_file_key):
pdf_bytes = _build_pdf_bytes(db, obj, tenant_id, company_id)
key = (
f"tenants/{tenant_id}/companies/{company_id}/fin-invoices/{obj.id}/"
f"factura-{obj.reference or obj.id}.pdf"
)
put_object_bytes(key, pdf_bytes, content_type="application/pdf")
obj.pdf_file_key = key
obj.status = "enviada"
obj.sent_at = datetime.now(timezone.utc)
if not obj.issue_date:
@@ -279,12 +496,24 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
if shipment.quote_id:
quote = db.query(Quote).filter(Quote.id == shipment.quote_id).first()
# Datos de facturación del cliente. La moneda del EMBARQUE gana sobre la de la ficha: es la
# que se coteó y en la que se operó de verdad, mientras la del cliente es una preferencia
# comercial. El cliente entra sólo como último recurso, antes del default MXN.
datos = {
"account_id": shipment.account_id,
"currency": (shipment.cost_currency or (quote.currency if quote else None)),
}
_inherit_account_billing(db, datos, tenant_id, company_id)
invoice = Invoice(
reference=shipment.reference,
case_id=shipment.case_id,
shipment_id=shipment.id,
quote_id=shipment.quote_id,
account_id=shipment.account_id,
currency=(shipment.cost_currency or (quote.currency if quote else "MXN")),
currency=(datos.get("currency") or "MXN"),
payment_form_id=datos.get("payment_form_id"),
payment_method_id=datos.get("payment_method_id"),
ops_cost_total=shipment.actual_cost_total,
status="borrador",
tenant_id=tenant_id,
@@ -294,6 +523,7 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
)
db.add(invoice)
db.flush()
cases_service.advance_stage(db, shipment.case_id, "facturacion")
if quote:
q_items = db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)).all()
@@ -304,6 +534,14 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None)
tenant_id=tenant_id, company_id=company_id,
))
db.flush()
# Las partidas se insertan directo, sin pasar por create_item, así que hay que derivar
# sus impuestos a mano o la factura nacería con el desglose vacío.
#
# PENDIENTE: crm.quote_items no tiene concept_id, así que estas partidas nacen sin claves
# fiscales (product_service_id, unit_of_measure_id, tax_object_id) y por tanto sin
# impuestos. Mapear el texto libre del concepto de la cotización contra fin.concepts es su
# propio ticket, con su propia decisión de qué hacer cuando el texto no coincide.
taxes_service.sync_invoice_taxes(db, invoice)
_recompute(db, invoice)
db.commit()
@@ -331,11 +569,141 @@ def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem:
return obj
# Claves del SAT que la partida hereda del concepto del catálogo cuando no se envían.
_CONCEPT_INHERITED_FIELDS = ("product_service_id", "unit_of_measure_id", "tax_object_id")
# Campos que la partida hereda del concepto con OTRO nombre: (campo de la partida, del concepto).
_CONCEPT_RENAMED_FIELDS = (("unit_amount", "unit_price"),)
# Datos de facturación que la factura hereda de la ficha del cliente: (campo de la factura,
# campo del Account, catálogo del SAT contra el que se resuelve la clave).
_ACCOUNT_INHERITED_BILLING = (
("payment_form_id", "payment_form", PaymentForm),
("payment_method_id", "payment_method", PaymentMethod),
)
def _inherit_account_billing(db, data: dict, tenant_id, company_id) -> None:
"""Completa los datos de facturación de la factura desde la ficha del cliente.
Hereda tres cosas y sólo tres: forma de pago, método de pago y moneda. Las dos primeras son
justo las que detienen el timbrado en validación si quedan vacías, y estaban capturándose a
mano en cada factura aunque ya vivieran en la ficha.
Dos reglas, las mismas que ``_resolve_item_concept``:
- **Lo que el cliente sí envía manda sobre la ficha**: sólo se escribe donde no hay valor.
- **Completa, nunca borra**: si la ficha trae un texto que no resuelve a ninguna clave del
SAT, no se asigna nada. Así cambiar de cliente no puede vaciar un dato ya capturado.
NO hereda ``due_date`` a partir de ``Account.credit_days``, ni ``commercial_terms`` hacia
las notas. Se decidió dejarlos fuera: el vencimiento depende de la fecha de emisión, que
puede no estar fijada todavía, y las notas de la factura son texto que alguien escribe.
"""
account_id = data.get("account_id")
if not account_id:
return
account = (
db.query(Account)
.filter(
Account.id == account_id,
Account.tenant_id == tenant_id,
Account.company_id == company_id,
Account.deleted_at.is_(None),
)
.first()
)
if not account:
return
for campo, campo_account, modelo in _ACCOUNT_INHERITED_BILLING:
if data.get(campo) is not None:
continue
texto = getattr(account, campo_account, None)
fila = catalogs_service.find_by_code(db, modelo, texto)
if fila is not None:
data[campo] = fila.id
elif texto:
# Se avisa para que se limpie el CRM: la factura se crea igual y el timbrado
# reportará la clave faltante junto al resto de los pendientes.
logger.info(
"factura: la clave %r de %s del cliente %s no existe en el catálogo del SAT; "
"no se hereda",
texto, campo_account, account_id,
)
if not data.get("currency"):
moneda = (account.currency or "").strip().upper()[:3]
if moneda:
data["currency"] = moneda
def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None:
"""Completa la partida a partir del concepto del catálogo.
Hereda, siempre y sólo cuando el cliente no lo manda:
- ``concept`` y ``description``: la descripción del concepto va a las dos, recortada a 60 en
la primera —que es lo que el PDF lee y lo que la columna admite— y completa en la segunda,
que es la que el CFDI prefiere. Antes sólo se llenaba ``concept``, así que el comprobante
declaraba el texto truncado aunque el catálogo lo tuviera entero.
- Las claves fiscales (``product_service_id``, ``unit_of_measure_id``,
``tax_object_id``): sin ellas la partida capturada por catálogo quedaría
incompleta para el CFDI. Lo que el cliente sí envía manda sobre el catálogo,
para poder facturar una partida con una unidad distinta a la del concepto.
- ``unit_amount`` desde el ``unit_price`` del concepto: si el catálogo ya tiene el precio,
volver a teclearlo en cada partida es trabajo doble y una fuente de discrepancias. Se
hereda en el service y no sólo en la pantalla, para que cualquier cliente de la API lo
obtenga igual — antes el precio lo prellenaba únicamente el formulario web.
El impuesto NO se hereda aquí: vive en filas propias y lo resuelve
``taxes_service.sync_item_taxes`` después del insert, que es quien sabe leer la
configuración fiscal del concepto.
"""
concept_id = data.get("concept_id")
if concept_id is not None:
catalog_concept = db.query(Concept).filter(
Concept.id == concept_id, Concept.tenant_id == tenant_id,
Concept.company_id == company_id, Concept.deleted_at.is_(None),
).first()
if not catalog_concept:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="El concepto del catálogo no existe en esta empresa",
)
if not data.get("concept"):
data["concept"] = catalog_concept.description[:60]
if not data.get("description"):
# La descripción COMPLETA va al campo largo. El CFDI la prefiere sobre ``concept``,
# que está recortado a 60 caracteres, así que sin esto el comprobante declaraba un
# texto truncado de un concepto que el catálogo tiene entero.
data["description"] = catalog_concept.description[:255]
for field in _CONCEPT_INHERITED_FIELDS:
if data.get(field) is None:
data[field] = getattr(catalog_concept, field)
for campo_partida, campo_concepto in _CONCEPT_RENAMED_FIELDS:
if data.get(campo_partida) is None:
data[campo_partida] = getattr(catalog_concept, campo_concepto)
# unit_amount es NOT NULL con server_default: un None que nadie llenó se retira para que
# mande el default de la columna, en vez de reventar en el flush.
if "unit_amount" in data and data["unit_amount"] is None:
data.pop("unit_amount")
if not data.get("concept"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="La partida requiere un concepto o una referencia al catálogo de conceptos",
)
def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> InvoiceItem:
invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id)
item = InvoiceItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
_reject_if_stamped(db, invoice, tenant_id, company_id, motivo="no se le pueden agregar partidas")
data = payload.model_dump()
_resolve_item_concept(db, data, tenant_id, company_id)
item = InvoiceItem(**data, tenant_id=tenant_id, company_id=company_id)
db.add(item)
db.flush()
taxes_service.sync_item_taxes(db, item, invoice)
_recompute(db, invoice)
db.commit()
db.refresh(item)
@@ -344,10 +712,21 @@ def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> Invoic
def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id) -> InvoiceItem:
item = _get_item(db, item_id, tenant_id, company_id)
for f, v in payload.model_dump(exclude_unset=True).items():
_reject_if_stamped(
db, get_invoice(db, item.invoice_id, tenant_id, company_id), tenant_id, company_id,
motivo="sus partidas no se pueden editar",
)
data = payload.model_dump(exclude_unset=True)
# Cambiar el concepto del catálogo revalida la referencia y vuelve a heredar
# descripción y claves fiscales del concepto nuevo.
if data.get("concept_id") is not None:
_resolve_item_concept(db, data, tenant_id, company_id)
for f, v in data.items():
setattr(item, f, v)
db.flush()
_recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id))
invoice = get_invoice(db, item.invoice_id, tenant_id, company_id)
taxes_service.sync_item_taxes(db, item, invoice)
_recompute(db, invoice)
db.commit()
db.refresh(item)
return item
@@ -356,6 +735,13 @@ def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id)
def delete_item(db, item_id, tenant_id, company_id) -> None:
item = _get_item(db, item_id, tenant_id, company_id)
invoice_id = item.invoice_id
_reject_if_stamped(
db, get_invoice(db, invoice_id, tenant_id, company_id), tenant_id, company_id,
motivo="sus partidas no se pueden borrar",
)
# Los impuestos de la partida se van con ella: con el impuesto saliendo de esas filas,
# dejarlas vivas sería seguir cobrando el IVA de una partida que ya no existe.
taxes_service.clear_item_taxes(db, item.id)
item.deleted_at = datetime.now(timezone.utc)
db.flush()
_recompute(db, get_invoice(db, invoice_id, tenant_id, company_id))

View File

@@ -0,0 +1,355 @@
"""Impuestos de las partidas de la factura — **la fuente del impuesto**, no un detalle.
El impuesto se declara y se cobra por partida, igual que en el CFDI: ``invoices.tax_amount`` y
``withheld_amount`` son la suma de estas filas, y de ahí sale el total. Antes el dinero salía de
``invoices.tax_rate`` aplicado al subtotal completo, y los dos planos podían divergir — una
partida que no causa IVA cobraba IVA, y una retención capturada dejaba la factura pidiendo un
importe distinto del que declaraba el comprobante.
De dónde sale la tasa de cada partida, en orden:
1. Si la partida no es objeto de impuesto con desglose (``ObjetoImp`` distinto de 02), no lleva
impuestos. Ni el nodo va en el XML ni el importe suma al total.
2. Si alguien capturó impuestos a mano en esa partida (``is_manual``), no se toca nada.
3. Si su concepto del catálogo trae configuración fiscal, esa manda: es la forma de tener
conceptos exentos o a tasa 0% sin pelear con el % global de la factura.
4. Si no, el traslado de IVA se deriva de ``invoices.tax_rate``.
Los importes se redondean **por renglón** y con ``ROUND_HALF_UP``, no al final y no sobre el
subtotal agregado. Es lo que hace el comprobante: su ``SubTotal`` es la suma de los ``Importe``
ya redondeados de cada concepto, y su ``TotalImpuestosTrasladados`` la suma de los ``Importe`` de
cada traslado. El plano que manda es el XML, y el dinero se le alinea.
"""
from decimal import ROUND_HALF_UP, Decimal
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..catalogs.models import Tax, TaxObject
from ..concepts.models import Concept
from .models import Invoice, InvoiceItem, InvoiceItemTax
# c_ObjetoImp que obligan al desglose de impuestos en el comprobante. El 01 «no objeto», el 03
# «objeto no obligado al desglose» y el 04 «objeto que no causa impuesto» NO llevan nodo de
# impuestos en el concepto, así que tampoco generan fila ni suman al total.
_OBJETO_CON_DESGLOSE = {"02"}
# c_Impuesto del IVA.
_IVA = "002"
# c_TipoFactor admitidos al capturar. 'Cuota' se acepta en la base porque el catálogo del SAT lo
# tiene, pero el service lo rechaza: su importe es cuota × cantidad, no base × tasa, y aceptarlo
# sin esa fórmula daría importes plausibles y equivocados.
FACTOR_TASA = "Tasa"
FACTOR_EXENTO = "Exento"
FACTORES_ADMITIDOS = (FACTOR_TASA, FACTOR_EXENTO)
def cents(value: Decimal) -> Decimal:
"""Redondea a centavos con la misma regla que el comprobante (``ROUND_HALF_UP``)."""
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# Alias interno histórico; se conserva para no tocar los llamadores existentes de este módulo.
_cents = cents
def line_base(item: InvoiceItem) -> Decimal:
"""Importe de la partida, ya redondeado.
Es el mismo valor que ``ConceptLine.amount`` del builder, y tiene que salir de una sola
definición: si la factura sumara los productos sin redondear, su subtotal no coincidiría con
la suma de los ``Importe`` del XML y el PAC rechazaría el comprobante.
"""
return cents(Decimal(str(item.quantity or 0)) * Decimal(str(item.unit_amount or 0)))
def _item_taxes(db: Session, item_id: int) -> list[InvoiceItemTax]:
return (
db.query(InvoiceItemTax)
.filter(InvoiceItemTax.invoice_item_id == item_id, InvoiceItemTax.deleted_at.is_(None))
.order_by(InvoiceItemTax.id)
.all()
)
def _tax_object_code(db: Session, item: InvoiceItem) -> str:
if not item.tax_object_id:
return ""
row = db.query(TaxObject).filter(TaxObject.id == item.tax_object_id).first()
return row.code if row else ""
def _reject_if_stamped_item(
db: Session, item: InvoiceItem, tenant_id: int, company_id: int, motivo: str
) -> None:
"""Bloquea la captura manual de impuestos sobre la partida de una factura ya timbrada.
El desglose viajó al CFDI y ahí quedó. Import diferido de ``service`` porque ese módulo
importa a este; al revés sería circular.
"""
from . import service # noqa: PLC0415
invoice = service.get_invoice(db, item.invoice_id, tenant_id, company_id)
service._reject_if_stamped(db, invoice, tenant_id, company_id, motivo=motivo)
def clear_item_taxes(db: Session, item_id: int) -> None:
"""Borra los impuestos de una partida.
Se llama al borrar la partida. Antes las filas quedaban vivas y era solo ruido; ahora que el
impuesto de la factura sale de ellas, dejarlas sería un cobro fantasma sobre una partida que
ya no existe.
"""
for t in _item_taxes(db, item_id):
db.delete(t)
def _default_fiscal_del_concepto(db: Session, item: InvoiceItem):
"""``(tax_id, rate, factor)`` del concepto del catálogo, o ``None`` si no lo define."""
if not item.concept_id:
return None
concepto = db.query(Concept).filter(Concept.id == item.concept_id).first()
if not concepto or not concepto.default_tax_id or not concepto.default_tax_factor:
return None
factor = concepto.default_tax_factor
rate = None if factor == FACTOR_EXENTO else Decimal(str(concepto.default_tax_rate or 0))
return concepto.default_tax_id, rate, factor
def sync_item_taxes(db: Session, item: InvoiceItem, invoice: Invoice) -> None:
"""Deja el impuesto derivado de la partida al día. Ver el orden de precedencia arriba."""
if _tax_object_code(db, item) not in _OBJETO_CON_DESGLOSE:
# Dejó de ser objeto de impuesto con desglose: se retiran TODOS sus impuestos, incluidos
# los capturados a mano. Una retención sobre una partida que ya no es 02 es inexpresable
# en el XML, y un ObjetoImp 01 con nodo de impuestos es motivo de rechazo.
clear_item_taxes(db, item.id)
return
existentes = _item_taxes(db, item.id)
if any(t.is_manual for t in existentes):
return # captura manual: el automatismo no pisa trabajo ajeno
default = _default_fiscal_del_concepto(db, item)
if default is not None:
tax_id, rate, factor = default
else:
iva = db.query(Tax).filter(Tax.code == _IVA).first()
if not iva:
return # sin catálogo no hay nada que derivar; el timbrado lo reportará
tax_id, factor = iva.id, FACTOR_TASA
rate = (Decimal(str(invoice.tax_rate or 0)) / Decimal(100)).quantize(Decimal("0.000001"))
if rate == 0:
# Un cero aquí NO significa "IVA al 0%": significa que nadie capturó el porcentaje, y
# son cosas distintas. No se inventa una tasa: la partida queda sin impuestos y el
# timbrado falla ruidosamente pidiendo el desglose que un ObjetoImp 02 exige. Quien
# de verdad quiere 0% lo declara en el concepto o por el endpoint de captura.
_borra_derivados(db, existentes)
return
# El derivado es uno solo por partida. Si cambió el impuesto (p. ej. el concepto pasó a
# definir otro), el anterior se retira en vez de acumularse.
for t in existentes:
if t.is_withholding or t.tax_id != tax_id:
db.delete(t)
traslado = next(
(t for t in existentes if t.tax_id == tax_id and not t.is_withholding), None
)
if traslado is None:
traslado = InvoiceItemTax(
invoice_item_id=item.id,
tax_id=tax_id,
is_withholding=False,
tenant_id=item.tenant_id,
company_id=item.company_id,
)
db.add(traslado)
traslado.factor = factor
traslado.is_manual = False
if factor == FACTOR_EXENTO:
# Un exento no lleva TasaOCuota ni Importe en el XML, y no suma a los totales.
traslado.rate = None
traslado.amount = Decimal("0.00")
else:
traslado.rate = rate
traslado.amount = cents(line_base(item) * rate)
def _borra_derivados(db: Session, filas: list[InvoiceItemTax]) -> None:
for t in filas:
if not t.is_manual:
db.delete(t)
def sync_invoice_taxes(db: Session, invoice: Invoice) -> None:
"""Recalcula el IVA derivado de todas las partidas. Se llama al cambiar ``tax_rate``."""
items = (
db.query(InvoiceItem)
.filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
.all()
)
for item in items:
sync_item_taxes(db, item, invoice)
# --------------------------------------------------------------------------------------
# Ajuste manual
# --------------------------------------------------------------------------------------
def list_item_taxes(
db: Session, item_id: int, tenant_id: int, company_id: int
) -> list[InvoiceItemTax]:
_get_item(db, item_id, tenant_id, company_id)
return _item_taxes(db, item_id)
def _get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> InvoiceItem:
item = (
db.query(InvoiceItem)
.filter(
InvoiceItem.id == item_id,
InvoiceItem.tenant_id == tenant_id,
InvoiceItem.company_id == company_id,
InvoiceItem.deleted_at.is_(None),
)
.first()
)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Partida no encontrada")
return item
def set_item_tax(
db: Session,
item_id: int,
tax_id: int,
rate: Decimal | None,
is_withholding: bool,
tenant_id: int,
company_id: int,
factor: str = FACTOR_TASA,
) -> InvoiceItemTax:
"""Alta o ajuste manual de un impuesto de la partida.
Marca la fila como ``is_manual``: desde ese momento la derivación automática no la vuelve a
tocar, ni al cambiar el importe de la partida ni al cambiar el % de la factura.
"""
item = _get_item(db, item_id, tenant_id, company_id)
_reject_if_stamped_item(db, item, tenant_id, company_id, "su desglose de impuestos no se puede editar")
if factor not in FACTORES_ADMITIDOS:
# 'Cuota' existe en el catálogo del SAT pero su importe es cuota × cantidad, no
# base × tasa. Sin esa fórmula, aceptarla produciría un importe plausible y equivocado.
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"Tipo de factor no admitido: {factor!r}. Por ahora sólo "
f"{' y '.join(FACTORES_ADMITIDOS)}."
),
)
# Un ObjetoImp distinto de 02 no lleva nodo de impuestos: capturar uno aquí sería armar un
# comprobante que el PAC rechaza, y la derivación lo borraría en la siguiente edición.
objeto = _tax_object_code(db, item)
if objeto not in _OBJETO_CON_DESGLOSE:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
"La partida no es objeto de impuesto con desglose (ObjetoImp "
f"{objeto or 'sin capturar'}): no admite impuestos. Cámbiala a 02 primero."
),
)
tax = db.query(Tax).filter(Tax.id == tax_id).first()
if not tax:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="El impuesto indicado no existe en el catálogo del SAT",
)
if is_withholding and not tax.is_withholding:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"El impuesto {tax.code} ({tax.description}) no puede retenerse",
)
if not is_withholding and not tax.is_transferred:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"El impuesto {tax.code} ({tax.description}) no puede trasladarse",
)
if factor == FACTOR_TASA and rate is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Un impuesto con factor Tasa requiere la tasa",
)
obj = (
db.query(InvoiceItemTax)
.filter(
InvoiceItemTax.invoice_item_id == item_id,
InvoiceItemTax.tax_id == tax_id,
InvoiceItemTax.is_withholding == is_withholding,
InvoiceItemTax.deleted_at.is_(None),
)
.first()
)
if obj is None:
obj = InvoiceItemTax(
invoice_item_id=item_id,
tax_id=tax_id,
is_withholding=is_withholding,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(obj)
obj.factor = factor
obj.is_manual = True
if factor == FACTOR_EXENTO:
# Se limpian tasa e importe: una fila exenta que conservara el importe de una tasa
# anterior descuadraría el Total del comprobante, que excluye los exentos de sus totales.
obj.rate = None
obj.amount = Decimal("0.00")
else:
obj.rate = Decimal(str(rate)).quantize(Decimal("0.000001"))
obj.amount = cents(line_base(item) * Decimal(str(rate)))
db.flush()
_recalcula_factura(db, item)
db.commit()
db.refresh(obj)
return obj
def _recalcula_factura(db: Session, item: InvoiceItem) -> None:
"""Recalcula los totales de la factura dueña de la partida.
Import diferido: ``service`` importa este módulo, y al revés sería circular. Capturar o
borrar un impuesto tiene que mover el total, o la factura queda mintiendo hasta que alguien
toque otra cosa.
"""
from . import service # noqa: PLC0415
invoice = db.query(Invoice).filter(Invoice.id == item.invoice_id).first()
if invoice is not None:
service.recompute_invoice(db, invoice)
def delete_item_tax(db: Session, tax_row_id: int, tenant_id: int, company_id: int) -> None:
obj = (
db.query(InvoiceItemTax)
.filter(
InvoiceItemTax.id == tax_row_id,
InvoiceItemTax.tenant_id == tenant_id,
InvoiceItemTax.company_id == company_id,
InvoiceItemTax.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Impuesto no encontrado")
item = _get_item(db, obj.invoice_item_id, tenant_id, company_id)
_reject_if_stamped_item(db, item, tenant_id, company_id, "su desglose de impuestos no se puede editar")
db.delete(obj)
db.flush()
_recalcula_factura(db, item)
db.commit()

View File

@@ -0,0 +1 @@
"""Datos fiscales del emisor por empresa."""

View File

@@ -0,0 +1,159 @@
"""Carga y lectura del CSD (Certificado de Sello Digital) de la empresa.
El ``.key`` es material con el que se puede firmar a nombre de la empresa ante el SAT: no se
devuelve nunca por la API, ni entero ni en partes. Sólo entra (al subirlo) y se usa del lado
del servidor (al timbrar).
"""
from datetime import datetime, timezone
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from core.crypto import SecretsNotConfigured, encrypt_secret
from core.s3_keys import tenant_company_prefix
from ...fin.stamping import sealer
from .models import IssuerSettings
from .service import get_issuer_settings
# Tamaño máximo razonable: un .cer del SAT ronda los 2 KB y un .key los 2 KB. El tope evita
# que alguien suba un archivo enorme por error o a propósito.
_MAX_BYTES = 64 * 1024
def _csd_keys(tenant_id: int, company_id: int) -> tuple[str, str]:
"""Claves de almacenamiento del par. Estables: subir de nuevo reemplaza el anterior."""
prefijo = tenant_company_prefix(tenant_id, company_id) + "certificates/"
return prefijo + "cfdi.cer", prefijo + "cfdi.key"
def _verify_pair(cer_bytes: bytes, key_bytes: bytes, password: str) -> str:
"""Comprueba que la llave privada corresponde al certificado. Devuelve el NoCertificado.
Se hace firmando un dato de prueba con la llave y verificándolo con la pública del
certificado. Es la única forma de saberlo antes de timbrar: si no cuadran, el error
aparecería hasta que el PAC rechace el comprobante, con un mensaje que no menciona el CSD.
"""
try:
cert_number, _ = sealer.read_certificate(cer_bytes)
private_key = sealer.load_private_key(key_bytes, password)
except sealer.SealingError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
) from exc
try:
cert = load_der_x509_certificate(cer_bytes)
except ValueError:
cert = load_pem_x509_certificate(cer_bytes)
reto = b"verificacion-de-par-csd"
firma = private_key.sign(reto, padding.PKCS1v15(), hashes.SHA256())
try:
cert.public_key().verify(firma, reto, padding.PKCS1v15(), hashes.SHA256())
except Exception as exc: # noqa: BLE001 — cualquier fallo aquí es "no corresponden"
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
"La llave privada (.key) no corresponde al certificado (.cer). "
"Verifica que ambos archivos sean del mismo CSD."
),
) from exc
return cert_number
def upload_csd(
db: Session,
tenant_id: int,
company_id: int,
cer_bytes: bytes,
key_bytes: bytes,
password: str,
user_id: str | None = None,
) -> IssuerSettings:
"""Valida el par, lo guarda en almacenamiento y cifra la contraseña."""
# Exige que ya existan los datos fiscales: el CSD pertenece a un emisor, no al aire.
obj = get_issuer_settings(db, tenant_id, company_id)
if not password:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="La contraseña de la llave privada es obligatoria.",
)
for etiqueta, datos in (("certificado (.cer)", cer_bytes), ("llave privada (.key)", key_bytes)):
if not datos:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Falta el archivo del {etiqueta}.",
)
if len(datos) > _MAX_BYTES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"El archivo del {etiqueta} es demasiado grande para ser un CSD.",
)
cert_number = _verify_pair(cer_bytes, key_bytes, password)
# La contraseña se cifra ANTES de subir los archivos: si no hay clave maestra, no se deja
# material criptográfico en el almacenamiento a medio configurar.
try:
password_enc = encrypt_secret(password)
except SecretsNotConfigured as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
) from exc
from core.storage_s3 import (
put_object_bytes,
) # noqa: PLC0415 (import diferido: tests sin MinIO)
cer_key, key_key = _csd_keys(tenant_id, company_id)
put_object_bytes(cer_key, cer_bytes, content_type="application/x-x509-ca-cert")
put_object_bytes(key_key, key_bytes, content_type="application/octet-stream")
obj.csd_cer_file_key = cer_key
obj.csd_key_file_key = key_key
obj.csd_password_enc = password_enc
obj.csd_cert_number = cert_number
obj.csd_uploaded_at = datetime.now(timezone.utc)
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def delete_csd(
db: Session, tenant_id: int, company_id: int, user_id: str | None = None
) -> IssuerSettings:
"""Desvincula el CSD de la empresa.
Se borran también los objetos del almacenamiento: dejar una llave privada huérfana es
justo lo que no se quiere. Si el borrado remoto falla, la referencia se limpia igual —
sin ella el sistema ya no puede firmar.
"""
obj = get_issuer_settings(db, tenant_id, company_id)
claves = [k for k in (obj.csd_cer_file_key, obj.csd_key_file_key) if k]
if claves:
try:
from core.storage_s3 import delete_object_if_exists # noqa: PLC0415
for k in claves:
delete_object_if_exists(k)
except Exception: # noqa: BLE001
# No se propaga: la referencia se limpia igual y el CSD queda inutilizable.
pass
obj.csd_cer_file_key = None
obj.csd_key_file_key = None
obj.csd_password_enc = None
obj.csd_cert_number = None
obj.csd_uploaded_at = None
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj

View File

@@ -0,0 +1,71 @@
"""Esquemas de los datos fiscales del emisor."""
import re
from datetime import datetime
from pydantic import BaseModel, ConfigDict, computed_field, Field, field_validator
from ..catalogs.dto import TaxRegimeResponse
# RFC de persona moral (3 letras) o física (4 letras) + fecha + homoclave.
RFC_PATTERN = re.compile(r"^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$")
ZIP_PATTERN = re.compile(r"^\d{5}$")
class IssuerSettingsInput(BaseModel):
"""Alta o actualización de los datos fiscales del emisor."""
legal_name: str = Field(..., min_length=1, max_length=255, description="Razón social")
rfc: str = Field(..., max_length=13, description="RFC del emisor")
tax_regime_id: int = Field(..., description="Régimen fiscal (c_RegimenFiscal)")
zip_code: str | None = Field(None, max_length=5, description="CP del lugar de expedición")
# mode="before": la normalización corre antes que el max_length del campo, para que
# un RFC con espacios de sobra no se rechace por longitud antes de limpiarlo.
@field_validator("rfc", mode="before")
@classmethod
def _validate_rfc(cls, value: str) -> str:
"""Normaliza a mayúsculas sin espacios y valida el formato oficial del RFC."""
if not isinstance(value, str):
raise ValueError("El RFC debe ser texto")
normalized = value.replace(" ", "").replace("-", "").upper()
if not RFC_PATTERN.match(normalized):
raise ValueError("El RFC no tiene un formato válido (ej. XAXX010101000)")
return normalized
@field_validator("zip_code")
@classmethod
def _validate_zip(cls, value: str | None) -> str | None:
if value is None or value == "":
return None
normalized = value.strip()
if not ZIP_PATTERN.match(normalized):
raise ValueError("El código postal debe tener 5 dígitos")
return normalized
class IssuerSettingsResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
company_id: int
legal_name: str
rfc: str
tax_regime_id: int
tax_regime: TaxRegimeResponse | None = None
zip_code: str | None = None
updated_by: str | None = None
created_at: datetime
updated_at: datetime
# ----- Estado del CSD -----
# Se expone SI hay certificado cargado y cuál, nunca su contenido ni la contraseña: con el
# .key se puede firmar a nombre de la empresa ante el SAT.
csd_cert_number: str | None = None
csd_uploaded_at: datetime | None = None
@computed_field
@property
def has_csd(self) -> bool:
"""Hay par de archivos y contraseña guardados, o sea que ya se puede timbrar."""
return bool(self.csd_cert_number and self.csd_uploaded_at)

View File

@@ -0,0 +1,57 @@
"""Datos fiscales del emisor — ``fin.issuer_settings``.
Es la identidad fiscal con la que la empresa emite CFDI: razón social, RFC, régimen
fiscal y código postal del lugar de expedición. Hay **una sola configuración vigente
por empresa**, garantizada con un índice único parcial.
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from ..catalogs.models import TaxRegime # noqa: F401 (resuelve la relación)
_ALIVE = text("deleted_at IS NULL")
class IssuerSettings(Base, TenantScopedMixin, TimestampMixin):
"""Configuración fiscal del emisor de la empresa."""
__tablename__ = "issuer_settings"
__table_args__ = (
Index(
"uq_fin_issuer_settings_company",
"tenant_id", "company_id",
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
),
{"schema": "fin"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
legal_name: Mapped[str] = mapped_column(String(255), nullable=False) # razón social
rfc: Mapped[str] = mapped_column(String(13), nullable=False)
tax_regime_id: Mapped[int] = mapped_column(
Integer, ForeignKey("sat.tax_regimes.id"), nullable=False, index=True
)
# CP del lugar de expedición del comprobante
zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
# ----- CSD (Certificado de Sello Digital) de la empresa -----
# Los archivos viven en MinIO; aquí sólo su clave. El .key es material con el que se puede
# firmar a nombre de la empresa: no se expone nunca por la API, ni siquiera su contenido en
# base64. Sólo se sube y se usa del lado del servidor.
csd_cer_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
csd_key_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
# Contraseña de la llave privada, cifrada con la clave maestra del entorno (core.crypto).
# Nunca se devuelve en una respuesta; sólo se sabe si está puesta o no.
csd_password_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
# Informativo, para mostrar en la pantalla qué certificado está cargado.
csd_cert_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
csd_uploaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
tax_regime: Mapped["TaxRegime"] = relationship("TaxRegime", lazy="selectin")

View File

@@ -0,0 +1,97 @@
"""Endpoints de los datos fiscales del emisor (una configuración por empresa)."""
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from sqlalchemy.orm import Session
from api.v1.modules.core.permissions.dependencies import PermissionChecker
from core.database import get_core_db
from core.security import get_current_user
from . import csd_service, service
from .dto import IssuerSettingsInput, IssuerSettingsResponse
router = APIRouter()
@router.get(
"/settings/issuer",
response_model=IssuerSettingsResponse,
dependencies=[Depends(PermissionChecker(["fin.settings.view"]))],
)
def get_issuer_settings(
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Devuelve 404 mientras la empresa no haya capturado sus datos fiscales."""
return service.get_issuer_settings(db, current_user["tenant_id"], company_id)
@router.put(
"/settings/issuer",
response_model=IssuerSettingsResponse,
dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))],
)
def save_issuer_settings(
payload: IssuerSettingsInput,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Alta o actualización (upsert) de los datos fiscales del emisor."""
return service.save_issuer_settings(
db,
payload,
current_user["tenant_id"],
company_id,
current_user.get("sub") or current_user.get("id"),
)
@router.post(
"/settings/issuer/csd",
response_model=IssuerSettingsResponse,
dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))],
)
async def upload_csd(
company_id: int = Query(..., description="Company ID"),
cer: UploadFile = File(..., description="Certificado del CSD (.cer)"),
key: UploadFile = File(..., description="Llave privada del CSD (.key)"),
password: str = Form(..., description="Contraseña de la llave privada"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Carga el CSD de la empresa.
Antes de guardar nada se comprueba que la llave privada corresponde al certificado: si no,
el error saldría hasta que el PAC rechace un comprobante, con un mensaje que no menciona
el CSD. La contraseña se guarda cifrada y **no se devuelve nunca**.
"""
return csd_service.upload_csd(
db,
current_user["tenant_id"],
company_id,
await cer.read(),
await key.read(),
password,
current_user.get("sub") or current_user.get("id"),
)
@router.delete(
"/settings/issuer/csd",
response_model=IssuerSettingsResponse,
dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))],
)
def delete_csd(
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Desvincula el CSD y borra sus archivos del almacenamiento."""
return csd_service.delete_csd(
db,
current_user["tenant_id"],
company_id,
current_user.get("sub") or current_user.get("id"),
)

View File

@@ -0,0 +1,58 @@
"""Lógica de los datos fiscales del emisor.
Una empresa tiene, a lo más, una configuración vigente: el guardado es un upsert, no
un alta que pueda duplicar filas.
"""
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..catalogs.models import TaxRegime
from .dto import IssuerSettingsInput
from .models import IssuerSettings
def _find(db: Session, tenant_id: int, company_id: int) -> IssuerSettings | None:
return db.query(IssuerSettings).filter(
IssuerSettings.tenant_id == tenant_id,
IssuerSettings.company_id == company_id,
IssuerSettings.deleted_at.is_(None),
).first()
def get_issuer_settings(db: Session, tenant_id: int, company_id: int) -> IssuerSettings:
obj = _find(db, tenant_id, company_id)
if not obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="La empresa aún no tiene datos fiscales del emisor configurados",
)
return obj
def save_issuer_settings(
db: Session,
payload: IssuerSettingsInput,
tenant_id: int,
company_id: int,
user_id: str | None = None,
) -> IssuerSettings:
"""Crea la configuración la primera vez y la actualiza en adelante."""
if db.query(TaxRegime.id).filter(TaxRegime.id == payload.tax_regime_id).first() is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="El régimen fiscal indicado no existe en el catálogo del SAT",
)
obj = _find(db, tenant_id, company_id)
data = payload.model_dump()
if obj is None:
obj = IssuerSettings(**data, tenant_id=tenant_id, company_id=company_id, updated_by=user_id)
db.add(obj)
else:
for field, value in data.items():
setattr(obj, field, value)
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj

View File

@@ -3,7 +3,7 @@
from api.v1.modules.core.permissions.registry import registry
MODULE = "fin"
_ENTITIES = [("invoice", "facturas"), ("payment", "pagos")]
_ENTITIES = [("invoice", "facturas"), ("payment", "pagos"), ("concept", "conceptos")]
_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")]
@@ -12,6 +12,11 @@ def register_permissions() -> None:
for entity, label in _ENTITIES:
for action, verb in _ACTIONS:
registry.register(code=f"{MODULE}.{entity}.{action}", description=f"{verb} {label}", module=MODULE, action=action)
# Datos fiscales del emisor: es configuración de la empresa, no una entidad con CRUD,
# así que solo tiene ver/editar. Los catálogos del SAT no llevan permiso propio:
# son globales y de solo lectura, basta con fin.access.
registry.register(code=f"{MODULE}.settings.view", description="Ver datos fiscales del emisor", module=MODULE, action="view")
registry.register(code=f"{MODULE}.settings.edit", description="Editar datos fiscales del emisor", module=MODULE, action="edit")
register_permissions()

View File

@@ -5,8 +5,16 @@ from fastapi import APIRouter, Depends
from api.v1.modules.core.permissions.dependencies import PermissionChecker
from . import permissions # noqa: F401 (side-effect: registra permisos)
from .catalogs.routes import router as catalogs_router
from .concepts.routes import router as concepts_router
from .invoices.routes import router as invoices_router
from .issuer.routes import router as issuer_router
from .stamping.routes import router as stamping_router
# Enforcement por área/carril (R-T-07): se exige fin.access para el módulo.
router = APIRouter(dependencies=[Depends(PermissionChecker(["fin.access"]))])
router.include_router(catalogs_router)
router.include_router(concepts_router)
router.include_router(issuer_router)
router.include_router(invoices_router)
router.include_router(stamping_router)

View File

@@ -0,0 +1 @@
"""Timbrado de CFDI 4.0 ante el PAC (Comercio Digital)."""

View File

@@ -0,0 +1,391 @@
"""Construcción del XML CFDI 4.0 de tipo ingreso.
El orden de los atributos **no es libre**: la cadena original se calcula recorriendo el
comprobante en el orden del XSD, y el sello se hace sobre esa cadena. Aquí se respeta el
mismo orden que usa el sistema legado (``CFDI.cs:14340-14394``), verificado contra el XSD.
Todo el dinero se maneja con ``Decimal``. Con ``float``, 0.1 + 0.2 no da 0.30 y el total del
comprobante no cuadra con la suma de las partidas: el PAC lo rechaza.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import ROUND_HALF_UP, Decimal
from lxml import etree
CFDI_NS = "http://www.sat.gob.mx/cfd/4"
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
SCHEMA_LOCATION = (
"http://www.sat.gob.mx/cfd/4 http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd"
)
VERSION = "4.0"
TIPO_INGRESO = "I"
# Declaración XML escrita a mano, con comillas DOBLES. lxml emite la suya con comillas simples
# (<?xml version='1.0' encoding='UTF-8'?>), que es XML válido —la especificación admite ambas—
# pero Comercio Digital lo rechaza con el código 642 "la versión del XML no es 1.0" porque
# compara la cadena literal version="1.0" en vez de parsear el prólogo. Por eso el comprobante
# se serializa sin declaración y ésta se antepone.
XML_DECLARATION = b'<?xml version="1.0" encoding="UTF-8"?>\n'
# c_Exportacion: "01" = No aplica. Es obligatorio en 4.0 y este módulo no emite exportaciones.
EXPORTACION_NO_APLICA = "01"
# Decimales por moneda (c_Moneda). El SAT admite hasta ese número en importes.
_DECIMALES = {"MXN": 2, "USD": 2, "EUR": 2}
_DECIMALES_DEFECTO = 2
class CfdiBuildError(Exception):
"""El comprobante no se puede construir con los datos disponibles."""
def __init__(self, missing: list[str]):
# Se acumulan TODOS los faltantes en vez de fallar en el primero: quien captura la
# factura necesita la lista completa, no descubrirlos de uno en uno.
self.missing = missing
super().__init__("Faltan datos fiscales: " + "; ".join(missing))
def _serialize(root: etree._Element) -> bytes:
"""Serializa el comprobante en UTF-8 con la declaración que acepta el PAC."""
# xml_declaration=False es obligatorio: con encoding distinto de ASCII, lxml la añade sola.
return XML_DECLARATION + etree.tostring(root, xml_declaration=False, encoding="UTF-8")
def _money(value: Decimal | float | int | None, currency: str) -> str:
"""Importe con los decimales de la moneda, sin separador de miles."""
dec = _DECIMALES.get(currency.upper(), _DECIMALES_DEFECTO)
q = Decimal(1).scaleb(-dec)
return str(Decimal(str(value or 0)).quantize(q, rounding=ROUND_HALF_UP))
def _qty(value: Decimal | float | int | None) -> str:
"""Cantidad: hasta 6 decimales, sin ceros finales innecesarios."""
d = Decimal(str(value or 0)).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
return format(d.normalize(), "f")
def _rate(value: Decimal | float | int) -> str:
"""Tasa o cuota: el SAT la exige con 6 decimales (p. ej. 0.160000)."""
return str(Decimal(str(value)).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP))
@dataclass
class TaxLine:
"""Impuesto trasladado o retenido de una partida."""
code: str # c_Impuesto: "002" = IVA
rate: Decimal
amount: Decimal
is_withholding: bool = False
factor: str = "Tasa" # c_TipoFactor: Tasa | Cuota | Exento
@dataclass
class ConceptLine:
"""Partida del comprobante."""
product_service_code: str # ClaveProdServ
unit_code: str # ClaveUnidad
description: str
quantity: Decimal
unit_price: Decimal
tax_object: str # c_ObjetoImp: "01" no objeto, "02" sí objeto
taxes: list[TaxLine] = field(default_factory=list)
identification: str | None = None # NoIdentificacion
@property
def amount(self) -> Decimal:
return (self.quantity * self.unit_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
@dataclass
class CfdiData:
"""Todo lo que necesita un CFDI 4.0 de ingreso, ya resuelto contra los catálogos."""
# Comprobante
folio: str | None
serie: str | None
date: str # YYYY-MM-DDTHH:MM:SS, hora local del lugar de expedición
payment_form: str # c_FormaPago
payment_method: str # c_MetodoPago: PUE | PPD
currency: str
exchange_rate: Decimal | None
expedition_zip: str # LugarExpedicion
payment_conditions: str | None
# Emisor
issuer_rfc: str
issuer_name: str
issuer_tax_regime: str # c_RegimenFiscal
# Receptor
receiver_rfc: str
receiver_name: str
receiver_zip: str # DomicilioFiscalReceptor
receiver_tax_regime: str # c_RegimenFiscal del receptor
receiver_cfdi_use: str # c_UsoCFDI
# Partidas
concepts: list[ConceptLine]
def validate(self) -> None:
"""Acumula los faltantes obligatorios del CFDI 4.0 y los reporta juntos."""
faltantes: list[str] = []
obligatorios = {
"fecha de emisión": self.date,
"forma de pago (c_FormaPago)": self.payment_form,
"método de pago (c_MetodoPago)": self.payment_method,
"moneda": self.currency,
"lugar de expedición (CP)": self.expedition_zip,
"RFC del emisor": self.issuer_rfc,
"razón social del emisor": self.issuer_name,
"régimen fiscal del emisor": self.issuer_tax_regime,
"RFC del receptor": self.receiver_rfc,
"razón social del receptor": self.receiver_name,
"domicilio fiscal del receptor (CP)": self.receiver_zip,
"régimen fiscal del receptor": self.receiver_tax_regime,
"uso de CFDI del receptor": self.receiver_cfdi_use,
}
for etiqueta, valor in obligatorios.items():
if not valor:
faltantes.append(f"falta {etiqueta}")
if not self.concepts:
faltantes.append("la factura no tiene partidas")
for i, c in enumerate(self.concepts, start=1):
if not c.product_service_code:
faltantes.append(f"partida {i}: falta la clave de producto/servicio")
if not c.unit_code:
faltantes.append(f"partida {i}: falta la clave de unidad")
if not c.tax_object:
faltantes.append(f"partida {i}: falta el objeto de impuesto")
if not c.description:
faltantes.append(f"partida {i}: falta la descripción")
if c.quantity is None or c.quantity <= 0:
faltantes.append(f"partida {i}: la cantidad debe ser mayor que cero")
# ObjetoImp "02" significa "sí objeto de impuesto": el SAT exige entonces el
# desglose. Sin él, el comprobante se rechaza; no se inventa una tasa por defecto.
if c.tax_object == "02" and not c.taxes:
faltantes.append(
f"partida {i}: es objeto de impuesto (02) pero no tiene impuestos capturados"
)
if self.currency.upper() != "MXN" and not self.exchange_rate:
faltantes.append("falta el tipo de cambio (moneda distinta de MXN)")
if faltantes:
raise CfdiBuildError(faltantes)
# ----- Totales -----
@property
def subtotal(self) -> Decimal:
return sum((c.amount for c in self.concepts), Decimal("0"))
@property
def transferred(self) -> Decimal:
# Los exentos se excluyen, igual que en ``_add_totals``: no llevan importe en el XML y no
# entran en TotalImpuestosTrasladados. Sin este filtro, una fila exenta que trajera un
# importe distinto de cero inflaría el Total y el comprobante quedaría inconsistente
# consigo mismo.
return sum(
(
t.amount
for c in self.concepts
for t in c.taxes
if not t.is_withholding and t.factor != "Exento"
),
Decimal("0"),
)
@property
def withheld(self) -> Decimal:
return sum(
(t.amount for c in self.concepts for t in c.taxes if t.is_withholding),
Decimal("0"),
)
@property
def total(self) -> Decimal:
return self.subtotal + self.transferred - self.withheld
def build_xml(data: CfdiData, cert_number: str = "", cert_b64: str = "") -> bytes:
"""XML CFDI 4.0 de ingreso, **sin** el atributo ``Sello``.
``cert_number`` y ``cert_b64`` salen del ``.cer`` y **tienen que venir puestos aquí**, no
después: la cadena original incluye ``NoCertificado``. Si se calculara la cadena con el
atributo vacío y se rellenara luego, el sello firmaría un texto distinto del que verifica
el SAT, y el comprobante se rechazaría con un error que no menciona el certificado.
``Sello`` sí se deja vacío —la cadena original no lo incluye, es su resultado— y lo inserta
``apply_seal`` en su posición.
"""
data.validate()
cur = data.currency.upper()
comprobante = etree.Element(
f"{{{CFDI_NS}}}Comprobante",
nsmap={"cfdi": CFDI_NS, "xsi": XSI_NS},
)
comprobante.set(f"{{{XSI_NS}}}schemaLocation", SCHEMA_LOCATION)
# ORDEN DEL XSD. No reordenar: la cadena original -y por tanto el sello- depende de él.
comprobante.set("Version", VERSION)
if data.serie:
comprobante.set("Serie", data.serie)
if data.folio:
comprobante.set("Folio", str(data.folio))
# Sin desplazamiento horario: el "-06:00" es de CFDI 3.3 (ver CFDI.cs:12654). En 4.0 la
# fecha va en hora local del lugar de expedición, a secas.
comprobante.set("Fecha", data.date)
# Sello vacío: reserva su posición en el orden del XSD para que apply_seal lo rellene sin
# mover nada. lxml conserva el orden de inserción de los atributos.
comprobante.set("Sello", "")
comprobante.set("FormaPago", data.payment_form)
comprobante.set("NoCertificado", cert_number)
comprobante.set("Certificado", cert_b64)
if data.payment_conditions:
comprobante.set("CondicionesDePago", data.payment_conditions)
comprobante.set("SubTotal", _money(data.subtotal, cur))
comprobante.set("Moneda", cur)
if cur != "MXN" and data.exchange_rate:
comprobante.set(
"TipoCambio", str(Decimal(str(data.exchange_rate)).quantize(Decimal("0.0001")))
)
comprobante.set("Total", _money(data.total, cur))
comprobante.set("TipoDeComprobante", TIPO_INGRESO)
comprobante.set("Exportacion", EXPORTACION_NO_APLICA)
comprobante.set("MetodoPago", data.payment_method)
comprobante.set("LugarExpedicion", data.expedition_zip)
emisor = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Emisor")
emisor.set("Rfc", data.issuer_rfc)
emisor.set("Nombre", data.issuer_name)
emisor.set("RegimenFiscal", data.issuer_tax_regime)
receptor = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Receptor")
receptor.set("Rfc", data.receiver_rfc)
receptor.set("Nombre", data.receiver_name)
receptor.set("DomicilioFiscalReceptor", data.receiver_zip)
receptor.set("RegimenFiscalReceptor", data.receiver_tax_regime)
receptor.set("UsoCFDI", data.receiver_cfdi_use)
conceptos = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Conceptos")
for c in data.concepts:
nodo = etree.SubElement(conceptos, f"{{{CFDI_NS}}}Concepto")
nodo.set("ClaveProdServ", c.product_service_code)
if c.identification:
nodo.set("NoIdentificacion", c.identification)
nodo.set("Cantidad", _qty(c.quantity))
nodo.set("ClaveUnidad", c.unit_code)
nodo.set("Descripcion", c.description)
nodo.set("ValorUnitario", _money(c.unit_price, cur))
nodo.set("Importe", _money(c.amount, cur))
nodo.set("ObjetoImp", c.tax_object)
if c.taxes:
_add_concept_taxes(nodo, c, cur)
if any(c.taxes for c in data.concepts):
_add_totals(comprobante, data, cur)
return _serialize(comprobante)
def _add_concept_taxes(nodo: etree._Element, c: ConceptLine, cur: str) -> None:
"""Nodo ``Impuestos`` de una partida: primero Traslados, después Retenciones."""
impuestos = etree.SubElement(nodo, f"{{{CFDI_NS}}}Impuestos")
traslados = [t for t in c.taxes if not t.is_withholding]
if traslados:
cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Traslados")
for t in traslados:
el = etree.SubElement(cont, f"{{{CFDI_NS}}}Traslado")
el.set("Base", _money(c.amount, cur))
el.set("Impuesto", t.code)
el.set("TipoFactor", t.factor)
# Un impuesto exento no lleva TasaOCuota ni Importe: ponerlos es motivo de rechazo.
if t.factor != "Exento":
el.set("TasaOCuota", _rate(t.rate))
el.set("Importe", _money(t.amount, cur))
retenciones = [t for t in c.taxes if t.is_withholding]
if retenciones:
cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Retenciones")
for t in retenciones:
el = etree.SubElement(cont, f"{{{CFDI_NS}}}Retencion")
el.set("Base", _money(c.amount, cur))
el.set("Impuesto", t.code)
el.set("TipoFactor", t.factor)
el.set("TasaOCuota", _rate(t.rate))
el.set("Importe", _money(t.amount, cur))
def _add_totals(comprobante: etree._Element, data: CfdiData, cur: str) -> None:
"""Nodo ``Impuestos`` del comprobante: totales agrupados por impuesto, factor y tasa."""
impuestos = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Impuestos")
def agrupa(withholding: bool) -> dict[tuple[str, str, str], Decimal]:
acc: dict[tuple[str, str, str], Decimal] = {}
for c in data.concepts:
for t in c.taxes:
if t.is_withholding != withholding or t.factor == "Exento":
continue
clave = (t.code, t.factor, _rate(t.rate))
acc[clave] = acc.get(clave, Decimal("0")) + t.amount
return acc
# En el XSD, Retenciones va ANTES que Traslados dentro del nodo Impuestos del comprobante
# —al revés que dentro del concepto—. Es una asimetría real del esquema, no un descuido.
retenidos = agrupa(True)
if retenidos:
impuestos.set("TotalImpuestosRetenidos", _money(data.withheld, cur))
cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Retenciones")
for (code, _factor, _tasa), monto in sorted(retenidos.items()):
el = etree.SubElement(cont, f"{{{CFDI_NS}}}Retencion")
el.set("Impuesto", code)
el.set("Importe", _money(monto, cur))
trasladados = agrupa(False)
if trasladados:
impuestos.set("TotalImpuestosTrasladados", _money(data.transferred, cur))
cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Traslados")
for (code, factor, tasa), monto in sorted(trasladados.items()):
el = etree.SubElement(cont, f"{{{CFDI_NS}}}Traslado")
el.set(
"Base",
_money(
sum(
(
c.amount
for c in data.concepts
for t in c.taxes
if not t.is_withholding
and t.code == code
and t.factor == factor
and _rate(t.rate) == tasa
),
Decimal("0"),
),
cur,
),
)
el.set("Impuesto", code)
el.set("TipoFactor", factor)
el.set("TasaOCuota", tasa)
el.set("Importe", _money(monto, cur))
def apply_seal(xml_bytes: bytes, seal: str) -> bytes:
"""Inserta el ``Sello`` en el comprobante ya construido.
Sólo el sello: ``NoCertificado`` y ``Certificado`` ya venían de ``build_xml`` porque el
primero entra en la cadena original que se acaba de firmar.
"""
root = etree.fromstring(xml_bytes)
if root.get("NoCertificado") is None or not root.get("NoCertificado"):
raise CfdiBuildError(
["el comprobante llegó a sellarse sin NoCertificado: la cadena original sería inválida"]
)
root.set("Sello", seal)
return _serialize(root)

View File

@@ -0,0 +1,31 @@
"""Esquemas del timbrado de CFDI."""
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class InvoiceStampResponse(BaseModel):
"""Resultado de un timbrado.
No expone el XML completo: se descarga por URL firmada desde ``/stamp/xml-url``.
"""
model_config = ConfigDict(from_attributes=True)
id: int
invoice_id: int
mode: str # pruebas | produccion
status: str # pendiente | timbrado | error
uuid: str | None = None
stamped_at: datetime | None = None
pac_rfc: str | None = None
sat_cert_number: str | None = None
pac_code: int | None = None
pac_balance: int | None = None
error_message: str | None = None
xml_file_key: str | None = None
# Rastro del intento: se descargan por URL firmada desde ``/stamp/attempts/{id}/xml-url``.
request_xml_file_key: str | None = None
response_xml_file_key: str | None = None
created_at: datetime | None = None

View File

@@ -0,0 +1,93 @@
"""Timbrado de CFDI — ``fin.invoice_stamps``.
Una fila por **intento** de timbrado, incluidos los fallidos: sin ellos no hay forma de
reconstruir por qué una factura no se timbró, y el error del PAC llega en un header HTTP que
se pierde en cuanto termina la petición.
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
_ALIVE = text("deleted_at IS NULL")
# Modos de timbrado. El host del PAC se deriva de aquí y de ningún otro lado.
MODE_TEST = "pruebas"
MODE_PROD = "produccion"
STAMPING_MODES = (MODE_TEST, MODE_PROD)
# RFC del proveedor de certificación según el entorno (CFDI.cs:16665 del sistema legado).
# Sirve para verificar que el timbre recibido viene del entorno que se pidió.
PAC_RFC_BY_MODE = {
MODE_TEST: "SPR190613I52",
MODE_PROD: "SCD110105654",
}
# Estados del intento.
STATUS_PENDING = "pendiente"
STATUS_STAMPED = "timbrado"
STATUS_ERROR = "error"
class InvoiceStamp(Base, TenantScopedMixin, TimestampMixin):
"""Intento de timbrado de una factura ante el PAC."""
__tablename__ = "invoice_stamps"
__table_args__ = (
# Un UUID no puede repetirse: el SAT lo emite una sola vez. El índice es parcial
# sobre uuid IS NOT NULL porque los intentos fallidos no traen UUID y serían todos
# "iguales" entre sí bajo un único convencional.
Index(
"uq_fin_invoice_stamps_uuid",
"uuid",
unique=True,
postgresql_where=text("uuid IS NOT NULL AND deleted_at IS NULL"),
sqlite_where=text("uuid IS NOT NULL AND deleted_at IS NULL"),
),
{"schema": "fin"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
invoice_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
)
# Copiado de invoices.stamping_mode al transmitir y congelado aquí: es el registro de
# contra qué entorno se timbró de verdad, aunque la factura cambie después.
mode: Mapped[str] = mapped_column(String(12), nullable=False)
status: Mapped[str] = mapped_column(
String(12), nullable=False, server_default=text("'pendiente'"), index=True
)
# ----- Datos del Timbre Fiscal Digital (sólo si el PAC timbró) -----
uuid: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
stamped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # FechaTimbrado
pac_rfc: Mapped[str | None] = mapped_column(String(13), nullable=True) # RfcProvCertif
sat_cert_number: Mapped[str | None] = mapped_column(
String(20), nullable=True
) # NoCertificadoSAT
sat_seal: Mapped[str | None] = mapped_column(Text, nullable=True) # SelloSAT
cfd_seal: Mapped[str | None] = mapped_column(Text, nullable=True) # SelloCFD
# ----- Respuesta del PAC -----
# El legado leía estos dos headers en una variable local que descartaba, así que su código
# de respuesta y su saldo de folios se perdían siempre (CFDI.cs:19324-19336). Aquí se
# persisten: sin ellos no se sabe cuántos folios quedan ni qué contestó el PAC.
pac_code: Mapped[int | None] = mapped_column(Integer, nullable=True) # header codigo
pac_balance: Mapped[int | None] = mapped_column(Integer, nullable=True) # header saldo
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) # header errmsg
# XML timbrado en MinIO. Sólo lo tienen los intentos exitosos: es el comprobante que se
# descarga, y su clave lleva el UUID.
xml_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
# ----- Rastro del intento en MinIO -----
# El par enviado/recibido de CADA intento, incluidos los rechazados. Es lo único que
# permite reconstruir por qué el PAC rechazó un comprobante: el XML sellado se construye
# en memoria y se pierde al terminar la petición, y el cuerpo de la respuesta también.
request_xml_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
response_xml_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)

View File

@@ -0,0 +1,171 @@
"""Cliente del PAC Comercio Digital — servicio ``timbrarV5``.
El contrato está tomado del sistema legado (``CFDI.cs:19274-19346``), que es la única fuente
de verdad disponible: se transmite el XML **sellado** en crudo por POST y la respuesta trae el
comprobante timbrado en el cuerpo y los metadatos en cabeceras HTTP.
Tres defectos del legado se corrigen aquí en vez de replicarse — ver ``_read_headers``.
"""
from __future__ import annotations
from dataclasses import dataclass
import httpx
from .models import MODE_TEST, STAMPING_MODES
# Códigos de error propios del cliente, con los mismos números que usa el legado para que los
# reportes de ambos sistemas se puedan comparar.
ERR_USER = 701 # usuario con longitud inválida
ERR_PASSWORD = 702 # password vacío
ERR_EMPTY_XML = 711 # XML vacío o demasiado corto
ERR_NETWORK = 833 # excepción de red
ERR_HTTP = 998 # respuesta HTTP distinta de 200
# El usrws de Comercio Digital tiene forma de RFC.
_USER_MIN, _USER_MAX = 12, 13
# Un CFDI sellado nunca baja de este tamaño; por debajo, es que algo se truncó.
_MIN_XML_BYTES = 200
class PacConfigError(Exception):
"""Configuración inválida del PAC. Se detecta antes de tocar la red."""
@dataclass
class StampResult:
"""Respuesta del PAC ante un intento de timbrado."""
ok: bool
code: int | None
error_message: str
xml: str = ""
uuid: str = ""
balance: int | None = None
email_error: str = ""
def resolve_host(mode: str, host_test: str, host_prod: str) -> str:
"""Host del PAC a partir del modo. **Es la única forma de elegirlo.**
No hay parámetro de host, ni de URL, ni forma de pasarlos desde la capa HTTP: el modo sale
de ``invoices.stamping_mode`` y nada más. En el legado, ``CFDIPacUrl`` es un campo mutable
que cualquier rama del código reasigna, y un host vacío cae silenciosamente al de pruebas
(``CFDI.cs:19288``). Aquí un modo desconocido es un error, no un valor por defecto: el
default equivocado emite un CFDI con validez fiscal real.
"""
if mode not in STAMPING_MODES:
raise PacConfigError(
f"Modo de timbrado inválido: {mode!r}. Sólo se admiten {STAMPING_MODES}."
)
return host_test if mode == MODE_TEST else host_prod
def stamp(
xml_bytes: bytes,
*,
mode: str,
user: str,
password: str,
host_test: str,
host_prod: str,
email: str = "",
timeout: int = 15,
) -> StampResult:
"""Transmite el CFDI sellado al PAC y devuelve el resultado.
Nunca lanza por causas de red o del PAC: esas se devuelven como ``StampResult`` con
``ok=False``, porque el llamador tiene que persistir el intento fallido. Sí lanza
``PacConfigError`` si el modo es inválido, que es un error de programación, no de operación.
"""
host = resolve_host(mode, host_test, host_prod)
# Validaciones previas: mismas condiciones y códigos que el legado, antes de salir a la red.
if not user or not (_USER_MIN <= len(user) <= _USER_MAX):
return StampResult(False, ERR_USER, f"{ERR_USER} Usuario del PAC inválido o no configurado")
if not password:
return StampResult(False, ERR_PASSWORD, f"{ERR_PASSWORD} Password del PAC no configurado")
if not xml_bytes or len(xml_bytes) < _MIN_XML_BYTES:
return StampResult(False, ERR_EMPTY_XML, f"{ERR_EMPTY_XML} Contenido XML vacío")
url = f"https://{host}/timbre4/timbrarV5"
headers = {
"usrws": user,
"pwdws": password,
"tipo": "XML",
"Content-Type": "text/plain",
}
if email:
headers["email"] = email.lower()
try:
# El cuerpo va en crudo: ni base64 ni SOAP. httpx no reintenta por defecto, y así debe
# ser: reintentar un timbrado puede consumir un folio y generar un CFDI duplicado.
response = httpx.post(url, content=xml_bytes, headers=headers, timeout=timeout)
except httpx.HTTPError as exc:
return StampResult(
False, ERR_NETWORK, f"{ERR_NETWORK} Error de transmisión a {host}: {exc}"
)
if response.status_code != 200:
# El cuerpo se conserva aunque el estado no sea 200: cuando el PAC contesta con un
# error de servidor, lo que explica el rechazo suele venir precisamente ahí.
return StampResult(
False,
ERR_HTTP,
f"{ERR_HTTP} El PAC respondió HTTP {response.status_code}",
response.text,
)
return _read_headers(response)
def _read_headers(response: httpx.Response) -> StampResult:
"""Interpreta la respuesta del PAC.
Aquí se corrigen tres defectos del legado (``CFDI.cs:19324-19336``):
1. ``codigo`` **se lee y se conserva**. El legado lo asignaba a una variable local que
descartaba, así que su parámetro de salida quedaba siempre en 999 o 991.
2. ``saldo`` **también**. Mismo patrón: se perdía siempre, y con él la única señal de
cuántos folios quedan.
3. La presencia de una cabecera se comprueba de verdad. En .NET, ``GetResponseHeader``
devuelve ``""`` y no ``null`` cuando falta, así que la rama ``== null`` del legado
prácticamente nunca se cumplía.
"""
headers = response.headers
error_message = (headers.get("errmsg") or "").strip()
uuid = (headers.get("uuid") or "").strip()
email_error = (headers.get("erremail") or "").strip()
def entero(nombre: str) -> int | None:
crudo = (headers.get(nombre) or "").strip()
if not crudo:
return None
try:
return int(crudo)
except ValueError:
# El PAC mandó algo no numérico: se ignora el valor, pero no se rompe el timbrado
# por ello. Queda como None, que es "no informado".
return None
code = entero("codigo")
balance = entero("saldo")
# Criterio de éxito del legado: errmsg vacío. Se le añade la exigencia de UUID, porque una
# respuesta 200 sin errmsg y sin UUID no es un comprobante timbrado.
if error_message:
return StampResult(False, code, error_message, response.text, uuid, balance, email_error)
if not uuid:
return StampResult(
False,
code,
"El PAC respondió sin error pero no devolvió UUID",
response.text,
"",
balance,
email_error,
)
return StampResult(True, code, "", response.text, uuid, balance, email_error)

View File

@@ -0,0 +1,95 @@
"""Endpoints del timbrado de CFDI."""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import InvoiceStampResponse
router = APIRouter()
def _uid(cu: dict) -> str | None:
return cu.get("sub") or cu.get("id")
@router.post(
"/invoices/{invoice_id}/stamp",
response_model=InvoiceStampResponse,
status_code=status.HTTP_200_OK,
)
def stamp_invoice(
invoice_id: int,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Timbra la factura ante el PAC.
El modo (pruebas o producción) sale de ``invoices.stamping_mode`` y **no se puede pasar
por aquí**: ni por cuerpo, ni por query, ni por cabecera. Es lo único que separa un timbre
de prueba de un CFDI con validez fiscal ante el SAT.
Es idempotente: si la factura ya tiene timbre, lo devuelve sin volver a llamar al PAC.
"""
return service.stamp_invoice(
db, invoice_id, current_user["tenant_id"], company_id, _uid(current_user)
)
@router.get("/invoices/{invoice_id}/stamp", response_model=InvoiceStampResponse)
def get_invoice_stamp(
invoice_id: int,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Timbre vigente de la factura."""
stamp = service.get_stamp(db, invoice_id, current_user["tenant_id"], company_id)
if not stamp:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="La factura no está timbrada"
)
return stamp
@router.get("/invoices/{invoice_id}/stamp/attempts", response_model=list[InvoiceStampResponse])
def list_stamp_attempts(
invoice_id: int,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Historial de intentos de timbrado, incluidos los rechazados por el PAC."""
return service.list_attempts(db, invoice_id, current_user["tenant_id"], company_id)
@router.get("/invoices/{invoice_id}/stamp/attempts/{attempt_id}/xml-url")
def get_stamp_attempt_xml_url(
invoice_id: int,
attempt_id: int,
kind: str = Query(..., pattern="^(request|response)$"),
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""URL firmada del XML transmitido al PAC (``request``) o del que contestó (``response``)."""
url = service.get_attempt_xml_url(
db, invoice_id, attempt_id, kind, current_user["tenant_id"], company_id
)
return {"url": url}
@router.get("/invoices/{invoice_id}/stamp/xml-url")
def get_stamp_xml_url(
invoice_id: int,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""URL firmada para descargar el XML timbrado."""
url = service.get_stamp_xml_url(db, invoice_id, current_user["tenant_id"], company_id)
return {"url": url}

View File

@@ -0,0 +1,147 @@
"""Cadena original y sello del CFDI.
El sello es la firma del emisor sobre el comprobante. Se obtiene en dos pasos:
1. **Cadena original**: transformación XSLT oficial del SAT sobre el XML *sin* sello.
2. **Sello**: firma RSA con SHA-256 de esa cadena, en base64.
Ambos pasos son exactos: un carácter de diferencia en la cadena produce un sello que el SAT
rechaza. Por eso la cadena no se construye a mano ni se "normaliza" — sale tal cual del XSLT.
"""
from __future__ import annotations
import base64
import threading
from pathlib import Path
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
from lxml import etree
_XSLT_DIR = Path(__file__).parent / "xslt"
_XSLT_CADENA = _XSLT_DIR / "cadenaoriginal_4_0.xslt"
# La compilación del XSLT es cara (33 includes) y el resultado es inmutable: se hace una vez.
# El lock evita que dos peticiones concurrentes la compilen a la vez en el arranque.
_transform: etree.XSLT | None = None
_transform_lock = threading.Lock()
class SealingError(Exception):
"""Falla al calcular la cadena original o el sello."""
def _get_transform() -> etree.XSLT:
global _transform
if _transform is None:
with _transform_lock:
if _transform is None: # otro hilo pudo compilarla mientras esperábamos
if not _XSLT_CADENA.is_file():
raise SealingError(
f"No encuentro el XSLT de la cadena original en {_XSLT_CADENA}"
)
# Los includes del XSLT apuntan a rutas RELATIVAS locales (ver xslt/README.md):
# no hay resolución por red, ni aquí ni dentro de libxslt.
_transform = etree.XSLT(etree.parse(str(_XSLT_CADENA)))
return _transform
def build_original_string(xml_bytes: bytes) -> str:
"""Cadena original del comprobante, vía el XSLT oficial del SAT.
``xml_bytes`` es el CFDI **sin** los atributos ``Sello``, ``NoCertificado`` ni
``Certificado``: son justamente los que se calculan a partir de esta cadena.
"""
try:
doc = etree.fromstring(xml_bytes)
except etree.XMLSyntaxError as exc:
raise SealingError(f"El XML del comprobante no es válido: {exc}") from exc
cadena = str(_get_transform()(doc))
# Una cadena vacía o sin los delimitadores significa que la transformación no aplicó ninguna
# plantilla — pasa si el XSLT perdió sus includes. Firmar eso daría un sello con pinta de
# correcto sobre nada, así que se corta aquí.
if not cadena.startswith("||") or not cadena.endswith("||"):
raise SealingError(
"La cadena original no tiene la forma esperada (debe abrir y cerrar con '||'). "
"Revisa los includes de xslt/cadenaoriginal_4_0.xslt."
)
return cadena
def load_private_key(key_der: bytes, password: str) -> rsa.RSAPrivateKey:
"""Carga la llave privada del CSD.
El ``.key`` que entrega el SAT es PKCS#8 **DER** cifrado con contraseña. Se acepta también
PEM para no obligar a convertir llaves que ya estén en ese formato.
"""
if not password:
raise SealingError(
"No se configuró la contraseña de la llave privada del CSD (CSD_PASSWORD)."
)
clave = password.encode("utf-8")
try:
key = serialization.load_der_private_key(key_der, password=clave)
except ValueError:
try:
key = serialization.load_pem_private_key(key_der, password=clave)
except ValueError as exc:
# Mismo error para "archivo corrupto" y "contraseña incorrecta" porque la librería no
# los distingue; el mensaje nombra las dos causas para no mandar a nadie al lugar
# equivocado.
raise SealingError(
"No pude abrir la llave privada del CSD: el archivo no es una llave válida "
"o la contraseña es incorrecta."
) from exc
if not isinstance(key, rsa.RSAPrivateKey):
raise SealingError("La llave privada del CSD no es RSA.")
return key
def read_certificate(cer_bytes: bytes) -> tuple[str, str]:
"""Devuelve ``(numero_de_certificado, certificado_base64)`` a partir del ``.cer``.
- El ``.cer`` del SAT es X.509 **DER**.
- El atributo ``Certificado`` del comprobante es el base64 de ese DER.
- El atributo ``NoCertificado`` son los 20 dígitos del número de serie. El SAT lo codifica
de forma que los bytes del serial son directamente sus caracteres ASCII, así que se
decodifica en vez de imprimirse en hexadecimal.
"""
try:
cert = load_der_x509_certificate(cer_bytes)
der = cer_bytes
except ValueError:
try:
cert = load_pem_x509_certificate(cer_bytes)
der = cert.public_bytes(serialization.Encoding.DER)
except ValueError as exc:
raise SealingError("El archivo del certificado no es un X.509 válido (.cer).") from exc
serial_bytes = cert.serial_number.to_bytes((cert.serial_number.bit_length() + 7) // 8, "big")
try:
numero = serial_bytes.decode("ascii")
except UnicodeDecodeError as exc:
raise SealingError(
"El número de serie del certificado no tiene el formato del SAT "
"(sus bytes deben ser los 20 dígitos en ASCII)."
) from exc
if len(numero) != 20 or not numero.isdigit():
raise SealingError(f"El número de certificado debe ser 20 dígitos; se obtuvo {numero!r}.")
return numero, base64.b64encode(der).decode("ascii")
def sign(original_string: str, private_key: rsa.RSAPrivateKey) -> str:
"""Sello del comprobante: RSA sobre SHA-256 de la cadena original, en base64.
SHA-256 y no SHA-1: SHA-1 sólo aplica al CFDI de retenciones con otros PAC (ver
``CFDI.cs:8157`` del sistema legado). Para CFDI de ingreso con Comercio Digital es SHA-256.
"""
firma = private_key.sign(
original_string.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256(),
)
return base64.b64encode(firma).decode("ascii")

View File

@@ -0,0 +1,685 @@
"""Orquestación del timbrado: factura → XML → sello → PAC → persistencia."""
from __future__ import annotations
import logging
from datetime import datetime
from decimal import Decimal
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from core.config import settings
from core.s3_keys import (
STAMP_XML_KINDS,
invoice_stamp_attempt_xml_key,
invoice_stamp_xml_key,
)
from ..catalogs.models import (
CfdiUse,
PaymentForm,
PaymentMethod,
ProductService,
Tax,
TaxObject,
TaxRegime,
UnitOfMeasure,
)
from ..invoices.models import Invoice, InvoiceItem, InvoiceItemTax
from ..issuer.models import IssuerSettings
from . import cfdi_builder as builder
from . import pac_comercio_digital as pac
from . import sealer
from .models import (
PAC_RFC_BY_MODE,
STAMPING_MODES,
STATUS_ERROR,
STATUS_STAMPED,
InvoiceStamp,
)
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
# --------------------------------------------------------------------------------------
def get_stamp(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> InvoiceStamp | None:
"""Timbre vigente de la factura, si lo hay. Sólo cuenta el exitoso."""
return (
db.query(InvoiceStamp)
.filter(
InvoiceStamp.invoice_id == invoice_id,
InvoiceStamp.tenant_id == tenant_id,
InvoiceStamp.company_id == company_id,
InvoiceStamp.status == STATUS_STAMPED,
InvoiceStamp.deleted_at.is_(None),
)
.order_by(InvoiceStamp.id.desc())
.first()
)
def list_attempts(
db: Session, invoice_id: int, tenant_id: int, company_id: int
) -> list[InvoiceStamp]:
"""Todos los intentos de la factura, del más reciente al más antiguo.
A diferencia de ``get_stamp``, incluye los rechazados: son los que hay que consultar
cuando el PAC devuelve un error y hace falta ver qué se le mandó.
"""
return (
db.query(InvoiceStamp)
.filter(
InvoiceStamp.invoice_id == invoice_id,
InvoiceStamp.tenant_id == tenant_id,
InvoiceStamp.company_id == company_id,
InvoiceStamp.deleted_at.is_(None),
)
.order_by(InvoiceStamp.id.desc())
.all()
)
def get_attempt_xml_url(
db: Session, invoice_id: int, attempt_id: int, kind: str, tenant_id: int, company_id: int
) -> str:
"""URL firmada del XML enviado o recibido en un intento concreto."""
from core.storage_s3 import presigned_get_url # noqa: PLC0415
if kind not in STAMP_XML_KINDS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Tipo de XML inválido: {kind!r}. Sólo se admiten {list(STAMP_XML_KINDS)}.",
)
attempt = (
db.query(InvoiceStamp)
.filter(
InvoiceStamp.id == attempt_id,
# invoice_id va en el filtro, no sólo en la ruta: sin él, el id de un intento de
# otra factura de la misma empresa devolvería su XML.
InvoiceStamp.invoice_id == invoice_id,
InvoiceStamp.tenant_id == tenant_id,
InvoiceStamp.company_id == company_id,
InvoiceStamp.deleted_at.is_(None),
)
.first()
)
if not attempt:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Intento no encontrado")
key = getattr(attempt, f"{kind}_xml_file_key")
if not key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"El intento no tiene guardado el XML de {kind}",
)
return presigned_get_url(key)
def _get_invoice(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> Invoice:
obj = (
db.query(Invoice)
.filter(
Invoice.id == invoice_id,
Invoice.tenant_id == tenant_id,
Invoice.company_id == company_id,
Invoice.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Factura no encontrada")
return obj
def _code(db: Session, model, pk: int | None) -> str:
"""Clave del SAT de un catálogo, o cadena vacía si no está capturado.
Devolver "" en vez de lanzar es deliberado: la validación del builder acumula TODOS los
faltantes y los reporta juntos, en vez de obligar a descubrirlos de uno en uno.
"""
if not pk:
return ""
row = db.query(model).filter(model.id == pk).first()
return row.code if row else ""
# --------------------------------------------------------------------------------------
# Armado de los datos fiscales
# --------------------------------------------------------------------------------------
def _build_data(db: Session, invoice: Invoice, tenant_id: int, company_id: int) -> builder.CfdiData:
"""Reúne emisor, receptor y partidas resolviendo las claves contra los catálogos."""
from ...crm.accounts.models import Account
from ...crm.addresses.models import Address
issuer = (
db.query(IssuerSettings)
.filter(
IssuerSettings.tenant_id == tenant_id,
IssuerSettings.company_id == company_id,
IssuerSettings.deleted_at.is_(None),
)
.first()
)
if not issuer:
raise builder.CfdiBuildError(
["no hay datos fiscales del emisor configurados para la empresa (fin.issuer_settings)"]
)
account = None
if invoice.account_id:
account = db.query(Account).filter(Account.id == invoice.account_id).first()
if not account:
raise builder.CfdiBuildError(["la factura no tiene cliente asignado"])
# CP fiscal del receptor: vive en la dirección de tipo 'fiscal' de la cuenta.
receiver_zip = ""
direccion = (
db.query(Address)
.filter(
Address.account_id == account.id,
Address.address_type == "fiscal",
Address.deleted_at.is_(None),
)
.first()
)
if direccion and direccion.postal_code:
receiver_zip = (direccion.postal_code or "").strip()[:5]
items = (
db.query(InvoiceItem)
.filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
.order_by(InvoiceItem.id)
.all()
)
concepts: list[builder.ConceptLine] = []
for it in items:
taxes: list[builder.TaxLine] = []
for t in (
db.query(InvoiceItemTax)
.filter(InvoiceItemTax.invoice_item_id == it.id, InvoiceItemTax.deleted_at.is_(None))
.order_by(InvoiceItemTax.id)
.all()
):
taxes.append(
builder.TaxLine(
code=_code(db, Tax, t.tax_id),
rate=Decimal(str(t.rate or 0)),
amount=Decimal(str(t.amount or 0)),
is_withholding=bool(t.is_withholding),
# Sin pasar el factor, un exento se timbraría como gravado al 0%: un CFDI
# incorrecto que el PAC acepta y que queda así ante el SAT.
factor=t.factor or "Tasa",
)
)
concepts.append(
builder.ConceptLine(
product_service_code=_code(db, ProductService, it.product_service_id),
unit_code=_code(db, UnitOfMeasure, it.unit_of_measure_id),
description=(it.description or it.concept or "").strip(),
quantity=Decimal(str(it.quantity or 0)),
unit_price=Decimal(str(it.unit_amount or 0)),
tax_object=_code(db, TaxObject, it.tax_object_id),
taxes=taxes,
)
)
# Fecha del comprobante: la de emisión si existe, y si no, ahora. Sin desplazamiento
# horario, que en CFDI 4.0 no se pone (ver cfdi_builder).
if invoice.issue_date:
fecha = datetime.combine(invoice.issue_date, datetime.now().time())
else:
fecha = datetime.now()
return builder.CfdiData(
folio=invoice.reference or str(invoice.id),
serie=None,
date=fecha.strftime("%Y-%m-%dT%H:%M:%S"),
payment_form=_code(db, PaymentForm, invoice.payment_form_id),
payment_method=_code(db, PaymentMethod, invoice.payment_method_id),
currency=(invoice.currency or "MXN").upper(),
# En MXN queda en None y el comprobante no lleva TipoCambio; con otra moneda es
# obligatorio y su ausencia la reporta la validación del builder junto al resto.
exchange_rate=(
Decimal(str(invoice.exchange_rate)) if invoice.exchange_rate is not None else None
),
expedition_zip=(invoice.expedition_zip_code or issuer.zip_code or "").strip()[:5],
payment_conditions=None,
issuer_rfc=(issuer.rfc or "").strip().upper(),
issuer_name=(issuer.legal_name or "").strip(),
issuer_tax_regime=_code(db, TaxRegime, issuer.tax_regime_id),
receiver_rfc=(account.rfc or "").strip().upper(),
receiver_name=(account.name or "").strip(),
receiver_zip=receiver_zip,
receiver_tax_regime=_code(db, TaxRegime, account.tax_regime_id),
receiver_cfdi_use=_code(db, CfdiUse, account.cfdi_use_id),
concepts=concepts,
)
def _verifica_cuadre_con_la_factura(invoice: Invoice, data: builder.CfdiData) -> None:
"""Comprueba que la factura y el comprobante digan el mismo total antes de sellar.
Con el impuesto por partida la igualdad es exacta por construcción: mismo importe de línea,
mismos importes de impuesto y misma composición (subtotal + trasladado retenido). Una
diferencia aquí significa que los totales guardados quedaron desincronizados por un camino que
el service no controla —una fila insertada por fuera, una migración a medias—.
Se **falla y no se corrige**: el timbrado es el punto donde el dinero se vuelve irreversible,
y recalcular en silencio cambiaría montos dentro de la operación de timbrado, que es
exactamente lo que no debe pasar sin que nadie lo vea. Sale como 422 junto al resto de los
faltantes, por el ``except`` que ya envuelve la construcción.
Las facturas anteriores al cálculo por partida no se verifican: su total viene de la fórmula
del porcentaje global y no tiene por qué coincidir con el desglose del comprobante.
"""
if not invoice.taxes_per_item:
return
guardado = Decimal(str(invoice.total or 0)).quantize(Decimal("0.01"))
del_comprobante = data.total.quantize(Decimal("0.01"))
if guardado != del_comprobante:
raise builder.CfdiBuildError(
[
f"los totales de la factura no cuadran con el comprobante: la factura dice "
f"{guardado} y el CFDI {del_comprobante}. Vuelve a guardar una partida para "
f"recalcular antes de timbrar."
]
)
def _load_csd(db: Session, tenant_id: int, company_id: int) -> tuple[bytes, bytes, str]:
"""Bytes del ``.cer``, del ``.key`` y la contraseña descifrada del CSD de la empresa.
Las rutas salen de ``fin.issuer_settings``, no de una convención fija: cada empresa carga
su propio certificado desde la configuración fiscal.
Import diferido de ``storage_s3``: importar arriba abre conexión y rompe los tests, que
corren sin MinIO. Es el mismo patrón que usa ``invoices.service``.
"""
from core.crypto import SecretDecryptionError, SecretsNotConfigured, decrypt_secret # noqa: PLC0415
issuer = (
db.query(IssuerSettings)
.filter(
IssuerSettings.tenant_id == tenant_id,
IssuerSettings.company_id == company_id,
IssuerSettings.deleted_at.is_(None),
)
.first()
)
if not issuer or not issuer.csd_cer_file_key or not issuer.csd_key_file_key:
raise builder.CfdiBuildError(
[
"la empresa no tiene CSD cargado: súbelo en Configuración de Facturación "
"(certificado .cer, llave .key y su contraseña)"
]
)
# Contraseña por empresa; la global de entorno queda sólo como respaldo del esquema previo.
if issuer.csd_password_enc:
try:
password = decrypt_secret(issuer.csd_password_enc)
except (SecretDecryptionError, SecretsNotConfigured) as exc:
raise builder.CfdiBuildError([str(exc)]) from exc
elif settings.CSD_PASSWORD:
password = settings.CSD_PASSWORD
else:
raise builder.CfdiBuildError(
["la empresa no tiene guardada la contraseña de su CSD: vuelve a cargarlo"]
)
from core.storage_s3 import get_object_bytes # noqa: PLC0415
try:
cer = get_object_bytes(issuer.csd_cer_file_key)
key = get_object_bytes(issuer.csd_key_file_key)
except Exception as exc: # noqa: BLE001 — cualquier fallo aquí es "no hay CSD utilizable"
raise builder.CfdiBuildError(
[f"no pude leer los archivos del CSD desde el almacenamiento: {exc}"]
) from exc
return cer, key, password
# --------------------------------------------------------------------------------------
# Timbrado
# --------------------------------------------------------------------------------------
def stamp_invoice(
db: Session,
invoice_id: int,
tenant_id: int,
company_id: int,
user_id: str | None = None,
) -> InvoiceStamp:
"""Genera, sella y transmite el CFDI de la factura.
Es idempotente: si la factura ya tiene un timbre exitoso lo devuelve tal cual, **sin**
llamar al PAC. Retimbrar cuesta un folio y genera un comprobante duplicado ante el SAT,
que después hay que cancelar.
"""
invoice = _get_invoice(db, invoice_id, tenant_id, company_id)
existente = get_stamp(db, invoice_id, tenant_id, company_id)
if existente:
return existente
if invoice.status == "cancelada":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="La factura está cancelada"
)
mode = (invoice.stamping_mode or "").strip()
if mode not in STAMPING_MODES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Modo de timbrado inválido en la factura: {mode!r}",
)
if not settings.PAC_USER or not settings.PAC_PASSWORD:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="No están configuradas las credenciales del PAC (PAC_USER / PAC_PASSWORD).",
)
# ----- Datos, CSD, XML y sello -----
try:
data = _build_data(db, invoice, tenant_id, company_id)
_verifica_cuadre_con_la_factura(invoice, data)
cer_bytes, key_bytes, csd_password = _load_csd(db, tenant_id, company_id)
cert_number, cert_b64 = sealer.read_certificate(cer_bytes)
xml = builder.build_xml(data, cert_number=cert_number, cert_b64=cert_b64)
cadena = sealer.build_original_string(xml)
private_key = sealer.load_private_key(key_bytes, csd_password)
sello = sealer.sign(cadena, private_key)
xml_sellado = builder.apply_seal(xml, sello)
except builder.CfdiBuildError as exc:
# 422 con la lista completa: son datos que falta capturar, no un fallo del sistema.
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"message": "Faltan datos fiscales para timbrar", "missing": exc.missing},
) from exc
except sealer.SealingError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
) from exc
# ----- Transmisión -----
resultado = pac.stamp(
xml_sellado,
mode=mode,
user=settings.PAC_USER,
password=settings.PAC_PASSWORD,
host_test=settings.PAC_HOST_TEST,
host_prod=settings.PAC_HOST_PROD,
email=settings.PAC_NOTIFICATION_EMAIL,
timeout=settings.PAC_TIMEOUT_SECONDS,
)
stamp = InvoiceStamp(
tenant_id=tenant_id,
company_id=company_id,
invoice_id=invoice.id,
mode=mode,
status=STATUS_ERROR,
pac_code=resultado.code,
pac_balance=resultado.balance,
error_message=resultado.error_message or None,
created_by=user_id,
)
# El id se necesita para nombrar los XML del intento, y sólo existe después del flush.
db.add(stamp)
db.flush()
_store_attempt_xml(stamp, xml_sellado, resultado.xml)
if not resultado.ok:
db.commit()
db.refresh(stamp)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail={
"message": "El PAC rechazó el comprobante",
"pac_code": resultado.code,
"pac_error": resultado.error_message,
"stamp_id": stamp.id,
},
)
# ----- Verificación del timbre recibido -----
try:
tfd = _read_tfd(resultado.xml)
except ValueError as exc:
stamp.error_message = str(exc)
db.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"El PAC devolvió un XML que no pude interpretar: {exc}",
) from exc
esperado = PAC_RFC_BY_MODE[mode]
if tfd["pac_rfc"] != esperado:
# Red de seguridad final: se pidió un entorno y contestó otro. Nunca se da por bueno.
stamp.error_message = (
f"El timbre viene del PAC {tfd['pac_rfc']!r} y para el modo {mode!r} se esperaba "
f"{esperado!r}: se timbró contra un entorno distinto del solicitado."
)
db.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=stamp.error_message)
# ----- Persistencia -----
stamp.status = STATUS_STAMPED
stamp.uuid = tfd["uuid"]
stamp.stamped_at = tfd["stamped_at"]
stamp.pac_rfc = tfd["pac_rfc"]
stamp.sat_cert_number = tfd["sat_cert_number"]
stamp.sat_seal = tfd["sat_seal"]
stamp.cfd_seal = tfd["cfd_seal"]
stamp.error_message = None
key = invoice_stamp_xml_key(tenant_id, company_id, invoice.id, tfd["uuid"])
try:
from core.storage_s3 import put_object_bytes # noqa: PLC0415
put_object_bytes(key, resultado.xml.encode("utf-8"), content_type="application/xml")
stamp.xml_file_key = key
except Exception as exc: # noqa: BLE001
# El comprobante YA está timbrado ante el SAT: perder el archivo no puede invalidar el
# 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``.
Nunca propaga una excepción. Este rastro es para diagnóstico: si el almacenamiento está
caído no puede tumbar un timbrado que el SAT ya dio por bueno, ni convertir el rechazo del
PAC —que es lo que hay que contarle a quien factura— en un error de almacenamiento. Lo que
no se pudo subir queda con la clave en NULL y en el log.
"""
from core.storage_s3 import put_object_bytes # noqa: PLC0415
# El de respuesta puede venir vacío: un fallo de red corta antes de que el PAC conteste.
partes = [("request", sent), ("response", received.encode("utf-8") if received else b"")]
for kind, cuerpo in partes:
if not cuerpo:
continue
key = invoice_stamp_attempt_xml_key(
stamp.tenant_id, stamp.company_id, stamp.invoice_id, stamp.id, kind
)
try:
put_object_bytes(key, cuerpo, content_type="application/xml")
except Exception: # noqa: BLE001
logger.exception("No se pudo guardar el XML de %s del intento %s", kind, stamp.id)
continue
setattr(stamp, f"{kind}_xml_file_key", key)
def _read_tfd(xml_text: str) -> dict:
"""Extrae el Timbre Fiscal Digital del XML que devolvió el PAC."""
from lxml import etree # noqa: PLC0415
try:
root = etree.fromstring(xml_text.encode("utf-8") if isinstance(xml_text, str) else xml_text)
except etree.XMLSyntaxError as exc:
raise ValueError(f"XML mal formado: {exc}") from exc
nodo = root.find(f".//{{{TFD_NS}}}TimbreFiscalDigital")
if nodo is None:
raise ValueError("no trae el nodo TimbreFiscalDigital")
uuid = (nodo.get("UUID") or "").strip()
if not uuid:
raise ValueError("el TimbreFiscalDigital no trae UUID")
crudo = (nodo.get("FechaTimbrado") or "").strip()
try:
stamped_at = datetime.fromisoformat(crudo) if crudo else None
except ValueError:
# Fecha ilegible: no invalida el timbre, que ya existe ante el SAT. Se deja en NULL.
stamped_at = None
return {
"uuid": uuid,
"stamped_at": stamped_at,
"pac_rfc": (nodo.get("RfcProvCertif") or "").strip(),
"sat_cert_number": (nodo.get("NoCertificadoSAT") or "").strip() or None,
"sat_seal": (nodo.get("SelloSAT") or "").strip() or None,
"cfd_seal": (nodo.get("SelloCFD") or "").strip() or None,
}
def get_stamp_xml_url(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> str:
"""URL firmada del XML timbrado. Las presignadas caducan, así que se genera al vuelo."""
from core.storage_s3 import presigned_get_url # noqa: PLC0415
stamp = get_stamp(db, invoice_id, tenant_id, company_id)
if not stamp or not stamp.xml_file_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="La factura no tiene XML timbrado almacenado",
)
return presigned_get_url(stamp.xml_file_key)

View File

@@ -0,0 +1,61 @@
# XSLT de la cadena original — CFDI 4.0
La cadena original es la secuencia de datos que se firma para producir el sello del comprobante.
Sólo se obtiene aplicando la transformación oficial del SAT: no se construye a mano.
| Archivo | Origen |
|---|---|
| `cadenaoriginal_4_0.xslt` | `http://www.sat.gob.mx/sitio_internet/cfd/4/cadenaoriginal_4_0/cadenaoriginal_4_0.xslt` |
| `utilerias.xslt` | `http://www.sat.gob.mx/sitio_internet/cfd/2/cadenaoriginal_2_0/utilerias.xslt` |
| `sin_complemento.xslt` | Nuestro. Marcador de posición, ver abajo. |
## La única modificación: los `href` de los `xsl:include`
El archivo del SAT trae 33 `xsl:include` apuntando a URLs de `sat.gob.mx`. **Se reescribieron los
`href` a rutas relativas locales**; no se tocó ni una plantilla, ni un `xsl:template`, ni el orden
de los campos.
- El include de `utilerias.xslt` apunta al archivo local del mismo nombre.
- Los otros 32, todos de complementos (Carta Porte, Comercio Exterior, Nómina, Pagos…), apuntan a
`sin_complemento.xslt`, que es un stylesheet vacío.
**Por qué es inocuo:** cada XSLT de complemento sólo aporta plantillas que hacen `match` sobre nodos
de su propio complemento. Este módulo emite CFDI 4.0 tipo ingreso **sin complementos**, así que esas
plantillas nunca se invocan. Verificado: la cadena original que produce esta versión local es
**idéntica, carácter a carácter**, a la que produce el archivo del SAT resolviendo los includes por
red. Hay una prueba que lo fija en `backend/tests/test_fin_stamping.py`.
## Por qué se hizo así, y no con un resolver
Porque **`libxslt` sale a internet a resolver los `xsl:include` aunque el parser de lxml se cree con
`no_network=True`**. Está comprobado: con el archivo original y sin resolver, la transformación
completa funciona, lo que sólo es posible si descargó `utilerias.xslt` de `sat.gob.mx` en ese
momento.
Eso es inaceptable aquí por tres razones: el timbrado dependería de que `sat.gob.mx` esté arriba y
responda rápido; la cadena original —el dato que se firma— vendría de una descarga no verificada en
tiempo de ejecución; y un cambio silencioso en el servidor del SAT cambiaría los sellos sin que
nadie lo note.
Se intentó primero con un `etree.Resolver` personalizado, que es lo que hace el sistema legado
(`CFDI.cs`, el bloque `SafeXsltResolver` comentado hacia la línea 8333). No funcionó: el resolver
también intercepta la resolución del documento principal, y devolver un stylesheet vacío ahí deja la
transformación sin plantillas y **la cadena original sale vacía, sin ningún error**. Un sello sobre
una cadena vacía es un CFDI que el SAT rechaza — o peor, un sello que parece válido y no lo es.
Con los `href` locales el problema desaparece de raíz: no hay nada que resolver fuera del directorio.
## Cómo actualizar estos archivos
1. Descarga el original del SAT (URLs de la tabla).
2. Reescribe los `href` de los `xsl:include`: el de `utilerias.xslt` al archivo local, el resto a
`sin_complemento.xslt`.
3. Corre `pytest tests/test_fin_stamping.py`. La prueba de la cadena original tiene que seguir
verde: si cambió el orden o el número de campos, el sello cambia y hay que revisarlo con Fiscal
antes de subir nada.
## Si algún día se soporta un complemento
Trae **su** XSLT oficial, déjalo en este directorio y apunta el `href` de ese include concreto al
archivo real, en lugar de a `sin_complemento.xslt`. No basta con añadir el nodo al XML: sin su
plantilla, el complemento no entra en la cadena original y el sello sale mal.

View File

@@ -0,0 +1,409 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:fn="http://www.w3.org/2005/xpath-functions" xmlns:cfdi="http://www.sat.gob.mx/cfd/4" xmlns:cce11="http://www.sat.gob.mx/ComercioExterior11" xmlns:cce20="http://www.sat.gob.mx/ComercioExterior20" xmlns:donat="http://www.sat.gob.mx/donat" xmlns:divisas="http://www.sat.gob.mx/divisas" xmlns:implocal="http://www.sat.gob.mx/implocal" xmlns:leyendasFisc="http://www.sat.gob.mx/leyendasFiscales" xmlns:pfic="http://www.sat.gob.mx/pfic" xmlns:tpe="http://www.sat.gob.mx/TuristaPasajeroExtranjero" xmlns:nomina12="http://www.sat.gob.mx/nomina12" xmlns:registrofiscal="http://www.sat.gob.mx/registrofiscal" xmlns:pagoenespecie="http://www.sat.gob.mx/pagoenespecie" xmlns:aerolineas="http://www.sat.gob.mx/aerolineas" xmlns:valesdedespensa="http://www.sat.gob.mx/valesdedespensa" xmlns:notariospublicos="http://www.sat.gob.mx/notariospublicos" xmlns:vehiculousado="http://www.sat.gob.mx/vehiculousado" xmlns:servicioparcial="http://www.sat.gob.mx/servicioparcialconstruccion" xmlns:decreto="http://www.sat.gob.mx/renovacionysustitucionvehiculos" xmlns:destruccion="http://www.sat.gob.mx/certificadodestruccion" xmlns:obrasarte="http://www.sat.gob.mx/arteantiguedades" xmlns:ine="http://www.sat.gob.mx/ine" xmlns:iedu="http://www.sat.gob.mx/iedu" xmlns:ventavehiculos="http://www.sat.gob.mx/ventavehiculos" xmlns:detallista="http://www.sat.gob.mx/detallista" xmlns:ecc12="http://www.sat.gob.mx/EstadoDeCuentaCombustible12" xmlns:consumodecombustibles11="http://www.sat.gob.mx/ConsumoDeCombustibles11" xmlns:gceh="http://www.sat.gob.mx/GastosHidrocarburos10" xmlns:ieeh="http://www.sat.gob.mx/IngresosHidrocarburos10" xmlns:cartaporte20="http://www.sat.gob.mx/CartaPorte20" xmlns:pago20="http://www.sat.gob.mx/Pagos20" xmlns:cartaporte30="http://www.sat.gob.mx/CartaPorte30" xmlns:cartaporte31="http://www.sat.gob.mx/CartaPorte31" xmlns:hidrocarburospetroliferos="http://www.sat.gob.mx/hidrocarburospetroliferos">
<!-- Con el siguiente método se establece que la salida deberá ser en texto -->
<xsl:output method="text" version="1.0" encoding="UTF-8" indent="no"/>
<!--
En esta sección se define la inclusión de las plantillas de utilerías para colapsar espacios
-->
<xsl:include href="utilerias.xslt"/>
<!--
En esta sección se define la inclusión de las demás plantillas de transformación para
la generación de las cadenas originales de los complementos fiscales
-->
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<xsl:include href="sin_complemento.xslt"/>
<!-- Aquí iniciamos el procesamiento de la cadena original con su | inicial y el terminador || -->
<xsl:template match="/">|<xsl:apply-templates select="/cfdi:Comprobante"/>||</xsl:template>
<!-- Aquí iniciamos el procesamiento de los datos incluidos en el comprobante -->
<xsl:template match="cfdi:Comprobante">
<!-- Iniciamos el tratamiento de los atributos de comprobante -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Version"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Serie"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Folio"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Fecha"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@FormaPago"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@NoCertificado"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@CondicionesDePago"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@SubTotal"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Descuento"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Moneda"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@TipoCambio"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Total"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@TipoDeComprobante"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Exportacion"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@MetodoPago"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@LugarExpedicion"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Confirmacion"/>
</xsl:call-template>
<!--
Llamadas para procesar al los sub nodos del comprobante
-->
<xsl:apply-templates select="./cfdi:InformacionGlobal"/>
<xsl:for-each select="./cfdi:CfdiRelacionados">
<xsl:apply-templates select="."/>
</xsl:for-each>
<xsl:apply-templates select="./cfdi:Emisor"/>
<xsl:apply-templates select="./cfdi:Receptor"/>
<xsl:apply-templates select="./cfdi:Conceptos"/>
<xsl:apply-templates select="./cfdi:Impuestos"/>
<xsl:apply-templates select="./cfdi:Complemento"/>
</xsl:template>
<!-- Manejador de nodos tipo InformacionGlobal -->
<xsl:template match="cfdi:InformacionGlobal">
<!-- Iniciamos el tratamiento de los atributos del nodo tipo InformacionGlobal -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Periodicidad"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Meses"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Año"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de nodos tipo CFDIRelacionados -->
<xsl:template match="cfdi:CfdiRelacionados">
<!-- Iniciamos el tratamiento de los atributos del nodo tipo CFDIRelacionados -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@TipoRelacion"/>
</xsl:call-template>
<xsl:for-each select="./cfdi:CfdiRelacionado">
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@UUID"/>
</xsl:call-template>
</xsl:for-each>
</xsl:template>
<!-- Manejador de nodos tipo Emisor -->
<xsl:template match="cfdi:Emisor">
<!-- Iniciamos el tratamiento de los atributos del nodo tipo Emisor -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Rfc"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Nombre"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@RegimenFiscal"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@FacAtrAdquirente"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de nodos tipo Receptor -->
<xsl:template match="cfdi:Receptor">
<!-- Iniciamos el tratamiento de los atributos del nodo tipo Receptor -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Rfc"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Nombre"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@DomicilioFiscalReceptor"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@ResidenciaFiscal"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@NumRegIdTrib"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@RegimenFiscalReceptor"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@UsoCFDI"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de nodos tipo Conceptos -->
<xsl:template match="cfdi:Conceptos">
<!-- Llamada para procesar los distintos nodos tipo Concepto -->
<xsl:for-each select="./cfdi:Concepto">
<xsl:apply-templates select="."/>
</xsl:for-each>
</xsl:template>
<!--Manejador de nodos tipo Concepto-->
<xsl:template match="cfdi:Concepto">
<!-- Iniciamos el tratamiento de los atributos del Concepto -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@ClaveProdServ"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@NoIdentificacion"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Cantidad"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@ClaveUnidad"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Unidad"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Descripcion"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@ValorUnitario"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Importe"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Descuento"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@ObjetoImp"/>
</xsl:call-template>
<!-- Manejo de sub nodos de información Traslado de Conceptos:Concepto:Impuestos:Traslados-->
<xsl:for-each select="./cfdi:Impuestos/cfdi:Traslados/cfdi:Traslado">
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Base"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Impuesto"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@TipoFactor"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@TasaOCuota"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Importe"/>
</xsl:call-template>
</xsl:for-each>
<!-- Manejo de sub nodos de Retencion por cada una de los Conceptos:Concepto:Impuestos:Retenciones-->
<xsl:for-each select="./cfdi:Impuestos/cfdi:Retenciones/cfdi:Retencion">
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Base"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Impuesto"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@TipoFactor"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@TasaOCuota"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Importe"/>
</xsl:call-template>
</xsl:for-each>
<!-- Manejo de los distintos sub nodos a cuenta de terceros de forma indistinta a su grado de dependencia -->
<xsl:for-each select="./cfdi:ACuentaTerceros">
<xsl:apply-templates select="."/>
</xsl:for-each>
<!-- Manejo de los distintos sub nodos de información aduanera de forma indistinta a su grado de dependencia -->
<xsl:for-each select="./cfdi:InformacionAduanera">
<xsl:apply-templates select="."/>
</xsl:for-each>
<!-- Llamada al manejador de nodos de CuentaPredial en caso de existir -->
<xsl:if test="./cfdi:CuentaPredial">
<xsl:apply-templates select="./cfdi:CuentaPredial"/>
</xsl:if>
<!-- Llamada al manejador de nodos de ComplementoConcepto en caso de existir -->
<xsl:if test="./cfdi:ComplementoConcepto">
<xsl:apply-templates select="./cfdi:ComplementoConcepto"/>
</xsl:if>
<!-- Llamada al manejador de nodos de Parte en caso de existir -->
<xsl:for-each select=".//cfdi:Parte">
<xsl:apply-templates select="."/>
</xsl:for-each>
</xsl:template>
<!-- Manejador de nodos tipo ACuentaTerceros -->
<xsl:template match="cfdi:ACuentaTerceros">
<!-- Manejo de los atributos del nodo tipo ACuentaTerceros -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@RfcACuentaTerceros"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@NombreACuentaTerceros"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@RegimenFiscalACuentaTerceros"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@DomicilioFiscalACuentaTerceros"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de nodos tipo Información Aduanera -->
<xsl:template match="cfdi:InformacionAduanera">
<!-- Manejo de los atributos de la información aduanera -->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@NumeroPedimento"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de nodos tipo Información CuentaPredial -->
<xsl:template match="cfdi:CuentaPredial">
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Numero"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de nodos tipo ComplementoConcepto -->
<xsl:template match="cfdi:ComplementoConcepto">
<xsl:for-each select="./*">
<xsl:apply-templates select="."/>
</xsl:for-each>
</xsl:template>
<!-- Manejador de nodos tipo Parte -->
<xsl:template match="cfdi:Parte">
<!-- Iniciamos el tratamiento de los atributos de Parte-->
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@ClaveProdServ"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@NoIdentificacion"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Cantidad"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Unidad"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Descripcion"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@ValorUnitario"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Importe"/>
</xsl:call-template>
<!-- Manejador de nodos tipo InformacionAduanera-->
<xsl:for-each select=".//cfdi:InformacionAduanera">
<xsl:apply-templates select="."/>
</xsl:for-each>
</xsl:template>
<!-- Manejador de nodos tipo Complemento -->
<xsl:template match="cfdi:Complemento">
<xsl:for-each select="./*">
<xsl:apply-templates select="."/>
</xsl:for-each>
</xsl:template>
<!-- Manejador de nodos tipo Domicilio fiscal -->
<xsl:template match="cfdi:Impuestos">
<!-- Manejo de sub nodos de Retencion por cada una de los Impuestos:Retenciones-->
<xsl:for-each select="./cfdi:Retenciones/cfdi:Retencion">
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Impuesto"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Importe"/>
</xsl:call-template>
</xsl:for-each>
<!-- Iniciamos el tratamiento de los atributos de TotalImpuestosRetenidos-->
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@TotalImpuestosRetenidos"/>
</xsl:call-template>
<!-- Manejo de sub nodos de información Traslado de Impuestos:Traslados-->
<xsl:for-each select="./cfdi:Traslados/cfdi:Traslado">
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Base"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@Impuesto"/>
</xsl:call-template>
<xsl:call-template name="Requerido">
<xsl:with-param name="valor" select="./@TipoFactor"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@TasaOCuota"/>
</xsl:call-template>
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@Importe"/>
</xsl:call-template>
</xsl:for-each>
<!-- Iniciamos el tratamiento de los atributos de TotalImpuestosTrasladados-->
<xsl:call-template name="Opcional">
<xsl:with-param name="valor" select="./@TotalImpuestosTrasladados"/>
</xsl:call-template>
</xsl:template>
</xsl:stylesheet>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Marcador de posicion para los complementos del CFDI que este modulo NO emite.
cadenaoriginal_4_0.xslt del SAT incluye 32 hojas de estilo de complementos (Carta Porte,
Comercio Exterior, Nomina, Pagos...). Cada una solo aporta plantillas que hacen match sobre
nodos de su complemento: si el comprobante no los lleva, nunca se invocan y su ausencia no
cambia la cadena original ni un caracter.
Este modulo emite CFDI 4.0 tipo ingreso SIN complementos (ver el plan del ticket, seccion 1),
asi que todos esos includes apuntan aqui. Si algun dia se soporta un complemento, hay que
traer SU xslt oficial y apuntar el href de ese include al archivo real.
-->
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"/>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:fn="http://www.w3.org/2005/xpath-functions">
<!-- Manejador de datos requeridos -->
<xsl:template name="Requerido">
<xsl:param name="valor"/>|<xsl:call-template name="ManejaEspacios">
<xsl:with-param name="s" select="$valor"/>
</xsl:call-template>
</xsl:template>
<!-- Manejador de datos opcionales -->
<xsl:template name="Opcional">
<xsl:param name="valor"/>
<xsl:if test="$valor">|<xsl:call-template name="ManejaEspacios"><xsl:with-param name="s" select="$valor"/></xsl:call-template></xsl:if>
</xsl:template>
<!-- Normalizador de espacios en blanco -->
<xsl:template name="ManejaEspacios">
<xsl:param name="s"/>
<xsl:value-of select="normalize-space(string($s))"/>
</xsl:template>
</xsl:stylesheet>

View File

@@ -82,6 +82,7 @@ class ShipmentResponse(ShipmentBase):
model_config = ConfigDict(from_attributes=True)
id: int
case_id: int | None = None
closed_at: datetime | None = None
closed_by: str | None = None
created_by: str | None = None

View File

@@ -15,6 +15,7 @@ class Shipment(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio de embarque
case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente
quote_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.quotes.id"), nullable=True, index=True
)

View File

@@ -65,11 +65,14 @@ def create_shipment(
def create_shipment_from_quote(
quote_id: int = Query(..., description="Cotización aceptada a liberar"),
company_id: int = Query(..., description="Company ID"),
operation_type: str | None = Query(None, description="Confirma la dirección: importacion | exportacion"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.create_shipment_from_quote(db, quote_id, tenant_id, company_id, _user_id(current_user))
return service.create_shipment_from_quote(
db, quote_id, tenant_id, company_id, _user_id(current_user), operation_type=operation_type
)
@router.post("/shipments/{shipment_id}/reschedule", response_model=ShipmentResponse)

View File

@@ -5,10 +5,15 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.cases import service as cases_service
from api.v1.modules.crm.common.folios import next_folio
from api.v1.modules.crm.quotes.models import Quote
from api.v1.modules.crm.service_requests.models import ServiceRequest
from api.v1.modules.crm.suppliers.models import Supplier
# Direcciones válidas de la operación (para validar y sembrar hitos).
_OPERATION_TYPES = ("importacion", "exportacion")
from .dto import (
ShipmentCloseInput,
ShipmentCreate,
@@ -173,9 +178,20 @@ def delete_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: i
def create_shipment_from_quote(
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None,
operation_type: str | None = None,
) -> Shipment:
"""Liberar a Operaciones: crea el embarque a partir de una cotización aceptada."""
"""Liberar a Operaciones: crea el embarque a partir de una cotización aceptada.
La dirección impo/expo se confirma al liberar (``operation_type``) y, si no se
envía, se hereda de la solicitud. Con la dirección resuelta se genera el folio
``OP...`` y se siembran automáticamente los hitos del proceso (Diagramas 2 y 3).
"""
if operation_type is not None and operation_type not in _OPERATION_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Tipo de operación inválido: usa 'importacion' o 'exportacion'",
)
quote = (
db.query(Quote)
.filter(
@@ -198,12 +214,16 @@ def create_shipment_from_quote(
if quote.service_request_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
# La dirección enviada al liberar manda; si no viene, se hereda de la solicitud
resolved = operation_type or (sr.operation_type if sr else None)
shipment = Shipment(
reference=quote.reference,
reference=next_folio(db, tenant_id, company_id, "OP", resolved),
case_id=quote.case_id,
quote_id=quote.id,
service_request_id=quote.service_request_id,
account_id=quote.account_id,
operation_type=sr.operation_type if sr else None,
operation_type=resolved,
transport_mode=sr.transport_mode if sr else None,
service_type=sr.service_type if sr else None,
incoterm=sr.incoterm if sr else None,
@@ -220,6 +240,14 @@ def create_shipment_from_quote(
db.add(shipment)
if sr:
sr.status = "liberada"
cases_service.advance_stage(db, quote.case_id, "operacion")
db.flush()
# Siembra automática de hitos si ya se conoce la dirección de la operación
for position, (event_type, title, kind) in enumerate(_DEFAULT_MILESTONES.get(resolved or "", [])):
db.add(ShipmentEvent(
shipment_id=shipment.id, event_type=event_type, title=title, kind=kind,
status="pendiente", position=position, tenant_id=tenant_id, company_id=company_id,
))
db.commit()
db.refresh(shipment)
return shipment

View File

@@ -96,6 +96,10 @@ def _reset_rls_context_from_task(task_id=None, task=None, **_):
celery_app.conf.update(
include=[
"api.v1.modules.core.help_center.tasks",
# Sin esta línea el worker rechaza las tareas del carril con "Received unregistered
# task of type 'expediente_gateway.deliver_outbox_row'": la fila queda en `pending`
# con 0 intentos y NUNCA se drena, aunque el encolado se vea perfecto.
"api.v1.modules.crm.expediente_gateway.tasks",
# Agrega aquí las tareas de tu proyecto:
# "api.v1.modules.example.tasks",
]
@@ -120,6 +124,25 @@ celery_app.conf.beat_schedule = {
"task": "cleanup_orphan_layout_imports",
"schedule": 3600.0,
},
# Carril CRM -> EFC. Los tres intervalos vienen del carril de referencia de Anexo22: 120 s para
# las dos colas y 300 s para la reconciliación. El reintento NO es exponencial a propósito —el
# backoff corto vive en el cliente HTTP y el largo es este barrido de intervalo fijo.
#
# Estos barridos son la red que atrapa la ventana entre el encolado y el commit: el despacho
# inmediato puede llegar al worker antes de que la transacción confirme, no encontrar la fila
# y darse por vencido. Aquí se recoge.
"efc-sweep-outbox-every-2-min": {
"task": "expediente_gateway.sweep_outbox",
"schedule": 120.0,
},
"efc-sweep-file-outbox-every-2-min": {
"task": "expediente_gateway.sweep_file_outbox",
"schedule": 120.0,
},
"efc-sweep-expediente-gaps-every-5-min": {
"task": "expediente_gateway.sweep_expediente_gaps",
"schedule": 300.0,
},
}
if __name__ == "__main__":

Some files were not shown because too many files have changed in this diff Show More