|
@@ -0,0 +1,424 @@
|
|
|
|
|
+<script setup lang="ts">
|
|
|
|
|
+import { ref } from "vue";
|
|
|
|
|
+import { message } from "@/utils/message";
|
|
|
|
|
+import { getDeviceInventory, increaseStock, adjustStock } from "@/api/inventory";
|
|
|
|
|
+import { getLayerTemplateByDeviceId } from "@/api/layer-template";
|
|
|
|
|
+import type { FloorConfig, GoodsItem } from "@/api/layer-template";
|
|
|
|
|
+import type { DeviceInventoryRecord } from "../utils/types";
|
|
|
|
|
+
|
|
|
|
|
+/** 合并后的商品展示数据 */
|
|
|
|
|
+interface GoodsDisplay {
|
|
|
|
|
+ productCode: string;
|
|
|
|
|
+ productName: string;
|
|
|
|
|
+ productImage: string;
|
|
|
|
|
+ templateStock: number;
|
|
|
|
|
+ realStock: number;
|
|
|
|
|
+ warningThreshold: number;
|
|
|
|
|
+ inventoryRecord: DeviceInventoryRecord | null;
|
|
|
|
|
+ quantity: number;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 按门+层分组后的展示结构 */
|
|
|
|
|
+interface FloorDisplay {
|
|
|
|
|
+ door: string;
|
|
|
|
|
+ floor: number;
|
|
|
|
|
+ floorLabel: string;
|
|
|
|
|
+ goods: GoodsDisplay[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const visible = ref(false);
|
|
|
|
|
+const deviceId = ref("");
|
|
|
|
|
+const deviceName = ref("");
|
|
|
|
|
+const loading = ref(false);
|
|
|
|
|
+const floorDisplays = ref<FloorDisplay[]>([]);
|
|
|
|
|
+const orphanGoods = ref<GoodsDisplay[]>([]);
|
|
|
|
|
+
|
|
|
|
|
+function buildInventoryMap(records: DeviceInventoryRecord[]): Map<string, DeviceInventoryRecord> {
|
|
|
|
|
+ const map = new Map<string, DeviceInventoryRecord>();
|
|
|
|
|
+ for (const r of records) {
|
|
|
|
|
+ if (r.product_code) {
|
|
|
|
|
+ map.set(r.product_code, r);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return map;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseFloors(raw: any): FloorConfig[] {
|
|
|
|
|
+ if (!raw) return [];
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
|
|
|
+ return Array.isArray(parsed) ? parsed : [];
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function mergeGoods(
|
|
|
|
|
+ goods: GoodsItem[],
|
|
|
|
|
+ inventoryMap: Map<string, DeviceInventoryRecord>,
|
|
|
|
|
+ usedCodes: Set<string>
|
|
|
|
|
+): GoodsDisplay[] {
|
|
|
|
|
+ return (goods || []).map(g => {
|
|
|
|
|
+ const code = g.key || "";
|
|
|
|
|
+ if (code) usedCodes.add(code);
|
|
|
|
|
+ const inv = inventoryMap.get(code);
|
|
|
|
|
+ return {
|
|
|
|
|
+ productCode: code,
|
|
|
|
|
+ productName: g.label || inv?.product_name || code,
|
|
|
|
|
+ productImage: g.pic || inv?.product_image || "",
|
|
|
|
|
+ templateStock: g.stock || 0,
|
|
|
|
|
+ realStock: inv?.stock ?? 0,
|
|
|
|
|
+ warningThreshold: inv?.warning_threshold ?? 0,
|
|
|
|
|
+ inventoryRecord: inv || null,
|
|
|
|
|
+ quantity: 1
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function loadData() {
|
|
|
|
|
+ loading.value = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const [invRes, tmplRes] = await Promise.all([
|
|
|
|
|
+ getDeviceInventory(deviceId.value).catch(() => ({ data: [] })),
|
|
|
|
|
+ getLayerTemplateByDeviceId(deviceId.value as any).catch(() => ({ data: null }))
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ const records = (invRes.data || []) as DeviceInventoryRecord[];
|
|
|
|
|
+ const template = tmplRes.data;
|
|
|
|
|
+ const inventoryMap = buildInventoryMap(records);
|
|
|
|
|
+ const usedCodes = new Set<string>();
|
|
|
|
|
+ const displays: FloorDisplay[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ if (template) {
|
|
|
|
|
+ const leftFloors = parseFloors(template.leftFloors);
|
|
|
|
|
+ const rightFloors = parseFloors(template.rightFloors);
|
|
|
|
|
+ const isDoubleDoor = template.deviceType === 2 && rightFloors.length > 0;
|
|
|
|
|
+
|
|
|
|
|
+ for (const floor of leftFloors) {
|
|
|
|
|
+ displays.push({
|
|
|
|
|
+ door: isDoubleDoor ? "左门" : "",
|
|
|
|
|
+ floor: floor.floor || 0,
|
|
|
|
|
+ floorLabel: `第${floor.floor}层`,
|
|
|
|
|
+ goods: mergeGoods(floor.goods || [], inventoryMap, usedCodes)
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (isDoubleDoor) {
|
|
|
|
|
+ for (const floor of rightFloors) {
|
|
|
|
|
+ displays.push({
|
|
|
|
|
+ door: "右门",
|
|
|
|
|
+ floor: floor.floor || 0,
|
|
|
|
|
+ floorLabel: `第${floor.floor}层`,
|
|
|
|
|
+ goods: mergeGoods(floor.goods || [], inventoryMap, usedCodes)
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const orphans: GoodsDisplay[] = [];
|
|
|
|
|
+ for (const r of records) {
|
|
|
|
|
+ if (r.product_code && !usedCodes.has(r.product_code)) {
|
|
|
|
|
+ orphans.push({
|
|
|
|
|
+ productCode: r.product_code,
|
|
|
|
|
+ productName: r.product_name,
|
|
|
|
|
+ productImage: r.product_image || "",
|
|
|
|
|
+ templateStock: 0,
|
|
|
|
|
+ realStock: r.stock,
|
|
|
|
|
+ warningThreshold: r.warning_threshold,
|
|
|
|
|
+ inventoryRecord: r,
|
|
|
|
|
+ quantity: 1
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ floorDisplays.value = displays;
|
|
|
|
|
+ orphanGoods.value = orphans;
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error("加载设备库存详情失败:", e);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ loading.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function open(dId: string, dName?: string) {
|
|
|
|
|
+ deviceId.value = dId;
|
|
|
|
|
+ deviceName.value = dName || "";
|
|
|
|
|
+ visible.value = true;
|
|
|
|
|
+ loadData();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleClose() {
|
|
|
|
|
+ visible.value = false;
|
|
|
|
|
+ floorDisplays.value = [];
|
|
|
|
|
+ orphanGoods.value = [];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleStockIn(row: GoodsDisplay) {
|
|
|
|
|
+ const quantity = row.quantity;
|
|
|
|
|
+ if (!quantity || quantity <= 0) {
|
|
|
|
|
+ message("请输入大于 0 的数量", { type: "warning" });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ await increaseStock({
|
|
|
|
|
+ deviceId: deviceId.value,
|
|
|
|
|
+ productId: row.inventoryRecord?.product_id || 0,
|
|
|
|
|
+ productCode: row.productCode,
|
|
|
|
|
+ productName: row.productName,
|
|
|
|
|
+ quantity
|
|
|
|
|
+ });
|
|
|
|
|
+ message("上货成功", { type: "success" });
|
|
|
|
|
+ row.quantity = 1;
|
|
|
|
|
+ loadData();
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error("上货失败:", e);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handleStockOut(row: GoodsDisplay) {
|
|
|
|
|
+ if (!row.inventoryRecord) {
|
|
|
|
|
+ message("该商品无库存记录,无法扣减", { type: "warning" });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const quantity = row.quantity;
|
|
|
|
|
+ if (!quantity || quantity <= 0) {
|
|
|
|
|
+ message("请输入大于 0 的数量", { type: "warning" });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (quantity > row.realStock) {
|
|
|
|
|
+ message(`扣减数量不能超过当前库存(${row.realStock})`, { type: "warning" });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ await adjustStock({
|
|
|
|
|
+ deviceId: deviceId.value,
|
|
|
|
|
+ productId: row.inventoryRecord.product_id,
|
|
|
|
|
+ newStock: row.realStock - quantity,
|
|
|
|
|
+ remark: "手动扣减"
|
|
|
|
|
+ });
|
|
|
|
|
+ message("扣减成功", { type: "success" });
|
|
|
|
|
+ row.quantity = 1;
|
|
|
|
|
+ loadData();
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error("扣减失败:", e);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+defineExpose({ open });
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<template>
|
|
|
|
|
+ <el-dialog
|
|
|
|
|
+ v-model="visible"
|
|
|
|
|
+ :title="`设备库存详情 - ${deviceId}`"
|
|
|
|
|
+ width="1100px"
|
|
|
|
|
+ destroy-on-close
|
|
|
|
|
+ :close-on-click-modal="false"
|
|
|
|
|
+ @close="handleClose"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div v-loading="loading">
|
|
|
|
|
+ <el-empty
|
|
|
|
|
+ v-if="!loading && floorDisplays.length === 0 && orphanGoods.length === 0"
|
|
|
|
|
+ description="该设备暂无库存记录"
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <template v-for="(display, di) in floorDisplays" :key="di">
|
|
|
|
|
+ <el-divider content-position="left">
|
|
|
|
|
+ {{ display.door ? `${display.door} · ` : "" }}{{ display.floorLabel }}
|
|
|
|
|
+ </el-divider>
|
|
|
|
|
+
|
|
|
|
|
+ <el-table :data="display.goods" border size="small" style="width: 100%">
|
|
|
|
|
+ <el-table-column label="商品图片" width="80" align="center">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <el-image
|
|
|
|
|
+ v-if="row.productImage"
|
|
|
|
|
+ :src="row.productImage"
|
|
|
|
|
+ :preview-src-list="[row.productImage]"
|
|
|
|
|
+ fit="cover"
|
|
|
|
|
+ style="width: 50px; height: 50px; border-radius: 4px"
|
|
|
|
|
+ preview-teleported
|
|
|
|
|
+ />
|
|
|
|
|
+ <span v-else class="text-gray-400 text-xs">无图片</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="商品名称" prop="productName" min-width="130" />
|
|
|
|
|
+ <el-table-column label="商品编码" prop="productCode" min-width="100" />
|
|
|
|
|
+ <el-table-column label="实时库存" width="90" align="center">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <el-tag
|
|
|
|
|
+ :type="row.realStock === 0 ? 'danger' : row.realStock <= row.warningThreshold ? 'warning' : 'success'"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ row.realStock }}
|
|
|
|
|
+ </el-tag>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="标准库存" width="90" align="center">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ {{ row.templateStock || row.warningThreshold || '-' }}
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="增减库存" width="200" align="center" fixed="right">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <div class="stock-ops">
|
|
|
|
|
+ <el-button
|
|
|
|
|
+ class="stock-btn stock-btn-minus"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ circle
|
|
|
|
|
+ :disabled="row.realStock <= 0"
|
|
|
|
|
+ @click="handleStockOut(row)"
|
|
|
|
|
+ >
|
|
|
|
|
+ −
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ <el-input-number
|
|
|
|
|
+ v-model="row.quantity"
|
|
|
|
|
+ :min="1"
|
|
|
|
|
+ :max="9999"
|
|
|
|
|
+ controls-position="right"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ class="stock-input"
|
|
|
|
|
+ />
|
|
|
|
|
+ <el-button
|
|
|
|
|
+ class="stock-btn stock-btn-plus"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ circle
|
|
|
|
|
+ @click="handleStockIn(row)"
|
|
|
|
|
+ >
|
|
|
|
|
+ +
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ </template>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 未分配商品 -->
|
|
|
|
|
+ <template v-if="orphanGoods.length > 0">
|
|
|
|
|
+ <el-divider content-position="left">未在模版中的商品</el-divider>
|
|
|
|
|
+ <el-table :data="orphanGoods" border size="small" style="width: 100%">
|
|
|
|
|
+ <el-table-column label="商品图片" width="80" align="center">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <el-image
|
|
|
|
|
+ v-if="row.productImage"
|
|
|
|
|
+ :src="row.productImage"
|
|
|
|
|
+ :preview-src-list="[row.productImage]"
|
|
|
|
|
+ fit="cover"
|
|
|
|
|
+ style="width: 50px; height: 50px; border-radius: 4px"
|
|
|
|
|
+ preview-teleported
|
|
|
|
|
+ />
|
|
|
|
|
+ <span v-else class="text-gray-400 text-xs">无图片</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="商品名称" prop="productName" min-width="130" />
|
|
|
|
|
+ <el-table-column label="商品编码" prop="productCode" min-width="100" />
|
|
|
|
|
+ <el-table-column label="实时库存" width="90" align="center">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <el-tag
|
|
|
|
|
+ :type="row.realStock === 0 ? 'danger' : row.realStock <= row.warningThreshold ? 'warning' : 'success'"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ row.realStock }}
|
|
|
|
|
+ </el-tag>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="标准库存" width="90" align="center">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ {{ row.warningThreshold || '-' }}
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ <el-table-column label="增减库存" width="200" align="center" fixed="right">
|
|
|
|
|
+ <template #default="{ row }">
|
|
|
|
|
+ <div class="stock-ops">
|
|
|
|
|
+ <el-button
|
|
|
|
|
+ class="stock-btn stock-btn-minus"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ circle
|
|
|
|
|
+ :disabled="row.realStock <= 0"
|
|
|
|
|
+ @click="handleStockOut(row)"
|
|
|
|
|
+ >
|
|
|
|
|
+ −
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ <el-input-number
|
|
|
|
|
+ v-model="row.quantity"
|
|
|
|
|
+ :min="1"
|
|
|
|
|
+ :max="9999"
|
|
|
|
|
+ controls-position="right"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ class="stock-input"
|
|
|
|
|
+ />
|
|
|
|
|
+ <el-button
|
|
|
|
|
+ class="stock-btn stock-btn-plus"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ circle
|
|
|
|
|
+ @click="handleStockIn(row)"
|
|
|
|
|
+ >
|
|
|
|
|
+ +
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+ </el-table>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <template #footer>
|
|
|
|
|
+ <el-button @click="handleClose">关闭</el-button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<style lang="scss" scoped>
|
|
|
|
|
+:deep(.el-divider__text) {
|
|
|
|
|
+ font-size: 15px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: var(--el-color-primary);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stock-ops {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ gap: 4px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stock-input {
|
|
|
|
|
+ width: 100px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stock-btn {
|
|
|
|
|
+ width: 26px;
|
|
|
|
|
+ height: 26px;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ font-size: 15px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ line-height: 1;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stock-btn-plus {
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background-color: #67c23a;
|
|
|
|
|
+ border-color: #67c23a;
|
|
|
|
|
+
|
|
|
|
|
+ &:hover {
|
|
|
|
|
+ background-color: #85ce61;
|
|
|
|
|
+ border-color: #85ce61;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.stock-btn-minus {
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ background-color: #f56c6c;
|
|
|
|
|
+ border-color: #f56c6c;
|
|
|
|
|
+
|
|
|
|
|
+ &:hover {
|
|
|
|
|
+ background-color: #f78989;
|
|
|
|
|
+ border-color: #f78989;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ &.is-disabled {
|
|
|
|
|
+ background-color: #fab6b6;
|
|
|
|
|
+ border-color: #fab6b6;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|