This commit is contained in:
2026-08-15 11:51:15 +08:00
parent 46cb95db15
commit 08187363a1
16 changed files with 203 additions and 39 deletions
@@ -27,6 +27,11 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
int allocatedPackages(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("loadingPlanId") long loadingPlanId);
@Select("SELECT account_id tenantId,inbound_id inboundId,COALESCE(SUM(package_count),0) allocatedPackageCount "
+ "FROM fl_loading_plan_item WHERE loading_plan_id=#{loadingPlanId} AND deleted=0 "
+ "GROUP BY account_id,inbound_id HAVING SUM(package_count)>0")
List<AllocationScope> allocatedScopes(@Param("loadingPlanId") long loadingPlanId);
@Update("UPDATE fl_preload_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},"
+ "version=version+1 WHERE id=#{preloadPlanId} AND tenant_id=#{tenantId} "
+ "AND inbound_id=#{inboundId} AND status='PENDING' AND version=#{expectedVersion}")
@@ -70,4 +75,5 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
record PreloadSnapshot(Long id, Long tenantId, Long inboundId, String status, Integer version) {}
record LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
String status, Integer version) {}
record AllocationScope(Long tenantId, Long inboundId, Integer allocatedPackageCount) {}
}
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper;
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
@@ -24,6 +25,7 @@ public class LoadingModelBridgeRepository {
public int allocatedPackages(long tenantId, long inboundId, long loadingPlanId) {
return mapper.allocatedPackages(tenantId, inboundId, loadingPlanId);
}
public List<AllocationScope> allocatedScopes(long loadingPlanId) { return mapper.allocatedScopes(loadingPlanId); }
public PreloadLoadingPlanLinkEntity byPair(long tenantId, long inboundId, long loadingPlanId) {
return mapper.selectOne(new LambdaQueryWrapper<PreloadLoadingPlanLinkEntity>()
@@ -7,6 +7,7 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository;
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
@@ -68,6 +69,40 @@ public class LoadingModelBridgeService {
return requireLink(tenantId, inboundId, loadingPlanId);
}
/**
* Creates every preload link required by a pushed loading plan before PDA can start.
* The caller owns the surrounding push transaction, so a missing preload or stale
* version rolls back the push and cannot leave a LOADING task without a link.
*/
@Transactional
public List<Link> bindAllocatedBeforeLoading(long loadingPlanId, long operatorId,
String operatorName, String orchestrationKey) {
var loadingPlan = repository.loadingPlan(loadingPlanId);
if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId);
if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物,不能开始PDA作业");
if (!"EDITING".equals(loadingPlan.status())) {
if (Set.of("LOADING", "COMPLETED").contains(loadingPlan.status())
&& scopes.stream().allMatch(scope -> repository.byPair(
scope.tenantId(), scope.inboundId(), loadingPlanId) != null)) {
return repository.byLoadingPlan(loadingPlanId);
}
throw conflict("PDA开始前的预装关联不完整,禁止在LOADING后补关联");
}
for (AllocationScope scope : scopes) {
var preload = repository.preload(scope.tenantId(), scope.inboundId());
var currentPlan = repository.loadingPlan(loadingPlanId);
if (preload == null || currentPlan == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在");
}
String key = "AUTO:" + hash(orchestrationKey + "|" + scope.tenantId() + "|"
+ scope.inboundId() + "|" + loadingPlanId).substring(0, 60);
bind(scope.tenantId(), scope.inboundId(), loadingPlanId, operatorId, operatorName, key,
new BindRequest(preload.version(), currentPlan.version()));
}
return repository.byLoadingPlan(loadingPlanId);
}
public List<Link> byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); }
public List<Link> byLoadingPlan(long loadingPlanId) { return repository.byLoadingPlan(loadingPlanId); }
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.entity.*;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -33,6 +34,7 @@ public class SmartLoadingService {
@Resource private SmartLoadingRepository repository;
@Resource private OrderCargoClient orderCargoClient;
@Resource private LoadingPickService loadingPickService;
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public SmartLoadingService() { }
@@ -233,6 +235,7 @@ public class SmartLoadingService {
throw validation("录入真实柜号后才能推送预装清单并开启PDA作业");
}
if (key.equals(plan.getPushIdempotencyKey())) {
loadingModelBridge.bindAllocatedBeforeLoading(plan.getId(), operatorId, operatorName, key);
loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName);
return view(plan, booking);
}
@@ -246,6 +249,7 @@ public class SmartLoadingService {
} catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); }
history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key, operatorId, operatorName);
LoadingPlanEntity pushed = repository.plan(planId);
loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key);
loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName);
return view(pushed, requireBooking(pushed.getContainerBookingId()));
}
@@ -363,6 +363,8 @@ paths:
description: >-
需要 platform:firstleg:smart-loading:push 权限。真实柜号必须已录入,
否则不得推送预装清单或创建 PDA作业任务;计划必须处于 EDITING 且版本匹配。
推送事务会先为所有已分配入库单建立 PENDING 预装计划与 EDITING 柜次计划的正式关联,
任一关联缺失、版本冲突或分配数量为零都会整体失败并禁止 PDA/LOADING;不得在 LOADING 后补关联。
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {$ref: '#/components/parameters/IdempotencyKey'}
@@ -2,6 +2,7 @@ openapi: 3.0.3
info:
title: Phase 3 Preload and Container Loading Bridge API
version: 1.0.0
description: Links must be created while preload=PENDING and loadingPlan=EDITING. Smart-loading push creates all allocated links atomically before PDA task creation; LOADING plans cannot be repaired through post-hoc binding.
paths:
/api/platform/v1/inbounds/{inboundId}/preload/loading-plans:
get:
@@ -12,7 +13,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/preload/loading-plans/{loadingPlanId}:
post:
summary: Atomically link an inbound preload and a container loading plan
description: Both optimistic-lock versions must match; either conflict rolls back the whole link operation.
description: Both optimistic-lock versions must match; either conflict rolls back the whole link operation. This endpoint never permits post-hoc binding after the loading plan enters LOADING.
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/LoadingPlanId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody:
required: true
@@ -4,6 +4,8 @@ import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.BindRequest
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.LoadingPlanSnapshot;
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.PreloadSnapshot;
import com.baiyue.firstleg.loading.bridge.mapper.LoadingModelBridgeMapper.AllocationScope;
import com.baiyue.firstleg.loading.bridge.entity.PreloadLoadingPlanLinkEntity;
import com.baiyue.firstleg.loading.bridge.repository.LoadingModelBridgeRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -42,6 +44,53 @@ class LoadingModelBridgeServiceTest {
verify(repository).touchLoadingPlan(40, 3, 7, "admin");
}
@Test
void bindsEveryAllocatedInboundBeforeLoadingStarts() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2));
when(repository.allocatedPackages(10, 20, 40)).thenReturn(8);
when(repository.touchPreload(30, 10, 20, 2, 7, "admin")).thenReturn(1);
when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(1);
Link linked = link("PENDING", 3, "EDITING", 4, 8);
when(repository.byInbound(10, 20)).thenReturn(List.of(linked));
when(repository.byLoadingPlan(40)).thenReturn(List.of(linked));
List<Link> result = service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1");
assertThat(result).containsExactly(linked);
verify(repository).insert(any());
verify(repository).touchPreload(30, 10, 20, 2, 7, "admin");
verify(repository).touchLoadingPlan(40, 3, 7, "admin");
}
@Test
void refusesPostHocBindingAfterLoadingStarts() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
assertThatThrownBy(() -> service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
.hasMessageContaining("禁止在LOADING后补关联");
verify(repository, never()).insert(any());
}
@Test
void loadingReplayOnlyReadsAlreadyCompleteLinks() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
PreloadLoadingPlanLinkEntity existing = new PreloadLoadingPlanLinkEntity();
when(repository.byPair(10, 20, 40)).thenReturn(existing);
Link linked = link("PENDING", 3, "LOADING", 4, 8);
when(repository.byLoadingPlan(40)).thenReturn(List.of(linked));
assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
.containsExactly(linked);
verify(repository, never()).insert(any());
verify(repository, never()).touchLoadingPlan(anyLong(), anyInt(), anyLong(), anyString());
}
@Test
void secondVersionConflictDoesNotProceedAsSuccess() {
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2));
@@ -15,6 +15,7 @@ import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
@@ -41,6 +42,8 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -51,6 +54,7 @@ class SmartLoadingServiceTest {
@MockBean private SmartLoadingRepository repository;
@MockBean private OrderCargoClient cargoClient;
@MockBean private LoadingPickService loadingPickService;
@MockBean private LoadingModelBridgeService loadingModelBridge;
@Resource private SmartLoadingService service;
@TestConfiguration
@@ -384,6 +388,29 @@ class SmartLoadingServiceTest {
assertThat(result.status()).isEqualTo("EDITING");
verify(repository).push(1, "key", 3, 7, "u");
var ordered = inOrder(loadingModelBridge, loadingPickService);
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
ordered.verify(loadingPickService).beginIfReady(99, 7, "u");
}
@Test
void rollsBackPushOrchestrationBeforePdaWhenBindingFails() {
LoadingPlanEntity editing = plan("EDITING", 3);
LoadingPlanEntity pushed = plan("EDITING", 4);
pushed.setPushIdempotencyKey("key");
when(repository.plan(1)).thenReturn(editing, pushed);
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
when(repository.booking(99)).thenReturn(booking());
when(repository.push(1, "key", 3, 7, "u")).thenReturn(1);
doThrow(new BizException(ResultCode.CONFLICT, "预装计划不存在"))
.when(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
BizException error = catchThrowableOfType(
() -> service.pushPreload(1, "key", new PushRequest(3), 7, "u"), BizException.class);
assertThat(error.getMessage()).contains("预装计划不存在");
verify(repository).push(1, "key", 3, 7, "u");
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
}
@Test
@@ -30,6 +30,12 @@ test('fee writes use verified Firstleg paths and do not fabricate historical IDs
assert.match(source, /chargeUnit === 'CBM'/)
})
test('PAYABLE fee selection uses the formal warehousePayable flag without name heuristics', () => {
const selector = source.slice(source.indexOf('async function loadFeeItem'), source.indexOf('async function createManualFee'))
assert.match(selector, /item\.warehousePayable === true/)
assert.doesNotMatch(selector, /BOOKING\|FREIGHT|订舱|海运|运费/)
})
test('automatic fee fixture completes the reviewed pricing workflow without recalculating', () => {
assert.match(source, /latest\?\.feeId/)
assert.doesNotMatch(source, /latest\?\.id/)
@@ -106,7 +106,7 @@ async function loadFeeItem(context, direction) {
const list = context.apiSuccess(MODULE, `查询${direction}人工费用项目`, response, '', OPTIONS)
return rows(list).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
? item.warehouseReceivable === true
: item.warehousePayable === true && /BOOKING|FREIGHT|OCEAN|SHIPPING|订舱|海运|运费/i.test(`${item.feeCode || item.code || ''} ${item.feeName || item.name || ''}`))) || null
: item.warehousePayable === true)) || null
}
async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate, partner = null) {
@@ -24,6 +24,15 @@ test('所有写入均走订单生命周期 HTTP API 且不清理保留数据', (
assert.doesNotMatch(source, /cleanup|remove|unlink/i)
})
test('状态矩阵复用订单共享的逐仓真实报价选择,不假设首仓或首税制', () => {
assert.match(source, /findPricedOrderRouteAcrossOrigins/)
assert.match(source, /loadChannels: \(receiving\)/)
assert.match(source, /pricing\/estimate/)
assert.doesNotMatch(source, /rows\(.*domestic.*\)\[0\]/)
assert.doesNotMatch(source, /pricingTaxModes\[0\]/)
assert.match(source, /pricingTaxMode: priced\.priced\.pricingTaxMode/)
})
test('入库通过真实订单测量链保留测量态并最终进入 WAIT_SPLIT', () => {
assert.match(source, /result\.inbounds\.WAIT_MEASURE/)
assert.match(source, /processStatus: 'WAIT_MEASURE'/)
@@ -1,3 +1,5 @@
import { findPricedOrderRouteAcrossOrigins } from './orders.mjs'
const MODULE = 'order-inbound-status-fixtures'
const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true }
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin'
@@ -64,19 +66,22 @@ async function loadFixtureInputs(context, token) {
const profile = context.apiSuccess(MODULE, '读取 IT 测试客户资料', profileResponse)
const domesticResponse = await merchantGet(context,
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', token)
const domestic = rows(context.apiSuccess(MODULE, '读取国内收货仓', domesticResponse))[0]
const domesticPage = context.apiSuccess(MODULE, '读取国内收货仓', domesticResponse)
const receivings = rows(domesticPage).filter((item) => item?.status === 'ACTIVE')
const destinationResponse = await merchantGet(context,
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', token)
const destination = rows(context.apiSuccess(MODULE, '读取 FBA 目的仓', destinationResponse))[0]
if (!profile?.id || !domestic?.id || !destination?.id) throw new Error('状态样本缺少客户或仓库基础数据')
const channelResponse = await merchantGet(context,
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${query(domestic.id)}`, token)
const channels = context.apiSuccess(MODULE, '读取可下单渠道', channelResponse)
const channel = Array.isArray(channels)
? channels.find((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0)
: null
if (!channel) throw new Error('状态样本缺少带计价税制的可用渠道')
return { tenantId: id(profile.id), customerName: profile.tenantName || 'IT 测试客户', domestic, destination, channel }
const destinations = rows(context.apiSuccess(MODULE, '读取 FBA 目的仓', destinationResponse))
if (!profile?.id || receivings.length === 0 || destinations.length === 0) throw new Error('状态样本缺少客户或仓库基础数据')
const priced = await findPricedOrderRouteAcrossOrigins({
receivings, destinations,
loadChannels: (receiving) => merchantGet(context,
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${query(receiving.id)}`, token),
estimate: (body) => merchantRequest(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, token),
})
if (!priced.receiving || !priced.priced) throw new Error('状态样本缺少真实可计价的仓库/渠道/目的仓组合')
return { tenantId: id(profile.id), customerName: profile.tenantName || 'IT 测试客户',
domestic: priced.receiving, destination: priced.priced.destination, channel: priced.priced.channel,
pricingTaxMode: priced.priced.pricingTaxMode }
}
function orderPayload(context, fixture, target) {
@@ -94,7 +99,7 @@ function orderPayload(context, fixture, target) {
receivingWarehouseId: fixture.domestic.id, receivingWarehouseName: fixture.domestic.warehouseName,
channelCode: fixture.channel.channelCode, channelName: fixture.channel.channelName,
carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION',
pricingTaxMode: fixture.channel.pricingTaxModes[0], containerMode: 'LCL', customsInspection: false,
pricingTaxMode: fixture.pricingTaxMode, containerMode: 'LCL', customsInspection: false,
destinationType: 'FBA', amazonWarehouseCode: fixture.destination.warehouseCode,
shipmentId, referenceId,
sender: { companyName: fixture.customerName, contactName: 'IT 测试联系人', phone: '13800138000',
@@ -10,8 +10,9 @@ test('each remainder uses a simulation linked to a confirmed then released booki
assert.match(source, /一次登记直接返回 CONFIRMED/)
assert.doesNotMatch(source, /ocean-bookings\/\$\{oceanBookingId\}\/(?:submit|confirm)/)
const createPayload = source.slice(source.indexOf('function bookingPayload'), source.indexOf('async function loadOneFragment'))
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt',
'estimatedArrivalAt']) assert.match(createPayload, new RegExp(`${field}:`))
assert.match(createPayload, /oceanBookingDraft\(\{ marker \}/)
assert.match(createPayload, /customerId: source\.tenantId/)
assert.match(createPayload, /channelCode: source\.channelCode/)
assert.match(source, /ocean-booking-link/)
assert.match(source, /bookingDetail\.oceanBooking\.status === 'CONFIRMED'/)
assert.match(source, /`\$\{bookingPath\}\/release`/)
@@ -69,5 +70,11 @@ test('booking freezes the source customer channel and warehouse scope', () => {
assert.match(source, /customerId: source\.tenantId/)
assert.match(source, /channelCode: source\.channelCode/)
assert.match(source, /warehouseId: source\.warehouseId/)
assert.match(source, /containerRequirements: \[\{ containerType: '40HQ'/)
assert.match(source, /containerType === '40HQ'/)
})
test('booking fixture never invents supplier names without formal IDs', () => {
assert.match(source, /oceanBookingDraft/)
assert.doesNotMatch(source, /SPLIT-SHIPPING|SPLIT-AGENT/)
assert.doesNotMatch(source, /shippingCompanyName: `|bookingAgentName: `/)
})
@@ -7,6 +7,8 @@ const CONTAINERS = '/api/platform/v1/containers'
const SMART = '/api/platform/v1/first-leg/smart-loading'
const PDA = '/api/platform/v1/first-leg/pda-loading'
import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function isPositiveId(value) { return /^[1-9][0-9]*$/.test(id(value)) }
@@ -118,23 +120,11 @@ function snapshotMatches(tree, original, loaded, remaining, activeSuffix) {
function bookingPayload(marker, sequence, source) {
const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18)
return {
customerId: source.tenantId, customerName: source.customerName,
channelCode: source.channelCode,
return oceanBookingDraft({ marker }, suffix, {
customerId: source.tenantId, customerName: source.customerName, channelCode: source.channelCode,
warehouseId: source.warehouseId, warehouseName: source.warehouseName,
billOfLadingNo: `BL-${suffix}`.slice(0, 128),
shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128),
bookingAgentId: null, bookingAgentName: `SPLIT-AGENT-${suffix}`.slice(0, 128),
vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `SV-${sequence}`.slice(0, 64),
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00', remark: marker,
parties: [{ partyType: 'SHIPPER', partyName: `SPLIT-SHIPPER-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `SPLIT-CONSIGNEE-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `SPLIT-NOTIFY-${suffix}` }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100,
maxWeightKg: 100000, quantity: 1 }],
}
}, { vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `SV-${sequence}`.slice(0, 64),
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' })
}
async function loadOneFragment(context, state) {
@@ -62,6 +62,22 @@ test('create edit and stale replay payloads keep the resolved booking identity s
assert.deepEqual(payloads.map((payload) => payload.expectedVersion), [0, 3, 0])
})
test('truck payload sends carrier fields only as a verified ID/name pair', () => {
const identity = { oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户' }
const missing = truckAppointmentPayload({ marker: 'RUN-2', createdResources: {} }, '2', identity)
assert.equal(Object.hasOwn(missing, 'carrierId'), false)
assert.equal(Object.hasOwn(missing, 'carrierName'), false)
const valid = truckAppointmentPayload({ marker: 'RUN-2', createdResources: {
truckSupplier: { supplierId: '501', supplierName: '正式拖车商' },
} }, '2', identity)
assert.deepEqual({ carrierId: valid.carrierId, carrierName: valid.carrierName }, { carrierId: '501', carrierName: '正式拖车商' })
const incomplete = truckAppointmentPayload({ marker: 'RUN-2', createdResources: {
truckSupplier: { supplierId: '501', supplierName: null },
} }, '2', identity)
assert.equal(Object.hasOwn(incomplete, 'carrierId'), false)
assert.equal(Object.hasOwn(incomplete, 'carrierName'), false)
})
test('truck appointment source has no historical list or hardcoded identity fallback', () => {
const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8')
assert.doesNotMatch(source, /customerId:\s*['"]10001['"]|warehouseId:\s*['"]193000000000000001['"]/)
@@ -69,10 +69,15 @@ export function inspectOceanBookingIdentity(marker, resource, detail = null) {
export function truckAppointmentPayload(context, suffix, identity, version = 0) {
const supplier = context.createdResources?.truckSupplier || {}
const supplierId = id(supplier.supplierId)
const supplierName = id(supplier.supplierName)
const carrierSnapshot = supplierId && supplierName
? { carrierId: supplierId, carrierName: supplierName }
: {}
return {
outboundOrderId: null, oceanBookingId: identity.oceanBookingId, containerBookingId: null, loadingPlanId: null,
warehouseId: identity.warehouseId, customerId: identity.customerId, customerName: identity.customerName,
carrierId: supplier.supplierId || null, carrierName: supplier.supplierName || `承运商-${suffix}`, fleetContact: '车队联系人', fleetPhone: '13800000004',
...carrierSnapshot, fleetContact: '车队联系人', fleetPhone: '13800000004',
driverName: '司机验收', driverPhone: '13800000005', driverIdCard: null, vehicleNo: `粤B-${suffix.slice(-6)}`,
storeName: `门店-${suffix}`, storeContact: '门店联系人', storePhone: '13800000006', storeAddress: '深圳市南山区验收地址',
storeCity: '深圳市', storeZipcode: '518000', packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5,
@@ -96,11 +101,12 @@ async function ensureTruckSupplier(context, suffix) {
const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(supplierId)}?tenantId=0`)
supplier = data(detailResponse)
}
context.addStep(MODULE, '创建本轮有效拖车供应商', response,
Boolean(supplierId) && supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE',
context.addStep(MODULE, '创建本轮有效拖车供应商(可选)', response,
!supplierId || (supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE' && Boolean(supplier?.name)),
`supplierId=${supplierId || 'missing'}, category=${supplier?.category || 'missing'}, status=${supplier?.status || 'missing'}`, OPTIONS)
if (supplierId) context.createdResources.truckSupplier = {
marker: context.marker, supplierId, supplierName: supplier.name, finalStatus: 'ACTIVE', retained: true,
marker: context.marker, supplierId, supplierName: supplier.name || supplier.supplierName || null,
category: supplier.category, status: supplier.status, finalStatus: 'ACTIVE', retained: true,
}
return context.createdResources.truckSupplier || null
}
@@ -148,8 +154,7 @@ export async function run(context) {
}
const bookingIdentity = Object.freeze({ ...identityResult.identity })
const truckSupplier = await ensureTruckSupplier(context, suffix)
if (!truckSupplier) return
await ensureTruckSupplier(context, suffix)
const createResponse = await request(context, 'POST', BASE, truckAppointmentPayload(context, suffix, bookingIdentity)); const appointment = context.apiSuccess(MODULE, '创建隔离拖车预约', createResponse, '', OPTIONS)
const appointmentId = id(appointment?.id)