小如重构

This commit is contained in:
zjf committed 2026-02-11 19:36:32 +08:00
1 parent f91b8587da
commit 4aae6393eb
20 files changed
+1061 -59

No files matched your search

@@ -13,6 +13,7 @@ import javax.validation.*;
import javax.servlet.http.*;
import java.util.*;
import java.io.IOException;
import java.util.stream.Collectors;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
@@ -28,6 +29,10 @@ import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.*;
import cn.iocoder.yudao.module.product.controller.admin.channel.vo.*;
import cn.iocoder.yudao.module.product.dal.dataobject.channel.ChannelDO;
import cn.iocoder.yudao.module.product.service.channel.ChannelService;
import cn.iocoder.yudao.module.product.dal.dataobject.spu.ProductSpuDO;
import cn.iocoder.yudao.module.product.dal.dataobject.sku.ProductSkuDO;
import cn.iocoder.yudao.module.product.service.sku.ProductSkuService;
import cn.iocoder.yudao.module.product.service.spu.ProductSpuService;
import org.springframework.web.multipart.MultipartFile;
@Tag(name = "管理后台 - 货道")
@@ -39,6 +44,12 @@ public class ChannelController {
@Resource
private ChannelService channelService;
@Resource
private ProductSpuService productSpuService;
@Resource
private ProductSkuService productSkuService;
@PostMapping("/create")
@Operation(summary = "创建货道")
@PreAuthorize("@ss.hasPermission('product:channel:create')")
@@ -119,4 +130,84 @@ public class ChannelController {
public CommonResult<String> testChannel(@RequestParam("id") Long id) {
return channelService.testChannel(id);
}
/**
* 获得某设备下「货道 + 商品」的综合信息列表
*
* 前端最终使用字段:
* - cabinetChannel:货道号(显示成「货道X」)
* - spuName:商品名称
* - spuStock:当前库存
* - id:货道唯一 ID(用于测试接口 channel-test)
* 预留字段:
* - deviceId:设备 IMEI
* - status:货道状态(后续可用于控制按钮是否可点)
*/
@GetMapping("/device-goods")
@Operation(summary = "根据设备 IMEI 获取货道 + 商品信息列表")
@PreAuthorize("@ss.hasPermission('product:channel:query')")
public CommonResult<List<ChannelGoodsRespVO>> getDeviceChannelGoods(@RequestParam("deviceId") String deviceId) {
// 1. 查询设备下所有货道(按行号 + 货道号排序)
List<ChannelDO> channelList = channelService.getChannelListByDeviceid(deviceId);
if (channelList == null || channelList.isEmpty()) {
return success(Collections.emptyList());
}
// 2. 查询该设备下所有「已上架」且仍绑定到该设备的商品 SPU
List<ProductSpuDO> spuList = productSpuService.getSpuListByDeviceid(deviceId);
if (spuList == null || spuList.isEmpty()) {
// 当售货机所有商品都已下架(例如执行了一键清空后),不再返回任何货道 + 商品数据
return success(Collections.emptyList());
}
Map<Long, ProductSpuDO> spuMap = spuList.stream()
.collect(Collectors.toMap(ProductSpuDO::getId, spu -> spu));
// 3. 根据 SPU 列表查询所有 SKU,并根据 cabinetChannel 建立映射
Map<String, ProductSkuDO> skuByChannel = new HashMap<>();
if (!spuMap.isEmpty()) {
List<Long> spuIds = spuMap.keySet().stream().collect(Collectors.toList());
List<ProductSkuDO> skuList = productSkuService.getSkuListBySpuId(spuIds);
if (skuList != null && !skuList.isEmpty()) {
for (ProductSkuDO sku : skuList) {
if (sku.getCabinetChannel() == null || sku.getCabinetChannel().isEmpty()) {
continue;
}
// 一个货道理论上只绑定一个 SKU;如果有多个,保留第一个
skuByChannel.putIfAbsent(sku.getCabinetChannel(), sku);
}
}
}
// 4. 组装返回结果:以货道为主,补充对应的商品名称、容量和库存
List<ChannelGoodsRespVO> result = new ArrayList<>(channelList.size());
for (ChannelDO channel : channelList) {
ChannelGoodsRespVO vo = new ChannelGoodsRespVO();
vo.setId(channel.getId());
// 这里返回设备 IMEI,便于前端直接使用
vo.setDeviceId(channel.getDeviceImei());
vo.setCabinetChannel(channel.getCabinetChannel());
vo.setMaxNum(channel.getMaxNum());
ProductSkuDO sku = skuByChannel.get(channel.getCabinetChannel());
if (sku != null) {
vo.setSkuId(sku.getId());
ProductSpuDO spu = spuMap.get(sku.getSpuId());
if (spu != null) {
vo.setSpuName(spu.getName());
}
vo.setSpuStock(sku.getStock());
} else {
// 没有关联商品时,名称和库存留空/0
vo.setSkuId(null);
vo.setSpuName(null);
vo.setSpuStock(0);
}
// 预留的货道状态字段,目前默认置为 1(启用)
vo.setStatus(1);
result.add(vo);
}
return success(result);
}
}
@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.product.controller.admin.channel.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 管理后台 - 设备货道 + 商品信息 Response VO
*
* 用于前端展示「货道 + 商品」的综合信息:
* - 货道:id、deviceId、cabinetChannel、maxNum、status
* - 商品:skuId、spuName、spuStock
*/
@Schema(description = "管理后台 - 设备货道 + 商品信息 Response VO")
@Data
public class ChannelGoodsRespVO {
@Schema(description = "货道主键 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "123")
private Long id;
@Schema(description = "设备 IMEI(字符串)", requiredMode = Schema.RequiredMode.REQUIRED, example = "869667070287061")
private String deviceId;
@Schema(description = "货道号(cabinetChannel)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private String cabinetChannel;
@Schema(description = "货道容量(最大货物数)", example = "10")
private Integer maxNum;
@Schema(description = "绑定的 SKU 主键 ID(用于补货提交)", example = "10086")
private Long skuId;
@Schema(description = "商品名称", example = "可乐330ml")
private String spuName;
@Schema(description = "对应 SKU 当前库存", example = "5")
private Integer spuStock;
@Schema(description = "货道状态:预留字段(例如 1=启用,0=禁用)", example = "1")
private Integer status;
}
@@ -0,0 +1,58 @@
package cn.iocoder.yudao.module.product.controller.admin.inventory;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.product.controller.admin.inventory.vo.*;
import cn.iocoder.yudao.module.product.service.inventory.InventoryCheckService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.validation.Valid;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
* 管理后台 - 设备盘点(盘点记录 + 盘点详情)
*
* 一次盘点:
* - 前端基于 /product/channel/device-goods 展示每个货道当前「系统库存」
* - 操作员录入实盘数量
* - 调本 Controller 的 create 接口提交,后端自动计算盘盈盘亏、更新库存并记录盘点明细
*/
@Tag(name = "管理后台 - 设备盘点")
@RestController
@RequestMapping("/product/inventory-check")
@Validated
public class InventoryCheckController {
@Resource
private InventoryCheckService inventoryCheckService;
@PostMapping("/create")
@Operation(summary = "创建盘点并更新库存(含盘点详情)")
@PreAuthorize("@ss.hasPermission('product:inventory-check:create')")
public CommonResult<Long> createInventoryCheck(@Valid @RequestBody InventoryCheckCreateReqVO createReqVO) {
return success(inventoryCheckService.createInventoryCheck(createReqVO));
}
@GetMapping("/page")
@Operation(summary = "获得盘点记录分页")
@PreAuthorize("@ss.hasPermission('product:inventory-check:query')")
public CommonResult<PageResult<InventoryCheckRespVO>> getInventoryCheckPage(@Valid InventoryCheckPageReqVO pageReqVO) {
return success(inventoryCheckService.getInventoryCheckPage(pageReqVO));
}
@GetMapping("/detail")
@Operation(summary = "获得某次盘点详情")
@PreAuthorize("@ss.hasPermission('product:inventory-check:query')")
public CommonResult<InventoryCheckDetailRespVO> getInventoryCheckDetail(
@RequestParam("id") @Parameter(description = "盘点记录 ID", required = true) Long id) {
return success(inventoryCheckService.getInventoryCheckDetail(id));
}
}
@@ -0,0 +1,56 @@
package cn.iocoder.yudao.module.product.controller.admin.inventory.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* 管理后台 - 设备盘点创建 Request VO
*
* 前端一般基于 /product/channel/device-goods 的数据,录入每个货道的实盘数量后一次性提交。
*/
@Schema(description = "管理后台 - 设备盘点创建 Request VO")
@Data
public class InventoryCheckCreateReqVO {
@Schema(description = "设备 IMEI", requiredMode = Schema.RequiredMode.REQUIRED, example = "869667070287061")
@NotEmpty(message = "设备 IMEI 不能为空")
private String deviceId;
@Schema(description = "盘点名称", example = "今日晚班盘点")
private String name;
@Schema(description = "备注", example = "交接班前全量盘点")
private String remark;
@Schema(description = "盘点明细列表", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty(message = "盘点明细不能为空")
@Valid
private List<Item> items;
@Data
@Schema(description = "设备盘点明细")
public static class Item {
@Schema(description = "货道主键 ID(channel.id)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "货道 ID 不能为空")
private Long channelId;
@Schema(description = "SKU 主键 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "10086")
@NotNull(message = "SKU ID 不能为空")
private Long skuId;
@Schema(description = "货道号(cabinetChannel)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotEmpty(message = "货道号不能为空")
private String cabinetChannel;
@Schema(description = "实盘库存(该货道当前实际数量)", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
@NotNull(message = "实盘库存不能为空")
private Integer actualStock;
}
}
@@ -0,0 +1,72 @@
package cn.iocoder.yudao.module.product.controller.admin.inventory.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 管理后台 - 设备盘点详情 Response VO(含明细)
*/
@Schema(description = "管理后台 - 设备盘点详情 Response VO")
@Data
public class InventoryCheckDetailRespVO {
@Schema(description = "盘点 ID", example = "1")
private Long id;
@Schema(description = "设备 IMEI", example = "869667070287061")
private String deviceId;
@Schema(description = "盘点名称", example = "晚班盘点")
private String name;
@Schema(description = "备注")
private String remark;
@Schema(description = "总差异(实盘总和 - 系统总和)", example = "3")
private Integer totalDiff;
@Schema(description = "明细条数", example = "30")
private Integer itemCount;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "盘点明细列表")
private List<Item> items;
@Data
@Schema(description = "盘点明细")
public static class Item {
@Schema(description = "货道主键 ID(channel.id)", example = "1")
private Long channelId;
@Schema(description = "货道号(cabinetChannel)", example = "1")
private String cabinetChannel;
@Schema(description = "SKU 主键 ID", example = "10086")
private Long skuId;
@Schema(description = "SPU 主键 ID", example = "20001")
private Long spuId;
@Schema(description = "商品名称", example = "可乐330ml")
private String spuName;
@Schema(description = "条形码", example = "6901234567890")
private String barCode;
@Schema(description = "系统库存(盘点前)", example = "5")
private Integer systemStock;
@Schema(description = "实盘库存", example = "7")
private Integer actualStock;
@Schema(description = "差异(实盘 - 系统)", example = "2")
private Integer diff;
}
}
@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.product.controller.admin.inventory.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
/**
* 管理后台 - 设备盘点记录分页 Request VO
*/
@Schema(description = "管理后台 - 设备盘点记录分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class InventoryCheckPageReqVO extends PageParam {
@Schema(description = "设备 IMEI", example = "869667070287061")
private String deviceId;
@Schema(description = "盘点名称", example = "晚班盘点")
private String name;
@Schema(description = "创建时间区间")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] createTime;
}
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.product.controller.admin.inventory.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 管理后台 - 设备盘点记录列表/详情(主表) Response VO
*/
@Schema(description = "管理后台 - 设备盘点记录 Response VO")
@Data
public class InventoryCheckRespVO {
@Schema(description = "盘点 ID", example = "1")
private Long id;
@Schema(description = "设备 IMEI", example = "869667070287061")
private String deviceId;
@Schema(description = "盘点名称", example = "晚班盘点")
private String name;
@Schema(description = "备注")
private String remark;
@Schema(description = "总差异(实盘总和 - 系统总和)", example = "3")
private Integer totalDiff;
@Schema(description = "明细条数", example = "30")
private Integer itemCount;
@Schema(description = "创建时间")
private LocalDateTime createTime;
}
@@ -0,0 +1,57 @@
package cn.iocoder.yudao.module.product.dal.dataobject.inventory;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
* 设备盘点记录 DO(盘点主表)
*
* 一次盘点 = 一条记录,对应多个货道盘点明细。
*
* @author 小如之家
*/
@TableName("product_inventory_check")
@KeySequence("product_inventory_check_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InventoryCheckDO extends BaseDO {
/**
* 主键
*/
@TableId
private Long id;
/**
* 设备 IMEI
*/
private String deviceId;
/**
* 盘点名称(可为空,默认“设备{deviceId}盘点”)
*/
private String name;
/**
* 备注
*/
private String remark;
/**
* 盘点总差异(实际库存总和 - 系统库存总和)
*/
private Integer totalDiff;
/**
* 盘点明细条数
*/
private Integer itemCount;
}
@@ -0,0 +1,87 @@
package cn.iocoder.yudao.module.product.dal.dataobject.inventory;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
* 设备盘点明细 DO(按货道 / SKU)
*
* 每一行对应一个货道(cabinetChannel)和其绑定的 SKU 的一次盘点结果。
*
* @author 小如之家
*/
@TableName("product_inventory_check_item")
@KeySequence("product_inventory_check_item_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InventoryCheckItemDO extends BaseDO {
/**
* 主键
*/
@TableId
private Long id;
/**
* 盘点记录主表 ID
*/
private Long checkId;
/**
* 设备 IMEI
*/
private String deviceId;
/**
* 货道主键 ID(channel.id)
*/
private Long channelId;
/**
* 货道号(cabinetChannel)
*/
private String cabinetChannel;
/**
* SKU 主键 ID
*/
private Long skuId;
/**
* 商品 SPU 主键 ID
*/
private Long spuId;
/**
* 商品名称
*/
private String spuName;
/**
* 条形码
*/
private String barCode;
/**
* 系统库存(盘点前)
*/
private Integer systemStock;
/**
* 实盘库存(盘点时录入)
*/
private Integer actualStock;
/**
* 差异 = 实盘 - 系统(>0 盘盈,<0 盘亏)
*/
private Integer diff;
}
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.product.dal.mysql.inventory;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.product.dal.dataobject.inventory.InventoryCheckItemDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 设备盘点明细 Mapper
*
* @author 小如之家
*/
@Mapper
public interface InventoryCheckItemMapper extends BaseMapperX<InventoryCheckItemDO> {
default List<InventoryCheckItemDO> selectListByCheckId(Long checkId) {
return selectList(new LambdaQueryWrapperX<InventoryCheckItemDO>()
.eq(InventoryCheckItemDO::getCheckId, checkId)
.orderByAsc(InventoryCheckItemDO::getId));
}
}
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.product.dal.mysql.inventory;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.product.controller.admin.inventory.vo.InventoryCheckPageReqVO;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.product.dal.dataobject.inventory.InventoryCheckDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 设备盘点记录 Mapper
*
* @author 小如之家
*/
@Mapper
public interface InventoryCheckMapper extends BaseMapperX<InventoryCheckDO> {
default List<InventoryCheckDO> selectListByDeviceId(String deviceId) {
return selectList(new LambdaQueryWrapperX<InventoryCheckDO>()
.eqIfPresent(InventoryCheckDO::getDeviceId, deviceId)
.orderByDesc(InventoryCheckDO::getId));
}
default PageResult<InventoryCheckDO> selectPage(InventoryCheckPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<InventoryCheckDO>()
.eqIfPresent(InventoryCheckDO::getDeviceId, reqVO.getDeviceId())
.likeIfPresent(InventoryCheckDO::getName, reqVO.getName())
.betweenIfPresent(InventoryCheckDO::getCreateTime, reqVO.getCreateTime())
.orderByDesc(InventoryCheckDO::getId));
}
}
@@ -188,6 +188,7 @@ public interface ProductSpuMapper extends BaseMapperX<ProductSpuDO> {
.selectAll(ProductSpuDO.class)
.selectAs(AdminUserDO::getUsername, ProductSpuCreatorDO::getUsername)
.likeIfPresent(ProductSpuDO::getName, pageReqVO.getName())
.eqIfPresent(ProductSpuDO::getDeviceId, pageReqVO.getDeviceId())
.eqIfPresent(ProductSpuDO::getCategoryId, pageReqVO.getCategoryId())
.eqIfPresent(ProductSpuDO::getTypes, pageReqVO.getTypes())
.eqIfPresent(ProductSpuDO::getCreator, pageReqVO.getCreator())
@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.product.service.inventory;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.product.controller.admin.inventory.vo.InventoryCheckCreateReqVO;
import cn.iocoder.yudao.module.product.controller.admin.inventory.vo.InventoryCheckDetailRespVO;
import cn.iocoder.yudao.module.product.controller.admin.inventory.vo.InventoryCheckPageReqVO;
import cn.iocoder.yudao.module.product.controller.admin.inventory.vo.InventoryCheckRespVO;
/**
* 设备盘点 Service
*
* @author 小如之家
*/
public interface InventoryCheckService {
/**
* 创建一次盘点:按前端提交的每个货道的实盘数量,计算盘盈盘亏并更新库存。
*
* @param createReqVO 盘点请求
* @return 盘点记录主键
*/
Long createInventoryCheck(InventoryCheckCreateReqVO createReqVO);
/**
* 盘点记录分页
*/
PageResult<InventoryCheckRespVO> getInventoryCheckPage(InventoryCheckPageReqVO pageReqVO);
/**
* 查询某次盘点详情(含明细列表)
*/
InventoryCheckDetailRespVO getInventoryCheckDetail(Long id);
}
@@ -0,0 +1,181 @@
package cn.iocoder.yudao.module.product.service.inventory;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.product.controller.admin.inventory.vo.*;
import cn.iocoder.yudao.module.product.controller.admin.spu.vo.ProductSkuUpdateStockReqVO;
import cn.iocoder.yudao.module.product.controller.admin.stocklog.vo.StockLogSaveReqVO;
import cn.iocoder.yudao.module.product.dal.dataobject.inventory.InventoryCheckDO;
import cn.iocoder.yudao.module.product.dal.dataobject.inventory.InventoryCheckItemDO;
import cn.iocoder.yudao.module.product.dal.dataobject.spu.ProductSpuDO;
import cn.iocoder.yudao.module.product.dal.mysql.spu.ProductSpuMapper;
import cn.iocoder.yudao.module.product.dal.dataobject.sku.ProductSkuDO;
import cn.iocoder.yudao.module.product.dal.dataobject.spu.ProductSpuDO;
import cn.iocoder.yudao.module.product.dal.mysql.inventory.InventoryCheckItemMapper;
import cn.iocoder.yudao.module.product.dal.mysql.inventory.InventoryCheckMapper;
import cn.iocoder.yudao.module.product.service.sku.ProductSkuService;
import cn.iocoder.yudao.module.product.service.stocklog.StockLogService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.*;
import java.util.stream.Collectors;
/**
* 设备盘点 Service 实现
*
* 基本思路:
* - 前端基于 /product/channel/device-goods 获取「货道 + 商品」列表(含 skuId、channelId、系统库存)
* - 用户录入 actualStock 后调用 createInventoryCheck
* - 后端按 diff=actual-system 计算盘盈/盘亏,更新 SKU 库存 & SPU 库存,并记录盘点主表 + 明细 + 库存日志
*/
@Service
@Slf4j
public class InventoryCheckServiceImpl implements InventoryCheckService {
@Resource
private InventoryCheckMapper inventoryCheckMapper;
@Resource
private InventoryCheckItemMapper inventoryCheckItemMapper;
@Resource
private ProductSkuService productSkuService;
@Resource
private ProductSpuMapper productSpuMapper;
@Resource
private StockLogService stockLogService;
@Override
@Transactional(rollbackFor = Exception.class)
public Long createInventoryCheck(InventoryCheckCreateReqVO createReqVO) {
String deviceId = createReqVO.getDeviceId();
List<InventoryCheckCreateReqVO.Item> items = createReqVO.getItems();
if (CollUtil.isEmpty(items)) {
return null;
}
// 1. 预查询所需的 SKU、SPU、货道信息,减少单条循环查询
Set<Long> skuIds = items.stream().map(InventoryCheckCreateReqVO.Item::getSkuId).collect(Collectors.toSet());
Map<Long, ProductSkuDO> skuMap = productSkuService.getSkuList(skuIds).stream()
.collect(Collectors.toMap(ProductSkuDO::getId, s -> s));
// SPU 信息在循环中按需懒加载到缓存,避免重复查询
Map<Long, ProductSpuDO> spuCache = new HashMap<>();
// 2. 逐条计算差异并更新 SKU 库存,顺带为 SPU 统计新的总库存
Map<Long, Integer> spuNewTotalStock = new HashMap<>();
List<InventoryCheckItemDO> detailList = new ArrayList<>(items.size());
int totalDiff = 0;
for (InventoryCheckCreateReqVO.Item item : items) {
ProductSkuDO sku = skuMap.get(item.getSkuId());
if (sku == null) {
log.warn("[createInventoryCheck][deviceId={}, skuId={} 不存在,跳过]", deviceId, item.getSkuId());
continue;
}
Integer systemStock = Optional.ofNullable(sku.getStock()).orElse(0);
Integer actualStock = Optional.ofNullable(item.getActualStock()).orElse(0);
int diff = actualStock - systemStock;
totalDiff += diff;
// 更新 SKU 库存为实盘值(绝对值更新)
ProductSkuUpdateStockReqVO updateSkuReqVO = new ProductSkuUpdateStockReqVO();
updateSkuReqVO.setId(sku.getId());
updateSkuReqVO.setStock(actualStock);
int updated = productSkuService.updateSkuStockById(updateSkuReqVO);
if (updated > 0) {
// 写库存日志:记录本次盘点导致的库存变化
StockLogSaveReqVO stockLog = new StockLogSaveReqVO();
stockLog.setDeviceId(deviceId);
stockLog.setCabinetChannel(item.getCabinetChannel());
// 需要设置商品名称,否则会触发 @NotEmpty 校验
ProductSpuDO spuForLog = spuCache.computeIfAbsent(sku.getSpuId(),
id -> productSpuMapper.selectById(id));
if (spuForLog != null) {
stockLog.setSpuName(spuForLog.getName());
}
stockLog.setBarCode(sku.getBarCode());
stockLog.setOldStock(systemStock);
stockLog.setNewStock(actualStock);
stockLog.setCount(diff); // 这次盘点造成的差异
stockLogService.createStockLog(stockLog);
}
// 为后续 SPU 汇总记录新库存(按照实盘值累加)
spuNewTotalStock.merge(sku.getSpuId(), actualStock, Integer::sum);
// 构造盘点明细 DO
ProductSpuDO spu = spuCache.computeIfAbsent(sku.getSpuId(),
id -> productSpuMapper.selectById(id));
InventoryCheckItemDO detail = new InventoryCheckItemDO();
detail.setDeviceId(deviceId);
detail.setChannelId(item.getChannelId());
detail.setCabinetChannel(item.getCabinetChannel());
detail.setSkuId(sku.getId());
detail.setSpuId(sku.getSpuId());
detail.setSpuName(spu != null ? spu.getName() : null);
detail.setBarCode(sku.getBarCode());
detail.setSystemStock(systemStock);
detail.setActualStock(actualStock);
detail.setDiff(diff);
detailList.add(detail);
}
// 3. 按新的 SKU 实盘值,回写各 SPU 的总库存
for (Map.Entry<Long, Integer> entry : spuNewTotalStock.entrySet()) {
ProductSkuUpdateStockReqVO updateSpuReqVO = new ProductSkuUpdateStockReqVO();
updateSpuReqVO.setId(entry.getKey());
updateSpuReqVO.setStock(entry.getValue());
// 直接将 SPU 的总库存更新为所有 SKU 实盘库存之和
productSpuMapper.updateSpuStockById(updateSpuReqVO);
}
// 4. 保存盘点主表
InventoryCheckDO check = new InventoryCheckDO();
check.setDeviceId(deviceId);
check.setName(Optional.ofNullable(createReqVO.getName())
.orElse("设备" + deviceId + "盘点"));
check.setRemark(createReqVO.getRemark());
check.setTotalDiff(totalDiff);
check.setItemCount(detailList.size());
inventoryCheckMapper.insert(check);
Long checkId = check.getId();
// 5. 批量写入盘点明细
for (InventoryCheckItemDO detail : detailList) {
detail.setCheckId(checkId);
}
if (!detailList.isEmpty()) {
inventoryCheckItemMapper.insertBatch(detailList);
}
return checkId;
}
@Override
public PageResult<InventoryCheckRespVO> getInventoryCheckPage(InventoryCheckPageReqVO pageReqVO) {
// BaseMapperX 已支持 selectPage,这里直接手动拼 Wrapper
PageResult<InventoryCheckDO> page = inventoryCheckMapper.selectPage(pageReqVO);
return BeanUtils.toBean(page, InventoryCheckRespVO.class);
}
@Override
public InventoryCheckDetailRespVO getInventoryCheckDetail(Long id) {
InventoryCheckDO check = inventoryCheckMapper.selectById(id);
if (check == null) {
return null;
}
InventoryCheckDetailRespVO respVO = BeanUtils.toBean(check, InventoryCheckDetailRespVO.class);
List<InventoryCheckItemDO> items = inventoryCheckItemMapper.selectListByCheckId(id);
if (CollUtil.isEmpty(items)) {
respVO.setItems(Collections.emptyList());
return respVO;
}
List<InventoryCheckDetailRespVO.Item> itemVos = BeanUtils.toBean(items, InventoryCheckDetailRespVO.Item.class);
respVO.setItems(itemVos);
return respVO;
}
}
@@ -28,6 +28,7 @@ import cn.iocoder.yudao.module.product.service.sku.ProductSkuService;
import cn.iocoder.yudao.module.product.service.stocklog.StockLogService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.google.common.collect.Maps;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@@ -465,6 +466,10 @@ public class ProductSpuServiceImpl implements ProductSpuService {
// 更新 SPU 库存
int ispu = productSpuMapper.updateSpuStockById(updateSpuReqVO);
if (ispu>0) {
// 同时将商品状态改为「仓库中」(下架),避免后续接口继续将其视为已上架售货机商品
productSpuMapper.update(null, new LambdaUpdateWrapper<ProductSpuDO>()
.eq(ProductSpuDO::getId, spu.getId())
.set(ProductSpuDO::getStatus, ProductSpuStatusEnum.DISABLE.getStatus()));
for (ProductSkuDO sku : skuListBySpuId) {
// 更新 SKU 库存
ProductSkuUpdateStockReqVO updateSkuReqVO = new ProductSkuUpdateStockReqVO();
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.trade.service.order.bo;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 设备出货接口 - 通道类型 T(message 首段)
*
* 对应接口文档:message 格式 T,C&N,C&N 中的 T
*/
@RequiredArgsConstructor
@Getter
public enum DispenseChannelTypeEnum {
CHANNEL(1, "货道"),
CHANNEL_WITH_DETECT(2, "货道带调货检测"),
DOOR(0x30, "柜门"),
DOOR_WITH_FEEDBACK(0x31, "柜门带反馈"),
;
private final int value;
private final String desc;
public static String getDescByValue(Integer value) {
if (value == null) {
return String.valueOf(value);
}
for (DispenseChannelTypeEnum e : values()) {
if (e.getValue() == value) {
return e.getDesc();
}
}
return "未知(" + value + ")";
}
}
@@ -3,17 +3,37 @@ package cn.iocoder.yudao.module.trade.service.order.bo;
import lombok.Data;
/**
* 货道机发货请求
* 设备出货/查询状态 - 请求体(入参)
*
* 接口:POST /ots/multiDispense
* Content-Type: application/json
* Authorization: Bearer {token}
*
* 入参说明:
* <ul>
* <li>deviceId - 设备 IMEI</li>
* <li>message - 发货时格式:T,C&N,C&N,C&N;
* 查询状态时固定为 "1"</li>
* </ul>
*
* message 发货格式说明:
* <ul>
* <li>T - 通道类型:1=货道,2=货道带调货检测,0x30=柜门,0x31=柜门带反馈</li>
* <li>C - 货道或柜门编号(1-80),多个货道同时出货逗号分隔</li>
* <li>N - 出货数量</li>
* </ul>
* 示例:1,1&1,2&1,3&1 表示通道类型 1(货道),货道 1 出 1 个、货道 2 出 1 个、货道 3 出 1 个
*/
@Data
public class DispenseRequestBo {
//设备IMEI
/** 设备 IMEI */
private String deviceId;
//参数格式:T,C&N,C&N,C&N
// T:通道类型(1:货道,2:货道带调货检测:0x30:柜门:0x31:柜门带反馈)
// C:货道或柜门编号,多个货道同时出货逗号分隔 =(1--80)指定货道出货
// N:出货数量
/**
* 发货:T,C&N,C&N(T=通道类型,C=货道/柜门编号,N=数量)
* 查询状态:固定 "1"
*/
private String message;
public DispenseRequestBo(String deviceId, String message) {
@@ -3,11 +3,34 @@ package cn.iocoder.yudao.module.trade.service.order.bo;
import lombok.Data;
/**
* 货道机发货请求
* 设备出货/查询状态 - 响应体(出参)
*
* 出参说明:
* <ul>
* <li>code - 0 成功,&gt;0 失败(接口层)</li>
* <li>msg - 提示信息</li>
* <li>data - 发货时格式 R,C&N,C&N;查询状态时为 0 表示在线</li>
* </ul>
*
* data 发货格式说明(R,C&N,C&N):
* <ul>
* <li>R - 执行结果状态(货道:0出货成功 1缺货 2电机超时 3未检测到掉货 4电机短路 5销售订单号重复 6通道号错误 7通道类型错误;
* 柜门:0开门成功 1缺货 2开门失败 5销售订单号重复)</li>
* <li>C&N - 货道编号&amp;出货数量,多个逗号分隔</li>
* </ul>
*/
@Data
public class DispenseResponseBo {
private int code;
/** 0 成功,>0 失败 */
private Integer code;
/** 提示信息 */
private String msg;
/**
* 发货:R,C&N,C&N(R=执行结果状态,C&N=货道编号&数量)
* 查询状态:0 表示在线
*/
private String data;
}
@@ -0,0 +1,40 @@
package cn.iocoder.yudao.module.trade.service.order.bo;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 设备出货接口 - 执行结果状态 R(响应 data 格式 R,C&N,C&N 中的 R)
*
* 货道:0 出货成功, 1 缺货, 2 电机超时, 3 未检测到掉货, 4 电机短路, 5 销售订单号重复, 6 通道号错误, 7 通道类型错误
* 柜门:0 开门成功, 1 缺货, 2 开门失败, 5 销售订单号重复
*/
@RequiredArgsConstructor
@Getter
public enum DispenseResultStatusEnum {
SUCCESS(0, "出货成功/开门成功"),
OUT_OF_STOCK(1, "缺货"),
MOTOR_TIMEOUT(2, "电机超时/开门失败"),
NO_DROP_DETECTED(3, "未检测到掉货"),
MOTOR_SHORT(4, "电机短路"),
ORDER_NO_DUPLICATE(5, "销售订单号重复"),
CHANNEL_ERROR(6, "通道号错误"),
CHANNEL_TYPE_ERROR(7, "通道类型错误"),
;
private final int value;
private final String desc;
public static String getDescByValue(Integer value) {
if (value == null) {
return "未知";
}
for (DispenseResultStatusEnum e : values()) {
if (e.getValue() == value) {
return e.getDesc();
}
}
return "未知(" + value + ")";
}
}
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.trade.service.order.handler;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.product.api.device.DeviceApi;
import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi;
import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO;
@@ -13,11 +14,13 @@ import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderDO;
import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderItemDO;
import cn.iocoder.yudao.module.trade.dal.mysql.order.TradeOrderMapper;
import cn.iocoder.yudao.module.trade.enums.order.TradeOrderStatusEnum;
import cn.iocoder.yudao.module.trade.enums.order.TradeOrderTypeEnum;
import cn.iocoder.yudao.module.trade.service.devicelog.DeviceLogService;
import cn.iocoder.yudao.module.trade.service.order.TradeOrderQueryService;
import cn.iocoder.yudao.module.trade.service.order.TradeOrderUpdateService;
import cn.iocoder.yudao.module.trade.service.order.bo.DispenseRequestBo;
import cn.iocoder.yudao.module.trade.service.order.bo.DispenseResponseBo;
import cn.iocoder.yudao.module.trade.service.order.bo.DispenseResultStatusEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
@@ -27,8 +30,12 @@ import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -86,6 +93,11 @@ public class TradeDeviceOrderHandler implements TradeOrderHandler {
@Override
public void afterPayOrder(TradeOrderDO order, List<TradeOrderItemDO> orderItems) {
// 商城与售货机共用同一支付回调,仅售货机订单才走设备出货流程
if (!Objects.equals(order.getType(), TradeOrderTypeEnum.MACHINE.getType())) {
return;
}
// 这里不要抛异常,否则会影响“支付成功后”的主流程
// 所有错误通过日志 + 设备出货日志记录
@@ -147,29 +159,32 @@ public class TradeDeviceOrderHandler implements TradeOrderHandler {
return;
}
// 3. 查询 SKU 信息,并校验是否为“单一通道类型”
List<Long> skuIds = orderItems.stream()
.map(TradeOrderItemDO::getSkuId)
.distinct()
.collect(Collectors.toList());
if (CollUtil.isEmpty(skuIds)) {
log.warn("订单:{} 无有效 SKU 明细,跳过设备出货处理", order.getNo());
return;
}
List<ProductSkuRespDTO> skuList = productSkuApi.getSkuList(skuIds);
if (CollUtil.isEmpty(skuList)) {
log.warn("订单:{} 根据 SKU 编号未查询到有效 SKU,跳过设备出货处理", order.getNo());
// 3. 按 SPU 查询该商品下所有货道(SKU),并校验单一通道类型
List<ProductSkuRespDTO> skuListBySpu = productSkuApi.getSkuListBySpuId(spuIds);
if (CollUtil.isEmpty(skuListBySpu)) {
log.warn("订单:{} 商品下无货道(SKU),跳过设备出货处理", order.getNo());
deviceLogSaveReqVO.setDeviceId(deviceId);
deviceLogSaveReqVO.setDeviceStatus(500);
deviceLogSaveReqVO.setContent("[出货异常]原因:商品下无货道(SKU)");
deviceLogService.createDeviceLog(deviceLogSaveReqVO);
return;
}
String cabinetType = findUniqueCabinetType(order, skuList, deviceId, deviceLogSaveReqVO);
String cabinetType = findUniqueCabinetType(order, skuListBySpu, deviceId, deviceLogSaveReqVO);
if (cabinetType == null) {
// 已在方法内部记录日志
return;
}
// 4. 组装出货请求
DispenseRequestBo request = buildDispenseRequest(orderItems, skuList, deviceId, cabinetType);
// 4. 组装出货请求:每条订单行在该商品下有库存的货道中随机选一个,同货道数量合并
DispenseRequestBo request = buildDispenseRequest(order, orderItems, deviceId, cabinetType);
if (request == null) {
deviceLogSaveReqVO.setDeviceId(deviceId);
deviceLogSaveReqVO.setDeviceStatus(500);
deviceLogSaveReqVO.setContent("[出货异常]原因:存在商品无可用货道(有库存且通道类型匹配)");
deviceLogService.createDeviceLog(deviceLogSaveReqVO);
return;
}
// 5. 调用设备出货接口,并记录出货日志
sendDispenseRequest(order, request, deviceLogSaveReqVO);
@@ -246,41 +261,50 @@ public class TradeDeviceOrderHandler implements TradeOrderHandler {
/**
* 构建设备出货请求体
*
* message 协议格式(示例):
* T,C1&N1,C2&N2
* 其中:
* 逻辑:一个商品(SPU)下可能存在多个货道(SKU)。每条订单行在该商品下「有库存且通道类型匹配」的货道中随机选一个出货,同一货道多次被选中则合并数量。
*
* message 协议格式(示例):T,C1&N1,C2&N2
* - T:通道类型(cabinetType)
* - C:货道或柜门编号(cabinetChannel)
* - N:出货数量(订单行的 count)
* - N:出货数量(同货道合并后的数量)
*
* @return 组装好的请求;若某商品无可用货道则返回 null
*/
private DispenseRequestBo buildDispenseRequest(List<TradeOrderItemDO> orderItems,
List<ProductSkuRespDTO> skuList,
private DispenseRequestBo buildDispenseRequest(TradeOrderDO order,
List<TradeOrderItemDO> orderItems,
String deviceId,
String cabinetType) {
Map<String, Integer> channelToCount = new LinkedHashMap<>();
String lastChannel = null;
for (TradeOrderItemDO orderItem : orderItems) {
List<ProductSkuRespDTO> skus = productSkuApi.getSkuListBySpuId(Collections.singletonList(orderItem.getSpuId()));
// 有库存、货道非空、通道类型一致
List<ProductSkuRespDTO> inStock = skus.stream()
.filter(sku -> sku != null
&& StrUtil.isNotBlank(sku.getCabinetChannel())
&& Objects.equals(sku.getCabinetType(), cabinetType)
&& sku.getStock() != null
&& sku.getStock() >= orderItem.getCount())
.collect(Collectors.toList());
if (inStock.isEmpty()) {
log.error("订单:{} 商品 spuId={} 无可用货道(有库存且通道类型匹配)", order.getNo(), orderItem.getSpuId());
return null;
}
ProductSkuRespDTO chosen = inStock.get(ThreadLocalRandom.current().nextInt(inStock.size()));
channelToCount.merge(chosen.getCabinetChannel(), orderItem.getCount(), Integer::sum);
lastChannel = chosen.getCabinetChannel();
}
String messageStr = cabinetType + "," + channelToCount.entrySet().stream()
.map(e -> e.getKey() + "&" + e.getValue())
.collect(Collectors.joining(","));
DispenseRequestBo request = new DispenseRequestBo();
request.setDeviceId(deviceId);
StringBuilder message = new StringBuilder(cabinetType).append(",");
for (int i = 0; i < orderItems.size(); i++) {
TradeOrderItemDO orderItem = orderItems.get(i);
ProductSkuRespDTO sku = productSkuApi.getSku(orderItem.getSkuId());
if (sku == null) {
// 理论上不应该为空,这里简单跳过该行,后续可以根据需要增强处理
continue;
}
message.append(sku.getCabinetChannel())
.append("&")
.append(orderItem.getCount());
if (i != orderItems.size() - 1) {
message.append(",");
}
// 记录最后一次出货的货道(用于设备记录“上一次出货货道”)
if (i == orderItems.size() - 1) {
deviceApi.updateDeviceLastChannel(deviceId, sku.getCabinetChannel());
}
request.setMessage(messageStr);
if (lastChannel != null) {
deviceApi.updateDeviceLastChannel(deviceId, lastChannel);
}
request.setMessage(message.toString());
return request;
}
@@ -320,18 +344,17 @@ public class TradeDeviceOrderHandler implements TradeOrderHandler {
return;
}
Integer respCode = body.getCode() != null ? body.getCode() : -1;
String respData = body.getData() != null ? body.getData() : "";
log.info("订单--{}:[设备出货响应]: code={}, msg={}, data={}", order.getNo(),
body.getCode(), body.getMsg(), body.getData());
respCode, body.getMsg(), respData);
deviceLogSaveReqVO.setDeviceId(request.getDeviceId());
deviceLogSaveReqVO.setDeviceStatus(body.getCode());
if (body.getCode() == 0) {
// 出货成功
deviceLogSaveReqVO.setContent("出货成功!详情: " + body.getData());
} else {
// 出货失败
log.error("订单--{}:[出货失败]:原因: {}", order.getNo(), body.getMsg());
deviceLogSaveReqVO.setContent("[出货失败]原因: " + body.getMsg());
deviceLogSaveReqVO.setDeviceStatus(respCode);
String content = buildDispenseLogContent(respCode, body.getMsg(), respData);
deviceLogSaveReqVO.setContent(content);
if (respCode != 0) {
log.error("订单--{}:[出货失败]: {}", order.getNo(), content);
}
deviceLogService.createDeviceLog(deviceLogSaveReqVO);
} catch (Exception e) {
@@ -344,6 +367,56 @@ public class TradeDeviceOrderHandler implements TradeOrderHandler {
}
}
/**
* 根据接口出参组装设备日志内容
*
* 出参 data 格式:R,C&N,C&N(R=执行结果状态 0-7,C&N=货道编号&数量)
* 查询状态时 data 为 "0" 表示在线
*/
private String buildDispenseLogContent(Integer code, String msg, String data) {
StringBuilder sb = new StringBuilder();
if (code != null && code == 0) {
sb.append("出货成功!");
} else {
sb.append("[出货失败]");
if (code != null) {
sb.append(" code=").append(code);
}
if (StrUtil.isNotBlank(msg)) {
sb.append(" msg=").append(msg);
}
sb.append(";");
}
if (StrUtil.isNotBlank(data)) {
String resultDesc = parseDispenseResultStatus(data);
if (resultDesc != null) {
sb.append("执行结果: ").append(resultDesc);
}
sb.append(" 详情: ").append(data);
}
return sb.toString().trim();
}
/**
* 解析 data 首段 R(执行结果状态),返回可读描述
* data 格式:R,C&N,C&N 或 仅 R
*/
private String parseDispenseResultStatus(String data) {
if (StrUtil.isBlank(data)) {
return null;
}
String first = data.split(",", 2)[0].trim();
if (first.isEmpty()) {
return null;
}
try {
int r = Integer.parseInt(first);
return r + "(" + DispenseResultStatusEnum.getDescByValue(r) + ")";
} catch (NumberFormatException e) {
return null;
}
}
/**
* 将订单状态更新为“已完成”
*