07-货道绑定-DeviceProduct与slotNo库存模型
系列:URM Ultra 商品/设备/订单/支付/视觉 业务实战篇
主角:设备域的「货道绑定」——把一台售货柜的每个格子,跟具体商品、库存、售价绑在一起。
适合人群:没写过自动售货柜后端、但会一点 Java/SpringBoot 的新手。
在无人零售系统里,商品和设备是两件独立的事。商品表pms_product描述「可乐是什么、卖多少钱」;设备表dms_device描述「这台柜子长什么样、在哪」。但真正做生意时,你要回答的是:这台柜子的 A01 格子里放的是可乐、还剩几瓶、按什么价卖。
把「格子」和「商品」关联起来的那张表,就是本篇的主角dms_device_product,我们叫它货道绑定表。读懂它,库存、扣减、低库存、补货全链路就通了一半。
一、先理清三个概念:设备、货道、商品
| 概念 | 对应表 | 一句话解释 |
|---|---|---|
| 设备 Device | dms_device | 一台真实的售货柜,有编号deviceNo(如DEV-0001) |
| 货道/格子 Slot | 无独立表 | 柜子里的一个格子,用编号slotNo表示(如A01、B02) |
| 商品 Product | pms_product | 商品主数据,有productId、售价、条码 |
注意:货道不是一个单独的数据库表,它只是dms_device_product里的一行数据。一行「设备 + 货道编号 + 商品」就代表「这台柜子的这个格子里放了这种商品」。
二、核心实体 DeviceProduct
DeviceProduct挂在dms_device_product表上,字段不多但每个都有讲究:
// 项目源码:device/entity/DeviceProduct.java@Entity@Table(name="dms_device_product")publicclassDeviceProductextendsBaseEntity{privateLongdeviceId;// 设备 ID(对应 dms_device.id)privateLongproductId;// 商品 ID(对应 pms_product.id)privateStringslotNo;// 货道/格子编号,如 "A01"privateIntegercapacity;// 货道容量(最多能放几件)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);// 校验设备存在,否则抛 404Productproduct=productRepository.findById(productId).orElseThrow(()->newBusinessException(404,"商品不存在"));// 重点:先按 deviceId + slotNo 查;查不到才 new 一个新对象DeviceProductdp=deviceProductRepository.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(相当于「满格上架」)。如果没传capacity,stock就是 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.java@PostMapping("/{deviceId}/products")publicResult<DeviceProduct>bindProduct(@PathVariableLongdeviceId,@RequestParamStringslotNo,@RequestParamLongproductId,@RequestParam(required=false)Integercapacity,@RequestParam(required=false)BigDecimalprice,@RequestParam(required=false,defaultValue="1")Integerenabled){returnResult.ok(deviceService.bindProduct(deviceId,slotNo,productId,capacity,price,enabled));}调用示例(基于文档约定,端口 8080):
POST /admin-api/device/1/products?slotNo=A01&productId=1&capacity=10含义:给 1 号设备(如DEV-0001)的A01货道,绑定 1 号商品(可乐),容量 10。不传price,则售价自动取可乐标准价。
五、查询时为什么要组装 DeviceProductView
绑定表dms_device_product只存了productId,不存商品名和条码。但管理端列表、小程序商品列表都需要把「可乐」「690xxx 条码」直接展示出来。如果每行都去JOIN商品表,SQL 写法繁琐;本项目用了一个内存组装的视图对象DeviceProductView:
// 项目源码:device/dto/DeviceProductView.java@DatapublicclassDeviceProductView{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){Productproduct=productRepository.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.javapublicList<DeviceProductView>listDeviceProducts(LongdeviceId){Devicedevice=get(deviceId);returndeviceProductRepository.findByDeviceId(deviceId).stream().map(dp->toView(device,dp)).toList();}新手视角理解:绑定表是「关系」,视图对象是「给人看的成品」。底层保持干净的多对多关系,展示层才去拼名字——这就是领域模型与展示模型分离。
六、演示数据里长什么样
项目启动时DataInitializer会灌一批演示货道(节选):
| deviceId | slotNo | 商品 | capacity | stock | 说明 |
|---|---|---|---|---|---|
| 1 | A01 | 可乐 | 8 | 8 | 满格 |
| 1 | A03 | 橙汁 | 3 | 3 | 满格 |
| 1 | B02 | 巧克力 | 2 | 2 | 低库存样例 |
| 1 | B01 | 纸巾 | 1 | 1 | 低库存样例 |
注意B02巧克力capacity=2, stock=2,B01纸巾capacity=1, stock=1——它们容量本身就小,所以是天然的低库存样例,后面第 08、09 篇会反复用到。
七、本篇小结
- 货道不是表,是
dms_device_product的一行:设备 + 货道号 + 商品。 bindProduct是覆盖语义:存在则更新,不存在才新增;stock默认=容量,price缺省取商品价,enabled默认 1。capacity是上限,stock是实时量:补货就是让stock回到capacity。DeviceProductView是展示视图:把deviceNo、productName、barcode组装进来,避免前端二次查表。
下一篇(08)我们顺着这条线看:下单后库存怎么扣,扣的时候有哪些守卫(校验)在保护数据。
系列目录:03-商品设备订单支付视觉实战篇(本篇 07 货道绑定)。