<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary Implementa el módulo **Programa de obra** por proyecto, vinculado al presupuesto ya existente. ### Flujo 1. Cargar presupuesto (requisito) 2. Definir fechas de inicio/término en el proyecto 3. Crear programa: **importar Excel Neodata/Opus**, **generar automático** o **crear vacío** 4. Editar en cuadrícula (conceptos con `Barra=` y partidas con erogaciones semanales) 5. Visualizar Gantt resumido y comparativo **programado vs ejecutado** ### Backend - Tablas: `work_programs`, `work_program_periods`, `work_program_concept_slots`, `work_program_partida_amounts`, `work_program_progress` - RPC: list/get, replace (import), generate (wizard), update celdas, recalculate partidas, vs-cost - Parser Excel para formatos **por concepto** (`Barra=`) y **por partida** (montos semanales) - Tests con fixtures reales del usuario ### Frontend - Nueva página `/programa-obra` con pestañas: Conceptos, Partidas, Gantt, Programado vs costos - Diálogos de importación y generación automática - Menú principal, enlace en ficha de proyecto, banner en presupuesto ### Permisos Reutiliza `manage_budget` en MVP. ## Test plan - [x] `deno test api/work_program_excel_test.ts` (5 tests) - [x] `deno check` API modules - [x] `npm run build` web-panel - [ ] Migrar DB (`029`, `030`) en entorno con Postgres - [ ] Importar Excel por concepto y por partida en obra con presupuesto - [ ] Generar automático con estrategia keyword - [ ] Recalcular partidas desde conceptos - [ ] Verificar tab Programado vs costos con gastos registrados <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-3985fe74-d26b-4506-bd4d-f9ad57374141?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-3985fe74-d26b-4506-bd4d-f9ad57374141&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div> |
||
|---|---|---|
| .. | ||
| backups | ||
| core | ||
| iam | ||
| platform | ||
| provision | ||
| bootstrap-tools.sh | ||
| README.md | ||
| RUNBOOK-corte.md | ||
| update.sh | ||
Base de datos (Postgres + Liquibase)
PANELS usa Postgres organizado como monolito modular: dos bases de datos físicamente separadas en el mismo servidor/instancia por ambiente.
| Base de datos | Esquema(s) | Changelog | Qué vive ahí |
|---|---|---|---|
panels_platform |
public |
db/platform/ |
Control plane SaaS: tenants, platform_users (identidad de PANELS como operador), smtp_settings. Solo lo tocan las rutas /v1/saas/*. |
panels_product |
iam |
db/iam/ |
Identidad/roles/permisos de cada tenant (ej. usuarios de ARCTEC). Aislado de core -- sin JOIN/FK cruzado. |
panels_product |
core |
db/core/ |
Negocio: empresas, personal, obras, expedientes, presupuesto, nómina, gafetes. |
No hay FK real entre panels_platform y panels_product (bases distintas),
ni entre los esquemas iam y core (aislamiento a propósito). tenant_id
es una referencia lógica validada en la capa de aplicación. Ver el plan de
migración para el detalle de la arquitectura y las reglas del monolito
modular.
Aprovisionamiento (una vez por ambiente, antes de Liquibase)
Ver db/provision/README.md: crea los 6 roles de
Postgres (panels_{platform,iam,core}_{owner,app}), las 2 bases de datos,
los esquemas iam/core, la extensión citext, y los usuarios ACL de
Redis. Incluye dev-local.sh para reproducir todo esto en una máquina de
desarrollo sin depender de Coolify, y verify-isolation.sh para confirmar
que el aislamiento realmente se cumple.
Aplicar migraciones
En Coolify no se corre a mano: el servicio migrate de docker-compose.yml
aplica all --context-filter=!dev en cada deploy, antes de levantar api.
A mano (Java 17+ y credenciales _owner, nunca _app):
# Local, después de correr db/provision/dev-local.sh:
set -a && source .env.dev-local && set +a
./db/update.sh all --context-filter=dev # dev: incluye datos de demo
./db/update.sh all --context-filter='!dev' # staging/producción: solo esquema + catálogos
# Un solo módulo
./db/update.sh core --context-filter='!dev'
./db/update.sh iam --context-filter='!dev'
./db/update.sh platform --context-filter='!dev'
Importante: Liquibase, si NO se le pasa --context-filter, corre TODOS
los changesets sin importar su context -- incluidos los de context="dev".
No pasar el filtro en staging/producción NO es "modo seguro por defecto",
es lo contrario: cargaría el tenant/empresas/proyecto de demostración. Por
eso --context-filter es obligatorio siempre, nunca opcional.
npm run db:migrate corre ./db/update.sh all. Las migraciones ya NO
corren automáticamente al arrancar la API -- son un paso explícito de
deploy (a diferencia del runLiquibase() que existía con SQLite).
Contexts de Liquibase:
- Sin
context: changesets de esquema y catálogos requeridos (risk_levels,document_types, etc.) -- corren en todos los ambientes. context="dev": datos de demostración (tenant/empresas/proyecto ARCTEC) -- nunca en staging/producción. Siempre pasar--context-filterexplícito; no confiar en el default de Liquibase.
Nuevos cambios de schema
Un changeset nuevo en db/{platform,iam,core}/changesets/, referenciado en
el changelog-master.xml correspondiente. Reglas:
- Nunca editar un changeset ya aplicado en un ambiente compartido -- crear uno nuevo.
- Usar
dbms:postgresqlen vez decontextpara lógica específica de motor. - No añadir migraciones directamente en
api/*.ts. - Las herramientas (Liquibase + driver JDBC de Postgres) viven en
db/tools/(gitignored);bootstrap-tools.shlas descarga.
Bootstrap del primer usuario administrador
El password_hash (PBKDF2) no lo puede generar un changeset SQL. El primer
platform_admin y el primer tenant_admin de un tenant nuevo se crean con
scripts/bootstrap-admin.ts (ver Fase 4 del plan de migración), no con
lógica de seed en el arranque de la API ni con datos hardcodeados en
Liquibase.
Funciones RPC (core.fn_*)
A partir del changeset 006-rpc-infra.sql, el esquema core expone la
lógica de negocio como funciones PostgreSQL invocadas desde la API con
SELECT core.fn_nombre($1::jsonb). La capa TypeScript no debe usar
db.prepare() contra tablas de core en rutas de negocio; solo
api/rpc.ts ejecuta el SELECT del RPC.
Envelope de respuesta (BD)
Toda función devuelve un jsonb con esta forma:
{
"ok": true,
"code": "OK",
"layer": "db",
"message": "Mensaje detallado en español",
"context": { "fn": "fn_empresa_get", "id": 5 },
"data": { },
"errors": null
}
code: código de negocio (OK,CREATED,VALIDATION,NOT_FOUND,CONFLICT,INTERNAL, …).layer: siempre"db"desde Postgres.message: texto legible y específico (nunca genérico).context: metadatos seguros para depuración (nombre de función, ids).data: payload de éxito;errors: mapa de campos en validación.
Helpers en 006-rpc-infra.sql: rpc_ok, rpc_err, rpc_created,
rpc_from_exception.
Contrato API
api/http_errors.ts mapea code → HTTP y añade
status al body. respondRpc() preserva layer y message de la BD;
respondApiError() construye envelopes de capa "api".
Convenciones al añadir funciones
- Un solo parámetro
payload jsonb. - Prefijo
fn_para funciones que devuelven envelope. SECURITY INVOKERySET search_path = core.- Errores de negocio con
RETURN core.rpc_err(...); constraints de Postgres capturados enEXCEPTIONy traducidos a mensajes claros. GRANT EXECUTE ... TO panels_core_appen el mismo changeset.- Comentario de ejemplo
SELECT core.fn_*(...)en el SQL.
Changesets RPC: 006 (infra) … 015 (documentos). Ver
changelog-master.xml.