feat: close overseas wallet and pallet cost flow
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
+21
@@ -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;
|
||||||
|
}
|
||||||
+18
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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='拆柜打托成本按客户实收体积分摊';
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
+6
-1
@@ -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();
|
||||||
|
|||||||
+5
-1
@@ -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) { }
|
||||||
|
}
|
||||||
+37
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
+3
@@ -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; }
|
||||||
|
|||||||
+4
@@ -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 {
|
||||||
|
|||||||
+16
-3
@@ -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
-2
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -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;
|
||||||
|
|||||||
+24
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -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){}
|
||||||
|
}
|
||||||
+11
@@ -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),
|
||||||
|
|||||||
+8
-1
@@ -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,
|
||||||
|
|||||||
+22
@@ -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) {
|
||||||
|
|||||||
+7
@@ -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,
|
||||||
|
|||||||
+10
-1
@@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
@@ -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;
|
||||||
|
}
|
||||||
+22
@@ -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;
|
||||||
|
}
|
||||||
+53
@@ -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) { }
|
||||||
|
}
|
||||||
+6
@@ -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) { }
|
||||||
|
}
|
||||||
+5
@@ -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> { }
|
||||||
+5
@@ -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> { }
|
||||||
+109
-4
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+48
@@ -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,
|
||||||
|
|||||||
+7
@@ -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;
|
||||||
|
|||||||
+50
-2
@@ -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) {
|
||||||
|
|||||||
+2
-1
@@ -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) {
|
||||||
|
|||||||
+73
@@ -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); }
|
||||||
|
}
|
||||||
+61
@@ -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:
|
||||||
|
|||||||
+20
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-1
@@ -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
|
||||||
|
|||||||
+17
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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)));
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-26
@@ -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;
|
||||||
|
|||||||
+4
-1
@@ -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))));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 哈希幂等'
|
||||||
|
|||||||
+8
@@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -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",
|
||||||
|
|||||||
+8
@@ -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,
|
||||||
|
|||||||
+6
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -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>()
|
||||||
|
|||||||
+27
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user