Ver código fonte

feat: 详情页可编辑+商品图片+提交ERP一步到位

- 补货单详情页支持编辑:供应商、仓库、备注、商品编码/名称/数量/单价,可增减商品行
- 补货建议和详情明细新增商品图片列
- 「提交」改为「提交ERP」,一键完成提交+同步ERP(syncToErp 改为实际更新状态)
- 修复详情页路由 404 问题
- Accumulator 从 GoodsItem 获取商品图片传给 SuggestionItem

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 1 semana atrás
pai
commit
fd5bf9e217

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

@@ -43,6 +43,15 @@ export default {
         icon: "ri:shopping-bag-3-line",
         title: "补货单管理"
       }
+    },
+    {
+      path: "/inventory/replenishment-order/detail",
+      name: "ReplenishmentOrderDetail",
+      component: () => import("@/views/replenishment-order/detail.vue"),
+      meta: {
+        title: "补货单详情",
+        showLink: false
+      }
     }
   ]
 } satisfies RouteConfigsTable;

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

@@ -33,6 +33,7 @@ export interface SuggestionItem {
   productId: string;
   productCode: string;
   productName: string;
+  productImage: string;
   templateStock: number;
   currentStock: number;
   suggestedQuantity: number;

+ 10 - 0
haha-admin-web/src/views/replenishment-order/components/CreateDialog.vue

@@ -242,6 +242,7 @@ defineExpose({ open });
           <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>
@@ -253,6 +254,15 @@ defineExpose({ open });
             </thead>
             <tbody>
               <tr v-for="(item, idx) in sug.items" :key="idx">
+                <td style="padding:4px;border:1px solid #ebeef5;text-align:center;width:60px;">
+                  <img
+                    v-if="item.productImage"
+                    :src="item.productImage"
+                    style="width:40px;height:40px;object-fit:cover;border-radius:4px;"
+                    @error="$event.target.style.display='none'"
+                  />
+                  <span v-else style="color:#ccc;font-size:20px;">-</span>
+                </td>
                 <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>

+ 186 - 35
haha-admin-web/src/views/replenishment-order/detail.vue

@@ -1,7 +1,10 @@
 <script setup lang="ts">
 import { ref, onMounted } from "vue";
 import { useRoute, useRouter } from "vue-router";
-import { getOrderDetail, getOrderItems } from "@/api/replenishmentOrder";
+import { getOrderDetail, getOrderItems, updateOrder, submitOrder, syncToErp } from "@/api/replenishmentOrder";
+import { message } from "@/utils/message";
+import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import DeleteIcon from "~icons/ep/delete";
 import dayjs from "dayjs";
 
 defineOptions({
@@ -11,6 +14,8 @@ defineOptions({
 const route = useRoute();
 const router = useRouter();
 const loading = ref(true);
+const saving = ref(false);
+const submitting = ref(false);
 const order = ref<any>({});
 const items = ref<any[]>([]);
 
@@ -37,17 +42,95 @@ async function loadData() {
       order.value = orderRes.data;
     }
     if (itemsRes.data) {
-      items.value = itemsRes.data;
+      // 深拷贝以便编辑,plannedQuantity 绑定到 v-model
+      items.value = (itemsRes.data || []).map((item: any) => ({ ...item }));
     }
   } finally {
     loading.value = false;
   }
 }
 
+function addItem() {
+  items.value.push({
+    orderId: order.value.id,
+    deviceId: order.value.deviceId,
+    productId: null,
+    productCode: "",
+    productName: "",
+    plannedQuantity: 1,
+    unitPrice: undefined,
+    shelfNum: undefined,
+    position: "",
+    beforeStock: undefined,
+    afterStock: undefined
+  });
+}
+
+function removeItem(index: number) {
+  if (items.value.length <= 1) return;
+  items.value.splice(index, 1);
+}
+
+async function handleSave() {
+  saving.value = true;
+  try {
+    const payload = {
+      id: Number(order.value.id),
+      supplierName: order.value.supplierName || undefined,
+      warehouseName: order.value.warehouseName || undefined,
+      expectedArrivalTime: order.value.expectedArrivalTime,
+      remark: order.value.remark || undefined,
+      items: items.value.map((item: any) => ({
+        productId: item.productId ? Number(item.productId) : undefined,
+        productCode: item.productCode || undefined,
+        productName: item.productName || undefined,
+        plannedQuantity: item.plannedQuantity,
+        unitPrice: item.unitPrice,
+        shelfNum: item.shelfNum,
+        position: item.position || undefined
+      }))
+    };
+    await updateOrder(payload);
+    message("保存成功", { type: "success" });
+    await loadData();
+  } catch (e) {
+    message("保存失败", { type: "error" });
+  } finally {
+    saving.value = false;
+  }
+}
+
+async function handleSubmitToErp() {
+  submitting.value = true;
+  try {
+    // 先提交
+    await submitOrder(order.value.id);
+    // 再同步 ERP
+    try {
+      await syncToErp(order.value.id);
+      message("已提交并同步ERP", { type: "success" });
+    } catch {
+      message("已提交,但ERP同步失败", { type: "warning" });
+    }
+    await loadData();
+  } catch (e) {
+    message("提交失败", { type: "error" });
+  } finally {
+    submitting.value = false;
+  }
+}
+
 function goBack() {
   router.push({ path: "/inventory/replenishment-order/list" });
 }
 
+const isDraft = () => order.value.status === 0;
+const canEdit = () => isDraft();
+
+function itemSubtotal(item: any) {
+  return ((item.plannedQuantity || 0) * (item.unitPrice || 0)).toFixed(2);
+}
+
 onMounted(() => {
   loadData();
 });
@@ -55,9 +138,19 @@ onMounted(() => {
 
 <template>
   <div class="main p-6">
-    <div class="mb-4 flex items-center gap-4">
-      <el-button :icon="'ep:arrow-left'" @click="goBack">返回列表</el-button>
-      <h2 class="text-lg font-bold">补货单详情</h2>
+    <div class="mb-4 flex items-center justify-between">
+      <div class="flex items-center gap-4">
+        <el-button :icon="'ep:arrow-left'" @click="goBack">返回列表</el-button>
+        <h2 class="text-lg font-bold">补货单详情</h2>
+      </div>
+      <div v-if="!loading && isDraft()" class="flex gap-2">
+        <el-button type="primary" :loading="submitting" @click="handleSubmitToErp">
+          提交ERP
+        </el-button>
+        <el-button type="success" :loading="saving" @click="handleSave">
+          保存修改
+        </el-button>
+      </div>
     </div>
 
     <el-card v-loading="loading" class="mb-4">
@@ -80,37 +173,43 @@ onMounted(() => {
         <el-descriptions-item label="设备ID">
           {{ order.deviceId }}
         </el-descriptions-item>
-        <el-descriptions-item label="门店ID">
-          {{ order.shopId || "-" }}
+        <el-descriptions-item label="门店">
+          {{ order.shopName || order.shopId || "-" }}
         </el-descriptions-item>
-        <el-descriptions-item label="ERP采购单号">
-          {{ order.erpOrderNo || "-" }}
+        <el-descriptions-item v-if="canEdit()" label="供应商">
+          <el-input v-model="order.supplierName" placeholder="供应商" size="small" style="width:160px;" />
         </el-descriptions-item>
-        <el-descriptions-item label="供应商">
+        <el-descriptions-item v-else label="供应商">
           {{ order.supplierName || "-" }}
         </el-descriptions-item>
-        <el-descriptions-item label="仓库">
+        <el-descriptions-item v-if="canEdit()" label="仓库">
+          <el-input v-model="order.warehouseName" placeholder="仓库" size="small" style="width:160px;" />
+        </el-descriptions-item>
+        <el-descriptions-item v-else label="仓库">
           {{ order.warehouseName || "-" }}
         </el-descriptions-item>
+        <el-descriptions-item label="ERP采购单号">
+          {{ order.erpOrderNo || "-" }}
+        </el-descriptions-item>
         <el-descriptions-item label="总数量">
-          {{ order.totalQuantity }}
+          {{ items.reduce((s: number, i: any) => s + (i.plannedQuantity || 0), 0) }}
         </el-descriptions-item>
         <el-descriptions-item label="总金额">
-          ¥{{ order.totalAmount != null ? Number(order.totalAmount).toFixed(2) : "0.00" }}
+          ¥{{ items.reduce((s: number, i: any) => s + (i.plannedQuantity || 0) * (i.unitPrice || 0), 0).toFixed(2) }}
         </el-descriptions-item>
         <el-descriptions-item label="预计到货时间">
           {{ order.expectedArrivalTime || "-" }}
         </el-descriptions-item>
-        <el-descriptions-item label="ERP同步时间">
-          {{ order.erpSyncTime || "-" }}
-        </el-descriptions-item>
         <el-descriptions-item label="创建人">
-          {{ order.creatorName }}
+          {{ order.creatorName || "-" }}
         </el-descriptions-item>
         <el-descriptions-item label="创建时间">
           {{ order.createTime ? dayjs(order.createTime).format("YYYY-MM-DD HH:mm:ss") : "-" }}
         </el-descriptions-item>
-        <el-descriptions-item label="备注" :span="2">
+        <el-descriptions-item v-if="canEdit()" label="备注" :span="2">
+          <el-input v-model="order.remark" placeholder="备注" size="small" />
+        </el-descriptions-item>
+        <el-descriptions-item v-else label="备注" :span="2">
           {{ order.remark || "-" }}
         </el-descriptions-item>
       </el-descriptions>
@@ -118,46 +217,98 @@ onMounted(() => {
 
     <el-card v-loading="loading">
       <template #header>
-        <span class="font-bold">补货明细</span>
+        <div class="flex items-center justify-between">
+          <span class="font-bold">补货明细</span>
+          <el-button v-if="canEdit()" type="primary" size="small" :icon="'ep:plus'" @click="addItem">
+            添加商品
+          </el-button>
+        </div>
       </template>
 
       <el-table :data="items" border stripe>
-        <el-table-column prop="productCode" label="商品编码" min-width="120" />
-        <el-table-column prop="productName" label="商品名称" min-width="140" />
-        <el-table-column prop="plannedQuantity" label="计划数量" width="100" />
-        <el-table-column prop="actualQuantity" label="实际数量" width="100">
+        <el-table-column label="图片" width="70">
+          <template #default="{ row }">
+            <img
+              v-if="row.productImage"
+              :src="row.productImage"
+              style="width:40px;height:40px;object-fit:cover;border-radius:4px;"
+              @error="$event.target.style.display='none'"
+            />
+            <span v-else style="color:#ccc;">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="商品编码" min-width="120">
           <template #default="{ row }">
-            {{ row.actualQuantity != null ? row.actualQuantity : "-" }}
+            <el-input v-if="canEdit()" v-model="row.productCode" placeholder="商品编码" size="small" />
+            <span v-else>{{ row.productCode || "-" }}</span>
           </template>
         </el-table-column>
-        <el-table-column label="补货前库存" width="110">
+        <el-table-column label="商品名称" min-width="140">
           <template #default="{ row }">
-            {{ row.beforeStock != null ? row.beforeStock : "-" }}
+            <el-input v-if="canEdit()" v-model="row.productName" placeholder="商品名称" size="small" />
+            <span v-else>{{ row.productName || "-" }}</span>
           </template>
         </el-table-column>
-        <el-table-column label="补货后库存" width="110">
+        <el-table-column label="计划数量" width="110">
           <template #default="{ row }">
-            {{ row.afterStock != null ? row.afterStock : "-" }}
+            <el-input-number
+              v-if="canEdit()"
+              v-model="row.plannedQuantity"
+              :min="1"
+              :max="99999"
+              size="small"
+              controls-position="right"
+            />
+            <span v-else>{{ row.plannedQuantity }}</span>
           </template>
         </el-table-column>
-        <el-table-column label="单价" width="100">
+        <el-table-column label="单价" width="110">
           <template #default="{ row }">
-            {{ row.unitPrice != null ? `¥${Number(row.unitPrice).toFixed(2)}` : "-" }}
+            <el-input-number
+              v-if="canEdit()"
+              v-model="row.unitPrice"
+              :min="0"
+              :precision="2"
+              placeholder="单价"
+              size="small"
+              controls-position="right"
+            />
+            <span v-else>{{ row.unitPrice != null ? `¥${Number(row.unitPrice).toFixed(2)}` : "-" }}</span>
           </template>
         </el-table-column>
         <el-table-column label="小计" width="100">
           <template #default="{ row }">
-            {{ row.totalPrice != null ? `¥${Number(row.totalPrice).toFixed(2)}` : "-" }}
+            <span class="font-medium">¥{{ itemSubtotal(row) }}</span>
           </template>
         </el-table-column>
-        <el-table-column prop="shelfNum" label="货架层" width="80">
+        <el-table-column label="层号" width="70">
           <template #default="{ row }">
-            {{ row.shelfNum != null ? row.shelfNum : "-" }}
+            <el-input-number
+              v-if="canEdit()"
+              v-model="row.shelfNum"
+              :min="1"
+              placeholder="层"
+              size="small"
+              controls-position="right"
+            />
+            <span v-else>{{ row.shelfNum != null ? row.shelfNum : "-" }}</span>
           </template>
         </el-table-column>
-        <el-table-column prop="position" label="货道位置" width="100">
+        <el-table-column label="货道" width="90">
           <template #default="{ row }">
-            {{ row.position || "-" }}
+            <el-input v-if="canEdit()" v-model="row.position" placeholder="位置" size="small" />
+            <span v-else>{{ row.position || "-" }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column v-if="canEdit()" label="操作" width="70" fixed="right">
+          <template #default="{ $index }">
+            <el-button
+              type="danger"
+              size="small"
+              :icon="useRenderIcon(DeleteIcon)"
+              :disabled="items.length <= 1"
+              @click="removeItem($index)"
+            />
           </template>
         </el-table-column>
       </el-table>

+ 3 - 14
haha-admin-web/src/views/replenishment-order/index.vue

@@ -31,8 +31,7 @@ const {
   onSearch,
   resetForm,
   handleDelete,
-  handleSubmit,
-  handleSyncErp,
+  handleSubmitToErp,
   handleComplete,
   handleCancel,
   handleSizeChange,
@@ -161,19 +160,9 @@ function onCreated() {
               type="warning"
               :size="size"
               :icon="useRenderIcon(Upload)"
-              @click="handleSubmit(row)"
+              @click="handleSubmitToErp(row)"
             >
-              提交
-            </el-button>
-            <el-button
-              v-if="row.status === 1"
-              link
-              type="primary"
-              :size="size"
-              :icon="useRenderIcon('ep:connection')"
-              @click="handleSyncErp(row)"
-            >
-              同步ERP
+              提交ERP
             </el-button>
             <el-button
               v-if="row.status === 2"

+ 9 - 19
haha-admin-web/src/views/replenishment-order/utils/hook.tsx

@@ -158,25 +158,16 @@ export function useReplenishmentOrder() {
     }
   }
 
-  async function handleSubmit(row) {
+  async function handleSubmitToErp(row) {
     try {
-      const { code } = await submitOrder(row.id);
-      if (code === 200) {
-        message("提交成功", { type: "success" });
-        onSearch();
-      }
-    } catch (e) {
-      // error handled by interceptor
-    }
-  }
-
-  async function handleSyncErp(row) {
-    try {
-      const { code } = await syncToErp(row.id);
-      if (code === 200) {
-        message("同步ERP成功", { type: "success" });
-        onSearch();
+      await submitOrder(row.id);
+      try {
+        await syncToErp(row.id);
+        message("已提交并同步ERP", { type: "success" });
+      } catch {
+        message("已提交,但ERP同步失败", { type: "warning" });
       }
+      onSearch();
     } catch (e) {
       // error handled by interceptor
     }
@@ -230,8 +221,7 @@ export function useReplenishmentOrder() {
     onSearch,
     resetForm,
     handleDelete,
-    handleSubmit,
-    handleSyncErp,
+    handleSubmitToErp,
     handleComplete,
     handleCancel,
     handleSizeChange,

+ 7 - 0
haha-entity/src/main/java/com/haha/entity/ReplenishmentOrderItem.java

@@ -1,6 +1,7 @@
 package com.haha.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import com.fasterxml.jackson.annotation.JsonFormat;
@@ -94,4 +95,10 @@ public class ReplenishmentOrderItem implements Serializable {
 
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
     private LocalDateTime createTime;
+
+    /**
+     * 商品图片(非数据库字段,查询时填充)
+     */
+    @TableField(exist = false)
+    private String productImage;
 }

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

@@ -23,6 +23,7 @@ public class ReplenishmentSuggestionVO {
         private Long productId;
         private String productCode;
         private String productName;
+        private String productImage;
         private Integer templateStock;
         private Integer currentStock;
         private Integer suggestedQuantity;

+ 41 - 6
haha-service/src/main/java/com/haha/service/impl/ReplenishmentOrderServiceImpl.java

@@ -130,7 +130,25 @@ public class ReplenishmentOrderServiceImpl extends ServiceImpl<ReplenishmentOrde
 
     @Override
     public List<ReplenishmentOrderItem> getItems(Long orderId) {
-        return orderItemMapper.selectByOrderId(orderId);
+        List<ReplenishmentOrderItem> items = orderItemMapper.selectByOrderId(orderId);
+        // 批量填充商品图片
+        Set<String> codes = items.stream()
+                .map(ReplenishmentOrderItem::getProductCode).filter(StringUtils::hasText)
+                .collect(Collectors.toSet());
+        Map<String, String> imageMap = new HashMap<>();
+        if (!codes.isEmpty()) {
+            List<Product> products = productMapper.selectList(
+                    new LambdaQueryWrapper<Product>().in(Product::getCode, codes));
+            imageMap = products.stream()
+                    .filter(p -> p.getPic() != null)
+                    .collect(Collectors.toMap(Product::getCode, Product::getPic, (a, b) -> a));
+        }
+        for (ReplenishmentOrderItem item : items) {
+            if (item.getProductCode() != null) {
+                item.setProductImage(imageMap.get(item.getProductCode()));
+            }
+        }
+        return items;
     }
 
     @Override
@@ -300,12 +318,19 @@ public class ReplenishmentOrderServiceImpl extends ServiceImpl<ReplenishmentOrde
     @Override
     @Transactional(rollbackFor = Exception.class)
     public ReplenishmentOrder syncToErp(Long id) {
-        // TODO: 企得宝 ERP SDK - 暂未对接,后续接入时实现
-        log.warn("syncToErp 暂未实现: id={}", id);
+        // TODO: 企得宝 ERP SDK - 暂未对接,后续接入时实现真实ERP推送
+        log.warn("syncToErp ERP对接尚未实现,仅更新状态: id={}", id);
         ReplenishmentOrder order = getById(id);
         if (order == null) {
             throw new BusinessException(404, "补货单不存在");
         }
+        if (order.getStatus() != ReplenishmentOrderStatusEnum.SUBMITTED.getCode()) {
+            throw new BusinessException(400, "仅已提交状态的补货单可同步ERP");
+        }
+        order.setStatus(ReplenishmentOrderStatusEnum.SYNCED_TO_ERP.getCode());
+        order.setUpdateTime(LocalDateTime.now());
+        updateById(order);
+        log.info("补货单已同步ERP(状态变更): id={}, orderNo={}", id, order.getOrderNo());
         fillStatusLabel(order);
         return order;
     }
@@ -461,7 +486,7 @@ public class ReplenishmentOrderServiceImpl extends ServiceImpl<ReplenishmentOrde
                     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");
+                                    .addTemplateStock(goods.getStock(), floorNum, "left", goods.getPic());
                         }
                     }
                 }
@@ -469,7 +494,7 @@ public class ReplenishmentOrderServiceImpl extends ServiceImpl<ReplenishmentOrde
                     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");
+                                    .addTemplateStock(goods.getStock(), floorNum, "right", goods.getPic());
                         }
                     }
                 }
@@ -503,6 +528,12 @@ public class ReplenishmentOrderServiceImpl extends ServiceImpl<ReplenishmentOrde
                     if (product.getRetailPrice() != null) {
                         item.setUnitPrice(BigDecimal.valueOf(product.getRetailPrice()));
                     }
+                    if (product.getPic() != null) {
+                        item.setProductImage(product.getPic());
+                    }
+                }
+                if (item.getProductImage() == null && acc.productImage != null) {
+                    item.setProductImage(acc.productImage);
                 }
                 items.add(item);
             }
@@ -543,13 +574,17 @@ public class ReplenishmentOrderServiceImpl extends ServiceImpl<ReplenishmentOrde
         int templateStock = 0;
         int shelfNum = 0;
         String position = "";
+        String productImage;
 
-        void addTemplateStock(int stock, int floor, String pos) {
+        void addTemplateStock(int stock, int floor, String pos, String image) {
             this.templateStock += stock;
             if (this.position.isEmpty()) {
                 this.shelfNum = floor;
                 this.position = pos;
             }
+            if (this.productImage == null && image != null) {
+                this.productImage = image;
+            }
         }
     }
     /**