feat: add dense lane outbound workflow

This commit is contained in:
zgw
2026-07-16 10:54:21 +08:00
parent dd82132205
commit 4641bddcea
15 changed files with 1481 additions and 1 deletions

View File

@@ -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));
}
}

View File

@@ -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<DenseLanePosition> trays = new ArrayList<>();
private BigDecimal totalQuantity = BigDecimal.ZERO;
private String previewToken;
public List<DenseLanePosition> getTrays() { return trays; }
public void setTrays(List<DenseLanePosition> 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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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<MydoJobDetail> 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<MydoJobDetail> getStockDetails() { return stockDetails; }
public void setStockDetails(List<MydoJobDetail> stockDetails) { this.stockDetails = stockDetails; }
}

View File

@@ -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; }
}

View File

@@ -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<DenseLaneOption> selectLaneOptions(@Param("warehouseId") String warehouseId,
@Param("theFloor") Long theFloor);
List<DenseLaneSummary> selectLaneSummaries(DenseLaneOutboundQuery query);
}

View File

@@ -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<DenseLaneSummary> list(DenseLaneOutboundQuery query);
DenseLaneOutboundPreview preview(DenseLaneOutboundRequest request);
List<String> generate(DenseLaneOutboundRequest request);
}

View File

@@ -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<DenseLaneSummary> list(DenseLaneOutboundQuery query)
{
if (query == null) {
query = new DenseLaneOutboundQuery();
}
if (!"03".equals(query.getWarehouseId())) {
throw new RuntimeException("指定通道出库仅支持03仓密集库");
}
List<DenseLaneSummary> 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<String> 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<String> jobIds = new ArrayList<>();
for (DenseLanePosition position : current.getTrays()) {
List<MydoJobDetail> 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<String, Object> 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<DenseLanePosition> available = relocationJobMapper.selectRelocationSourcesForUpdate(sourceQuery);
if (available == null || available.size() < request.getTrayCount()) {
throw new RuntimeException("当前通道可出库托盘数量不足");
}
List<DenseLanePosition> selected = denseLaneService.orderOutermostFirst(available)
.stream().limit(request.getTrayCount()).collect(Collectors.toList());
selected.forEach(position -> {
List<MydoJobDetail> 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<DenseLanePosition> 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<DenseLanePosition> 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();
}
});
}
}

View File

