Medicion de 3 vueltas mas contra datos reales. El aplanado del commit anterior
NO redujo los reintentos: subieron de 3 a 10. Se mantiene porque quitar ese
nivel es correcto de todos modos, pero no cumplio su proposito.
Lo que si salio de esa medicion son dos restricciones mias que provocaban el
rechazo:
- min(5) en los textos de materiales, decisiones, menciones y red flags. Con una
transcripcion pobre no hay nada que listar, asi que el modelo mete "N/A" o un
guion para rellenar y falla. Ocurrio 4 veces SEGUIDAS en una vuelta. Baja a
min(3), y el prompt ahora dice explicitamente que devolver el array vacio es la
respuesta correcta y que no rellene.
- beneficios.etiqueta.max(40) se pasaba en 2 de 3 vueltas. Sube a 70.
Es el mismo patron que el tope de 40 partidas: el schema peleandose con la
realidad, no el modelo fallando.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
El mas urgente lo cause yo al hacer que el bloqueo bloqueara de verdad: si un
aviso bloqueante caia en un campo que el panel no dejaba editar, el asesor
quedaba sin salida salvo regenerar. Ocho campos eran editables y bastantes mas
se imprimen al cliente. Ahora se pueden editar tambien la cita destacada y su
autor, las dimensiones del valor y su nota de metodologia, los resultados con su
metrica y periodo, los materiales, las decisiones con quien decide, y el backlog.
El boton de descarga del documento del cliente aparece deshabilitado cuando hay
bloqueantes, en vez de invitar a un clic que devuelve 409.
Doble propuesta: se rechaza generar. El documento asume UNA lista de alcance y
UNA caja de totales, asi que sumaria las dos opciones —que son alternativas
excluyentes— y mostraria un total que no existe. Soportarlas es rediseniar el
documento; mientras tanto es mejor no producir uno incorrecto. Hoy no hay
cotizaciones dobles en produccion, asi que no bloquea a nadie.
La transcripcion ahora se guarda con la propuesta (columna nueva, migracion
aditiva). Solo vivia en memoria durante la generacion, asi que al editar y
revalidar, R3 se quedaba sin fuente contra la cual comprobar que la cita
destacada siguiera siendo literal, y el aviso desaparecia solo.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Segunda tanda de rechazos observados contra datos reales (3 vueltas mas):
- Cada dimension del valor del problema tenia sus factores dentro de un objeto
`calculo`. Ese nivel no aportaba nada semantico y era justo donde el modelo se
perdia: devolvia {item: {...}, notaMetodologia} y quemaba reintentos. Ahora
`factores` y `montoAnualMXN` cuelgan de la dimension. Un nivel menos de
anidamiento en el punto exacto donde fallaba.
- subtitulo.max(300) se quedaba corto. Sube a 400.
La primera tanda de arreglos ya habia bajado los rechazos de 7 a 3 en tres
vueltas, y de 3 vueltas con reintento en la redaccion a solo 1.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
La auditoria confirma lo que se pedia verificar: la IA SOLO puede usar las tres
herramientas internas. Una por llamada, ninguna se ejecuta jamas (el input del
modelo solo va a schema.safeParse, no hay despachador), y no se envia ningun
tool del proveedor, web search, ejecucion de codigo, MCP ni beta.
Lo que NO cumplia era la otra mitad, la correspondencia con las plantillas:
CRITICO - el documento cobraba un IVA que la cotizacion no cobra. Cada partida
se imprimia a precio SIN IVA y debajo un unico total CON IVA, rematado con
"Importes con IVA incluido", mientras el PDF economico del mismo correo dice
"los precios no incluyen IVA" sobre las mismas cifras. Las lineas no sumaban su
propio total. Ahora la caja de totales desglosa subtotal / IVA / total como la
plantilla autorizada, las lineas siguen sin IVA igual que el otro documento, y
la nota al pie dice lo que de verdad hacen las lineas.
CRITICO - los avisos bloqueantes no bloqueaban nada. hayBloqueantes() no se
llamaba en ningun sitio y la ruta del PDF nunca leia avisos: el documento del
cliente se descargaba igual con una fuga de notas internas dentro. Ahora
devuelve 409 con la lista de lo que hay que corregir. El anexo interno si se
permite: es justo el que el asesor necesita para arreglarlo.
ALTO - seis campos que SI se imprimen al cliente no pasaban por los filtros de
marca, moneda, garantias y PII: el texto y el autor de la cita destacada, la
metrica y el periodo de cada resultado, quien decide cada pendiente, y el
momento del backlog. textoVisible ahora los cubre y queda documentado que debe
seguir a lo que dibuja el PDF.
ALTO - el plan Bucefalo se caia del documento y de sus totales, aunque si
aparece en el PDF economico y en el Excel: cargarEconomia nunca leia la
relacion. El cliente recibia dos documentos con alcances distintos.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Seis vueltas completas del pipeline contra los datos reales de UJ2606UR001
mostraron que la mayoria de los reintentos los provocaba el propio schema, no
el modelo:
- alcance.max(40) contra una cotizacion de 58 partidas garantizaba un rechazo de
Zod en CADA corrida. Sube a 120. El tope solo acota una respuesta desbocada;
la completitud del documento ya no depende de este array desde d20007d2.
- titulo.max(80) se quedaba corto y costo un reintento en 2 de 6 corridas.
Sube a 140.
Y dos rarezas del proveedor, ambas observadas contra la API real:
- MiniMax a veces envuelve los elementos de un array en {item: {...}}. Se
normaliza antes de validar, solo cuando "item" es la unica clave, para no
tocar un campo legitimo con ese nombre.
- A veces emite DOS bloques tool_use en una respuesta. Antes se tomaba el
primero a secas; ahora se prueban todos y gana el que valide.
Sobre el error de tipo de documento que se vio en produccion: NO se reprodujo en
seis corridas completas contra los mismos datos, y la generacion que lo siguio
completo sin problema (la fila quedo en la tabla). La evidencia apunta a un
fallo transitorio del proveedor, no a un defecto determinista nuestro. En vez de
inventar un arreglo para algo que no se puede reproducir, se reintentan los
fallos transitorios (5xx, 429, timeouts, red y los 400 con mensaje de parseo
interno) con espera creciente.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Encontrado con datos reales de produccion. UJ2606UR001 tiene 58 partidas y el
schema acota alcance a 40, asi que 18 servicios cotizados desaparecian del
documento del cliente en silencio: aparecian en el PDF economico pero no en el
consultivo, para el mismo envio.
El arreglo no es subir el tope. El generador ahora recorre las partidas de la
COTIZACION, no las que la IA alcanzo a describir, y usa la prosa de la IA cuando
existe. Si falta, imprime el detalle del catalogo (entregables y tiempo de
entrega) como respaldo. La completitud la manda la base de datos; la IA solo
aporta la redaccion. Es el mismo principio que ya rige para el dinero.
Verificado contra la propuesta real que genero produccion: 58 de 58 presentes,
cero faltantes.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
El compose filtra que variables llegan al contenedor. Sin declararlas aqui,
ponerlas en el panel de Coolify no habria servido de nada: el boton de generar
propuesta habria fallado con 'Falta MINIMAX_API_KEY' aun estando configurada.
Editados los dos archivos, como exige AGENTS.md: docker-compose.yaml es una copia
identica de coolify.yml que existe para la deteccion por defecto de Coolify, y
editar solo uno despliega el viejo.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Tres defectos que solo se veian llamando al modelo de verdad.
1. min(2) en los factores obligaba a inventar relleno. Con una dimension sin
cifras, MiniMax produjo "herramienta_de_seguimiento_actual=0 x canal=1" solo
para satisfacer la restriccion. Ahora los dos factores se exigen unicamente
cuando hay montoAnualMXN.
2. El modelo OMITE montoAnualMXN en vez de mandar null explicito, que es lo
natural para un LLM. Exigirlo presente quemaba los tres intentos del paso.
Ahora es .default(null) y la omision se tolera.
3. Sin confianza por factor, el modelo la metia dentro del nombre
("tasa_conversion (por_validar)=0.1"). Ahora es un campo.
Y el hallazgo que mas importa, porque es comercial y no de formato: el modelo
puede OMITIR un factor y aun asi cuadrar la aritmetica. En una corrida calculo
40 mensajes x 0.5 sin contestar x 52 semanas x 3,000 de utilidad POR CLIENTE =
3,120,000, asumiendo que cada mensaje sin responder es un cliente perdido. R5 lo
acepto porque los factores si multiplican al monto: R5 no puede ver lo que falta.
El ratio habria dicho "subcotizado" con el denominador inflado diez veces. Como
no se puede impedir que el modelo produzca estimaciones plausibles y erroneas, lo
que se hace es que el asesor las cache de un vistazo:
- R5b avisa cuando una cifra no tiene ni un factor confirmado.
- El anexo interno desglosa cada factor con su confianza, y alerta en rojo si el
valor descansa entero en estimaciones.
Ademas, el presupuesto de reintentos sube (5 en extraccion, 4 en los otros dos):
MiniMax se equivoca de array de vez en cuando con schemas anidados, y la
extraccion es el paso fundacional. Una corrida real necesito los 5.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Cierra la funcionalidad: el Cotizador ahora ofrece tres descargas por cotizacion
—Excel, PDF economico y PDF consultivo con marca— mas un anexo interno aparte.
Rutas: POST genera, GET lee la ultima, PATCH guarda la edicion del asesor
(revalidando, porque una edicion manual puede introducir una fuga o un USD), y
GET /pdf descarga, con ?anexo=1 para el interno.
Panel: transcripcion opcional, avisos de validacion, campos editables (titulo,
subtitulo, hallazgos, alcance, exclusiones y beneficios) y las descargas.
Los avisos van deliberadamente ARRIBA de los botones de descarga: el objetivo es
que revisar sea mas facil que aprobar.
El anexo interno lleva la ponderacion: ratio precio/valor sobre el desembolso
del primer ano con IVA, su lectura (subcotizado / en rango / alto / objecion
probable), conteo de hallazgos y evidencia, red flags y notas para el asesor.
Va en archivo separado con banda roja "NO ENVIAR", nunca como seccion oculta.
Verificado con 6 suites (114 comprobaciones), typecheck, lint y build. Las mas
importantes son las estructurales: ningun schema declara un campo de dinero de
E3, ningun prompt filtra precios ni cuids, y el documento del cliente no
contiene notas internas ni red flags aunque se inyecten a la fuerza.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
El documento del cliente sigue la arquitectura argumental de la plantilla de
referencia pero en tema claro con PDFKit: la plantilla original es oscura, y en
impresion eso depende de una casilla del navegador desactivada por defecto.
El anexo interno va en archivo separado, no como seccion oculta.
44 comprobaciones sobre PDFs reales: contenido presente, notas internas y red
flags ausentes del documento del cliente, precios inyectados por el codigo,
branding invalido tolerado y contenido largo multipagina.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
R0 existe porque ninguna otra regla la cubre: las notas internas del asesor son
una ENTRADA que el modelo recibe en el prompt y puede copiar literalmente.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Verificado que ni el precio ni el cuid de ServicioCotizado aparecen en ningun
prompt: el modelo solo ve refPartida. P1 deja de depender de una validacion.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
El contrato se fuerza con el input_schema de la herramienta porque MiniMax no
soporta structured outputs. tool_choice no se envia: fijarlo solo en el reintento
le daria un prefijo distinto y se pagaria el contexto completo.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
La capa de economia existe para que el dinero viva en un solo sitio y nunca
cruce hacia el prompt. El pipeline recibe de ahi solo refPartida y nombre.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
MCP: se retira por completo (endpoint, paquete api/app/mcp/ y dependencia).
Tres razones, en orden de peso:
- Nadie lo usa.
- Llevaba roto desde antes de este trabajo. Con credencial valida devolvia 500:
el handler construia un StreamableHTTPServerTransport nuevo por peticion, sin
manejo de sesion. Lo que estaba expuesto a internet era la puerta abierta de un
cuarto averiado.
- Su SDK sin fijar tumbo el API entero en produccion al saltar a 2.0.0.
Retirarlo es una mitigacion mas fuerte que autenticarlo, que fue lo que hizo el
commit anterior. Recuperable con `git show edb500f5:api/app/mcp/server.py`.
Se limpia tambien la ruta "/mcp" que el endpoint raiz seguia anunciando, y las
menciones a MCP del docstring y la descripcion de Swagger.
Dependencias: once de las doce eran rangos `>=` sin techo, o sea que cada
reconstruccion era una tirada de dados contra PyPI. `pydantic>=2.0` habria
aceptado pydantic 3 con la misma alegria con la que `mcp>=1.0.0` acepto 2.0.0.
Ahora todas van fijadas a la version exacta que corre sana en produccion,
capturada con pip freeze del contenedor healthy.
El lado Next.js ya era reproducible via package-lock.json; por eso el web nunca
se cayo durante el incidente y el api si.
Docs actualizados: AGENTS.md, README.md, api/COTIZADOR_API_SKILL.md y el spec,
que ademas registra en su seccion 0 las tres desviaciones de Fase 0 respecto a
lo disenado (el Despliegue B cancelado, la retirada del MCP y la deriva de
dependencia).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
El primer rebuild del api en dos semanas trajo mcp 2.0.0, que elimino
Server.list_tools(). app/mcp/server.py lo usa como decorador en su linea 27,
asi que el import lanzaba AttributeError al arrancar.
El agravante: main.py envolvia el montaje del MCP en `except ImportError`.
Un AttributeError no es ImportError, asi que se escapaba y tumbaba toda la
aplicacion. En produccion el contenedor quedo en crash-loop y el REST dejo de
responder por completo, no solo el MCP.
Dos arreglos:
- requirements.txt fija `mcp>=1.28.1,<2.0.0`. La imagen anterior que llevaba dos
semanas sana tenia 1.28.1; el rango abierto `>=1.0.0` permitio el salto mayor.
- main.py captura cualquier excepcion al montar el MCP y la registra. El servidor
MCP es opcional; el REST no. Si el MCP no monta, la API sigue de pie.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Primera fase del plugin de propuesta consultiva (docs/superpowers/specs/
2026-07-28-propuesta-consultiva-ia-fase0-fase1-design.md). Recupera el contexto
humano que hoy se captura y se descarta, y cierra los bloqueadores que el
analisis previo destapo.
Seguridad (lo mas urgente):
- POST /mcp no tenia NINGUNA autenticacion: cero Depends() en main.py, mientras
el servicio recibe dominio publico en produccion (SERVICE_FQDN_API_8000).
Cualquiera en internet podia leer y escribir cotizaciones. Ahora exige
require_auth, que ya existia en app/auth.py y no se estaba usando ahi.
- _obtener_cotizacion hacia SELECT c.* y devolvia dict(cot) al agente, asi que
cualquier columna nueva se publicaba sola. Ahora usa lista blanca espejo de
CotizacionResponse, excluyendo observacionesInternas.
Totales:
- Nueva calcularTotalesCotizacion() en calculators.ts como fuente de verdad
unica. El calculo estaba duplicado a mano en siete consumidores.
- conIva() sustituye a los `* 1.16` hardcodeados de pdf-generator y
excel-builder, que ignoraban IVA_RATE y el flag incluirIva.
Contexto humano:
- Campo nuevo Cotizacion.observacionesInternas. La migracion es PURAMENTE
ADITIVA: no mueve ni una fila. El movimiento de datos no hace falta porque la
unica fila de produccion con observaciones ya contiene texto dirigido al
cliente, y separarlo en dos despliegues mantiene el rollback limpio.
- Dos textareas visualmente inconfundibles en el formulario.
- observaciones se imprime por primera vez en el PDF y el Excel; el dato ya
viajaba hasta las rutas de borrador y se tiraba.
- observacionesInternas solo se ve en la app. La garantia es estructural: el
campo no existe en CotizacionPDFData ni en ExcelData, asi que el generador no
puede filtrarlo aunque alguien lo intente.
Bugs vecinos:
- orderBy explicito en las cuatro rutas de export: el PDF asume las partidas
agrupadas por fase y sin orderBy podia diferir del Excel del mismo envio.
- El PDF de borrador leia solo el branding, no los datos bancarios, e imprimia
los hardcodeados del generador.
- detalleModelo local en PDF y Excel omitia la rama "demanda": esa partida
salia en $0 y sin explicacion. Ahora delegan en la version canonica.
- Los bonos salen de la tabla Bono; la lista hardcodeada queda de respaldo y su
texto ya no coincidia con el seed.
- La palomita de los bonos mide 0pt en las fuentes base de PDFKit (verificado),
o sea que salia como dos espacios. Sustituida por una vineta.
Ademas: zod pasa a ser dependencia declarada. Se importaba en schemas.ts y
resolvia transitivamente, asi que un npm ci --omit=dev reventaba.
Verificado con build, lint y 22 comprobaciones funcionales sobre PDF y Excel
reales (texto del cliente presente, texto interno ausente incluso inyectandolo
a la fuerza, texto largo multipagina, y totales con y sin IVA).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Convierte el análisis de negocio de docs/BI-propuesta-consultiva-IA.md en una
especificación cerrada para las dos primeras fases del roadmap.
El análisis previo destapó cuatro problemas que el documento de negocio no
anticipaba, los cuatro verificados en disco:
- POST /mcp no tiene autenticación (api/main.py:107) y _obtener_cotizacion hace
SELECT c.* sin lista blanca, con dominio público en producción. Publicaría la
columna de notas internas sin tocar una línea de código.
- No existe una fuente de verdad para los totales: el cálculo está duplicado en
siete consumidores y el IVA está hardcodeado como * 1.16 en PDF y Excel.
- El Excel es un documento del cliente, no una herramienta interna. El diseño
inicial proponía poner ahí las notas internas; se corrigió.
- Las transcripciones se sincronizarían a la nube de MEGA: .megaignore solo
excluye .next, node_modules y .turbo.
También reconcilia nueve contradicciones de contrato entre los diseños
explorados (clave de partida, forma de la evidencia, nombres del valor anual)
que habrían hecho que la capa de validación validara el vacío.
Actualiza AGENTS.md, que había quedado desfasado: 14 modelos y 10 migraciones
(decía 13 y 3), el registro de horas, la doble propuesta, los modelos de cobro
y la convención de no usar diálogos nativos del navegador.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Los confirm()/alert()/prompt() nativos se veían fuera de la marca ("<dominio> dice…").
Se agrega un DialogProvider (montado en el layout autenticado) con su estilo visual y se
consume via hooks useConfirm / usePrompt / useToast:
- confirmaciones -> modal propio (con variante "peligro" para eliminaciones)
- prompts (crear paquete / fase) -> modal con input
- alerts de error/éxito -> toasts
Reemplazados todos los diálogos nativos: RegistroHorasPanel, CatalogoClient,
ConfiguracionClient, CotizacionForm, ExportButtons, ListDeleteButton, DeleteButton,
CambiarEstadoButtons. Validado E2E (Playwright): al revertir una partida pagada aparece
el modal propio y NO se dispara el confirm nativo.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
El boton de revertir solo aparecia en la vista "Pagadas", pero el filtro por defecto
es "Por pagar", que oculta las pagadas -> el usuario no encontraba como corregirlas.
Ahora:
- Las tarjetas de resumen "Por pagar" / "Pagado" son clicables y filtran la tabla; la
de "Pagado" muestra el hint "Ver / editar" y lleva a las partidas pagadas.
- Cuando no hay pendientes pero si pagadas, el estado vacio ofrece un boton
"Ver N pagada(s) para editar o regresar a Por pagar".
- Los botones de accion por fila ahora llevan texto ("Pagada" / "Por pagar"), no solo
icono, para que revertir sea evidente.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
El badge de estado deja de ser un toggle (facil de disparar por error) y pasa a ser
un indicador. En su lugar, la columna de acciones muestra un boton claro segun el estado:
"Marcar como pagada" (pendientes) o "Regresar a Por pagar" (pagadas, con confirmacion).
Asi se corrigen los casos donde una partida se marca como pagada por error. El backend
ya limpiaba la fechaPago al regresar a por_pagar; solo cambia la UX del panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- Las notas "por pagar" ahora incluyen los datos bancarios de cobro configurados
(Transferencia Nacional/Internacional: cuenta, CLABE, beneficiario, RFC, banco, SWIFT),
igual que el PDF de cotizacion. Solo se muestran en las notas por pagar.
- El nombre del PDF incluye el periodo cobrado (fecha minima a maxima de los registros),
formato DD-MM-YYYY_DD-MM-YYYY. Ej: "Nota de horas por pagar UJ2606AG777 - 24-06-2026_24-06-2026".
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Imprimir el HTML de la nota desde el navegador siempre inserta encabezado/pie con la
URL de la cotizacion, fecha y no. de pagina (el @page{margin:0} no lo evita al imprimir
un iframe). Se reemplaza por un PDF generado en el servidor con pdfkit, que no lleva
ningun encabezado del navegador.
- src/lib/nota-horas-pdf.ts: generateNotaHorasPDF (logo, titulo segun estado, tabla
detalle/agrupada, columna Estado en "todas", totales con IVA opcional).
- API POST /api/cotizaciones/[id]/nota-horas: filtra por estado/periodo, agrupa y
calcula totales del lado servidor y devuelve el PDF.
- Panel: el boton del modal pasa de "Imprimir" a "Descargar PDF" (baja el PDF del
servidor); se conserva la vista previa HTML en pantalla.
- pdf-generator: toPngBuffer ahora convierte webp/otros a PNG con sharp, así el logo
(webp) tambien aparece en el PDF de la cotizacion.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Se agrega @page{margin:0} al HTML de la nota para que el navegador no inserte sus
encabezados/pies automaticos (URL de la cotizacion, fecha, no. de pagina) al imprimir
o guardar como PDF. El margen visual del contenido se pasa a padding del body.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
El filtro de estado por defecto pasa de "Todas" a "Por pagar", de modo que el
documento que se emite/manda al cliente ("Nota de horas por pagar") solo lista las
horas pendientes y nunca las ya pagadas. "Pagadas" (recibo) y "Todas" (estado de
cuenta) quedan como vistas internas seleccionables.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Permite separar las horas ya cobradas de las pendientes para que los totales no se
acumulen y poder emitir la nota por pagar al cliente y conservar el recibo de lo pagado.
- Modelo: RegistroHoras.estadoPago ("por_pagar" default | "pagada") + fechaPago
(se sella al marcar pagada, se limpia al revertir). Migración idempotente.
- API: PATCH /api/cotizaciones/[id]/horas/[registroId] para marcar por pagar/pagada.
- calculators: ESTADOS_PAGO_HORAS + resumenPagoHoras() que suma por separado
pendiente vs pagado.
- Panel: resumen "Por pagar" vs "Pagado" siempre visible (con IVA si aplica),
badge/toggle de estado por fila, filtro de estado (Por pagar/Pagadas/Todas) que
además define el documento: "Nota de horas por pagar", "Recibo de horas pagadas"
o "Estado de cuenta de horas".
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
La cotización UJ2606AG777 (aprobada, "Agentes IA") se cobra por hora bajo
demanda, pero la rama desplegada (main) no incluía la feature de registro de
horas que sí existía en desarrollo (rama master): el cargo de horas de junio no
se veía en la web y los proyectos por tiempo no mostraban sus cobros en el panel
individual.
Se porta la feature de forma quirúrgica (sin arrastrar cambios no relacionados):
- Modelo RegistroHoras + columnas Cotizacion.incluirIva, Cliente.rfc,
ServicioCotizado.beneficios (migración idempotente para la web).
- calculators.ts: modelo de cobro "demanda", helpers de horas
(calcularHorasRango, agrupación por día/semana/mes, notas de pago).
- RegistroHorasPanel: panel para registrar/editar/borrar horas y previsualizar
la nota de pago con branding. Solo aparece en cotizaciones aprobadas con cobro
por tiempo (horas/retainer/demanda).
- PreciosEditables: los servicios "demanda" muestran la tarifa/hr en vez de $0.
- API /api/cotizaciones/[id]/horas (+ /[registroId]) para el CRUD de registros.
- Handlers de cotizaciones: persisten incluirIva/rfc/beneficios, "demanda" nunca
suma al total, y fast-path para el cambio de estado (arregla CambiarEstado que
fallaba la validación al enviar solo { estado }).
- .megaignore para evitar que MegaSync corrompa node_modules/.next.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
El archivo raíz docker-compose.yaml (usado por Coolify como "Docker Compose
Location") tenía un BOM UTF-8 al inicio y comentarios con doble codificación.
El parser YAML de Symfony en Coolify fallaba al parsearlo
(Yaml::parse(): argument must be string, null given) dejando docker_compose
en null y abortando el despliegue. Ahora es una copia limpia (UTF-8 sin BOM)
de docker-compose.coolify.yml.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Coolify auto-injected env vars from an attached Postgres use DB_DATABASE,
but the cotizador code read DB_NAME. Now both are accepted (DB_DATABASE
takes priority) across db.ts, seed.ts, and prisma.config.ts so attaching a
managed Postgres in Coolify Just Works.
Coolify's dockercompose build pack looks for /docker-compose.yaml by default.
The repo had /docker-compose.coolify.yml, causing deploy to fail with
'Docker Compose file not found at: /docker-compose.yaml'. Adding the expected
filename as a copy of docker-compose.coolify.yml so default detection works.
En despliegues Dockerfile (Coolify) no existe DATABASE_URL y
prisma migrate deploy abortaba al arrancar. Ahora la URL se arma
desde las mismas DB_* que usan la app y el seed.
Co-Authored-By: Claude Fable 5 <[email protected]>