Test #1
+2
-2
@@ -27,7 +27,7 @@ public class PlatformFinanceContainerSettlementController {
|
||||
+ "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));
|
||||
@RequestParam long containerBookingId) {
|
||||
return FinanceResponses.success(service.details(customerId, containerBookingId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,8 +164,8 @@ public class PlatformFinanceController {
|
||||
@BusinessAudit(event = "FINANCE_COST_CREATE", resourceType = "FINANCE_COST")
|
||||
public ApiResponse<?> cost(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CostRequest request) {
|
||||
if (request.currency() != null && !"CNY".equals(request.currency())) throw new IllegalArgumentException("首期仅支持CNY");
|
||||
var internal = new FinanceService.CostRequest(null, request.costType(), request.orderId(), null, request.containerId(), null, null, "MANUAL", request.expenseName(), request.amount(), request.voucherFileIds(), request.remark());
|
||||
java.util.List<FinanceService.ContainerOrderVolume> volumes = java.util.List.of();
|
||||
Long containerBookingId = null;
|
||||
if ("CONTAINER_SHARED".equals(request.costType())) {
|
||||
if (request.containerId() == null) throw new IllegalArgumentException("整柜成本必须指定柜次");
|
||||
var response = costSnapshotClient.loadingPlan(fileToken, request.customerId(), request.containerId());
|
||||
@@ -180,9 +180,13 @@ public class PlatformFinanceController {
|
||||
|| snapshot.orders() == null || snapshot.orders().isEmpty()) {
|
||||
throw new IllegalArgumentException("Firstleg未返回有效柜内订单体积快照");
|
||||
}
|
||||
containerBookingId = snapshot.containerBookingId();
|
||||
volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(),
|
||||
v.inboundId(), snapshot.loadingPlanId(), snapshot.customerId(), v.volumeCbm())).toList();
|
||||
v.inboundId(), snapshot.containerBookingId(), snapshot.customerId(), v.volumeCbm())).toList();
|
||||
}
|
||||
var internal = new FinanceService.CostRequest(null, request.costType(), request.orderId(), null,
|
||||
containerBookingId, null, null, "MANUAL", request.expenseName(), request.amount(),
|
||||
request.voucherFileIds(), request.remark());
|
||||
return FinanceResponses.success(resourceView(service.createCost(request.customerId(), user.userId(), internal, volumes, key)));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ public final class FinanceContainerSettlementDtos {
|
||||
|
||||
public record ContainerSettlementDetails(
|
||||
long customerId,
|
||||
long containerId,
|
||||
long containerBookingId,
|
||||
List<Long> settledSourceFeeIds,
|
||||
List<ContainerCostDetail> costs) {}
|
||||
|
||||
@@ -20,7 +20,7 @@ public final class FinanceContainerSettlementDtos {
|
||||
String costOrigin,
|
||||
Long sourceFeeId,
|
||||
Integer sourceFeeVersion,
|
||||
long containerId,
|
||||
long containerBookingId,
|
||||
String supplierName,
|
||||
String feeItemName,
|
||||
BigDecimal amount,
|
||||
|
||||
+14
-13
@@ -13,14 +13,15 @@ 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}
|
||||
SELECT customer_id, container_booking_id AS container_id FROM fin_fee_source
|
||||
WHERE customer_id = #{customerId} AND container_booking_id = #{containerBookingId}
|
||||
UNION ALL
|
||||
SELECT tenant_id AS customer_id, container_id FROM fin_cost
|
||||
WHERE tenant_id = #{customerId} AND container_id = #{containerId}
|
||||
WHERE tenant_id = #{customerId} AND container_id = #{containerBookingId}
|
||||
) links
|
||||
""")
|
||||
OwnershipRow ownership(@Param("customerId") long customerId, @Param("containerId") long containerId);
|
||||
OwnershipRow ownership(@Param("customerId") long customerId,
|
||||
@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT settled.source_fee_id
|
||||
@@ -32,14 +33,14 @@ public interface FinanceContainerSettlementQueryMapper {
|
||||
WHERE s.tenant_id = #{customerId}
|
||||
AND s.status = 'PAID'
|
||||
AND f.customer_id = #{customerId}
|
||||
AND f.container_id = #{containerId}
|
||||
AND f.container_booking_id = #{containerBookingId}
|
||||
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.container_id = #{containerBookingId}
|
||||
AND c.cost_origin = 'FIRSTLEG_DIRECT_PAID'
|
||||
AND c.source_system = 'FIRSTLEG'
|
||||
AND c.source_type = 'CONTAINER_FEE'
|
||||
@@ -49,12 +50,12 @@ public interface FinanceContainerSettlementQueryMapper {
|
||||
ORDER BY settled.source_fee_id
|
||||
""")
|
||||
List<Long> settledSourceFeeIds(@Param("customerId") long customerId,
|
||||
@Param("containerId") long containerId);
|
||||
@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@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.container_id AS containerBookingId, 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,
|
||||
@@ -63,11 +64,11 @@ public interface FinanceContainerSettlementQueryMapper {
|
||||
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}
|
||||
WHERE c.tenant_id = #{customerId} AND c.container_id = #{containerBookingId}
|
||||
ORDER BY c.created_at DESC, c.id DESC, a.id
|
||||
""")
|
||||
List<CostAllocationRow> costs(@Param("customerId") long customerId,
|
||||
@Param("containerId") long containerId);
|
||||
@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
final class OwnershipRow {
|
||||
private long customerLinkCount;
|
||||
@@ -83,7 +84,7 @@ public interface FinanceContainerSettlementQueryMapper {
|
||||
private String costOrigin;
|
||||
private Long sourceFeeId;
|
||||
private Integer sourceFeeVersion;
|
||||
private long containerId;
|
||||
private long containerBookingId;
|
||||
private String supplierName;
|
||||
private String feeItemName;
|
||||
private BigDecimal amount;
|
||||
@@ -110,8 +111,8 @@ public interface FinanceContainerSettlementQueryMapper {
|
||||
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 long getContainerBookingId() { return containerBookingId; }
|
||||
public void setContainerBookingId(long value) { this.containerBookingId = value; }
|
||||
public String getSupplierName() { return supplierName; }
|
||||
public void setSupplierName(String value) { this.supplierName = value; }
|
||||
public String getFeeItemName() { return feeItemName; }
|
||||
|
||||
+7
-7
@@ -22,21 +22,21 @@ public class FinanceContainerSettlementQueryService {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public ContainerSettlementDetails details(long customerId, long containerId) {
|
||||
public ContainerSettlementDetails details(long customerId, long containerBookingId) {
|
||||
if (customerId <= 0) throw new IllegalArgumentException("客户范围无效");
|
||||
if (containerId <= 0) throw new IllegalArgumentException("柜次范围无效");
|
||||
if (containerBookingId <= 0) throw new IllegalArgumentException("货柜范围无效");
|
||||
|
||||
var ownership = mapper.ownership(customerId, containerId);
|
||||
var ownership = mapper.ownership(customerId, containerBookingId);
|
||||
if (ownership == null || ownership.getCustomerLinkCount() == 0) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "柜次不存在或不属于客户");
|
||||
}
|
||||
|
||||
List<Long> settledSourceFeeIds = mapper.settledSourceFeeIds(customerId, containerId).stream()
|
||||
List<Long> settledSourceFeeIds = mapper.settledSourceFeeIds(customerId, containerBookingId).stream()
|
||||
.filter(value -> value != null && value > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
List<ContainerCostDetail> costs = groupCosts(mapper.costs(customerId, containerId));
|
||||
return new ContainerSettlementDetails(customerId, containerId, settledSourceFeeIds, costs);
|
||||
List<ContainerCostDetail> costs = groupCosts(mapper.costs(customerId, containerBookingId));
|
||||
return new ContainerSettlementDetails(customerId, containerBookingId, settledSourceFeeIds, costs);
|
||||
}
|
||||
|
||||
private static List<ContainerCostDetail> groupCosts(List<CostAllocationRow> rows) {
|
||||
@@ -60,7 +60,7 @@ public class FinanceContainerSettlementQueryService {
|
||||
|
||||
private ContainerCostDetail view() {
|
||||
return new ContainerCostDetail(cost.getCostId(), cost.getCostNo(), cost.getCostType(),
|
||||
cost.getCostOrigin(), cost.getSourceFeeId(), cost.getSourceFeeVersion(), cost.getContainerId(),
|
||||
cost.getCostOrigin(), cost.getSourceFeeId(), cost.getSourceFeeVersion(), cost.getContainerBookingId(),
|
||||
cost.getSupplierName(), cost.getFeeItemName(), cost.getAmount(), cost.getCurrency(),
|
||||
cost.getStatus(), List.copyOf(allocations));
|
||||
}
|
||||
|
||||
@@ -86,10 +86,11 @@ public class FinanceFeeSourceEventService {
|
||||
snapshot.sourceType(), snapshot.feeId());
|
||||
jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源版本已替代',version=version+1,updated_by=0 WHERE cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND status='CONFIRMED'",
|
||||
snapshot.sourceType(), snapshot.feeId());
|
||||
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')",
|
||||
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,container_booking_id,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')",
|
||||
snapshot.feeId(), snapshot.feeVersion(), snapshot.customerId(), snapshot.sourceType(),
|
||||
snapshot.inboundCode() == null || snapshot.inboundCode().isBlank() ? snapshot.containerNo() : snapshot.inboundCode(),
|
||||
snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(),
|
||||
financeContainerBookingId(snapshot),
|
||||
snapshot.feeItemCode(), snapshot.feeItemName(), snapshot.totalAmount(), snapshot.currency(),
|
||||
snapshot.settlementMode(), snapshot.paidAt(), snapshot.paymentMethod(),
|
||||
snapshot.paymentReferenceNo(), voucherJson(snapshot.paymentVoucherFileIds()),
|
||||
@@ -116,7 +117,7 @@ public class FinanceFeeSourceEventService {
|
||||
String costNo = "DIRECT-" + snapshot.sourceType() + "-" + snapshot.feeId() + "-V" + snapshot.feeVersion();
|
||||
jdbc.update("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,cost_origin,source_system,source_type,source_fee_id,source_fee_version,source_hash,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,currency_code,cost_amount,voucher_file_ids,paid_at,payment_method,payment_reference_no,status,confirmed_at,created_by,created_by_name,updated_by) VALUES(?,?,'SOURCE_DIRECT_PAID','FIRSTLEG_DIRECT_PAID','FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
|
||||
snapshot.customerId(), costNo, snapshot.sourceType(), snapshot.feeId(), snapshot.feeVersion(),
|
||||
snapshot.sourceHash(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(),
|
||||
snapshot.sourceHash(), snapshot.orderId(), snapshot.inboundId(), financeContainerBookingId(snapshot),
|
||||
snapshot.supplierId(), snapshot.supplierName(), snapshot.feeItemCode(), snapshot.feeItemName(),
|
||||
snapshot.currency(), snapshot.totalAmount(), voucherJsonOrEmpty(snapshot.paymentVoucherFileIds()),
|
||||
snapshot.paidAt(), snapshot.paymentMethod(), snapshot.paymentReferenceNo());
|
||||
@@ -184,7 +185,11 @@ public class FinanceFeeSourceEventService {
|
||||
throw error("FIN-EVT-012", "应收费用不得携带结算方式或已付快照");
|
||||
}
|
||||
if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致");
|
||||
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致");
|
||||
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null
|
||||
|| !String.valueOf(value.containerId()).equals(event.payload().containerId())
|
||||
|| value.containerBookingId() == null || value.containerBookingId() <= 0)) {
|
||||
throw error("FIN-EVT-014", "柜子归属不一致");
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePayableSettlement(FirstlegFeeSourceClient.Snapshot value) {
|
||||
@@ -216,6 +221,10 @@ public class FinanceFeeSourceEventService {
|
||||
return values == null || values.isEmpty() ? "[]" : json(values);
|
||||
}
|
||||
|
||||
private static Long financeContainerBookingId(FirstlegFeeSourceClient.Snapshot snapshot) {
|
||||
return "CONTAINER_FEE".equals(snapshot.sourceType()) ? snapshot.containerBookingId() : null;
|
||||
}
|
||||
|
||||
private String json(Object value) {
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
catch (JsonProcessingException e) { throw error("FIN-EVT-001", "事件序列化失败"); }
|
||||
|
||||
@@ -229,7 +229,7 @@ CREATE TABLE IF NOT EXISTS fin_cost (
|
||||
source_hash VARCHAR(71) NULL COMMENT '来源不可变快照SHA-256,兼容sha256前缀格式',
|
||||
order_id BIGINT UNSIGNED NULL COMMENT '成本归属的头程订单ID,单票成本必填',
|
||||
inbound_id BIGINT UNSIGNED NULL COMMENT '成本关联的入库单ID,可为空',
|
||||
container_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划ID,整柜公共成本必填',
|
||||
container_id BIGINT UNSIGNED NULL COMMENT 'Firstleg真实货柜containerBookingId,整柜公共成本必填',
|
||||
warehouse_id BIGINT UNSIGNED NULL COMMENT '业务直接已付成本关联仓库ID,可为空',
|
||||
supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse公共往来单位或供应商引用ID',
|
||||
supplier_name_snapshot VARCHAR(200) NULL COMMENT '登记成本时供应商或成本对象名称快照',
|
||||
|
||||
@@ -10,7 +10,7 @@ paths:
|
||||
get:
|
||||
summary: 按客户和柜次查询已结算柜费及成本分摊
|
||||
description: >-
|
||||
customerId 与 containerId 必须在 Finance 的费用来源或成本快照中存在归属关系。
|
||||
customerId 与 containerBookingId 必须在 Finance 的费用来源或成本快照中存在归属关系。
|
||||
服务端固定执行三条集合 SQL,不按结算单或成本逐项查询;响应不包含整张结算单、
|
||||
其他客户数据、付款凭证或内部备注。
|
||||
x-permissions-all-of:
|
||||
@@ -25,10 +25,10 @@ paths:
|
||||
in: query
|
||||
required: true
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
- name: containerId
|
||||
- name: containerBookingId
|
||||
in: query
|
||||
required: true
|
||||
description: Firstleg 装柜计划 ID。
|
||||
description: Firstleg 真实货柜 containerBookingId,不是装柜计划 loadingPlanId。
|
||||
schema: {$ref: '#/components/schemas/Id'}
|
||||
responses:
|
||||
'200':
|
||||
@@ -63,10 +63,10 @@ components:
|
||||
timestamp: {type: string, format: date-time}
|
||||
ContainerSettlementDetails:
|
||||
type: object
|
||||
required: [customerId, containerId, settledSourceFeeIds, costs]
|
||||
required: [customerId, containerBookingId, settledSourceFeeIds, costs]
|
||||
properties:
|
||||
customerId: {$ref: '#/components/schemas/Id'}
|
||||
containerId: {$ref: '#/components/schemas/Id'}
|
||||
containerBookingId: {$ref: '#/components/schemas/Id'}
|
||||
settledSourceFeeIds:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
@@ -76,7 +76,7 @@ components:
|
||||
items: {$ref: '#/components/schemas/ContainerCostDetail'}
|
||||
ContainerCostDetail:
|
||||
type: object
|
||||
required: [costId, costNo, costType, costOrigin, containerId, feeItemName, amount, currency, status, allocations]
|
||||
required: [costId, costNo, costType, costOrigin, containerBookingId, feeItemName, amount, currency, status, allocations]
|
||||
properties:
|
||||
costId: {$ref: '#/components/schemas/Id'}
|
||||
costNo: {type: string}
|
||||
@@ -84,7 +84,7 @@ components:
|
||||
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'}
|
||||
containerBookingId: {$ref: '#/components/schemas/Id'}
|
||||
supplierName: {type: string, nullable: true}
|
||||
feeItemName: {type: string}
|
||||
amount: {$ref: '#/components/schemas/Money'}
|
||||
|
||||
+3
-1
@@ -22,6 +22,8 @@ class FinanceContainerSettlementOpenApiContractTest {
|
||||
.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:");
|
||||
.contains("containerBookingId:", "不是装柜计划 loadingPlanId",
|
||||
"settledSourceFeeIds:", "costs:", "allocations:")
|
||||
.doesNotContain("- name: containerId");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@ class FinanceContainerSettlementQueryMapperContractTest {
|
||||
.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}");
|
||||
"f.container_booking_id = #{containerBookingId}",
|
||||
"c.container_id = #{containerBookingId}");
|
||||
assertThat(sql).doesNotContain("${");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -33,7 +33,7 @@ class FinanceContainerSettlementQueryServiceTest {
|
||||
var result = new FinanceContainerSettlementQueryService(mapper).details(101L, 301L);
|
||||
|
||||
assertThat(result.customerId()).isEqualTo(101L);
|
||||
assertThat(result.containerId()).isEqualTo(301L);
|
||||
assertThat(result.containerBookingId()).isEqualTo(301L);
|
||||
assertThat(result.settledSourceFeeIds()).containsExactly(901L, 902L);
|
||||
assertThat(result.costs()).hasSize(2);
|
||||
assertThat(result.costs().get(0).costId()).isEqualTo(501L);
|
||||
@@ -79,14 +79,14 @@ class FinanceContainerSettlementQueryServiceTest {
|
||||
return row;
|
||||
}
|
||||
|
||||
private static CostAllocationRow row(long costId, long containerId, Long allocationId,
|
||||
private static CostAllocationRow row(long costId, long containerBookingId, 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.setContainerBookingId(containerBookingId);
|
||||
row.setSupplierName("供应商");
|
||||
row.setFeeItemName("柜费");
|
||||
row.setAmount(new BigDecimal("100.00"));
|
||||
|
||||
@@ -62,6 +62,31 @@ class FinanceFeeSourceEventServiceTest {
|
||||
assertThat(service.consume(event)).isEqualTo(101L);
|
||||
verify(firstleg).container("test-token", 41, 31, 1);
|
||||
verify(jdbc).update(contains("status='SUPERSEDED'"), eq("CONTAINER_FEE"), eq(31L));
|
||||
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
|
||||
assertThat(values.getValue()[8]).as("fin_fee_source.container_id keeps loadingPlanId")
|
||||
.isEqualTo(41L);
|
||||
assertThat(values.getValue()[9]).as("fin_fee_source.container_booking_id stores physical container")
|
||||
.isEqualTo(61L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsContainerFeeSnapshotWithoutPhysicalContainerBookingId() {
|
||||
FinanceFeeSourceEvent event = containerEvent("container-missing-booking", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
|
||||
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"),
|
||||
eq("container-missing-booking"))).thenReturn(List.of());
|
||||
var snapshot = new FirstlegFeeSourceClient.Snapshot(7, "CONTAINER_FEE", 31, 1,
|
||||
null, null, null, 41L, null, "C-41", 7, "客户", null, null,
|
||||
null, null, null, null, null, List.of(), 61L, "CUSTOMS", "清关费",
|
||||
"RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", new BigDecimal("100.00"),
|
||||
"APPROVED", "PUBLISHED", LocalDateTime.of(2026, 8, 11, 1, 0),
|
||||
BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", 1);
|
||||
when(firstleg.container("test-token", 41, 31, 1)).thenReturn(ApiResponse.success(snapshot));
|
||||
|
||||
assertThatThrownBy(() -> service.consume(event))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-014");
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -83,7 +108,7 @@ class FinanceFeeSourceEventServiceTest {
|
||||
verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class));
|
||||
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
|
||||
assertThat(values.getValue()[19].toString())
|
||||
assertThat(values.getValue()[20].toString())
|
||||
.contains("\"supplierId\":71", "\"supplierName\":\"现有供应商\"", "\"supplierCategory\":\"TRUCKING\"");
|
||||
}
|
||||
|
||||
@@ -121,9 +146,9 @@ class FinanceFeeSourceEventServiceTest {
|
||||
|
||||
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
|
||||
assertThat(values.getValue()[13]).isEqualTo("DIRECT_PAID");
|
||||
assertThat(values.getValue()[15]).isEqualTo("BANK_TRANSFER");
|
||||
assertThat(values.getValue()[17]).isEqualTo("[901]");
|
||||
assertThat(values.getValue()[14]).isEqualTo("DIRECT_PAID");
|
||||
assertThat(values.getValue()[16]).isEqualTo("BANK_TRANSFER");
|
||||
assertThat(values.getValue()[18]).isEqualTo("[901]");
|
||||
ArgumentCaptor<Object[]> costValues = ArgumentCaptor.forClass(Object[].class);
|
||||
verify(jdbc).update(startsWith("INSERT INTO fin_cost"), costValues.capture());
|
||||
assertThat(costValues.getValue()[0]).isEqualTo(7L);
|
||||
@@ -131,6 +156,8 @@ class FinanceFeeSourceEventServiceTest {
|
||||
assertThat(costValues.getValue()[2]).isEqualTo("CONTAINER_FEE");
|
||||
assertThat(costValues.getValue()[3]).isEqualTo(31L);
|
||||
assertThat(costValues.getValue()[4]).isEqualTo(1);
|
||||
assertThat(costValues.getValue()[8]).as("fin_cost.container_id stores containerBookingId")
|
||||
.isEqualTo(61L);
|
||||
assertThat(costValues.getValue()[15]).isEqualTo("[901]");
|
||||
assertThat(costValues.getValue()[17]).isEqualTo("BANK_TRANSFER");
|
||||
assertThat(costValues.getValue()[18]).isEqualTo("BANK-001");
|
||||
@@ -284,7 +311,7 @@ class FinanceFeeSourceEventServiceTest {
|
||||
Long inboundId, Long containerId,
|
||||
String publicationStatus, BigDecimal amount) {
|
||||
return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId,
|
||||
inboundId == null ? null : "IB-21", 51L, containerId, null,
|
||||
inboundId == null ? null : "IB-21", 51L, containerId, containerId == null ? null : 61L,
|
||||
containerId == null ? null : "C-41", customerId, "客户", null, null, null,
|
||||
null, null, null, null, List.of(), 61L, "CUSTOMS",
|
||||
"清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount,
|
||||
|
||||
@@ -19,6 +19,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
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.ArgumentMatchers.eq;
|
||||
@@ -55,9 +56,13 @@ class PlatformFinanceControllerBehaviorTest {
|
||||
controller.cost(user, "cost-key", request);
|
||||
|
||||
verify(snapshotClient).loadingPlan("internal-token", 10L, 88L);
|
||||
verify(service).createCost(eq(10L), eq(7L), any(), eq(List.of(
|
||||
new FinanceService.ContainerOrderVolume(101L, 1001L, 88L, 10L, new BigDecimal("1.25")),
|
||||
new FinanceService.ContainerOrderVolume(102L, 1002L, 88L, 10L, new BigDecimal("2.75")))), eq("cost-key"));
|
||||
org.mockito.ArgumentCaptor<FinanceService.CostRequest> cost =
|
||||
org.mockito.ArgumentCaptor.forClass(FinanceService.CostRequest.class);
|
||||
verify(service).createCost(eq(10L), eq(7L), cost.capture(), eq(List.of(
|
||||
new FinanceService.ContainerOrderVolume(101L, 1001L, 99L, 10L, new BigDecimal("1.25")),
|
||||
new FinanceService.ContainerOrderVolume(102L, 1002L, 99L, 10L, new BigDecimal("2.75")))), eq("cost-key"));
|
||||
assertThat(cost.getValue().containerId()).as("Finance persists the physical containerBookingId")
|
||||
.isEqualTo(99L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
-2
@@ -40,9 +40,9 @@ public class ContainerFeeDetailRepository {
|
||||
.eq(ContainerFeeDetailEntity::getId, feeId));
|
||||
}
|
||||
|
||||
public ContainerFeeDetailEntity find(long loadingPlanId, long feeId) {
|
||||
public ContainerFeeDetailEntity findByContainerBookingId(long containerBookingId, long feeId) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<ContainerFeeDetailEntity>()
|
||||
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
|
||||
.eq(ContainerFeeDetailEntity::getContainerBookingId, containerBookingId)
|
||||
.eq(ContainerFeeDetailEntity::getId, feeId));
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -49,10 +49,10 @@ public class ContainerFeeSourceSnapshotRepository {
|
||||
mapper.insert(value);
|
||||
}
|
||||
|
||||
public ContainerFeeSourceSnapshotEntity find(long loadingPlanId, long sourceFeeId,
|
||||
int sourceVersion) {
|
||||
public ContainerFeeSourceSnapshotEntity findByContainerBookingId(long containerBookingId, long sourceFeeId,
|
||||
int sourceVersion) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<ContainerFeeSourceSnapshotEntity>()
|
||||
.eq(ContainerFeeSourceSnapshotEntity::getLoadingPlanId, loadingPlanId)
|
||||
.eq(ContainerFeeSourceSnapshotEntity::getContainerBookingId, containerBookingId)
|
||||
.eq(ContainerFeeSourceSnapshotEntity::getSourceFeeId, sourceFeeId)
|
||||
.eq(ContainerFeeSourceSnapshotEntity::getSourceVersion, sourceVersion));
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ public class ContainerFeeDetailService {
|
||||
sourceSnapshots.saveApproved(approved);
|
||||
String eventType = approved.getSourceVersion() > 1
|
||||
? FinanceFeeSourceOutboxService.ADJUSTED : FinanceFeeSourceOutboxService.PUBLISHED;
|
||||
financeOutbox.publishContainer(eventType, tenantId, loadingPlanId, feeId,
|
||||
financeOutbox.publishContainer(eventType, tenantId, containerBookingId(approved), feeId,
|
||||
approved.getSourceVersion(), LocalDateTime.now());
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ public class ContainerFeeDetailService {
|
||||
throw conflict("只有当前版本的已审核柜次费用可以作废");
|
||||
}
|
||||
financeOutbox.publishContainer(FinanceFeeSourceOutboxService.VOIDED, tenantId,
|
||||
loadingPlanId, feeId, expectedVersion, LocalDateTime.now());
|
||||
containerBookingId(current), feeId, expectedVersion, LocalDateTime.now());
|
||||
}
|
||||
|
||||
private PlanContext requireOwnedPlan(long tenantId, long loadingPlanId) {
|
||||
@@ -372,6 +372,13 @@ public class ContainerFeeDetailService {
|
||||
return new BizException(ResultCode.CONFLICT, message);
|
||||
}
|
||||
|
||||
private static long containerBookingId(ContainerFeeDetailEntity value) {
|
||||
if (value.getContainerBookingId() == null || value.getContainerBookingId() <= 0) {
|
||||
throw conflict("柜次费用缺少真实货柜关联");
|
||||
}
|
||||
return value.getContainerBookingId();
|
||||
}
|
||||
|
||||
private record PlanContext(LoadingPlanEntity plan, ContainerBookingEntity booking) {
|
||||
}
|
||||
private static String text(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
|
||||
+4
-3
@@ -123,9 +123,10 @@ public class FinanceFeeSourceCommandService {
|
||||
}
|
||||
|
||||
private void voidContainer(VoidRequest request) {
|
||||
long containerId = positive(request.containerId(), "柜次ID");
|
||||
long containerBookingId = positive(request.containerId(), "柜次ID");
|
||||
if (request.inboundId() != null) throw bad("柜次费用不能携带入库单ID");
|
||||
ContainerFeeDetailEntity detail = containerFees.find(containerId, request.sourceFeeId());
|
||||
ContainerFeeDetailEntity detail = containerFees.findByContainerBookingId(
|
||||
containerBookingId, request.sourceFeeId());
|
||||
if (detail == null || !Long.valueOf(request.customerId()).equals(detail.getTenantId())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "指定客户的柜次费用来源版本不存在");
|
||||
}
|
||||
@@ -133,7 +134,7 @@ public class FinanceFeeSourceCommandService {
|
||||
throw conflict("柜次费用来源版本已变化");
|
||||
}
|
||||
if ("VOIDED".equals(detail.getSourceStatus())) return;
|
||||
containerFeeService.voidApproved(request.customerId(), containerId,
|
||||
containerFeeService.voidApproved(request.customerId(), detail.getLoadingPlanId(),
|
||||
request.sourceFeeId(), request.sourceVersion(), request.operatorId());
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -36,11 +36,11 @@ public class FinanceFeeSourceOutboxService {
|
||||
payload, occurredAt));
|
||||
}
|
||||
|
||||
public void publishContainer(String eventType, long tenantId, long containerId,
|
||||
public void publishContainer(String eventType, long tenantId, long containerBookingId,
|
||||
long feeId, int feeVersion, LocalDateTime occurredAt) {
|
||||
FinanceFeeSourceEvent.Payload payload = new FinanceFeeSourceEvent.Payload(
|
||||
null, String.valueOf(containerId), String.valueOf(feeId), feeVersion);
|
||||
publish(event(eventType, tenantId, "CONTAINER", containerId, feeId, feeVersion,
|
||||
null, String.valueOf(containerBookingId), String.valueOf(feeId), feeVersion);
|
||||
publish(event(eventType, tenantId, "CONTAINER", containerBookingId, feeId, feeVersion,
|
||||
payload, occurredAt));
|
||||
}
|
||||
|
||||
|
||||
+6
-4
@@ -58,12 +58,13 @@ public class FinanceFeeSourceSnapshotService {
|
||||
detail.getActualGrossWeightKg(), detail.getActualVolumeCbm(), sha256(canonical), 1);
|
||||
}
|
||||
|
||||
public FinanceFeeSourceSnapshot container(long containerId, long feeId, int sourceVersion) {
|
||||
ContainerFeeSourceSnapshotEntity detail = containerSnapshots.find(containerId, feeId, sourceVersion);
|
||||
public FinanceFeeSourceSnapshot container(long containerBookingId, long feeId, int sourceVersion) {
|
||||
ContainerFeeSourceSnapshotEntity detail = containerSnapshots.findByContainerBookingId(
|
||||
containerBookingId, feeId, sourceVersion);
|
||||
if (detail == null) throw new BizException(ResultCode.NOT_FOUND, "柜次费用来源不存在");
|
||||
int version = detail.getSourceVersion();
|
||||
String canonical = String.join("|", "CONTAINER_FEE", String.valueOf(feeId),
|
||||
String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(containerId),
|
||||
String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(containerBookingId),
|
||||
nullSafe(detail.getFeeItemCode()), nullSafe(detail.getDirection()),
|
||||
nullSafe(detail.getSupplierId()), nullSafe(detail.getSupplierName()),
|
||||
nullSafe(detail.getSupplierCategory()),
|
||||
@@ -76,7 +77,8 @@ public class FinanceFeeSourceSnapshotService {
|
||||
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
|
||||
nullSafe(detail.getReviewStatus()));
|
||||
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
|
||||
version, detail.getAllocationInboundId(), detail.getAllocationInboundCode(), detail.getAllocationOrderId(), containerId, detail.getContainerBookingId(),
|
||||
version, detail.getAllocationInboundId(), detail.getAllocationInboundCode(), detail.getAllocationOrderId(),
|
||||
containerBookingId, containerBookingId,
|
||||
detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(),
|
||||
detail.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(),
|
||||
detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(),
|
||||
|
||||
@@ -184,7 +184,7 @@ paths:
|
||||
/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:
|
||||
get:
|
||||
summary: Finance 按柜次费用来源版本精确回查不可变快照
|
||||
description: sourceVersion 必填;只返回 sourceFeeId 与该版本精确匹配的已发布费用,不回退到当前可变版本。
|
||||
description: sourceVersion 必填;containerId 固定为真实 containerBookingId,只返回 sourceFeeId 与该版本精确匹配的已发布费用,不接受 loadingPlanId 回退。
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
parameters:
|
||||
@@ -2650,8 +2650,8 @@ components:
|
||||
inboundId: {type: integer, format: int64, nullable: true}
|
||||
inboundCode: {type: string, nullable: true}
|
||||
orderId: {type: integer, format: int64, nullable: true}
|
||||
containerId: {type: integer, format: int64, nullable: true}
|
||||
containerBookingId: {type: integer, format: int64, nullable: true}
|
||||
containerId: {type: integer, format: int64, nullable: true, description: CONTAINER_FEE时固定为真实containerBookingId}
|
||||
containerBookingId: {type: integer, format: int64, nullable: true, description: 真实containerBookingId;CONTAINER_FEE时与containerId一致}
|
||||
containerNo: {type: string, nullable: true}
|
||||
customerId: {type: integer, format: int64}
|
||||
customerName: {type: string}
|
||||
|
||||
+4
-1
@@ -75,6 +75,7 @@ class ContainerFeeDetailServiceTest {
|
||||
approved.setId(51L);
|
||||
approved.setTenantId(10001L);
|
||||
approved.setLoadingPlanId(41L);
|
||||
approved.setContainerBookingId(61L);
|
||||
approved.setFeeItemId(11L);
|
||||
approved.setDirection("RECEIVABLE");
|
||||
approved.setSourceVersion(1);
|
||||
@@ -87,7 +88,7 @@ class ContainerFeeDetailServiceTest {
|
||||
service.approve(10001L, 41L, 51L, 9L, "admin");
|
||||
|
||||
verify(outbox).publishContainer(eq(FinanceFeeSourceOutboxService.PUBLISHED),
|
||||
eq(10001L), eq(41L), eq(51L), eq(1), any(LocalDateTime.class));
|
||||
eq(10001L), eq(61L), eq(51L), eq(1), any(LocalDateTime.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -284,6 +285,7 @@ class ContainerFeeDetailServiceTest {
|
||||
private void ownedPlan() {
|
||||
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||
plan.setId(41L);
|
||||
plan.setContainerBookingId(61L);
|
||||
when(loading.plan(41L)).thenReturn(plan);
|
||||
LoadingPlanItemEntity item = new LoadingPlanItemEntity();
|
||||
item.setAccountId(10001L);
|
||||
@@ -316,6 +318,7 @@ class ContainerFeeDetailServiceTest {
|
||||
value.setId(51L);
|
||||
value.setTenantId(10001L);
|
||||
value.setLoadingPlanId(41L);
|
||||
value.setContainerBookingId(61L);
|
||||
value.setFeeItemId(11L);
|
||||
value.setFeeItemCodeSnapshot("TRUCKING");
|
||||
value.setFeeItemNameSnapshot("拖车费");
|
||||
|
||||
+3
-2
@@ -96,11 +96,12 @@ class FinanceFeeSourceCommandServiceTest {
|
||||
@Test
|
||||
void routesContainerVoidThroughExistingBusinessService() throws Exception {
|
||||
VoidRequest request = new VoidRequest("CONTAINER_FEE", 800L, 4, 9L,
|
||||
null, 200L, 71L, "finance", "柜次费用作废");
|
||||
null, 61L, 71L, "finance", "柜次费用作废");
|
||||
ContainerFeeDetailEntity detail = new ContainerFeeDetailEntity();
|
||||
detail.setId(800L); detail.setTenantId(9L); detail.setLoadingPlanId(200L);
|
||||
detail.setContainerBookingId(61L);
|
||||
detail.setSourceVersion(4); detail.setSourceStatus("ACTIVE");
|
||||
when(containerFees.find(200L, 800L)).thenReturn(detail);
|
||||
when(containerFees.findByContainerBookingId(61L, 800L)).thenReturn(detail);
|
||||
|
||||
service.voidSource("void-container", hash(request), request);
|
||||
|
||||
|
||||
+14
@@ -49,4 +49,18 @@ class FinanceFeeSourceOutboxServiceTest {
|
||||
assertThatCode(() -> service.publishContainer(FinanceFeeSourceOutboxService.VOIDED,
|
||||
10001L, 41L, 51L, 2, LocalDateTime.now())).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void containerEventUsesTheRealContainerBookingIdAsAggregateAndPayloadIdentity() {
|
||||
service.publishContainer(FinanceFeeSourceOutboxService.PUBLISHED,
|
||||
10001L, 61L, 51L, 2, LocalDateTime.of(2026, 8, 11, 12, 0));
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(mapper).insert(captor.capture());
|
||||
InboundOutboxEntity event = captor.getValue();
|
||||
assertThat(event.getAggregateType()).isEqualTo("CONTAINER");
|
||||
assertThat(event.getAggregateId()).isEqualTo(61L);
|
||||
assertThat(event.getPayloadJson()).contains("\"containerId\":\"61\"")
|
||||
.doesNotContain("\"containerId\":\"41\"");
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -90,10 +90,12 @@ class FinanceFeeSourceSnapshotServiceTest {
|
||||
detail.setCurrency("CNY");
|
||||
detail.setReviewStatus("APPROVED");
|
||||
detail.setSourceVersion(2);
|
||||
when(containerFees.find(41L, 51L, 2)).thenReturn(detail);
|
||||
when(containerFees.findByContainerBookingId(61L, 51L, 2)).thenReturn(detail);
|
||||
|
||||
FinanceFeeSourceSnapshot snapshot = service.container(41L, 51L, 2);
|
||||
FinanceFeeSourceSnapshot snapshot = service.container(61L, 51L, 2);
|
||||
|
||||
assertThat(snapshot.containerId()).isEqualTo(61L);
|
||||
assertThat(snapshot.containerBookingId()).isEqualTo(61L);
|
||||
assertThat(snapshot.supplierId()).isEqualTo(71L);
|
||||
assertThat(snapshot.supplierName()).isEqualTo("现有供应商");
|
||||
assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING");
|
||||
|
||||
Reference in New Issue
Block a user