Kaynağa Gözat

feat: 补货单管理功能,支持设备多选+自动计算补货建议

- 新增 GET /replenishment-orders/suggestions 端点,根据层模版标准库存与当前库存差值自动计算补货数量
- 改造创建补货单弹窗为两步式:勾选设备→补货建议→确认创建
- 增强补货单列表查询,批量填充门店名称和设备名称
- 修复 token 刷新时 data 为 null 导致的崩溃问题
- 修复 LayerTemplateMapper 缺少 @Select 注解、DeviceConstants 缺少 StatusLabel、Lombok final 字段注入冲突等编译问题
- 添加 /replenishment-orders 到 Vite 代理配置

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 1 hafta önce
ebeveyn
işleme
51b03bcd3a

+ 6 - 0
haha-admin-web/src/api/replenishmentOrder.ts

@@ -56,3 +56,9 @@ export const completeOrder = (id: string) =>
 /** 取消补货单 */
 export const cancelOrder = (id: string) =>
   http.request<Result>("post", `/replenishment-orders/${id}/cancel`);
+
+/** 获取补货建议(根据设备ID批量计算标准库存与当前库存差值) */
+export const getReplenishmentSuggestions = (deviceIds: string[]) =>
+  http.request<Result>("get", "/replenishment-orders/suggestions", {
+    params: { deviceIds: deviceIds.join(",") }
+  });

+ 9 - 0
haha-admin-web/src/router/modules/inventory.ts

@@ -34,6 +34,15 @@ export default {
         icon: "ri:file-text-line",
         title: "上货记录"
       }
+    },
+    {
+      path: "/inventory/replenishment",
+      name: "ReplenishmentOrders",
+      component: () => import("@/views/inventory/replenishment/index.vue"),
+      meta: {
+        icon: "ri:shopping-bag-3-line",
+        title: "补货单管理"
+      }
     }
   ]
 } satisfies RouteConfigsTable;

+ 3 - 1
haha-admin-web/src/store/modules/user.ts

