mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 20:23:17 +00:00
<!-- 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> <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> </div>
235 lines
10 KiB
Vue
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>
|