fix(acceptance): enforce immutable confirmed fees

This commit is contained in:
2026-09-10 21:18:52 +08:00
parent 7b35373da3
commit 800ebb875f
14 changed files with 116 additions and 53 deletions
@@ -222,6 +222,7 @@ public class CarrierScheduleQueryService {
}
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
requireManualActualAdvance(value, node, request.actualAt());
CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
@@ -306,6 +307,16 @@ public class CarrierScheduleQueryService {
return false;
}
private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
LocalDateTime actualAt) {
if (actualAt == null) return;
CarrierScheduleNode current = value.getLatestStandardNode() == null ? null
: CarrierScheduleNode.require(value.getLatestStandardNode());
if ("STOPPED".equals(value.getStatus()) || current != null && node.sequence() <= current.sequence()) {
throw conflict("实际船节点必须按业务顺序向后推进");
}
}
public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) {
CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
@@ -98,8 +98,8 @@ public class FbaPricingService {
if (published == null || !"SENT".equals(published.getFeeStatus())) {
throw conflict("费用状态已变化,请刷新后重试");
}
if (feeRevisionLocked(inbound, published)) {
throw conflict("客户已确认且该票已装柜完成,不允许再修改或重新发送费用");
if (feeRevisionLocked(published)) {
throw conflict("客户已确认,不允许再修改或重新发送费用");
}
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
@@ -490,8 +490,8 @@ public class FbaPricingService {
if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
throw conflict("入库单没有可修改的初算或已发布费用");
}
if (feeRevisionLocked(inbound, current)) {
throw conflict("客户已确认且该票已装柜完成,不允许再修改费用");
if (feeRevisionLocked(current)) {
throw conflict("客户已确认,不允许再修改费用");
}
if (!request.expectedFeeVersion().equals(current.getFeeVersion())) {
throw conflict("费用版本已变化,请刷新后重试");
@@ -613,9 +613,8 @@ public class FbaPricingService {
.contains(processStatus);
}
private static boolean feeRevisionLocked(InboundPricingEntity inbound, InboundFeeEntity fee) {
return fee != null && fee.getCustomerConfirmedAt() != null
&& List.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
private static boolean feeRevisionLocked(InboundFeeEntity fee) {
return fee != null && fee.getCustomerConfirmedAt() != null;
}
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
@@ -474,6 +474,26 @@ class CarrierScheduleQueryServiceTest {
verify(repository).updateState(before, 3, 7L, "admin");
}
@Test
void manualActualFactRejectsCurrentOrEarlierBusinessNodeBeforeWritingFact() {
CarrierScheduleQueryEntity task = task(0, 3);
task.setStatus("READY"); task.setLatestStandardNode("AT_ORIGIN_PORT");
task.setLatestNodeObservedAt(NOW.minusHours(1));
when(repository.lock(1L)).thenReturn(task);
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-same-1",
new ManualNodeRequest(3, "AT_ORIGIN_PORT", NOW, null, NOW,
"CNSZX", "深圳", "重复当前节点")))
.isInstanceOf(BizException.class).hasMessageContaining("按业务顺序向后推进");
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-earlier-1",
new ManualNodeRequest(3, "DEPARTED_WAREHOUSE", NOW, null, NOW,
"CNSZX", "深圳", "补录更早节点")))
.isInstanceOf(BizException.class).hasMessageContaining("按业务顺序向后推进");
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
}
@Test
void holdIsPersistedAsSeparateImmutableExceptionFact() {
CarrierScheduleQueryEntity task = task(0, 1);
@@ -461,7 +461,6 @@ class FbaPricingServiceTest {
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("SENT");
InboundFeeEntity current = savedFee("SENT");
current.setCustomerConfirmedAt(NOW.minusHours(1));
AtomicReference<InboundFeeEntity> inserted = new AtomicReference<>();
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
@@ -531,10 +530,10 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("客户已确认且装柜完成时拒绝平台再次修改")
@DisplayName("客户确认后即拒绝平台再次修改")
void rejectsAdjustmentAfterCustomerConfirmation() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("LOADED");
inbound.setProcessStatus("WAIT_SPLIT");
InboundFeeEntity current = savedFee("SENT");
current.setCustomerConfirmedAt(NOW);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
+8 -5
View File
@@ -531,6 +531,7 @@ export class AcceptanceRun {
this.modules = []
this.scenarios = []
this.createdResources = {}
this.phase4ProducerAttempts = new Map()
this.deploymentBlockers = []
this.coverageGaps = []
this.context = {}
@@ -1466,7 +1467,9 @@ export class AcceptanceRun {
async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD', pickupRequired = false) {
const matrixMarker = this.marker
const variantMarker = `${matrixMarker}-P4-${label}`
const attempt = (this.phase4ProducerAttempts.get(label) || 0) + 1
this.phase4ProducerAttempts.set(label, attempt)
const variantMarker = `${matrixMarker}-P4-${label}${attempt === 1 ? '' : `-R${attempt}`}`
const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker']
const createdKeys = [
'phase3Loading', 'phase3LoadingCompletedProducer', 'phase3ContainerDocuments', 'orchestrationFixtures', 'oceanBooking',
@@ -1571,7 +1574,7 @@ export class AcceptanceRun {
const hasStandardFbaProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || [])
.some((item) => item?.destinationType === 'FBA'
&& item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP'
&& String(item?.resourceKey || '').includes('-P4-FBA-STANDARD:'))
&& /-P4-FBA-STANDARD(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
if (!hasStandardFbaProducer()) {
await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', true)
}
@@ -1579,7 +1582,7 @@ export class AcceptanceRun {
await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true)
}
const hasSecondaryProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || [])
.some((item) => String(item?.resourceKey || '').includes('-P4-SECONDARY:'))
.some((item) => /-P4-SECONDARY(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
if (!hasSecondaryProducer()) {
await this.runPhase4ProducerVariant('SECONDARY', 'FBA', 'STANDARD', false)
}
@@ -1588,10 +1591,10 @@ export class AcceptanceRun {
const dropshipReady = containers.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP')
const standardFbaReady = containers.find((item) => item?.destinationType === 'FBA'
&& item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP'
&& String(item?.resourceKey || '').includes('-P4-FBA-STANDARD:'))
&& /-P4-FBA-STANDARD(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
const overseasReady = containers.find((item) => item?.destinationType === 'OVERSEAS')
const awaitingVariants = containers.filter((item) => ['SECONDARY']
.some((label) => String(item?.resourceKey || '').includes(`-P4-${label}:`)))
.some((label) => new RegExp(`-P4-${label}(?:-R\\d+)?:`).test(String(item?.resourceKey || ''))))
const shippingMatrixReady = dropshipReady?.shippingReady === true
&& Boolean(dropshipReady?.carrierScheduleQueryId)
&& standardFbaReady?.shippingReady === true && Boolean(standardFbaReady?.carrierScheduleQueryId)
@@ -43,4 +43,6 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async (
assert.doesNotMatch(source, /跨仓库渠道或目的仓港创建整组被拒绝/)
assert.doesNotMatch(source, /已有父子拆分订单创建同柜组被拒绝/)
assert.doesNotMatch(source, /push-preload/)
assert.match(source, /import \{ channelGroup \} from '\.\/smart-loading-channel-group\.mjs'/)
assert.equal((source.match(/channelGroupCode:\s*channelGroup\(/g) || []).length, 6)
})
@@ -1,6 +1,7 @@
const MODULE = 'coload-groups'
const OPTIONS = { scenario: 'indivisible-coload-group', deploymentBlocker: true }
const CONTAINERS = '/api/platform/v1/containers'
import { channelGroup } from './smart-loading-channel-group.mjs'
const SMART = '/api/platform/v1/first-leg/smart-loading'
const FIRST_LEG = '/api/platform/v1/first-leg'
const PDA = '/api/platform/v1/first-leg/pda-loading'
@@ -10,19 +11,6 @@ import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
function id(value) { return value == null ? '' : String(value) }
function channelGroup(channelCode) {
const value = String(channelCode || '').toUpperCase()
if (value.startsWith('EXX_')) return 'EXX'
if (value.startsWith('MATSON_CLX_')) return 'MATSON_CLX'
if (value.startsWith('MATSON_MAX_')) return 'MATSON_MAX'
if (value.startsWith('HEDE_')) return 'HEDE'
if (value.startsWith('OA_LA_') || value === 'OA_PARCEL') return 'OA_LA'
if (value.startsWith('OA_CHICAGO_')) return 'OA_CHICAGO'
if (value.startsWith('OA_HOUSTON_')) return 'OA_HOUSTON'
if (value.startsWith('OA_SAVANNAH_')) return 'OA_SAVANNAH'
if (value === 'OA_NEW_YORK_TRUCK') return value
throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
}
function code(response) { return id(response?.body?.code) || 'missing' }
function evidence(response, extra = '') {
return `HTTP=${response?.http ?? 'missing'}, code=${code(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}${extra ? `, ${extra}` : ''}`
@@ -105,7 +93,7 @@ async function createSecondGroup(context, third, fourth) {
async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
const simulationResponse = await context.request('POST', CONTAINERS, {
channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC',
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse,
@@ -131,7 +119,7 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
async function createNormalPushedPlan(context, fixture, suffix) {
const simulationResponse = await context.request('POST', CONTAINERS, {
channelCode: fixture.channelCode, warehouseId: fixture.warehouseId, containerType: '40HC',
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse,
@@ -522,7 +510,7 @@ export async function run(context) {
`status=${queried.data?.status || 'missing'}, members=${summaryInboundCodes(queried.data).join('|')}`, OPTIONS)
const simulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '20GP',
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '20GP',
capacityCbm: 33, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用20GP模拟柜', simulationResponse,
@@ -613,7 +601,7 @@ export async function run(context) {
`targetGroupId=${groupId}, assignedMembers=${capacityGroupItems.map((item) => id(item?.inboundId)).join('|') || 'none'}, before=${planSignature(beforeRecommend)}, after=${planSignature(capacityRecommendation)}`, OPTIONS)
const recommendSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HC模拟柜',
@@ -659,7 +647,7 @@ export async function run(context) {
}
const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelCode: first.channelCode, warehouseId: first.warehouseId, containerType: '40HC',
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HC模拟柜', workingSimulationResponse,
@@ -978,7 +966,7 @@ export async function run(context) {
const uiGroup = context.apiSuccess(MODULE, '为Admin页面保留ACTIVE未分配跨目的仓组', uiGroupResponse,
evidence(uiGroupResponse), OPTIONS)
const uiSimulationResponse = await context.request('POST', CONTAINERS, {
channelCode: fifth.channelCode, warehouseId: fifth.warehouseId, containerType: '40HC',
channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse,
@@ -18,6 +18,7 @@ test('candidate plans only come from resources created by the current acceptance
financeFullFixtures: { feeSources: [{ loadingPlanId: '104' }] },
}), ['101', '102', '103', '104'])
assert.doesNotMatch(source, /pageSize=\d+.*smart-loading|containers\?page|历史柜/)
assert.match(source, /channelGroupCode:\s*channelGroup\(first\.channelCode\)/)
})
test('allocation builder selects one real order per distinct customer and preserves exact cents', () => {
@@ -2,6 +2,7 @@ const MODULE = 'firstleg-container-fee-allocation'
const OPTIONS = { scenario: 'firstleg-container-fee-allocation-http', deploymentBlocker: true }
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
import { channelGroup } from './smart-loading-channel-group.mjs'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] }
@@ -140,7 +141,7 @@ async function provisionCrossCustomerPlan(context) {
|| id(first.receivingWarehouseId) !== id(second.receivingWarehouseId)
|| first.channelCode !== second.channelCode) return null
const containerResponse = await context.request('POST', '/api/platform/v1/containers', {
channelCode: first.channelCode, warehouseId: id(first.receivingWarehouseId),
channelGroupCode: channelGroup(first.channelCode), warehouseId: id(first.receivingWarehouseId),
containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
})
const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse,
@@ -17,6 +17,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro
assert.match(source, /\.\/orders\.mjs/)
assert.match(source, /\.\/inbound-measurement\.mjs/)
assert.match(source, /context\.request\('POST', CONTAINERS/)
assert.match(source, /channelGroupCode:\s*channelGroup\(summary\.channelCode\)/)
assert.match(source, /summary\?\.processStatus === 'WAIT_SPLIT'/)
const pushIndex = source.indexOf('`${SMART}/plans/${plan.id}`')
const transportIndex = source.indexOf('ocean-booking-link')
@@ -9,6 +9,7 @@ const PRELOAD_STATUSES = ['EDITING', 'LOADING']
const PDA_STATUSES = ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETED']
import { resolveOceanBookingAgent } from './ocean-booking-fixture.mjs'
import { channelGroup } from './smart-loading-channel-group.mjs'
function id(value) { return value == null ? '' : String(value) }
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
@@ -127,7 +128,7 @@ async function createConfirmedContainer(context, source, label) {
maxWeightKg: 28000 }],
}
const simulationResponse = await context.request('POST', CONTAINERS, {
channelCode: summary.channelCode, warehouseId: source.warehouseId, containerType: '40HC',
channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
@@ -106,6 +106,14 @@ test('booking freezes the source customer channel and warehouse scope', () => {
assert.match(source, /containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000/)
})
test('split chain starts from the partial inbound and derives quantities from the real tree', () => {
assert.match(source, /fixture\?\.incrementalInboundId \|\| fixture\?\.inboundId/)
assert.match(source, /const originalPackageCount = Number\(firstTree\?\.originalPackageCount\)/)
assert.match(source, /while \(activeRemainders\(tree\)\.length > 0\)/)
assert.match(source, /sourcePackageCount = Number\(source\?\.packageCount\)/)
assert.doesNotMatch(source, /snapshotMatches\(firstTree, 4, 1, 3, 1\)/)
})
test('booking fixture never invents supplier names without formal IDs', () => {
assert.match(source, /oceanBookingDraft/)
assert.doesNotMatch(source, /SPLIT-SHIPPING|SPLIT-AGENT/)
@@ -486,7 +486,7 @@ async function loadOneFragment(context, state) {
export async function run(context) {
const fixture = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading
const tenantId = id(fixture?.tenantId)
const inboundId = id(fixture?.inboundId)
const inboundId = id(fixture?.incrementalInboundId || fixture?.inboundId)
const warehouseId = id(fixture?.warehouseId)
const containerBookingId = id(fixture?.containerBookingId)
if (!tenantId || !inboundId || !warehouseId || !containerBookingId || fixture?.marker !== context.marker) {
@@ -513,8 +513,20 @@ export async function run(context) {
const firstTreeResponse = await context.request('GET', treePath(orderId, tenantId))
const firstTree = context.apiSuccess(MODULE, '查询首次拆分后的根履约树', firstTreeResponse,
`orderId=${orderId}, tenantId=${tenantId}`, OPTIONS)
const originalPackageCount = Number(firstTree?.originalPackageCount)
const initialLoadedPackageCount = Number(firstTree?.cumulativeLoadedPackageCount)
const initialRemainingPackageCount = Number(firstTree?.currentRemainderPackageCount)
const initialActive = activeRemainders(firstTree)
const initialActiveSequence = Number(initialActive[0]?.splitSequence)
context.assert(MODULE, '新批次由来源单承接已装且只创建一个余量子单', firstTreeResponse,
treeShape(firstTree, orderId) && snapshotMatches(firstTree, 4, 1, 3, 1)
treeShape(firstTree, orderId)
&& Number.isInteger(originalPackageCount) && originalPackageCount > 1
&& Number.isInteger(initialLoadedPackageCount) && initialLoadedPackageCount > 0
&& Number.isInteger(initialRemainingPackageCount) && initialRemainingPackageCount > 0
&& originalPackageCount === initialLoadedPackageCount + initialRemainingPackageCount
&& initialActive.length === 1 && Number.isInteger(initialActiveSequence)
&& snapshotMatches(firstTree, originalPackageCount, initialLoadedPackageCount,
initialRemainingPackageCount, initialActiveSequence)
&& firstTree.children.filter((row) => row.splitRole === 'LOADED').length === 1
&& firstTree.children.filter((row) => row.splitRole === 'REMAINDER').length === 1,
`original=${firstTree?.originalPackageCount ?? 'missing'}, loaded=${firstTree?.cumulativeLoadedPackageCount ?? 'missing'}, remaining=${firstTree?.currentRemainderPackageCount ?? 'missing'}`, OPTIONS)
@@ -536,7 +548,9 @@ export async function run(context) {
`/api/merchant/v1/orders/${encodeURIComponent(orderId)}/fulfillment-tree`, undefined, merchantToken)
const merchantTree = context.apiSuccess(MODULE, '客户查询本客户根履约树', merchantTreeResponse, '', OPTIONS)
context.assert(MODULE, '客户履约树与平台数量聚合一致', merchantTreeResponse,
treeShape(merchantTree, orderId) && snapshotMatches(merchantTree, 4, 1, 3, 1),
treeShape(merchantTree, orderId)
&& snapshotMatches(merchantTree, originalPackageCount, initialLoadedPackageCount,
initialRemainingPackageCount, initialActiveSequence),
`children=${merchantTree?.children?.length ?? 'missing'}`, OPTIONS)
const forbidden = await request(context, 'GET', treePath(orderId, tenantId), undefined, {}, merchantToken)
@@ -571,41 +585,54 @@ export async function run(context) {
cleanupNote: '公开 API 不允许删除订单/柜次/PDA审计记录;只有全部分片完成且无活动余量时标记业务清理完成。',
}
let tree = firstTree
for (const step of [
{ sourcePackageCount: 3, expectedLoaded: 2, expectedRemaining: 2, sourceSuffix: 1, activeSuffix: 2 },
{ sourcePackageCount: 2, expectedLoaded: 3, expectedRemaining: 1, sourceSuffix: 2, activeSuffix: 3 },
{ sourcePackageCount: 1, expectedLoaded: 4, expectedRemaining: 0, sourceSuffix: 3, activeSuffix: null },
]) {
let completedFragments = 0
while (activeRemainders(tree).length > 0) {
const active = activeRemainders(tree)
const source = active[0]
context.assert(MODULE, `链式拆分来源固定为根编号 -${step.sourceSuffix}`, null,
active.length === 1 && source?.orderNo === `${tree.parentOrderNo}-${step.sourceSuffix}`,
const sourceSuffix = Number(source?.splitSequence)
const sourcePackageCount = Number(source?.packageCount)
const beforeLoaded = Number(tree.cumulativeLoadedPackageCount)
const beforeRemaining = Number(tree.currentRemainderPackageCount)
context.assert(MODULE, `链式拆分来源固定为根编号 -${sourceSuffix}`, null,
active.length === 1 && Number.isInteger(sourceSuffix) && sourceSuffix > 0
&& source?.orderNo === `${tree.parentOrderNo}-${sourceSuffix}`
&& Number.isInteger(sourcePackageCount) && sourcePackageCount === beforeRemaining,
`active=${active.map((row) => row.orderNo).join(',') || 'none'}`, OPTIONS)
if (!source) return
const result = await loadOneFragment(context, {
tenantId, rootOrderId: orderId, sourceOrderId: id(source.orderId), sourceOrderNo: source.orderNo,
customerName, channelCode, channelName, warehouseId, warehouseName,
sourcePackageCount: step.sourcePackageCount, sequence: step.sourceSuffix,
sourcePackageCount, sequence: sourceSuffix,
})
if (!result?.tree) return
tree = result.tree
completedFragments += 1
const expectedLoaded = beforeLoaded + 1
const expectedRemaining = beforeRemaining - 1
const activeSuffix = expectedRemaining > 0 ? sourceSuffix + 1 : null
Object.assign(context.createdResources.phase3OrderSplitting, {
activeRemainderCount: activeRemainders(tree).length,
finalLoadedPackageCount: Number(tree.cumulativeLoadedPackageCount),
finalRemainingPackageCount: Number(tree.currentRemainderPackageCount),
})
context.assert(MODULE, `完成第 ${step.sourceSuffix + 1} 个分片后根数量守恒`, result.treeResponse,
context.assert(MODULE, `完成余量分片 -${sourceSuffix} 后根数量守恒`, result.treeResponse,
treeShape(tree, orderId)
&& snapshotMatches(tree, 4, step.expectedLoaded, step.expectedRemaining, step.activeSuffix),
&& snapshotMatches(tree, originalPackageCount, expectedLoaded, expectedRemaining, activeSuffix),
`original=${tree.originalPackageCount}, loaded=${tree.cumulativeLoadedPackageCount}, remaining=${tree.currentRemainderPackageCount}`, OPTIONS)
if (completedFragments > originalPackageCount) {
context.addStep(MODULE, '余量链在原始箱数边界内收敛', null, false,
`original=${originalPackageCount}, fragments=${completedFragments}`, OPTIONS)
return
}
}
const remainderCodes = tree.children.filter((row) => row.splitRole === 'REMAINDER').map((row) => row.orderNo)
const expectedRemainderCodes = Array.from({ length: completedFragments }, (_, index) =>
`${tree.parentOrderNo}-${initialActiveSequence + index}`)
context.assert(MODULE, '余量子单始终按根编号全局递增且禁止嵌套编号', null,
JSON.stringify(remainderCodes) === JSON.stringify([
`${tree.parentOrderNo}-1`, `${tree.parentOrderNo}-2`, `${tree.parentOrderNo}-3`,
]) && remainderCodes.every((code) => !/-[0-9]+-[0-9]+$/.test(code)),
JSON.stringify(remainderCodes) === JSON.stringify(expectedRemainderCodes)
&& remainderCodes.every((code) => !/-[0-9]+-[0-9]+$/.test(code)),
`codes=${remainderCodes.join(',')}`, OPTIONS)
context.assert(MODULE, '最后一次全量装完不再生成子单且根整体完成', null,
activeRemainders(tree).length === 0 && Number(tree.currentRemainderPackageCount) === 0
@@ -160,10 +160,12 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV
assert.match(source, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/)
assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true\)/)
assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', true\)/)
assert.match(source, /includes\('-P4-FBA-STANDARD:'\)/)
assert.match(source, /-P4-FBA-STANDARD\(\?:-R\\d\+\)\?:/)
assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/)
assert.match(source, /runUntilPickupReady\(this\)/)
const variant = source.slice(source.indexOf('async runPhase4ProducerVariant'), source.indexOf('async ensurePhase4PdaProducerMatrix'))
assert.match(variant, /this\.phase4ProducerAttempts\.get\(label\)/)
assert.match(variant, /attempt === 1 \? '' : `-R\$\{attempt\}`/)
assert.ok(variant.indexOf('ensureInitialCalculated') < variant.indexOf('phase3-loading-phase4-'))
assert.match(variant, /fulfillmentType === 'ONE_CLICK_DROPSHIP'/)
assert.doesNotMatch(variant, /inboundFees\.run\(this\)/)