Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
+4
@@ -10,6 +10,8 @@ public class PlatformSmartLoadingController {
|
||||
@Resource private SmartLoadingService service;
|
||||
@GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||
public ApiResponse<List<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(bookingId,warehouseId));}
|
||||
@GetMapping("/inbound-candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||
public ApiResponse<List<InboundCandidate>> inboundCandidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.inboundCandidates(bookingId,warehouseId));}
|
||||
@GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||
public ApiResponse<Plan> draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));}
|
||||
@GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||
@@ -18,6 +20,8 @@ public class PlatformSmartLoadingController {
|
||||
public ApiResponse<Plan> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/inbound-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Plan> assignInbound(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignInboundRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignInbound(planId,r,u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/recommend") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Plan> recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));}
|
||||
@PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.util.List;
|
||||
public final class SmartLoadingDtos { private SmartLoadingDtos(){}
|
||||
public record InboundCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode,
|
||||
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary,
|
||||
Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg,
|
||||
BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm) {}
|
||||
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
|
||||
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
|
||||
BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) {
|
||||
@@ -19,6 +24,9 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
|
||||
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
|
||||
public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
|
||||
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
|
||||
public record AssignInboundRequest(@NotNull @Positive Long inboundId,
|
||||
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max=128) String operationId) {}
|
||||
public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max=128) String operationId) {}
|
||||
public record AdjustRequest(@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedPlanVersion,
|
||||
|
||||
+4
-1
@@ -187,7 +187,10 @@ public class CarrierScheduleQueryService {
|
||||
}
|
||||
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
|
||||
operatorId, operatorName);
|
||||
CarrierScheduleQueryEntity after = advanced ? required(id) : value;
|
||||
if (!advanced && repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
CarrierScheduleQueryEntity after = required(id);
|
||||
addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after,
|
||||
operatorId, operatorName);
|
||||
return complete(id, operatorId, key, detail(after));
|
||||
|
||||
@@ -31,6 +31,13 @@ public class SmartLoadingRepository {
|
||||
public Candidate candidate(long warehouseId, long inboundId, long detailId) {
|
||||
return candidates.candidate(warehouseId, inboundId, detailId);
|
||||
}
|
||||
public List<LoadingPlanItemEntity> activeItemsByInbound(long planId, long accountId, long inboundId) {
|
||||
return items.selectList(new LambdaQueryWrapper<LoadingPlanItemEntity>()
|
||||
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
|
||||
.eq(LoadingPlanItemEntity::getAccountId, accountId)
|
||||
.eq(LoadingPlanItemEntity::getInboundId, inboundId)
|
||||
.eq(LoadingPlanItemEntity::getDeleted, false));
|
||||
}
|
||||
public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); }
|
||||
public LoadingPlanEntity planByBooking(long bookingId) {
|
||||
return plans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
|
||||
|
||||
@@ -58,6 +58,33 @@ public class SmartLoadingService {
|
||||
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
|
||||
}
|
||||
|
||||
/** Operator-facing candidates are grouped by inbound code; specification rows remain internal snapshots. */
|
||||
public List<InboundCandidate> inboundCandidates(long bookingId, long warehouseId) {
|
||||
Map<Long, List<Candidate>> grouped = candidates(bookingId, warehouseId).stream()
|
||||
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
|
||||
java.util.stream.Collectors.toList()));
|
||||
return grouped.values().stream().map(this::inboundCandidate).toList();
|
||||
}
|
||||
|
||||
private InboundCandidate inboundCandidate(List<Candidate> details) {
|
||||
Candidate first = details.get(0);
|
||||
int packages = details.stream().map(Candidate::availablePackageCount)
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||
BigDecimal volume = details.stream()
|
||||
.map(detail -> unit(detail.unitVolumeCbm()).multiply(BigDecimal.valueOf(
|
||||
Math.max(0, detail.availablePackageCount() == null ? 0 : detail.availablePackageCount()))))
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP);
|
||||
BigDecimal averageVolume = packages == 0 ? BigDecimal.ZERO : volume.divide(
|
||||
BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP);
|
||||
String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText)
|
||||
.map(String::trim).distinct().collect(java.util.stream.Collectors.joining("、"));
|
||||
return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
|
||||
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
|
||||
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
|
||||
first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(),
|
||||
averageVolume, volume);
|
||||
}
|
||||
|
||||
public Plan draftByBooking(long bookingId) {
|
||||
ContainerBookingEntity booking = requireBooking(bookingId);
|
||||
LoadingPlanEntity plan = repository.planByBooking(bookingId);
|
||||
@@ -144,6 +171,84 @@ public class SmartLoadingService {
|
||||
return view(repository.plan(planId), booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns one inbound code by total carton count. The selected quantity is proportionally
|
||||
* distributed over the remaining measured specifications only for volume estimation and
|
||||
* downstream compatibility; it is not presented as verified physical carton identity.
|
||||
*/
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SMART_LOADING_INBOUND_ASSIGN", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "按入仓码分配智能装柜箱数")
|
||||
public Plan assignInbound(long planId, AssignInboundRequest request,
|
||||
long operatorId, String operatorName) {
|
||||
String operation = operation(request.operationId());
|
||||
String requestHash = sha256("ASSIGN_INBOUND|" + planId + '|' + request.inboundId() + '|'
|
||||
+ request.packageCount() + '|' + request.expectedVersion());
|
||||
Plan replay = replay(operation, planId, "ADD_INBOUND", null, requestHash);
|
||||
if (replay != null) return replay;
|
||||
|
||||
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
List<Candidate> details = candidates(plan.getContainerBookingId(), booking.getWarehouseId()).stream()
|
||||
.filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList();
|
||||
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
|
||||
Candidate first = details.get(0);
|
||||
if (!repository.activeItemsByInbound(planId, first.accountId(), request.inboundId()).isEmpty()) {
|
||||
throw conflict("该入仓码已在当前柜次中,请先调整或移除现有分配");
|
||||
}
|
||||
List<InboundAllocation> allocations = proportionalAllocations(details, request.packageCount());
|
||||
int sequence = 0;
|
||||
for (InboundAllocation allocation : allocations) {
|
||||
if (allocation.packageCount() == 0) continue;
|
||||
LoadingPlanEntity current = repository.plan(planId);
|
||||
String childOperation = "INB-" + sha256(operation + '|' + allocation.candidate().inboundDetailId())
|
||||
.substring(0, 48);
|
||||
assign(planId, new AssignRequest(request.inboundId(), allocation.candidate().inboundDetailId(),
|
||||
allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName);
|
||||
sequence++;
|
||||
}
|
||||
LoadingPlanEntity assigned = repository.plan(planId);
|
||||
history(planId, null, "ADD_INBOUND", plan.getPlanStatus(), assigned.getPlanStatus(), operation,
|
||||
json(values("version", request.expectedVersion(), "inboundId", request.inboundId())),
|
||||
json(values("requestHash", requestHash, "packageCount", request.packageCount(),
|
||||
"specificationLineCount", sequence, "cartonIdentityStatus", "UNVERIFIED")),
|
||||
operatorId, operatorName);
|
||||
return view(assigned, booking);
|
||||
}
|
||||
|
||||
List<InboundAllocation> proportionalAllocations(List<Candidate> details, int selected) {
|
||||
int available = details.stream().map(Candidate::availablePackageCount).filter(Objects::nonNull)
|
||||
.mapToInt(Integer::intValue).sum();
|
||||
if (selected < 1 || selected > available) throw validation("分配箱数超过入仓码可用箱数");
|
||||
if (selected == available) {
|
||||
return details.stream().map(detail -> new InboundAllocation(detail, detail.availablePackageCount(),
|
||||
BigDecimal.ZERO)).toList();
|
||||
}
|
||||
List<InboundAllocation> result = new ArrayList<>();
|
||||
int allocated = 0;
|
||||
for (Candidate detail : details) {
|
||||
int detailAvailable = Math.max(0, detail.availablePackageCount() == null ? 0 : detail.availablePackageCount());
|
||||
BigDecimal exact = BigDecimal.valueOf(selected).multiply(BigDecimal.valueOf(detailAvailable))
|
||||
.divide(BigDecimal.valueOf(available), 12, RoundingMode.HALF_UP);
|
||||
int base = exact.setScale(0, RoundingMode.DOWN).intValueExact();
|
||||
result.add(new InboundAllocation(detail, base, exact.subtract(BigDecimal.valueOf(base))));
|
||||
allocated += base;
|
||||
}
|
||||
int remaining = selected - allocated;
|
||||
List<Integer> priority = java.util.stream.IntStream.range(0, result.size()).boxed()
|
||||
.sorted(Comparator.<Integer, BigDecimal>comparing(index -> result.get(index).remainder()).reversed()
|
||||
.thenComparingInt(Integer::intValue)).toList();
|
||||
for (int index = 0; index < remaining; index++) {
|
||||
int target = priority.get(index);
|
||||
InboundAllocation current = result.get(target);
|
||||
result.set(target, new InboundAllocation(current.candidate(), current.packageCount() + 1,
|
||||
current.remainder()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
record InboundAllocation(Candidate candidate, int packageCount, BigDecimal remainder) {}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SMART_LOADING_RECOMMEND", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "执行智能装柜推荐")
|
||||
|
||||
@@ -392,6 +392,17 @@ paths:
|
||||
- {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses: {'200': {description: Candidates filtered by the server to the booking channel}}
|
||||
/api/platform/v1/first-leg/smart-loading/inbound-candidates:
|
||||
get:
|
||||
summary: 按入仓码查询智能装柜候选
|
||||
description: >-
|
||||
需要 platform:firstleg:smart-loading:list 权限。每个入库单/入仓码只返回一行;
|
||||
平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。
|
||||
这些数据用于数量级计划,不表示具体箱号已核验归属。
|
||||
parameters:
|
||||
- {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses: {'200': {description: 成功,data 为入仓码级候选列表}}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/{planId}:
|
||||
get:
|
||||
summary: 查询智能装柜草稿详情
|
||||
@@ -419,6 +430,20 @@ paths:
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingAssignRequest'}}}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/{planId}/inbound-assignments:
|
||||
post:
|
||||
summary: 按入仓码分配计划装柜箱数
|
||||
description: >-
|
||||
需要 platform:firstleg:smart-loading:assign 权限。操作员只选择某个入仓码的本次计划箱数;
|
||||
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单。
|
||||
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
|
||||
parameters:
|
||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingInboundAssignRequest'}}}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:
|
||||
patch:
|
||||
summary: 调整预装计划明细箱数
|
||||
@@ -1599,6 +1624,14 @@ components:
|
||||
packageCount: {type: integer, minimum: 1}
|
||||
expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion}
|
||||
operationId: {type: string, minLength: 1, maxLength: 128}
|
||||
SmartLoadingInboundAssignRequest:
|
||||
type: object
|
||||
required: [inboundId, packageCount, expectedVersion, operationId]
|
||||
properties:
|
||||
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
packageCount: {type: integer, minimum: 1, description: 该入仓码本次计划装柜总箱数}
|
||||
expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion}
|
||||
operationId: {type: string, minLength: 1, maxLength: 128}
|
||||
SmartLoadingAdjustRequest:
|
||||
type: object
|
||||
required: [packageCount, expectedPlanVersion, expectedItemVersion, operationId]
|
||||
|
||||
+4
@@ -12,6 +12,7 @@ class PlatformSmartLoadingControllerContractTest {
|
||||
authority("draftByBooking","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
|
||||
authority("detail","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
|
||||
authority("candidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class,long.class);
|
||||
authority("inboundCandidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class,long.class);
|
||||
}
|
||||
@Test void openApiRequiresBookingAndWarehouseForCandidates() throws Exception {
|
||||
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
|
||||
@@ -21,6 +22,7 @@ class PlatformSmartLoadingControllerContractTest {
|
||||
}
|
||||
@Test void everyMutationDeclaresDedicatedPermission() throws Exception {
|
||||
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
|
||||
authority("assignInbound","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignInboundRequest.class);
|
||||
authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class);
|
||||
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
|
||||
authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class);
|
||||
@@ -41,6 +43,8 @@ class PlatformSmartLoadingControllerContractTest {
|
||||
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:")
|
||||
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/inbound-assignments:")
|
||||
.contains("SmartLoadingInboundAssignRequest:")
|
||||
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:")
|
||||
.contains("SmartLoadingAssignRequest:")
|
||||
.contains("SmartLoadingAdjustRequest:")
|
||||
|
||||
+23
@@ -182,6 +182,29 @@ class CarrierScheduleQueryServiceTest {
|
||||
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() {
|
||||
CarrierScheduleQueryEntity before = task(0, 3);
|
||||
before.setStatus("READY"); before.setLatestStandardNode("LOADED_ON_VESSEL");
|
||||
before.setLatestNodeObservedAt(NOW.minusDays(1));
|
||||
CarrierScheduleQueryEntity after = task(0, 4);
|
||||
after.setStatus("READY"); after.setLatestStandardNode("LOADED_ON_VESSEL");
|
||||
after.setLatestNodeObservedAt(NOW.minusDays(1));
|
||||
when(repository.lock(1L)).thenReturn(before); when(repository.find(1L)).thenReturn(after);
|
||||
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
|
||||
.thenReturn(true);
|
||||
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
|
||||
|
||||
var result = service.addManualNode(1L, 7L, "admin", "manual-estimated-1",
|
||||
new ManualNodeRequest(3, "ARRIVED_DESTINATION_PORT", NOW, NOW.plusDays(5), null,
|
||||
"USLAX", "Los Angeles", "人工预计到港"));
|
||||
|
||||
assertThat(result.query().query().version()).isEqualTo(4);
|
||||
assertThat(result.query().query().latestActualNode()).isEqualTo("LOADED_ON_VESSEL");
|
||||
assertThat(result.query().query().latestActualAt()).isEqualTo(NOW.minusDays(1));
|
||||
verify(repository).updateState(before, 3, 7L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void holdIsPersistedAsSeparateImmutableExceptionFact() {
|
||||
CarrierScheduleQueryEntity task = task(0, 1);
|
||||
|
||||
@@ -104,6 +104,49 @@ class SmartLoadingServiceTest {
|
||||
assertThat(service.draftByBooking(99L).status()).isEqualTo("LOADING");
|
||||
}
|
||||
|
||||
@Test
|
||||
void groupsCandidateSpecificationsByInboundCodeForQuantityOnlyPlanning() {
|
||||
Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L,
|
||||
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "服装", 3,
|
||||
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"));
|
||||
Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L,
|
||||
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7,
|
||||
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"));
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.candidates(100L)).thenReturn(List.of(first, second));
|
||||
|
||||
var result = service.inboundCandidates(99L, 100L);
|
||||
|
||||
assertThat(result).singleElement().satisfies(candidate -> {
|
||||
assertThat(candidate.inboundCode()).isEqualTo("I");
|
||||
assertThat(candidate.goodsSummary()).isEqualTo("服装、鞋类");
|
||||
assertThat(candidate.availablePackageCount()).isEqualTo(10);
|
||||
assertThat(candidate.averageUnitVolumeCbm()).isEqualByComparingTo("0.410000");
|
||||
assertThat(candidate.availableVolumeCbm()).isEqualByComparingTo("4.1000");
|
||||
assertThat(candidate.unitGrossWeightKg()).isEqualByComparingTo("2");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void proportionallyDistributesInboundQuantityWithoutLosingCartons() {
|
||||
Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L,
|
||||
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "A", 30,
|
||||
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"));
|
||||
Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L,
|
||||
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "B", 40,
|
||||
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"));
|
||||
Candidate third = new Candidate(9L, 10L, 22L, 30L, "O", "I", 100L,
|
||||
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "C", 30,
|
||||
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.8"));
|
||||
|
||||
var result = service.proportionalAllocations(List.of(first, second, third), 60);
|
||||
|
||||
assertThat(result).extracting(SmartLoadingService.InboundAllocation::packageCount)
|
||||
.containsExactly(18, 24, 18);
|
||||
assertThat(result).extracting(SmartLoadingService.InboundAllocation::candidate)
|
||||
.extracting(Candidate::inboundDetailId).containsExactly(20L, 21L, 22L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesPickedMinimumAndItemCapabilitiesForRunningTask() {
|
||||
LoadingPlanEntity plan = plan("LOADING", 3);
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function run(context) {
|
||||
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
|
||||
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
|
||||
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;仅登记 `containerRequirements`) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
|
||||
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;船司查询只创建并写当前 marker 登记任务) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
|
||||
| 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空;DISPATCH 前需提单号和有效 `BILL_OF_LADING` 文件) | `truck-appointments-contract.test.mjs` |
|
||||
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
|
||||
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
|
||||
@@ -57,7 +57,7 @@ export async function run(context) {
|
||||
| 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
|
||||
| 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
| 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` |
|
||||
| 四期船节点只读冒烟 | `phase4-carrier-nodes.mjs` | `phase4-carrier-nodes-contract.test.mjs` |
|
||||
| 四期船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` |
|
||||
| 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` |
|
||||
|
||||
表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。
|
||||
|
||||
@@ -2,20 +2,13 @@ const MODULE = 'phase3-carrier-schedule-queries'
|
||||
const OPTIONS = { scenario: 'carrier-schedule-query-policy', deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/carrier-schedule-queries'
|
||||
const OCEAN = '/api/platform/v1/ocean-bookings'
|
||||
const PORT = '/api/platform/v1/port-schedules'
|
||||
const AUTOMATED_CARRIERS = Object.freeze(['MATSON', 'SML', 'ZIM', 'EMC', 'HEDE'])
|
||||
import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
export function matchesManualScheduleScope(resource, expected) {
|
||||
return Boolean(resource?.id)
|
||||
&& id(resource.customerId) === id(expected.customerId)
|
||||
&& id(resource.oceanBookingId) === id(expected.oceanBookingId)
|
||||
&& id(resource.containerBookingId) === id(expected.containerBookingId)
|
||||
}
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function data(response) { return codeOf(response) === '200' ? response.body.data : null }
|
||||
function localIso(value) { return value.toISOString().slice(0, 19) }
|
||||
function at(offsetMinutes) { return localIso(new Date(Date.now() + offsetMinutes * 60_000)) }
|
||||
function step(context, name, response, passed, evidence) {
|
||||
context.addStep(MODULE, name, response, passed, evidence, { ...OPTIONS, deploymentBlocker: !passed })
|
||||
}
|
||||
@@ -39,12 +32,23 @@ async function request(context, method, path, body, headers = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function ownsCarrierQuery(resource, marker) {
|
||||
return Boolean(resource?.ownedByModule && resource?.queryId && resource?.marker === marker)
|
||||
}
|
||||
|
||||
export function markerTrackingNo(marker, explicit = process.env.ADMIN_TEST_CARRIER_CONTAINER_NO) {
|
||||
const configured = String(explicit || '').trim().toUpperCase()
|
||||
if (configured) return configured
|
||||
let hash = 0x811c9dc5
|
||||
for (const char of String(marker || 'carrier-marker')) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193)
|
||||
return `TSTU${String(hash >>> 0).padStart(10, '0').slice(-7)}`
|
||||
}
|
||||
|
||||
function oceanDraft(context, suffix, master) {
|
||||
return {
|
||||
...oceanBookingDraft(context, suffix, master),
|
||||
departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
|
||||
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
|
||||
remark: context.marker,
|
||||
parties: [
|
||||
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
|
||||
@@ -56,115 +60,125 @@ function oceanDraft(context, suffix, master) {
|
||||
|
||||
async function ensureOcean(context, suffix, master) {
|
||||
const existing = context.createdResources?.oceanBooking
|
||||
if (existing?.id && id(existing.customerId) === id(master.customerId)) return { id: id(existing.id), containerBookingId: null }
|
||||
if (existing?.id && existing.marker === context.marker && id(existing.customerId) === id(master.customerId)) {
|
||||
return { id: id(existing.id), containerBookingId: null }
|
||||
}
|
||||
const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) },
|
||||
{ 'Idempotency-Key': `${context.marker}-carrier-query-ocean`.slice(0, 128) })
|
||||
const detail = context.apiSuccess(MODULE, '创建船司查询隔离订舱', response, '', OPTIONS)
|
||||
const detail = context.apiSuccess(MODULE, '创建本轮船司查询隔离订舱', response, '', OPTIONS)
|
||||
return { id: id(detail?.oceanBooking?.id), containerBookingId: null }
|
||||
}
|
||||
|
||||
async function createManualSchedule(context, suffix, oceanBookingId, customerId) {
|
||||
const existing = context.createdResources?.portSchedule
|
||||
const expectedScope = { customerId, oceanBookingId, containerBookingId: null }
|
||||
if (matchesManualScheduleScope(existing, expectedScope)) return id(existing.id)
|
||||
const body = {
|
||||
expectedVersion: 0,
|
||||
data: {
|
||||
customerId, oceanBookingId, containerBookingId: null, sourceType: 'MANUAL',
|
||||
sourceName: `船司查询人工兜底-${suffix}`, sourceRef: `${context.marker}-CARRIER-MANUAL`,
|
||||
vesselName: `人工船期船-${suffix}`, voyageNo: `CM${suffix}`, departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
etdAt: at(10 * 24 * 60), etaAt: at(30 * 24 * 60), bookingCutoffAt: at(7 * 24 * 60),
|
||||
customsCutoffAt: at(8 * 24 * 60), portCutoffAt: at(9 * 24 * 60),
|
||||
observedAt: at(-1), expiresAt: at(3 * 24 * 60), remark: context.marker,
|
||||
},
|
||||
reason: '船司官网人工查询兜底',
|
||||
}
|
||||
const response = await request(context, 'POST', PORT, body,
|
||||
{ 'Idempotency-Key': `${context.marker}-carrier-manual-schedule`.slice(0, 128) })
|
||||
const detail = context.apiSuccess(MODULE, '创建真实人工船期兜底结果', response, '', OPTIONS)
|
||||
return id(detail?.portSchedule?.schedule?.id)
|
||||
function nodeSnapshot(detail) {
|
||||
return (Array.isArray(detail?.nodes) ? detail.nodes : []).map((item) => ({
|
||||
id: id(item.id), providerCode: item.providerCode, providerEventId: item.providerEventId,
|
||||
standardNode: item.standardNode, observedAt: item.observedAt, estimatedAt: item.estimatedAt, actualAt: item.actualAt,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM官方API自动查询',
|
||||
'ZIM Cargo Tracking API 未配置订阅凭据或来源授权时返回MANUAL_REQUIRED;不记录或回显订阅键',
|
||||
'external-third-party')
|
||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询',
|
||||
'COSCO Shipping Open Platform Cargo Tracking API 待注册订阅和凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF或伪造自动成功',
|
||||
'COSCO Shipping Open Platform 待正式凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF',
|
||||
'external-third-party')
|
||||
|
||||
const suffix = context.marker.replace(/\D/g, '').slice(-10)
|
||||
const carriersResponse = await request(context, 'GET', `${BASE}/carriers`)
|
||||
const carriers = context.apiSuccess(MODULE, '六家船司查询策略入口已部署', carriersResponse, '', OPTIONS)
|
||||
const byCode = new Map((Array.isArray(carriers) ? carriers : []).map((item) => [item.carrierCode, item]))
|
||||
context.assert(MODULE, '六家官方来源及合规模式完整', carriersResponse,
|
||||
context.assert(MODULE, '五家自动Provider与COSCO覆盖缺口精确', carriersResponse,
|
||||
['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code))
|
||||
&& byCode.get('ZIM')?.accessMode === 'OFFICIAL_API' && byCode.get('ZIM')?.coverageGap === true
|
||||
&& byCode.get('ZIM')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED'
|
||||
&& byCode.get('COSCO')?.accessMode === 'OFFICIAL_API' && byCode.get('COSCO')?.coverageGap === true
|
||||
&& AUTOMATED_CARRIERS.every((code) => byCode.get(code)?.automated === true
|
||||
&& byCode.get(code)?.coverageGap === false)
|
||||
&& byCode.get('COSCO')?.automated === false && byCode.get('COSCO')?.coverageGap === true
|
||||
&& byCode.get('COSCO')?.accessMode === 'OFFICIAL_API'
|
||||
&& byCode.get('COSCO')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED'
|
||||
&& [...byCode.values()].every((item) => item.officialUrl?.startsWith('http') && item.automated === false),
|
||||
`codes=${[...byCode.keys()].join(',') || 'missing'}`, OPTIONS)
|
||||
&& [...byCode.values()].every((item) => item.officialUrl?.startsWith('http')),
|
||||
`automated=${AUTOMATED_CARRIERS.filter((code) => byCode.get(code)?.automated).join(',') || 'none'}`, OPTIONS)
|
||||
|
||||
const master = await resolveOceanBookingMaster(context, MODULE)
|
||||
if (!master) return
|
||||
const ocean = await ensureOcean(context, suffix, master)
|
||||
if (!ocean.id) return
|
||||
const trackingNo = markerTrackingNo(context.marker)
|
||||
const createBody = {
|
||||
expectedVersion: 0, customerId: master.customerId, oceanBookingId: ocean.id,
|
||||
containerBookingId: ocean.containerBookingId, carrierCode: 'ZIM', trackingType: 'BILL_OF_LADING',
|
||||
trackingNo: `${context.marker}-ZIM`, remark: context.marker,
|
||||
containerBookingId: ocean.containerBookingId, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO',
|
||||
trackingNo, remark: context.marker,
|
||||
}
|
||||
const createKey = `${context.marker}-carrier-query-create`.slice(0, 128)
|
||||
const createResponse = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey })
|
||||
const created = context.apiSuccess(MODULE, '创建真实船司查询任务', createResponse, '', OPTIONS)
|
||||
const created = context.apiSuccess(MODULE, '创建本轮marker专属船司查询任务', createResponse, '', OPTIONS)
|
||||
const queryId = id(created?.query?.query?.id)
|
||||
context.assert(MODULE, '查询任务初始来源和过期字段稳定', createResponse,
|
||||
context.assert(MODULE, '本轮查询任务使用ZIM柜号契约', createResponse,
|
||||
Boolean(queryId) && created?.query?.query?.status === 'READY'
|
||||
&& created?.query?.expired === true && created?.query?.query?.accessMode === 'OFFICIAL_API',
|
||||
`queryId=${queryId || 'missing'}, status=${created?.query?.query?.status || 'missing'}`, OPTIONS)
|
||||
&& created?.query?.query?.carrierCode === 'ZIM'
|
||||
&& created?.query?.query?.trackingType === 'CONTAINER_NO'
|
||||
&& created?.query?.query?.trackingNo === trackingNo,
|
||||
`queryId=${queryId || 'missing'}, trackingNo=${created?.query?.query?.trackingNo || 'missing'}`, OPTIONS)
|
||||
if (!queryId) return
|
||||
context.createdResources.phase3CarrierScheduleQuery = {
|
||||
queryId, marker: context.marker, ownedByModule: true, customerId: id(master.customerId),
|
||||
oceanBookingId: ocean.id, trackingNo,
|
||||
}
|
||||
|
||||
const replay = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey })
|
||||
context.assert(MODULE, '创建任务幂等重放返回同一资源', replay,
|
||||
context.assert(MODULE, '创建任务幂等重放返回同一marker资源', replay,
|
||||
id(data(replay)?.query?.query?.id) === queryId,
|
||||
`first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS)
|
||||
|
||||
const version = Number(created.query.query.version)
|
||||
|
||||
const unknownCarrier = await request(context, 'POST', BASE,
|
||||
{ ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${context.marker}-UNKNOWN` },
|
||||
{ ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${trackingNo}U` },
|
||||
{ 'Idempotency-Key': `${context.marker}-carrier-unknown`.slice(0, 128) })
|
||||
step(context, '非法船司代码被拒绝', unknownCarrier,
|
||||
unknownCarrier.http === 200 && ['A0400', '400'].includes(codeOf(unknownCarrier)),
|
||||
`HTTP=${unknownCarrier.http}, code=${codeOf(unknownCarrier)}`)
|
||||
|
||||
const forgedCustomer = await request(context, 'POST', BASE,
|
||||
{ ...createBody, customerId: '10002', trackingNo: `${context.marker}-FORGED` },
|
||||
{ 'Idempotency-Key': `${context.marker}-carrier-forged-customer`.slice(0, 128) })
|
||||
step(context, '伪造客户与订舱关联被拒绝', forgedCustomer,
|
||||
forgedCustomer.http === 200 && ['A0400', '400'].includes(codeOf(forgedCustomer)),
|
||||
`HTTP=${forgedCustomer.http}, code=${codeOf(forgedCustomer)}`)
|
||||
const initialTask = created.query.query
|
||||
const observedAt = localIso(new Date())
|
||||
const estimatedAt = localIso(new Date(Date.now() + 30 * 24 * 60 * 60_000))
|
||||
const manualBody = {
|
||||
expectedVersion: Number(initialTask.version), standardNode: 'ARRIVED_DESTINATION_PORT',
|
||||
observedAt, estimatedAt, actualAt: null, locationCode: 'USLAX', locationName: 'Los Angeles',
|
||||
reason: `本轮marker预计节点验收 ${context.marker}`,
|
||||
}
|
||||
const manualKey = `${context.marker}-carrier-manual-node`.slice(0, 128)
|
||||
const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody,
|
||||
{ 'Idempotency-Key': manualKey })
|
||||
const manualDetail = context.apiSuccess(MODULE, '仅向本轮marker任务补录预计节点', manual, '', OPTIONS)
|
||||
const manualTask = manualDetail?.query?.query
|
||||
context.assert(MODULE, '预计事实可见、版本前进且实际进度零推进', manual,
|
||||
id(manualTask?.id) === queryId && Number(manualTask?.version) > Number(initialTask.version)
|
||||
&& manualTask?.status === initialTask.status
|
||||
&& manualTask?.latestActualNode === initialTask.latestActualNode
|
||||
&& manualTask?.latestActualAt === initialTask.latestActualAt
|
||||
&& manualDetail?.nodes?.some((item) => item.providerCode === 'MANUAL'
|
||||
&& item.standardNode === 'ARRIVED_DESTINATION_PORT' && item.estimatedAt === estimatedAt && item.actualAt == null),
|
||||
`version=${manualTask?.version ?? 'missing'}, latest=${manualTask?.latestActualNode || 'none'}`, OPTIONS)
|
||||
|
||||
const scheduleId = await createManualSchedule(context, suffix, ocean.id, master.customerId)
|
||||
if (!scheduleId) return
|
||||
const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-results`,
|
||||
{ expectedVersion: version, portScheduleId: scheduleId, reason: '人工完成官网查询并复核船期' },
|
||||
{ 'Idempotency-Key': `${context.marker}-carrier-manual-result`.slice(0, 128) })
|
||||
const completed = context.apiSuccess(MODULE, '绑定真实人工船期结果', manual, '', OPTIONS)
|
||||
context.assert(MODULE, '人工兜底保留官网尝试且形成有效更新时间和过期状态', manual,
|
||||
completed?.query?.query?.status === 'READY' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId
|
||||
&& Boolean(completed?.query?.query?.lastSuccessAt) && Boolean(completed?.query?.query?.sourceUpdatedAt)
|
||||
&& completed?.query?.expired === false
|
||||
&& completed?.histories?.some((item) => item.action === 'MANUAL_RESULT'),
|
||||
`status=${completed?.query?.query?.status || 'missing'}, result=${id(completed?.query?.query?.resultPortScheduleId) || 'missing'}`, OPTIONS)
|
||||
const replayManual = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody,
|
||||
{ 'Idempotency-Key': manualKey })
|
||||
const replayDetail = context.apiSuccess(MODULE, '人工节点同key同payload幂等重放', replayManual, '', OPTIONS)
|
||||
context.assert(MODULE, '人工节点重放版本和事实集合不漂移', replayManual,
|
||||
Number(replayDetail?.query?.query?.version) === Number(manualTask?.version)
|
||||
&& JSON.stringify(nodeSnapshot(replayDetail)) === JSON.stringify(nodeSnapshot(manualDetail)),
|
||||
`version=${replayDetail?.query?.query?.version ?? 'missing'}, nodes=${replayDetail?.nodes?.length ?? 'missing'}`, OPTIONS)
|
||||
|
||||
const stale = await request(context, 'POST', `${BASE}/${queryId}/manual-nodes`, manualBody,
|
||||
{ 'Idempotency-Key': `${context.marker}-carrier-manual-stale`.slice(0, 128) })
|
||||
step(context, '旧version配合新幂等键被A0409拒绝', stale,
|
||||
stale.http === 200 && ['A0409', '409'].includes(codeOf(stale)),
|
||||
`HTTP=${stale.http}, code=${codeOf(stale)}`)
|
||||
|
||||
const detailResponse = await request(context, 'GET', `${BASE}/${queryId}`)
|
||||
const detail = context.apiSuccess(MODULE, '回查船司查询任务详情与历史', detailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '平台详情不暴露调度尝试且保留业务版本历史', detailResponse,
|
||||
!Object.hasOwn(detail || {}, 'attempts')
|
||||
const detail = context.apiSuccess(MODULE, '回查本轮marker任务零漂移', detailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '并发拒绝后版本、实际进度和事实集合零漂移', detailResponse,
|
||||
Number(detail?.query?.query?.version) === Number(manualTask?.version)
|
||||
&& detail?.query?.query?.latestActualNode === manualTask?.latestActualNode
|
||||
&& detail?.query?.query?.latestActualAt === manualTask?.latestActualAt
|
||||
&& JSON.stringify(nodeSnapshot(detail)) === JSON.stringify(nodeSnapshot(manualDetail))
|
||||
&& !Object.hasOwn(detail || {}, 'attempts')
|
||||
&& detail?.histories?.some((item) => item.action === 'CREATE')
|
||||
&& detail?.histories?.some((item) => item.action === 'MANUAL_RESULT'),
|
||||
`attemptsExposed=${Object.hasOwn(detail || {}, 'attempts')}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS)
|
||||
&& detail?.histories?.some((item) => item.action === 'MANUAL_NODE'),
|
||||
`version=${detail?.query?.query?.version ?? 'missing'}, nodes=${detail?.nodes?.length ?? 'missing'}`, OPTIONS)
|
||||
context.createdResources.phase3CarrierScheduleQuery.version = Number(manualTask?.version)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { matchesManualScheduleScope } from './phase3-carrier-schedule-queries.mjs'
|
||||
import { markerTrackingNo, ownsCarrierQuery } from './phase3-carrier-schedule-queries.mjs'
|
||||
|
||||
const expected = { customerId: '10001', oceanBookingId: '20001', containerBookingId: null }
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase3-carrier-schedule-queries.mjs'), 'utf8')
|
||||
|
||||
test('does not reuse a schedule from the same booking when the container scope differs', () => {
|
||||
assert.equal(matchesManualScheduleScope({
|
||||
id: '30001', customerId: '10001', oceanBookingId: '20001', containerBookingId: '40001',
|
||||
}, expected), false)
|
||||
test('marker ownership requires explicit module ownership and the exact current marker', () => {
|
||||
assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'M', ownedByModule: true }, 'M'), true)
|
||||
assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'OLD', ownedByModule: true }, 'M'), false)
|
||||
assert.equal(ownsCarrierQuery({ queryId: '1', marker: 'M', ownedByModule: false }, 'M'), false)
|
||||
assert.equal(ownsCarrierQuery({ marker: 'M', ownedByModule: true }, 'M'), false)
|
||||
})
|
||||
|
||||
test('does not reuse a schedule whose customer scope is unknown', () => {
|
||||
assert.equal(matchesManualScheduleScope({
|
||||
id: '30001', oceanBookingId: '20001', containerBookingId: null,
|
||||
}, expected), false)
|
||||
test('default tracking number is deterministic marker data while explicit samples are opt-in', () => {
|
||||
const first = markerTrackingNo('RUN-20260821-001', '')
|
||||
const second = markerTrackingNo('RUN-20260821-001', '')
|
||||
assert.equal(first, second)
|
||||
assert.match(first, /^TSTU\d{7}$/)
|
||||
assert.equal(markerTrackingNo('RUN', ' zcsu-test-only '), 'ZCSU-TEST-ONLY')
|
||||
})
|
||||
|
||||
test('reuses a schedule only when customer, booking and container scopes all match', () => {
|
||||
assert.equal(matchesManualScheduleScope({
|
||||
id: '30001', customerId: 10001, oceanBookingId: 20001, containerBookingId: null,
|
||||
}, expected), true)
|
||||
test('phase3 contract creates and mutates only its marker-owned container query', () => {
|
||||
assert.match(source, /carrierCode:\s*'ZIM',\s*trackingType:\s*'CONTAINER_NO'/)
|
||||
assert.match(source, /phase3CarrierScheduleQuery\s*=\s*\{[\s\S]*queryId,[\s\S]*marker:\s*context\.marker,[\s\S]*ownedByModule:\s*true/)
|
||||
assert.match(source, /\/manual-nodes/)
|
||||
assert.match(source, /'Idempotency-Key'/)
|
||||
assert.match(source, /expectedVersion:\s*Number\(initialTask\.version\)/)
|
||||
assert.match(source, /actualAt:\s*null/)
|
||||
assert.match(source, /A0409/)
|
||||
assert.match(source, /nodeSnapshot\(detail\)[\s\S]*nodeSnapshot\(manualDetail\)/)
|
||||
assert.doesNotMatch(source, /\/manual-results/)
|
||||
assert.doesNotMatch(source, /\/attempts|\/scheduler|\/internal|\/pda/)
|
||||
assert.doesNotMatch(source, /BILL_OF_LADING/)
|
||||
assert.doesNotMatch(source, /createManualSchedule|portScheduleId|\/api\/platform\/v1\/port-schedules/)
|
||||
})
|
||||
|
||||
test('carrier capability assertion freezes five automated providers and COSCO only gap', () => {
|
||||
assert.match(source, /\['MATSON', 'SML', 'ZIM', 'EMC', 'HEDE'\]/)
|
||||
assert.match(source, /AUTOMATED_CARRIERS\.every[\s\S]*automated === true[\s\S]*coverageGap === false/)
|
||||
assert.match(source, /byCode\.get\('COSCO'\)\?\.automated === false/)
|
||||
assert.match(source, /OFFICIAL_API_CREDENTIALS_REQUIRED/)
|
||||
})
|
||||
|
||||
@@ -4,17 +4,17 @@ import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { EXCEPTION_CODES, QUERY_STATUSES, STANDARD_NODES, run } from './phase4-carrier-nodes.mjs'
|
||||
import { EXCEPTION_CODES, QUERY_STATUSES, STANDARD_NODES, markerOwnedCarrierQuery, run } from './phase4-carrier-nodes.mjs'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8')
|
||||
|
||||
function response(data) { return { http: 200, body: { code: '200', data } } }
|
||||
function contextFor(routes) {
|
||||
function contextFor(routes, createdResources = {}) {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
marker: 'T-PHASE4', token: 'token',
|
||||
marker: 'T-PHASE4', token: 'token', createdResources,
|
||||
request: async (method, requestPath, body, _token, headers = {}) => {
|
||||
calls.push(method === 'POST' ? { method, path: requestPath, body, headers } : { method, path: requestPath })
|
||||
assert.ok(['GET', 'POST'].includes(method))
|
||||
@@ -84,6 +84,19 @@ test('canonical task node and exception sets are locked to the phase-4 contract'
|
||||
assert.deepEqual(EXCEPTION_CODES, ['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD'])
|
||||
})
|
||||
|
||||
test('marker ownership requires explicit module ownership, query id and exact current marker', () => {
|
||||
assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: {} }), null)
|
||||
assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: {
|
||||
phase3CarrierScheduleQuery: { queryId: '1', marker: 'OLD', ownedByModule: true },
|
||||
} }), null)
|
||||
assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: {
|
||||
phase3CarrierScheduleQuery: { queryId: '1', marker: 'M', ownedByModule: false },
|
||||
} }), null)
|
||||
assert.equal(markerOwnedCarrierQuery({ marker: 'M', createdResources: {
|
||||
phase3CarrierScheduleQuery: { queryId: '1', marker: 'M', ownedByModule: true },
|
||||
} }).queryId, '1')
|
||||
})
|
||||
|
||||
test('empty page validates read-only pagination without detail calls', async () => {
|
||||
const context = contextFor({
|
||||
'/api/platform/v1/carrier-schedule-queries/carriers': carriers,
|
||||
@@ -96,17 +109,40 @@ test('empty page validates read-only pagination without detail calls', async ()
|
||||
])
|
||||
})
|
||||
|
||||
test('existing task reads page, detail and history with nullable node times', async () => {
|
||||
test('standalone existing historical task is read-only and never receives manual nodes', async () => {
|
||||
const base = '/api/platform/v1/carrier-schedule-queries'
|
||||
const context = contextFor({
|
||||
[`${base}/carriers`]: carriers,
|
||||
[`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 },
|
||||
[`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], exceptions: [exception], histories: [history] },
|
||||
[`${base}/${task.id}/manual-nodes`]: { query: { query: task, expired: false }, nodes: [node, manualNode], exceptions: [exception], histories: [history] },
|
||||
[`${base}/${task.id}/history`]: [history],
|
||||
})
|
||||
await run(context)
|
||||
assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'POST', 'GET'])
|
||||
assert.equal(context.calls.find((item) => item.method === 'POST').headers['Idempotency-Key'], `T-PHASE4-carrier-manual-${task.id}`)
|
||||
assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'GET'])
|
||||
assert.equal(context.calls.some((item) => item.method === 'POST'), false)
|
||||
assert.equal(context.calls.at(-1).path, `${base}/${task.id}/history`)
|
||||
})
|
||||
|
||||
test('phase4 writes only the exact current-marker query registered by phase3', async () => {
|
||||
const base = '/api/platform/v1/carrier-schedule-queries'
|
||||
const ownedTask = { ...task, id: '9007199254740994', queryNo: 'CSQ9007199254740994',
|
||||
status: 'READY', trackingNo: 'TSTU1234567', version: 6 }
|
||||
const resource = { queryId: ownedTask.id, marker: 'T-PHASE4', ownedByModule: true, trackingNo: ownedTask.trackingNo }
|
||||
const context = contextFor({
|
||||
[`${base}/carriers`]: carriers,
|
||||
[`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 },
|
||||
[`${base}/${ownedTask.id}`]: { query: { query: ownedTask, expired: false }, nodes: [node], exceptions: [exception], histories: [history] },
|
||||
[`${base}/${ownedTask.id}/manual-nodes`]: { query: { query: { ...ownedTask, version: 7 }, expired: false },
|
||||
nodes: [node, manualNode], exceptions: [exception], histories: [history] },
|
||||
[`${base}/${ownedTask.id}/history`]: [history],
|
||||
}, { phase3CarrierScheduleQuery: resource })
|
||||
|
||||
await run(context)
|
||||
|
||||
assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'POST', 'GET'])
|
||||
const write = context.calls.find((item) => item.method === 'POST')
|
||||
assert.equal(write.path, `${base}/${ownedTask.id}/manual-nodes`)
|
||||
assert.equal(write.body.expectedVersion, ownedTask.version)
|
||||
assert.equal(write.body.actualAt, null)
|
||||
assert.equal(write.headers['Idempotency-Key'], `T-PHASE4-carrier-manual-${ownedTask.id}`)
|
||||
})
|
||||
|
||||
@@ -16,6 +16,12 @@ function nullableText(value) { return value == null || typeof value === 'string'
|
||||
function nullableId(value) { return value == null || Boolean(id(value)) }
|
||||
function nullableIso(value) { return value == null || (typeof value === 'string' && !Number.isNaN(Date.parse(value))) }
|
||||
|
||||
export function markerOwnedCarrierQuery(context) {
|
||||
const resource = context?.createdResources?.phase3CarrierScheduleQuery
|
||||
return resource?.ownedByModule === true && Boolean(id(resource.queryId)) && resource.marker === context?.marker
|
||||
? resource : null
|
||||
}
|
||||
|
||||
function validTask(task) {
|
||||
return Boolean(id(task?.id) && task?.queryNo && id(task?.customerId) && id(task?.oceanBookingId))
|
||||
&& QUERY_STATUSES.includes(task?.status)
|
||||
@@ -73,14 +79,15 @@ export async function run(context) {
|
||||
&& records.every((item) => typeof item?.expired === 'boolean' && validTask(item?.query)),
|
||||
`records=${records.length}, total=${page?.total ?? 'missing'}`, OPTIONS)
|
||||
|
||||
if (records.length === 0) {
|
||||
const ownedResource = markerOwnedCarrierQuery(context)
|
||||
if (records.length === 0 && !ownedResource) {
|
||||
context.assert(MODULE, '无真实任务时返回稳定分页空态', pageResponse,
|
||||
Number(page?.total) === 0 && Number(page?.pages) === 0,
|
||||
`records=0, total=${page?.total ?? 'missing'}, pages=${page?.pages ?? 'missing'}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
|
||||
const queryId = id(records[0]?.query?.id)
|
||||
const queryId = ownedResource ? id(ownedResource.queryId) : id(records[0]?.query?.id)
|
||||
const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '查询真实船节点任务详情', detailResponse, `queryId=${queryId}`, OPTIONS)
|
||||
const nodes = Array.isArray(detail?.nodes) ? detail.nodes : []
|
||||
@@ -94,6 +101,20 @@ export async function run(context) {
|
||||
`status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`, OPTIONS)
|
||||
|
||||
const task = detail.query.query
|
||||
if (!ownedResource) {
|
||||
context.assert(MODULE, 'standalone无本轮marker资源时严格只读并跳过人工写', detailResponse,
|
||||
true, `readOnlyQueryId=${queryId}`, OPTIONS)
|
||||
const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`)
|
||||
const history = context.apiSuccess(MODULE, '只读查询历史任务历史', historyResponse, `queryId=${queryId}`, OPTIONS)
|
||||
context.assert(MODULE, 'standalone历史详情只读且结构稳定', historyResponse,
|
||||
Array.isArray(history) && history.every(validHistory),
|
||||
`history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS)
|
||||
return
|
||||
}
|
||||
const ownershipMatches = id(task?.id) === queryId && task?.trackingNo === ownedResource.trackingNo
|
||||
context.assert(MODULE, 'phase4写入目标严格匹配本轮phase3登记', detailResponse,
|
||||
ownershipMatches, `registered=${queryId}, actual=${id(task?.id) || 'missing'}`, OPTIONS)
|
||||
if (!ownershipMatches) return
|
||||
const observedAt = task.latestActualAt || '2026-08-21T00:00:00'
|
||||
const manualResponse = await context.request('POST', `${BASE}/${encodeURIComponent(queryId)}/manual-nodes`, {
|
||||
expectedVersion: task.version,
|
||||
|
||||
Reference in New Issue
Block a user