优化
This commit is contained in:
+52
@@ -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";
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package com.baiyue.eventbridge.service;
|
||||||
|
|
||||||
|
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||||
|
|
||||||
|
public interface FinanceEventClient {
|
||||||
|
String deliver(BridgeInbox event) throws Exception;
|
||||||
|
}
|
||||||
+56
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+38
@@ -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); }
|
||||||
|
}
|
||||||
+12
-12
@@ -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", "费用来源已进入有效单据,禁止调整或替代");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
+44
@@ -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;
|
||||||
|
|||||||
+4
@@ -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;
|
||||||
|
|||||||
+17
@@ -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>()
|
||||||
|
|||||||
+4
@@ -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) { }
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -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',
|
||||||
|
|||||||
+87
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -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) { }
|
||||||
|
}
|
||||||
+21
@@ -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;
|
||||||
|
}
|
||||||
+59
@@ -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;
|
||||||
|
}
|
||||||
+14
@@ -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);
|
||||||
|
}
|
||||||
+13
@@ -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);
|
||||||
|
}
|
||||||
+56
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -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) { }
|
||||||
|
}
|
||||||
+46
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
+195
@@ -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 '合作仓编码快照',
|
||||||
|
|||||||
Reference in New Issue
Block a user