This commit is contained in:
2026-08-17 19:13:08 +08:00
parent 5199146e97
commit cddd23e4c7
40 changed files with 1203 additions and 71 deletions
@@ -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]