优化
This commit is contained in:
@@ -84,7 +84,7 @@ public class ContainerFeeDetailService {
|
|||||||
SaveRequest common = request.fee();
|
SaveRequest common = request.fee();
|
||||||
FeeItemEntity item = requireItem(common.feeItemId(), common.direction());
|
FeeItemEntity item = requireItem(common.feeItemId(), common.direction());
|
||||||
BigDecimal total = common.amount().setScale(2, RoundingMode.HALF_UP);
|
BigDecimal total = common.amount().setScale(2, RoundingMode.HALF_UP);
|
||||||
BigDecimal allocated = request.allocations().stream().map(Allocation::amount)
|
BigDecimal allocated = request.allocations().stream().map(value -> allocationAmount(value.amount()))
|
||||||
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP);
|
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP);
|
||||||
if (allocated.compareTo(total) != 0) throw conflict("柜级费用分摊合计必须等于费用总额");
|
if (allocated.compareTo(total) != 0) throw conflict("柜级费用分摊合计必须等于费用总额");
|
||||||
Map<InboundAllocationKey, Long> ownership = new HashMap<>();
|
Map<InboundAllocationKey, Long> ownership = new HashMap<>();
|
||||||
@@ -158,7 +158,7 @@ public class ContainerFeeDetailService {
|
|||||||
throw conflict("只有完整的当前已审核分摊组可以调整");
|
throw conflict("只有完整的当前已审核分摊组可以调整");
|
||||||
Map<Long, BigDecimal> amounts = new HashMap<>();
|
Map<Long, BigDecimal> amounts = new HashMap<>();
|
||||||
for (AllocationAmount allocation : request.allocations()) {
|
for (AllocationAmount allocation : request.allocations()) {
|
||||||
if (amounts.put(allocation.feeId(), allocation.amount().setScale(2, RoundingMode.HALF_UP)) != null)
|
if (amounts.put(allocation.feeId(), allocationAmount(allocation.amount())) != null)
|
||||||
throw conflict("分摊费用行不得重复");
|
throw conflict("分摊费用行不得重复");
|
||||||
}
|
}
|
||||||
if (!amounts.keySet().equals(rows.stream().map(ContainerFeeDetailEntity::getId).collect(java.util.stream.Collectors.toSet())))
|
if (!amounts.keySet().equals(rows.stream().map(ContainerFeeDetailEntity::getId).collect(java.util.stream.Collectors.toSet())))
|
||||||
@@ -372,6 +372,12 @@ public class ContainerFeeDetailService {
|
|||||||
return new BizException(ResultCode.CONFLICT, message);
|
return new BizException(ResultCode.CONFLICT, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static BigDecimal allocationAmount(BigDecimal amount) {
|
||||||
|
BigDecimal rounded = amount.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
if (rounded.signum() <= 0) throw conflict("分摊金额按两位小数取整后必须大于0");
|
||||||
|
return rounded;
|
||||||
|
}
|
||||||
|
|
||||||
private static long containerBookingId(ContainerFeeDetailEntity value) {
|
private static long containerBookingId(ContainerFeeDetailEntity value) {
|
||||||
if (value.getContainerBookingId() == null || value.getContainerBookingId() <= 0) {
|
if (value.getContainerBookingId() == null || value.getContainerBookingId() <= 0) {
|
||||||
throw conflict("柜次费用缺少真实货柜关联");
|
throw conflict("柜次费用缺少真实货柜关联");
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ public final class TerminalDrayageDtos {
|
|||||||
|
|
||||||
public record ActualTimeRequest(@NotNull @Min(0) Integer expectedVersion,
|
public record ActualTimeRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||||
@NotNull LocalDateTime actualAt,
|
@NotNull LocalDateTime actualAt,
|
||||||
@Size(max = 500) String reason) {}
|
@Size(max = 500) String remark) {}
|
||||||
|
|
||||||
public record Warning(String status, LocalDateTime deadlineAt,
|
public record Warning(String status, LocalDateTime deadlineAt,
|
||||||
long overdueHours, long overdueDays) {}
|
long overdueHours, long overdueDays) {}
|
||||||
|
|||||||
+4
-4
@@ -147,14 +147,14 @@ public class TerminalDrayageService {
|
|||||||
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
|
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
|
||||||
}
|
}
|
||||||
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
|
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
|
||||||
request.reason(), operatorId, operatorName);
|
request.remark(), operatorId, operatorName);
|
||||||
int fromVersion = version(value);
|
int fromVersion = version(value);
|
||||||
String fromStatus = status(context, value);
|
String fromStatus = status(context, value);
|
||||||
value.setActualPickupAt(request.actualAt());
|
value.setActualPickupAt(request.actualAt());
|
||||||
applyAuthoritativeDeadline(context, value);
|
applyAuthoritativeDeadline(context, value);
|
||||||
persist(value, fromVersion, operatorId, operatorName);
|
persist(value, fromVersion, operatorId, operatorName);
|
||||||
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
|
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
|
||||||
fromVersion, request.reason(), operatorId, operatorName);
|
fromVersion, request.remark(), operatorId, operatorName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -174,14 +174,14 @@ public class TerminalDrayageService {
|
|||||||
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
|
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
|
||||||
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
|
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
|
||||||
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
|
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
|
||||||
request.reason(), operatorId, operatorName);
|
request.remark(), operatorId, operatorName);
|
||||||
int fromVersion = version(value);
|
int fromVersion = version(value);
|
||||||
String fromStatus = status(context, value);
|
String fromStatus = status(context, value);
|
||||||
applyAuthoritativeDeadline(context, value);
|
applyAuthoritativeDeadline(context, value);
|
||||||
value.setActualEmptyReturnAt(request.actualAt());
|
value.setActualEmptyReturnAt(request.actualAt());
|
||||||
persist(value, fromVersion, operatorId, operatorName);
|
persist(value, fromVersion, operatorId, operatorName);
|
||||||
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
|
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
|
||||||
fromVersion, request.reason(), operatorId, operatorName);
|
fromVersion, request.remark(), operatorId, operatorName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
|
private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
|
||||||
|
|||||||
@@ -1,45 +1,371 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info: { title: Firstleg Container Fee Allocation API, version: 1.0.0 }
|
info:
|
||||||
|
title: Firstleg Container Fee API
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
本契约只生产国内段费用来源(订舱、国内拖车、出口报关、海运等)。
|
||||||
|
loadingPlanId 由服务端解析到同一真实 containerBookingId;海外段费用由 Lastleg 的 OVERSEAS_CONTAINER_FEE 独立维护。
|
||||||
|
审核通过后发布不可变来源版本到 Finance,不复制海外费用或财务状态。
|
||||||
|
应付必须选择有效供应商;DIRECT_PAID 是已付快照,不再进入待结算。
|
||||||
|
当前币种仅支持 CNY,不进行跨币种加总。公共费用按柜内真实客户与订单分摊,
|
||||||
|
各行按 HALF_UP 保留两位后必须大于零,落库金额合计必须等于两位小数的总额。
|
||||||
|
成功和业务错误均使用 HTTP 200 包装,业务冲突 code 为 A0409。
|
||||||
|
security: [{bearerAuth: []}]
|
||||||
paths:
|
paths:
|
||||||
|
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details:
|
||||||
|
get:
|
||||||
|
operationId: listContainerFees
|
||||||
|
summary: 查询当前客户的国内段柜级费用
|
||||||
|
x-permission: firstleg:inbound:fee-detail:view
|
||||||
|
parameters:
|
||||||
|
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||||
|
- {$ref: '#/components/parameters/AccountId'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||||
|
post:
|
||||||
|
operationId: createContainerFee
|
||||||
|
summary: 创建单一业务归属的国内段柜级费用
|
||||||
|
x-permission: firstleg:inbound:fee-detail:edit
|
||||||
|
parameters:
|
||||||
|
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||||
|
- {$ref: '#/components/parameters/AccountId'}
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/SaveRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/FeeResponse'}
|
||||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups:
|
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups:
|
||||||
post:
|
post:
|
||||||
summary: 原子创建柜级公共费用分摊组
|
operationId: createContainerFeeAllocationGroup
|
||||||
description: 客户、订单必填;入仓ID与入仓码同时填写或同时留空。各分摊金额必须大于0且合计等于费用总额。
|
summary: 原子创建国内段柜级公共费用分摊组
|
||||||
security: [{ bearerAuth: [] }]
|
x-permission: firstleg:inbound:fee-detail:edit
|
||||||
parameters: [{ $ref: '#/components/parameters/LoadingPlanId' }]
|
|
||||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/AllocationGroupRequest' } } } }
|
|
||||||
responses: { '200': { description: 按客户和业务归属生成的待审核费用行 }, '409': { description: 分摊合计或柜内真实归属不一致 } }
|
|
||||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/{action}:
|
|
||||||
post:
|
|
||||||
summary: 整组审核、驳回、调整或作废柜级分摊费用
|
|
||||||
description: action 仅为 approve、reject、adjust、void;单行接口拒绝处理分摊组,避免部分成功。
|
|
||||||
security: [{ bearerAuth: [] }]
|
|
||||||
parameters:
|
parameters:
|
||||||
- { $ref: '#/components/parameters/LoadingPlanId' }
|
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||||
- { in: path, name: groupId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } }
|
requestBody:
|
||||||
- { in: path, name: action, required: true, schema: { type: string, enum: [approve, reject, adjust, void] } }
|
required: true
|
||||||
responses: { '200': { description: 整组处理成功 }, '409': { description: 版本、状态或完整性冲突 } }
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/AllocationGroupRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||||
|
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/approve:
|
||||||
|
post:
|
||||||
|
operationId: approveContainerFeeAllocationGroup
|
||||||
|
summary: 整组审核国内段柜级费用
|
||||||
|
x-permission: firstleg:inbound:fee-detail:review
|
||||||
|
parameters:
|
||||||
|
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||||
|
- {$ref: '#/components/parameters/GroupId'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||||
|
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/reject:
|
||||||
|
post:
|
||||||
|
operationId: rejectContainerFeeAllocationGroup
|
||||||
|
summary: 整组驳回国内段柜级费用
|
||||||
|
x-permission: firstleg:inbound:fee-detail:review
|
||||||
|
parameters:
|
||||||
|
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||||
|
- {$ref: '#/components/parameters/GroupId'}
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/RejectRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||||
|
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/adjust:
|
||||||
|
post:
|
||||||
|
operationId: adjustContainerFeeAllocationGroup
|
||||||
|
summary: 整组调整国内段柜级费用
|
||||||
|
x-permission: firstleg:inbound:fee-detail:edit
|
||||||
|
parameters:
|
||||||
|
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||||
|
- {$ref: '#/components/parameters/GroupId'}
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/AllocationGroupAdjustRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||||
|
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/void:
|
||||||
|
post:
|
||||||
|
operationId: voidContainerFeeAllocationGroup
|
||||||
|
summary: 整组作废国内段柜级费用
|
||||||
|
x-permission: firstleg:inbound:fee-detail:review
|
||||||
|
parameters:
|
||||||
|
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||||
|
- {$ref: '#/components/parameters/GroupId'}
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/VersionRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/EmptyResponse'}
|
||||||
|
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/approve:
|
||||||
|
post:
|
||||||
|
operationId: approveContainerFee
|
||||||
|
summary: 审核单行国内段柜级费用
|
||||||
|
x-permission: firstleg:inbound:fee-detail:review
|
||||||
|
parameters:
|
||||||
|
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||||
|
- {$ref: '#/components/parameters/AccountId'}
|
||||||
|
- {$ref: '#/components/parameters/FeeId'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/EmptyResponse'}
|
||||||
|
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/reject:
|
||||||
|
post:
|
||||||
|
operationId: rejectContainerFee
|
||||||
|
summary: 驳回单行国内段柜级费用
|
||||||
|
x-permission: firstleg:inbound:fee-detail:review
|
||||||
|
parameters:
|
||||||
|
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||||
|
- {$ref: '#/components/parameters/AccountId'}
|
||||||
|
- {$ref: '#/components/parameters/FeeId'}
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/RejectRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/EmptyResponse'}
|
||||||
|
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/adjust:
|
||||||
|
post:
|
||||||
|
operationId: adjustContainerFee
|
||||||
|
summary: 调整单行国内段柜级费用
|
||||||
|
x-permission: firstleg:inbound:fee-detail:edit
|
||||||
|
parameters:
|
||||||
|
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||||
|
- {$ref: '#/components/parameters/AccountId'}
|
||||||
|
- {$ref: '#/components/parameters/FeeId'}
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/AdjustRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/FeeResponse'}
|
||||||
|
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/void:
|
||||||
|
post:
|
||||||
|
operationId: voidContainerFee
|
||||||
|
summary: 作废单行国内段柜级费用
|
||||||
|
x-permission: firstleg:inbound:fee-detail:review
|
||||||
|
parameters:
|
||||||
|
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||||
|
- {$ref: '#/components/parameters/AccountId'}
|
||||||
|
- {$ref: '#/components/parameters/FeeId'}
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/VersionRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/EmptyResponse'}
|
||||||
components:
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||||
parameters:
|
parameters:
|
||||||
LoadingPlanId: { in: path, name: loadingPlanId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } }
|
LoadingPlanId: {in: path, name: loadingPlanId, required: true, schema: {$ref: '#/components/schemas/Id'}}
|
||||||
|
GroupId: {in: path, name: groupId, required: true, schema: {$ref: '#/components/schemas/Id'}}
|
||||||
|
FeeId: {in: path, name: feeId, required: true, schema: {$ref: '#/components/schemas/Id'}}
|
||||||
|
AccountId: {in: query, name: accountId, required: true, schema: {type: integer, format: int64, minimum: 1}, description: 平台选择的客户,服务端校验柜内归属}
|
||||||
schemas:
|
schemas:
|
||||||
|
Id: {type: string, pattern: '^[1-9][0-9]{0,19}$'}
|
||||||
|
Amount: {type: number, format: decimal, minimum: 0, description: HALF_UP 保留两位小数}
|
||||||
|
SaveRequest:
|
||||||
|
type: object
|
||||||
|
required: [feeItemId, direction, businessDate, amount, currency]
|
||||||
|
properties:
|
||||||
|
feeItemId: {$ref: '#/components/schemas/Id'}
|
||||||
|
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
|
||||||
|
businessDate: {type: string, format: date}
|
||||||
|
amount: {$ref: '#/components/schemas/Amount'}
|
||||||
|
currency: {type: string, enum: [CNY]}
|
||||||
|
supplierId: {type: string, nullable: true, description: PAYABLE 必填,必须为启用供应商}
|
||||||
|
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 仅 PAYABLE 必填}
|
||||||
|
paidAt: {type: string, format: date-time, nullable: true}
|
||||||
|
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
|
||||||
|
paymentReferenceNo: {type: string, maxLength: 128, nullable: true}
|
||||||
|
paymentVoucherFileIds:
|
||||||
|
type: array
|
||||||
|
maxItems: 20
|
||||||
|
nullable: true
|
||||||
|
items: {$ref: '#/components/schemas/Id'}
|
||||||
|
remark: {type: string, maxLength: 500, nullable: true}
|
||||||
|
description: DIRECT_PAID 必须填写付款时间、方式及流水号或当前客户凭证;RECEIVABLE 和 ON_ACCOUNT 不允许残留付款字段。
|
||||||
|
Allocation:
|
||||||
|
type: object
|
||||||
|
required: [customerId, orderId, orderNo, amount]
|
||||||
|
properties:
|
||||||
|
customerId: {$ref: '#/components/schemas/Id'}
|
||||||
|
orderId: {$ref: '#/components/schemas/Id'}
|
||||||
|
orderNo: {type: string, minLength: 1, maxLength: 64}
|
||||||
|
inboundId: {type: string, nullable: true}
|
||||||
|
inboundCode: {type: string, maxLength: 64, nullable: true}
|
||||||
|
amount: {type: number, format: decimal, minimum: 0, exclusiveMinimum: true}
|
||||||
|
description: 客户和订单必须属于当前柜;入仓ID与入仓码同时填写或同时留空。
|
||||||
AllocationGroupRequest:
|
AllocationGroupRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [fee, allocations]
|
required: [fee, allocations]
|
||||||
properties:
|
properties:
|
||||||
fee: { type: object, description: 公共费用字段;amount为原始柜级总额 }
|
fee: {$ref: '#/components/schemas/SaveRequest'}
|
||||||
allocations:
|
allocations:
|
||||||
type: array
|
type: array
|
||||||
minItems: 1
|
minItems: 1
|
||||||
maxItems: 200
|
maxItems: 200
|
||||||
items:
|
items: {$ref: '#/components/schemas/Allocation'}
|
||||||
type: object
|
AdjustRequest:
|
||||||
required: [customerId, orderId, orderNo, amount]
|
type: object
|
||||||
properties:
|
required: [expectedSourceVersion, businessDate, amount]
|
||||||
customerId: { type: string }
|
properties:
|
||||||
orderId: { type: string }
|
expectedSourceVersion: {type: integer, minimum: 1}
|
||||||
orderNo: { type: string, maxLength: 64 }
|
businessDate: {type: string, format: date}
|
||||||
inboundId: { type: string, nullable: true }
|
amount: {$ref: '#/components/schemas/Amount'}
|
||||||
inboundCode: { type: string, maxLength: 64, nullable: true }
|
description: 只允许调整当前已审核有效版本,成功递增来源版本并重新待审核。
|
||||||
amount: { type: number, format: decimal, exclusiveMinimum: 0 }
|
AllocationAmount:
|
||||||
securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } }
|
type: object
|
||||||
|
required: [feeId, amount]
|
||||||
|
properties:
|
||||||
|
feeId: {$ref: '#/components/schemas/Id'}
|
||||||
|
amount: {type: number, format: decimal, minimum: 0, exclusiveMinimum: true}
|
||||||
|
AllocationGroupAdjustRequest:
|
||||||
|
type: object
|
||||||
|
required: [expectedSourceVersion, businessDate, totalAmount, allocations]
|
||||||
|
properties:
|
||||||
|
expectedSourceVersion: {type: integer, minimum: 1}
|
||||||
|
businessDate: {type: string, format: date}
|
||||||
|
totalAmount: {type: number, format: decimal, minimum: 0, exclusiveMinimum: true}
|
||||||
|
allocations:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
maxItems: 200
|
||||||
|
items: {$ref: '#/components/schemas/AllocationAmount'}
|
||||||
|
description: 必须包含整组全部费用行,逐行取两位后的合计与总额一致;成功后整组重新待审核。
|
||||||
|
VersionRequest:
|
||||||
|
type: object
|
||||||
|
required: [expectedSourceVersion]
|
||||||
|
properties:
|
||||||
|
expectedSourceVersion: {type: integer, minimum: 1}
|
||||||
|
RejectRequest:
|
||||||
|
type: object
|
||||||
|
required: [reason]
|
||||||
|
properties:
|
||||||
|
reason: {type: string, minLength: 1, maxLength: 500}
|
||||||
|
FeeDetail:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: {type: string, nullable: true}
|
||||||
|
tenantId: {type: string, nullable: true}
|
||||||
|
allocationGroupId: {type: string, nullable: true}
|
||||||
|
allocationOrderId: {type: string, nullable: true}
|
||||||
|
allocationInboundId: {type: string, nullable: true}
|
||||||
|
loadingPlanId: {type: string, nullable: true}
|
||||||
|
containerBookingId: {type: string, nullable: true}
|
||||||
|
feeItemId: {type: string, nullable: true}
|
||||||
|
supplierId: {type: string, nullable: true}
|
||||||
|
reviewerId: {type: string, nullable: true}
|
||||||
|
createdBy: {type: string, nullable: true}
|
||||||
|
updatedBy: {type: string, nullable: true}
|
||||||
|
allocationOrderNo: {type: string, nullable: true}
|
||||||
|
allocationInboundCode: {type: string, nullable: true}
|
||||||
|
containerNo: {type: string, nullable: true}
|
||||||
|
feeItemCodeSnapshot: {type: string, nullable: true}
|
||||||
|
feeItemNameSnapshot: {type: string, nullable: true}
|
||||||
|
supplierName: {type: string, nullable: true}
|
||||||
|
supplierCategory: {type: string, nullable: true}
|
||||||
|
remark: {type: string, nullable: true}
|
||||||
|
reviewerName: {type: string, nullable: true}
|
||||||
|
reviewReason: {type: string, nullable: true}
|
||||||
|
paymentReferenceNo: {type: string, nullable: true}
|
||||||
|
paidAt: {type: string, format: date-time, nullable: true}
|
||||||
|
reviewedAt: {type: string, format: date-time, nullable: true}
|
||||||
|
createdAt: {type: string, format: date-time, nullable: true}
|
||||||
|
updatedAt: {type: string, format: date-time, nullable: true}
|
||||||
|
amount: {$ref: '#/components/schemas/Amount'}
|
||||||
|
allocationGroupAmount: {$ref: '#/components/schemas/Amount'}
|
||||||
|
businessDate: {type: string, format: date}
|
||||||
|
currency: {type: string, enum: [CNY]}
|
||||||
|
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
|
||||||
|
reviewStatus: {type: string, enum: [PENDING, APPROVED, REJECTED]}
|
||||||
|
sourceStatus: {type: string, enum: [ACTIVE, VOIDED]}
|
||||||
|
sourceVersion: {type: integer, minimum: 1}
|
||||||
|
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true}
|
||||||
|
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
|
||||||
|
paymentVoucherFileIds: {type: array, nullable: true, items: {$ref: '#/components/schemas/Id'}}
|
||||||
|
FeeResponse:
|
||||||
|
type: object
|
||||||
|
required: [code, message]
|
||||||
|
properties:
|
||||||
|
code: {type: string, description: 成功为200,业务冲突为A0409}
|
||||||
|
message: {type: string}
|
||||||
|
requestId: {type: string, nullable: true}
|
||||||
|
traceId: {type: string, nullable: true}
|
||||||
|
data: {$ref: '#/components/schemas/FeeDetail'}
|
||||||
|
timestamp: {type: string, format: date-time}
|
||||||
|
FeeListResponse:
|
||||||
|
type: object
|
||||||
|
required: [code, message]
|
||||||
|
properties:
|
||||||
|
code: {type: string, description: 成功为200,业务冲突为A0409}
|
||||||
|
message: {type: string}
|
||||||
|
requestId: {type: string, nullable: true}
|
||||||
|
traceId: {type: string, nullable: true}
|
||||||
|
data: {type: array, items: {$ref: '#/components/schemas/FeeDetail'}}
|
||||||
|
timestamp: {type: string, format: date-time}
|
||||||
|
EmptyResponse:
|
||||||
|
type: object
|
||||||
|
required: [code, message]
|
||||||
|
properties:
|
||||||
|
code: {type: string, description: 成功为200,业务冲突为A0409}
|
||||||
|
message: {type: string}
|
||||||
|
requestId: {type: string, nullable: true}
|
||||||
|
traceId: {type: string, nullable: true}
|
||||||
|
data: {type: object, nullable: true}
|
||||||
|
timestamp: {type: string, format: date-time}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
expectedVersion: {type: integer, minimum: 0}
|
expectedVersion: {type: integer, minimum: 0}
|
||||||
actualAt: {type: string, format: date-time}
|
actualAt: {type: string, format: date-time}
|
||||||
reason: {type: string, maxLength: 500, nullable: true}
|
remark: {type: string, maxLength: 500, nullable: true}
|
||||||
Warning:
|
Warning:
|
||||||
type: object
|
type: object
|
||||||
required: [status, overdueHours, overdueDays]
|
required: [status, overdueHours, overdueDays]
|
||||||
|
|||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
package com.baiyue.firstleg.controller;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos;
|
||||||
|
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.lang.reflect.Modifier;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ContainerFeeOpenApiContractTest {
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void coversEveryControllerOperationPermissionAndResponse() throws Exception {
|
||||||
|
Map<String, Object> api = read();
|
||||||
|
Map<String, Map<String, Map<String, Object>>> paths = (Map<String, Map<String, Map<String, Object>>>) api.get("paths");
|
||||||
|
String base = PlatformContainerFeeDetailController.class.getAnnotation(RequestMapping.class).value()[0];
|
||||||
|
int operationCount = 0;
|
||||||
|
for (var method : PlatformContainerFeeDetailController.class.getDeclaredMethods()) {
|
||||||
|
var get = method.getAnnotation(GetMapping.class);
|
||||||
|
var post = method.getAnnotation(PostMapping.class);
|
||||||
|
if (get == null && post == null) continue;
|
||||||
|
String[] suffix = get != null ? get.value() : post.value();
|
||||||
|
String path = base + (suffix.length == 0 ? "" : suffix[0]);
|
||||||
|
assertThat(paths).containsKey(path);
|
||||||
|
var operation = paths.get(path).get(get != null ? "get" : "post");
|
||||||
|
assertThat(operation).isNotNull();
|
||||||
|
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||||
|
.isEqualTo("hasAuthority('" + operation.get("x-permission") + "')");
|
||||||
|
var responses = (Map<String, Object>) operation.get("responses");
|
||||||
|
assertThat((Map<String, Object>) responses.get("200")).containsKey("content");
|
||||||
|
operationCount++;
|
||||||
|
}
|
||||||
|
assertThat(paths.values().stream().mapToInt(Map::size).sum()).isEqualTo(operationCount);
|
||||||
|
validateReferences(api, api);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
void requestAndResponseSchemasMatchJavaFields() throws Exception {
|
||||||
|
var components = (Map<String, Object>) read().get("components");
|
||||||
|
var schemas = (Map<String, Map<String, Object>>) components.get("schemas");
|
||||||
|
for (Class<?> dto : List.of(ContainerFeeDetailDtos.SaveRequest.class,
|
||||||
|
ContainerFeeDetailDtos.AdjustRequest.class, ContainerFeeDetailDtos.Allocation.class,
|
||||||
|
ContainerFeeDetailDtos.AllocationGroupRequest.class, ContainerFeeDetailDtos.AllocationAmount.class,
|
||||||
|
ContainerFeeDetailDtos.AllocationGroupAdjustRequest.class, ContainerFeeDetailDtos.VersionRequest.class,
|
||||||
|
ContainerFeeDetailDtos.RejectRequest.class)) {
|
||||||
|
Set<String> fields = Arrays.stream(dto.getRecordComponents()).map(value -> value.getName()).collect(Collectors.toSet());
|
||||||
|
var properties = (Map<String, Object>) schemas.get(dto.getSimpleName()).get("properties");
|
||||||
|
assertThat(properties.keySet()).containsExactlyInAnyOrderElementsOf(fields);
|
||||||
|
}
|
||||||
|
Set<String> responseFields = Arrays.stream(ContainerFeeDetailEntity.class.getDeclaredFields())
|
||||||
|
.filter(field -> !Modifier.isStatic(field.getModifiers())).map(field -> field.getName()).collect(Collectors.toSet());
|
||||||
|
var properties = (Map<String, Object>) schemas.get("FeeDetail").get("properties");
|
||||||
|
assertThat(properties.keySet()).containsExactlyInAnyOrderElementsOf(responseFields);
|
||||||
|
Set<String> envelopeFields = Arrays.stream(com.baiyue.common.core.api.ApiResponse.class.getRecordComponents())
|
||||||
|
.map(component -> component.getName()).collect(Collectors.toSet());
|
||||||
|
for (String name : List.of("FeeResponse", "FeeListResponse", "EmptyResponse")) {
|
||||||
|
var envelope = (Map<String, Object>) schemas.get(name).get("properties");
|
||||||
|
assertThat(envelope.keySet()).containsExactlyInAnyOrderElementsOf(envelopeFields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> read() throws Exception {
|
||||||
|
return new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/container-fees-v1.yaml")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateReferences(Object value, Map<String, Object> root) {
|
||||||
|
if (value instanceof Map<?, ?> map) {
|
||||||
|
if (map.get("$ref") instanceof String ref) {
|
||||||
|
assertThat(ref).startsWith("#/");
|
||||||
|
Object target = root;
|
||||||
|
for (String segment : ref.substring(2).split("/")) target = ((Map<?, ?>) target).get(segment);
|
||||||
|
assertThat(target).as(ref).isNotNull();
|
||||||
|
}
|
||||||
|
map.values().forEach(child -> validateReferences(child, root));
|
||||||
|
} else if (value instanceof List<?> list) {
|
||||||
|
list.forEach(child -> validateReferences(child, root));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -265,6 +265,48 @@ class ContainerFeeDetailServiceTest {
|
|||||||
.doesNotContainNull().allMatch(captor.getAllValues().get(0).getAllocationGroupId()::equals);
|
.doesNotContainNull().allMatch(captor.getAllValues().get(0).getAllocationGroupId()::equals);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsAllocationWhenPersistedRoundedRowsWouldExceedTotal() {
|
||||||
|
ownedPlan();
|
||||||
|
when(feeItems.findItem(11L)).thenReturn(item());
|
||||||
|
var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
|
||||||
|
LocalDate.of(2026, 8, 20), new BigDecimal("0.01"), "CNY", null, null);
|
||||||
|
var request = new AllocationGroupRequest(fee, List.of(
|
||||||
|
new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("0.005")),
|
||||||
|
new Allocation(10002L, 502L, "FL-502", null, null, new BigDecimal("0.005"))));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("分摊合计必须等于费用总额");
|
||||||
|
verify(repository, never()).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsAllocationThatRoundsToZero() {
|
||||||
|
ownedPlan();
|
||||||
|
when(feeItems.findItem(11L)).thenReturn(item());
|
||||||
|
var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
|
||||||
|
LocalDate.of(2026, 8, 20), new BigDecimal("0.00"), "CNY", null, null);
|
||||||
|
var request = new AllocationGroupRequest(fee, List.of(
|
||||||
|
new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("0.001"))));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("取整后必须大于0");
|
||||||
|
verify(repository, never()).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsGroupAdjustmentThatRoundsAnAllocationToZero() {
|
||||||
|
ownedPlan();
|
||||||
|
when(repository.listGroup(41L, 81L)).thenReturn(List.of(approvedFee(1)));
|
||||||
|
var request = new ContainerFeeDetailDtos.AllocationGroupAdjustRequest(1,
|
||||||
|
LocalDate.of(2026, 8, 20), new BigDecimal("0.001"), List.of(
|
||||||
|
new ContainerFeeDetailDtos.AllocationAmount(51L, new BigDecimal("0.001"))));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.adjustAllocationGroup(41L, 81L, 9L, request))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("取整后必须大于0");
|
||||||
|
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(), anyInt(), any(), any(), anyLong(), any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsAllocationWhenInboundIdAndCodeAreOnlyPartiallyProvided() {
|
void rejectsAllocationWhenInboundIdAndCodeAreOnlyPartiallyProvided() {
|
||||||
LoadingPlanEntity plan = new LoadingPlanEntity();
|
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||||
|
|||||||
+13
@@ -9,6 +9,19 @@ import java.nio.file.Path;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
class TerminalDrayageOpenApiContractTest {
|
class TerminalDrayageOpenApiContractTest {
|
||||||
|
@Test
|
||||||
|
void actualTimeRequestBindsRemarkFromPublicJson() throws Exception {
|
||||||
|
var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||||
|
var request = mapper.readValue("""
|
||||||
|
{"expectedVersion":3,"actualAt":"2026-09-04T09:00:00","remark":"已还柜"}
|
||||||
|
""", com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest.class);
|
||||||
|
assertThat(request.remark()).isEqualTo("已还柜");
|
||||||
|
assertThat(request.expectedVersion()).isEqualTo(3);
|
||||||
|
String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml"));
|
||||||
|
String schema = yaml.substring(yaml.indexOf(" ActualTimeRequest:"), yaml.indexOf(" Warning:"));
|
||||||
|
assertThat(schema).contains("remark:", "expectedVersion:", "actualAt:").doesNotContain("reason:");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void controllerUsesFrozenOverseasPermissionsWithoutGenericContainerFallback() throws Exception {
|
void controllerUsesFrozenOverseasPermissionsWithoutGenericContainerFallback() throws Exception {
|
||||||
assertPermission("views", "platform:firstleg:terminal-drayage:list");
|
assertPermission("views", "platform:firstleg:terminal-drayage:list");
|
||||||
|
|||||||
+20
@@ -177,6 +177,23 @@ class TerminalDrayageServiceTest {
|
|||||||
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
|
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unloadedContainerOffersEmptyReturnOnlyWithDedicatedPermission() {
|
||||||
|
Context context = context("PICKED_UP", LocalDateTime.of(2026, 9, 3, 0, 0));
|
||||||
|
TerminalDrayageEntity row = row(3);
|
||||||
|
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
|
||||||
|
when(mapper.context(11L)).thenReturn(context);
|
||||||
|
when(mapper.find(11L)).thenReturn(row);
|
||||||
|
|
||||||
|
var raw = service.detail(11L);
|
||||||
|
assertThat(raw.status()).isEqualTo("UNLOADED");
|
||||||
|
assertThat(service.authorize(raw,
|
||||||
|
Set.of("platform:firstleg:terminal-drayage:empty-return:confirm")).allowedActions())
|
||||||
|
.containsExactly("RECORD_EMPTY_RETURN");
|
||||||
|
assertThat(service.authorize(raw,
|
||||||
|
Set.of("platform:firstleg:terminal-drayage:list")).allowedActions()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() {
|
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() {
|
||||||
when(mapper.contexts(List.of(11L))).thenReturn(List.of(
|
when(mapper.contexts(List.of(11L))).thenReturn(List.of(
|
||||||
@@ -276,6 +293,9 @@ class TerminalDrayageServiceTest {
|
|||||||
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
|
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
|
||||||
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||||
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
|
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
|
||||||
|
assertThat(node.getValue().reason()).isEqualTo("已还柜");
|
||||||
|
assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0));
|
||||||
|
assertThat(result.version()).isEqualTo(4);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Context context(String node, LocalDateTime unloadedAt) {
|
private Context context(String node, LocalDateTime unloadedAt) {
|
||||||
|
|||||||
Reference in New Issue
Block a user