64 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
Ernesto Herrera
87d23b3d23 feat(crm): rediseño profesional del PDF de cotización
Banda de encabezado, logo + emisor, título con regla, panel de datos, barras de
sección en color, tabla de costos con encabezado y filas alternadas, caja de
totales, y pie con banda. Fix: elipsis "…" -> "..." (evita "?" en latin-1);
oculta secciones sin datos; color por defecto navy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 11:06:16 -06:00
Ernesto Herrera
e269e46d88 fix(crm): envío de cotización por correo con aiosmtplib.send (sin doble STARTTLS)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:49:23 -06:00
Ernesto Herrera
03055cd377 fix(crm): servir el PDF de cotización por el backend (no exponer MinIO)
La URL prefirmada usaba el host interno http://minio:9000 (no accesible desde el
navegador). Se agrega GET /quotes/{id}/pdf que devuelve el PDF por el backend
(vía nginx) y el visor usa un blob autenticado (api.getBlob).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:33:14 -06:00
Ernesto Herrera
206ff450f8 feat(crm): PDF de cotización (formato maestro) + marca por tenant + envío por correo
- Modelo crm.quote_settings (emisor, logo, color, prefijo, términos) por compañía
  + columna quotes.pdf_file_key + migración con down().
- Generador PDF (formato maestro: emisor+logo, cliente, carga/ruta, costos,
  resumen, condiciones); logo incrustado como JPEG (Pillow).
- Endpoints: GET /quotes/{id}/pdf-url, POST /quotes/{id}/send-email (adjunta PDF),
  GET/PUT /quote-settings, POST /quote-settings/logo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:18:07 -06:00
