diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index b3f1e97f1..662b2cf74 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -20,6 +20,7 @@ | 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | | 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 | +| 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | 契约说明 | `package-info.java` | | 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` | 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 c992eea78..a8886358c 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 @@ -15,6 +15,8 @@ public final class EventTypes { public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged"; public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"; + public static final String WALLET_COMMAND_REQUESTED = "WalletCommandRequested"; + public static final String WALLET_COMMAND_COMPLETED = "WalletCommandCompleted"; 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/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java new file mode 100644 index 000000000..f786e58c1 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandCompletedV1.java @@ -0,0 +1,21 @@ +package com.baiyue.common.event; + +import java.math.BigDecimal; +import java.time.Instant; + +/** 国内 Finance 对海外资金指令的最终、可幂等投影结果。 */ +public record WalletCommandCompletedV1( + String commandEventId, + String operation, + long customerId, + String businessType, + String businessNo, + String result, + String holdStatus, + BigDecimal amount, + String errorCode, + String errorMessage, + Instant completedAt +) { + public static final int VERSION = 1; +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java new file mode 100644 index 000000000..8c3514ec4 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/WalletCommandRequestedV1.java @@ -0,0 +1,18 @@ +package com.baiyue.common.event; + +import java.math.BigDecimal; +import java.time.Instant; + +/** 海外业务向国内 Finance 发起的客户资金冻结、结算或释放指令。 */ +public record WalletCommandRequestedV1( + String operation, + long customerId, + String businessType, + String businessNo, + BigDecimal amount, + String remark, + String idempotencyKey, + Instant requestedAt +) { + public static final int VERSION = 1; +} diff --git a/config/nacos/cn/event-bridge-service.yml b/config/nacos/cn/event-bridge-service.yml index 11f68149e..27a06967c 100644 --- a/config/nacos/cn/event-bridge-service.yml +++ b/config/nacos/cn/event-bridge-service.yml @@ -12,3 +12,4 @@ baiyue: 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:9203/internal/finance/v1/business-fee-source-events} + finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/wallet-command-events} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f6523c568..59079dc6a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -117,6 +117,7 @@ services: FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME} FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD} FINANCE_WALLET_DEFAULT_CREDIT_LIMIT: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:-0.00} + OVERSEAS_EVENT_BRIDGE_URL: ${OVERSEAS_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312} order: <<: *common @@ -238,6 +239,7 @@ services: EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} FINANCE_FEE_EVENT_CONSUMER_URL: ${FINANCE_FEE_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/business-fee-source-events} + FINANCE_WALLET_EVENT_CONSUMER_URL: ${FINANCE_WALLET_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/wallet-command-events} SPRING_SQL_INIT_MODE: never networks: firstleg-network: diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 0f969178e..e792b7563 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -17874,3 +17874,37 @@ CALL migrate_wallet_permissions(); DROP PROCEDURE IF EXISTS migrate_wallet_permissions; SELECT '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、客户资金账户、公共资源、可靠审计及IAM安全事件结构校验通过' AS message; + +USE baiyue_finance; + + +CREATE TABLE IF NOT EXISTS fin_wallet_command_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '海外资金指令事件ID', + event_type VARCHAR(96) NOT NULL COMMENT '事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256', + payload_json JSON NOT NULL COMMENT '冻结事件信封', + status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、SUCCEEDED或FAILED', + result_json JSON NULL COMMENT '最终资金执行结果', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + KEY idx_fin_wallet_command_status (status,received_at) +) ENGINE=InnoDB COMMENT='跨区客户资金指令幂等收件箱'; + +CREATE TABLE IF NOT EXISTS fin_wallet_event_outbox ( + event_id VARCHAR(64) NOT NULL COMMENT '资金结果事件ID', + event_key VARCHAR(160) NOT NULL COMMENT '业务幂等键', + payload_json JSON NOT NULL COMMENT '完整事件信封', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、PUBLISHED或DEAD', + retry_count INT NOT NULL DEFAULT 0 COMMENT '失败次数', + next_retry_at DATETIME(3) NOT NULL COMMENT '下次投递时间', + locked_by VARCHAR(128) NULL COMMENT '租约持有者', + locked_at DATETIME(3) NULL COMMENT '租约时间', + published_at DATETIME(3) NULL COMMENT '投递完成时间', + last_error VARCHAR(1000) NULL COMMENT '最后失败摘要', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_fin_wallet_event_key (event_key), + KEY idx_fin_wallet_event_publish (status,next_retry_at,created_at) +) ENGINE=InnoDB COMMENT='国内Finance到海外资金结果可靠发送箱'; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index c43aa3305..bb79d6db3 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1676,3 +1676,74 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_command ( KEY idx_ll_finance_fee_command_source(source_fee_id,source_version) ) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计'; SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message; + + +CREATE TABLE IF NOT EXISTS ll_wallet_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '国内Finance资金结果事件ID', + command_event_id VARCHAR(64) NOT NULL COMMENT '原海外资金指令事件ID', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256', + payload_json JSON NOT NULL COMMENT '冻结事件信封', + status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_ll_wallet_command_result (command_event_id), + KEY idx_ll_wallet_event_status (status,received_at) +) ENGINE=InnoDB COMMENT='跨区客户资金结果幂等收件箱'; + + + +CREATE TABLE IF NOT EXISTS ll_procurement_daily_sequence ( + customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', + business_date DATE NOT NULL COMMENT '北京时间业务日期', + sequence_value INT NOT NULL COMMENT '客户当日已分配最大序号', + PRIMARY KEY (customer_id,business_date), + CONSTRAINT chk_ll_procurement_sequence CHECK (sequence_value BETWEEN 1 AND 9999) +) ENGINE=InnoDB COMMENT='海外采购客户日序列'; + + + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_file ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', + category VARCHAR(16) NOT NULL COMMENT 'RECEIPT签收文件或VOUCHER费用凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '媒体类型', size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小', + created_by VARCHAR(128) NULL COMMENT '上传人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_file(order_id,file_id), KEY idx_ll_procurement_file_order(customer_id,order_id,category,created_at), + CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER')) +) ENGINE=InnoDB COMMENT='海外采购签收文件及费用凭证'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', + fee_no VARCHAR(64) NOT NULL COMMENT '费用业务号', fee_item_code VARCHAR(32) NOT NULL COMMENT '费用项编码', fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项名称', + amount DECIMAL(20,2) NOT NULL COMMENT 'CNY金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', voucher_file_id BIGINT NOT NULL COMMENT '凭证文件ID', + voucher_file_name VARCHAR(255) NOT NULL COMMENT '凭证文件名', remark VARCHAR(500) NULL COMMENT '说明', + wallet_status VARCHAR(32) NOT NULL COMMENT '资金状态', wallet_error_code VARCHAR(64) NULL COMMENT '资金错误码', wallet_error_message VARCHAR(500) NULL COMMENT '资金错误说明', + wallet_command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件ID', created_by VARCHAR(128) NULL COMMENT '登记人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_fee_no(fee_no), KEY idx_ll_procurement_fee_order(customer_id,order_id,created_at), + CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY') +) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期'; + + + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='charge_amount'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN charge_amount DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT ''换标费用CNY'' AFTER notified_package_count')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='charge_currency'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN charge_currency CHAR(3) NOT NULL DEFAULT ''CNY'' COMMENT ''费用币种'' AFTER charge_amount')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_status VARCHAR(32) NOT NULL DEFAULT ''FREEZE_PENDING'' COMMENT ''资金状态'' AFTER charge_currency')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_error_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_error_code VARCHAR(64) NULL COMMENT ''资金错误码'' AFTER wallet_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_error_message'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_error_message VARCHAR(500) NULL COMMENT ''资金错误说明'' AFTER wallet_error_code')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_command_event_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_command_event_id VARCHAR(64) NULL COMMENT ''当前资金指令事件ID'' AFTER wallet_error_message')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + + + +CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost ( + id BIGINT NOT NULL COMMENT '主键ID', source_container_booking_id VARCHAR(128) NOT NULL COMMENT '来源柜次业务ID', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', warehouse_id BIGINT NULL COMMENT '海外仓ID', + pallet_count INT NOT NULL COMMENT '实际打托数', total_actual_volume_cbm DECIMAL(20,6) NOT NULL COMMENT '客户实收总体积', total_cost DECIMAL(20,2) NOT NULL COMMENT '打托总成本', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '成本币种', + status VARCHAR(16) NOT NULL COMMENT '生成状态', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_destuff_cost_booking(source_container_booking_id), CONSTRAINT chk_ll_destuff_cost CHECK(pallet_count>0 AND total_actual_volume_cbm>0 AND total_cost>0 AND currency='CNY') +) ENGINE=InnoDB COMMENT='拆柜打托实际成本汇总'; +CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation ( + id BIGINT NOT NULL COMMENT '主键ID', cost_id BIGINT NOT NULL COMMENT '打托成本汇总ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', actual_volume_cbm DECIMAL(20,6) NOT NULL COMMENT '客户实收体积', + allocated_amount DECIMAL(20,2) NOT NULL COMMENT '客户分摊金额', fee_source_id BIGINT NOT NULL COMMENT '海外费用来源ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id), + CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0) +) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊'; diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 591d0dfb4..890bd77e2 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -13,10 +13,10 @@ |---|---| | 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | | Inbox 接收与幂等 | `service/EventInboxService.java` | -| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 的 `BUSINESS_FEE_SOURCE_CHANGED` 定向投 Finance,其余国内回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload | +| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 的 `BUSINESS_FEE_SOURCE_CHANGED` 与 `WALLET_COMMAND_REQUESTED` 定向投 Finance,其余国内回投 Firstleg;海外业务事件及 `WALLET_COMMAND_COMPLETED` 投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload | | 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | | 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | -| 国内 Finance 费用事件客户端 | `service/FinanceEventClient.java`、`HttpFinanceEventClient.java`;统一内部入口 `/internal/finance/v1/business-fee-source-events`,配置 `baiyue.event-dispatcher.finance-consumer-url`,不得误投 Firstleg | +| 国内 Finance 事件客户端 | `service/FinanceEventClient.java`、`HttpFinanceEventClient.java`;费用入口 `/internal/finance/v1/business-fee-source-events`,资金指令入口 `/internal/finance/v1/wallet-command-events`,分别配置 `finance-consumer-url`、`finance-wallet-consumer-url`,不得误投 Firstleg | | 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | | Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | | 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` | 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 index 41a5902f9..e86b022f9 100644 --- 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 @@ -1,6 +1,7 @@ package com.baiyue.eventbridge.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeInbox; @@ -24,6 +25,8 @@ public class HttpFinanceEventClient implements FinanceEventClient { @Resource private EventBridgeHttpTracePropagation tracePropagation; @Value("${baiyue.event-dispatcher.finance-consumer-url:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events}") private URI consumerUrl; + @Value("${baiyue.event-dispatcher.finance-wallet-consumer-url:http://127.0.0.1:9203/internal/finance/v1/wallet-command-events}") + private URI walletConsumerUrl; @Value("${baiyue.security.internal-service-token:}") private String serviceToken; private HttpClient httpClient; @@ -39,7 +42,9 @@ public class HttpFinanceEventClient implements FinanceEventClient { 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) + URI target = EventTypes.WALLET_COMMAND_REQUESTED.equals(row.getEventType()) + ? walletConsumerUrl : consumerUrl; + HttpRequest request = tracePropagation.apply(HttpRequest.newBuilder(target) .timeout(properties.requestTimeout()).header("Content-Type", "application/json") .header("X-Internal-Service-Token", serviceToken)) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); 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 e8b23334e..17ff106b7 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 @@ -90,7 +90,7 @@ public class OverseasEventDispatcher { throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者"); } return switch (event.getTargetRegion()) { - case "CN" -> EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(eventType) + case "CN" -> FINANCE_EVENT_TYPES.contains(eventType) ? financeEventClient.deliver(event) : firstlegEventClient.deliver(event); case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType) ? securityProjectionEventClient.deliver(event) : eventClient.deliver(event); @@ -103,6 +103,10 @@ public class OverseasEventDispatcher { EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED); + private static final Set FINANCE_EVENT_TYPES = Set.of( + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, + EventTypes.WALLET_COMMAND_REQUESTED); + private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); attempt.setEventId(event.getEventId()); diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 697269244..69685d3e3 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -12,6 +12,8 @@ enum BusinessFileCategory { CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CUSTOMS_RECEIPT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FINANCE_VOUCHER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_PROCUREMENT_RECEIPT(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_PROCUREMENT_VOUCHER(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_CLEARANCE_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), LOADING_CUSTOMS_DECLARATION(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), diff --git a/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java b/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java new file mode 100644 index 000000000..1aab9c099 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java @@ -0,0 +1,18 @@ +package com.baiyue.finance.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(name = "event-bridge-service", contextId = "financeWalletResultEventBridgeClient", + url = "${OVERSEAS_EVENT_BRIDGE_URL:${baiyue.wallet-event.event-bridge-url:}}") +public interface WalletResultEventBridgeClient { + @PostMapping("/internal/event-bridge/events") + ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + + record Acknowledgement(String eventId, String result, String receivedAt) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java new file mode 100644 index 000000000..51a94c358 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java @@ -0,0 +1,37 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.WalletCommandEventService; +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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/internal/finance/v1/wallet-command-events") +public class InternalWalletCommandEventController { + private final WalletCommandEventService 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("eventId", service.consume(event))); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java new file mode 100644 index 000000000..87725a744 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java @@ -0,0 +1,142 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventEnvelopeValidator; +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.common.event.WalletCommandCompletedV1; +import com.baiyue.common.event.WalletCommandRequestedV1; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class WalletCommandEventService { + private static final Set OPERATIONS = Set.of("FREEZE", "SETTLE", "RELEASE"); + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final FinanceWalletService walletService; + private final TransactionTemplate transactions; + + public String consume(EventEnvelope envelope) { + WalletCommandRequestedV1 command = validate(envelope); + String existing = existingHash(envelope.eventId()); + if (existing != null && !existing.equals(envelope.payloadHash())) { + throw new FinanceEventException("FIN-EVT-002", "同一事件ID对应不同资金指令"); + } + if (existing == null) reserve(envelope); + if (resultExists(envelope.eventId())) return envelope.eventId(); + + WalletCommandCompletedV1 result; + try { + WalletHold hold = transactions.execute(status -> execute(command)); + result = completed(envelope.eventId(), command, "SUCCEEDED", hold.status(), hold.amount(), null, null); + } catch (FinanceEventException exception) { + result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), exception.code(), exception.getMessage()); + } catch (RuntimeException exception) { + result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), "FIN-WALLET-COMMAND-FAILED", safe(exception.getMessage())); + } + WalletCommandCompletedV1 finalResult = result; + transactions.executeWithoutResult(status -> finalizeResult(envelope, finalResult)); + return envelope.eventId(); + } + + private WalletHold execute(WalletCommandRequestedV1 command) { + return switch (command.operation()) { + case "FREEZE" -> walletService.freeze(command.customerId(), command.idempotencyKey(), + new WalletFreezeRequest(command.amount(), command.businessType(), command.businessNo(), command.remark())); + case "SETTLE" -> walletService.settle(command.customerId(), command.businessType(), command.businessNo(), + command.idempotencyKey(), new WalletHoldActionRequest(command.remark())); + case "RELEASE" -> walletService.release(command.customerId(), command.businessType(), command.businessNo(), + command.idempotencyKey(), new WalletHoldActionRequest(command.remark())); + default -> throw new FinanceEventException("FIN-EVT-001", "资金指令操作无效"); + }; + } + + private WalletCommandRequestedV1 validate(EventEnvelope envelope) { + try { EventEnvelopeValidator.requireValid(envelope); } + catch (RuntimeException exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令信封无效"); } + if (!EventTypes.WALLET_COMMAND_REQUESTED.equals(envelope.eventType()) + || envelope.eventVersion() != WalletCommandRequestedV1.VERSION + || envelope.sourceRegion() != RegionCode.OVERSEAS || envelope.targetRegion() != RegionCode.CN) { + throw new FinanceEventException("FIN-EVT-001", "资金指令类型、版本或区域无效"); + } + try { + WalletCommandRequestedV1 value = objectMapper.treeToValue(envelope.payload(), WalletCommandRequestedV1.class); + String operation = value.operation() == null ? "" : value.operation().trim().toUpperCase(Locale.ROOT); + if (!OPERATIONS.contains(operation) || value.customerId() <= 0 || blank(value.businessType()) + || blank(value.businessNo()) || blank(value.idempotencyKey()) + || ("FREEZE".equals(operation) && (value.amount() == null || value.amount().signum() <= 0))) { + throw new FinanceEventException("FIN-EVT-001", "资金指令字段无效"); + } + if (!String.valueOf(value.customerId()).equals(envelope.tenantId())) { + throw new FinanceEventException("FIN-EVT-007", "资金指令客户归属不一致"); + } + return new WalletCommandRequestedV1(operation, value.customerId(), value.businessType(), value.businessNo(), + value.amount(), value.remark(), value.idempotencyKey(), value.requestedAt()); + } catch (FinanceEventException exception) { throw exception; } + catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令载荷无法解析"); } + } + + private void reserve(EventEnvelope envelope) { + try { + jdbc.update("INSERT INTO fin_wallet_command_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) VALUES(?,?,?,CAST(? AS JSON),'PROCESSING',NOW(3))", + envelope.eventId(), envelope.eventType(), envelope.payloadHash(), json(envelope)); + } catch (DuplicateKeyException ignored) { } + } + + private void finalizeResult(EventEnvelope commandEnvelope, WalletCommandCompletedV1 result) { + JsonNode payload = objectMapper.valueToTree(result); + String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)); + EventEnvelope completed = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_COMPLETED, + WalletCommandCompletedV1.VERSION, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, + String.valueOf(result.customerId()), "WALLET_HOLD", result.businessNo(), 1L, null, + commandEnvelope.traceId(), commandEnvelope.correlationId(), commandEnvelope.eventId(), payload, payloadHash).requireValid(); + jdbc.update("INSERT IGNORE INTO fin_wallet_event_outbox(event_id,event_key,payload_json,status,next_retry_at,created_at,updated_at) VALUES(?,?,CAST(? AS JSON),'PENDING',NOW(3),NOW(3),NOW(3))", + completed.eventId(), "WALLET_RESULT:" + commandEnvelope.eventId(), json(completed)); + jdbc.update("UPDATE fin_wallet_command_inbox SET status=?,result_json=CAST(? AS JSON),processed_at=NOW(3) WHERE event_id=?", + result.result(), json(result), commandEnvelope.eventId()); + } + + private String existingHash(String eventId) { + List rows = jdbc.queryForList("SELECT payload_hash FROM fin_wallet_command_inbox WHERE event_id=?", String.class, eventId); + return rows.isEmpty() ? null : rows.get(0); + } + private boolean resultExists(String eventId) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_event_outbox WHERE event_key=?", Integer.class, + "WALLET_RESULT:" + eventId); + return count != null && count > 0; + } + private WalletCommandCompletedV1 completed(String eventId, WalletCommandRequestedV1 command, String result, + String holdStatus, BigDecimal amount, String errorCode, String errorMessage) { + return new WalletCommandCompletedV1(eventId, command.operation(), command.customerId(), command.businessType(), + command.businessNo(), result, holdStatus, amount, errorCode, errorMessage, Instant.now()); + } + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金事件序列化失败"); } + } + private static boolean blank(String value) { return value == null || value.isBlank(); } + private static String safe(String value) { + if (value == null || value.isBlank()) return "资金指令执行失败"; + String normalized = value.replaceAll("[\\r\\n\\t]+", " "); + return normalized.length() <= 500 ? normalized : normalized.substring(0, 500); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java new file mode 100644 index 000000000..2a57aaed1 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java @@ -0,0 +1,68 @@ +package com.baiyue.finance.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.finance.client.WalletResultEventBridgeClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Slf4j +public class WalletResultOutboxRelay { + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final WalletResultEventBridgeClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.wallet-event.max-attempts:12}") private int maxAttempts; + private final String worker = UUID.randomUUID().toString(); + + @Scheduled(fixedDelayString = "${baiyue.wallet-event.fixed-delay-ms:5000}") + public void relay() { + recoverExpired(); + for (String eventId : candidates()) deliver(eventId); + } + + public void deliver(String eventId) { + LocalDateTime now = LocalDateTime.now(); + int claimed = jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by=?,locked_at=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", + worker, now, now, eventId, now); + if (claimed != 1) return; + Map row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=?", eventId); + try { + EventEnvelope envelope = objectMapper.readValue(String.valueOf(row.get("payload_json")), EventEnvelope.class).requireValid(); + var response = client.receive(internalToken, envelope); + if (response == null || !response.successful() || response.data() == null + || !eventId.equals(response.data().eventId())) throw new IllegalStateException("海外事件桥未确认资金结果事件"); + jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=NOW(3),locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=NOW(3) WHERE event_id=? AND locked_by=?", + eventId, worker); + } catch (Exception exception) { + int retries = ((Number) row.get("retry_count")).intValue() + 1; + String status = retries >= maxAttempts ? "DEAD" : "PENDING"; + long delay = Math.min(1800, 5L << Math.min(retries, 8)); + jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND locked_by=?", + status, retries, now.plusSeconds(delay), safe(exception.getMessage()), eventId, worker); + log.warn("资金结果事件投递失败 eventId={}, reason={}", eventId, exception.getMessage()); + } + } + + private List candidates() { + return jdbc.queryForList("SELECT event_id FROM fin_wallet_event_outbox WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class); + } + private void recoverExpired() { + jdbc.update("UPDATE fin_wallet_event_outbox SET status='PENDING',locked_by=NULL,locked_at=NULL,next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND locked_at 0 AND actual_frozen_amount >= 0 AND credit_frozen_amount >= 0 AND amount=actual_frozen_amount+credit_frozen_amount), CONSTRAINT chk_fin_wallet_hold_status CHECK (status IN ('FROZEN','SETTLED','RELEASED')) ) ENGINE=InnoDB COMMENT='客户资金冻结及结算生命周期'; + + +CREATE TABLE IF NOT EXISTS fin_wallet_command_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '海外资金指令事件ID', + event_type VARCHAR(96) NOT NULL COMMENT '事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256', + payload_json JSON NOT NULL COMMENT '冻结事件信封', + status VARCHAR(16) NOT NULL COMMENT 'PROCESSING、SUCCEEDED或FAILED', + result_json JSON NULL COMMENT '最终资金执行结果', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + KEY idx_fin_wallet_command_status (status,received_at) +) ENGINE=InnoDB COMMENT='跨区客户资金指令幂等收件箱'; + +CREATE TABLE IF NOT EXISTS fin_wallet_event_outbox ( + event_id VARCHAR(64) NOT NULL COMMENT '资金结果事件ID', + event_key VARCHAR(160) NOT NULL COMMENT '业务幂等键', + payload_json JSON NOT NULL COMMENT '完整事件信封', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、PUBLISHED或DEAD', + retry_count INT NOT NULL DEFAULT 0 COMMENT '失败次数', + next_retry_at DATETIME(3) NOT NULL COMMENT '下次投递时间', + locked_by VARCHAR(128) NULL COMMENT '租约持有者', + locked_at DATETIME(3) NULL COMMENT '租约时间', + published_at DATETIME(3) NULL COMMENT '投递完成时间', + last_error VARCHAR(1000) NULL COMMENT '最后失败摘要', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_fin_wallet_event_key (event_key), + KEY idx_fin_wallet_event_publish (status,next_retry_at,created_at) +) ENGINE=InnoDB COMMENT='国内Finance到海外资金结果可靠发送箱'; diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index 9457d3a25..f4d561e1c 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest { missing.add(currentTable + "." + column.group(1)); } } - assertThat(tableCount).isEqualTo(21); - assertThat(columnCount).isEqualTo(330); + assertThat(tableCount).isEqualTo(23); + assertThat(columnCount).isEqualTo(350); assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty(); } diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java new file mode 100644 index 000000000..5abfcde17 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java @@ -0,0 +1,100 @@ +package com.baiyue.finance; + +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.baiyue.common.event.WalletCommandRequestedV1; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceWalletService; +import com.baiyue.finance.service.WalletCommandEventService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WalletCommandEventServiceTest { + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + private final FinanceWalletService wallet = mock(FinanceWalletService.class); + private final TransactionTemplate transactions = mock(TransactionTemplate.class); + private final WalletCommandEventService service = new WalletCommandEventService(jdbc, mapper, wallet, transactions); + + @BeforeEach + void executeTransactionCallbacks() { + when(transactions.execute(any())).thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(mock(TransactionStatus.class)); + }); + doAnswer(invocation -> { + Consumer callback = invocation.getArgument(0); + callback.accept(mock(TransactionStatus.class)); + return null; + }).when(transactions).executeWithoutResult(any()); + } + + @Test + void freezesWalletAndPublishesCompletedEvent() { + EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("88.50")); + when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId()))) + .thenReturn(List.of()); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); + when(wallet.freeze(eq(31L), eq("freeze-procurement-1"), any())).thenReturn( + new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("88.50"), + new BigDecimal("80.00"), new BigDecimal("8.50"), "FROZEN", 71L, null, + null, null, null)); + + assertThat(service.consume(envelope)).isEqualTo(envelope.eventId()); + + verify(wallet).freeze(eq(31L), eq("freeze-procurement-1"), argThat(request -> + request.amount().compareTo(new BigDecimal("88.50")) == 0 + && request.businessNo().equals("PO-1"))); + verify(jdbc).update(startsWith("INSERT IGNORE INTO fin_wallet_event_outbox"), any(Object[].class)); + verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), + eq("SUCCEEDED"), contains("\"holdStatus\":\"FROZEN\""), eq(envelope.eventId())); + } + + @Test + void rejectsCustomerMismatchBeforeTouchingWallet() { + EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00")); + envelope = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(), + envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), "32", envelope.aggregateType(), + envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), envelope.traceId(), + envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash()); + + EventEnvelope mismatched = envelope; + assertThatThrownBy(() -> service.consume(mismatched)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-007"); + verifyNoInteractions(wallet); + } + + private EventEnvelope envelope(String operation, long customerId, BigDecimal amount) { + WalletCommandRequestedV1 command = new WalletCommandRequestedV1(operation, customerId, + "OVERSEAS_PROCUREMENT_FEE", "PO-1", amount, "采购快递费", + "freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z")); + JsonNode payload = mapper.valueToTree(command); + return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_REQUESTED, + WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), + RegionCode.OVERSEAS, RegionCode.CN, String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1, + null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid(); + } +} diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml index d1274b7a8..01edad467 100644 --- a/lastleg-service/pom.xml +++ b/lastleg-service/pom.xml @@ -20,6 +20,7 @@ com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest com.tngtech.archunitarchunittest + com.belerwebpinyin4j2.5.1 org.springframework.bootspring-boot-maven-plugin diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java index 0cec9a7e7..481cfe5e3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java @@ -11,6 +11,7 @@ public class LastlegRestClientProperties { private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts customerVisitFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts procurementFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); @@ -20,6 +21,8 @@ public class LastlegRestClientProperties { public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); } public ClientTimeouts getCustomerVisitFile() { return customerVisitFile; } public void setCustomerVisitFile(ClientTimeouts value) { customerVisitFile = required(value, "customer-visit-file"); } + public ClientTimeouts getProcurementFile() { return procurementFile; } + public void setProcurementFile(ClientTimeouts value) { procurementFile = required(value, "procurement-file"); } public ClientTimeouts getWarehouseResource() { return warehouseResource; } public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); } public ClientTimeouts getPalletPhysical() { return palletPhysical; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 17ad9e114..a559f0cfc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -16,6 +16,7 @@ import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.wallet.WalletCommandCompletedConsumer; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -45,6 +46,7 @@ public class LastlegOrderController { @Resource private ContainerShippingNodeConsumer shippingNodeConsumer; @Resource private LastlegOrderService orderService; @Resource private InternalServiceTokenGuard tokenGuard; + @Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer; @PostMapping("/internal/lastleg/events") @Operation(summary = "Receive a first-leg loading or actual shipping-node event", @@ -56,6 +58,8 @@ public class LastlegOrderController { int consumed; if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) { consumed = shippingNodeConsumer.consume(event); + } else if (EventTypes.WALLET_COMMAND_COMPLETED.equals(event.eventType())) { + consumed = walletCommandCompletedConsumer.consume(event); } else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) { consumed = consumer.consume(event); } else { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 490bba307..db2b47740 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -45,7 +45,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response"); return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(), response.data().locationId(), response.data().status(), - response.data().fbaWarehouseCode(), response.data().version()); + response.data().fbaWarehouseCode(), response.data().unitPrice(), response.data().version()); } @Override @@ -122,7 +122,20 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { } return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(), response.data().locationId(), response.data().status(), - response.data().fbaWarehouseCode(), response.data().version()); + response.data().fbaWarehouseCode(), response.data().unitPrice(), response.data().version()); + } + + @Override + public WarehousePalletReference resolvePallet(long palletId) { + WarehousePalletResponse response = client.get().uri("/internal/v1/warehouse-pallets/resolve/id/{id}", palletId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw palletLookupFailure(result.getStatusCode()); }) + .body(WarehousePalletResponse.class); + if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet service returned no usable response"); + return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(), + response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(), + response.data().unitPrice(), response.data().version()); } private ResponseStatusException palletLookupFailure(HttpStatusCode status) { @@ -187,7 +200,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { private record WarehousePalletResponse(String code, String message, WarehousePalletData data) {} private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId, - String status, String fbaWarehouseCode, int version) {} + String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {} private record ReserveEnvelope(ReserveData data) {} private record ReserveData(long lockId) {} private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index 138c9a784..af7fd8eac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -8,6 +8,7 @@ public interface WarehouseResourceGateway { void scrap(String customerId, long resourceLockId); void scrapPallet(String customerId, long palletId); WarehousePalletReference resolvePallet(String palletCode); + WarehousePalletReference resolvePallet(long palletId); default WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) { throw new UnsupportedOperationException("Warehouse location resolution is not configured"); } @@ -19,9 +20,14 @@ public interface WarehouseResourceGateway { } record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId, - String status, String fbaWarehouseCode, int version) { + String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) { public WarehousePalletReference(long palletId, String palletNo, long locationId) { - this(palletId, palletNo, 0L, locationId, null, null, 0); + this(palletId, palletNo, 0L, locationId, null, null, java.math.BigDecimal.ZERO, 0); + } + public WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) { + this(palletId, palletNo, warehouseId, locationId, status, fbaWarehouseCode, + java.math.BigDecimal.ZERO, version); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index ae5d85b9a..73962eb29 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -14,6 +14,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.baiyue.lastleg.inboundplan.service.DestuffingPalletCostService; import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.entity.LastlegOrder; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostJob.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostJob.java new file mode 100644 index 000000000..690564395 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostJob.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DestuffingPalletCostJob { + private final DestuffingPalletCostService service; + + @Scheduled(fixedDelayString = "${baiyue.destuff-pallet-cost.fixed-delay-ms:10000}") + public void allocateReadyContainers() { + for (String bookingId : service.readyBookingIds()) { + try { + service.generateIfReady(bookingId, "SYSTEM"); + } catch (RuntimeException exception) { + log.warn("Destuff pallet cost allocation will retry bookingId={}", bookingId, exception); + } + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java new file mode 100644 index 000000000..751f48f4b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java @@ -0,0 +1,90 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.financefee.service.OverseasFeeOutboxService; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class DestuffingPalletCostService { + private final JdbcTemplate jdbc; + private final PalletPhysicalGateway pallets; + private final OverseasFeeSourceMapper feeSources; + private final OverseasFeeOutboxService feeOutbox; + + public List readyBookingIds() { + return jdbc.queryForList("SELECT o.source_container_booking_id FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id JOIN ll_inbound_pda_task t ON t.plan_id=p.id AND t.customer_id=p.customer_id LEFT JOIN ll_destuff_pallet_cost c ON c.source_container_booking_id=o.source_container_booking_id WHERE o.source_container_booking_id IS NOT NULL AND c.id IS NULL GROUP BY o.source_container_booking_id HAVING COUNT(*)=SUM(CASE WHEN t.status='COMPLETED' THEN 1 ELSE 0 END) ORDER BY MIN(t.completed_at) LIMIT 50", String.class); + } + + @Transactional + public void generateIfReady(String bookingId, String operatorId) { + Integer exists = jdbc.queryForObject("SELECT COUNT(*) FROM ll_destuff_pallet_cost WHERE source_container_booking_id=?", Integer.class, bookingId); + if (exists != null && exists > 0) return; + List> plans = jdbc.queryForList("SELECT p.id,p.customer_id,p.container_no,p.overseas_warehouse_id,t.status FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id LEFT JOIN ll_inbound_pda_task t ON t.plan_id=p.id AND t.customer_id=p.customer_id WHERE o.source_container_booking_id=? ORDER BY p.customer_id,p.id", bookingId); + if (plans.isEmpty() || plans.stream().anyMatch(row -> !"COMPLETED".equals(row.get("status")))) return; + List palletIds = jdbc.queryForList("SELECT DISTINCT pp.warehouse_pallet_id FROM ll_inbound_plan_pallet pp JOIN ll_inbound_plan p ON p.id=pp.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? AND pp.warehouse_pallet_id IS NOT NULL ORDER BY pp.warehouse_pallet_id", Long.class, bookingId); + BigDecimal totalCost = BigDecimal.ZERO; + for (Long palletId : palletIds) totalCost = totalCost.add(pallets.resolve(palletId).unitPrice()); + totalCost = totalCost.setScale(2, RoundingMode.HALF_UP); + if (palletIds.isEmpty() || totalCost.signum() <= 0) return; + List volumes = jdbc.query("SELECT a.customer_id,SUM(a.actual_volume_cbm) actual_volume FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan p ON p.id=a.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE o.source_container_booking_id=? GROUP BY a.customer_id ORDER BY a.customer_id", + (rs, n) -> new CustomerVolume(rs.getString("customer_id"), rs.getBigDecimal("actual_volume")), bookingId); + BigDecimal totalVolume = volumes.stream().map(CustomerVolume::volume).reduce(BigDecimal.ZERO, BigDecimal::add); + if (totalVolume.signum() <= 0) throw new IllegalStateException("拆柜打托费用缺少客户实收体积,不能分摊"); + long costId = IdWorker.getId(); String containerNo = String.valueOf(plans.get(0).get("container_no")); + Long warehouseId = plans.get(0).get("overseas_warehouse_id") == null ? null : ((Number) plans.get(0).get("overseas_warehouse_id")).longValue(); + jdbc.update("INSERT INTO ll_destuff_pallet_cost(id,source_container_booking_id,container_no,warehouse_id,pallet_count,total_actual_volume_cbm,total_cost,currency,status,created_at) VALUES(?,?,?,?,?,?,?,'CNY','ALLOCATED',NOW(3))", + costId, bookingId, containerNo, warehouseId, palletIds.size(), totalVolume, totalCost); + List ordered = new ArrayList<>(volumes); + ordered.sort(Comparator.comparing(CustomerVolume::customerId)); + List amounts = allocate(totalCost, ordered.stream().map(CustomerVolume::volume).toList()); + for (int i=0; i allocate(BigDecimal totalCost, List volumes) { + BigDecimal totalVolume = volumes.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + if (totalVolume.signum() <= 0) throw new IllegalArgumentException("实收体积必须大于零"); + List result = new ArrayList<>(); BigDecimal allocated = BigDecimal.ZERO; + for (int i = 0; i < volumes.size(); i++) { + BigDecimal amount = i == volumes.size() - 1 ? totalCost.subtract(allocated) + : totalCost.multiply(volumes.get(i)).divide(totalVolume, 2, RoundingMode.HALF_UP); + result.add(amount); allocated = allocated.add(amount); + } + return result; + } + + private OverseasFeeSource fee(long costId, String bookingId, String containerNo, Long warehouseId, CustomerVolume volume, BigDecimal amount, String operator) { + LocalDateTime now=LocalDateTime.now(ZoneOffset.UTC); OverseasFeeSource v=new OverseasFeeSource(); + v.setId(IdWorker.getId()); v.setSourceFeeId(IdWorker.getId()); v.setSourceVersion(1); v.setCustomerId(volume.customerId()); + v.setSourceType("OVERSEAS_CONTAINER_FEE"); v.setSourceNo("DESTUFF-"+bookingId); v.setBusinessType("CONTAINER_DESTUFFING"); + v.setBusinessId(costId); v.setBusinessNo("DESTUFF-"+bookingId); v.setContainerBookingId(parseLong(bookingId)); v.setContainerNo(containerNo); + v.setWarehouseId(warehouseId); v.setFeeItemCode("DESTUFF_PALLET"); v.setFeeItemName("拆柜打托费"); v.setDirection("RECEIVABLE"); + v.setPaymentVoucherFileIds(List.of()); v.setBusinessDate(LocalDate.now()); v.setCurrency("CNY"); v.setAmount(amount); + v.setReviewStatus("APPROVED"); v.setPublicationStatus("PUBLISHED"); v.setSourceStatus("ACTIVE"); v.setReviewerId(operator); + v.setReviewedAt(now); v.setCreatedBy(operator); v.setUpdatedBy(operator); v.setCreatedAt(now); v.setUpdatedAt(now); return v; + } + private static Long parseLong(String value){try{return Long.parseLong(value);}catch(Exception ignored){return null;}} + private record CustomerVolume(String customerId, BigDecimal volume){} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java index dbdfa5044..45a0448c6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java @@ -45,6 +45,17 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { return response.data(); } + @Override + public PalletReference resolve(long palletId) { + ResolveEnvelope response = client.get().uri("/internal/v1/warehouse-pallets/resolve/id/{id}", palletId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw palletLookupFailure(result.getStatusCode()); }) + .body(ResolveEnvelope.class); + if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet service returned no usable response"); + return response.data(); + } + private ResponseStatusException palletLookupFailure(HttpStatusCode status) { return new ResponseStatusException(status.value() == 404 ? HttpStatus.NOT_FOUND : (status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java index f00726b97..157e25681 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java @@ -2,13 +2,20 @@ package com.baiyue.lastleg.palletops.gateway; public interface PalletPhysicalGateway { PalletReference resolve(String palletCode); + PalletReference resolve(long palletId); LocationReference resolveLocation(long warehouseId, long locationId); Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId, String operationKey, String reason); void compensate(long warehouseId, Transition transition); record PalletReference(long palletId, String palletCode, long warehouseId, long locationId, - String status, String fbaWarehouseCode, int version) { } + String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) { + public PalletReference(long palletId, String palletCode, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) { + this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode, + java.math.BigDecimal.ZERO, version); + } + } record LocationReference(long locationId, String locationCode, String locationType, String status) { } record Transition(String operationKey, long palletId, String palletCode, long warehouseId, String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java index 1693086fc..a444577ea 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java @@ -21,6 +21,9 @@ import org.springframework.web.bind.annotation.RequestBody; 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.bind.annotation.RequestPart; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import java.util.List; @@ -91,6 +94,25 @@ public class PlatformOverseasProcurementController { return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request)); } + @PostMapping(value = "/{orderNo}/files/{category}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')") + public ApiResponse uploadFile( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @PathVariable String category, @RequestPart("file") MultipartFile file) { + requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); + return ApiResponse.success(service.uploadFile(requiredCustomer(customerId), orderNo, + String.valueOf(user.userId()), category.trim().toUpperCase(), file)); + } + + @PostMapping("/{orderNo}/fees") + @PreAuthorize("hasAuthority('" + OverseasPermissions.PURCHASE_EDIT + "')") + public ApiResponse createFee( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.CreateFee request) { + requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); + return ApiResponse.success(service.createFee(requiredCustomer(customerId), orderNo, operator(user), request)); + } + private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java index 9903b3b71..453e9a0bd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -53,6 +53,13 @@ public final class OverseasProcurementRequests { @NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} + public record CreateFee( + @NotBlank @Size(max = 32) String feeItemCode, + @NotBlank @Size(max = 128) String feeItemName, + @NotNull @DecimalMin(value = "0.01") BigDecimal amount, + @NotNull @Positive Long voucherFileId, + @Size(max = 500) String remark) {} + public record Update( @NotNull @Min(0) Long version, @Size(max = 128) String purchaseCategory, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java index 06bd0242f..9a48f9b4a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java @@ -27,6 +27,14 @@ public final class OverseasProcurementResponses { String idempotencyKey, String changeDetail, String remark, LocalDateTime createdAt) {} + public record FileInfo(Long id, String category, Long fileId, String fileName, + String contentType, long sizeBytes, LocalDateTime createdAt) {} + + public record Fee(Long id, String feeNo, String feeItemCode, String feeItemName, + BigDecimal amount, String currency, Long voucherFileId, + String voucherFileName, String remark, String walletStatus, + String walletErrorMessage, LocalDateTime createdAt) {} + public record Detail( Summary summary, String productUrl, String receivingAddress, String contactName, String contactPhone, @@ -34,5 +42,6 @@ public final class OverseasProcurementResponses { String overseasCarrier, String overseasTrackingNo, LocalDateTime estimatedArrivalAt, String remark, String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt, - List histories, List outboundDetails) {} + List histories, List outboundDetails, + List files, List fees) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java new file mode 100644 index 000000000..89a289562 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.procurement.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_overseas_procurement_fee") +public class OverseasProcurementFee { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String feeNo; + private String feeItemCode; + private String feeItemName; + private BigDecimal amount; + private String currency; + private Long voucherFileId; + private String voucherFileName; + private String remark; + private String walletStatus; + private String walletErrorCode; + private String walletErrorMessage; + private String walletCommandEventId; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFile.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFile.java new file mode 100644 index 000000000..6c23977e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFile.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.procurement.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_overseas_procurement_file") +public class OverseasProcurementFile { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String category; + private Long fileId; + private String fileName; + private String contentType; + private Long sizeBytes; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java new file mode 100644 index 000000000..69773f9a5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.procurement.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +@Component +@RequiredArgsConstructor +public class HttpProcurementFileGateway implements ProcurementFileGateway { + private final RestClient.Builder builder; + private final LastlegRestClientFactory factory; + private final LastlegRestClientProperties properties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String token; + private RestClient client; + @PostConstruct void initialize() { client = factory.build(builder, baseUrl, properties.getProcurementFile()); } + + @Override + public UploadedFile upload(String customerId, long orderId, long operatorId, String category, MultipartFile file) { + if (file == null || file.isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择文件"); + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(file.getContentType())); + } catch (Exception exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "采购文件无法读取", exception); } + String businessType = "RECEIPT".equals(category) ? "OVERSEAS_PROCUREMENT_RECEIPT" : "OVERSEAS_PROCUREMENT_VOUCHER"; + Envelope response = client.post().uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + Long.parseLong(customerId), businessType, orderId) + .header("X-Internal-Service-Token", token).header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException( + result.getStatusCode().is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "采购文件上传失败"); }).body(Envelope.class); + if (response == null || !"200".equals(response.code()) || response.data() == null || response.data().fileId() == null) + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回采购文件元数据"); + Metadata data = response.data(); + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); + } + private record Envelope(String code, String message, Metadata data) { } + private record Metadata(Long fileId, String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java new file mode 100644 index 000000000..07eddfe5e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.procurement.gateway; +import org.springframework.web.multipart.MultipartFile; +public interface ProcurementFileGateway { + UploadedFile upload(String customerId, long orderId, long operatorId, String category, MultipartFile file); + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java new file mode 100644 index 000000000..57db8b5fe --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.lastleg.procurement.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface OverseasProcurementFeeMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFileMapper.java new file mode 100644 index 000000000..41a7b46d0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFileMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.lastleg.procurement.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface OverseasProcurementFileMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 9d3becfb7..09aea0c1a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -9,6 +9,14 @@ import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFile; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper; +import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; +import com.baiyue.lastleg.wallet.WalletResultHandler; +import com.baiyue.common.event.WalletCommandCompletedV1; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; import com.baiyue.lastleg.security.OverseasPermissions; @@ -34,10 +42,11 @@ import java.util.Map; import java.util.HexFormat; import java.util.Objects; import java.util.Set; +import org.springframework.web.multipart.MultipartFile; @Service -public class OverseasProcurementService { - @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; +public class OverseasProcurementService implements WalletResultHandler { + @Resource private ProcurementOrderNumberGenerator orderNoGenerator; public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW; public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT; public static final String PENDING_REVIEW = "PENDING_REVIEW"; @@ -49,6 +58,10 @@ public class OverseasProcurementService { @Resource private OverseasProcurementStatusHistoryMapper histories; @Resource private PartnerWarehouseDirectory warehouseDirectory; @Resource private ObjectMapper objectMapper; + @Resource private OverseasProcurementFileMapper procurementFiles; + @Resource private OverseasProcurementFeeMapper procurementFees; + @Resource private ProcurementFileGateway fileGateway; + @Resource private WalletCommandOutboxService walletCommands; @Transactional public OverseasProcurementResponses.Detail create(String customerId, String operatorId, @@ -63,7 +76,7 @@ public class OverseasProcurementService { order.setId(IdWorker.getId()); order.setCustomerId(customerId); order.setCustomerName(trim(request.customerName())); - order.setOrderNo(orderNoGenerator.next("PROCUREMENT", request.receivingAddress(), order.getId())); + order.setOrderNo(orderNoGenerator.next(customerId, request.customerName())); order.setPurchaseCategory(request.purchaseCategory().trim()); order.setProductName(request.productName().trim()); order.setProductSpecification(trim(request.productSpecification())); @@ -148,7 +161,90 @@ public class OverseasProcurementService { new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(), value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), value.getChangeDetail(), value.getRemark(), - value.getCreatedAt())).toList(), outboundDetails(order)); + value.getCreatedAt())).toList(), outboundDetails(order), files(order), fees(order)); + } + + @Transactional + public OverseasProcurementResponses.FileInfo uploadFile(String customerId, String orderNo, String operatorId, + String category, MultipartFile file) { + OverseasProcurementOrder order = lock(customerId, orderNo); + if (!Set.of("RECEIPT", "VOUCHER").contains(category)) throw badRequest("File category must be RECEIPT or VOUCHER"); + ProcurementFileGateway.UploadedFile uploaded = fileGateway.upload(customerId, order.getId(), Long.parseLong(operatorId), category, file); + OverseasProcurementFile row = new OverseasProcurementFile(); + row.setId(IdWorker.getId()); row.setOrderId(order.getId()); row.setCustomerId(customerId); row.setCategory(category); + row.setFileId(uploaded.fileId()); row.setFileName(uploaded.fileName()); row.setContentType(uploaded.contentType()); + row.setSizeBytes(uploaded.sizeBytes()); row.setCreatedBy(operatorId); row.setCreatedAt(LocalDateTime.now()); + procurementFiles.insert(row); + return file(row); + } + + @Transactional + public OverseasProcurementResponses.Fee createFee(String customerId, String orderNo, String operatorId, + OverseasProcurementRequests.CreateFee request) { + OverseasProcurementOrder order = lock(customerId, orderNo); + if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can add fees"); + OverseasProcurementFile voucher = procurementFiles.selectOne(new LambdaQueryWrapper() + .eq(OverseasProcurementFile::getOrderId, order.getId()).eq(OverseasProcurementFile::getCustomerId, customerId) + .eq(OverseasProcurementFile::getCategory, "VOUCHER").eq(OverseasProcurementFile::getFileId, request.voucherFileId())); + if (voucher == null) throw badRequest("Fee voucher does not belong to this procurement order"); + LocalDateTime now = LocalDateTime.now(); + OverseasProcurementFee row = new OverseasProcurementFee(); + row.setId(IdWorker.getId()); row.setOrderId(order.getId()); row.setCustomerId(customerId); + row.setFeeNo("PF" + row.getId()); row.setFeeItemCode(request.feeItemCode().trim().toUpperCase()); + row.setFeeItemName(request.feeItemName().trim()); row.setAmount(request.amount()); row.setCurrency("CNY"); + row.setVoucherFileId(voucher.getFileId()); row.setVoucherFileName(voucher.getFileName()); row.setRemark(trim(request.remark())); + row.setWalletStatus("FREEZE_PENDING"); row.setCreatedBy(operatorId); row.setCreatedAt(now); row.setUpdatedAt(now); + procurementFees.insert(row); + String eventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "PROCUREMENT_FEE", row.getFeeNo(), + row.getAmount(), "海外采购费用冻结:" + orderNo, 1L); + row.setWalletCommandEventId(eventId); procurementFees.updateById(row); + return fee(row); + } + + @Override public boolean supports(String businessType) { return "PROCUREMENT_FEE".equals(businessType); } + + @Override + @Transactional + public void handle(WalletCommandCompletedV1 result) { + OverseasProcurementFee row = procurementFees.selectOne(new LambdaQueryWrapper() + .eq(OverseasProcurementFee::getCustomerId, String.valueOf(result.customerId())) + .eq(OverseasProcurementFee::getFeeNo, result.businessNo())); + if (row == null) throw notFound("Procurement fee not found for wallet result"); + if ("SUCCEEDED".equals(result.result())) { + row.setWalletStatus(switch (result.operation()) { case "FREEZE" -> "FROZEN"; case "SETTLE" -> "SETTLED"; case "RELEASE" -> "RELEASED"; default -> result.holdStatus(); }); + row.setWalletErrorCode(null); row.setWalletErrorMessage(null); + } else { + row.setWalletStatus(result.operation() + "_FAILED"); row.setWalletErrorCode(result.errorCode()); row.setWalletErrorMessage(result.errorMessage()); + } + row.setUpdatedAt(LocalDateTime.now()); procurementFees.updateById(row); + if ("FREEZE".equals(result.operation()) && "SUCCEEDED".equals(result.result())) { + OverseasProcurementOrder order = orders.selectById(row.getOrderId()); + if (order != null && order.getInboundQuantity() >= order.getPurchaseQuantity()) { + walletCommands.publish("SETTLE", result.customerId(), "PROCUREMENT_FEE", row.getFeeNo(), null, + "海外采购完成入库,结算费用", 2L); + row.setWalletStatus("SETTLE_PENDING"); procurementFees.updateById(row); + } + } + } + + private List files(OverseasProcurementOrder order) { + return procurementFiles.selectList(new LambdaQueryWrapper() + .eq(OverseasProcurementFile::getOrderId, order.getId()).orderByAsc(OverseasProcurementFile::getCreatedAt)) + .stream().map(this::file).toList(); + } + private OverseasProcurementResponses.FileInfo file(OverseasProcurementFile value) { + return new OverseasProcurementResponses.FileInfo(value.getId(), value.getCategory(), value.getFileId(), value.getFileName(), + value.getContentType(), Objects.requireNonNullElse(value.getSizeBytes(), 0L), value.getCreatedAt()); + } + private List fees(OverseasProcurementOrder order) { + return procurementFees.selectList(new LambdaQueryWrapper() + .eq(OverseasProcurementFee::getOrderId, order.getId()).orderByAsc(OverseasProcurementFee::getCreatedAt)) + .stream().map(this::fee).toList(); + } + private OverseasProcurementResponses.Fee fee(OverseasProcurementFee value) { + return new OverseasProcurementResponses.Fee(value.getId(), value.getFeeNo(), value.getFeeItemCode(), value.getFeeItemName(), + value.getAmount(), value.getCurrency(), value.getVoucherFileId(), value.getVoucherFileName(), value.getRemark(), + value.getWalletStatus(), value.getWalletErrorMessage(), value.getCreatedAt()); } @Transactional @@ -192,6 +288,7 @@ public class OverseasProcurementService { Map before = editableSnapshot(order); if (request.inboundQuantity() != null) { if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can receive stock"); + if (request.inboundQuantity() > order.getPurchaseQuantity()) throw badRequest("Inbound quantity cannot exceed purchase quantity"); order.setInboundQuantity(request.inboundQuantity()); } setIfText(request.purchaseCategory(), order::setPurchaseCategory); @@ -213,6 +310,14 @@ public class OverseasProcurementService { orders.updateById(order); history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, changeDetail(before, after), null, now); + if (order.getInboundQuantity() >= order.getPurchaseQuantity()) { + for (OverseasProcurementFee fee : procurementFees.selectList(new LambdaQueryWrapper() + .eq(OverseasProcurementFee::getOrderId, order.getId()).eq(OverseasProcurementFee::getWalletStatus, "FROZEN"))) { + walletCommands.publish("SETTLE", Long.parseLong(customerId), "PROCUREMENT_FEE", fee.getFeeNo(), null, + "海外采购完成入库,结算费用", 2L); + fee.setWalletStatus("SETTLE_PENDING"); fee.setUpdatedAt(now); procurementFees.updateById(fee); + } + } return detail(customerId, orderNo); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java new file mode 100644 index 000000000..da15190a1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.procurement.service; + +import net.sourceforge.pinyin4j.PinyinHelper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +@Component +public class ProcurementOrderNumberGenerator { + private final JdbcTemplate jdbc; + public ProcurementOrderNumberGenerator(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + @Transactional + public String next(String customerId, String customerName) { + LocalDate date = LocalDate.now(); + jdbc.update("INSERT INTO ll_procurement_daily_sequence(customer_id,business_date,sequence_value) VALUES(?,?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE sequence_value=LAST_INSERT_ID(sequence_value+1)", + customerId, date); + Integer value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Integer.class); + if (value == null || value < 1 || value > 9999) throw new IllegalStateException("客户采购订单当日序号已耗尽"); + return initials(customerName) + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format("%04d", value); + } + + static String initials(String customerName) { + if (!StringUtils.hasText(customerName)) return "KH"; + StringBuilder result = new StringBuilder(); + boolean latinWord = false; + for (char value : customerName.trim().toCharArray()) { + if (result.length() >= 8) break; + if (Character.UnicodeScript.of(value) == Character.UnicodeScript.HAN) { + String[] pinyin = PinyinHelper.toHanyuPinyinStringArray(value); + if (pinyin != null && pinyin.length > 0 && !pinyin[0].isBlank()) result.append(Character.toUpperCase(pinyin[0].charAt(0))); + latinWord = false; + } else if (Character.isLetterOrDigit(value)) { + if (!latinWord) result.append(Character.toUpperCase(value)); + latinWord = true; + } else { + latinWord = false; + } + } + String normalized = result.toString().replaceAll("[^A-Z0-9]", "").toUpperCase(Locale.ROOT); + return normalized.isBlank() ? "KH" : normalized.substring(0, Math.min(normalized.length(), 8)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index 32ddaea7c..6dda7ce0c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -11,6 +11,8 @@ import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.List; +import java.math.BigDecimal; +import jakarta.validation.constraints.DecimalMin; public final class RelabelRequests { private RelabelRequests() {} @@ -21,6 +23,7 @@ public final class RelabelRequests { @NotNull LocalDateTime labelDeadline, @NotBlank @Size(max = 128) String contactName, @NotBlank @Size(max = 64) String contactPhone, + @NotNull @DecimalMin(value = "0.01") BigDecimal chargeAmount, @Size(max = 1000) String remark, @NotBlank @Size(max = 255) String idempotencyKey, @NotEmpty @Size(max = 500) List<@Valid Item> items diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index f148026a7..249130cad 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.relabel.dto; import java.time.LocalDateTime; import java.util.List; +import java.math.BigDecimal; public final class RelabelResponses { private RelabelResponses() {} @@ -10,6 +11,8 @@ public final class RelabelResponses { String customerOrderNo, String overseasWarehouseId, LocalDateTime labelDeadline, String contactName, String contactPhone, String status, int itemCount, int totalPackageCount, int notifiedPackageCount, String skuCodes, + BigDecimal chargeAmount, String chargeCurrency, String walletStatus, + String walletErrorMessage, LocalDateTime createdAt) {} public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, String productImageUrl, String productNameZh, int totalPackageCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java index 607c2f25f..ed0435236 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; +import java.math.BigDecimal; @Data @TableName("ll_relabel_application") @@ -24,6 +25,12 @@ public class RelabelApplication { private Integer itemCount; private Integer totalPackageCount; private Integer notifiedPackageCount; + private BigDecimal chargeAmount; + private String chargeCurrency; + private String walletStatus; + private String walletErrorCode; + private String walletErrorMessage; + private String walletCommandEventId; private String idempotencyKey; private String requestHash; private String createdBy; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 99cc8d7cc..cf9b0250b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -17,6 +17,9 @@ import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; +import com.baiyue.lastleg.wallet.WalletResultHandler; +import com.baiyue.common.event.WalletCommandCompletedV1; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; @@ -36,7 +39,7 @@ import java.util.Set; import java.util.stream.Collectors; @Service -public class RelabelApplicationService { +public class RelabelApplicationService implements WalletResultHandler { public static final String PENDING_PROCESSING = "PENDING_PROCESSING"; private static final Set STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", "COMPLETED", "REJECTED", "CANCELLED"); @@ -47,6 +50,7 @@ public class RelabelApplicationService { @Resource private LastlegOrderStatusHistoryMapper orderHistories; @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private ObjectMapper objectMapper; + @Resource private WalletCommandOutboxService walletCommands; @Transactional public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) { @@ -109,6 +113,9 @@ public class RelabelApplicationService { application.setItemCount(request.items().size()); application.setTotalPackageCount(total); application.setNotifiedPackageCount(notified); + application.setChargeAmount(request.chargeAmount()); + application.setChargeCurrency("CNY"); + application.setWalletStatus("FREEZE_PENDING"); application.setIdempotencyKey(request.idempotencyKey().trim()); application.setRequestHash(requestHash); application.setCreatedBy(trim(operatorId)); @@ -116,6 +123,11 @@ public class RelabelApplicationService { application.setUpdatedAt(now); applications.insert(application); + String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER", + relabelNo, request.chargeAmount(), "换标订单费用冻结", 1L); + application.setWalletCommandEventId(walletEventId); + applications.updateById(application); + for (RelabelRequests.Item source : request.items()) insertItem(customerId, applicationId, source, now); insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); insertOrderHistory(customerId, order.getId(), null, order.getCurrentStatus(), @@ -172,11 +184,23 @@ public class RelabelApplicationService { if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status"); if (next.equals(application.getStatus())) return detail(customerId, relabelNo); validateTransition(application.getStatus(), next); + if ("PROCESSING".equals(next) && !"FROZEN".equals(application.getWalletStatus())) { + throw conflict("Relabel funds must be frozen before processing"); + } String before = application.getStatus(); LocalDateTime now = LocalDateTime.now(); application.setStatus(next); application.setUpdatedAt(now); applications.updateById(application); + if ("COMPLETED".equals(next)) { + walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, + "换标完成,结算费用", 2L); + application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application); + } else if (Set.of("REJECTED", "CANCELLED").contains(next) && "FROZEN".equals(application.getWalletStatus())) { + walletCommands.publish("RELEASE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, + "换标取消,释放冻结", 2L); + application.setWalletStatus("RELEASE_PENDING"); applications.updateById(application); + } LastlegOrder order = orders.selectById(application.getLastlegOrderId()); if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing"); String orderBefore = order.getCurrentStatus(); @@ -221,7 +245,31 @@ public class RelabelApplicationService { value.getLabelDeadline(), value.getContactName(), value.getContactPhone(), value.getStatus(), Objects.requireNonNullElse(value.getItemCount(), detailItems.size()), Objects.requireNonNullElse(value.getTotalPackageCount(), 0), - Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes, value.getCreatedAt()); + Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes, + value.getChargeAmount(), value.getChargeCurrency(), value.getWalletStatus(), value.getWalletErrorMessage(), + value.getCreatedAt()); + } + + @Override public boolean supports(String businessType) { return "RELABEL_ORDER".equals(businessType); } + + @Override + @Transactional + public void handle(WalletCommandCompletedV1 result) { + RelabelApplication value = applications.findNo(String.valueOf(result.customerId()), result.businessNo()); + if (value == null) throw notFound("Relabel application not found for wallet result"); + if ("SUCCEEDED".equals(result.result())) { + value.setWalletStatus(switch (result.operation()) { case "FREEZE" -> "FROZEN"; case "SETTLE" -> "SETTLED"; case "RELEASE" -> "RELEASED"; default -> result.holdStatus(); }); + value.setWalletErrorCode(null); value.setWalletErrorMessage(null); + if ("FREEZE".equals(result.operation()) && Set.of("REJECTED", "CANCELLED").contains(value.getStatus())) { + walletCommands.publish("RELEASE", result.customerId(), "RELABEL_ORDER", value.getRelabelNo(), null, + "换标已取消,冻结成功后立即释放", 2L); + value.setWalletStatus("RELEASE_PENDING"); + } + } else { + value.setWalletStatus(result.operation() + "_FAILED"); value.setWalletErrorCode(result.errorCode()); + value.setWalletErrorMessage(result.errorMessage()); + } + value.setUpdatedAt(LocalDateTime.now()); applications.updateById(value); } private RelabelResponses.Item item(RelabelItem value) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index 92863e47a..c0cb73682 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -15,7 +15,8 @@ import java.util.List; public class UnloadCompletedOutboxRepository { private static final List OVERSEAS_TO_CN_TYPES = List.of( EventTypes.OVERSEAS_CONTAINER_UNLOADED, - EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED); + EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, + EventTypes.WALLET_COMMAND_REQUESTED); @Resource private LastlegEventOutboxMapper mapper; public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java new file mode 100644 index 000000000..a95dd7e06 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java @@ -0,0 +1,73 @@ +package com.baiyue.lastleg.wallet; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventEnvelopeValidator; +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.common.event.WalletCommandCompletedV1; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +@Service +@RequiredArgsConstructor +public class WalletCommandCompletedConsumer { + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final List handlers; + + @Transactional + public int consume(EventEnvelope envelope) { + WalletCommandCompletedV1 result = validate(envelope); + List existing = jdbc.queryForList("SELECT payload_hash FROM ll_wallet_event_inbox WHERE event_id=?", String.class, envelope.eventId()); + if (!existing.isEmpty()) { + if (!existing.get(0).equals(envelope.payloadHash())) throw bad("同一资金结果事件ID对应不同载荷"); + return 0; + } + try { + jdbc.update("INSERT INTO ll_wallet_event_inbox(event_id,command_event_id,payload_hash,payload_json,status,received_at) VALUES(?,?,?,CAST(? AS JSON),'RECEIVED',NOW(3))", + envelope.eventId(), result.commandEventId(), envelope.payloadHash(), json(envelope)); + } catch (DuplicateKeyException exception) { return 0; } + WalletResultHandler handler = handlers.stream().filter(value -> value.supports(result.businessType())).findFirst() + .orElseThrow(() -> bad("没有可处理该资金结果的业务模块")); + handler.handle(result); + jdbc.update("UPDATE ll_wallet_event_inbox SET status='PROCESSED',processed_at=NOW(3) WHERE event_id=?", envelope.eventId()); + return 1; + } + + private WalletCommandCompletedV1 validate(EventEnvelope envelope) { + try { EventEnvelopeValidator.requireValid(envelope); } + catch (RuntimeException exception) { throw bad("资金结果事件信封无效"); } + if (!EventTypes.WALLET_COMMAND_COMPLETED.equals(envelope.eventType()) + || envelope.eventVersion() != WalletCommandCompletedV1.VERSION + || envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { + throw bad("资金结果事件类型、版本或区域无效"); + } + if (!EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(envelope.payload())).equals(envelope.payloadHash())) { + throw bad("资金结果事件载荷哈希无效"); + } + try { + WalletCommandCompletedV1 result = objectMapper.treeToValue(envelope.payload(), WalletCommandCompletedV1.class); + if (result.customerId() <= 0 || result.commandEventId() == null || result.commandEventId().isBlank() + || result.businessType() == null || result.businessType().isBlank() + || result.businessNo() == null || result.businessNo().isBlank()) throw bad("资金结果事件字段无效"); + return result; + } catch (ResponseStatusException exception) { throw exception; } + catch (Exception exception) { throw bad("资金结果事件载荷无法解析"); } + } + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (Exception exception) { throw bad("资金结果事件序列化失败"); } + } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(BAD_REQUEST, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java new file mode 100644 index 000000000..5225583d9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.wallet; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +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.common.event.WalletCommandRequestedV1; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +@Service +@RequiredArgsConstructor +public class WalletCommandOutboxService { + private final LastlegEventOutboxMapper mapper; + private final ObjectMapper objectMapper; + + public String publish(String operation, long customerId, String businessType, String businessNo, + BigDecimal amount, String remark, long aggregateVersion) { + String eventKey = "WALLET_COMMAND:" + businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion; + LastlegEventOutbox existing = mapper.findByEventKey(eventKey); + if (existing != null) return eventId(existing.getPayloadJson()); + String idempotencyKey = businessType + ':' + businessNo + ':' + operation + ':' + aggregateVersion; + WalletCommandRequestedV1 value = new WalletCommandRequestedV1(operation, customerId, businessType, + businessNo, amount, remark, idempotencyKey, Instant.now()); + JsonNode payload = objectMapper.valueToTree(value); + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_REQUESTED, + WalletCommandRequestedV1.VERSION, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, + String.valueOf(customerId), businessType, businessNo, aggregateVersion, null, + EventIdentifiers.newUuid(), eventKey, null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid(); + LastlegEventOutbox row = new LastlegEventOutbox(); + row.setId(IdWorker.getId()); row.setCustomerId(String.valueOf(customerId)); + row.setAggregateType(businessType); row.setAggregateId(businessNo); + 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); + return envelope.eventId(); + } + + private String eventId(String json) { + try { return objectMapper.readValue(json, EventEnvelope.class).eventId(); } + catch (Exception exception) { throw new IllegalStateException("资金指令事件快照损坏", exception); } + } + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (Exception exception) { throw new IllegalStateException("资金指令事件序列化失败", exception); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletResultHandler.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletResultHandler.java new file mode 100644 index 000000000..b4a2360b9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletResultHandler.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.wallet; + +import com.baiyue.common.event.WalletCommandCompletedV1; + +public interface WalletResultHandler { + boolean supports(String businessType); + void handle(WalletCommandCompletedV1 result); +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 51a293ff7..de2ac3d03 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1421,6 +1421,12 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application ( item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', total_package_count INT NOT NULL DEFAULT 0 COMMENT '总箱数', notified_package_count INT NOT NULL DEFAULT 0 COMMENT '已通知箱数', + charge_amount DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '换标费用CNY', + charge_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', + wallet_status VARCHAR(32) NOT NULL DEFAULT 'FREEZE_PENDING' COMMENT '资金状态', + wallet_error_code VARCHAR(64) NULL COMMENT '资金错误码', + wallet_error_message VARCHAR(500) NULL COMMENT '资金错误说明', + wallet_command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', @@ -1879,3 +1885,65 @@ INSERT IGNORE INTO ll_security_projection ( 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), ('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256), 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); + + +CREATE TABLE IF NOT EXISTS ll_wallet_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '国内Finance资金结果事件ID', + command_event_id VARCHAR(64) NOT NULL COMMENT '原海外资金指令事件ID', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256', + payload_json JSON NOT NULL COMMENT '冻结事件信封', + status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_ll_wallet_command_result (command_event_id), + KEY idx_ll_wallet_event_status (status,received_at) +) ENGINE=InnoDB COMMENT='跨区客户资金结果幂等收件箱'; + + + +CREATE TABLE IF NOT EXISTS ll_procurement_daily_sequence ( + customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', + business_date DATE NOT NULL COMMENT '北京时间业务日期', + sequence_value INT NOT NULL COMMENT '客户当日已分配最大序号', + PRIMARY KEY (customer_id,business_date), + CONSTRAINT chk_ll_procurement_sequence CHECK (sequence_value BETWEEN 1 AND 9999) +) ENGINE=InnoDB COMMENT='海外采购客户日序列'; + + + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_file ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', + category VARCHAR(16) NOT NULL COMMENT 'RECEIPT签收文件或VOUCHER费用凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '媒体类型', size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小', + created_by VARCHAR(128) NULL COMMENT '上传人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_file(order_id,file_id), KEY idx_ll_procurement_file_order(customer_id,order_id,category,created_at), + CONSTRAINT chk_ll_procurement_file_category CHECK(category IN ('RECEIPT','VOUCHER')) +) ENGINE=InnoDB COMMENT='海外采购签收文件及费用凭证'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', + fee_no VARCHAR(64) NOT NULL COMMENT '费用业务号', fee_item_code VARCHAR(32) NOT NULL COMMENT '费用项编码', fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项名称', + amount DECIMAL(20,2) NOT NULL COMMENT 'CNY金额', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '费用币种', voucher_file_id BIGINT NOT NULL COMMENT '凭证文件ID', + voucher_file_name VARCHAR(255) NOT NULL COMMENT '凭证文件名', remark VARCHAR(500) NULL COMMENT '说明', + wallet_status VARCHAR(32) NOT NULL COMMENT '资金状态', wallet_error_code VARCHAR(64) NULL COMMENT '资金错误码', wallet_error_message VARCHAR(500) NULL COMMENT '资金错误说明', + wallet_command_event_id VARCHAR(64) NULL COMMENT '当前资金指令事件ID', created_by VARCHAR(128) NULL COMMENT '登记人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_fee_no(fee_no), KEY idx_ll_procurement_fee_order(customer_id,order_id,created_at), + CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY') +) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期'; + + + +CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost ( + id BIGINT NOT NULL COMMENT '主键ID', source_container_booking_id VARCHAR(128) NOT NULL COMMENT '来源柜次业务ID', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', warehouse_id BIGINT NULL COMMENT '海外仓ID', + pallet_count INT NOT NULL COMMENT '实际打托数', total_actual_volume_cbm DECIMAL(20,6) NOT NULL COMMENT '客户实收总体积', total_cost DECIMAL(20,2) NOT NULL COMMENT '打托总成本', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '成本币种', + status VARCHAR(16) NOT NULL COMMENT '生成状态', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_destuff_cost_booking(source_container_booking_id), CONSTRAINT chk_ll_destuff_cost CHECK(pallet_count>0 AND total_actual_volume_cbm>0 AND total_cost>0 AND currency='CNY') +) ENGINE=InnoDB COMMENT='拆柜打托实际成本汇总'; +CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation ( + id BIGINT NOT NULL COMMENT '主键ID', cost_id BIGINT NOT NULL COMMENT '打托成本汇总ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', actual_volume_cbm DECIMAL(20,6) NOT NULL COMMENT '客户实收体积', + allocated_amount DECIMAL(20,2) NOT NULL COMMENT '客户分摊金额', fee_source_id BIGINT NOT NULL COMMENT '海外费用来源ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id), + CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0) +) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊'; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 6da109335..33efcbff2 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -678,6 +678,27 @@ paths: required: true content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementReview' } } } responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/files/{category}: + post: + summary: 上传采购签收文件或费用凭证 + description: 权限 platform:overseas-business:purchase:edit;category 仅允许 RECEIPT 或 VOUCHER。 + security: [{ bearerAuth: [] }] + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: category, in: path, required: true, schema: { type: string, enum: [RECEIPT, VOUCHER] } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } + responses: { '200': { description: 已保存文件元数据 } } + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/fees: + post: + summary: 登记采购费用并发起资金冻结 + description: 权限 platform:overseas-business:purchase:edit;订单必须已审核通过,费用凭证必须属于本订单。 + security: [{ bearerAuth: [] }] + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementFeeCreate' } } } } + responses: { '200': { description: 已创建费用并进入冻结流程 }, '409': { description: 订单状态或凭证不允许 } } /api/merchant/v1/last-leg/delivery-orders: get: summary: 客户分页查询自己的派送单 @@ -1641,13 +1662,14 @@ components: RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] } RelabelCreate: type: object - required: [customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items] + required: [customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, chargeAmount, idempotencyKey, items] properties: customerOrderNo: { type: string, maxLength: 128 } overseasWarehouseId: { type: string, maxLength: 128 } labelDeadline: { type: string, format: date-time } contactName: { type: string, maxLength: 128 } contactPhone: { type: string, maxLength: 64 } + chargeAmount: { type: number, minimum: 0.01, description: 换标费用,币种固定 CNY } remark: { type: string, nullable: true, maxLength: 1000 } idempotencyKey: { type: string, maxLength: 255 } items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } } @@ -1764,7 +1786,7 @@ components: updatedAt: { type: string, format: date-time } ProcurementDetail: type: object - required: [summary, histories, outboundDetails] + required: [summary, histories, outboundDetails, files, fees] properties: summary: { $ref: '#/components/schemas/ProcurementSummary' } productUrl: { type: string, nullable: true } @@ -1783,6 +1805,42 @@ components: reviewedAt: { type: string, format: date-time, nullable: true } histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } } outboundDetails: { type: array, items: { $ref: '#/components/schemas/ProcurementOutboundDetail' } } + files: { type: array, items: { $ref: '#/components/schemas/ProcurementFile' } } + fees: { type: array, items: { $ref: '#/components/schemas/ProcurementFee' } } + ProcurementFeeCreate: + type: object + required: [feeItemCode, feeItemName, amount, voucherFileId] + properties: + feeItemCode: { type: string, maxLength: 64 } + feeItemName: { type: string, maxLength: 128 } + amount: { type: number, minimum: 0.01 } + voucherFileId: { type: string, pattern: '^[0-9]+$' } + remark: { type: string, nullable: true, maxLength: 500 } + ProcurementFile: + type: object + properties: + id: { type: string } + category: { type: string, enum: [RECEIPT, VOUCHER] } + fileId: { type: string } + fileName: { type: string } + contentType: { type: string, nullable: true } + sizeBytes: { type: integer, format: int64 } + createdAt: { type: string, format: date-time } + ProcurementFee: + type: object + properties: + id: { type: string } + feeNo: { type: string } + feeItemCode: { type: string } + feeItemName: { type: string } + amount: { type: number } + currency: { type: string, enum: [CNY] } + voucherFileId: { type: string } + voucherFileName: { type: string } + remark: { type: string, nullable: true } + walletStatus: { type: string } + walletErrorMessage: { type: string, nullable: true } + createdAt: { type: string, format: date-time } ProcurementOutboundDetail: type: object properties: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java new file mode 100644 index 000000000..6c35754d0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.inboundplan.service; + +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DestuffingPalletCostServiceTest { + @Test void preservesTotalAndLetsFinalCustomerAbsorbRoundingRemainder() { + List result = DestuffingPalletCostService.allocate(new BigDecimal("10.00"), + List.of(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("1"))); + assertThat(result).containsExactly(new BigDecimal("3.33"), new BigDecimal("3.33"), new BigDecimal("3.34")); + assertThat(result.stream().reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo("10.00"); + } + @Test void rejectsZeroActualVolume() { + assertThatThrownBy(() -> DestuffingPalletCostService.allocate(BigDecimal.ONE, List.of(BigDecimal.ZERO))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index f96d0f514..ca20222a1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -6,6 +6,10 @@ import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFileMapper; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper; +import com.baiyue.lastleg.procurement.gateway.ProcurementFileGateway; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; import com.baiyue.lastleg.masterdata.quote.service.PartnerWarehouseDirectory; import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses; import com.fasterxml.jackson.databind.ObjectMapper; @@ -29,6 +33,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -36,6 +41,11 @@ class OverseasProcurementServiceTest { @Mock OverseasProcurementOrderMapper orders; @Mock OverseasProcurementStatusHistoryMapper histories; @Mock PartnerWarehouseDirectory warehouseDirectory; + @Mock ProcurementOrderNumberGenerator orderNoGenerator; + @Mock OverseasProcurementFileMapper procurementFiles; + @Mock OverseasProcurementFeeMapper procurementFees; + @Mock ProcurementFileGateway fileGateway; + @Mock WalletCommandOutboxService walletCommands; private OverseasProcurementService service; @BeforeEach @@ -45,7 +55,14 @@ class OverseasProcurementServiceTest { ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); - ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); + ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles); + ReflectionTestUtils.setField(service, "procurementFees", procurementFees); + ReflectionTestUtils.setField(service, "fileGateway", fileGateway); + ReflectionTestUtils.setField(service, "walletCommands", walletCommands); + lenient().when(orderNoGenerator.next(anyString(), any())).thenReturn("ACME202609050001"); + lenient().when(procurementFiles.selectList(any())).thenReturn(List.of()); + lenient().when(procurementFees.selectList(any())).thenReturn(List.of()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGeneratorTest.java new file mode 100644 index 000000000..bd8ce150b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGeneratorTest.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.procurement.service; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class ProcurementOrderNumberGeneratorTest { + @Test void convertsChineseCompanyNameToInitials() { + assertThat(ProcurementOrderNumberGenerator.initials("杭州积乐贸易有限公司")).isEqualTo("HZJLMYYX"); + } + @Test void keepsOneInitialPerLatinWordAndUsesFallback() { + assertThat(ProcurementOrderNumberGenerator.initials("Great Ocean Trading LLC")).isEqualTo("GOTL"); + assertThat(ProcurementOrderNumberGenerator.initials(" ")).isEqualTo("KH"); + } + @Test void limitsInitialsToEightCharacters() { + assertThat(ProcurementOrderNumberGenerator.initials("一二三四五六七八九十")).hasSize(8); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java index 5cdcbc004..ebe8452b6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java @@ -36,7 +36,7 @@ class RelabelApplicationControllerTest { private RelabelRequests.Create request() { return new RelabelRequests.Create("CUSTOMER-1", "WH-1", - LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", null, "key-1", + LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", null, null, "key-1", List.of(new RelabelRequests.Item("SKU-A", null, null, null, "Product", 1, 1, "SHIP-1", null, "SKU-B", "Replace", null, null, null))); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index a6b93f678..039d0bf63 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.relabel.service; +import java.math.BigDecimal; + import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.mapper.LastlegOrderMapper; @@ -13,6 +15,7 @@ import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.wallet.WalletCommandOutboxService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -45,6 +48,7 @@ class RelabelApplicationServiceTest { @Mock RelabelStatusHistoryMapper histories; @Mock LastlegOrderMapper orders; @Mock LastlegOrderStatusHistoryMapper orderHistories; + @Mock WalletCommandOutboxService walletCommands; @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); private RelabelApplicationService service; @@ -58,23 +62,25 @@ class RelabelApplicationServiceTest { ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderHistories", orderHistories); ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); + ReflectionTestUtils.setField(service, "walletCommands", walletCommands); ReflectionTestUtils.setField(service, "objectMapper", mapper()); + lenient().when(walletCommands.publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class))).thenReturn("wallet-event-1"); } @Test void createBuildsPendingRelabelTailOrderItemsAndBothHistories() { RelabelRequests.Create request = request("key-1", 10, 6); - RelabelApplication persisted = application(11L, "FBR-1", "TENANT-A", "key-1", null, + RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-1", null, RelabelApplicationService.PENDING_PROCESSING); persisted.setLastlegOrderId(21L); persisted.setItemCount(1); persisted.setTotalPackageCount(10); persisted.setNotifiedPackageCount(6); - when(applications.findNo(eq("TENANT-A"), any())).thenReturn(persisted); + when(applications.findNo(eq("10001"), any())).thenReturn(persisted); when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6))); when(histories.selectList(any())).thenReturn(List.of()); - when(orders.selectById(21L)).thenReturn(order(21L, "TENANT-A", "LMO-RELABEL-1", + when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1", RelabelApplicationService.PENDING_PROCESSING)); - RelabelResponses.Detail result = service.create("TENANT-A", "operator-1", request); + RelabelResponses.Detail result = service.create("10001", "operator-1", request); ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); ArgumentCaptor application = ArgumentCaptor.forClass(RelabelApplication.class); @@ -106,7 +112,7 @@ class RelabelApplicationServiceTest { @Test void createRejectsNotifiedBoxesAboveTotalBeforeAnyWrite() { ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> - service.create("TENANT-A", "operator-1", request("key-1", 5, 6))); + service.create("10001", "operator-1", request("key-1", 5, 6))); assertEquals(400, exception.getStatusCode().value()); verify(orders, never()).insert(any(LastlegOrder.class)); @@ -117,14 +123,14 @@ class RelabelApplicationServiceTest { @Test void sameIdempotencyKeyAndPayloadReturnsExistingApplicationWithoutWrites() { RelabelRequests.Create request = request("key-1", 10, 6); - RelabelApplication existing = application(11L, "FBR-1", "TENANT-A", "key-1", hash(request), + RelabelApplication existing = application(11L, "FBR-1", "10001", "key-1", hash(request), RelabelApplicationService.PENDING_PROCESSING); existing.setLastlegOrderId(21L); existing.setItemCount(1); existing.setTotalPackageCount(10); existing.setNotifiedPackageCount(6); - when(applications.findKey("TENANT-A", "key-1")).thenReturn(existing); + when(applications.findKey("10001", "key-1")).thenReturn(existing); stubDetail(existing); - RelabelResponses.Detail result = service.create("TENANT-A", "operator-1", request); + RelabelResponses.Detail result = service.create("10001", "operator-1", request); assertEquals("FBR-1", result.application().relabelNo()); verify(orders, never()).insert(any(LastlegOrder.class)); @@ -135,12 +141,12 @@ class RelabelApplicationServiceTest { @Test void idempotencyKeyCannotBeReusedWithDifferentRelabelPayload() { RelabelRequests.Create request = request("key-1", 10, 6); - RelabelApplication existing = application(11L, "FBR-1", "TENANT-A", "key-1", "different-hash", + RelabelApplication existing = application(11L, "FBR-1", "10001", "key-1", "different-hash", RelabelApplicationService.PENDING_PROCESSING); - when(applications.findKey("TENANT-A", "key-1")).thenReturn(existing); + when(applications.findKey("10001", "key-1")).thenReturn(existing); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> - service.create("TENANT-A", "operator-1", request)); + service.create("10001", "operator-1", request)); assertEquals(409, exception.getStatusCode().value()); verify(orders, never()).insert(any(LastlegOrder.class)); @@ -148,13 +154,13 @@ class RelabelApplicationServiceTest { @Test void statusChangeIsTenantScopedAndWritesApplicationAndTailOrderHistory() { - RelabelApplication application = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", + RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.PENDING_PROCESSING); application.setLastlegOrderId(21L); - when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(application); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); stubDetail(application); - RelabelResponses.Detail result = service.changeStatus("TENANT-A", "FBR-1", "operator-2", + RelabelResponses.Detail result = service.changeStatus("10001", "FBR-1", "operator-2", new RelabelRequests.ChangeStatus("processing", "started")); assertEquals("PROCESSING", application.getStatus()); @@ -170,32 +176,32 @@ class RelabelApplicationServiceTest { @Test void completedApplicationCannotReturnToProcessingAndOtherTenantCannotReadIt() { - RelabelApplication completed = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", "COMPLETED"); + RelabelApplication completed = application(11L, "FBR-1", "10001", "key-1", "hash", "COMPLETED"); completed.setLastlegOrderId(21L); - when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(completed); + when(applications.lockNo("10001", "FBR-1")).thenReturn(completed); ResponseStatusException transition = assertThrows(ResponseStatusException.class, () -> - service.changeStatus("TENANT-A", "FBR-1", "operator-2", new RelabelRequests.ChangeStatus("PROCESSING", null))); + service.changeStatus("10001", "FBR-1", "operator-2", new RelabelRequests.ChangeStatus("PROCESSING", null))); assertEquals(409, transition.getStatusCode().value()); - when(applications.findNo("TENANT-B", "FBR-1")).thenReturn(null); + when(applications.findNo("10002", "FBR-1")).thenReturn(null); ResponseStatusException boundary = assertThrows(ResponseStatusException.class, () -> - service.detail("TENANT-B", "FBR-1")); + service.detail("10002", "FBR-1")); assertEquals(404, boundary.getStatusCode().value()); - verify(applications).findNo("TENANT-B", "FBR-1"); + verify(applications).findNo("10002", "FBR-1"); } @Test void completedReturnRelabelOrderEntersPendingAssignment() { - RelabelApplication application = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", "PROCESSING"); + RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", "PROCESSING"); application.setLastlegOrderId(21L); - LastlegOrder order = order(21L, "TENANT-A", "LMO-RELABEL-1", "PROCESSING"); + LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PROCESSING"); order.setOrderType("RETURN_RELABEL"); - when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(application); + when(applications.lockNo("10001", "FBR-1")).thenReturn(application); stubDetail(application); when(orders.selectById(21L)).thenReturn(order); - service.changeStatus("TENANT-A", "FBR-1", "operator-2", + service.changeStatus("10001", "FBR-1", "operator-2", new RelabelRequests.ChangeStatus("COMPLETED", "finished")); assertEquals("COMPLETED", application.getStatus()); @@ -213,7 +219,7 @@ class RelabelApplicationServiceTest { private static RelabelRequests.Create request(String key, int total, int notified) { return new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), - "Li", "13800000000", "remark", key, List.of(new RelabelRequests.Item("SKU-A", "img-1", + "Li", "13800000000", new BigDecimal("20.00"), "remark", key, List.of(new RelabelRequests.Item("SKU-A", "img-1", "image.png", "https://files/image.png", "Chinese name", total, notified, "SHP-1", "TRACK-1", "SKU-NEW", "Label on carton", "file-1", "relabel.pdf", "https://files/relabel.pdf"))); } @@ -226,12 +232,13 @@ class RelabelApplicationServiceTest { value.setContactName("Li"); value.setContactPhone("13800000000"); value.setCustomerOrderNo("CUSTOMER-1"); value.setOverseasWarehouseId("WH-1"); value.setLabelDeadline(LocalDateTime.now().plusDays(1)); value.setItemCount(1); value.setTotalPackageCount(10); value.setNotifiedPackageCount(6); + value.setChargeAmount(new BigDecimal("20.00")); value.setChargeCurrency("CNY"); value.setWalletStatus("FROZEN"); return value; } private static RelabelItem item(long id, long applicationId, String sku, int total, int notified) { RelabelItem value = new RelabelItem(); - value.setId(id); value.setApplicationId(applicationId); value.setCustomerId("TENANT-A"); value.setSkuCode(sku); + value.setId(id); value.setApplicationId(applicationId); value.setCustomerId("10001"); value.setSkuCode(sku); value.setProductNameZh("Chinese name"); value.setTotalPackageCount(total); value.setNotifiedPackageCount(notified); value.setShipmentNo("SHP-1"); value.setNewSkuCode("SKU-NEW"); return value; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index 4680db2b1..c0cd97198 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -84,6 +84,9 @@ class TaillegRealConsumerAcceptanceTest { return new WarehousePalletReference(1L, palletCode, 1L, 1L, "AVAILABLE", null, 0); } + public WarehousePalletReference resolvePallet(long palletId) { + return new WarehousePalletReference(palletId, "UNUSED", 1L); + } }; } } @@ -104,7 +107,7 @@ class TaillegRealConsumerAcceptanceTest { if (!exists("ll_relabel_application", MARKER + "-RELABEL-KEY")) { relabel.create(CUSTOMER, "real-acceptance", new RelabelRequests.Create( MARKER + "-RELABEL", "92", RELABEL_DEADLINE, "验收联系人", "13800000000", - MARKER, MARKER + "-RELABEL-KEY", List.of(new RelabelRequests.Item("SKU-REAL-A", null, null, + new java.math.BigDecimal("20.00"), MARKER, MARKER + "-RELABEL-KEY", List.of(new RelabelRequests.Item("SKU-REAL-A", null, null, null, "真实验收商品", 3, 3, MARKER + "-SHIP", MARKER + "-TRACK", "SKU-REAL-A-NEW", "换标验收", null, null, null)))); } diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index db1f39048..e8f6546bc 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -675,9 +675,11 @@ Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceCha Assert-Contains $businessFeeEvent 'int sourceVersion' '海外费用事件来源版本' Assert-Contains $businessFeeEvent 'List paymentVoucherFileIds' '海外费用事件付款证据快照' Assert-Contains $businessFeeEvent 'String sourceHash' '海外费用事件来源哈希' -Assert-Contains $eventBridgeAgents '`BUSINESS_FEE_SOURCE_CHANGED` 定向投 Finance' 'Event Bridge Finance 定向路由' +Assert-Contains $eventBridgeAgents '`BUSINESS_FEE_SOURCE_CHANGED` 与 `WALLET_COMMAND_REQUESTED` 定向投 Finance' 'Event Bridge Finance 定向路由' Assert-Contains $eventBridgeAgents '/internal/finance/v1/business-fee-source-events' 'Event Bridge Finance 内部入口' -Assert-Matches $overseasDispatcher '(?s)EventTypes\.BUSINESS_FEE_SOURCE_CHANGED\.equals\(eventType\).{0,150}?financeEventClient\.deliver\(event\)' 'Event Bridge 海外费用投 Finance' +Assert-Contains $overseasDispatcher 'EventTypes.BUSINESS_FEE_SOURCE_CHANGED' 'Event Bridge 海外费用事件 Finance 路由集合' +Assert-Contains $overseasDispatcher 'EventTypes.WALLET_COMMAND_REQUESTED' 'Event Bridge 海外资金指令 Finance 路由集合' +Assert-Matches $overseasDispatcher '(?s)FINANCE_EVENT_TYPES\.contains\(eventType\).{0,150}?financeEventClient\.deliver\(event\)' 'Event Bridge Finance 事件定向投递' Assert-Contains $financeAgents '`eventId + payloadHash` 防重放冲突' 'Finance 海外事件幂等边界' Assert-Contains $financeAgents '`PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID`' 'Finance 直接已付隔离边界' Assert-Contains $financeEventConsumer 'envelope.payloadHash().equals(row.get("payload_hash"))' 'Finance 海外事件 Payload 哈希幂等' diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java index 1d651fc2d..894d3dd8a 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -32,4 +32,12 @@ public class InternalWarehousePalletController { return ApiResponse.success(service.resolvePublicPallet(palletCode)); } + @GetMapping("/resolve/id/{palletId}") + public ApiResponse resolveById( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long palletId) { + tokenGuard.verify(token); + return ApiResponse.success(service.resolvePublicPallet(palletId)); + } + } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java index b3f271267..b930b438e 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java @@ -47,6 +47,15 @@ public class PlatformWarehouseDirectoryController { return ApiResponse.success(resources.publicPallets(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, query)); } + @GetMapping("/pallets/summary") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')") + public ApiResponse palletSummary( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid PalletRequests.QueryRequest query) { + requirePlatform(user); + return ApiResponse.success(resources.publicPalletSummary(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, query)); + } + @GetMapping @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") @Operation(summary = "Page shared overseas receiving warehouses", diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java index be951ca52..1e18b3afb 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -117,6 +117,14 @@ public class PlatformWarehouseResourceController { return ApiResponse.success(service.pallets(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, query)); } + @GetMapping("/pallets/summary") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')") + public ApiResponse palletSummary(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) { + requirePlatform(user); + return ApiResponse.success(service.palletSummary(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, query)); + } + @GetMapping("/pallets/{id}") @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')") @Operation(summary = "Get a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_LIST) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java index d209bfb06..8680e2d31 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -14,6 +14,7 @@ public final class PalletRequests { private PalletRequests() { } + public record Summary(long totalCount, long idleCount, long occupiedCount, long scrappedCount) { } public record SaveRequest( @Size(max = 128) String palletCode, diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java index be3e2610c..9cd58aea6 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java @@ -7,6 +7,12 @@ public record WarehousePalletResolveResponse( long locationId, String status, String fbaWarehouseCode, + java.math.BigDecimal unitPrice, int version ) { + public WarehousePalletResolveResponse(long palletId, String palletCode, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) { + this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode, + java.math.BigDecimal.ZERO, version); + } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java index 9ebed969c..bcc991bcd 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -125,6 +125,11 @@ public class WarehouseResourceRepository { .orderByAsc(WarehousePalletEntity::getId)); } + public WarehousePalletEntity findPublicPallet(long palletId) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, 0L).eq(WarehousePalletEntity::getId, palletId)); + } + public boolean palletCodeExists(long customerId, long warehouseId, String code, Long excludedId) { return pallets.selectCount(new LambdaQueryWrapper() .eq(WarehousePalletEntity::getCustomerId, customerId) @@ -146,6 +151,20 @@ public class WarehouseResourceRepository { return PageResult.from(pallets.selectPage(new Page<>(query.page(), query.size()), wrapper)); } + public long countPallets(long customerId, long warehouseId, PalletRequests.QueryRequest query, String status) { + return countPallets(customerId, Long.valueOf(warehouseId), query, status); + } + + public long countPallets(long customerId, Long warehouseId, PalletRequests.QueryRequest query, String status) { + return pallets.selectCount(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(warehouseId != null, WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(StringUtils.hasText(query.palletType()), WarehousePalletEntity::getPalletType, query.palletType()) + .eq(StringUtils.hasText(status), WarehousePalletEntity::getStatus, status) + .eq(query.locationId() != null, WarehousePalletEntity::getLocationId, query.locationId()) + .like(StringUtils.hasText(query.keyword()), WarehousePalletEntity::getPalletCode, query.keyword())); + } + public PageResult pagePublicPallets(long customerId, PalletRequests.QueryRequest query) { var wrapper = new LambdaQueryWrapper() diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index f4c47176f..3e95bb267 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -190,6 +190,16 @@ public class WarehouseResourceService { if ("SCRAPPED".equals(pallet.getStatus())) throw new BizException(ResultCode.CONFLICT, "托盘已报废"); return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(), pallet.getLocationId(), pallet.getStatus(), pallet.getFbaWarehouseCode(), + pallet.getUnitPrice(), + pallet.getVersion() == null ? 0 : pallet.getVersion()); + } + + public WarehousePalletResolveResponse resolvePublicPallet(long palletId) { + WarehousePalletEntity pallet = repository.findPublicPallet(palletId); + if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "托盘不存在"); + if (pallet.getLocationId() == null) throw new BizException(ResultCode.CONFLICT, "托盘未绑定库位"); + return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(), + pallet.getLocationId(), pallet.getStatus(), pallet.getFbaWarehouseCode(), pallet.getUnitPrice(), pallet.getVersion() == null ? 0 : pallet.getVersion()); } @@ -218,6 +228,23 @@ public class WarehouseResourceService { return repository.pagePallets(customerId, warehouseId, query); } + public PalletRequests.Summary palletSummary(long customerId, long warehouseId, PalletRequests.QueryRequest query) { + requireWarehouse(customerId, warehouseId); + long total = repository.countPallets(customerId, warehouseId, query, null); + return new PalletRequests.Summary(total, + repository.countPallets(customerId, warehouseId, query, "IDLE"), + repository.countPallets(customerId, warehouseId, query, "OCCUPIED"), + repository.countPallets(customerId, warehouseId, query, "SCRAPPED")); + } + + public PalletRequests.Summary publicPalletSummary(long customerId, PalletRequests.QueryRequest query) { + long total = repository.countPallets(customerId, (Long) null, query, null); + return new PalletRequests.Summary(total, + repository.countPallets(customerId, (Long) null, query, "IDLE"), + repository.countPallets(customerId, (Long) null, query, "OCCUPIED"), + repository.countPallets(customerId, (Long) null, query, "SCRAPPED")); + } + public PageResult publicPallets(long customerId, PalletRequests.QueryRequest query) { return repository.pagePublicPallets(customerId, query); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java index 2296589e1..7a5350c10 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPalletController.java @@ -50,6 +50,14 @@ public class PlatformPalletController { return ApiResponse.success(service.page(0L, query)); } + @GetMapping("/summary") + @PreAuthorize("hasAuthority('platform:pallet:list')") + public ApiResponse summary(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) @Min(0) Long tenantId, @Valid @ParameterObject PalletRequests.PageQuery query) { + requirePlatform(user); requirePlatformScope(tenantId); + return ApiResponse.success(service.summary(0L, query)); + } + @GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:pallet:list')") @Operation(summary = "查询托盘详情") diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java index 8305d7997..f0d32895b 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -75,6 +75,8 @@ public final class PalletRequests { ) { } + public record Summary(long totalCount, long idleCount, long occupiedCount) { } + @Schema(name = "InternalPalletSnapshot", description = "内部只读托盘主数据快照;不包含动态资源状态") public record Snapshot( @Schema(description = "托盘ID") long palletId, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java index 7bfda9710..0ded2a6cd 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletApplicationService.java @@ -9,6 +9,7 @@ import com.baiyue.warehouse.entity.PalletEntity; import com.baiyue.warehouse.repository.PalletRepository; import com.baiyue.warehouse.repository.WarehouseRepository; import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +19,7 @@ import java.util.List; /** 托盘主数据应用服务。 */ @Service public class PalletApplicationService { + @Resource private JdbcTemplate jdbc; private static final int CODE_INSERT_ATTEMPTS = 5; @Resource @@ -31,6 +33,19 @@ public class PalletApplicationService { source.page(), source.size(), source.total(), source.pages()); } + public PalletRequests.Summary summary(long tenantId, PalletRequests.PageQuery query) { + StringBuilder where = new StringBuilder("p.tenant_id=? AND p.deleted=0"); + java.util.List args = new java.util.ArrayList<>(); args.add(tenantId); + if (query.warehouseId() != null) { where.append(" AND p.warehouse_id=?"); args.add(query.warehouseId()); } + if (org.springframework.util.StringUtils.hasText(query.palletCode())) { where.append(" AND p.pallet_code LIKE ?"); args.add("%" + query.palletCode().trim() + "%"); } + if (org.springframework.util.StringUtils.hasText(query.palletType())) { where.append(" AND p.pallet_type=?"); args.add(query.palletType().trim()); } + String active = "EXISTS (SELECT 1 FROM wh_location_reservation r WHERE r.tenant_id=p.tenant_id AND r.warehouse_id=p.warehouse_id AND r.pallet_id=p.id AND r.reservation_status='RESERVED') OR EXISTS (SELECT 1 FROM wh_location_occupancy o WHERE o.tenant_id=p.tenant_id AND o.warehouse_id=p.warehouse_id AND o.pallet_id=p.id AND o.occupancy_status='OCCUPIED')"; + java.util.Map row = jdbc.queryForMap("SELECT COUNT(*) total_count,SUM(CASE WHEN " + active + " THEN 1 ELSE 0 END) occupied_count FROM wh_pallet p WHERE " + where, args.toArray()); + long total = ((Number) row.get("total_count")).longValue(); + long occupied = row.get("occupied_count") == null ? 0 : ((Number) row.get("occupied_count")).longValue(); + return new PalletRequests.Summary(total, total - occupied, occupied); + } + public PalletRequests.View detail(long tenantId, long warehouseId, long id) { return view(owned(tenantId, warehouseId, id)); } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java index 110f24a07..8d588f5de 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletApplicationServiceTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.jdbc.core.JdbcTemplate; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; @@ -24,8 +25,18 @@ import java.util.List; class PalletApplicationServiceTest { @MockBean private PalletRepository repository; @MockBean private WarehouseRepository warehouseRepository; + @MockBean private JdbcTemplate jdbcTemplate; @Resource private PalletApplicationService service; + @Test + void summarizesIdleAndOccupiedPallets() { + PalletRequests.PageQuery query = new PalletRequests.PageQuery(1, 20, 20L, "PLT", "木托盘"); + when(jdbcTemplate.queryForMap(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(Object[].class))) + .thenReturn(java.util.Map.of("total_count", 7L, "occupied_count", 3L)); + org.assertj.core.api.Assertions.assertThat(service.summary(0, query)) + .extracting("totalCount", "idleCount", "occupiedCount").containsExactly(7L, 4L, 3L); + } + @Test void shouldHandleCase01() { PalletEntity entity = pallet();