From e3a5ea7341cc67cece6e6e69092f7f32ce2cee33 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 21:45:57 +0800 Subject: [PATCH] feat(lastleg): add overseas container fee sources --- deploy/sql/海外区域统一增量-MySQL8.sql | 7 ++ .../PlatformOverseasFeeSourceController.java | 16 ++- .../financefee/dto/OverseasFeeSourceDtos.java | 6 +- .../service/OverseasFeeBusinessGuard.java | 45 +++++++ .../service/OverseasFeeSourceService.java | 24 +++- .../src/main/resources/db/lastleg.sql | 4 + .../openapi/overseas-finance-fees-v1.yaml | 52 +++++++- ...OverseasContainerFeeSourceServiceTest.java | 67 ++++++++++ .../service/OverseasFeeBusinessGuardTest.java | 115 ++++++++++++++++++ 9 files changed, 325 insertions(+), 11 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 7b0bb79c2..96ece00a2 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1267,6 +1267,7 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version), KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at), + KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at), CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'), CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')), CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')), @@ -1274,6 +1275,9 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED')) ) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_finance_fee_source' AND index_name='idx_ll_finance_fee_container_customer'), 'SELECT 1', 'ALTER TABLE ll_finance_fee_source ADD KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at)')); +PREPARE ll_finance_fee_ddl FROM @ddl; EXECUTE ll_finance_fee_ddl; DEALLOCATE PREPARE ll_finance_fee_ddl; + CREATE TABLE IF NOT EXISTS ll_finance_fee_command ( id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', @@ -1689,6 +1693,9 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED')) ) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_finance_fee_source' AND index_name='idx_ll_finance_fee_container_customer'), 'SELECT 1', 'ALTER TABLE ll_finance_fee_source ADD KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at)')); +PREPARE ll_finance_fee_ddl FROM @ddl; EXECUTE ll_finance_fee_ddl; DEALLOCATE PREPARE ll_finance_fee_ddl; + CREATE TABLE IF NOT EXISTS ll_finance_fee_command ( id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java index 5ee2b8226..81cb13590 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ActionRequest; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest; @@ -37,8 +38,19 @@ public class PlatformOverseasFeeSourceController { public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @RequestParam(required = false) String businessType, - @RequestParam(required = false) String businessNo) { - requirePlatform(user); return ApiResponse.success(service.list(customerId, businessType, businessNo)); + @RequestParam(required = false) String businessNo, + @RequestParam(required = false) Long containerBookingId) { + requirePlatform(user); + return ApiResponse.success(service.list(customerId, businessType, businessNo, containerBookingId)); + } + + @GetMapping("/container-customers") + @PreAuthorize("hasAuthority('platform:finance:customer-arap:list')") + public ApiResponse> listContainerCustomers( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long containerBookingId) { + requirePlatform(user); + return ApiResponse.success(service.listContainerCustomers(containerBookingId)); } @PostMapping diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java index dc16b5f69..af3d859bf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java @@ -15,11 +15,13 @@ import java.util.List; public final class OverseasFeeSourceDtos { private OverseasFeeSourceDtos() { } + public record ContainerCustomerOption(String customerId, String customerName) { } + public record SaveRequest( @NotBlank @Size(max = 128) String customerId, @Size(max = 200) String customerName, - @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE") String sourceType, - @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE") String businessType, + @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE|OVERSEAS_CONTAINER_FEE") String sourceType, + @NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE|OVERSEAS_CONTAINER") String businessType, @Min(1) Long businessId, @NotBlank @Size(max = 128) String businessNo, @Min(1) Long orderId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java index b6b6823ee..d58d435b2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java @@ -1,18 +1,37 @@ package com.baiyue.lastleg.financefee.service; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.web.server.ResponseStatusException; +import java.util.List; + @Component @RequiredArgsConstructor public class OverseasFeeBusinessGuard { private final JdbcTemplate jdbc; + public List listContainerCustomers(long containerBookingId) { + List customers = jdbc.query( + "SELECT customer_id,CASE WHEN COUNT(DISTINCT NULLIF(TRIM(customer_name),''))=1 " + + "THEN MAX(NULLIF(TRIM(customer_name),'')) ELSE NULL END AS customer_name " + + "FROM ll_order WHERE source_container_booking_id=? GROUP BY customer_id ORDER BY customer_id", + (result, rowNum) -> new ContainerCustomerOption( + result.getString("customer_id"), result.getString("customer_name")), + String.valueOf(containerBookingId)); + if (customers.isEmpty()) throw bad("真实货柜不存在或尚无尾程订单"); + return customers; + } + public void requireOwned(SaveRequest fee) { + if ("OVERSEAS_CONTAINER".equals(fee.businessType())) { + requireOwnedContainer(fee); + return; + } if (fee.businessId() == null) { if (!"WAREHOUSE_SERVICE".equals(fee.businessType())) throw bad("业务记录ID不能为空"); @@ -38,6 +57,32 @@ public class OverseasFeeBusinessGuard { } } + private void requireOwnedContainer(SaveRequest fee) { + if (fee.businessId() == null || fee.containerBookingId() == null + || !fee.businessId().equals(fee.containerBookingId())) + throw bad("海外货柜费用必须以真实来源柜ID同时关联业务和订舱柜"); + if (blank(fee.containerNo()) || !fee.businessNo().trim().equals(fee.containerNo().trim())) + throw bad("海外货柜费用的业务编号必须与真实集装箱号一致"); + + String bookingId = String.valueOf(fee.containerBookingId()); + Integer total = jdbc.queryForObject( + "SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=?", + Integer.class, bookingId); + if (total == null || total < 1) throw bad("费用关联的真实货柜不存在"); + + Integer inconsistent = jdbc.queryForObject( + "SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=? " + + "AND (container_no IS NULL OR TRIM(container_no)='' OR BINARY TRIM(container_no)<>BINARY ?)", + Integer.class, bookingId, fee.containerNo().trim()); + if (inconsistent == null || inconsistent > 0) + throw bad("真实货柜的集装箱号缺失或不一致"); + + Integer owned = jdbc.queryForObject( + "SELECT COUNT(*) FROM ll_order WHERE source_container_booking_id=? AND customer_id=?", + Integer.class, bookingId, fee.customerId().trim()); + if (owned == null || owned < 1) throw bad("该客户不属于费用关联的真实货柜"); + } + private static boolean blank(String value) { return value == null || value.isBlank(); } private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java index 0e2704fc0..8318c2813 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.financefee.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest; @@ -26,26 +27,36 @@ import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.HexFormat; import java.util.List; +import java.util.Objects; import java.util.Set; @Service @RequiredArgsConstructor public class OverseasFeeSourceService { + static final Set OVERSEAS_CONTAINER_FEE_ITEMS = Set.of( + "PORT_FEE", "STORAGE_FEE", "CONTAINER_REPAIR_FEE", "CRANE_FEE", "TERMINAL_DRAYAGE_FEE", "OTHER"); + private final OverseasFeeSourceMapper mapper; private final OverseasFeeCommandMapper commands; private final OverseasFeeBusinessGuard businessGuard; private final OverseasFeeOutboxService outbox; private final ObjectMapper objectMapper; - public List list(String customerId, String businessType, String businessNo) { + public List list(String customerId, String businessType, String businessNo, + Long containerBookingId) { return mapper.selectList(new LambdaQueryWrapper() .eq(text(customerId) != null, OverseasFeeSource::getCustomerId, text(customerId)) .eq(text(businessType) != null, OverseasFeeSource::getBusinessType, text(businessType)) .like(text(businessNo) != null, OverseasFeeSource::getBusinessNo, text(businessNo)) + .eq(containerBookingId != null, OverseasFeeSource::getContainerBookingId, containerBookingId) .inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id") .orderByDesc(OverseasFeeSource::getCreatedAt).orderByDesc(OverseasFeeSource::getId)); } + public List listContainerCustomers(long containerBookingId) { + return businessGuard.listContainerCustomers(containerBookingId); + } + @Transactional public OverseasFeeSource create(String operatorId, String key, SaveRequest request) { OverseasFeeCommand replay = replay(operatorId, "CREATE", key, request); @@ -99,6 +110,13 @@ public class OverseasFeeSourceService { validate(request.fee()); businessGuard.requireOwned(request.fee()); if (!old.getCustomerId().equals(request.fee().customerId().trim())) throw conflict("调整不得改变费用所属客户"); + if (!old.getSourceType().equals(request.fee().sourceType()) + || !old.getBusinessType().equals(request.fee().businessType()) + || !Objects.equals(old.getBusinessId(), request.fee().businessId()) + || !old.getBusinessNo().equals(request.fee().businessNo().trim()) + || !Objects.equals(old.getContainerBookingId(), request.fee().containerBookingId()) + || !Objects.equals(old.getContainerNo(), text(request.fee().containerNo()))) + throw conflict("调整不得改变费用来源或业务归属"); old.setSourceStatus("SUPERSEDED"); old.setUpdatedBy(operatorId); old.setUpdatedAt(now()); mapper.updateById(old); OverseasFeeSource next = build(IdWorker.getId(), sourceFeeId, old.getSourceVersion() + 1, operatorId, request.fee()); next.setReviewStatus("PENDING"); @@ -147,9 +165,13 @@ public class OverseasFeeSourceService { case "US_TRUCK" -> "OVERSEAS_TRUCK_FEE"; case "LOCAL_LOOSEBOARD" -> "OVERSEAS_LOOSEBOARD_FEE"; case "WAREHOUSE_SERVICE" -> "OVERSEAS_WAREHOUSE_FEE"; + case "OVERSEAS_CONTAINER" -> "OVERSEAS_CONTAINER_FEE"; default -> ""; }; if (!expected.equals(value.sourceType())) throw bad("来源类型与业务类型不匹配"); + if ("OVERSEAS_CONTAINER".equals(value.businessType()) + && !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim())) + throw bad("海外货柜费用项目不受支持"); boolean payable = "PAYABLE".equals(value.direction()); boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty(); if (!payable) { diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index baf8ce0d3..41a57fe57 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -799,6 +799,7 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version), KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at), + KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at), CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'), CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')), CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')), @@ -806,6 +807,9 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source ( CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED')) ) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_finance_fee_source' AND index_name='idx_ll_finance_fee_container_customer'), 'SELECT 1', 'ALTER TABLE ll_finance_fee_source ADD KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at)')); +PREPARE ll_finance_fee_ddl FROM @ddl; EXECUTE ll_finance_fee_ddl; DEALLOCATE PREPARE ll_finance_fee_ddl; + CREATE TABLE IF NOT EXISTS ll_finance_fee_command ( id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', diff --git a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml index d7895d875..6c2072bf1 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml @@ -9,14 +9,28 @@ paths: - { in: query, name: customerId, schema: { type: string } } - { in: query, name: businessType, schema: { $ref: '#/components/schemas/BusinessType' } } - { in: query, name: businessNo, schema: { type: string } } + - { in: query, name: containerBookingId, description: 精确查询真实来源柜下全部客户的费用;可再传customerId收窄到单个客户, schema: { type: string, pattern: '^[1-9][0-9]*$' } } responses: { '200': { description: 当前版本列表 } } post: summary: 创建海外业务费用来源草稿 - description: 客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。 + description: 客户和业务编号必填;入仓ID、入仓码均按业务事实选填,不得为补字段伪造。海外货柜费用必须以相同的businessId和containerBookingId引用真实来源柜,且businessNo与containerNo均为该柜真实集装箱号。 security: [{ bearerAuth: [] }] parameters: [{ in: header, name: Idempotency-Key, required: true, schema: { type: string, maxLength: 128 } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SaveRequest' } } } } responses: { '200': { description: 草稿费用来源 }, '400': { description: 业务归属或付款事实无效 } } + /api/platform/v1/last-leg/finance-fee-sources/container-customers: + get: + summary: 查询真实来源柜内客户选项 + description: 单次按真实来源柜聚合全部客户,供柜详情维护费用时选择,不逐票查询。 + security: [{ bearerAuth: [] }] + parameters: + - { in: query, name: containerBookingId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + responses: + '200': + description: 柜内客户选项 + content: + application/json: + schema: { $ref: '#/components/schemas/ContainerCustomerListResponse' } /api/platform/v1/last-leg/finance-fee-sources/{sourceFeeId}/{action}: post: summary: 提交、审核、调整或作废海外业务费用来源 @@ -28,23 +42,49 @@ paths: responses: { '200': { description: 当前费用来源版本 }, '409': { description: 状态、版本或幂等冲突 } } components: schemas: - BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE] } + BusinessType: { type: string, enum: [OVERSEAS_INBOUND, OVERSEAS_PROCUREMENT, US_TRUCK, LOCAL_LOOSEBOARD, WAREHOUSE_SERVICE, OVERSEAS_CONTAINER] } + OverseasContainerFeeItem: + type: string + description: 海外阶段柜级费用项目:港口费、堆存费、修箱费、吊机费、码头拖车费、其他。 + enum: [PORT_FEE, STORAGE_FEE, CONTAINER_REPAIR_FEE, CRANE_FEE, TERMINAL_DRAYAGE_FEE, OTHER] + ContainerCustomerOption: + type: object + required: [customerId] + properties: + customerId: { type: string } + customerName: { type: string, nullable: true } + ContainerCustomerListResponse: + type: object + required: [data] + properties: + code: { type: string } + message: { type: string } + data: + type: array + items: { $ref: '#/components/schemas/ContainerCustomerOption' } + requestId: { type: string, nullable: true } + traceId: { type: string, nullable: true } + timestamp: { type: string, format: date-time } SaveRequest: type: object required: [customerId, sourceType, businessType, businessNo, feeItemCode, feeItemName, direction, businessDate, currency, amount] properties: customerId: { type: string } - sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_PROCUREMENT_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE] } + sourceType: { type: string, enum: [OVERSEAS_INBOUND_FEE, OVERSEAS_PROCUREMENT_FEE, OVERSEAS_TRUCK_FEE, OVERSEAS_LOOSEBOARD_FEE, OVERSEAS_WAREHOUSE_FEE, OVERSEAS_CONTAINER_FEE] } businessType: { $ref: '#/components/schemas/BusinessType' } - businessId: { type: string, nullable: true } + businessId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true } businessNo: { type: string, maxLength: 128 } + containerBookingId: { type: string, pattern: '^[1-9][0-9]*$', nullable: true } + containerNo: { type: string, maxLength: 64, nullable: true } warehouseId: { type: string, nullable: true } inboundId: { type: string, nullable: true } inboundCode: { type: string, maxLength: 64, nullable: true } - feeItemCode: { type: string } + feeItemCode: + type: string + description: OVERSEAS_CONTAINER时必须取OverseasContainerFeeItem枚举;其他业务类型沿用各自费用项目编码。 feeItemName: { type: string } direction: { type: string, enum: [RECEIVABLE, PAYABLE] } businessDate: { type: string, format: date } currency: { type: string, enum: [CNY] } - amount: { type: number, format: decimal, exclusiveMinimum: 0 } + amount: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true } securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java new file mode 100644 index 000000000..a0a883740 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.web.server.ResponseStatusException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OverseasContainerFeeSourceServiceTest { + private final OverseasFeeSourceMapper mapper = mock(OverseasFeeSourceMapper.class); + private final OverseasFeeCommandMapper commands = mock(OverseasFeeCommandMapper.class); + private final OverseasFeeBusinessGuard guard = mock(OverseasFeeBusinessGuard.class); + private final OverseasFeeOutboxService outbox = mock(OverseasFeeOutboxService.class); + private final OverseasFeeSourceService service = new OverseasFeeSourceService( + mapper, commands, guard, outbox, new ObjectMapper().findAndRegisterModules()); + + @Test + void createsContainerFeeUsingTheRealContainerAsItsStableBusinessReference() { + SaveRequest request = OverseasFeeBusinessGuardTest.containerFee("customer-a", 701L, "MSCU1234567"); + when(commands.find("operator-a", "CREATE", "create-container-fee")).thenReturn(null); + OverseasFeeSource result = service.create("operator-a", "create-container-fee", request); + + verify(guard).requireOwned(request); + ArgumentCaptor inserted = ArgumentCaptor.forClass(OverseasFeeSource.class); + verify(mapper).insert(inserted.capture()); + assertThat(result).isSameAs(inserted.getValue()); + assertThat(result.getSourceType()).isEqualTo("OVERSEAS_CONTAINER_FEE"); + assertThat(result.getBusinessType()).isEqualTo("OVERSEAS_CONTAINER"); + assertThat(result.getBusinessId()).isEqualTo(701L); + assertThat(result.getContainerBookingId()).isEqualTo(701L); + assertThat(result.getSourceNo()).isEqualTo("MSCU1234567"); + } + + @Test + void rejectsUnsupportedContainerFeeItems() { + SaveRequest source = OverseasFeeBusinessGuardTest.containerFee("customer-a", 701L, "MSCU1234567"); + SaveRequest request = new SaveRequest(source.customerId(), source.customerName(), source.sourceType(), + source.businessType(), source.businessId(), source.businessNo(), source.orderId(), source.inboundId(), + source.inboundCode(), source.containerId(), source.containerBookingId(), source.containerNo(), + source.warehouseId(), "CUSTOM_FEE", "自定义费", source.direction(), source.supplierId(), + source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(), + source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(), + source.businessDate(), source.currency(), source.amount()); + when(commands.find("operator-a", "CREATE", "bad-container-fee")).thenReturn(null); + + assertThatThrownBy(() -> service.create("operator-a", "bad-container-fee", request)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("海外货柜费用项目不受支持"); + } + + @Test + void canListAllCustomerFeesForAContainerInOneQuery() { + service.list(null, "OVERSEAS_CONTAINER", null, 701L); + + verify(mapper).selectList(any()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java new file mode 100644 index 000000000..a3ae4605a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java @@ -0,0 +1,115 @@ +package com.baiyue.lastleg.financefee.service; + +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OverseasFeeBusinessGuardTest { + + @Test + void acceptsOnlyARealContainerOwnedByTheCustomer() { + StubJdbc jdbc = new StubJdbc(3, 0, 1); + new OverseasFeeBusinessGuard(jdbc).requireOwned(containerFee("customer-a", 701L, "MSCU1234567")); + + assertThat(jdbc.sql()).hasSize(3); + assertThat(jdbc.sql().get(0)).contains("source_container_booking_id"); + assertThat(jdbc.sql().get(1)).contains("container_no"); + assertThat(jdbc.sql().get(2)).contains("customer_id"); + } + + @Test + void rejectsAContainerThatDoesNotContainTheClaimedCustomer() { + StubJdbc jdbc = new StubJdbc(3, 0, 0); + + assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc) + .requireOwned(containerFee("other-customer", 701L, "MSCU1234567"))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("该客户不属于费用关联的真实货柜"); + } + + @Test + void rejectsAContainerWhoseOrdersDoNotShareTheClaimedContainerNumber() { + StubJdbc jdbc = new StubJdbc(3, 1); + + assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc) + .requireOwned(containerFee("customer-a", 701L, "MSCU1234567"))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("真实货柜的集装箱号缺失或不一致"); + } + + @Test + void rejectsDifferentBusinessAndContainerBookingIdentifiersBeforeQuerying() { + SaveRequest source = containerFee("customer-a", 701L, "MSCU1234567"); + SaveRequest inconsistent = new SaveRequest(source.customerId(), source.customerName(), source.sourceType(), + source.businessType(), 702L, source.businessNo(), source.orderId(), source.inboundId(), + source.inboundCode(), source.containerId(), source.containerBookingId(), source.containerNo(), + source.warehouseId(), source.feeItemCode(), source.feeItemName(), source.direction(), source.supplierId(), + source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(), + source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(), + source.businessDate(), source.currency(), source.amount()); + StubJdbc jdbc = new StubJdbc(); + + assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc).requireOwned(inconsistent)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("真实来源柜ID"); + assertThat(jdbc.sql()).isEmpty(); + } + + @Test + void listsAllContainerCustomersWithOneGroupedQuery() { + StubJdbc jdbc = new StubJdbc(); + jdbc.queryResults = List.of( + new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption("customer-a", "客户A"), + new com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ContainerCustomerOption("customer-b", "客户B")); + + var result = new OverseasFeeBusinessGuard(jdbc).listContainerCustomers(701L); + + assertThat(result).extracting("customerId").containsExactly("customer-a", "customer-b"); + assertThat(jdbc.sql()).singleElement().asString().contains("GROUP BY customer_id"); + } + + static SaveRequest containerFee(String customerId, Long bookingId, String containerNo) { + return new SaveRequest(customerId, "客户A", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_CONTAINER", + bookingId, containerNo, null, null, null, null, bookingId, containerNo, 91L, + "PORT_FEE", "港口费", "RECEIVABLE", null, null, null, null, null, null, null, + List.of(), LocalDate.of(2026, 9, 8), "CNY", new BigDecimal("88.00")); + } + + private static final class StubJdbc extends JdbcTemplate { + private final ArrayDeque results = new ArrayDeque<>(); + private final List sql = new ArrayList<>(); + private List queryResults = List.of(); + + private StubJdbc(Integer... values) { + results.addAll(List.of(values)); + } + + @Override + public T queryForObject(String statement, Class requiredType, Object... args) { + sql.add(statement); + return requiredType.cast(results.removeFirst()); + } + + @Override + @SuppressWarnings("unchecked") + public List query(String statement, RowMapper rowMapper, Object... args) { + sql.add(statement); + return (List) queryResults; + } + + private List sql() { + return sql; + } + } +}