07-货道绑定-DeviceProduct与slotNo库存模型系列URM Ultra 商品/设备/订单/支付/视觉 业务实战篇主角设备域的「货道绑定」——把一台售货柜的每个格子跟具体商品、库存、售价绑在一起。适合人群没写过自动售货柜后端、但会一点 Java/SpringBoot 的新手。在无人零售系统里商品和设备是两件独立的事。商品表pms_product描述「可乐是什么、卖多少钱」设备表dms_device描述「这台柜子长什么样、在哪」。但真正做生意时你要回答的是这台柜子的 A01 格子里放的是可乐、还剩几瓶、按什么价卖。把「格子」和「商品」关联起来的那张表就是本篇的主角dms_device_product我们叫它货道绑定表。读懂它库存、扣减、低库存、补货全链路就通了一半。一、先理清三个概念设备、货道、商品概念对应表一句话解释设备 Devicedms_device一台真实的售货柜有编号deviceNo如DEV-0001货道/格子 Slot无独立表柜子里的一个格子用编号slotNo表示如A01、B02商品 Productpms_product商品主数据有productId、售价、条码注意货道不是一个单独的数据库表它只是dms_device_product里的一行数据。一行「设备 货道编号 商品」就代表「这台柜子的这个格子里放了这种商品」。二、核心实体 DeviceProductDeviceProduct挂在dms_device_product表上字段不多但每个都有讲究// 项目源码device/entity/DeviceProduct.javaEntityTable(namedms_device_product)publicclassDeviceProductextendsBaseEntity{privateLongdeviceId;// 设备 ID对应 dms_device.idprivateLongproductId;// 商品 ID对应 pms_product.idprivateStringslotNo;// 货道/格子编号如 A01privateIntegercapacity;// 货道容量最多能放几件privateIntegerstock;// 当前库存还剩几件privateBigDecimalprice;// 设备端售价可覆盖标准售价privateIntegerenabled;// 是否启用 1启用 0停用}几个新手容易踩的点继承BaseEntity自动带id主键自增、createTime、updateTime不用自己写。price是「设备端售价」商品表里也有一个标准价pms_product.price。这里允许「同一瓶可乐A 柜卖 3 元、B 柜卖 3.5 元」——地铁口和写字楼定价不同很常见。capacity与stock是两回事capacity是容量上限stock是实时库存。补货就是把stock拉回capacity。enabled是软开关不想卖这个格子了置 0 即可不用删数据。三、关键方法bindProduct 的「覆盖语义」绑定货道的入口是DeviceService.bindProduct。它的行为不是「新增就新增」而是存在即覆盖——这是本篇最容易误解的地方。// 项目源码device/service/DeviceService.javapublicDeviceProductbindProduct(LongdeviceId,StringslotNo,LongproductId,Integercapacity,BigDecimalprice,Integerenabled){get(deviceId);// 校验设备存在否则抛 404ProductproductproductRepository.findById(productId).orElseThrow(()-newBusinessException(404,商品不存在));// 重点先按 deviceId slotNo 查查不到才 new 一个新对象DeviceProductdpdeviceProductRepository.findByDeviceIdAndSlotNo(deviceId,slotNo).orElseGet(DeviceProduct::new);dp.setDeviceId(deviceId);dp.setSlotNo(slotNo);dp.setProductId(productId);dp.setCapacity(capacity);dp.setStock(capacity!null?capacity:0);// ① 库存初始化为容量dp.setPrice(price!null?price:product.getPrice());// ② 价格缺省取商品价dp.setEnabled(enabled!null?enabled:1);// ③ 默认启用returndeviceProductRepository.save(dp);}逐条拆解它的「覆盖语义」① 库存初始化为容量dp.setStock(capacity!null?capacity:0);新绑定一个货道时如果传了capacity就把stock设成等于capacity相当于「满格上架」。如果没传capacitystock就是 0——意味着这个格子暂时是空的。为什么这样设计因为「第一次上架」通常就是「把格子装满」所以默认库存容量省得前端再单独调一次更新库存接口。② 价格缺省取商品价dp.setPrice(price!null?price:product.getPrice());这是兜底策略管理员在绑定时如果懒得填售价系统自动用商品主数据的标准价。但如果他想做差异化定价比如景区柜子贵一点传一个price就能覆盖。小提示这里用的是product.getPrice()所以绑定时商品价是多少就定多少。绑完之后如果改了商品标准价已绑定的货道价不会自动变——这是有意为之避免改主数据把历史定价全冲掉。③ 默认启用enabled不传就是 1启用。停用某个格子时再单独传 0。覆盖而不是报错findByDeviceIdAndSlotNo(...).orElseGet(DeviceProduct::new)这行是关键同一个deviceId slotNo再次调用bindProduct不会插入第二行而是查出旧行重新赋值。所以「改商品」「改价」「改容量」都复用同一个绑定接口前端不用区分「新增」还是「编辑」。这是一种很省心的接口设计。四、绑定接口长什么样管理端管理端控制器AdminDeviceController暴露的绑定接口// 项目源码device/controller/AdminDeviceController.javaPostMapping(/{deviceId}/products)publicResultDeviceProductbindProduct(PathVariableLongdeviceId,RequestParamStringslotNo,RequestParamLongproductId,RequestParam(requiredfalse)Integercapacity,RequestParam(requiredfalse)BigDecimalprice,RequestParam(requiredfalse,defaultValue1)Integerenabled){returnResult.ok(deviceService.bindProduct(deviceId,slotNo,productId,capacity,price,enabled));}调用示例基于文档约定端口 8080POST /admin-api/device/1/products?slotNoA01productId1capacity10含义给 1 号设备如DEV-0001的A01货道绑定 1 号商品可乐容量 10。不传price则售价自动取可乐标准价。五、查询时为什么要组装 DeviceProductView绑定表dms_device_product只存了productId不存商品名和条码。但管理端列表、小程序商品列表都需要把「可乐」「690xxx 条码」直接展示出来。如果每行都去JOIN商品表SQL 写法繁琐本项目用了一个内存组装的视图对象DeviceProductView// 项目源码device/dto/DeviceProductView.javaDatapublicclassDeviceProductView{privateLongid;privateLongdeviceId;privateStringdeviceNo;// 来自 dms_deviceprivateStringslotNo;privateLongproductId;privateStringproductName;// 来自 pms_productprivateStringbarcode;// 来自 pms_productprivateIntegercapacity;privateIntegerstock;privateBigDecimalprice;privateIntegerenabled;}组装逻辑在toView里先拿到Device取deviceNo再拿Product取productName、barcode拼成视图。// 项目源码device/service/DeviceService.javaprivateDeviceProductViewtoView(Devicedevice,DeviceProductdp){ProductproductproductRepository.findById(dp.getProductId()).orElse(null);returnnewDeviceProductView(dp.getId(),dp.getDeviceId(),device!null?device.getDeviceNo():null,dp.getSlotNo(),dp.getProductId(),product!null?product.getName():null,product!null?product.getBarcode():null,dp.getCapacity(),dp.getStock(),dp.getPrice(),dp.getEnabled());}而listDeviceProducts就是把某设备的全部绑定行逐行toView// 项目源码device/service/DeviceService.javapublicListDeviceProductViewlistDeviceProducts(LongdeviceId){Devicedeviceget(deviceId);returndeviceProductRepository.findByDeviceId(deviceId).stream().map(dp-toView(device,dp)).toList();}新手视角理解绑定表是「关系」视图对象是「给人看的成品」。底层保持干净的多对多关系展示层才去拼名字——这就是领域模型与展示模型分离。六、演示数据里长什么样项目启动时DataInitializer会灌一批演示货道节选deviceIdslotNo商品capacitystock说明1A01可乐88满格1A03橙汁33满格1B02巧克力22低库存样例1B01纸巾11低库存样例注意B02巧克力capacity2, stock2B01纸巾capacity1, stock1——它们容量本身就小所以是天然的低库存样例后面第 08、09 篇会反复用到。七、本篇小结货道不是表是dms_device_product的一行设备 货道号 商品。bindProduct是覆盖语义存在则更新不存在才新增stock默认容量price缺省取商品价enabled默认 1。capacity是上限stock是实时量补货就是让stock回到capacity。DeviceProductView是展示视图把deviceNo、productName、barcode组装进来避免前端二次查表。下一篇08我们顺着这条线看下单后库存怎么扣扣的时候有哪些守卫校验在保护数据。系列目录03-商品设备订单支付视觉实战篇本篇 07 货道绑定。