diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java index 1f0c39d60..4f84f3649 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java @@ -84,7 +84,7 @@ public class ContainerFeeDetailService { SaveRequest common = request.fee(); FeeItemEntity item = requireItem(common.feeItemId(), common.direction()); 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); if (allocated.compareTo(total) != 0) throw conflict("柜级费用分摊合计必须等于费用总额"); Map ownership = new HashMap<>(); @@ -158,7 +158,7 @@ public class ContainerFeeDetailService { throw conflict("只有完整的当前已审核分摊组可以调整"); Map amounts = new HashMap<>(); 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("分摊费用行不得重复"); } 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); } + 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) { if (value.getContainerBookingId() == null || value.getContainerBookingId() <= 0) { throw conflict("柜次费用缺少真实货柜关联"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java index 2148b61b2..60d2c36b3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java @@ -23,7 +23,7 @@ public final class TerminalDrayageDtos { public record ActualTimeRequest(@NotNull @Min(0) Integer expectedVersion, @NotNull LocalDateTime actualAt, - @Size(max = 500) String reason) {} + @Size(max = 500) String remark) {} public record Warning(String status, LocalDateTime deadlineAt, long overdueHours, long overdueDays) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java index 7c2c16d72..1c25435c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java @@ -147,14 +147,14 @@ public class TerminalDrayageService { throw badRequest("实际提柜时间不能早于当前实际船节点时间"); } advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key, - request.reason(), operatorId, operatorName); + request.remark(), operatorId, operatorName); int fromVersion = version(value); String fromStatus = status(context, value); value.setActualPickupAt(request.actualAt()); applyAuthoritativeDeadline(context, value); persist(value, fromVersion, operatorId, operatorName); return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus, - fromVersion, request.reason(), operatorId, operatorName); + fromVersion, request.remark(), operatorId, operatorName); } @Transactional @@ -174,14 +174,14 @@ public class TerminalDrayageService { if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间"); if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认"); advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key, - request.reason(), operatorId, operatorName); + request.remark(), operatorId, operatorName); int fromVersion = version(value); String fromStatus = status(context, value); applyAuthoritativeDeadline(context, value); value.setActualEmptyReturnAt(request.actualAt()); persist(value, fromVersion, operatorId, operatorName); 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, diff --git a/firstleg/src/main/resources/openapi/container-fees-v1.yaml b/firstleg/src/main/resources/openapi/container-fees-v1.yaml index cf36252e9..0fabb34ca 100644 --- a/firstleg/src/main/resources/openapi/container-fees-v1.yaml +++ b/firstleg/src/main/resources/openapi/container-fees-v1.yaml @@ -1,45 +1,371 @@ 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: + /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: post: - summary: 原子创建柜级公共费用分摊组 - description: 客户、订单必填;入仓ID与入仓码同时填写或同时留空。各分摊金额必须大于0且合计等于费用总额。 - security: [{ bearerAuth: [] }] - 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: [] }] + operationId: createContainerFeeAllocationGroup + summary: 原子创建国内段柜级公共费用分摊组 + x-permission: firstleg:inbound:fee-detail:edit parameters: - - { $ref: '#/components/parameters/LoadingPlanId' } - - { in: path, name: groupId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } } - - { in: path, name: action, required: true, schema: { type: string, enum: [approve, reject, adjust, void] } } - responses: { '200': { description: 整组处理成功 }, '409': { description: 版本、状态或完整性冲突 } } + - {$ref: '#/components/parameters/LoadingPlanId'} + requestBody: + required: true + 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: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} 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: + 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: type: object required: [fee, allocations] properties: - fee: { type: object, description: 公共费用字段;amount为原始柜级总额 } + fee: {$ref: '#/components/schemas/SaveRequest'} allocations: type: array minItems: 1 maxItems: 200 - items: - type: object - required: [customerId, orderId, orderNo, amount] - properties: - customerId: { type: string } - orderId: { type: string } - orderNo: { type: string, maxLength: 64 } - inboundId: { type: string, nullable: true } - inboundCode: { type: string, maxLength: 64, nullable: true } - amount: { type: number, format: decimal, exclusiveMinimum: 0 } - securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } } + items: {$ref: '#/components/schemas/Allocation'} + AdjustRequest: + type: object + required: [expectedSourceVersion, businessDate, amount] + properties: + expectedSourceVersion: {type: integer, minimum: 1} + businessDate: {type: string, format: date} + amount: {$ref: '#/components/schemas/Amount'} + description: 只允许调整当前已审核有效版本,成功递增来源版本并重新待审核。 + AllocationAmount: + 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} diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml index 3a9b6332b..8cc4e5935 100644 --- a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml +++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml @@ -77,7 +77,7 @@ components: properties: expectedVersion: {type: integer, minimum: 0} actualAt: {type: string, format: date-time} - reason: {type: string, maxLength: 500, nullable: true} + remark: {type: string, maxLength: 500, nullable: true} Warning: type: object required: [status, overdueHours, overdueDays] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerFeeOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerFeeOpenApiContractTest.java new file mode 100644 index 000000000..5ae9794c8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerFeeOpenApiContractTest.java @@ -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 api = read(); + Map>> paths = (Map>>) 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) operation.get("responses"); + assertThat((Map) 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) read().get("components"); + var schemas = (Map>) 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 fields = Arrays.stream(dto.getRecordComponents()).map(value -> value.getName()).collect(Collectors.toSet()); + var properties = (Map) schemas.get(dto.getSimpleName()).get("properties"); + assertThat(properties.keySet()).containsExactlyInAnyOrderElementsOf(fields); + } + Set responseFields = Arrays.stream(ContainerFeeDetailEntity.class.getDeclaredFields()) + .filter(field -> !Modifier.isStatic(field.getModifiers())).map(field -> field.getName()).collect(Collectors.toSet()); + var properties = (Map) schemas.get("FeeDetail").get("properties"); + assertThat(properties.keySet()).containsExactlyInAnyOrderElementsOf(responseFields); + Set 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) schemas.get(name).get("properties"); + assertThat(envelope.keySet()).containsExactlyInAnyOrderElementsOf(envelopeFields); + } + } + + private static Map 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 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)); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java index a0788b792..b805953b1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java @@ -265,6 +265,48 @@ class ContainerFeeDetailServiceTest { .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 void rejectsAllocationWhenInboundIdAndCodeAreOnlyPartiallyProvided() { LoadingPlanEntity plan = new LoadingPlanEntity(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java index 9686b8a93..2e35fc928 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java @@ -9,6 +9,19 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; 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 void controllerUsesFrozenOverseasPermissionsWithoutGenericContainerFallback() throws Exception { assertPermission("views", "platform:firstleg:terminal-drayage:list"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java index 399d45cb0..5f70e4462 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java @@ -177,6 +177,23 @@ class TerminalDrayageServiceTest { 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 void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() { when(mapper.contexts(List.of(11L))).thenReturn(List.of( @@ -276,6 +293,9 @@ class TerminalDrayageServiceTest { ArgumentCaptor node = ArgumentCaptor.forClass(ManualNodeRequest.class); verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); 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) {