mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 19:03:17 +00:00
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary
Migrates the `core` schema business logic from inline `db.prepare()` calls in the API to PostgreSQL RPC functions (`core.fn_*`) with a unified JSON envelope for errors and HTTP status mapping.
### Database (Liquibase changesets 006–017)
- **006** — RPC infra: `rpc_ok`, `rpc_err`, `rpc_created`, `rpc_from_exception`
- **007** — Catalogs and tenant settings
- **008** — Companies CRUD
- **009** — Projects, checklists, document lists
- **010** — Workers CRUD, pipeline, checklist, assign
- **011** — Budget CRUD + `fn_budget_replace`
- **012** — Badge jobs
- **013** — Payroll (settings, attendance, loans, weeks, destajo)
- **014** — Worker import batch + document store
- **015** — Project/company/worker document metadata RPCs
- **016–017** — Fixes: `needs_badge` default on worker create; Liquibase `splitStatements:false` on function changesets
### API
- `api/rpc.ts` — `callCoreFn()`, `RpcCallError` (jsonb payload fix: pass JS object, not `JSON.stringify`)
- `api/http_errors.ts` — `mapRpcToStatus()`, `respondRpc()`, `respondApiError()`, `onAppError()`
- Refactored: `main.ts`, `companies.ts`, `db.ts`, `budget.ts`, `excel.ts`, `payroll.ts`, `payroll_http.ts`
- Front helpers: `web-panel/composables/api-response.ts`, `web-saas/composables/api-response.ts`
### Envelope contract
DB functions return `{ ok, code, layer: "db", message, context, data, errors }`. The API adds `status` (HTTP code) via `respondRpc()` / `respondApiError()`.
### Out of scope
`iam`, `platform`, `saas.ts`, auth/sessions, S3, PDF generation, Excel parsing, and bootstrap scripts still use direct SQL where appropriate.
## Test plan
- [x] `deno check main.ts` — compila sin errores de tipos
- [x] `npm run build` — web-panel y web-saas compilan
- [x] `deno test` — 25 tests unitarios (http_errors, companies, budget, mx, document_validity)
- [x] Liquibase migrations `006`–`017` aplicadas en Postgres local (`--context-filter=dev`)
- [x] API levantada localmente; `/v1/health` OK
- [x] Smoke CRUD vía `scripts/crud-smoke-test.sh`: empresas, proyectos, trabajadores, catálogos (create/get/list/patch)
- [ ] Import Excel de trabajadores (flujo multipart + S3/local storage)
- [ ] Import presupuesto desde Excel
- [ ] Flujo nómina: asistencia → cerrar semana
- [ ] CI en el remoto (sin checks reportados aún)
<!-- CURSOR_AGENT_PR_BODY_END -->
<div><a href="https://cursor.com/agents/bc-06667c14-38e8-42a8-9ed9-6b1322f12ae7?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-06667c14-38e8-42a8-9ed9-6b1322f12ae7&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>
932 lines
30 KiB
TypeScript
932 lines
30 KiB
TypeScript
import * as XLSX from "xlsx";
|
|
import type { Db } from "./db.ts";
|
|
import { callCoreFn, RpcCallError } from "./rpc.ts";
|
|
|
|
export const BUDGET_IVA = 0.16;
|
|
|
|
export const BUDGET_COLUMNS = [
|
|
"WBS",
|
|
"TIPO",
|
|
"CODIGO",
|
|
"DESCRIPCION",
|
|
"UNIDAD",
|
|
"CANTIDAD",
|
|
"PRECIO",
|
|
] as const;
|
|
|
|
const GROUP_TYPES = new Set(["lote", "capitulo", "capítulo", "partida", "titulo", "título", "group", "capitulo"]);
|
|
const ITEM_TYPES = new Set(["concepto", "item", "partida-concepto"]);
|
|
|
|
const HEADER_ALIASES: Record<string, string> = {
|
|
WBS: "WBS",
|
|
NIVEL: "WBS",
|
|
TIPO: "TIPO",
|
|
TYPE: "TIPO",
|
|
KIND: "TIPO",
|
|
CAPITULO: "CAPITULO",
|
|
CAPÍTULO: "CAPITULO",
|
|
CAP: "CAPITULO",
|
|
CLAVE: "CLAVE",
|
|
CODIGO: "CLAVE",
|
|
CÓDIGO: "CLAVE",
|
|
CODE: "CLAVE",
|
|
DESCRIPCION: "DESCRIPCION",
|
|
DESCRIPCIÓN: "DESCRIPCION",
|
|
CONCEPT: "DESCRIPCION",
|
|
CONCEPTO: "DESCRIPCION",
|
|
PARTIDA: "DESCRIPCION",
|
|
UNIDAD: "UNIDAD",
|
|
UD: "UNIDAD",
|
|
U: "UNIDAD",
|
|
CANTIDAD: "CANTIDAD",
|
|
CANT: "CANTIDAD",
|
|
QTY: "CANTIDAD",
|
|
PRECIO: "PRECIO",
|
|
"PRECIO U": "PRECIO",
|
|
"PRECIO U.": "PRECIO",
|
|
"P.U.": "PRECIO",
|
|
"P.U": "PRECIO",
|
|
PU: "PRECIO",
|
|
"PRECIO UNITARIO": "PRECIO",
|
|
"P. UNITARIO": "PRECIO",
|
|
"P UNITARIO": "PRECIO",
|
|
IMPORTE: "IMPORTE",
|
|
TOTAL: "IMPORTE",
|
|
MONTO: "IMPORTE",
|
|
};
|
|
|
|
export type ParsedGroup = {
|
|
kind: "lote" | "capitulo" | "partida";
|
|
wbs: string;
|
|
parentWbs: string;
|
|
code: string;
|
|
name: string;
|
|
};
|
|
|
|
export type ParsedBudgetItem = {
|
|
chapter: string;
|
|
chapterCode: string;
|
|
wbs: string;
|
|
parentWbs: string;
|
|
code: string;
|
|
description: string;
|
|
unit: string;
|
|
quantity: number;
|
|
unit_price: number;
|
|
amount: number;
|
|
};
|
|
|
|
export type ParsedBudget = {
|
|
format: "wbs" | "opus" | "flat" | "none";
|
|
groups: ParsedGroup[];
|
|
items: ParsedBudgetItem[];
|
|
skipped: number;
|
|
};
|
|
|
|
export type BudgetPreviewNode = {
|
|
wbs: string;
|
|
code: string;
|
|
name: string;
|
|
kind: ParsedGroup["kind"];
|
|
amount: number;
|
|
item_count: number;
|
|
children: BudgetPreviewNode[];
|
|
};
|
|
|
|
export type BudgetPreview = {
|
|
format: ParsedBudget["format"];
|
|
format_label: string;
|
|
sheet: string;
|
|
tree: BudgetPreviewNode[];
|
|
items: {
|
|
code: string;
|
|
description: string;
|
|
unit: string;
|
|
quantity: number;
|
|
unit_price: number;
|
|
amount: number;
|
|
chapter: string;
|
|
parentWbs: string;
|
|
}[];
|
|
totals: { subtotal: number; iva: number; total: number; item_count: number };
|
|
skipped: number;
|
|
errors: { row: number; messages: string[] }[];
|
|
};
|
|
|
|
export type BudgetImportReport = {
|
|
replaced: boolean;
|
|
chapters: number;
|
|
inserted: number;
|
|
skipped: number;
|
|
errors: { row: number; messages: string[] }[];
|
|
totals?: { subtotal: number; iva: number; total: number; item_count: number };
|
|
contract_amount?: number;
|
|
};
|
|
|
|
export type BudgetTreeNode = {
|
|
id: number;
|
|
parent_id: number | null;
|
|
code: string;
|
|
name: string;
|
|
wbs: string;
|
|
amount: number;
|
|
item_count: number;
|
|
children: BudgetTreeNode[];
|
|
};
|
|
|
|
export type BudgetItemRow = {
|
|
id: number;
|
|
project_id: number;
|
|
chapter_id: number | null;
|
|
code: string;
|
|
description: string;
|
|
unit: string;
|
|
quantity: number;
|
|
unit_price: number;
|
|
amount: number;
|
|
sort_order: number;
|
|
wbs: string;
|
|
chapter_name: string | null;
|
|
chapter_code: string | null;
|
|
parent_path: string;
|
|
ancestor_ids: number[];
|
|
};
|
|
|
|
function normHeader(raw: unknown): string {
|
|
const k = String(raw ?? "")
|
|
.normalize("NFD")
|
|
.replace(/\p{M}/gu, "")
|
|
.toUpperCase()
|
|
.replace(/\s+/g, " ")
|
|
.replace(/\.$/, "")
|
|
.trim();
|
|
return HEADER_ALIASES[k] || k;
|
|
}
|
|
|
|
function fold(value: string) {
|
|
return value.normalize("NFD").replace(/\p{M}/gu, "").toUpperCase().replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
export function parseMoney(value: unknown): number {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
const raw = String(value ?? "").trim();
|
|
if (!raw) return 0;
|
|
const cleaned = raw.replace(/[$%\s]/g, "");
|
|
if (cleaned.includes(",") && cleaned.includes(".")) {
|
|
return Number(cleaned.replace(/,/g, "")) || 0;
|
|
}
|
|
if (cleaned.includes(",")) return Number(cleaned.replace(",", ".")) || 0;
|
|
return Number(cleaned) || 0;
|
|
}
|
|
|
|
function isSummaryLabel(value: string) {
|
|
const t = fold(value);
|
|
if (!t) return false;
|
|
if (/^\(.*\*.*\)$/.test(t) && /(PESOS|MILLON|M\.?\s*N\.?)/.test(t)) return true;
|
|
return /(SUB\s*TOTAL|^TOTAL\b|GRAN TOTAL|\bIVA\b|SIN IVA)/.test(t);
|
|
}
|
|
|
|
function rowHasNumbers(row: unknown[]) {
|
|
return row.slice(0, 6).some((cell, i) => i >= 2 && parseMoney(cell) !== 0);
|
|
}
|
|
|
|
function isGroupCode(code: string) {
|
|
const t = fold(code);
|
|
return /^[A-Z]$/.test(t) || /^[A-Z]\d{2,4}$/.test(t);
|
|
}
|
|
|
|
function padChild(parent: string, index: number) {
|
|
const part = String(index).padStart(parent ? 2 : 1, "0");
|
|
if (!parent) return String(index);
|
|
return `${parent}.${part}`;
|
|
}
|
|
|
|
function parentOfWbs(wbs: string) {
|
|
const i = wbs.lastIndexOf(".");
|
|
return i < 0 ? "" : wbs.slice(0, i);
|
|
}
|
|
|
|
function tipoOf(raw: string): "group" | "item" | "" {
|
|
const t = fold(raw).toLowerCase();
|
|
if (!t) return "";
|
|
if (ITEM_TYPES.has(t) || t === "concepto") return "item";
|
|
if (GROUP_TYPES.has(t) || t === "lote" || t === "capitulo" || t === "partida") return "group";
|
|
return "";
|
|
}
|
|
|
|
function groupKind(raw: string, depth: number): ParsedGroup["kind"] {
|
|
const t = fold(raw).toLowerCase();
|
|
if (t === "lote") return "lote";
|
|
if (t === "partida") return "partida";
|
|
if (depth <= 1) return "lote";
|
|
if (depth === 2) return "capitulo";
|
|
return "partida";
|
|
}
|
|
|
|
type ColMap = Record<string, number>;
|
|
|
|
function findHeader(rows: unknown[][]): { headerAt: number; map: ColMap } {
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const headers = rows[i].map(normHeader);
|
|
const map: ColMap = {
|
|
WBS: headers.indexOf("WBS"),
|
|
TIPO: headers.indexOf("TIPO"),
|
|
CAPITULO: headers.indexOf("CAPITULO"),
|
|
CLAVE: headers.indexOf("CLAVE"),
|
|
DESCRIPCION: headers.indexOf("DESCRIPCION"),
|
|
UNIDAD: headers.indexOf("UNIDAD"),
|
|
CANTIDAD: headers.indexOf("CANTIDAD"),
|
|
PRECIO: headers.indexOf("PRECIO"),
|
|
IMPORTE: headers.indexOf("IMPORTE"),
|
|
};
|
|
if (map.WBS >= 0 && (map.TIPO >= 0 || map.DESCRIPCION >= 0)) return { headerAt: i, map };
|
|
if (map.DESCRIPCION >= 0 && (map.CLAVE >= 0 || map.CANTIDAD >= 0 || map.PRECIO >= 0)) {
|
|
return { headerAt: i, map };
|
|
}
|
|
}
|
|
return { headerAt: -1, map: {} };
|
|
}
|
|
|
|
function cell(row: unknown[], map: ColMap, key: string) {
|
|
const i = map[key];
|
|
return i >= 0 ? row[i] : "";
|
|
}
|
|
|
|
function looksOpus(rows: unknown[][], headerAt: number, map: ColMap) {
|
|
for (let r = headerAt + 1; r < Math.min(rows.length, headerAt + 40); r++) {
|
|
const code = String(cell(rows[r] || [], map, "CLAVE") ?? "").trim();
|
|
const unit = String(cell(rows[r] || [], map, "UNIDAD") ?? "").trim();
|
|
const qty = parseMoney(cell(rows[r] || [], map, "CANTIDAD"));
|
|
const price = parseMoney(cell(rows[r] || [], map, "PRECIO"));
|
|
if (isGroupCode(code) && !unit && qty === 0 && price === 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function parseWbsRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<ParsedBudget, "format"> {
|
|
const groups: ParsedGroup[] = [];
|
|
const items: ParsedBudgetItem[] = [];
|
|
let skipped = 0;
|
|
const names = new Map<string, { name: string; code: string }>();
|
|
|
|
for (let r = headerAt + 1; r < rows.length; r++) {
|
|
const row = rows[r] || [];
|
|
const values = row.map((v) => String(v ?? "").trim());
|
|
if (values.every((v) => !v)) continue;
|
|
if (values.some((value) => isSummaryLabel(value))) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
const wbs = String(cell(row, map, "WBS") ?? "").trim();
|
|
const tipoRaw = String(cell(row, map, "TIPO") ?? "").trim();
|
|
const code = String(cell(row, map, "CLAVE") ?? "").trim();
|
|
const description = String(cell(row, map, "DESCRIPCION") ?? "").trim();
|
|
const unit = String(cell(row, map, "UNIDAD") ?? "").trim();
|
|
const qty = parseMoney(cell(row, map, "CANTIDAD"));
|
|
const price = parseMoney(cell(row, map, "PRECIO"));
|
|
const importe = parseMoney(cell(row, map, "IMPORTE"));
|
|
if (!wbs && !code && !description) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
const depth = wbs ? wbs.split(".").length : 1;
|
|
let kind = tipoOf(tipoRaw);
|
|
if (!kind) kind = unit || qty || price ? "item" : "group";
|
|
const parentWbs = wbs ? parentOfWbs(wbs) : "";
|
|
if (kind === "group") {
|
|
const name = description || code || "Capítulo";
|
|
const node: ParsedGroup = {
|
|
kind: groupKind(tipoRaw, depth),
|
|
wbs: wbs || String(groups.length + 1),
|
|
parentWbs,
|
|
code,
|
|
name,
|
|
};
|
|
groups.push(node);
|
|
names.set(node.wbs, { name, code });
|
|
continue;
|
|
}
|
|
const parent = names.get(parentWbs);
|
|
items.push({
|
|
chapter: parent?.name || "General",
|
|
chapterCode: parent?.code || "",
|
|
wbs: wbs || padChild(parentWbs, items.length + 1),
|
|
parentWbs,
|
|
code,
|
|
description: description || code,
|
|
unit,
|
|
quantity: qty,
|
|
unit_price: price,
|
|
amount: Math.round((qty * price || importe) * 100) / 100,
|
|
});
|
|
}
|
|
return { groups, items, skipped };
|
|
}
|
|
|
|
function parseOpusRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<ParsedBudget, "format"> {
|
|
const groups: ParsedGroup[] = [];
|
|
const items: ParsedBudgetItem[] = [];
|
|
let skipped = 0;
|
|
let loteWbs = "";
|
|
let chapterWbs = "";
|
|
let loteIndex = 0;
|
|
let chapterIndex = 0;
|
|
let itemIndex = 0;
|
|
let lastItem: ParsedBudgetItem | null = null;
|
|
const groupNames = new Map<string, { name: string; code: string }>();
|
|
|
|
for (let r = headerAt + 1; r < rows.length; r++) {
|
|
const row = rows[r] || [];
|
|
const values = row.map((v) => String(v ?? "").trim());
|
|
if (values.every((v) => !v)) continue;
|
|
const code = String(cell(row, map, "CLAVE") ?? "").trim();
|
|
const description = String(cell(row, map, "DESCRIPCION") ?? "").trim();
|
|
const unit = String(cell(row, map, "UNIDAD") ?? "").trim();
|
|
const qty = parseMoney(cell(row, map, "CANTIDAD"));
|
|
const price = parseMoney(cell(row, map, "PRECIO"));
|
|
const importe = parseMoney(cell(row, map, "IMPORTE"));
|
|
if ([code, description].some((value) => isSummaryLabel(value)) || values.some((value) => isSummaryLabel(value))) {
|
|
skipped++;
|
|
lastItem = null;
|
|
continue;
|
|
}
|
|
if (!code && description && !unit && qty === 0 && price === 0 && importe === 0) {
|
|
if (lastItem) {
|
|
lastItem.description = `${lastItem.description} ${description}`.replace(/\s+/g, " ").trim();
|
|
} else {
|
|
skipped++;
|
|
}
|
|
continue;
|
|
}
|
|
if (code && !unit && qty === 0 && price === 0 && importe === 0 && (isGroupCode(code) || !rowHasNumbers(row))) {
|
|
lastItem = null;
|
|
if (isGroupCode(code) && /^[A-Z]$/i.test(fold(code))) {
|
|
loteIndex += 1;
|
|
chapterIndex = 0;
|
|
itemIndex = 0;
|
|
loteWbs = String(loteIndex);
|
|
chapterWbs = loteWbs;
|
|
const node: ParsedGroup = {
|
|
kind: "lote",
|
|
wbs: loteWbs,
|
|
parentWbs: "",
|
|
code,
|
|
name: description || code,
|
|
};
|
|
groups.push(node);
|
|
groupNames.set(node.wbs, { name: node.name, code: node.code });
|
|
} else {
|
|
chapterIndex += 1;
|
|
itemIndex = 0;
|
|
const parent = loteWbs || "";
|
|
if (!loteWbs) {
|
|
loteIndex += 1;
|
|
loteWbs = String(loteIndex);
|
|
}
|
|
chapterWbs = padChild(parent || loteWbs, chapterIndex);
|
|
const node: ParsedGroup = {
|
|
kind: "capitulo",
|
|
wbs: chapterWbs,
|
|
parentWbs: loteWbs && chapterWbs !== loteWbs ? loteWbs : "",
|
|
code,
|
|
name: description || code,
|
|
};
|
|
groups.push(node);
|
|
groupNames.set(node.wbs, { name: node.name, code: node.code });
|
|
}
|
|
continue;
|
|
}
|
|
if (!description && !code) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
if (!code && !unit && qty === 0 && price === 0) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
itemIndex += 1;
|
|
const parentWbs = chapterWbs || loteWbs;
|
|
const parent = groupNames.get(parentWbs);
|
|
const item: ParsedBudgetItem = {
|
|
chapter: parent?.name || "General",
|
|
chapterCode: parent?.code || "",
|
|
wbs: padChild(parentWbs, itemIndex),
|
|
parentWbs,
|
|
code,
|
|
description: description || code,
|
|
unit,
|
|
quantity: qty,
|
|
unit_price: price,
|
|
amount: Math.round((qty * price || importe) * 100) / 100,
|
|
};
|
|
items.push(item);
|
|
lastItem = item;
|
|
}
|
|
return { groups, items, skipped };
|
|
}
|
|
|
|
function parseFlatRows(rows: unknown[][], headerAt: number, map: ColMap): Omit<ParsedBudget, "format"> {
|
|
const groups: ParsedGroup[] = [];
|
|
const items: ParsedBudgetItem[] = [];
|
|
let skipped = 0;
|
|
let chapter = "";
|
|
let chapterCode = "";
|
|
let chapterWbs = "";
|
|
let chapterIndex = 0;
|
|
let itemIndex = 0;
|
|
const cache = new Map<string, ParsedGroup>();
|
|
|
|
function ensureChapter(name: string, code = "") {
|
|
const key = name.trim() || "General";
|
|
const hit = cache.get(key.toLowerCase());
|
|
if (hit) {
|
|
chapter = hit.name;
|
|
chapterCode = hit.code;
|
|
chapterWbs = hit.wbs;
|
|
return hit;
|
|
}
|
|
chapterIndex += 1;
|
|
itemIndex = 0;
|
|
const node: ParsedGroup = {
|
|
kind: "capitulo",
|
|
wbs: String(chapterIndex),
|
|
parentWbs: "",
|
|
code,
|
|
name: key,
|
|
};
|
|
groups.push(node);
|
|
cache.set(key.toLowerCase(), node);
|
|
chapter = node.name;
|
|
chapterCode = node.code;
|
|
chapterWbs = node.wbs;
|
|
return node;
|
|
}
|
|
|
|
for (let r = 0; r < headerAt; r++) {
|
|
const first = String(rows[r]?.[0] ?? "").trim();
|
|
const second = String(rows[r]?.[1] ?? "").trim();
|
|
if (/^obra:?$/i.test(first) && second) ensureChapter(second);
|
|
const restEmpty = (rows[r] || []).slice(1).every((value) => !String(value ?? "").trim());
|
|
if (first && restEmpty && !/^(cliente|concurso|obra|lugar|ciudad|fecha):?$/i.test(first) && !isSummaryLabel(first)) {
|
|
ensureChapter(first);
|
|
}
|
|
}
|
|
|
|
for (let r = headerAt + 1; r < rows.length; r++) {
|
|
const row = rows[r] || [];
|
|
const values = row.map((v) => String(v ?? "").trim());
|
|
if (values.every((v) => !v)) continue;
|
|
if (values.some((value) => isSummaryLabel(value))) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
const code = String(cell(row, map, "CLAVE") ?? "").trim();
|
|
const description = String(cell(row, map, "DESCRIPCION") ?? "").trim();
|
|
const unit = String(cell(row, map, "UNIDAD") ?? "").trim();
|
|
const chapterCell = String(cell(row, map, "CAPITULO") ?? "").trim();
|
|
const qty = parseMoney(cell(row, map, "CANTIDAD"));
|
|
const price = parseMoney(cell(row, map, "PRECIO"));
|
|
const importe = parseMoney(cell(row, map, "IMPORTE"));
|
|
const first = values[0];
|
|
const label = chapterCell || description || first;
|
|
const looksChapter = !code && !unit && qty === 0 && price === 0 && !rowHasNumbers(row) && !!label;
|
|
if (looksChapter) {
|
|
ensureChapter(label);
|
|
continue;
|
|
}
|
|
if (chapterCell) ensureChapter(chapterCell);
|
|
if (!chapterWbs) ensureChapter("General");
|
|
if (!description && !code) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
itemIndex += 1;
|
|
items.push({
|
|
chapter: chapter || "General",
|
|
chapterCode,
|
|
wbs: padChild(chapterWbs, itemIndex),
|
|
parentWbs: chapterWbs,
|
|
code,
|
|
description: description || code,
|
|
unit,
|
|
quantity: qty,
|
|
unit_price: price,
|
|
amount: Math.round((qty * price || importe) * 100) / 100,
|
|
});
|
|
}
|
|
return { groups, items, skipped };
|
|
}
|
|
|
|
export function parseBudgetRows(rows: unknown[][]): ParsedBudget {
|
|
const empty: ParsedBudget = { format: "none", groups: [], items: [], skipped: 0 };
|
|
const { headerAt, map } = findHeader(rows);
|
|
if (headerAt < 0) return empty;
|
|
if (map.WBS >= 0) return { format: "wbs", ...parseWbsRows(rows, headerAt, map) };
|
|
if (looksOpus(rows, headerAt, map)) return { format: "opus", ...parseOpusRows(rows, headerAt, map) };
|
|
return { format: "flat", ...parseFlatRows(rows, headerAt, map) };
|
|
}
|
|
|
|
function xlsxBytes(wb: XLSX.WorkBook): Uint8Array {
|
|
const out = XLSX.write(wb, { type: "array", bookType: "xlsx" });
|
|
const bytes = out instanceof Uint8Array ? out : new Uint8Array(out);
|
|
return bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
|
|
? bytes
|
|
: bytes.slice();
|
|
}
|
|
|
|
export function buildBudgetTemplate(): Uint8Array {
|
|
const wb = XLSX.utils.book_new();
|
|
const instructions = [
|
|
["Plantilla de presupuesto — Panel de proyectos"],
|
|
[""],
|
|
["1. Llene la hoja PRESUPUESTO. No cambie los nombres de las columnas."],
|
|
["2. WBS es el árbol: 1 = lote, 1.01 = capítulo, 1.01.01 = concepto."],
|
|
["3. TIPO: lote, capitulo o partida para agrupadores; concepto para renglones con unidad y precio."],
|
|
["4. El padre se infiere del WBS (1.01.01 cuelga de 1.01). No capture importes, IVA ni totales."],
|
|
["5. Una fila = un nodo. Ponga la descripción completa en una sola celda."],
|
|
["6. También se acepta un Excel Opus/Neodata (Código, Concepto, Unidad, Cantidad, P. Unitario) o uno plano con CLAVE y DESCRIPCION."],
|
|
];
|
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(instructions), "INSTRUCCIONES");
|
|
const sheet = [
|
|
[...BUDGET_COLUMNS],
|
|
["1", "lote", "A", "LOCAL L224", "", "", ""],
|
|
["1.01", "capitulo", "A001", "Preliminares y albañilería", "", "", ""],
|
|
["1.01.01", "concepto", "10301-001", "Trazo y nivelación manual para establecer ejes, banco de nivel y referencias.", "M2", 61.9, 15.53],
|
|
["1.01.02", "concepto", "10606-002", "Firme de 5 cm acabado común, de concreto F'c= 150 kg/cm2.", "M2", 61.9, 342.55],
|
|
["1.02", "capitulo", "A002", "Instalación hidrosanitaria", "", "", ""],
|
|
["1.02.01", "concepto", "11402-2996C200MX.020", "Inodoro alargado de una pieza, cerámica porcelanizada.", "PZA", 1, 6422.87],
|
|
["1.02.02", "concepto", "11403-E928-1.9", "Monomando de lavabo, negro mate.", "PZA", 2, 4272.4],
|
|
];
|
|
const ws = XLSX.utils.aoa_to_sheet(sheet);
|
|
ws["!cols"] = [{ wch: 10 }, { wch: 12 }, { wch: 24 }, { wch: 70 }, { wch: 10 }, { wch: 12 }, { wch: 14 }];
|
|
XLSX.utils.book_append_sheet(wb, ws, "PRESUPUESTO");
|
|
return xlsxBytes(wb);
|
|
}
|
|
|
|
type ExportChapter = { id: number; parent_id: number | null; code: string; name: string; wbs: string; sort_order: number };
|
|
type ExportItem = {
|
|
chapter_id?: number | null;
|
|
wbs?: string;
|
|
code: string;
|
|
description: string;
|
|
unit: string;
|
|
quantity: number;
|
|
unit_price: number;
|
|
};
|
|
|
|
export function exportBudgetWorkbook(
|
|
projectName: string,
|
|
budget: { tree: BudgetTreeNode[]; chapters?: ExportChapter[]; items: ExportItem[] },
|
|
): Uint8Array {
|
|
const wb = XLSX.utils.book_new();
|
|
const rows: unknown[][] = [
|
|
["Obra", projectName],
|
|
[],
|
|
[...BUDGET_COLUMNS],
|
|
];
|
|
const itemsByChapter = new Map<number, ExportItem[]>();
|
|
for (const item of budget.items) {
|
|
const id = Number(item.chapter_id || 0);
|
|
const list = itemsByChapter.get(id) || [];
|
|
list.push(item);
|
|
itemsByChapter.set(id, list);
|
|
}
|
|
|
|
function walk(nodes: BudgetTreeNode[], depth: number) {
|
|
for (const node of nodes) {
|
|
const tipo = depth <= 0 ? "lote" : depth === 1 ? "capitulo" : "partida";
|
|
rows.push([node.wbs || "", tipo, node.code || "", node.name, "", "", ""]);
|
|
for (const item of itemsByChapter.get(node.id) || []) {
|
|
rows.push([
|
|
item.wbs || "",
|
|
"concepto",
|
|
item.code,
|
|
item.description,
|
|
item.unit,
|
|
item.quantity,
|
|
item.unit_price,
|
|
]);
|
|
}
|
|
if (node.children?.length) walk(node.children, depth + 1);
|
|
}
|
|
}
|
|
walk(budget.tree, 0);
|
|
const unassigned = itemsByChapter.get(0) || [];
|
|
for (const item of unassigned) {
|
|
rows.push([item.wbs || "", "concepto", item.code, item.description, item.unit, item.quantity, item.unit_price]);
|
|
}
|
|
const ws = XLSX.utils.aoa_to_sheet(rows);
|
|
ws["!cols"] = [{ wch: 10 }, { wch: 12 }, { wch: 24 }, { wch: 70 }, { wch: 10 }, { wch: 12 }, { wch: 14 }];
|
|
XLSX.utils.book_append_sheet(wb, ws, "PRESUPUESTO");
|
|
return xlsxBytes(wb);
|
|
}
|
|
|
|
type ChapterRow = {
|
|
id: number;
|
|
project_id: number;
|
|
parent_id: number | null;
|
|
code: string;
|
|
name: string;
|
|
wbs: string;
|
|
sort_order: number;
|
|
};
|
|
|
|
function compareWbs(a: string, b: string) {
|
|
const as = a.split(".").map(Number);
|
|
const bs = b.split(".").map(Number);
|
|
const n = Math.max(as.length, bs.length);
|
|
for (let i = 0; i < n; i++) {
|
|
const d = (as[i] || 0) - (bs[i] || 0);
|
|
if (d) return d;
|
|
}
|
|
return a.localeCompare(b, undefined, { numeric: true });
|
|
}
|
|
|
|
export async function listBudget(database: Db, projectId: number) {
|
|
const env = await callCoreFn<{
|
|
chapters: ChapterRow[];
|
|
items: (BudgetItemRow & { chapter_name: string | null; chapter_code: string | null })[];
|
|
totals: { subtotal: number; iva: number; total: number; item_count: number };
|
|
}>(database, "core.fn_budget_list", { project_id: projectId });
|
|
if (!env.ok) throw new RpcCallError(env);
|
|
const chapters = env.data?.chapters ?? [];
|
|
const rawItems = env.data?.items ?? [];
|
|
|
|
const byParent = new Map<number | null, ChapterRow[]>();
|
|
const byId = new Map<number, ChapterRow>();
|
|
for (const chapter of chapters) {
|
|
byId.set(chapter.id, chapter);
|
|
const key = chapter.parent_id || null;
|
|
const list = byParent.get(key) || [];
|
|
list.push(chapter);
|
|
byParent.set(key, list);
|
|
}
|
|
|
|
function ancestorsOf(id: number | null): ChapterRow[] {
|
|
const chain: ChapterRow[] = [];
|
|
let cur = id ? byId.get(id) : undefined;
|
|
const seen = new Set<number>();
|
|
while (cur && !seen.has(cur.id)) {
|
|
seen.add(cur.id);
|
|
chain.unshift(cur);
|
|
cur = cur.parent_id ? byId.get(cur.parent_id) : undefined;
|
|
}
|
|
return chain;
|
|
}
|
|
|
|
function descendantIds(id: number): number[] {
|
|
const ids = [id];
|
|
for (const child of byParent.get(id) || []) ids.push(...descendantIds(child.id));
|
|
return ids;
|
|
}
|
|
|
|
const amounts = new Map<number, { amount: number; item_count: number }>();
|
|
for (const chapter of chapters) {
|
|
const ids = new Set(descendantIds(chapter.id));
|
|
const owned = rawItems.filter((item) => item.chapter_id && ids.has(Number(item.chapter_id)));
|
|
amounts.set(chapter.id, {
|
|
amount: Math.round(owned.reduce((sum, item) => sum + Number(item.amount || 0), 0) * 100) / 100,
|
|
item_count: owned.length,
|
|
});
|
|
}
|
|
|
|
function toTree(nodes: ChapterRow[]): BudgetTreeNode[] {
|
|
return nodes.map((chapter) => {
|
|
const stats = amounts.get(chapter.id) || { amount: 0, item_count: 0 };
|
|
return {
|
|
id: chapter.id,
|
|
parent_id: chapter.parent_id,
|
|
code: chapter.code || "",
|
|
name: chapter.name,
|
|
wbs: chapter.wbs || "",
|
|
amount: stats.amount,
|
|
item_count: stats.item_count,
|
|
children: toTree(byParent.get(chapter.id) || []),
|
|
};
|
|
});
|
|
}
|
|
|
|
const tree = toTree(byParent.get(null) || chapters.filter((chapter) => !chapter.parent_id));
|
|
const items: BudgetItemRow[] = rawItems.map((item) => {
|
|
const chain = ancestorsOf(item.chapter_id);
|
|
return {
|
|
...item,
|
|
wbs: item.wbs || "",
|
|
parent_path: chain.map((chapter) => [chapter.code, chapter.name].filter(Boolean).join(" ").trim()).join(" > "),
|
|
ancestor_ids: chain.map((chapter) => chapter.id),
|
|
};
|
|
});
|
|
|
|
const subtotal = items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
|
const iva = env.data?.totals?.iva ?? Math.round(subtotal * BUDGET_IVA * 100) / 100;
|
|
const chapterView = chapters.map((chapter) => {
|
|
const stats = amounts.get(chapter.id) || { amount: 0, item_count: 0 };
|
|
const chain = ancestorsOf(chapter.id);
|
|
return {
|
|
...chapter,
|
|
...stats,
|
|
path: chain.map((node) => [node.code, node.name].filter(Boolean).join(" ").trim()).join(" > "),
|
|
};
|
|
});
|
|
|
|
return {
|
|
tree,
|
|
chapters: chapterView,
|
|
items,
|
|
totals: {
|
|
subtotal: Math.round(subtotal * 100) / 100,
|
|
iva,
|
|
total: Math.round((subtotal + iva) * 100) / 100,
|
|
item_count: items.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function replaceBudgetFromParsed(database: Db, projectId: number, parsed: ParsedBudget): Promise<void> {
|
|
const groups = [...parsed.groups].sort((a, b) => compareWbs(a.wbs, b.wbs));
|
|
let sort = 1;
|
|
const chapters = groups.map((group) => ({
|
|
wbs: group.wbs,
|
|
parent_wbs: group.parentWbs || null,
|
|
code: group.code,
|
|
name: group.name,
|
|
sort_order: sort++,
|
|
}));
|
|
let itemSort = 1;
|
|
const items = parsed.items.map((item) => ({
|
|
chapter_wbs: item.parentWbs || null,
|
|
code: item.code,
|
|
description: item.description,
|
|
unit: item.unit,
|
|
quantity: item.quantity,
|
|
unit_price: item.unit_price,
|
|
amount: Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100,
|
|
wbs: item.wbs,
|
|
sort_order: itemSort++,
|
|
}));
|
|
const env = await callCoreFn(database, "core.fn_budget_replace", {
|
|
project_id: projectId,
|
|
chapters,
|
|
items,
|
|
});
|
|
if (!env.ok) throw new RpcCallError(env);
|
|
}
|
|
|
|
export async function replaceBudgetFromItems(database: Db, projectId: number, parsed: ParsedBudgetItem[]): Promise<void> {
|
|
const groups: ParsedGroup[] = [];
|
|
const seen = new Set<string>();
|
|
for (const item of parsed) {
|
|
const wbs = item.parentWbs || "1";
|
|
if (seen.has(wbs)) continue;
|
|
seen.add(wbs);
|
|
groups.push({
|
|
kind: "capitulo",
|
|
wbs,
|
|
parentWbs: parentOfWbs(wbs),
|
|
code: item.chapterCode || "",
|
|
name: item.chapter || "General",
|
|
});
|
|
}
|
|
await replaceBudgetFromParsed(database, projectId, { format: "flat", groups, items: parsed, skipped: 0 });
|
|
}
|
|
|
|
export function lineAmount(quantity: number, unitPrice: number) {
|
|
return Math.round(quantity * unitPrice * 100) / 100;
|
|
}
|
|
|
|
const FORMAT_LABEL: Record<ParsedBudget["format"], string> = {
|
|
wbs: "Plantilla del panel",
|
|
opus: "Opus / Neodata",
|
|
flat: "Excel plano",
|
|
none: "No reconocido",
|
|
};
|
|
|
|
function readBudgetSheet(bytes: Uint8Array): { sheet: string; rows: unknown[][] } | { error: string } {
|
|
const wb = XLSX.read(bytes, { type: "array" });
|
|
const sheet = wb.SheetNames.find((name) => /presupuesto/i.test(name)) || wb.SheetNames[0];
|
|
if (!sheet) return { error: "El archivo no tiene hojas" };
|
|
return { sheet, rows: XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: "" }) as unknown[][] };
|
|
}
|
|
|
|
function previewTreeOf(parsed: ParsedBudget): BudgetPreviewNode[] {
|
|
const byParent = new Map<string, ParsedGroup[]>();
|
|
for (const group of parsed.groups) {
|
|
const key = group.parentWbs || "";
|
|
const list = byParent.get(key) || [];
|
|
list.push(group);
|
|
byParent.set(key, list);
|
|
}
|
|
function stats(wbs: string): { amount: number; item_count: number } {
|
|
const direct = parsed.items.filter((item) => item.parentWbs === wbs);
|
|
const children = byParent.get(wbs) || [];
|
|
const childStats = children.map((child) => stats(child.wbs));
|
|
return {
|
|
amount: Math.round(
|
|
(direct.reduce((sum, item) => sum + Number(item.amount || 0), 0) +
|
|
childStats.reduce((sum, child) => sum + child.amount, 0)) * 100,
|
|
) / 100,
|
|
item_count: direct.length + childStats.reduce((sum, child) => sum + child.item_count, 0),
|
|
};
|
|
}
|
|
function node(group: ParsedGroup): BudgetPreviewNode {
|
|
const roll = stats(group.wbs);
|
|
return {
|
|
wbs: group.wbs,
|
|
code: group.code,
|
|
name: group.name,
|
|
kind: group.kind,
|
|
amount: roll.amount,
|
|
item_count: roll.item_count,
|
|
children: (byParent.get(group.wbs) || []).map(node),
|
|
};
|
|
}
|
|
const roots = byParent.get("") || parsed.groups.filter((group) => !group.parentWbs);
|
|
return roots.map(node);
|
|
}
|
|
|
|
export function previewBudgetExcel(bytes: Uint8Array): BudgetPreview {
|
|
const read = readBudgetSheet(bytes);
|
|
if ("error" in read) {
|
|
return {
|
|
format: "none",
|
|
format_label: FORMAT_LABEL.none,
|
|
sheet: "",
|
|
tree: [],
|
|
items: [],
|
|
totals: { subtotal: 0, iva: 0, total: 0, item_count: 0 },
|
|
skipped: 0,
|
|
errors: [{ row: 0, messages: [read.error] }],
|
|
};
|
|
}
|
|
const parsed = parseBudgetRows(read.rows);
|
|
const subtotal = Math.round(parsed.items.reduce((sum, item) => sum + Number(item.amount || 0), 0) * 100) / 100;
|
|
const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100;
|
|
const errors = parsed.items.length
|
|
? []
|
|
: [{ row: 0, messages: ["No se encontraron partidas. Revise que el Excel tenga Código/Concepto o la plantilla WBS."] }];
|
|
return {
|
|
format: parsed.format,
|
|
format_label: FORMAT_LABEL[parsed.format],
|
|
sheet: read.sheet,
|
|
tree: previewTreeOf(parsed),
|
|
items: parsed.items.map((item) => ({
|
|
code: item.code,
|
|
description: item.description,
|
|
unit: item.unit,
|
|
quantity: item.quantity,
|
|
unit_price: item.unit_price,
|
|
amount: item.amount,
|
|
chapter: item.chapter,
|
|
parentWbs: item.parentWbs,
|
|
})),
|
|
totals: {
|
|
subtotal,
|
|
iva,
|
|
total: Math.round((subtotal + iva) * 100) / 100,
|
|
item_count: parsed.items.length,
|
|
},
|
|
skipped: parsed.skipped,
|
|
errors,
|
|
};
|
|
}
|
|
|
|
export async function importBudgetExcel(database: Db, projectId: number, bytes: Uint8Array): Promise<BudgetImportReport> {
|
|
const read = readBudgetSheet(bytes);
|
|
if ("error" in read) {
|
|
return { replaced: false, chapters: 0, inserted: 0, skipped: 0, errors: [{ row: 0, messages: [read.error] }] };
|
|
}
|
|
const parsed = parseBudgetRows(read.rows);
|
|
if (!parsed.items.length) {
|
|
return {
|
|
replaced: false,
|
|
chapters: 0,
|
|
inserted: 0,
|
|
skipped: parsed.skipped,
|
|
errors: [{ row: 0, messages: ["No se encontraron partidas. Use la plantilla o un Excel con CLAVE, DESCRIPCION, UNIDAD, CANTIDAD y PRECIO."] }],
|
|
};
|
|
}
|
|
await replaceBudgetFromParsed(database, projectId, parsed);
|
|
const subtotal = Math.round(parsed.items.reduce((sum, item) => sum + Number(item.amount || 0), 0) * 100) / 100;
|
|
const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100;
|
|
const totals = {
|
|
subtotal,
|
|
iva,
|
|
total: Math.round((subtotal + iva) * 100) / 100,
|
|
item_count: parsed.items.length,
|
|
};
|
|
// El monto de contrato del proyecto refleja el subtotal del presupuesto importado.
|
|
const projectEnv = await callCoreFn(database, "core.fn_project_update", {
|
|
id: projectId,
|
|
contract_amount: subtotal,
|
|
});
|
|
if (!projectEnv.ok) throw new RpcCallError(projectEnv);
|
|
return {
|
|
replaced: true,
|
|
chapters: parsed.groups.length,
|
|
inserted: parsed.items.length,
|
|
skipped: parsed.skipped,
|
|
errors: [],
|
|
totals,
|
|
contract_amount: subtotal,
|
|
};
|
|
}
|