提交
This commit is contained in:
@@ -100,8 +100,8 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
WHERE departure.container_booking_id=b.id
|
||||
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
|
||||
AND departure.status='WAITING_FOR_LOADING')
|
||||
AND ((#{query.scope}='ACTIVE' AND b.actual_empty_reported_at IS NULL)
|
||||
OR (#{query.scope}='HISTORY' AND b.actual_empty_reported_at IS NOT NULL))))
|
||||
AND ((#{query.scope}='ACTIVE' AND NOT EXISTS (SELECT 1 FROM fl_terminal_drayage returned WHERE returned.container_booking_id=b.id AND returned.actual_empty_return_at IS NOT NULL UNION ALL SELECT 1 FROM fl_carrier_schedule_node_fact returned_node WHERE returned_node.container_booking_id=b.id AND returned_node.standard_node='EMPTY_RETURNED' AND returned_node.actual_at IS NOT NULL))
|
||||
OR (#{query.scope}='HISTORY' AND EXISTS (SELECT 1 FROM fl_terminal_drayage returned WHERE returned.container_booking_id=b.id AND returned.actual_empty_return_at IS NOT NULL UNION ALL SELECT 1 FROM fl_carrier_schedule_node_fact returned_node WHERE returned_node.container_booking_id=b.id AND returned_node.standard_node='EMPTY_RETURNED' AND returned_node.actual_at IS NOT NULL)))))
|
||||
AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.bill_of_lading_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
|
||||
@@ -379,6 +379,26 @@ public interface DropshipPalletizationMapper {
|
||||
ORDER BY inbound_detail_id
|
||||
""") List<PalletLine> sealedPalletLines(@Param("palletId") long palletId);
|
||||
|
||||
/** 未装托盘标签按物理箱身份转移,保留原箱码不重新编号。 */
|
||||
@Update("""
|
||||
<script>UPDATE fl_inbound_carton_label SET inbound_id=#{child},order_id=#{order},inbound_code=#{code},
|
||||
total_carton_count=#{packages},detail_package_count=LEAST(detail_package_count,#{packages})
|
||||
WHERE tenant_id=#{tenant} AND inbound_id=#{source} AND batch_status='ACTIVE'
|
||||
AND id IN (SELECT c.carton_label_id FROM fl_dropship_pallet_carton c
|
||||
WHERE c.tenant_id=#{tenant} AND c.inbound_id=#{source} AND c.pallet_id IS NOT NULL
|
||||
AND c.pallet_id NOT IN <foreach collection="picked" item="id" open="(" separator="," close=")">#{id}</foreach>)
|
||||
</script>""")
|
||||
int transferRemainderLabels(@Param("tenant") long tenant,@Param("source") long source,@Param("child") long child,
|
||||
@Param("order") long order,@Param("code") String code,@Param("packages") int packages,@Param("picked") List<Long> picked);
|
||||
/** 实箱随余量单转移,已装托与跨客户托内其他成员保持原归属。 */
|
||||
@Update("""
|
||||
<script>UPDATE fl_dropship_pallet_carton SET inbound_id=#{child}
|
||||
WHERE tenant_id=#{tenant} AND inbound_id=#{source} AND pallet_id IS NOT NULL
|
||||
AND pallet_id NOT IN <foreach collection="picked" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</script>""")
|
||||
int transferRemainderCartons(@Param("tenant") long tenant,@Param("source") long source,
|
||||
@Param("child") long child,@Param("picked") List<Long> picked);
|
||||
|
||||
@Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,expectedPalletCount,version; }
|
||||
@Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; }
|
||||
@Getter @Setter class PlanPalletMemberRow { private Long palletId,accountId,inboundId,orderId; private String orderNo,inboundCode,customerName,orderStatusSnapshot; private Integer availablePackageCount; private LocalDateTime expectedArrivalAt; }
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
|
||||
/** 分托装柜余量的实箱归属迁移。 */
|
||||
@Repository
|
||||
public class DropshipSplitRepository {
|
||||
private final DropshipPalletizationMapper mapper;
|
||||
/** 复用一件代发实箱账本,不另建库存。 */
|
||||
public DropshipSplitRepository(DropshipPalletizationMapper mapper) { this.mapper = mapper; }
|
||||
/** 排除本柜已拣托盘,逐箱迁移到余量入仓单;数量不一致回滚本地结算。 */
|
||||
public void transferRemainder(long tenant, long source, long child, long order, String code,
|
||||
int packages, List<Long> pickedPallets) {
|
||||
if (pickedPallets.isEmpty()) throw new BizException(ResultCode.CONFLICT, "缺少已装整托事实");
|
||||
if (mapper.transferRemainderLabels(tenant, source, child, order, code, packages, pickedPallets) != packages
|
||||
|| mapper.transferRemainderCartons(tenant, source, child, pickedPallets) != packages)
|
||||
throw new BizException(ResultCode.CONFLICT, "未装托盘箱数与余量不一致,请刷新后重试");
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,8 @@ public class PdaPickSettlementService {
|
||||
private InboundHoldGuard inboundHoldGuard;
|
||||
@Resource
|
||||
private DropshipPalletizationMapper dropshipPallets;
|
||||
@Resource
|
||||
private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits;
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String internalToken;
|
||||
|
||||
@@ -121,6 +123,7 @@ public class PdaPickSettlementService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 按柜次实拣数量结算,一件代发保留未装整托供后续柜次使用。 */
|
||||
private boolean settleInbound(long parentInboundId, int pickedPackageCount,
|
||||
List<LoadingPickItemEntity> taskItems, long taskId,
|
||||
long operatorId, String operatorName) {
|
||||
@@ -184,9 +187,7 @@ public class PdaPickSettlementService {
|
||||
&& pickedByDetail.values().stream().mapToInt(Integer::intValue).sum() != pickedPackageCount) {
|
||||
throw conflict("PDA 已拣明细与入库单已拣箱数不一致");
|
||||
}
|
||||
if (dropshipWholePallet && childPackageCount != 0) {
|
||||
throw conflict("一件代发现场托盘必须整票装柜,不能生成余量分片");
|
||||
}
|
||||
|
||||
List<MeasurementSplitLine> measurementLines = dropshipWholePallet
|
||||
? wholePalletMeasurementLines(measurements, pickedPackageCount, parentAvailable)
|
||||
: measurementLines(measurements, pickedByDetail);
|
||||
@@ -219,8 +220,17 @@ public class PdaPickSettlementService {
|
||||
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId,
|
||||
orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(),
|
||||
remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单");
|
||||
splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId,
|
||||
orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount);
|
||||
if (dropshipWholePallet) {
|
||||
var pickedPalletIds = taskItems.stream().filter(item -> "PICKED".equals(item.getItemStatus())
|
||||
&& "SEALED_PALLET".equals(item.getUnitType())).map(LoadingPickItemEntity::getPalletId)
|
||||
.filter(Objects::nonNull).distinct().toList();
|
||||
// 托内箱按实际箱唛移动,不能按箱序号截取,否则会移错已装托盘。
|
||||
dropshipSplits.transferRemainder(parent.tenantId(), parentInboundId, remainderChildId,
|
||||
orderSplit.remainderChild().orderId(), remainderChildCode, childPackageCount, pickedPalletIds);
|
||||
} else {
|
||||
splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId,
|
||||
orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount);
|
||||
}
|
||||
splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount);
|
||||
}
|
||||
requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId,
|
||||
@@ -408,17 +418,24 @@ public class PdaPickSettlementService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 一件代发逐箱封托已经冻结 SKU 归属;测量批次只承担整票体积重量,不再冒充 SKU 明细。 */
|
||||
/** 整托箱数守恒,测量行按箱数分摊重量体积;真实SKU组成仍由封托箱账本冻结。 */
|
||||
private static List<MeasurementSplitLine> wholePalletMeasurementLines(List<MeasurementItemRow> measurements,
|
||||
int pickedPackageCount,
|
||||
int parentAvailable) {
|
||||
int measuredPackages = measurements.stream()
|
||||
.mapToInt(item -> count(item.packageCount(), "测量明细箱数")).sum();
|
||||
if (pickedPackageCount != parentAvailable || measuredPackages != parentAvailable) {
|
||||
if (pickedPackageCount <= 0 || pickedPackageCount > parentAvailable || measuredPackages != parentAvailable) {
|
||||
throw conflict("一件代发整托箱数与入库单、测量批次不守恒");
|
||||
}
|
||||
return measurements.stream().map(item -> new MeasurementSplitLine(item,
|
||||
count(item.packageCount(), "测量明细箱数"), 0)).toList();
|
||||
int left = pickedPackageCount;
|
||||
List<MeasurementSplitLine> result = new ArrayList<>();
|
||||
for (var item : measurements) {
|
||||
int total = count(item.packageCount(), "测量明细箱数");
|
||||
int picked = Math.min(total, left);
|
||||
result.add(new MeasurementSplitLine(item, picked, total - picked));
|
||||
left -= picked;
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static Map<Long, Integer> measurementQuantities(List<MeasurementSplitLine> lines) {
|
||||
|
||||
@@ -72,7 +72,7 @@ public class SplitPreloadService {
|
||||
@Transactional
|
||||
public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName) {
|
||||
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, false);
|
||||
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName);
|
||||
}
|
||||
|
||||
/** Creates a provisional preload lifecycle for an order selected before it reaches WAIT_SPLIT. */
|
||||
@@ -113,16 +113,17 @@ public class SplitPreloadService {
|
||||
@Transactional
|
||||
public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName) {
|
||||
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, true);
|
||||
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName);
|
||||
}
|
||||
|
||||
/** 复用既有预装计划;同一入仓单其他未分配整托可继续进入不同柜次。 */
|
||||
private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName, boolean running) {
|
||||
String operatorName) {
|
||||
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
|
||||
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
|
||||
boolean waitingSplit = isWaitingSplit(inbound.processStatus());
|
||||
boolean loading = "LOADING".equals(inbound.processStatus());
|
||||
if (!waitingSplit && !(running && loading)) {
|
||||
if (!waitingSplit && !loading) {
|
||||
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划");
|
||||
}
|
||||
if (loading && plan == null) {
|
||||
|
||||
+23
@@ -28,6 +28,29 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@DisplayName("货柜数据库映射契约")
|
||||
class ContainerBookingMapperContractTest {
|
||||
/** 卸货不等于还柜;人工还柜及船司实际还柜都进入历史页签,两个集合互斥。 */
|
||||
@Test void completedScopeUsesActualEmptyReturnInsteadOfUnloadingReport() throws Exception {
|
||||
String page = String.join("\n", ContainerBookingMapper.class
|
||||
.getDeclaredMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
|
||||
com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
int start = page.indexOf("SELECT 1 FROM fl_terminal_drayage returned");
|
||||
String returned = page.substring(start, page.indexOf("))", start));
|
||||
var data = new JdbcDataSource();
|
||||
data.setURL("jdbc:h2:mem:return-scope-" + java.util.UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
var db = new JdbcTemplate(data);
|
||||
db.execute("CREATE TABLE fl_container_booking(id BIGINT,actual_empty_reported_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR(40),actual_at TIMESTAMP)");
|
||||
db.execute("INSERT INTO fl_container_booking VALUES(1,CURRENT_TIMESTAMP),(2,NULL),(3,NULL),(4,NULL)");
|
||||
db.execute("INSERT INTO fl_terminal_drayage VALUES(2,CURRENT_TIMESTAMP)");
|
||||
db.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(3,'EMPTY_RETURNED',CURRENT_TIMESTAMP),(4,'EMPTY_RETURNED',NULL)");
|
||||
assertThat(db.queryForList("SELECT b.id FROM fl_container_booking b WHERE EXISTS ("+returned+") ORDER BY b.id",Long.class))
|
||||
.containsExactly(2L,3L);
|
||||
assertThat(db.queryForList("SELECT b.id FROM fl_container_booking b WHERE NOT EXISTS ("+returned+") ORDER BY b.id",Long.class))
|
||||
.containsExactly(1L,4L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("货柜映射平台级最终表且状态只从装柜计划读取")
|
||||
void mapsFinalBookingContract() throws Exception {
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 分托结算直接验证实际Mapper,箱序号不代表物理已装托盘。 */
|
||||
class DropshipSplitRepositoryTest {
|
||||
/** 已装箱位于序号末尾时,仍只迁移另一托的箱,跨客户同码不受影响。 */
|
||||
@Test void transfersOnlyUnloadedPhysicalCartons() throws Exception {
|
||||
var data = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var db = new JdbcTemplate(data);
|
||||
db.execute("CREATE TABLE fl_inbound_carton_label(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,order_id BIGINT,inbound_code VARCHAR(50),batch_status VARCHAR(20),total_carton_count INT,detail_package_count INT,box_code VARCHAR(50))");
|
||||
db.execute("CREATE TABLE fl_dropship_pallet_carton(id BIGINT,tenant_id BIGINT,inbound_id BIGINT,pallet_id BIGINT,carton_label_id BIGINT)");
|
||||
db.update("INSERT INTO fl_inbound_carton_label VALUES(1,10,31,100,'IN-1','ACTIVE',4,4,'BOX-1'),(2,10,31,100,'IN-1','ACTIVE',4,4,'BOX-2'),(3,10,31,100,'IN-1','ACTIVE',4,4,'BOX-3'),(4,10,31,100,'IN-1','ACTIVE',4,4,'BOX-4'),(5,20,31,200,'IN-2','ACTIVE',1,1,'BOX-1')");
|
||||
db.update("INSERT INTO fl_dropship_pallet_carton VALUES(11,10,31,802,1),(12,10,31,802,2),(13,10,31,801,3),(14,10,31,801,4),(15,20,31,802,5)");
|
||||
var config = new MybatisConfiguration(); config.addMapper(DropshipPalletizationMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DropshipPalletizationMapper.class);
|
||||
new DropshipSplitRepository(mapper).transferRemainder(10,31,32,101,"IN-1-1",2,List.of(801L));
|
||||
assertThat(db.queryForList("SELECT id FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(1L,2L);
|
||||
assertThat(db.queryForList("SELECT id FROM fl_dropship_pallet_carton WHERE inbound_id=32 ORDER BY id",Long.class)).containsExactly(11L,12L);
|
||||
assertThat(db.queryForObject("SELECT inbound_id FROM fl_inbound_carton_label WHERE id=5",Long.class)).isEqualTo(31L);
|
||||
assertThat(db.queryForList("SELECT box_code FROM fl_inbound_carton_label WHERE inbound_id=32 ORDER BY id",String.class)).containsExactly("BOX-1","BOX-2");
|
||||
// 下一柜重新读取封托内容时,货物身份必须归到余量子入库单和余量子订单。
|
||||
db.execute("CREATE TABLE fl_dropship_pallet(id BIGINT,pallet_no VARCHAR(50),pallet_status VARCHAR(20))");
|
||||
db.execute("INSERT INTO fl_dropship_pallet VALUES(802,'DP-802','SEALED')");
|
||||
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD inbound_detail_id BIGINT DEFAULT 41");
|
||||
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD sku_code VARCHAR(30) DEFAULT 'SKU-1'");
|
||||
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD cargo_name VARCHAR(30) DEFAULT '货物'");
|
||||
db.execute("ALTER TABLE fl_dropship_pallet_carton ADD actual_sku_quantity INT DEFAULT 1");
|
||||
db.execute("CREATE TABLE fl_dropship_pallet_carton_sku(carton_id BIGINT,inbound_detail_id BIGINT,sku_code VARCHAR(30),cargo_name VARCHAR(30),actual_quantity INT)");
|
||||
db.execute("CREATE TABLE fl_inbound_order(id BIGINT,tenant_id BIGINT,order_id BIGINT,order_no VARCHAR(50),inbound_code VARCHAR(50),warehouse_id BIGINT,channel_code VARCHAR(30),channel_name VARCHAR(30),destination_warehouse_id BIGINT,destination_code VARCHAR(30),destination_name VARCHAR(30),customer_name VARCHAR(30),actual_gross_weight_kg DECIMAL,actual_net_weight_kg DECIMAL,actual_volume_cbm DECIMAL,total_package_count INT)");
|
||||
db.execute("INSERT INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,total_package_count) VALUES(32,10,101,'ORDER-1-1','IN-1-1',2)");
|
||||
var remainder = mapper.sealedPalletLines(802);
|
||||
assertThat(remainder).hasSize(1);
|
||||
assertThat(remainder.get(0).getInboundId()).isEqualTo(32L);
|
||||
assertThat(remainder.get(0).getOrderId()).isEqualTo(101L);
|
||||
assertThat(remainder.get(0).getInboundDetailId()).isEqualTo(41L);
|
||||
assertThat(remainder.get(0).getCartonCount()).isEqualTo(2);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ class PdaPickSettlementServiceTest {
|
||||
@MockBean private OrderFulfillmentSplitClient orderSplits;
|
||||
@MockBean private InboundHoldGuard inboundHoldGuard;
|
||||
@MockBean private DropshipPalletizationMapper dropshipPallets;
|
||||
@MockBean private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits;
|
||||
@Resource private PdaPickSettlementService service;
|
||||
|
||||
@Test
|
||||
@@ -266,6 +267,29 @@ class PdaPickSettlementServiceTest {
|
||||
&& request.lines().size() == 1 && request.lines().get(0).packageCount() == 20));
|
||||
}
|
||||
|
||||
@Test
|
||||
void settlesOneSealedPalletAndMovesOnlyUnloadedPalletsToRemainder() {
|
||||
var picked = item(11L, 21L, 31L, 41L, 2, 2, "PICKED");
|
||||
picked.setUnitType("SEALED_PALLET"); picked.setPalletId(801L); picked.setPalletNo("DP-1");
|
||||
resolved(List.of(picked));
|
||||
var physical = new DropshipPalletizationMapper.PlanPalletMemberRow();
|
||||
physical.setPalletId(801L); physical.setInboundId(31L); physical.setAccountId(10001L);
|
||||
physical.setAvailablePackageCount(2);
|
||||
when(dropshipPallets.palletInboundMembersByPalletIds(any())).thenReturn(List.of(physical));
|
||||
when(splits.lockParent(31L)).thenReturn(parent(4));
|
||||
when(splits.fulfillmentType(10001L, 31L)).thenReturn("ONE_CLICK_DROPSHIP");
|
||||
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
|
||||
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 4)));
|
||||
when(allocations.lockConfirmed(10001L, 31L)).thenReturn(List.of(allocation(1L, 1, 501L, 4)));
|
||||
writes(2, 2);
|
||||
service.settle(1L, 9L, "operator");
|
||||
verify(dropshipSplits).transferRemainder(eq(10001L), eq(31L), anyLong(), eq(1002L),
|
||||
eq("IN-001-1"), eq(2), eq(List.of(801L)));
|
||||
verify(splits, never()).transferRemainingCartonLabels(anyLong(), anyLong(), anyLong(), anyLong(), anyString(), anyInt(), anyInt());
|
||||
verify(splits).updateMeasurementItemForLoaded(10001L, 51L, 2);
|
||||
verify(splits).markSourceLoaded(10001L, 31L, 2, 9L, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyPureSkippedInboundRestoresCandidate() {
|
||||
LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 8, 0, "SKIPPED");
|
||||
|
||||
+2
-3
@@ -148,14 +148,13 @@ class SplitPreloadServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryLoadingAllocationStillRequiresWaitingSplit() {
|
||||
void otherCabinetMayReuseExistingPreloadWhileSourceIsLoading() {
|
||||
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3));
|
||||
PreloadPlanEntity plan = new PreloadPlanEntity();
|
||||
plan.setStatus("PENDING");
|
||||
when(repository.findPlan(10, 20)).thenReturn(plan);
|
||||
|
||||
assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"))
|
||||
.hasMessageContaining("仅待分柜订单");
|
||||
service.ensurePendingForLoadingAllocation(10, 20, 7, "admin");
|
||||
|
||||
verify(repository, never()).createPlan(any());
|
||||
}
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ public interface DispatchWholePdaReadMapper {
|
||||
record Picked(Long itemId,long packages) { }
|
||||
record Invalid(Long dispatchId,long quantity) { }
|
||||
record Carton(Long itemId,Long cartonId,String cartonMark,String status,long productQuantity) { }
|
||||
/** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */
|
||||
@Select("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' "
|
||||
/** 绑定仓仅下发仓内作业;本土卡车与到仓取货排车继续在后台执行。 */
|
||||
@Select("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' AND COALESCE(task_type,'DELIVERY') NOT IN ('LOCAL_TRUCK','PICKUP') "
|
||||
+"<choose><when test='number != null'>AND dispatch_no=#{number} </when><when test='stage != null'>AND stage=#{stage} </when>"
|
||||
+"<otherwise>AND (stage IN('LOADING','AFTER_LOADING','DELIVERING') OR (stage='BEFORE_LOADING' AND review_state='LOADING_PENDING')) </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
|
||||
List<DispatchAggregate> headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage);
|
||||
|
||||
+1
-2
@@ -962,8 +962,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
return batches.stream().collect(Collectors.toMap(
|
||||
b -> List.of(b.batchKey(),b.customerId(),b.destinationKey()),b->b,(a,b)->a,LinkedHashMap::new)).values().stream().map(b -> {
|
||||
var confirmed=confirmedAppointments.stream().filter(w->w.batchKey().equals(b.batchKey()) && w.customerId().equals(b.customerId()) && w.destinationKey().equals(b.destinationKey())).findFirst().orElse(null);
|
||||
return new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),confirmed==null?null:confirmed.confirmationNo(),b.destinationKey(),b.warehouseCode(),
|
||||
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null,b.customerId(),List.of());
|
||||
return DispatchDestinationAppointments.booked(b,confirmed);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -26,13 +26,13 @@ final class DispatchDestinationAppointments {
|
||||
if (maintenance != null) for (var entry : maintenance.destinationAppointments()) entered.put(entry.destinationKey(),entry.appointmentNo());
|
||||
var result = new ArrayList<FbaAppointment>();
|
||||
destinations.forEach((key,code) -> {
|
||||
var confirmed = booked.stream().filter(a -> key.equals(a.destinationKey()) && "SUCCESS".equals(a.status()) && a.appointmentNo() != null && !a.appointmentNo().isBlank()).toList();
|
||||
var confirmed = booked.stream().filter(a -> key.equals(a.destinationKey()) && ("SUCCESS".equals(a.status()) || "REGISTERED".equals(a.status())) && a.appointmentNo() != null && !a.appointmentNo().isBlank()).toList();
|
||||
var numbers = confirmed.stream().map(FbaAppointment::appointmentNo).distinct().toList();
|
||||
// 草稿暂未填写不能遮挡后来取得的唯一正式确认号,已填写值仍保留。
|
||||
var enteredNumber = entered.get(key);
|
||||
var number = enteredNumber != null ? enteredNumber : numbers.size() == 1 ? numbers.get(0) : null;
|
||||
var matching = confirmed.stream().filter(a -> Objects.equals(number,a.appointmentNo())).toList();
|
||||
var at = matching.stream().map(FbaAppointment::confirmedAt).filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null);
|
||||
var at = matching.stream().filter(a -> "SUCCESS".equals(a.status())).map(FbaAppointment::confirmedAt).filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null);
|
||||
var shipments = cargoShipments.stream().filter(a -> key.equals(a.destinationKey())).map(com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.CargoShipment::shipmentNo).filter(Objects::nonNull).map(String::trim).filter(s -> !s.isEmpty()).distinct().sorted().toList();
|
||||
result.add(new FbaAppointment(null,null,number,key,code,number == null ? "PENDING" : at == null ? "REGISTERED" : "SUCCESS",at,
|
||||
number == null ? "请填写目的仓 " + code + " 的预约号" : null,null,shipments));
|
||||
@@ -40,6 +40,15 @@ final class DispatchDestinationAppointments {
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** 已约批次可回显已登记编号;只有当前有效收货确认才能提供审核用确认时间。 */
|
||||
static FbaAppointment booked(com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow batch, ReceivingWindow confirmed) {
|
||||
var registered = "SUCCESS".equals(batch.status()) && batch.confirmationNo() != null && !batch.confirmationNo().isBlank();
|
||||
var number = confirmed != null ? confirmed.confirmationNo() : registered ? batch.confirmationNo() : null;
|
||||
return new FbaAppointment(batch.batchKey(),batch.batchNo(),number,batch.destinationKey(),batch.warehouseCode(),
|
||||
confirmed != null ? "SUCCESS" : number != null ? "REGISTERED" : "PENDING",confirmed == null ? null : confirmed.startAt(),
|
||||
confirmed == null ? "尚无当前有效收货确认" : null,batch.customerId(),List.of());
|
||||
}
|
||||
|
||||
/** 保存允许暂缺编号,但不能把别的目的仓写入本车。 */
|
||||
static void validate(Maintenance maintenance, List<FbaAppointment> destinations) {
|
||||
if (maintenance == null) return;
|
||||
|
||||
+3
-1
@@ -26,7 +26,7 @@ public class DispatchPdaQueryService {
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public DispatchViews.PdaTask task(Actor actor,String number) {
|
||||
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号");
|
||||
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0);
|
||||
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");var tasks=assemble(actor,rows);if(tasks.isEmpty())throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return tasks.get(0);
|
||||
}
|
||||
/** 按收货已确认的实托批量读取;未交接的原PLAN不推算实收仓位或箱数。 */
|
||||
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
|
||||
@@ -42,6 +42,8 @@ public class DispatchPdaQueryService {
|
||||
var consumed=hasRelabel?sourceOrigins.pickedAll(ids).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())):Map.<Long,Long>of();
|
||||
for(var root:roots) {
|
||||
var remote=workflow.remoteItemIds(root);var items=cargo.getOrDefault(root.getId(),List.of()).stream().filter(i->!remote.contains(i.getId())).toList();var detail=new ArrayList<DispatchViews.PdaItem>();
|
||||
// 纯异地取货没有仓内拣货,不向PDA推送空任务。
|
||||
if(items.isEmpty())continue;
|
||||
boolean sourceValid=true;var expectedHold=new HashMap<String,Long>();
|
||||
for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) {
|
||||
long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ public interface InboundPlanQueryMapper {
|
||||
String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" +
|
||||
" JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" +
|
||||
" WHERE di.customer_id=p.customer_id AND di.order_id=l.order_id" +
|
||||
" AND dispatch.status<>'CANCELLED')";
|
||||
" AND dispatch.status<>'CANCELLED' AND COALESCE(dispatch.task_type,'DELIVERY') NOT IN ('PICKUP','LOCAL_TRUCK'))";
|
||||
|
||||
String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" +
|
||||
" JOIN ll_inbound_plan p ON p.id=l.plan_id" +
|
||||
|
||||
@@ -51,14 +51,15 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
String externalBookingNo, boolean poolSource) { }
|
||||
|
||||
// Manual node snapshots replace container nodes permanently for the order.
|
||||
String SAILING_STAGE_FILTER_SQL = "(ll_order.order_type='FIRST_LEG' AND " +
|
||||
String SAILING_STAGE_VALUE_SQL = "(ll_order.order_type='FIRST_LEG' AND " +
|
||||
"(CASE WHEN COALESCE(ll_order.manual_node_override,FALSE)=TRUE THEN " +
|
||||
"CASE WHEN EXISTS (SELECT 1 FROM ll_order_manual_node n WHERE n.order_id=ll_order.id " +
|
||||
"AND n.customer_id=ll_order.customer_id AND n.node_code='ACTUAL_DEPARTURE' AND n.node_at IS NOT NULL) " +
|
||||
"THEN 1 ELSE 0 END ELSE " +
|
||||
"CASE WHEN EXISTS (SELECT 1 FROM ll_container_shipping_gate g " +
|
||||
"WHERE g.container_booking_id=ll_order.source_container_booking_id " +
|
||||
"AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)={0})";
|
||||
"AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)";
|
||||
String SAILING_STAGE_FILTER_SQL = SAILING_STAGE_VALUE_SQL + "={0})";
|
||||
|
||||
@Select("<script>SELECT r.lastleg_order_id,r.relabel_no,r.status,r.notified_package_count AS quantity, " +
|
||||
"CASE WHEN NOT EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=r.id AND ri.operation_type<>'CARTON_LABEL') THEN '箱' ELSE '件' END AS quantity_unit,r.created_at " +
|
||||
@@ -71,11 +72,18 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
|
||||
@Select("<script>SELECT o.id AS orderId,CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di " +
|
||||
"JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id " +
|
||||
"WHERE di.customer_id=o.customer_id AND di.order_id=o.id AND d.status!='CANCELLED') " +
|
||||
"WHERE di.customer_id=o.customer_id AND di.order_id=o.id AND d.status!='CANCELLED' AND COALESCE(d.task_type,'DELIVERY') NOT IN ('PICKUP','LOCAL_TRUCK')) " +
|
||||
"THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END AS dispatchStatus FROM ll_order o WHERE o.id IN " +
|
||||
"<foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
List<DispatchStatusRow> findDispatchStatuses(@Param("orderIds") List<Long> orderIds);
|
||||
|
||||
/** 已开船来源键用于聚合前去重,保持客户隔离和人工节点覆盖规则。 */
|
||||
@Select("<script>SELECT DISTINCT customer_id,source_order_no FROM ll_order WHERE source_order_no IS NOT NULL " +
|
||||
"<if test='customer != null'>AND customer_id=#{customer} </if>AND " +
|
||||
SAILING_STAGE_VALUE_SQL + "=1)" + "</script>")
|
||||
List<DepartedSource> departedSources(@Param("customer") String customer);
|
||||
record DepartedSource(String customerId, String sourceOrderNo) { }
|
||||
|
||||
record DispatchStatusRow(Long orderId, String dispatchStatus) { }
|
||||
|
||||
record OrderWeightRow(Long orderId, BigDecimal grossWeightKg, BigDecimal netWeightKg) { }
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.repository;
|
||||
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 尾程实际船节点优先于头程摘要中缺失的开船时间。 */
|
||||
@Repository
|
||||
public class PreDepartureOrderRepository {
|
||||
private final LastlegOrderMapper mapper;
|
||||
/** 仅读本服务尾程事实,不读取订单服务数据库。 */
|
||||
public PreDepartureOrderRepository(LastlegOrderMapper mapper) { this.mapper = mapper; }
|
||||
/** 按客户与来源订单组成去重键,在分页前排除已开船记录。 */
|
||||
public Set<String> departedKeys(String customer) {
|
||||
return mapper.departedSources(customer).stream()
|
||||
.map(row -> row.customerId() + ":" + row.sourceOrderNo()).collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ public class LastlegOrderService {
|
||||
@Resource private ReturnOrderReferenceResolver returnOrderReferences;
|
||||
@Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities;
|
||||
@Resource private OrderPreDepartureClient orderPreDepartureClient;
|
||||
@Resource private com.baiyue.lastleg.repository.PreDepartureOrderRepository preDepartureOrders;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalServiceToken;
|
||||
|
||||
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query) {
|
||||
@@ -101,12 +102,7 @@ public class LastlegOrderService {
|
||||
|
||||
public PageResult<LastlegOrderSummary> page(String customerId, LastlegOrderRequests.Query query, String relabelNo) {
|
||||
if (query.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE) {
|
||||
Long customer = parseLong(customerId);
|
||||
var response = orderPreDepartureClient.page(internalServiceToken, customer, query.orderNo(), query.inboundCode(),
|
||||
query.page().intValue(), query.size().intValue());
|
||||
if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
|
||||
List<LastlegOrderSummary> rows = response.data().records().stream().map(this::preDepartureSummary).toList();
|
||||
return new PageResult<>(rows, response.data().page(), response.data().size(), response.data().total(), response.data().pages());
|
||||
return preDeparturePage(customerId, query);
|
||||
}
|
||||
if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query);
|
||||
Page<LastlegOrder> page = orderMapper.selectPage(
|
||||
@@ -115,6 +111,27 @@ public class LastlegOrderService {
|
||||
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
|
||||
}
|
||||
|
||||
/** 开船前先扣除尾程已有实际开船事实,再分页,避免两页签重复及总数失真。 */
|
||||
private PageResult<LastlegOrderSummary> preDeparturePage(String customerId, LastlegOrderRequests.Query query) {
|
||||
var departed = preDepartureOrders.departedKeys(customerId);
|
||||
var records = new java.util.ArrayList<LastlegOrderSummary>();
|
||||
for (int sourcePage = 1; ; sourcePage++) {
|
||||
var response = orderPreDepartureClient.page(internalServiceToken, parseLong(customerId),
|
||||
query.orderNo(), query.inboundCode(), sourcePage, 2000);
|
||||
if (response == null || !response.successful() || response.data() == null)
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
|
||||
response.data().records().stream()
|
||||
.filter(row -> !departed.contains(orderKey(String.valueOf(row.customerId()), row.orderNo())))
|
||||
.map(this::preDepartureSummary).forEach(records::add);
|
||||
if (sourcePage >= response.data().pages()) break;
|
||||
}
|
||||
int from = (int)Math.min((query.page() - 1) * query.size(), records.size());
|
||||
int to = (int)Math.min(from + query.size(), records.size());
|
||||
long total = records.size();
|
||||
return new PageResult<>(records.subList(from, to), query.page(), query.size(), total,
|
||||
total == 0 ? 0 : (total + query.size() - 1) / query.size());
|
||||
}
|
||||
|
||||
/** 全部及已开船列表使用同一组业务事实,避免聚合列表丢失并托、排车和换标信息。 */
|
||||
private List<LastlegOrderSummary> nativeSummaries(List<LastlegOrder> orders) {
|
||||
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(orders);
|
||||
|
||||
+17
@@ -9,6 +9,23 @@ import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 本车同目的仓跨客户只填一次,先约自动带号,先排可暂缺。 */
|
||||
class DispatchDestinationAppointmentsTest {
|
||||
/** 已预约的编号不能因尚无有效收货确认而丢失,也不能冒充确认时间。 */
|
||||
@Test void registeredBatchNumberIsVisibleWithoutInventingReceivingConfirmation() {
|
||||
var batch=new com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow(1L,2L,"FBA_APPOINTMENT:1","AP-1","A","SOURCE","FBA:ONT8",22,"ISA-BOOKED","SUCCESS","ONT8",java.time.LocalDateTime.parse("2026-11-05T10:00:00"),1L);
|
||||
var booked=DispatchDestinationAppointments.booked(batch,null);
|
||||
assertThat(booked.status()).isEqualTo("REGISTERED");
|
||||
assertThat(booked.confirmedAt()).isNull();
|
||||
var rows=resolve(List.of(item("A","ONT8")),maintenance(new DestinationAppointment("FBA:ONT8",null)),List.of(booked));
|
||||
assertThat(rows).singleElement().satisfies(a->{
|
||||
assertThat(a.appointmentNo()).isEqualTo("ISA-BOOKED");
|
||||
assertThat(a.status()).isEqualTo("REGISTERED");assertThat(a.confirmedAt()).isNull();
|
||||
});
|
||||
var window=new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:1","AP-1","A","FBA:ONT8","ISA-CURRENT",java.time.Instant.parse("2026-11-06T10:00:00Z"),null,"AG-1",1);
|
||||
var confirmed=DispatchDestinationAppointments.booked(batch,window);
|
||||
assertThat(confirmed.appointmentNo()).isEqualTo("ISA-CURRENT");
|
||||
assertThat(confirmed.confirmedAt()).isEqualTo(window.startAt());
|
||||
assertThat(confirmed.status()).isEqualTo("SUCCESS");
|
||||
}
|
||||
@Test void groupsMultipleCustomersAndPalletsByDestination() {
|
||||
var rows=resolve(List.of(item("A","ONT8"),item("B","ONT8"),item("A","LGB8")),null,List.of());
|
||||
assertThat(rows).extracting(FbaAppointment::destinationWarehouseCode).containsExactly("LGB8","ONT8");
|
||||
|
||||
+7
@@ -62,6 +62,13 @@ class DispatchPdaReadPersistenceTest {
|
||||
assertThat(task.items()).allSatisfy(item->assertThat(item.positions()).hasSize(1));
|
||||
assertThatThrownBy(()->f.tx.execute(s->queries.task(new DispatchContracts.Actor("foreign",ActorMode.PDA,"99"),created.dispatchNo())))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("当前绑定仓无此排车作业");
|
||||
for (String type : List.of("LOCAL_TRUCK", "PICKUP")) {
|
||||
f.db.update("UPDATE ll_dispatch_order SET task_type=?", type);
|
||||
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
|
||||
assertThatThrownBy(()->f.tx.execute(s->queries.task(actor,created.dispatchNo())))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
}
|
||||
f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY'");
|
||||
f.db.update("DELETE FROM ll_dispatch_pda_pick_order WHERE customer_id='202'");
|
||||
assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).pickingReconciled()).isFalse();
|
||||
}
|
||||
|
||||
+11
-2
@@ -32,7 +32,7 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
|
||||
db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR(30),stage VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR(30),stage VARCHAR(30),task_type VARCHAR(30) DEFAULT 'DELIVERY')");
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(1,'LIP-1','A',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)");
|
||||
db.update("INSERT INTO ll_inbound_plan_line VALUES(2,1,3,'IN-1','91','ABE4','ABE4','ABE4',1,1,1)");
|
||||
db.update("INSERT INTO ll_order VALUES(3,'A','LX-1','FIRST_LEG','LA','洛杉矶仓','客户A')");
|
||||
@@ -64,6 +64,15 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
assertStatus("UNSCHEDULED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickupTripDoesNotCountAsOutboundScheduling() {
|
||||
dispatch(10, "ACTIVE", "DELIVERED", "A");
|
||||
db.update("UPDATE ll_dispatch_order SET task_type='PICKUP'");
|
||||
assertStatus("UNSCHEDULED");
|
||||
dispatch(11, "ACTIVE", "BEFORE_LOADING", "A");
|
||||
assertStatus("SCHEDULED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void departedAndCompletedDispatchesRemainScheduled() {
|
||||
dispatch(10, "ACTIVE", "DELIVERING", "A");
|
||||
@@ -110,7 +119,7 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
}
|
||||
|
||||
private void dispatch(long id, String status, String stage, String itemOwner) {
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage);
|
||||
db.update("INSERT INTO ll_dispatch_order(id,status,stage) VALUES(?,?,?)", id, status, stage);
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id);
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -53,6 +53,18 @@ class LastlegOrderSailingStageTest {
|
||||
assertEquals(List.of(3L, 4L, 7L, 8L, 9L), find(db, null, SailingStage.AFTER_DEPARTURE, null));
|
||||
assertEquals(List.of(3L, 4L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, null));
|
||||
assertEquals(List.of(3L, 7L, 9L), find(db, "A", SailingStage.AFTER_DEPARTURE, "PENDING_ASSIGNMENT"));
|
||||
// 去重仓储执行真实Mapper,沿用客户隔离和人工覆盖后的实际开船事实。
|
||||
sql.execute("ALTER TABLE ll_order ADD source_order_no VARCHAR(64)");
|
||||
sql.execute("UPDATE ll_order SET source_order_no=CONCAT('SOURCE-',id)");
|
||||
var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);
|
||||
var factory = new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:sailing;MODE=MySQL", "", ""));
|
||||
factory.setConfiguration(config);
|
||||
var mapper = new org.mybatis.spring.SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);
|
||||
assertEquals(java.util.Set.of("A:SOURCE-3", "A:SOURCE-4", "A:SOURCE-7", "A:SOURCE-9"),
|
||||
new com.baiyue.lastleg.repository.PreDepartureOrderRepository(mapper).departedKeys("A"));
|
||||
|
||||
assertEquals(java.util.stream.LongStream.rangeClosed(1, 14).boxed().toList(), find(db, null, null, null)); // Discard applications have their own workflow and are excluded.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ class LastlegOrderServiceTest {
|
||||
@Mock WarehouseResourceGateway warehouseResourceGateway;
|
||||
@Mock ContainerShippingGateMapper containerShippingGates;
|
||||
@Mock OrderPreDepartureClient preDeparture;
|
||||
@Mock com.baiyue.lastleg.repository.PreDepartureOrderRepository preDepartureOrders;
|
||||
@Mock ReturnOrderReferenceResolver returnOrderReferences;
|
||||
@Mock com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts;
|
||||
LastlegOrderService service;
|
||||
@@ -118,6 +119,27 @@ class LastlegOrderServiceTest {
|
||||
assertEquals(new java.math.BigDecimal("22"), summary.netWeightKg());
|
||||
}
|
||||
|
||||
/** 已清关来源即使订单服务仍返回开船前记录,也应先去重再分页。 */
|
||||
@Test void excludesDepartedSourcesBeforePagination() {
|
||||
when(preDepartureOrders.departedKeys("101")).thenReturn(java.util.Set.of("101:EXXHK2611050001"));
|
||||
var departed = preDepartureRow(1L, "EXXHK2611050001");
|
||||
var waiting = preDepartureRow(2L, "WAITING-2");
|
||||
when(preDeparture.page(any(), eq(101L), any(), any(), eq(1), eq(2000)))
|
||||
.thenReturn(ApiResponse.success(new PageResult<>(List.of(departed, waiting), 1, 2000, 2, 1)));
|
||||
var query = new LastlegOrderRequests.Query(1L, 1L, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null,
|
||||
LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE, null, null, null);
|
||||
var page = service.page("101", query);
|
||||
assertEquals(1L, page.total());
|
||||
assertEquals(1L, page.pages());
|
||||
assertEquals("WAITING-2", page.records().get(0).sourceOrderNo());
|
||||
}
|
||||
|
||||
private OrderPreDepartureClient.Row preDepartureRow(long id, String no) {
|
||||
return new OrderPreDepartureClient.Row(id,101L,no,"IN-"+id,"WAIT_SPLIT", "客户",null,null,null,
|
||||
null,2,null,null,null,null,null,null,null,null,null,null);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
|
||||
@@ -144,6 +166,7 @@ class LastlegOrderServiceTest {
|
||||
ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway);
|
||||
ReflectionTestUtils.setField(service, "containerShippingGates", containerShippingGates);
|
||||
ReflectionTestUtils.setField(service, "orderPreDepartureClient", preDeparture);
|
||||
ReflectionTestUtils.setField(service, "preDepartureOrders", preDepartureOrders);
|
||||
lenient().when(preDeparture.page(any(), any(), any(), any(), anyInt(), anyInt()))
|
||||
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 2000, 0, 0)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user