Ver código fonte

feat: 设备层模版管理优化 — 设备名称列、数据居中、文档更新

- LayerTemplate 实体新增 deviceName 字段,fillLabels 中从 Device 表查询填充
- 前端表格新增设备名称列、修正标题、所有列显式居中对齐
- 文档更新:库存管理模块补充 initInventoryFromTemplate 和 standardStock 说明
- 系统架构模块补充层模版同步与库存联动章节

Co-Authored-By: Claude <noreply@anthropic.com>
skyline 3 dias atrás
pai
commit
f0cd96440c

+ 72 - 5
docs/库存管理模块.md

@@ -28,15 +28,42 @@ haha 平台的职责是 AI 识别(开关门事件 + 商品识别结果)和
 
 ### 1.4 库存初始化机制
 
-库存数据**没有自动初始化**,唯一的入口是人工上货操作:
+库存数据有**两个入口**进入系统:
+
+#### 入口一:人工上货(主要,创建实际库存)
 
 ```
 管理员/补货员手动上货 → DeviceInventoryService.increaseStock()
-    → 首次上货该商品 → 新建 t_device_inventory 记录
+    → 首次上货该商品 → 新建 t_device_inventory 记录(stock > 0)
     → 再次上货 → 累加 stock
 ```
 
-以下流程**不创建库存**:
+人工上货创建的记录带有 **实际库存数量**(`stock > 0`),是唯一的物理库存来源。
+
+#### 入口二:层模版同步(辅助,创建占位记录)
+
+```
+设备层模版同步 → syncFromHaha(deviceId) → initInventoryFromTemplate(deviceId, apiData)
+    → 解析层模版中的商品编码
+    → 查本地 Product 表确认商品存在
+    → 查 t_device_inventory 确认记录不存在
+    → 新建 t_device_inventory 记录(stock = 0)
+```
+
+层模版同步创建的记录 **stock 初始化为 0**,目的是:
+- 让设备在库存列表中**提前可见**所有理论货道商品
+- 为 `recalculateStandardStock` 提供记录载体(标准库存值写入已有记录)
+- 已存在的人工上货记录**不受影响**
+
+**创建条件**:
+
+| 条件 | 行为 |
+|------|------|
+| 商品编码在本地 Product 表中不存在 | 跳过 |
+| 该设备+商品已有库存记录 | 跳过(不覆盖已有 stock) |
+| 该设备+商品无库存记录 | 新建,stock=0, warningThreshold=5 |
+
+#### 不会创建库存的流程
 
 | 流程 | 做了什么 | 为什么不创建库存 |
 |------|---------|-----------------|
@@ -44,7 +71,45 @@ haha 平台的职责是 AI 识别(开关门事件 + 商品识别结果)和
 | 商品同步 | 拉取商品目录写入 `t_product` | 商品信息不绑定设备和数量 |
 | 商品上架 | 调用 haha API 设置可售卖商品 | 只是标记可售卖,不涉及数量 |
 
-> **未来扩展**:如果 haha 平台升级 API 在设备同步时回传库存数据,策略是 **首次同步用 haha 数据初始化,后续同步以本地为准**。因为首次时本地无数据,哈哈的数据可作为初始值;之后本地实时扣减/补货,本地数据才是最新真相源。
+> **初始化策略总结**:人工上货是唯一写入实际库存(stock>0)的途径,层模版同步提供占位能力让运营平台提前看到设备货道商品列表。
+
+---
+
+### 1.5 标准库存重算(recalculateStandardStock)
+
+层模版同步完成后,会根据层模版中的货道商品配置**重新计算**每台设备每个商品的 `standard_stock`(理论满柜库存量)。
+
+**触发时机**:层模版创建 / 更新 / 删除 / 同步 操作完成后自动调用。
+
+**计算逻辑**:
+
+```
+查询该设备所有层模版 (t_layer_template by deviceId)
+  │
+  ├─ 无层模版 → 所有库存记录的 standard_stock 置 0
+  │
+  └─ 有层模版 → 遍历每个 LayerTemplate:
+        │
+        ├─ 解析 leftFloors JSON → 遍历每层每个货道 → goodsItem.stock
+        └─ 解析 rightFloors JSON → 遍历每层每个货道 → goodsItem.stock
+              │
+              └─ 按 productCode 累加 → standardMap.merge(code, stock, Integer::sum)
+                 (同一商品跨层/跨门配置数量叠加)
+        │
+        └─ 遍历设备所有 t_device_inventory 记录:
+              standard_stock = standardMap 中的累计值(无则为 0)
+              → updateById()
+```
+
+**关键规则**:
+- 同一商品在多个楼层或左右门都有配置 → **累加**各货道的摆放数量
+- 层模版有但库存表没有的商品 → 不处理(由 `initInventoryFromTemplate` 预先建好记录)
+- 库存表有但层模版没有的商品 → `standard_stock = 0`
+- 每次全量覆盖,非增量更新
+
+**用途**:standard_stock 作为理论满柜值,用于库存管理页面展示库存占比(`stock / standard_stock`),帮助运营判断缺货程度。
+
+> **为什么不在同步时直接写入 stock?** 物理库存必须由实际补货操作确认,层模版是"应该放什么商品"的配置,不代表当前柜内真实存量。standard_stock 记录的是"满柜时应该有多少",与实际 stock 分离。
 
 ---
 
