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