Ernesto Herrera
ef7e69ed57 feat(crm): tarifario — editor de cargos adicionales + alta manual de rutas
- Backend: CRUD de cargos (rate_charges) por tarifario.
- Frontend: detalle del tarifario con alta manual de rutas (con editor de quiebres
  para aéreo/LCL) y sección de cargos adicionales (agregar/eliminar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:46:25 -06:00
Ernesto Herrera
2ae6901b6a feat(crm): módulo Tarifario — modelos, CRUD, import Excel y motor de costeo
- Tablas rate_sheets/lanes/breaks/charges (esquema crm) + migración con down().
- Catálogos nuevos: modo_tarifario, unidad_tarifa, concepto_cargo.
- CRUD de tarifarios y rutas; descarga de plantilla Excel por modo; import con
  vista previa y validación; alta directa desde Excel.
- Motor de costeo /rate-quote: aéreo (peso facturable + quiebres + optimización),
  marítimo FCL (por contenedor), LCL (W/M) y terrestre; suma cargos adicionales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:28:49 -06:00
Ernesto Herrera
8576ec7e37 feat(crm): catálogo Tipos de equipo/contenedor con medidas (Medidas de Equipos)
Agrega el catálogo global 'tipo_equipo' (27 opciones: contenedores marítimos,
ULD aéreos y remolques terrestres) con dimensiones en el campo extra (modo,
largo/ancho/alto, capacidad m³, tara, carga máx., pallets). Base para la sección
FCL del módulo de Cotización (T2026-07-183). Expone extra en el API de catálogos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:10:19 -06:00
Ernesto Herrera
de8557dec2 feat(crm): formularios Clientes/Prospectos y Proveedores por catálogo — T2026-07-081/082
Frontend de los catálogos de referencia en BD:
- Store crm-catalogs + cliente API; selects poblados desde /v1/crm/catalogs
  (fiscal: régimen, uso CFDI, forma/método de pago SAT, moneda ISO; comercial;
  país/estado/tipo de domicilio/área). Muestran la descripción, no la clave.
- "Otro → especificar" (medio de contacto y clasificación), observaciones
  generales, y datos de auditoría (ID, fechas, usuarios) en la vista de edición.
- Botón "Editar" explícito en los listados.
- Pantalla de administración de catálogos (insertar/editar/borrar): globales
  solo activar/desactivar; los de la empresa con alta/edición/borrado.
- addresses.country ampliado a 3 (país ISO alfa-3) + migración con down().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:59 -06:00
Ernesto Herrera
8aef99df9c feat(crm): catálogos de referencia en BD (SAT/ISO + cliente) — T2026-07-081/082
- Modelo crm.catalog_items (global tenant_id NULL / por tenant) + migración con
  índices únicos parciales y down().
- Seed de 18 catálogos globales (485 opciones): tipo registro/persona, estatus,
  giro, clasificaciones, medio contacto, idioma, régimen, uso CFDI, forma/método
  de pago SAT, moneda/país ISO, estados MX, tipo domicilio, área, cobertura.
- Servicio + endpoints CRUD /v1/crm/catalogs (listar/insertar/editar/borrar),
  con global solo para hub_admin y catálogos del cliente por tenant.
- Columnas nuevas: accounts.commercial_observations, accounts.preferred_contact_other,
  suppliers.classification_other.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:15:52 -06:00
Ernesto Herrera
de9e35c501 feat(core): botón "dar de alta usuario" (invitación) en Usuarios + fix hub_admin
- Frontend Usuarios: formulario para dar de alta (email + rol) → invitación; muestra
  el enlace copiable por si el correo no llega.
- Backend invites: resuelve tenant_slug desde la compañía cuando el token no lo trae
  (hub_admin) y usa el token KC de la sesión (valkey) para crear el invite en el Hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:12:11 -06:00
Ernesto Herrera
868724d1f2 fix(core): usar token KC (valkey) para listar usuarios del tenant, no la sesión local
list_users/stats llamaban al Hub (users-with-info) con el Bearer de la app, que con
el patrón SIWEB es la sesión local (HS256) → el Hub la rechaza (401) → 0 usuarios.
Nuevo core.hub_token.get_hub_access_token: toma el token KC de la sesión (valkey vía
crm_sid) y lo refresca si está por expirar. list_users y stats lo usan para el Hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:03:37 -06:00
Ernesto Herrera
387b3c0e78 feat(core,crm): auto-sync de tenants Workspace→CRM + mostrar tenant de la compañía activa
- assignable-tenants: para hub_admin, sincroniza automáticamente los tenants del
  Workspace (Hub GET /hub/tenants) a core.tenants con su mismo ID, y los devuelve.
  Así los tenants creados en el Workspace aparecen solos en el CRM para asignarles
  compañías (best-effort con el token KC de la sesión). CRM→Workspace ya lo hace
  Organizaciones (POST /hub/tenants).
- my-companies devuelve tenant_name/tenant_slug (join core.tenants).
- Switcher muestra el tenant de la compañía activa (antes "Sin tenant asignado").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:20:50 -06:00
Ernesto Herrera
706da34f4a feat(core): auto-ligar usuario a todas las compañías de su tenant (sin rol)
Antes solo se ligaba al usuario que CREABA la compañía; un segundo usuario del mismo
tenant no quedaba ligado → veía "sin compañía". Ahora, al entrar, el usuario con
tenant queda como MIEMBRO de todas las compañías de su tenant (solo membresía; el rol
lo asigna un admin, salvo el primer usuario que recibe super_admin en /permissions/me).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:44:51 -06:00
Ernesto Herrera
ce8b042e84 fix(auth): hornear is_hub_admin (autoritativo del Hub) en la sesión local
create_company (y otros checks) usan is_hub_admin, pero la sesión local se emitía
desde el token KC crudo, que no trae ese claim → el hub_admin sin tenant recibía 403
al crear compañía. Ahora la sesión se emite con los claims de /auth/me del Hub
(is_hub_admin, roles), con fallback al decode del token KC si el Hub no responde.
Se mantiene intacto el control de autorización (solo hub_admin crea fuera de su tenant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:27:27 -06:00
Ernesto Herrera
63ad2e2ecd feat(core,crm): gestión de compañías en el CRM + soporte hub_admin sin tenant
Las compañías (a76.company) se gestionan en el CRM, ligadas a un tenant del
Workspace. Cambios:

- my-companies: por MEMBRESÍA (user_tenants ∪ user_company_roles), así el hub_admin
  sin tenant en el token ve las compañías que creó/se le asignaron; para usuarios
  con tenant, autocrea una por defecto en el primer acceso.
- POST /auth/companies: da de alta una compañía bajo un tenant + asigna al usuario.
  GET /auth/assignable-tenants: tenants elegibles (hub_admin: todos; usuario: el suyo).
- security.validate_access_to_resource: si el token no trae tenant (hub_admin),
  resuelve el tenant desde la compañía activa (a76.company.tenant_id) → puede operar
  por compañía seleccionada.
- set-active: fija sso_tenant_id/pub con el tenant de la compañía (override).
- Pantalla "Compañías" (nav) para crear/listar/seleccionar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:44:36 -06:00