@@ -64,6 +129,7 @@ haha 平台的职责是 AI 识别(开关门事件 + 商品识别结果)和
 | `product_code` | VARCHAR(100) | haha 平台商品编码 |
 | `product_name` | VARCHAR(200) | 商品名称快照 |
 | `stock` | INT | **当前库存数量** |
+| `standard_stock` | INT | **标准库存**(满柜理论值,由层模版同步自动重算) |
 | `shelf_num` | INT | 货架层位置 |
 | `position` | VARCHAR(50) | 通道位置(left/right) |
 | `warning_threshold` | INT | 低库存预警阈值(默认 5) |
@@ -335,7 +401,8 @@ if (deducted > 0) {
 | 幂等性通过日志表实现 | 不在订单表增加 `stock_deducted` 字段,避免侵入;日志表本身就是审计需求 |
 | 退款恢复用 `adjustStock` | 相比 `increaseStock`,不需要补货所需的 shelf_num/position 等字段 |
 | 库存纯本地管理 | haha 平台不提供库存 API,且本地管理可灵活控制预警阈值、统计维度 |
-| **无自动初始化** | 库存数据完全由人工上货创建,设备/商品同步不创建库存记录 |
+| **层模版同步自动初始化** | 层模版同步时为设备中配置的商品自动创建 stock=0 的库存占位记录,已存在的人工上货记录不受影响 |
+| **标准库存自动重算** | 层模版创建/更新/同步后自动调用 `recalculateStandardStock`,根据货道配置重算每个商品的理论满柜库存值 |
 | **哈哈库存首次导入策略** | 如哈哈后续提供设备库存数据:首次同步做初始化,后续同步丢弃以本地为准 |
 
 ---

+ 32 - 3
docs/系统架构与公共模块.md

@@ -311,15 +311,44 @@ haha-sdk(独立模块,不依赖其他内部模块)
 
 ### 8.1 LayerTemplate 实体
 
-设备货架层配置模板,定义每一层的商品排列方式。
+设备货架层配置模板,对应数据库 `t_layer_template` 表。每个设备可有多个层模版(左右门分别管理)。数据来源于哈哈平台 API(`GoodsApi.getLayerTemplate()`)。
+
+| 核心字段 | 说明 |
+|---------|------|
+| `deviceId` | 设备 SN |
+| `templateName` | 模板名称 |
+| `deviceType` | 设备类型(1-静态柜, 2-动态柜) |
+| `shelfNum` | 机柜层数 |
+| `leftFloors` | 左门层数据(JSON,含每层货道商品配置) |
+| `rightFloors` | 右门层数据(JSON) |
+| `syncStatus` | 同步状态(0-未同步, 1-同步中, 2-已同步, 3-同步失败) |
 
 ### 8.2 核心功能
 
 | 功能 | 说明 |
 |------|------|
-| 模板CRUD | 创建/编辑/删除层模板 |
+| 模板 CRUD | 创建/编辑/删除层模板 |
 | 层配置 | 每层的商品类型、数量、位置 |
-| 设备应用 | 将模板应用到指定设备 |
+| 同步拉取 | 从哈哈平台拉取最新层模版配置(支持单设备/批量) |
+| 库存联动 | 同步后自动初始化库存记录 + 重算标准库存(详见下方) |
+
+### 8.3 同步与库存联动
+
+层模版同步不仅仅更新 `t_layer_template` 表,还会联动操作 `t_device_inventory` 表:
+
+```
+syncFromHaha(deviceId) / batchSync(deviceIds)
+  │
+  ├─ ① 调用 haha API 拉取层模版 JSON
+  ├─ ② 解析并写入 t_layer_template(INSERT 或 UPDATE)
+  ├─ ③ initInventoryFromTemplate() → 为缺失商品建库存记录(stock=0)
+  └─ ④ recalculateStandardStock() → 按货道配置重算 standard_stock
+```
+
+**步骤③**:遍历层模版中的商品编码,对库存表中不存在的商品创建 `stock=0` 的占位记录,让运营平台提前看到设备所有理论商品。
+**步骤④**:遍历层模版中每个货道的商品配置,按 productCode 累加摆放数量,写入对应库存记录的 `standard_stock` 字段,用于前端展示库存占比。
+
+> 详细逻辑见 [库存管理模块 §1.4-1.5](./库存管理模块.md)。
 
 ---
 

+ 8 - 2
haha-admin-web/src/views/layer-template/index.vue

@@ -128,7 +128,7 @@ function handleSingleSync() {
 
     <!-- 表格区域 -->
     <PureTableBar
-      title="设备设备层模版管理"
+      title="设备层模版管理"
       :columns="columns"
       @refresh="onSearch"
     >
@@ -227,7 +227,8 @@ function handleSingleSync() {
           :pagination="{ ...pagination, size }"
           :header-cell-style="{
             background: 'var(--el-fill-color-light)',
-            color: 'var(--el-text-color-primary)'
+            color: 'var(--el-text-color-primary)',
+            textAlign: 'center'
           }"
           @page-size-change="handleSizeChange"
           @page-current-change="handleCurrentChange"
@@ -280,4 +281,9 @@ function handleSingleSync() {
     margin-bottom: 12px;
   }
 }
+
+/* 强制表格单元格内容居中,解决 showOverflowTooltip 导致的对齐问题 */
+:deep(.el-table__body-wrapper .el-table__cell) {
+  text-align: center;
+}
 </style>

+ 18 - 3
haha-admin-web/src/views/layer-template/utils/hook.ts

@@ -111,29 +111,41 @@ export function useProduct(tableRef?: Ref) {
     {
       label: "ID",
       prop: "id",
-      width: 100
+      width: 100,
+      align: "center"
     },
     {
       label: "模板名称",
       prop: "templateName",
       minWidth: 100,
-      showOverflowTooltip: true
+      showOverflowTooltip: true,
+      align: "center"
+    },
+    {
+      label: "设备名称",
+      prop: "deviceName",
+      minWidth: 120,
+      showOverflowTooltip: true,
+      align: "center"
     },
     {
       label: "设备ID",
       prop: "deviceId",
-      minWidth: 100
+      minWidth: 100,
+      align: "center"
     },
     {
       label: "机柜层数",
       prop: "totalFloors",
       width: 100,
+      align: "center",
       formatter: ({ totalFloors }) => (totalFloors != null ? `${totalFloors}层` : "-")
     },
     {
       label: "同步状态",
       prop: "syncStatus",
       width: 100,
+      align: "center",
       cellRenderer: ({ row }) => {
         const statusMap: Record<number, { label: string; type: string }> = {
           0: { label: "未同步", type: "info" },
@@ -152,6 +164,7 @@ export function useProduct(tableRef?: Ref) {
       label: "最后同步时间",
       prop: "lastSyncTime",
       minWidth: 160,
+      align: "center",
       formatter: ({ lastSyncTime }) =>
         lastSyncTime ? dayjs(lastSyncTime).format("YYYY-MM-DD HH:mm:ss") : "-"
     },
@@ -159,6 +172,7 @@ export function useProduct(tableRef?: Ref) {
       label: "创建时间",
       prop: "createTime",
       minWidth: 160,
+      align: "center",
       formatter: ({ createTime }) =>
         createTime ? dayjs(createTime).format("YYYY-MM-DD HH:mm:ss") : "-"
     },
@@ -166,6 +180,7 @@ export function useProduct(tableRef?: Ref) {
       label: "操作",
       fixed: "right",
       width: 200,
+      align: "center",
       slot: "operation"
     }
   ];

+ 3 - 0
haha-entity/src/main/java/com/haha/entity/LayerTemplate.java

@@ -119,4 +119,7 @@ public class LayerTemplate implements Serializable {
 
     @TableField(exist = false)
     private String statusText;
+
+    @TableField(exist = false)
+    private String deviceName;
 }

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

@@ -16,10 +16,12 @@ import com.haha.common.constant.CommonConstants;
 import com.haha.common.constant.SyncConstants;
 import com.haha.common.exception.BusinessException;
 import com.haha.common.utils.EntityLabelUtils;
+import com.haha.entity.Device;
 import com.haha.entity.DeviceInventory;
 import com.haha.entity.InventoryLog;
 import com.haha.entity.LayerTemplate;
 import com.haha.entity.Product;
+import com.haha.mapper.DeviceMapper;
 import com.haha.mapper.LayerTemplateMapper;
 import com.haha.sdk.HahaClient;
 import com.haha.sdk.api.GoodsApi;
@@ -58,6 +60,9 @@ public class LayerTemplateServiceImpl extends ServiceImpl<LayerTemplateMapper, L
     @Autowired
     private DeviceInventoryService deviceInventoryService;
 
+    @Autowired
+    private DeviceMapper deviceMapper;
+
     @Autowired
     private ProductService productService;
 
@@ -625,6 +630,16 @@ public class LayerTemplateServiceImpl extends ServiceImpl<LayerTemplateMapper, L
         if (template.getGoodsRule() != null && !template.getGoodsRule().isEmpty()) {
             template.setGoodsRuleDisplay(formatJsonForDisplay(template.getGoodsRule()));
         }
+
+        // 8. 设备名称(从 Device 表查询)
+        if (template.getDeviceId() != null && !template.getDeviceId().isEmpty()) {
+            try {
+                Device device = deviceMapper.selectByDeviceId(template.getDeviceId());
+                template.setDeviceName(device != null ? device.getName() : null);
+            } catch (Exception e) {
+                log.debug("查询设备名称失败: deviceId={}", template.getDeviceId(), e);
+            }
+        }
     }
 
     /**