This commit is contained in:
2026-08-17 19:13:08 +08:00
parent 5199146e97
commit cddd23e4c7
40 changed files with 1203 additions and 71 deletions
@@ -7316,7 +7316,7 @@ DELIMITER ;
CALL unified_publish_fee_add_index('fl_fee_item', 'uk_fee_item_code',
'UNIQUE KEY `uk_fee_item_code` (`fee_code`)');
CALL unified_publish_fee_add_index('fl_inbound_fee_detail', 'uk_inbound_fee_source',
'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`)');
'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`, `source_fee_version`)');
CALL unified_publish_fee_add_index('fl_inbound_fee_detail', 'idx_inbound_fee_published',
'KEY `idx_inbound_fee_published` (`tenant_id`, `inbound_id`, `publication_status`, `source_fee_version`)');
DROP PROCEDURE IF EXISTS unified_publish_fee_add_index;
@@ -41,8 +41,10 @@ public class PlatformInboundFeeDetailController {
public ApiResponse<List<InboundFeeDetailEntity>> list(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@RequestParam(defaultValue = "false") boolean includeHistory,
@PathVariable long inboundId) {
return ApiResponse.success(service.list(FirstlegScopeGuard.platform(user, accountId), inboundId));
return ApiResponse.success(service.list(
FirstlegScopeGuard.platform(user, accountId), inboundId, includeHistory));
}
@PostMapping
@@ -12,9 +12,19 @@ public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Update("""
UPDATE fl_inbound_order
SET process_status = 'WAIT_SPLIT', 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_gross_weight_kg = CASE
WHEN EXISTS (SELECT 1
FROM fl_inbound_weight_result weight
WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId}
AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED')
THEN (SELECT COALESCE(SUM(weight.net_weight_kg), 0)
FROM fl_inbound_weight_result weight
WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId}
AND weight.deleted = 0 AND weight.recognition_status = 'CONFIRMED')
ELSE (SELECT COALESCE(SUM(item.total_weight_kg), 0)
FROM fl_inbound_measurement_item item
WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId})
END,
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}),
@@ -31,6 +31,7 @@ public interface LoadingCompletedEventMapper {
JOIN fl_inbound_order o ON o.id = i.inbound_id AND o.tenant_id = i.account_id AND o.deleted = 0
LEFT JOIN fl_inbound_order root_order ON root_order.id = COALESCE(o.root_inbound_id, o.id)
WHERE p.id = #{planId} AND p.plan_status = 'COMPLETED'
AND o.process_status = 'LOADED'
AND i.package_count > 0
ORDER BY i.account_id, i.inbound_id, i.inbound_detail_id, i.id
""")
@@ -98,11 +98,13 @@ public class FeeItemRepository {
.eq(InboundOrderEntity::getId, inboundId));
}
public List<InboundFeeDetailEntity> listDetails(long tenantId, long inboundId) {
public List<InboundFeeDetailEntity> listDetails(long tenantId, long inboundId, boolean includeHistory) {
return feeDetails.selectList(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
.ne(!includeHistory, InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED")
.orderByDesc(InboundFeeDetailEntity::getBusinessDate)
.orderByDesc(InboundFeeDetailEntity::getSourceFeeVersion)
.orderByDesc(InboundFeeDetailEntity::getId));
}
@@ -223,13 +225,20 @@ public class FeeItemRepository {
.set(InboundFeeDetailEntity::getUpdatedBy, reviewerId));
}
public int adjustApproved(InboundFeeDetailEntity detail, int expectedSourceVersion) {
return feeDetails.update(detail, new LambdaUpdateWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, detail.getTenantId())
.eq(InboundFeeDetailEntity::getInboundId, detail.getInboundId())
.eq(InboundFeeDetailEntity::getId, detail.getId())
public int supersedeApprovedManual(long tenantId, long inboundId, long detailId,
int expectedSourceVersion, long operatorId,
LocalDateTime now) {
return feeDetails.update(null, new LambdaUpdateWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
.eq(InboundFeeDetailEntity::getId, detailId)
.eq(InboundFeeDetailEntity::getSourceType, "MANUAL")
.eq(InboundFeeDetailEntity::getReviewStatus, "APPROVED")
.eq(InboundFeeDetailEntity::getSourceFeeVersion, expectedSourceVersion));
.eq(InboundFeeDetailEntity::getSourceFeeVersion, expectedSourceVersion)
.ne(InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED")
.set(InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED")
.set(InboundFeeDetailEntity::getUpdatedBy, operatorId)
.set(InboundFeeDetailEntity::getUpdatedAt, now));
}
public int voidApproved(long tenantId, long inboundId, long detailId,
@@ -30,9 +30,24 @@ public class InboundFeeDetailService {
@Resource
private FinanceFeeSourceOutboxService financeOutbox;
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId) {
@Transactional
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId, boolean includeHistory) {
requiredInbound(tenantId, inboundId);
return repository.listDetails(tenantId, inboundId);
materializeLatestFreightIfMissing(tenantId, inboundId);
return repository.listDetails(tenantId, inboundId, includeHistory);
}
private void materializeLatestFreightIfMissing(long tenantId, long inboundId) {
InboundFeeEntity latest = pricingRepository.findLatestFee(tenantId, inboundId);
if (latest == null || repository.findBySourceFee(
tenantId, "FBA_PRICING", latest.getId()) != null) return;
LocalDateTime occurredAt = latest.getSentAt() != null ? latest.getSentAt()
: latest.getCalculatedAt();
if ("SENT".equals(latest.getFeeStatus())) {
publishFreight(tenantId, inboundId, latest.getId(), 0L, "SYSTEM", occurredAt);
} else if ("CALCULATED".equals(latest.getFeeStatus())) {
materializeCalculatedFreight(tenantId, inboundId, latest.getId(), 0L, occurredAt);
}
}
public InboundFeeDetailEntity publishedFreight(long tenantId, long inboundId) {
@@ -147,8 +162,15 @@ public class InboundFeeDetailService {
repository.supersedeFreight(tenantId, inboundId, feeId, operatorId, now);
InboundFeeDetailEntity detail = freightDetail(
tenantId, inboundId, operatorId, null, now, inbound, fee, requiredFreightItem(), false);
repository.insertDetail(detail);
return detail;
try {
repository.insertDetail(detail);
return detail;
} catch (DuplicateKeyException exception) {
InboundFeeDetailEntity concurrent = repository.findBySourceFee(
tenantId, "FBA_PRICING", feeId);
if (concurrent != null) return concurrent;
throw exception;
}
}
private InboundFeeDetailEntity freightDetail(long tenantId, long inboundId, long operatorId,
@@ -230,7 +252,7 @@ public class InboundFeeDetailService {
@Transactional
public void approve(long tenantId, long inboundId, long detailId,
long reviewerId, String reviewerName) {
required(tenantId, inboundId, detailId);
requiredManual(tenantId, inboundId, detailId);
if (repository.review(tenantId, inboundId, detailId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
throw conflict("只有待审核费用明细可以审核通过");
@@ -243,7 +265,7 @@ public class InboundFeeDetailService {
public InboundFeeDetailEntity adjust(long tenantId, long inboundId, long detailId,
int expectedSourceVersion, long operatorId,
SaveRequest request) {
InboundFeeDetailEntity value = required(tenantId, inboundId, detailId);
InboundFeeDetailEntity value = requiredManual(tenantId, inboundId, detailId);
if (!"APPROVED".equals(value.getReviewStatus()) || "VOIDED".equals(value.getPublicationStatus())) {
throw conflict("只有审核通过且未作废的费用明细可以调整");
}
@@ -252,25 +274,30 @@ public class InboundFeeDetailService {
}
InboundOrderEntity inbound = requiredInbound(tenantId, inboundId);
FeeItemEntity item = requiredActiveItem(request.feeItemId());
apply(value, inbound, item, request, operatorId);
value.setSourceType("MANUAL");
value.setSourceFeeVersion(expectedSourceVersion + 1);
value.setReviewStatus("PENDING");
value.setReviewerId(null);
value.setReviewerName(null);
value.setReviewedAt(null);
value.setReviewReason(null);
value.setPublicationStatus("DRAFT");
if (repository.adjustApproved(value, expectedSourceVersion) != 1) {
LocalDateTime now = LocalDateTime.now();
if (repository.supersedeApprovedManual(tenantId, inboundId, detailId,
expectedSourceVersion, operatorId, now) != 1) {
throw conflict("费用明细已被其他人调整,请刷新后重试");
}
return required(tenantId, inboundId, detailId);
InboundFeeDetailEntity next = new InboundFeeDetailEntity();
next.setTenantId(tenantId);
next.setInboundId(inboundId);
next.setSourceType("MANUAL");
next.setSourceFeeId(value.getSourceFeeId() == null ? value.getId() : value.getSourceFeeId());
next.setSourceFeeVersion(expectedSourceVersion + 1);
next.setSourceFeeNo(value.getSourceFeeNo());
next.setCreatedBy(operatorId);
apply(next, inbound, item, request, operatorId);
next.setReviewStatus("PENDING");
next.setPublicationStatus("DRAFT");
repository.insertDetail(next);
return next;
}
@Transactional
public void voidApproved(long tenantId, long inboundId, long detailId,
int expectedSourceVersion, long operatorId) {
InboundFeeDetailEntity current = required(tenantId, inboundId, detailId);
InboundFeeDetailEntity current = requiredManual(tenantId, inboundId, detailId);
if (repository.voidApproved(tenantId, inboundId, detailId, expectedSourceVersion, operatorId) != 1) {
throw conflict("只有当前版本的已审核费用明细可以作废");
}
@@ -283,7 +310,7 @@ public class InboundFeeDetailService {
@Transactional
public void reject(long tenantId, long inboundId, long detailId, long reviewerId,
String reviewerName, String reason) {
required(tenantId, inboundId, detailId);
requiredManual(tenantId, inboundId, detailId);
if (repository.review(tenantId, inboundId, detailId, "REJECTED",
reviewerId, reviewerName, reason.trim()) != 1) {
throw conflict("只有待审核费用明细可以驳回");
@@ -333,11 +360,19 @@ public class InboundFeeDetailService {
}
private InboundFeeDetailEntity requiredEditable(long tenantId, long inboundId, long detailId) {
InboundFeeDetailEntity value = required(tenantId, inboundId, detailId);
InboundFeeDetailEntity value = requiredManual(tenantId, inboundId, detailId);
if ("APPROVED".equals(value.getReviewStatus())) throw conflict("审核通过的费用明细不能修改或删除");
return value;
}
private InboundFeeDetailEntity requiredManual(long tenantId, long inboundId, long detailId) {
InboundFeeDetailEntity value = required(tenantId, inboundId, detailId);
if ("FBA_PRICING".equals(value.getSourceType())) {
throw conflict("自动头程运费明细为只读派生,请在头程费用中调整或发布");
}
return value;
}
private static void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound,
FeeItemEntity item, SaveRequest request, long operatorId) {
ensureWarehouseApplicable(item, request.direction());
@@ -380,7 +415,8 @@ public class InboundFeeDetailService {
String eventType = "VOIDED".equals(status) ? FinanceFeeSourceOutboxService.VOIDED
: version > 1 ? FinanceFeeSourceOutboxService.ADJUSTED
: FinanceFeeSourceOutboxService.PUBLISHED;
long sourceId = detail.getSourceFeeId() == null ? detail.getId() : detail.getSourceFeeId();
financeOutbox.publishInbound(eventType, detail.getTenantId(), detail.getInboundId(),
detail.getId(), version, occurredAt);
sourceId, version, occurredAt);
}
}
@@ -508,6 +508,14 @@ public class LoadingListService {
json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED",
"version", before.getVersion() + 1)), operatorId, trim(operatorName),
"PDA已人工确认装柜完成");
}
/**
* Publishes the cross-region handover only after every inbound projection has reached LOADED.
* The PDA completion orchestrator invokes this after finalizing all fulfillment slices.
*/
@Transactional
public void publishCompletedEvent(long planId) {
loadingCompletedOutboxService.publish(planId);
}
@@ -472,6 +472,7 @@ public class LoadingPickService {
loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), operatorId,
operatorName, "PDA-" + operationId + '-' + owner.inboundId(), remark);
}
loadingListService.publishCompletedEvent(task.getLoadingPlanId());
return view(requiredTask(taskId), null);
}
@@ -406,7 +406,7 @@ DEALLOCATE PREPARE statement;
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_type',
"VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT 'MANUAL或FBA_PRICING' AFTER fee_item_id");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_fee_id',
"BIGINT UNSIGNED NULL COMMENT '来源计费快照ID' AFTER source_type");
"BIGINT UNSIGNED NULL COMMENT '来源计费快照ID或人工费用版本链根ID' AFTER source_type");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_fee_version',
"INT UNSIGNED NULL COMMENT '来源计费快照版本' AFTER source_fee_id");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_fee_no',
@@ -457,7 +457,7 @@ DELIMITER ;
CALL phase2_publish_add_index('fl_fee_item', 'uk_fee_item_code',
'UNIQUE KEY `uk_fee_item_code` (`fee_code`)');
CALL phase2_publish_add_index('fl_inbound_fee_detail', 'uk_inbound_fee_source',
'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`)');
'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`, `source_fee_version`)');
CALL phase2_publish_add_index('fl_inbound_fee_detail', 'idx_inbound_fee_published',
'KEY `idx_inbound_fee_published` (`tenant_id`, `inbound_id`, `publication_status`, `source_fee_version`)');
@@ -133,24 +133,25 @@ paths:
description: 仅匹配当前唯一 ACTIVE 船期价格表中的 ACTIVE 价格行,按起运仓、渠道、目的仓、pricingTaxMode 和计费单位估价;不再以 carrierId、业务 customerId 或客户计费规则作为前置。区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域和重量阶梯匹配;测量审核后即使主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态;需要 firstleg:fba-pricing:calculate 权限。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {content: {application/json: {schema: {$ref: '#/components/schemas/CalculateRequest'}}}}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/confirm:
post:
summary: 确认最新费用快照
description: 确认费用只更新费用状态;主流程已到 WAIT_SPLIT 时保持不变。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/send:
post:
summary: 发送已确认费用快照
description: 该动作涉及后续仓储流程,本轮二期验收不调用。
description: 人工发布当前已确认版本;仅此动作将费用置为 SENT、生成 PUBLISHED 的 FBA_PRICING 只读派生明细并写入 Finance Outbox。入库单已到 WAIT_SPLIT 时不得回退或阻塞主流程。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/latest:
get:
summary: 查询最新费用快照
summary: 查询 fl_inbound_fee 最新费用快照
description: fl_inbound_fee 是头程运费唯一事实源;fee-details 中 sourceType=FBA_PRICING 的记录仅为最新版本只读派生。
parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}]
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fees/published:
put:
summary: 调整初算或已发布费用
@@ -161,7 +162,62 @@ paths:
content:
application/json:
schema: {$ref: '#/components/schemas/AdjustFeeRequest'}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无调整权限}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/FeeSnapshotApiResponse'}}}}, '403': {description: 无调整权限}}
/api/platform/v1/inbounds/{inboundId}/fee-details:
get:
summary: 查询入仓费用明细
description: 默认隐藏 publicationStatus=SUPERSEDED 的历史版本。FBA_PRICING 是 fl_inbound_fee 最新版本的只读派生;MANUAL 是独立新增、审核和版本调整的其他应收/应付。
parameters:
- {$ref: '#/components/parameters/AccountId'}
- {$ref: '#/components/parameters/InboundId'}
- {name: includeHistory, in: query, required: false, schema: {type: boolean, default: false}, description: true 时包含 SUPERSEDED 历史版本}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailListApiResponse'}}}}, '403': {description: 无权限}}
post:
summary: 新增 MANUAL 其他费用
description: 只允许创建 MANUAL 来源;FBA_PRICING 由系统从 fl_inbound_fee 派生。
parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}:
put:
summary: 编辑待审核 MANUAL 费用
description: FBA_PRICING 只读派生禁止通过通用费用接口修改。
parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}}
delete:
summary: 删除待审核 MANUAL 费用
description: FBA_PRICING 只读派生禁止删除。
parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}]
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/VoidApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/approve:
post:
summary: 审核通过 MANUAL 费用当前版本
parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}]
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/VoidApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/reject:
post:
summary: 驳回 MANUAL 费用当前版本
parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailRejectRequest'}}}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/VoidApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/adjust:
post:
summary: 调整已审核 MANUAL 费用并创建新版本
description: 原版本置为 SUPERSEDED,新记录使用相同 sourceFeeId、sourceFeeVersion+1 并重新进入 PENDING/DRAFT;FBA_PRICING 必须通过头程费用调整接口处理。
parameters:
- {$ref: '#/components/parameters/AccountId'}
- {$ref: '#/components/parameters/InboundId'}
- {$ref: '#/components/parameters/DetailId'}
- {name: expectedSourceVersion, in: query, required: true, schema: {type: integer, minimum: 1}}
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}}
/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/void:
post:
summary: 作废已审核 MANUAL 费用当前版本
parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/DetailId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailVersionRequest'}}}}
responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/VoidApiResponse'}}}}, '403': {description: 无权限}}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
@@ -170,9 +226,182 @@ components:
TenantId: {name: tenantId, in: query, required: true, description: 目标客户 ID;参数名为历史兼容, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
InboundId: {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
DetailId: {name: detailId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
AccountId: {name: accountId, in: query, required: true, description: 目标客户 ID, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
VersionId: {name: versionId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas:
FeeSnapshotApiResponse:
type: object
required: [code, message, data]
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/FeeSnapshotResponse'}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time, nullable: true}
FeeSnapshotResponse:
type: object
description: fl_inbound_fee 当前版本快照;自动初算为 CALCULATED,人工确认后为 CONFIRMED,只有人工发布后为 SENT。
required: [feeId, feeNo, feeVersion, feeStatus, inboundId, totalAmount, currency]
properties:
feeId: {type: string, pattern: '^[1-9][0-9]*$'}
feeNo: {type: string}
feeVersion: {type: integer, minimum: 1}
feeStatus: {type: string, enum: [CALCULATED, CONFIRMED, SENT, SUPERSEDED]}
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundCode: {type: string, nullable: true}
chargeRuleId: {type: string, nullable: true}
ruleCode: {type: string, nullable: true}
chargeRuleVersion: {type: integer, nullable: true}
carrierRateId: {type: string, nullable: true}
rateCode: {type: string, nullable: true}
carrierRateVersion: {type: integer, nullable: true}
priceBookVersionId: {type: string, nullable: true}
customerId: {type: string, nullable: true}
customerName: {type: string, nullable: true}
originWarehouseId: {type: string, nullable: true}
originWarehouseName: {type: string, nullable: true}
businessType: {type: string, nullable: true}
channelCode: {type: string, nullable: true}
carrierId: {type: string, nullable: true}
carrierName: {type: string, nullable: true}
destinationType: {type: string, nullable: true}
destinationWarehouseId: {type: string, nullable: true}
destinationName: {type: string, nullable: true}
destinationCode: {type: string, nullable: true}
taxMode: {type: string, nullable: true}
pricingTaxMode: {type: string, nullable: true}
chargeMode: {type: string, nullable: true}
conversionFactor: {type: number, nullable: true}
roundingScale: {type: integer, nullable: true}
roundingMode: {type: string, nullable: true}
actualWeightKg: {type: number, nullable: true}
actualVolumeCbm: {type: number, nullable: true}
convertedQuantity: {type: number, nullable: true}
chargeableQuantity: {type: number, nullable: true}
chargeUnit: {type: string, nullable: true}
unitPrice: {type: number, nullable: true}
subtotalAmount: {type: number, nullable: true}
additionalAmount: {type: number, nullable: true}
totalAmount: {type: number}
currency: {type: string, pattern: '^[A-Z]{3}$'}
rateEffectiveFrom: {type: string, format: date-time, nullable: true}
rateEffectiveTo: {type: string, format: date-time, nullable: true}
calculatedAt: {type: string, format: date-time, nullable: true}
confirmedAt: {type: string, format: date-time, nullable: true}
sentAt: {type: string, format: date-time, nullable: true}
nextProcessStatus: {type: string, nullable: true, description: 费用动作后的入库主流程状态;审核完成后保持 WAIT_SPLIT}
InboundFeeDetailSaveRequest:
type: object
required: [feeItemId, direction, businessDate, unitPrice, currency, amount, settlementIncluded]
properties:
feeItemId: {type: string, pattern: '^[1-9][0-9]*$'}
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
businessDate: {type: string, format: date}
unitPrice: {type: number, minimum: 0}
currency: {type: string, pattern: '^[A-Z]{3}$'}
amount: {type: number, minimum: 0}
settlementIncluded: {type: boolean}
partnerId: {type: string, nullable: true}
partnerName: {type: string, maxLength: 200, nullable: true}
remark: {type: string, maxLength: 500, nullable: true}
InboundFeeDetailVersionRequest:
type: object
required: [expectedSourceVersion]
properties: {expectedSourceVersion: {type: integer, minimum: 1}}
InboundFeeDetailRejectRequest:
type: object
required: [reason]
properties: {reason: {type: string, minLength: 1, maxLength: 500}}
InboundFeeDetailResource:
type: object
description: 入仓费用展示记录。sourceType=FBA_PRICING 时是只读派生;sourceType=MANUAL 时按 sourceFeeId/sourceFeeVersion 独立版本化。
required: [id, tenantId, inboundId, feeItemId, sourceType, sourceFeeVersion, direction, businessDate, unitPrice, currency, amount, reviewStatus, publicationStatus, settlementIncluded]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
feeItemId: {type: string, pattern: '^[1-9][0-9]*$'}
sourceType: {type: string, enum: [MANUAL, FBA_PRICING]}
sourceFeeId: {type: string, nullable: true, description: FBA_PRICING 对应 fl_inbound_fee.id;MANUAL V2+ 指向稳定的首版本来源 ID}
sourceFeeVersion: {type: integer, minimum: 1}
sourceFeeNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
partnerId: {type: string, nullable: true}
partnerName: {type: string, nullable: true}
businessDate: {type: string, format: date}
customerId: {type: string, nullable: true}
customerName: {type: string, nullable: true}
invoiceTitle: {type: string, nullable: true}
feeCategorySnapshot: {type: string, nullable: true}
tradeScopeSnapshot: {type: string, nullable: true}
feeTypeNameSnapshot: {type: string, nullable: true}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
destinationCode: {type: string, nullable: true}
destinationName: {type: string, nullable: true}
packageCount: {type: integer, nullable: true}
actualGrossWeightKg: {type: number, nullable: true}
actualVolumeCbm: {type: number, nullable: true}
volumeWeightKg: {type: number, nullable: true}
chargeableWeightKg: {type: number, nullable: true}
chargeableVolumeCbm: {type: number, nullable: true}
chargeableQuantity: {type: number, nullable: true}
chargeUnit: {type: string, nullable: true}
unitPrice: {type: number}
currency: {type: string, pattern: '^[A-Z]{3}$'}
amount: {type: number}
remark: {type: string, nullable: true}
reviewStatus: {type: string, enum: [PENDING, APPROVED, REJECTED]}
reviewerId: {type: string, nullable: true}
reviewerName: {type: string, nullable: true}
reviewedAt: {type: string, format: date-time, nullable: true}
reviewReason: {type: string, nullable: true}
publicationStatus: {type: string, enum: [DRAFT, PUBLISHED, SUPERSEDED, VOIDED]}
publishedBy: {type: string, nullable: true}
publishedByName: {type: string, nullable: true}
publishedAt: {type: string, format: date-time, nullable: true}
inboundedAt: {type: string, format: date-time, nullable: true}
settlementIncluded: {type: boolean}
createdBy: {type: string, nullable: true}
createdAt: {type: string, format: date-time, nullable: true}
updatedBy: {type: string, nullable: true}
updatedAt: {type: string, format: date-time, nullable: true}
deleted: {type: boolean, nullable: true}
version: {type: integer, nullable: true}
InboundFeeDetailApiResponse:
type: object
required: [code, message, data]
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/InboundFeeDetailResource'}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time, nullable: true}
InboundFeeDetailListApiResponse:
type: object
required: [code, message, data]
properties:
code: {type: string}
message: {type: string}
data: {type: array, items: {$ref: '#/components/schemas/InboundFeeDetailResource'}}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time, nullable: true}
VoidApiResponse:
type: object
required: [code, message]
properties:
code: {type: string}
message: {type: string}
data: {nullable: true}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time, nullable: true}
AdjustFeeRequest:
type: object
required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason]
@@ -1053,6 +1053,7 @@ components:
sailingAt: {type: string, format: date-time, nullable: true}
portArrivalAt: {type: string, format: date-time, nullable: true}
expressTrackingNo: {type: string, nullable: true}
createdByName: {type: string, nullable: true}
createdAt: {type: string, format: date-time, nullable: true}
updatedAt: {type: string, format: date-time, nullable: true}
UnifiedPreloadSummary:
@@ -1629,6 +1630,37 @@ components:
companyId: {type: string, nullable: true}
taxMode: {type: string, nullable: true, description: 订单报关方式快照}
pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED], nullable: true, description: 下单时选择的报价包税维度快照}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
customerName: {type: string, nullable: true}
companyName: {type: string, nullable: true}
warehouseId: {type: string, nullable: true}
warehouseName: {type: string, nullable: true}
deliveryWarehouseId: {type: string, nullable: true}
deliveryWarehouseName: {type: string, nullable: true}
businessType: {type: string, nullable: true}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
containerMode: {type: string, nullable: true}
customsInspection: {type: boolean, nullable: true}
destinationType: {type: string, nullable: true}
destinationWarehouseId: {type: string, nullable: true}
destinationCode: {type: string, nullable: true}
destinationName: {type: string, nullable: true}
totalPackageCount: {type: integer, nullable: true}
actualGrossWeightKg: {type: number, nullable: true}
actualNetWeightKg: {type: number, nullable: true}
actualVolumeCbm: {type: number, nullable: true}
volumeWeightKg: {type: number, nullable: true}
chargeableWeightKg: {type: number, nullable: true}
chargeableVolumeCbm: {type: number, nullable: true}
palletCount: {type: integer, nullable: true}
locationCodes: {type: string, nullable: true}
measureCompletedAt: {type: string, format: date-time, nullable: true}
inboundedAt: {type: string, format: date-time, nullable: true}
expressCompanyName: {type: string, nullable: true}
expressTrackingNo: {type: string, nullable: true}
createdAt: {type: string, format: date-time, nullable: true}
updatedAt: {type: string, format: date-time, nullable: true}
InboundMeasurementCallbackRequest:
type: object
required: [eventNo, externalDetailNo, warehouseCode, inboundCode, inboundDetailId, deviceCode, measuredAt, boxSpecification, packageCount, lengthCm, widthCm, heightCm]
@@ -51,14 +51,13 @@ class FbaPricingContractTest {
.contains("主流程已到 WAIT_SPLIT,也可独立试算且不回退主流程状态")
.contains("区域阶梯渠道可按平台 FBA 仓或海外中转仓的邮编区域")
.contains("不再以 carrierId、业务 customerId 或客户计费规则作为前置")
.contains("该动作涉及后续仓储流程,本轮二期验收不调用")
.contains("仅此动作将费用置为 SENT")
.contains("写入 Finance Outbox")
.contains("不回退 WAIT_SPLIT 状态")
.contains("expectedFeeVersion")
.contains("chargeableQuantity")
.contains("unitPrice")
.doesNotContain("required: [subtotalAmount, additionalAmount, reason]")
.doesNotContain("应收")
.doesNotContain("应付")
.doesNotContain("待客户确认");
String customerRules = openapi.substring(
openapi.indexOf("/api/platform/v1/fba-pricing/customer-rules:"),
@@ -0,0 +1,80 @@
package com.baiyue.firstleg.controller;
import com.baiyue.firstleg.dto.FbaPricingDtos;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.yaml.snakeyaml.Yaml;
import java.lang.reflect.Field;
import java.lang.reflect.RecordComponent;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class InboundFeeOpenApiContractTest {
@Test
void documentsEveryInboundFeeControllerPathAndReadOnlySourceContract() throws Exception {
String source = new ClassPathResource("openapi/fba-pricing-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = map(new Yaml().load(source));
Map<String, Object> paths = map(document.get("paths"));
assertThat(paths).containsKeys(
"/api/platform/v1/inbounds/{inboundId}/fees/calculate",
"/api/platform/v1/inbounds/{inboundId}/fees/confirm",
"/api/platform/v1/inbounds/{inboundId}/fees/send",
"/api/platform/v1/inbounds/{inboundId}/fees/latest",
"/api/platform/v1/inbounds/{inboundId}/fees/published",
"/api/platform/v1/inbounds/{inboundId}/fee-details",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/approve",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/reject",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/adjust",
"/api/platform/v1/inbounds/{inboundId}/fee-details/{detailId}/void");
assertThat(source).contains("includeHistory", "FBA_PRICING", "MANUAL", "只读派生",
"Finance Outbox", "CALCULATED", "CONFIRMED", "SENT", "SUPERSEDED");
}
@Test
void responseAndRequestSchemasTrackActualDtoAndEntityFields() throws Exception {
Map<String, Object> document = map(new Yaml().load(new ClassPathResource(
"openapi/fba-pricing-v1.yaml").getContentAsString(StandardCharsets.UTF_8)));
Map<String, Object> schemas = map(map(document.get("components")).get("schemas"));
assertThat(properties(schemas, "FeeSnapshotResponse").keySet())
.containsExactlyInAnyOrder(recordFields(FbaPricingDtos.FeeSnapshotResponse.class).toArray(String[]::new));
assertThat(properties(schemas, "InboundFeeDetailSaveRequest").keySet())
.containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.SaveRequest.class).toArray(String[]::new));
assertThat(properties(schemas, "InboundFeeDetailVersionRequest").keySet())
.containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.VersionRequest.class).toArray(String[]::new));
assertThat(properties(schemas, "InboundFeeDetailRejectRequest").keySet())
.containsExactlyInAnyOrder(recordFields(InboundFeeDetailDtos.RejectRequest.class).toArray(String[]::new));
Set<String> entityFields = new LinkedHashSet<>();
for (Class<?> type = InboundFeeDetailEntity.class; type != Object.class; type = type.getSuperclass()) {
Arrays.stream(type.getDeclaredFields()).map(Field::getName).forEach(entityFields::add);
}
assertThat(properties(schemas, "InboundFeeDetailResource").keySet())
.containsExactlyInAnyOrder(entityFields.toArray(String[]::new));
}
private static Set<String> recordFields(Class<?> type) {
Set<String> fields = new LinkedHashSet<>();
Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName).forEach(fields::add);
return fields;
}
private static Map<String, Object> properties(Map<String, Object> schemas, String schema) {
return map(map(schemas.get(schema)).get("properties"));
}
@SuppressWarnings("unchecked")
private static Map<String, Object> map(Object value) {
return (Map<String, Object>) value;
}
}
@@ -22,6 +22,16 @@ class InboundOrderStatusSqlContractTest {
.contains("process_status = 'INBOUNDING'");
}
@Test
void keepsConfirmedWeightNetSumAuthoritativeWhenMeasurementIsApproved() {
assertThat(sql("approveMeasurement"))
.contains("FROM fl_inbound_weight_result weight")
.contains("weight.recognition_status = 'CONFIRMED'")
.contains("weight.deleted = 0")
.contains("SUM(weight.net_weight_kg)")
.contains("ELSE (SELECT COALESCE(SUM(item.total_weight_kg), 0)");
}
private static String sql(String methodName) {
Method method = Arrays.stream(InboundOrderMapper.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
@@ -223,6 +223,37 @@ class FeeManagementServiceTest {
assertThat(saved.getSettlementIncluded()).isTrue();
}
@Test
@DisplayName("查询费用明细会幂等补齐历史初算缺失的派生行")
void materializesMissingLatestCalculatedFreightOnList() {
InboundOrderEntity inbound = inbound();
inbound.setCustomerId(101L);
inbound.setCustomerName("客户A");
when(repository.findInbound(10001L, 21L)).thenReturn(inbound);
when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null);
when(repository.listDetails(10001L, 21L, false)).thenReturn(List.of());
FeeItemEntity freightItem = item(true, false);
freightItem.setFeeCode("FBA_HEAD_FREIGHT");
when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(freightItem);
InboundFeeEntity fee = new InboundFeeEntity();
fee.setId(501L); fee.setInboundId(21L); fee.setFeeNo("FBAF-21-2");
fee.setFeeVersion(2); fee.setFeeStatus("CALCULATED");
fee.setActualWeightKg(new BigDecimal("100.0000"));
fee.setActualVolumeCbm(new BigDecimal("1.000000"));
fee.setChargeableQuantity(new BigDecimal("167.0000"));
fee.setChargeUnit("KG"); fee.setCurrency("USD");
fee.setTotalAmount(new BigDecimal("354.00"));
fee.setCalculatedAt(LocalDateTime.of(2026, 8, 17, 12, 0));
when(pricingRepository.findLatestFee(10001L, 21L)).thenReturn(fee);
when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee);
details.list(10001L, 21L, false);
verify(repository).insertDetail(any(InboundFeeDetailEntity.class));
verify(repository).listDetails(10001L, 21L, false);
}
@Test
@DisplayName("系统头程运费项目缺失时拒绝运行期补建")
void rejectsMissingSystemFreightItem() {
@@ -321,6 +352,53 @@ class FeeManagementServiceTest {
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
@Test
@DisplayName("自动头程运费派生明细不能在费用明细中二次审核")
void rejectsReviewingDerivedFreight() {
InboundFeeDetailEntity derived = new InboundFeeDetailEntity();
derived.setSourceType("FBA_PRICING");
derived.setReviewStatus("PENDING");
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(derived);
assertThatThrownBy(() -> details.approve(10001L, 21L, 31L, 9L, "admin"))
.isInstanceOf(BizException.class)
.hasMessageContaining("只读派生");
verify(repository, never()).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null);
}
@Test
@DisplayName("已审核人工费用调整保留旧版本并创建待审核新版本")
void versionsApprovedManualAdjustment() {
InboundFeeDetailEntity approved = new InboundFeeDetailEntity();
approved.setId(31L);
approved.setTenantId(10001L);
approved.setInboundId(21L);
approved.setSourceType("MANUAL");
approved.setSourceFeeVersion(1);
approved.setReviewStatus("APPROVED");
approved.setPublicationStatus("PUBLISHED");
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved);
when(repository.findInbound(10001L, 21L)).thenReturn(inbound());
when(repository.findItem(11L)).thenReturn(item(true, true));
when(repository.supersedeApprovedManual(eq(10001L), eq(21L), eq(31L),
eq(1), eq(9L), any(LocalDateTime.class))).thenReturn(1);
doAnswer(invocation -> {
((InboundFeeDetailEntity) invocation.getArgument(0)).setId(32L);
return null;
}).when(repository).insertDetail(any());
InboundFeeDetailEntity next = details.adjust(
10001L, 21L, 31L, 1, 9L, detailRequest("RECEIVABLE"));
assertThat(next.getId()).isEqualTo(32L);
assertThat(next.getSourceFeeId()).isEqualTo(31L);
assertThat(next.getSourceFeeVersion()).isEqualTo(2);
assertThat(next.getReviewStatus()).isEqualTo("PENDING");
assertThat(next.getPublicationStatus()).isEqualTo("DRAFT");
verify(repository).insertDetail(next);
}
@Test
@DisplayName("作废已入池人工费用引用当前版本并发布VOIDED")
void voidsPublishedSettlementFee() {
@@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.apache.ibatis.annotations.Select;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
@@ -48,6 +49,14 @@ class LoadingCompletedOutboxServiceTest {
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:1");
}
@Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception {
String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class)
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
assertThat(sql).contains("p.plan_status = 'COMPLETED'")
.contains("o.process_status = 'LOADED'");
}
private Row row(long account,long inbound,long detail) {
Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L);
r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L);
@@ -176,6 +176,10 @@ class LoadingListServiceTest {
verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"),
eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(),
eq(9L), eq("operator"), eq("PDA已人工确认装柜完成"));
verify(loadingCompletedOutboxService, never()).publish(10L);
service.publishCompletedEvent(10L);
verify(loadingCompletedOutboxService).publish(10L);
}
@@ -523,6 +523,12 @@ class LoadingPickServiceTest {
verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
verify(loadingListService).publishCompletedEvent(10L);
InOrder finalization = inOrder(loadingListService, loadingDeliveryService);
finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
finalization.verify(loadingListService).publishCompletedEvent(10L);
}
@Test
@@ -548,6 +554,31 @@ class LoadingPickServiceTest {
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyString(), anyString(), any());
verify(loadingListService, never()).publishCompletedEvent(anyLong());
}
@Test
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
LoadingPickTaskEntity task = task();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"入库单并发修改"))
.when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-finalize-fails-50", "checked");
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "complete-finalize-fails", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("并发修改");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingListService, never()).publishCompletedEvent(anyLong());
}
@Test
@@ -13,7 +13,7 @@ import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
import com.baiyue.iam.dto.TenantDtos.CreationResponse;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse;
import com.baiyue.iam.service.TenantGuard;
import com.baiyue.iam.service.TenantService;
import com.baiyue.iam.service.MemberService;
@@ -179,7 +179,7 @@ public class PlatformTenantController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Review list returned / 查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域")
})
public ApiResponse<List<IamTenantReview>> reviews(@AuthenticationPrincipal AuthenticatedUser user) {
public ApiResponse<List<PendingReviewResponse>> reviews(@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.pendingReviews());
}
@@ -101,6 +101,21 @@ public final class TenantDtos {
@Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment
) { }
@Schema(name = "PendingEnterpriseReviewResponse", description = "Pending enterprise review with the customer summary required by platform reviewers / 待审核企业及客户摘要")
public record PendingReviewResponse(
@Schema(description = "Review ID / 审核单 ID", example = "190000000000000001") Long id,
@Schema(description = "Customer ID / 客户 ID", example = "190000000000000002") Long tenantId,
@Schema(description = "Review type / 审核类型", example = "REGISTER") String reviewType,
@Schema(description = "Review status / 审核状态", example = "PENDING") String status,
@Schema(description = "Customer name / 客户名称", example = "深圳虫虫出海科技有限公司") String tenantName,
@Schema(description = "Customer code / 客户编号", example = "TEN190000000000000002") String tenantCode,
@Schema(description = "Primary contact / 联系人", example = "张三") String contactName,
@Schema(description = "Contact phone / 联系电话", example = "13800138000") String contactPhone,
@Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") String contactEmail,
@Schema(description = "Review submission time / 提交审核时间") LocalDateTime submittedAt,
@Schema(description = "Review comment / 审核意见", nullable = true) String reviewComment
) { }
@Schema(name = "TenantApprovalResponse", description = "Enterprise approval result and one-time administrator credentials / 企业审核结果及一次性管理员凭据")
public record ApprovalResponse(
@Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId,
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Collection;
/**
* 租户仓储,封装租户及审核单的唯一性校验、查询、创建、更新和待审列表操作。
@@ -48,6 +49,12 @@ public class TenantRepository {
}
public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); }
public List<IamTenant> findMerchantsByIds(Collection<Long> tenantIds) {
if (tenantIds == null || tenantIds.isEmpty()) return List.of();
return tenantMapper.selectList(new LambdaQueryWrapper<IamTenant>()
.in(IamTenant::getId, tenantIds)
.eq(IamTenant::getTenantType, "MERCHANT"));
}
/**
* Serializes the draft qualification workflow for one enterprise. The file service and the
* merchant submit action run in separate requests, so qualification-row locks alone cannot
@@ -22,6 +22,7 @@ import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse;
import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
import com.baiyue.iam.dto.TenantDtos.TenantResponse;
import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary;
import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse;
import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
@@ -40,7 +41,10 @@ import feign.FeignException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 租户生命周期应用服务,编排商户入驻、资料维护、审核流转和租户状态变更事务。
@@ -189,8 +193,17 @@ public class TenantService {
return new CreationResponse(response(tenant), administrator.username(), administrator.temporaryPassword());
}
public List<IamTenantReview> pendingReviews() {
return repository.findPendingReviews();
public List<PendingReviewResponse> pendingReviews() {
List<IamTenantReview> reviews = repository.findPendingReviews();
if (reviews.isEmpty()) return List.of();
Map<Long, IamTenant> tenants = repository.findMerchantsByIds(reviews.stream()
.map(IamTenantReview::getTenantId)
.collect(Collectors.toSet()))
.stream()
.collect(Collectors.toMap(IamTenant::getId, Function.identity()));
return reviews.stream()
.map(review -> pendingReviewResponse(review, tenants.get(review.getTenantId())))
.toList();
}
public PageResult<TenantResponse> pageMerchants(long page, long size, String keyword, String status) {
@@ -497,6 +510,17 @@ public class TenantService {
);
}
private PendingReviewResponse pendingReviewResponse(IamTenantReview review, IamTenant tenant) {
return new PendingReviewResponse(
review.getId(), review.getTenantId(), review.getReviewType(), review.getStatus(),
tenant == null ? null : tenant.getTenantName(),
tenant == null ? null : tenant.getTenantCode(),
tenant == null ? null : tenant.getContactName(),
tenant == null ? null : tenant.getContactPhone(),
tenant == null ? null : tenant.getContactEmail(),
review.getCreatedAt(), review.getReviewComment());
}
private TenantResponse summaryResponse(IamTenant tenant) {
TenantResponse response = response(tenant);
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(),
+28 -1
View File
@@ -259,7 +259,11 @@ paths:
summary: List pending tenant reviews / 查询待审核企业
description: Return enterprise registrations waiting for a platform decision. / 返回等待平台处理的企业注册审核记录。
responses:
'200': {$ref: '#/components/responses/Success'}
'200':
description: 待审核企业及客户摘要查询成功
content:
application/json:
schema: {$ref: '#/components/schemas/PendingEnterpriseReviewListApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/platform/v1/iam/reviews/{id}/approve:
post:
@@ -903,6 +907,29 @@ components:
- type: object
properties:
data: {$ref: '#/components/schemas/TenantApprovalResponse'}
PendingEnterpriseReviewListApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data:
type: array
items: {$ref: '#/components/schemas/PendingEnterpriseReviewResponse'}
PendingEnterpriseReviewResponse:
type: object
required: [id, tenantId, reviewType, status, tenantName, tenantCode, contactName, submittedAt]
properties:
id: {type: string, pattern: '^\d+$', description: 审核单 ID}
tenantId: {type: string, pattern: '^\d+$', description: 客户 ID}
reviewType: {type: string, example: REGISTER}
status: {type: string, enum: [PENDING]}
tenantName: {type: string, description: 客户名称}
tenantCode: {type: string, description: 客户编号}
contactName: {type: string, description: 联系人}
contactPhone: {type: string, nullable: true, description: 联系电话}
contactEmail: {type: string, format: email, nullable: true, description: 联系邮箱}
submittedAt: {type: string, format: date-time, description: 提交审核时间}
reviewComment: {type: string, nullable: true, description: 审核意见}
TenantCreationApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
@@ -23,6 +23,10 @@ class IamOpenApiContractTest {
.contains("PermissionCodesApiResponse:")
.contains("TenantPageApiResponse:")
.contains("TenantApiResponse:")
.contains("PendingEnterpriseReviewListApiResponse:")
.contains("PendingEnterpriseReviewResponse:")
.contains("required: [id, tenantId, reviewType, status, tenantName, tenantCode, contactName, submittedAt]")
.contains("submittedAt: {type: string, format: date-time, description: 提交审核时间}")
.contains("TenantSuccess:")
.contains("salesOwnerName:")
.contains("paymentTermDays:")
@@ -46,6 +46,11 @@ class PlatformTenantControllerContractTest {
assertEquals("hasAuthority('platform:customer:review')",
method.getAnnotation(PreAuthorize.class).value());
ParameterizedType apiResponse = (ParameterizedType) method.getGenericReturnType();
ParameterizedType list = (ParameterizedType) apiResponse.getActualTypeArguments()[0];
assertEquals(java.util.List.class, list.getRawType());
assertEquals(com.baiyue.iam.dto.TenantDtos.PendingReviewResponse.class,
list.getActualTypeArguments()[0]);
}
@Test
@@ -30,6 +30,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
@@ -93,6 +94,37 @@ class TenantServiceTest {
verify(repository).pageMerchants(1, 10, "Example", "ACTIVE");
}
@Test
void pendingReviewsIncludeCustomerSummaryWithoutPerReviewQueries() {
IamTenantReview review = review(71L, 10001L);
review.setReviewType("REGISTER");
review.setCreatedAt(LocalDateTime.of(2026, 8, 17, 10, 27));
IamTenant tenant = merchant(10001L, "PENDING");
tenant.setTenantCode("TEN10001");
tenant.setTenantName("Example Merchant");
tenant.setContactName("Alice");
tenant.setContactPhone("13800138000");
tenant.setContactEmail("alice@example.com");
when(repository.findPendingReviews()).thenReturn(List.of(review));
when(repository.findMerchantsByIds(Set.of(10001L))).thenReturn(List.of(tenant));
var result = service.pendingReviews();
assertEquals(1, result.size());
assertEquals(71L, result.get(0).id());
assertEquals(10001L, result.get(0).tenantId());
assertEquals("REGISTER", result.get(0).reviewType());
assertEquals("PENDING", result.get(0).status());
assertEquals("Example Merchant", result.get(0).tenantName());
assertEquals("TEN10001", result.get(0).tenantCode());
assertEquals("Alice", result.get(0).contactName());
assertEquals("13800138000", result.get(0).contactPhone());
assertEquals("alice@example.com", result.get(0).contactEmail());
assertEquals(LocalDateTime.of(2026, 8, 17, 10, 27), result.get(0).submittedAt());
verify(repository).findMerchantsByIds(Set.of(10001L));
verify(repository, never()).findTenant(10001L);
}
@Test
void pageFinanceCustomersKeepsFullContactSummaryForInternalFinance() {
IamTenant tenant = merchant(10001L, "ACTIVE");
@@ -47,8 +47,13 @@ public interface FirstlegUnifiedOrderClient {
Long customerId, String customerName, Long warehouseId, String warehouseName,
Long deliveryWarehouseId, String deliveryWarehouseName, String channelName,
Integer totalPackageCount, String locationCodes,
BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
LocalDateTime expectedArrivalAt, String feeReviewStatus,
String companyName, String businessType, String taxMode, String containerMode,
Boolean customsInspection, String destinationType, String destinationCode,
BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm,
BigDecimal volumeWeightKg, BigDecimal chargeableVolumeCbm, Integer palletCount,
BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt,
LocalDateTime inboundedAt, String expressCompanyName, String createdByName,
String loadingStatus, String containerNo, String cabinetNo, String vesselName, String voyageNo,
LocalDateTime sailingAt, LocalDateTime portArrivalAt,
String expressTrackingNo, LocalDateTime createdAt, LocalDateTime updatedAt) {}
@@ -21,6 +21,15 @@ public final class UnifiedOrderResponse {
Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate,
Integer totalPackageCount, String recommendedPalletType, String locationCodes,
BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
LocalDateTime expectedArrivalAt, String feeReviewStatus,
String companyName, String businessType, String pickupMethod,
String taxMode, String containerMode, Boolean customsInspection,
String destinationType, String destinationCode,
Integer totalPalletCount, BigDecimal chargeableVolumeCbm,
BigDecimal volumetricWeightKg, BigDecimal totalNetWeightKg,
LocalDateTime measurementCompletedAt, LocalDateTime inboundCompletedAt,
String expressCompany, String expressTrackingNo, String createdBy,
List<String> skuNos, Integer totalQuantity, String shipmentId, String referenceId,
MeasurementSummary measurement, PreloadSummary preload,
LoadingSummary loading, DeliverySummary delivery, TransportSummary transport,
String customerRemark, String latestInternalNote,
@@ -9,6 +9,7 @@ import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.entity.OrderInternalNoteEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
@@ -23,6 +24,7 @@ import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -56,9 +58,12 @@ public class UnifiedOrderQueryService {
? Map.of()
: summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList());
Map<Long, String> latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList());
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(tenantId,
source.records().stream().map(OrderEntity::getId).toList());
Map<Long, Integer> childCounts = childCounts(source.records());
List<UnifiedOrderResponse.Row> rows = source.records().stream()
.map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()), childCounts)).toList();
.map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()),
childCounts, cargoSummaries.get(order.getId()))).toList();
return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages());
}
@@ -72,7 +77,7 @@ public class UnifiedOrderQueryService {
? null : firstlegDetail(tenantId, orderId);
String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent();
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote,
childCounts(List.of(order)));
childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes()));
List<UnifiedOrderResponse.TimelineEvent> timeline = new ArrayList<>();
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
"ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(),
@@ -106,10 +111,12 @@ public class UnifiedOrderQueryService {
Map<Long, OrderEntity> orderById = orders.findByIds(tenantId, orderIds).stream()
.collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left));
Map<Long, String> latestNotes = latestNotes(tenantId, orderIds);
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(tenantId, orderIds);
Map<Long, Integer> childCounts = childCounts(List.copyOf(orderById.values()));
List<UnifiedOrderResponse.Row> records = source.records().stream()
.map(summary -> row(summary, orderById.get(summary.inbound().orderId()),
latestNotes.get(summary.inbound().orderId()), childCounts))
latestNotes.get(summary.inbound().orderId()), childCounts,
cargoSummaries.get(summary.inbound().orderId())))
.toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
}
@@ -134,6 +141,13 @@ public class UnifiedOrderQueryService {
OrderInternalNoteEntity::getOrderId, OrderInternalNoteEntity::getContent, (latest, ignored) -> latest));
}
private Map<Long, CargoSummary> cargoSummaries(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return Map.of();
return orders.findCargoByOrderIds(tenantId, orderIds).stream()
.collect(Collectors.groupingBy(OrderCargoEntity::getOrderId,
Collectors.collectingAndThen(Collectors.toList(), CargoSummary::from)));
}
private Map<Long, Integer> childCounts(List<OrderEntity> pageOrders) {
if (pageOrders.isEmpty()) return Map.of();
return orders.findChildrenByParentOrders(pageOrders).stream()
@@ -155,7 +169,8 @@ public class UnifiedOrderQueryService {
}
private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary,
String latestInternalNote, Map<Long, Integer> childCounts) {
String latestInternalNote, Map<Long, Integer> childCounts,
CargoSummary cargo) {
if (summary == null) {
String mainStatus = orderMainStatus(order.getStatus());
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus,
@@ -165,7 +180,12 @@ public class UnifiedOrderQueryService {
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
order.getInboundCode(),
order.getExpectedShipDate(), null, null, null, null, null, null, null, null, null, null,
order.getExpectedShipDate(), null, null, null, null,
null, null, null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(),
order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(),
null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(),
string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(),
null, null, null, null, null,
order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
}
@@ -175,15 +195,21 @@ public class UnifiedOrderQueryService {
order.getCustomerId(), order.getCustomerName(),
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
order.getExpectedShipDate(), inbound.totalPackageCount(), "STANDARD", inbound.locationCodes(),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), measurement(inbound),
order.getExpectedShipDate(), inbound.totalPackageCount(), null, inbound.locationCodes(),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(),
inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(),
inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(),
inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(),
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order.getShipmentId(), order.getReferenceId(), measurement(inbound),
preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
}
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order,
String latestInternalNote, Map<Long, Integer> childCounts) {
String latestInternalNote, Map<Long, Integer> childCounts,
CargoSummary cargo) {
var inbound = summary.inbound();
return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(),
summary.mainStatus(), summary.nextAction(), relation(order, childCounts),
@@ -191,8 +217,14 @@ public class UnifiedOrderQueryService {
inbound.customerId(), inbound.customerName(), inbound.channelName(), inbound.warehouseId(),
inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(),
inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(),
"STANDARD", inbound.locationCodes(), inbound.actualGrossWeightKg(),
inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
null, inbound.locationCodes(), inbound.actualGrossWeightKg(),
inbound.actualVolumeCbm(), inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(),
inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(),
inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(),
inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(),
inbound.measureCompletedAt(), inbound.inboundedAt(), inbound.expressCompanyName(), inbound.expressTrackingNo(),
inbound.createdByName(), skuNos(cargo), totalQuantity(cargo), order == null ? null : order.getShipmentId(),
order == null ? null : order.getReferenceId(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), order == null ? null : order.getCustomerRemark(), latestInternalNote,
inbound.createdAt(), inbound.updatedAt());
}
@@ -225,6 +257,28 @@ public class UnifiedOrderQueryService {
return status == OrderStatus.SUBMITTED ? "PENDING_REVIEW"
: ACTIVE_ORDER_STATUSES.contains(status) ? "WAIT_MEASURE" : null;
}
private String string(Long value) {
return value == null ? null : value.toString();
}
private List<String> skuNos(CargoSummary cargo) {
return cargo == null || cargo.skuNos().isEmpty() ? null : cargo.skuNos();
}
private Integer totalQuantity(CargoSummary cargo) {
return cargo == null ? null : cargo.totalQuantity();
}
private record CargoSummary(List<String> skuNos, Integer totalQuantity) {
private static CargoSummary from(List<OrderCargoEntity> cargoes) {
List<String> skuNos = cargoes.stream().map(OrderCargoEntity::getSkuNo)
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
return new CargoSummary(skuNos, totalQuantity);
}
}
private String inboundMainStatus(String status) {
if (status == null) return null;
return switch (status) {
+24 -1
View File
@@ -424,10 +424,33 @@ components:
inboundCode: {type: string, nullable: true}
expectedShipDate: {type: string, format: date, nullable: true}
totalPackageCount: {type: integer, nullable: true}
recommendedPalletType: {type: string, nullable: true}
recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型}
locationCodes: {type: string, nullable: true}
actualGrossWeightKg: {type: number, nullable: true}
actualVolumeCbm: {type: number, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达}
companyName: {type: string, nullable: true}
businessType: {type: string, nullable: true}
pickupMethod: {type: string, nullable: true}
taxMode: {type: string, nullable: true}
containerMode: {type: string, nullable: true}
customsInspection: {type: boolean, nullable: true}
destinationType: {type: string, nullable: true}
destinationCode: {type: string, nullable: true}
totalPalletCount: {type: integer, nullable: true}
chargeableVolumeCbm: {type: number, nullable: true}
volumetricWeightKg: {type: number, nullable: true}
totalNetWeightKg: {type: number, nullable: true, description: Firstleg 确认后的实际净重;未确认时返回 null}
measurementCompletedAt: {type: string, format: date-time, nullable: true}
inboundCompletedAt: {type: string, format: date-time, nullable: true}
expressCompany: {type: string, nullable: true}
expressTrackingNo: {type: string, nullable: true}
createdBy: {type: string, nullable: true}
skuNos: {type: array, nullable: true, items: {type: string}}
totalQuantity: {type: integer, nullable: true}
shipmentId: {type: string, nullable: true}
referenceId: {type: string, nullable: true}
measurement: {$ref: '#/components/schemas/UnifiedMeasurementSummary'}
preload: {$ref: '#/components/schemas/UnifiedPreloadSummary'}
loading: {$ref: '#/components/schemas/UnifiedLoadingSummary'}
@@ -52,7 +52,12 @@ class UnifiedOrderApiContractTest {
Map<String, Object> row = stringMap(schemas.get("UnifiedOrderRow"));
assertThat(stringMap(row.get("properties"))).containsKeys(
"orderId", "mainStatus", "inboundId", "inboundVersion", "measurement", "preload",
"loading", "delivery", "transport", "customerRemark", "latestInternalNote");
"loading", "delivery", "transport", "customerRemark", "latestInternalNote",
"expectedArrivalAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod",
"taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode",
"totalPalletCount", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg",
"measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo",
"createdBy", "skuNos", "totalQuantity", "shipmentId", "referenceId");
assertThat(responseSchema(paths, "/api/platform/v1/orders", "get"))
.containsEntry("$ref", "#/components/schemas/UnifiedOrderPageEnvelope");
assertThat(responseSchema(paths, "/api/merchant/v1/orders/{orderId}", "get"))
@@ -77,8 +77,8 @@ class UnifiedOrderQueryServiceTest {
void pendingReviewInboundCodeFilterStaysInOrderQuery() {
UnifiedOrderQuery query = new UnifiedOrderQuery(
null, null, "PENDING_REVIEW", null, null, null, "RK-77", null,
null, null, null, null, null, null, null, null, null, null,
null, null, 1, 20);
null, null, null, null, null, null, null, null, null,
null, null, null, 1, 20);
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
.thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
@@ -167,7 +167,7 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.loading().loadingVersion()).isEqualTo(3);
assertThat(row.loading().pickingVersion()).isEqualTo(3);
assertThat(row.inboundCode()).isEqualTo("RK-77");
assertThat(row.recommendedPalletType()).isEqualTo("STANDARD");
assertThat(row.recommendedPalletType()).isNull();
assertThat(row.locationCodes()).isEqualTo("A0-01, B2-03");
});
verify(orders, never()).unifiedPage(any(), any(), any());
@@ -192,6 +192,53 @@ class UnifiedOrderQueryServiceTest {
assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
void listKeepsLoadedFulfillmentAheadOfRawApprovedOrderStatus() {
UnifiedOrderQuery query = query(null);
OrderEntity order = order(OrderStatus.APPROVED);
var summary = summary("LOADED", null);
when(orders.unifiedPage(eq(10001L), eq(query), any()))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
when(firstleg.summaries(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(List.of(summary)));
var result = service.page(10001L, query);
assertThat(result.records()).singleElement().satisfies(row -> {
assertThat(row.mainStatus()).isEqualTo("LOADED");
assertThat(row.internalOrderStatus()).isEqualTo(OrderStatus.APPROVED);
assertThat(row.inboundId()).isEqualTo(88L);
});
ArgumentCaptor<FirstlegUnifiedOrderClient.BatchRequest> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.BatchRequest.class);
verify(firstleg).summaries(eq("internal-token"), captor.capture());
assertThat(captor.getValue().tenantId()).isEqualTo(10001L);
assertThat(captor.getValue().orderIds()).containsExactly(77L);
}
@Test
void detailKeepsLoadedFulfillmentAheadOfRawApprovedOrderStatus() {
OrderEntity order = order(OrderStatus.APPROVED);
when(orders.findOwned(10001L, 77L)).thenReturn(order);
when(orders.findCargo(10001L, 77L)).thenReturn(List.of());
when(orders.findParties(10001L, 77L)).thenReturn(List.of());
when(orders.findNotes(10001L, 77L)).thenReturn(List.of());
when(orders.findHistory(10001L, 77L)).thenReturn(List.of());
when(firstleg.detail(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new FirstlegUnifiedOrderClient.Detail(summary("LOADED", null), List.of())));
var result = service.detail(10001L, 77L, false);
assertThat(result.summary().mainStatus()).isEqualTo("LOADED");
assertThat(result.summary().internalOrderStatus()).isEqualTo(OrderStatus.APPROVED);
assertThat(result.summary().inboundId()).isEqualTo(88L);
ArgumentCaptor<FirstlegUnifiedOrderClient.DetailRequest> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.DetailRequest.class);
verify(firstleg).detail(eq("internal-token"), captor.capture());
assertThat(captor.getValue().tenantId()).isEqualTo(10001L);
assertThat(captor.getValue().orderId()).isEqualTo(77L);
}
@Test
void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() {
List<String> statuses = List.of(
@@ -278,6 +325,7 @@ class UnifiedOrderQueryServiceTest {
var inbound = new FirstlegUnifiedOrderClient.Inbound(
88L, 10001L, 77L, "FL-77", "RK-77", "INBOUNDED", "APPROVED", "APPROVED", 7,
30001L, "测试客户", 11L, "深圳仓", 22L, "ONT8", "美森", 10, "A0-01, B2-03", null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null,
LocalDateTime.of(2026, 8, 6, 10, 0), LocalDateTime.of(2026, 8, 6, 11, 0));
return new FirstlegUnifiedOrderClient.Summary(inbound, status, action,
+7 -1
View File
@@ -12,7 +12,7 @@ import process from 'node:process'
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
const DEFAULT_USERNAME = 'platform_admin'
const DEFAULT_PASSWORD = 'ChangeMe123!'
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics']
export function normalizeModuleOrder(modules) {
const normalized = [...modules]
@@ -91,6 +91,12 @@ export function normalizeModuleOrder(modules) {
normalized.splice(0, normalized.length, ...requested)
}
}
if (normalized.includes('inbound-fees')) {
const prerequisites = ['merchant-qualification-fixture', 'orders', 'inbound-measurement']
const targetIndex = normalized.indexOf('inbound-fees')
const missing = prerequisites.filter((name) => !normalized.includes(name))
if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing)
}
const loadingIndex = normalized.indexOf('phase3-loading')
const overviewIndex = normalized.indexOf('phase3-container-overview')
if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized
+1
View File
@@ -46,6 +46,7 @@ export async function run(context) {
| 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` |
| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
@@ -4,6 +4,7 @@ import test from 'node:test'
const supplierFixture = readFileSync(new URL('./suppliers.mjs', import.meta.url), 'utf8')
const financeFixture = readFileSync(new URL('./finance-full-fixtures.mjs', import.meta.url), 'utf8')
const oceanBookingFixture = readFileSync(new URL('./ocean-bookings.mjs', import.meta.url), 'utf8')
const supplierOpenapi = readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml', import.meta.url), 'utf8')
const firstlegSnapshot = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java', import.meta.url), 'utf8')
const firstlegClient = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java', import.meta.url), 'utf8')
@@ -34,3 +35,10 @@ test('Firstleg booking supplier snapshot requires ACTIVE BOOKING and has no dome
assert.doesNotMatch(firstlegSnapshot, /"INTERNATIONAL"|"DOMESTIC"|String scope/)
assert.doesNotMatch(firstlegClient, /String scope|String category,\s*String scope/s)
})
test('HTTP ocean booking acceptance selects an ACTIVE BOOKING supplier without a geographic scope', () => {
assert.match(oceanBookingFixture, /category=BOOKING&status=ACTIVE/)
assert.match(oceanBookingFixture, /bookingAgentId: bookingAgent\.supplierId/)
assert.match(oceanBookingFixture, /订舱持久化启用订舱供应商快照/)
assert.doesNotMatch(oceanBookingFixture, /category=BOOKING[^'\n]*scope=/)
})
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
const entry = new URL('../admin-test-env-acceptance.mjs', import.meta.url)
const moduleFile = new URL('./inbound-fees.mjs', import.meta.url)
test('admin full acceptance registers inbound fee lifecycle after measurement', async () => {
const source = await readFile(entry, 'utf8')
assert.match(source, /'inbound-measurement', 'inbound-fees', 'phase3-container-loading'/)
assert.match(source, /normalized\.includes\('inbound-fees'\)/)
assert.match(source, /\['merchant-qualification-fixture', 'orders', 'inbound-measurement'\]/)
})
test('inbound fee lifecycle covers frozen source, version, publication and WAIT_SPLIT contracts', async () => {
const source = await readFile(moduleFile, 'utf8')
for (const token of [
'/fees/latest', '/fees/published', '/fees/confirm', '/fees/send', '/fee-details',
'FBA_PRICING', 'MANUAL', 'CALCULATED', 'CONFIRMED', 'SENT', 'PUBLISHED',
'sourceFeeVersion', 'reviewStatus', 'publicationStatus', 'settlementIncluded', 'WAIT_SPLIT',
]) assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
assert.match(source, /人工发布头程费用并触发 Finance Outbox/)
assert.match(source, /对客合计仅含已发布头程与已审核其他应收且其他应付单列/)
})
@@ -0,0 +1,190 @@
const MODULE = 'inbound-fees'
const OPTIONS = { scenario: 'inbound-fee-lifecycle', deploymentBlocker: true }
function id(value) { return value === null || value === undefined ? '' : String(value) }
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
function money(value) { return Number(value) }
function requestHeaders(key) { return { 'Idempotency-Key': key.slice(0, 128) } }
function finish(context, start) {
const steps = context.steps.slice(start)
context.scenarios.push({
name: OPTIONS.scenario,
module: MODULE,
success: steps.every((step) => step.passed),
deploymentBlocked: steps.some((step) => step.deploymentBlocker),
passed: steps.filter((step) => step.passed).length,
failed: steps.filter((step) => !step.passed).length,
})
}
async function listDetails(context, tenantId, inboundId, name) {
const response = await context.request('GET',
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details?accountId=${encodeURIComponent(tenantId)}`)
const rows = context.apiSuccess(MODULE, name, response, '', OPTIONS)
return { response, rows: Array.isArray(rows) ? rows : [] }
}
export async function run(context) {
const start = context.steps.length
const fixture = context.createdResources.inboundMeasurement
const tenantId = id(fixture?.tenantId)
const inboundId = id(fixture?.inboundId)
context.assert(MODULE, '复用本轮审核通过且进入 WAIT_SPLIT 的入库单', null,
Boolean(tenantId && inboundId && fixture?.finalStatus === 'APPROVED' && fixture?.processStatus === 'WAIT_SPLIT'),
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, measurement=${fixture?.finalStatus || 'missing'}, process=${fixture?.processStatus || 'missing'}`, OPTIONS)
if (!tenantId || !inboundId) { finish(context, start); return }
const latestPath = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/latest?tenantId=${encodeURIComponent(tenantId)}`
const latestResponse = await context.request('GET', latestPath)
const initial = context.apiSuccess(MODULE, '审核成功后读取自动初算费用', latestResponse, '', OPTIONS)
context.assert(MODULE, '自动初算保持 WAIT_SPLIT 且尚未发布', latestResponse,
initial?.feeStatus === 'CALCULATED' && initial?.nextProcessStatus === 'WAIT_SPLIT'
&& Number(initial?.feeVersion) >= 1 && money(initial?.totalAmount) >= 0,
`feeId=${id(initial?.feeId) || 'missing'}, version=${initial?.feeVersion ?? 'missing'}, status=${initial?.feeStatus || 'missing'}, process=${initial?.nextProcessStatus || 'missing'}`, OPTIONS)
if (!initial?.feeId || initial?.feeStatus !== 'CALCULATED') { finish(context, start); return }
const initialDetails = await listDetails(context, tenantId, inboundId, '查询自动初算派生费用明细')
const initialDerived = initialDetails.rows.find((row) => row.sourceType === 'FBA_PRICING'
&& id(row.sourceFeeId) === id(initial.feeId))
context.assert(MODULE, 'FBA_PRICING 明细是初算版本的只读草稿派生', initialDetails.response,
initialDerived?.sourceFeeVersion === initial.feeVersion && initialDerived?.reviewStatus === 'PENDING'
&& initialDerived?.publicationStatus === 'DRAFT' && initialDerived?.direction === 'RECEIVABLE'
&& money(initialDerived?.amount) === money(initial?.totalAmount),
`sourceType=${initialDerived?.sourceType || 'missing'}, sourceVersion=${initialDerived?.sourceFeeVersion ?? 'missing'}, review=${initialDerived?.reviewStatus || 'missing'}, publication=${initialDerived?.publicationStatus || 'missing'}`, OPTIONS)
if (initialDerived?.id) {
const derivedUpdateResponse = await context.request('PUT',
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details/${encodeURIComponent(initialDerived.id)}?accountId=${encodeURIComponent(tenantId)}`,
{ feeItemId: initialDerived.feeItemId, direction: 'RECEIVABLE',
businessDate: initialDerived.businessDate, unitPrice: initialDerived.unitPrice,
currency: initialDerived.currency, amount: initialDerived.amount,
settlementIncluded: initialDerived.settlementIncluded, remark: `${context.marker}-禁止修改派生` })
context.assert(MODULE, 'FBA_PRICING 派生详情拒绝通用编辑', derivedUpdateResponse,
derivedUpdateResponse.http === 200 && !success(derivedUpdateResponse)
&& String(derivedUpdateResponse.body?.message || '').includes('只读派生'),
`code=${derivedUpdateResponse.body?.code ?? 'missing'}, message=${derivedUpdateResponse.body?.message || 'missing'}`, OPTIONS)
}
const adjustedQuantity = Math.max(0.0001, money(initial.chargeableQuantity) + 0.01)
const adjustedUnitPrice = Math.max(0, money(initial.unitPrice))
const adjustedAdditional = money(initial.additionalAmount) + 0.01
const adjustResponse = await context.request('PUT',
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/published?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedFeeVersion: Number(initial.feeVersion), chargeableQuantity: adjustedQuantity,
unitPrice: adjustedUnitPrice, additionalAmount: adjustedAdditional,
reason: `${context.marker}-自动费用版本调整` }, undefined,
requestHeaders(`${context.marker}-inbound-fee-adjust`))
const adjusted = context.apiSuccess(MODULE, '调整初算并生成新版本', adjustResponse, '', OPTIONS)
context.assert(MODULE, '调整版本仍为 CALCULATED 且不阻塞 WAIT_SPLIT', adjustResponse,
Number(adjusted?.feeVersion) > Number(initial.feeVersion) && adjusted?.feeStatus === 'CALCULATED'
&& adjusted?.nextProcessStatus === 'WAIT_SPLIT' && money(adjusted?.totalAmount) >= adjustedAdditional,
`oldVersion=${initial.feeVersion}, newVersion=${adjusted?.feeVersion ?? 'missing'}, status=${adjusted?.feeStatus || 'missing'}, process=${adjusted?.nextProcessStatus || 'missing'}`, OPTIONS)
if (!adjusted?.feeId) { finish(context, start); return }
const adjustedDetails = await listDetails(context, tenantId, inboundId, '回查调整后的派生详情版本')
const currentDerived = adjustedDetails.rows.find((row) => row.sourceType === 'FBA_PRICING'
&& id(row.sourceFeeId) === id(adjusted.feeId))
context.assert(MODULE, '派生详情切换到最新费用版本且旧版本不再当前发布', adjustedDetails.response,
currentDerived?.sourceFeeVersion === adjusted.feeVersion && currentDerived?.publicationStatus === 'DRAFT'
&& !adjustedDetails.rows.some((row) => row.sourceType === 'FBA_PRICING'
&& id(row.sourceFeeId) !== id(adjusted.feeId) && row.publicationStatus === 'PUBLISHED'),
`derivedFeeId=${id(currentDerived?.sourceFeeId) || 'missing'}, derivedVersion=${currentDerived?.sourceFeeVersion ?? 'missing'}`, OPTIONS)
const confirmResponse = await context.request('POST',
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/confirm?tenantId=${encodeURIComponent(tenantId)}`,
undefined, undefined, requestHeaders(`${context.marker}-inbound-fee-confirm`))
const confirmed = context.apiSuccess(MODULE, '人工确认最新头程费用', confirmResponse, '', OPTIONS)
context.assert(MODULE, '人工确认只改变费用状态且保持 WAIT_SPLIT', confirmResponse,
confirmed?.feeStatus === 'CONFIRMED' && confirmed?.feeVersion === adjusted.feeVersion
&& confirmed?.nextProcessStatus === 'WAIT_SPLIT',
`version=${confirmed?.feeVersion ?? 'missing'}, status=${confirmed?.feeStatus || 'missing'}, process=${confirmed?.nextProcessStatus || 'missing'}`, OPTIONS)
const sendResponse = await context.request('POST',
`/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees/send?tenantId=${encodeURIComponent(tenantId)}`,
undefined, undefined, requestHeaders(`${context.marker}-inbound-fee-send`))
const sent = context.apiSuccess(MODULE, '人工发布头程费用并触发 Finance Outbox', sendResponse, '', OPTIONS)
const publishedDetails = await listDetails(context, tenantId, inboundId, '发布后回查对客费用派生')
const published = publishedDetails.rows.find((row) => row.sourceType === 'FBA_PRICING'
&& id(row.sourceFeeId) === id(sent?.feeId))
context.assert(MODULE, '仅人工发布后费用为 SENT 且派生明细为 PUBLISHED', sendResponse,
sent?.feeStatus === 'SENT' && sent?.nextProcessStatus === 'WAIT_SPLIT'
&& published?.publicationStatus === 'PUBLISHED' && published?.reviewStatus === 'APPROVED'
&& published?.settlementIncluded === true,
`status=${sent?.feeStatus || 'missing'}, process=${sent?.nextProcessStatus || 'missing'}, publication=${published?.publicationStatus || 'missing'}`, OPTIONS)
const feeItemsResponse = await context.request('GET', '/api/platform/v1/fee-items?page=1&size=500&status=ACTIVE')
const feeItemsPage = context.apiSuccess(MODULE, '查询可用于其他应收的费用项目', feeItemsResponse, '', OPTIONS)
const feeItems = Array.isArray(feeItemsPage?.records) ? feeItemsPage.records
: Array.isArray(feeItemsPage) ? feeItemsPage : []
const manualItem = feeItems.find((item) => item.status === 'ACTIVE' && item.warehouseReceivable === true
&& item.feeCode !== 'FBA_HEAD_FREIGHT')
context.assert(MODULE, '存在启用的非头程运费仓储应收项目', feeItemsResponse,
Boolean(manualItem?.id), `feeItemId=${id(manualItem?.id) || 'missing'}, feeCode=${manualItem?.feeCode || 'missing'}`, OPTIONS)
if (!manualItem?.id) { finish(context, start); return }
const detailsBase = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details`
const manualPayload = { feeItemId: manualItem.id, direction: 'RECEIVABLE',
businessDate: new Date().toISOString().slice(0, 10), unitPrice: 12.34, currency: 'CNY', amount: 12.34,
settlementIncluded: true, remark: `${context.marker}-其他应收` }
const manualCreateResponse = await context.request('POST', `${detailsBase}?accountId=${encodeURIComponent(tenantId)}`, manualPayload)
const manual = context.apiSuccess(MODULE, '新增独立 MANUAL 其他应收', manualCreateResponse, '', OPTIONS)
context.assert(MODULE, 'MANUAL 初始版本待审核且不冒充 FBA_PRICING', manualCreateResponse,
manual?.sourceType === 'MANUAL' && manual?.sourceFeeVersion === 1
&& manual?.reviewStatus === 'PENDING' && manual?.publicationStatus === 'DRAFT',
`detailId=${id(manual?.id) || 'missing'}, sourceType=${manual?.sourceType || 'missing'}, sourceVersion=${manual?.sourceFeeVersion ?? 'missing'}`, OPTIONS)
if (!manual?.id) { finish(context, start); return }
const approveResponse = await context.request('POST',
`${detailsBase}/${encodeURIComponent(manual.id)}/approve?accountId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, '审核通过 MANUAL 其他应收', approveResponse, '', OPTIONS)
const afterApproval = await listDetails(context, tenantId, inboundId, '回查已审核 MANUAL 其他应收')
const approved = afterApproval.rows.find((row) => id(row.id) === id(manual.id))
context.assert(MODULE, '已审核 MANUAL 纳入对客应收但与头程费用独立', afterApproval.response,
approved?.sourceType === 'MANUAL' && approved?.reviewStatus === 'APPROVED'
&& approved?.settlementIncluded === true,
`review=${approved?.reviewStatus || 'missing'}, settlementIncluded=${approved?.settlementIncluded ?? 'missing'}`, OPTIONS)
const adjustedManualPayload = { ...manualPayload, unitPrice: 15.67, amount: 15.67,
remark: `${context.marker}-其他应收版本调整` }
const manualAdjustResponse = await context.request('POST',
`${detailsBase}/${encodeURIComponent(manual.id)}/adjust?accountId=${encodeURIComponent(tenantId)}&expectedSourceVersion=1`,
adjustedManualPayload)
const manualV2 = context.apiSuccess(MODULE, '调整已审核 MANUAL 并生成新版本', manualAdjustResponse, '', OPTIONS)
context.assert(MODULE, 'MANUAL V2 重新进入待审核且不影响头程 SENT', manualAdjustResponse,
id(manualV2?.id) !== id(manual.id) && id(manualV2?.sourceFeeId) === id(manual.id)
&& manualV2?.sourceType === 'MANUAL' && manualV2?.sourceFeeVersion === 2
&& manualV2?.reviewStatus === 'PENDING' && manualV2?.publicationStatus === 'DRAFT'
&& sent?.feeStatus === 'SENT',
`sourceVersion=${manualV2?.sourceFeeVersion ?? 'missing'}, review=${manualV2?.reviewStatus || 'missing'}, freight=${sent?.feeStatus || 'missing'}`, OPTIONS)
const currentOnly = await listDetails(context, tenantId, inboundId, '默认列表隐藏 MANUAL 已替换版本')
const historyResponse = await context.request('GET',
`${detailsBase}?accountId=${encodeURIComponent(tenantId)}&includeHistory=true`)
const historyRows = context.apiSuccess(MODULE, '显式查询费用历史版本', historyResponse, '', OPTIONS)
context.assert(MODULE, 'includeHistory 仅扩展 SUPERSEDED 历史且当前版本不重复', historyResponse,
!currentOnly.rows.some((row) => id(row.id) === id(manual.id))
&& Array.isArray(historyRows) && historyRows.some((row) => id(row.id) === id(manual.id)
&& row.publicationStatus === 'SUPERSEDED')
&& historyRows.some((row) => id(row.id) === id(manualV2.id) && row.sourceFeeVersion === 2),
`currentHasV1=${currentOnly.rows.some((row) => id(row.id) === id(manual.id))}, historyCount=${Array.isArray(historyRows) ? historyRows.length : 'n/a'}`, OPTIONS)
const approveV2Response = await context.request('POST',
`${detailsBase}/${encodeURIComponent(manualV2.id)}/approve?accountId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, '审核通过 MANUAL 新版本', approveV2Response, '', OPTIONS)
const finalDetails = await listDetails(context, tenantId, inboundId, '汇总已发布头程与已审核其他应收')
const receivableTotal = finalDetails.rows.filter((row) => row.direction === 'RECEIVABLE'
&& ((row.sourceType === 'FBA_PRICING' && row.publicationStatus === 'PUBLISHED')
|| (row.sourceType === 'MANUAL' && row.reviewStatus === 'APPROVED')))
.reduce((sum, row) => sum + money(row.amount), 0)
const payableTotal = finalDetails.rows.filter((row) => row.direction === 'PAYABLE'
&& row.sourceType === 'MANUAL' && row.reviewStatus === 'APPROVED')
.reduce((sum, row) => sum + money(row.amount), 0)
context.assert(MODULE, '对客合计仅含已发布头程与已审核其他应收且其他应付单列', finalDetails.response,
receivableTotal >= money(published?.amount) + money(manualV2?.amount) && payableTotal >= 0,
`receivableTotal=${receivableTotal.toFixed(2)}, freight=${money(published?.amount).toFixed(2)}, manual=${money(manualV2?.amount).toFixed(2)}, payableTotal=${payableTotal.toFixed(2)}`, OPTIONS)
context.createdResources.inboundFees = { marker: context.marker, tenantId, inboundId,
freightFeeId: id(sent.feeId), freightVersion: sent.feeVersion, manualDetailId: id(manualV2.id),
manualVersion: manualV2.sourceFeeVersion }
finish(context, start)
}
@@ -16,13 +16,17 @@ async function request(context, method, path, body, headers = {}) {
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
}
function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null }
async function createBooking(context, suffix, key, master) {
async function createBooking(context, suffix, key, master, bookingAgent) {
const containers = [
{ containerNo: `TST-${suffix}-01`.slice(0, 64), containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 },
{ containerNo: `TST-${suffix}-02`.slice(0, 64), containerType: '20GP', containerKind: 'FCL', capacityCbm: 33, maxWeightKg: 28000 },
]
return request(context, 'POST', BASE,
{ expectedVersion: 0, data: oceanBookingDraft(context, suffix, master, { containers }) },
{ expectedVersion: 0, data: oceanBookingDraft(context, suffix, master, {
containers,
bookingAgentId: bookingAgent.supplierId,
bookingAgentName: bookingAgent.name,
}) },
{ 'Idempotency-Key': key })
}
function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) }
@@ -116,6 +120,15 @@ export async function run(context) {
}
const master = await resolveOceanBookingMaster(context, MODULE)
if (!master) return
const bookingAgentResponse = await request(context, 'GET',
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20')
const bookingAgentPage = context.apiSuccess(MODULE, '查询启用的订舱供应商', bookingAgentResponse, '', OPTIONS)
const bookingAgent = bookingAgentPage?.records?.find?.((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE')
context.assert(MODULE, '订舱供应商不区分国内或国外', bookingAgentResponse,
Boolean(bookingAgent?.id && bookingAgent?.name) && !Object.hasOwn(bookingAgent || {}, 'scope'),
`supplierId=${id(bookingAgent?.id) || 'missing'}, scopeExposed=${Object.hasOwn(bookingAgent || {}, 'scope')}`, OPTIONS)
if (!bookingAgent?.id || !bookingAgent?.name || Object.hasOwn(bookingAgent, 'scope')) return
const bookingAgentSnapshot = { supplierId: id(bookingAgent.id), name: bookingAgent.name }
const list = await request(context, 'GET', `${BASE}?page=1&size=5`)
if (missing(list) || list.http === 0) {
step(context, '海运订舱服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`)
@@ -148,7 +161,7 @@ export async function run(context) {
await createConfirmedUnassignedBooking(context, suffix, master)
const key = `${context.marker}-ocean-create`
const createResponse = await createBooking(context, suffix, key, master)
const createResponse = await createBooking(context, suffix, key, master, bookingAgentSnapshot)
const detail = data(createResponse); const bookingId = id(detail?.oceanBooking?.id)
context.apiSuccess(MODULE, '创建隔离海运订舱', createResponse, `key=${key}`, OPTIONS)
context.assert(MODULE, '线下已确认订舱 DTO、运输快照和关联字段完整', createResponse,
@@ -160,6 +173,10 @@ export async function run(context) {
context.assert(MODULE, '指定客户订舱持久化本轮客户范围快照', createResponse,
id(detail?.oceanBooking?.customerId) === id(master.customerId) && detail?.oceanBooking?.customerName === master.customerName,
`expectedCustomerId=${id(master.customerId)}, actualCustomerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
context.assert(MODULE, '订舱持久化启用订舱供应商快照', createResponse,
id(detail?.oceanBooking?.bookingAgentId) === bookingAgentSnapshot.supplierId
&& detail?.oceanBooking?.bookingAgentName === bookingAgentSnapshot.name,
`expected=${bookingAgentSnapshot.supplierId}/${bookingAgentSnapshot.name}, actual=${id(detail?.oceanBooking?.bookingAgentId) || 'missing'}/${detail?.oceanBooking?.bookingAgentName || 'missing'}`, OPTIONS)
const expectedContainerNos = [`TST-${suffix}-01`.slice(0, 64), `TST-${suffix}-02`.slice(0, 64)]
context.assert(MODULE, '一单两柜逐柜原子生成独立需求分配和 ACTIVE 操作身份', createResponse,
detail?.containers?.length === 2 && expectedContainerNos.every((containerNo) => {
@@ -172,7 +189,7 @@ export async function run(context) {
if (!bookingId) return
context.createdResources.oceanBooking = { id: bookingId, customerId: master.customerId, marker: context.marker,
containerBookingId: null, loadingPlanId: null, finalStatus: 'CONFIRMED' }
const replay = await createBooking(context, suffix, key, master)
const replay = await createBooking(context, suffix, key, master, bookingAgentSnapshot)
context.assert(MODULE, '创建 Idempotency-Key 重放返回同一订舱', replay, String(data(replay)?.oceanBooking?.id) === bookingId, `first=${bookingId}, replay=${id(data(replay)?.oceanBooking?.id)}`, OPTIONS)
const existingContainerNo = detail?.containers?.[0]?.containerNo
const globalDuplicateDraft = oceanBookingDraft(context, `${suffix}G`, master, { containers: [{
@@ -40,6 +40,37 @@ async function request(context, method, path, body, headers = {}) {
}
}
async function uploadLoadingEvidence(context, tenantId, inboundId, evidenceType) {
const path = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}`
const form = new FormData()
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-${evidenceType.toLowerCase()}.png`)
try {
const response = await fetch(`${context.options.gateway}${path}`, {
method: 'POST',
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
body: form,
signal: AbortSignal.timeout(60_000),
})
const raw = await response.text(); let payload = null
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
return { method: 'POST', path, http: response.status, body: payload,
raw: payload === null ? raw.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id') }
} catch (error) {
return { method: 'POST', path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
}
}
async function bindLoadingEvidence(context, inboundId, tenantId, evidenceType, fileId, sealNo) {
return request(context, 'POST',
`/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: 0, evidenceType, fileId, fileName: 'client-must-not-override.png',
sealNo: evidenceType === 'SEAL' ? sealNo : null, remark: context.marker },
{ 'Idempotency-Key': `${context.marker}-cab-evidence-${evidenceType}`.slice(0, 128) })
}
export function selectContainerLoadingFixture(context) {
const candidates = [
context.createdResources?.orchestrationFixtures?.phase3Container,
@@ -662,6 +693,52 @@ export async function run(context) {
&& pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
`taskStatus=${pickReplay?.taskStatus || 'missing'}`, OPTIONS)
const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
const sealNo = `SEAL-${suffix}`.slice(0, 64)
for (const evidenceType of evidenceTypes) {
const uploadResponse = await uploadLoadingEvidence(context, tenantId, inboundId, evidenceType)
const uploaded = context.apiSuccess(MODULE, `上传柜次 ${evidenceType} 真实证据文件`, uploadResponse,
responseEvidence(uploadResponse), OPTIONS)
const fileId = id(uploaded?.fileId)
context.assert(MODULE, `${evidenceType} 文件类型、归属和二进制元数据完整`, uploadResponse,
Boolean(fileId) && uploaded?.businessType === `LOADING_${evidenceType}`
&& uploaded?.contentType === 'image/png' && Number(uploaded?.sizeBytes) > 0,
`fileId=${fileId || 'missing'}, businessType=${uploaded?.businessType || 'missing'}, size=${uploaded?.sizeBytes ?? 'missing'}`, OPTIONS)
if (!fileId) return
const bindResponse = await bindLoadingEvidence(context, inboundId, tenantId, evidenceType, fileId, sealNo)
const bound = context.apiSuccess(MODULE, `绑定柜次 ${evidenceType} 装柜证据`, bindResponse,
responseEvidence(bindResponse), OPTIONS)
const boundItem = bound?.evidences?.evidences?.find?.((item) =>
item.evidenceType === evidenceType && id(item.fileId) === fileId && item.status !== 'VOID')
context.assert(MODULE, `${evidenceType} 有效证据归属稳定且锁码门禁完整`, bindResponse,
bound?.mainStatus === 'LOADING' && Boolean(boundItem?.id)
&& boundItem?.remark === context.marker
&& (evidenceType !== 'SEAL' || boundItem?.sealNo === sealNo),
`mainStatus=${bound?.mainStatus || 'missing'}, evidenceId=${id(boundItem?.id) || 'missing'}, sealNo=${boundItem?.sealNo || 'n/a'}`, OPTIONS)
if (!boundItem?.id) return
const downloadResponse = await context.requestBinary('GET',
`/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}/${fileId}`)
context.assert(MODULE, `下载柜次 ${evidenceType} 证据为非空受控二进制`, downloadResponse,
downloadResponse.http === 200 && downloadResponse.bytes.length > 0
&& downloadResponse.contentType.toLowerCase().includes('image/png'),
`bytes=${downloadResponse.bytes.length}, contentType=${downloadResponse.contentType || 'missing'}`, OPTIONS)
}
const evidenceListResponse = await context.request('GET',
`/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`)
const evidenceList = context.apiSuccess(MODULE, 'PDA 完成前回查五类有效装柜证据与锁码', evidenceListResponse,
'', OPTIONS)
const activeEvidence = Array.isArray(evidenceList?.evidences)
? evidenceList.evidences.filter((item) => item.status !== 'VOID') : []
context.assert(MODULE, '五类有效照片和 SEAL 锁码齐全后才调用 PDA complete', evidenceListResponse,
evidenceTypes.every((type) => activeEvidence.some((item) => item.evidenceType === type))
&& activeEvidence.some((item) => item.evidenceType === 'SEAL' && item.sealNo === sealNo),
`types=${[...new Set(activeEvidence.map((item) => item.evidenceType))].join(',') || 'missing'}, sealNo=${sealNo}`, OPTIONS)
if (!evidenceTypes.every((type) => activeEvidence.some((item) => item.evidenceType === type))
|| !activeEvidence.some((item) => item.evidenceType === 'SEAL' && item.sealNo === sealNo)) return
const completePath = `${PDA}/tasks/${taskId}/complete`
const completePayload = {
expectedTaskVersion: Number(picked?.version),
@@ -405,6 +405,18 @@ test('preload and loading versions use immediate operation baselines and exact p
test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => {
const container = source('./phase3-container-loading.mjs')
const convergence = source('./phase3-pda-convergence.mjs')
assertOrder(container, [
'最后一箱拣货后 PDA 任务等待人工确认装柜完成',
'上传柜次 ${evidenceType} 真实证据文件',
'绑定柜次 ${evidenceType} 装柜证据',
'PDA 完成前回查五类有效装柜证据与锁码',
'PDA 人工确认装柜完成',
])
assert.match(container, /const evidenceTypes = \['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'\]/)
assert.match(container, /businessType === `LOADING_\$\{evidenceType\}`/)
assert.match(container, /item\.evidenceType === 'SEAL' && item\.sealNo === sealNo/)
assert.match(container, /if \(!evidenceTypes\.every[\s\S]*\) return/)
assert.match(container, /requestBinary\('GET',[\s\S]*files\/loading-evidence/)
assert.match(container, /回查完成后的 PDA 操作审计/)
assert.match(container, /item\.actionType === 'BATCH'/)
assert.match(container, /item\.actionType === 'COMPLETE_LOADING'/)