Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-08 21:29:21 +08:00
24 changed files with 154 additions and 75 deletions
@@ -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,
@@ -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; }
@@ -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", "事件序列化失败"); }
+1 -1
View File
@@ -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'}
@@ -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");
}
}
@@ -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("${");
}
}
@@ -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
@@ -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));
}
@@ -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(); }
@@ -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());
}
@@ -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));
}
@@ -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}
@@ -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("拖车费");
@@ -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);
@@ -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\"");
}
}
@@ -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");