优化
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user