This commit is contained in:
2026-09-02 16:17:41 +08:00
parent 12fbf2aa67
commit 03d04431f2
33 changed files with 1367 additions and 22 deletions
@@ -0,0 +1,52 @@
package com.baiyue.common.event;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
/**
* 跨区域业务费用来源的完整不可变快照。
*
* <p>海外业务与国内 Finance 不做同步回查,因此事件必须携带可独立校验、入池的完整快照。
* 标识统一使用字符串,避免跨语言大整数精度损失。</p>
*/
public record BusinessFeeSourceChangedV1(
String action,
String sourceSystem,
String sourceType,
String sourceFeeId,
int sourceVersion,
String customerId,
String customerName,
String sourceNo,
String businessType,
String businessId,
String businessNo,
String orderId,
String inboundId,
String inboundCode,
String containerId,
String containerBookingId,
String containerNo,
String warehouseId,
String feeItemCode,
String feeItemName,
String direction,
String supplierId,
String supplierName,
String supplierCategory,
String settlementMode,
Instant paidAt,
String paymentMethod,
String paymentReferenceNo,
List<String> paymentVoucherFileIds,
LocalDate businessDate,
String currency,
BigDecimal totalAmount,
String reviewStatus,
String publicationStatus,
String sourceStatus,
String sourceHash
) {
}
@@ -13,6 +13,7 @@ public final class EventTypes {
public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED = public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
"FirstLegContainerShippingNodeRecorded"; "FirstLegContainerShippingNodeRecorded";
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged";
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged"; public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged"; public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
+1
View File
@@ -11,3 +11,4 @@ baiyue:
event-dispatcher: event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://finance:9208/internal/finance/v1/business-fee-source-events}
@@ -0,0 +1,7 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface FinanceEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -0,0 +1,56 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Component
public class HttpFinanceEventClient implements FinanceEventClient {
@Resource private ObjectMapper objectMapper;
@Resource private EventDispatcherProperties properties;
@Resource private EventBridgeHttpTracePropagation tracePropagation;
@Value("${baiyue.event-dispatcher.finance-consumer-url:http://127.0.0.1:9208/internal/finance/v1/business-fee-source-events}")
private URI consumerUrl;
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
private HttpClient httpClient;
@PostConstruct
void initializeHttpClient() {
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
}
@Override
public String deliver(BridgeInbox row) throws Exception {
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
HttpRequest request = tracePropagation.apply(HttpRequest.newBuilder(consumerUrl)
.timeout(properties.requestTimeout()).header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode body;
try { body = objectMapper.readTree(response.body()); }
catch (Exception ignored) { body = null; }
if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText())) {
throw new IllegalStateException("Finance消费失败: HTTP " + response.statusCode()
+ ", code=" + (body == null ? "missing" : body.path("code").asText("missing")));
}
return String.valueOf(response.statusCode());
}
}
@@ -27,6 +27,7 @@ public class OverseasEventDispatcher {
@Resource private BridgeDeliveryAttemptMapper attemptMapper; @Resource private BridgeDeliveryAttemptMapper attemptMapper;
@Resource private LastlegEventClient eventClient; @Resource private LastlegEventClient eventClient;
@Resource private FirstlegEventClient firstlegEventClient; @Resource private FirstlegEventClient firstlegEventClient;
@Resource private FinanceEventClient financeEventClient;
@Resource private SecurityProjectionEventClient securityProjectionEventClient; @Resource private SecurityProjectionEventClient securityProjectionEventClient;
@Resource private EventDispatcherProperties properties; @Resource private EventDispatcherProperties properties;
@Resource private EventBridgeMetrics metrics; @Resource private EventBridgeMetrics metrics;
@@ -89,7 +90,8 @@ public class OverseasEventDispatcher {
throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者"); throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者");
} }
return switch (event.getTargetRegion()) { return switch (event.getTargetRegion()) {
case "CN" -> firstlegEventClient.deliver(event); case "CN" -> EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(eventType)
? financeEventClient.deliver(event) : firstlegEventClient.deliver(event);
case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType) case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType)
? securityProjectionEventClient.deliver(event) : eventClient.deliver(event); ? securityProjectionEventClient.deliver(event) : eventClient.deliver(event);
default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion()); default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion());
@@ -33,6 +33,7 @@ baiyue:
event-dispatcher: event-dispatcher:
consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}}
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events} firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events}
finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://127.0.0.1:9208/internal/finance/v1/business-fee-source-events}
security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events} security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events}
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
+1
View File
@@ -11,6 +11,7 @@
</parent> </parent>
<artifactId>finance-service</artifactId> <artifactId>finance-service</artifactId>
<dependencies> <dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency> <dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency> <dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency> <dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
@@ -0,0 +1,38 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.finance.service.BusinessFeeSourceEventService;
import com.baiyue.finance.service.FinanceEventException;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Map;
/** Receives full immutable fee snapshots from cross-region business producers. */
@RestController
@RequiredArgsConstructor
@RequestMapping("/internal/finance/v1/business-fee-source-events")
public class InternalBusinessFeeSourceController {
private final BusinessFeeSourceEventService service;
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
@PostMapping
public ApiResponse<Map<String, Long>> consume(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope event) {
if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效");
}
return FinanceResponses.success(Map.of("feeSourceId", service.consume(event)));
}
}
@@ -0,0 +1,199 @@
package com.baiyue.finance.service;
import com.baiyue.common.event.BusinessFeeSourceChangedV1;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Consumes complete overseas business fee snapshots without a cross-region synchronous callback. */
@Service
@RequiredArgsConstructor
public class BusinessFeeSourceEventService {
private static final Set<String> ACTIONS = Set.of("PUBLISHED", "ADJUSTED", "VOIDED");
private static final Set<String> DIRECTIONS = Set.of("RECEIVABLE", "PAYABLE");
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FinanceSupplierValidator supplierValidator;
@Transactional
public long consume(EventEnvelope envelope) {
try {
envelope.requireValid();
} catch (RuntimeException exception) {
throw error("FIN-EVT-001", "跨区费用事件信封无效");
}
if (!EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(envelope.eventType())
|| envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.OVERSEAS
|| envelope.targetRegion() != RegionCode.CN) {
throw error("FIN-EVT-001", "跨区费用事件类型、版本或区域无效");
}
BusinessFeeSourceChangedV1 source = payload(envelope);
validate(envelope, source);
String sourceSystem = source.sourceSystem().trim().toUpperCase();
String eventJson = json(envelope);
List<Map<String, Object>> inbox = jdbc.queryForList(
"SELECT payload_hash,status,result_resource_id FROM fin_inbox_event WHERE source_system=? AND event_id=? FOR UPDATE",
sourceSystem, envelope.eventId());
if (!inbox.isEmpty()) {
Map<String, Object> row = inbox.get(0);
if (!envelope.payloadHash().equals(row.get("payload_hash")))
throw error("FIN-EVT-002", "同一事件ID对应不同费用快照");
if ("PROCESSED".equals(row.get("status")) && row.get("result_resource_id") instanceof Number id)
return id.longValue();
throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败");
}
jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')",
envelope.eventId(), sourceSystem, envelope.eventType(), eventJson, envelope.payloadHash());
long result = apply(sourceSystem, source);
jdbc.update("UPDATE fin_inbox_event SET status='PROCESSED',result_resource_id=?,processed_at=CURRENT_TIMESTAMP(3),error_code=NULL WHERE source_system=? AND event_id=?",
result, sourceSystem, envelope.eventId());
return result;
}
private long apply(String system, BusinessFeeSourceChangedV1 source) {
long feeId = positive(source.sourceFeeId(), "sourceFeeId");
List<Map<String, Object>> same = jdbc.queryForList(
"SELECT id,source_hash,status FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? AND source_fee_version=? FOR UPDATE",
system, source.sourceType(), feeId, source.sourceVersion());
if (!same.isEmpty()) {
Map<String, Object> row = same.get(0);
if (!source.sourceHash().equals(row.get("source_hash")))
throw error("FIN-EVT-004", "同一来源版本快照不一致");
long id = ((Number) row.get("id")).longValue();
if ("VOIDED".equals(source.action())) {
requireNotFrozen(id);
jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id);
voidDirectPaidCost(system, source);
}
return id;
}
if ("VOIDED".equals(source.action()))
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
requireSourceNotFrozen(system, source.sourceType(), feeId);
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system=? AND source_type=? AND source_fee_id=? AND status='AVAILABLE'",
system, source.sourceType(), 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='BUSINESS_DIRECT_PAID' AND source_system=? AND source_type=? AND source_fee_id=? AND status='CONFIRMED'",
system, source.sourceType(), feeId);
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,business_type,business_id,business_no,inbound_code,warehouse_id,direction,order_id,inbound_id,container_id,container_booking_id,container_no,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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'AVAILABLE')",
system, feeId, source.sourceVersion(), positive(source.customerId(), "customerId"),
source.sourceType(), trim(source.sourceNo()), trim(source.businessType()), number(source.businessId()),
trim(source.businessNo()), trim(source.inboundCode()), number(source.warehouseId()), source.direction(),
number(source.orderId()), number(source.inboundId()), number(source.containerId()),
number(source.containerBookingId()), trim(source.containerNo()), source.feeItemCode().trim(),
source.feeItemName().trim(), source.totalAmount(), source.currency(), trim(source.settlementMode()),
source.paidAt(), trim(source.paymentMethod()), trim(source.paymentReferenceNo()),
json(source.paymentVoucherFileIds()), source.sourceHash(), json(source));
long id = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system=? AND source_type=? AND source_fee_id=? AND source_fee_version=?",
Long.class, system, source.sourceType(), feeId, source.sourceVersion());
if ("PAYABLE".equals(source.direction()) && "DIRECT_PAID".equals(source.settlementMode()))
materializeDirectPaidCost(system, feeId, source);
return id;
}
private void validate(EventEnvelope envelope, BusinessFeeSourceChangedV1 value) {
if (value == null || !ACTIONS.contains(value.action()) || blank(value.sourceSystem())
|| "FIRSTLEG".equalsIgnoreCase(value.sourceSystem()) || blank(value.sourceType())
|| value.sourceVersion() < 1 || !envelope.tenantId().equals(value.customerId())
|| blank(value.businessType()) || blank(value.businessNo()) || blank(value.feeItemCode())
|| blank(value.feeItemName()) || !DIRECTIONS.contains(value.direction())
|| value.businessDate() == null || !"CNY".equals(value.currency())
|| value.totalAmount() == null || value.totalAmount().signum() <= 0
|| !"APPROVED".equals(value.reviewStatus()) || blank(value.sourceHash())) {
throw error("FIN-EVT-012", "跨区费用来源快照字段不完整");
}
positive(value.sourceFeeId(), "sourceFeeId");
positive(value.customerId(), "customerId");
if (!"VOIDED".equals(value.action()) && (!"PUBLISHED".equals(value.publicationStatus())
|| !"ACTIVE".equals(value.sourceStatus()))) {
throw error("FIN-EVT-015", "费用来源尚未发布或已失效");
}
if ("PAYABLE".equals(value.direction())) {
long supplierId = positive(value.supplierId(), "supplierId");
var supplier = supplierValidator.requireActive(supplierId, trim(value.supplierCategory()));
if (blank(value.supplierName()) || !value.supplierName().equals(supplier.name()))
throw error("FIN-EVT-012", "应付来源供应商快照不一致");
validateSettlement(value);
} else if (!blank(value.supplierId()) || !blank(value.supplierName())
|| !blank(value.settlementMode()) || value.paidAt() != null
|| !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo())
|| (value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty())) {
throw error("FIN-EVT-012", "应收来源不得携带供应商或付款事实");
}
BusinessFeeSourceChangedV1 unsigned = new BusinessFeeSourceChangedV1(value.action(), value.sourceSystem(),
value.sourceType(), value.sourceFeeId(), value.sourceVersion(), value.customerId(), value.customerName(),
value.sourceNo(), value.businessType(), value.businessId(), value.businessNo(), value.orderId(),
value.inboundId(), value.inboundCode(), value.containerId(), value.containerBookingId(), value.containerNo(),
value.warehouseId(), value.feeItemCode(), value.feeItemName(), value.direction(), value.supplierId(),
value.supplierName(), value.supplierCategory(), value.settlementMode(), value.paidAt(), value.paymentMethod(),
value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(),
value.totalAmount(), value.reviewStatus(), value.publicationStatus(), value.sourceStatus(), null);
String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
}
private void validateSettlement(BusinessFeeSourceChangedV1 value) {
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
if ("ON_ACCOUNT".equals(value.settlementMode())) {
if (value.paidAt() != null || !blank(value.paymentMethod()) || !blank(value.paymentReferenceNo()) || files)
throw error("FIN-EVT-012", "账期应付不得携带已付事实");
} else if (!"DIRECT_PAID".equals(value.settlementMode()) || value.paidAt() == null
|| !Set.of("ONLINE", "BANK_TRANSFER", "CASH", "OTHER").contains(value.paymentMethod())
|| (blank(value.paymentReferenceNo()) && !files)) {
throw error("FIN-EVT-012", "直接已付来源快照不完整");
}
}
private void requireSourceNotFrozen(String system, String type, long feeId) {
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system=? AND f.source_type=? AND f.source_fee_id=? AND (EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, system, type, feeId);
if (count != null && count > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止调整或替代");
}
private void requireNotFrozen(long id) {
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.id=? AND (EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, id);
if (count != null && count > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止作废");
}
private void materializeDirectPaidCost(String system, long feeId, BusinessFeeSourceChangedV1 value) {
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','BUSINESS_DIRECT_PAID',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED',CURRENT_TIMESTAMP(3),0,'系统',0)",
positive(value.customerId(), "customerId"), "DIRECT-" + system + '-' + value.sourceType() + '-' + feeId + "-V" + value.sourceVersion(),
system, value.sourceType(), feeId, value.sourceVersion(), value.sourceHash(), number(value.orderId()),
number(value.inboundId()), number(value.containerId()), positive(value.supplierId(), "supplierId"),
value.supplierName(), value.feeItemCode(), value.feeItemName(), value.currency(), value.totalAmount(),
json(value.paymentVoucherFileIds()), value.paidAt(), value.paymentMethod(), value.paymentReferenceNo());
}
private void voidDirectPaidCost(String system, BusinessFeeSourceChangedV1 value) {
jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源费用已作废',version=version+1,updated_by=0 WHERE cost_origin='BUSINESS_DIRECT_PAID' AND source_system=? AND source_type=? AND source_fee_id=? AND source_fee_version=? AND status='CONFIRMED'",
system, value.sourceType(), positive(value.sourceFeeId(), "sourceFeeId"), value.sourceVersion());
}
private BusinessFeeSourceChangedV1 payload(EventEnvelope envelope) {
try { return objectMapper.treeToValue(envelope.payload(), BusinessFeeSourceChangedV1.class); }
catch (JsonProcessingException exception) { throw error("FIN-EVT-001", "跨区费用事件载荷无法解析"); }
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value == null ? List.of() : value); }
catch (JsonProcessingException exception) { throw error("FIN-EVT-001", "费用事件序列化失败"); }
}
private static Long number(String value) { return blank(value) ? null : positive(value, "business identifier"); }
private static long positive(String value, String name) {
try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; }
catch (RuntimeException ignored) { }
throw error("FIN-EVT-001", name + "无效");
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
private static String trim(String value) { return blank(value) ? null : value.trim(); }
private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); }
}
@@ -63,8 +63,8 @@ public class FinanceFeeSourceEventService {
private long applyEvent(FinanceFeeSourceEvent event, FirstlegFeeSourceClient.Snapshot snapshot) { private long applyEvent(FinanceFeeSourceEvent event, FirstlegFeeSourceClient.Snapshot snapshot) {
List<Map<String, Object>> sameVersion = jdbc.queryForList( List<Map<String, Object>> sameVersion = jdbc.queryForList(
"SELECT id,source_hash,status FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=? FOR UPDATE", "SELECT id,source_hash,status FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND source_fee_version=? FOR UPDATE",
snapshot.feeId(), snapshot.feeVersion()); snapshot.sourceType(), snapshot.feeId(), snapshot.feeVersion());
if (!sameVersion.isEmpty()) { if (!sameVersion.isEmpty()) {
Map<String, Object> row = sameVersion.get(0); Map<String, Object> row = sameVersion.get(0);
if (!snapshot.sourceHash().equals(row.get("source_hash"))) { if (!snapshot.sourceHash().equals(row.get("source_hash"))) {
@@ -81,29 +81,29 @@ public class FinanceFeeSourceEventService {
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) { if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在"); throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
} }
requireSourceNotFrozen(snapshot.feeId()); requireSourceNotFrozen(snapshot.sourceType(), snapshot.feeId());
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_fee_id=? AND status='AVAILABLE'", jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND status='AVAILABLE'",
snapshot.feeId()); 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_fee_id=? AND status='CONFIRMED'", 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.feeId()); 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,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.feeId(), snapshot.feeVersion(), snapshot.customerId(), snapshot.sourceType(),
snapshot.sourceType().equals("INBOUND_FEE") ? snapshot.inboundCode() : snapshot.containerNo(), snapshot.inboundCode() == null || snapshot.inboundCode().isBlank() ? snapshot.containerNo() : snapshot.inboundCode(),
snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(), snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(),
snapshot.feeItemCode(), snapshot.feeItemName(), snapshot.totalAmount(), snapshot.currency(), snapshot.feeItemCode(), snapshot.feeItemName(), snapshot.totalAmount(), snapshot.currency(),
snapshot.settlementMode(), snapshot.paidAt(), snapshot.paymentMethod(), snapshot.settlementMode(), snapshot.paidAt(), snapshot.paymentMethod(),
snapshot.paymentReferenceNo(), voucherJson(snapshot.paymentVoucherFileIds()), snapshot.paymentReferenceNo(), voucherJson(snapshot.paymentVoucherFileIds()),
snapshot.sourceHash(), json(snapshot)); snapshot.sourceHash(), json(snapshot));
long sourceId = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=?", long sourceId = jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND source_fee_version=?",
Long.class, snapshot.feeId(), snapshot.feeVersion()); Long.class, snapshot.sourceType(), snapshot.feeId(), snapshot.feeVersion());
if ("PAYABLE".equals(snapshot.direction()) && "DIRECT_PAID".equals(snapshot.settlementMode())) { if ("PAYABLE".equals(snapshot.direction()) && "DIRECT_PAID".equals(snapshot.settlementMode())) {
materializeDirectPaidCost(snapshot); materializeDirectPaidCost(snapshot);
} }
return sourceId; return sourceId;
} }
private void requireSourceNotFrozen(long sourceFeeId) { private void requireSourceNotFrozen(String sourceType, long sourceFeeId) {
Integer frozen = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG' AND f.source_fee_id=? AND (EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, sourceFeeId); Integer frozen = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source f WHERE f.source_system='FIRSTLEG' AND f.source_type=? AND f.source_fee_id=? AND (EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') OR EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.status<>'VOIDED'))", Integer.class, sourceType, sourceFeeId);
if (frozen != null && frozen > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止调整或替代"); if (frozen != null && frozen > 0) throw error("FIN-EVT-016", "费用来源已进入有效单据,禁止调整或替代");
} }
+11 -4
View File
@@ -4,16 +4,23 @@ USE baiyue_finance;
CREATE TABLE IF NOT EXISTS fin_fee_source ( CREATE TABLE IF NOT EXISTS fin_fee_source (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
source_system VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '费用来源系统,当前FIRSTLEG表示头程服务', source_system VARCHAR(32) NOT NULL COMMENT '费用来源系统:FIRSTLEG头程、LASTLEG海外仓等',
source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '来源系统费用记录ID', source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '来源系统费用记录ID',
source_fee_version INT UNSIGNED NOT NULL COMMENT '来源费用版本号,用于固化不可变来源版本', source_fee_version INT UNSIGNED NOT NULL COMMENT '来源费用版本号,用于固化不可变来源版本',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '费用来源所属客户ID', customer_id BIGINT UNSIGNED NOT NULL COMMENT '费用来源所属客户ID',
source_type VARCHAR(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用', source_type VARCHAR(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用',
source_no VARCHAR(64) NULL COMMENT '来源入库单号或柜号快照,可为空', source_no VARCHAR(64) NULL COMMENT '来源入库单号或柜号快照,可为空',
business_type VARCHAR(32) NULL COMMENT '来源业务类型,如海外入仓、采购、卡车或柜级分摊',
business_id BIGINT UNSIGNED NULL COMMENT '来源业务记录ID,可为空',
business_no VARCHAR(128) NULL COMMENT '来源业务编号快照,可为空',
inbound_code VARCHAR(64) NULL COMMENT '来源入仓码快照;并非所有费用必填',
warehouse_id BIGINT UNSIGNED NULL COMMENT '来源海外仓或业务仓ID,可为空',
direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付',
order_id BIGINT UNSIGNED NULL COMMENT '来源头程订单ID,可为空', order_id BIGINT UNSIGNED NULL COMMENT '来源头程订单ID,可为空',
inbound_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装柜计划ID,可为空',
container_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱柜ID,可为空',
container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照,可为空',
fee_item_code VARCHAR(64) NOT NULL COMMENT '来源费用项目编码快照', fee_item_code VARCHAR(64) NOT NULL COMMENT '来源费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '来源费用项目名称快照', fee_item_name VARCHAR(200) NOT NULL COMMENT '来源费用项目名称快照',
amount DECIMAL(20,6) NOT NULL COMMENT '来源费用金额,精度6位小数', amount DECIMAL(20,6) NOT NULL COMMENT '来源费用金额,精度6位小数',
@@ -32,7 +39,7 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '来源快照入池时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '来源快照入池时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fin_fee_source (source_system, source_fee_id, source_fee_version), UNIQUE KEY uk_fin_fee_source (source_system, source_type, source_fee_id, source_fee_version),
KEY idx_fin_fee_source_customer (customer_id, status), KEY idx_fin_fee_source_customer (customer_id, status),
KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id), KEY idx_fin_fee_source_customer_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id),
CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'), CONSTRAINT chk_fin_fee_source_currency CHECK (currency_code = 'CNY'),
@@ -46,7 +53,7 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER') AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')
AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0)) AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0))
) )
) ENGINE=InnoDB COMMENT='Firstleg费用来源快照池;账单人工选择并固化明细'; ) ENGINE=InnoDB COMMENT='多业务系统费用来源快照池;账单人工选择并固化明细';
CREATE TABLE IF NOT EXISTS fin_inbox_event ( CREATE TABLE IF NOT EXISTS fin_inbox_event (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
@@ -62,7 +69,7 @@ CREATE TABLE IF NOT EXISTS fin_inbox_event (
processed_at DATETIME(3) NULL COMMENT '事件处理完成时间,未完成时为空', processed_at DATETIME(3) NULL COMMENT '事件处理完成时间,未完成时为空',
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_fin_inbox_event (source_system, event_id) UNIQUE KEY uk_fin_inbox_event (source_system, event_id)
) ENGINE=InnoDB COMMENT='Firstleg费用事件幂等接收记录'; ) ENGINE=InnoDB COMMENT='多业务系统费用事件幂等接收记录';
CREATE TABLE IF NOT EXISTS fin_subject ( CREATE TABLE IF NOT EXISTS fin_subject (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
@@ -4,6 +4,8 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest; import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupAdjustRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.RejectRequest; import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.RejectRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.VersionRequest; import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.VersionRequest;
@@ -43,6 +45,48 @@ public class PlatformContainerFeeDetailController {
loadingPlanId, user.userId(), request)); loadingPlanId, user.userId(), request));
} }
@PostMapping("/allocation-groups")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:edit')")
@BusinessAudit(event = "CONTAINER_FEE_ALLOCATION_GROUP_CREATE", resourceType = "LOADING_PLAN", resourceId = "#loadingPlanId", captureBefore = false)
public ApiResponse<List<ContainerFeeDetailEntity>> createAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@Valid @RequestBody AllocationGroupRequest request) {
return ApiResponse.success(service.createAllocationGroup(loadingPlanId, user.userId(), request));
}
@PostMapping("/allocation-groups/{groupId}/approve")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
public ApiResponse<List<ContainerFeeDetailEntity>> approveAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@PathVariable long groupId) {
return ApiResponse.success(service.approveAllocationGroup(loadingPlanId, groupId, user.userId(), user.username()));
}
@PostMapping("/allocation-groups/{groupId}/reject")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
public ApiResponse<List<ContainerFeeDetailEntity>> rejectAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@PathVariable long groupId, @Valid @RequestBody RejectRequest request) {
return ApiResponse.success(service.rejectAllocationGroup(loadingPlanId, groupId, user.userId(), user.username(), request.reason()));
}
@PostMapping("/allocation-groups/{groupId}/adjust")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:edit')")
public ApiResponse<List<ContainerFeeDetailEntity>> adjustAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@PathVariable long groupId, @Valid @RequestBody AllocationGroupAdjustRequest request) {
return ApiResponse.success(service.adjustAllocationGroup(loadingPlanId, groupId, user.userId(), request));
}
@PostMapping("/allocation-groups/{groupId}/void")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
public ApiResponse<Void> voidAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@PathVariable long groupId, @Valid @RequestBody VersionRequest request) {
service.voidAllocationGroup(loadingPlanId, groupId, request.expectedSourceVersion(), user.userId());
return ApiResponse.success(null);
}
@PostMapping("/{feeId}/approve") @PostMapping("/{feeId}/approve")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')") @PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
@BusinessAudit(event = "CONTAINER_FEE_DETAIL_APPROVE", resourceType = "CONTAINER_FEE_DETAIL", resourceId = "#feeId") @BusinessAudit(event = "CONTAINER_FEE_DETAIL_APPROVE", resourceType = "CONTAINER_FEE_DETAIL", resourceId = "#feeId")
@@ -1,5 +1,6 @@
package com.baiyue.firstleg.dto; package com.baiyue.firstleg.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
@@ -44,6 +45,30 @@ public final class ContainerFeeDetailDtos {
) { ) {
} }
public record Allocation(
@NotNull @Min(1) Long customerId,
@NotNull @Min(1) Long inboundId,
@NotBlank @Size(max = 64) String inboundCode,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount
) { }
public record AllocationGroupRequest(
@NotNull @Valid SaveRequest fee,
@NotNull @Size(min = 1, max = 200) List<@Valid Allocation> allocations
) { }
public record AllocationAmount(
@NotNull @Min(1) Long feeId,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount
) { }
public record AllocationGroupAdjustRequest(
@NotNull @Min(1) Integer expectedSourceVersion,
@NotNull LocalDate businessDate,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal totalAmount,
@NotNull @Size(min = 1, max = 200) List<@Valid AllocationAmount> allocations
) { }
public record VersionRequest(@NotNull @Min(1) Integer expectedSourceVersion) { public record VersionRequest(@NotNull @Min(1) Integer expectedSourceVersion) {
} }
@@ -21,6 +21,10 @@ public class ContainerFeeDetailEntity {
@TableId(type = IdType.ASSIGN_ID) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
private Long tenantId; private Long tenantId;
private Long allocationGroupId;
private Long allocationInboundId;
private String allocationInboundCode;
private BigDecimal allocationGroupAmount;
private Long loadingPlanId; private Long loadingPlanId;
private Long containerBookingId; private Long containerBookingId;
private String containerNo; private String containerNo;
@@ -23,6 +23,10 @@ public class ContainerFeeSourceSnapshotEntity {
private Long sourceFeeId; private Long sourceFeeId;
private Integer sourceVersion; private Integer sourceVersion;
private Long tenantId; private Long tenantId;
private Long allocationGroupId;
private Long allocationInboundId;
private String allocationInboundCode;
private BigDecimal allocationGroupAmount;
private Long loadingPlanId; private Long loadingPlanId;
private Long containerBookingId; private Long containerBookingId;
private String containerNo; private String containerNo;
@@ -25,6 +25,14 @@ public class ContainerFeeDetailRepository {
.orderByDesc(ContainerFeeDetailEntity::getId)); .orderByDesc(ContainerFeeDetailEntity::getId));
} }
public List<ContainerFeeDetailEntity> listGroup(long loadingPlanId, long allocationGroupId) {
return mapper.selectList(new LambdaQueryWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getAllocationGroupId, allocationGroupId)
.orderByAsc(ContainerFeeDetailEntity::getTenantId)
.orderByAsc(ContainerFeeDetailEntity::getAllocationInboundCode));
}
public ContainerFeeDetailEntity find(long tenantId, long loadingPlanId, long feeId) { public ContainerFeeDetailEntity find(long tenantId, long loadingPlanId, long feeId) {
return mapper.selectOne(new LambdaQueryWrapper<ContainerFeeDetailEntity>() return mapper.selectOne(new LambdaQueryWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, tenantId) .eq(ContainerFeeDetailEntity::getTenantId, tenantId)
@@ -96,6 +104,15 @@ public class ContainerFeeDetailRepository {
.set(ContainerFeeDetailEntity::getUpdatedAt, updatedAt)); .set(ContainerFeeDetailEntity::getUpdatedAt, updatedAt));
} }
public int updateAllocationGroupAmount(long loadingPlanId, long feeId, int sourceVersion,
BigDecimal groupAmount) {
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getId, feeId)
.eq(ContainerFeeDetailEntity::getSourceVersion, sourceVersion)
.set(ContainerFeeDetailEntity::getAllocationGroupAmount, groupAmount));
}
public int voidApproved(long tenantId, long loadingPlanId, long feeId, public int voidApproved(long tenantId, long loadingPlanId, long feeId,
int expectedVersion, long operatorId) { int expectedVersion, long operatorId) {
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>() return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
@@ -19,6 +19,10 @@ public class ContainerFeeSourceSnapshotRepository {
value.setSourceFeeId(source.getId()); value.setSourceFeeId(source.getId());
value.setSourceVersion(source.getSourceVersion()); value.setSourceVersion(source.getSourceVersion());
value.setTenantId(source.getTenantId()); value.setTenantId(source.getTenantId());
value.setAllocationGroupId(source.getAllocationGroupId());
value.setAllocationInboundId(source.getAllocationInboundId());
value.setAllocationInboundCode(source.getAllocationInboundCode());
value.setAllocationGroupAmount(source.getAllocationGroupAmount());
value.setLoadingPlanId(source.getLoadingPlanId()); value.setLoadingPlanId(source.getLoadingPlanId());
value.setContainerBookingId(source.getContainerBookingId()); value.setContainerBookingId(source.getContainerBookingId());
value.setContainerNo(source.getContainerNo()); value.setContainerNo(source.getContainerNo());
@@ -3,6 +3,10 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest; import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.Allocation;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationAmount;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupAdjustRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity; import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
@@ -17,8 +21,14 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -40,9 +50,17 @@ public class ContainerFeeDetailService {
public ContainerFeeDetailEntity create(long tenantId, long loadingPlanId, long operatorId, public ContainerFeeDetailEntity create(long tenantId, long loadingPlanId, long operatorId,
SaveRequest request) { SaveRequest request) {
PlanContext plan = requireOwnedPlan(tenantId, loadingPlanId); PlanContext plan = requireOwnedPlan(tenantId, loadingPlanId);
List<InboundAllocationKey> owned = loading.items(loadingPlanId).stream()
.filter(item -> Long.valueOf(tenantId).equals(item.getAccountId()))
.map(item -> new InboundAllocationKey(item.getInboundId(), item.getInboundCode()))
.distinct().toList();
if (owned.size() != 1) throw conflict("柜次包含多个入仓码,必须使用柜级费用分摊录入");
FeeItemEntity item = requireItem(request.feeItemId(), request.direction()); FeeItemEntity item = requireItem(request.feeItemId(), request.direction());
ContainerFeeDetailEntity value = new ContainerFeeDetailEntity(); ContainerFeeDetailEntity value = new ContainerFeeDetailEntity();
value.setTenantId(tenantId); value.setTenantId(tenantId);
value.setAllocationInboundId(owned.get(0).inboundId());
value.setAllocationInboundCode(owned.get(0).inboundCode());
value.setAllocationGroupAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
value.setLoadingPlanId(loadingPlanId); value.setLoadingPlanId(loadingPlanId);
value.setContainerBookingId(plan.plan().getContainerBookingId()); value.setContainerBookingId(plan.plan().getContainerBookingId());
value.setContainerNo(plan.booking() == null ? null : plan.booking().getContainerNo()); value.setContainerNo(plan.booking() == null ? null : plan.booking().getContainerNo());
@@ -56,10 +74,112 @@ public class ContainerFeeDetailService {
return value; return value;
} }
@Transactional
public List<ContainerFeeDetailEntity> createAllocationGroup(long loadingPlanId, long operatorId,
AllocationGroupRequest request) {
PlanContext plan = requirePlan(loadingPlanId);
SaveRequest common = request.fee();
FeeItemEntity item = requireItem(common.feeItemId(), common.direction());
BigDecimal total = common.amount().setScale(2, RoundingMode.HALF_UP);
BigDecimal allocated = request.allocations().stream().map(Allocation::amount)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP);
if (allocated.compareTo(total) != 0) throw conflict("柜级费用分摊合计必须等于费用总额");
Map<InboundAllocationKey, Long> ownership = new HashMap<>();
loading.items(loadingPlanId).forEach(value -> ownership.put(
new InboundAllocationKey(value.getInboundId(), value.getInboundCode()), value.getAccountId()));
Set<InboundAllocationKey> unique = new HashSet<>();
long groupId = IdWorker.getId();
List<ContainerFeeDetailEntity> rows = new java.util.ArrayList<>();
for (Allocation allocation : request.allocations()) {
InboundAllocationKey key = new InboundAllocationKey(allocation.inboundId(), allocation.inboundCode().trim());
if (!unique.add(key)) throw conflict("同一入仓码不得重复分摊");
if (!allocation.customerId().equals(ownership.get(key)))
throw conflict("分摊客户、入仓码与柜内真实归属不一致");
ContainerFeeDetailEntity value = new ContainerFeeDetailEntity();
value.setId(IdWorker.getId()); value.setTenantId(allocation.customerId());
value.setAllocationGroupId(groupId); value.setAllocationInboundId(allocation.inboundId());
value.setAllocationInboundCode(allocation.inboundCode().trim()); value.setAllocationGroupAmount(total);
value.setLoadingPlanId(loadingPlanId); value.setContainerBookingId(plan.plan().getContainerBookingId());
value.setContainerNo(plan.booking() == null ? null : plan.booking().getContainerNo());
value.setSourceVersion(1); value.setSourceStatus("ACTIVE"); value.setReviewStatus("PENDING");
value.setCreatedBy(operatorId); value.setCreatedAt(LocalDateTime.now());
SaveRequest allocationRequest = new SaveRequest(common.feeItemId(), common.direction(),
common.businessDate(), allocation.amount(), common.currency(), common.supplierId(),
common.settlementMode(), common.paidAt(), common.paymentMethod(), common.paymentReferenceNo(),
common.paymentVoucherFileIds(), common.remark());
apply(value, item, allocationRequest, operatorId); repository.insert(value); rows.add(value);
}
return rows;
}
@Transactional
public List<ContainerFeeDetailEntity> approveAllocationGroup(long loadingPlanId, long groupId,
long reviewerId, String reviewerName) {
requirePlan(loadingPlanId);
List<ContainerFeeDetailEntity> rows = requiredGroup(loadingPlanId, groupId);
for (ContainerFeeDetailEntity row : rows) approveRow(row, reviewerId, reviewerName);
return requiredGroup(loadingPlanId, groupId);
}
@Transactional
public List<ContainerFeeDetailEntity> rejectAllocationGroup(long loadingPlanId, long groupId,
long reviewerId, String reviewerName, String reason) {
requirePlan(loadingPlanId);
List<ContainerFeeDetailEntity> rows = requiredGroup(loadingPlanId, groupId);
for (ContainerFeeDetailEntity row : rows) {
if (repository.review(row.getTenantId(), loadingPlanId, row.getId(), "REJECTED",
reviewerId, reviewerName, reason.trim()) != 1) throw conflict("分摊组已变化,请刷新后重试");
}
return requiredGroup(loadingPlanId, groupId);
}
@Transactional
public List<ContainerFeeDetailEntity> adjustAllocationGroup(long loadingPlanId, long groupId,
long operatorId, AllocationGroupAdjustRequest request) {
requirePlan(loadingPlanId);
List<ContainerFeeDetailEntity> rows = requiredGroup(loadingPlanId, groupId);
if (rows.stream().anyMatch(row -> row.getSourceVersion() != request.expectedSourceVersion()
|| !"APPROVED".equals(row.getReviewStatus()) || !"ACTIVE".equals(row.getSourceStatus())))
throw conflict("只有完整的当前已审核分摊组可以调整");
Map<Long, BigDecimal> amounts = new HashMap<>();
for (AllocationAmount allocation : request.allocations()) {
if (amounts.put(allocation.feeId(), allocation.amount().setScale(2, RoundingMode.HALF_UP)) != null)
throw conflict("分摊费用行不得重复");
}
if (!amounts.keySet().equals(rows.stream().map(ContainerFeeDetailEntity::getId).collect(java.util.stream.Collectors.toSet())))
throw conflict("调整必须包含分摊组全部费用行");
BigDecimal sum = amounts.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP);
if (sum.compareTo(request.totalAmount().setScale(2, RoundingMode.HALF_UP)) != 0)
throw conflict("调整后的分摊合计必须等于费用总额");
for (ContainerFeeDetailEntity row : rows) {
if (repository.adjust(row.getTenantId(), loadingPlanId, row.getId(), request.expectedSourceVersion(),
request.businessDate(), amounts.get(row.getId()), operatorId, LocalDateTime.now()) != 1)
throw conflict("柜级费用分摊组已变化,请刷新后重试");
repository.updateAllocationGroupAmount(loadingPlanId, row.getId(), request.expectedSourceVersion() + 1,
request.totalAmount().setScale(2, RoundingMode.HALF_UP));
}
return requiredGroup(loadingPlanId, groupId);
}
@Transactional
public void voidAllocationGroup(long loadingPlanId, long groupId, int expectedVersion, long operatorId) {
requirePlan(loadingPlanId);
List<ContainerFeeDetailEntity> rows = requiredGroup(loadingPlanId, groupId);
for (ContainerFeeDetailEntity row : rows) voidRow(row, expectedVersion, operatorId);
}
@Transactional @Transactional
public void approve(long tenantId, long loadingPlanId, long feeId, public void approve(long tenantId, long loadingPlanId, long feeId,
long reviewerId, String reviewerName) { long reviewerId, String reviewerName) {
ContainerFeeDetailEntity pending = required(tenantId, loadingPlanId, feeId); ContainerFeeDetailEntity pending = required(tenantId, loadingPlanId, feeId);
if (pending.getAllocationGroupId() != null) throw conflict("分摊费用必须整组审核");
approveRow(pending, reviewerId, reviewerName);
}
private void approveRow(ContainerFeeDetailEntity pending, long reviewerId, String reviewerName) {
long tenantId = pending.getTenantId();
long loadingPlanId = pending.getLoadingPlanId();
long feeId = pending.getId();
FeeItemEntity item = requireItem(pending.getFeeItemId(), pending.getDirection()); FeeItemEntity item = requireItem(pending.getFeeItemId(), pending.getDirection());
revalidatePaymentSnapshot(pending); revalidatePaymentSnapshot(pending);
refreshSupplierSnapshot(pending, item, reviewerId); refreshSupplierSnapshot(pending, item, reviewerId);
@@ -79,6 +199,8 @@ public class ContainerFeeDetailService {
public void reject(long tenantId, long loadingPlanId, long feeId, long reviewerId, public void reject(long tenantId, long loadingPlanId, long feeId, long reviewerId,
String reviewerName, String reason) { String reviewerName, String reason) {
required(tenantId, loadingPlanId, feeId); required(tenantId, loadingPlanId, feeId);
if (required(tenantId, loadingPlanId, feeId).getAllocationGroupId() != null)
throw conflict("分摊费用必须整组驳回");
if (repository.review(tenantId, loadingPlanId, feeId, "REJECTED", reviewerId, if (repository.review(tenantId, loadingPlanId, feeId, "REJECTED", reviewerId,
reviewerName, reason.trim()) != 1) { reviewerName, reason.trim()) != 1) {
throw conflict("只有待审核柜次费用可以驳回"); throw conflict("只有待审核柜次费用可以驳回");
@@ -90,6 +212,7 @@ public class ContainerFeeDetailService {
long operatorId, AdjustRequest request) { long operatorId, AdjustRequest request) {
requireOwnedPlan(tenantId, loadingPlanId); requireOwnedPlan(tenantId, loadingPlanId);
ContainerFeeDetailEntity value = required(tenantId, loadingPlanId, feeId); ContainerFeeDetailEntity value = required(tenantId, loadingPlanId, feeId);
if (value.getAllocationGroupId() != null) throw conflict("分摊费用必须整组调整");
int expectedVersion = request.expectedSourceVersion(); int expectedVersion = request.expectedSourceVersion();
var amount = request.amount().setScale(2, RoundingMode.HALF_UP); var amount = request.amount().setScale(2, RoundingMode.HALF_UP);
if (value.getSourceVersion() != null && value.getSourceVersion() == expectedVersion + 1 if (value.getSourceVersion() != null && value.getSourceVersion() == expectedVersion + 1
@@ -113,6 +236,14 @@ public class ContainerFeeDetailService {
public void voidApproved(long tenantId, long loadingPlanId, long feeId, public void voidApproved(long tenantId, long loadingPlanId, long feeId,
int expectedVersion, long operatorId) { int expectedVersion, long operatorId) {
ContainerFeeDetailEntity current = required(tenantId, loadingPlanId, feeId); ContainerFeeDetailEntity current = required(tenantId, loadingPlanId, feeId);
if (current.getAllocationGroupId() != null) throw conflict("分摊费用必须整组作废");
voidRow(current, expectedVersion, operatorId);
}
private void voidRow(ContainerFeeDetailEntity current, int expectedVersion, long operatorId) {
long tenantId = current.getTenantId();
long loadingPlanId = current.getLoadingPlanId();
long feeId = current.getId();
if (repository.voidApproved(tenantId, loadingPlanId, feeId, expectedVersion, operatorId) != 1) { if (repository.voidApproved(tenantId, loadingPlanId, feeId, expectedVersion, operatorId) != 1) {
throw conflict("只有当前版本的已审核柜次费用可以作废"); throw conflict("只有当前版本的已审核柜次费用可以作废");
} }
@@ -121,13 +252,24 @@ public class ContainerFeeDetailService {
} }
private PlanContext requireOwnedPlan(long tenantId, long loadingPlanId) { private PlanContext requireOwnedPlan(long tenantId, long loadingPlanId) {
LoadingPlanEntity plan = loading.plan(loadingPlanId); PlanContext context = requirePlan(loadingPlanId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在"); LoadingPlanEntity plan = context.plan();
boolean owned = loading.items(loadingPlanId).stream() boolean owned = loading.items(loadingPlanId).stream()
.anyMatch(item -> Long.valueOf(tenantId).equals(item.getAccountId())); .anyMatch(item -> Long.valueOf(tenantId).equals(item.getAccountId()));
if (!owned) throw new BizException(ResultCode.NOT_FOUND, "柜次不属于该客户"); if (!owned) throw new BizException(ResultCode.NOT_FOUND, "柜次不属于该客户");
return new PlanContext(plan, plan.getContainerBookingId() == null return context;
? null : loading.booking(plan.getContainerBookingId())); }
private PlanContext requirePlan(long loadingPlanId) {
LoadingPlanEntity plan = loading.plan(loadingPlanId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
return new PlanContext(plan, plan.getContainerBookingId() == null ? null : loading.booking(plan.getContainerBookingId()));
}
private List<ContainerFeeDetailEntity> requiredGroup(long loadingPlanId, long groupId) {
List<ContainerFeeDetailEntity> rows = repository.listGroup(loadingPlanId, groupId);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "柜级费用分摊组不存在");
return rows;
} }
private FeeItemEntity requireItem(long feeItemId, String direction) { private FeeItemEntity requireItem(long feeItemId, String direction) {
@@ -217,4 +359,5 @@ public class ContainerFeeDetailService {
private record PlanContext(LoadingPlanEntity plan, ContainerBookingEntity booking) { private record PlanContext(LoadingPlanEntity plan, ContainerBookingEntity booking) {
} }
private record InboundAllocationKey(Long inboundId, String inboundCode) { }
} }
@@ -70,10 +70,12 @@ public class FinanceFeeSourceSnapshotService {
nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()), nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()),
nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()), nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()),
nullSafe(detail.getPaymentVoucherFileIds()), nullSafe(detail.getPaymentVoucherFileIds()),
nullSafe(detail.getAllocationGroupId()), nullSafe(detail.getAllocationInboundId()),
nullSafe(detail.getAllocationInboundCode()), nullSafe(detail.getAllocationGroupAmount()),
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()), detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
nullSafe(detail.getReviewStatus())); nullSafe(detail.getReviewStatus()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId, return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
version, null, null, null, containerId, detail.getContainerBookingId(), version, detail.getAllocationInboundId(), detail.getAllocationInboundCode(), null, containerId, detail.getContainerBookingId(),
detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(), detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(), detail.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(),
detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(), detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(),
@@ -64,6 +64,10 @@ DROP PROCEDURE IF EXISTS firstleg_finance_add_index;
CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID', id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
allocation_group_id BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空',
allocation_inbound_id BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID',
allocation_inbound_code VARCHAR(64) NULL COMMENT '本分摊行对应入仓码',
allocation_group_amount DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID,对应fl_loading_plan.id', loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID,对应fl_loading_plan.id',
container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照', container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照',
container_no VARCHAR(64) NULL COMMENT '柜号快照', container_no VARCHAR(64) NULL COMMENT '柜号快照',
@@ -118,6 +122,10 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID', source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID',
source_version INT UNSIGNED NOT NULL COMMENT '费用来源版本', source_version INT UNSIGNED NOT NULL COMMENT '费用来源版本',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
allocation_group_id BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID',
allocation_inbound_id BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照',
allocation_inbound_code VARCHAR(64) NULL COMMENT '分摊入仓码快照',
allocation_group_amount DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID', loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照', container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照',
container_no VARCHAR(64) NULL COMMENT '柜号快照', container_no VARCHAR(64) NULL COMMENT '柜号快照',
@@ -186,6 +194,14 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_reference_n
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method"); "VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_voucher_file_ids', CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_group_id',
"BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空' AFTER tenant_id");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_inbound_id',
"BIGINT UNSIGNED NULL COMMENT '本分摊行对应入仓单ID' AFTER allocation_group_id");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_inbound_code',
"VARCHAR(64) NULL COMMENT '本分摊行对应入仓码' AFTER allocation_inbound_id");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'allocation_group_amount',
"DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照' AFTER allocation_inbound_code");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode', CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode',
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category"); "VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'paid_at', CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'paid_at',
@@ -196,6 +212,14 @@ CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_re
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method"); "VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_voucher_file_ids', CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_voucher_file_ids',
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no"); "JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_group_id',
"BIGINT UNSIGNED NULL COMMENT '柜级公共费用分摊组ID' AFTER tenant_id");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_inbound_id',
"BIGINT UNSIGNED NULL COMMENT '分摊入仓单ID快照' AFTER allocation_group_id");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_inbound_code',
"VARCHAR(64) NULL COMMENT '分摊入仓码快照' AFTER allocation_inbound_id");
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'allocation_group_amount',
"DECIMAL(18,2) NULL COMMENT '柜级原始费用总额快照' AFTER allocation_inbound_code");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id', CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id',
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction"); "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name', CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name',
@@ -0,0 +1,87 @@
package com.baiyue.lastleg.financefee.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ActionRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/finance-fee-sources")
public class PlatformOverseasFeeSourceController {
private final OverseasFeeSourceService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public ApiResponse<List<OverseasFeeSource>> list(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String businessType,
@RequestParam(required = false) String businessNo) {
requirePlatform(user); return ApiResponse.success(service.list(customerId, businessType, businessNo));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody SaveRequest request) {
requirePlatform(user); return ApiResponse.success(service.create(operator(user), key, request));
}
@PostMapping("/{sourceFeeId}/submit")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) {
requirePlatform(user); return ApiResponse.success(service.submit(sourceFeeId, operator(user), request.idempotencyKey()));
}
@PostMapping("/{sourceFeeId}/review")
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
public ApiResponse<OverseasFeeSource> review(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody ReviewRequest request) {
requirePlatform(user); return ApiResponse.success(service.review(sourceFeeId, operator(user), request));
}
@PostMapping("/{sourceFeeId}/adjust")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody AdjustRequest request) {
requirePlatform(user); return ApiResponse.success(service.adjust(sourceFeeId, operator(user), request));
}
@PostMapping("/{sourceFeeId}/void")
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
public ApiResponse<OverseasFeeSource> voidSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody VoidRequest request) {
requirePlatform(user); return ApiResponse.success(service.voidSource(sourceFeeId, operator(user), request));
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L)
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required");
}
private static String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.financefee.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class OverseasFeeSourceDtos {
private OverseasFeeSourceDtos() { }
public record SaveRequest(
@NotBlank @Size(max = 128) String customerId,
@Size(max = 200) String customerName,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE") String sourceType,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE") String businessType,
@Min(1) Long businessId,
@NotBlank @Size(max = 128) String businessNo,
@Min(1) Long orderId,
@Min(1) Long inboundId,
@Size(max = 64) String inboundCode,
@Min(1) Long containerId,
@Min(1) Long containerBookingId,
@Size(max = 64) String containerNo,
@Min(1) Long warehouseId,
@NotBlank @Size(max = 64) String feeItemCode,
@NotBlank @Size(max = 200) String feeItemName,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@Min(1) Long supplierId,
@Size(max = 200) String supplierName,
@Size(max = 32) String supplierCategory,
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
@NotNull LocalDate businessDate,
@NotBlank @Pattern(regexp = "CNY") String currency,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount
) { }
public record ActionRequest(@NotBlank @Size(max = 128) String idempotencyKey) { }
public record ReviewRequest(@NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision,
@Size(max = 500) String reason,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record AdjustRequest(@NotNull @Min(1) Integer expectedVersion,
@NotNull SaveRequest fee,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record VoidRequest(@NotNull @Min(1) Integer expectedVersion,
@NotBlank @Size(max = 500) String reason,
@NotBlank @Size(max = 128) String idempotencyKey) { }
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.financefee.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_finance_fee_command")
public class OverseasFeeCommand {
@TableId(type = IdType.INPUT) private Long id;
private String operatorId;
private String action;
private String idempotencyKey;
private String requestHash;
private Long sourceFeeId;
private Integer sourceVersion;
private LocalDateTime createdAt;
}
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.financefee.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Data
@TableName(value = "ll_finance_fee_source", autoResultMap = true)
public class OverseasFeeSource {
@TableId(type = IdType.INPUT) private Long id;
private Long sourceFeeId;
private Integer sourceVersion;
private String customerId;
private String customerName;
private String sourceType;
private String sourceNo;
private String businessType;
private Long businessId;
private String businessNo;
private Long orderId;
private Long inboundId;
private String inboundCode;
private Long containerId;
private Long containerBookingId;
private String containerNo;
private Long warehouseId;
private String feeItemCode;
private String feeItemName;
private String direction;
private Long supplierId;
private String supplierName;
private String supplierCategory;
private String settlementMode;
private LocalDateTime paidAt;
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class) private List<String> paymentVoucherFileIds;
private LocalDate businessDate;
private String currency;
private BigDecimal amount;
private String reviewStatus;
private String publicationStatus;
private String sourceStatus;
private String reviewReason;
private String reviewerId;
private LocalDateTime reviewedAt;
private String createdBy;
private String updatedBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.financefee.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface OverseasFeeCommandMapper extends BaseMapper<OverseasFeeCommand> {
@Select("SELECT * FROM ll_finance_fee_command WHERE operator_id=#{operatorId} AND action=#{action} AND idempotency_key=#{key} LIMIT 1")
OverseasFeeCommand find(@Param("operatorId") String operatorId, @Param("action") String action,
@Param("key") String key);
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.financefee.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface OverseasFeeSourceMapper extends BaseMapper<OverseasFeeSource> {
@Select("SELECT * FROM ll_finance_fee_source WHERE source_fee_id=#{sourceFeeId} ORDER BY source_version DESC LIMIT 1 FOR UPDATE")
OverseasFeeSource lockCurrent(@Param("sourceFeeId") long sourceFeeId);
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.financefee.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.common.event.EventTypes;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class OverseasFeeOutboxRepository {
private final LastlegEventOutboxMapper mapper;
public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime stale) {
return mapper.selectList(new LambdaQueryWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now))
.or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale)))
.orderByAsc(LastlegEventOutbox::getCreatedAt).last("LIMIT " + limit));
}
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime stale) {
return mapper.update(null, new LambdaUpdateWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING").or(processing ->
processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale)))
.set(LastlegEventOutbox::getStatus, "PROCESSING").set(LastlegEventOutbox::getLockedBy, worker)
.set(LastlegEventOutbox::getLockedAt, now)) == 1;
}
public LastlegEventOutbox claimed(long id, String worker) {
return mapper.selectOne(new LambdaQueryWrapper<LastlegEventOutbox>().eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker));
}
public void published(long id, String worker, LocalDateTime now) {
mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now)
.set(LastlegEventOutbox::getNextRetryAt, null).set(LastlegEventOutbox::getLastError, null));
}
public void retry(long id, String worker, int retryCount, LocalDateTime next, String error) {
mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount)
.set(LastlegEventOutbox::getNextRetryAt, next).set(LastlegEventOutbox::getLastError, truncate(error)));
}
private LambdaUpdateWrapper<LastlegEventOutbox> claimed(long id, String worker, String status) {
return new LambdaUpdateWrapper<LastlegEventOutbox>().eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker)
.set(LastlegEventOutbox::getStatus, status).set(LastlegEventOutbox::getLockedBy, null)
.set(LastlegEventOutbox::getLockedAt, null);
}
private static String truncate(String value) {
String safe = value == null ? "unknown" : value; return safe.length() <= 1000 ? safe : safe.substring(0, 1000);
}
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
@Component
@RequiredArgsConstructor
public class OverseasFeeBusinessGuard {
private final JdbcTemplate jdbc;
public void requireOwned(SaveRequest fee) {
if (fee.businessId() == null) {
if (!"WAREHOUSE_SERVICE".equals(fee.businessType()))
throw bad("业务记录ID不能为空");
if (fee.warehouseId() == null || (fee.inboundId() == null && blank(fee.inboundCode())))
throw bad("海外仓服务费必须关联仓库以及入仓记录或入仓码");
return;
}
Table table = switch (fee.businessType()) {
case "OVERSEAS_INBOUND" -> new Table("ll_inbound_plan", "plan_no");
case "OVERSEAS_PROCUREMENT" -> new Table("ll_overseas_procurement_order", "order_no");
case "US_TRUCK" -> new Table("ll_us_truck_order", "order_no");
case "LOCAL_LOOSEBOARD" -> new Table("ll_local_looseboard_order", "order_no");
default -> throw bad("业务类型与业务记录ID不匹配");
};
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM " + table.name
+ " WHERE id=? AND customer_id=? AND " + table.number + "=?",
Integer.class, fee.businessId(), fee.customerId().trim(), fee.businessNo().trim());
if (count == null || count != 1) throw bad("费用关联业务不存在或不属于该客户");
if ("OVERSEAS_INBOUND".equals(fee.businessType()) && !blank(fee.inboundCode())) {
Integer inbound = jdbc.queryForObject("SELECT COUNT(*) FROM ll_inbound_plan_line WHERE plan_id=? AND customer_id=? AND inbound_code=?",
Integer.class, fee.businessId(), fee.customerId().trim(), fee.inboundCode().trim());
if (inbound == null || inbound < 1) throw bad("入仓码不属于该客户和海外入仓计划");
}
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
private static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
private record Table(String name, String number) { }
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.lastleg.client.EventBridgeClient;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.lang.management.ManagementFactory;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
@Component
public class OverseasFeeOutboxRelay {
@Resource private OverseasFeeOutboxRepository repository;
@Resource private EventBridgeClient client;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String token;
private final String worker = ManagementFactory.getRuntimeMXBean().getName() + ':' + UUID.randomUUID();
@Scheduled(fixedDelayString = "${baiyue.finance-fee-relay.fixed-delay:3000}")
public void relay() {
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(30))) {
if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(30))) continue;
LastlegEventOutbox row = repository.claimed(candidate.getId(), worker);
if (row == null) continue;
try {
EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid();
var response = client.receive(token, envelope);
if (response == null || !"200".equals(response.code()) || response.data() == null
|| !envelope.eventId().equals(response.data().eventId()))
throw new IllegalStateException("Event Bridge未确认海外费用事件");
repository.published(row.getId(), worker, LocalDateTime.now(ZoneOffset.UTC));
} catch (Exception exception) {
int retries = row.getRetryCount() == null ? 1 : row.getRetryCount() + 1;
repository.retry(row.getId(), worker, retries, now.plusSeconds(Math.min(900, 5L << Math.min(retries, 7))), exception.getMessage());
}
}
}
}
@@ -0,0 +1,79 @@
package com.baiyue.lastleg.financefee.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.BusinessFeeSourceChangedV1;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
@Service
public class OverseasFeeOutboxService {
@Resource private LastlegEventOutboxMapper mapper;
@Resource private ObjectMapper objectMapper;
public void publish(OverseasFeeSource source, String action) {
BusinessFeeSourceChangedV1 unsigned = payload(source, action, null);
String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
BusinessFeeSourceChangedV1 payload = payload(source, action, sourceHash);
JsonNode payloadNode = objectMapper.valueToTree(payload);
String eventKey = EventTypes.BUSINESS_FEE_SOURCE_CHANGED + ":LASTLEG:"
+ source.getSourceType() + ':' + source.getSourceFeeId() + ':' + source.getSourceVersion() + ':' + action;
if (mapper.findByEventKey(eventKey) != null) return;
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN,
source.getCustomerId(), source.getSourceType(), String.valueOf(source.getSourceFeeId()),
source.getSourceVersion(), null, EventIdentifiers.newUuid(), eventKey, null, payloadNode,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
LastlegEventOutbox row = new LastlegEventOutbox();
row.setId(IdWorker.getId());
row.setCustomerId(source.getCustomerId());
row.setAggregateType(envelope.aggregateType());
row.setAggregateId(envelope.aggregateId());
row.setEventType(envelope.eventType());
row.setEventVersion(envelope.eventVersion());
row.setEventKey(eventKey);
row.setPayloadJson(json(envelope));
row.setStatus("PENDING");
row.setRetryCount(0);
row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
mapper.insert(row);
}
private BusinessFeeSourceChangedV1 payload(OverseasFeeSource value, String action, String hash) {
return new BusinessFeeSourceChangedV1(action, "LASTLEG", value.getSourceType(),
String.valueOf(value.getSourceFeeId()), value.getSourceVersion(), value.getCustomerId(),
value.getCustomerName(), value.getSourceNo(), value.getBusinessType(), text(value.getBusinessId()),
value.getBusinessNo(), text(value.getOrderId()), text(value.getInboundId()), value.getInboundCode(),
text(value.getContainerId()), text(value.getContainerBookingId()), value.getContainerNo(),
text(value.getWarehouseId()), value.getFeeItemCode(), value.getFeeItemName(), value.getDirection(),
text(value.getSupplierId()), value.getSupplierName(), value.getSupplierCategory(),
value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(),
value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds() == null ? List.of() : value.getPaymentVoucherFileIds(),
value.getBusinessDate(), value.getCurrency(), value.getAmount(), value.getReviewStatus(),
value.getPublicationStatus(), value.getSourceStatus(), hash);
}
private static String text(Long value) { return value == null ? null : String.valueOf(value); }
private static Instant instant(LocalDateTime value) { return value == null ? null : value.toInstant(ZoneOffset.UTC); }
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException("海外费用事件序列化失败", exception); }
}
}
@@ -0,0 +1,195 @@
package com.baiyue.lastleg.financefee.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class OverseasFeeSourceService {
private final OverseasFeeSourceMapper mapper;
private final OverseasFeeCommandMapper commands;
private final OverseasFeeBusinessGuard businessGuard;
private final OverseasFeeOutboxService outbox;
private final ObjectMapper objectMapper;
public List<OverseasFeeSource> list(String customerId, String businessType, String businessNo) {
return mapper.selectList(new LambdaQueryWrapper<OverseasFeeSource>()
.eq(text(customerId) != null, OverseasFeeSource::getCustomerId, text(customerId))
.eq(text(businessType) != null, OverseasFeeSource::getBusinessType, text(businessType))
.like(text(businessNo) != null, OverseasFeeSource::getBusinessNo, text(businessNo))
.inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id")
.orderByDesc(OverseasFeeSource::getCreatedAt).orderByDesc(OverseasFeeSource::getId));
}
@Transactional
public OverseasFeeSource create(String operatorId, String key, SaveRequest request) {
OverseasFeeCommand replay = replay(operatorId, "CREATE", key, request);
if (replay != null) return current(replay.getSourceFeeId());
validate(request);
businessGuard.requireOwned(request);
long sourceFeeId = IdWorker.getId();
OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request);
mapper.insert(value);
command(operatorId, "CREATE", key, request, sourceFeeId, 1);
return value;
}
@Transactional
public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) {
OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
if (!"DRAFT".equals(value.getReviewStatus()) && !"REJECTED".equals(value.getReviewStatus()))
throw conflict("只有草稿或已驳回费用可以提交");
value.setReviewStatus("PENDING"); value.setReviewReason(null);
value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value);
command(operatorId, "SUBMIT", key, sourceFeeId, sourceFeeId, value.getSourceVersion());
return value;
}
@Transactional
public OverseasFeeSource review(long sourceFeeId, String operatorId, ReviewRequest request) {
OverseasFeeCommand replay = replay(operatorId, "REVIEW", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()))
throw conflict("只有待审核的有效费用可以审核");
boolean approved = "APPROVE".equals(request.decision());
value.setReviewStatus(approved ? "APPROVED" : "REJECTED");
value.setPublicationStatus(approved ? "PUBLISHED" : "UNPUBLISHED");
value.setReviewReason(text(request.reason())); value.setReviewerId(operatorId); value.setReviewedAt(now());
value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value);
if (approved) outbox.publish(value, value.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED");
command(operatorId, "REVIEW", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion());
return value;
}
@Transactional
public OverseasFeeSource adjust(long sourceFeeId, String operatorId, AdjustRequest request) {
OverseasFeeCommand replay = replay(operatorId, "ADJUST", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource old = lock(sourceFeeId);
if (!"APPROVED".equals(old.getReviewStatus()) || !"ACTIVE".equals(old.getSourceStatus())
|| old.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以调整");
validate(request.fee()); businessGuard.requireOwned(request.fee());
if (!old.getCustomerId().equals(request.fee().customerId().trim()))
throw conflict("调整不得改变费用所属客户");
old.setSourceStatus("SUPERSEDED"); old.setUpdatedBy(operatorId); old.setUpdatedAt(now()); mapper.updateById(old);
OverseasFeeSource next = build(IdWorker.getId(), sourceFeeId, old.getSourceVersion() + 1, operatorId, request.fee());
next.setReviewStatus("PENDING");
mapper.insert(next);
command(operatorId, "ADJUST", request.idempotencyKey(), request, sourceFeeId, next.getSourceVersion());
return next;
}
@Transactional
public OverseasFeeSource voidSource(long sourceFeeId, String operatorId, VoidRequest request) {
OverseasFeeCommand replay = replay(operatorId, "VOID", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
if (!"APPROVED".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus())
|| value.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以作废");
value.setSourceStatus("VOIDED"); value.setReviewReason(request.reason().trim());
value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value);
outbox.publish(value, "VOIDED");
command(operatorId, "VOID", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion());
return value;
}
private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request) {
OverseasFeeSource value = new OverseasFeeSource();
value.setId(id); value.setSourceFeeId(sourceFeeId); value.setSourceVersion(version);
value.setCustomerId(request.customerId().trim()); value.setCustomerName(text(request.customerName()));
value.setSourceType(request.sourceType()); value.setSourceNo(text(request.inboundCode()) == null ? request.businessNo().trim() : request.inboundCode().trim());
value.setBusinessType(request.businessType()); value.setBusinessId(request.businessId()); value.setBusinessNo(request.businessNo().trim());
value.setOrderId(request.orderId()); value.setInboundId(request.inboundId()); value.setInboundCode(text(request.inboundCode()));
value.setContainerId(request.containerId()); value.setContainerBookingId(request.containerBookingId()); value.setContainerNo(text(request.containerNo()));
value.setWarehouseId(request.warehouseId()); value.setFeeItemCode(request.feeItemCode().trim()); value.setFeeItemName(request.feeItemName().trim());
value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName()));
value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode()));
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate()); value.setCurrency(request.currency());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP)); value.setReviewStatus("DRAFT");
value.setPublicationStatus("UNPUBLISHED"); value.setSourceStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId);
value.setCreatedAt(now()); value.setUpdatedAt(now());
return value;
}
private void validate(SaveRequest value) {
String expected = switch (value.businessType()) {
case "OVERSEAS_INBOUND" -> "OVERSEAS_INBOUND_FEE";
case "OVERSEAS_PROCUREMENT" -> "OVERSEAS_PROCUREMENT_FEE";
case "US_TRUCK" -> "OVERSEAS_TRUCK_FEE";
case "LOCAL_LOOSEBOARD" -> "OVERSEAS_LOOSEBOARD_FEE";
case "WAREHOUSE_SERVICE" -> "OVERSEAS_WAREHOUSE_FEE";
default -> "";
};
if (!expected.equals(value.sourceType())) throw bad("来源类型与业务类型不匹配");
boolean payable = "PAYABLE".equals(value.direction());
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
if (!payable) {
if (value.supplierId() != null || text(value.supplierName()) != null || text(value.settlementMode()) != null
|| value.paidAt() != null || text(value.paymentMethod()) != null || text(value.paymentReferenceNo()) != null || files)
throw bad("应收费用不得携带供应商或付款信息");
return;
}
if (value.supplierId() == null || text(value.supplierName()) == null || !Set.of("ON_ACCOUNT", "DIRECT_PAID").contains(value.settlementMode()))
throw bad("应付费用必须携带供应商和结算方式");
if ("ON_ACCOUNT".equals(value.settlementMode())) {
if (value.paidAt() != null || text(value.paymentMethod()) != null || text(value.paymentReferenceNo()) != null || files)
throw bad("账期应付不得携带已付信息");
} else if (value.paidAt() == null || !Set.of("ONLINE", "BANK_TRANSFER", "CASH", "OTHER").contains(value.paymentMethod())
|| (text(value.paymentReferenceNo()) == null && !files)) throw bad("直接已付信息不完整");
}
private OverseasFeeSource current(long sourceFeeId) {
OverseasFeeSource value = mapper.selectOne(new LambdaQueryWrapper<OverseasFeeSource>()
.eq(OverseasFeeSource::getSourceFeeId, sourceFeeId).orderByDesc(OverseasFeeSource::getSourceVersion).last("LIMIT 1"));
if (value == null) throw notFound(); return value;
}
private OverseasFeeSource lock(long sourceFeeId) { OverseasFeeSource value = mapper.lockCurrent(sourceFeeId); if (value == null) throw notFound(); return value; }
private OverseasFeeCommand replay(String operator, String action, String key, Object request) {
OverseasFeeCommand existing = commands.find(operator, action, key.trim());
if (existing != null && !existing.getRequestHash().equals(hash(request))) throw conflict("幂等键已被不同请求使用");
return existing;
}
private void command(String operator, String action, String key, Object request, long sourceFeeId, int version) {
OverseasFeeCommand value = new OverseasFeeCommand(); value.setId(IdWorker.getId()); value.setOperatorId(operator);
value.setAction(action); value.setIdempotencyKey(key.trim()); value.setRequestHash(hash(request)); value.setSourceFeeId(sourceFeeId);
value.setSourceVersion(version); value.setCreatedAt(now()); commands.insert(value);
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(value))); }
catch (NoSuchAlgorithmException | JsonProcessingException exception) { throw new IllegalStateException(exception); }
}
private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
private static String text(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "海外费用来源不存在"); }
}
@@ -576,6 +576,45 @@ CREATE TABLE IF NOT EXISTS ll_event_outbox (
KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at) KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at)
) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; ) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱';
CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
id BIGINT NOT NULL COMMENT '费用版本记录ID', source_fee_id BIGINT NOT NULL COMMENT '跨版本稳定费用来源ID',
source_version INT NOT NULL COMMENT '费用来源版本,从1递增', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键',
customer_name VARCHAR(200) NULL COMMENT '客户名称快照', source_type VARCHAR(64) NOT NULL COMMENT '海外费用来源类型',
source_no VARCHAR(128) NOT NULL COMMENT '来源业务编号或入仓码快照', business_type VARCHAR(32) NOT NULL COMMENT '来源业务类型',
business_id BIGINT NULL COMMENT '来源业务记录ID', business_no VARCHAR(128) NOT NULL COMMENT '来源业务编号',
order_id BIGINT NULL COMMENT '关联订单ID', inbound_id BIGINT NULL COMMENT '关联海外入仓计划或入仓记录ID',
inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID',
container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照',
warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付',
supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照',
supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付',
paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式',
payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照',
business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY',
amount DECIMAL(20,6) NOT NULL COMMENT '费用金额', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED',
publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED',
review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID',
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version),
KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at),
CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'),
CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')),
CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')),
CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')),
CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED'))
) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance';
CREATE TABLE IF NOT EXISTS ll_finance_fee_command (
id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID',
action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID',
source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key),
KEY idx_ll_finance_fee_command_source(source_fee_id,source_version)
) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计';
CREATE TABLE IF NOT EXISTS ll_overseas_quote ( CREATE TABLE IF NOT EXISTS ll_overseas_quote (
id BIGINT NOT NULL COMMENT '报价ID', name VARCHAR(128) NOT NULL COMMENT '报价名称', id BIGINT NOT NULL COMMENT '报价ID', name VARCHAR(128) NOT NULL COMMENT '报价名称',
warehouse_id BIGINT NOT NULL COMMENT '国内平台海外合作仓ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '合作仓编码快照', warehouse_id BIGINT NOT NULL COMMENT '国内平台海外合作仓ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '合作仓编码快照',