优化
This commit is contained in:
@@ -12,6 +12,15 @@ public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
|
|||||||
@Update("""
|
@Update("""
|
||||||
UPDATE fl_inbound_order
|
UPDATE fl_inbound_order
|
||||||
SET process_status = 'INBOUNDED', measure_review_status = 'APPROVED',
|
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)),
|
inbounded_at = COALESCE(inbounded_at, CURRENT_TIMESTAMP(3)),
|
||||||
updated_by = #{operatorId}, version = version + 1
|
updated_by = #{operatorId}, version = version + 1
|
||||||
WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0
|
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,
|
int approveMeasurement(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||||
@Param("operatorId") long operatorId,
|
@Param("operatorId") long operatorId,
|
||||||
@Param("expectedVersion") int expectedVersion);
|
@Param("expectedVersion") int expectedVersion,
|
||||||
|
@Param("measurementId") long measurementId);
|
||||||
|
|
||||||
@Update("""
|
@Update("""
|
||||||
UPDATE fl_inbound_order
|
UPDATE fl_inbound_order
|
||||||
|
|||||||
+2
-2
@@ -67,9 +67,9 @@ public class InboundWorkflowRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public int transitionReview(long tenantId, long inboundId, long operatorId, int expectedVersion,
|
public int transitionReview(long tenantId, long inboundId, long operatorId, int expectedVersion,
|
||||||
boolean approved) {
|
boolean approved, long measurementId) {
|
||||||
return approved
|
return approved
|
||||||
? orders.approveMeasurement(tenantId, inboundId, operatorId, expectedVersion)
|
? orders.approveMeasurement(tenantId, inboundId, operatorId, expectedVersion, measurementId)
|
||||||
: orders.rejectMeasurement(tenantId, inboundId, operatorId, expectedVersion);
|
: orders.rejectMeasurement(tenantId, inboundId, operatorId, expectedVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 INBOUNDED;
|
* 测量审核应用服务。客服确认测量数据后将入库单推进到产品主状态 INBOUNDED;
|
||||||
@@ -69,8 +70,8 @@ public class MeasurementReviewService {
|
|||||||
throw new BizException(ResultCode.CONFLICT, "测量批次不是待审核状态");
|
throw new BizException(ResultCode.CONFLICT, "测量批次不是待审核状态");
|
||||||
}
|
}
|
||||||
if (approved) {
|
if (approved) {
|
||||||
long confirmed = repository.listMeasurementItems(tenantId, measurement.getId()).stream()
|
var items = repository.listMeasurementItems(tenantId, measurement.getId());
|
||||||
.map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getPackageCount)
|
long confirmed = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getPackageCount)
|
||||||
.filter(java.util.Objects::nonNull)
|
.filter(java.util.Objects::nonNull)
|
||||||
.mapToLong(Integer::longValue)
|
.mapToLong(Integer::longValue)
|
||||||
.sum();
|
.sum();
|
||||||
@@ -78,10 +79,18 @@ public class MeasurementReviewService {
|
|||||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
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,
|
if (repository.transitionReview(tenantId, inboundId, operatorId,
|
||||||
request.expectedVersion(), approved) != 1) {
|
request.expectedVersion(), approved, request.measurementId()) != 1) {
|
||||||
throw concurrentConflict();
|
throw concurrentConflict();
|
||||||
}
|
}
|
||||||
String result = approved ? "APPROVED" : "REJECTED";
|
String result = approved ? "APPROVED" : "REJECTED";
|
||||||
|
|||||||
+11
-7
@@ -18,6 +18,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
|
|||||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
@@ -57,20 +58,20 @@ class MeasurementReviewServiceTest {
|
|||||||
.isEqualTo(ResultCode.NOT_FOUND);
|
.isEqualTo(ResultCode.NOT_FOUND);
|
||||||
|
|
||||||
verify(repository).findOwned(10002L, 10L);
|
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
|
@Test
|
||||||
@DisplayName("审核通过是测量到计费的唯一正常推进关口")
|
@DisplayName("审核通过是测量到计费的唯一正常推进关口")
|
||||||
void shouldApproveOnlyPendingSubmittedMeasurement() {
|
void shouldApproveOnlyPendingSubmittedMeasurement() {
|
||||||
Fixture fixture = fixture(false);
|
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);
|
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
|
||||||
|
|
||||||
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-1",
|
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-1",
|
||||||
new MeasurementReviewRequest(20L, 3, null));
|
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).transitionMeasurement(10001L, 10L, 20L, "APPROVED");
|
||||||
verify(fixture.repository).insertReview(any());
|
verify(fixture.repository).insertReview(any());
|
||||||
ArgumentCaptor<InboundStatusHistoryEntity> history =
|
ArgumentCaptor<InboundStatusHistoryEntity> history =
|
||||||
@@ -94,7 +95,7 @@ class MeasurementReviewServiceTest {
|
|||||||
.extracting(error -> ((BizException) error).getResultCode())
|
.extracting(error -> ((BizException) error).getResultCode())
|
||||||
.isEqualTo(ResultCode.VALIDATION_FAILED);
|
.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
|
@Test
|
||||||
@@ -106,7 +107,7 @@ class MeasurementReviewServiceTest {
|
|||||||
.isInstanceOf(BizException.class)
|
.isInstanceOf(BizException.class)
|
||||||
.extracting(error -> ((BizException) error).getResultCode())
|
.extracting(error -> ((BizException) error).getResultCode())
|
||||||
.isEqualTo(ResultCode.VALIDATION_FAILED);
|
.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
|
@Test
|
||||||
@@ -120,7 +121,7 @@ class MeasurementReviewServiceTest {
|
|||||||
new MeasurementReviewRequest(20L, 3, null));
|
new MeasurementReviewRequest(20L, 3, null));
|
||||||
|
|
||||||
verify(fixture.repository, never()).findOwned(anyLong(), anyLong());
|
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
|
@Test
|
||||||
@@ -158,7 +159,7 @@ class MeasurementReviewServiceTest {
|
|||||||
Fixture fixture = fixture(false);
|
Fixture fixture = fixture(false);
|
||||||
AtomicInteger attempts = new AtomicInteger();
|
AtomicInteger attempts = new AtomicInteger();
|
||||||
CountDownLatch bothArrived = new CountDownLatch(2);
|
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 -> {
|
.thenAnswer(invocation -> {
|
||||||
bothArrived.countDown();
|
bothArrived.countDown();
|
||||||
bothArrived.await();
|
bothArrived.await();
|
||||||
@@ -199,6 +200,9 @@ class MeasurementReviewServiceTest {
|
|||||||
measurement.setStatus("SUBMITTED");
|
measurement.setStatus("SUBMITTED");
|
||||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||||
item.setPackageCount(5);
|
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.findOwned(10001L, 10L)).thenReturn(order);
|
||||||
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
|
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
|
||||||
when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
|
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', () => {
|
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, /listPath = `\$\{basePath\}\?accountId=/)
|
||||||
assert.match(source, /\/api\/platform\/v1\/loading-plans\/.*fee-details\?accountId=/)
|
assert.match(source, /const basePath = `\/api\/platform\/v1\/loading-plans\//)
|
||||||
assert.match(source, /sourceType: detail\?\.sourceType \|\| 'MANUAL'/)
|
assert.match(source, /sourceType: detail\?\.sourceType \|\| 'MANUAL'/)
|
||||||
assert.match(source, /created\?\.marker === context\.marker/)
|
assert.match(source, /created\?\.marker === context\.marker/)
|
||||||
assert.match(source, /\/fees`/)
|
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].waybillId, 'WB-1')
|
||||||
assert.equal(fixture.orders[0].actualWeightKg, 12.5)
|
assert.equal(fixture.orders[0].actualWeightKg, 12.5)
|
||||||
assert.equal(fixture.orders[0].totalVolumeCbm, 0.072)
|
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']))
|
new Set(['WEIGHT_KG', 'VOLUME_CBM']))
|
||||||
assert.ok(new Set(fixture.feeSources.map((item) => item.businessDate)).size >= 2)
|
assert.ok(new Set(fixture.feeSources.map((item) => item.businessDate)).size >= 2)
|
||||||
assert.equal(fixture.supplier.supplierId, 'PARTNER-1')
|
assert.equal(fixture.supplier.supplierId, 'PARTNER-1')
|
||||||
assert.equal(fixture.supplier.supplierName, '往来单位一')
|
assert.equal(fixture.supplier.supplierName, '往来单位一')
|
||||||
assert.equal(fixture.voucherFileId, 'VOUCHER-1')
|
assert.equal(fixture.voucherFileId, 'VOUCHER-1')
|
||||||
assert.ok(Array.isArray(fixture.allocations))
|
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 () => {
|
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('往来单位')))
|
||||||
assert.ok(context.steps.some((step) => step.deploymentBlocker && step.name.includes('voucherFileId')))
|
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) {
|
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)
|
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,
|
const body = { feeItemId: Number(feeItem.id), direction, businessDate,
|
||||||
unitPrice: amount, currency: 'CNY', amount, settlementIncluded: true, remark: `${context.marker}-${basis}` }
|
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 detail = context.apiSuccess(MODULE, `${basis}人工费用明细`, response, `inboundId=${resource.inboundId}`, OPTIONS)
|
||||||
const feeId = id(detail?.id)
|
const feeId = id(detail?.id)
|
||||||
context.assert(MODULE, `${basis}费用来源身份和金额`, response,
|
context.assert(MODULE, `${basis}费用来源身份和金额`, response,
|
||||||
Boolean(feeId) && id(detail?.inboundId) === id(resource.inboundId) && Number(detail?.amount) === Number(amount),
|
Boolean(feeId) && id(detail?.inboundId) === id(resource.inboundId) && Number(detail?.amount) === Number(amount),
|
||||||
`feeId=${feeId || 'missing'}, amount=${detail?.amount ?? 'missing'}`, OPTIONS)
|
`feeId=${feeId || 'missing'}, amount=${detail?.amount ?? 'missing'}`, OPTIONS)
|
||||||
if (!feeId) return null
|
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)
|
context.apiSuccess(MODULE, `${basis}费用审核`, approve, '', OPTIONS)
|
||||||
return { feeSourceId: feeId, sourceType: detail?.sourceType || 'MANUAL', direction, amount: Number(amount), businessDate,
|
return { feeSourceId: feeId, sourceType: detail?.sourceType || 'MANUAL', direction, amount: Number(amount), businessDate,
|
||||||
currency: 'CNY', billingBasis: basis, inboundId: id(resource.inboundId), orderId: id(resource.orderId),
|
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} 来源,价格配置/订单条件不支持,不能伪装成功`)
|
`activePriceVersions=${pricingEvidence.activeVersionIds?.join(',') || 'none'};本轮隔离订单未产生 ${basis} 来源,价格配置/订单条件不支持,不能伪装成功`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const manualResource = measured.at(-1)
|
const receivableResources = measured.slice(0, 4)
|
||||||
if (manualResource && feeItems.PAYABLE && supplier?.id) {
|
if (feeItems.RECEIVABLE) {
|
||||||
const fee = await createManualFee(context, manualResource, feeItems.PAYABLE, 'PAYABLE', 80, 'MANUAL_ORDER_FEE', dateOffset(-1))
|
for (const [index, resource] of receivableResources.entries()) {
|
||||||
if (fee) fixtures.push({ ...fee, supplierId: id(supplier.id), partnerId: id(supplier.id), supplierName: supplier.partnerName || null })
|
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
|
const container = context.createdResources?.phase3ContainerLoading
|
||||||
@@ -229,12 +241,13 @@ export async function run(context) {
|
|||||||
&& container?.loadingPlanId && container?.containerBookingId
|
&& container?.loadingPlanId && container?.containerBookingId
|
||||||
let containerFixture = null
|
let containerFixture = null
|
||||||
if (containerOwned && feeItems.PAYABLE && supplier?.id) {
|
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 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 response = await request(context, 'POST', path, body)
|
||||||
const detail = context.apiSuccess(MODULE, '创建按柜费用来源', response, '', OPTIONS)
|
const detail = context.apiSuccess(MODULE, '创建按柜费用来源', response, '', OPTIONS)
|
||||||
const feeId = id(detail?.id)
|
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,
|
containerFixture = { feeSourceId: feeId || null, sourceType: 'MANUAL', direction: 'PAYABLE', amount: 300,
|
||||||
businessDate: body.businessDate, currency: 'CNY', billingBasis: 'CONTAINER_SHARED',
|
businessDate: body.businessDate, currency: 'CNY', billingBasis: 'CONTAINER_SHARED',
|
||||||
supplierId: supplier?.id ? id(supplier.id) : null, partnerId: supplier?.id ? id(supplier.id) : null,
|
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([{ id: 1 }]), [{ id: 1 }])
|
||||||
assert.deepEqual(values({ records: [{ id: 2 }] }), [{ id: 2 }])
|
assert.deepEqual(values({ records: [{ id: 2 }] }), [{ id: 2 }])
|
||||||
assert.deepEqual(values({ marker: 'x', selected: { id: 3 } }), [{ id: 3 }])
|
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.length, 1); assert.equal(noVoucher.steps[0].passed, false)
|
||||||
assert.equal(noVoucher.steps[0].options.deploymentBlocker, true)
|
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,禁止借历史单据')
|
gap(context, '取得本轮财务 Fixture', 'financeFullFixtures', '缺少匹配 marker 的 financeFullFixtures,禁止借历史单据')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const statements = values(fixtures.statements)
|
const statements = Array.isArray(fixtures.statements) ? fixtures.statements.filter(Boolean) : []
|
||||||
const receivables = values(fixtures.receivableSettlements)
|
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 customerId = id(fixtures.customerId)
|
||||||
const voucherFileId = id(fixtures.voucherFileId ?? fixtures.voucher?.fileId ?? fixtures.voucher?.id)
|
const voucherFileId = id(fixtures.voucherFileId ?? fixtures.voucher?.fileId ?? fixtures.voucher?.id)
|
||||||
if (!customerId || !voucherFileId) {
|
if (!customerId || !voucherFileId) {
|
||||||
@@ -63,13 +64,26 @@ export async function run(context) {
|
|||||||
return
|
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 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)))
|
const usedIds = new Set(statements.flatMap((statement) => values(statement.feeSourceIds).map(id)))
|
||||||
context.assert(MODULE, '手动上传费用出账后来源完结', null,
|
context.assert(MODULE, '手动上传费用出账后来源完结', null,
|
||||||
manualIds.length > 0 && manualIds.every((feeId) => usedIds.has(feeId)),
|
manualIds.length > 0 && manualIds.every((feeId) => usedIds.has(feeId)),
|
||||||
`manual=${manualIds.length}, settled=${manualIds.filter((feeId) => usedIds.has(feeId)).length}`, OPTIONS)
|
`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', '本轮没有三张独占应收单,禁止借历史单据')
|
if (multiTargets.length < 3) gap(context, '取得三张应收结算单', 'multi receipt allocation', '本轮没有三张独占应收单,禁止借历史单据')
|
||||||
else {
|
else {
|
||||||
const details = await Promise.all(multiTargets.map((item) => detail(context, settlementId(item))))
|
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) }
|
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')
|
if (!primary) gap(context, '取得应收结算单', 'receivable partial/full', 'receivableSettlements 未提供 settlementId 与 statementId')
|
||||||
else {
|
else {
|
||||||
const initial = await detail(context, settlementId(primary))
|
const initial = await detail(context, settlementId(primary))
|
||||||
@@ -95,7 +109,7 @@ export async function run(context) {
|
|||||||
fixtures.settlementFinalStates = { ...(fixtures.settlementFinalStates || {}), receivable: final.settlement }
|
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)
|
const payableId = settlementId(payable)
|
||||||
if (!payableId) gap(context, '取得应付结算单', 'payable partial/full/overpayment', '前置 Fixture 的 payableSettlements 数组为空或缺少 PAYABLE 结算单')
|
if (!payableId) gap(context, '取得应付结算单', 'payable partial/full/overpayment', '前置 Fixture 的 payableSettlements 数组为空或缺少 PAYABLE 结算单')
|
||||||
else {
|
else {
|
||||||
|
|||||||
Reference in New Issue
Block a user