@@ -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
&lt;= 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
</select>
@@ -266,6 +282,108 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by lane.group_coding
</select>
<select id="selectLaneSummaries" resultType="com.ruoyi.out.dense.domain.DenseLaneSummary">
;with lane_positions as
(
select cargo.cargo_space_id,
cargo.warehouse_id,
cargo.group_coding,
cargo.this_cargo_area,
cargo.the_platoon,
cargo.the_column,
cargo.the_floor,
cargo.status,
cargo.tray_code,
isnull(config.direction_type, '1') as direction_type,
min(cargo.the_column) over
(partition by cargo.group_coding, cargo.the_floor) as min_column,
max(cargo.the_column) over
(partition by cargo.group_coding, cargo.the_floor) as max_column,
case when exists
(
select 1 from mydo_job active_job
where active_job.status in ('1', '2', '3')
and active_job.in_or_out &gt;= 20
and (active_job.cargo_space_id = cargo.cargo_space_id
or active_job.tray_code = cargo.tray_code)
) then 1 else 0 end as has_active_job
from bas_cargo_space cargo
left join bas_cargo_area_config config
on config.warehouse_id = cargo.warehouse_id
and config.cargo_area_code = cargo.this_cargo_area
and config.is_active = '1'
where cargo.warehouse_id = #{warehouseId}
and cargo.group_coding is not null
and ltrim(rtrim(cargo.group_coding)) != ''
<if test="theFloor != null">
and cargo.the_floor = #{theFloor}
</if>
<if test="groupCoding != null and groupCoding != ''">
and cargo.group_coding like concat('%', #{groupCoding}, '%')
</if>
), lane_totals as
(
select group_coding,
the_floor,
min(this_cargo_area) as cargo_area,
min(the_platoon) as the_platoon,
min(direction_type) as direction_type,
sum(case when status = '2' and tray_code is not null then 1 else 0 end) as full_tray_count,
sum(case when status = '2' and tray_code is not null
and has_active_job = 0 then 1 else 0 end) as available_tray_count
from lane_positions
group by group_coding, the_floor
), stock_totals as
(
select position.group_coding,
position.the_floor,
sum(isnull(detail.quantity, 0)) as total_quantity
from lane_positions position
inner join bas_stock stock on stock.cargo_space_id = position.cargo_space_id
inner join bas_stock_detail detail on detail.stock_id = stock.stock_id
group by position.group_coding, position.the_floor
)
select totals.group_coding,
totals.cargo_area,
totals.the_platoon,
totals.the_floor,
totals.direction_type,
totals.full_tray_count,
totals.available_tray_count,
isnull(stock_totals.total_quantity, 0) as total_quantity,
outer_position.tray_code as outermost_tray_code,
outer_position.cargo_space_id as outermost_cargo_space_id
from lane_totals totals
left join stock_totals
on stock_totals.group_coding = totals.group_coding
and stock_totals.the_floor = totals.the_floor
outer apply
(
select top 1 position.tray_code,
position.cargo_space_id,
case when position.the_column - position.min_column
&lt;= position.max_column - position.the_column
then position.the_column - position.min_column
else position.max_column - position.the_column end as distance_to_edge
from lane_positions position
where position.group_coding = totals.group_coding
and position.the_floor = totals.the_floor
and position.status = '2'
and position.tray_code is not null
and position.has_active_job = 0
order by case when totals.direction_type = '1' then position.the_column end desc,
case when totals.direction_type = '2' then position.the_column end asc,
case when totals.direction_type = '3' then
case when position.the_column - position.min_column
&lt;= position.max_column - position.the_column
then position.the_column - position.min_column
else position.max_column - position.the_column end end asc,
position.the_column asc
) outer_position
where totals.full_tray_count &gt; 0
order by totals.the_floor, totals.group_coding
</select>
<select id="isCurrentOutermost" resultType="java.lang.Integer">
select case
when source_space.warehouse_id != '03'

View File

@@ -31,6 +31,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where stock.cargo_space_id = #{cargoSpaceId}
and stock.tray_code = #{trayCode}
and isnull(detail.quantity, 0) &gt; 0
order by detail.detail_id
</select>
<select id="selectRelocationSourcesForUpdate"

View File

@@ -63,6 +63,25 @@ class DenseLaneMapperSqlTest
.contains("not exists");
}
@Test
void directionThreeOrderingUsesActualLaneBoundaries() throws Exception
{
String xml = Files.readString(resolveMapperXml());
String summarySql = section(xml, "<select id=\"selectLaneSummaries\"", "</select>");
String targetSql = section(xml, "<select id=\"selectRelocationTargetForUpdate\"", "</select>");
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(

View File

@@ -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<DenseLanePosition> 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<MydoJobDetail> 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<MydoJobDetail> 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<String> jobIds = service.generate(request);
assertThat(jobIds).containsExactly("OUT-JOB-1");
ArgumentCaptor<MydoJob> 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;
}
}

View File

@@ -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
})
}

View File

