优化
This commit is contained in:
@@ -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;
|
||||
|
||||
+3
-1
@@ -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:"),
|
||||
|
||||
+80
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
@@ -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() {
|
||||
|
||||
+9
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'/)
|
||||
|
||||
Reference in New Issue
Block a user