merge: sync test and stabilize full acceptance

This commit is contained in:
2026-09-09 21:14:25 +08:00
40 changed files with 595 additions and 134 deletions
+1 -1
View File
@@ -79,7 +79,7 @@
- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
- `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。
@@ -79,7 +79,7 @@ public class PlatformInboundMeasurementController {
@BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "保存入库测量草稿")
@Operation(summary = "保存测量草稿", description = "仅在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;"
@Operation(summary = "保存测量草稿", description = "订单待审核但货物已到仓时可先保存;也可在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;"
+ "进入待审核后批次冻结,确认箱数修正须使用 measurement/items/{itemId}/confirmed-package-count 专用接口。")
@PutMapping("/measurement/draft")
@PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit',"
@@ -96,7 +96,7 @@ public class PlatformInboundMeasurementController {
@BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND",
resourceId = "#inboundId", description = "确认测量并提交审核")
@Operation(summary = "确认测量并提交审核",
description = "校验测量图片和全部 OCR 重量后,最多推进到 MEASURE_PENDING。")
description = "校验测量图片和全部 OCR 重量后,最多推进到 MEASURE_PENDING;订单未审核时只保留待审核测量,不能审核通过或进入后续履约。")
@PostMapping("/measurement/submit")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:submit')")
public ApiResponse<InboundMeasurementResponses.MeasurementSubmitted> submit(
@@ -34,9 +34,9 @@ import java.util.Set;
public class CartonMeasurementService {
private static final Logger log = LoggerFactory.getLogger(CartonMeasurementService.class);
private static final Set<String> DEVICE_WRITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
private static final Set<String> MANUAL_COUNT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final long MAX_IMAGE_BYTES = 5L * 1024 * 1024;
@@ -219,6 +219,7 @@ public class FbaPricingService {
/**
* 柜内明细变更后,按最新有效柜内成员重新命中区域重量阶梯。
* 仅使用当前费用快照冻结的选定重量、体积和测量来源,绝不回退到入库单实测值。
* 已发送或客户已确认的费用继续参与柜级计费量聚合,但自身快照保持不变。
*/
@Transactional
public ContainerFeeRecalculationResult recalculateUnlockedContainerCustomerFees(
@@ -257,19 +258,22 @@ public class FbaPricingService {
notReady = true;
continue;
}
if ("SENT".equals(current.getFeeStatus()) || current.getCustomerConfirmedAt() != null) {
throw conflict("柜内存在已发送或客户已确认费用,不能重算阶梯费用");
}
if (!List.of("CALCULATED", "CONFIRMED").contains(current.getFeeStatus())) {
boolean locked = List.of("CONFIRMED", "SENT").contains(current.getFeeStatus())
|| current.getCustomerConfirmedAt() != null;
if (!locked && !"CALCULATED".equals(current.getFeeStatus())) {
throw conflict("柜内费用状态已变化,不能重算阶梯费用");
}
BigDecimal weight = requiredFrozenQuantity(current.getActualWeightKg(), "重量");
BigDecimal volume = requiredFrozenQuantity(current.getActualVolumeCbm(), "体积");
members.add(new ContainerFeeMember(inbound, current,
weight.setScale(3, RoundingMode.HALF_UP), volume.setScale(4, RoundingMode.HALF_UP),
chargeableWeight(weight, volume)));
chargeableWeight(weight, volume), locked));
}
if (notReady) return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_READY, 0);
List<ContainerFeeMember> unlockedMembers = members.stream().filter(member -> !member.locked()).toList();
if (unlockedMembers.isEmpty()) {
return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_APPLICABLE, 0);
}
InboundPricingIdempotencyEntity token = beginContainerRecalculation(
tenantId, containerBookingId, key, normalizedTrigger);
if ("COMPLETED".equals(token.getStatus())) {
@@ -281,7 +285,7 @@ public class FbaPricingService {
.map(member -> new ContainerTierAmountAllocator.Member(member.inbound().getId(), member.chargeableQuantity()))
.toList();
List<ContainerFeeQuote> quotes = new ArrayList<>();
for (ContainerFeeMember member : members) {
for (ContainerFeeMember member : unlockedMembers) {
PricingPolicy policy = pricingPolicy(member.inbound(), now);
ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest(
member.inbound().getWarehouseId(), member.inbound().getChannelCode(), member.inbound().getDestinationCode(),
@@ -371,7 +375,8 @@ public class FbaPricingService {
}
private record ContainerFeeMember(InboundPricingEntity inbound, InboundFeeEntity current,
BigDecimal weight, BigDecimal volume, BigDecimal chargeableQuantity) { }
BigDecimal weight, BigDecimal volume, BigDecimal chargeableQuantity,
boolean locked) { }
private record ContainerFeeQuote(ContainerFeeMember member, PricingPolicy policy,
ActivePriceBookEstimator.Estimate estimate) { }
@@ -47,7 +47,8 @@ import java.util.Set;
public class InboundMeasurementOpenApiService {
private static final Logger log = LoggerFactory.getLogger(InboundMeasurementOpenApiService.class);
static final String CALLBACK_PATH = "/openapi/v1/inbound-measurements";
private static final Set<String> ACCEPTABLE_INBOUND_STATUSES = Set.of("WAIT_MEASURE", "MEASURE_REJECTED");
private static final Set<String> ACCEPTABLE_INBOUND_STATUSES = Set.of(
"PREPARING", "WAIT_MEASURE", "MEASURE_REJECTED");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
private static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final String CALCULATION_RULE_VERSION = "V1_CBM_CEILING_4_GIRTH_L2WH";
@@ -49,9 +49,9 @@ public class InboundMeasurementService {
/** OCR 结果无效或不完整,必须人工录入或修正。 */
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
private static final Set<String> MEASUREMENT_DRAFT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
private static final Set<String> WEIGHT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
/** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */
private static final String RECOMMENDED_PALLET_TYPE = "STANDARD";
private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237");
@@ -167,7 +167,7 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SubmitRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
if (!Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")
if (!Set.of("PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "测量已提交或审核完成,不能重复提交或修改冻结结果");
}
@@ -384,7 +384,7 @@ public class InboundMeasurementService {
inbound.setActualGrossWeightKg(totals.grossWeightKg());
inbound.setActualNetWeightKg(totals.netWeightKg());
inbound.setPalletCount(totals.palletCount());
if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) {
if (Set.of("PREPARING", "WAIT_MEASURE").contains(inbound.getProcessStatus())) {
inbound.setProcessStatus("MEASURING");
}
inbound.setUpdatedBy(operatorId);
@@ -40,7 +40,7 @@ public class InboundOrderCreationService {
long inboundId = requireMatchingInboundCode(existing, command.inboundCode());
FulfillmentTaskEntity task = fulfillmentTasks.provision(command, LocalDateTime.now());
linkTask(existing, task.getId());
upgradePreparing(existing, command);
approvePreparedInbound(existing, command);
savePickupSnapshot(existing.getId(), command, LocalDateTime.now());
return inboundId;
}
@@ -132,7 +132,7 @@ public class InboundOrderCreationService {
}
LocalDateTime now = LocalDateTime.now();
InboundOrderEntity inbound = newInbound(command, now, null, "PREPARING", "DRAFT");
InboundOrderEntity inbound = newInbound(command, now, null, "PREPARING", "SUBMITTED");
try {
orders.insert(inbound);
savePickupSnapshot(inbound.getId(), command, now);
@@ -153,6 +153,7 @@ public class InboundOrderCreationService {
.eq(InboundOrderEntity::getTenantId, existing.getTenantId())
.eq(InboundOrderEntity::getId, existing.getId())
.eq(InboundOrderEntity::getProcessStatus, "PREPARING")
.set(InboundOrderEntity::getOrderStatusSnapshot, "SUBMITTED")
.set(InboundOrderEntity::getOrderNo, command.orderNo())
.set(InboundOrderEntity::getCustomerId, command.customerId())
.set(InboundOrderEntity::getCustomerName, command.customerName())
@@ -175,13 +176,16 @@ public class InboundOrderCreationService {
.setSql("version = version + 1"));
}
private void upgradePreparing(InboundOrderEntity existing, OrderApprovedInboundCommand command) {
if (!"PREPARING".equals(existing.getProcessStatus())) return;
private void approvePreparedInbound(InboundOrderEntity existing, OrderApprovedInboundCommand command) {
String currentStatus = existing.getProcessStatus();
if (currentStatus == null || !java.util.Set.of("PREPARING", "MEASURING", "MEASURE_PENDING")
.contains(currentStatus)) return;
String approvedStatus = "PREPARING".equals(currentStatus) ? "WAIT_MEASURE" : currentStatus;
var update = new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, existing.getTenantId())
.eq(InboundOrderEntity::getId, existing.getId())
.eq(InboundOrderEntity::getProcessStatus, "PREPARING")
.set(InboundOrderEntity::getProcessStatus, "WAIT_MEASURE")
.eq(InboundOrderEntity::getProcessStatus, currentStatus)
.set(InboundOrderEntity::getProcessStatus, approvedStatus)
.set(InboundOrderEntity::getOrderStatusSnapshot, "APPROVED")
.set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount())
.set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType()))
@@ -189,12 +193,12 @@ public class InboundOrderCreationService {
.setSql("version = version + 1");
if (orders.update(null, update) != 1) {
InboundOrderEntity current = findByOrder(existing.getTenantId(), existing.getOrderId(), true);
if (current == null || !"WAIT_MEASURE".equals(current.getProcessStatus())) {
if (current == null || !"APPROVED".equals(current.getOrderStatusSnapshot())) {
throw new BizException(ResultCode.CONFLICT, "预入库单审核升级发生并发冲突");
}
return;
}
addHistory(existing.getId(), existing.getTenantId(), "PREPARING", "WAIT_MEASURE",
addHistory(existing.getId(), existing.getTenantId(), currentStatus, approvedStatus,
"ORDER_APPROVED", LocalDateTime.now());
}
@@ -43,7 +43,7 @@ public class MeasurementBatchService {
/** 已接收第三方测量数据,待平台提交审核。 */
static final String STATUS_RECEIVED = "RECEIVED";
private static final Set<String> EDITABLE_INBOUND_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999");
private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999");
@@ -64,6 +64,9 @@ public class MeasurementReviewService {
InboundOrderEntity order = requireOrder(tenantId, inboundId);
requirePendingReview(order);
if (!"APPROVED".equals(order.getOrderStatusSnapshot())) {
throw new BizException(ResultCode.CONFLICT, "订单尚未审核通过,已到仓测量数据可保留但暂不能审核");
}
InboundMeasurementEntity measurement = repository.findMeasurement(
tenantId, inboundId, request.measurementId());
if (measurement == null) {
@@ -950,8 +950,8 @@ paths:
summary: 保存测量草稿
description: >-
原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖;
actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 WAIT_MEASURE、MEASURING
或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用
actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 PREPARING(订单待审核但已到仓)、
WAIT_MEASURE、MEASURING 或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用
measurement/items/{itemId}/confirmed-package-count 明细专用接口。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1154,7 +1154,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/approve:
post:
summary: 审核通过测量
description: 需要 firstleg:inbound:measurement-review 权限。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示。
description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和后续履约状态。
parameters:
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1175,7 +1175,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/submit:
post:
summary: 提交测量完成
description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。
description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -788,7 +788,7 @@ class FbaPricingServiceTest {
InboundFeeEntity first = savedFee("CALCULATED");
first.setActualWeightKg(new BigDecimal("90.000")); first.setActualVolumeCbm(new BigDecimal("0.1000"));
first.setMeasurementId(301L); first.setMeasurementSource("OUTER");
InboundFeeEntity second = savedFee("CONFIRMED");
InboundFeeEntity second = savedFee("CALCULATED");
second.setId(502L); second.setInboundId(30002L); second.setActualWeightKg(new BigDecimal("110.000"));
second.setActualVolumeCbm(new BigDecimal("0.1000")); second.setMeasurementId(302L);
second.setMeasurementSource("ACTUAL");
@@ -834,20 +834,61 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("柜内任一已发送或客户确认费用阻断整组重算")
void rejectsContainerRecalculationWhenAnyFeeIsImmutable() {
@DisplayName("柜内已发送费用保持锁定且不阻断其他未锁定费用重算")
void keepsImmutableContainerFeeAndRecalculatesOnlyUnlockedMembers() {
readyRepository();
InboundPricingEntity inbound = inbound("100", "0.1");
InboundPricingEntity lockedInbound = inbound("100", "0.1");
InboundPricingEntity unlockedInbound = inbound("80", "0.1");
unlockedInbound.setId(30002L); unlockedInbound.setInboundCode("RK-002");
InboundFeeEntity sent = savedFee("SENT");
sent.setActualWeightKg(new BigDecimal("100")); sent.setActualVolumeCbm(new BigDecimal("0.1"));
InboundFeeEntity calculated = savedFee("CALCULATED");
calculated.setId(502L); calculated.setInboundId(30002L);
calculated.setActualWeightKg(new BigDecimal("80")); calculated.setActualVolumeCbm(new BigDecimal("0.1"));
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "100", "0.1"), memberRow(901L, 30002L, "80", "0.1")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(lockedInbound);
when(repository.findInbound(TENANT, 30002L)).thenReturn(unlockedInbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(calculated);
when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2);
when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1);
when(repository.supersedeCurrent(TENANT, 502L, "CALCULATED")).thenReturn(1);
when(repository.markInboundContainerFeeRecalculated(eq(TENANT), eq(30002L), anyString(), anyString(), any()))
.thenReturn(1);
when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("180", "8", "1440"));
when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn(
new ContainerTierAmountAllocator.Allocation(new BigDecimal("180"), new BigDecimal("1440"),
Map.of(INBOUND, new BigDecimal("800.00"), 30002L, new BigDecimal("640.00"))));
var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2");
assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.RECALCULATED);
assertThat(result.recalculatedCount()).isEqualTo(1);
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getInboundId()).isEqualTo(30002L);
assertThat(fee.getValue().getSubtotalAmount()).isEqualByComparingTo("640.00");
verify(repository, never()).supersedeCurrent(TENANT, 501L, "SENT");
verify(priceBookEstimator).estimate(eq(TENANT), any());
}
@Test
@DisplayName("柜内费用全部锁定时关系变更无需重算且不报错")
void skipsContainerRecalculationWhenEveryFeeIsImmutable() {
InboundPricingEntity inbound = inbound("100", "0.1");
InboundFeeEntity confirmed = savedFee("CONFIRMED");
when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of(
memberRow(901L, INBOUND, "100", "0.1")
));
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent);
when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(confirmed);
assertThatThrownBy(() -> service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2"))
.isInstanceOf(BizException.class).hasMessageContaining("已发送或客户已确认");
var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-locked");
assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.NOT_APPLICABLE);
assertThat(result.recalculatedCount()).isZero();
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), anyString());
verify(repository, never()).insertFee(any());
verify(priceBookEstimator, never()).estimate(anyLong(), any());
@@ -157,6 +157,26 @@ class InboundOrderCreationServiceTest {
verify(orders, times(2)).update(any(), any(Wrapper.class));
}
@Test
void approvalKeepsMeasurementAlreadyCapturedBeforeOrderReview() {
InboundOrderEntity prepared = inbound(900L, "PCHK2608140001", "UNPRICED", null, null);
prepared.setProcessStatus("MEASURING");
prepared.setOrderStatusSnapshot("SUBMITTED");
when(orders.selectOne(any(Wrapper.class))).thenReturn(prepared);
when(orders.update(any(), any(Wrapper.class))).thenReturn(1);
when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L));
assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(900L);
verify(orders, times(2)).update(any(), any(Wrapper.class));
ArgumentCaptor<InboundStatusHistoryEntity> historyCaptor =
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(histories).insert(historyCaptor.capture());
assertThat(historyCaptor.getValue().getFromStatus()).isEqualTo("MEASURING");
assertThat(historyCaptor.getValue().getToStatus()).isEqualTo("MEASURING");
assertThat(historyCaptor.getValue().getAction()).isEqualTo("ORDER_APPROVED");
}
private static InboundOrderEntity inbound(long id, String code, String feeStatus,
Long carrierId, String carrierName) {
InboundOrderEntity inbound = new InboundOrderEntity();
@@ -65,6 +65,21 @@ class MeasurementReviewServiceTest {
verify(repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@DisplayName("订单待审核时保留测量数据但不能审核测量")
void shouldBlockMeasurementReviewUntilOrderIsApproved() {
Fixture fixture = fixture(false);
fixture.order.setOrderStatusSnapshot("SUBMITTED");
assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L,
"approve-before-order", new MeasurementReviewRequest(20L, 3, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("订单尚未审核通过");
verify(fixture.repository, never()).transitionReview(
anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@DisplayName("审核通过是测量到计费的唯一正常推进关口")
void shouldApproveOnlyPendingSubmittedMeasurement() {
@@ -249,6 +264,7 @@ class MeasurementReviewServiceTest {
when(properties.isAllowRejectedMeasurement()).thenReturn(allowRelease);
InboundOrderEntity order = new InboundOrderEntity();
order.setProcessStatus("MEASURE_PENDING");
order.setOrderStatusSnapshot("APPROVED");
order.setMeasureReviewStatus("PENDING");
order.setTotalPackageCount(5);
order.setActualGrossWeightKg(new BigDecimal("1787.500"));