This commit is contained in:
2026-08-12 12:18:59 +08:00
parent 5a26a84f78
commit 0fceece378
8 changed files with 113 additions and 31 deletions
@@ -12,6 +12,15 @@ public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update("""
UPDATE fl_inbound_order
SET process_status = 'INBOUNDED', measure_review_status = 'APPROVED',
actual_gross_weight_kg = (SELECT COALESCE(SUM(item.total_weight_kg), 0)
FROM fl_inbound_measurement_item item
WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}),
actual_volume_cbm = (SELECT COALESCE(SUM(item.total_volume_cbm), 0)
FROM fl_inbound_measurement_item item
WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}),
volume_weight_kg = (SELECT COALESCE(SUM(item.volume_weight_kg), 0)
FROM fl_inbound_measurement_item item
WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}),
inbounded_at = COALESCE(inbounded_at, CURRENT_TIMESTAMP(3)),
updated_by = #{operatorId}, version = version + 1
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
@@ -20,7 +29,8 @@ public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
""")
int approveMeasurement(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("operatorId") long operatorId,
@Param("expectedVersion") int expectedVersion);
@Param("expectedVersion") int expectedVersion,
@Param("measurementId") long measurementId);
@Update("""
UPDATE fl_inbound_order
@@ -67,9 +67,9 @@ public class InboundWorkflowRepository {
}
public int transitionReview(long tenantId, long inboundId, long operatorId, int expectedVersion,
boolean approved) {
boolean approved, long measurementId) {
return approved
? orders.approveMeasurement(tenantId, inboundId, operatorId, expectedVersion)
? orders.approveMeasurement(tenantId, inboundId, operatorId, expectedVersion, measurementId)
: orders.rejectMeasurement(tenantId, inboundId, operatorId, expectedVersion);
}
@@ -18,6 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.math.BigDecimal;
/**
* 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 INBOUNDED;
@@ -69,8 +70,8 @@ public class MeasurementReviewService {
throw new BizException(ResultCode.CONFLICT, "测量批次不是待审核状态");
}
if (approved) {
long confirmed = repository.listMeasurementItems(tenantId, measurement.getId()).stream()
.map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getPackageCount)
var items = repository.listMeasurementItems(tenantId, measurement.getId());
long confirmed = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getPackageCount)
.filter(java.util.Objects::nonNull)
.mapToLong(Integer::longValue)
.sum();
@@ -78,10 +79,18 @@ public class MeasurementReviewService {
throw new BizException(ResultCode.VALIDATION_FAILED,
"人工确认箱数合计与入库单总箱数不一致,不能审核通过");
}
BigDecimal weight = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalWeightKg)
.filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal volume = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalVolumeCbm)
.filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
if (weight.signum() <= 0 || volume.signum() <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"审核通过的测量快照必须包含大于零的实重和体积");
}
}
if (repository.transitionReview(tenantId, inboundId, operatorId,
request.expectedVersion(), approved) != 1) {
request.expectedVersion(), approved, request.measurementId()) != 1) {
throw concurrentConflict();
}
String result = approved ? "APPROVED" : "REJECTED";
@@ -18,6 +18,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import java.math.BigDecimal;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
@@ -57,20 +58,20 @@ class MeasurementReviewServiceTest {
.isEqualTo(ResultCode.NOT_FOUND);
verify(repository).findOwned(10002L, 10L);
verify(repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean());
verify(repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@DisplayName("审核通过是测量到计费的唯一正常推进关口")
void shouldApproveOnlyPendingSubmittedMeasurement() {
Fixture fixture = fixture(false);
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true)).thenReturn(1);
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-1",
new MeasurementReviewRequest(20L, 3, null));
verify(fixture.repository).transitionReview(10001L, 10L, 9L, 3, true);
verify(fixture.repository).transitionReview(10001L, 10L, 9L, 3, true, 20L);
verify(fixture.repository).transitionMeasurement(10001L, 10L, 20L, "APPROVED");
verify(fixture.repository).insertReview(any());
ArgumentCaptor<InboundStatusHistoryEntity> history =
@@ -94,7 +95,7 @@ class MeasurementReviewServiceTest {
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean());
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@@ -106,7 +107,7 @@ class MeasurementReviewServiceTest {
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean());
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@@ -120,7 +121,7 @@ class MeasurementReviewServiceTest {
new MeasurementReviewRequest(20L, 3, null));
verify(fixture.repository, never()).findOwned(anyLong(), anyLong());
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean());
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@@ -158,7 +159,7 @@ class MeasurementReviewServiceTest {
Fixture fixture = fixture(false);
AtomicInteger attempts = new AtomicInteger();
CountDownLatch bothArrived = new CountDownLatch(2);
when(fixture.repository.transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean()))
when(fixture.repository.transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong()))
.thenAnswer(invocation -> {
bothArrived.countDown();
bothArrived.await();
@@ -199,6 +200,9 @@ class MeasurementReviewServiceTest {
measurement.setStatus("SUBMITTED");
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(5);
item.setTotalWeightKg(new BigDecimal("54.400"));
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
item.setVolumeWeightKg(new BigDecimal("124.916"));
when(repository.findOwned(10001L, 10L)).thenReturn(order);
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
@@ -21,8 +21,8 @@ test('fixture covers weight, volume, manual order, and container allocation cont
})
test('fee writes use verified Firstleg paths and do not fabricate historical IDs', () => {
assert.match(source, /\/api\/platform\/v1\/inbounds\/.*fee-details\?accountId=/)
assert.match(source, /\/api\/platform\/v1\/loading-plans\/.*fee-details\?accountId=/)
assert.match(source, /listPath = `\$\{basePath\}\?accountId=/)
assert.match(source, /const basePath = `\/api\/platform\/v1\/loading-plans\//)
assert.match(source, /sourceType: detail\?\.sourceType \|\| 'MANUAL'/)
assert.match(source, /created\?\.marker === context\.marker/)
assert.match(source, /\/fees`/)
@@ -91,13 +91,15 @@ test('run behavior emits consumer shape, both billing bases, distinct dates, sup
assert.equal(fixture.orders[0].waybillId, 'WB-1')
assert.equal(fixture.orders[0].actualWeightKg, 12.5)
assert.equal(fixture.orders[0].totalVolumeCbm, 0.072)
assert.deepEqual(new Set(fixture.feeSources.map((item) => item.billingBasis).filter((value) => value !== 'MANUAL_ORDER_FEE')),
assert.deepEqual(new Set(fixture.feeSources.map((item) => item.billingBasis).filter((value) => ['WEIGHT_KG', 'VOLUME_CBM'].includes(value))),
new Set(['WEIGHT_KG', 'VOLUME_CBM']))
assert.ok(new Set(fixture.feeSources.map((item) => item.businessDate)).size >= 2)
assert.equal(fixture.supplier.supplierId, 'PARTNER-1')
assert.equal(fixture.supplier.supplierName, '往来单位一')
assert.equal(fixture.voucherFileId, 'VOUCHER-1')
assert.ok(Array.isArray(fixture.allocations))
assert.ok(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').length >= 4)
assert.ok(new Set(fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').map((item) => item.businessDate)).size >= 2)
})
test('run behavior preserves null IDs and deployment blockers when supplier, voucher and container are unavailable', async () => {
@@ -113,3 +115,16 @@ test('run behavior preserves null IDs and deployment blockers when supplier, vou
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('往来单位')))
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('voucherFileId')))
})
test('manual fee approval path keeps accountId as a query parameter', () => {
assert.match(source, /basePath = `\/api\/platform\/v1\/inbounds\/.*fee-details`/)
assert.match(source, /\$\{basePath\}\/\$\{feeId\}\/approve\?accountId=/)
assert.doesNotMatch(source, /fee-details\?accountId=\$\{encodeURIComponent\(resource\.tenantId\)\}`\)/)
})
test('fixture creates four real receivable manual sources before payable source', () => {
assert.match(source, /const receivableResources = measured\.slice\(0, 4\)/)
assert.match(source, /feeItems\.RECEIVABLE, 'RECEIVABLE'/)
assert.match(source, /'MANUAL_RECEIVABLE'/)
assert.match(source, /dateOffset\(-1 - \(index % 2\)\)/)
})
@@ -77,17 +77,18 @@ async function loadFeeItem(context, direction) {
async function createManualFee(context, resource, feeItem, direction, amount, basis, businessDate) {
if (context.financeFixtureHooks?.createManualFee) return context.financeFixtureHooks.createManualFee(context, resource, feeItem, direction, amount, basis, businessDate)
const path = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details?accountId=${encodeURIComponent(resource.tenantId)}`
const basePath = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details`
const listPath = `${basePath}?accountId=${encodeURIComponent(resource.tenantId)}`
const body = { feeItemId: Number(feeItem.id), direction, businessDate,
unitPrice: amount, currency: 'CNY', amount, settlementIncluded: true, remark: `${context.marker}-${basis}` }
const response = await request(context, 'POST', path, body)
const response = await request(context, 'POST', listPath, body)
const detail = context.apiSuccess(MODULE, `${basis}人工费用明细`, response, `inboundId=${resource.inboundId}`, OPTIONS)
const feeId = id(detail?.id)
context.assert(MODULE, `${basis}费用来源身份和金额`, response,
Boolean(feeId) && id(detail?.inboundId) === id(resource.inboundId) && Number(detail?.amount) === Number(amount),
`feeId=${feeId || 'missing'}, amount=${detail?.amount ?? 'missing'}`, OPTIONS)
if (!feeId) return null
const approve = await request(context, 'POST', `${path}/${feeId}/approve`)
const approve = await request(context, 'POST', `${basePath}/${feeId}/approve?accountId=${encodeURIComponent(resource.tenantId)}`)
context.apiSuccess(MODULE, `${basis}费用审核`, approve, '', OPTIONS)
return { feeSourceId: feeId, sourceType: detail?.sourceType || 'MANUAL', direction, amount: Number(amount), businessDate,
currency: 'CNY', billingBasis: basis, inboundId: id(resource.inboundId), orderId: id(resource.orderId),
@@ -218,10 +219,21 @@ export async function run(context) {
`activePriceVersions=${pricingEvidence.activeVersionIds?.join(',') || 'none'};本轮隔离订单未产生 ${basis} 来源,价格配置/订单条件不支持,不能伪装成功`)
}
}
const manualResource = measured.at(-1)
if (manualResource && feeItems.PAYABLE && supplier?.id) {
const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80, 'MANUAL_ORDER_FEE', dateOffset(-1))
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null })
const receivableResources = measured.slice(0, 4)
if (feeItems.RECEIVABLE) {
for (const [index, resource] of receivableResources.entries()) {
const fee = await createManualFee(context, resource, feeItems.RECEIVABLE, 'RECEIVABLE', 100 + index * 10,
'MANUAL_RECEIVABLE', dateOffset(-1 - (index % 2)))
if (fee) fixtures.push(fee)
}
}
const manualResources = [measured.at(-1)].filter(Boolean)
if (feeItems.PAYABLE && supplier?.id) {
for (const [index, manualResource] of manualResources.entries()) {
const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80 + index * 10,
'MANUAL_ORDER_FEE', dateOffset(-3 - index))
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null })
}
}
const container = context.createdResources?.phase3ContainerLoading
@@ -229,12 +241,13 @@ export async function run(context) {
&& container?.loadingPlanId && container?.containerBookingId
let containerFixture = null
if (containerOwned && feeItems.PAYABLE && supplier?.id) {
const path = `/api/platform/v1/loading-plans/${encodeURIComponent(container.loadingPlanId)}/fee-details?accountId=${encodeURIComponent(container.tenantId)}`
const basePath = `/api/platform/v1/loading-plans/${encodeURIComponent(container.loadingPlanId)}/fee-details`
const path = `${basePath}?accountId=${encodeURIComponent(container.tenantId)}`
const body = { feeItemId: Number(feeItems.PAYABLE.id), direction: 'PAYABLE', businessDate: dateOffset(-2), amount: 300, currency: 'CNY', settlementIncluded: true, remark: `${context.marker}-CONTAINER_SHARED` }
const response = await request(context, 'POST', path, body)
const detail = context.apiSuccess(MODULE, '创建按柜费用来源', response, '', OPTIONS)
const feeId = id(detail?.id)
if (feeId) { const approval = await request(context, 'POST', `${path}/${feeId}/approve`); context.apiSuccess(MODULE, '审核按柜费用来源', approval, '', OPTIONS) }
if (feeId) { const approval = await request(context, 'POST', `${basePath}/${feeId}/approve?accountId=${encodeURIComponent(container.tenantId)}`); context.apiSuccess(MODULE, '审核按柜费用来源', approval, '', OPTIONS) }
containerFixture = { feeSourceId: feeId || null, sourceType: 'MANUAL', direction: 'PAYABLE', amount: 300,
businessDate: body.businessDate, currency: 'CNY', billingBasis: 'CONTAINER_SHARED',
supplierId: supplier?.id ? id(supplier.id) : null, partnerId: supplier?.id ? id(supplier.id) : null,
@@ -41,7 +41,7 @@ function fakeContext(overrides = {}) {
} }
}
test('values 兼容数组、records 和 named map', () => {
test('values helper supports nested arrays while run consumes frozen top-level arrays', () => {
assert.deepEqual(values([{ id: 1 }]), [{ id: 1 }])
assert.deepEqual(values({ records: [{ id: 2 }] }), [{ id: 2 }])
assert.deepEqual(values({ marker: 'x', selected: { id: 3 } }), [{ id: 3 }])
@@ -83,3 +83,20 @@ test('缺 fixture 或真实 voucher 时记录 blocker 且不写接口', async ()
assert.equal(noVoucher.steps.length, 1); assert.equal(noVoucher.steps[0].passed, false)
assert.equal(noVoucher.steps[0].options.deploymentBlocker, true)
})
test('AR/AP 数组为空或不完整时原子阻断且不查询、不写入', async () => {
const empty = fakeContext({ statements: [], receivableSettlements: [], payableSettlements: [] })
await run(empty)
assert.equal(empty.calls.length, 0)
assert.equal(empty.gaps.length, 1)
assert.equal(empty.steps[0].passed, false)
assert.match(empty.steps[0].evidence, /actual AR=0, AP=0/)
const partial = fakeContext({ receivableSettlements: [
{ settlementId: '101', statementId: '11', direction: 'RECEIVABLE', status: 'ISSUED' },
] })
await run(partial)
assert.equal(partial.calls.length, 0)
assert.equal(partial.gaps.length, 1)
assert.match(partial.steps[0].evidence, /actual AR=1, AP=1/)
})
@@ -54,8 +54,9 @@ export async function run(context) {
gap(context, '取得本轮财务 Fixture', 'financeFullFixtures', '缺少匹配 marker 的 financeFullFixtures,禁止借历史单据')
return
}
const statements = values(fixtures.statements)
const receivables = values(fixtures.receivableSettlements)
const statements = Array.isArray(fixtures.statements) ? fixtures.statements.filter(Boolean) : []
const receivables = Array.isArray(fixtures.receivableSettlements) ? fixtures.receivableSettlements.filter(Boolean) : []
const payables = Array.isArray(fixtures.payableSettlements) ? fixtures.payableSettlements.filter(Boolean) : []
const customerId = id(fixtures.customerId)
const voucherFileId = id(fixtures.voucherFileId ?? fixtures.voucher?.fileId ?? fixtures.voucher?.id)
if (!customerId || !voucherFileId) {
@@ -63,13 +64,26 @@ export async function run(context) {
return
}
const issuedStatementIds = new Set(statements.filter((item) => item.status === 'ISSUED').map(statementId).filter(Boolean))
const validReceivables = receivables.filter((item) => item.direction === 'RECEIVABLE'
&& settlementId(item) && statementId(item) && issuedStatementIds.has(statementId(item)))
const validPayables = payables.filter((item) => item.direction === 'PAYABLE' && settlementId(item) && item.supplierId)
const arSettlementIds = new Set(validReceivables.map(settlementId))
const arStatementIds = new Set(validReceivables.map(statementId))
if (validReceivables.length !== 3 || arSettlementIds.size !== 3 || arStatementIds.size !== 3 || validPayables.length !== 1) {
gap(context, '取得完整收付结算 Fixture', 'three AR and one AP settlements',
`需要 3 张关联本轮 ISSUED 对账单的独立 AR 和 1 张带 supplierId 的 AP;actual AR=${validReceivables.length}, AP=${validPayables.length}`,
'incomplete-settlement-fixture')
return
}
const manualIds = values(fixtures.feeSources).filter((fee) => fee.sourceType === 'MANUAL').map((fee) => id(fee.feeSourceId))
const usedIds = new Set(statements.flatMap((statement) => values(statement.feeSourceIds).map(id)))
context.assert(MODULE, '手动上传费用出账后来源完结', null,
manualIds.length > 0 && manualIds.every((feeId) => usedIds.has(feeId)),
`manual=${manualIds.length}, settled=${manualIds.filter((feeId) => usedIds.has(feeId)).length}`, OPTIONS)
const multiTargets = receivables.filter((item) => settlementId(item) && statementId(item)).slice(0, 3)
const multiTargets = validReceivables
if (multiTargets.length < 3) gap(context, '取得三张应收结算单', 'multi receipt allocation', '本轮没有三张独占应收单,禁止借历史单据')
else {
const details = await Promise.all(multiTargets.map((item) => detail(context, settlementId(item))))
@@ -81,7 +95,7 @@ export async function run(context) {
fixtures.receipts = { ...(fixtures.receipts || {}), multi: data(response) }
}
const primary = receivables.find((item) => settlementId(item) && statementId(item))
const primary = validReceivables[0]
if (!primary) gap(context, '取得应收结算单', 'receivable partial/full', 'receivableSettlements 未提供 settlementId 与 statementId')
else {
const initial = await detail(context, settlementId(primary))
@@ -95,7 +109,7 @@ export async function run(context) {
fixtures.settlementFinalStates = { ...(fixtures.settlementFinalStates || {}), receivable: final.settlement }
}
const payable = values(fixtures.payableSettlements).find((item) => item.direction === 'PAYABLE' && settlementId(item))
const payable = validPayables[0]
const payableId = settlementId(payable)
if (!payableId) gap(context, '取得应付结算单', 'payable partial/full/overpayment', '前置 Fixture 的 payableSettlements 数组为空或缺少 PAYABLE 结算单')
else {