优化
This commit is contained in:
@@ -9,6 +9,7 @@ import com.baiyue.firstleg.client.OrderCargoClient;
|
|||||||
import com.baiyue.firstleg.entity.*;
|
import com.baiyue.firstleg.entity.*;
|
||||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||||
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||||
|
import com.baiyue.firstleg.split.service.SplitPreloadService;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -35,6 +36,7 @@ public class SmartLoadingService {
|
|||||||
@Resource private OrderCargoClient orderCargoClient;
|
@Resource private OrderCargoClient orderCargoClient;
|
||||||
@Resource private LoadingPickService loadingPickService;
|
@Resource private LoadingPickService loadingPickService;
|
||||||
@Resource private LoadingModelBridgeService loadingModelBridge;
|
@Resource private LoadingModelBridgeService loadingModelBridge;
|
||||||
|
@Resource private SplitPreloadService splitPreloadService;
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||||
public SmartLoadingService() { }
|
public SmartLoadingService() { }
|
||||||
@@ -104,6 +106,8 @@ public class SmartLoadingService {
|
|||||||
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
|
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
|
||||||
requireSameChannel(booking, candidate);
|
requireSameChannel(booking, candidate);
|
||||||
requireSameCustomerScope(booking, candidate);
|
requireSameCustomerScope(booking, candidate);
|
||||||
|
splitPreloadService.ensurePendingForLoadingAllocation(candidate.accountId(), candidate.inboundId(),
|
||||||
|
operatorId, operatorName);
|
||||||
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
|
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
|
||||||
LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId());
|
LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId());
|
||||||
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
|
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
|
||||||
@@ -235,6 +239,9 @@ public class SmartLoadingService {
|
|||||||
throw validation("录入真实柜号后才能推送预装清单并开启PDA作业");
|
throw validation("录入真实柜号后才能推送预装清单并开启PDA作业");
|
||||||
}
|
}
|
||||||
if (key.equals(plan.getPushIdempotencyKey())) {
|
if (key.equals(plan.getPushIdempotencyKey())) {
|
||||||
|
if ("EDITING".equals(plan.getPlanStatus())) {
|
||||||
|
ensurePendingPreloads(repository.items(planId), operatorId, operatorName);
|
||||||
|
}
|
||||||
loadingModelBridge.bindAllocatedBeforeLoading(plan.getId(), operatorId, operatorName, key);
|
loadingModelBridge.bindAllocatedBeforeLoading(plan.getId(), operatorId, operatorName, key);
|
||||||
loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName);
|
loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName);
|
||||||
return view(plan, booking);
|
return view(plan, booking);
|
||||||
@@ -242,7 +249,7 @@ public class SmartLoadingService {
|
|||||||
if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送");
|
if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送");
|
||||||
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
|
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
|
||||||
List<LoadingPlanItemEntity> items = repository.items(planId);
|
List<LoadingPlanItemEntity> items = repository.items(planId);
|
||||||
if (items.isEmpty()) throw validation("装柜明细不能为空");
|
ensurePendingPreloads(items, operatorId, operatorName);
|
||||||
checkPlanCapacity(booking, items);
|
checkPlanCapacity(booking, items);
|
||||||
try {
|
try {
|
||||||
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改");
|
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改");
|
||||||
@@ -254,6 +261,17 @@ public class SmartLoadingService {
|
|||||||
return view(pushed, requireBooking(pushed.getContainerBookingId()));
|
return view(pushed, requireBooking(pushed.getContainerBookingId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ensurePendingPreloads(List<LoadingPlanItemEntity> items, long operatorId, String operatorName) {
|
||||||
|
if (items.isEmpty()) throw validation("装柜明细不能为空");
|
||||||
|
if (items.stream().anyMatch(item -> item.getAccountId() == null || item.getInboundId() == null)) {
|
||||||
|
throw conflict("装柜明细缺少客户或入库单稳定归属,不能推送预装计划");
|
||||||
|
}
|
||||||
|
items.stream().collect(java.util.stream.Collectors.toMap(
|
||||||
|
item -> item.getAccountId() + "|" + item.getInboundId(), item -> item, (left, right) -> left))
|
||||||
|
.values().forEach(item -> splitPreloadService.ensurePendingForLoadingAllocation(
|
||||||
|
item.getAccountId(), item.getInboundId(), operatorId, operatorName));
|
||||||
|
}
|
||||||
|
|
||||||
private LoadingPlanItemEntity snapshot(long planId, Candidate c, OrderCargoClient.CargoBinding cargo, int count, long op, String name, int seq) {
|
private LoadingPlanItemEntity snapshot(long planId, Candidate c, OrderCargoClient.CargoBinding cargo, int count, long op, String name, int seq) {
|
||||||
LoadingPlanItemEntity i = new LoadingPlanItemEntity(); i.setLoadingPlanId(planId); i.setAccountId(c.accountId()); i.setCustomerName(c.customerName());
|
LoadingPlanItemEntity i = new LoadingPlanItemEntity(); i.setLoadingPlanId(planId); i.setAccountId(c.accountId()); i.setCustomerName(c.customerName());
|
||||||
i.setInboundId(c.inboundId()); i.setInboundDetailId(c.inboundDetailId()); i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setInboundCode(c.inboundCode());
|
i.setInboundId(c.inboundId()); i.setInboundDetailId(c.inboundDetailId()); i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setInboundCode(c.inboundCode());
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import jakarta.annotation.Resource;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -72,6 +73,34 @@ public class SplitPreloadService {
|
|||||||
new SplitActionResponse("WAIT_SPLIT", result));
|
new SplitActionResponse("WAIT_SPLIT", result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ensures the inbound-level preload lifecycle exists before a loading allocation can be pushed. */
|
||||||
|
@Transactional
|
||||||
|
public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId,
|
||||||
|
String operatorName) {
|
||||||
|
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
|
||||||
|
requireWaitingSplit(inbound, "仅待分柜订单可以建立智能装柜预装计划");
|
||||||
|
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
|
||||||
|
if (plan != null) {
|
||||||
|
if (!PreloadStatus.PENDING.equals(plan.getStatus())) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
plan = new PreloadPlanEntity();
|
||||||
|
plan.setTenantId(tenantId); plan.setInboundId(inboundId); plan.setStatus(PreloadStatus.PENDING);
|
||||||
|
plan.setVersion(0); plan.setItemsJson("[]"); plan.setRemark("智能装柜分配自动建立");
|
||||||
|
plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName);
|
||||||
|
plan.setUpdatedBy(operatorId); plan.setUpdatedByName(operatorName);
|
||||||
|
try {
|
||||||
|
repository.createPlan(plan);
|
||||||
|
} catch (DuplicateKeyException exception) {
|
||||||
|
PreloadPlanEntity concurrent = repository.findPlan(tenantId, inboundId);
|
||||||
|
if (concurrent == null || !PreloadStatus.PENDING.equals(concurrent.getStatus())) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "预装计划已被并发修改,请刷新后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public SplitActionResponse startConfirming(long tenantId, long inboundId, long operatorId,
|
public SplitActionResponse startConfirming(long tenantId, long inboundId, long operatorId,
|
||||||
String operatorName, String key,
|
String operatorName, String key,
|
||||||
|
|||||||
@@ -363,6 +363,8 @@ paths:
|
|||||||
description: >-
|
description: >-
|
||||||
需要 platform:firstleg:smart-loading:push 权限。真实柜号必须已录入,
|
需要 platform:firstleg:smart-loading:push 权限。真实柜号必须已录入,
|
||||||
否则不得推送预装清单或创建 PDA作业任务;计划必须处于 EDITING 且版本匹配。
|
否则不得推送预装清单或创建 PDA作业任务;计划必须处于 EDITING 且版本匹配。
|
||||||
|
智能装柜首次分配会创建入库单级 PENDING 预装聚合;push 会在同一事务内对全部实际分配再次确保该聚合存在且仍为 PENDING,
|
||||||
|
但不会覆盖 CONFIRMING/CONFIRMED 记录,也不会在 LOADING 后补建。
|
||||||
推送事务会先为所有已分配入库单建立 PENDING 预装计划与 EDITING 柜次计划的正式关联,
|
推送事务会先为所有已分配入库单建立 PENDING 预装计划与 EDITING 柜次计划的正式关联,
|
||||||
任一关联缺失、版本冲突或分配数量为零都会整体失败并禁止 PDA/LOADING;不得在 LOADING 后补关联。
|
任一关联缺失、版本冲突或分配数量为零都会整体失败并禁止 PDA/LOADING;不得在 LOADING 后补关联。
|
||||||
parameters:
|
parameters:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
|
|||||||
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
||||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||||
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||||
|
import com.baiyue.firstleg.split.service.SplitPreloadService;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import feign.FeignException;
|
import feign.FeignException;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
@@ -55,6 +56,7 @@ class SmartLoadingServiceTest {
|
|||||||
@MockBean private OrderCargoClient cargoClient;
|
@MockBean private OrderCargoClient cargoClient;
|
||||||
@MockBean private LoadingPickService loadingPickService;
|
@MockBean private LoadingPickService loadingPickService;
|
||||||
@MockBean private LoadingModelBridgeService loadingModelBridge;
|
@MockBean private LoadingModelBridgeService loadingModelBridge;
|
||||||
|
@MockBean private SplitPreloadService splitPreloadService;
|
||||||
@Resource private SmartLoadingService service;
|
@Resource private SmartLoadingService service;
|
||||||
|
|
||||||
@TestConfiguration
|
@TestConfiguration
|
||||||
@@ -158,6 +160,7 @@ class SmartLoadingServiceTest {
|
|||||||
|
|
||||||
service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-110-ok"), 7, "u");
|
service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-110-ok"), 7, "u");
|
||||||
|
|
||||||
|
verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
|
||||||
verify(repository).touch(1, 0, 7, "u");
|
verify(repository).touch(1, 0, 7, "u");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,11 +391,28 @@ class SmartLoadingServiceTest {
|
|||||||
|
|
||||||
assertThat(result.status()).isEqualTo("EDITING");
|
assertThat(result.status()).isEqualTo("EDITING");
|
||||||
verify(repository).push(1, "key", 3, 7, "u");
|
verify(repository).push(1, "key", 3, 7, "u");
|
||||||
var ordered = inOrder(loadingModelBridge, loadingPickService);
|
var ordered = inOrder(splitPreloadService, loadingModelBridge, loadingPickService);
|
||||||
|
ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
|
||||||
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
||||||
ordered.verify(loadingPickService).beginIfReady(99, 7, "u");
|
ordered.verify(loadingPickService).beginIfReady(99, 7, "u");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editingPushReplayEnsuresPendingPreloadBeforePda() {
|
||||||
|
LoadingPlanEntity pushed = plan("EDITING", 4);
|
||||||
|
pushed.setPushIdempotencyKey("key");
|
||||||
|
when(repository.plan(1)).thenReturn(pushed);
|
||||||
|
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
|
||||||
|
when(repository.booking(99)).thenReturn(booking());
|
||||||
|
|
||||||
|
service.pushPreload(1, "key", new PushRequest(4), 7, "u");
|
||||||
|
|
||||||
|
verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
|
||||||
|
verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
||||||
|
verify(loadingPickService).beginIfReady(99, 7, "u");
|
||||||
|
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rollsBackPushOrchestrationBeforePdaWhenBindingFails() {
|
void rollsBackPushOrchestrationBeforePdaWhenBindingFails() {
|
||||||
LoadingPlanEntity editing = plan("EDITING", 3);
|
LoadingPlanEntity editing = plan("EDITING", 3);
|
||||||
@@ -509,7 +529,7 @@ class SmartLoadingServiceTest {
|
|||||||
private static LoadingPlanItemEntity item(long id, long account, long detail, int boxes) {
|
private static LoadingPlanItemEntity item(long id, long account, long detail, int boxes) {
|
||||||
LoadingPlanItemEntity value = new LoadingPlanItemEntity();
|
LoadingPlanItemEntity value = new LoadingPlanItemEntity();
|
||||||
value.setId(id); value.setLoadingPlanId(1L); value.setAccountId(account);
|
value.setId(id); value.setLoadingPlanId(1L); value.setAccountId(account);
|
||||||
value.setInboundDetailId(detail); value.setPackageCount(boxes);
|
value.setInboundId(10L); value.setInboundDetailId(detail); value.setPackageCount(boxes);
|
||||||
value.setVersion(0); value.setDeleted(false);
|
value.setVersion(0); value.setDeleted(false);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
|||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class SplitPreloadServiceTest {
|
class SplitPreloadServiceTest {
|
||||||
@@ -56,6 +57,34 @@ class SplitPreloadServiceTest {
|
|||||||
verify(repository).createPlan(any(PreloadPlanEntity.class));
|
verify(repository).createPlan(any(PreloadPlanEntity.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadingAllocationCreatesPendingPreloadLifecycle() {
|
||||||
|
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3));
|
||||||
|
when(repository.findPlan(10, 20)).thenReturn(null);
|
||||||
|
|
||||||
|
service.ensurePendingForLoadingAllocation(10, 20, 7, "admin");
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<PreloadPlanEntity> captor =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(PreloadPlanEntity.class);
|
||||||
|
verify(repository).createPlan(captor.capture());
|
||||||
|
assertThat(captor.getValue().getStatus()).isEqualTo("PENDING");
|
||||||
|
assertThat(captor.getValue().getItemsJson()).isEqualTo("[]");
|
||||||
|
assertThat(captor.getValue().getRemark()).isEqualTo("智能装柜分配自动建立");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadingAllocationDoesNotOverwriteConfirmedPreload() {
|
||||||
|
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3));
|
||||||
|
PreloadPlanEntity plan = new PreloadPlanEntity();
|
||||||
|
plan.setStatus("CONFIRMING");
|
||||||
|
when(repository.findPlan(10, 20)).thenReturn(plan);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"))
|
||||||
|
.hasMessageContaining("已进入确认流程");
|
||||||
|
|
||||||
|
verify(repository, never()).createPlan(any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void confirmsPlanAndMovesInboundToLoading() {
|
void confirmsPlanAndMovesInboundToLoading() {
|
||||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
|
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import { run } from './finance-reconciliation-full.mjs'
|
|||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const source = fs.readFileSync(path.join(here, 'finance-reconciliation-full.mjs'), 'utf8')
|
const source = fs.readFileSync(path.join(here, 'finance-reconciliation-full.mjs'), 'utf8')
|
||||||
|
|
||||||
|
test('Finance pool keeps formal MANUAL_COST and maps container cost by stable container identity', () => {
|
||||||
|
assert.doesNotMatch(source, /sourceType \?\? ''\) !== 'MANUAL_COST'/)
|
||||||
|
assert.match(source, /\[raw\.containerBookingId, raw\.loadingPlanId\]\.map\(id\)\.includes\(id\(item\?\.containerId\)\)/)
|
||||||
|
assert.match(source, /!isUnboundContainerCost\(source\.raw\) \|\| hasContainerAllocations/)
|
||||||
|
})
|
||||||
const financePermissions = [
|
const financePermissions = [
|
||||||
'platform:finance:customer-account:list', 'platform:finance:customer-statement:list',
|
'platform:finance:customer-account:list', 'platform:finance:customer-statement:list',
|
||||||
'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review',
|
'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review',
|
||||||
|
|||||||
@@ -176,7 +176,6 @@ async function normalizeFixture(context) {
|
|||||||
`${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`)
|
`${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`)
|
||||||
const poolData = poolResponse?.body?.data
|
const poolData = poolResponse?.body?.data
|
||||||
pool = rows(poolData).concat(rows(poolData?.items))
|
pool = rows(poolData).concat(rows(poolData?.items))
|
||||||
.filter((item) => String(item?.sourceType ?? '') !== 'MANUAL_COST')
|
|
||||||
const visible = new Set(pool.map((item) => id(item?.sourceId)))
|
const visible = new Set(pool.map((item) => id(item?.sourceId)))
|
||||||
if ([...expectedSourceIds].every((source) => visible.has(source))) break
|
if ([...expectedSourceIds].every((source) => visible.has(source))) break
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
@@ -185,6 +184,8 @@ async function normalizeFixture(context) {
|
|||||||
const attemptedSources = root.feeSources.map((raw) => {
|
const attemptedSources = root.feeSources.map((raw) => {
|
||||||
const order = orders.get(id(raw.orderId))
|
const order = orders.get(id(raw.orderId))
|
||||||
const financeSource = poolBySource.get(sourceId(raw))
|
const financeSource = poolBySource.get(sourceId(raw))
|
||||||
|
|| pool.find((item) => String(item?.direction || '').toUpperCase() === 'PAYABLE'
|
||||||
|
&& [raw.containerBookingId, raw.loadingPlanId].map(id).includes(id(item?.containerId)))
|
||||||
return {
|
return {
|
||||||
raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId ?? customerId),
|
raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId ?? customerId),
|
||||||
direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(),
|
direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(),
|
||||||
@@ -199,9 +200,11 @@ async function normalizeFixture(context) {
|
|||||||
|| (isUnboundContainerCost(source.raw) && !hasContainerAllocations))
|
|| (isUnboundContainerCost(source.raw) && !hasContainerAllocations))
|
||||||
return {
|
return {
|
||||||
root, customerId,
|
root, customerId,
|
||||||
sources: attemptedSources.filter((source) => source.feeSourceId && !isUnboundContainerCost(source.raw)),
|
sources: attemptedSources.filter((source) => source.feeSourceId
|
||||||
|
&& (!isUnboundContainerCost(source.raw) || hasContainerAllocations)),
|
||||||
uncreatedSources,
|
uncreatedSources,
|
||||||
sourceMappings: attemptedSources.filter((source) => source.feeSourceId && !isUnboundContainerCost(source.raw))
|
sourceMappings: attemptedSources.filter((source) => source.feeSourceId
|
||||||
|
&& (!isUnboundContainerCost(source.raw) || hasContainerAllocations))
|
||||||
.map((source) => ({ sourceFeeId: source.sourceFeeId, feeSourceId: source.feeSourceId,
|
.map((source) => ({ sourceFeeId: source.sourceFeeId, feeSourceId: source.feeSourceId,
|
||||||
sourceType: source.raw?.sourceType ?? null, direction: source.direction })),
|
sourceType: source.raw?.sourceType ?? null, direction: source.direction })),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ test('each remainder uses a simulation linked to a confirmed then released booki
|
|||||||
const releaseStart = source.indexOf('const releaseResponse')
|
const releaseStart = source.indexOf('const releaseResponse')
|
||||||
const releaseBlock = source.slice(releaseStart, source.indexOf('bookingDetailResponse = await', releaseStart))
|
const releaseBlock = source.slice(releaseStart, source.indexOf('bookingDetailResponse = await', releaseStart))
|
||||||
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt',
|
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt',
|
||||||
'estimatedArrivalAt', 'expectedVersion']) assert.match(releaseBlock, new RegExp(`${field}:`))
|
'estimatedArrivalAt']) assert.match(releaseBlock, new RegExp(`${field}: persistedTransport\\.${field}`))
|
||||||
|
assert.match(releaseBlock, /expectedVersion: Number\(bookingDetail\.oceanBooking\.version\)/)
|
||||||
|
assert.doesNotMatch(releaseBlock, /BL-\$\{|SPLIT-VESSEL|SV-\$\{|2026-/)
|
||||||
assert.match(releaseBlock, /'Idempotency-Key': `\$\{operationBase\}-booking-release`\.slice\(0, 128\)/)
|
assert.match(releaseBlock, /'Idempotency-Key': `\$\{operationBase\}-booking-release`\.slice\(0, 128\)/)
|
||||||
assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/)
|
assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/)
|
||||||
assert.match(source, /id\(item\?\.containerBookingId\) === containerId/)
|
assert.match(source, /id\(item\?\.containerBookingId\) === containerId/)
|
||||||
@@ -28,6 +30,15 @@ test('each remainder uses a simulation linked to a confirmed then released booki
|
|||||||
assert.doesNotMatch(source, /candidates\?warehouseId=/)
|
assert.doesNotMatch(source, /candidates\?warehouseId=/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('release stops unless creation response and detail expose the same complete transport snapshot', () => {
|
||||||
|
assert.match(source, /const BOOKING_TRANSPORT_FIELDS = \[/)
|
||||||
|
assert.match(source, /const createdTransport = bookingTransportSnapshot\(bookingCreated\?\.oceanBooking\)/)
|
||||||
|
assert.match(source, /const persistedTransport = bookingTransportSnapshot\(bookingDetail\?\.oceanBooking\)/)
|
||||||
|
assert.match(source, /completeBookingTransport\(createdTransport\)/)
|
||||||
|
assert.match(source, /sameBookingTransport\(createdTransport, persistedTransport\)/)
|
||||||
|
assert.match(source, /if \(!transportFrozen\) return null/)
|
||||||
|
})
|
||||||
|
|
||||||
test('PDA opens only after a real container number and uses stable plan identity', () => {
|
test('PDA opens only after a real container number and uses stable plan identity', () => {
|
||||||
const pushIndex = source.indexOf("`${SMART}/plans/${planId}/push-preload`")
|
const pushIndex = source.indexOf("`${SMART}/plans/${planId}/push-preload`")
|
||||||
const transportIndex = source.indexOf("`${CONTAINERS}/${encodeURIComponent(containerId)}/transport`")
|
const transportIndex = source.indexOf("`${CONTAINERS}/${encodeURIComponent(containerId)}/transport`")
|
||||||
@@ -71,6 +82,7 @@ test('booking freezes the source customer channel and warehouse scope', () => {
|
|||||||
assert.match(source, /channelCode: source\.channelCode/)
|
assert.match(source, /channelCode: source\.channelCode/)
|
||||||
assert.match(source, /warehouseId: source\.warehouseId/)
|
assert.match(source, /warehouseId: source\.warehouseId/)
|
||||||
assert.match(source, /containerType === '40HQ'/)
|
assert.match(source, /containerType === '40HQ'/)
|
||||||
|
assert.match(source, /containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('booking fixture never invents supplier names without formal IDs', () => {
|
test('booking fixture never invents supplier names without formal IDs', () => {
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ function hasTrace(response) {
|
|||||||
&& typeof response?.traceId === 'string' && response.traceId.length > 0
|
&& typeof response?.traceId === 'string' && response.traceId.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BOOKING_TRANSPORT_FIELDS = [
|
||||||
|
'billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt',
|
||||||
|
]
|
||||||
|
|
||||||
|
function bookingTransportSnapshot(value) {
|
||||||
|
return Object.fromEntries(BOOKING_TRANSPORT_FIELDS.map((field) => [field, value?.[field] ?? null]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeBookingTransport(snapshot) {
|
||||||
|
return BOOKING_TRANSPORT_FIELDS.every((field) => typeof snapshot?.[field] === 'string'
|
||||||
|
&& snapshot[field].trim().length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameBookingTransport(left, right) {
|
||||||
|
return BOOKING_TRANSPORT_FIELDS.every((field) => left?.[field] === right?.[field])
|
||||||
|
}
|
||||||
|
|
||||||
async function request(context, method, path, body, headers = {}, token = context.token) {
|
async function request(context, method, path, body, headers = {}, token = context.token) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${context.options.gateway}${path}`, {
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
||||||
@@ -131,7 +148,7 @@ async function loadOneFragment(context, state) {
|
|||||||
const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state
|
const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state
|
||||||
const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100)
|
const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100)
|
||||||
const simulationResponse = await request(context, 'POST', CONTAINERS, {
|
const simulationResponse = await request(context, 'POST', CONTAINERS, {
|
||||||
channelCode: state.channelCode, warehouseId, containerType: '40HQ', capacityCbm: 100, maxWeightKg: 100000,
|
channelCode: state.channelCode, warehouseId, containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000,
|
||||||
})
|
})
|
||||||
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
|
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
|
||||||
const containerId = id(simulation?.id)
|
const containerId = id(simulation?.id)
|
||||||
@@ -221,12 +238,24 @@ async function loadOneFragment(context, state) {
|
|||||||
`oceanBookingId=${id(bookingDetail?.oceanBooking?.id) || 'missing'}, containerBookingId=${containerId}, allocations=${bookingDetail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
|
`oceanBookingId=${id(bookingDetail?.oceanBooking?.id) || 'missing'}, containerBookingId=${containerId}, allocations=${bookingDetail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
|
||||||
if (!allocationMatches || id(bookingDetail?.oceanBooking?.id) !== oceanBookingId) return null
|
if (!allocationMatches || id(bookingDetail?.oceanBooking?.id) !== oceanBookingId) return null
|
||||||
|
|
||||||
|
const createdTransport = bookingTransportSnapshot(bookingCreated?.oceanBooking)
|
||||||
|
const persistedTransport = bookingTransportSnapshot(bookingDetail?.oceanBooking)
|
||||||
|
const transportFrozen = completeBookingTransport(createdTransport)
|
||||||
|
&& completeBookingTransport(persistedTransport)
|
||||||
|
&& sameBookingTransport(createdTransport, persistedTransport)
|
||||||
|
context.assert(MODULE, `${sourceOrderNo} 创建响应与详情五项运输字段冻结一致`, bookingDetailResponse,
|
||||||
|
transportFrozen,
|
||||||
|
BOOKING_TRANSPORT_FIELDS.map((field) => `${field}=${persistedTransport[field] || 'missing'}`).join(', '), OPTIONS)
|
||||||
|
if (!transportFrozen) return null
|
||||||
|
|
||||||
if (bookingDetail.oceanBooking.status === 'CONFIRMED') {
|
if (bookingDetail.oceanBooking.status === 'CONFIRMED') {
|
||||||
const releaseResponse = await request(context, 'POST', `${bookingPath}/release`, {
|
const releaseResponse = await request(context, 'POST', `${bookingPath}/release`, {
|
||||||
expectedVersion: Number(bookingDetail.oceanBooking.version),
|
expectedVersion: Number(bookingDetail.oceanBooking.version),
|
||||||
billOfLadingNo: `BL-${operationBase}`.slice(0, 128),
|
billOfLadingNo: persistedTransport.billOfLadingNo,
|
||||||
vesselName: `SPLIT-VESSEL-${sequence}`.slice(0, 128), voyageNo: `SV-${sequence}`.slice(0, 64),
|
vesselName: persistedTransport.vesselName,
|
||||||
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00',
|
voyageNo: persistedTransport.voyageNo,
|
||||||
|
estimatedSailingAt: persistedTransport.estimatedSailingAt,
|
||||||
|
estimatedArrivalAt: persistedTransport.estimatedArrivalAt,
|
||||||
reason: context.marker,
|
reason: context.marker,
|
||||||
}, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) })
|
}, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) })
|
||||||
context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的真实订舱并满足 PDA 门禁`, releaseResponse, '', OPTIONS)
|
context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的真实订舱并满足 PDA 门禁`, releaseResponse, '', OPTIONS)
|
||||||
|
|||||||
Reference in New Issue
Block a user