ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

货道绑定-DeviceProduct与slotNo库存模型

货道绑定-DeviceProduct与slotNo库存模型

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里的一行数据。一行「设备 + 货道编号 + 商品」就代表「这台柜子的这个格子里放了这种商品」。


二、核心实体 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=停用}

几个新手容易踩的点:

  1. 继承BaseEntity:自动带id(主键自增)、createTime、updateTime,不用自己写。
  2. price是「设备端售价」:商品表里也有一个标准价pms_product.price。这里允许「同一瓶可乐,A 柜卖 3 元、B 柜卖 3.5 元」——地铁口和写字楼定价不同,很常见。
  3. capacity与stock是两回事:capacity是容量上限,stock是实时库存。补货就是把stock拉回capacity。
  4. 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会灌一批演示货道(节选):

deviceIdslotNo商品capacitystock说明
1A01可乐88满格
1A03橙汁33满格
1B02巧克力22低库存样例
1B01纸巾11低库存样例

注意B02巧克力capacity=2, stock=2,B01纸巾capacity=1, stock=1——它们容量本身就小,所以是天然的低库存样例,后面第 08、09 篇会反复用到。


七、本篇小结

  1. 货道不是表,是dms_device_product的一行:设备 + 货道号 + 商品。
  2. bindProduct是覆盖语义:存在则更新,不存在才新增;stock默认=容量,price缺省取商品价,enabled默认 1。
  3. capacity是上限,stock是实时量:补货就是让stock回到capacity。
  4. DeviceProductView是展示视图:把deviceNo、productName、barcode组装进来,避免前端二次查表。

下一篇(08)我们顺着这条线看:下单后库存怎么扣,扣的时候有哪些守卫(校验)在保护数据。


系列目录:03-商品设备订单支付视觉实战篇(本篇 07 货道绑定)。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进