feat(finance): add container settlement aggregate
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@
|
|||||||
| 对账单客户确认 | `controller/ClientFinanceController.java`、`service/ReconciliationStatementService.java`;客户接口 `/api/client/finance/v1/statements/{statementId}/confirm` |
|
| 对账单客户确认 | `controller/ClientFinanceController.java`、`service/ReconciliationStatementService.java`;客户接口 `/api/client/finance/v1/statements/{statementId}/confirm` |
|
||||||
| 财务单据编号 | `service/FinanceDocumentNumberGenerator.java`;对账单、应收结算单、应付结算单、充值申请、账户流水分别使用 `REC`、`AR`、`AP`、`RC`、`AC` 前缀 |
|
| 财务单据编号 | `service/FinanceDocumentNumberGenerator.java`;对账单、应收结算单、应付结算单、充值申请、账户流水分别使用 `REC`、`AR`、`AP`、`RC`、`AC` 前缀 |
|
||||||
| Finance 导出数据 Provider | `controller/FinanceExportProviderController.java`、`service/FinanceExportProviderService.java`;由 File `ExportProviderClient` 按已登记 `exportType` 分批拉取,不从 Admin 直接生成文件 |
|
| 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` |
|
| 业务费用来源事件入口 | 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/FinanceQueryService.java`、`dto/FinanceReadModels.java`;`StatementLine.inboundCode` 从对应费用来源不可变快照读取,供 Client 跳转客户隔离的入仓关系详情 |
|
||||||
| 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` |
|
| 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` |
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.baiyue.finance;
|
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.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||||
@@ -7,6 +9,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
|
|
||||||
/** Finance 独立服务启动入口。首期只承载人工账单、收款和成本台账。 */
|
/** Finance 独立服务启动入口。首期只承载人工账单、收款和成本台账。 */
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@MapperScan(basePackages = "com.baiyue.finance", annotationClass = Mapper.class)
|
||||||
@EnableFeignClients(basePackages = "com.baiyue.finance.client")
|
@EnableFeignClients(basePackages = "com.baiyue.finance.client")
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
public class BaiyueFinanceApplication {
|
public class BaiyueFinanceApplication {
|
||||||
|
|||||||
+33
@@ -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<ContainerSettlementDetails> details(@RequestParam long customerId,
|
||||||
|
@RequestParam long containerId) {
|
||||||
|
return FinanceResponses.success(service.details(customerId, containerId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long> settledSourceFeeIds,
|
||||||
|
List<ContainerCostDetail> 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<ContainerCostAllocation> allocations) {}
|
||||||
|
|
||||||
|
public record ContainerCostAllocation(
|
||||||
|
long allocationId,
|
||||||
|
long orderId,
|
||||||
|
Long inboundId,
|
||||||
|
BigDecimal volumeCbm,
|
||||||
|
BigDecimal totalVolumeCbm,
|
||||||
|
BigDecimal allocationRate,
|
||||||
|
BigDecimal allocatedAmount,
|
||||||
|
boolean roundingAdjustment) {}
|
||||||
|
}
|
||||||
+142
@@ -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<Long> 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<CostAllocationRow> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -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<Long> settledSourceFeeIds = mapper.settledSourceFeeIds(customerId, containerId).stream()
|
||||||
|
.filter(value -> value != null && value > 0)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
List<ContainerCostDetail> costs = groupCosts(mapper.costs(customerId, containerId));
|
||||||
|
return new ContainerSettlementDetails(customerId, containerId, settledSourceFeeIds, costs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ContainerCostDetail> groupCosts(List<CostAllocationRow> rows) {
|
||||||
|
Map<Long, CostAccumulator> 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<ContainerCostAllocation> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
+27
@@ -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:");
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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("${");
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user