mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 12:43:18 +00:00
Pre-computed merge for change #5
This commit is contained in:
commit
31f67d40b7
17 changed files with 2644 additions and 1 deletions
|
|
@ -59,6 +59,7 @@ import { registerPayrollRoutes } from "./payroll_http.ts";
|
|||
import { registerExpenseRoutes } from "./expenses_http.ts";
|
||||
import { registerWarehouseRoutes } from "./warehouse_http.ts";
|
||||
import { registerCostControlRoutes } from "./cost_control_http.ts";
|
||||
import { registerWorkProgramRoutes } from "./work_program_http.ts";
|
||||
import { registerIamRoutes } from "./iam_http.ts";
|
||||
import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts";
|
||||
import {
|
||||
|
|
@ -1347,6 +1348,7 @@ registerPayrollRoutes(app);
|
|||
registerExpenseRoutes(app);
|
||||
registerWarehouseRoutes(app);
|
||||
registerCostControlRoutes(app);
|
||||
registerWorkProgramRoutes(app);
|
||||
registerIamRoutes(app);
|
||||
|
||||
const port = config.port;
|
||||
|
|
|
|||
245
api/work_program.ts
Normal file
245
api/work_program.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import * as XLSX from "xlsx";
|
||||
import type { Db } from "./db.ts";
|
||||
import { callCoreFn, RpcCallError } from "./rpc.ts";
|
||||
import { listBudget } from "./budget.ts";
|
||||
import {
|
||||
exportWorkProgramWorkbook,
|
||||
matchWorkProgramImport,
|
||||
parseWorkProgramWorkbook,
|
||||
type WorkProgramExcelPreview,
|
||||
} from "./work_program_excel.ts";
|
||||
|
||||
export type WorkProgramSummary = {
|
||||
program: Record<string, unknown> | null;
|
||||
periods: Record<string, unknown>[];
|
||||
budget_subtotal: number;
|
||||
curve: { period_index: number; label: string; amount: number; pct_period: number; pct_cumulative: number }[];
|
||||
};
|
||||
|
||||
async function rpc<T>(db: Db, fn: string, payload: Record<string, unknown>): Promise<T> {
|
||||
const env = await callCoreFn(db, fn, payload);
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
return env.data as T;
|
||||
}
|
||||
|
||||
export async function getWorkProgram(db: Db, projectId: number): Promise<WorkProgramSummary> {
|
||||
return rpc(db, "core.fn_work_program_get", { project_id: projectId });
|
||||
}
|
||||
|
||||
export async function listConcepts(db: Db, projectId: number) {
|
||||
return rpc<{ periods: Record<string, unknown>[]; items: Record<string, unknown>[] }>(
|
||||
db,
|
||||
"core.fn_work_program_list_concepts",
|
||||
{ project_id: projectId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPartidas(db: Db, projectId: number) {
|
||||
return rpc<{ periods: Record<string, unknown>[]; chapters: Record<string, unknown>[] }>(
|
||||
db,
|
||||
"core.fn_work_program_list_partidas",
|
||||
{ project_id: projectId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getVsCost(db: Db, projectId: number) {
|
||||
return rpc(db, "core.fn_work_program_vs_cost", { project_id: projectId });
|
||||
}
|
||||
|
||||
export async function previewWorkProgramExcel(
|
||||
bytes: Uint8Array,
|
||||
projectId: number,
|
||||
db: Db,
|
||||
): Promise<WorkProgramExcelPreview & { match?: ReturnType<typeof matchWorkProgramImport> }> {
|
||||
const parsed = parseWorkProgramWorkbook(bytes);
|
||||
if (parsed.format === "none") return parsed;
|
||||
|
||||
const budget = await listBudget(db, projectId);
|
||||
const ctx = {
|
||||
items: budget.items.map((i) => ({
|
||||
id: i.id,
|
||||
code: i.code,
|
||||
description: i.description,
|
||||
unit: i.unit,
|
||||
amount: Number(i.amount),
|
||||
chapter_id: i.chapter_id,
|
||||
})),
|
||||
chapters: budget.chapters.map((c) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
amount: budget.items.filter((i) => i.chapter_id === c.id).reduce((s, i) => s + Number(i.amount), 0),
|
||||
})),
|
||||
};
|
||||
|
||||
const matched = matchWorkProgramImport(parsed, ctx);
|
||||
return { ...matched.preview, match: matched };
|
||||
}
|
||||
|
||||
export async function importWorkProgramExcel(
|
||||
db: Db,
|
||||
projectId: number,
|
||||
bytes: Uint8Array,
|
||||
opts?: { createChapters?: boolean },
|
||||
): Promise<Record<string, unknown>> {
|
||||
const parsed = parseWorkProgramWorkbook(bytes);
|
||||
const budget = await listBudget(db, projectId);
|
||||
let chapters = [...budget.chapters];
|
||||
|
||||
const ctx = {
|
||||
items: budget.items.map((i) => ({
|
||||
id: i.id,
|
||||
code: i.code,
|
||||
description: i.description,
|
||||
unit: i.unit,
|
||||
amount: Number(i.amount),
|
||||
chapter_id: i.chapter_id,
|
||||
})),
|
||||
chapters: chapters.map((c) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
amount: budget.items.filter((i) => i.chapter_id === c.id).reduce((s, i) => s + Number(i.amount), 0),
|
||||
})),
|
||||
};
|
||||
|
||||
let { payload, preview } = matchWorkProgramImport(parsed, ctx);
|
||||
|
||||
if (opts?.createChapters !== false && payload.chapters_to_create.length) {
|
||||
for (const ch of payload.chapters_to_create) {
|
||||
await callCoreFn(db, "core.fn_budget_chapter_create", {
|
||||
project_id: projectId,
|
||||
code: ch.code,
|
||||
name: ch.name,
|
||||
sort_order: ch.sort_order,
|
||||
});
|
||||
}
|
||||
const refreshed = await listBudget(db, projectId);
|
||||
chapters = refreshed.chapters;
|
||||
const chapterByCode = new Map(chapters.map((c) => [c.code.toUpperCase(), c]));
|
||||
for (const row of preview.partidas) {
|
||||
const ch = chapterByCode.get(row.code.toUpperCase());
|
||||
if (!ch) continue;
|
||||
row.amounts.forEach((amount, i) => {
|
||||
if (amount !== 0) {
|
||||
payload.partida_amounts.push({
|
||||
budget_chapter_id: ch.id,
|
||||
period_index: i + 1,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rpc(db, "core.fn_work_program_replace", {
|
||||
project_id: projectId,
|
||||
source: "import",
|
||||
status: "active",
|
||||
concept_slots: payload.concept_slots,
|
||||
partida_amounts: payload.partida_amounts,
|
||||
chapters_to_create: [],
|
||||
});
|
||||
}
|
||||
|
||||
export async function createEmptyWorkProgram(db: Db, projectId: number) {
|
||||
return rpc(db, "core.fn_work_program_replace", {
|
||||
project_id: projectId,
|
||||
source: "manual",
|
||||
status: "draft",
|
||||
concept_slots: [],
|
||||
partida_amounts: [],
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateWorkProgram(
|
||||
db: Db,
|
||||
projectId: number,
|
||||
strategy: string,
|
||||
) {
|
||||
return rpc(db, "core.fn_work_program_generate", { project_id: projectId, strategy });
|
||||
}
|
||||
|
||||
export async function updateConceptSlot(
|
||||
db: Db,
|
||||
projectId: number,
|
||||
budgetItemId: number,
|
||||
periodId: number,
|
||||
start_pct: number,
|
||||
end_pct: number,
|
||||
) {
|
||||
return rpc(db, "core.fn_work_program_update_concept", {
|
||||
project_id: projectId,
|
||||
budget_item_id: budgetItemId,
|
||||
period_id: periodId,
|
||||
start_pct,
|
||||
end_pct,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePartidaAmount(
|
||||
db: Db,
|
||||
projectId: number,
|
||||
chapterId: number,
|
||||
periodId: number,
|
||||
amount: number,
|
||||
) {
|
||||
return rpc(db, "core.fn_work_program_update_partida", {
|
||||
project_id: projectId,
|
||||
budget_chapter_id: chapterId,
|
||||
period_id: periodId,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProgress(
|
||||
db: Db,
|
||||
projectId: number,
|
||||
budgetItemId: number,
|
||||
periodId: number,
|
||||
pct_complete: number,
|
||||
) {
|
||||
return rpc(db, "core.fn_work_program_update_progress", {
|
||||
project_id: projectId,
|
||||
budget_item_id: budgetItemId,
|
||||
period_id: periodId,
|
||||
pct_complete,
|
||||
});
|
||||
}
|
||||
|
||||
export async function recalculatePartidas(db: Db, projectId: number) {
|
||||
return rpc(db, "core.fn_work_program_recalculate_partidas", { project_id: projectId });
|
||||
}
|
||||
|
||||
export async function exportWorkProgram(
|
||||
db: Db,
|
||||
projectId: number,
|
||||
kind: "concepto" | "partida",
|
||||
projectName: string,
|
||||
): Promise<Uint8Array> {
|
||||
const summary = await getWorkProgram(db, projectId);
|
||||
const periodCount = summary.periods.length || 1;
|
||||
|
||||
if (kind === "concepto") {
|
||||
const data = await listConcepts(db, projectId);
|
||||
const items = (data.items || []).map((item) => ({
|
||||
code: String(item.code || ""),
|
||||
description: String(item.description || ""),
|
||||
unit: String(item.unit || ""),
|
||||
periods: ((item.periods as { period_index: number; start_pct: number; end_pct: number }[]) || []).filter(
|
||||
(p) => Number(p.start_pct) !== 0 || Number(p.end_pct) !== 0,
|
||||
),
|
||||
}));
|
||||
return exportWorkProgramWorkbook(kind, projectName, items, [], periodCount);
|
||||
}
|
||||
|
||||
const data = await listPartidas(db, projectId);
|
||||
const chapters = (data.chapters || []).map((ch) => ({
|
||||
code: String(ch.code || ""),
|
||||
name: String(ch.name || ""),
|
||||
periods: ((ch.periods as { period_index: number; amount: number }[]) || []).filter((p) => Number(p.amount) !== 0),
|
||||
}));
|
||||
return exportWorkProgramWorkbook(kind, projectName, [], chapters, periodCount);
|
||||
}
|
||||
|
||||
export { parseBarString } from "./work_program_excel.ts";
|
||||
395
api/work_program_excel.ts
Normal file
395
api/work_program_excel.ts
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
import * as XLSX from "xlsx";
|
||||
import { parseMoney } from "./budget.ts";
|
||||
|
||||
export type WorkProgramBarSlot = {
|
||||
period_index: number;
|
||||
start_pct: number;
|
||||
end_pct: number;
|
||||
};
|
||||
|
||||
export type ParsedConceptRow = {
|
||||
code: string;
|
||||
description: string;
|
||||
unit: string;
|
||||
bar: string;
|
||||
slots: WorkProgramBarSlot[];
|
||||
row: number;
|
||||
};
|
||||
|
||||
export type ParsedPartidaRow = {
|
||||
code: string;
|
||||
name: string;
|
||||
amounts: number[];
|
||||
total: number;
|
||||
bar?: string;
|
||||
row: number;
|
||||
};
|
||||
|
||||
export type WorkProgramExcelPreview = {
|
||||
format: "concepto" | "partida" | "none";
|
||||
format_label: string;
|
||||
sheet: string;
|
||||
period_count: number;
|
||||
period_labels: string[];
|
||||
concepts: ParsedConceptRow[];
|
||||
partidas: ParsedPartidaRow[];
|
||||
unmatched_codes: string[];
|
||||
warnings: string[];
|
||||
errors: { row: number; messages: string[] }[];
|
||||
};
|
||||
|
||||
export type BudgetMatchContext = {
|
||||
items: { id: number; code: string; description: string; unit: string; amount: number; chapter_id: number | null }[];
|
||||
chapters: { id: number; code: string; name: string; amount: number }[];
|
||||
};
|
||||
|
||||
export type WorkProgramImportPayload = {
|
||||
concept_slots: { budget_item_id: number; period_index: number; start_pct: number; end_pct: number }[];
|
||||
partida_amounts: { budget_chapter_id: number; period_index: number; amount: number }[];
|
||||
chapters_to_create: { code: string; name: string; sort_order: number }[];
|
||||
};
|
||||
|
||||
function fold(value: string) {
|
||||
return value.normalize("NFD").replace(/\p{M}/gu, "").toUpperCase().replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isSkipRow(a: unknown, b: unknown, c: unknown) {
|
||||
const texts = [a, b, c].map((v) => fold(String(v ?? "")));
|
||||
if (texts.some((t) => /^(DIRECTOR GENERAL|TOTAL DEL PRESUPUESTO|ACUMULADO|PORCENTAJE PERIODO|PORCENTAJE ACUMULADO|MONTO ESTA HOJA)/.test(t))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function parseBarString(bar: string): WorkProgramBarSlot[] {
|
||||
const raw = String(bar ?? "").trim();
|
||||
if (!raw.startsWith("Barra=")) return [];
|
||||
const segs = raw.slice(6).split(",");
|
||||
return segs.map((seg, i) => {
|
||||
const [a, b] = seg.split("-").map((x) => Number(x.trim()) || 0);
|
||||
if (a === 0 && b === 0) return null;
|
||||
return { period_index: i + 1, start_pct: a, end_pct: b };
|
||||
}).filter((x): x is WorkProgramBarSlot => x !== null);
|
||||
}
|
||||
|
||||
function findSemHeader(row: unknown[]) {
|
||||
const labels: string[] = [];
|
||||
let firstSem = -1;
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const t = fold(String(row[i] ?? ""));
|
||||
const m = t.match(/^SEM\s*(\d+)$/);
|
||||
if (m) {
|
||||
if (firstSem < 0) firstSem = i;
|
||||
labels.push(`Sem ${m[1]}`);
|
||||
}
|
||||
}
|
||||
return { firstSem, labels, totalCol: firstSem >= 0 ? firstSem + labels.length : -1 };
|
||||
}
|
||||
|
||||
function detectFormat(rows: unknown[][]): "concepto" | "partida" | "none" {
|
||||
for (let i = 0; i < Math.min(rows.length, 30); i++) {
|
||||
const row = rows[i] || [];
|
||||
const joined = row.map((c) => fold(String(c ?? ""))).join("|");
|
||||
if (joined.includes("POR CONCEPTO") || (joined.includes("CODIGO") && joined.includes("DESCRIPCION") && joined.includes("UNIDAD"))) {
|
||||
return "concepto";
|
||||
}
|
||||
if (joined.includes("PARTIDA") && joined.includes("SEM")) return "partida";
|
||||
}
|
||||
for (let i = 0; i < Math.min(rows.length, 30); i++) {
|
||||
const row = rows[i] || [];
|
||||
const a = fold(String(row[0] ?? ""));
|
||||
const b = fold(String(row[1] ?? ""));
|
||||
if (a === "CODIGO" || a === "CLAVE") return "concepto";
|
||||
if (a === "PARTIDA" && b === "PARTIDA") return "partida";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
function isPartidaCode(code: string) {
|
||||
return /^A\d{3}$/i.test(code.trim());
|
||||
}
|
||||
|
||||
function isConceptCode(code: string) {
|
||||
const t = code.trim();
|
||||
if (!t || isPartidaCode(t)) return false;
|
||||
if (/^A$/i.test(t)) return false;
|
||||
return t.includes("-") || /^[A-Z]{2,}\d/.test(t) || /^SELE|^PUER|^COL|^INS|^VYD|^IE|^ABA/i.test(t);
|
||||
}
|
||||
|
||||
export function parseWorkProgramRows(rows: unknown[][]): Omit<WorkProgramExcelPreview, "sheet"> {
|
||||
const format = detectFormat(rows);
|
||||
const warnings: string[] = [];
|
||||
const errors: { row: number; messages: string[] }[] = [];
|
||||
|
||||
if (format === "none") {
|
||||
return {
|
||||
format: "none",
|
||||
format_label: "No reconocido",
|
||||
period_count: 0,
|
||||
period_labels: [],
|
||||
concepts: [],
|
||||
partidas: [],
|
||||
unmatched_codes: [],
|
||||
warnings: ["No se detectó formato de programa Neodata/Opus (por concepto o por partida)."],
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
let headerAt = -1;
|
||||
let periodLabels: string[] = [];
|
||||
let firstSem = -1;
|
||||
let totalCol = -1;
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const info = findSemHeader(rows[i] || []);
|
||||
if (info.firstSem >= 0 && info.labels.length >= 1) {
|
||||
headerAt = i;
|
||||
periodLabels = info.labels;
|
||||
firstSem = info.firstSem;
|
||||
totalCol = info.totalCol;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (headerAt < 0) {
|
||||
return {
|
||||
format,
|
||||
format_label: format === "concepto" ? "Programa por concepto" : "Programa por partida",
|
||||
period_count: 0,
|
||||
period_labels: [],
|
||||
concepts: [],
|
||||
partidas: [],
|
||||
unmatched_codes: [],
|
||||
warnings: ["No se encontró fila de encabezado con columnas Sem 1..N."],
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
const concepts: ParsedConceptRow[] = [];
|
||||
const partidas: ParsedPartidaRow[] = [];
|
||||
|
||||
if (format === "concepto") {
|
||||
let currentDesc: string[] = [];
|
||||
for (let r = headerAt + 1; r < rows.length; r++) {
|
||||
const row = rows[r] || [];
|
||||
const code = String(row[0] ?? "").trim();
|
||||
const desc = String(row[1] ?? "").trim();
|
||||
const unit = String(row[2] ?? "").trim();
|
||||
if (isSkipRow(row[0], row[1], row[2])) continue;
|
||||
if (fold(String(row[2] ?? "")) === "MONTO ESTA HOJA:") continue;
|
||||
|
||||
if (isPartidaCode(code) && !unit) continue;
|
||||
if (/^A$/i.test(code)) continue;
|
||||
|
||||
if (isConceptCode(code) && unit) {
|
||||
let bar = "";
|
||||
for (let c = firstSem; c < totalCol; c++) {
|
||||
const v = String(row[c] ?? "").trim();
|
||||
if (v.startsWith("Barra=")) {
|
||||
bar = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!bar) {
|
||||
const v = String(row[firstSem] ?? "").trim();
|
||||
if (v.startsWith("Barra=")) bar = v;
|
||||
}
|
||||
const description = [desc, ...currentDesc].filter(Boolean).join(" ");
|
||||
currentDesc = [];
|
||||
concepts.push({
|
||||
code,
|
||||
description,
|
||||
unit,
|
||||
bar,
|
||||
slots: parseBarString(bar),
|
||||
row: r + 1,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!code && desc && !unit) {
|
||||
currentDesc.push(desc);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code.startsWith("Barra=")) continue;
|
||||
}
|
||||
} else {
|
||||
for (let r = headerAt + 1; r < rows.length; r++) {
|
||||
const row = rows[r] || [];
|
||||
const code = String(row[0] ?? "").trim();
|
||||
const name = String(row[1] ?? "").trim();
|
||||
if (isSkipRow(row[0], row[1], row[2])) continue;
|
||||
|
||||
if (isPartidaCode(code) && name) {
|
||||
const amounts: number[] = [];
|
||||
for (let c = firstSem; c < totalCol; c++) {
|
||||
amounts.push(parseMoney(row[c]));
|
||||
}
|
||||
const total = totalCol >= 0 ? parseMoney(row[totalCol]) : amounts.reduce((s, n) => s + n, 0);
|
||||
partidas.push({ code: code.toUpperCase(), name, amounts, total, row: r + 1 });
|
||||
continue;
|
||||
}
|
||||
|
||||
const barCell = String(row[firstSem] ?? "").trim();
|
||||
if (!code && barCell.startsWith("Barra=") && partidas.length) {
|
||||
partidas[partidas.length - 1].bar = barCell;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
format,
|
||||
format_label: format === "concepto" ? "Programa por concepto (Neodata/Opus)" : "Programa por partida (erogaciones)",
|
||||
period_count: periodLabels.length,
|
||||
period_labels: periodLabels,
|
||||
concepts,
|
||||
partidas,
|
||||
unmatched_codes: [],
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkProgramWorkbook(bytes: Uint8Array, sheetName?: string): WorkProgramExcelPreview {
|
||||
const wb = XLSX.read(bytes, { type: "array", cellDates: true });
|
||||
const sheet = sheetName && wb.SheetNames.includes(sheetName) ? sheetName : wb.SheetNames[0];
|
||||
const ws = wb.Sheets[sheet];
|
||||
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1, defval: "" }) as unknown[][];
|
||||
const parsed = parseWorkProgramRows(rows);
|
||||
return { ...parsed, sheet };
|
||||
}
|
||||
|
||||
export function matchWorkProgramImport(
|
||||
preview: WorkProgramExcelPreview,
|
||||
ctx: BudgetMatchContext,
|
||||
): { payload: WorkProgramImportPayload; preview: WorkProgramExcelPreview } {
|
||||
const itemByCode = new Map(ctx.items.map((i) => [fold(i.code), i]));
|
||||
const chapterByCode = new Map(ctx.chapters.map((c) => [fold(c.code), c]));
|
||||
const unmatched: string[] = [];
|
||||
const warnings = [...preview.warnings];
|
||||
|
||||
const concept_slots: WorkProgramImportPayload["concept_slots"] = [];
|
||||
for (const row of preview.concepts) {
|
||||
const item = itemByCode.get(fold(row.code));
|
||||
if (!item) {
|
||||
unmatched.push(row.code);
|
||||
continue;
|
||||
}
|
||||
for (const slot of row.slots) {
|
||||
concept_slots.push({
|
||||
budget_item_id: item.id,
|
||||
period_index: slot.period_index,
|
||||
start_pct: slot.start_pct,
|
||||
end_pct: slot.end_pct,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const partida_amounts: WorkProgramImportPayload["partida_amounts"] = [];
|
||||
const chapters_to_create: WorkProgramImportPayload["chapters_to_create"] = [];
|
||||
|
||||
for (const row of preview.partidas) {
|
||||
let chapter = chapterByCode.get(fold(row.code));
|
||||
if (!chapter) {
|
||||
const byName = ctx.chapters.find((c) => fold(c.name) === fold(row.name));
|
||||
if (byName) chapter = byName;
|
||||
}
|
||||
if (!chapter) {
|
||||
chapters_to_create.push({ code: row.code, name: row.name, sort_order: partida_amounts.length });
|
||||
unmatched.push(row.code);
|
||||
warnings.push(`Partida ${row.code} no encontrada en presupuesto; se creará capítulo al importar si confirma.`);
|
||||
continue;
|
||||
}
|
||||
row.amounts.forEach((amount, i) => {
|
||||
if (amount !== 0) {
|
||||
partida_amounts.push({
|
||||
budget_chapter_id: chapter!.id,
|
||||
period_index: i + 1,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
});
|
||||
const sum = row.amounts.reduce((s, n) => s + n, 0);
|
||||
if (chapter.amount > 0 && Math.abs(sum - chapter.amount) / chapter.amount > 0.01) {
|
||||
warnings.push(
|
||||
`Partida ${row.code}: suma semanal $${sum.toFixed(2)} difiere del presupuesto $${chapter.amount.toFixed(2)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
payload: { concept_slots, partida_amounts, chapters_to_create },
|
||||
preview: { ...preview, unmatched_codes: unmatched, warnings },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConceptExportRows(
|
||||
items: {
|
||||
code: string;
|
||||
description: string;
|
||||
unit: string;
|
||||
periods: { period_index: number; start_pct: number; end_pct: number }[];
|
||||
}[],
|
||||
periodCount: number,
|
||||
): unknown[][] {
|
||||
const header = ["Código", "Descripción", "Unidad", ...Array.from({ length: periodCount }, (_, i) => `Sem ${i + 1}`), "Total"];
|
||||
const rows: unknown[][] = [header];
|
||||
for (const item of items) {
|
||||
const slots = Array.from({ length: periodCount }, (_, i) => {
|
||||
const p = item.periods.find((x) => x.period_index === i + 1);
|
||||
if (!p || (p.start_pct === 0 && p.end_pct === 0)) return 0;
|
||||
return `Barra=${Array.from({ length: periodCount }, (_, j) => {
|
||||
const s = item.periods.find((x) => x.period_index === j + 1);
|
||||
if (!s || (s.start_pct === 0 && s.end_pct === 0)) return "0-0";
|
||||
return `${s.start_pct}-${s.end_pct}`;
|
||||
}).join(",")}`;
|
||||
});
|
||||
const barStr = `Barra=${Array.from({ length: periodCount }, (_, j) => {
|
||||
const s = item.periods.find((x) => x.period_index === j + 1);
|
||||
if (!s || (s.start_pct === 0 && s.end_pct === 0)) return "0-0";
|
||||
return `${s.start_pct}-${s.end_pct}`;
|
||||
}).join(",")}`;
|
||||
rows.push([item.code, item.description, item.unit, barStr, ...Array(periodCount - 1).fill(""), ""]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function buildPartidaExportRows(
|
||||
chapters: { code: string; name: string; periods: { period_index: number; amount: number }[] }[],
|
||||
periodCount: number,
|
||||
): unknown[][] {
|
||||
const header = ["PARTIDA", "PARTIDA", ...Array.from({ length: periodCount }, (_, i) => `Sem ${i + 1}`), "Total"];
|
||||
const rows: unknown[][] = [header];
|
||||
for (const ch of chapters) {
|
||||
const amounts = Array.from({ length: periodCount }, (_, i) => {
|
||||
const p = ch.periods.find((x) => x.period_index === i + 1);
|
||||
return p?.amount ?? 0;
|
||||
});
|
||||
const total = amounts.reduce((s, n) => s + n, 0);
|
||||
rows.push([ch.code, ch.name, ...amounts, total]);
|
||||
const bar = `Barra=${amounts.map((a) => (a > 0 ? "0-100" : "0-0")).join(",")}`;
|
||||
rows.push(["", "", bar, ...Array(periodCount + 1).fill("")]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function exportWorkProgramWorkbook(
|
||||
kind: "concepto" | "partida",
|
||||
projectName: string,
|
||||
conceptItems: Parameters<typeof buildConceptExportRows>[0],
|
||||
partidaChapters: Parameters<typeof buildPartidaExportRows>[0],
|
||||
periodCount: number,
|
||||
): Uint8Array {
|
||||
const rows = kind === "concepto"
|
||||
? buildConceptExportRows(conceptItems, periodCount)
|
||||
: buildPartidaExportRows(partidaChapters, periodCount);
|
||||
const ws = XLSX.utils.aoa_to_sheet([
|
||||
["PROGRAMA DE OBRA", projectName],
|
||||
[],
|
||||
...rows,
|
||||
]);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, kind === "concepto" ? "Por concepto" : "Por partida");
|
||||
return XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Uint8Array;
|
||||
}
|
||||
64
api/work_program_excel_test.ts
Normal file
64
api/work_program_excel_test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { assertEquals, assertGreater } from "jsr:@std/assert@1";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parseBarString, parseWorkProgramRows, parseWorkProgramWorkbook } from "./work_program_excel.ts";
|
||||
|
||||
Deno.test("parseBarString decodifica formato Neodata", () => {
|
||||
const slots = parseBarString("Barra=14.29-100,0-100,0-14.29,0-0,0-0,0-0,0-0,0-0,0-0");
|
||||
assertEquals(slots.length, 3);
|
||||
assertEquals(slots[0], { period_index: 1, start_pct: 14.29, end_pct: 100 });
|
||||
assertEquals(slots[2], { period_index: 3, start_pct: 0, end_pct: 14.29 });
|
||||
});
|
||||
|
||||
Deno.test("detecta programa por partida con montos semanales", () => {
|
||||
const parsed = parseWorkProgramRows([
|
||||
["PARTIDA", "PARTIDA", "Sem 1", "Sem 2", "Sem 3", "Total"],
|
||||
["A001", "PRELIMINARES Y ALBAÑILERÍA", 19493.27, 32531.38, 1161.31, 123219.19],
|
||||
[null, null, "Barra=14.29-100,0-100,0-100,0-100,0-100,0-0,0-100,0-100,0-57.14"],
|
||||
["A002", "INSTALACION HIDROSANITARIA", 1889.2, 19822.48, 3668.97, 48630.1],
|
||||
]);
|
||||
assertEquals(parsed.format, "partida");
|
||||
assertEquals(parsed.partidas.length, 2);
|
||||
assertEquals(parsed.partidas[0].code, "A001");
|
||||
assertEquals(parsed.partidas[0].amounts[0], 19493.27);
|
||||
assertEquals(parsed.partidas[0].bar?.startsWith("Barra="), true);
|
||||
});
|
||||
|
||||
Deno.test("detecta programa por concepto con Barra=", () => {
|
||||
const parsed = parseWorkProgramRows([
|
||||
["Código", "Descripción", "Unidad", "Sem 1", "Sem 2", "Sem 3", "Total"],
|
||||
["A001", "PRELIMINARES Y ALBAÑILERÍA"],
|
||||
["10301-001", "Trazo y nivelación manual", "M2", "Barra=14.29-100,0-100,0-14.29,0-0,0-0,0-0,0-0,0-0,0-0"],
|
||||
["10301-018", "Carpa de lona", "M2", "Barra=42.86-100,0-14.29,0-0,0-0,0-0,0-0,0-0,0-0,0-0"],
|
||||
]);
|
||||
assertEquals(parsed.format, "concepto");
|
||||
assertEquals(parsed.concepts.length, 2);
|
||||
assertEquals(parsed.concepts[0].code, "10301-001");
|
||||
assertEquals(parsed.concepts[0].slots.length, 3);
|
||||
});
|
||||
|
||||
const conceptoPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Concepto_a_Est_ndar__E__4-9-2026_Hr18Mn46_85cd.xlsx";
|
||||
const partidaPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Partida_a_Est_ndar__E__4-9-2026_Hr18Mn43_18ff.xlsx";
|
||||
|
||||
Deno.test("lee Excel real por concepto del usuario", () => {
|
||||
try {
|
||||
const bytes = readFileSync(conceptoPath);
|
||||
const parsed = parseWorkProgramWorkbook(new Uint8Array(bytes));
|
||||
assertEquals(parsed.format, "concepto");
|
||||
assertGreater(parsed.concepts.length, 50);
|
||||
assertGreater(parsed.period_count, 8);
|
||||
} catch {
|
||||
// fixture no disponible en CI
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("lee Excel real por partida del usuario", () => {
|
||||
try {
|
||||
const bytes = readFileSync(partidaPath);
|
||||
const parsed = parseWorkProgramWorkbook(new Uint8Array(bytes));
|
||||
assertEquals(parsed.format, "partida");
|
||||
assertGreater(parsed.partidas.length, 5);
|
||||
assertEquals(parsed.partidas[0].code, "A001");
|
||||
} catch {
|
||||
// fixture no disponible en CI
|
||||
}
|
||||
});
|
||||
145
api/work_program_http.ts
Normal file
145
api/work_program_http.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import type { Hono } from "hono";
|
||||
import type { AuthUser } from "./auth.ts";
|
||||
import { requireCoreAuth } from "./scope.ts";
|
||||
import type { Db } from "./db.ts";
|
||||
import { projectById } from "./db.ts";
|
||||
import { requirePermission } from "./permissions.ts";
|
||||
import {
|
||||
createEmptyWorkProgram,
|
||||
exportWorkProgram,
|
||||
generateWorkProgram,
|
||||
getVsCost,
|
||||
getWorkProgram,
|
||||
importWorkProgramExcel,
|
||||
listConcepts,
|
||||
listPartidas,
|
||||
previewWorkProgramExcel,
|
||||
recalculatePartidas,
|
||||
updateConceptSlot,
|
||||
updatePartidaAmount,
|
||||
updateProgress,
|
||||
} from "./work_program.ts";
|
||||
|
||||
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
|
||||
|
||||
const auth = [...requireCoreAuth, requirePermission("manage_budget")] as const;
|
||||
|
||||
async function readUpload(c: { req: { formData: () => Promise<FormData> } }): Promise<Uint8Array> {
|
||||
const form = await c.req.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) throw new Error("Archivo Excel requerido (campo file)");
|
||||
return new Uint8Array(await file.arrayBuffer());
|
||||
}
|
||||
|
||||
export function registerWorkProgramRoutes(app: App) {
|
||||
app.get("/v1/projects/:id/work-program", ...auth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const data = await getWorkProgram(db, Number(c.req.param("id")));
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id/work-program/concepts", ...auth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const data = await listConcepts(db, Number(c.req.param("id")));
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id/work-program/partidas", ...auth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const data = await listPartidas(db, Number(c.req.param("id")));
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id/work-program/vs-cost", ...auth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const data = await getVsCost(db, Number(c.req.param("id")));
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/work-program/preview", ...auth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const bytes = await readUpload(c);
|
||||
const preview = await previewWorkProgramExcel(bytes, Number(c.req.param("id")), db);
|
||||
return c.json({ ok: true, data: preview });
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/work-program/import", ...auth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const bytes = await readUpload(c);
|
||||
const createChapters = c.req.query("create_chapters") !== "false";
|
||||
const data = await importWorkProgramExcel(db, Number(c.req.param("id")), bytes, { createChapters });
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/work-program", ...auth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const data = await createEmptyWorkProgram(db, Number(c.req.param("id")));
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/work-program/generate", ...auth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const body = await c.req.json().catch(() => ({})) as { strategy?: string };
|
||||
const data = await generateWorkProgram(db, Number(c.req.param("id")), body.strategy || "keyword");
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.post("/v1/projects/:id/work-program/recalculate-partidas", ...auth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const env = await recalculatePartidas(db, Number(c.req.param("id")));
|
||||
return c.json({ ok: true, data: env });
|
||||
});
|
||||
|
||||
app.patch("/v1/projects/:id/work-program/concepts/:itemId/periods/:periodId", ...auth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const body = await c.req.json() as { start_pct?: number; end_pct?: number };
|
||||
const data = await updateConceptSlot(
|
||||
db,
|
||||
Number(c.req.param("id")),
|
||||
Number(c.req.param("itemId")),
|
||||
Number(c.req.param("periodId")),
|
||||
Number(body.start_pct ?? 0),
|
||||
Number(body.end_pct ?? 0),
|
||||
);
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.patch("/v1/projects/:id/work-program/partidas/:chapterId/periods/:periodId", ...auth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const body = await c.req.json() as { amount?: number };
|
||||
const data = await updatePartidaAmount(
|
||||
db,
|
||||
Number(c.req.param("id")),
|
||||
Number(c.req.param("chapterId")),
|
||||
Number(c.req.param("periodId")),
|
||||
Number(body.amount ?? 0),
|
||||
);
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.patch("/v1/projects/:id/work-program/progress/:itemId/periods/:periodId", ...auth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const body = await c.req.json() as { pct_complete?: number };
|
||||
const data = await updateProgress(
|
||||
db,
|
||||
Number(c.req.param("id")),
|
||||
Number(c.req.param("itemId")),
|
||||
Number(c.req.param("periodId")),
|
||||
Number(body.pct_complete ?? 0),
|
||||
);
|
||||
return c.json({ ok: true, data });
|
||||
});
|
||||
|
||||
app.get("/v1/projects/:id/work-program/export", ...auth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const kind = (c.req.query("kind") === "partida" ? "partida" : "concepto") as "concepto" | "partida";
|
||||
const project = await projectById(db, id);
|
||||
if (!project) return c.json({ error: "Proyecto no encontrado" }, 404);
|
||||
const bytes = await exportWorkProgram(db, id, kind, String(project.name || "Obra"));
|
||||
const filename = kind === "partida" ? "programa-partidas.xlsx" : "programa-conceptos.xlsx";
|
||||
c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
c.header("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
return c.body(bytes.slice());
|
||||
});
|
||||
}
|
||||
|
|
@ -35,5 +35,7 @@
|
|||
<include file="changesets/026-rpc-project-update-warehouse.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/027-iam-rpc-cross-grants.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/028-fix-cost-settings-ambiguous.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
|
|||
81
db/core/changesets/029-schema-work-program.sql
Normal file
81
db/core/changesets/029-schema-work-program.sql
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
--liquibase formatted sql
|
||||
-- PANELS · core · programa de obra (cronograma / erogaciones)
|
||||
|
||||
--changeset panel:core-029a-work-programs endDelimiter:; splitStatements:true
|
||||
--preconditions onFail:MARK_RAN
|
||||
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='work_programs'
|
||||
CREATE TABLE core.work_programs (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
duration_days INTEGER NOT NULL,
|
||||
period_unit TEXT NOT NULL DEFAULT 'week' CHECK (period_unit IN ('week')),
|
||||
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('import', 'wizard', 'manual')),
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (project_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_core_work_programs_project ON core.work_programs(project_id);
|
||||
|
||||
--changeset panel:core-029b-work-program-periods endDelimiter:; splitStatements:true
|
||||
--preconditions onFail:MARK_RAN
|
||||
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='work_program_periods'
|
||||
CREATE TABLE core.work_program_periods (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
program_id BIGINT NOT NULL REFERENCES core.work_programs(id) ON DELETE CASCADE,
|
||||
period_index INTEGER NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
UNIQUE (program_id, period_index)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_core_work_program_periods_program ON core.work_program_periods(program_id);
|
||||
|
||||
--changeset panel:core-029c-work-program-concept-slots endDelimiter:; splitStatements:true
|
||||
--preconditions onFail:MARK_RAN
|
||||
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='work_program_concept_slots'
|
||||
CREATE TABLE core.work_program_concept_slots (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
program_id BIGINT NOT NULL REFERENCES core.work_programs(id) ON DELETE CASCADE,
|
||||
budget_item_id BIGINT NOT NULL REFERENCES core.budget_items(id) ON DELETE CASCADE,
|
||||
period_id BIGINT NOT NULL REFERENCES core.work_program_periods(id) ON DELETE CASCADE,
|
||||
start_pct NUMERIC(6,2) NOT NULL DEFAULT 0,
|
||||
end_pct NUMERIC(6,2) NOT NULL DEFAULT 0,
|
||||
UNIQUE (program_id, budget_item_id, period_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_core_wp_concept_slots_program ON core.work_program_concept_slots(program_id);
|
||||
CREATE INDEX idx_core_wp_concept_slots_item ON core.work_program_concept_slots(budget_item_id);
|
||||
|
||||
--changeset panel:core-029d-work-program-partida-amounts endDelimiter:; splitStatements:true
|
||||
--preconditions onFail:MARK_RAN
|
||||
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='work_program_partida_amounts'
|
||||
CREATE TABLE core.work_program_partida_amounts (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
program_id BIGINT NOT NULL REFERENCES core.work_programs(id) ON DELETE CASCADE,
|
||||
budget_chapter_id BIGINT NOT NULL REFERENCES core.budget_chapters(id) ON DELETE CASCADE,
|
||||
period_id BIGINT NOT NULL REFERENCES core.work_program_periods(id) ON DELETE CASCADE,
|
||||
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
UNIQUE (program_id, budget_chapter_id, period_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_core_wp_partida_amounts_program ON core.work_program_partida_amounts(program_id);
|
||||
CREATE INDEX idx_core_wp_partida_amounts_chapter ON core.work_program_partida_amounts(budget_chapter_id);
|
||||
|
||||
--changeset panel:core-029e-work-program-progress endDelimiter:; splitStatements:true
|
||||
--preconditions onFail:MARK_RAN
|
||||
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='work_program_progress'
|
||||
CREATE TABLE core.work_program_progress (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
program_id BIGINT NOT NULL REFERENCES core.work_programs(id) ON DELETE CASCADE,
|
||||
budget_item_id BIGINT NOT NULL REFERENCES core.budget_items(id) ON DELETE CASCADE,
|
||||
period_id BIGINT NOT NULL REFERENCES core.work_program_periods(id) ON DELETE CASCADE,
|
||||
pct_complete NUMERIC(6,2) NOT NULL DEFAULT 0 CHECK (pct_complete >= 0 AND pct_complete <= 100),
|
||||
UNIQUE (program_id, budget_item_id, period_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_core_wp_progress_program ON core.work_program_progress(program_id);
|
||||
835
db/core/changesets/030-rpc-work-program.sql
Normal file
835
db/core/changesets/030-rpc-work-program.sql
Normal file
|
|
@ -0,0 +1,835 @@
|
|||
--liquibase formatted sql
|
||||
-- PANELS · core · RPC programa de obra
|
||||
|
||||
--changeset panel:core-030a-wp-generate-periods splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core._wp_generate_periods(p_start date, p_end date)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_periods jsonb := '[]'::jsonb;
|
||||
v_idx integer := 0;
|
||||
v_cur date := p_start;
|
||||
v_end date;
|
||||
BEGIN
|
||||
IF p_start IS NULL OR p_end IS NULL OR p_end < p_start THEN
|
||||
RETURN '[]'::jsonb;
|
||||
END IF;
|
||||
WHILE v_cur <= p_end LOOP
|
||||
v_idx := v_idx + 1;
|
||||
v_end := LEAST(v_cur + 6, p_end);
|
||||
v_periods := v_periods || jsonb_build_array(jsonb_build_object(
|
||||
'period_index', v_idx,
|
||||
'label', format('Sem %s', v_idx),
|
||||
'start_date', v_cur,
|
||||
'end_date', v_end
|
||||
));
|
||||
v_cur := v_cur + 7;
|
||||
END LOOP;
|
||||
RETURN v_periods;
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-030b-wp-assert-project splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core._wp_assert_project_ready(p_project_id bigint)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_start date;
|
||||
v_end date;
|
||||
v_item_count integer;
|
||||
BEGIN
|
||||
SELECT start_date, end_date INTO v_start, v_end FROM projects WHERE id = p_project_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN core.rpc_err('NOT_FOUND', format('Proyecto id=%s no encontrado', p_project_id),
|
||||
jsonb_build_object('project_id', p_project_id));
|
||||
END IF;
|
||||
IF v_start IS NULL OR v_end IS NULL THEN
|
||||
RETURN core.rpc_err('VALIDATION', 'El proyecto debe tener fecha de inicio y término para el programa de obra',
|
||||
jsonb_build_object('project_id', p_project_id, 'field', 'dates'));
|
||||
END IF;
|
||||
IF v_end < v_start THEN
|
||||
RETURN core.rpc_err('VALIDATION', 'La fecha de término debe ser posterior al inicio',
|
||||
jsonb_build_object('project_id', p_project_id));
|
||||
END IF;
|
||||
SELECT COUNT(*)::integer INTO v_item_count FROM budget_items WHERE project_id = p_project_id;
|
||||
IF v_item_count = 0 THEN
|
||||
RETURN core.rpc_err('VALIDATION', 'El proyecto debe tener presupuesto cargado antes del programa de obra',
|
||||
jsonb_build_object('project_id', p_project_id, 'field', 'budget'));
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-030c-wp-slot-weight splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core._wp_slot_weight(p_start_pct numeric, p_end_pct numeric)
|
||||
RETURNS numeric
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT GREATEST(0, COALESCE(p_end_pct, 0) - COALESCE(p_start_pct, 0)) / 100.0;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-030d-fn-work-program-get splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core.fn_work_program_get(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_program jsonb;
|
||||
v_periods jsonb;
|
||||
v_budget_total numeric(14,2);
|
||||
v_scheduled jsonb;
|
||||
v_weekly jsonb;
|
||||
v_cumulative numeric := 0;
|
||||
v_row jsonb;
|
||||
v_arr jsonb := '[]'::jsonb;
|
||||
v_pct_period numeric;
|
||||
v_pct_cum numeric;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err('VALIDATION', 'fn_work_program_get: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_work_program_get'));
|
||||
END IF;
|
||||
|
||||
SELECT to_jsonb(wp) INTO v_program
|
||||
FROM work_programs wp WHERE wp.project_id = v_project_id;
|
||||
|
||||
IF v_program IS NULL THEN
|
||||
SELECT COALESCE(SUM(amount), 0) INTO v_budget_total FROM budget_items WHERE project_id = v_project_id;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object(
|
||||
'program', NULL,
|
||||
'periods', '[]'::jsonb,
|
||||
'budget_subtotal', v_budget_total,
|
||||
'curve', '[]'::jsonb
|
||||
),
|
||||
format('Proyecto %s sin programa de obra', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_work_program_get', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.period_index), '[]'::jsonb) INTO v_periods
|
||||
FROM work_program_periods p WHERE p.program_id = (v_program->>'id')::bigint;
|
||||
|
||||
SELECT COALESCE(SUM(amount), 0) INTO v_budget_total FROM budget_items WHERE project_id = v_project_id;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'period_index', pr.period_index,
|
||||
'label', pr.label,
|
||||
'amount', round(COALESCE(SUM(pa.amount), 0), 2)
|
||||
) ORDER BY pr.period_index), '[]'::jsonb)
|
||||
INTO v_scheduled
|
||||
FROM work_program_periods pr
|
||||
LEFT JOIN work_program_partida_amounts pa ON pa.period_id = pr.id
|
||||
WHERE pr.program_id = (v_program->>'id')::bigint
|
||||
GROUP BY pr.period_index, pr.label;
|
||||
|
||||
FOR v_row IN SELECT value FROM jsonb_array_elements(v_scheduled) LOOP
|
||||
v_pct_period := CASE WHEN v_budget_total > 0
|
||||
THEN round((v_row.value->>'amount')::numeric / v_budget_total, 6) ELSE 0 END;
|
||||
v_cumulative := v_cumulative + (v_row.value->>'amount')::numeric;
|
||||
v_pct_cum := CASE WHEN v_budget_total > 0 THEN round(v_cumulative / v_budget_total, 6) ELSE 0 END;
|
||||
v_arr := v_arr || jsonb_build_array(jsonb_build_object(
|
||||
'period_index', v_row.value->'period_index',
|
||||
'label', v_row.value->'label',
|
||||
'amount', v_row.value->'amount',
|
||||
'pct_period', v_pct_period,
|
||||
'pct_cumulative', v_pct_cum
|
||||
));
|
||||
END LOOP;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object(
|
||||
'program', v_program,
|
||||
'periods', v_periods,
|
||||
'budget_subtotal', v_budget_total,
|
||||
'curve', v_arr
|
||||
),
|
||||
format('Programa de obra del proyecto %s', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_work_program_get', 'project_id', v_project_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_work_program_get', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-030e-fn-work-program-replace splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core.fn_work_program_replace(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_source text := COALESCE(nullif(btrim(payload->>'source'), ''), 'import');
|
||||
v_status text := COALESCE(nullif(btrim(payload->>'status'), ''), 'active');
|
||||
v_start date;
|
||||
v_end date;
|
||||
v_program_id bigint;
|
||||
v_period jsonb;
|
||||
v_period_id bigint;
|
||||
v_period_map jsonb := '{}'::jsonb;
|
||||
v_slot jsonb;
|
||||
v_partida jsonb;
|
||||
v_chapter jsonb;
|
||||
v_ch jsonb;
|
||||
v_slot_count integer := 0;
|
||||
v_partida_count integer := 0;
|
||||
v_err jsonb;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err('VALIDATION', 'fn_work_program_replace: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_work_program_replace'));
|
||||
END IF;
|
||||
|
||||
v_err := core._wp_assert_project_ready(v_project_id);
|
||||
IF v_err IS NOT NULL THEN RETURN v_err; END IF;
|
||||
|
||||
SELECT start_date, end_date INTO v_start, v_end FROM projects WHERE id = v_project_id;
|
||||
|
||||
DELETE FROM work_programs WHERE project_id = v_project_id;
|
||||
|
||||
INSERT INTO work_programs (project_id, start_date, end_date, duration_days, source, status)
|
||||
VALUES (v_project_id, v_start, v_end, (v_end - v_start + 1), v_source, v_status)
|
||||
RETURNING id INTO v_program_id;
|
||||
|
||||
FOR v_period IN SELECT * FROM jsonb_array_elements(core._wp_generate_periods(v_start, v_end)) LOOP
|
||||
INSERT INTO work_program_periods (program_id, period_index, label, start_date, end_date)
|
||||
VALUES (
|
||||
v_program_id,
|
||||
(v_period->>'period_index')::integer,
|
||||
v_period->>'label',
|
||||
(v_period->>'start_date')::date,
|
||||
(v_period->>'end_date')::date
|
||||
)
|
||||
RETURNING id INTO v_period_id;
|
||||
v_period_map := v_period_map || jsonb_build_object(v_period->>'period_index', v_period_id);
|
||||
END LOOP;
|
||||
|
||||
FOR v_slot IN SELECT * FROM jsonb_array_elements(COALESCE(payload->'concept_slots', '[]'::jsonb)) LOOP
|
||||
v_period_id := (v_period_map->>(v_slot->>'period_index'))::bigint;
|
||||
IF v_period_id IS NULL THEN CONTINUE; END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM budget_items bi
|
||||
WHERE bi.id = (v_slot->>'budget_item_id')::bigint AND bi.project_id = v_project_id
|
||||
) THEN CONTINUE; END IF;
|
||||
IF core._wp_slot_weight((v_slot->>'start_pct')::numeric, (v_slot->>'end_pct')::numeric) <= 0 THEN CONTINUE; END IF;
|
||||
INSERT INTO work_program_concept_slots (program_id, budget_item_id, period_id, start_pct, end_pct)
|
||||
VALUES (
|
||||
v_program_id,
|
||||
(v_slot->>'budget_item_id')::bigint,
|
||||
v_period_id,
|
||||
COALESCE((v_slot->>'start_pct')::numeric, 0),
|
||||
COALESCE((v_slot->>'end_pct')::numeric, 0)
|
||||
);
|
||||
v_slot_count := v_slot_count + 1;
|
||||
END LOOP;
|
||||
|
||||
FOR v_ch IN SELECT * FROM jsonb_array_elements(COALESCE(payload->'chapters_to_create', '[]'::jsonb)) LOOP
|
||||
INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order)
|
||||
VALUES (
|
||||
v_project_id,
|
||||
NULLIF(v_ch->>'parent_id', '')::bigint,
|
||||
upper(COALESCE(nullif(btrim(v_ch->>'code'), ''), '')),
|
||||
COALESCE(nullif(btrim(v_ch->>'name'), ''), 'Partida'),
|
||||
COALESCE(nullif(btrim(v_ch->>'wbs'), ''), ''),
|
||||
COALESCE((v_ch->>'sort_order')::integer, 0)
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
FOR v_partida IN SELECT * FROM jsonb_array_elements(COALESCE(payload->'partida_amounts', '[]'::jsonb)) LOOP
|
||||
v_period_id := (v_period_map->>(v_partida->>'period_index'))::bigint;
|
||||
IF v_period_id IS NULL THEN CONTINUE; END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM budget_chapters bc
|
||||
WHERE bc.id = (v_partida->>'budget_chapter_id')::bigint AND bc.project_id = v_project_id
|
||||
) THEN CONTINUE; END IF;
|
||||
IF COALESCE((v_partida->>'amount')::numeric, 0) = 0 THEN CONTINUE; END IF;
|
||||
INSERT INTO work_program_partida_amounts (program_id, budget_chapter_id, period_id, amount)
|
||||
VALUES (
|
||||
v_program_id,
|
||||
(v_partida->>'budget_chapter_id')::bigint,
|
||||
v_period_id,
|
||||
round(COALESCE((v_partida->>'amount')::numeric, 0), 2)
|
||||
);
|
||||
v_partida_count := v_partida_count + 1;
|
||||
END LOOP;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object(
|
||||
'program_id', v_program_id,
|
||||
'concept_slots', v_slot_count,
|
||||
'partida_amounts', v_partida_count,
|
||||
'period_count', jsonb_object_length(v_period_map)
|
||||
),
|
||||
format('Programa de obra del proyecto %s reemplazado (%s slots, %s erogaciones)',
|
||||
v_project_id, v_slot_count, v_partida_count),
|
||||
jsonb_build_object('fn', 'fn_work_program_replace', 'project_id', v_project_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_work_program_replace', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-030f-fn-work-program-list-concepts splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core.fn_work_program_list_concepts(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_program_id bigint;
|
||||
v_periods jsonb;
|
||||
v_rows jsonb;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err('VALIDATION', 'fn_work_program_list_concepts: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_work_program_list_concepts'));
|
||||
END IF;
|
||||
|
||||
SELECT id INTO v_program_id FROM work_programs WHERE project_id = v_project_id;
|
||||
IF v_program_id IS NULL THEN
|
||||
RETURN core.rpc_ok(jsonb_build_object('periods', '[]'::jsonb, 'items', '[]'::jsonb),
|
||||
format('Proyecto %s sin programa', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_work_program_list_concepts', 'project_id', v_project_id));
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.period_index), '[]'::jsonb) INTO v_periods
|
||||
FROM work_program_periods p WHERE p.program_id = v_program_id;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.ch_sort, x.sort_order, x.id), '[]'::jsonb) INTO v_rows
|
||||
FROM (
|
||||
SELECT
|
||||
bi.id,
|
||||
bi.code,
|
||||
bi.description,
|
||||
bi.unit,
|
||||
bi.amount,
|
||||
bi.chapter_id,
|
||||
ch.code AS chapter_code,
|
||||
ch.name AS chapter_name,
|
||||
ch.sort_order AS ch_sort,
|
||||
bi.sort_order,
|
||||
COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'period_id', pr.id,
|
||||
'period_index', pr.period_index,
|
||||
'start_pct', COALESCE(cs.start_pct, 0),
|
||||
'end_pct', COALESCE(cs.end_pct, 0)
|
||||
) ORDER BY pr.period_index)
|
||||
FROM work_program_periods pr
|
||||
LEFT JOIN work_program_concept_slots cs
|
||||
ON cs.period_id = pr.id AND cs.budget_item_id = bi.id AND cs.program_id = v_program_id
|
||||
WHERE pr.program_id = v_program_id
|
||||
), '[]'::jsonb) AS periods,
|
||||
COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'period_id', pr.id,
|
||||
'period_index', pr.period_index,
|
||||
'pct_complete', COALESCE(pg.pct_complete, 0)
|
||||
) ORDER BY pr.period_index)
|
||||
FROM work_program_periods pr
|
||||
LEFT JOIN work_program_progress pg
|
||||
ON pg.period_id = pr.id AND pg.budget_item_id = bi.id AND pg.program_id = v_program_id
|
||||
WHERE pr.program_id = v_program_id
|
||||
), '[]'::jsonb) AS progress
|
||||
FROM budget_items bi
|
||||
LEFT JOIN budget_chapters ch ON ch.id = bi.chapter_id
|
||||
WHERE bi.project_id = v_project_id
|
||||
) x;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('periods', v_periods, 'items', v_rows),
|
||||
format('Conceptos del programa del proyecto %s', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_work_program_list_concepts', 'project_id', v_project_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_work_program_list_concepts', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-030g-fn-work-program-list-partidas splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core.fn_work_program_list_partidas(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_program_id bigint;
|
||||
v_periods jsonb;
|
||||
v_rows jsonb;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err('VALIDATION', 'fn_work_program_list_partidas: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_work_program_list_partidas'));
|
||||
END IF;
|
||||
|
||||
SELECT id INTO v_program_id FROM work_programs WHERE project_id = v_project_id;
|
||||
IF v_program_id IS NULL THEN
|
||||
RETURN core.rpc_ok(jsonb_build_object('periods', '[]'::jsonb, 'chapters', '[]'::jsonb),
|
||||
format('Proyecto %s sin programa', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_work_program_list_partidas', 'project_id', v_project_id));
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.period_index), '[]'::jsonb) INTO v_periods
|
||||
FROM work_program_periods p WHERE p.program_id = v_program_id;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.sort_order, x.id), '[]'::jsonb) INTO v_rows
|
||||
FROM (
|
||||
SELECT
|
||||
bc.id,
|
||||
bc.code,
|
||||
bc.name,
|
||||
bc.sort_order,
|
||||
COALESCE(SUM(bi.amount), 0) AS budget_amount,
|
||||
COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'period_id', pr.id,
|
||||
'period_index', pr.period_index,
|
||||
'amount', round(COALESCE(pa.amount, 0), 2)
|
||||
) ORDER BY pr.period_index)
|
||||
FROM work_program_periods pr
|
||||
LEFT JOIN work_program_partida_amounts pa
|
||||
ON pa.period_id = pr.id AND pa.budget_chapter_id = bc.id AND pa.program_id = v_program_id
|
||||
WHERE pr.program_id = v_program_id
|
||||
), '[]'::jsonb) AS periods
|
||||
FROM budget_chapters bc
|
||||
LEFT JOIN budget_items bi ON bi.chapter_id = bc.id
|
||||
WHERE bc.project_id = v_project_id
|
||||
GROUP BY bc.id, bc.code, bc.name, bc.sort_order
|
||||
) x;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('periods', v_periods, 'chapters', v_rows),
|
||||
format('Partidas del programa del proyecto %s', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_work_program_list_partidas', 'project_id', v_project_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_work_program_list_partidas', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-030h-fn-work-program-update-concept splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core.fn_work_program_update_concept(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint;
|
||||
v_period_id bigint := NULLIF(payload->>'period_id', '')::bigint;
|
||||
v_program_id bigint;
|
||||
v_start numeric := COALESCE((payload->>'start_pct')::numeric, 0);
|
||||
v_end numeric := COALESCE((payload->>'end_pct')::numeric, 0);
|
||||
BEGIN
|
||||
IF v_project_id IS NULL OR v_item_id IS NULL OR v_period_id IS NULL THEN
|
||||
RETURN core.rpc_err('VALIDATION', 'project_id, budget_item_id y period_id son obligatorios',
|
||||
jsonb_build_object('fn', 'fn_work_program_update_concept'));
|
||||
END IF;
|
||||
|
||||
SELECT wp.id INTO v_program_id FROM work_programs wp WHERE wp.project_id = v_project_id;
|
||||
IF v_program_id IS NULL THEN
|
||||
RETURN core.rpc_err('NOT_FOUND', 'No hay programa de obra en este proyecto',
|
||||
jsonb_build_object('fn', 'fn_work_program_update_concept', 'project_id', v_project_id));
|
||||
END IF;
|
||||
|
||||
IF core._wp_slot_weight(v_start, v_end) <= 0 THEN
|
||||
DELETE FROM work_program_concept_slots
|
||||
WHERE program_id = v_program_id AND budget_item_id = v_item_id AND period_id = v_period_id;
|
||||
ELSE
|
||||
INSERT INTO work_program_concept_slots (program_id, budget_item_id, period_id, start_pct, end_pct)
|
||||
VALUES (v_program_id, v_item_id, v_period_id, v_start, v_end)
|
||||
ON CONFLICT (program_id, budget_item_id, period_id)
|
||||
DO UPDATE SET start_pct = EXCLUDED.start_pct, end_pct = EXCLUDED.end_pct;
|
||||
END IF;
|
||||
|
||||
UPDATE work_programs SET updated_at = now(), source = 'manual' WHERE id = v_program_id;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('budget_item_id', v_item_id, 'period_id', v_period_id, 'start_pct', v_start, 'end_pct', v_end),
|
||||
format('Slot de concepto %s actualizado', v_item_id),
|
||||
jsonb_build_object('fn', 'fn_work_program_update_concept', 'project_id', v_project_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_work_program_update_concept', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-030i-fn-work-program-update-partida splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core.fn_work_program_update_partida(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_chapter_id bigint := NULLIF(payload->>'budget_chapter_id', '')::bigint;
|
||||
v_period_id bigint := NULLIF(payload->>'period_id', '')::bigint;
|
||||
v_program_id bigint;
|
||||
v_amount numeric := round(COALESCE((payload->>'amount')::numeric, 0), 2);
|
||||
BEGIN
|
||||
IF v_project_id IS NULL OR v_chapter_id IS NULL OR v_period_id IS NULL THEN
|
||||
RETURN core.rpc_err('VALIDATION', 'project_id, budget_chapter_id y period_id son obligatorios',
|
||||
jsonb_build_object('fn', 'fn_work_program_update_partida'));
|
||||
END IF;
|
||||
|
||||
SELECT wp.id INTO v_program_id FROM work_programs wp WHERE wp.project_id = v_project_id;
|
||||
IF v_program_id IS NULL THEN
|
||||
RETURN core.rpc_err('NOT_FOUND', 'No hay programa de obra en este proyecto',
|
||||
jsonb_build_object('fn', 'fn_work_program_update_partida', 'project_id', v_project_id));
|
||||
END IF;
|
||||
|
||||
IF v_amount = 0 THEN
|
||||
DELETE FROM work_program_partida_amounts
|
||||
WHERE program_id = v_program_id AND budget_chapter_id = v_chapter_id AND period_id = v_period_id;
|
||||
ELSE
|
||||
INSERT INTO work_program_partida_amounts (program_id, budget_chapter_id, period_id, amount)
|
||||
VALUES (v_program_id, v_chapter_id, v_period_id, v_amount)
|
||||
ON CONFLICT (program_id, budget_chapter_id, period_id)
|
||||
DO UPDATE SET amount = EXCLUDED.amount;
|
||||
END IF;
|
||||
|
||||
UPDATE work_programs SET updated_at = now(), source = 'manual' WHERE id = v_program_id;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('budget_chapter_id', v_chapter_id, 'period_id', v_period_id, 'amount', v_amount),
|
||||
format('Erogación de partida %s actualizada', v_chapter_id),
|
||||
jsonb_build_object('fn', 'fn_work_program_update_partida', 'project_id', v_project_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_work_program_update_partida', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-030j-fn-work-program-update-progress splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core.fn_work_program_update_progress(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint;
|
||||
v_period_id bigint := NULLIF(payload->>'period_id', '')::bigint;
|
||||
v_program_id bigint;
|
||||
v_pct numeric := COALESCE((payload->>'pct_complete')::numeric, 0);
|
||||
BEGIN
|
||||
IF v_project_id IS NULL OR v_item_id IS NULL OR v_period_id IS NULL THEN
|
||||
RETURN core.rpc_err('VALIDATION', 'project_id, budget_item_id y period_id son obligatorios',
|
||||
jsonb_build_object('fn', 'fn_work_program_update_progress'));
|
||||
END IF;
|
||||
|
||||
SELECT wp.id INTO v_program_id FROM work_programs wp WHERE wp.project_id = v_project_id;
|
||||
IF v_program_id IS NULL THEN
|
||||
RETURN core.rpc_err('NOT_FOUND', 'No hay programa de obra en este proyecto',
|
||||
jsonb_build_object('fn', 'fn_work_program_update_progress', 'project_id', v_project_id));
|
||||
END IF;
|
||||
|
||||
IF v_pct <= 0 THEN
|
||||
DELETE FROM work_program_progress
|
||||
WHERE program_id = v_program_id AND budget_item_id = v_item_id AND period_id = v_period_id;
|
||||
ELSE
|
||||
INSERT INTO work_program_progress (program_id, budget_item_id, period_id, pct_complete)
|
||||
VALUES (v_program_id, v_item_id, v_period_id, LEAST(100, GREATEST(0, v_pct)))
|
||||
ON CONFLICT (program_id, budget_item_id, period_id)
|
||||
DO UPDATE SET pct_complete = EXCLUDED.pct_complete;
|
||||
END IF;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('budget_item_id', v_item_id, 'period_id', v_period_id, 'pct_complete', v_pct),
|
||||
format('Avance físico del concepto %s actualizado', v_item_id),
|
||||
jsonb_build_object('fn', 'fn_work_program_update_progress', 'project_id', v_project_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_work_program_update_progress', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-030k-fn-work-program-recalculate splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core.fn_work_program_recalculate_partidas(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_program_id bigint;
|
||||
v_updated integer := 0;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err('VALIDATION', 'fn_work_program_recalculate_partidas: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_work_program_recalculate_partidas'));
|
||||
END IF;
|
||||
|
||||
SELECT id INTO v_program_id FROM work_programs WHERE project_id = v_project_id;
|
||||
IF v_program_id IS NULL THEN
|
||||
RETURN core.rpc_err('NOT_FOUND', 'No hay programa de obra en este proyecto',
|
||||
jsonb_build_object('fn', 'fn_work_program_recalculate_partidas', 'project_id', v_project_id));
|
||||
END IF;
|
||||
|
||||
DELETE FROM work_program_partida_amounts WHERE program_id = v_program_id;
|
||||
|
||||
WITH item_weights AS (
|
||||
SELECT
|
||||
cs.budget_item_id,
|
||||
cs.period_id,
|
||||
bi.chapter_id,
|
||||
bi.amount AS item_amount,
|
||||
core._wp_slot_weight(cs.start_pct, cs.end_pct) AS slot_weight,
|
||||
SUM(core._wp_slot_weight(cs.start_pct, cs.end_pct)) OVER (PARTITION BY cs.budget_item_id) AS total_weight
|
||||
FROM work_program_concept_slots cs
|
||||
JOIN budget_items bi ON bi.id = cs.budget_item_id
|
||||
WHERE cs.program_id = v_program_id AND bi.chapter_id IS NOT NULL
|
||||
),
|
||||
allocations AS (
|
||||
SELECT
|
||||
iw.chapter_id,
|
||||
iw.period_id,
|
||||
round(SUM(
|
||||
CASE WHEN iw.total_weight > 0
|
||||
THEN iw.item_amount * iw.slot_weight / iw.total_weight
|
||||
ELSE 0 END
|
||||
), 2) AS amount
|
||||
FROM item_weights iw
|
||||
GROUP BY iw.chapter_id, iw.period_id
|
||||
HAVING SUM(
|
||||
CASE WHEN iw.total_weight > 0
|
||||
THEN iw.item_amount * iw.slot_weight / iw.total_weight
|
||||
ELSE 0 END
|
||||
) > 0
|
||||
)
|
||||
INSERT INTO work_program_partida_amounts (program_id, budget_chapter_id, period_id, amount)
|
||||
SELECT v_program_id, a.chapter_id, a.period_id, a.amount
|
||||
FROM allocations a;
|
||||
|
||||
GET DIAGNOSTICS v_updated = ROW_COUNT;
|
||||
|
||||
UPDATE work_programs SET updated_at = now() WHERE id = v_program_id;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('updated', v_updated),
|
||||
format('Recalculadas %s erogaciones por partida desde conceptos', v_updated),
|
||||
jsonb_build_object('fn', 'fn_work_program_recalculate_partidas', 'project_id', v_project_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_work_program_recalculate_partidas', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-030l-fn-work-program-generate splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core.fn_work_program_generate(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_strategy text := COALESCE(nullif(btrim(payload->>'strategy'), ''), 'wbs');
|
||||
v_start date;
|
||||
v_end date;
|
||||
v_period_count integer;
|
||||
v_program_id bigint;
|
||||
v_period jsonb;
|
||||
v_period_id bigint;
|
||||
v_period_map jsonb := '{}'::jsonb;
|
||||
v_item record;
|
||||
v_ch record;
|
||||
v_idx integer;
|
||||
v_start_week integer;
|
||||
v_end_week integer;
|
||||
v_name text;
|
||||
v_slots jsonb := '[]'::jsonb;
|
||||
v_err jsonb;
|
||||
v_total_items integer;
|
||||
v_item_idx integer := 0;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err('VALIDATION', 'fn_work_program_generate: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_work_program_generate'));
|
||||
END IF;
|
||||
|
||||
v_err := core._wp_assert_project_ready(v_project_id);
|
||||
IF v_err IS NOT NULL THEN RETURN v_err; END IF;
|
||||
|
||||
SELECT start_date, end_date INTO v_start, v_end FROM projects WHERE id = v_project_id;
|
||||
v_period_count := GREATEST(1, CEIL((v_end - v_start + 1)::numeric / 7)::integer);
|
||||
SELECT COUNT(*)::integer INTO v_total_items FROM budget_items WHERE project_id = v_project_id;
|
||||
|
||||
FOR v_item IN
|
||||
SELECT bi.id, bi.code, bi.description, bi.amount,
|
||||
COALESCE(ch.sort_order, 9999) AS ch_sort, bi.sort_order
|
||||
FROM budget_items bi
|
||||
LEFT JOIN budget_chapters ch ON ch.id = bi.chapter_id
|
||||
WHERE bi.project_id = v_project_id
|
||||
ORDER BY COALESCE(ch.sort_order, 9999), bi.sort_order, bi.id
|
||||
LOOP
|
||||
v_item_idx := v_item_idx + 1;
|
||||
v_name := upper(COALESCE(v_item.description, '') || ' ' || COALESCE((SELECT name FROM budget_chapters WHERE id = (
|
||||
SELECT chapter_id FROM budget_items WHERE id = v_item.id
|
||||
)), ''));
|
||||
|
||||
IF v_strategy = 'amount' THEN
|
||||
v_start_week := GREATEST(1, LEAST(v_period_count, CEIL(v_item_idx::numeric / v_total_items * v_period_count)::integer));
|
||||
v_end_week := GREATEST(v_start_week, LEAST(v_period_count, CEIL((v_item_idx + 1)::numeric / v_total_items * v_period_count)::integer));
|
||||
ELSIF v_strategy = 'keyword' THEN
|
||||
IF v_name ~ 'PRELIMIN|TRAZO|DEMOL|ALBA' THEN v_start_week := 1; v_end_week := GREATEST(1, LEAST(2, v_period_count));
|
||||
ELSIF v_name ~ 'HIDRO|SANIT|ELECT|CONTRA|INTERNET|TV|DATOS' THEN v_start_week := GREATEST(1, LEAST(2, v_period_count)); v_end_week := GREATEST(v_start_week, LEAST(CEIL(v_period_count * 0.7)::integer, v_period_count));
|
||||
ELSIF v_name ~ 'TABLAR|PLAFON|MURO' THEN v_start_week := GREATEST(1, LEAST(CEIL(v_period_count * 0.35)::integer, v_period_count)); v_end_week := GREATEST(v_start_week, LEAST(CEIL(v_period_count * 0.85)::integer, v_period_count));
|
||||
ELSIF v_name ~ 'PINTUR|ACABAD|WPC|PASTA' THEN v_start_week := GREATEST(1, LEAST(CEIL(v_period_count * 0.55)::integer, v_period_count)); v_end_week := GREATEST(v_start_week, LEAST(CEIL(v_period_count * 0.95)::integer, v_period_count));
|
||||
ELSIF v_name ~ 'CARPINT|PUERT|ZOCLO' THEN v_start_week := GREATEST(1, LEAST(CEIL(v_period_count * 0.75)::integer, v_period_count)); v_end_week := v_period_count;
|
||||
ELSE v_start_week := GREATEST(1, LEAST(v_period_count, CEIL(v_item_idx::numeric / v_total_items * v_period_count)::integer)); v_end_week := GREATEST(v_start_week, LEAST(v_period_count, v_start_week + 1));
|
||||
END IF;
|
||||
ELSE
|
||||
v_start_week := GREATEST(1, LEAST(v_period_count, CEIL(v_item_idx::numeric / v_total_items * v_period_count)::integer));
|
||||
v_end_week := GREATEST(v_start_week, LEAST(v_period_count, v_start_week + GREATEST(0, CEIL(v_period_count::numeric / v_total_items)::integer - 1)));
|
||||
END IF;
|
||||
|
||||
FOR v_idx IN v_start_week..v_end_week LOOP
|
||||
v_slots := v_slots || jsonb_build_array(jsonb_build_object(
|
||||
'budget_item_id', v_item.id,
|
||||
'period_index', v_idx,
|
||||
'start_pct', CASE WHEN v_idx = v_start_week AND v_start_week = v_end_week THEN 0 WHEN v_idx = v_start_week THEN 14.29 ELSE 0 END,
|
||||
'end_pct', CASE WHEN v_idx = v_end_week THEN 100 ELSE 100 END
|
||||
));
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
PERFORM core.fn_work_program_replace(jsonb_build_object(
|
||||
'project_id', v_project_id,
|
||||
'source', 'wizard',
|
||||
'status', 'active',
|
||||
'concept_slots', v_slots,
|
||||
'partida_amounts', '[]'::jsonb
|
||||
));
|
||||
|
||||
SELECT id INTO v_program_id FROM work_programs WHERE project_id = v_project_id;
|
||||
|
||||
PERFORM core.fn_work_program_recalculate_partidas(jsonb_build_object('project_id', v_project_id));
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('program_id', v_program_id, 'strategy', v_strategy, 'period_count', v_period_count),
|
||||
format('Programa generado automáticamente para proyecto %s (%s)', v_project_id, v_strategy),
|
||||
jsonb_build_object('fn', 'fn_work_program_generate', 'project_id', v_project_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_work_program_generate', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-030m-fn-work-program-vs-cost splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core.fn_work_program_vs_cost(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_program_id bigint;
|
||||
v_periods jsonb;
|
||||
v_by_period jsonb;
|
||||
v_by_chapter jsonb;
|
||||
v_budget_total numeric(14,2);
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err('VALIDATION', 'fn_work_program_vs_cost: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_work_program_vs_cost'));
|
||||
END IF;
|
||||
|
||||
SELECT id INTO v_program_id FROM work_programs WHERE project_id = v_project_id;
|
||||
IF v_program_id IS NULL THEN
|
||||
RETURN core.rpc_ok(jsonb_build_object('periods', '[]'::jsonb, 'by_period', '[]'::jsonb, 'by_chapter', '[]'::jsonb),
|
||||
format('Proyecto %s sin programa', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_work_program_vs_cost', 'project_id', v_project_id));
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(SUM(amount), 0) INTO v_budget_total FROM budget_items WHERE project_id = v_project_id;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.period_index), '[]'::jsonb) INTO v_periods
|
||||
FROM work_program_periods p WHERE p.program_id = v_program_id;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.period_index), '[]'::jsonb) INTO v_by_period
|
||||
FROM (
|
||||
SELECT
|
||||
pr.period_index,
|
||||
pr.label,
|
||||
pr.start_date,
|
||||
pr.end_date,
|
||||
round(COALESCE(SUM(pa.amount), 0), 2) AS scheduled_amount,
|
||||
round(COALESCE((
|
||||
SELECT SUM(e.subtotal)
|
||||
FROM expense_entries e
|
||||
WHERE e.project_id = v_project_id
|
||||
AND e.status = 'confirmed'
|
||||
AND e.counts_toward_budget = true
|
||||
AND e.expense_date BETWEEN pr.start_date AND pr.end_date
|
||||
), 0) + COALESCE((
|
||||
SELECT SUM(m.total_cost)
|
||||
FROM warehouse_movements m
|
||||
WHERE m.project_id = v_project_id
|
||||
AND m.movement_type = 'exit'
|
||||
AND m.movement_date BETWEEN pr.start_date AND pr.end_date
|
||||
), 0), 2) AS executed_amount
|
||||
FROM work_program_periods pr
|
||||
LEFT JOIN work_program_partida_amounts pa ON pa.period_id = pr.id AND pa.program_id = v_program_id
|
||||
WHERE pr.program_id = v_program_id
|
||||
GROUP BY pr.id, pr.period_index, pr.label, pr.start_date, pr.end_date
|
||||
) x;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(y) ORDER BY y.chapter_code), '[]'::jsonb) INTO v_by_chapter
|
||||
FROM (
|
||||
SELECT
|
||||
bc.id AS chapter_id,
|
||||
bc.code AS chapter_code,
|
||||
bc.name AS chapter_name,
|
||||
round(COALESCE(SUM(pa.amount), 0), 2) AS scheduled_amount,
|
||||
round(COALESCE(SUM(core._cost_executed_amount(bi.id)), 0), 2) AS executed_amount,
|
||||
round(COALESCE(SUM(bi.amount), 0), 2) AS budget_amount
|
||||
FROM budget_chapters bc
|
||||
LEFT JOIN budget_items bi ON bi.chapter_id = bc.id
|
||||
LEFT JOIN work_program_partida_amounts pa ON pa.budget_chapter_id = bc.id AND pa.program_id = v_program_id
|
||||
WHERE bc.project_id = v_project_id
|
||||
GROUP BY bc.id, bc.code, bc.name
|
||||
) y;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object(
|
||||
'periods', v_periods,
|
||||
'budget_subtotal', v_budget_total,
|
||||
'by_period', v_by_period,
|
||||
'by_chapter', v_by_chapter
|
||||
),
|
||||
format('Programado vs ejecutado del proyecto %s', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_work_program_vs_cost', 'project_id', v_project_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_work_program_vs_cost', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-030n-wp-grants splitStatements:false
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON core.work_programs TO panels_core_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON core.work_program_periods TO panels_core_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON core.work_program_concept_slots TO panels_core_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON core.work_program_partida_amounts TO panels_core_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON core.work_program_progress TO panels_core_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
|
||||
|
|
@ -17,6 +17,8 @@
|
|||
</div>
|
||||
<Button label="Editar registro" icon="pi pi-pencil" outlined class="w-full mt-3" @click="emit('edit')" />
|
||||
<Button label="Ver presupuesto" icon="pi pi-calculator" outlined class="w-full mt-2" @click="navigateTo(`/presupuesto?project=${projectId}`)" />
|
||||
<Button label="Programa de obra" icon="pi pi-calendar" outlined class="w-full mt-2" @click="navigateTo(`/programa-obra?project=${projectId}`)" />
|
||||
<Tag v-if="workProgramLabel" class="mt-2" :value="workProgramLabel" :severity="workProgramSeverity" />
|
||||
|
||||
<Tabs v-model:value="tab" class="mt-3 detail-tabs">
|
||||
<TabList>
|
||||
|
|
@ -77,6 +79,8 @@ const docsReady = computed(() => {
|
|||
return required.length > 0 && required.every((item) => item.present);
|
||||
});
|
||||
const docType = computed(() => props.doc || "contrato");
|
||||
const workProgramLabel = ref("");
|
||||
const workProgramSeverity = ref<"success" | "warn" | "secondary">("secondary");
|
||||
const rows = computed(() => {
|
||||
const p = view.value;
|
||||
if (!p) return [];
|
||||
|
|
@ -111,6 +115,18 @@ async function load() {
|
|||
loading.value = true;
|
||||
try {
|
||||
detail.value = await api<DetailData>(`/v1/projects/${projectId.value}`);
|
||||
try {
|
||||
const wp = await api<{ program: { status?: string } | null }>(`/v1/projects/${projectId.value}/work-program`);
|
||||
if (wp.program) {
|
||||
workProgramLabel.value = wp.program.status === "active" ? "Programa activo" : "Programa borrador";
|
||||
workProgramSeverity.value = wp.program.status === "active" ? "success" : "warn";
|
||||
} else {
|
||||
workProgramLabel.value = "Sin programa de obra";
|
||||
workProgramSeverity.value = "secondary";
|
||||
}
|
||||
} catch {
|
||||
workProgramLabel.value = "";
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
|
|
|||
84
web-panel/components/WorkProgramCostCompare.vue
Normal file
84
web-panel/components/WorkProgramCostCompare.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="summary.length" class="kpi-row mb-3">
|
||||
<div class="kpi-card">
|
||||
<span class="kpi-label">Programado total</span>
|
||||
<strong>{{ money(totalScheduled) }}</strong>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<span class="kpi-label">Ejecutado total</span>
|
||||
<strong>{{ money(totalExecuted) }}</strong>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<span class="kpi-label">Δ acumulado</span>
|
||||
<strong :class="deltaClass">{{ money(totalExecuted - totalScheduled) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable :value="summary" scrollable scroll-height="40vh" size="small" table-style="min-width: 52rem">
|
||||
<Column field="label" header="Semana" />
|
||||
<Column field="scheduled_amount" header="Programado">
|
||||
<template #body="{ data }">{{ money(data.scheduled_amount) }}</template>
|
||||
</Column>
|
||||
<Column field="executed_amount" header="Ejecutado">
|
||||
<template #body="{ data }">{{ money(data.executed_amount) }}</template>
|
||||
</Column>
|
||||
<Column header="Δ">
|
||||
<template #body="{ data }">
|
||||
<span :class="Number(data.executed_amount) > Number(data.scheduled_amount) ? 'cost-critical-text' : ''">
|
||||
{{ money(Number(data.executed_amount) - Number(data.scheduled_amount)) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<div class="section-title mt-4 mb-2">Por partida</div>
|
||||
<DataTable :value="byChapter" size="small" scrollable scroll-height="28vh">
|
||||
<Column field="chapter_code" header="Código" style="width: 6rem" />
|
||||
<Column field="chapter_name" header="Partida" />
|
||||
<Column field="scheduled_amount" header="Programado">
|
||||
<template #body="{ data }">{{ money(data.scheduled_amount) }}</template>
|
||||
</Column>
|
||||
<Column field="executed_amount" header="Ejecutado">
|
||||
<template #body="{ data }">{{ money(data.executed_amount) }}</template>
|
||||
</Column>
|
||||
<Column field="budget_amount" header="Presupuesto">
|
||||
<template #body="{ data }">{{ money(data.budget_amount) }}</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
byPeriod: { label: string; scheduled_amount: number; executed_amount: number }[];
|
||||
byChapter: { chapter_code: string; chapter_name: string; scheduled_amount: number; executed_amount: number; budget_amount: number }[];
|
||||
}>();
|
||||
|
||||
const summary = computed(() => props.byPeriod || []);
|
||||
const byChapter = computed(() => props.byChapter || []);
|
||||
|
||||
const totalScheduled = computed(() => summary.value.reduce((s, r) => s + Number(r.scheduled_amount || 0), 0));
|
||||
const totalExecuted = computed(() => summary.value.reduce((s, r) => s + Number(r.executed_amount || 0), 0));
|
||||
const deltaClass = computed(() => (totalExecuted.value > totalScheduled.value ? "cost-critical-text" : ""));
|
||||
|
||||
function money(v: number) {
|
||||
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kpi-row { display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.kpi-card {
|
||||
flex: 1;
|
||||
min-width: 9rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.kpi-label { font-size: 0.75rem; color: var(--text-color-secondary); }
|
||||
.section-title { font-weight: 600; font-size: 0.9rem; }
|
||||
</style>
|
||||
98
web-panel/components/WorkProgramGantt.vue
Normal file
98
web-panel/components/WorkProgramGantt.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<div class="gantt-panel">
|
||||
<div class="gantt-header">
|
||||
<span v-for="p in periods" :key="p.id" class="gantt-col-head">{{ p.label }}</span>
|
||||
</div>
|
||||
<div v-for="row in bars" :key="row.id" class="gantt-row">
|
||||
<div class="gantt-row-label">
|
||||
<span class="identifier">{{ row.code }}</span>
|
||||
<span>{{ row.label }}</span>
|
||||
</div>
|
||||
<div class="gantt-track">
|
||||
<div
|
||||
v-for="seg in row.segments"
|
||||
:key="`${row.id}-${seg.period_index}`"
|
||||
class="gantt-bar"
|
||||
:style="barStyle(seg)"
|
||||
:title="`${row.code} · ${seg.label}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Message v-if="!bars.length" severity="info" :closable="false">Sin partidas programadas.</Message>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type Period = { id: number; period_index: number; label: string; start_date?: string; end_date?: string };
|
||||
type ChapterRow = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
periods: { period_id: number; period_index: number; amount: number }[];
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
periods: Period[];
|
||||
chapters: ChapterRow[];
|
||||
drillConcepts?: { id: number; code: string; description: string; chapter_id: number | null; periods: { period_index: number; start_pct: number; end_pct: number }[] }[];
|
||||
drillChapterId?: number | null;
|
||||
}>();
|
||||
|
||||
const periodCount = computed(() => props.periods.length || 1);
|
||||
|
||||
const bars = computed(() => {
|
||||
if (props.drillChapterId && props.drillConcepts?.length) {
|
||||
return props.drillConcepts
|
||||
.filter((c) => c.chapter_id === props.drillChapterId)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
label: c.description,
|
||||
segments: props.periods
|
||||
.map((p) => {
|
||||
const slot = c.periods.find((x) => x.period_index === p.period_index);
|
||||
if (!slot || (slot.start_pct === 0 && slot.end_pct === 0)) return null;
|
||||
return { period_index: p.period_index, label: p.label, start_pct: slot.start_pct, end_pct: slot.end_pct };
|
||||
})
|
||||
.filter(Boolean) as { period_index: number; label: string; start_pct: number; end_pct: number }[],
|
||||
}));
|
||||
}
|
||||
return props.chapters.map((ch) => ({
|
||||
id: ch.id,
|
||||
code: ch.code,
|
||||
label: ch.name,
|
||||
segments: props.periods
|
||||
.map((p) => {
|
||||
const slot = ch.periods.find((x) => x.period_index === p.period_index);
|
||||
if (!slot || Number(slot.amount) <= 0) return null;
|
||||
return { period_index: p.period_index, label: p.label, start_pct: 0, end_pct: 100 };
|
||||
})
|
||||
.filter(Boolean) as { period_index: number; label: string; start_pct: number; end_pct: number }[],
|
||||
}));
|
||||
});
|
||||
|
||||
function barStyle(seg: { period_index: number; start_pct: number; end_pct: number }) {
|
||||
const n = periodCount.value;
|
||||
const colW = 100 / n;
|
||||
const left = (seg.period_index - 1) * colW + (seg.start_pct / 100) * colW;
|
||||
const width = Math.max(1, ((seg.end_pct - seg.start_pct) / 100) * colW);
|
||||
return { left: `${left}%`, width: `${width}%` };
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gantt-panel { display: flex; flex-direction: column; gap: 0.35rem; padding: 0.5rem 0; }
|
||||
.gantt-header { display: grid; grid-template-columns: 12rem 1fr; gap: 0.5rem; font-size: 0.7rem; color: var(--text-color-secondary); }
|
||||
.gantt-col-head { text-align: center; }
|
||||
.gantt-row { display: grid; grid-template-columns: 12rem 1fr; gap: 0.5rem; align-items: center; min-height: 2rem; }
|
||||
.gantt-row-label { display: flex; flex-direction: column; font-size: 0.8rem; overflow: hidden; }
|
||||
.gantt-track { position: relative; height: 1.25rem; background: var(--surface-100); border-radius: 4px; }
|
||||
.gantt-bar {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 3px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
59
web-panel/components/WorkProgramGenerateDialog.vue
Normal file
59
web-panel/components/WorkProgramGenerateDialog.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<Dialog v-model:visible="visibleModel" header="Generar programa automático" modal :style="{ width: '28rem' }">
|
||||
<Message severity="info" :closable="false">
|
||||
Crea un borrador distribuyendo los conceptos del presupuesto según la estrategia elegida.
|
||||
</Message>
|
||||
<FormField label="Estrategia" class="mt-3">
|
||||
<Select v-model="strategy" :options="options" option-label="label" option-value="value" class="w-full" />
|
||||
</FormField>
|
||||
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="visibleModel = false" />
|
||||
<Button label="Generar" icon="pi pi-bolt" :loading="busy" @click="run" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FormField from "~/components/padron/FormField.vue";
|
||||
|
||||
const props = defineProps<{ visible: boolean; projectId: number }>();
|
||||
const emit = defineEmits<{ "update:visible": [boolean]; generated: [] }>();
|
||||
|
||||
const { api } = useApi();
|
||||
const toast = useToast();
|
||||
|
||||
const visibleModel = computed({
|
||||
get: () => props.visible,
|
||||
set: (v) => emit("update:visible", v),
|
||||
});
|
||||
|
||||
const strategy = ref("keyword");
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const options = [
|
||||
{ value: "keyword", label: "Por tipo de partida (preliminares → acabados)" },
|
||||
{ value: "wbs", label: "Por orden WBS del presupuesto" },
|
||||
{ value: "amount", label: "Por peso económico" },
|
||||
];
|
||||
|
||||
async function run() {
|
||||
if (!props.projectId) return;
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await api(`/v1/projects/${props.projectId}/work-program/generate`, {
|
||||
method: "POST",
|
||||
json: { strategy: strategy.value },
|
||||
});
|
||||
toast.add({ severity: "success", summary: "Programa generado", life: 2500 });
|
||||
emit("generated");
|
||||
visibleModel.value = false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "No se pudo generar";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
121
web-panel/components/WorkProgramGrid.vue
Normal file
121
web-panel/components/WorkProgramGrid.vue
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<template>
|
||||
<div class="wp-grid-wrap">
|
||||
<DataTable :value="rows" scrollable scroll-height="flex" table-style="min-width: 48rem" size="small">
|
||||
<Column frozen :header="rowLabel" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="wp-row-label">
|
||||
<span class="identifier">{{ data.code || "—" }}</span>
|
||||
<span class="wp-desc">{{ shortDesc(data.label) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column v-for="p in periods" :key="p.id" :header="p.label" style="width: 7rem">
|
||||
<template #body="{ data }">
|
||||
<div v-if="mode === 'concept'" class="wp-cell" :class="{ active: cellActive(data, p.id) }">
|
||||
<InputNumber
|
||||
:model-value="cellStart(data, p.id)"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:min-fraction-digits="0"
|
||||
:max-fraction-digits="2"
|
||||
class="wp-mini"
|
||||
@update:model-value="(v: number | null) => patchConcept(data, p.id, v ?? 0, cellEnd(data, p.id))"
|
||||
/>
|
||||
<span class="wp-sep">–</span>
|
||||
<InputNumber
|
||||
:model-value="cellEnd(data, p.id)"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:min-fraction-digits="0"
|
||||
:max-fraction-digits="2"
|
||||
class="wp-mini"
|
||||
@update:model-value="(v: number | null) => patchConcept(data, p.id, cellStart(data, p.id), v ?? 0)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="wp-cell" :class="{ active: cellAmount(data, p.id) > 0 }">
|
||||
<InputNumber
|
||||
:model-value="cellAmount(data, p.id)"
|
||||
mode="currency"
|
||||
currency="MXN"
|
||||
locale="es-MX"
|
||||
:min="0"
|
||||
class="w-full"
|
||||
@update:model-value="(v: number | null) => patchPartida(data, p.id, v ?? 0)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type Period = { id: number; period_index: number; label: string };
|
||||
type GridRow = {
|
||||
id: number;
|
||||
code: string;
|
||||
label: string;
|
||||
periods: { period_id: number; start_pct?: number; end_pct?: number; amount?: number }[];
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
mode: "concept" | "partida";
|
||||
periods: Period[];
|
||||
rows: GridRow[];
|
||||
projectId: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ updated: [] }>();
|
||||
const { api } = useApi();
|
||||
|
||||
const rowLabel = computed(() => (props.mode === "concept" ? "Concepto" : "Partida"));
|
||||
|
||||
function shortDesc(v: string) {
|
||||
const t = String(v || "").trim();
|
||||
return t.length > 48 ? `${t.slice(0, 45)}…` : t;
|
||||
}
|
||||
|
||||
function periodData(row: GridRow, periodId: number) {
|
||||
return row.periods.find((p) => p.period_id === periodId);
|
||||
}
|
||||
|
||||
function cellStart(row: GridRow, periodId: number) {
|
||||
return Number(periodData(row, periodId)?.start_pct ?? 0);
|
||||
}
|
||||
function cellEnd(row: GridRow, periodId: number) {
|
||||
return Number(periodData(row, periodId)?.end_pct ?? 0);
|
||||
}
|
||||
function cellAmount(row: GridRow, periodId: number) {
|
||||
return Number(periodData(row, periodId)?.amount ?? 0);
|
||||
}
|
||||
function cellActive(row: GridRow, periodId: number) {
|
||||
return cellStart(row, periodId) > 0 || cellEnd(row, periodId) > 0;
|
||||
}
|
||||
|
||||
async function patchConcept(row: GridRow, periodId: number, start: number, end: number) {
|
||||
await api(`/v1/projects/${props.projectId}/work-program/concepts/${row.id}/periods/${periodId}`, {
|
||||
method: "PATCH",
|
||||
json: { start_pct: start, end_pct: end },
|
||||
});
|
||||
emit("updated");
|
||||
}
|
||||
|
||||
async function patchPartida(row: GridRow, periodId: number, amount: number) {
|
||||
await api(`/v1/projects/${props.projectId}/work-program/partidas/${row.id}/periods/${periodId}`, {
|
||||
method: "PATCH",
|
||||
json: { amount },
|
||||
});
|
||||
emit("updated");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wp-row-label { display: flex; flex-direction: column; gap: 0.15rem; }
|
||||
.wp-desc { font-size: 0.75rem; color: var(--text-color-secondary); }
|
||||
.wp-cell { padding: 0.15rem; border-radius: 4px; }
|
||||
.wp-cell.active { background: color-mix(in srgb, var(--primary-color) 12%, transparent); }
|
||||
.wp-cell { display: flex; align-items: center; gap: 0.15rem; }
|
||||
.wp-mini { width: 3rem; }
|
||||
.wp-sep { opacity: 0.5; font-size: 0.7rem; }
|
||||
.wp-grid-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
</style>
|
||||
127
web-panel/components/WorkProgramImportDialog.vue
Normal file
127
web-panel/components/WorkProgramImportDialog.vue
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visibleModel"
|
||||
header="Importar programa de obra (Excel Neodata/Opus)"
|
||||
modal
|
||||
maximizable
|
||||
:style="{ width: 'min(96vw, 88rem)' }"
|
||||
>
|
||||
<Message severity="info" :closable="false">
|
||||
Suba el Excel <strong>por concepto</strong> (Barra=) o <strong>por partida</strong> (erogaciones semanales).
|
||||
Debe existir presupuesto cargado en <strong>{{ projectName }}</strong>.
|
||||
</Message>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-3">
|
||||
<input ref="fileInput" type="file" accept=".xlsx,.xls" class="hidden-file" @change="onFile" />
|
||||
<Button label="Seleccionar Excel" icon="pi pi-upload" :disabled="busy" @click="pickFile" />
|
||||
<span v-if="fileName" class="muted">{{ fileName }}</span>
|
||||
</div>
|
||||
<ProgressBar v-if="busy" mode="indeterminate" class="mt-2" style="height: 4px" />
|
||||
|
||||
<template v-if="preview">
|
||||
<div class="import-summary mt-3">
|
||||
<div><strong>{{ preview.format_label }}</strong><span>Formato</span></div>
|
||||
<div><strong>{{ preview.period_count }}</strong><span>Semanas</span></div>
|
||||
<div v-if="preview.format === 'concepto'"><strong>{{ preview.concepts.length }}</strong><span>Conceptos</span></div>
|
||||
<div v-if="preview.format === 'partida'"><strong>{{ preview.partidas.length }}</strong><span>Partidas</span></div>
|
||||
<div><strong>{{ preview.unmatched_codes?.length || 0 }}</strong><span>Sin match</span></div>
|
||||
</div>
|
||||
<Message v-for="(w, i) in preview.warnings" :key="i" severity="warn" class="mt-2" :closable="false">{{ w }}</Message>
|
||||
<Message v-if="preview.unmatched_codes?.length" severity="warn" class="mt-2" :closable="false">
|
||||
Códigos no encontrados en presupuesto: {{ preview.unmatched_codes.join(", ") }}
|
||||
</Message>
|
||||
</template>
|
||||
|
||||
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cerrar" text @click="visibleModel = false" />
|
||||
<Button label="Importar" icon="pi pi-check" :disabled="!preview || preview.format === 'none'" :loading="saving" @click="confirm" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ visible: boolean; projectId: number; projectName: string }>();
|
||||
const emit = defineEmits<{ "update:visible": [boolean]; imported: [] }>();
|
||||
|
||||
const { api } = useApi();
|
||||
const toast = useToast();
|
||||
|
||||
const visibleModel = computed({
|
||||
get: () => props.visible,
|
||||
set: (v) => emit("update:visible", v),
|
||||
});
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const fileName = ref("");
|
||||
const pending = ref<File | null>(null);
|
||||
const preview = ref<Record<string, unknown> | null>(null);
|
||||
const busy = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
function pickFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onFile(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file || !props.projectId) return;
|
||||
pending.value = file;
|
||||
fileName.value = file.name;
|
||||
error.value = "";
|
||||
busy.value = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
preview.value = await api(`/v1/projects/${props.projectId}/work-program/preview`, { method: "POST", body: fd });
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "No se pudo leer el Excel";
|
||||
preview.value = null;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!pending.value || !props.projectId) return;
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", pending.value);
|
||||
await api(`/v1/projects/${props.projectId}/work-program/import?create_chapters=true`, { method: "POST", body: fd });
|
||||
toast.add({ severity: "success", summary: "Programa importado", life: 2500 });
|
||||
emit("imported");
|
||||
visibleModel.value = false;
|
||||
preview.value = null;
|
||||
pending.value = null;
|
||||
fileName.value = "";
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Error al importar";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.import-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.import-summary div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.import-summary span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.hidden-file { display: none; }
|
||||
</style>
|
||||
|
|
@ -144,6 +144,7 @@ const menu = [
|
|||
{ label: "Inicio", icon: "pi pi-home", to: "/", command: () => navigateTo("/") },
|
||||
{ label: "Proyectos", icon: "pi pi-building", to: "/proyectos", command: () => navigateTo("/proyectos") },
|
||||
{ label: "Presupuesto", icon: "pi pi-calculator", to: "/presupuesto", command: () => navigateTo("/presupuesto") },
|
||||
{ label: "Programa de obra", icon: "pi pi-calendar", to: "/programa-obra", command: () => navigateTo("/programa-obra") },
|
||||
{ label: "Control costos", icon: "pi pi-chart-bar", to: "/control-presupuesto", command: () => navigateTo("/control-presupuesto") },
|
||||
{ label: "Gastos", icon: "pi pi-money-bill", to: "/gastos", command: () => navigateTo("/gastos") },
|
||||
{ label: "Almacén", icon: "pi pi-box", to: "/almacen", command: () => navigateTo("/almacen") },
|
||||
|
|
|
|||
|
|
@ -17,6 +17,21 @@
|
|||
</div>
|
||||
</Message>
|
||||
|
||||
<Message
|
||||
v-else-if="currentProject?.start_date && currentProject?.end_date && items.length"
|
||||
severity="success"
|
||||
:closable="true"
|
||||
>
|
||||
Presupuesto listo. Puede crear el programa de obra para esta obra.
|
||||
<Button
|
||||
label="Ir a Programa de obra"
|
||||
icon="pi pi-calendar"
|
||||
class="mt-2"
|
||||
size="small"
|
||||
@click="navigateTo({ path: '/programa-obra', query: { project: currentProject?.id } })"
|
||||
/>
|
||||
</Message>
|
||||
|
||||
<DataTable
|
||||
v-model:selection="selected"
|
||||
:value="listed"
|
||||
|
|
@ -106,7 +121,7 @@
|
|||
import FormField from "~/components/padron/FormField.vue";
|
||||
import BudgetImportDialog from "~/components/BudgetImportDialog.vue";
|
||||
|
||||
type Project = { id: number; code: string; name: string; status: string; budget_count?: number };
|
||||
type Project = { id: number; code: string; name: string; status: string; budget_count?: number; start_date?: string | null; end_date?: string | null };
|
||||
type Chapter = {
|
||||
id: number;
|
||||
name: string;
|
||||
|
|
|
|||
353
web-panel/pages/programa-obra.vue
Normal file
353
web-panel/pages/programa-obra.vue
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
<template>
|
||||
<div class="programa-page">
|
||||
<DeskTableBar v-model="tableQ" placeholder="Buscar concepto o partida">
|
||||
<Chip :label="projectName" icon="pi pi-building" />
|
||||
<Chip v-if="program" :label="programStatus" icon="pi pi-calendar" />
|
||||
<Chip v-if="curve.length" :label="`${periods.length} semanas`" icon="pi pi-clock" />
|
||||
</DeskTableBar>
|
||||
|
||||
<Message v-if="!currentProject" severity="info" :closable="false">Seleccione una obra en el árbol.</Message>
|
||||
|
||||
<Message v-else-if="!hasBudget" severity="warn" :closable="false">
|
||||
Esta obra no tiene presupuesto. Cárguelo primero en Presupuesto.
|
||||
<Button label="Ir a Presupuesto" class="mt-2" size="small" @click="goPresupuesto" />
|
||||
</Message>
|
||||
|
||||
<Message v-else-if="!hasDates" severity="warn" :closable="false">
|
||||
Defina fecha de inicio y término en la ficha del proyecto.
|
||||
<Button label="Ir a Proyectos" class="mt-2" size="small" @click="navigateTo('/proyectos')" />
|
||||
</Message>
|
||||
|
||||
<Message v-else-if="!program" severity="info" :closable="false">
|
||||
Presupuesto listo. Cree el programa importando Excel Neodata, generándolo automáticamente o en blanco.
|
||||
<div class="flex flex-wrap gap-2 mt-3">
|
||||
<Button label="Importar Excel" icon="pi pi-upload" @click="showImport = true" />
|
||||
<Button label="Generar automático" icon="pi pi-bolt" outlined @click="showGenerate = true" />
|
||||
<Button label="Crear vacío" icon="pi pi-plus" outlined :loading="creating" @click="createEmpty" />
|
||||
</div>
|
||||
</Message>
|
||||
|
||||
<template v-else>
|
||||
<TabView v-model:active-index="activeTab" class="programa-tabs">
|
||||
<TabPanel header="Conceptos">
|
||||
<WorkProgramGrid
|
||||
mode="concept"
|
||||
:periods="periods"
|
||||
:rows="conceptRows"
|
||||
:project-id="loadedProjectId"
|
||||
@updated="reload"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel header="Partidas">
|
||||
<WorkProgramGrid
|
||||
mode="partida"
|
||||
:periods="periods"
|
||||
:rows="partidaRows"
|
||||
:project-id="loadedProjectId"
|
||||
@updated="reload"
|
||||
/>
|
||||
<div v-if="curve.length" class="curve-row mt-3">
|
||||
<div v-for="c in curve" :key="c.period_index" class="curve-cell">
|
||||
<span class="curve-label">{{ c.label }}</span>
|
||||
<strong>{{ money(c.amount) }}</strong>
|
||||
<span class="curve-pct">{{ pct(c.pct_period) }} · acum {{ pct(c.pct_cumulative) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel header="Gantt">
|
||||
<div class="gantt-toolbar mb-2">
|
||||
<Select
|
||||
v-model="ganttChapterId"
|
||||
:options="ganttChapterOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Todas las partidas"
|
||||
show-clear
|
||||
class="gantt-select"
|
||||
/>
|
||||
</div>
|
||||
<WorkProgramGantt
|
||||
:periods="periods"
|
||||
:chapters="ganttChapters"
|
||||
:drill-chapter-id="ganttChapterId"
|
||||
:drill-concepts="ganttConcepts"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel header="Programado vs costos">
|
||||
<WorkProgramCostCompare :by-period="vsCostPeriods" :by-chapter="vsCostChapters" />
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</template>
|
||||
|
||||
<WorkProgramImportDialog
|
||||
v-model:visible="showImport"
|
||||
:project-id="loadedProjectId"
|
||||
:project-name="projectName"
|
||||
@imported="reload"
|
||||
/>
|
||||
<WorkProgramGenerateDialog
|
||||
v-model:visible="showGenerate"
|
||||
:project-id="loadedProjectId"
|
||||
@generated="reload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import WorkProgramGrid from "~/components/WorkProgramGrid.vue";
|
||||
import WorkProgramGantt from "~/components/WorkProgramGantt.vue";
|
||||
import WorkProgramCostCompare from "~/components/WorkProgramCostCompare.vue";
|
||||
import WorkProgramImportDialog from "~/components/WorkProgramImportDialog.vue";
|
||||
import WorkProgramGenerateDialog from "~/components/WorkProgramGenerateDialog.vue";
|
||||
|
||||
type Project = {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
budget_count?: number;
|
||||
};
|
||||
type Period = { id: number; period_index: number; label: string; start_date?: string; end_date?: string };
|
||||
type CurvePoint = { period_index: number; label: string; amount: number; pct_period: number; pct_cumulative: number };
|
||||
|
||||
const desk = useDesktop();
|
||||
const route = useRoute();
|
||||
const { api, download } = useApi();
|
||||
const toast = useToast();
|
||||
|
||||
const projects = ref<Project[]>([]);
|
||||
const loadedProjectId = ref(0);
|
||||
const program = ref<Record<string, unknown> | null>(null);
|
||||
const periods = ref<Period[]>([]);
|
||||
const curve = ref<CurvePoint[]>([]);
|
||||
const conceptItems = ref<Record<string, unknown>[]>([]);
|
||||
const partidaChapters = ref<Record<string, unknown>[]>([]);
|
||||
const vsCostPeriods = ref<Record<string, unknown>[]>([]);
|
||||
const vsCostChapters = ref<Record<string, unknown>[]>([]);
|
||||
const activeTab = ref(0);
|
||||
const showImport = ref(false);
|
||||
const showGenerate = ref(false);
|
||||
const creating = ref(false);
|
||||
const ganttChapterId = ref<number | null>(null);
|
||||
const { tableQ, matches } = useTableSearch();
|
||||
|
||||
const currentProject = computed(() => projects.value.find((p) => p.id === loadedProjectId.value) || null);
|
||||
const projectName = computed(() => currentProject.value?.name || "Sin proyecto");
|
||||
const hasBudget = computed(() => Number(currentProject.value?.budget_count || 0) > 0);
|
||||
const hasDates = computed(() => !!(currentProject.value?.start_date && currentProject.value?.end_date));
|
||||
const programStatus = computed(() => {
|
||||
const s = String(program.value?.status || "draft");
|
||||
return s === "active" ? "Programa activo" : "Borrador";
|
||||
});
|
||||
|
||||
const conceptRows = computed(() =>
|
||||
conceptItems.value
|
||||
.filter((i) => matches(i.code, i.description, i.chapter_name))
|
||||
.map((i) => ({
|
||||
id: Number(i.id),
|
||||
code: String(i.code || ""),
|
||||
label: String(i.description || ""),
|
||||
periods: (i.periods as { period_id: number; start_pct?: number; end_pct?: number }[]) || [],
|
||||
})),
|
||||
);
|
||||
|
||||
const partidaRows = computed(() =>
|
||||
partidaChapters.value
|
||||
.filter((c) => matches(c.code, c.name))
|
||||
.map((c) => ({
|
||||
id: Number(c.id),
|
||||
code: String(c.code || ""),
|
||||
label: String(c.name || ""),
|
||||
periods: (c.periods as { period_id: number; amount?: number }[]) || [],
|
||||
})),
|
||||
);
|
||||
|
||||
const ganttChapterOptions = computed(() =>
|
||||
partidaChapters.value.map((c) => ({
|
||||
value: Number(c.id),
|
||||
label: [c.code, c.name].filter(Boolean).join(" "),
|
||||
})),
|
||||
);
|
||||
|
||||
const ganttChapters = computed(() =>
|
||||
partidaChapters.value.map((c) => ({
|
||||
id: Number(c.id),
|
||||
code: String(c.code || ""),
|
||||
name: String(c.name || ""),
|
||||
periods: ((c.periods as { period_id: number; period_index: number; amount: number }[]) || []),
|
||||
})),
|
||||
);
|
||||
|
||||
const ganttConcepts = computed(() =>
|
||||
conceptItems.value.map((c) => ({
|
||||
id: Number(c.id),
|
||||
code: String(c.code || ""),
|
||||
description: String(c.description || ""),
|
||||
chapter_id: c.chapter_id != null ? Number(c.chapter_id) : null,
|
||||
periods: ((c.periods as { period_index: number; start_pct: number; end_pct: number }[]) || []),
|
||||
})),
|
||||
);
|
||||
|
||||
function money(v: number) {
|
||||
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
||||
}
|
||||
function pct(v: number) {
|
||||
return `${(Number(v || 0) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function goPresupuesto() {
|
||||
navigateTo({ path: "/presupuesto", query: { project: loadedProjectId.value } });
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const res = await api<{ projects: Project[] }>("/v1/projects");
|
||||
projects.value = res.projects;
|
||||
}
|
||||
|
||||
async function loadProgram(id: number) {
|
||||
try {
|
||||
const wp = await api<{ program: Record<string, unknown> | null; periods: Period[]; curve: CurvePoint[] }>(
|
||||
`/v1/projects/${id}/work-program`,
|
||||
);
|
||||
program.value = wp.program;
|
||||
periods.value = wp.periods || [];
|
||||
curve.value = wp.curve || [];
|
||||
if (wp.program) {
|
||||
const [concepts, partidas, vs] = await Promise.all([
|
||||
api<{ items: Record<string, unknown>[] }>(`/v1/projects/${id}/work-program/concepts`),
|
||||
api<{ chapters: Record<string, unknown>[] }>(`/v1/projects/${id}/work-program/partidas`),
|
||||
api<{ by_period: Record<string, unknown>[]; by_chapter: Record<string, unknown>[] }>(
|
||||
`/v1/projects/${id}/work-program/vs-cost`,
|
||||
),
|
||||
]);
|
||||
conceptItems.value = concepts.items || [];
|
||||
partidaChapters.value = partidas.chapters || [];
|
||||
vsCostPeriods.value = vs.by_period || [];
|
||||
vsCostChapters.value = vs.by_chapter || [];
|
||||
} else {
|
||||
conceptItems.value = [];
|
||||
partidaChapters.value = [];
|
||||
vsCostPeriods.value = [];
|
||||
vsCostChapters.value = [];
|
||||
}
|
||||
} catch (err) {
|
||||
toast.add({ severity: "error", summary: err instanceof Error ? err.message : "Error al cargar programa", life: 3500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
if (loadedProjectId.value) await loadProgram(loadedProjectId.value);
|
||||
syncChrome();
|
||||
}
|
||||
|
||||
async function createEmpty() {
|
||||
creating.value = true;
|
||||
try {
|
||||
await api(`/v1/projects/${loadedProjectId.value}/work-program`, { method: "POST" });
|
||||
await reload();
|
||||
} catch (err) {
|
||||
toast.add({ severity: "error", summary: err instanceof Error ? err.message : "No se pudo crear", life: 3000 });
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function recalculate() {
|
||||
await api(`/v1/projects/${loadedProjectId.value}/work-program/recalculate-partidas`, { method: "POST" });
|
||||
toast.add({ severity: "success", summary: "Partidas recalculadas desde conceptos", life: 2000 });
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function exportExcel(kind: "concepto" | "partida") {
|
||||
const name = kind === "partida" ? "programa-partidas.xlsx" : "programa-conceptos.xlsx";
|
||||
await download(`/v1/projects/${loadedProjectId.value}/work-program/export?kind=${kind}`, name);
|
||||
}
|
||||
|
||||
function syncChrome() {
|
||||
const project = currentProject.value;
|
||||
desk.configure({
|
||||
tab: "Programa de obra",
|
||||
treeTitle: "Obras",
|
||||
showTree: true,
|
||||
showDetail: false,
|
||||
treeKey: desk.treeKey.value || (project ? `p:${project.id}` : ""),
|
||||
countLabel: program.value ? `${periods.value.length} semanas` : "",
|
||||
tree: projects.value.map((item) => ({
|
||||
key: `p:${item.id}`,
|
||||
label: item.name,
|
||||
icon: "pi pi-building",
|
||||
data: { count: item.id === project?.id ? conceptItems.value.length : 0 },
|
||||
})),
|
||||
ribbon: [
|
||||
{
|
||||
key: "programa",
|
||||
label: "Programa",
|
||||
items: [
|
||||
{ key: "import", label: "Importar", icon: "pi pi-upload", disabled: !project || !hasBudget.value, command: () => { showImport.value = true; } },
|
||||
{ key: "generate", label: "Generar", icon: "pi pi-bolt", disabled: !project || !hasBudget.value, command: () => { showGenerate.value = true; } },
|
||||
{ key: "recalc", label: "Recalcular partidas", icon: "pi pi-calculator", disabled: !program.value, command: recalculate },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "datos",
|
||||
label: "Datos",
|
||||
items: [
|
||||
{ key: "exp-c", label: "Export conceptos", icon: "pi pi-download", disabled: !program.value, command: () => exportExcel("concepto") },
|
||||
{ key: "exp-p", label: "Export partidas", icon: "pi pi-download", disabled: !program.value, command: () => exportExcel("partida") },
|
||||
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", disabled: !project, command: reload },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => desk.treeKey.value,
|
||||
async (key) => {
|
||||
if (key?.startsWith("p:")) {
|
||||
const id = Number(key.split(":")[1]);
|
||||
if (id && id !== loadedProjectId.value) {
|
||||
loadedProjectId.value = id;
|
||||
await loadProgram(id);
|
||||
syncChrome();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProjects();
|
||||
const fromQuery = Number(route.query.project || 0);
|
||||
const initial = fromQuery || projects.value[0]?.id;
|
||||
if (initial) {
|
||||
loadedProjectId.value = initial;
|
||||
desk.treeKey.value = `p:${initial}`;
|
||||
await loadProgram(initial);
|
||||
}
|
||||
syncChrome();
|
||||
});
|
||||
|
||||
watch([program, periods, conceptItems], syncChrome);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.programa-page { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
||||
.programa-tabs { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.programa-tabs :deep(.p-tabview-panels) { flex: 1; min-height: 0; overflow: auto; }
|
||||
.curve-row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.curve-cell {
|
||||
flex: 1;
|
||||
min-width: 7rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.curve-label { color: var(--text-color-secondary); font-size: 0.7rem; }
|
||||
.curve-pct { font-size: 0.65rem; color: var(--text-color-secondary); }
|
||||
.gantt-toolbar { display: flex; gap: 0.5rem; }
|
||||
.gantt-select { min-width: 16rem; }
|
||||
</style>
|
||||
Loading…
Reference in a new issue