From 4641bddcea1be5f5a13988234131e248a2d7d839 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Thu, 16 Jul 2026 10:54:21 +0800 Subject: [PATCH] feat: add dense lane outbound workflow --- .../DenseLaneOutboundController.java | 49 ++ .../domain/DenseLaneOutboundPreview.java | 22 + .../dense/domain/DenseLaneOutboundQuery.java | 18 + .../domain/DenseLaneOutboundRequest.java | 27 + .../out/dense/domain/DenseLanePosition.java | 6 + .../out/dense/domain/DenseLaneSummary.java | 41 ++ .../out/dense/mapper/DenseLaneMapper.java | 4 + .../service/IDenseLaneOutboundService.java | 19 + .../impl/DenseLaneOutboundServiceImpl.java | 315 ++++++++++ .../resources/mapper/out/DenseLaneMapper.xml | 120 +++- .../mapper/out/RelocationJobMapper.xml | 1 + .../dense/mapper/DenseLaneMapperSqlTest.java | 19 + .../DenseLaneOutboundServiceImplTest.java | 260 ++++++++ ruoyi-ui/src/api/out/denseLaneOutbound.js | 28 + .../src/views/out/denseLaneOutbound/index.vue | 553 ++++++++++++++++++ 15 files changed, 1481 insertions(+), 1 deletion(-) create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/out/dense/controller/DenseLaneOutboundController.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundPreview.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundQuery.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundRequest.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneSummary.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/IDenseLaneOutboundService.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/impl/DenseLaneOutboundServiceImpl.java create mode 100644 ruoyi-admin/src/test/java/com/ruoyi/out/dense/service/impl/DenseLaneOutboundServiceImplTest.java create mode 100644 ruoyi-ui/src/api/out/denseLaneOutbound.js create mode 100644 ruoyi-ui/src/views/out/denseLaneOutbound/index.vue diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/controller/DenseLaneOutboundController.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/controller/DenseLaneOutboundController.java new file mode 100644 index 0000000..ba78b65 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/controller/DenseLaneOutboundController.java @@ -0,0 +1,49 @@ +package com.ruoyi.out.dense.controller; + +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.out.dense.domain.DenseLaneOutboundQuery; +import com.ruoyi.out.dense.domain.DenseLaneOutboundRequest; +import com.ruoyi.out.dense.service.IDenseLaneOutboundService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 指定密集库通道出库接口。 + */ +@RestController +@RequestMapping("/out/denseLaneOutbound") +public class DenseLaneOutboundController extends BaseController +{ + private final IDenseLaneOutboundService service; + + public DenseLaneOutboundController(IDenseLaneOutboundService service) + { + this.service = service; + } + + @PreAuthorize("@ss.hasPermi('out:denseLaneOutbound:list')") + @GetMapping("/list") + public AjaxResult list(DenseLaneOutboundQuery query) + { + return success(service.list(query)); + } + + @PreAuthorize("@ss.hasPermi('out:denseLaneOutbound:preview')") + @GetMapping("/preview") + public AjaxResult preview(DenseLaneOutboundRequest request) + { + return success(service.preview(request)); + } + + @PreAuthorize("@ss.hasPermi('out:denseLaneOutbound:generate')") + @PostMapping("/generate") + public AjaxResult generate(@RequestBody DenseLaneOutboundRequest request) + { + return success(service.generate(request)); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundPreview.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundPreview.java new file mode 100644 index 0000000..6ea8331 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundPreview.java @@ -0,0 +1,22 @@ +package com.ruoyi.out.dense.domain; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** + * 指定通道出库托盘预览。 + */ +public class DenseLaneOutboundPreview +{ + private List trays = new ArrayList<>(); + private BigDecimal totalQuantity = BigDecimal.ZERO; + private String previewToken; + + public List getTrays() { return trays; } + public void setTrays(List trays) { this.trays = trays; } + public BigDecimal getTotalQuantity() { return totalQuantity; } + public void setTotalQuantity(BigDecimal totalQuantity) { this.totalQuantity = totalQuantity; } + public String getPreviewToken() { return previewToken; } + public void setPreviewToken(String previewToken) { this.previewToken = previewToken; } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundQuery.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundQuery.java new file mode 100644 index 0000000..acc9b2c --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundQuery.java @@ -0,0 +1,18 @@ +package com.ruoyi.out.dense.domain; + +/** + * 指定通道出库列表查询。 + */ +public class DenseLaneOutboundQuery +{ + private String warehouseId = "03"; + private Long theFloor; + private String groupCoding; + + public String getWarehouseId() { return warehouseId; } + public void setWarehouseId(String warehouseId) { this.warehouseId = warehouseId; } + public Long getTheFloor() { return theFloor; } + public void setTheFloor(Long theFloor) { this.theFloor = theFloor; } + public String getGroupCoding() { return groupCoding; } + public void setGroupCoding(String groupCoding) { this.groupCoding = groupCoding; } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundRequest.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundRequest.java new file mode 100644 index 0000000..506f620 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneOutboundRequest.java @@ -0,0 +1,27 @@ +package com.ruoyi.out.dense.domain; + +/** + * 指定通道整托出库预览/生成请求。 + */ +public class DenseLaneOutboundRequest +{ + private String warehouseId = "03"; + private Long theFloor; + private String groupCoding; + private Integer trayCount; + private String exitId; + private String previewToken; + + public String getWarehouseId() { return warehouseId; } + public void setWarehouseId(String warehouseId) { this.warehouseId = warehouseId; } + public Long getTheFloor() { return theFloor; } + public void setTheFloor(Long theFloor) { this.theFloor = theFloor; } + public String getGroupCoding() { return groupCoding; } + public void setGroupCoding(String groupCoding) { this.groupCoding = groupCoding; } + public Integer getTrayCount() { return trayCount; } + public void setTrayCount(Integer trayCount) { this.trayCount = trayCount; } + public String getExitId() { return exitId; } + public void setExitId(String exitId) { this.exitId = exitId; } + public String getPreviewToken() { return previewToken; } + public void setPreviewToken(String previewToken) { this.previewToken = previewToken; } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLanePosition.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLanePosition.java index f6a6a27..ab8ad6a 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLanePosition.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLanePosition.java @@ -1,6 +1,9 @@ package com.ruoyi.out.dense.domain; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import com.ruoyi.in.jobdetail.domain.MydoJobDetail; /** * 密集库通道货位及其几何信息。 @@ -22,6 +25,7 @@ public class DenseLanePosition private Long minColumn; private Long maxColumn; private BigDecimal stockQuantity; + private List stockDetails = new ArrayList<>(); public String getCargoSpaceId() { return cargoSpaceId; } public void setCargoSpaceId(String cargoSpaceId) { this.cargoSpaceId = cargoSpaceId; } @@ -53,4 +57,6 @@ public class DenseLanePosition public void setMaxColumn(Long maxColumn) { this.maxColumn = maxColumn; } public BigDecimal getStockQuantity() { return stockQuantity; } public void setStockQuantity(BigDecimal stockQuantity) { this.stockQuantity = stockQuantity; } + public List getStockDetails() { return stockDetails; } + public void setStockDetails(List stockDetails) { this.stockDetails = stockDetails; } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneSummary.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneSummary.java new file mode 100644 index 0000000..8a18b4c --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/domain/DenseLaneSummary.java @@ -0,0 +1,41 @@ +package com.ruoyi.out.dense.domain; + +import java.math.BigDecimal; + +/** + * 03仓密集库通道整托库存汇总。 + */ +public class DenseLaneSummary +{ + private String groupCoding; + private String cargoArea; + private Long thePlatoon; + private Long theFloor; + private String directionType; + private Integer fullTrayCount; + private Integer availableTrayCount; + private BigDecimal totalQuantity; + private String outermostTrayCode; + private String outermostCargoSpaceId; + + public String getGroupCoding() { return groupCoding; } + public void setGroupCoding(String groupCoding) { this.groupCoding = groupCoding; } + public String getCargoArea() { return cargoArea; } + public void setCargoArea(String cargoArea) { this.cargoArea = cargoArea; } + public Long getThePlatoon() { return thePlatoon; } + public void setThePlatoon(Long thePlatoon) { this.thePlatoon = thePlatoon; } + public Long getTheFloor() { return theFloor; } + public void setTheFloor(Long theFloor) { this.theFloor = theFloor; } + public String getDirectionType() { return directionType; } + public void setDirectionType(String directionType) { this.directionType = directionType; } + public Integer getFullTrayCount() { return fullTrayCount; } + public void setFullTrayCount(Integer fullTrayCount) { this.fullTrayCount = fullTrayCount; } + public Integer getAvailableTrayCount() { return availableTrayCount; } + public void setAvailableTrayCount(Integer availableTrayCount) { this.availableTrayCount = availableTrayCount; } + public BigDecimal getTotalQuantity() { return totalQuantity; } + public void setTotalQuantity(BigDecimal totalQuantity) { this.totalQuantity = totalQuantity; } + public String getOutermostTrayCode() { return outermostTrayCode; } + public void setOutermostTrayCode(String outermostTrayCode) { this.outermostTrayCode = outermostTrayCode; } + public String getOutermostCargoSpaceId() { return outermostCargoSpaceId; } + public void setOutermostCargoSpaceId(String outermostCargoSpaceId) { this.outermostCargoSpaceId = outermostCargoSpaceId; } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/mapper/DenseLaneMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/mapper/DenseLaneMapper.java index 6b2a6c9..5cac2ba 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/mapper/DenseLaneMapper.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/mapper/DenseLaneMapper.java @@ -4,6 +4,8 @@ import java.util.List; import com.ruoyi.bas.cargospace.domain.BasCargoSpace; import com.ruoyi.out.dense.domain.DenseLanePosition; import com.ruoyi.out.dense.domain.DenseLaneOption; +import com.ruoyi.out.dense.domain.DenseLaneOutboundQuery; +import com.ruoyi.out.dense.domain.DenseLaneSummary; import com.ruoyi.out.dense.domain.RelocationTargetQuery; import org.apache.ibatis.annotations.Param; @@ -24,4 +26,6 @@ public interface DenseLaneMapper List selectLaneOptions(@Param("warehouseId") String warehouseId, @Param("theFloor") Long theFloor); + + List selectLaneSummaries(DenseLaneOutboundQuery query); } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/IDenseLaneOutboundService.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/IDenseLaneOutboundService.java new file mode 100644 index 0000000..7cde62c --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/IDenseLaneOutboundService.java @@ -0,0 +1,19 @@ +package com.ruoyi.out.dense.service; + +import java.util.List; +import com.ruoyi.out.dense.domain.DenseLaneOutboundPreview; +import com.ruoyi.out.dense.domain.DenseLaneOutboundQuery; +import com.ruoyi.out.dense.domain.DenseLaneOutboundRequest; +import com.ruoyi.out.dense.domain.DenseLaneSummary; + +/** + * 指定密集库通道整托出库服务。 + */ +public interface IDenseLaneOutboundService +{ + List list(DenseLaneOutboundQuery query); + + DenseLaneOutboundPreview preview(DenseLaneOutboundRequest request); + + List generate(DenseLaneOutboundRequest request); +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/impl/DenseLaneOutboundServiceImpl.java b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/impl/DenseLaneOutboundServiceImpl.java new file mode 100644 index 0000000..655a755 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/out/dense/service/impl/DenseLaneOutboundServiceImpl.java @@ -0,0 +1,315 @@ +package com.ruoyi.out.dense.service.impl; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.stream.Collectors; +import com.ruoyi.bas.cargospace.mapper.BasCargoSpaceMapper; +import com.ruoyi.bas.id.job.service.JobIdService; +import com.ruoyi.bas.id.taskno.service.TaskNoService; +import com.ruoyi.bas.stationpoint.domain.BasStationPoint; +import com.ruoyi.bas.stationpoint.service.IBasStationPointService; +import com.ruoyi.bas.stock.mapper.BasStockMapper; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.in.job.domain.MydoJob; +import com.ruoyi.in.job.service.IMydoJobService; +import com.ruoyi.in.jobdetail.domain.MydoJobDetail; +import com.ruoyi.in.jobdetail.service.IMydoJobDetailService; +import com.ruoyi.out.dense.domain.DenseLaneOutboundPreview; +import com.ruoyi.out.dense.domain.DenseLaneOutboundQuery; +import com.ruoyi.out.dense.domain.DenseLaneOutboundRequest; +import com.ruoyi.out.dense.domain.DenseLanePosition; +import com.ruoyi.out.dense.domain.DenseLaneSummary; +import com.ruoyi.out.dense.mapper.DenseLaneMapper; +import com.ruoyi.out.dense.service.IDenseLaneOutboundService; +import com.ruoyi.out.dense.service.IDenseLaneService; +import com.ruoyi.out.dispatch.domain.DispatchTrigger; +import com.ruoyi.out.dispatch.service.IOutboundDispatchService; +import com.ruoyi.out.location.mapper.LocationOutboundMapper; +import com.ruoyi.out.relocation.domain.RelocationGenerateRequest; +import com.ruoyi.out.relocation.mapper.RelocationJobMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * 指定密集库通道整托出库服务实现。 + */ +@Service +public class DenseLaneOutboundServiceImpl implements IDenseLaneOutboundService +{ + private final DenseLaneMapper denseLaneMapper; + private final IDenseLaneService denseLaneService; + private final RelocationJobMapper relocationJobMapper; + private final IBasStationPointService stationPointService; + private final IMydoJobService mydoJobService; + private final IMydoJobDetailService mydoJobDetailService; + private final LocationOutboundMapper locationOutboundMapper; + private final BasStockMapper basStockMapper; + private final BasCargoSpaceMapper cargoSpaceMapper; + private final JobIdService jobIdService; + private final TaskNoService taskNoService; + private final IOutboundDispatchService dispatchService; + + public DenseLaneOutboundServiceImpl(DenseLaneMapper denseLaneMapper, + IDenseLaneService denseLaneService, + RelocationJobMapper relocationJobMapper, + IBasStationPointService stationPointService, + IMydoJobService mydoJobService, + IMydoJobDetailService mydoJobDetailService, + LocationOutboundMapper locationOutboundMapper, + BasStockMapper basStockMapper, + BasCargoSpaceMapper cargoSpaceMapper, + JobIdService jobIdService, + TaskNoService taskNoService, + IOutboundDispatchService dispatchService) + { + this.denseLaneMapper = denseLaneMapper; + this.denseLaneService = denseLaneService; + this.relocationJobMapper = relocationJobMapper; + this.stationPointService = stationPointService; + this.mydoJobService = mydoJobService; + this.mydoJobDetailService = mydoJobDetailService; + this.locationOutboundMapper = locationOutboundMapper; + this.basStockMapper = basStockMapper; + this.cargoSpaceMapper = cargoSpaceMapper; + this.jobIdService = jobIdService; + this.taskNoService = taskNoService; + this.dispatchService = dispatchService; + } + + @Override + public List list(DenseLaneOutboundQuery query) + { + if (query == null) { + query = new DenseLaneOutboundQuery(); + } + if (!"03".equals(query.getWarehouseId())) { + throw new RuntimeException("指定通道出库仅支持03仓密集库"); + } + List summaries = denseLaneMapper.selectLaneSummaries(query); + return summaries == null ? new ArrayList<>() : summaries; + } + + @Override + public DenseLaneOutboundPreview preview(DenseLaneOutboundRequest request) + { + validateRequest(request, false); + return buildPreview(request); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List generate(DenseLaneOutboundRequest request) + { + validateRequest(request, true); + DenseLaneOutboundPreview current = buildPreview(request); + if (!request.getPreviewToken().equals(current.getPreviewToken())) { + throw new RuntimeException("预览已失效,请刷新后重新确认"); + } + BasStationPoint exit = validateExit(request.getExitId(), current.getTrays()); + List jobIds = new ArrayList<>(); + for (DenseLanePosition position : current.getTrays()) { + List stockDetails = relocationJobMapper.selectStockDetailsForUpdate( + position.getCargoSpaceId(), position.getTrayCode()); + if (stockDetails == null || stockDetails.isEmpty()) { + throw new RuntimeException("托盘" + position.getTrayCode() + "没有可出库库存明细"); + } + String jobId = jobIdService.getId("CK"); + if (!StringUtils.hasText(jobId)) { + throw new RuntimeException("指定通道出库作业ID生成失败"); + } + MydoJob job = buildJob(jobId, taskNoService.getTaskNo("20"), position, exit); + if (mydoJobService.insertMydoJob(job) != 1) { + throw new RuntimeException("指定通道出库作业生成失败"); + } + int index = 1; + for (MydoJobDetail template : stockDetails) { + MydoJobDetail detail = buildDetail(template, jobId, index++); + if (mydoJobDetailService.insertMydoJobDetail(detail) != 1) { + throw new RuntimeException("指定通道出库作业明细生成失败"); + } + HashMap reserve = new HashMap<>(); + reserve.put("stockDetailId", template.getStockinDetailId()); + reserve.put("stockId", template.getStockId()); + reserve.put("jobDetailId", detail.getJobDetailId()); + reserve.put("quantity", detail.getOutQuantity()); + if (locationOutboundMapper.reserveStockDetailForOutbound(reserve) != 1) { + throw new RuntimeException("托盘库存状态已变化,请刷新后重试"); + } + } + stockDetails.stream().map(MydoJobDetail::getStockId).filter(StringUtils::hasText) + .distinct().forEach(basStockMapper::refreshBasStockSummary); + if (cargoSpaceMapper.markOutboundAssignedByCargoSpaceId(position.getCargoSpaceId()) != 1) { + throw new RuntimeException("端头货位状态已变化,请刷新后重试"); + } + jobIds.add(jobId); + } + afterCommit(() -> jobIds.forEach(id -> dispatchService.dispatch(id, DispatchTrigger.INITIAL))); + return jobIds; + } + + private DenseLaneOutboundPreview buildPreview(DenseLaneOutboundRequest request) + { + RelocationGenerateRequest sourceQuery = new RelocationGenerateRequest(); + sourceQuery.setWarehouseId(request.getWarehouseId()); + sourceQuery.setTheFloor(request.getTheFloor()); + sourceQuery.setSourceGroupCoding(request.getGroupCoding()); + sourceQuery.setTrayCount(request.getTrayCount()); + List available = relocationJobMapper.selectRelocationSourcesForUpdate(sourceQuery); + if (available == null || available.size() < request.getTrayCount()) { + throw new RuntimeException("当前通道可出库托盘数量不足"); + } + List selected = denseLaneService.orderOutermostFirst(available) + .stream().limit(request.getTrayCount()).collect(Collectors.toList()); + selected.forEach(position -> { + List details = relocationJobMapper.selectStockDetailsForUpdate( + position.getCargoSpaceId(), position.getTrayCode()); + position.setStockDetails(details == null ? new ArrayList<>() : details); + }); + String[] trayCodes = selected.stream().map(DenseLanePosition::getTrayCode).toArray(String[]::new); + BigDecimal total = relocationJobMapper.sumTrayStockQuantity(trayCodes); + DenseLaneOutboundPreview preview = new DenseLaneOutboundPreview(); + preview.setTrays(selected); + preview.setTotalQuantity(total == null ? BigDecimal.ZERO : total); + preview.setPreviewToken(previewToken(request, selected)); + return preview; + } + + private void validateRequest(DenseLaneOutboundRequest request, boolean requireToken) + { + if (request == null || !"03".equals(request.getWarehouseId())) { + throw new RuntimeException("指定通道出库仅支持03仓密集库"); + } + if (request.getTheFloor() == null || !StringUtils.hasText(request.getGroupCoding())) { + throw new RuntimeException("请选择密集库层和通道"); + } + if (request.getTrayCount() == null || request.getTrayCount() <= 0) { + throw new RuntimeException("出库托盘数量必须大于0"); + } + if (requireToken && !StringUtils.hasText(request.getPreviewToken())) { + throw new RuntimeException("请先预览要出库的端头托盘"); + } + } + + private BasStationPoint validateExit(String exitId, List positions) + { + if (!StringUtils.hasText(exitId)) { + throw new RuntimeException("请选择出库口"); + } + BasStationPoint point = stationPointService.selectBasStationPointByPointId(exitId); + if (point == null || !"03".equals(point.getWarehouseId())) { + throw new RuntimeException("出库口不存在或不属于03仓"); + } + if ((!"2".equals(point.getPointType()) && !"3".equals(point.getPointType())) + || !"0".equals(point.getStatus()) + || (!StringUtils.isEmpty(point.getPointStatus()) && !"0".equals(point.getPointStatus()))) { + throw new RuntimeException("当前出库口不可用"); + } + for (DenseLanePosition position : positions) { + String trayType = trayType(position.getTrayCode()); + if (StringUtils.hasText(trayType) && StringUtils.hasText(point.getTrayType()) + && !supportsTrayType(point.getTrayType(), trayType)) { + throw new RuntimeException("出库口不支持托盘类型" + trayType); + } + } + return point; + } + + private MydoJob buildJob(String jobId, String taskNo, DenseLanePosition position, + BasStationPoint exit) + { + MydoJob job = new MydoJob(); + job.setJobId(jobId); + job.setTaskNo(StringUtils.hasText(taskNo) ? taskNo : jobId); + job.setInOrOut(20L); + job.setStatus("1"); + job.setExecutionPhase("0"); + job.setTrayCode(position.getTrayCode()); + job.setWarehouseId("03"); + job.setCargoSpaceId(position.getCargoSpaceId()); + job.setCargoSpaceName(position.getCargoSpaceName()); + job.setExitId(exit.getPointId()); + job.setLineType("1"); + job.setPriority(10L); + job.setCreateTime(DateUtils.getNowDate()); + return job; + } + + private MydoJobDetail buildDetail(MydoJobDetail source, String jobId, int index) + { + MydoJobDetail detail = new MydoJobDetail(); + detail.setJobDetailId(jobId + "-" + index); + detail.setJobId(jobId); + detail.setStockId(source.getStockId()); + detail.setLotNo(source.getLotNo()); + detail.setInDate(source.getInDate()); + detail.setProductId(source.getProductId()); + detail.setProductName(source.getProductName()); + detail.setStockQuantity(source.getStockQuantity()); + detail.setOutQuantity(source.getStockQuantity()); + detail.setStockinId(source.getStockinId()); + detail.setStockinDetailId(source.getStockinDetailId()); + detail.setSupplierId(source.getSupplierId()); + detail.setSupplierName(source.getSupplierName()); + detail.setProjectNo(source.getProjectNo()); + return detail; + } + + private String previewToken(DenseLaneOutboundRequest request, List selected) + { + String payload = request.getWarehouseId() + "|" + request.getTheFloor() + "|" + + request.getGroupCoding() + "|" + request.getTrayCount() + "|" + + selected.stream().map(item -> item.getCargoSpaceId() + ":" + item.getTrayCode() + + "[" + item.getStockDetails().stream() + .map(detail -> String.valueOf(detail.getStockinDetailId()) + ":" + + String.valueOf(detail.getStockId()) + ":" + + String.valueOf(detail.getStockQuantity())) + .sorted() + .collect(Collectors.joining(";")) + "]") + .collect(Collectors.joining(",")); + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(payload.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new RuntimeException("生成通道出库预览签名失败", e); + } + } + + private String trayType(String trayCode) + { + return StringUtils.hasText(trayCode) ? trayCode.trim().substring(0, 1).toUpperCase() : null; + } + + private boolean supportsTrayType(String supported, String trayType) + { + for (String item : supported.split(",")) { + if (trayType.equalsIgnoreCase(item.trim())) { + return true; + } + } + return false; + } + + private void afterCommit(Runnable action) + { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() + { + @Override + public void afterCommit() + { + action.run(); + } + }); + } +} diff --git a/ruoyi-admin/src/main/resources/mapper/out/DenseLaneMapper.xml b/ruoyi-admin/src/main/resources/mapper/out/DenseLaneMapper.xml index c0a198a..c1b4c0c 100644 --- a/ruoyi-admin/src/main/resources/mapper/out/DenseLaneMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/out/DenseLaneMapper.xml @@ -185,6 +185,22 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" on area_config.warehouse_id = target_space.warehouse_id and area_config.cargo_area_code = target_space.this_cargo_area and area_config.is_active = '1' + cross apply + ( + select min(lane_boundary.the_column) as min_column, + max(lane_boundary.the_column) as max_column + from bas_cargo_space lane_boundary with (UPDLOCK,HOLDLOCK) + where lane_boundary.warehouse_id = target_space.warehouse_id + and lane_boundary.group_coding = target_space.group_coding + and lane_boundary.the_floor = target_space.the_floor + ) boundary + cross apply + ( + select case when target_space.the_column - boundary.min_column + <= boundary.max_column - target_space.the_column + then target_space.the_column - boundary.min_column + else boundary.max_column - target_space.the_column end as distance_to_edge + ) geometry where target_space.warehouse_id = #{warehouseId} and target_space.the_floor = #{theFloor} and target_space.group_coding != #{sourceGroupCoding} @@ -214,7 +230,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" case when area_config.direction_type = '2' then target_space.the_column end desc, case when area_config.direction_type = '3' - then abs(target_space.the_column - 1) end asc, + then geometry.distance_to_edge end asc, target_space.group_coding asc, target_space.cargo_space_id asc @@ -266,6 +282,108 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" order by lane.group_coding + + "); + + assertThat(summarySql) + .contains("min(cargo.the_column) over") + .contains("max(cargo.the_column) over") + .contains("distance_to_edge") + .doesNotContain("abs(position.the_column - 1)"); + assertThat(targetSql) + .contains("min(lane_boundary.the_column)") + .contains("max(lane_boundary.the_column)") + .contains("distance_to_edge") + .doesNotContain("abs(target_space.the_column - 1)"); + } + private Path resolveMapperXml() { return Stream.of( diff --git a/ruoyi-admin/src/test/java/com/ruoyi/out/dense/service/impl/DenseLaneOutboundServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/out/dense/service/impl/DenseLaneOutboundServiceImplTest.java new file mode 100644 index 0000000..b011548 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/out/dense/service/impl/DenseLaneOutboundServiceImplTest.java @@ -0,0 +1,260 @@ +package com.ruoyi.out.dense.service.impl; + +import java.math.BigDecimal; +import java.util.List; +import com.ruoyi.bas.cargospace.mapper.BasCargoSpaceMapper; +import com.ruoyi.bas.id.job.service.JobIdService; +import com.ruoyi.bas.id.taskno.service.TaskNoService; +import com.ruoyi.bas.stationpoint.domain.BasStationPoint; +import com.ruoyi.bas.stationpoint.service.IBasStationPointService; +import com.ruoyi.bas.stock.mapper.BasStockMapper; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.in.job.domain.MydoJob; +import com.ruoyi.in.job.service.IMydoJobService; +import com.ruoyi.in.jobdetail.domain.MydoJobDetail; +import com.ruoyi.in.jobdetail.service.IMydoJobDetailService; +import com.ruoyi.out.dense.domain.DenseLaneOutboundPreview; +import com.ruoyi.out.dense.domain.DenseLaneOutboundQuery; +import com.ruoyi.out.dense.domain.DenseLaneOutboundRequest; +import com.ruoyi.out.dense.domain.DenseLanePosition; +import com.ruoyi.out.dense.domain.DenseLaneSummary; +import com.ruoyi.out.dense.mapper.DenseLaneMapper; +import com.ruoyi.out.dense.service.IDenseLaneService; +import com.ruoyi.out.dispatch.domain.DispatchTrigger; +import com.ruoyi.out.dispatch.service.IOutboundDispatchService; +import com.ruoyi.out.location.mapper.LocationOutboundMapper; +import com.ruoyi.out.relocation.mapper.RelocationJobMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DenseLaneOutboundServiceImplTest +{ + private DenseLaneMapper denseLaneMapper; + private IDenseLaneService denseLaneService; + private RelocationJobMapper relocationJobMapper; + private IBasStationPointService stationPointService; + private IMydoJobService mydoJobService; + private IMydoJobDetailService mydoJobDetailService; + private LocationOutboundMapper locationOutboundMapper; + private BasStockMapper basStockMapper; + private BasCargoSpaceMapper cargoSpaceMapper; + private JobIdService jobIdService; + private TaskNoService taskNoService; + private IOutboundDispatchService dispatchService; + private DenseLaneOutboundServiceImpl service; + + @BeforeEach + void setUp() + { + denseLaneMapper = mock(DenseLaneMapper.class); + denseLaneService = mock(IDenseLaneService.class); + relocationJobMapper = mock(RelocationJobMapper.class); + stationPointService = mock(IBasStationPointService.class); + mydoJobService = mock(IMydoJobService.class); + mydoJobDetailService = mock(IMydoJobDetailService.class); + locationOutboundMapper = mock(LocationOutboundMapper.class); + basStockMapper = mock(BasStockMapper.class); + cargoSpaceMapper = mock(BasCargoSpaceMapper.class); + jobIdService = mock(JobIdService.class); + taskNoService = mock(TaskNoService.class); + dispatchService = mock(IOutboundDispatchService.class); + service = new DenseLaneOutboundServiceImpl(denseLaneMapper, denseLaneService, + relocationJobMapper, stationPointService, mydoJobService, mydoJobDetailService, + locationOutboundMapper, basStockMapper, cargoSpaceMapper, jobIdService, + taskNoService, dispatchService); + } + + @Test + void listOnlyAcceptsWarehouse03DenseLanes() + { + DenseLaneOutboundQuery query = new DenseLaneOutboundQuery(); + query.setWarehouseId("02"); + + assertThatThrownBy(() -> service.list(query)).hasMessageContaining("03仓"); + } + + @Test + void listReturnsFullLaneTotalsAndCurrentOuterTray() + { + DenseLaneOutboundQuery query = new DenseLaneOutboundQuery(); + query.setWarehouseId("03"); + DenseLaneSummary summary = new DenseLaneSummary(); + summary.setGroupCoding("G-01"); + summary.setFullTrayCount(3); + summary.setAvailableTrayCount(2); + summary.setTotalQuantity(new BigDecimal("40")); + summary.setOutermostTrayCode("A001"); + when(denseLaneMapper.selectLaneSummaries(query)).thenReturn(List.of(summary)); + + assertThat(service.list(query)).containsExactly(summary); + } + + @Test + void previewSelectsExactlyOutermostNUnreservedTrays() + { + DenseLaneOutboundRequest request = request(2); + List positions = List.of(position("CS-2", 2L, "A002"), + position("CS-3", 3L, "A003"), position("CS-1", 1L, "A001")); + when(relocationJobMapper.selectRelocationSourcesForUpdate(any())).thenReturn(positions); + when(denseLaneService.orderOutermostFirst(positions)) + .thenReturn(List.of(positions.get(1), positions.get(0), positions.get(2))); + when(relocationJobMapper.sumTrayStockQuantity(any(String[].class))) + .thenReturn(new BigDecimal("25")); + + DenseLaneOutboundPreview preview = service.preview(request); + + assertThat(preview.getTrays()).extracting(DenseLanePosition::getTrayCode) + .containsExactly("A003", "A002"); + assertThat(preview.getTotalQuantity()).isEqualByComparingTo("25"); + assertThat(preview.getPreviewToken()).isNotBlank(); + } + + @Test + void previewIncludesCompleteInventoryDetailsForEverySelectedTray() + { + DenseLaneOutboundRequest request = request(1); + DenseLanePosition position = position("CS-1", 1L, "A001"); + List details = List.of( + detail("STOCK-1", "D-1", "P-1", "7"), + detail("STOCK-1", "D-2", "P-2", "5")); + when(relocationJobMapper.selectRelocationSourcesForUpdate(any())).thenReturn(List.of(position)); + when(denseLaneService.orderOutermostFirst(any())).thenReturn(List.of(position)); + when(relocationJobMapper.selectStockDetailsForUpdate("CS-1", "A001")).thenReturn(details); + when(relocationJobMapper.sumTrayStockQuantity(any(String[].class))) + .thenReturn(new BigDecimal("12")); + + DenseLaneOutboundPreview preview = service.preview(request); + + assertThat(preview.getTrays()).singleElement().satisfies(tray -> + assertThat(tray.getStockDetails()).containsExactlyElementsOf(details)); + } + + @Test + void previewRejectsQuantityOverAvailableCapacity() + { + DenseLaneOutboundRequest request = request(2); + when(relocationJobMapper.selectRelocationSourcesForUpdate(any())) + .thenReturn(List.of(position("CS-1", 1L, "A001"))); + + assertThatThrownBy(() -> service.preview(request)).hasMessageContaining("可出库托盘数量不足"); + } + + @Test + void previewTokenChangesWhenSelectedTrayInventoryChanges() + { + DenseLaneOutboundRequest request = request(1); + DenseLanePosition position = position("CS-1", 1L, "A001"); + when(relocationJobMapper.selectRelocationSourcesForUpdate(any())).thenReturn(List.of(position)); + when(denseLaneService.orderOutermostFirst(any())).thenReturn(List.of(position)); + when(relocationJobMapper.sumTrayStockQuantity(any(String[].class))).thenReturn(BigDecimal.TEN); + when(relocationJobMapper.selectStockDetailsForUpdate("CS-1", "A001")) + .thenReturn(List.of(detail("STOCK-1", "D-1", "P-1", "10"))) + .thenReturn(List.of(detail("STOCK-1", "D-1", "P-1", "9"))); + + String firstToken = service.preview(request).getPreviewToken(); + String changedToken = service.preview(request).getPreviewToken(); + + assertThat(changedToken).isNotEqualTo(firstToken); + } + + @Test + void generationRejectsStalePreview() + { + DenseLaneOutboundRequest request = request(1); + DenseLanePosition first = position("CS-1", 1L, "A001"); + when(relocationJobMapper.selectRelocationSourcesForUpdate(any())).thenReturn(List.of(first)); + when(denseLaneService.orderOutermostFirst(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(relocationJobMapper.sumTrayStockQuantity(any(String[].class))).thenReturn(BigDecimal.TEN); + request.setPreviewToken(service.preview(request).getPreviewToken()); + DenseLanePosition changed = position("CS-2", 2L, "A002"); + when(relocationJobMapper.selectRelocationSourcesForUpdate(any())).thenReturn(List.of(changed)); + + assertThatThrownBy(() -> service.generate(request)).hasMessageContaining("预览已失效"); + } + + @Test + void generationValidatesExitAndCreatesCompleteType20Jobs() + { + DenseLaneOutboundRequest request = request(1); + DenseLanePosition position = position("CS-1", 1L, "A001"); + when(relocationJobMapper.selectRelocationSourcesForUpdate(any())).thenReturn(List.of(position)); + when(denseLaneService.orderOutermostFirst(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(relocationJobMapper.sumTrayStockQuantity(any(String[].class))).thenReturn(new BigDecimal("12")); + List stockDetails = List.of(detail("STOCK-1", "D-1", "P-1", "7"), + detail("STOCK-1", "D-2", "P-2", "5")); + when(relocationJobMapper.selectStockDetailsForUpdate("CS-1", "A001")) + .thenReturn(stockDetails); + request.setPreviewToken(service.preview(request).getPreviewToken()); + BasStationPoint exit = new BasStationPoint(); + exit.setPointId("OUT-1"); + exit.setWarehouseId("03"); + exit.setPointType("2"); + exit.setStatus("0"); + exit.setPointStatus("0"); + exit.setTrayType("A,B"); + when(stationPointService.selectBasStationPointByPointId("OUT-1")).thenReturn(exit); + when(jobIdService.getId("CK")).thenReturn("OUT-JOB-1"); + when(taskNoService.getTaskNo("20")).thenReturn("TASK-20-1"); + when(mydoJobService.insertMydoJob(any(MydoJob.class))).thenReturn(1); + when(mydoJobDetailService.insertMydoJobDetail(any(MydoJobDetail.class))).thenReturn(1); + when(locationOutboundMapper.reserveStockDetailForOutbound(any())).thenReturn(1); + when(cargoSpaceMapper.markOutboundAssignedByCargoSpaceId("CS-1")).thenReturn(1); + when(dispatchService.dispatch("OUT-JOB-1", DispatchTrigger.INITIAL)) + .thenReturn(AjaxResult.success()); + + List jobIds = service.generate(request); + + assertThat(jobIds).containsExactly("OUT-JOB-1"); + ArgumentCaptor jobCaptor = ArgumentCaptor.forClass(MydoJob.class); + verify(mydoJobService).insertMydoJob(jobCaptor.capture()); + assertThat(jobCaptor.getValue().getInOrOut()).isEqualTo(20L); + assertThat(jobCaptor.getValue().getTrayCode()).isEqualTo("A001"); + verify(mydoJobDetailService, org.mockito.Mockito.times(2)) + .insertMydoJobDetail(any(MydoJobDetail.class)); + verify(dispatchService).dispatch("OUT-JOB-1", DispatchTrigger.INITIAL); + } + + private DenseLaneOutboundRequest request(int count) + { + DenseLaneOutboundRequest request = new DenseLaneOutboundRequest(); + request.setWarehouseId("03"); + request.setTheFloor(1L); + request.setGroupCoding("G-01"); + request.setTrayCount(count); + request.setExitId("OUT-1"); + return request; + } + + private DenseLanePosition position(String cargoSpaceId, Long column, String trayCode) + { + DenseLanePosition position = new DenseLanePosition(); + position.setCargoSpaceId(cargoSpaceId); + position.setCargoSpaceName(cargoSpaceId); + position.setWarehouseId("03"); + position.setGroupCoding("G-01"); + position.setTheFloor(1L); + position.setTheColumn(column); + position.setTrayCode(trayCode); + position.setStockQuantity(BigDecimal.TEN); + return position; + } + + private MydoJobDetail detail(String stockId, String detailId, String productId, String quantity) + { + MydoJobDetail detail = new MydoJobDetail(); + detail.setStockId(stockId); + detail.setStockinDetailId(detailId); + detail.setProductId(productId); + detail.setProductName(productId); + detail.setStockQuantity(new BigDecimal(quantity)); + return detail; + } +} diff --git a/ruoyi-ui/src/api/out/denseLaneOutbound.js b/ruoyi-ui/src/api/out/denseLaneOutbound.js new file mode 100644 index 0000000..28117c0 --- /dev/null +++ b/ruoyi-ui/src/api/out/denseLaneOutbound.js @@ -0,0 +1,28 @@ +import request from '@/utils/request' + +// 查询03仓密集库通道整托库存汇总 +export function listDenseLaneOutbound(query) { + return request({ + url: '/out/denseLaneOutbound/list', + method: 'get', + params: query + }) +} + +// 预览指定通道当前最外侧托盘 +export function previewDenseLaneOutbound(query) { + return request({ + url: '/out/denseLaneOutbound/preview', + method: 'get', + params: query + }) +} + +// 按预览结果生成整托出库作业 +export function generateDenseLaneOutbound(data) { + return request({ + url: '/out/denseLaneOutbound/generate', + method: 'post', + data: data + }) +} diff --git a/ruoyi-ui/src/views/out/denseLaneOutbound/index.vue b/ruoyi-ui/src/views/out/denseLaneOutbound/index.vue new file mode 100644 index 0000000..c0d588f --- /dev/null +++ b/ruoyi-ui/src/views/out/denseLaneOutbound/index.vue @@ -0,0 +1,553 @@ + + + + +