@@ -0,0 +1,553 @@
<template>
<div class="app-container dense-lane-outbound">
<el-form
ref="queryForm"
:model="queryParams"
size="small"
:inline="true"
v-show="showSearch"
label-width="76px"
>
<el-form-item label="仓库">
<el-input value="03仓密集库" disabled class="warehouse-input" />
</el-form-item>
<el-form-item label="通道编码" prop="groupCoding">
<el-input
v-model="queryParams.groupCoding"
placeholder="请输入通道组编码"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="层" prop="theFloor">
<el-input-number
v-model="queryParams.theFloor"
:min="1"
:precision="0"
controls-position="right"
placeholder="全部"
class="floor-input"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-alert
title="仅显示03仓有托盘的密集通道;生成时严格按当前端头顺序选择整托。"
type="info"
:closable="false"
show-icon
class="lane-hint"
/>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table
v-loading="loading"
:data="laneList"
row-key="laneKey"
border
stripe
@expand-change="handleExpandChange"
>
<el-table-column type="expand" width="44">
<template slot-scope="scope">
<div class="inventory-expand" v-loading="scope.row.inventoryLoading">
<div class="inventory-heading">
<span>当前最外侧托盘库存</span>
<span class="inventory-subtitle">
{{ scope.row.outermostTrayCode || '-' }} / {{ scope.row.outermostCargoSpaceId || '-' }}
</span>
</div>
<el-table :data="scope.row.inventoryDetails || []" size="mini" border max-height="260">
<el-table-column label="项目号" prop="projectNo" min-width="120" show-overflow-tooltip />
<el-table-column label="物料编码" prop="productId" min-width="130" show-overflow-tooltip />
<el-table-column label="物料名称" prop="productName" min-width="150" show-overflow-tooltip />
<el-table-column label="批次" prop="lotNo" min-width="110" show-overflow-tooltip />
<el-table-column label="库存数量" prop="stockQuantity" width="110" align="right" />
<el-table-column label="入库日期" prop="inDate" width="160" />
<template slot="empty">
<div class="empty-detail">{{ scope.row.inventoryLoaded ? '当前端头托盘无库存明细' : '正在读取库存明细' }}</div>
</template>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column label="通道组编码" prop="groupCoding" min-width="140" fixed="left" show-overflow-tooltip />
<el-table-column label="货区" prop="cargoArea" width="90" align="center" />
<el-table-column label="排" prop="thePlatoon" width="72" align="center" />
<el-table-column label="层" prop="theFloor" width="72" align="center" />
<el-table-column label="出库方向" prop="directionType" width="110" align="center">
<template slot-scope="scope">
<el-tag size="mini" effect="plain">{{ directionLabel(scope.row.directionType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="满托盘数" prop="fullTrayCount" width="105" align="right" />
<el-table-column label="可出库托盘" prop="availableTrayCount" width="120" align="right">
<template slot-scope="scope">
<span :class="{ 'available-count': Number(scope.row.availableTrayCount) > 0 }">
{{ scope.row.availableTrayCount || 0 }}
</span>
</template>
</el-table-column>
<el-table-column label="总库存" prop="totalQuantity" min-width="110" align="right" />
<el-table-column label="当前最外侧托盘" prop="outermostTrayCode" min-width="145" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.outermostTrayCode || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="端头货位" prop="outermostCargoSpaceId" min-width="130" show-overflow-tooltip />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-s-promotion"
:disabled="Number(scope.row.availableTrayCount || 0) < 1"
v-hasPermi="['out:denseLaneOutbound:generate']"
@click="openGenerateDialog(scope.row)"
>整托出库</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
title="指定通道整托出库"
:visible.sync="dialogVisible"
width="920px"
append-to-body
:close-on-click-modal="false"
@closed="resetDialog"
>
<el-descriptions v-if="currentLane" :column="4" size="small" border class="lane-summary">
<el-descriptions-item label="通道">{{ currentLane.groupCoding }}</el-descriptions-item>
<el-descriptions-item label="货区 / 排">{{ currentLane.cargoArea || '-' }} / {{ currentLane.thePlatoon || '-' }}</el-descriptions-item>
<el-descriptions-item label="层">{{ currentLane.theFloor }}</el-descriptions-item>
<el-descriptions-item label="可出库">{{ currentLane.availableTrayCount || 0 }} 托</el-descriptions-item>
</el-descriptions>
<el-form :model="generateForm" size="small" :inline="true" label-width="86px" class="generate-form">
<el-form-item label="整托数量" required>
<el-input-number
v-model="generateForm.trayCount"
:min="1"
:max="currentLane ? Number(currentLane.availableTrayCount || 1) : 1"
:precision="0"
controls-position="right"
@change="handleTrayCountChange"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
plain
icon="el-icon-view"
:loading="previewLoading"
v-hasPermi="['out:denseLaneOutbound:preview']"
@click="loadPreview"
>刷新预览</el-button>
</el-form-item>
<el-form-item label="出库口" required class="exit-form-item">
<el-select
v-model="generateForm.exitId"
placeholder="请选择与托盘类型匹配的出库口"
filterable
clearable
:loading="exitLoading"
class="exit-select"
>
<el-option
v-for="point in compatibleExitOptions"
:key="point.pointId"
:label="formatExitLabel(point)"
:value="point.pointId"
/>
</el-select>
</el-form-item>
</el-form>
<div class="preview-header">
<div>
<span class="preview-title">本次准确出库托盘</span>
<span class="preview-note">提交前会再次校验端头顺序和库存</span>
</div>
<div class="preview-total">合计库存:<strong>{{ previewData.totalQuantity || 0 }}</strong></div>
</div>
<el-table
v-loading="previewLoading"
:data="previewData.trays || []"
row-key="cargoSpaceId"
border
max-height="390"
>
<el-table-column type="expand" width="42">
<template slot-scope="scope">
<el-table :data="scope.row.stockDetails || []" size="mini" border class="preview-detail-table">
<el-table-column label="项目号" prop="projectNo" min-width="110" show-overflow-tooltip />
<el-table-column label="物料编码" prop="productId" min-width="120" show-overflow-tooltip />
<el-table-column label="物料名称" prop="productName" min-width="140" show-overflow-tooltip />
<el-table-column label="批次" prop="lotNo" min-width="100" show-overflow-tooltip />
<el-table-column label="库存数量" prop="stockQuantity" width="105" align="right" />
</el-table>
</template>
</el-table-column>
<el-table-column label="顺序" type="index" width="62" align="center" />
<el-table-column label="托盘号" prop="trayCode" min-width="140" show-overflow-tooltip />
<el-table-column label="货位编码" prop="cargoSpaceId" min-width="140" show-overflow-tooltip />
<el-table-column label="货位名称" prop="cargoSpaceName" min-width="140" show-overflow-tooltip />
<el-table-column label="列" prop="theColumn" width="72" align="center" />
<el-table-column label="库存数量" prop="stockQuantity" width="115" align="right" />
</el-table>
<div slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="generating"
:disabled="!canGenerate"
@click="confirmGenerate"
>确认生成 {{ generateForm.trayCount || 0 }} 托出库作业</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listDenseLaneOutbound,
previewDenseLaneOutbound,
generateDenseLaneOutbound
} from '@/api/out/denseLaneOutbound'
import { listStationPoint } from '@/api/bas/stationpoint'
const emptyPreview = () => ({ trays: [], totalQuantity: 0, previewToken: null })
export default {
name: 'DenseLaneOutbound',
data() {
return {
loading: false,
showSearch: true,
laneList: [],
queryParams: {
warehouseId: '03',
groupCoding: null,
theFloor: null
},
dialogVisible: false,
currentLane: null,
previewLoading: false,
generating: false,
exitLoading: false,
exitOptions: [],
previewData: emptyPreview(),
generateForm: {
warehouseId: '03',
groupCoding: null,
theFloor: null,
trayCount: 1,
exitId: null,
previewToken: null
}
}
},
computed: {
compatibleExitOptions() {
const trayTypes = Array.from(new Set((this.previewData.trays || [])
.map(tray => this.trayType(tray.trayCode))
.filter(Boolean)))
return this.exitOptions.filter(point => trayTypes.every(type => this.pointSupportsTrayType(point, type)))
},
canGenerate() {
return Boolean(
this.previewData.previewToken &&
this.generateForm.exitId &&
(this.previewData.trays || []).length === Number(this.generateForm.trayCount)
)
}
},
created() {
this.getList()
},
methods: {
laneKey(row) {
return `${row.groupCoding}-${row.theFloor}`
},
getList() {
this.loading = true
listDenseLaneOutbound(this.queryParams).then(response => {
this.laneList = (response.data || []).map(row => ({
...row,
inventoryLoading: false,
inventoryLoaded: false,
inventoryDetails: []
}))
this.loading = false
}).catch(() => {
this.loading = false
})
},
handleQuery() {
this.getList()
},
resetQuery() {
this.resetForm('queryForm')
this.queryParams.warehouseId = '03'
this.getList()
},
handleExpandChange(row, expandedRows) {
const expanded = expandedRows.some(item => this.laneKey(item) === this.laneKey(row))
if (!expanded || row.inventoryLoaded || row.inventoryLoading || !row.outermostTrayCode) {
return
}
this.$set(row, 'inventoryLoading', true)
previewDenseLaneOutbound(this.previewParams(row, 1)).then(response => {
const tray = ((response.data || {}).trays || [])[0]
this.$set(row, 'inventoryDetails', tray ? (tray.stockDetails || []) : [])
this.$set(row, 'inventoryLoaded', true)
this.$set(row, 'inventoryLoading', false)
}).catch(() => {
this.$set(row, 'inventoryLoading', false)
})
},
openGenerateDialog(row) {
if (Number(row.availableTrayCount || 0) < 1) {
this.$modal.msgWarning('当前通道没有可出库的端头托盘')
return
}
this.currentLane = row
this.generateForm = {
warehouseId: '03',
groupCoding: row.groupCoding,
theFloor: row.theFloor,
trayCount: 1,
exitId: null,
previewToken: null
}
this.previewData = emptyPreview()
this.dialogVisible = true
this.loadExitOptions()
this.loadPreview()
},
resetDialog() {
this.currentLane = null
this.previewData = emptyPreview()
this.exitOptions = []
this.generateForm.previewToken = null
},
handleTrayCountChange() {
this.previewData = emptyPreview()
this.generateForm.previewToken = null
this.generateForm.exitId = null
},
previewParams(row, trayCount) {
return {
warehouseId: '03',
groupCoding: row.groupCoding,
theFloor: row.theFloor,
trayCount: trayCount
}
},
loadPreview() {
if (!this.currentLane || !this.generateForm.trayCount) {
return
}
this.previewLoading = true
this.generateForm.exitId = null
previewDenseLaneOutbound(this.previewParams(this.currentLane, this.generateForm.trayCount)).then(response => {
this.previewData = response.data || emptyPreview()
this.generateForm.previewToken = this.previewData.previewToken
this.selectOnlyCompatibleExit()
this.previewLoading = false
}).catch(() => {
this.previewData = emptyPreview()
this.generateForm.previewToken = null
this.previewLoading = false
})
},
loadExitOptions() {
this.exitLoading = true
listStationPoint({ warehouseId: '03', status: '0' }).then(response => {
const rows = response.rows || response.data || []
this.exitOptions = rows.filter(point => ['2', '3'].includes(String(point.pointType)) &&
String(point.pointStatus || '0') === '0')
this.selectOnlyCompatibleExit()
this.exitLoading = false
}).catch(() => {
this.exitOptions = []
this.exitLoading = false
})
},
selectOnlyCompatibleExit() {
this.$nextTick(() => {
if (this.compatibleExitOptions.length === 1) {
this.generateForm.exitId = this.compatibleExitOptions[0].pointId
}
})
},
trayType(trayCode) {
return trayCode ? String(trayCode).trim().slice(0, 1).toUpperCase() : null
},
pointSupportsTrayType(point, trayType) {
if (!trayType || !point.trayType) {
return true
}
return String(point.trayType).split(',').map(item => item.trim().toUpperCase()).includes(trayType)
},
formatExitLabel(point) {
const typeName = String(point.pointType) === '3' ? '入出库' : '出库'
const trayType = point.trayType ? `,${point.trayType}类托盘` : ''
return `${point.pointName || point.pointId}(${point.pointId},${typeName}${trayType})`
},
directionLabel(directionType) {
if (String(directionType) === '1') return '大列号端'
if (String(directionType) === '2') return '小列号端'
if (String(directionType) === '3') return '最近端头'
return '-'
},
confirmGenerate() {
if (!this.canGenerate) {
this.$modal.msgWarning('请先刷新预览并选择匹配的出库口')
return
}
const trays = (this.previewData.trays || []).map(item => item.trayCode).join('、')
this.$modal.confirm(`确认按当前端头顺序生成 ${this.generateForm.trayCount} 托出库作业?托盘:${trays}`).then(() => {
this.generating = true
return generateDenseLaneOutbound({
...this.generateForm,
previewToken: this.previewData.previewToken
})
}).then(response => {
const ids = response.data || []
this.$modal.msgSuccess(`已生成 ${ids.length} 条出库作业`)
this.generating = false
this.dialogVisible = false
this.getList()
}).catch(error => {
this.generating = false
if (error && error.message && error.message.includes('预览已失效')) {
this.$modal.msgWarning('端头托盘或库存已变化,已为你刷新预览,请重新确认')
this.loadPreview()
}
})
}
}
}
</script>
<style scoped>
.dense-lane-outbound .warehouse-input {
width: 150px;
}
.dense-lane-outbound .floor-input {
width: 128px;
}
.lane-hint {
width: auto;
padding: 5px 12px;
}
.available-count {
color: #1890ff;
font-weight: 600;
}
.inventory-expand {
padding: 8px 18px 14px 58px;
background: #f8fafc;
}
.inventory-heading,
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
color: #303133;
font-size: 14px;
font-weight: 600;
}
.inventory-subtitle,
.preview-note {
margin-left: 10px;
color: #909399;
font-size: 12px;
font-weight: 400;
}
.empty-detail {
color: #909399;
}
.lane-summary {
margin-bottom: 16px;
}
.generate-form {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 12px 12px 0;
margin-bottom: 14px;
border: 1px solid #ebeef5;
background: #f8fafc;
}
.exit-form-item {
flex: 1;
min-width: 360px;
}
.exit-select {
width: 100%;
}
.preview-title {
color: #303133;
}
.preview-total {
color: #606266;
font-weight: 400;
}
.preview-total strong {
color: #1890ff;
font-size: 16px;
}
.preview-detail-table {
width: calc(100% - 48px);
margin: 4px 18px 10px 48px;
}
@media (max-width: 768px) {
.inventory-expand {
padding-left: 10px;
}
.generate-form {
display: block;
}
.exit-form-item {
min-width: 100%;
}
.preview-header {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
}
</style>