panels-origin/web-panel/pages/almacen.vue
Alberto Martinez 41273dfb04 Gastos, almacén y control presupuestal
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Validación de endpoints y SPs

Se probaron todos los endpoints nuevos (gastos, almacén, control presupuestal, IAM) y sus RPCs asociados.

### Bugs corregidos en migraciones/SQL

1. **IAM grants en schema core** — `iam-004e` intentaba `GRANT` sobre `core` con rol `iam_owner` (sin permiso). Los grants cruzados se movieron a `core-027-iam-rpc-cross-grants.sql`.
2. **`_cost_settings` ambiguo** — columnas `budget_warn_pct` etc. colisionaban con `RETURNS TABLE` en PL/pgSQL, rompiendo `fn_expense_create` y control presupuestal. Corregido en `core-028-fix-cost-settings-ambiguous.sql`.

### Tests añadidos

- `api/cost_modules_test.ts` — CRUD RPC gastos, flujo almacén completo, control presupuestal, IAM permisos
- `scripts/crud-smoke-test.sh` — smoke HTTP de todos los endpoints nuevos

### Resultados

- `deno test cost_modules_test.ts` — 5/5 OK
- `./scripts/crud-smoke-test.sh` — todos los checks OK (gastos CRUD, IVA, cost-settings, almacén, transferencias, cost-control, IAM)
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-eeec3b5c-f789-43e8-a353-0755e4706377?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-eeec3b5c-f789-43e8-a353-0755e4706377&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-04 04:29:02 +00:00

235 lines
10 KiB
Vue

