diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java new file mode 100644 index 000000000..8ae7a67e5 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java @@ -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; + +/** + * 跨区域业务费用来源的完整不可变快照。 + * + *

海外业务与国内 Finance 不做同步回查,因此事件必须携带可独立校验、入池的完整快照。 + * 标识统一使用字符串,避免跨语言大整数精度损失。

+ */ +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 paymentVoucherFileIds, + LocalDate businessDate, + String currency, + BigDecimal totalAmount, + String reviewStatus, + String publicationStatus, + String sourceStatus, + String sourceHash +) { +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java index 029056ada..0357657b8 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java @@ -13,6 +13,7 @@ public final class EventTypes { public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED = "FirstLegContainerShippingNodeRecorded"; 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_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged"; diff --git a/config/nacos/cn/event-bridge-service.yml b/config/nacos/cn/event-bridge-service.yml index 5b81ac297..d14d59c6d 100644 --- a/config/nacos/cn/event-bridge-service.yml +++ b/config/nacos/cn/event-bridge-service.yml @@ -11,3 +11,4 @@ baiyue: event-dispatcher: 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} + finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://finance:9208/internal/finance/v1/business-fee-source-events} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FinanceEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FinanceEventClient.java new file mode 100644 index 000000000..a8d4e7107 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FinanceEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface FinanceEventClient { + String deliver(BridgeInbox event) throws Exception; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java new file mode 100644 index 000000000..fcdd42872 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java @@ -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 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()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java index b20be547f..e8b23334e 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -27,6 +27,7 @@ public class OverseasEventDispatcher { @Resource private BridgeDeliveryAttemptMapper attemptMapper; @Resource private LastlegEventClient eventClient; @Resource private FirstlegEventClient firstlegEventClient; + @Resource private FinanceEventClient financeEventClient; @Resource private SecurityProjectionEventClient securityProjectionEventClient; @Resource private EventDispatcherProperties properties; @Resource private EventBridgeMetrics metrics; @@ -89,7 +90,8 @@ public class OverseasEventDispatcher { throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者"); } 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) ? securityProjectionEventClient.deliver(event) : eventClient.deliver(event); default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion()); diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index b20743a41..82980c9c3 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -33,6 +33,7 @@ baiyue: event-dispatcher: 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} + 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} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} diff --git a/finance/pom.xml b/finance/pom.xml index 08d3df433..8da2c4701 100644 --- a/finance/pom.xml +++ b/finance/pom.xml @@ -11,6 +11,7 @@ finance-service + com.baiyue.sharedcommon-event-contract${project.version} com.baiyue.sharedbaiyue-common-web${project.version} com.baiyue.sharedbaiyue-common-openapi${project.version} com.baiyue.sharedbaiyue-common-security${project.version} diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java new file mode 100644 index 000000000..88f9dc71e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java @@ -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> 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))); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java new file mode 100644 index 000000000..ea2e32880 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -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 ACTIONS = Set.of("PUBLISHED", "ADJUSTED", "VOIDED"); + private static final Set 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> 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 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> 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 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); } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java index 989f86b1d..6687e7d3f 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceFeeSourceEventService.java @@ -63,8 +63,8 @@ public class FinanceFeeSourceEventService { private long applyEvent(FinanceFeeSourceEvent event, FirstlegFeeSourceClient.Snapshot snapshot) { List> 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", - snapshot.feeId(), snapshot.feeVersion()); + "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.sourceType(), snapshot.feeId(), snapshot.feeVersion()); if (!sameVersion.isEmpty()) { Map row = sameVersion.get(0); if (!snapshot.sourceHash().equals(row.get("source_hash"))) { @@ -81,29 +81,29 @@ public class FinanceFeeSourceEventService { if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) { throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在"); } - requireSourceNotFrozen(snapshot.feeId()); - jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_fee_id=? AND status='AVAILABLE'", - 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'", - snapshot.feeId()); + requireSourceNotFrozen(snapshot.sourceType(), snapshot.feeId()); + jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND status='AVAILABLE'", + snapshot.sourceType(), snapshot.feeId()); + jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源版本已替代',version=version+1,updated_by=0 WHERE cost_origin='FIRSTLEG_DIRECT_PAID' AND source_system='FIRSTLEG' AND source_type=? AND source_fee_id=? AND status='CONFIRMED'", + snapshot.sourceType(), snapshot.feeId()); jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')", 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.feeItemCode(), snapshot.feeItemName(), snapshot.totalAmount(), snapshot.currency(), snapshot.settlementMode(), snapshot.paidAt(), snapshot.paymentMethod(), snapshot.paymentReferenceNo(), voucherJson(snapshot.paymentVoucherFileIds()), 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.class, snapshot.feeId(), snapshot.feeVersion()); + 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.sourceType(), snapshot.feeId(), snapshot.feeVersion()); if ("PAYABLE".equals(snapshot.direction()) && "DIRECT_PAID".equals(snapshot.settlementMode())) { materializeDirectPaidCost(snapshot); } return sourceId; } - private void requireSourceNotFrozen(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); + 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_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", "费用来源已进入有效单据,禁止调整或替代"); } diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index cf2fd567c..bb4a41591 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -4,16 +4,23 @@ USE baiyue_finance; CREATE TABLE IF NOT EXISTS fin_fee_source ( 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_version INT UNSIGNED NOT NULL COMMENT '来源费用版本号,用于固化不可变来源版本', customer_id BIGINT UNSIGNED NOT NULL COMMENT '费用来源所属客户ID', source_type VARCHAR(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用', 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应付', order_id BIGINT UNSIGNED NULL COMMENT '来源头程订单ID,可为空', inbound_id BIGINT UNSIGNED NULL COMMENT '来源入库单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_name VARCHAR(200) NOT NULL COMMENT '来源费用项目名称快照', 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 '来源快照入池时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新', 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_fund (customer_id, direction, settlement_mode, review_status, status, currency_code, id), 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_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 ( 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 '事件处理完成时间,未完成时为空', PRIMARY KEY (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 ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java index b0a0769bc..7fb433976 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java @@ -4,6 +4,8 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; 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.SaveRequest; import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.VersionRequest; @@ -43,6 +45,48 @@ public class PlatformContainerFeeDetailController { 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> 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> 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> 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> 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 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") @PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')") @BusinessAudit(event = "CONTAINER_FEE_DETAIL_APPROVE", resourceType = "CONTAINER_FEE_DETAIL", resourceId = "#feeId") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java index cb2768b79..1906e10d8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java @@ -1,5 +1,6 @@ package com.baiyue.firstleg.dto; +import jakarta.validation.Valid; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Min; 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) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java index cf52d5f17..2ee0e9e3e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeDetailEntity.java @@ -21,6 +21,10 @@ public class ContainerFeeDetailEntity { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long tenantId; + private Long allocationGroupId; + private Long allocationInboundId; + private String allocationInboundCode; + private BigDecimal allocationGroupAmount; private Long loadingPlanId; private Long containerBookingId; private String containerNo; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java index 779387c2a..f249fd369 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerFeeSourceSnapshotEntity.java @@ -23,6 +23,10 @@ public class ContainerFeeSourceSnapshotEntity { private Long sourceFeeId; private Integer sourceVersion; private Long tenantId; + private Long allocationGroupId; + private Long allocationInboundId; + private String allocationInboundCode; + private BigDecimal allocationGroupAmount; private Long loadingPlanId; private Long containerBookingId; private String containerNo; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java index d490ba2b7..eeec19d02 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java @@ -25,6 +25,14 @@ public class ContainerFeeDetailRepository { .orderByDesc(ContainerFeeDetailEntity::getId)); } + public List listGroup(long loadingPlanId, long allocationGroupId) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId) + .eq(ContainerFeeDetailEntity::getAllocationGroupId, allocationGroupId) + .orderByAsc(ContainerFeeDetailEntity::getTenantId) + .orderByAsc(ContainerFeeDetailEntity::getAllocationInboundCode)); + } + public ContainerFeeDetailEntity find(long tenantId, long loadingPlanId, long feeId) { return mapper.selectOne(new LambdaQueryWrapper() .eq(ContainerFeeDetailEntity::getTenantId, tenantId) @@ -96,6 +104,15 @@ public class ContainerFeeDetailRepository { .set(ContainerFeeDetailEntity::getUpdatedAt, updatedAt)); } + public int updateAllocationGroupAmount(long loadingPlanId, long feeId, int sourceVersion, + BigDecimal groupAmount) { + return mapper.update(null, new LambdaUpdateWrapper() + .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, int expectedVersion, long operatorId) { return mapper.update(null, new LambdaUpdateWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java index a013890cc..051dccc75 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeSourceSnapshotRepository.java @@ -19,6 +19,10 @@ public class ContainerFeeSourceSnapshotRepository { value.setSourceFeeId(source.getId()); value.setSourceVersion(source.getSourceVersion()); 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.setContainerBookingId(source.getContainerBookingId()); value.setContainerNo(source.getContainerNo()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java index ce42157e9..2e597df2e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java @@ -3,6 +3,10 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; 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.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerFeeDetailEntity; @@ -17,8 +21,14 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.RoundingMode; +import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; @Service @RequiredArgsConstructor @@ -40,9 +50,17 @@ public class ContainerFeeDetailService { public ContainerFeeDetailEntity create(long tenantId, long loadingPlanId, long operatorId, SaveRequest request) { PlanContext plan = requireOwnedPlan(tenantId, loadingPlanId); + List 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()); ContainerFeeDetailEntity value = new ContainerFeeDetailEntity(); 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.setContainerBookingId(plan.plan().getContainerBookingId()); value.setContainerNo(plan.booking() == null ? null : plan.booking().getContainerNo()); @@ -56,10 +74,112 @@ public class ContainerFeeDetailService { return value; } + @Transactional + public List 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 ownership = new HashMap<>(); + loading.items(loadingPlanId).forEach(value -> ownership.put( + new InboundAllocationKey(value.getInboundId(), value.getInboundCode()), value.getAccountId())); + Set unique = new HashSet<>(); + long groupId = IdWorker.getId(); + List 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 approveAllocationGroup(long loadingPlanId, long groupId, + long reviewerId, String reviewerName) { + requirePlan(loadingPlanId); + List rows = requiredGroup(loadingPlanId, groupId); + for (ContainerFeeDetailEntity row : rows) approveRow(row, reviewerId, reviewerName); + return requiredGroup(loadingPlanId, groupId); + } + + @Transactional + public List rejectAllocationGroup(long loadingPlanId, long groupId, + long reviewerId, String reviewerName, String reason) { + requirePlan(loadingPlanId); + List 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 adjustAllocationGroup(long loadingPlanId, long groupId, + long operatorId, AllocationGroupAdjustRequest request) { + requirePlan(loadingPlanId); + List rows = requiredGroup(loadingPlanId, groupId); + if (rows.stream().anyMatch(row -> row.getSourceVersion() != request.expectedSourceVersion() + || !"APPROVED".equals(row.getReviewStatus()) || !"ACTIVE".equals(row.getSourceStatus()))) + throw conflict("只有完整的当前已审核分摊组可以调整"); + Map 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 rows = requiredGroup(loadingPlanId, groupId); + for (ContainerFeeDetailEntity row : rows) voidRow(row, expectedVersion, operatorId); + } + @Transactional public void approve(long tenantId, long loadingPlanId, long feeId, long reviewerId, String reviewerName) { 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()); revalidatePaymentSnapshot(pending); refreshSupplierSnapshot(pending, item, reviewerId); @@ -79,6 +199,8 @@ public class ContainerFeeDetailService { public void reject(long tenantId, long loadingPlanId, long feeId, long reviewerId, String reviewerName, String reason) { required(tenantId, loadingPlanId, feeId); + if (required(tenantId, loadingPlanId, feeId).getAllocationGroupId() != null) + throw conflict("分摊费用必须整组驳回"); if (repository.review(tenantId, loadingPlanId, feeId, "REJECTED", reviewerId, reviewerName, reason.trim()) != 1) { throw conflict("只有待审核柜次费用可以驳回"); @@ -90,6 +212,7 @@ public class ContainerFeeDetailService { long operatorId, AdjustRequest request) { requireOwnedPlan(tenantId, loadingPlanId); ContainerFeeDetailEntity value = required(tenantId, loadingPlanId, feeId); + if (value.getAllocationGroupId() != null) throw conflict("分摊费用必须整组调整"); int expectedVersion = request.expectedSourceVersion(); var amount = request.amount().setScale(2, RoundingMode.HALF_UP); if (value.getSourceVersion() != null && value.getSourceVersion() == expectedVersion + 1 @@ -113,6 +236,14 @@ public class ContainerFeeDetailService { public void voidApproved(long tenantId, long loadingPlanId, long feeId, int expectedVersion, long operatorId) { 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) { throw conflict("只有当前版本的已审核柜次费用可以作废"); } @@ -121,13 +252,24 @@ public class ContainerFeeDetailService { } private PlanContext requireOwnedPlan(long tenantId, long loadingPlanId) { - LoadingPlanEntity plan = loading.plan(loadingPlanId); - if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在"); + PlanContext context = requirePlan(loadingPlanId); + LoadingPlanEntity plan = context.plan(); boolean owned = loading.items(loadingPlanId).stream() .anyMatch(item -> Long.valueOf(tenantId).equals(item.getAccountId())); if (!owned) throw new BizException(ResultCode.NOT_FOUND, "柜次不属于该客户"); - return new PlanContext(plan, plan.getContainerBookingId() == null - ? null : loading.booking(plan.getContainerBookingId())); + return context; + } + + 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 requiredGroup(long loadingPlanId, long groupId) { + List rows = repository.listGroup(loadingPlanId, groupId); + if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "柜级费用分摊组不存在"); + return rows; } private FeeItemEntity requireItem(long feeItemId, String direction) { @@ -217,4 +359,5 @@ public class ContainerFeeDetailService { private record PlanContext(LoadingPlanEntity plan, ContainerBookingEntity booking) { } + private record InboundAllocationKey(Long inboundId, String inboundCode) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java index fd789a4a6..c08c08cea 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FinanceFeeSourceSnapshotService.java @@ -70,10 +70,12 @@ public class FinanceFeeSourceSnapshotService { nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()), nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()), nullSafe(detail.getPaymentVoucherFileIds()), + nullSafe(detail.getAllocationGroupId()), nullSafe(detail.getAllocationInboundId()), + nullSafe(detail.getAllocationInboundCode()), nullSafe(detail.getAllocationGroupAmount()), detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()), nullSafe(detail.getReviewStatus())); 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.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(), detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(), diff --git a/firstleg/src/main/resources/db/finance-fee-source-migration.sql b/firstleg/src/main/resources/db/finance-fee-source-migration.sql index 15ae1686c..9340eee23 100644 --- a/firstleg/src/main/resources/db/finance-fee-source-migration.sql +++ b/firstleg/src/main/resources/db/finance-fee-source-migration.sql @@ -64,6 +64,10 @@ DROP PROCEDURE IF EXISTS firstleg_finance_add_index; CREATE TABLE IF NOT EXISTS fl_container_fee_detail ( 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', container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照', 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_version INT UNSIGNED NOT NULL COMMENT '费用来源版本', 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', container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照', 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"); CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_voucher_file_ids', "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', "VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category"); 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"); 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"); +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', "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction"); CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java new file mode 100644 index 000000000..5ee2b8226 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java @@ -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(@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 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 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 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 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 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()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java new file mode 100644 index 000000000..dc16b5f69 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeCommand.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeCommand.java new file mode 100644 index 000000000..a192dcebc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeCommand.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java new file mode 100644 index 000000000..59507799f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java @@ -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 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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeCommandMapper.java new file mode 100644 index 000000000..d7b65c29b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeCommandMapper.java @@ -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 { + @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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java new file mode 100644 index 000000000..db82e9b59 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java @@ -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 { + @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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java new file mode 100644 index 000000000..c1d28ee5a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java @@ -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 pending(int limit, LocalDateTime now, LocalDateTime stale) { + return mapper.selectList(new LambdaQueryWrapper() + .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() + .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().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 claimed(long id, String worker, String status) { + return new LambdaUpdateWrapper().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); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java new file mode 100644 index 000000000..8ec07e596 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java new file mode 100644 index 000000000..5ec65a694 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java @@ -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()); + } + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java new file mode 100644 index 000000000..feef547df --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java @@ -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); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java new file mode 100644 index 000000000..0e2704fc0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java @@ -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 list(String customerId, String businessType, String businessNo) { + return mapper.selectList(new LambdaQueryWrapper() + .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() + .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, "海外费用来源不存在"); } +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 2d1bf4d6b..e11fcd5b3 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -576,6 +576,45 @@ CREATE TABLE IF NOT EXISTS ll_event_outbox ( KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at) ) 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 ( 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 '合作仓编码快照',