feat: close overseas wallet and pallet cost flow

This commit is contained in:
2026-09-05 22:26:25 +08:00
parent f62283fb7f
commit 5fbd2bbd04
69 changed files with 1598 additions and 53 deletions
+1
View File
@@ -20,6 +20,7 @@
| 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | | 海外入仓单暂扣变更 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 | `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 | `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`;账号、授权快照等安全事件的冻结模型 | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
| 契约说明 | `package-info.java` | | 契约说明 | `package-info.java` |
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` | | 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
@@ -15,6 +15,8 @@ public final class EventTypes {
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged"; public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged";
public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"; 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_USER_STATUS_CHANGED = "IamUserStatusChanged";
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged"; public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
@@ -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;
}
@@ -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;
}
+1
View File
@@ -12,3 +12,4 @@ baiyue:
consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/business-fee-source-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}
+2
View File
@@ -117,6 +117,7 @@ services:
FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME} FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME}
FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD} FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD}
FINANCE_WALLET_DEFAULT_CREDIT_LIMIT: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:-0.00} 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: order:
<<: *common <<: *common
@@ -238,6 +239,7 @@ services:
EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} 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} 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_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 SPRING_SQL_INIT_MODE: never
networks: networks:
firstleg-network: firstleg-network:
@@ -17874,3 +17874,37 @@ CALL migrate_wallet_permissions();
DROP PROCEDURE IF EXISTS migrate_wallet_permissions; DROP PROCEDURE IF EXISTS migrate_wallet_permissions;
SELECT '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、客户资金账户、公共资源、可靠审计及IAM安全事件结构校验通过' AS message; 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到海外资金结果可靠发送箱';
@@ -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) KEY idx_ll_finance_fee_command_source(source_fee_id,source_version)
) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计'; ) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计';
SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message; 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='拆柜打托成本按客户实收体积分摊';
+2 -2
View File
@@ -13,10 +13,10 @@
|---|---| |---|---|
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | | 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
| Inbox 接收与幂等 | `service/EventInboxService.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/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | | 国内头程 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` | | 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` |
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | | Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` | | 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` |
@@ -1,6 +1,7 @@
package com.baiyue.eventbridge.service; package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode; import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.domain.BridgeInbox;
@@ -24,6 +25,8 @@ public class HttpFinanceEventClient implements FinanceEventClient {
@Resource private EventBridgeHttpTracePropagation tracePropagation; @Resource private EventBridgeHttpTracePropagation tracePropagation;
@Value("${baiyue.event-dispatcher.finance-consumer-url:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events}") @Value("${baiyue.event-dispatcher.finance-consumer-url:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events}")
private URI consumerUrl; 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; @Value("${baiyue.security.internal-service-token:}") private String serviceToken;
private HttpClient httpClient; private HttpClient httpClient;
@@ -39,7 +42,9 @@ public class HttpFinanceEventClient implements FinanceEventClient {
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); 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") .timeout(properties.requestTimeout()).header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken)) .header("X-Internal-Service-Token", serviceToken))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
@@ -90,7 +90,7 @@ public class OverseasEventDispatcher {
throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者"); throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者");
} }
return switch (event.getTargetRegion()) { 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); ? financeEventClient.deliver(event) : firstlegEventClient.deliver(event);
case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType) case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType)
? securityProjectionEventClient.deliver(event) : eventClient.deliver(event); ? securityProjectionEventClient.deliver(event) : eventClient.deliver(event);
@@ -103,6 +103,10 @@ public class OverseasEventDispatcher {
EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_TENANT_STATUS_CHANGED,
EventTypes.IAM_PERMISSION_VERSION_CHANGED); EventTypes.IAM_PERMISSION_VERSION_CHANGED);
private static final Set<String> FINANCE_EVENT_TYPES = Set.of(
EventTypes.BUSINESS_FEE_SOURCE_CHANGED,
EventTypes.WALLET_COMMAND_REQUESTED);
private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) {
BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt();
attempt.setEventId(event.getEventId()); attempt.setEventId(event.getEventId());
@@ -12,6 +12,8 @@ enum BusinessFileCategory {
CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
CUSTOMS_RECEIPT(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), 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), CONTAINER_CLEARANCE_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
LOADING_CUSTOMS_DECLARATION(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), CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -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<Acknowledgement> receive(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
record Acknowledgement(String eventId, String result, String receivedAt) { }
}
@@ -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<Map<String, String>> 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)));
}
}
@@ -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<String> 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<String> 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);
}
}
@@ -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<String, Object> 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<String> 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<DATE_SUB(NOW(3),INTERVAL 2 MINUTE)");
}
private static String safe(String value) {
String text = value == null ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " ");
return text.length() <= 1000 ? text : text.substring(0, 1000);
}
}
@@ -26,3 +26,7 @@ spring:
namespace: ${NACOS_NAMESPACE:public} namespace: ${NACOS_NAMESPACE:public}
management.endpoints.web.exposure.include: health,info,prometheus management.endpoints.web.exposure.include: health,info,prometheus
baiyue:
wallet-event:
event-bridge-url: ${OVERSEAS_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}
+32
View File
@@ -538,3 +538,35 @@ CREATE TABLE IF NOT EXISTS fin_wallet_hold (
CONSTRAINT chk_fin_wallet_hold_amount CHECK (amount > 0 AND actual_frozen_amount >= 0 AND credit_frozen_amount >= 0 AND amount=actual_frozen_amount+credit_frozen_amount), CONSTRAINT chk_fin_wallet_hold_amount CHECK (amount > 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')) CONSTRAINT chk_fin_wallet_hold_status CHECK (status IN ('FROZEN','SETTLED','RELEASED'))
) ENGINE=InnoDB COMMENT='客户资金冻结及结算生命周期'; ) 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到海外资金结果可靠发送箱';
@@ -43,8 +43,8 @@ class FinanceSqlCommentContractTest {
missing.add(currentTable + "." + column.group(1)); missing.add(currentTable + "." + column.group(1));
} }
} }
assertThat(tableCount).isEqualTo(21); assertThat(tableCount).isEqualTo(23);
assertThat(columnCount).isEqualTo(330); assertThat(columnCount).isEqualTo(350);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty(); assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
} }
@@ -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<TransactionStatus> 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();
}
}
+1
View File
@@ -20,6 +20,7 @@
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency> <dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency> <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.tngtech.archunit</groupId><artifactId>archunit</artifactId><scope>test</scope></dependency> <dependency><groupId>com.tngtech.archunit</groupId><artifactId>archunit</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.belerweb</groupId><artifactId>pinyin4j</artifactId><version>2.5.1</version></dependency>
</dependencies> </dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build> <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project> </project>
@@ -11,6 +11,7 @@ public class LastlegRestClientProperties {
private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30));
private ClientTimeouts relabelPhotoFile = 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 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 warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5));
private ClientTimeouts palletPhysical = 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 void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); }
public ClientTimeouts getCustomerVisitFile() { return customerVisitFile; } public ClientTimeouts getCustomerVisitFile() { return customerVisitFile; }
public void setCustomerVisitFile(ClientTimeouts value) { customerVisitFile = required(value, "customer-visit-file"); } 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 ClientTimeouts getWarehouseResource() { return warehouseResource; }
public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); } public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); }
public ClientTimeouts getPalletPhysical() { return palletPhysical; } public ClientTimeouts getPalletPhysical() { return palletPhysical; }
@@ -16,6 +16,7 @@ import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.service.LoadingCompletedConsumer; import com.baiyue.lastleg.service.LoadingCompletedConsumer;
import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer;
import com.baiyue.common.event.EventTypes; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -45,6 +46,7 @@ public class LastlegOrderController {
@Resource private ContainerShippingNodeConsumer shippingNodeConsumer; @Resource private ContainerShippingNodeConsumer shippingNodeConsumer;
@Resource private LastlegOrderService orderService; @Resource private LastlegOrderService orderService;
@Resource private InternalServiceTokenGuard tokenGuard; @Resource private InternalServiceTokenGuard tokenGuard;
@Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer;
@PostMapping("/internal/lastleg/events") @PostMapping("/internal/lastleg/events")
@Operation(summary = "Receive a first-leg loading or actual shipping-node event", @Operation(summary = "Receive a first-leg loading or actual shipping-node event",
@@ -56,6 +58,8 @@ public class LastlegOrderController {
int consumed; int consumed;
if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) { if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) {
consumed = shippingNodeConsumer.consume(event); 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())) { } else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) {
consumed = consumer.consume(event); consumed = consumer.consume(event);
} else { } else {
@@ -45,7 +45,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response"); if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response");
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
response.data().warehouseId(), response.data().locationId(), response.data().status(), 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 @Override
@@ -122,7 +122,20 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
} }
return new WarehousePalletReference(response.data().id(), response.data().palletCode(), return new WarehousePalletReference(response.data().id(), response.data().palletCode(),
response.data().warehouseId(), response.data().locationId(), response.data().status(), 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) { 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 WarehousePalletResponse(String code, String message, WarehousePalletData data) {}
private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId, 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 ReserveEnvelope(ReserveData data) {}
private record ReserveData(long lockId) {} private record ReserveData(long lockId) {}
private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {} private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {}
@@ -8,6 +8,7 @@ public interface WarehouseResourceGateway {
void scrap(String customerId, long resourceLockId); void scrap(String customerId, long resourceLockId);
void scrapPallet(String customerId, long palletId); void scrapPallet(String customerId, long palletId);
WarehousePalletReference resolvePallet(String palletCode); WarehousePalletReference resolvePallet(String palletCode);
WarehousePalletReference resolvePallet(long palletId);
default WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) { default WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) {
throw new UnsupportedOperationException("Warehouse location resolution is not configured"); 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, 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) { 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);
} }
} }
@@ -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.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; 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.model.DropshipPalletSnapshot;
import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrder;
@@ -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);
}
}
}
}
@@ -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<String> 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<Map<String,Object>> 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<Long> 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<CustomerVolume> 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<CustomerVolume> ordered = new ArrayList<>(volumes);
ordered.sort(Comparator.comparing(CustomerVolume::customerId));
List<BigDecimal> amounts = allocate(totalCost, ordered.stream().map(CustomerVolume::volume).toList());
for (int i=0; i<ordered.size(); i++) {
CustomerVolume volume = ordered.get(i);
BigDecimal amount = amounts.get(i);
OverseasFeeSource fee = fee(costId, bookingId, containerNo, warehouseId, volume, amount, operatorId);
feeSources.insert(fee); feeOutbox.publish(fee, "PUBLISHED");
jdbc.update("INSERT INTO ll_destuff_pallet_cost_allocation(id,cost_id,customer_id,actual_volume_cbm,allocated_amount,fee_source_id,created_at) VALUES(?,?,?,?,?,?,NOW(3))",
IdWorker.getId(), costId, volume.customerId(), volume.volume(), amount, fee.getSourceFeeId());
}
}
static List<BigDecimal> allocate(BigDecimal totalCost, List<BigDecimal> volumes) {
BigDecimal totalVolume = volumes.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (totalVolume.signum() <= 0) throw new IllegalArgumentException("实收体积必须大于零");
List<BigDecimal> 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){}
}
@@ -45,6 +45,17 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
return response.data(); 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) { private ResponseStatusException palletLookupFailure(HttpStatusCode status) {
return new ResponseStatusException(status.value() == 404 ? HttpStatus.NOT_FOUND return new ResponseStatusException(status.value() == 404 ? HttpStatus.NOT_FOUND
: (status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE), : (status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE),
@@ -2,13 +2,20 @@ package com.baiyue.lastleg.palletops.gateway;
public interface PalletPhysicalGateway { public interface PalletPhysicalGateway {
PalletReference resolve(String palletCode); PalletReference resolve(String palletCode);
PalletReference resolve(long palletId);
LocationReference resolveLocation(long warehouseId, long locationId); LocationReference resolveLocation(long warehouseId, long locationId);
Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId, Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId,
String operationKey, String reason); String operationKey, String reason);
void compensate(long warehouseId, Transition transition); void compensate(long warehouseId, Transition transition);
record PalletReference(long palletId, String palletCode, long warehouseId, long locationId, 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 LocationReference(long locationId, String locationCode, String locationType, String status) { }
record Transition(String operationKey, long palletId, String palletCode, long warehouseId, record Transition(String operationKey, long palletId, String palletCode, long warehouseId,
String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId, String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId,
@@ -21,6 +21,9 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; 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 org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
@@ -91,6 +94,25 @@ public class PlatformOverseasProcurementController {
return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request)); 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<OverseasProcurementResponses.FileInfo> 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<OverseasProcurementResponses.Fee> 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) { private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
@@ -53,6 +53,13 @@ public final class OverseasProcurementRequests {
@NotBlank @Size(max = 255) String idempotencyKey, @NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) String remark) {} @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( public record Update(
@NotNull @Min(0) Long version, @NotNull @Min(0) Long version,
@Size(max = 128) String purchaseCategory, @Size(max = 128) String purchaseCategory,
@@ -27,6 +27,14 @@ public final class OverseasProcurementResponses {
String idempotencyKey, String changeDetail, String remark, String idempotencyKey, String changeDetail, String remark,
LocalDateTime createdAt) {} 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( public record Detail(
Summary summary, String productUrl, String receivingAddress, Summary summary, String productUrl, String receivingAddress,
String contactName, String contactPhone, String contactName, String contactPhone,
@@ -34,5 +42,6 @@ public final class OverseasProcurementResponses {
String overseasCarrier, String overseasTrackingNo, String overseasCarrier, String overseasTrackingNo,
LocalDateTime estimatedArrivalAt, String remark, LocalDateTime estimatedArrivalAt, String remark,
String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt, String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt,
List<History> histories, List<OutboundDetail> outboundDetails) {} List<History> histories, List<OutboundDetail> outboundDetails,
List<FileInfo> files, List<Fee> fees) {}
} }
@@ -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;
}
@@ -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;
}
@@ -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) { }
}
@@ -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) { }
}
@@ -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<OverseasProcurementFee> { }
@@ -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<OverseasProcurementFile> { }
@@ -9,6 +9,14 @@ import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory; 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.OverseasProcurementOrderMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper;
import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.security.OverseasPermissions;
@@ -34,10 +42,11 @@ import java.util.Map;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import org.springframework.web.multipart.MultipartFile;
@Service @Service
public class OverseasProcurementService { public class OverseasProcurementService implements WalletResultHandler {
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; @Resource private ProcurementOrderNumberGenerator orderNoGenerator;
public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW; public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW;
public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT; public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT;
public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String PENDING_REVIEW = "PENDING_REVIEW";
@@ -49,6 +58,10 @@ public class OverseasProcurementService {
@Resource private OverseasProcurementStatusHistoryMapper histories; @Resource private OverseasProcurementStatusHistoryMapper histories;
@Resource private PartnerWarehouseDirectory warehouseDirectory; @Resource private PartnerWarehouseDirectory warehouseDirectory;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private OverseasProcurementFileMapper procurementFiles;
@Resource private OverseasProcurementFeeMapper procurementFees;
@Resource private ProcurementFileGateway fileGateway;
@Resource private WalletCommandOutboxService walletCommands;
@Transactional @Transactional
public OverseasProcurementResponses.Detail create(String customerId, String operatorId, public OverseasProcurementResponses.Detail create(String customerId, String operatorId,
@@ -63,7 +76,7 @@ public class OverseasProcurementService {
order.setId(IdWorker.getId()); order.setId(IdWorker.getId());
order.setCustomerId(customerId); order.setCustomerId(customerId);
order.setCustomerName(trim(request.customerName())); 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.setPurchaseCategory(request.purchaseCategory().trim());
order.setProductName(request.productName().trim()); order.setProductName(request.productName().trim());
order.setProductSpecification(trim(request.productSpecification())); order.setProductSpecification(trim(request.productSpecification()));
@@ -148,7 +161,90 @@ public class OverseasProcurementService {
new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(), new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(), 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<OverseasProcurementFile>()
.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<OverseasProcurementFee>()
.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<OverseasProcurementResponses.FileInfo> files(OverseasProcurementOrder order) {
return procurementFiles.selectList(new LambdaQueryWrapper<OverseasProcurementFile>()
.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<OverseasProcurementResponses.Fee> fees(OverseasProcurementOrder order) {
return procurementFees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>()
.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 @Transactional
@@ -192,6 +288,7 @@ public class OverseasProcurementService {
Map<String, Object> before = editableSnapshot(order); Map<String, Object> before = editableSnapshot(order);
if (request.inboundQuantity() != null) { if (request.inboundQuantity() != null) {
if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can receive stock"); 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()); order.setInboundQuantity(request.inboundQuantity());
} }
setIfText(request.purchaseCategory(), order::setPurchaseCategory); setIfText(request.purchaseCategory(), order::setPurchaseCategory);
@@ -213,6 +310,14 @@ public class OverseasProcurementService {
orders.updateById(order); orders.updateById(order);
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
null, changeDetail(before, after), null, now); null, changeDetail(before, after), null, now);
if (order.getInboundQuantity() >= order.getPurchaseQuantity()) {
for (OverseasProcurementFee fee : procurementFees.selectList(new LambdaQueryWrapper<OverseasProcurementFee>()
.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); return detail(customerId, orderNo);
} }
@@ -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));
}
}
@@ -11,6 +11,8 @@ import jakarta.validation.constraints.Size;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.math.BigDecimal;
import jakarta.validation.constraints.DecimalMin;
public final class RelabelRequests { public final class RelabelRequests {
private RelabelRequests() {} private RelabelRequests() {}
@@ -21,6 +23,7 @@ public final class RelabelRequests {
@NotNull LocalDateTime labelDeadline, @NotNull LocalDateTime labelDeadline,
@NotBlank @Size(max = 128) String contactName, @NotBlank @Size(max = 128) String contactName,
@NotBlank @Size(max = 64) String contactPhone, @NotBlank @Size(max = 64) String contactPhone,
@NotNull @DecimalMin(value = "0.01") BigDecimal chargeAmount,
@Size(max = 1000) String remark, @Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey, @NotBlank @Size(max = 255) String idempotencyKey,
@NotEmpty @Size(max = 500) List<@Valid Item> items @NotEmpty @Size(max = 500) List<@Valid Item> items
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.relabel.dto;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.math.BigDecimal;
public final class RelabelResponses { public final class RelabelResponses {
private RelabelResponses() {} private RelabelResponses() {}
@@ -10,6 +11,8 @@ public final class RelabelResponses {
String customerOrderNo, String overseasWarehouseId, LocalDateTime labelDeadline, String customerOrderNo, String overseasWarehouseId, LocalDateTime labelDeadline,
String contactName, String contactPhone, String status, int itemCount, String contactName, String contactPhone, String status, int itemCount,
int totalPackageCount, int notifiedPackageCount, String skuCodes, int totalPackageCount, int notifiedPackageCount, String skuCodes,
BigDecimal chargeAmount, String chargeCurrency, String walletStatus,
String walletErrorMessage,
LocalDateTime createdAt) {} LocalDateTime createdAt) {}
public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount, String productImageUrl, String productNameZh, int totalPackageCount,
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.math.BigDecimal;
@Data @Data
@TableName("ll_relabel_application") @TableName("ll_relabel_application")
@@ -24,6 +25,12 @@ public class RelabelApplication {
private Integer itemCount; private Integer itemCount;
private Integer totalPackageCount; private Integer totalPackageCount;
private Integer notifiedPackageCount; 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 idempotencyKey;
private String requestHash; private String requestHash;
private String createdBy; private String createdBy;
@@ -17,6 +17,9 @@ import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator; 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 com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -36,7 +39,7 @@ import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
public class RelabelApplicationService { public class RelabelApplicationService implements WalletResultHandler {
public static final String PENDING_PROCESSING = "PENDING_PROCESSING"; public static final String PENDING_PROCESSING = "PENDING_PROCESSING";
private static final Set<String> STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", "COMPLETED", "REJECTED", "CANCELLED"); private static final Set<String> STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", "COMPLETED", "REJECTED", "CANCELLED");
@@ -47,6 +50,7 @@ public class RelabelApplicationService {
@Resource private LastlegOrderStatusHistoryMapper orderHistories; @Resource private LastlegOrderStatusHistoryMapper orderHistories;
@Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private LastlegOrderNoGenerator orderNoGenerator;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private WalletCommandOutboxService walletCommands;
@Transactional @Transactional
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) { public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
@@ -109,6 +113,9 @@ public class RelabelApplicationService {
application.setItemCount(request.items().size()); application.setItemCount(request.items().size());
application.setTotalPackageCount(total); application.setTotalPackageCount(total);
application.setNotifiedPackageCount(notified); application.setNotifiedPackageCount(notified);
application.setChargeAmount(request.chargeAmount());
application.setChargeCurrency("CNY");
application.setWalletStatus("FREEZE_PENDING");
application.setIdempotencyKey(request.idempotencyKey().trim()); application.setIdempotencyKey(request.idempotencyKey().trim());
application.setRequestHash(requestHash); application.setRequestHash(requestHash);
application.setCreatedBy(trim(operatorId)); application.setCreatedBy(trim(operatorId));
@@ -116,6 +123,11 @@ public class RelabelApplicationService {
application.setUpdatedAt(now); application.setUpdatedAt(now);
applications.insert(application); 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); for (RelabelRequests.Item source : request.items()) insertItem(customerId, applicationId, source, now);
insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now);
insertOrderHistory(customerId, order.getId(), null, order.getCurrentStatus(), insertOrderHistory(customerId, order.getId(), null, order.getCurrentStatus(),
@@ -172,11 +184,23 @@ public class RelabelApplicationService {
if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status"); if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status");
if (next.equals(application.getStatus())) return detail(customerId, relabelNo); if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
validateTransition(application.getStatus(), next); 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(); String before = application.getStatus();
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
application.setStatus(next); application.setStatus(next);
application.setUpdatedAt(now); application.setUpdatedAt(now);
applications.updateById(application); 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()); LastlegOrder order = orders.selectById(application.getLastlegOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing"); if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing");
String orderBefore = order.getCurrentStatus(); String orderBefore = order.getCurrentStatus();
@@ -221,7 +245,31 @@ public class RelabelApplicationService {
value.getLabelDeadline(), value.getContactName(), value.getContactPhone(), value.getStatus(), value.getLabelDeadline(), value.getContactName(), value.getContactPhone(), value.getStatus(),
Objects.requireNonNullElse(value.getItemCount(), detailItems.size()), Objects.requireNonNullElse(value.getItemCount(), detailItems.size()),
Objects.requireNonNullElse(value.getTotalPackageCount(), 0), 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) { private RelabelResponses.Item item(RelabelItem value) {
@@ -15,7 +15,8 @@ import java.util.List;
public class UnloadCompletedOutboxRepository { public class UnloadCompletedOutboxRepository {
private static final List<String> OVERSEAS_TO_CN_TYPES = List.of( private static final List<String> OVERSEAS_TO_CN_TYPES = List.of(
EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_CONTAINER_UNLOADED,
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED); EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED,
EventTypes.WALLET_COMMAND_REQUESTED);
@Resource private LastlegEventOutboxMapper mapper; @Resource private LastlegEventOutboxMapper mapper;
public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
@@ -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<WalletResultHandler> handlers;
@Transactional
public int consume(EventEnvelope envelope) {
WalletCommandCompletedV1 result = validate(envelope);
List<String> 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); }
}
@@ -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); }
}
}
@@ -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);
}
@@ -1421,6 +1421,12 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application (
item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量',
total_package_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 '已通知箱数', 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 '业务幂等键', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
created_by VARCHAR(128) NULL COMMENT '创建人ID', 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'), '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), ('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'); '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='拆柜打托成本按客户实收体积分摊';
@@ -678,6 +678,27 @@ paths:
required: true required: true
content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementReview' } } } content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementReview' } } }
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } 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: /api/merchant/v1/last-leg/delivery-orders:
get: get:
summary: 客户分页查询自己的派送单 summary: 客户分页查询自己的派送单
@@ -1641,13 +1662,14 @@ components:
RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] } RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] }
RelabelCreate: RelabelCreate:
type: object type: object
required: [customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items] required: [customerOrderNo, overseasWarehouseId, labelDeadline, contactName, contactPhone, chargeAmount, idempotencyKey, items]
properties: properties:
customerOrderNo: { type: string, maxLength: 128 } customerOrderNo: { type: string, maxLength: 128 }
overseasWarehouseId: { type: string, maxLength: 128 } overseasWarehouseId: { type: string, maxLength: 128 }
labelDeadline: { type: string, format: date-time } labelDeadline: { type: string, format: date-time }
contactName: { type: string, maxLength: 128 } contactName: { type: string, maxLength: 128 }
contactPhone: { type: string, maxLength: 64 } contactPhone: { type: string, maxLength: 64 }
chargeAmount: { type: number, minimum: 0.01, description: 换标费用,币种固定 CNY }
remark: { type: string, nullable: true, maxLength: 1000 } remark: { type: string, nullable: true, maxLength: 1000 }
idempotencyKey: { type: string, maxLength: 255 } idempotencyKey: { type: string, maxLength: 255 }
items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } } items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } }
@@ -1764,7 +1786,7 @@ components:
updatedAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time }
ProcurementDetail: ProcurementDetail:
type: object type: object
required: [summary, histories, outboundDetails] required: [summary, histories, outboundDetails, files, fees]
properties: properties:
summary: { $ref: '#/components/schemas/ProcurementSummary' } summary: { $ref: '#/components/schemas/ProcurementSummary' }
productUrl: { type: string, nullable: true } productUrl: { type: string, nullable: true }
@@ -1783,6 +1805,42 @@ components:
reviewedAt: { type: string, format: date-time, nullable: true } reviewedAt: { type: string, format: date-time, nullable: true }
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } } histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
outboundDetails: { type: array, items: { $ref: '#/components/schemas/ProcurementOutboundDetail' } } 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: ProcurementOutboundDetail:
type: object type: object
properties: properties:
@@ -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<BigDecimal> 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);
}
}
@@ -6,6 +6,10 @@ import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper;
import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; 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.service.PartnerWarehouseDirectory;
import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses; import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses;
import com.fasterxml.jackson.databind.ObjectMapper; 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.ArgumentMatchers.anyString;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -36,6 +41,11 @@ class OverseasProcurementServiceTest {
@Mock OverseasProcurementOrderMapper orders; @Mock OverseasProcurementOrderMapper orders;
@Mock OverseasProcurementStatusHistoryMapper histories; @Mock OverseasProcurementStatusHistoryMapper histories;
@Mock PartnerWarehouseDirectory warehouseDirectory; @Mock PartnerWarehouseDirectory warehouseDirectory;
@Mock ProcurementOrderNumberGenerator orderNoGenerator;
@Mock OverseasProcurementFileMapper procurementFiles;
@Mock OverseasProcurementFeeMapper procurementFees;
@Mock ProcurementFileGateway fileGateway;
@Mock WalletCommandOutboxService walletCommands;
private OverseasProcurementService service; private OverseasProcurementService service;
@BeforeEach @BeforeEach
@@ -45,7 +55,14 @@ class OverseasProcurementServiceTest {
ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory); ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); 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 @Test
@@ -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);
}
}
@@ -36,7 +36,7 @@ class RelabelApplicationControllerTest {
private RelabelRequests.Create request() { private RelabelRequests.Create request() {
return new RelabelRequests.Create("CUSTOMER-1", "WH-1", 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, List.of(new RelabelRequests.Item("SKU-A", null, null, null, "Product", 1, 1,
"SHIP-1", null, "SKU-B", "Replace", null, null, null))); "SHIP-1", null, "SKU-B", "Replace", null, null, null)));
} }
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.relabel.service; package com.baiyue.lastleg.relabel.service;
import java.math.BigDecimal;
import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper; 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.RelabelItemMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.baiyue.lastleg.wallet.WalletCommandOutboxService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -45,6 +48,7 @@ class RelabelApplicationServiceTest {
@Mock RelabelStatusHistoryMapper histories; @Mock RelabelStatusHistoryMapper histories;
@Mock LastlegOrderMapper orders; @Mock LastlegOrderMapper orders;
@Mock LastlegOrderStatusHistoryMapper orderHistories; @Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock WalletCommandOutboxService walletCommands;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
private RelabelApplicationService service; private RelabelApplicationService service;
@@ -58,23 +62,25 @@ class RelabelApplicationServiceTest {
ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories); ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
ReflectionTestUtils.setField(service, "walletCommands", walletCommands);
ReflectionTestUtils.setField(service, "objectMapper", mapper()); 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 @Test
void createBuildsPendingRelabelTailOrderItemsAndBothHistories() { void createBuildsPendingRelabelTailOrderItemsAndBothHistories() {
RelabelRequests.Create request = request("key-1", 10, 6); 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); RelabelApplicationService.PENDING_PROCESSING);
persisted.setLastlegOrderId(21L); persisted.setLastlegOrderId(21L);
persisted.setItemCount(1); persisted.setTotalPackageCount(10); persisted.setNotifiedPackageCount(6); 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(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6)));
when(histories.selectList(any())).thenReturn(List.of()); 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)); RelabelApplicationService.PENDING_PROCESSING));
RelabelResponses.Detail result = service.create("TENANT-A", "operator-1", request); RelabelResponses.Detail result = service.create("10001", "operator-1", request);
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
ArgumentCaptor<RelabelApplication> application = ArgumentCaptor.forClass(RelabelApplication.class); ArgumentCaptor<RelabelApplication> application = ArgumentCaptor.forClass(RelabelApplication.class);
@@ -106,7 +112,7 @@ class RelabelApplicationServiceTest {
@Test @Test
void createRejectsNotifiedBoxesAboveTotalBeforeAnyWrite() { void createRejectsNotifiedBoxesAboveTotalBeforeAnyWrite() {
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> 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()); assertEquals(400, exception.getStatusCode().value());
verify(orders, never()).insert(any(LastlegOrder.class)); verify(orders, never()).insert(any(LastlegOrder.class));
@@ -117,14 +123,14 @@ class RelabelApplicationServiceTest {
@Test @Test
void sameIdempotencyKeyAndPayloadReturnsExistingApplicationWithoutWrites() { void sameIdempotencyKeyAndPayloadReturnsExistingApplicationWithoutWrites() {
RelabelRequests.Create request = request("key-1", 10, 6); 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); RelabelApplicationService.PENDING_PROCESSING);
existing.setLastlegOrderId(21L); existing.setItemCount(1); existing.setTotalPackageCount(10); existing.setLastlegOrderId(21L); existing.setItemCount(1); existing.setTotalPackageCount(10);
existing.setNotifiedPackageCount(6); existing.setNotifiedPackageCount(6);
when(applications.findKey("TENANT-A", "key-1")).thenReturn(existing); when(applications.findKey("10001", "key-1")).thenReturn(existing);
stubDetail(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()); assertEquals("FBR-1", result.application().relabelNo());
verify(orders, never()).insert(any(LastlegOrder.class)); verify(orders, never()).insert(any(LastlegOrder.class));
@@ -135,12 +141,12 @@ class RelabelApplicationServiceTest {
@Test @Test
void idempotencyKeyCannotBeReusedWithDifferentRelabelPayload() { void idempotencyKeyCannotBeReusedWithDifferentRelabelPayload() {
RelabelRequests.Create request = request("key-1", 10, 6); 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); 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, () -> ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
service.create("TENANT-A", "operator-1", request)); service.create("10001", "operator-1", request));
assertEquals(409, exception.getStatusCode().value()); assertEquals(409, exception.getStatusCode().value());
verify(orders, never()).insert(any(LastlegOrder.class)); verify(orders, never()).insert(any(LastlegOrder.class));
@@ -148,13 +154,13 @@ class RelabelApplicationServiceTest {
@Test @Test
void statusChangeIsTenantScopedAndWritesApplicationAndTailOrderHistory() { 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); RelabelApplicationService.PENDING_PROCESSING);
application.setLastlegOrderId(21L); application.setLastlegOrderId(21L);
when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(application); when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
stubDetail(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")); new RelabelRequests.ChangeStatus("processing", "started"));
assertEquals("PROCESSING", application.getStatus()); assertEquals("PROCESSING", application.getStatus());
@@ -170,32 +176,32 @@ class RelabelApplicationServiceTest {
@Test @Test
void completedApplicationCannotReturnToProcessingAndOtherTenantCannotReadIt() { 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); 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, () -> 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()); 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, () -> ResponseStatusException boundary = assertThrows(ResponseStatusException.class, () ->
service.detail("TENANT-B", "FBR-1")); service.detail("10002", "FBR-1"));
assertEquals(404, boundary.getStatusCode().value()); assertEquals(404, boundary.getStatusCode().value());
verify(applications).findNo("TENANT-B", "FBR-1"); verify(applications).findNo("10002", "FBR-1");
} }
@Test @Test
void completedReturnRelabelOrderEntersPendingAssignment() { 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); 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"); order.setOrderType("RETURN_RELABEL");
when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(application); when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
stubDetail(application); stubDetail(application);
when(orders.selectById(21L)).thenReturn(order); 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")); new RelabelRequests.ChangeStatus("COMPLETED", "finished"));
assertEquals("COMPLETED", application.getStatus()); assertEquals("COMPLETED", application.getStatus());
@@ -213,7 +219,7 @@ class RelabelApplicationServiceTest {
private static RelabelRequests.Create request(String key, int total, int notified) { 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), 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", "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"))); "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.setContactName("Li"); value.setContactPhone("13800000000"); value.setCustomerOrderNo("CUSTOMER-1");
value.setOverseasWarehouseId("WH-1"); value.setLabelDeadline(LocalDateTime.now().plusDays(1)); value.setOverseasWarehouseId("WH-1"); value.setLabelDeadline(LocalDateTime.now().plusDays(1));
value.setItemCount(1); value.setTotalPackageCount(10); value.setNotifiedPackageCount(6); value.setItemCount(1); value.setTotalPackageCount(10); value.setNotifiedPackageCount(6);
value.setChargeAmount(new BigDecimal("20.00")); value.setChargeCurrency("CNY"); value.setWalletStatus("FROZEN");
return value; return value;
} }
private static RelabelItem item(long id, long applicationId, String sku, int total, int notified) { private static RelabelItem item(long id, long applicationId, String sku, int total, int notified) {
RelabelItem value = new RelabelItem(); 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.setProductNameZh("Chinese name"); value.setTotalPackageCount(total); value.setNotifiedPackageCount(notified);
value.setShipmentNo("SHP-1"); value.setNewSkuCode("SKU-NEW"); value.setShipmentNo("SHP-1"); value.setNewSkuCode("SKU-NEW");
return value; return value;
@@ -84,6 +84,9 @@ class TaillegRealConsumerAcceptanceTest {
return new WarehousePalletReference(1L, palletCode, 1L, 1L, return new WarehousePalletReference(1L, palletCode, 1L, 1L,
"AVAILABLE", null, 0); "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")) { if (!exists("ll_relabel_application", MARKER + "-RELABEL-KEY")) {
relabel.create(CUSTOMER, "real-acceptance", new RelabelRequests.Create( relabel.create(CUSTOMER, "real-acceptance", new RelabelRequests.Create(
MARKER + "-RELABEL", "92", RELABEL_DEADLINE, "验收联系人", "13800000000", 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, "真实验收商品", 3, 3, MARKER + "-SHIP", MARKER + "-TRACK", "SKU-REAL-A-NEW",
"换标验收", null, null, null)))); "换标验收", null, null, null))));
} }
+4 -2
View File
@@ -675,9 +675,11 @@ Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceCha
Assert-Contains $businessFeeEvent 'int sourceVersion' '海外费用事件来源版本' Assert-Contains $businessFeeEvent 'int sourceVersion' '海外费用事件来源版本'
Assert-Contains $businessFeeEvent 'List<String> paymentVoucherFileIds' '海外费用事件付款证据快照' Assert-Contains $businessFeeEvent 'List<String> paymentVoucherFileIds' '海外费用事件付款证据快照'
Assert-Contains $businessFeeEvent 'String sourceHash' '海外费用事件来源哈希' 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-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 '`eventId + payloadHash` 防重放冲突' 'Finance 海外事件幂等边界'
Assert-Contains $financeAgents '`PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID`' 'Finance 直接已付隔离边界' Assert-Contains $financeAgents '`PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID`' 'Finance 直接已付隔离边界'
Assert-Contains $financeEventConsumer 'envelope.payloadHash().equals(row.get("payload_hash"))' 'Finance 海外事件 Payload 哈希幂等' Assert-Contains $financeEventConsumer 'envelope.payloadHash().equals(row.get("payload_hash"))' 'Finance 海外事件 Payload 哈希幂等'
@@ -32,4 +32,12 @@ public class InternalWarehousePalletController {
return ApiResponse.success(service.resolvePublicPallet(palletCode)); return ApiResponse.success(service.resolvePublicPallet(palletCode));
} }
@GetMapping("/resolve/id/{palletId}")
public ApiResponse<WarehousePalletResolveResponse> resolveById(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long palletId) {
tokenGuard.verify(token);
return ApiResponse.success(service.resolvePublicPallet(palletId));
}
} }
@@ -47,6 +47,15 @@ public class PlatformWarehouseDirectoryController {
return ApiResponse.success(resources.publicPallets(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, query)); return ApiResponse.success(resources.publicPallets(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, query));
} }
@GetMapping("/pallets/summary")
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')")
public ApiResponse<PalletRequests.Summary> palletSummary(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid PalletRequests.QueryRequest query) {
requirePlatform(user);
return ApiResponse.success(resources.publicPalletSummary(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, query));
}
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')")
@Operation(summary = "Page shared overseas receiving warehouses", @Operation(summary = "Page shared overseas receiving warehouses",
@@ -117,6 +117,14 @@ public class PlatformWarehouseResourceController {
return ApiResponse.success(service.pallets(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, query)); return ApiResponse.success(service.pallets(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, query));
} }
@GetMapping("/pallets/summary")
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')")
public ApiResponse<PalletRequests.Summary> 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}") @GetMapping("/pallets/{id}")
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')")
@Operation(summary = "Get a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_LIST) @Operation(summary = "Get a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_LIST)
@@ -14,6 +14,7 @@ public final class PalletRequests {
private PalletRequests() { private PalletRequests() {
} }
public record Summary(long totalCount, long idleCount, long occupiedCount, long scrappedCount) { }
public record SaveRequest( public record SaveRequest(
@Size(max = 128) String palletCode, @Size(max = 128) String palletCode,
@@ -7,6 +7,12 @@ public record WarehousePalletResolveResponse(
long locationId, long locationId,
String status, String status,
String fbaWarehouseCode, String fbaWarehouseCode,
java.math.BigDecimal unitPrice,
int version 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);
}
} }
@@ -125,6 +125,11 @@ public class WarehouseResourceRepository {
.orderByAsc(WarehousePalletEntity::getId)); .orderByAsc(WarehousePalletEntity::getId));
} }
public WarehousePalletEntity findPublicPallet(long palletId) {
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getCustomerId, 0L).eq(WarehousePalletEntity::getId, palletId));
}
public boolean palletCodeExists(long customerId, long warehouseId, String code, Long excludedId) { public boolean palletCodeExists(long customerId, long warehouseId, String code, Long excludedId) {
return pallets.selectCount(new LambdaQueryWrapper<WarehousePalletEntity>() return pallets.selectCount(new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getCustomerId, customerId) .eq(WarehousePalletEntity::getCustomerId, customerId)
@@ -146,6 +151,20 @@ public class WarehouseResourceRepository {
return PageResult.from(pallets.selectPage(new Page<>(query.page(), query.size()), wrapper)); 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<WarehousePalletEntity>()
.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<WarehousePalletEntity> pagePublicPallets(long customerId, public PageResult<WarehousePalletEntity> pagePublicPallets(long customerId,
PalletRequests.QueryRequest query) { PalletRequests.QueryRequest query) {
var wrapper = new LambdaQueryWrapper<WarehousePalletEntity>() var wrapper = new LambdaQueryWrapper<WarehousePalletEntity>()
@@ -190,6 +190,16 @@ public class WarehouseResourceService {
if ("SCRAPPED".equals(pallet.getStatus())) throw new BizException(ResultCode.CONFLICT, "托盘已报废"); if ("SCRAPPED".equals(pallet.getStatus())) throw new BizException(ResultCode.CONFLICT, "托盘已报废");
return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(), return new WarehousePalletResolveResponse(pallet.getId(), pallet.getPalletCode(), pallet.getWarehouseId(),
pallet.getLocationId(), pallet.getStatus(), pallet.getFbaWarehouseCode(), 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()); pallet.getVersion() == null ? 0 : pallet.getVersion());
} }
@@ -218,6 +228,23 @@ public class WarehouseResourceService {
return repository.pagePallets(customerId, warehouseId, query); 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<WarehousePalletEntity> publicPallets(long customerId, public PageResult<WarehousePalletEntity> publicPallets(long customerId,
PalletRequests.QueryRequest query) { PalletRequests.QueryRequest query) {
return repository.pagePublicPallets(customerId, query); return repository.pagePublicPallets(customerId, query);
@@ -50,6 +50,14 @@ public class PlatformPalletController {
return ApiResponse.success(service.page(0L, query)); return ApiResponse.success(service.page(0L, query));
} }
@GetMapping("/summary")
@PreAuthorize("hasAuthority('platform:pallet:list')")
public ApiResponse<PalletRequests.Summary> 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}") @GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:pallet:list')") @PreAuthorize("hasAuthority('platform:pallet:list')")
@Operation(summary = "查询托盘详情") @Operation(summary = "查询托盘详情")
@@ -75,6 +75,8 @@ public final class PalletRequests {
) { ) {
} }
public record Summary(long totalCount, long idleCount, long occupiedCount) { }
@Schema(name = "InternalPalletSnapshot", description = "内部只读托盘主数据快照;不包含动态资源状态") @Schema(name = "InternalPalletSnapshot", description = "内部只读托盘主数据快照;不包含动态资源状态")
public record Snapshot( public record Snapshot(
@Schema(description = "托盘ID") long palletId, @Schema(description = "托盘ID") long palletId,
@@ -9,6 +9,7 @@ import com.baiyue.warehouse.entity.PalletEntity;
import com.baiyue.warehouse.repository.PalletRepository; import com.baiyue.warehouse.repository.PalletRepository;
import com.baiyue.warehouse.repository.WarehouseRepository; import com.baiyue.warehouse.repository.WarehouseRepository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -18,6 +19,7 @@ import java.util.List;
/** 托盘主数据应用服务。 */ /** 托盘主数据应用服务。 */
@Service @Service
public class PalletApplicationService { public class PalletApplicationService {
@Resource private JdbcTemplate jdbc;
private static final int CODE_INSERT_ATTEMPTS = 5; private static final int CODE_INSERT_ATTEMPTS = 5;
@Resource @Resource
@@ -31,6 +33,19 @@ public class PalletApplicationService {
source.page(), source.size(), source.total(), source.pages()); 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<Object> 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<String,Object> 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) { public PalletRequests.View detail(long tenantId, long warehouseId, long id) {
return view(owned(tenantId, warehouseId, id)); return view(owned(tenantId, warehouseId, id));
} }
@@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; 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.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@@ -24,8 +25,18 @@ import java.util.List;
class PalletApplicationServiceTest { class PalletApplicationServiceTest {
@MockBean private PalletRepository repository; @MockBean private PalletRepository repository;
@MockBean private WarehouseRepository warehouseRepository; @MockBean private WarehouseRepository warehouseRepository;
@MockBean private JdbcTemplate jdbcTemplate;
@Resource private PalletApplicationService service; @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 @Test
void shouldHandleCase01() { void shouldHandleCase01() {
PalletEntity entity = pallet(); PalletEntity entity = pallet();