<template>
<div class="almacen-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar material o obra">
<Chip label="Almacén global" icon="pi pi-box" />
<Chip :label="`${stockRows.length} líneas`" icon="pi pi-list" />
<Chip :label="money(stockValue)" icon="pi pi-wallet" />
</DeskTableBar>
<TabView>
<TabPanel header="Stock global">
<DataTable :value="filteredStock" data-key="material_id" paginator :rows="25" scrollable scroll-height="flex">
<Column field="project_code" header="Obra / Central" />
<Column field="material_code" header="Clave" style="width: 8rem" />
<Column field="material_name" header="Material" />
<Column field="qty_on_hand" header="Cantidad" style="width: 8rem" />
<Column field="stock_value" header="Valor" style="width: 9rem">
<template #body="{ data }">{{ money(data.stock_value) }}</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel header="Kardex">
<DataTable :value="movements" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
<Column field="movement_date" header="Fecha" style="width: 7rem" />
<Column field="movement_type" header="Tipo" style="width: 8rem" />
<Column field="material_name" header="Material" />
<Column field="quantity" header="Cant." style="width: 7rem" />
<Column field="total_cost" header="Importe" style="width: 9rem">
<template #body="{ data }">{{ money(data.total_cost) }}</template>
</Column>
<Column field="destination_note" header="Destino / nota" />
</DataTable>
</TabPanel>
</TabView>
<Dialog v-model:visible="showEntry" header="Entrada de material" modal :style="{ width: '640px' }">
<div class="form-grid form-grid-compact">
<FormField label="Almacén">
<Select v-model="entryForm.warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Código material">
<InputText v-model="entryForm.material_code" class="w-full identifier-input" />
</FormField>
<FormField label="Nombre material">
<InputText v-model="entryForm.material_name" class="w-full" />
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="entryForm.quantity" :min="0" class="w-full" />
</FormField>
<FormField label="Costo unitario">
<InputNumber v-model="entryForm.unit_cost" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
</FormField>
</div>
<template #footer>
<Button label="Cancelar" text @click="showEntry = false" />
<Button label="Registrar entrada" icon="pi pi-check" @click="saveEntry" />
</template>
</Dialog>
<Dialog v-model:visible="showExit" header="Salida a obra / concepto" modal :style="{ width: '720px' }">
<div class="form-grid form-grid-compact">
<FormField label="Almacén obra">
<Select v-model="exitForm.warehouse_id" :options="projectWarehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Material">
<Select v-model="exitForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="exitForm.quantity" :min="0" class="w-full" />
</FormField>
<FormField label="Concepto presupuesto" class="span-2">
<BudgetItemPicker v-if="exitProjectId" v-model="exitForm.budget_item_id" :project-id="exitProjectId" />
</FormField>
<FormField label="Destino físico" class="span-2">
<InputText v-model="exitForm.destination_note" class="w-full" />
</FormField>
</div>
<Message v-if="exitWarning" severity="warn" :closable="false">{{ exitWarning }}</Message>
<template #footer>
<Button label="Cancelar" text @click="showExit = false" />
<Button label="Registrar salida" icon="pi pi-check" @click="saveExit" />
</template>
</Dialog>
<Dialog v-model:visible="showTransfer" header="Transferencia central ↔ obra" modal :style="{ width: '640px' }">
<div class="form-grid form-grid-compact">
<FormField label="Desde">
<Select v-model="transferForm.from_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Hacia">
<Select v-model="transferForm.to_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Material">
<Select v-model="transferForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="transferForm.quantity" :min="0" class="w-full" />
</FormField>
</div>
<template #footer>
<Button label="Cancelar" text @click="showTransfer = false" />
<Button label="Transferir" icon="pi pi-arrow-right-arrow-left" @click="saveTransfer" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
import { displayErrorMessage } from "~/composables/api-response";
type WarehouseRow = { id: number; kind: string; project_id?: number; project_code?: string; label: string };
type StockRow = Record<string, unknown>;
type MovementRow = Record<string, unknown>;
const desk = useDesktop();
const { api } = useApi();
const toast = useToast();
const tableQ = ref("");
const warehouses = ref<WarehouseRow[]>([]);
const stockRows = ref<StockRow[]>([]);
const movements = ref<MovementRow[]>([]);
const materialOptions = ref<{ id: number; label: string }[]>([]);
const showEntry = ref(false);
const showExit = ref(false);
const showTransfer = ref(false);
const exitWarning = ref("");
const entryForm = reactive({ warehouse_id: 0, material_code: "", material_name: "", quantity: 0, unit_cost: 0 });
const exitForm = reactive({ warehouse_id: 0, material_id: 0, quantity: 0, budget_item_id: null as number | null, destination_note: "" });
const transferForm = reactive({ from_warehouse_id: 0, to_warehouse_id: 0, material_id: 0, quantity: 0 });
const projectWarehouses = computed(() => warehouses.value.filter((w) => w.kind === "project"));
const exitProjectId = computed(() => projectWarehouses.value.find((w) => w.id === exitForm.warehouse_id)?.project_id ?? 0);
const stockValue = computed(() => stockRows.value.reduce((s, r) => s + Number(r.stock_value || 0), 0));
const filteredStock = computed(() => {
const q = tableQ.value.toLowerCase();
return stockRows.value.filter((r) =>
!q || [r.material_name, r.material_code, r.project_code].join(" ").toLowerCase().includes(q),
);
});
function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
async function loadWarehouses() {
await api("/v1/warehouses/central/ensure", { method: "POST", json: {} });
const r = await api<{ data?: { warehouses?: Array<Record<string, unknown>> } }>("/v1/warehouses");
const rows = (r.data?.warehouses ?? []) as Array<Record<string, unknown>>;
warehouses.value = rows.map((w) => ({
id: Number(w.id),
kind: String(w.kind),
project_id: w.project_id ? Number(w.project_id) : undefined,
project_code: String(w.project_code ?? (w.kind === "central" ? "CENTRAL" : "")),
label: w.kind === "central" ? "Almacén central" : `${w.project_code} — ${w.project_name ?? "Obra"}`,
}));
}
async function loadStock() {
const r = await api<{ data?: { stock?: StockRow[] } }>("/v1/warehouses/stock/global");
stockRows.value = r.data?.stock ?? [];
const mats = new Map<number, string>();
for (const row of stockRows.value) {
mats.set(Number(row.material_id), `${row.material_code} — ${row.material_name}`);
}
materialOptions.value = [...mats.entries()].map(([id, label]) => ({ id, label }));
}
async function loadMovements() {
const wh = warehouses.value[0]?.id;
if (!wh) return;
const r = await api<{ data?: { movements?: MovementRow[] } }>(`/v1/warehouses/${wh}/movements`);
movements.value = r.data?.movements ?? [];
}
async function saveEntry() {
try {
const r = await api<{ message?: string }>(`/v1/warehouses/${entryForm.warehouse_id}/entries`, {
method: "POST",
json: entryForm,
});
toast.add({ severity: "success", summary: r.message || "Entrada registrada", life: 2000 });
showEntry.value = false;
await loadStock();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
async function saveExit() {
try {
const r = await api<{ data?: { budget_warning?: { message: string } }; message?: string }>(
`/v1/warehouses/${exitForm.warehouse_id}/exits`,
{ method: "POST", json: exitForm },
);
exitWarning.value = r.data?.budget_warning?.message ?? "";
toast.add({ severity: "success", summary: r.message || "Salida registrada", life: 2000 });
if (!exitWarning.value) showExit.value = false;
await loadStock();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
async function saveTransfer() {
try {
const r = await api<{ message?: string }>("/v1/warehouse-transfers", { method: "POST", json: transferForm });
toast.add({ severity: "success", summary: r.message || "Transferencia completada", life: 2000 });
showTransfer.value = false;
await loadStock();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
onMounted(async () => {
desk.configure({
tab: "Almacén",
ribbon: [{
key: "wh",
label: "Movimientos",
items: [
{ key: "entry", label: "Entrada", icon: "pi pi-download", command: () => { showEntry.value = true; } },
{ key: "exit", label: "Salida", icon: "pi pi-upload", command: () => { showExit.value = true; } },
{ key: "xfer", label: "Transferir", icon: "pi pi-arrow-right-arrow-left", command: () => { showTransfer.value = true; } },
],
}],
});
await loadWarehouses();
await loadStock();
await loadMovements();
});
</script>