@@ -115,9 +115,11 @@ export const useUserStore = defineStore("pure-user", {
       return new Promise<RefreshTokenResult>((resolve, reject) => {
         refreshTokenApi(data)
           .then(data => {
-            if (data) {
+            if (data?.data) {
               setToken(data.data as any);
               resolve(data);
+            } else {
+              reject(new Error("刷新token失败:返回数据为空"));
             }
           })
           .catch(error => {

+ 1 - 0
haha-admin-web/src/utils/auth.ts

@@ -46,6 +46,7 @@ export function getToken(): DataInfo<number> {
  * 将`avatar`、`username`、`nickname`、`roles`、`permissions`、`refreshToken`、`expires`这七条信息放在key值为`user-info`的localStorage里(利用`multipleTabsKey`当浏览器完全关闭后自动销毁)
  */
 export function setToken(data: DataInfo<Date>) {
+  if (!data) return;
   let expires = 0;
   const { accessToken, refreshToken } = data;
   const { isRemembered, loginDay } = useUserStoreHook();

+ 5 - 1
haha-admin-web/src/utils/http/index.ts

@@ -86,11 +86,15 @@ class PureHttp {
                     useUserStoreHook()
                       .handRefreshToken({ refreshToken: data.refreshToken })
                       .then(res => {
-                        const token = res.data.accessToken;
+                        const token = res?.data?.accessToken || data.accessToken;
                         config.headers["Authorization"] = formatToken(token);
                         PureHttp.requests.forEach(cb => cb(token));
                         PureHttp.requests = [];
                       })
+                      .catch(() => {
+                        PureHttp.requests = [];
+                        useUserStoreHook().logOut();
+                      })
                       .finally(() => {
                         PureHttp.isRefreshing = false;
                       });

+ 234 - 0
haha-admin-web/src/views/inventory/replenishment/index.vue

@@ -0,0 +1,234 @@
+<script setup lang="ts">
+import { ref } from "vue";
+import { useReplenishment } from "./utils/hook";
+import { PureTableBar } from "@/components/RePureTableBar";
+import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import Refresh from "~icons/ep/refresh";
+import AddFill from "~icons/ri/add-circle-line";
+import Eye from "~icons/ri/eye-line";
+import Send from "~icons/ri/send-plane-line";
+import Cloud from "~icons/ri/cloud-line";
+import Check from "~icons/ri/checkbox-circle-line";
+import Close from "~icons/ri/close-circle-line";
+import Delete from "~icons/ri/delete-bin-line";
+
+defineOptions({
+  name: "ReplenishmentManage"
+});
+
+const formRef = ref();
+
+const {
+  form,
+  loading,
+  columns,
+  dataList,
+  pagination,
+  deviceOptions,
+  statusMap,
+  onSearch,
+  resetForm,
+  handleCreate,
+  handleViewDetail,
+  handleDelete,
+  handleSubmit,
+  handleSyncErp,
+  handleComplete,
+  handleCancel,
+  handleSizeChange,
+  handleCurrentChange
+} = useReplenishment();
+</script>
+
+<template>
+  <div class="main">
+    <el-form
+      ref="formRef"
+      :inline="true"
+      :model="form"
+      class="search-form bg-bg_color w-full pl-8 pt-[12px] overflow-auto"
+    >
+      <el-form-item label="补货单号:" prop="orderNo">
+        <el-input
+          v-model="form.orderNo"
+          placeholder="请输入补货单号"
+          clearable
+          class="w-[180px]!"
+        />
+      </el-form-item>
+      <el-form-item label="设备:" prop="deviceId">
+        <el-select
+          v-model="form.deviceId"
+          placeholder="请选择设备"
+          clearable
+          filterable
+          class="w-[180px]!"
+        >
+          <el-option
+            v-for="item in deviceOptions"
+            :key="item.deviceId"
+            :label="`${item.deviceId} — ${item.shopName || ''}`"
+            :value="item.deviceId"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="状态:" prop="status">
+        <el-select
+          v-model="form.status"
+          placeholder="请选择状态"
+          clearable
+          class="w-[140px]!"
+        >
+          <el-option
+            v-for="(v, k) in statusMap"
+            :key="k"
+            :label="v.text"
+            :value="Number(k)"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间:" prop="timeRange">
+        <el-date-picker
+          v-model="form.timeRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          value-format="YYYY-MM-DD"
+          class="w-[240px]!"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button
+          type="primary"
+          :icon="useRenderIcon('ri/search-line')"
+          :loading="loading"
+          @click="onSearch"
+        >
+          搜索
+        </el-button>
+        <el-button :icon="useRenderIcon(Refresh)" @click="resetForm(formRef)">
+          重置
+        </el-button>
+      </el-form-item>
+    </el-form>
+
+    <PureTableBar
+      title="补货单管理"
+      :columns="columns"
+      @refresh="onSearch"
+    >
+      <template #buttons>
+        <el-button
+          type="primary"
+          :icon="useRenderIcon(AddFill)"
+          @click="handleCreate"
+        >
+          创建补货单
+        </el-button>
+      </template>
+      <template v-slot="{ size, dynamicColumns }">
+        <pure-table
+          row-key="id"
+          adaptive
+          :adaptiveConfig="{ offsetBottom: 108 }"
+          align-whole="center"
+          table-layout="auto"
+          :loading="loading"
+          :size="size"
+          :data="dataList"
+          :columns="dynamicColumns"
+          :pagination="{ ...pagination, size }"
+          :header-cell-style="{
+            background: 'var(--el-fill-color-light)',
+            color: 'var(--el-text-color-primary)'
+          }"
+          @page-size-change="handleSizeChange"
+          @page-current-change="handleCurrentChange"
+        >
+          <template #operation="{ row }">
+            <el-button
+              class="reset-margin"
+              link
+              type="primary"
+              :size="size"
+              :icon="useRenderIcon(Eye)"
+              @click="handleViewDetail(row)"
+            >
+              查看详情
+            </el-button>
+            <el-button
+              v-if="row.status === 0"
+              class="reset-margin"
+              link
+              type="success"
+              :size="size"
+              :icon="useRenderIcon(Send)"
+              @click="handleSubmit(row)"
+            >
+              提交
+            </el-button>
+            <el-button
+              v-if="row.status === 1"
+              class="reset-margin"
+              link
+              type="warning"
+              :size="size"
+              :icon="useRenderIcon(Cloud)"
+              @click="handleSyncErp(row)"
+            >
+              同步ERP
+            </el-button>
+            <el-button
+              v-if="row.status === 2"
+              class="reset-margin"
+              link
+              type="success"
+              :size="size"
+              :icon="useRenderIcon(Check)"
+              @click="handleComplete(row)"
+            >
+              完成
+            </el-button>
+            <el-button
+              v-if="row.status === 0 || row.status === 1"
+              class="reset-margin"
+              link
+              type="danger"
+              :size="size"
+              :icon="useRenderIcon(Close)"
+              @click="handleCancel(row)"
+            >
+              取消
+            </el-button>
+            <el-popconfirm
+              v-if="row.status === 0"
+              title="是否确认删除?"
+              @confirm="handleDelete(row)"
+            >
+              <template #reference>
+                <el-button
+                  class="reset-margin"
+                  link
+                  type="danger"
+                  :size="size"
+                  :icon="useRenderIcon(Delete)"
+                >
+                  删除
+                </el-button>
+              </template>
+            </el-popconfirm>
+          </template>
+        </pure-table>
+      </template>
+    </PureTableBar>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.search-form {
+  :deep(.el-form-item) {
+    margin-bottom: 12px;
+  }
+}
+</style>

+ 615 - 0
haha-admin-web/src/views/inventory/replenishment/utils/hook.tsx

@@ -0,0 +1,615 @@
+import dayjs from "dayjs";
+import type { PaginationProps } from "@pureadmin/table";
+import { deviceDetection } from "@pureadmin/utils";
+import { message } from "@/utils/message";
+import { addDialog } from "@/components/ReDialog";
+import {
+  getOrderList,
+  getOrderDetail,
+  getOrderItems,
+  createOrder,
+  deleteOrder,
+  submitOrder,
+  syncToErp,
+  completeOrder,
+  cancelOrder,
+  getReplenishmentSuggestions
+} from "@/api/replenishmentOrder";
+import { getDeviceList } from "@/api/device";
+import { reactive, ref, onMounted } from "vue";
+import {
+  ElMessageBox,
+  ElTag,
+  ElInput,
+  ElInputNumber,
+  ElDatePicker,
+  ElDescriptions,
+  ElDescriptionsItem,
+  ElTable,
+  ElTableColumn,
+  ElButton,
+  ElDivider,
+  ElCard
+} from "element-plus";
+import type {
+  ReplenishmentSearchForm,
+  ReplenishmentOrderRow,
+  ReplenishmentSuggestion,
+  SuggestionItem
+} from "./types";
+
+export function useReplenishment() {
+  const form = reactive<ReplenishmentSearchForm>({
+    orderNo: "",
+    deviceId: "",
+    status: undefined,
+    timeRange: []
+  });
+  const formRef = ref();
+  const dataList = ref<ReplenishmentOrderRow[]>([]);
+  const loading = ref(true);
+  const deviceOptions = ref<any[]>([]);
+  const pagination = reactive<PaginationProps>({
+    total: 0,
+    pageSize: 10,
+    currentPage: 1,
+    background: true
+  });
+
+  const statusMap: Record<number, { text: string; type: string }> = {
+    0: { text: "草稿", type: "info" },
+    1: { text: "已提交", type: "warning" },
+    2: { text: "已同步ERP", type: "primary" },
+    3: { text: "已完成", type: "success" },
+    4: { text: "已取消", type: "danger" }
+  };
+
+  const columns: TableColumnList = [
+    { label: "补货单号", prop: "orderNo", minWidth: 180 },
+    { label: "门店名称", prop: "shopName", minWidth: 130 },
+    { label: "设备ID", prop: "deviceId", minWidth: 130 },
+    { label: "设备名称", prop: "deviceName", minWidth: 130 },
+    { label: "补货数量", prop: "totalQuantity", width: 100 },
+    {
+      label: "补货金额",
+      prop: "totalAmount",
+      width: 110,
+      formatter: ({ totalAmount }: any) =>
+        totalAmount != null ? `¥${Number(totalAmount).toFixed(2)}` : "-"
+    },
+    { label: "供应商", prop: "supplierName", minWidth: 120 },
+    {
+      label: "状态",
+      prop: "status",
+      width: 100,
+      cellRenderer: ({ row }: any) => {
+        const s = statusMap[row.status];
+        return s ? <ElTag type={s.type as any}>{s.text}</ElTag> : "-";
+      }
+    },
+    { label: "创建人", prop: "creatorName", width: 100 },
+    { label: "明细数", prop: "itemCount", width: 80 },
+    {
+      label: "创建时间",
+      prop: "createTime",
+      minWidth: 160,
+      formatter: ({ createTime }: any) =>
+        createTime ? dayjs(createTime).format("YYYY-MM-DD HH:mm:ss") : "-"
+    },
+    { label: "操作", fixed: "right", width: 280, slot: "operation" }
+  ];
+
+  // ==================== 搜索 / 分页 ====================
+
+  async function onSearch() {
+    loading.value = true;
+    try {
+      const params: any = {
+        page: pagination.currentPage,
+        pageSize: pagination.pageSize
+      };
+      if (form.orderNo) params.orderNo = form.orderNo;
+      if (form.deviceId) params.deviceId = form.deviceId;
+      if (form.status !== undefined && form.status !== null && form.status !== "")
+        params.status = form.status;
+      if (form.timeRange?.length === 2) {
+        params.startTime = form.timeRange[0];
+        params.endTime = form.timeRange[1];
+      }
+      const { data } = await getOrderList(params);
+      if (data) {
+        dataList.value = (data.list || []) as ReplenishmentOrderRow[];
+        pagination.total = Number(data.total) || 0;
+      }
+    } catch (e) {
+      console.error("获取补货单列表失败:", e);
+    } finally {
+      setTimeout(() => { loading.value = false; }, 300);
+    }
+  }
+
+  function resetForm(formEl: any) {
+    if (!formEl) return;
+    formEl.resetFields();
+    (form as any).timeRange = [];
+    pagination.currentPage = 1;
+    onSearch();
+  }
+
+  function handleSizeChange(val: number) {
+    pagination.pageSize = val;
+    onSearch();
+  }
+
+  function handleCurrentChange(val: number) {
+    pagination.currentPage = val;
+    onSearch();
+  }
+
+  async function fetchDeviceOptions() {
+    try {
+      const { data } = await getDeviceList({ page: 1, pageSize: 1000 });
+      deviceOptions.value = data?.list || [];
+    } catch (e) {
+      console.error("获取设备列表失败:", e);
+    }
+  }
+
+  // ==================== 创建补货单弹窗 ====================
+
+  function handleCreate() {
+    const selectedDevices = ref<any[]>([]);
+    const suggestions = ref<ReplenishmentSuggestion[]>([]);
+    const suggestionsLoading = ref(false);
+    const commonForm = reactive({
+      supplierName: "",
+      warehouseName: "",
+      expectedArrivalTime: ""
+    });
+    const innerDeviceOptions = ref<any[]>([]);
+
+    async function loadDevices() {
+      try {
+        const { data } = await getDeviceList({ page: 1, pageSize: 1000 });
+        innerDeviceOptions.value = data?.list || [];
+      } catch (e) {
+        console.error("加载设备失败:", e);
+      }
+    }
+
+    function handleSelectionChange(selection: any[]) {
+      selectedDevices.value = selection;
+    }
+
+    async function fetchSuggestions() {
+      if (selectedDevices.value.length === 0) {
+        message("请先勾选设备", { type: "warning" });
+        return;
+      }
+      suggestionsLoading.value = true;
+      try {
+        const ids = selectedDevices.value.map((d: any) => d.deviceId as string);
+        const { data } = await getReplenishmentSuggestions(ids);
+        const list: ReplenishmentSuggestion[] = Array.isArray(data) ? data : [];
+        if (list.length === 0) {
+          message("所选设备当前均无需补货", { type: "info" });
+        }
+        suggestions.value = list;
+      } catch (e) {
+        console.error("获取补货建议失败:", e);
+        message("获取补货建议失败", { type: "error" });
+      } finally {
+        suggestionsLoading.value = false;
+      }
+    }
+
+    async function doSubmit(done: () => void) {
+      const validSuggestions = suggestions.value.filter(
+        s => s.items.some(i => i.suggestedQuantity > 0)
+      );
+      if (validSuggestions.length === 0) {
+        message("没有有效的补货项,请检查补货数量", { type: "warning" });
+        return;
+      }
+
+      const payloads = validSuggestions.map(s => ({
+          deviceId: s.deviceId,
+          shopId: s.shopId ? Number(s.shopId) : undefined,
+          supplierName: commonForm.supplierName || undefined,
+          warehouseName: commonForm.warehouseName || undefined,
+          expectedArrivalTime: commonForm.expectedArrivalTime
+            ? dayjs(commonForm.expectedArrivalTime).format("YYYY-MM-DD HH:mm:ss")
+            : undefined,
+          items: s.items
+            .filter(i => i.suggestedQuantity > 0)
+            .map(i => ({
+              productId: Number(i.productId),
+              productCode: i.productCode,
+              productName: i.productName,
+              plannedQuantity: i.suggestedQuantity,
+              unitPrice: i.unitPrice != null ? i.unitPrice : undefined,
+              shelfNum: i.shelfNum,
+              position: i.position
+            }))
+        }));
+
+        const results = await Promise.allSettled(
+          payloads.map(p => createOrder(p))
+        );
+
+        const successCount = results.filter(r => r.status === "fulfilled").length;
+        const failCount = results.filter(r => r.status === "rejected").length;
+
+        if (failCount === 0) {
+          message(`成功创建 ${successCount} 个补货单`, { type: "success" });
+        } else {
+          message(`创建完成:${successCount} 成功, ${failCount} 失败`, { type: "warning" });
+        }
+        done();
+        onSearch();
+    }
+
+    const createContentRenderer = () => {
+      const hasSuggestions = suggestions.value.length > 0;
+
+      return (
+        <div style="max-height: 65vh; overflow-y: auto;">
+          {/* ===== 第一步:设备列表 ===== */}
+          <div style="margin-bottom:8px;font-size:14px;font-weight:600;color:#303133;">
+            第一步:勾选需要补货的设备
+          </div>
+          <ElTable
+            data={innerDeviceOptions.value}
+            max-height={220}
+            onSelectionChange={handleSelectionChange}
+          >
+            <ElTableColumn type="selection" width="55" />
+            <ElTableColumn prop="deviceId" label="设备ID" minWidth={140} />
+            <ElTableColumn prop="name" label="设备名称" minWidth={140} />
+            <ElTableColumn prop="shopName" label="所属门店" minWidth={140} />
+          </ElTable>
+
+          <div style="margin-top:12px;display:flex;align-items:center;gap:12px;">
+            <ElButton
+              type="primary"
+              loading={suggestionsLoading.value}
+              onClick={fetchSuggestions}
+              disabled={selectedDevices.value.length === 0}
+            >
+              补货建议
+            </ElButton>
+            <span style="color:#909399;font-size:13px;">
+              已勾选 {selectedDevices.value.length} 台设备
+              {hasSuggestions
+                ? `,共 ${suggestions.value.reduce((s: number, sug) => s + sug.items.length, 0)} 项待补商品`
+                : ""}
+            </span>
+          </div>
+
+          {/* ===== 第二步:补货明细 ===== */}
+          {hasSuggestions && (
+            <div style="margin-top:24px;">
+              <div style="margin-bottom:12px;font-size:14px;font-weight:600;color:#303133;">
+                第二步:确认补货明细,填写供应商等信息
+              </div>
+
+              <div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:16px;padding:12px;background:#fafafa;border-radius:6px;">
+                <div style="display:flex;align-items:center;gap:6px;">
+                  <span style="font-size:13px;color:#606266;white-space:nowrap;">供应商</span>
+                  <ElInput v-model={commonForm.supplierName} placeholder="选填" clearable size="small" style="width:150px;" />
+                </div>
+                <div style="display:flex;align-items:center;gap:6px;">
+                  <span style="font-size:13px;color:#606266;white-space:nowrap;">仓库</span>
+                  <ElInput v-model={commonForm.warehouseName} placeholder="选填" clearable size="small" style="width:150px;" />
+                </div>
+                <div style="display:flex;align-items:center;gap:6px;">
+                  <span style="font-size:13px;color:#606266;white-space:nowrap;">预计到货</span>
+                  <ElDatePicker v-model={commonForm.expectedArrivalTime} type="datetime" placeholder="选填"
+                    value-format="YYYY-MM-DD HH:mm:ss" size="small" style="width:190px;" />
+                </div>
+              </div>
+
+              {suggestions.value.map((sug: ReplenishmentSuggestion) => {
+                const deviceTotalQty = sug.items.reduce(
+                  (sum: number, i) => sum + (i.suggestedQuantity || 0), 0
+                );
+                const deviceTotalAmt = sug.items.reduce(
+                  (sum: number, i) => sum + (i.suggestedQuantity || 0) * (i.unitPrice || 0), 0
+                );
+
+                const itemRows = sug.items.map((item: SuggestionItem, idx: number) => (
+                  <tr key={idx}>
+                    <td style="padding:6px 10px;border:1px solid #ebeef5;font-size:13px;">{item.productCode}</td>
+                    <td style="padding:6px 10px;border:1px solid #ebeef5;font-size:13px;">{item.productName || "-"}</td>
+                    <td style="padding:6px 10px;border:1px solid #ebeef5;text-align:center;font-size:13px;">{item.templateStock}</td>
+                    <td style="padding:6px 10px;border:1px solid #ebeef5;text-align:center;font-size:13px;">
+                      <span style={`font-weight:500;color:${item.currentStock <= 0 ? '#f56c6c' : '#67c23a'}`}>{item.currentStock}</span>
+                    </td>
+                    <td style="padding:4px 6px;border:1px solid #ebeef5;">
+                      <ElInputNumber v-model={item.suggestedQuantity} min={1} max={9999} size="small" controls-position="right" style="width:100px;" />
+                    </td>
+                    <td style="padding:4px 6px;border:1px solid #ebeef5;">
+                      <ElInput v-model={item.unitPrice} placeholder="单价" size="small" style="width:90px;" />
+                    </td>
+                    <td style="padding:6px 10px;border:1px solid #ebeef5;text-align:right;font-size:13px;font-weight:500;">
+                      ¥{((item.suggestedQuantity || 0) * (item.unitPrice || 0)).toFixed(2)}
+                    </td>
+                  </tr>
+                ));
+
+                return (
+                  <ElCard key={sug.deviceId} style="margin-bottom:12px;" shadow="hover">
+                    {{
+                      header: () => (
+                        <div style="display:flex;justify-content:space-between;align-items:center;">
+                          <span style="font-size:14px;font-weight:600;">
+                            {sug.deviceId}{sug.deviceName ? ` (${sug.deviceName})` : ""}
+                            <span style="margin:0 8px;color:#dcdfe6;">|</span>
+                            {sug.shopName || "未绑定门店"}
+                          </span>
+                          <span style="color:#409eff;font-size:13px;">{deviceTotalQty} 件 / ¥{deviceTotalAmt.toFixed(2)}</span>
+                        </div>
+                      ),
+                      default: () => (
+                        <table style="width:100%;border-collapse:collapse;">
+                          <thead>
+                            <tr style="background:#f5f7fa;">
+                              <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">商品编码</th>
+                              <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">商品名称</th>
+                              <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">标准库存</th>
+                              <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">当前库存</th>
+                              <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">补货数量</th>
+                              <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">单价</th>
+                              <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">小计</th>
+                            </tr>
+                          </thead>
+                          <tbody>{itemRows}</tbody>
+                        </table>
+                      )
+                    }}
+                  </ElCard>
+                );
+              })}
+
+              {/* 提示 */}
+              <div style="margin-top:16px;padding:10px 16px;background:#ecf5ff;border-radius:6px;color:#409eff;font-size:13px;">
+                确认补货明细无误后,点击「确认」按钮即可创建补货单
+              </div>
+            </div>
+          )}
+        </div>
+      );
+    };
+
+    loadDevices();
+    addDialog({
+      title: "创建补货单",
+      width: "80%",
+      draggable: true,
+      closeOnClickModal: false,
+      fullscreen: deviceDetection(),
+      contentRenderer: createContentRenderer,
+      beforeSure: async (done: () => void) => {
+        // 确保用户已获取补货建议
+        if (suggestions.value.length === 0) {
+          message("请先勾选设备并点击「补货建议」生成补货明细", { type: "warning" });
+          return;
+        }
+        await doSubmit(done);
+      }
+    });
+  }
+
+  // ==================== 查看详情弹窗 ====================
+
+  function handleViewDetail(row: ReplenishmentOrderRow) {
+    const orderDetail = ref<any>(null);
+    const items = ref<any[]>([]);
+    const detailLoading = ref(true);
+
+    async function loadDetail() {
+      detailLoading.value = true;
+      try {
+        const [orderRes, itemsRes] = await Promise.all([
+          getOrderDetail(row.id),
+          getOrderItems(row.id)
+        ]);
+        orderDetail.value = orderRes.data || (orderRes as any);
+        items.value = Array.isArray(itemsRes.data) ? itemsRes.data : [];
+      } catch (e) {
+        console.error("获取补货单详情失败:", e);
+      } finally {
+        detailLoading.value = false;
+      }
+    }
+
+    const detailRenderer = () => {
+      if (detailLoading.value) {
+        return <div style="text-align:center;padding:40px;">加载中...</div>;
+      }
+      const o = orderDetail.value || {};
+      const s = statusMap[o.status as number];
+      return (
+        <div style="max-height:60vh;overflow-y:auto;">
+          <ElDescriptions column={2} border size="small">
+            <ElDescriptionsItem label="补货单号" span={2}>
+              {o.orderNo || "-"}
+            </ElDescriptionsItem>
+            <ElDescriptionsItem label="设备ID">{o.deviceId || "-"}</ElDescriptionsItem>
+            <ElDescriptionsItem label="设备名称">{o.deviceName || "-"}</ElDescriptionsItem>
+            <ElDescriptionsItem label="门店">{o.shopName || "-"}</ElDescriptionsItem>
+            <ElDescriptionsItem label="状态">
+              {s ? <ElTag type={s.type as any} size="small">{s.text}</ElTag> : "-"}
+            </ElDescriptionsItem>
+            <ElDescriptionsItem label="总数量">{o.totalQuantity ?? "-"}</ElDescriptionsItem>
+            <ElDescriptionsItem label="总金额">
+              {o.totalAmount != null ? `¥${Number(o.totalAmount).toFixed(2)}` : "-"}
+            </ElDescriptionsItem>
+            <ElDescriptionsItem label="供应商">
+              {o.supplierName || "-"}
+            </ElDescriptionsItem>
+            <ElDescriptionsItem label="仓库">
+              {o.warehouseName || "-"}
+            </ElDescriptionsItem>
+            <ElDescriptionsItem label="预计到货">
+              {o.expectedArrivalTime
+                ? dayjs(o.expectedArrivalTime).format("YYYY-MM-DD HH:mm:ss")
+                : "-"}
+            </ElDescriptionsItem>
+            <ElDescriptionsItem label="创建人">{o.creatorName || "-"}</ElDescriptionsItem>
+            <ElDescriptionsItem label="创建时间">
+              {o.createTime
+                ? dayjs(o.createTime).format("YYYY-MM-DD HH:mm:ss")
+                : "-"}
+            </ElDescriptionsItem>
+            <ElDescriptionsItem label="备注" span={2}>
+              {o.remark || "-"}
+            </ElDescriptionsItem>
+          </ElDescriptions>
+
+          <ElDivider contentPosition="left">补货明细</ElDivider>
+          <ElTable data={items.value} size="small" max-height={300}>
+            <ElTableColumn prop="productCode" label="商品编码" minWidth={120} />
+            <ElTableColumn prop="productName" label="商品名称" minWidth={130} />
+            <ElTableColumn prop="plannedQuantity" label="计划数量" width={90} />
+            <ElTableColumn prop="actualQuantity" label="实际数量" width={90} />
+            <ElTableColumn prop="beforeStock" label="补货前库存" width={110} />
+            <ElTableColumn prop="afterStock" label="补货后库存" width={110} />
+            <ElTableColumn
+              prop="unitPrice"
+              label="单价"
+              width={90}
+              formatter={(r: any) =>
+                r.unitPrice != null ? `¥${Number(r.unitPrice).toFixed(2)}` : "-"
+              }
+            />
+            <ElTableColumn
+              prop="totalPrice"
+              label="小计"
+              width={90}
+              formatter={(r: any) =>
+                r.totalPrice != null
+                  ? `¥${Number(r.totalPrice).toFixed(2)}`
+                  : "-"
+              }
+            />
+            <ElTableColumn prop="shelfNum" label="层号" width={70} />
+            <ElTableColumn prop="position" label="货道" width={80} />
+          </ElTable>
+        </div>
+      );
+    };
+
+    loadDetail();
+    addDialog({
+      title: `补货单详情 - ${row.orderNo}`,
+      width: "70%",
+      draggable: true,
+      closeOnClickModal: false,
+      fullscreen: deviceDetection(),
+      contentRenderer: detailRenderer,
+      hideFooter: true
+    });
+  }
+
+  // ==================== 生命周期操作 ====================
+
+  async function handleSubmit(row: ReplenishmentOrderRow) {
+    try {
+      await ElMessageBox.confirm(`确认提交补货单 ${row.orderNo}?`, "提示", {
+        confirmButtonText: "确定",
+        cancelButtonText: "取消",
+        type: "warning"
+      });
+      await submitOrder(row.id);
+      message("提交成功", { type: "success" });
+      onSearch();
+    } catch {
+      // cancelled
+    }
+  }
+
+  async function handleDelete(row: ReplenishmentOrderRow) {
+    try {
+      await deleteOrder(row.id);
+      message("删除成功", { type: "success" });
+      onSearch();
+    } catch (e) {
+      console.error("删除补货单失败:", e);
+    }
+  }
+
+  async function handleSyncErp(row: ReplenishmentOrderRow) {
+    try {
+      await ElMessageBox.confirm(
+        `确认将补货单 ${row.orderNo} 同步到ERP?`,
+        "提示",
+        { confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
+      );
+      await syncToErp(row.id);
+      message("同步成功", { type: "success" });
+      onSearch();
+    } catch {
+      // cancelled
+    }
+  }
+
+  async function handleComplete(row: ReplenishmentOrderRow) {
+    try {
+      await ElMessageBox.confirm(
+        `确认完成补货单 ${row.orderNo}?`,
+        "提示",
+        { confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
+      );
+      await completeOrder(row.id);
+      message("完成", { type: "success" });
+      onSearch();
+    } catch {
+      // cancelled
+    }
+  }
+
+  async function handleCancel(row: ReplenishmentOrderRow) {
+    try {
+      await ElMessageBox.confirm(
+        `确认取消补货单 ${row.orderNo}?取消后不可恢复。`,
+        "提示",
+        { confirmButtonText: "确定", cancelButtonText: "取消", type: "error" }
+      );
+      await cancelOrder(row.id);
+      message("已取消", { type: "success" });
+      onSearch();
+    } catch {
+      // cancelled
+    }
+  }
+
+  // ==================== 初始化 ====================
+
+  onMounted(async () => {
+    await fetchDeviceOptions();
+    onSearch();
+  });
+
+  return {
+    form,
+    formRef,
+    loading,
+    columns,
+    dataList,
+    pagination,
+    deviceOptions,
+    statusMap,
+    onSearch,
+    resetForm,
+    handleSizeChange,
+    handleCurrentChange,
+    handleCreate,
+    handleViewDetail,
+    handleDelete,
+    handleSubmit,
+    handleSyncErp,
+    handleComplete,
+    handleCancel
+  };
+}

+ 51 - 0
haha-admin-web/src/views/inventory/replenishment/utils/types.ts

@@ -0,0 +1,51 @@
+/** 搜索表单 */
+export interface ReplenishmentSearchForm {
+  orderNo: string;
+  deviceId: string;
+  status: number | undefined;
+  timeRange: string[];
+}
+
+/** 补货单列表行 */
+export interface ReplenishmentOrderRow {
+  id: string;
+  orderNo: string;
+  deviceId: string;
+  deviceName: string;
+  shopId: string;
+  shopName: string;
+  status: number;
+  statusLabel: string;
+  statusColor: string;
+  totalQuantity: number;
+  totalAmount: number;
+  supplierName: string;
+  warehouseName: string;
+  expectedArrivalTime: string;
+  remark: string;
+  creatorName: string;
+  itemCount: number;
+  createTime: string;
+}
+
+/** 单条补货建议 */
+export interface SuggestionItem {
+  productId: string;
+  productCode: string;
+  productName: string;
+  templateStock: number;
+  currentStock: number;
+  suggestedQuantity: number;
+  shelfNum: number;
+  position: string;
+  unitPrice: number;
+}
+
+/** 按设备分组的补货建议 */
+export interface ReplenishmentSuggestion {
+  deviceId: string;
+  deviceName: string;
+  shopId: string;
+  shopName: string;
+  items: SuggestionItem[];
+}

+ 248 - 202
haha-admin-web/src/views/replenishment-order/components/CreateDialog.vue

@@ -1,8 +1,8 @@
 <script setup lang="ts">
 import { ref, reactive } from "vue";
 import { message } from "@/utils/message";
-import { createOrder } from "@/api/replenishmentOrder";
-import type { FormInstance, FormRules } from "element-plus";
+import { createOrder, getReplenishmentSuggestions } from "@/api/replenishmentOrder";
+import { getDeviceList } from "@/api/device";
 import type { ReplenishmentOrderItem } from "../utils/types";
 
 const emit = defineEmits<{
@@ -10,124 +10,140 @@ const emit = defineEmits<{
 }>();
 
 const visible = ref(false);
-const formRef = ref<FormInstance>();
 const loading = ref(false);
+const suggestionsLoading = ref(false);
 
-const form = reactive({
-  deviceId: "",
-  shopId: undefined as string | undefined,
+// 设备列表
+const deviceList = ref<any[]>([]);
+const selectedDevices = ref<any[]>([]);
+
+// 补货建议结果(按设备分组)
+const suggestions = ref<any[]>([]);
+
+// 公共字段
+const commonForm = reactive({
   supplierName: "",
   warehouseName: "",
-  expectedArrivalTime: "",
-  remark: ""
+  expectedArrivalTime: ""
 });
 
-const items = ref<ReplenishmentOrderItem[]>([
-  {
-    productId: "",
-    productCode: "",
-    productName: "",
-    plannedQuantity: 1,
-    unitPrice: undefined,
-    shelfNum: undefined,
-    position: ""
-  }
-]);
-
-const rules: FormRules = {
-  deviceId: [
-    { required: true, message: "请输入设备SN号", trigger: "blur" }
-  ]
-};
-
-function open() {
-  resetForm();
+async function open() {
+  resetState();
   visible.value = true;
+  await loadDevices();
 }
 
-function resetForm() {
-  form.deviceId = "";
-  form.shopId = undefined;
-  form.supplierName = "";
-  form.warehouseName = "";
-  form.expectedArrivalTime = "";
-  form.remark = "";
-  items.value = [
-    {
-      productId: "",
-      productCode: "",
-      productName: "",
-      plannedQuantity: 1,
-      unitPrice: undefined,
-      shelfNum: undefined,
-      position: ""
-    }
-  ];
+function resetState() {
+  selectedDevices.value = [];
+  suggestions.value = [];
+  suggestionsLoading.value = false;
+  commonForm.supplierName = "";
+  commonForm.warehouseName = "";
+  commonForm.expectedArrivalTime = "";
 }
 
-function addItem() {
-  items.value.push({
-    productId: "",
-    productCode: "",
-    productName: "",
-    plannedQuantity: 1,
-    unitPrice: undefined,
-    shelfNum: undefined,
-    position: ""
-  });
+async function loadDevices() {
+  try {
+    const { data } = await getDeviceList({ page: 1, pageSize: 1000 });
+    deviceList.value = data?.list || [];
+  } catch (e) {
+    console.error("加载设备列表失败:", e);
+  }
 }
 
-function removeItem(index: number) {
-  if (items.value.length <= 1) return;
-  items.value.splice(index, 1);
+function handleSelectionChange(selection: any[]) {
+  selectedDevices.value = selection;
 }
 
-async function handleSubmit(formEl: FormInstance | undefined) {
-  if (!formEl) return;
-  const valid = await formEl.validate().catch(() => false);
-  if (!valid) return;
+function resetToSelect() {
+  suggestions.value = [];
+  selectedDevices.value = [];
+}
 
-  // Validate items
-  const emptyItem = items.value.find(
-    item => !item.productId && !item.productCode
+async function fetchSuggestions() {
+  if (selectedDevices.value.length === 0) {
+    message("请先勾选设备", { type: "warning" });
+    return;
+  }
+  suggestionsLoading.value = true;
+  try {
+    const ids = selectedDevices.value.map((d: any) => d.deviceId as string);
+    const { data } = await getReplenishmentSuggestions(ids);
+    const list: any[] = Array.isArray(data) ? data : [];
+    if (list.length === 0) {
+      message("所选设备当前均无需补货", { type: "info" });
+    }
+    suggestions.value = list;
+  } catch (e) {
+    console.error("获取补货建议失败:", e);
+    message("获取补货建议失败", { type: "error" });
+  } finally {
+    suggestionsLoading.value = false;
+  }
+}
+
+async function handleSubmit() {
+  const validSuggestions = suggestions.value.filter(
+    s => s.items.some((i: any) => i.suggestedQuantity > 0)
   );
-  if (emptyItem) {
-    message("请填写商品信息", { type: "warning" });
+  if (validSuggestions.length === 0) {
+    message("没有有效的补货项,请检查补货数量", { type: "warning" });
     return;
   }
 
   loading.value = true;
   try {
-    const { code } = await createOrder({
-      deviceId: form.deviceId,
-      shopId: form.shopId ? Number(form.shopId) : undefined,
-      supplierName: form.supplierName || undefined,
-      warehouseName: form.warehouseName || undefined,
-      expectedArrivalTime: form.expectedArrivalTime
-        ? form.expectedArrivalTime.replace("T", " ") + ":00"
-        : undefined,
-      remark: form.remark || undefined,
-      items: items.value.map(item => ({
-        productId: Number(item.productId),
-        productCode: item.productCode || undefined,
-        productName: item.productName || undefined,
-        plannedQuantity: item.plannedQuantity,
-        unitPrice: item.unitPrice,
-        shelfNum: item.shelfNum,
-        position: item.position || undefined
-      }))
-    });
+    const payloads = validSuggestions.map((s: any) => ({
+      deviceId: s.deviceId,
+      shopId: s.shopId ? Number(s.shopId) : undefined,
+      supplierName: commonForm.supplierName || undefined,
+      warehouseName: commonForm.warehouseName || undefined,
+      expectedArrivalTime: commonForm.expectedArrivalTime || undefined,
+      remark: undefined,
+      items: s.items
+        .filter((i: any) => i.suggestedQuantity > 0)
+        .map((i: any) => ({
+          productId: Number(i.productId),
+          productCode: i.productCode,
+          productName: i.productName,
+          plannedQuantity: i.suggestedQuantity,
+          unitPrice: i.unitPrice != null ? i.unitPrice : undefined,
+          shelfNum: i.shelfNum,
+          position: i.position
+        }))
+    }));
+
+    const results = await Promise.allSettled(
+      payloads.map((p: any) => createOrder(p))
+    );
 
-    if (code === 200) {
-      message("创建补货单成功", { type: "success" });
-      visible.value = false;
-      emit("success");
+    const successCount = results.filter(r => r.status === "fulfilled").length;
+    const failCount = results.filter(r => r.status === "rejected").length;
+
+    if (failCount === 0) {
+      message(`成功创建 ${successCount} 个补货单`, { type: "success" });
+    } else {
+      message(`创建完成:${successCount} 成功, ${failCount} 失败`, { type: "warning" });
     }
+    visible.value = false;
+    emit("success");
   } finally {
     loading.value = false;
   }
 }
 
+function deviceTotalQty(sug: any) {
+  return sug.items.reduce((sum: number, i: any) => sum + (i.suggestedQuantity || 0), 0);
+}
+
+function deviceTotalAmt(sug: any) {
+  return sug.items.reduce((sum: number, i: any) => sum + (i.suggestedQuantity || 0) * (i.unitPrice || 0), 0);
+}
+
+function itemSubtotal(item: any) {
+  return ((item.suggestedQuantity || 0) * (item.unitPrice || 0)).toFixed(2);
+}
+
 defineExpose({ open });
 </script>
 
@@ -135,131 +151,161 @@ defineExpose({ open });
   <el-dialog
     v-model="visible"
     title="新建补货单"
-    width="800px"
+    width="85%"
+    top="3vh"
     :close-on-click-modal="false"
     destroy-on-close
   >
-    <el-form
-      ref="formRef"
-      :model="form"
-      :rules="rules"
-      label-width="110px"
-    >
-      <el-form-item label="设备SN号" prop="deviceId">
-        <el-input v-model="form.deviceId" placeholder="请输入设备SN号" />
-      </el-form-item>
+    <div style="min-height: 500px; max-height: 78vh; overflow-y: auto;">
+      <!-- 第一步:设备列表 -->
+      <div style="margin-bottom:8px;font-size:14px;font-weight:600;color:#303133;">
+        第一步:勾选需要补货的设备
+      </div>
+      <el-table
+        ref="deviceTableRef"
+        :data="deviceList"
+        max-height="420"
+        @selection-change="handleSelectionChange"
+      >
+        <el-table-column type="selection" width="55" />
+        <el-table-column prop="deviceId" label="设备ID" min-width="140" />
+        <el-table-column prop="name" label="设备名称" min-width="140" />
+        <el-table-column prop="shopName" label="所属门店" min-width="140" />
+      </el-table>
 
-      <el-row :gutter="16">
-        <el-col :span="12">
-          <el-form-item label="供应商">
-            <el-input v-model="form.supplierName" placeholder="ERP供应商名称" />
-          </el-form-item>
-        </el-col>
-        <el-col :span="12">
-          <el-form-item label="仓库">
-            <el-input v-model="form.warehouseName" placeholder="ERP仓库名称" />
-          </el-form-item>
-        </el-col>
-      </el-row>
+      <div style="margin-top:12px;display:flex;align-items:center;gap:12px;">
+        <el-button
+          type="primary"
+          :loading="suggestionsLoading"
+          :disabled="selectedDevices.length === 0"
+          @click="fetchSuggestions"
+        >
+          补货建议
+        </el-button>
+        <span style="color:#909399;font-size:13px;">
+          已勾选 {{ selectedDevices.length }} 台设备
+          <template v-if="suggestions.length > 0">
+            ,共 {{ suggestions.reduce((s, sug) => s + sug.items.length, 0) }} 项待补商品
+          </template>
+        </span>
+      </div>
+
+      <!-- 第二步:补货明细 -->
+      <template v-if="suggestions.length > 0">
+        <div style="margin-top:24px;margin-bottom:12px;font-size:14px;font-weight:600;color:#303133;">
+          第二步:确认补货明细,填写供应商等信息
+        </div>
 
-      <el-row :gutter="16">
-        <el-col :span="12">
-          <el-form-item label="预计到货时间">
+        <div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:16px;padding:12px;background:#fafafa;border-radius:6px;">
+          <div style="display:flex;align-items:center;gap:6px;">
+            <span style="font-size:13px;color:#606266;white-space:nowrap;">供应商</span>
+            <el-input v-model="commonForm.supplierName" placeholder="选填" clearable size="small" style="width:150px;" />
+          </div>
+          <div style="display:flex;align-items:center;gap:6px;">
+            <span style="font-size:13px;color:#606266;white-space:nowrap;">仓库</span>
+            <el-input v-model="commonForm.warehouseName" placeholder="选填" clearable size="small" style="width:150px;" />
+          </div>
+          <div style="display:flex;align-items:center;gap:6px;">
+            <span style="font-size:13px;color:#606266;white-space:nowrap;">预计到货</span>
             <el-date-picker
-              v-model="form.expectedArrivalTime"
+              v-model="commonForm.expectedArrivalTime"
               type="datetime"
-              placeholder="选择日期时间"
+              placeholder="选"
               value-format="YYYY-MM-DD HH:mm:ss"
-              class="w-full!"
+              size="small"
+              style="width:190px;"
             />
-          </el-form-item>
-        </el-col>
-        <el-col :span="12">
-          <el-form-item label="备注">
-            <el-input v-model="form.remark" placeholder="备注信息" />
-          </el-form-item>
-        </el-col>
-      </el-row>
+          </div>
+        </div>
 
-      <el-divider content-position="left">补货商品</el-divider>
+        <!-- 按设备分组 -->
+        <el-card
+          v-for="sug in suggestions"
+          :key="sug.deviceId"
+          style="margin-bottom:12px;"
+          shadow="hover"
+        >
+          <template #header>
+            <div style="display:flex;justify-content:space-between;align-items:center;">
+              <span style="font-size:14px;font-weight:600;">
+                {{ sug.deviceId }}
+                <template v-if="sug.deviceName">({{ sug.deviceName }})</template>
+                <span style="margin:0 8px;color:#dcdfe6;">|</span>
+                {{ sug.shopName || "未绑定门店" }}
+              </span>
+              <span style="color:#409eff;font-size:13px;">
+                {{ deviceTotalQty(sug) }} 件 / ¥{{ deviceTotalAmt(sug).toFixed(2) }}
+              </span>
+            </div>
+          </template>
 
-      <div v-for="(item, index) in items" :key="index" class="mb-2">
-        <el-row :gutter="8" align="middle">
-          <el-col :span="5">
-            <el-input
-              v-model="item.productCode"
-              placeholder="商品编码"
-              size="small"
-            />
-          </el-col>
-          <el-col :span="5">
-            <el-input
-              v-model="item.productName"
-              placeholder="商品名称"
-              size="small"
-            />
-          </el-col>
-          <el-col :span="3">
-            <el-input-number
-              v-model="item.plannedQuantity"
-              :min="1"
-              :max="99999"
-              size="small"
-              controls-position="right"
-              class="w-full!"
-            />
-          </el-col>
-          <el-col :span="3">
-            <el-input-number
-              v-model="item.unitPrice"
-              :min="0"
-              :precision="2"
-              placeholder="单价"
-              size="small"
-              controls-position="right"
-              class="w-full!"
-            />
-          </el-col>
-          <el-col :span="2">
-            <el-input-number
-              v-model="item.shelfNum"
-              :min="1"
-              placeholder="层"
-              size="small"
-              controls-position="right"
-              class="w-full!"
-            />
-          </el-col>
-          <el-col :span="3">
-            <el-input
-              v-model="item.position"
-              placeholder="货道位置"
-              size="small"
-            />
-          </el-col>
-          <el-col :span="2">
-            <el-button
-              type="danger"
-              size="small"
-              :icon="'ep:delete'"
-              circle
-              :disabled="items.length <= 1"
-              @click="removeItem(index)"
-            />
-          </el-col>
-        </el-row>
-      </div>
+          <table style="width:100%;border-collapse:collapse;">
+            <thead>
+              <tr style="background:#f5f7fa;">
+                <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">商品编码</th>
+                <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">商品名称</th>
+                <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">标准库存</th>
+                <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">当前库存</th>
+                <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">补货数量</th>
+                <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">单价</th>
+                <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">小计</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="(item, idx) in sug.items" :key="idx">
+                <td style="padding:6px 10px;border:1px solid #ebeef5;font-size:13px;">{{ item.productCode }}</td>
+                <td style="padding:6px 10px;border:1px solid #ebeef5;font-size:13px;">{{ item.productName || "-" }}</td>
+                <td style="padding:6px 10px;border:1px solid #ebeef5;text-align:center;font-size:13px;">{{ item.templateStock }}</td>
+                <td style="padding:6px 10px;border:1px solid #ebeef5;text-align:center;font-size:13px;">
+                  <span :style="{ fontWeight:'500', color: item.currentStock <= 0 ? '#f56c6c' : '#67c23a' }">
+                    {{ item.currentStock }}
+                  </span>
+                </td>
+                <td style="padding:4px 6px;border:1px solid #ebeef5;">
+                  <el-input-number
+                    v-model="item.suggestedQuantity"
+                    :min="1"
+                    :max="9999"
+                    size="small"
+                    controls-position="right"
+                    style="width:100px;"
+                  />
+                </td>
+                <td style="padding:4px 6px;border:1px solid #ebeef5;">
+                  <el-input v-model="item.unitPrice" placeholder="单价" size="small" style="width:90px;" />
+                </td>
+                <td style="padding:6px 10px;border:1px solid #ebeef5;text-align:right;font-size:13px;font-weight:500;">
+                  ¥{{ itemSubtotal(item) }}
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </el-card>
 
-      <el-button type="primary" link :icon="'ep:plus'" @click="addItem">
-        添加商品
-      </el-button>
-    </el-form>
+        <div style="margin-top:16px;padding:10px 16px;background:#ecf5ff;border-radius:6px;color:#409eff;font-size:13px;">
+          确认补货明细无误后,点击「确认创建」按钮即可创建补货单
+        </div>
+      </template>
+    </div>
 
     <template #footer>
-      <el-button @click="visible = false">取消</el-button>
-      <el-button type="primary" :loading="loading" @click="handleSubmit(formRef)">
-        确认创建
-      </el-button>
+      <div style="display:flex;justify-content:flex-end;gap:12px;">
+        <el-button @click="visible = false">取消</el-button>
+        <el-button
+          v-if="suggestions.length > 0"
+          @click="resetToSelect"
+        >
+          重新选择
+        </el-button>
+        <el-button
+          v-if="suggestions.length > 0"
+          type="primary"
+          :loading="loading"
+          @click="handleSubmit"
+        >
+          确认创建
+        </el-button>
+      </div>
     </template>
   </el-dialog>
 </template>

+ 2 - 1
haha-admin-web/vite.config.ts

@@ -42,7 +42,8 @@ export default async ({ mode }: ConfigEnv): Promise<UserConfigExport> => {
     "/refund",
     "/replenishers",
     "/menu",
-    "/upload"
+    "/upload",
+    "/replenishment-orders"
   ];
   
   // 动态生成代理配置

+ 23 - 0
haha-admin/src/main/java/com/haha/admin/controller/ReplenishmentOrderController.java

@@ -11,13 +11,17 @@ import com.haha.entity.ReplenishmentOrderItem;
 import com.haha.entity.dto.ReplenishmentOrderCreateDTO;
 import com.haha.entity.dto.ReplenishmentOrderQueryDTO;
 import com.haha.entity.dto.ReplenishmentOrderUpdateDTO;
+import com.haha.entity.dto.ReplenishmentSuggestionVO;
 import com.haha.service.ReplenishmentOrderService;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
+import java.util.stream.Collectors;
 
 /**
  * 设备补货单管理控制器
@@ -141,6 +145,25 @@ public class ReplenishmentOrderController {
         return Result.success("取消成功", order);
     }
 
+    /**
+     * 获取补货建议
+     * 根据设备ID批量计算:层模版标准库存 - 当前实际库存
+     */
+    @RequirePermission("replenishment:order:read")
+    @GetMapping("/suggestions")
+    public Result<List<ReplenishmentSuggestionVO>> getSuggestions(
+            @RequestParam("deviceIds") String deviceIds) {
+        List<String> ids = Arrays.stream(deviceIds.split(","))
+                .map(String::trim)
+                .filter(s -> !s.isEmpty())
+                .collect(Collectors.toList());
+        if (ids.isEmpty()) {
+            return Result.success("查询成功", Collections.emptyList());
+        }
+        List<ReplenishmentSuggestionVO> suggestions = orderService.getSuggestions(ids);
+        return Result.success("查询成功", suggestions);
+    }
+
     private Long getCurrentUserId() {
         try {
             return cn.dev33.satoken.stp.StpUtil.getLoginIdAsLong();

+ 30 - 0
haha-common/src/main/java/com/haha/common/constant/DeviceConstants.java

@@ -79,4 +79,34 @@ public final class DeviceConstants {
     public static boolean isOnline(Integer status) {
         return status != null && status == STATUS_ONLINE;
     }
+
+    /**
+     * 设备状态标签
+     */
+    public static class StatusLabel {
+        private final String label;
+        private final String color;
+
+        public StatusLabel(String label, String color) {
+            this.label = label;
+            this.color = color;
+        }
+
+        public String getLabel() { return label; }
+        public String getColor() { return color; }
+    }
+
+    /**
+     * 根据状态码获取状态标签
+     */
+    public static StatusLabel getStatusLabel(Integer status) {
+        if (status == null) return new StatusLabel("未知", "info");
+        return switch (status) {
+            case STATUS_ONLINE -> new StatusLabel("在线", "success");
+            case STATUS_FROZEN -> new StatusLabel("冻结", "warning");
+            case STATUS_OFFLINE -> new StatusLabel("离线", "danger");
+            case STATUS_MAINTENANCE -> new StatusLabel("维护中", "info");
+            default -> new StatusLabel("未知", "info");
+        };
+    }
 }

+ 33 - 0
haha-entity/src/main/java/com/haha/entity/dto/ReplenishmentSuggestionVO.java

@@ -0,0 +1,33 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 补货建议响应VO
+ * 根据设备层模版标准库存与当前实际库存,计算建议补货量
+ */
+@Data
+public class ReplenishmentSuggestionVO {
+
+    private String deviceId;
+    private String deviceName;
+    private Long shopId;
+    private String shopName;
+    private List<SuggestionItem> items;
+
+    @Data
+    public static class SuggestionItem {
+        private Long productId;
+        private String productCode;
+        private String productName;
+        private Integer templateStock;
+        private Integer currentStock;
+        private Integer suggestedQuantity;
+        private Integer shelfNum;
+        private String position;
+        private BigDecimal unitPrice;
+    }
+}

+ 2 - 0
haha-mapper/src/main/java/com/haha/mapper/LayerTemplateMapper.java

@@ -3,6 +3,7 @@ package com.haha.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.haha.entity.LayerTemplate;
 import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
 
 import java.util.List;
 
@@ -17,5 +18,6 @@ public interface LayerTemplateMapper extends BaseMapper<LayerTemplate> {
      * @param deviceId 设备ID(SN号)
      * @return 层模版列表
      */
+    @Select("SELECT * FROM t_layer_template WHERE device_id = #{deviceId} AND is_deleted = 0")
     List<LayerTemplate> selectByDeviceId(@Param("deviceId") String deviceId);
 }

+ 8 - 0
haha-service/src/main/java/com/haha/service/ReplenishmentOrderService.java

@@ -8,6 +8,8 @@ import com.haha.entity.dto.ReplenishmentOrderCreateDTO;
 import com.haha.entity.dto.ReplenishmentOrderQueryDTO;
 import com.haha.entity.dto.ReplenishmentOrderUpdateDTO;
 
+import com.haha.entity.dto.ReplenishmentSuggestionVO;
+
 import java.util.List;
 
 /**
@@ -64,4 +66,10 @@ public interface ReplenishmentOrderService extends IService<ReplenishmentOrder>
      * 取消补货单(草稿/已提交→已取消)
      */
     ReplenishmentOrder cancelOrder(Long id);
+
+    /**
+     * 根据设备ID列表获取补货建议
+     * 计算规则:层模版标准库存 - 当前实际库存,只返回 > 0 的项
+     */
+    List<ReplenishmentSuggestionVO> getSuggestions(List<String> deviceIds);
 }

+ 1 - 1
haha-service/src/main/java/com/haha/service/impl/LayerTemplateServiceImpl.java

@@ -53,7 +53,7 @@ import java.util.Map;
 public class LayerTemplateServiceImpl extends ServiceImpl<LayerTemplateMapper, LayerTemplate> implements LayerTemplateService {
 
     private final HahaClient hahaClient;
-    private final ObjectMapper objectMapper = new ObjectMapper();
+    private ObjectMapper objectMapper = new ObjectMapper();
 
     @Autowired
     private DeviceInventoryService deviceInventoryService;

+ 218 - 8
haha-service/src/main/java/com/haha/service/impl/ReplenishmentOrderServiceImpl.java

@@ -4,15 +4,19 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.haha.common.dto.FloorConfig;
+import com.haha.common.dto.GoodsItem;
 import com.haha.common.enums.ReplenishmentOrderStatusEnum;
 import com.haha.common.exception.BusinessException;
-import com.haha.entity.ReplenishmentOrder;
-import com.haha.entity.ReplenishmentOrderItem;
+import com.haha.entity.*;
 import com.haha.entity.dto.ReplenishmentOrderCreateDTO;
 import com.haha.entity.dto.ReplenishmentOrderQueryDTO;
 import com.haha.entity.dto.ReplenishmentOrderUpdateDTO;
-import com.haha.mapper.ReplenishmentOrderItemMapper;
-import com.haha.mapper.ReplenishmentOrderMapper;
+import com.haha.entity.dto.ReplenishmentSuggestionVO;
+import com.haha.entity.dto.ReplenishmentSuggestionVO.SuggestionItem;
+import com.haha.mapper.*;
 import com.haha.service.ReplenishmentOrderService;
 // TODO: 企得宝 ERP SDK - 暂未对接,后续接入时取消注释
 // import com.qdb.sdk.QdbClient;
@@ -29,10 +33,7 @@ import java.math.BigDecimal;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 import java.util.stream.Collectors;
 
 /**
@@ -46,6 +47,13 @@ public class ReplenishmentOrderServiceImpl extends ServiceImpl<ReplenishmentOrde
 
     private final ReplenishmentOrderMapper orderMapper;
     private final ReplenishmentOrderItemMapper orderItemMapper;
+    private final DeviceMapper deviceMapper;
+    private final ShopMapper shopMapper;
+    private final LayerTemplateMapper layerTemplateMapper;
+    private final DeviceInventoryMapper deviceInventoryMapper;
+    private final ProductMapper productMapper;
+
+    private ObjectMapper objectMapper = new ObjectMapper();
 
     // TODO: 企得宝 ERP SDK - 暂未对接,后续接入时取消注释
     // @Autowired(required = false)
@@ -103,6 +111,9 @@ public class ReplenishmentOrderServiceImpl extends ServiceImpl<ReplenishmentOrde
             order.setItemCount(countMap.getOrDefault(order.getId(), 0L).intValue());
         }
 
+        // 批量填充 shopName 和 deviceName
+        populateNames(result.getRecords());
+
         return result;
     }
 
@@ -113,6 +124,7 @@ public class ReplenishmentOrderServiceImpl extends ServiceImpl<ReplenishmentOrde
             throw new BusinessException(404, "补货单不存在");
         }
         fillStatusLabel(order);
+        populateNames(java.util.Collections.singletonList(order));
         return order;
     }
 
@@ -342,6 +354,204 @@ public class ReplenishmentOrderServiceImpl extends ServiceImpl<ReplenishmentOrde
         return order;
     }
 
+    /**
+     * 批量填充 shopName 和 deviceName
+     */
+    private void populateNames(List<ReplenishmentOrder> orders) {
+        Set<Long> shopIds = orders.stream()
+                .map(ReplenishmentOrder::getShopId).filter(id -> id != null).collect(Collectors.toSet());
+        Set<String> deviceIds = orders.stream()
+                .map(ReplenishmentOrder::getDeviceId).filter(StringUtils::hasText).collect(Collectors.toSet());
+
+        Map<Long, String> shopNameMap = new HashMap<>();
+        if (!shopIds.isEmpty()) {
+            List<Shop> shops = shopMapper.selectBatchIds(shopIds);
+            shopNameMap = shops.stream().collect(Collectors.toMap(Shop::getId, Shop::getName, (a, b) -> a));
+        }
+
+        Map<String, String> deviceNameMap = new HashMap<>();
+        if (!deviceIds.isEmpty()) {
+            List<Device> devices = deviceMapper.selectList(
+                    new LambdaQueryWrapper<Device>().in(Device::getDeviceId, deviceIds));
+            deviceNameMap = devices.stream().collect(Collectors.toMap(Device::getDeviceId, Device::getName, (a, b) -> a));
+        }
+
+        for (ReplenishmentOrder order : orders) {
+            order.setShopName(shopNameMap.getOrDefault(order.getShopId(), ""));
+            order.setDeviceName(deviceNameMap.getOrDefault(order.getDeviceId(), ""));
+        }
+    }
+
+    @Override
+    public List<ReplenishmentSuggestionVO> getSuggestions(List<String> deviceIds) {
+        if (deviceIds == null || deviceIds.isEmpty()) {
+            return java.util.Collections.emptyList();
+        }
+
+        // 批量查询设备
+        List<Device> devices = deviceMapper.selectList(
+                new LambdaQueryWrapper<Device>().in(Device::getDeviceId, deviceIds));
+        Map<String, Device> deviceMap = devices.stream()
+                .collect(Collectors.toMap(Device::getDeviceId, d -> d, (a, b) -> a));
+
+        // 批量查询门店
+        Set<Long> shopIds = devices.stream().map(Device::getShopId).filter(id -> id != null).collect(Collectors.toSet());
+        Map<Long, String> shopNameMap = new HashMap<>();
+        if (!shopIds.isEmpty()) {
+            shopNameMap = shopMapper.selectBatchIds(shopIds).stream()
+                    .collect(Collectors.toMap(Shop::getId, Shop::getName, (a, b) -> a));
+        }
+
+        // 批量查询层模版
+        List<LayerTemplate> allTemplates = new java.util.ArrayList<>();
+        for (String deviceId : deviceIds) {
+            allTemplates.addAll(layerTemplateMapper.selectByDeviceId(deviceId));
+        }
+        Map<String, List<LayerTemplate>> templateMap = allTemplates.stream()
+                .collect(Collectors.groupingBy(LayerTemplate::getDeviceId));
+
+        // 批量查询当前库存
+        List<DeviceInventory> allInventories = deviceInventoryMapper.selectList(
+                new LambdaQueryWrapper<DeviceInventory>().in(DeviceInventory::getDeviceId, deviceIds));
+        Map<String, Map<String, DeviceInventory>> inventoryMap = new HashMap<>();
+        for (DeviceInventory inv : allInventories) {
+            inventoryMap.computeIfAbsent(inv.getDeviceId(), k -> new HashMap<>())
+                    .put(inv.getProductCode(), inv);
+        }
+
+        // 收集所有需要查询的 productCode
+        Set<String> allCodes = new HashSet<>();
+        for (String deviceId : deviceIds) {
+            List<LayerTemplate> temps = templateMap.getOrDefault(deviceId, java.util.Collections.emptyList());
+            for (LayerTemplate temp : temps) {
+                for (FloorConfig floor : parseFloors(temp.getLeftFloors())) {
+                    for (GoodsItem goods : floor.getGoods()) {
+                        if (goods.getKey() != null) allCodes.add(goods.getKey());
+                    }
+                }
+                for (FloorConfig floor : parseFloors(temp.getRightFloors())) {
+                    for (GoodsItem goods : floor.getGoods()) {
+                        if (goods.getKey() != null) allCodes.add(goods.getKey());
+                    }
+                }
+            }
+        }
+
+        // 批量查询 Product(按 code)
+        Map<String, Product> productMap = new HashMap<>();
+        if (!allCodes.isEmpty()) {
+            List<Product> products = productMapper.selectList(
+                    new LambdaQueryWrapper<Product>().in(Product::getCode, allCodes));
+            productMap = products.stream().collect(Collectors.toMap(Product::getCode, p -> p, (a, b) -> a));
+        }
+
+        // 生成补货建议
+        List<ReplenishmentSuggestionVO> result = new java.util.ArrayList<>();
+
+        for (String deviceId : deviceIds) {
+            Device device = deviceMap.get(deviceId);
+            if (device == null) continue;
+
+            // 解析模版,按 productCode 累加标准库存
+            Map<String, Accumulator> accumulators = new LinkedHashMap<>();
+            List<LayerTemplate> temps = templateMap.getOrDefault(deviceId, java.util.Collections.emptyList());
+            for (LayerTemplate temp : temps) {
+                int floorNum = temp.getShelfNum() != null ? temp.getShelfNum() : 1;
+                for (FloorConfig floor : parseFloors(temp.getLeftFloors())) {
+                    for (GoodsItem goods : floor.getGoods()) {
+                        if (goods.getKey() != null && goods.getStock() != null && goods.getStock() > 0) {
+                            accumulators.computeIfAbsent(goods.getKey(), k -> new Accumulator())
+                                    .addTemplateStock(goods.getStock(), floorNum, "left");
+                        }
+                    }
+                }
+                for (FloorConfig floor : parseFloors(temp.getRightFloors())) {
+                    for (GoodsItem goods : floor.getGoods()) {
+                        if (goods.getKey() != null && goods.getStock() != null && goods.getStock() > 0) {
+                            accumulators.computeIfAbsent(goods.getKey(), k -> new Accumulator())
+                                    .addTemplateStock(goods.getStock(), floorNum, "right");
+                        }
+                    }
+                }
+            }
+
+            if (accumulators.isEmpty()) continue;
+
+            // 获取当前库存
+            Map<String, DeviceInventory> deviceInvs = inventoryMap.getOrDefault(deviceId, new HashMap<>());
+
+            List<SuggestionItem> items = new java.util.ArrayList<>();
+            for (Map.Entry<String, Accumulator> entry : accumulators.entrySet()) {
+                String code = entry.getKey();
+                Accumulator acc = entry.getValue();
+                int currentStock = deviceInvs.containsKey(code) ? deviceInvs.get(code).getStock() : 0;
+                int suggestedQty = acc.templateStock - currentStock;
+                if (suggestedQty <= 0) continue;
+
+                SuggestionItem item = new SuggestionItem();
+                item.setProductCode(code);
+                item.setTemplateStock(acc.templateStock);
+                item.setCurrentStock(currentStock);
+                item.setSuggestedQuantity(suggestedQty);
+                item.setShelfNum(acc.shelfNum);
+                item.setPosition(acc.position);
+
+                Product product = productMap.get(code);
+                if (product != null) {
+                    item.setProductId(product.getId());
+                    item.setProductName(product.getName());
+                    if (product.getRetailPrice() != null) {
+                        item.setUnitPrice(BigDecimal.valueOf(product.getRetailPrice()));
+                    }
+                }
+                items.add(item);
+            }
+
+            if (!items.isEmpty()) {
+                ReplenishmentSuggestionVO vo = new ReplenishmentSuggestionVO();
+                vo.setDeviceId(deviceId);
+                vo.setDeviceName(device.getName());
+                vo.setShopId(device.getShopId());
+                vo.setShopName(shopNameMap.getOrDefault(device.getShopId(), ""));
+                vo.setItems(items);
+                result.add(vo);
+            }
+        }
+
+        return result;
+    }
+
+    /**
+     * 解析楼层 JSON 字符串
+     */
+    private List<FloorConfig> parseFloors(String floorsJson) {
+        if (!StringUtils.hasText(floorsJson)) {
+            return java.util.Collections.emptyList();
+        }
+        try {
+            return objectMapper.readValue(floorsJson, new TypeReference<List<FloorConfig>>() {});
+        } catch (Exception e) {
+            log.warn("解析楼层JSON失败: {}", e.getMessage());
+            return java.util.Collections.emptyList();
+        }
+    }
+
+    /**
+     * 累加器:合并同一商品在多层的标准库存
+     */
+    private static class Accumulator {
+        int templateStock = 0;
+        int shelfNum = 0;
+        String position = "";
+
+        void addTemplateStock(int stock, int floor, String pos) {
+            this.templateStock += stock;
+            if (this.position.isEmpty()) {
+                this.shelfNum = floor;
+                this.position = pos;
+            }
+        }
+    }
     /**
      * 生成补货单号:RO + yyyyMMdd + 4位序号
      */