diff --git a/finance/AGENTS.md b/finance/AGENTS.md index c76ab009c..a408b9497 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -22,7 +22,7 @@ | 对账单客户确认 | `controller/ClientFinanceController.java`、`service/ReconciliationStatementService.java`;客户接口 `/api/client/finance/v1/statements/{statementId}/confirm` | | 财务单据编号 | `service/FinanceDocumentNumberGenerator.java`;对账单、应收结算单、应付结算单、充值申请、账户流水分别使用 `REC`、`AR`、`AP`、`RC`、`AC` 前缀 | | Finance 导出数据 Provider | `controller/FinanceExportProviderController.java`、`service/FinanceExportProviderService.java`;由 File `ExportProviderClient` 按已登记 `exportType` 分批拉取,不从 Admin 直接生成文件 | -| 财务 OpenAPI | 五页 `../../docs/财务管理/openapi/finance-core-five-pages-v1.yaml`;人工财务 `finance-f1-manual-v1.yaml`;资金账户 `finance-wallet-v1.yaml` | +| 财务 OpenAPI | 五页 `../../docs/财务管理/openapi/finance-core-five-pages-v1.yaml`;人工财务 `finance-f1-manual-v1.yaml`;资金账户 `finance-wallet-v1.yaml`;柜级聚合 `src/main/resources/openapi/finance-container-settlement-v1.yaml` | | 业务费用来源事件入口 | Firstleg:`controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java`;海外完整快照:`controller/InternalBusinessFeeSourceController.java` 的 `/internal/finance/v1/business-fee-source-events`、`service/BusinessFeeSourceEventService.java`;事件模型 `../common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java` | | 查询模型、客户可见性与账单行入仓码 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java`;`StatementLine.inboundCode` 从对应费用来源不可变快照读取,供 Client 跳转客户隔离的入仓关系详情 | | 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` | diff --git a/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java b/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java index f52fc80c2..b29f68e94 100644 --- a/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java +++ b/finance/src/main/java/com/baiyue/finance/BaiyueFinanceApplication.java @@ -1,5 +1,7 @@ package com.baiyue.finance; +import org.apache.ibatis.annotations.Mapper; +import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.openfeign.EnableFeignClients; @@ -7,6 +9,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; /** Finance 独立服务启动入口。首期只承载人工账单、收款和成本台账。 */ @SpringBootApplication +@MapperScan(basePackages = "com.baiyue.finance", annotationClass = Mapper.class) @EnableFeignClients(basePackages = "com.baiyue.finance.client") @EnableScheduling public class BaiyueFinanceApplication { diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceContainerSettlementController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceContainerSettlementController.java new file mode 100644 index 000000000..651b704da --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceContainerSettlementController.java @@ -0,0 +1,33 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.finance.dto.FinanceContainerSettlementDtos.ContainerSettlementDetails; +import com.baiyue.finance.service.FinanceContainerSettlementQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "柜级财务聚合") +@RestController +@RequestMapping("/api/platform/finance/v1") +public class PlatformFinanceContainerSettlementController { + private final FinanceContainerSettlementQueryService service; + + public PlatformFinanceContainerSettlementController(FinanceContainerSettlementQueryService service) { + this.service = service; + } + + @GetMapping("/container-settlement-details") + @PreAuthorize("hasAuthority('platform:finance:cost:view') and " + + "hasAuthority('platform:finance:settlement-ar:list') and " + + "hasAuthority('platform:finance:settlement-ap:list')") + @Operation(summary = "按客户和柜次批量查询已结算柜费及成本分摊") + public ApiResponse details(@RequestParam long customerId, + @RequestParam long containerId) { + return FinanceResponses.success(service.details(customerId, containerId)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceContainerSettlementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceContainerSettlementDtos.java new file mode 100644 index 000000000..c6b7ce5b9 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceContainerSettlementDtos.java @@ -0,0 +1,40 @@ +package com.baiyue.finance.dto; + +import java.math.BigDecimal; +import java.util.List; + +/** Read-only container finance projection for the loading-list workbench. */ +public final class FinanceContainerSettlementDtos { + private FinanceContainerSettlementDtos() {} + + public record ContainerSettlementDetails( + long customerId, + long containerId, + List settledSourceFeeIds, + List costs) {} + + public record ContainerCostDetail( + long costId, + String costNo, + String costType, + String costOrigin, + Long sourceFeeId, + Integer sourceFeeVersion, + long containerId, + String supplierName, + String feeItemName, + BigDecimal amount, + String currency, + String status, + List allocations) {} + + public record ContainerCostAllocation( + long allocationId, + long orderId, + Long inboundId, + BigDecimal volumeCbm, + BigDecimal totalVolumeCbm, + BigDecimal allocationRate, + BigDecimal allocatedAmount, + boolean roundingAdjustment) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/mapper/FinanceContainerSettlementQueryMapper.java b/finance/src/main/java/com/baiyue/finance/mapper/FinanceContainerSettlementQueryMapper.java new file mode 100644 index 000000000..0844c3247 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/mapper/FinanceContainerSettlementQueryMapper.java @@ -0,0 +1,142 @@ +package com.baiyue.finance.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.math.BigDecimal; +import java.util.List; + +@Mapper +public interface FinanceContainerSettlementQueryMapper { + + @Select(""" + SELECT COUNT(*) AS customerLinkCount + FROM ( + SELECT customer_id, container_id FROM fin_fee_source + WHERE customer_id = #{customerId} AND container_id = #{containerId} + UNION ALL + SELECT tenant_id AS customer_id, container_id FROM fin_cost + WHERE tenant_id = #{customerId} AND container_id = #{containerId} + ) links + """) + OwnershipRow ownership(@Param("customerId") long customerId, @Param("containerId") long containerId); + + @Select(""" + SELECT DISTINCT settled.source_fee_id + FROM ( + SELECT f.source_fee_id + FROM fin_settlement s + JOIN fin_settlement_line sl ON sl.tenant_id = s.tenant_id AND sl.settlement_id = s.id + JOIN fin_fee_source f ON f.id = sl.fee_source_id AND f.customer_id = sl.tenant_id + WHERE s.tenant_id = #{customerId} + AND s.status = 'PAID' + AND f.customer_id = #{customerId} + AND f.container_id = #{containerId} + AND f.source_system = 'FIRSTLEG' + AND f.source_type = 'CONTAINER_FEE' + UNION ALL + SELECT c.source_fee_id + FROM fin_cost c + WHERE c.tenant_id = #{customerId} + AND c.container_id = #{containerId} + AND c.cost_origin = 'FIRSTLEG_DIRECT_PAID' + AND c.source_system = 'FIRSTLEG' + AND c.source_type = 'CONTAINER_FEE' + AND c.status = 'CONFIRMED' + AND c.source_fee_id IS NOT NULL + ) settled + ORDER BY settled.source_fee_id + """) + List settledSourceFeeIds(@Param("customerId") long customerId, + @Param("containerId") long containerId); + + @Select(""" + SELECT c.id AS costId, c.cost_no AS costNo, c.cost_type AS costType, c.cost_origin AS costOrigin, + c.source_fee_id AS sourceFeeId, c.source_fee_version AS sourceFeeVersion, + c.container_id AS containerId, c.supplier_name_snapshot AS supplierName, + c.fee_item_name AS feeItemName, c.cost_amount AS amount, c.currency_code AS currency, + c.status, + a.id AS allocationId, a.order_id AS orderId, a.inbound_id AS inboundId, + a.volume_cbm AS volumeCbm, a.total_volume_cbm AS totalVolumeCbm, + a.allocation_rate AS allocationRate, a.allocated_amount AS allocatedAmount, + a.rounding_adjustment AS roundingAdjustment + FROM fin_cost c + LEFT JOIN fin_cost_allocation a ON a.tenant_id = c.tenant_id AND a.cost_id = c.id + WHERE c.tenant_id = #{customerId} AND c.container_id = #{containerId} + ORDER BY c.created_at DESC, c.id DESC, a.id + """) + List costs(@Param("customerId") long customerId, + @Param("containerId") long containerId); + + final class OwnershipRow { + private long customerLinkCount; + + public long getCustomerLinkCount() { return customerLinkCount; } + public void setCustomerLinkCount(long value) { this.customerLinkCount = value; } + } + + final class CostAllocationRow { + private long costId; + private String costNo; + private String costType; + private String costOrigin; + private Long sourceFeeId; + private Integer sourceFeeVersion; + private long containerId; + private String supplierName; + private String feeItemName; + private BigDecimal amount; + private String currency; + private String status; + private Long allocationId; + private Long orderId; + private Long inboundId; + private BigDecimal volumeCbm; + private BigDecimal totalVolumeCbm; + private BigDecimal allocationRate; + private BigDecimal allocatedAmount; + private boolean roundingAdjustment; + + public long getCostId() { return costId; } + public void setCostId(long value) { this.costId = value; } + public String getCostNo() { return costNo; } + public void setCostNo(String value) { this.costNo = value; } + public String getCostType() { return costType; } + public void setCostType(String value) { this.costType = value; } + public String getCostOrigin() { return costOrigin; } + public void setCostOrigin(String value) { this.costOrigin = value; } + public Long getSourceFeeId() { return sourceFeeId; } + public void setSourceFeeId(Long value) { this.sourceFeeId = value; } + public Integer getSourceFeeVersion() { return sourceFeeVersion; } + public void setSourceFeeVersion(Integer value) { this.sourceFeeVersion = value; } + public long getContainerId() { return containerId; } + public void setContainerId(long value) { this.containerId = value; } + public String getSupplierName() { return supplierName; } + public void setSupplierName(String value) { this.supplierName = value; } + public String getFeeItemName() { return feeItemName; } + public void setFeeItemName(String value) { this.feeItemName = value; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal value) { this.amount = value; } + public String getCurrency() { return currency; } + public void setCurrency(String value) { this.currency = value; } + public String getStatus() { return status; } + public void setStatus(String value) { this.status = value; } + public Long getAllocationId() { return allocationId; } + public void setAllocationId(Long value) { this.allocationId = value; } + public Long getOrderId() { return orderId; } + public void setOrderId(Long value) { this.orderId = value; } + public Long getInboundId() { return inboundId; } + public void setInboundId(Long value) { this.inboundId = value; } + public BigDecimal getVolumeCbm() { return volumeCbm; } + public void setVolumeCbm(BigDecimal value) { this.volumeCbm = value; } + public BigDecimal getTotalVolumeCbm() { return totalVolumeCbm; } + public void setTotalVolumeCbm(BigDecimal value) { this.totalVolumeCbm = value; } + public BigDecimal getAllocationRate() { return allocationRate; } + public void setAllocationRate(BigDecimal value) { this.allocationRate = value; } + public BigDecimal getAllocatedAmount() { return allocatedAmount; } + public void setAllocatedAmount(BigDecimal value) { this.allocatedAmount = value; } + public boolean isRoundingAdjustment() { return roundingAdjustment; } + public void setRoundingAdjustment(boolean value) { this.roundingAdjustment = value; } + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceContainerSettlementQueryService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceContainerSettlementQueryService.java new file mode 100644 index 000000000..a0a14b188 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceContainerSettlementQueryService.java @@ -0,0 +1,68 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.finance.dto.FinanceContainerSettlementDtos.ContainerCostAllocation; +import com.baiyue.finance.dto.FinanceContainerSettlementDtos.ContainerCostDetail; +import com.baiyue.finance.dto.FinanceContainerSettlementDtos.ContainerSettlementDetails; +import com.baiyue.finance.mapper.FinanceContainerSettlementQueryMapper; +import com.baiyue.finance.mapper.FinanceContainerSettlementQueryMapper.CostAllocationRow; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class FinanceContainerSettlementQueryService { + private final FinanceContainerSettlementQueryMapper mapper; + + public FinanceContainerSettlementQueryService(FinanceContainerSettlementQueryMapper mapper) { + this.mapper = mapper; + } + + public ContainerSettlementDetails details(long customerId, long containerId) { + if (customerId <= 0) throw new IllegalArgumentException("客户范围无效"); + if (containerId <= 0) throw new IllegalArgumentException("柜次范围无效"); + + var ownership = mapper.ownership(customerId, containerId); + if (ownership == null || ownership.getCustomerLinkCount() == 0) { + throw new BizException(ResultCode.NOT_FOUND, "柜次不存在或不属于客户"); + } + + List settledSourceFeeIds = mapper.settledSourceFeeIds(customerId, containerId).stream() + .filter(value -> value != null && value > 0) + .distinct() + .toList(); + List costs = groupCosts(mapper.costs(customerId, containerId)); + return new ContainerSettlementDetails(customerId, containerId, settledSourceFeeIds, costs); + } + + private static List groupCosts(List rows) { + Map grouped = new LinkedHashMap<>(); + for (CostAllocationRow row : rows) { + CostAccumulator accumulator = grouped.computeIfAbsent(row.getCostId(), ignored -> new CostAccumulator(row)); + if (row.getAllocationId() != null) { + accumulator.allocations.add(new ContainerCostAllocation(row.getAllocationId(), row.getOrderId(), + row.getInboundId(), row.getVolumeCbm(), row.getTotalVolumeCbm(), row.getAllocationRate(), + row.getAllocatedAmount(), row.isRoundingAdjustment())); + } + } + return grouped.values().stream().map(CostAccumulator::view).toList(); + } + + private static final class CostAccumulator { + private final CostAllocationRow cost; + private final List allocations = new ArrayList<>(); + + private CostAccumulator(CostAllocationRow cost) { this.cost = cost; } + + private ContainerCostDetail view() { + return new ContainerCostDetail(cost.getCostId(), cost.getCostNo(), cost.getCostType(), + cost.getCostOrigin(), cost.getSourceFeeId(), cost.getSourceFeeVersion(), cost.getContainerId(), + cost.getSupplierName(), cost.getFeeItemName(), cost.getAmount(), cost.getCurrency(), + cost.getStatus(), List.copyOf(allocations)); + } + } +} diff --git a/finance/src/main/resources/openapi/finance-container-settlement-v1.yaml b/finance/src/main/resources/openapi/finance-container-settlement-v1.yaml new file mode 100644 index 000000000..067491414 --- /dev/null +++ b/finance/src/main/resources/openapi/finance-container-settlement-v1.yaml @@ -0,0 +1,109 @@ +openapi: 3.0.3 +info: + title: Finance Container Settlement Query API + version: 1.0.0 + description: 柜级已结算费用来源与成本分摊的固定请求量只读聚合契约。 +servers: + - url: /api +paths: + /platform/finance/v1/container-settlement-details: + get: + summary: 按客户和柜次查询已结算柜费及成本分摊 + description: >- + customerId 与 containerId 必须在 Finance 的费用来源或成本快照中存在归属关系。 + 服务端固定执行三条集合 SQL,不按结算单或成本逐项查询;响应不包含整张结算单、 + 其他客户数据、付款凭证或内部备注。 + x-permissions-all-of: + - platform:finance:cost:view + - platform:finance:settlement-ar:list + - platform:finance:settlement-ap:list + x-query-count: 3 fixed set-based SQL statements + security: + - bearerAuth: [] + parameters: + - name: customerId + in: query + required: true + schema: {$ref: '#/components/schemas/Id'} + - name: containerId + in: query + required: true + description: Firstleg 装柜计划 ID。 + schema: {$ref: '#/components/schemas/Id'} + responses: + '200': + description: 柜级财务聚合结果。 + content: + application/json: + schema: {$ref: '#/components/schemas/ContainerSettlementDetailsEnvelope'} + '403': {description: 权限不足。} + '404': {description: 柜次不存在或不属于客户。} +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + Id: + type: string + pattern: '^[1-9][0-9]*$' + Money: + type: number + format: decimal + minimum: 0 + ContainerSettlementDetailsEnvelope: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string, example: '200'} + message: {type: string, example: success} + data: {$ref: '#/components/schemas/ContainerSettlementDetails'} + requestId: {type: string} + traceId: {type: string} + timestamp: {type: string, format: date-time} + ContainerSettlementDetails: + type: object + required: [customerId, containerId, settledSourceFeeIds, costs] + properties: + customerId: {$ref: '#/components/schemas/Id'} + containerId: {$ref: '#/components/schemas/Id'} + settledSourceFeeIds: + type: array + uniqueItems: true + items: {$ref: '#/components/schemas/Id'} + costs: + type: array + items: {$ref: '#/components/schemas/ContainerCostDetail'} + ContainerCostDetail: + type: object + required: [costId, costNo, costType, costOrigin, containerId, feeItemName, amount, currency, status, allocations] + properties: + costId: {$ref: '#/components/schemas/Id'} + costNo: {type: string} + costType: {type: string, enum: [SINGLE_ORDER, CONTAINER_SHARED, SOURCE_DIRECT_PAID]} + costOrigin: {type: string, enum: [MANUAL, FIRSTLEG_DIRECT_PAID, BUSINESS_DIRECT_PAID]} + sourceFeeId: {$ref: '#/components/schemas/Id'} + sourceFeeVersion: {type: integer, minimum: 1} + containerId: {$ref: '#/components/schemas/Id'} + supplierName: {type: string, nullable: true} + feeItemName: {type: string} + amount: {$ref: '#/components/schemas/Money'} + currency: {type: string, enum: [CNY]} + status: {type: string, enum: [DRAFT, CONFIRMED, VOIDED]} + allocations: + type: array + items: {$ref: '#/components/schemas/ContainerCostAllocation'} + ContainerCostAllocation: + type: object + required: [allocationId, orderId, volumeCbm, totalVolumeCbm, allocationRate, allocatedAmount, roundingAdjustment] + properties: + allocationId: {$ref: '#/components/schemas/Id'} + orderId: {$ref: '#/components/schemas/Id'} + inboundId: + allOf: [{$ref: '#/components/schemas/Id'}] + nullable: true + volumeCbm: {$ref: '#/components/schemas/Money'} + totalVolumeCbm: {$ref: '#/components/schemas/Money'} + allocationRate: {type: number, format: decimal, minimum: 0, maximum: 1} + allocatedAmount: {$ref: '#/components/schemas/Money'} + roundingAdjustment: {type: boolean} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementOpenApiContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementOpenApiContractTest.java new file mode 100644 index 000000000..9cacd2197 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementOpenApiContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.finance; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceContainerSettlementOpenApiContractTest { + + @Test + void contractPublishesFixedQueryAggregateAndExactReadPermissions() throws Exception { + String openApi = Files.readString( + Path.of("src/main/resources/openapi/finance-container-settlement-v1.yaml"), + StandardCharsets.UTF_8); + + assertThat(openApi) + .contains("/platform/finance/v1/container-settlement-details:") + .contains("platform:finance:cost:view") + .contains("platform:finance:settlement-ar:list") + .contains("platform:finance:settlement-ap:list") + .contains("x-query-count: 3 fixed set-based SQL statements") + .contains("settledSourceFeeIds:", "costs:", "allocations:"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementQueryMapperContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementQueryMapperContractTest.java new file mode 100644 index 000000000..1d0e0c574 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementQueryMapperContractTest.java @@ -0,0 +1,29 @@ +package com.baiyue.finance; + +import com.baiyue.finance.mapper.FinanceContainerSettlementQueryMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceContainerSettlementQueryMapperContractTest { + + @Test + void mapperUsesThreeSetBasedStatementsAndNoPerSettlementOrPerCostLookup() { + assertThat(FinanceContainerSettlementQueryMapper.class.isAnnotationPresent(Mapper.class)).isTrue(); + var methods = FinanceContainerSettlementQueryMapper.class.getDeclaredMethods(); + assertThat(methods).hasSize(3); + assertThat(Arrays.stream(methods).allMatch(method -> method.isAnnotationPresent(Select.class))).isTrue(); + + String sql = Arrays.stream(methods) + .flatMap(method -> Arrays.stream(method.getAnnotation(Select.class).value())) + .reduce("", (left, right) -> left + "\n" + right); + assertThat(sql).contains("JOIN fin_settlement_line", "JOIN fin_fee_source", + "LEFT JOIN fin_cost_allocation", "c.tenant_id = #{customerId}", + "c.container_id = #{containerId}"); + assertThat(sql).doesNotContain("${"); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementQueryServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementQueryServiceTest.java new file mode 100644 index 000000000..5514a6f31 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceContainerSettlementQueryServiceTest.java @@ -0,0 +1,105 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.finance.mapper.FinanceContainerSettlementQueryMapper; +import com.baiyue.finance.mapper.FinanceContainerSettlementQueryMapper.CostAllocationRow; +import com.baiyue.finance.mapper.FinanceContainerSettlementQueryMapper.OwnershipRow; +import com.baiyue.finance.service.FinanceContainerSettlementQueryService; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FinanceContainerSettlementQueryServiceTest { + + @Test + void returnsSettledSourcesAndGroupsAllCostAllocationsWithThreeFixedMapperCalls() { + FinanceContainerSettlementQueryMapper mapper = mock(FinanceContainerSettlementQueryMapper.class); + when(mapper.ownership(101L, 301L)).thenReturn(ownership(3)); + when(mapper.settledSourceFeeIds(101L, 301L)).thenReturn(List.of(901L, 902L)); + when(mapper.costs(101L, 301L)).thenReturn(List.of( + row(501L, 301L, 601L, 701L, false), + row(501L, 301L, 602L, 702L, true), + row(502L, 301L, null, null, false))); + + var result = new FinanceContainerSettlementQueryService(mapper).details(101L, 301L); + + assertThat(result.customerId()).isEqualTo(101L); + assertThat(result.containerId()).isEqualTo(301L); + assertThat(result.settledSourceFeeIds()).containsExactly(901L, 902L); + assertThat(result.costs()).hasSize(2); + assertThat(result.costs().get(0).costId()).isEqualTo(501L); + assertThat(result.costs().get(0).allocations()).extracting(allocation -> allocation.orderId()) + .containsExactly(701L, 702L); + assertThat(result.costs().get(1).allocations()).isEmpty(); + verify(mapper, times(1)).ownership(101L, 301L); + verify(mapper, times(1)).settledSourceFeeIds(101L, 301L); + verify(mapper, times(1)).costs(101L, 301L); + } + + @Test + void rejectsUnknownAndCrossCustomerContainerBeforeReadingFinanceDetails() { + FinanceContainerSettlementQueryMapper mapper = mock(FinanceContainerSettlementQueryMapper.class); + var service = new FinanceContainerSettlementQueryService(mapper); + when(mapper.ownership(101L, 301L)).thenReturn(ownership(0)); + when(mapper.ownership(101L, 302L)).thenReturn(ownership(0)); + + assertThatThrownBy(() -> service.details(101L, 301L)) + .isInstanceOf(BizException.class).hasMessageContaining("不属于客户"); + assertThatThrownBy(() -> service.details(101L, 302L)) + .isInstanceOf(BizException.class).hasMessageContaining("不属于客户"); + verify(mapper, never()).settledSourceFeeIds(101L, 301L); + verify(mapper, never()).settledSourceFeeIds(101L, 302L); + verify(mapper, never()).costs(101L, 301L); + verify(mapper, never()).costs(101L, 302L); + } + + @Test + void rejectsInvalidBusinessIdsWithoutQueryingMapper() { + FinanceContainerSettlementQueryMapper mapper = mock(FinanceContainerSettlementQueryMapper.class); + var service = new FinanceContainerSettlementQueryService(mapper); + + assertThatThrownBy(() -> service.details(0, 301)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.details(101, 0)).isInstanceOf(IllegalArgumentException.class); + verify(mapper, never()).ownership(0, 301); + verify(mapper, never()).ownership(101, 0); + } + + private static OwnershipRow ownership(long customerLinks) { + var row = new OwnershipRow(); + row.setCustomerLinkCount(customerLinks); + return row; + } + + private static CostAllocationRow row(long costId, long containerId, Long allocationId, + Long orderId, boolean roundingAdjustment) { + var row = new CostAllocationRow(); + row.setCostId(costId); + row.setCostNo("COST-" + costId); + row.setCostType("CONTAINER_SHARED"); + row.setCostOrigin("MANUAL"); + row.setContainerId(containerId); + row.setSupplierName("供应商"); + row.setFeeItemName("柜费"); + row.setAmount(new BigDecimal("100.00")); + row.setCurrency("CNY"); + row.setStatus("CONFIRMED"); + row.setAllocationId(allocationId); + row.setOrderId(orderId); + row.setInboundId(orderId == null ? null : orderId + 1000); + row.setVolumeCbm(allocationId == null ? null : new BigDecimal("1.00")); + row.setTotalVolumeCbm(allocationId == null ? null : new BigDecimal("2.00")); + row.setAllocationRate(allocationId == null ? null : new BigDecimal("0.50")); + row.setAllocatedAmount(allocationId == null ? null : new BigDecimal("50.00")); + row.setRoundingAdjustment(roundingAdjustment); + return row; + } +} diff --git a/finance/src/test/java/com/baiyue/finance/PlatformFinanceContainerSettlementControllerTest.java b/finance/src/test/java/com/baiyue/finance/PlatformFinanceContainerSettlementControllerTest.java new file mode 100644 index 000000000..8c189690f --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/PlatformFinanceContainerSettlementControllerTest.java @@ -0,0 +1,39 @@ +package com.baiyue.finance; + +import com.baiyue.finance.controller.PlatformFinanceContainerSettlementController; +import com.baiyue.finance.dto.FinanceContainerSettlementDtos.ContainerSettlementDetails; +import com.baiyue.finance.service.FinanceContainerSettlementQueryService; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; + +import java.lang.reflect.Method; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PlatformFinanceContainerSettlementControllerTest { + + @Test + void delegatesOneAggregateReadAndRequiresAllThreeFinanceReadPermissions() throws Exception { + FinanceContainerSettlementQueryService service = mock(FinanceContainerSettlementQueryService.class); + var expected = new ContainerSettlementDetails(101L, 301L, List.of(901L), List.of()); + when(service.details(101L, 301L)).thenReturn(expected); + var controller = new PlatformFinanceContainerSettlementController(service); + + var response = controller.details(101L, 301L); + + assertThat(response.data()).isSameAs(expected); + verify(service).details(101L, 301L); + Method method = PlatformFinanceContainerSettlementController.class + .getMethod("details", long.class, long.class); + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/container-settlement-details"); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .contains("platform:finance:cost:view", "platform:finance:settlement-ar:list", + "platform:finance:settlement-ap:list"); + } +}