This commit is contained in:
2025-10-16 19:49:50 +00:00
parent ba6b4fef4f
commit c4097bc737
119 changed files with 3765 additions and 14390 deletions
+50
View File
@@ -0,0 +1,50 @@
// packages/api/v1/routes/comandas.mjs
import { Router } from 'express';
import { tenantContext } from '@suitecoffee/middlewares';
import { listarComandas, detalleComanda, actualizarComanda, eliminarComanda } from './handlers/comandasHand.mjs';
const comandasApiRouter = Router();
comandasApiRouter.use(tenantContext);
// Colección
comandasApiRouter.route('/comandas').get(listarComandas);
// Recurso
comandasApiRouter.route('/comandas/:id').get(detalleComanda)
.put(actualizarComanda)
.delete(eliminarComanda);
export default comandasApiRouter;
// ----------------------------------------------------------
// API Comandas
/*
Escalabilidad: si luego agregás PUT /comandas/:id o DELETE /comandas/:id,
lo hacés en la misma ruta encadenando métodos:
router
.route('/comandas/:id')
.get(detalleComanda)
.put(actualizarComanda)
.delete(eliminarComanda);
Middleware común: podrías usar .all(requireAuth) o .all(validarTenant) si necesitás autenticación o contexto del tenant.
*/
// ----------------------------------------------------------
/*
router.route('/comandas').get(listarComandas); // GET /comandas
// router.route('/comandas/:id').get(detalleComanda); // GET /comandas/:id
// router.route('/comandas/:id/abrir').post(abrirComanda); // POST /comandas/:id/abrir
// router.route('/comandas/:id/cerrar').post(cerrarComanda); // POST /comandas/:id/cerrar
// Recurso
router.route('/comandas/:id')
.get(detalleComanda) // GET /comandas/:id
.put(actualizarComanda) // PUT /comandas/:id (accion: abrir|cerrar|restaurar) o patch genérico
.delete(eliminarComanda); // DELETE /comandas/:id -> borrado lógico (eliminada=true)
*/
@@ -0,0 +1,91 @@
// packages/api/v1/routes/handlers/comandas.js
import {
listComandas,
getDetalleItems,
abrirComanda,
cerrarComanda,
restaurarComanda,
eliminarComanda as eliminarComandaRepo,
patchComanda
} from '../../repositories/comandasRepo.mjs';
const asBoolean = (v) => {
const s = (v ?? '').toString().trim().toLowerCase();
return s === 'true' ? true : s === 'false' ? false : null;
};
export async function listarComandas(req, res, next) {
try {
const abierta = asBoolean(req.query.abierta);
const limit = req.query.limit;
const rows = await listComandas({ schema: req.tenant.schema, abierta, limit });
res.json(rows);
} catch (e) { next(e); }
}
export async function detalleComanda(req, res, next) {
try {
const id = parseId(req.params.id);
const rows = await getDetalleItems({ schema: req.tenant.schema, id });
res.json(rows);
} catch (e) { next(e); }
}
export async function actualizarComanda(req, res, next) {
try {
const id = parseId(req.params.id);
const { accion, ...patch } = req.body || {};
if (accion === 'abrir') {
const data = await abrirComanda({ schema: req.tenant.schema, id });
return data ? res.json(data) : res.status(404).json({ error: 'Comanda no encontrada' });
}
if (accion === 'cerrar') {
const data = await cerrarComanda({ schema: req.tenant.schema, id });
return data ? res.json(data) : res.status(404).json({ error: 'Comanda no encontrada' });
}
if (accion === 'restaurar') {
const data = await restaurarComanda({ schema: req.tenant.schema, id });
return data ? res.json(data) : res.status(404).json({ error: 'Comanda no encontrada' });
}
const result = await patchComanda({ schema: req.tenant.schema, id, payload: patch });
if (!result) return res.status(404).json({ error: 'Comanda no encontrada' });
if (result?.error) return res.status(400).json({ error: result.error });
res.json(result);
} catch (e) {
if (e?.http?.status) return res.status(e.http.status).json({ error: e.message });
// PG codes comunes
if (e?.code === '23503') return res.status(409).json({ error: 'Violación de clave foránea', detail: e.detail });
if (e?.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
if (e?.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
if (e?.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
next(e);
}
}
export async function eliminarComanda(req, res, next) {
try {
const id = parseId(req.params.id);
const data = await eliminarComandaRepo({ schema: req.tenant.schema, id });
return data ? res.json(data) : res.status(404).json({ error: 'Comanda no encontrada' });
} catch (e) {
if (e?.http?.status) return res.status(e.http.status).json({ error: e.message });
if (e?.code === '23503') return res.status(409).json({ error: 'Violación de clave foránea', detail: e.detail });
if (e?.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
if (e?.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
if (e?.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
next(e);
}
}
function parseId(value) {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
const err = new Error('id inválido');
err.http = { status: 400 };
throw err;
}
return id;
}
+449
View File
@@ -0,0 +1,449 @@
// packages/api/v1/routes/routes.js
import { Router } from 'express';
import { withTenantClient, tquery } from '../repositories/db.mjs'
import { listarComandas, detalleComanda, actualizarComanda, eliminarComanda } from './handlers/comandasHand.mjs';
import { loadColumns, loadForeignKeys, loadPrimaryKey, pickLabelColumn } from './utils/schemaInspector.mjs';
const router = Router();
const ALLOWED_TABLES = [
'roles', 'usuarios', 'usua_roles',
'categorias', 'productos',
'clientes', 'mesas',
'comandas', 'deta_comandas',
'proveedores', 'compras', 'deta_comp_producto',
'mate_primas', 'deta_comp_materias',
'prov_producto', 'prov_mate_prima',
'receta_producto', 'asistencia_resumen_diario',
'asistencia_intervalo', 'asistencia_detalle',
'vw_compras'
];
const VALID_IDENT = /^[a-z_][a-z0-9_]*$/i;
const q = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
function ensureTable(name) {
if (!VALID_IDENT.test(name)) throw new Error('Identificador inválido');
if (!ALLOWED_TABLES.includes(name)) throw new Error('Tabla no permitida');
return name;
}
// ==========================================================
// Rutas de API v1
// ==========================================================
// ----------------------------------------------------------
// API Tablas
/*router.route('/tables').get( async (_req, res) => {
res.json(ALLOWED_TABLES);
});*/
// GET /api/schema/:table → columnas + foreign keys
/*router.get('/schema/:table', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const client = await poolTenants.getClient();
try {
const columns = await loadColumns(client, table);
const fks = await loadForeignKeys(client, table);
const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
res.json({ table, columns: enriched });
} finally {
client.release();
}
} catch (e) {
res.status(400).json({ error: e.message });
}
});*/
// GET /api/options/:table/:column → opciones FK
/*router.get('/options/:table/:column', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const column = req.params.column;
if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
const client = await poolTenants.getClient();
try {
const fks = await loadForeignKeys(client, table);
const fk = fks[column];
if (!fk) return res.json([]);
const refTable = fk.foreign_table;
const refId = fk.foreign_column;
const labelCol = await pickLabelColumn(client, refTable);
const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label
FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
const result = await client.query(sql);
res.json(result.rows);
} finally {
client.release();
}
} catch (e) {
res.status(400).json({ error: e.message });
}
});*/
// GET /api/table/:table → preview de datos
/*router.get('/table/:table', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
await withTenantClient(req, async (client) => {
const pks = await loadPrimaryKey(client, table);
const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
const { rows } = await client.query(sql);
res.json(rows);
});
} catch (e) {
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
}
});*/
// POST /api/table/:table → insertar fila
/*router.post('/table/:table', async (req, res) => {
const table = ensureTable(req.params.table);
const payload = req.body || {};
try {
const client = await poolTenants.getClient();
try {
const columns = await loadColumns(client, table);
const insertable = columns.filter(c =>
!c.is_primary &&
!c.is_identity &&
!(c.column_default || '').startsWith('nextval(')
);
const allowedCols = new Set(insertable.map(c => c.column_name));
const cols = [];
const vals = [];
const params = [];
let idx = 1;
for (const [k, v] of Object.entries(payload)) {
if (!allowedCols.has(k)) continue;
if (!VALID_IDENT.test(k)) continue;
cols.push(q(k));
vals.push(`$${idx++}`);
params.push(v);
}
let rows;
if (!cols.length) {
({ rows } = await client.query(
`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`
));
} else {
({ rows } = await client.query(
`INSERT INTO ${q(table)} (${cols.join(', ')})
VALUES (${vals.join(', ')}) RETURNING *`,
params
));
}
res.status(201).json({ inserted: rows[0] });
} catch (e) {
if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
throw e;
} finally {
client.release();
}
} catch (e) {
res.status(400).json({ error: e.message });
}
});
*/
// ----------------------------------------------------------
// API Productos
// ----------------------------------------------------------
// GET producto + receta
/*router.route('/rpc/get_producto/:id').get( async (req, res) => {
const client = await poolTenants.getClient()
const id = Number(req.params.id);
const { rows } = await client.query('SELECT public.get_producto($1) AS data', [id]);
res.json(rows[0]?.data || {});
client.release();
});*/
// POST guardar producto + receta
/*router.route('/rpc/save_producto').post(async (req, res) => {
try {
// console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
const client = await poolTenants.getClient()
const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
const { rows } = await client.query(q, params);
res.json(rows[0] || {});
client.release();
} catch(e) {
console.error(e);
res.status(500).json({ error: 'save_producto failed' });
}
});*/
// ----------------------------------------------------------
// API Materias Primas
// ----------------------------------------------------------
// GET MP + proveedores
/*router.route('/rpc/get_materia/:id').get(async (req, res) => {
const id = Number(req.params.id);
try {
const client = await poolTenants.getClient()
const { rows } = await client.query('SELECT public.get_materia_prima($1) AS data', [id]);
res.json(rows[0]?.data || {});
client.release();
} catch (e) {
console.error(e);
res.status(500).json({ error: 'get_materia failed' });
}
});
// SAVE MP + proveedores (array)
router.route('/rpc/save_materia').post( async (req, res) => {
const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {};
try {
const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])];
const { rows } = await poolTenants.query(q, params);
res.json(rows[0] || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'save_materia failed' });
}
});
// ----------------------------------------------------------
// API Usuarios y Asistencias
// ----------------------------------------------------------
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
router.route('/rpc/find_usuarios_por_documentos').post( async (req, res) => {
try {
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
const { rows } = await poolTenants.query(sql, [JSON.stringify(docs)]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
}
});
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
router.route('/rpc/import_asistencia').post( async (req, res) => {
try {
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
const origen = req.body?.origen || null;
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
const { rows } = await poolTenants.query(sql, [JSON.stringify(registros), origen]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'import_asistencia failed' });
}
});
// Consultar datos de asistencia (raw + pares) para un usuario y rango
router.route('/rpc/asistencia_get').post( async (req, res) => {
try {
const { doc, desde, hasta } = req.body || {};
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
const { rows } = await poolTenants.query(sql, [doc, desde, hasta]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
}
});
// Editar un registro crudo y recalcular pares
router.route('/rpc/asistencia_update_raw').post( async (req, res) => {
try {
const { id_raw, fecha, hora, modo } = req.body || {};
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
const { rows } = await poolTenants.query(sql, [id_raw, fecha, hora, modo ?? null]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
}
});
// Eliminar un registro crudo y recalcular pares
router.route('/rpc/asistencia_delete_raw').post( async (req, res) => {
try {
const { id_raw } = req.body || {};
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
const { rows } = await poolTenants.query(sql, [id_raw]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
}
});
// ----------------------------------------------------------
// API Reportes
// ----------------------------------------------------------
// POST /api/rpc/report_tickets { year }
router.route('/rpc/report_tickets').post( async (req, res) => {
try {
const y = parseInt(req.body?.year ?? req.query?.year, 10);
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
? y
: (new Date()).getFullYear();
const { rows } = await poolTenants.query(
'SELECT public.report_tickets_year($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_tickets error:', e);
res.status(500).json({
error: 'report_tickets failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
router.route('/rpc/report_asistencia').post( async (req, res) => {
try {
let { desde, hasta } = req.body || {};
// defaults si vienen vacíos/invalidos
const re = /^\d{4}-\d{2}-\d{2}$/;
if (!re.test(desde) || !re.test(hasta)) {
const end = new Date();
const start = new Date(end); start.setDate(end.getDate()-30);
desde = start.toISOString().slice(0,10);
hasta = end.toISOString().slice(0,10);
}
const { rows } = await poolTenants.query(
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_asistencia error:', e);
res.status(500).json({
error: 'report_asistencia failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// ----------------------------------------------------------
// API Compras y Gastos
// ----------------------------------------------------------
// Guardar (insert/update)
router.route('/rpc/save_compra').post( async (req, res) => {
try {
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
const { rows } = await poolTenants.query(sql, args);
res.json(rows[0]); // { id_compra, total }
} catch (e) {
console.error('save_compra error:', e);
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
}
});
// Obtener para editar
router.route('/rpc/get_compra').post( async (req, res) => {
try {
const { id_compra } = req.body || {};
const sql = `SELECT public.get_compra($1::int) AS data`;
const { rows } = await poolTenants.query(sql, [id_compra]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'get_compra failed' });
}
});
// Eliminar
router.route('/rpc/delete_compra').post( async (req, res) => {
try {
const { id_compra } = req.body || {};
await poolTenants.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
res.json({ ok: true });
} catch (e) {
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
}
});
// POST /api/rpc/report_gastos { year: 2025 }
router.route('/rpc/report_gastos').post( async (req, res) => {
try {
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
const { rows } = await poolTenants.query(
'SELECT public.report_gastos($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_gastos error:', e);
res.status(500).json({
error: 'report_gastos failed',
message: e.message, detail: e.detail, code: e.code
});
}
});*/
export default router;
@@ -0,0 +1,76 @@
// services/app/src/api/v1/routes/utils/schemaInspector.mjs
// Utilidades para inspeccionar columnas, claves y relaciones en PostgreSQL.
export async function loadColumns(client, table) {
const sql = `
SELECT
c.column_name,
c.data_type,
c.is_nullable = 'YES' AS is_nullable,
c.column_default,
(SELECT EXISTS (
SELECT 1 FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
)) AS is_primary,
(SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
WHERE t.relname = $1 AND a.attname = c.column_name
) AS is_identity
FROM information_schema.columns c
WHERE c.table_schema='public' AND c.table_name=$1
ORDER BY c.ordinal_position
`;
const { rows } = await client.query(sql, [table]);
return rows;
}
export async function loadForeignKeys(client, table) {
const sql = `
SELECT
kcu.column_name,
ccu.table_name AS foreign_table,
ccu.column_name AS foreign_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY'
`;
const { rows } = await client.query(sql, [table]);
const map = {};
for (const r of rows)
map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
return map;
}
export async function loadPrimaryKey(client, table) {
const sql = `
SELECT a.attname AS column_name
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
JOIN pg_class t ON t.oid = i.indrelid
WHERE t.relname = $1 AND i.indisprimary
`;
const { rows } = await client.query(sql, [table]);
return rows.map(r => r.column_name);
}
export async function pickLabelColumn(client, refTable) {
const preferred = ['nombre','raz_social','apodo','documento','correo','telefono'];
const { rows } = await client.query(
`SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema='public' AND table_name=$1
ORDER BY ordinal_position`, [refTable]
);
for (const cand of preferred)
if (rows.find(r => r.column_name === cand)) return cand;
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
if (textish) return textish.column_name;
return rows[0]?.column_name || 'id';
}