Test #12

Merged
admin merged 27 commits from test into main 2026-09-20 19:48:44 +08:00
165 changed files with 5592 additions and 252 deletions
Vendored
+24
View File
@@ -56,6 +56,8 @@ def commonPathServices = [
]
def databaseMigrationPaths = ['deploy/sql/']
// 暂停数据库结构只读门禁;保留实现,完成 Credentials 配置后改为 true。
def databaseSchemaContractEnabled = false
def downstreamResults = [:]
def downstreamBuildUrls = [:]
@@ -369,6 +371,28 @@ pipeline {
}
}
stage('数据库结构只读门禁') {
when {
expression { databaseSchemaContractEnabled }
}
agent { label 'java-builder' }
steps {
script {
String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-schema-readonly"
withCredentials([file(credentialsId: credentialId, variable: 'SCHEMA_CHECK_ENV_FILE')]) {
sh '''
set -eu
set -a
. "$SCHEMA_CHECK_ENV_FILE"
set +a
test "${SCHEMA_CHECK_READ_ONLY:-}" = "true"
node scripts/database-schema-contract.mjs --region BOTH
'''
}
}
}
}
stage('统一 Maven 测试门禁') {
when {
expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS }
@@ -1,4 +1,8 @@
spring:
cloud:
nacos:
discovery:
ip: ${NACOS_DISCOVERY_IP:127.0.0.1}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
@@ -84,7 +84,7 @@ public class BaiyueSecurityAutoConfiguration {
.exceptionHandling(errors -> errors
.authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/api/pda-updates/**").permitAll()
.requestMatchers("/internal/**").permitAll()
.requestMatchers("/openapi/**").permitAll()
.requestMatchers("/api/merchant/**").hasAuthority("APP_MERCHANT")
+1
View File
@@ -163,6 +163,7 @@ services:
FILE_DB_USERNAME: ${FILE_DB_USERNAME}
FILE_DB_PASSWORD: ${FILE_DB_PASSWORD}
FILE_STORAGE_ROOT: /data/files
PDA_UPDATE_PUBLIC_BASE_URL: ${PDA_UPDATE_PUBLIC_BASE_URL:-}
JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
# Compose 会插值整份文件,即使只部署 auth 等其他服务;File 的必填校验由 deploy-services.sh 按服务执行。
ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER-}
+10 -1
View File
@@ -62,7 +62,16 @@
## 后续迁移工具(已开始实施)
机制启用前,以上两份统一 SQL 仍是空库初始化入口;本节工具不会把当前可丢弃的开发/测试存量转换成迁移链。当前已登记一份生产尾程结构补齐迁移 `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`,由 `scripts/prod-database-schema-acceptance.mjs` 默认只读核对,只有显式确认参数才执行;它只新增字段、放宽生成后才确定字段的可空性并创建缺失表,不执行 DML。
机制启用前,以上两份统一 SQL 仍是空库初始化入口;本节工具不会把当前可丢弃的开发/测试存量自动转换成迁移链。现有候选迁移包括:
| 区域 / schema | 迁移 | 目的 |
|---|---|---|
| CN / `baiyue_iam` | `migrations/cn/baiyue_iam/V1__align_schema_baseline.sql` | 收敛客户编号、昵称和资质原文件名定义;收紧前检查存量数据 |
| CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql` | 收敛一件代发托盘码非空定义;收紧前检查存量数据 |
| CN / `baiyue_file` | `migrations/cn/baiyue_file/V1__align_schema_baseline.sql` | 补齐 PDA 发布表和固定渠道,收敛文件业务类型长度 |
| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql` | 补齐尾程字段并收敛字段定义和排序规则;收紧前检查存量数据 |
上述新增候选当前只完成编写和离线检查,尚未在测试或生产执行。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。
迁移范围映射位于 `migration-scopes.json`,按区域、schema、所属服务和顺序登记。迁移文件位于 `migrations/{cn|overseas}/<schema>/`,一个文件只允许修改一个 schema。目录中的 `.gitkeep` 仅用于保留空目录,不是迁移文件。
@@ -0,0 +1,59 @@
-- Region: CN
-- Schema: baiyue_file
-- Purpose: 补齐 PDA 发布表,并将文件业务类型长度收敛到当前空库基线。
-- Preconditions: 连接必须指向 baiyue_file;既有 business_type 不超过 32 字符。
-- Postconditions: PDA 发布表及固定渠道存在,file_object.business_type 为 varchar(32)。
SET NAMES utf8mb4;
SET @guard = IF(
EXISTS (SELECT 1 FROM file_object WHERE CHAR_LENGTH(business_type) > 32 LIMIT 1),
'SELECT 1 FROM migration_blocked_file_business_type_exceeds_32_chars',
'SELECT 1'
);
PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'file_object' AND column_name = 'business_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'YES')),
'ALTER TABLE file_object MODIFY COLUMN business_type varchar(32) DEFAULT NULL COMMENT ''关联业务类型''',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS file_pda_release_channel (
app_code varchar(16) NOT NULL,
app_id varchar(64) NOT NULL,
current_apk_id char(36) DEFAULT NULL,
current_wgt_id char(36) DEFAULT NULL,
PRIMARY KEY (app_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内/海外PDA当前发布指针';
CREATE TABLE IF NOT EXISTS file_pda_release (
id char(36) NOT NULL,
app_code varchar(16) NOT NULL,
app_id varchar(64) NOT NULL,
package_type varchar(8) NOT NULL,
version_name varchar(32) NOT NULL,
version_code int unsigned NOT NULL,
min_app_version_code int unsigned NOT NULL,
mandatory tinyint(1) NOT NULL DEFAULT '0',
release_notes varchar(1000) NOT NULL DEFAULT '',
status varchar(16) NOT NULL DEFAULT 'DRAFT',
object_key varchar(128) NOT NULL,
sha256 char(64) NOT NULL,
size_bytes bigint unsigned NOT NULL,
created_by bigint unsigned NOT NULL,
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
published_at datetime(3) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_pda_release_version (app_code,package_type,version_name),
KEY idx_pda_release_created (app_code,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA安装包与热更新发布历史';
INSERT INTO file_pda_release_channel (app_code,app_id)
SELECT 'domestic','__UNI__9BFFAF2'
WHERE NOT EXISTS (SELECT 1 FROM file_pda_release_channel WHERE app_code = 'domestic');
INSERT INTO file_pda_release_channel (app_code,app_id)
SELECT 'overseas','__UNI__936D079'
WHERE NOT EXISTS (SELECT 1 FROM file_pda_release_channel WHERE app_code = 'overseas');
@@ -0,0 +1,21 @@
-- Region: CN
-- Schema: baiyue_firstleg
-- Purpose: 将一件代发作业托盘码可空性收敛到当前空库基线。
-- Preconditions: 连接必须指向 baiyue_firstleg;fl_dropship_pallet.pallet_no 不存在 NULL。
-- Postconditions: pallet_no 为 varchar(32) NOT NULL。
SET NAMES utf8mb4;
SET @guard = IF(
EXISTS (SELECT 1 FROM fl_dropship_pallet WHERE pallet_no IS NULL LIMIT 1),
'SELECT 1 FROM migration_blocked_firstleg_pallet_no_contains_null',
'SELECT 1'
);
PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_dropship_pallet' AND column_name = 'pallet_no' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO')),
'ALTER TABLE fl_dropship_pallet MODIFY COLUMN pallet_no varchar(32) NOT NULL COMMENT ''现场扫描的系统DP托盘码''',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,43 @@
-- Region: CN
-- Schema: baiyue_iam
-- Purpose: 将客户编号、昵称和资质原文件名定义收敛到当前空库基线。
-- Preconditions: 连接必须指向 baiyue_iam;待收紧字段不存在 NULL,tenant_code 不超过 8 字符。
-- Postconditions: 三个字段的类型和可空性与头程统一初始化 SQL 一致。
SET NAMES utf8mb4;
SET @guard = IF(
EXISTS (SELECT 1 FROM iam_tenant WHERE customer_nickname IS NULL LIMIT 1),
'SELECT 1 FROM migration_blocked_iam_customer_nickname_contains_null',
'SELECT 1'
);
PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @guard = IF(
EXISTS (SELECT 1 FROM iam_tenant WHERE CHAR_LENGTH(tenant_code) > 8 LIMIT 1),
'SELECT 1 FROM migration_blocked_iam_tenant_code_exceeds_8_chars',
'SELECT 1'
);
PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @guard = IF(
EXISTS (SELECT 1 FROM iam_tenant_qualification WHERE original_file_name IS NULL LIMIT 1),
'SELECT 1 FROM migration_blocked_iam_original_file_name_contains_null',
'SELECT 1'
);
PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'tenant_code' AND (column_type <> 'varchar(8)' OR is_nullable <> 'NO'))
OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'customer_nickname' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO')),
'ALTER TABLE iam_tenant MODIFY COLUMN tenant_code varchar(8) NOT NULL COMMENT ''客户编号:企业客户固定五位数字,平台技术主体为PLATFORM'', MODIFY COLUMN customer_nickname varchar(32) NOT NULL COMMENT ''客户昵称:创建时默认取公司名称中文拼音大写首字母,可修改''',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_tenant_qualification' AND column_name = 'original_file_name' AND (column_type <> 'varchar(255)' OR is_nullable <> 'NO')),
'ALTER TABLE iam_tenant_qualification MODIFY COLUMN original_file_name varchar(255) NOT NULL COMMENT ''上传时原始文件名''',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,70 @@
-- Region: OVERSEAS
-- Schema: baiyue_lastleg
-- Purpose: 补齐测试与生产各自缺失的尾程字段,并将字段定义收敛到当前空库基线。
-- Preconditions: 连接必须指向 baiyue_lastleg;待收紧字段不存在 NULL,review_decision 不超过 16 字符。
-- Postconditions: 本文件涉及的字段集合、类型、默认值、可空性和测量照片表排序规则与海外统一初始化 SQL 一致。
SET NAMES utf8mb4;
-- 收紧测试库漂移字段前先阻断不满足目标定义的数据,不执行自动回填或清理。
SET @guard = IF(EXISTS (SELECT 1 FROM ll_delivery_order WHERE delivery_group_key IS NULL LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_delivery_group_key_null', 'SELECT 1');
PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @guard = IF(EXISTS (SELECT 1 FROM ll_local_looseboard_order WHERE container_type IS NULL LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_looseboard_container_type_null', 'SELECT 1');
PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @guard = IF(EXISTS (SELECT 1 FROM ll_us_truck_order WHERE container_type IS NULL LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_truck_container_type_null', 'SELECT 1');
PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @guard = IF(EXISTS (SELECT 1 FROM ll_outbound_pda_pick WHERE inventory_position_id IS NULL LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_inventory_position_id_null', 'SELECT 1');
PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @guard = IF(EXISTS (SELECT 1 FROM ll_relabel_item WHERE new_sku_code IS NULL LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_new_sku_code_null', 'SELECT 1');
PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @guard = IF(EXISTS (SELECT 1 FROM ll_relabel_item WHERE source_warehouse_id IS NULL LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_source_warehouse_id_null', 'SELECT 1');
PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @guard = IF(EXISTS (SELECT 1 FROM ll_relabel_pda_task WHERE CHAR_LENGTH(review_decision) > 16 LIMIT 1), 'SELECT 1 FROM migration_blocked_lastleg_review_decision_exceeds_16_chars', 'SELECT 1');
PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 测试环境当前缺失、生产已存在的 PDA 废弃作业字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'actual_discard_packages'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages int NULL COMMENT ''PDA确认实际废弃箱数'' AFTER discard_packages', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_status'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED'' AFTER actual_discard_packages', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_operator_id'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''PDA操作人'' AFTER pda_status', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'pda_completed_at'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at datetime(3) NULL COMMENT ''PDA完成时间'' AFTER pda_operator_id', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 生产环境当前缺失、测试已存在的换标与退货字段。
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_unit_fact' AND column_name = 'quantity'), 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity int NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_inventory_carton' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'discrepancy_reason'), 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason varchar(500) COLLATE utf8mb4_unicode_ci NULL COMMENT ''手动完结差异原因'' AFTER status', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_order' AND column_name = 'manually_completed_at'), 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at datetime(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_return_receipt' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''本次签收绑定的托盘码'' AFTER carton_mark', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_delivery_order' AND column_name = 'delivery_group_key' AND (column_type <> 'varchar(255)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_delivery_order MODIFY COLUMN delivery_group_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_local_looseboard_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_local_looseboard_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'container_type' AND (column_type <> 'varchar(32)' OR is_nullable <> 'NO' OR column_default <> '40HQ' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''40HQ'' COMMENT ''平台统一柜型:40GP、40HQ、45HQ''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_outbound_pda_pick' AND column_name = 'inventory_position_id' AND (column_type <> 'bigint' OR is_nullable <> 'NO')), 'ALTER TABLE ll_outbound_pda_pick MODIFY COLUMN inventory_position_id bigint NOT NULL COMMENT ''库存仓位ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'new_sku_code' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci'))
OR EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_item' AND column_name = 'source_warehouse_id' AND (column_type <> 'varchar(128)' OR is_nullable <> 'NO' OR collation_name <> 'utf8mb4_unicode_ci')),
'ALTER TABLE ll_relabel_item MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''换标后SKU编码'', MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT ''原海外中转仓ID''',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_relabel_pda_task' AND column_name = 'review_decision' AND (column_type <> 'varchar(16)' OR is_nullable <> 'YES' OR collation_name <> 'utf8mb4_unicode_ci')), 'ALTER TABLE ll_relabel_pda_task MODIFY COLUMN review_decision varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT ''最近复核结论:APPROVE或REWORK''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
(SELECT table_collation FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_measurement_photo') <> 'utf8mb4_unicode_ci',
'ALTER TABLE ll_warehouse_transfer_measurement_photo CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
'SELECT 1'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -4449,6 +4449,39 @@ CREATE TABLE file_object (
KEY idx_file_tenant_created (tenant_id,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文件元数据表';
CREATE TABLE file_pda_release_channel (
app_code varchar(16) NOT NULL,
app_id varchar(64) NOT NULL,
current_apk_id char(36) DEFAULT NULL,
current_wgt_id char(36) DEFAULT NULL,
PRIMARY KEY (app_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内/海外PDA当前发布指针';
INSERT INTO file_pda_release_channel (app_code,app_id) VALUES
('domestic','__UNI__9BFFAF2'),('overseas','__UNI__936D079');
CREATE TABLE file_pda_release (
id char(36) NOT NULL,
app_code varchar(16) NOT NULL,
app_id varchar(64) NOT NULL,
package_type varchar(8) NOT NULL,
version_name varchar(32) NOT NULL,
version_code int unsigned NOT NULL,
min_app_version_code int unsigned NOT NULL,
mandatory tinyint(1) NOT NULL DEFAULT '0',
release_notes varchar(1000) NOT NULL DEFAULT '',
status varchar(16) NOT NULL DEFAULT 'DRAFT',
object_key varchar(128) NOT NULL,
sha256 char(64) NOT NULL,
size_bytes bigint unsigned NOT NULL,
created_by bigint unsigned NOT NULL,
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
published_at datetime(3) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_pda_release_version (app_code,package_type,version_name),
KEY idx_pda_release_created (app_code,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA安装包与热更新发布历史';
-- 服务 schema:baiyue_audit
CREATE DATABASE IF NOT EXISTS baiyue_audit DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_audit;
@@ -6004,6 +6037,8 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101034,0,'PLATFORM','BUTTON',5101030,'platform:permission:delete','删除权限资源',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101040,0,'PLATFORM','MENU',5101000,'platform:member:list','平台成员',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101041,0,'PLATFORM','BUTTON',5101040,'platform:member:grant-role','分配成员角色',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101060,0,'PLATFORM','MENU',5101000,'platform:pda-release:list','PDA版本','/system/pda-releases',NULL,60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101061,0,'PLATFORM','BUTTON',5101060,'platform:pda-release:manage','发布PDA版本',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102000,0,'PLATFORM','MENU',0,'platform:order','头程订单','/first-leg-orders',NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102010,0,'PLATFORM','MENU',5102000,'platform:order:list','订单列表','/first-leg-orders/list',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102011,0,'PLATFORM','BUTTON',5102010,'platform:order:approve','订单审核通过',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
@@ -6023,6 +6058,7 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102025,0,'PLATFORM','BUTTON',5102010,'platform:order:submit','平台代客提交订单',NULL,NULL,110,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102026,0,'PLATFORM','BUTTON',5102010,'platform:order:export','导出订单列表',NULL,NULL,120,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102027,0,'PLATFORM','BUTTON',5102010,'platform:order:declaration:edit','修改订单申报与运营预估',NULL,NULL,130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102028,0,'PLATFORM','BUTTON',5102010,'platform:order:import','导入旧系统订单',NULL,NULL,140,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102030,0,'PLATFORM','MENU',5102000,'platform:firstleg:inbound:list','入库单','/inbounds','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102031,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:view','查看入库单详情',NULL,'GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102032,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:hold','扣货与解除扣货',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
@@ -6328,7 +6364,7 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货预报','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货换标','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
@@ -6380,6 +6416,8 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101034,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101040,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101041,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101060,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101061,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102000,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102010,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102011,0,CURRENT_TIMESTAMP(3));
@@ -6399,6 +6437,7 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102025,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102026,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102027,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102028,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102030,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102031,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102032,0,CURRENT_TIMESTAMP(3));
@@ -6676,6 +6715,7 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102025,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102026,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102027,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102028,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102030,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102031,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102041,0,CURRENT_TIMESTAMP(3));
@@ -2314,6 +2314,7 @@ CREATE TABLE ll_relabel_pda_unit_fact (
result varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION',
exception_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '异常编码',
reason varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '跳过或异常原因',
quantity int NOT NULL DEFAULT '1' COMMENT '本次事实处理件数,箱级作业固定为1',
operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号标识',
operator_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作员名称快照',
occurred_at datetime(3) NOT NULL COMMENT '逐件事实发生时间',
@@ -2360,6 +2361,7 @@ CREATE TABLE ll_return_inventory_carton (
source_receipt_id bigint DEFAULT NULL COMMENT '来源PDA签收单ID',
source_batch_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源签收批次号,多个批次逗号分隔',
source_return_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源退货单号,多个单号逗号分隔',
pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收时绑定的托盘码',
carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码',
fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA箱唛',
destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码',
@@ -2382,8 +2384,8 @@ CREATE TABLE ll_return_inventory_carton (
UNIQUE KEY uk_ll_return_inventory_carton_mark (carton_mark),
UNIQUE KEY uk_ll_return_inventory_carton_receipt (source_receipt_id),
KEY idx_ll_return_inventory_carton_customer (customer_id,overseas_warehouse_id,status,created_at),
CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'OUTBOUNDED')) and (sku_types > 0) and (total_quantity > 0)) or ((status = _utf8mb4'MERGED') and (sku_types = 0) and (total_quantity = 0)))),
CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED')))
CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUNDED')) and (sku_types > 0) and (total_quantity > 0)) or ((status = _utf8mb4'MERGED') and (sku_types = 0) and (total_quantity = 0)))),
CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED')))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛';
CREATE TABLE ll_return_inventory_carton_item (
@@ -2432,6 +2434,8 @@ CREATE TABLE ll_return_order (
expected_quantity bigint NOT NULL COMMENT '预报总数量',
received_quantity bigint NOT NULL DEFAULT '0' COMMENT '累计实收总数量',
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户备注',
discrepancy_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手动完结差异原因',
manually_completed_at datetime(3) DEFAULT NULL COMMENT '手动差异完结时间',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键',
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求SHA-256',
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建操作人ID',
@@ -2466,6 +2470,7 @@ CREATE TABLE ll_return_receipt (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
receipt_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号',
receipt_batch_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号',
pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收绑定的托盘码',
carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收生成或绑定的退货箱号',
tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收包裹的可选快递单号',
bound_by varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选',
+1
View File
@@ -26,6 +26,7 @@
| 派送单三类资料 | `src/main/resources/openapi/delivery-files-v1.yaml`;依次为装车照片 `DELIVERY_LOADING_PHOTO`、BOL文件(含签名)`DELIVERY_BOL`、签收文件(POD签收/快递签收)`DELIVERY_RECEIPT`,按客户、派送内部ID、类别隔离,任意格式单文件50MiB;Lastleg持有分类绑定、保存、替换删除和历史可见性,按非空类别计数x/3,不强制必传或识别签名;File保留不可变对象与真实createdAt |
| 下游 OCR | `client/WeightOcrClient.java` |
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
| 国内/海外 PDA 版本 | `controller/PdaReleaseController.java`、`service/PdaReleaseService.java`、`service/PdaPackageStorage.java`、`src/main/resources/openapi/pda-releases-v1.yaml`;平台上传草稿、发布和停发,匿名清单与包下载只暴露当前已发布/历史未停发文件;国内和海外 AppID、指针独立 |
| 测试/验证 | `src/test/java/com/baiyue/file/`;`mvn -pl file -am test` |
## 修改约束
@@ -44,6 +44,15 @@ public class InternalBusinessFileController {
return ApiResponse.success(service.upload(tenantId, businessType, businessId, operatorId, file));
}
@GetMapping("/{tenantId}/{businessType}/{businessId}")
public ApiResponse<java.util.List<InternalBusinessFileService.Metadata>> list(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId, @PathVariable @NotBlank String businessType,
@PathVariable @NotBlank String businessId) {
check(token); checkTenantId(tenantId);
return ApiResponse.success(service.list(tenantId, businessType, businessId));
}
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}")
public ApiResponse<InternalBusinessFileService.Metadata> metadata(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -0,0 +1,88 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.service.PdaReleaseService;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
@RestController
public class PdaReleaseController {
private final PdaReleaseService releases;
public PdaReleaseController(PdaReleaseService releases) { this.releases = releases; }
@GetMapping("/api/platform/v1/files/pda-releases/{app}")
@PreAuthorize("hasAuthority('platform:pda-release:list')")
public ApiResponse<List<PdaReleaseService.Release>> list(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String app) {
requirePlatform(user);
return ApiResponse.success(releases.list(app));
}
@PostMapping(value = "/api/platform/v1/files/pda-releases/{app}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:pda-release:manage')")
public ApiResponse<PdaReleaseService.Release> upload(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String app, @RequestParam String packageType, @RequestParam String versionName,
@RequestParam int versionCode, @RequestParam int minAppVersionCode,
@RequestParam(defaultValue = "false") boolean mandatory, @RequestParam(defaultValue = "") String releaseNotes,
@RequestPart("file") MultipartFile file) {
return ApiResponse.success(releases.upload(app, packageType, versionName, versionCode,
minAppVersionCode, mandatory, releaseNotes, file, requirePlatform(user)));
}
@PostMapping("/api/platform/v1/files/pda-releases/{id}/publish")
@PreAuthorize("hasAuthority('platform:pda-release:manage')")
public ApiResponse<PdaReleaseService.Release> publish(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String id) {
requirePlatform(user);
return ApiResponse.success(releases.publish(id));
}
@PostMapping("/api/platform/v1/files/pda-releases/{id}/withdraw")
@PreAuthorize("hasAuthority('platform:pda-release:manage')")
public ApiResponse<PdaReleaseService.Release> withdraw(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String id) {
requirePlatform(user);
return ApiResponse.success(releases.withdraw(id));
}
@GetMapping("/api/pda-updates/{app}/latest")
public Map<String, Object> latest(@PathVariable String app, @RequestParam String appid,
@RequestParam String platform, @RequestParam int appVersionCode, @RequestParam String wgtVersion) {
return releases.latest(app, appid, platform, appVersionCode, wgtVersion);
}
@GetMapping("/api/pda-updates/{app}/{id}/package")
public ResponseEntity<Resource> download(@PathVariable String app, @PathVariable String id) {
PdaReleaseService.PackageFile file = releases.packageFile(app, id);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.APPLICATION_OCTET_STREAM).contentLength(file.sizeBytes())
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.body(file.resource());
}
private static long requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() != 0L
|| user.userId() == null || user.userId() <= 0) {
throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份");
}
return user.userId();
}
}
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class InternalBusinessFileService {
@Resource private FileObjectMapper files;
@@ -52,6 +53,16 @@ public class InternalBusinessFileService {
return metadata(required(tenantId, businessType, businessId, fileId));
}
public List<Metadata> list(long tenantId, String businessType, String businessId) {
BusinessFileCategory category = BusinessFileCategory.require(businessType);
category.validateOwner(tenantId); category.storageBusinessId(businessId);
return files.selectList(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId).eq(FileObjectEntity::getBusinessType, businessType)
.eq(FileObjectEntity::getBusinessId, businessId).eq(FileObjectEntity::getStatus, "ACTIVE")
.eq(FileObjectEntity::getDeleted, false).orderByAsc(FileObjectEntity::getCreatedAt, FileObjectEntity::getId))
.stream().map(InternalBusinessFileService::metadata).toList();
}
public Download download(long tenantId, String businessType, String businessId, long fileId) {
FileObjectEntity value = required(tenantId, businessType, businessId, fileId);
return new Download(value.getOriginalName(), value.getContentType(), storage.read(value.getObjectKey()));
@@ -0,0 +1,84 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.PathResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.zip.ZipFile;
@Component
public class PdaPackageStorage {
private static final long MAX_BYTES = 200L * 1024 * 1024;
private final Path root;
public PdaPackageStorage(@Value("${baiyue.file.storage-root}") String storageRoot) {
root = Path.of(storageRoot).toAbsolutePath().normalize().resolve("pda-packages");
}
public record Stored(String key, long sizeBytes, String sha256) { }
public Stored store(String app, String id, String type, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > MAX_BYTES) {
throw new BizException(ResultCode.BAD_REQUEST, "更新包为空或超过 200MB");
}
String name = file.getOriginalFilename();
if (name == null || !name.toLowerCase().endsWith("." + type)) {
throw new BizException(ResultCode.BAD_REQUEST, "文件扩展名与包类型不符");
}
String key = app + "/" + id + "." + type;
Path target = path(key);
try {
Files.createDirectories(target.getParent());
file.transferTo(target);
try (ZipFile zip = new ZipFile(target.toFile())) {
String required = type.equals("apk") ? "AndroidManifest.xml" : "manifest.json";
if (zip.getEntry(required) == null) throw new IOException("missing " + required);
}
return new Stored(key, Files.size(target), sha256(target));
} catch (IOException | IllegalStateException exception) {
delete(key);
throw new BizException(ResultCode.BAD_REQUEST, "更新包不是有效的 APK/WGT 文件");
}
}
public Resource load(String key) {
Path file = path(key);
if (!Files.isRegularFile(file)) throw new BizException(ResultCode.NOT_FOUND, "更新包文件不存在");
return new PathResource(file);
}
public void delete(String key) {
try { Files.deleteIfExists(path(key)); } catch (IOException ignored) { }
}
private Path path(String key) {
if (key == null || !key.matches("(domestic|overseas)/[a-f0-9-]{36}\\.(apk|wgt)")) {
throw new BizException(ResultCode.BAD_REQUEST, "更新包路径无效");
}
Path path = root.resolve(key).normalize();
if (!path.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "更新包路径越界");
return path;
}
private static String sha256(Path file) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream input = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count);
}
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
}
}
@@ -0,0 +1,229 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
@Service
public class PdaReleaseService {
private final JdbcTemplate jdbc;
private final PdaPackageStorage storage;
private final String publicBaseUrl;
private final String domesticAppId;
private final String overseasAppId;
public PdaReleaseService(JdbcTemplate jdbc, PdaPackageStorage storage,
@Value("${baiyue.file.pda.public-base-url:}") String publicBaseUrl,
@Value("${baiyue.file.pda.domestic-appid:__UNI__9BFFAF2}") String domesticAppId,
@Value("${baiyue.file.pda.overseas-appid:__UNI__936D079}") String overseasAppId) {
this.jdbc = jdbc;
this.storage = storage;
this.publicBaseUrl = publicBaseUrl.replaceAll("/+$", "");
this.domesticAppId = domesticAppId;
this.overseasAppId = overseasAppId;
if (!this.publicBaseUrl.isEmpty() && !this.publicBaseUrl.startsWith("https://")) {
throw new IllegalArgumentException("PDA_UPDATE_PUBLIC_BASE_URL must use HTTPS");
}
}
public record Release(String id, String app, String appid, String packageType, String versionName,
int versionCode, int minAppVersionCode, boolean mandatory, String releaseNotes,
String status, boolean current, String sha256, long sizeBytes, String downloadUrl,
LocalDateTime createdAt, LocalDateTime publishedAt) { }
public record PackageFile(Resource resource, long sizeBytes, String fileName) { }
private record Row(String id, String app, String appid, String type, String name, int code,
int minCode, boolean mandatory, String notes, String status,
String key, String sha256, long bytes, LocalDateTime createdAt,
LocalDateTime publishedAt) { }
private record Channel(String appid, String apkId, String wgtId) { }
public Release upload(String app, String type, String versionName, int versionCode,
int minAppVersionCode, boolean mandatory, String notes,
MultipartFile file, long operatorId) {
String appid = appid(app);
type = type == null ? "" : type.toLowerCase();
if (!type.equals("apk") && !type.equals("wgt")) throw bad("只支持 APK 或 WGT");
if (versionName == null || versionName.length() > 32
|| !versionName.matches("\\d+(\\.\\d+)+")) throw bad("版本号格式无效");
if (versionCode < 1 || minAppVersionCode < 1
|| (type.equals("apk") && versionCode != minAppVersionCode)) {
throw bad("APK 版本号或 WGT 最低基座版本无效");
}
if (notes != null && notes.length() > 1000) throw bad("更新说明过长");
String id = UUID.randomUUID().toString();
PdaPackageStorage.Stored stored = storage.store(app, id, type, file);
try {
jdbc.update("""
INSERT INTO file_pda_release
(id,app_code,app_id,package_type,version_name,version_code,min_app_version_code,
mandatory,release_notes,status,object_key,sha256,size_bytes,created_by)
VALUES (?,?,?,?,?,?,?,?,?,'DRAFT',?,?,?,?)
""", id, app, appid, type, versionName, versionCode, minAppVersionCode,
mandatory, notes == null ? "" : notes, stored.key(), stored.sha256(),
stored.sizeBytes(), operatorId);
} catch (RuntimeException exception) {
storage.delete(stored.key());
throw exception;
}
return view(get(id));
}
@Transactional
public Release publish(String id) {
Row row = get(id);
if (!row.status().equals("DRAFT")) throw conflict("只有草稿可以发布");
if (publicBaseUrl.isEmpty()) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "更新服务公开地址未配置");
}
Channel channel = channel(row.app(), true);
if (!Objects.equals(channel.appid(), row.appid())) throw conflict("应用 AppID 已改变,请重新上传");
Row current = current(row.type().equals("apk") ? channel.apkId() : channel.wgtId());
if (current != null && (row.type().equals("apk")
? row.code() <= current.code()
: compareVersion(row.name(), current.name()) <= 0)) {
throw conflict("新版本必须高于当前发布版本");
}
if (row.type().equals("wgt")) {
Row apk = current(channel.apkId());
if (apk == null || row.minCode() > apk.code()) throw conflict("请先发布兼容的 APK 基座");
}
storage.load(row.key());
if (jdbc.update("UPDATE file_pda_release SET status='PUBLISHED',published_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='DRAFT'", id) != 1) {
throw conflict("版本状态已改变");
}
String column = row.type().equals("apk") ? "current_apk_id" : "current_wgt_id";
jdbc.update("UPDATE file_pda_release_channel SET " + column + "=? WHERE app_code=?", id, row.app());
return view(get(id));
}
@Transactional
public Release withdraw(String id) {
Row row = get(id);
Channel channel = channel(row.app(), true);
if (!row.status().equals("PUBLISHED")) throw conflict("只有已发布版本可以停发");
String column = row.type().equals("apk") ? "current_apk_id" : "current_wgt_id";
String currentId = row.type().equals("apk") ? channel.apkId() : channel.wgtId();
if (id.equals(currentId)) {
jdbc.update("UPDATE file_pda_release_channel SET " + column + "=NULL WHERE app_code=?", row.app());
}
jdbc.update("UPDATE file_pda_release SET status='WITHDRAWN' WHERE id=?", id);
return view(get(id));
}
public List<Release> list(String app) {
appid(app);
Channel channel = channel(app, false);
List<Row> rows = jdbc.query("SELECT * FROM file_pda_release WHERE app_code=? ORDER BY created_at DESC LIMIT 100",
(rs, index) -> map(rs), app);
return rows.stream().map(row -> view(row, channel)).toList();
}
public Map<String, Object> latest(String app, String requestedAppid, String platform,
int appVersionCode, String wgtVersion) {
if (!appid(app).equals(requestedAppid) || !"android".equalsIgnoreCase(platform)
|| appVersionCode < 1 || wgtVersion == null || !wgtVersion.matches("\\d+(\\.\\d+)+")) {
throw bad("更新检查参数无效");
}
Channel channel = channel(app, false);
Row apk = current(channel.apkId());
if (apk != null && apk.code() > appVersionCode) return manifest(apk);
Row wgt = current(channel.wgtId());
if (wgt != null && wgt.minCode() <= appVersionCode
&& compareVersion(wgt.name(), wgtVersion) > 0) return manifest(wgt);
return Map.of("update", false);
}
public PackageFile packageFile(String app, String id) {
Row row = get(id);
if (!row.app().equals(app) || !row.status().equals("PUBLISHED")) {
throw new BizException(ResultCode.NOT_FOUND, "更新包不存在");
}
return new PackageFile(storage.load(row.key()), row.bytes(),
row.app() + "-pda-" + row.name() + "." + row.type());
}
private Map<String, Object> manifest(Row row) {
if (publicBaseUrl.isEmpty()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "更新服务公开地址未配置");
return Map.of("update", true, "appid", row.appid(), "packageType", row.type(),
"versionName", row.name(), "versionCode", row.code(), "minAppVersionCode", row.minCode(),
"mandatory", row.mandatory(), "releaseNotes", row.notes(), "downloadUrl", url(row));
}
private Release view(Row row) {
return view(row, channel(row.app(), false));
}
private Release view(Row row, Channel channel) {
boolean current = row.id().equals(row.type().equals("apk") ? channel.apkId() : channel.wgtId());
return new Release(row.id(), row.app(), row.appid(), row.type(), row.name(), row.code(),
row.minCode(), row.mandatory(), row.notes(), row.status(), current, row.sha256(), row.bytes(),
row.status().equals("PUBLISHED") && !publicBaseUrl.isEmpty() ? url(row) : null,
row.createdAt(), row.publishedAt());
}
private String url(Row row) {
return publicBaseUrl + "/api/pda-updates/" + row.app() + "/" + row.id() + "/package";
}
private Channel channel(String app, boolean locked) {
String sql = "SELECT app_id,current_apk_id,current_wgt_id FROM file_pda_release_channel WHERE app_code=?"
+ (locked ? " FOR UPDATE" : "");
List<Channel> rows = jdbc.query(sql, (rs, index) ->
new Channel(rs.getString(1), rs.getString(2), rs.getString(3)), app);
if (rows.isEmpty()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "PDA 发布通道未初始化");
return rows.get(0);
}
private Row current(String id) { return id == null ? null : get(id); }
private Row get(String id) {
List<Row> rows = jdbc.query("SELECT * FROM file_pda_release WHERE id=?", (rs, index) -> map(rs), id);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "PDA 版本不存在");
return rows.get(0);
}
private String appid(String app) {
return switch (app) {
case "domestic" -> domesticAppId;
case "overseas" -> overseasAppId;
default -> throw bad("PDA 应用类型无效");
};
}
private static Row map(ResultSet rs) throws SQLException {
return new Row(rs.getString("id"), rs.getString("app_code"), rs.getString("app_id"),
rs.getString("package_type"), rs.getString("version_name"), rs.getInt("version_code"),
rs.getInt("min_app_version_code"), rs.getBoolean("mandatory"), rs.getString("release_notes"),
rs.getString("status"), rs.getString("object_key"), rs.getString("sha256"), rs.getLong("size_bytes"),
rs.getTimestamp("created_at").toLocalDateTime(),
rs.getTimestamp("published_at") == null ? null : rs.getTimestamp("published_at").toLocalDateTime());
}
private static int compareVersion(String left, String right) {
String[] a = left.split("\\.");
String[] b = right.split("\\.");
for (int i = 0; i < Math.max(a.length, b.length); i++) {
int x = i < a.length ? Integer.parseInt(a[i]) : 0;
int y = i < b.length ? Integer.parseInt(b[i]) : 0;
if (x != y) return Integer.compare(x, y);
}
return 0;
}
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
}
+6 -2
View File
@@ -40,8 +40,8 @@ spring:
read-timeout: 15000
servlet:
multipart:
max-file-size: 50MB
max-request-size: 51MB
max-file-size: 200MB
max-request-size: 201MB
rocketmq:
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
producer:
@@ -60,6 +60,10 @@ baiyue:
url: ${FINANCE_SERVICE_URL:}
file:
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
pda:
public-base-url: ${PDA_UPDATE_PUBLIC_BASE_URL:}
domestic-appid: ${PDA_DOMESTIC_APPID:__UNI__9BFFAF2}
overseas-appid: ${PDA_OVERSEAS_APPID:__UNI__936D079}
price-book:
max-size-bytes: ${FILE_PRICE_BOOK_MAX_SIZE_BYTES:10485760}
export:
+34
View File
@@ -28,6 +28,40 @@ CREATE TABLE IF NOT EXISTS file_object (
KEY idx_file_tenant_created (tenant_id, created_at)
) ENGINE=InnoDB COMMENT='文件元数据表';
CREATE TABLE IF NOT EXISTS file_pda_release_channel (
app_code VARCHAR(16) NOT NULL,
app_id VARCHAR(64) NOT NULL,
current_apk_id CHAR(36) NULL,
current_wgt_id CHAR(36) NULL,
PRIMARY KEY (app_code)
) ENGINE=InnoDB COMMENT='国内/海外PDA当前发布指针';
INSERT INTO file_pda_release_channel (app_code, app_id) VALUES
('domestic', '__UNI__9BFFAF2'), ('overseas', '__UNI__936D079')
ON DUPLICATE KEY UPDATE app_id=VALUES(app_id);
CREATE TABLE IF NOT EXISTS file_pda_release (
id CHAR(36) NOT NULL,
app_code VARCHAR(16) NOT NULL,
app_id VARCHAR(64) NOT NULL,
package_type VARCHAR(8) NOT NULL,
version_name VARCHAR(32) NOT NULL,
version_code INT UNSIGNED NOT NULL,
min_app_version_code INT UNSIGNED NOT NULL,
mandatory TINYINT(1) NOT NULL DEFAULT 0,
release_notes VARCHAR(1000) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
object_key VARCHAR(128) NOT NULL,
sha256 CHAR(64) NOT NULL,
size_bytes BIGINT UNSIGNED NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
published_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_pda_release_version (app_code, package_type, version_name),
KEY idx_pda_release_created (app_code, created_at)
) ENGINE=InnoDB COMMENT='PDA安装包与热更新发布历史';
CREATE TABLE IF NOT EXISTS file_download_token (
id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -0,0 +1,77 @@
openapi: 3.0.3
info:
title: PDA version releases
version: 1.0.0
description: Domestic and overseas PDA packages have independent AppIDs and publication pointers.
paths:
/api/platform/v1/files/pda-releases/{app}:
parameters:
- $ref: '#/components/parameters/app'
get:
summary: List recent releases for one PDA application
security: [{ bearerAuth: [] }]
description: Requires platform:pda-release:list and PLATFORM membership.
responses:
'200': { description: ApiResponse containing release records and current flags }
post:
summary: Upload a draft APK or WGT (does not publish)
security: [{ bearerAuth: [] }]
description: Requires platform:pda-release:manage and PLATFORM membership. Maximum 200 MiB.
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [packageType, versionName, versionCode, minAppVersionCode, file]
properties:
packageType: { type: string, enum: [apk, wgt] }
versionName: { type: string, example: 0.0.4 }
versionCode: { type: integer, minimum: 1 }
minAppVersionCode: { type: integer, minimum: 1 }
mandatory: { type: boolean }
releaseNotes: { type: string, maxLength: 1000 }
file: { type: string, format: binary }
responses:
'200': { description: ApiResponse containing a DRAFT release }
/api/platform/v1/files/pda-releases/{id}/publish:
post:
summary: Publish a draft and switch the current package pointer
security: [{ bearerAuth: [] }]
description: Requires platform:pda-release:manage and PLATFORM membership.
parameters: [{ $ref: '#/components/parameters/id' }]
responses:
'200': { description: ApiResponse containing the published release }
/api/platform/v1/files/pda-releases/{id}/withdraw:
post:
summary: Stop serving an active release
security: [{ bearerAuth: [] }]
description: Requires platform:pda-release:manage and PLATFORM membership.
parameters: [{ $ref: '#/components/parameters/id' }]
responses:
'200': { description: ApiResponse containing the withdrawn release }
/api/pda-updates/{app}/latest:
get:
summary: Anonymous update manifest for one PDA application
parameters:
- $ref: '#/components/parameters/app'
- { name: appid, in: query, required: true, schema: { type: string } }
- { name: platform, in: query, required: true, schema: { type: string, enum: [android] } }
- { name: appVersionCode, in: query, required: true, schema: { type: integer, minimum: 1 } }
- { name: wgtVersion, in: query, required: true, schema: { type: string } }
responses:
'200':
description: Plain JSON with update=false or an APK/WGT manifest including appid and minAppVersionCode.
/api/pda-updates/{app}/{id}/package:
get:
summary: Download a published package
parameters: [{ $ref: '#/components/parameters/app' }, { $ref: '#/components/parameters/id' }]
responses:
'200': { description: APK or WGT binary }
'404': { description: Draft or withdrawn package is not available }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
parameters:
app: { name: app, in: path, required: true, schema: { type: string, enum: [domestic, overseas] } }
id: { name: id, in: path, required: true, schema: { type: string, format: uuid } }
@@ -0,0 +1,52 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
import java.io.ByteArrayOutputStream;
import java.nio.file.Path;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PdaPackageStorageTest {
@TempDir Path root;
@Test
void rejectsWrongContentAndExtension() throws Exception {
var storage = new PdaPackageStorage(root.toString());
var invalid = new MockMultipartFile("file", "app.wgt", "application/octet-stream", new byte[] {1, 2, 3});
assertThatThrownBy(() -> storage.store("domestic", "11111111-1111-1111-1111-111111111111", "wgt", invalid))
.isInstanceOf(BizException.class);
var wrongName = new MockMultipartFile("file", "app.apk", "application/octet-stream", archive("manifest.json"));
assertThatThrownBy(() -> storage.store("domestic", "11111111-1111-1111-1111-111111111111", "wgt", wrongName))
.isInstanceOf(BizException.class);
}
@Test
void validatesPackageStructureAndReadsStoredContent() throws Exception {
var storage = new PdaPackageStorage(root.toString());
byte[] content = archive("manifest.json");
var file = new MockMultipartFile("file", "app.wgt", "application/octet-stream", content);
var stored = storage.store("overseas", "22222222-2222-2222-2222-222222222222", "wgt", file);
assertThat(stored.key()).isEqualTo("overseas/22222222-2222-2222-2222-222222222222.wgt");
assertThat(stored.sha256()).hasSize(64);
assertThat(storage.load(stored.key()).getContentAsByteArray()).isEqualTo(content);
assertThatThrownBy(() -> storage.load("../domestic/22222222-2222-2222-2222-222222222222.wgt"))
.isInstanceOf(BizException.class);
}
private static byte[] archive(String entryName) throws Exception {
var output = new ByteArrayOutputStream();
try (var zip = new ZipOutputStream(output)) {
zip.putNextEntry(new ZipEntry(entryName));
zip.write("{}".getBytes(java.nio.charset.StandardCharsets.UTF_8));
zip.closeEntry();
}
return output.toByteArray();
}
}
@@ -0,0 +1,81 @@
package com.baiyue.file.service;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class PdaReleaseServiceTest {
private static final String APK = "11111111-1111-1111-1111-111111111111";
private static final String WGT = "22222222-2222-2222-2222-222222222222";
@Test
void selectsApkForOldBaseThenWgtForCompatibleBase() throws Exception {
var jdbc = mock(JdbcTemplate.class);
stubQueries(jdbc);
var service = new PdaReleaseService(jdbc, mock(PdaPackageStorage.class),
"https://updates.example.com", "domestic-app", "overseas-app");
assertThat(service.latest("domestic", "domestic-app", "android", 1, "1.0.0"))
.containsEntry("packageType", "apk").containsEntry("appid", "domestic-app");
assertThat(service.latest("domestic", "domestic-app", "android", 2, "1.0.0"))
.containsEntry("packageType", "wgt").containsEntry("minAppVersionCode", 2);
assertThat(service.latest("domestic", "domestic-app", "android", 2, "1.0.10"))
.isEqualTo(Map.of("update", false));
assertThatThrownBy(() -> service.latest("domestic", "overseas-app", "android", 2, "1.0.0"))
.hasMessageContaining("参数无效");
}
@SuppressWarnings({"unchecked", "rawtypes"})
private static void stubQueries(JdbcTemplate jdbc) throws Exception {
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
String sql = invocation.getArgument(0);
RowMapper mapper = invocation.getArgument(1);
if (sql.contains("file_pda_release_channel")) {
ResultSet rs = mock(ResultSet.class);
when(rs.getString(1)).thenReturn("domestic-app");
when(rs.getString(2)).thenReturn(APK);
when(rs.getString(3)).thenReturn(WGT);
return List.of(mapper.mapRow(rs, 0));
}
if (sql.contains("file_pda_release")) {
String id = invocation.getArgument(2);
ResultSet rs = releaseRow(id);
return List.of(mapper.mapRow(rs, 0));
}
return List.of();
});
}
private static ResultSet releaseRow(String id) throws Exception {
ResultSet rs = mock(ResultSet.class);
boolean apk = id.equals(APK);
when(rs.getString("id")).thenReturn(id);
when(rs.getString("app_code")).thenReturn("domestic");
when(rs.getString("app_id")).thenReturn("domestic-app");
when(rs.getString("package_type")).thenReturn(apk ? "apk" : "wgt");
when(rs.getString("version_name")).thenReturn(apk ? "1.0.0" : "1.0.10");
when(rs.getInt("version_code")).thenReturn(2);
when(rs.getInt("min_app_version_code")).thenReturn(2);
when(rs.getString("release_notes")).thenReturn("Update");
when(rs.getString("status")).thenReturn("PUBLISHED");
when(rs.getString("object_key")).thenReturn("domestic/" + id + (apk ? ".apk" : ".wgt"));
when(rs.getString("sha256")).thenReturn("a".repeat(64));
when(rs.getLong("size_bytes")).thenReturn(256L);
when(rs.getTimestamp("created_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 9, 20, 1, 0)));
when(rs.getTimestamp("published_at")).thenReturn(Timestamp.valueOf(LocalDateTime.of(2026, 9, 20, 2, 0)));
return rs;
}
}
+1 -1
View File
@@ -37,7 +37,7 @@
| 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端允许待审核、待测量/测量中、待分柜及装柜中订单,冻结每票全部有效入仓片段与满箱数;成员全部未进柜时创建未锁定组,成员已满箱进入同一装柜计划时直接创建锁定组,部分占用或跨柜时拒绝;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 |
@@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestParam;
@@ -29,6 +31,13 @@ public interface PendingReviewOrderClient {
@RequestParam long page,
@RequestParam long size);
@PostMapping("/batch")
ApiResponse<List<Row>> batch(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BatchRequest request);
record BatchRequest(List<Long> orderIds) {}
record Row(Long orderId, String orderNo, String inboundCode, Long customerId,
String customerName, Long warehouseId, String channelCode, String channelName,
int totalPackageCount, LocalDateTime createdAt, LocalDateTime expectedArrivalAt,
@@ -12,6 +12,8 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -58,6 +60,15 @@ public class InternalPriceBookController {
request.channelCode(), request.destinationType(), request.requireInboundCodePrefix()));
}
@GetMapping("/channels")
public ApiResponse<java.util.List<com.baiyue.firstleg.dto.PriceBookDtos.CurrentChannel>> channels(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestParam long tenantId,
@RequestParam long originWarehouseId) {
requireToken(token);
return ApiResponse.success(service.channels(tenantId, originWarehouseId));
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
@@ -13,6 +13,11 @@ import static com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRo
/** 同柜不可拆组在 Firstleg 单库内的唯一持久化入口。 */
@Mapper
public interface ColoadGroupMapper {
@Select({"<script>", "SELECT DISTINCT order_id FROM fl_inbound_order WHERE deleted=0 AND order_id IN",
"<foreach collection='orderIds' item='orderId' open='(' separator=',' close=')'>",
"#{orderId}", "</foreach>", "</script>"})
List<Long> existingOrderIds(@Param("orderIds") List<Long> orderIds);
@Select({"<script>", """
SELECT o.tenant_id tenantId,o.order_id orderId,o.order_no orderNo,
o.id inboundId,o.inbound_code inboundCode,o.process_status processStatus,
@@ -22,6 +27,15 @@ public interface ColoadGroupMapper {
(SELECT COUNT(*) FROM fl_loading_plan_item item
WHERE item.account_id=o.tenant_id AND item.inbound_id=o.id AND item.deleted=0)
activeAllocationCount,
(SELECT COUNT(DISTINCT item.loading_plan_id) FROM fl_loading_plan_item item
WHERE item.account_id=o.tenant_id AND item.inbound_id=o.id AND item.deleted=0)
activeAllocationPlanCount,
(SELECT MIN(item.loading_plan_id) FROM fl_loading_plan_item item
WHERE item.account_id=o.tenant_id AND item.inbound_id=o.id AND item.deleted=0)
activeAllocationPlanId,
(SELECT COALESCE(SUM(item.package_count),0) FROM fl_loading_plan_item item
WHERE item.account_id=o.tenant_id AND item.inbound_id=o.id AND item.deleted=0)
activeAllocatedPackageCount,
(SELECT member.group_id FROM fl_coload_group_member member
WHERE member.tenant_id=o.tenant_id AND member.inbound_id=o.id
AND member.active_flag=1 LIMIT 1) activeGroupId
@@ -25,6 +25,9 @@ public final class ColoadPersistenceRows {
private String channelName;
private Integer fullPackageCount;
private Integer activeAllocationCount;
private Integer activeAllocationPlanCount;
private Long activeAllocationPlanId;
private Integer activeAllocatedPackageCount;
private Long activeGroupId;
}
@@ -13,6 +13,10 @@ import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.Comm
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.GroupRow;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.InboundCandidateRow;
import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PendingReviewOrderClient;
import com.baiyue.firstleg.service.InboundOrderCreationService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -35,9 +39,16 @@ import java.util.Set;
@Service
public class ColoadGroupService implements ColoadGroupAssignmentPort {
private static final Set<String> CURRENT_GROUP_STATUSES = Set.of("ACTIVE", "LOCKED", "CONSUMED");
private static final Set<String> PRE_LOADING_GROUP_STATUSES = Set.of(
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT");
@Resource
private ColoadGroupMapper mapper;
@Resource private PendingReviewOrderClient pendingReviewOrders;
@Resource private OrderCargoClient orderCargoClient;
@Resource private InboundOrderCreationService inboundOrderCreationService;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Transactional
public GroupSnapshot create(List<Long> requestedOrderIds, String idempotencyKey,
@@ -48,8 +59,10 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
CommandClaim claim = claim(operatorId, "CREATE", key, requestHash);
if (claim.replayGroupId() != null) return requiredSnapshot(claim.replayGroupId());
preparePendingReviewInbounds(orderIds);
List<InboundCandidateRow> candidates = authoritativeCandidates(orderIds, null);
validateSameRoute(candidates);
Long lockedPlanId = validateAllocationState(candidates);
long groupId = IdWorker.getId();
String groupNo = "CLG-" + groupId;
InboundCandidateRow first = candidates.get(0);
@@ -60,6 +73,14 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
candidate.getInboundId(), candidate.getFullPackageCount());
}
history(groupId, "CREATE", null, "ACTIVE", null, key, null, operatorId, operatorName);
if (lockedPlanId != null) {
if (mapper.lockToPlan(groupId, lockedPlanId, operatorId, trim(operatorName)) != 1) {
throw conflict("装柜中订单的同柜组锁定失败,请刷新后重试");
}
history(groupId, "LOCK", "ACTIVE", "LOCKED", lockedPlanId,
"CREATE-LOCK-" + sha256(key).substring(0, 48), null,
operatorId, operatorName);
}
complete(claim.commandId(), groupId);
return requiredSnapshot(groupId);
}
@@ -253,12 +274,45 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
return resolved;
}
private void preparePendingReviewInbounds(List<Long> orderIds) {
Set<Long> existing = Set.copyOf(mapper.existingOrderIds(orderIds));
List<Long> missing = orderIds.stream().filter(orderId -> !existing.contains(orderId)).toList();
if (missing.isEmpty()) return;
try {
var response = pendingReviewOrders.batch(internalToken,
new PendingReviewOrderClient.BatchRequest(missing));
if (response == null || !response.successful() || response.data() == null) throw unavailable();
for (PendingReviewOrderClient.Row row : response.data()) {
if (row == null || row.orderId() == null || !missing.contains(row.orderId())
|| row.customerId() == null || !"PENDING_REVIEW".equals(row.mainStatus())) {
throw conflict("待审核订单权威快照已变化,请刷新后重试");
}
var snapshotResponse = orderCargoClient.inboundSnapshot(
internalToken, row.customerId(), row.orderId());
if (snapshotResponse == null || !snapshotResponse.successful()
|| snapshotResponse.data() == null
|| snapshotResponse.data().tenantId() != row.customerId()
|| snapshotResponse.data().orderId() != row.orderId()
|| !Objects.equals(snapshotResponse.data().inboundCode(), row.inboundCode())) {
throw conflict("待审核订单履约快照已变化,请刷新后重试");
}
inboundOrderCreationService.prepareFromDraft(snapshotResponse.data());
}
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw unavailable();
}
}
private String unavailableReason(InboundCandidateRow row, Long allowedGroupId) {
if (row.getTenantId() == null || row.getTenantId() <= 0
|| row.getInboundId() == null || row.getInboundId() <= 0
|| !StringUtils.hasText(row.getInboundCode())) return "权威业务快照缺失";
if (!"WAIT_SPLIT".equals(row.getProcessStatus())) return "状态非待分柜";
if (!Objects.equals(row.getActiveAllocationCount(), 0)) return "已分配装柜计划";
if (!PRE_LOADING_GROUP_STATUSES.contains(row.getProcessStatus())
&& !"LOADING".equals(row.getProcessStatus())) {
return "状态不允许创建同柜组";
}
if (row.getActiveGroupId() != null && !Objects.equals(row.getActiveGroupId(), allowedGroupId)) {
return "已在其他同柜组";
}
@@ -271,6 +325,33 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
return null;
}
private Long validateAllocationState(List<InboundCandidateRow> rows) {
boolean hasAllocation = rows.stream().anyMatch(row -> !Objects.equals(row.getActiveAllocationCount(), 0));
boolean hasUnallocated = rows.stream().anyMatch(row -> Objects.equals(row.getActiveAllocationCount(), 0));
if (!hasAllocation) {
if (rows.stream().anyMatch(row -> "LOADING".equals(row.getProcessStatus()))) {
throw conflict("装柜中订单缺少有效装柜计划占用");
}
return null;
}
if (hasUnallocated) {
throw conflict("所选订单只有部分成员进入装柜计划,不能创建同柜组");
}
Long planId = null;
for (InboundCandidateRow row : rows) {
if (!Objects.equals(row.getActiveAllocationPlanCount(), 1)
|| row.getActiveAllocationPlanId() == null
|| !Objects.equals(row.getActiveAllocatedPackageCount(), row.getFullPackageCount())) {
throw conflict(candidateLabel(row) + "未完整分配到唯一装柜计划");
}
if (planId == null) planId = row.getActiveAllocationPlanId();
else if (!Objects.equals(planId, row.getActiveAllocationPlanId())) {
throw conflict("所选装柜中订单不在同一个装柜计划,不能创建同柜组");
}
}
return planId;
}
private static String candidateLabel(InboundCandidateRow row) {
return StringUtils.hasText(row.getInboundCode())
? "入仓码“" + row.getInboundCode().trim() + "”" : "入仓码缺失的片段";
@@ -429,6 +510,10 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
return new BizException(ResultCode.CONFLICT, message);
}
private static BizException unavailable() {
return new BizException(ResultCode.INTERNAL_ERROR, "待审核订单权威服务暂不可用");
}
private record CommandClaim(long commandId, Long replayGroupId) {
}
}
@@ -32,6 +32,10 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
AND label.inbound_id = plan_item.inbound_id
AND label.inbound_detail_id = plan_item.inbound_detail_id
AND label.batch_status = 'ACTIVE'
AND (plan_item.unit_type <> 'SEALED_PALLET'
OR EXISTS (SELECT 1 FROM fl_dropship_pallet_carton pallet_carton
WHERE pallet_carton.pallet_id = plan_item.pallet_id
AND pallet_carton.carton_label_id = label.id))
WHERE pick_item.pick_task_id = #{taskId} AND plan_item.deleted = 0
ORDER BY pick_item.id, label.carton_index
""")
@@ -52,4 +52,17 @@ public class IamCustomerSnapshotService {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户主数据服务暂不可用");
}
}
/**
* 打印托盘信息单时客户昵称属于展示字段,客户资料尚未补全不应阻断 PDF 生成。
* 下游服务不可用等基础设施错误仍然向上抛出,避免静默生成不完整数据。
*/
public String customerNicknameForPrint(long customerId) {
try {
return customerNickname(customerId);
} catch (BizException exception) {
if (exception.getResultCode() == ResultCode.CONFLICT) return "—";
throw exception;
}
}
}
@@ -32,6 +32,8 @@ public class InboundCartonLabelPrintService {
private static final BigDecimal PAGE_MM = new BigDecimal("100");
private static final BigDecimal A4_WIDTH_MM = new BigDecimal("210");
private static final BigDecimal A4_HEIGHT_MM = new BigDecimal("297");
private static final int DROPSHIP_INBOUND_ROWS_PER_PAGE = 10;
private static final int DROPSHIP_SKU_ROWS_PER_PAGE = 34;
private static final List<String> SAFE_RESPONSE_HEADERS = List.of(
HttpHeaders.CONTENT_DISPOSITION, HttpHeaders.CACHE_CONTROL, HttpHeaders.PRAGMA,
"X-Print-Page-Count", "X-Request-Id", "X-Trace-Id");
@@ -134,7 +136,7 @@ public class InboundCartonLabelPrintService {
}
Map<Long, String> customerNicknameCache = new HashMap<>();
List<PrintPdfClient.DropshipPalletDocument> documents = pallets.stream()
.map(pallet -> dropshipPalletDocument(pallet, customerNicknameCache)).toList();
.flatMap(pallet -> dropshipPalletDocuments(pallet, customerNicknameCache).stream()).toList();
String fileName = pallets.size() == 1
? pallets.get(0).palletNo() + "-A4托盘信息单.pdf"
: inboundCode + "-" + pallets.size() + "托-A4托盘信息单.pdf";
@@ -148,7 +150,7 @@ public class InboundCartonLabelPrintService {
}
}
private PrintPdfClient.DropshipPalletDocument dropshipPalletDocument(
private List<PrintPdfClient.DropshipPalletDocument> dropshipPalletDocuments(
Pallet pallet, Map<Long, String> customerNicknameCache) {
if (pallet == null || pallet.palletId() == null || !StringUtils.hasText(pallet.palletNo())) {
throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印");
@@ -161,7 +163,7 @@ public class InboundCartonLabelPrintService {
List<PrintPdfClient.DropshipPalletInboundRow> inboundRows = inboundFacts.stream()
.map(row -> new PrintPdfClient.DropshipPalletInboundRow(
row.getInboundCode(),
customerNicknameCache.computeIfAbsent(row.getTenantId(), customerSnapshots::customerNickname),
customerNicknameCache.computeIfAbsent(row.getTenantId(), customerSnapshots::customerNicknameForPrint),
number(row.getCartonCount()), number(row.getItemQuantity())))
.toList();
String destinationWarehouse = inboundFacts.stream()
@@ -172,8 +174,31 @@ public class InboundCartonLabelPrintService {
.stream().map(row -> new PrintPdfClient.DropshipPalletSkuRow(
row.getSkuCode(), number(row.getCartonCount()), number(row.getItemQuantity()),
unitsPerCarton(row))).toList();
return new PrintPdfClient.DropshipPalletDocument(
pallet.palletNo(), destinationWarehouse, pallet.palletNo(), inboundRows, skuRows);
int pageCount = Math.max(1, Math.max(pageCount(inboundRows.size(), DROPSHIP_INBOUND_ROWS_PER_PAGE),
pageCount(skuRows.size(), DROPSHIP_SKU_ROWS_PER_PAGE)));
List<PrintPdfClient.DropshipPalletDocument> documents = new ArrayList<>(pageCount);
for (int page = 0; page < pageCount; page++) {
documents.add(new PrintPdfClient.DropshipPalletDocument(
pallet.palletNo(), destinationWarehouse, pallet.palletNo(),
pageOrRepeat(inboundRows, page, DROPSHIP_INBOUND_ROWS_PER_PAGE),
page(skuRows, page, DROPSHIP_SKU_ROWS_PER_PAGE)));
}
return List.copyOf(documents);
}
private int pageCount(int size, int pageSize) {
return size == 0 ? 0 : (size + pageSize - 1) / pageSize;
}
private <T> List<T> page(List<T> rows, int page, int pageSize) {
int from = Math.min(rows.size(), page * pageSize);
int to = Math.min(rows.size(), from + pageSize);
return rows.subList(from, to);
}
private <T> List<T> pageOrRepeat(List<T> rows, int page, int pageSize) {
List<T> selected = page(rows, page, pageSize);
return selected.isEmpty() ? page(rows, 0, pageSize) : selected;
}
private String unitsPerCarton(DropshipPalletizationMapper.PrintSkuRow row) {
@@ -8,11 +8,13 @@ paths:
post:
summary: 创建同柜不可拆组
description: >-
需要 platform:firstleg:smart-loading:assign。orderIds 至少2项;服务端解析每个订单全部 WAIT_SPLIT、
未分配且未属于其他活动组的可用入库片段,要求国内仓和渠道一致,并冻结全部片段及其满箱数。
同一订单部分片段不可用时只纳入全部当前可用片段;全部不可用时按入仓码明确返回状态非待分柜、
已分配装柜计划、已在其他同柜组、箱数无效或仓库/渠道权威快照缺失。跨成员仓库或渠道不一致时
返回入仓码及仓库名/渠道名;平台错误不得展示 orderId、inboundId 或 groupId。
需要 platform:firstleg:smart-loading:assign。orderIds 至少2项;服务端解析每个订单处于待审核、
待测量/测量中、待分柜或装柜中的全部有效入仓片段;待审核订单先经 Order 批量权威核验并幂等准备
PREPARING 入库投影。全部成员要求国内仓和渠道一致,并冻结全部片段及其满箱数。
全部成员未进入装柜计划时创建 ACTIVE 组;全部成员已经按满箱数完整进入同一个装柜计划时直接创建并
锁定为 LOCKED 组。仅部分成员进柜、分散在不同装柜计划、箱数不完整、已属于其他活动组或权威仓库/
渠道快照缺失时拒绝。跨成员仓库或渠道不一致时返回入仓码及仓库名/渠道名;平台错误不得展示
orderId、inboundId 或 groupId。
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
@@ -136,6 +136,17 @@ paths:
requireInboundCodePrefix: {type: boolean}
responses:
'200': {description: 成功时 data 返回 channelCode;requireInboundCodePrefix=true 时同时返回 inboundCodePrefix}
/internal/firstleg/v1/pricing/channels:
get:
summary: 查询指定客户和起运仓当前可用渠道
description: 仅供订单服务的旧系统导入解析使用,不经公网网关暴露。
security:
- internalServiceToken: []
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: originWarehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 返回当前有效价格簿中的渠道编码和名称}
/internal/firstleg/v1/pricing/order-channel-changes/validate:
post:
summary: Order 提交渠道变更前校验 Firstleg 计价资格与费用可逆性
@@ -39,12 +39,17 @@ class ColoadGroupOpenApiContractTest {
.contains("platform:firstleg:smart-loading:assign")
.contains("platform:firstleg:smart-loading:list")
.contains("任一冻结成员缺少有效 inboundCode 时失败关闭")
.contains("全部 WAIT_SPLIT")
.contains("待审核")
.contains("待测量/测量中")
.contains("待分柜或装柜中")
.contains("直接创建并")
.contains("锁定为 LOCKED 组")
.contains("国内仓和渠道一致")
.contains("状态非待分柜")
.contains("已分配装柜计划")
.contains("已在其他同柜组")
.contains("平台错误不得展示 orderId、inboundId 或 groupId")
.contains("仅部分成员进柜")
.contains("分散在不同装柜计划")
.contains("已属于其他活动组")
.contains("平台错误不得展示")
.contains("orderId、inboundId 或 groupId")
.doesNotContain("destinationType")
.doesNotContain("destinationWarehouseId")
.doesNotContain("destinationCode")
@@ -1,6 +1,11 @@
package com.baiyue.firstleg.loading.coload;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PendingReviewOrderClient;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.firstleg.service.InboundOrderCreationService;
import com.baiyue.firstleg.loading.coload.persistence.ColoadGroupMapper;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.GroupRow;
import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.CommandRow;
@@ -12,6 +17,7 @@ import com.baiyue.firstleg.loading.coload.persistence.ColoadPersistenceRows.Orde
import com.baiyue.firstleg.loading.coload.service.ColoadGroupService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -35,9 +41,19 @@ import static org.mockito.Mockito.when;
class ColoadGroupServiceTest {
@MockBean
private ColoadGroupMapper mapper;
@MockBean private PendingReviewOrderClient pendingReviewOrders;
@MockBean private OrderCargoClient orderCargoClient;
@MockBean private InboundOrderCreationService inboundOrderCreationService;
@Resource
private ColoadGroupService service;
@BeforeEach
void allowNoPendingReviewProjectionByDefault() {
when(mapper.existingOrderIds(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(pendingReviewOrders.batch(anyString(), any(PendingReviewOrderClient.BatchRequest.class)))
.thenReturn(ApiResponse.success(List.of()));
}
@Test
void readsCrossCustomerTwoMemberGroupByEitherInboundCodeWithoutInternalIdentifiers() {
when(mapper.groupsByInboundCode("IN-A")).thenReturn(List.of(lookup(500L, "ACTIVE")));
@@ -123,11 +139,9 @@ class ColoadGroupServiceTest {
@Test
void createsCrossCustomerGroupAndFreezesAllAvailableFragmentsForEveryOrder() {
InboundCandidateRow alreadyAllocatedFragment = candidate(101L, 11L, 1004L, 40);
alreadyAllocatedFragment.setActiveAllocationCount(1);
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(
candidate(101L, 11L, 1001L, 30), candidate(101L, 11L, 1003L, 20),
alreadyAllocatedFragment, candidate(202L, 12L, 1002L, 50)));
candidate(202L, 12L, 1002L, 50)));
when(mapper.insertGroup(anyLong(), anyString(), eq(9L), eq("CH"),
eq(7L), eq("operator"))).thenReturn(1);
when(mapper.insertMember(anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt()))
@@ -154,8 +168,107 @@ class ColoadGroupServiceTest {
verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L), eq(1001L), eq(30));
verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L), eq(1003L), eq(20));
verify(mapper).insertMember(anyLong(), eq(groupId.getValue()), eq(202L), eq(12L), eq(1002L), eq(50));
verify(mapper, never()).insertMember(anyLong(), eq(groupId.getValue()), eq(101L), eq(11L),
eq(1004L), anyInt());
}
@Test
void provisionsSelectedPendingReviewOrderBeforeResolvingColoadCandidates() {
PendingReviewOrderClient.Row pending = new PendingReviewOrderClient.Row(
11L, "FO-11", "IN-1001", 101L, "客户", 9L, "CH", "美森海卡",
30, LocalDateTime.now(), null, "PENDING_REVIEW");
OrderApprovedInboundCommand snapshot = new OrderApprovedInboundCommand(
101L, 11L, "FO-11", "IN-1001", 101L, "客户", 9L, "义乌仓",
null, null, null, "FIRST_LEG", "CH", "美森海卡", null, null,
null, null, null, false, "FBA", null, null, null, null, null, null, 30);
when(pendingReviewOrders.batch(anyString(), any(PendingReviewOrderClient.BatchRequest.class)))
.thenReturn(ApiResponse.success(List.of(pending)));
when(mapper.existingOrderIds(List.of(11L, 12L))).thenReturn(List.of(12L));
when(orderCargoClient.inboundSnapshot(anyString(), eq(101L), eq(11L)))
.thenReturn(ApiResponse.success(snapshot));
when(inboundOrderCreationService.prepareFromDraft(snapshot)).thenReturn(1001L);
stubSuccessfulCreate("pending-review", List.of(
candidate(101L, 11L, 1001L, 30), candidate(202L, 12L, 1002L, 50)), "ACTIVE", null);
var result = service.create(List.of(11L, 12L), "pending-review", 7L, "operator");
assertThat(result.status()).isEqualTo("ACTIVE");
verify(inboundOrderCreationService).prepareFromDraft(snapshot);
verify(pendingReviewOrders).batch(anyString(), eq(new PendingReviewOrderClient.BatchRequest(List.of(11L))));
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {
"PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT"
})
void createsUnlockedGroupBeforeLoading(String status) {
InboundCandidateRow first = candidate(101L, 11L, 1001L, 30); first.setProcessStatus(status);
InboundCandidateRow second = candidate(202L, 12L, 1002L, 50); second.setProcessStatus(status);
stubSuccessfulCreate("status-" + status, List.of(first, second), "ACTIVE", null);
var result = service.create(List.of(11L, 12L), "status-" + status, 7L, "operator");
assertThat(result.status()).isEqualTo("ACTIVE");
verify(mapper, never()).lockToPlan(anyLong(), anyLong(), anyLong(), any());
}
@Test
void createsLockedGroupWhenEveryLoadingMemberIsFullyAllocatedToTheSamePlan() {
InboundCandidateRow first = loadingCandidate(101L, 11L, 1001L, 30, 700L);
InboundCandidateRow second = loadingCandidate(202L, 12L, 1002L, 50, 700L);
stubSuccessfulCreate("loading-key", List.of(first, second), "LOCKED", 700L);
when(mapper.lockToPlan(anyLong(), eq(700L), eq(7L), eq("operator"))).thenReturn(1);
var result = service.create(List.of(11L, 12L), "loading-key", 7L, "operator");
assertThat(result.status()).isEqualTo("LOCKED");
assertThat(result.lockedPlanId()).isEqualTo(700L);
verify(mapper).lockToPlan(anyLong(), eq(700L), eq(7L), eq("operator"));
verify(mapper).insertHistory(anyLong(), anyLong(), eq("LOCK"), eq("ACTIVE"), eq("LOCKED"),
eq(700L), anyString(), isNull(), eq(7L), eq("operator"));
}
@Test
void createsLockedGroupForPreMeasurementOrdersAlreadyFullyReservedInTheSamePlan() {
InboundCandidateRow first = loadingCandidate(101L, 11L, 1001L, 30, 700L);
first.setProcessStatus("PREPARING");
InboundCandidateRow second = loadingCandidate(202L, 12L, 1002L, 50, 700L);
second.setProcessStatus("MEASURING");
stubSuccessfulCreate("reserved-early", List.of(first, second), "LOCKED", 700L);
when(mapper.lockToPlan(anyLong(), eq(700L), eq(7L), eq("operator"))).thenReturn(1);
var result = service.create(List.of(11L, 12L), "reserved-early", 7L, "operator");
assertThat(result.status()).isEqualTo("LOCKED");
assertThat(result.lockedPlanId()).isEqualTo(700L);
}
@Test
void rejectsLoadingMembersSplitAcrossPlansOrNotFullyAllocated() {
when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), anyString(), anyString())).thenReturn(1);
InboundCandidateRow first = loadingCandidate(101L, 11L, 1001L, 30, 700L);
InboundCandidateRow otherPlan = loadingCandidate(202L, 12L, 1002L, 50, 701L);
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(first, otherPlan));
assertThatThrownBy(() -> service.create(List.of(11L, 12L), "different-plans", 7L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("不在同一个装柜计划");
InboundCandidateRow partial = loadingCandidate(202L, 12L, 1002L, 50, 700L);
partial.setActiveAllocatedPackageCount(49);
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(first, partial));
assertThatThrownBy(() -> service.create(List.of(11L, 12L), "partial-loading", 7L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("未完整分配到唯一装柜计划");
}
@Test
void rejectsSelectionWhenOnlyPartOfTheMembersHasEnteredAPlan() {
when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), anyString(), anyString())).thenReturn(1);
InboundCandidateRow reserved = loadingCandidate(101L, 11L, 1001L, 30, 700L);
reserved.setProcessStatus("WAIT_MEASURE");
InboundCandidateRow unallocated = candidate(202L, 12L, 1002L, 50);
unallocated.setProcessStatus("MEASURING");
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(reserved, unallocated));
assertThatThrownBy(() -> service.create(List.of(11L, 12L), "partly-reserved", 7L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("只有部分成员进入装柜计划");
}
@Test
@@ -179,7 +292,7 @@ class ColoadGroupServiceTest {
assertThatThrownBy(() -> service.create(List.of(11L, 12L), "key", 7L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("订单“FO-11”")
.hasMessageContaining("入仓码“IN-1001”已分配装柜计划")
.hasMessageContaining("入仓码“IN-1001”已在其他同柜组")
.hasMessageNotContaining("订单 11")
.hasMessageNotContaining("groupId")
.hasMessageNotContaining("inboundId");
@@ -219,8 +332,8 @@ class ColoadGroupServiceTest {
when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), anyString(), anyString()))
.thenReturn(1);
InboundCandidateRow status = candidate(101L, 11L, 1001L, 30);
status.setProcessStatus("LOADING");
assertUnavailable(status, "state-key", "状态非待分柜");
status.setProcessStatus("LOADED");
assertUnavailable(status, "state-key", "状态不允许创建同柜组");
InboundCandidateRow grouped = candidate(101L, 11L, 1001L, 30);
grouped.setActiveGroupId(900L);
@@ -393,9 +506,33 @@ class ColoadGroupServiceTest {
value.setProcessStatus("WAIT_SPLIT"); value.setWarehouseId(9L); value.setWarehouseName("义乌仓");
value.setChannelCode("CH"); value.setChannelName("美森海卡");
value.setFullPackageCount(packages); value.setActiveAllocationCount(0);
value.setActiveAllocationPlanCount(0); value.setActiveAllocatedPackageCount(0);
return value;
}
private static InboundCandidateRow loadingCandidate(long tenantId, long orderId, long inboundId,
int packages, long planId) {
InboundCandidateRow value = candidate(tenantId, orderId, inboundId, packages);
value.setProcessStatus("LOADING"); value.setActiveAllocationCount(1);
value.setActiveAllocationPlanCount(1); value.setActiveAllocationPlanId(planId);
value.setActiveAllocatedPackageCount(packages);
return value;
}
private void stubSuccessfulCreate(String key, List<InboundCandidateRow> candidates,
String resultStatus, Long planId) {
when(mapper.insertCommand(anyLong(), eq(7L), eq("CREATE"), eq(key), anyString())).thenReturn(1);
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(candidates);
when(mapper.insertGroup(anyLong(), anyString(), eq(9L), eq("CH"), eq(7L), eq("operator"))).thenReturn(1);
when(mapper.insertMember(anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), anyInt())).thenReturn(1);
when(mapper.insertHistory(anyLong(), anyLong(), anyString(), any(), anyString(), any(),
anyString(), any(), anyLong(), any())).thenReturn(1);
when(mapper.completeCommand(anyLong(), anyLong())).thenReturn(1);
when(mapper.group(anyLong())).thenReturn(group(500L, resultStatus, planId, "LOCKED".equals(resultStatus) ? 1 : 0));
when(mapper.members(anyLong())).thenReturn(candidates.stream().map(row -> member(
row.getTenantId(), row.getOrderId(), row.getInboundId(), row.getFullPackageCount())).toList());
}
private void assertUnavailable(InboundCandidateRow row, String key, String reason) {
when(mapper.lockCandidates(List.of(11L, 12L))).thenReturn(List.of(
row, candidate(202L, 12L, 1002L, 50)));
@@ -49,6 +49,9 @@ class ColoadGroupSqlContractTest {
.contains("FROM fl_inbound_order o")
.contains("o.order_no orderNo", "o.inbound_code inboundCode")
.contains("o.warehouse_name warehouseName", "o.channel_name channelName")
.contains("activeAllocationPlanCount", "activeAllocationPlanId", "activeAllocatedPackageCount")
.contains("COUNT(DISTINCT item.loading_plan_id)", "MIN(item.loading_plan_id)")
.contains("COALESCE(SUM(item.package_count),0)")
.contains("FROM fl_loading_plan_item item")
.contains("item.deleted=0")
.contains("member.active_flag=1")
@@ -63,6 +66,15 @@ class ColoadGroupSqlContractTest {
.doesNotContain("destination_code");
}
@Test
void mapperDetectsExistingFirstlegProjectionsBeforeCallingThePendingReviewOwner() throws Exception {
Method method = ColoadGroupMapper.class.getDeclaredMethod("existingOrderIds", java.util.List.class);
String sql = String.join(" ", method.getAnnotation(Select.class).value());
assertThat(sql).contains("SELECT DISTINCT order_id FROM fl_inbound_order")
.contains("deleted=0", "order_id IN")
.doesNotContain("FOR UPDATE");
}
@Test
void inboundCodeReadKeepsConsumedGroupsAndAllFrozenEffectiveFragments() throws Exception {
Method lookup = ColoadGroupMapper.class.getDeclaredMethod("groupsByInboundCode", String.class);
@@ -73,6 +73,19 @@ class LoadingPickPdaTaskSqlContractTest {
.contains("label.box_code AS boxCode");
}
@Test
void sealedPalletCartonsAreRestrictedToTheirPhysicalPalletMembership() throws Exception {
String sql = LoadingPickItemMapper.class
.getDeclaredMethod("selectTaskCartonLabels", long.class)
.getAnnotation(Select.class).value()[0];
assertThat(sql)
.contains("plan_item.unit_type <> 'SEALED_PALLET'")
.contains("fl_dropship_pallet_carton pallet_carton")
.contains("pallet_carton.pallet_id = plan_item.pallet_id")
.contains("pallet_carton.carton_label_id = label.id");
}
@Test
void taskDetailResolvesOnlyTheActiveBookingOwnedByItsCurrentPlan() throws Exception {
String sql = LoadingPickTaskMapper.class
@@ -66,4 +66,12 @@ class IamCustomerSnapshotServiceTest {
assertThat(service.customerNickname(7L)).isEqualTo("Short Name");
}
@Test
void usesPlaceholderForPrintWhenCustomerNicknameIsMissing() {
when(client.page("internal-token", 1, 2, null, null, 7L))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 2, 0, 0)));
assertThat(service.customerNicknameForPrint(7L)).isEqualTo("—");
}
}
@@ -18,6 +18,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -108,6 +109,63 @@ class InboundCartonLabelPrintServiceTest {
assertThat(response.getBody()).containsExactly(4, 5, 6);
}
@Test
void splitsLargeDropshipPalletDetailsAcrossPrintableA4Pages() {
Pallet pallet = dropshipPallet("OPEN");
stubPalletFacts(90001L);
List<DropshipPalletizationMapper.PrintSkuRow> skuRows = new ArrayList<>();
for (int index = 1; index <= 35; index++) {
DropshipPalletizationMapper.PrintSkuRow row = new DropshipPalletizationMapper.PrintSkuRow();
row.setSkuCode("SKU-" + index);
row.setCartonCount(1);
row.setItemQuantity(index);
row.setMinUnitsPerCarton(index);
row.setMaxUnitsPerCarton(index);
skuRows.add(row);
}
when(palletization.printSkuRows(90001L)).thenReturn(skuRows);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.eq("request-large"), org.mockito.ArgumentMatchers.any()))
.thenReturn(new ResponseEntity<>(new byte[] {1}, headers,
org.springframework.http.HttpStatus.OK));
service.generateDropshipPallet(pallet, "PCHK2608040174", "request-large");
ArgumentCaptor<PrintPdfClient.DropshipPalletA4PdfRequest> request =
ArgumentCaptor.forClass(PrintPdfClient.DropshipPalletA4PdfRequest.class);
verify(print).generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.eq("request-large"), request.capture());
assertThat(request.getValue().documents()).hasSize(2).allSatisfy(document -> {
assertThat(document.palletNo()).isEqualTo("DP-260911-0001");
assertThat(document.inboundRows()).isNotEmpty().hasSizeLessThanOrEqualTo(10);
assertThat(document.skuRows()).hasSizeLessThanOrEqualTo(34);
});
assertThat(request.getValue().documents().get(0).skuRows()).hasSize(34);
assertThat(request.getValue().documents().get(1).skuRows()).hasSize(1);
}
@Test
void printsPalletDocumentWhenCustomerNicknameIsMissing() {
Pallet pallet = dropshipPallet("OPEN");
stubPalletFacts(90001L);
when(customerSnapshots.customerNicknameForPrint(10001L)).thenReturn("—");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.eq("request-missing-nickname"), org.mockito.ArgumentMatchers.any()))
.thenReturn(new ResponseEntity<>(new byte[] {1}, headers, org.springframework.http.HttpStatus.OK));
service.generateDropshipPallet(pallet, "PCHK2608040174", "request-missing-nickname");
ArgumentCaptor<PrintPdfClient.DropshipPalletA4PdfRequest> request =
ArgumentCaptor.forClass(PrintPdfClient.DropshipPalletA4PdfRequest.class);
verify(print).generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.eq("request-missing-nickname"), request.capture());
assertThat(request.getValue().documents().get(0).inboundRows().get(0).customerNickname()).isEqualTo("—");
}
@Test
void forwardsStableLabelsWithoutSkuAndOnlySafePdfHeaders() throws Exception {
when(labels.list(10001L, 70001L, List.of(1))).thenReturn(batch());
@@ -262,6 +320,6 @@ class InboundCartonLabelPrintServiceTest {
sku.setMaxUnitsPerCarton(19);
when(palletization.printSkuRows(palletId))
.thenReturn(palletId == 90001L ? List.of(sku) : List.of());
when(customerSnapshots.customerNickname(10001L)).thenReturn("小虫科技");
when(customerSnapshots.customerNicknameForPrint(10001L)).thenReturn("小虫科技");
}
}
+2 -1
View File
@@ -69,7 +69,7 @@ spring:
- id: file-service
uri: lb://file-service
predicates:
- Path=/api/merchant/v1/files/**,/api/platform/v1/files/**
- Path=/api/merchant/v1/files/**,/api/platform/v1/files/**,/api/pda-updates/**
- id: print-service
uri: lb://print-service
predicates:
@@ -161,6 +161,7 @@ baiyue:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
permit-paths:
- /api/pda-updates/**
- /api/auth/login
- /api/auth/register
- /api/auth/refresh
@@ -99,7 +99,7 @@ VALUES
(5306180,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:discard:list','货物废弃','/overseas-warehouse/cargo-disposal','GET',130,'ACTIVE',1,0),
(5306181,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:create','创建货物废弃申请',NULL,'POST',10,'ACTIVE',1,0),
(5306182,0,'MERCHANT','BUTTON',5306180,'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请',NULL,'POST',20,'ACTIVE',1,0),
(5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货预报','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,0),
(5306190,0,'MERCHANT','MENU',0,'merchant:overseas-warehouse:return-order:list','退货换标','/overseas-warehouse/return-relabel','GET',140,'ACTIVE',1,0),
(5306191,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:create','创建退货预报',NULL,'POST',10,'ACTIVE',1,0),
(5306192,0,'MERCHANT','BUTTON',5306190,'merchant:overseas-warehouse:return-order:relabel','退货转换标申请',NULL,'POST',20,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
@@ -131,18 +131,19 @@ VALUES
(5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0),
(5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0),
(5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0),
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', '修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0)
(5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', '修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0),
(5102028, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:import', '导入旧系统订单', NULL, NULL, 140, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission
WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)
WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027, 5102028)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission
WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)
WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027, 5102028)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES
@@ -150,6 +151,13 @@ INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
-- Full administrator roles receive all phase-one permissions in their application scope.
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
VALUES
(5101060, 0, 'PLATFORM', 'MENU', 5101000, 'platform:pda-release:list', 'PDA版本', '/system/pda-releases', NULL, 60, 'ACTIVE', 0),
(5101061, 0, 'PLATFORM', 'BUTTON', 5101060, 'platform:pda-release:manage', '发布PDA版本', NULL, NULL, 10, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), status='ACTIVE', deleted=0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission
WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON')
@@ -73,7 +73,9 @@ class CustomerProfileMigrationContractTest {
.contains("SELECT 'PASS' AS initialization_status")
.doesNotContain("ALTER TABLE iam_tenant", "phase1_iam_add_column");
assertThat(instructions)
.contains("当前开发阶段只维护纯初始化")
.contains("两个统一 SQL 文件仍只用于空库初始化")
.contains("已有生产库的受控结构补齐单独放在 `migrations/`")
.contains("不提供存量数据回填、清理或兼容入口")
.contains("模块 `src/main/resources/db/` 用作结构来源和开发追溯")
.contains("不再向历史迁移文件追加发布步骤");
}
@@ -51,6 +51,7 @@ class OverseasCustomerPermissionSqlContractTest {
"'merchant:cross-border:pallet-shipping:list','美本土散板','/cross-border/pallet-shipping'",
"'merchant:warehouse:inbound:list','海外仓入库订单','/orders/inbound'",
"'merchant:overseas-warehouse:return-inventory:list','退货库存','/overseas-warehouse/return-inventory'",
"'merchant:overseas-warehouse:return-order:list','退货换标','/overseas-warehouse/return-relabel'",
"'merchant:overseas-warehouse:relabel:files','管理贴标文件'",
"'merchant:overseas-warehouse:discard:cancel','取消货物废弃申请'");
}
@@ -160,8 +160,9 @@ class Phase5DeliveryPermissionSqlContractTest {
String readme = repositoryFile("deploy/sql/README.md");
assertThat(readme).contains(
"当前开发阶段只维护纯初始化",
"两个 SQL 文件沿用原文件名",
"两个统一 SQL 文件仍只用于空库初始化",
"已有生产库的受控结构补齐单独放在 `migrations/`",
"不提供存量数据回填、清理或兼容入口",
"国内 IAM 是全部平台与客户权限的唯一权威",
"模块 `src/main/resources/db/` 用作结构来源和开发追溯",
"不再向历史迁移文件追加发布步骤");
@@ -39,7 +39,17 @@ public class DeliveryCompletedOutboxService {
Objects.requireNonNull(completedAt, "completedAt");
Map<String, LastlegOrder> bySourceInbound = new LinkedHashMap<>();
for (LastlegOrder order : orders == null ? List.<LastlegOrder>of() : orders) {
if ("RELABEL_APPLICATION".equals(order.getOrderType())) continue;
// Only orders projected from the domestic first-leg domain need an
// acknowledgement event. Customer/local overseas orders can still carry
// a local sourceInboundId, but deliberately have no domestic sourceOrderId.
if (StringUtils.hasText(order.getSourceRegion())
&& !RegionCode.CN.name().equalsIgnoreCase(order.getSourceRegion())) continue;
// Relabel and warehouse-transfer deliveries are created and completed wholly
// in the overseas last-leg domain. They do not have a domestic source order
// to acknowledge, so publishing the cross-region event would make receipt
// completion fail on the missing source-order fields.
if ("RELABEL_APPLICATION".equals(order.getOrderType())
|| "WAREHOUSE_TRANSFER".equals(order.getOrderType())) continue;
if (!StringUtils.hasText(order.getSourceInboundId())) continue;
requireText(order.getSourceOrderId(), "sourceOrderId");
requireText(order.getInboundCode(), "inboundCode");
@@ -95,6 +95,19 @@ public class ReceivingContainerController {
.contentLength(download.content().length).body(download.content());
}
@GetMapping("/{containerNo}/unload-photos/files/{fileId}/download")
public ResponseEntity<byte[]> downloadContainerPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo,
@PathVariable String fileId) {
PdaWarehouseIdentity identity = identity(user);
var download = service.downloadContainerPhoto(identity.overseasWarehouseId(), containerNo, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline().filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(download.contentType()))
.contentLength(download.content().length).body(download.content());
}
@GetMapping("/current/domestic-loading-photos/{evidenceType}/content")
public ResponseEntity<byte[]> downloadDomesticLoadingPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String evidenceType) {
@@ -107,6 +120,19 @@ public class ReceivingContainerController {
.contentLength(download.content().length).body(download.content());
}
@GetMapping("/{containerNo}/domestic-loading-photos/{evidenceType}/content")
public ResponseEntity<byte[]> downloadContainerDomesticLoadingPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String containerNo,
@PathVariable String evidenceType) {
PdaWarehouseIdentity identity = identity(user);
var download = service.downloadContainerDomesticLoadingPhoto(identity.overseasWarehouseId(), containerNo, evidenceType);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.inline().filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.parseMediaType(download.contentType()))
.contentLength(download.content().length).body(download.content());
}
@PostMapping("/current/sign")
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
@@ -22,6 +22,10 @@ public interface ReceivingContainerSessionMapper extends BaseMapper<ReceivingCon
"WHERE active_container_key=#{activeContainerKey} LIMIT 1")
ReceivingContainerSession findActiveByContainer(@Param("activeContainerKey") String activeContainerKey);
@Select("SELECT * FROM ll_receiving_container_session " +
"WHERE container_no=#{containerNo} ORDER BY created_at DESC LIMIT 1")
ReceivingContainerSession findLatestByContainerNo(@Param("containerNo") String containerNo);
@Select("SELECT * FROM ll_receiving_container_session WHERE active_container_key IS NOT NULL")
List<ReceivingContainerSession> findActiveContainers();
@@ -70,8 +70,12 @@ public class ReceivingContainerService {
private static final String PLATFORM_SCOPE = "0";
private static final String PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download";
private static final String CONTAINER_PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/{containerNo}/unload-photos/files/{fileId}/download";
private static final String DOMESTIC_PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/current/domestic-loading-photos/{type}/content";
private static final String CONTAINER_DOMESTIC_PHOTO_DOWNLOAD_PATH =
"/api/pda/v1/last-leg/receiving-containers/{containerNo}/domestic-loading-photos/{type}/content";
private static final String PALLET_MERGE_HINT =
"该入库计划可选择合托:合托时扫描同目的仓已使用的托盘码,独立收货时扫描新托盘码;不强制合托";
private static final int MAX_PACKAGES_PER_PALLET = 66;
@@ -275,6 +279,39 @@ public class ReceivingContainerService {
return domesticEvidenceFiles.download(bookingId, evidence.getSourceFileId());
}
public UnloadPhotoFileGateway.DownloadedFile downloadContainerPhoto(
long overseasWarehouseId, String containerNo, String fileId) {
ReceivingContainerSession session = requireContainerPhotoSession(overseasWarehouseId, containerNo);
long parsedFileId = positiveFileId(fileId);
if (signedPhotos(session).stream().noneMatch(photo -> fileId.equals(photo.fileId()))) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "该柜没有对应的卸柜照片");
}
return unloadPhotos.download(PLATFORM_SCOPE, session.getId(), parsedFileId);
}
public DomesticLoadingEvidenceGateway.DownloadedFile downloadContainerDomesticLoadingPhoto(
long overseasWarehouseId, String containerNo, String evidenceType) {
ReceivingContainerSession session = requireContainerPhotoSession(overseasWarehouseId, containerNo);
String type = requiredEvidenceType(evidenceType);
var evidence = domesticEvidence.find(session.getSourceContainerBookingId(), type);
if (evidence == null) throw notFound("当前柜没有该国内装柜照片");
long bookingId;
try { bookingId = Long.parseLong(session.getSourceContainerBookingId()); }
catch (NumberFormatException exception) { throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "当前柜国内照片来源无效", exception); }
return domesticEvidenceFiles.download(bookingId, evidence.getSourceFileId());
}
private ReceivingContainerSession requireContainerPhotoSession(long overseasWarehouseId, String containerNo) {
String value = trim(containerNo);
if (value == null) throw notFound("柜号不能为空");
ReceivingContainerSession session = sessions.findLatestByContainerNo(value);
if (session == null || !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) {
throw notFound("当前仓没有该柜的照片");
}
return session;
}
public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId);
}
@@ -1050,7 +1087,8 @@ public class ReceivingContainerService {
if (bookingId == null) return List.of();
return domesticEvidence.listByContainer(bookingId).stream()
.map(value -> new ReceivingContainerResponses.DomesticLoadingPhoto(value.getEvidenceType(),
evidenceLabel(value.getEvidenceType()), DOMESTIC_PHOTO_DOWNLOAD_PATH.replace("{type}", value.getEvidenceType())))
evidenceLabel(value.getEvidenceType()), CONTAINER_DOMESTIC_PHOTO_DOWNLOAD_PATH
.replace("{containerNo}", session.getContainerNo()).replace("{type}", value.getEvidenceType())))
.toList();
}
@@ -1384,7 +1422,8 @@ public class ReceivingContainerService {
private List<ReceivingContainerResponses.SignedPhoto> signPhotos(ReceivingContainerSession session) {
return signedPhotos(session).stream()
.map(photo -> new ReceivingContainerResponses.SignedPhoto(photo.role(), photo.fileId(),
PHOTO_DOWNLOAD_PATH.replace("{fileId}", photo.fileId())))
CONTAINER_PHOTO_DOWNLOAD_PATH.replace("{containerNo}", session.getContainerNo())
.replace("{fileId}", photo.fileId())))
.toList();
}
@@ -1,24 +1,36 @@
package com.baiyue.lastleg.palletops.dto;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import java.time.LocalDateTime;
import java.util.List;
public final class PalletOperationResponses {
private PalletOperationResponses() { }
public record RelabelSource(long positionId, long positionVersion, String palletCode,
public record RelabelSource(
@JsonSerialize(using = ToStringSerializer.class) long positionId,
long positionVersion,
@JsonSerialize(using = ToStringSerializer.class) long palletId, String palletCode,
String locationCode, String inboundCode, long stockPackages,
long movablePackages, List<String> cartonMarks) { }
public record InventoryLine(String customerId, String customerName, long balanceId, long positionId,
public record InventoryLine(String customerId, String customerName,
@JsonSerialize(using = ToStringSerializer.class) long balanceId,
@JsonSerialize(using = ToStringSerializer.class) long positionId,
String inboundCode, String skuCode, String productName,
long availablePackages, long holdPackages, long allocatedPackages,
long pickedPackages, long receivingPackages, long positionVersion) { }
public record PalletContent(long palletId, String palletCode, String status, int palletVersion,
Long locationId, String locationCode, String destinationWarehouseCode,
public record PalletContent(@JsonSerialize(using = ToStringSerializer.class) long palletId,
String palletCode, String status, int palletVersion,
@JsonSerialize(using = ToStringSerializer.class) Long locationId,
String locationCode, String destinationWarehouseCode,
long totalPackages, List<InventoryLine> inventory) { }
public record PrintablePallet(long palletId, String palletCode, int palletVersion,
public record PrintablePallet(@JsonSerialize(using = ToStringSerializer.class) long palletId,
String palletCode, int palletVersion,
String relabelNo, String printableHtml) { }
public record Operation(long operationId, String operationType, String idempotencyKey,
public record Operation(@JsonSerialize(using = ToStringSerializer.class) long operationId,
String operationType, String idempotencyKey,
String status, boolean replayed, LocalDateTime completedAt,
List<PalletContent> pallets) {
public Operation asReplay() {
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.palletops.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
@@ -30,7 +31,14 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
@Override
public PalletReference ensureRelabelPallet(long warehouseId, String businessId) {
Map<String, Object> body = Map.of("warehouseId", warehouseId, "businessId", businessId);
return ensureRelabelPallet(warehouseId, businessId, null);
}
@Override
public PalletReference ensureRelabelPallet(long warehouseId, String businessId, String fbaWarehouseCode) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("warehouseId", warehouseId); body.put("businessId", businessId);
if (fbaWarehouseCode != null && !fbaWarehouseCode.isBlank()) body.put("fbaWarehouseCode", fbaWarehouseCode.trim());
ResolveEnvelope response = client.post().uri("/internal/v1/warehouse-pallets/ensure-relabel")
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
@@ -117,7 +125,10 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
}).toBodilessEntity();
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record ResolveEnvelope(String code, String message, PalletReference data) { }
@JsonIgnoreProperties(ignoreUnknown = true)
private record LocationEnvelope(LocationReference data) { }
@JsonIgnoreProperties(ignoreUnknown = true)
private record TransitionEnvelope(Transition data) { }
}
@@ -1,7 +1,12 @@
package com.baiyue.lastleg.palletops.gateway;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
public interface PalletPhysicalGateway {
PalletReference ensureRelabelPallet(long warehouseId, String businessId);
default PalletReference ensureRelabelPallet(long warehouseId, String businessId, String fbaWarehouseCode) {
return ensureRelabelPallet(warehouseId, businessId);
}
PalletReference resolve(String palletCode);
PalletReference resolve(long palletId);
LocationReference resolveLocation(long warehouseId, long locationId);
@@ -9,6 +14,7 @@ public interface PalletPhysicalGateway {
String operationKey, String reason);
void compensate(long warehouseId, Transition transition);
@JsonIgnoreProperties(ignoreUnknown = true)
record PalletReference(long palletId, String palletCode, long warehouseId, Long locationId,
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {
public PalletReference(long palletId, String palletCode, long warehouseId, Long locationId,
@@ -18,6 +18,11 @@ import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.service.RelabelPalletMutationService;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -48,6 +53,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class PdaPalletOperationService {
@@ -61,6 +67,9 @@ public class PdaPalletOperationService {
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelPalletMutationService relabelPalletMutations;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private ReturnInventoryBalanceMapper returnBalances;
private TransactionTemplate transaction;
private TransactionTemplate newTransaction;
@@ -85,6 +94,26 @@ public class PdaPalletOperationService {
Set<String> boundMarks = boundRelabelMarks(app);
List<RelabelItem> items = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()));
if (!items.isEmpty() && items.stream().allMatch(item -> item.getReturnCartonId() != null)) {
List<PalletOperationResponses.RelabelSource> result = new ArrayList<>();
Map<Long, List<RelabelItem>> byCarton = items.stream().filter(item -> !boundMarks.contains(item.getCartonMark())).collect(Collectors.groupingBy(
RelabelItem::getReturnCartonId, LinkedHashMap::new, Collectors.toList()));
for (var entry : byCarton.entrySet()) {
ReturnInventoryCarton carton = returnCartons.selectById(entry.getKey());
if (carton == null || !Objects.equals(carton.getCustomerId(), app.getCustomerId())
|| !Objects.equals(carton.getOverseasWarehouseId(), app.getOverseasWarehouseId())
|| !Objects.equals(carton.getStatus(), "RELABEL_PENDING"))
throw conflict("退货换标来源托盘不存在或已被占用");
PalletPhysicalGateway.PalletReference pallet = physical.resolve(carton.getPalletCode());
if (pallet == null || pallet.warehouseId() != identity.overseasWarehouseId())
throw conflict("退货换标来源托盘不属于当前仓库");
long quantity = value(carton.getTotalQuantity());
result.add(new PalletOperationResponses.RelabelSource(carton.getId(), 0L,
pallet.palletId(), pallet.palletCode(), null, carton.getSourceBatchNo(), quantity, quantity,
entry.getValue().stream().map(RelabelItem::getCartonMark).filter(Objects::nonNull).toList()));
}
return result;
}
Map<Long, List<RelabelItem>> byBalance = new LinkedHashMap<>();
for (RelabelItem item : items) {
InventoryPosition anchor = positions.selectById(item.getSourceInventoryPositionId());
@@ -99,7 +128,7 @@ public class PdaPalletOperationService {
long ownHold = group.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), row.getPositionId()))
.mapToLong(item -> value(item.getNotifiedPackageCount())).sum();
result.add(new PalletOperationResponses.RelabelSource(row.getPositionId(), value(row.getVersion()),
row.getPalletNo(), row.getWarehouseLocationCode(), row.getInboundCode(),
row.getPalletId(), row.getPalletNo(), row.getWarehouseLocationCode(), row.getInboundCode(),
value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages())
+ value(row.getPickedPackages()),
value(row.getAvailablePackages()) + Math.min(ownHold, value(row.getHoldPackages())),
@@ -118,15 +147,22 @@ public class PdaPalletOperationService {
throw conflict("换标单不属于 Token 冻结的海外仓");
}
String key = required(request.idempotencyKey(), "幂等键不能为空");
String destination = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("application_id", application.getId()).eq("customer_id", application.getCustomerId()))
.stream().map(RelabelItem::getDestinationFbaWarehouseCode).filter(value -> value != null && !value.isBlank())
.distinct().reduce((left, right) -> { throw conflict("同一换标单只能绑定一个 FBA 目的仓"); }).orElse(null);
PalletPhysicalGateway.PalletReference pallet = physical.ensureRelabelPallet(identity.overseasWarehouseId(),
relabelNo + ":" + key);
relabelNo + ":" + key, destination);
String code = HtmlUtils.htmlEscape(pallet.palletCode());
String order = HtmlUtils.htmlEscape(relabelNo);
String fbaValue = destination == null ? pallet.fbaWarehouseCode() : destination;
String fba = HtmlUtils.htmlEscape(fbaValue == null ? "" : fbaValue);
if (fba == null || fba.isBlank()) fba = "待绑定";
String html = "<!doctype html><html><head><meta charset=\"UTF-8\"><title>" + code
+ "</title><style>@page{size:100mm 70mm;margin:4mm}body{font-family:Arial,sans-serif;text-align:center;margin:0}"
+ "h1{font-size:24px;margin:8mm 0 4mm;letter-spacing:0}.code{font-size:18px;font-weight:700;word-break:break-all}"
+ ".ref{font-size:12px;margin-top:6mm}</style></head><body><h1>托盘标签</h1><div class=\"code\">"
+ code + "</div><div class=\"ref\">换标单:" + order + "</div></body></html>";
+ code + "</div><div class=\"ref\">换标单:" + order + "<br>FBA目的仓:" + fba + "</div></body></html>";
return new PalletOperationResponses.PrintablePallet(pallet.palletId(), pallet.palletCode(), pallet.version(),
relabelNo, html);
}
@@ -205,8 +241,10 @@ public class PdaPalletOperationService {
Map<Long, Long> packagesByPosition = new LinkedHashMap<>();
Map<Long, List<RelabelItem>> itemsByPosition = new LinkedHashMap<>();
Map<Long, InventoryPosition> anchors = new LinkedHashMap<>();
InventoryPosition actual = request.actualSource() == null ? null : positions.selectById(request.actualSource().positionId());
if (request.actualSource() != null && (actual == null
boolean returnRelabel = selected.stream().allMatch(item -> item.getReturnCartonId() != null);
InventoryPosition actual = returnRelabel || request.actualSource() == null
? null : positions.selectById(request.actualSource().positionId());
if (!returnRelabel && request.actualSource() != null && (actual == null
|| !Objects.equals(actual.getCustomerId(), application.getCustomerId())
|| !Objects.equals(actual.getPalletId(), source.palletId())
|| !Objects.equals(actual.getOverseasWarehouseId(), application.getOverseasWarehouseId())
@@ -219,13 +257,17 @@ public class PdaPalletOperationService {
if ("PROCESSING".equals(application.getStatus())) {
var workTask = relabelWorkTasks.find(application.getCustomerId(), application.getId());
for (RelabelItem item : selected) {
Long completed = relabelWorkFacts.selectCount(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact>()
.eq("task_id", workTask.getId()).eq("customer_id", application.getCustomerId())
.eq("item_id", item.getId()).eq("result", "RELABELLED"));
if (completed == null || completed < value(item.getNotifiedPackageCount()))
long completed = relabelWorkFacts.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact>()
.eq("task_id", workTask.getId()).eq("customer_id", application.getCustomerId())
.eq("item_id", item.getId()).eq("result", "RELABELLED"))
.stream().mapToLong(fact -> fact.getQuantity() == null ? 1L : fact.getQuantity()).sum();
if (completed < value(item.getNotifiedPackageCount()))
throw conflict("请先完成本箱全部换标作业,再进行组托");
}
}
if (returnRelabel) {
return bindReturnRelabelPallet(identity, application, selected, source, target, request);
}
for (RelabelItem item : selected) {
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null
|| item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照");
@@ -265,6 +307,113 @@ public class PdaPalletOperationService {
reason, relabelPlan);
}
private PalletOperationResponses.Operation bindReturnRelabelPallet(PdaWarehouseIdentity identity,
RelabelApplication application, List<RelabelItem> selected,
PalletPhysicalGateway.PalletReference source, PalletPhysicalGateway.PalletReference target,
PalletOperationRequests.RelabelDisassemble request) {
requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
Set<String> alreadyBound = boundRelabelMarks(application);
if (selected.stream().map(RelabelItem::getCartonMark).anyMatch(alreadyBound::contains))
throw conflict("退货换标箱已在本轮完成组托,请刷新后重试");
long quantity = 0;
List<ReturnInventoryCarton> cartons = new ArrayList<>();
for (RelabelItem item : selected) {
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId())
|| !Objects.equals(carton.getPalletCode(), source.palletCode())
|| !Objects.equals(carton.getRelabelApplicationId(), application.getId())
|| !Objects.equals(carton.getStatus(), "RELABEL_PENDING"))
throw conflict("退货换标来源托盘或库存状态已变化,请刷新后重试");
quantity += value(item.getNotifiedPackageCount());
cartons.add(carton);
}
if (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId()
|| request.actualSource().packages() != quantity)
throw conflict("退货换标拆出数量必须等于本批已完成贴标数量");
String key = required(request.idempotencyKey(), "幂等键不能为空");
String requestHash = hash(request);
PdaPalletOperation operation = begin(identity, "RELABEL_PALLET_DISASSEMBLE", key, requestHash);
if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay();
physical.apply(identity.overseasWarehouseId(), target, "OCCUPIED", target.locationId(), key, "退货换标组托");
return transaction.execute(status -> {
for (int index = 0; index < cartons.size(); index++) {
RelabelItem item = selected.get(index);
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId())
|| !Objects.equals(carton.getPalletCode(), source.palletCode())
|| !Objects.equals(carton.getRelabelApplicationId(), application.getId())
|| !"RELABEL_PENDING".equals(carton.getStatus()))
throw conflict("退货换标来源库存已变化,请刷新后重试");
long move = value(item.getNotifiedPackageCount());
if (move <= 0 || move > value(carton.getTotalQuantity())) throw conflict("来源托盘可拆数量不足");
if (move < value(carton.getTotalQuantity())) {
splitReturnRelabelCarton(application, item, carton, target, move);
} else {
carton.setPalletCode(target.palletCode());
carton.setDestinationWarehouseCode(item.getDestinationFbaWarehouseCode());
carton.setRelabelStatus("PROCESSING");
carton.setUpdatedAt(LocalDateTime.now());
returnCartons.updateById(carton);
}
}
recordReturnRelabelBinding(identity, application, selected, target.palletCode());
List<PalletOperationResponses.PalletContent> contents = List.of(
content(identity.overseasWarehouseId(), physical.resolve(source.palletCode())),
content(identity.overseasWarehouseId(), physical.resolve(target.palletCode())));
PalletOperationResponses.Operation result = new PalletOperationResponses.Operation(operation.getId(),
operation.getOperationType(), key, "COMPLETED", false, LocalDateTime.now(), contents);
operation.setStatus("COMPLETED"); operation.setResultJson(write(result));
operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation);
return result;
});
}
/** Keep the unlabelled remainder on its original pallet; move only the confirmed SKU pieces. */
private void splitReturnRelabelCarton(RelabelApplication application, RelabelItem item,
ReturnInventoryCarton source, PalletPhysicalGateway.PalletReference target, long quantity) {
if (relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("application_id", application.getId()).eq("return_carton_id", source.getId())).size() != 1)
throw conflict("来源托盘包含多项换标任务,不能按单 SKU 拆分,请核对库存");
List<ReturnInventoryCartonItem> lines = returnCartonItems.lockCarton(source.getId());
List<ReturnInventoryCartonItem> matching = lines.stream()
.filter(line -> item.getSkuCode().equalsIgnoreCase(line.getSkuCode())).toList();
if (matching.size() != 1 || quantity <= 0 || quantity > value(matching.get(0).getQuantity())
|| lines.stream().anyMatch(line -> value(line.getQuantity()) <= 0)
|| lines.stream().mapToLong(line -> value(line.getQuantity())).sum() != value(source.getTotalQuantity()))
throw conflict("来源托盘SKU数量不一致,请核对库存");
long remaining = value(source.getTotalQuantity()) - quantity;
ReturnInventoryCartonItem sourceLine = matching.get(0);
long skuRemaining = value(sourceLine.getQuantity()) - quantity;
int remainingSkuTypes = lines.size() - (skuRemaining == 0 ? 1 : 0);
sourceLine.setQuantity(skuRemaining);
int changed = skuRemaining == 0 ? returnCartonItems.deleteById(sourceLine.getId()) : returnCartonItems.updateById(sourceLine);
if (changed != 1
|| returnCartons.releasePartial(source.getId(), application.getCustomerId(), application.getId(), remaining, remainingSkuTypes) != 1)
throw conflict("来源托盘数量已变化,请刷新后重试");
LocalDateTime now = LocalDateTime.now();
long targetId = IdWorker.getId();
ReturnInventoryCarton moved = new ReturnInventoryCarton();
moved.setId(targetId); moved.setCustomerId(source.getCustomerId());
moved.setOverseasWarehouseId(source.getOverseasWarehouseId());
moved.setSourceBatchNo(source.getSourceBatchNo()); moved.setSourceReturnNo(source.getSourceReturnNo());
moved.setPalletCode(target.palletCode()); moved.setCartonMark("RL-CARTON-" + targetId);
moved.setDestinationWarehouseCode(item.getDestinationFbaWarehouseCode());
moved.setRelabelApplicationId(application.getId()); moved.setRelabelNo(application.getRelabelNo());
moved.setRelabelStatus("PROCESSING"); moved.setStatus("RELABEL_PENDING");
moved.setSkuTypes(1); moved.setTotalQuantity(quantity);
moved.setCreatedAt(now); moved.setUpdatedAt(now);
if (returnCartons.insert(moved) != 1) throw conflict("新托盘库存创建失败");
ReturnInventoryCartonItem targetLine = new ReturnInventoryCartonItem();
targetLine.setId(IdWorker.getId()); targetLine.setCartonId(targetId);
targetLine.setCustomerId(source.getCustomerId()); targetLine.setSkuCode(sourceLine.getSkuCode());
targetLine.setProductNameZh(sourceLine.getProductNameZh()); targetLine.setQuantity(quantity);
if (returnCartonItems.insert(targetLine) != 1
|| returnBalances.adjustCartonQuantity(source.getCustomerId(), source.getOverseasWarehouseId(),
sourceLine.getSkuCode(), skuRemaining == 0 ? 0 : 1) != 1) throw conflict("退货库存拆分记账失败");
item.setReturnCartonId(targetId);
if (relabelItems.updateById(item) != 1) throw conflict("换标单来源托盘更新失败");
}
public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity,
PalletOperationRequests.Swap request) {
PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_SWAP",
@@ -610,6 +759,22 @@ public class PdaPalletOperationService {
relabelWorkHistory.insert(record);
}
private void recordReturnRelabelBinding(PdaWarehouseIdentity identity, RelabelApplication app,
List<RelabelItem> items, String targetPallet) {
var task = relabelWorkTasks.find(app.getCustomerId(), app.getId());
if (task == null) throw conflict("退货换标作业任务不存在,不能记录组托");
var record = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
record.setId(IdWorker.getId()); record.setTaskId(task.getId()); record.setApplicationId(app.getId());
record.setCustomerId(app.getCustomerId()); record.setAction("PALLET_BIND");
record.setFromStatus(task.getStatus()); record.setToStatus(task.getStatus());
record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName());
record.setCreatedAt(LocalDateTime.now());
record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"),
"cartonMarks", items.stream().map(RelabelItem::getCartonMark).toList(),
"targetPallets", List.of(targetPallet))));
relabelWorkHistory.insert(record);
}
private void requireRelabelWork(PdaWarehouseIdentity identity, RelabelApplication application) {
if (application == null || !Set.of("PROCESSING", "READY_FOR_OUTBOUND").contains(application.getStatus()))
throw conflict("当前换标状态不能组托");
@@ -78,6 +78,22 @@ public class PdaRelabelController {
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/return-skus/complete")
public ApiResponse<PdaRelabelResponses.Detail> completeReturnSku(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.BulkSku request) {
Identity identity = identity(user);
return ApiResponse.success(service.completeReturnSku(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/return-skus/complete-total")
public ApiResponse<PdaRelabelResponses.Detail> completeReturnSkuTotal(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.BulkSkuTotal request) {
Identity identity = identity(user);
return ApiResponse.success(service.completeReturnSkuTotal(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@GetMapping("/{relabelNo}/documents")
public ApiResponse<List<PdaRelabelService.Document>> documents(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo) {
@@ -37,6 +37,16 @@ public final class PdaRelabelRequests {
public record SkuVerification(@NotBlank String skuCode, @NotBlank String newSkuCode,
@Positive int quantity) { }
public record BulkSku(@NotNull @Positive Long itemId,
@Positive int quantity,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record BulkSkuTotal(@NotBlank @Size(max = 255) String skuCode,
@Positive int quantity,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Skip(@NotNull @Positive Long itemId,
@NotBlank @Size(max = 255) String oldLabel,
@NotBlank @Size(max = 1000) String reason,
@@ -32,7 +32,7 @@ public final class PdaRelabelResponses {
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
String result, String exceptionCode, String reason,
String operatorName, LocalDateTime occurredAt) { }
int quantity, String operatorName, LocalDateTime occurredAt) { }
public record History(String action, String fromStatus, String toStatus,
String operatorName, String detail, LocalDateTime createdAt) { }
@@ -20,6 +20,7 @@ public class PdaRelabelUnitFact {
private String result;
private String exceptionCode;
private String reason;
private Integer quantity;
private String operatorKey;
private String operatorName;
private LocalDateTime occurredAt;
@@ -90,6 +90,17 @@ public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway {
}
}
@Override
public java.util.List<UploadedFile> list(String customerId, long taskId) {
MetadataListEnvelope response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", tenantId(customerId), BUSINESS_TYPE, taskId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Relabel photo list failed"); })
.body(MetadataListEnvelope.class);
if (response == null || !"200".equals(response.code()) || response.data() == null) return java.util.List.of();
return response.data().stream().map(value -> new UploadedFile(value.fileId(), value.fileName(), value.contentType(), value.sizeBytes() == null ? 0L : value.sizeBytes())).toList();
}
@Override
public DownloadedFile download(String customerId, long taskId, long fileId) {
validate(customerId, taskId, fileId);
@@ -130,6 +141,7 @@ public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway {
}
private record MetadataEnvelope(String code, String message, Metadata data) { }
private record MetadataListEnvelope(String code, String message, java.util.List<Metadata> data) { }
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) { }
}
@@ -6,6 +6,7 @@ public interface RelabelPhotoFileGateway {
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
void validate(String customerId, long taskId, long fileId);
DownloadedFile download(String customerId, long taskId, long fileId);
java.util.List<UploadedFile> list(String customerId, long taskId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
record DownloadedFile(byte[] content, String fileName, String contentType) { }
@@ -39,6 +39,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
@@ -238,6 +239,88 @@ public class PdaRelabelService {
return result;
}
@Transactional
public PdaRelabelResponses.Detail completeReturnSku(String operatorKey, String operatorName, long warehouseId,
String relabelNo, PdaRelabelRequests.BulkSku request) {
UnitCommand command = new UnitCommand(relabelNo, "RETURN_BULK_SKU", request);
PdaRelabelResponses.Detail replay = replay(operatorKey, warehouseId, relabelNo, "RETURN_BULK_SKU",
request.idempotencyKey(), command);
if (replay != null) return replay;
RelabelApplication application = requireApplication(relabelNo, warehouseId, true);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
requireVersion(task, request.expectedVersion());
RelabelItem item = requireItem(application, request.itemId());
if (!"RETURN_BULK_SKU_LABEL".equals(operationType(item))) throw conflict("当前明细不是退货按件贴标任务");
int completed = quantity(factList(task).stream().filter(fact -> item.getId().equals(fact.getItemId())).toList(), "RELABELLED");
int remaining = value(item.getNotifiedPackageCount()) - completed;
if (request.quantity() > remaining) throw conflict("本次完成数量超过该 SKU 剩余贴标数量");
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
// A bulk SKU is shared by many units/pallets; old_label is uniquely indexed for physical carton scans.
fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId());
fact.setResult("RELABELLED"); fact.setQuantity(request.quantity()); fact.setOperatorKey(operatorKey);
fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now()); unitFacts.insert(fact);
task.setRelabelledPackages(value(task.getRelabelledPackages()) + request.quantity());
updateTask(task, request.expectedVersion());
insertHistory(task, "RETURN_BULK_SKU", PROCESSING, PROCESSING, operatorKey, operatorName,
Map.of("factId", fact.getId(), "itemId", item.getId(), "skuCode", item.getSkuCode(), "quantity", request.quantity()));
PdaRelabelResponses.Detail result = detail(application, task);
saveOperation(task, operatorKey, "RETURN_BULK_SKU", request.idempotencyKey(), command, result);
return result;
}
@Transactional
public PdaRelabelResponses.Detail completeReturnSkuTotal(String operatorKey, String operatorName, long warehouseId,
String relabelNo, PdaRelabelRequests.BulkSkuTotal request) {
UnitCommand command = new UnitCommand(relabelNo, "RETURN_BULK_SKU_TOTAL", request);
PdaRelabelResponses.Detail replay = replay(operatorKey, warehouseId, relabelNo, "RETURN_BULK_SKU_TOTAL",
request.idempotencyKey(), command);
if (replay != null) return replay;
RelabelApplication application = requireApplication(relabelNo, warehouseId, true);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
requireVersion(task, request.expectedVersion());
String skuCode = request.skuCode().trim();
List<RelabelItem> skuItems = itemList(application).stream()
.filter(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))
.filter(item -> skuCode.equalsIgnoreCase(Objects.toString(item.getSkuCode(), "").trim()))
.toList();
if (skuItems.isEmpty()) throw notFound("当前换标单没有该退货 SKU");
List<PdaRelabelUnitFact> existingFacts = factList(task);
Map<Long, Integer> completedByItem = new HashMap<>();
for (RelabelItem item : skuItems) {
completedByItem.put(item.getId(), quantity(existingFacts.stream()
.filter(fact -> item.getId().equals(fact.getItemId())).toList(), "RELABELLED"));
}
int totalRemaining = skuItems.stream().mapToInt(item -> value(item.getNotifiedPackageCount())
- completedByItem.getOrDefault(item.getId(), 0)).sum();
if (request.quantity() > totalRemaining) throw conflict("本次完成数量超过该 SKU 剩余贴标数量");
int remainingToAllocate = request.quantity();
List<Map<String, Object>> allocations = new ArrayList<>();
LocalDateTime occurredAt = LocalDateTime.now();
for (RelabelItem item : skuItems) {
if (remainingToAllocate == 0) break;
int itemRemaining = value(item.getNotifiedPackageCount()) - completedByItem.getOrDefault(item.getId(), 0);
int allocated = Math.min(remainingToAllocate, itemRemaining);
if (allocated <= 0) continue;
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
// Do not write a shared SKU into the unique physical old-label column.
fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId());
fact.setResult("RELABELLED"); fact.setQuantity(allocated); fact.setOperatorKey(operatorKey);
fact.setOperatorName(operatorName); fact.setOccurredAt(occurredAt); unitFacts.insert(fact);
allocations.add(Map.of("itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""),
"quantity", allocated, "factId", fact.getId()));
remainingToAllocate -= allocated;
}
task.setRelabelledPackages(value(task.getRelabelledPackages()) + request.quantity());
updateTask(task, request.expectedVersion());
insertHistory(task, "RETURN_BULK_SKU_TOTAL", PROCESSING, PROCESSING, operatorKey, operatorName,
Map.of("skuCode", skuCode, "quantity", request.quantity(), "allocations", allocations));
PdaRelabelResponses.Detail result = detail(application, task);
saveOperation(task, operatorKey, "RETURN_BULK_SKU_TOTAL", request.idempotencyKey(), command, result);
return result;
}
@Transactional(readOnly = true)
public List<Document> documents(long warehouseId, String relabelNo) {
RelabelApplication app = requireApplication(relabelNo, warehouseId, false);
@@ -311,26 +394,28 @@ public class PdaRelabelService {
if (!request.manualCompletion()) bindingGuard.requireBound(application);
List<RelabelItem> detailItems = itemList(application);
List<PdaRelabelUnitFact> facts = factList(task);
boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream()
.filter(fact -> item.getId().equals(fact.getItemId())).count() < value(item.getNotifiedPackageCount()));
boolean incomplete = detailItems.stream().anyMatch(item -> processedQuantity(facts, item.getId()) < value(item.getNotifiedPackageCount()));
if ((incomplete || request.manualCompletion()) && !StringUtils.hasText(request.remark()))
throw conflict("手动完成必须填写仓库异常作业备注");
if (incomplete && !request.manualCompletion()) throw conflict("仍有未处理箱,请选择手动完成并填写作业备注");
if (request.photoFileIds() == null || request.photoFileIds().isEmpty() || request.photoFileIds().size() > 20) throw conflict("完成换标必须上传 1 至 20 张作业照片");
if (request.manualCompletion() && request.remark().trim().length() > 900) throw conflict("手动作业备注不能超过 900 字");
for (RelabelItem item : detailItems) {
long actual = facts.stream().filter(fact -> item.getId().equals(fact.getItemId())).count();
long actual = processedQuantity(facts, item.getId());
if (actual > value(item.getNotifiedPackageCount())) {
throw conflict("Every relabel item must have exactly one fact per notified package");
}
for (long i = actual; i < value(item.getNotifiedPackageCount()); i++) {
long missing = value(item.getNotifiedPackageCount()) - actual;
int factCount = "RETURN_BULK_SKU_LABEL".equals(operationType(item)) && missing > 0 ? 1 : Math.toIntExact(missing);
for (int i = 0; i < factCount; i++) {
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId());
fact.setResult("EXCEPTION"); fact.setExceptionCode("MANUAL_COMPLETE");
fact.setQuantity("RETURN_BULK_SKU_LABEL".equals(operationType(item)) ? Math.toIntExact(missing) : 1);
fact.setReason(request.remark()); fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName);
fact.setOccurredAt(LocalDateTime.now()); unitFacts.insert(fact);
task.setExceptionPackages(value(task.getExceptionPackages()) + 1);
task.setExceptionPackages(value(task.getExceptionPackages()) + fact.getQuantity());
}
}
List<Long> photoIds = request.photoFileIds().stream().distinct().toList();
@@ -475,6 +560,7 @@ public class PdaRelabelService {
resolvedNewLabel = trim(item.getFbaCartonMark());
}
fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(resolvedNewLabel); fact.setResult(resultCode);
fact.setQuantity(1);
fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason));
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
try { unitFacts.insert(fact); }
@@ -622,15 +708,15 @@ public class PdaRelabelService {
item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(),
item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(),
item.getCartonMark(), item.getFbaCartonMark(), operationType(item), readSkuItems(item.getSkuRelabelItemsJson()),
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
count(itemFacts, "EXCEPTION"),
value(item.getNotifiedPackageCount()), quantity(itemFacts, "RELABELLED"), quantity(itemFacts, "SKIPPED"),
quantity(itemFacts, "EXCEPTION"),
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0),
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0),
item.getDestinationFbaWarehouseCode());
}).toList();
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
fact.getResult(), fact.getExceptionCode(), fact.getReason(), fact.getOperatorName(), fact.getOccurredAt())).toList();
fact.getResult(), fact.getExceptionCode(), fact.getReason(), factQuantity(fact), fact.getOperatorName(), fact.getOccurredAt())).toList();
List<PdaRelabelResponses.History> historyResponses = task == null ? List.of() : histories.selectList(
new LambdaQueryWrapper<PdaRelabelHistory>().eq(PdaRelabelHistory::getCustomerId, application.getCustomerId())
.eq(PdaRelabelHistory::getTaskId, task.getId())
@@ -727,6 +813,9 @@ public class PdaRelabelService {
private String operationType(RelabelApplication application) {
List<RelabelItem> items = itemList(application);
if (!items.isEmpty() && items.stream().allMatch(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))) {
return "RETURN_BULK_SKU_LABEL";
}
boolean sku = items.stream().anyMatch(item -> Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item)));
boolean carton = items.stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
return sku && carton ? "CARTON_AND_SKU" : sku ? "SKU_LABEL" : "CARTON_LABEL";
@@ -748,6 +837,13 @@ public class PdaRelabelService {
private static int count(List<PdaRelabelUnitFact> facts, String result) {
return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count();
}
private static int quantity(List<PdaRelabelUnitFact> facts, String result) {
return facts.stream().filter(fact -> result.equals(fact.getResult())).mapToInt(PdaRelabelService::factQuantity).sum();
}
private static int processedQuantity(List<PdaRelabelUnitFact> facts, Long itemId) {
return facts.stream().filter(fact -> itemId.equals(fact.getItemId())).mapToInt(PdaRelabelService::factQuantity).sum();
}
private static int factQuantity(PdaRelabelUnitFact fact) { return fact.getQuantity() == null ? 1 : fact.getQuantity(); }
private static int value(Integer value) { return value == null ? 0 : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String idempotencyKey(String value) { return value == null ? null : value.trim(); }
@@ -31,8 +31,10 @@ import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
@@ -70,10 +72,11 @@ import java.util.stream.Collectors;
@Service
public class RelabelApplicationService implements WalletResultHandler {
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String PENDING_PROCESSING = "PENDING_PROCESSING";
public static final String CUSTOMER_CONFIRM_PENDING = "CUSTOMER_CONFIRM_PENDING";
public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND";
private static final Set<String> STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "TRUCK_PICKUP", "EXPRESS_PICKUP", "PICKUP");
@@ -90,6 +93,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private InventoryPositionMapper inventoryPositions;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private ReturnOrderMapper returnOrders;
@Resource private RelabelDocumentFileGateway documentFiles;
@Resource private PdaRelabelTaskMapper pdaTasks;
@Resource private RelabelPhotoFileGateway photoFiles;
@@ -203,7 +207,7 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!StringUtils.hasText(customerId)) throw badRequest("客户不能为空");
RelabelApplication application = applications.lockNo(customerId, relabelNo);
if (application == null) throw notFound("Relabel application not found");
if (!Set.of(PENDING_PROCESSING, "PROCESSING").contains(application.getStatus())) throw conflict("当前换标状态不允许修改资料");
if (!Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING").contains(application.getStatus())) throw conflict("当前换标状态不允许修改资料");
return application;
}
@@ -270,9 +274,13 @@ public class RelabelApplicationService implements WalletResultHandler {
if (cartonSkus.isEmpty()) continue;
String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode)
.collect(Collectors.joining(","));
long availablePackages = value(carton.getTotalQuantity());
if (availablePackages <= 0) {
availablePackages = cartonSkus.stream().mapToLong(RelabelResponses.SkuItem::quantity).sum();
}
result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(),
null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(),
carton.getDestinationWarehouseName(), skuCodes, "退货合箱库存", 1, 0, List.of(),
carton.getDestinationWarehouseName(), skuCodes, "退货入库库存", availablePackages, 0, List.of(),
"RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus));
}
return result;
@@ -347,6 +355,8 @@ public class RelabelApplicationService implements WalletResultHandler {
int notified = 0;
List<SourceInventory> sources = new java.util.ArrayList<>();
Set<String> selectedCartonMarks = new HashSet<>();
Map<Long, ReturnInventoryCarton> selectedReturnCartons = new LinkedHashMap<>();
Map<Long, Long> selectedReturnPackages = new LinkedHashMap<>();
for (RelabelRequests.Item item : request.items()) {
validateOperation(item);
if (item.notifiedPackageCount() > item.totalPackageCount()) {
@@ -358,7 +368,11 @@ public class RelabelApplicationService implements WalletResultHandler {
throw badRequest("The same carton mark cannot create more than one relabel task");
}
if (item.returnCartonId() != null) {
ReturnInventoryCarton carton = returnCartons.lockId(item.returnCartonId());
ReturnInventoryCarton carton = selectedReturnCartons.get(item.returnCartonId());
if (carton == null) {
carton = returnCartons.lockId(item.returnCartonId());
if (carton != null) selectedReturnCartons.put(item.returnCartonId(), carton);
}
if (carton == null || !customerId.equals(carton.getCustomerId())
|| !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) {
throw conflict("所选退货箱不属于当前客户和操作仓");
@@ -366,8 +380,10 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) {
throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标");
}
if (!carton.getCartonMark().equalsIgnoreCase(item.cartonMark().trim())) {
throw conflict("退货箱仓库唛头已变化,请重新查询");
long requestedPackages = selectedReturnPackages.merge(carton.getId(),
(long) item.notifiedPackageCount(), Math::addExact);
if (requestedPackages > value(carton.getTotalQuantity())) {
throw conflict("退货托盘 " + carton.getCartonMark() + " 可用箱数不足");
}
sources.add(new SourceInventory(item, null, carton));
continue;
@@ -438,9 +454,7 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setUpdatedAt(now);
applications.insert(application);
for (SourceInventory source : sources) {
if (source.returnCarton() == null) continue;
ReturnInventoryCarton carton = source.returnCarton();
for (ReturnInventoryCarton carton : selectedReturnCartons.values()) {
carton.setRelabelApplicationId(applicationId); carton.setRelabelNo(relabelNo);
carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now);
returnCartons.updateById(carton);
@@ -530,6 +544,12 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status");
if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
validateTransition(application.getStatus(), next);
if (PENDING_REVIEW.equals(application.getStatus()) && PENDING_PROCESSING.equals(next)
&& itemList(application.getId()).stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))) {
boolean hasCartonFile = StringUtils.hasText(application.getCartonTotalFileId());
boolean hasSkuFile = StringUtils.hasText(application.getSkuTotalFileId());
if (hasCartonFile == hasSkuFile) throw conflict("退货贴标审核前必须且只能上传一个箱唛总文件或 SKU 总文件");
}
if ("PROCESSING".equals(next)
&& !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) {
throw conflict("Relabel funds must be frozen before processing");
@@ -541,6 +561,10 @@ public class RelabelApplicationService implements WalletResultHandler {
applications.updateById(application);
List<LastlegOrder> linkedOrders = tailOrders(application);
if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing");
if (PENDING_PROCESSING.equals(next)) {
application.setFulfillmentStatus("WAIT_RELABEL");
applications.updateById(application);
}
if (CUSTOMER_CONFIRM_PENDING.equals(next)) {
application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION");
applications.updateById(application);
@@ -586,6 +610,12 @@ public class RelabelApplicationService implements WalletResultHandler {
case "REJECTED", "CANCELLED" -> "CANCELLED";
default -> mainStatus;
};
// Reviewing/processing a return relabel must not move the already
// completed inbound order back to assignment or relabel waiting.
if ("RETURN_RELABEL".equals(order.getOrderType())
&& !Set.of("COMPLETED", "REJECTED", "CANCELLED").contains(next)) {
orderStatus = "INBOUND_COMPLETED";
}
order.setCurrentStatus(orderStatus);
order.setSubOrderStatus("RELABEL:" + next);
order.setRelabelStatus(businessRelabelStatus(next));
@@ -595,6 +625,15 @@ public class RelabelApplicationService implements WalletResultHandler {
insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now);
}
}
ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper<ReturnOrder>()
.eq(ReturnOrder::getRelabelApplicationId, application.getId())
.eq(ReturnOrder::getCustomerId, customerId)
.last("LIMIT 1"));
if (returnOrder != null) {
returnOrder.setRelabelStatus(next);
returnOrder.setUpdatedAt(now);
returnOrders.updateById(returnOrder);
}
insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now);
return detail(customerId, relabelNo);
}
@@ -689,7 +728,7 @@ public class RelabelApplicationService implements WalletResultHandler {
}
List<RelabelItem> relabelItems = itemList(application.getId());
boolean requiresOutbound = relabelItems.stream()
.anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
.anyMatch(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(operationType(item)));
reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED");
reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认");
reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now());
@@ -721,6 +760,22 @@ public class RelabelApplicationService implements WalletResultHandler {
request.remark(), now);
}
}
ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper<ReturnOrder>()
.eq(ReturnOrder::getRelabelApplicationId, application.getId())
.eq(ReturnOrder::getCustomerId, customerId)
.last("LIMIT 1"));
if (returnOrder != null) {
returnOrder.setRelabelStatus("COMPLETED");
returnOrder.setUpdatedAt(now);
returnOrders.updateById(returnOrder);
}
for (RelabelItem item : relabelItems) {
if (item.getReturnCartonId() == null) continue;
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
if (carton == null || !Objects.equals(carton.getRelabelApplicationId(), application.getId())) continue;
carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now);
carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
}
insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId,
request.remark(), now);
// 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。
@@ -739,7 +794,8 @@ public class RelabelApplicationService implements WalletResultHandler {
transfer.setRelabelCompletedAt(now);
transfer.setUpdatedAt(now);
warehouseTransfers.updateById(transfer);
application.setFulfillmentStatus(transferStatus);
application.setFulfillmentStatus("READY_FOR_DISPATCH".equals(transferStatus)
? "WAIT_PRE_DISPATCH" : transferStatus);
applications.updateById(application);
// 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。
LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId());
@@ -1057,7 +1113,8 @@ public class RelabelApplicationService implements WalletResultHandler {
orderHistories.insert(value);
}
private static void validateTransition(String from, String to) {
boolean allowed = PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to)
boolean allowed = PENDING_REVIEW.equals(from) && Set.of(PENDING_PROCESSING, "REJECTED", "CANCELLED").contains(to)
|| PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to)
|| "PROCESSING".equals(from) && Set.of(CUSTOMER_CONFIRM_PENDING, "REJECTED", "CANCELLED").contains(to)
|| CUSTOMER_CONFIRM_PENDING.equals(from) && Set.of(READY_FOR_OUTBOUND, "REJECTED", "CANCELLED").contains(to)
|| READY_FOR_OUTBOUND.equals(from) && Set.of("COMPLETED", "CANCELLED").contains(to);
@@ -1065,7 +1122,9 @@ public class RelabelApplicationService implements WalletResultHandler {
}
private static String businessRelabelStatus(String workflowStatus) {
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING";
if (Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING", "REJECTED", "CANCELLED").contains(workflowStatus)) {
return workflowStatus;
}
return Set.of(CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
? "COMPLETED" : PENDING_PROCESSING;
}
@@ -7,6 +7,7 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -20,12 +21,14 @@ public class RelabelBindingGuard {
@Resource private RelabelItemMapper items;
@Resource private PdaRelabelHistoryMapper histories;
@Resource private InventoryPositionMapper positions;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ObjectMapper objectMapper;
public void requireBound(RelabelApplication app) {
List<RelabelItem> cartons = items.selectList(new QueryWrapper<RelabelItem>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()))
.stream().filter(item -> !"SKU_LABEL".equals(item.getOperationType())).toList();
.stream().filter(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL")
.contains(item.getOperationType())).toList();
if (cartons.isEmpty()) return;
Map<String, Set<String>> bindings = new HashMap<>();
for (PdaRelabelHistory history : histories.selectList(new QueryWrapper<PdaRelabelHistory>()
@@ -41,6 +44,13 @@ public class RelabelBindingGuard {
} catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); }
}
for (RelabelItem item : cartons) {
if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())
|| !"PROCESSING".equals(carton.getRelabelStatus()))
throw conflict("退货箱 " + item.getCartonMark() + " 尚未绑定带目的仓的新托盘,不能完成换标");
continue;
}
var position = item.getSourceInventoryPositionId() == null ? null
: positions.findTenantPosition(app.getCustomerId(), item.getSourceInventoryPositionId());
if (position == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo())
@@ -10,6 +10,8 @@ import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@@ -28,6 +30,14 @@ public class MerchantReturnOrderController {
@GetMapping("/{returnNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')")
public ApiResponse<ReturnOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String returnNo) { return ApiResponse.success(service.detail(customer(user), returnNo)); }
@GetMapping("/{returnNo}/photos/files/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')")
public ResponseEntity<byte[]> downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String returnNo, @PathVariable long fileId) {
var file = service.downloadPhoto(customer(user), returnNo, fileId);
return ResponseEntity.ok().contentType(MediaType.parseMediaType(file.contentType()))
.header("Cache-Control", "no-store").body(file.content());
}
@GetMapping("/{returnNo}/relabel-candidates") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL + "')")
public ApiResponse<java.util.List<ReturnOrderResponses.RelabelCandidate>> relabelCandidates(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo) {
@@ -6,17 +6,28 @@ import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
@RestController
@RequestMapping("/api/pda/v1/last-leg/return-receipts")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaReturnReceiptController {
@Resource private ReturnReceiptService service;
@Resource private PalletPhysicalGateway physicalPallets;
@Resource private ReturnInventoryCartonMapper inventoryCartons;
@Resource private ReturnInventoryCartonItemMapper inventoryCartonItems;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper returnOrders;
@Resource private RelabelPhotoFileGateway photoFiles;
@PostMapping("/start") public ApiResponse<PdaReturnReceiptResponses.Start> start(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaReturnReceiptRequests.Start request) {
return ApiResponse.success(service.start(PdaWarehouseIdentity.require(user), request));
@@ -30,4 +41,61 @@ public class PdaReturnReceiptController {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.confirm(identity, request));
}
@PostMapping("/{returnNo}/manual-complete") public ApiResponse<com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.Detail> manualComplete(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo, @Valid @RequestBody PdaReturnReceiptRequests.ManualComplete request) {
return ApiResponse.success(service.manualComplete(PdaWarehouseIdentity.require(user), returnNo, request));
}
public record Pallet(String palletCode, long palletId, String status, String printableHtml) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
@PostMapping(value = "/{returnNo}/photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo,
@RequestPart("file") MultipartFile file) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
var order = returnOrders.findActiveNo(String.valueOf(identity.overseasWarehouseId()), returnNo);
if (order == null) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该待签收退货单");
if (file == null || file.isEmpty()) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "请选择退货打托照片");
if (file.getSize() > 10L * 1024 * 1024) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "照片不能超过 10MB");
if (file.getContentType() == null || !file.getContentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "只能上传图片");
var uploaded = photoFiles.upload(order.getCustomerId(), order.getId(), user.userId(), file);
return ApiResponse.success(new Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()));
}
@GetMapping("/{returnNo}/photos/files/{fileId}/download")
public org.springframework.http.ResponseEntity<byte[]> downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String returnNo, @PathVariable long fileId) {
var file = service.downloadPhoto(PdaWarehouseIdentity.require(user), returnNo, fileId);
return org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.parseMediaType(file.contentType()))
.header("Cache-Control", "no-store").body(file.content());
}
@PostMapping("/pallets") public ApiResponse<Pallet> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaReturnReceiptRequests.Pallet request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
if (returnOrders.findActiveNo(String.valueOf(identity.overseasWarehouseId()), request.returnNo()) == null) {
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该待签收退货单");
}
var pallet = physicalPallets.ensureRelabelPallet(identity.overseasWarehouseId(),
"RETURN:" + request.returnNo() + ":" + request.skuCode() + ":" + System.nanoTime());
String code = org.springframework.web.util.HtmlUtils.htmlEscape(pallet.palletCode());
String returnNo = org.springframework.web.util.HtmlUtils.htmlEscape(request.returnNo());
String sku = org.springframework.web.util.HtmlUtils.htmlEscape(request.skuCode());
String html = "<!doctype html><html><head><meta charset=\"UTF-8\"><title>" + code + "</title><style>@page{size:100mm 70mm;margin:4mm}body{font-family:Arial,sans-serif;text-align:center;margin:0}h1{font-size:24px;margin:7mm 0 3mm}.code{font-size:17px;font-weight:700;word-break:break-all}.ref{font-size:11px;margin-top:4mm}</style></head><body><h1>退货托盘标签</h1><div class=\"code\">" + code + "</div><div class=\"ref\">退货单:" + returnNo + "<br>SKU:" + sku + "</div></body></html>";
return ApiResponse.success(new Pallet(pallet.palletCode(), pallet.palletId(), pallet.status(), html));
}
@PostMapping("/pallets/check") public ApiResponse<PdaReturnReceiptResponses.PalletCheck> checkPallet(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaReturnReceiptRequests.CheckPallet request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
var order = returnOrders.findActiveNo(String.valueOf(identity.overseasWarehouseId()), request.returnNo().trim());
if (order == null) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该待签收退货单");
var physical = physicalPallets.resolve(request.palletCode().trim());
if (physical == null || physical.warehouseId() != identity.overseasWarehouseId()) {
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该托盘");
}
var inventory = inventoryCartons.listPalletInventory(String.valueOf(identity.overseasWarehouseId()), physical.palletCode());
if (inventory.isEmpty()) return ApiResponse.success(new PdaReturnReceiptResponses.PalletCheck(physical.palletCode(), true, java.util.List.of(), 0));
if (inventory.stream().anyMatch(carton -> !order.getReturnNo().equals(carton.getSourceReturnNo()))) {
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "该托盘已绑定其他退货单,不能用于当前退货单");
}
java.util.LinkedHashSet<String> skuCodes = new java.util.LinkedHashSet<>(); long quantity = 0;
for (var carton : inventory) for (var item : inventoryCartonItems.listCarton(carton.getId())) { skuCodes.add(item.getSkuCode()); quantity += item.getQuantity() == null ? 0 : item.getQuantity(); }
return ApiResponse.success(new PdaReturnReceiptResponses.PalletCheck(physical.palletCode(), false, java.util.List.copyOf(skuCodes), quantity));
}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/platform/v1/last-leg/return-orders")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list')")
public class PlatformReturnOrderController {
@Resource private ReturnReceiptService service;
@PostMapping("/{returnNo}/review")
public ApiResponse<ReturnOrderResponses.Detail> review(@RequestHeader("X-Customer-Id") String customerId,
@PathVariable String returnNo, @Valid @RequestBody ReturnOrderRequests.Review request) {
return ApiResponse.success(service.review(customerId, returnNo, request));
}
}
@@ -12,10 +12,13 @@ public final class PdaReturnReceiptRequests {
private PdaReturnReceiptRequests() { }
public record Start(@NotBlank @Size(max = 128) String returnNo) { }
public record SkuLookup(@NotBlank @Size(max = 128) String skuCode) { }
public record Pallet(@NotBlank @Size(max = 128) String returnNo, @NotBlank @Size(max = 128) String skuCode) { }
public record CheckPallet(@NotBlank @Size(max = 128) String returnNo, @NotBlank @Size(max = 128) String palletCode) { }
public record ManualComplete(@NotBlank @Size(max = 500) String discrepancyReason) { }
public record Confirm(@NotBlank @Size(max = 128) String returnNo,
@Size(max = 128) String trackingNo,
@NotBlank @Size(max = 16) String startedBy,
@Size(max = 128) String cartonMark,
@NotBlank @Size(max = 128) String palletCode,
@NotEmpty @Size(max = 500) List<@Valid Item> items,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Item(@NotBlank @Size(max = 128) String skuCode,
@@ -5,13 +5,16 @@ import java.util.List;
public final class PdaReturnReceiptResponses {
private PdaReturnReceiptResponses() { }
public record Start(String returnNo, String status, List<Item> expectedItems) { }
public record Start(String returnNo, String status, List<Item> expectedItems, List<CompletedPallet> completedPallets, List<Photo> photos) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
public record CompletedPallet(String palletCode, String skuCode, long quantity, String receiptBatchNo) { }
public record PalletCheck(String palletCode, boolean empty, List<String> skuCodes, long quantity) { }
public record SkuCandidates(String skuCode, List<Candidate> candidates) { }
public record Candidate(String returnNo, String customerId, String customerReferenceNo, String status,
long skuExpectedQuantity, long skuReceivedQuantity, long orderExpectedQuantity,
long orderReceivedQuantity) { }
public record Item(String skuCode, String productNameZh, long expectedQuantity, long receivedQuantity) { }
public record Confirmed(String returnNo, String receiptNo, String receiptBatchNo, String cartonMark, String orderStatus,
public record Confirmed(String returnNo, String receiptNo, String receiptBatchNo, String palletCode, String orderStatus,
boolean fullyMatched, long expectedQuantity, long receivedQuantity,
LocalDateTime signedAt) { }
}
@@ -28,8 +28,13 @@ public final class ReturnOrderRequests {
String trackingNo, String skuCode, String status) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record RelabelCreate(@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
public record RelabelCreate(@NotEmpty @Size(max = 500) List<@Valid RelabelItem> items,
@NotBlank @Size(max = 16) String fileScope,
@NotBlank @Size(max = 128) String destinationFbaWarehouseId,
@NotBlank @Size(max = 128) String destinationFbaWarehouseCode,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 128) String contactName, @Size(max = 64) String contactPhone,
@Size(max = 32) String deliveryMethod, @Size(max = 1000) String remark) {}
@Size(max = 1000) String cartonRemark) {}
public record RelabelItem(@NotBlank @Size(max = 128) String skuCode,
@Positive long quantity) {}
public record Review(@NotBlank String decision, @Size(max = 500) String remark) { }
}
@@ -8,14 +8,17 @@ public final class ReturnOrderResponses {
public record Summary(String returnNo, String lastlegOrderNo, String overseasWarehouseId,
String warehouseContactName, String warehouseContactPhone, String customerReferenceNo, String status,
int skuTypes, long expectedQuantity, long receivedQuantity, boolean needRelabel,
String relabelNo, String relabelStatus, LocalDateTime createdAt) { }
String relabelNo, String relabelStatus, String discrepancyReason, LocalDateTime createdAt) { }
public record Item(String skuCode, String productNameZh, long expectedQuantity,
long receivedQuantity) { }
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts) { }
public record Receipt(String receiptNo, String receiptBatchNo, String cartonMark, String trackingNo, String startedBy,
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts, List<PalletDetail> pallets, List<Photo> photos) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
public record PalletDetail(String palletCode, int skuTypes, long totalQuantity, List<PalletItem> items) { }
public record PalletItem(String skuCode, String productNameZh, long quantity) { }
public record Receipt(String receiptNo, String receiptBatchNo, String palletCode, String trackingNo, String startedBy,
long receivedQuantity, String operatorName, LocalDateTime signedAt) { }
public record RelabelCandidate(Long cartonId, String cartonMark, String sourceReturnNo, String status,
long totalQuantity, List<Item> items, String relabelStatus) { }
public record RelabelCreated(String relabelNo, Long relabelApplicationId, String returnNo,
List<String> cartonMarks, String status) { }
List<String> skuCodes, String status) { }
}
@@ -18,6 +18,7 @@ public class ReturnInventoryCarton {
private Long sourceReceiptId;
private String sourceBatchNo;
private String sourceReturnNo;
private String palletCode;
private String cartonMark;
private String fbaCartonMark;
private String destinationWarehouseCode;
@@ -28,6 +28,8 @@ public class ReturnOrder {
private Long expectedQuantity;
private Long receivedQuantity;
private String remark;
private String discrepancyReason;
private LocalDateTime manuallyCompletedAt;
private String idempotencyKey;
private String requestHash;
private String createdBy;
@@ -15,6 +15,7 @@ public class ReturnReceipt {
private String customerId;
private String receiptNo;
private String receiptBatchNo;
private String palletCode;
private String cartonMark;
private String trackingNo;
private String boundBy;
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
@@ -35,9 +36,26 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
@Select("SELECT * FROM ll_return_inventory_carton WHERE id=#{id} FOR UPDATE")
ReturnInventoryCarton lockId(@Param("id") long id);
@Update("UPDATE ll_return_inventory_carton SET total_quantity=#{remaining},sku_types=#{skuTypes},status='AVAILABLE'," +
"relabel_application_id=NULL,relabel_no=NULL,relabel_status=NULL,updated_at=NOW(3) " +
"WHERE id=#{id} AND customer_id=#{customerId} AND relabel_application_id=#{applicationId} " +
"AND status='RELABEL_PENDING' AND total_quantity>#{remaining}")
int releasePartial(@Param("id") long id, @Param("customerId") String customerId,
@Param("applicationId") long applicationId, @Param("remaining") long remaining,
@Param("skuTypes") int skuTypes);
@Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} LIMIT 1")
ReturnInventoryCarton findReceipt(@Param("receiptId") long receiptId);
@Select("SELECT c.* FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.customer_id=#{customerId} AND c.overseas_warehouse_id=#{warehouseId} AND c.pallet_code=#{palletCode} AND c.status='AVAILABLE' ORDER BY c.id LIMIT 1 FOR UPDATE")
ReturnInventoryCarton findPalletSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
@Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' ORDER BY id LIMIT 1")
ReturnInventoryCarton findPalletSkuForCheck(@Param("customerId") String ignoredCustomerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
@Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' ORDER BY id")
List<ReturnInventoryCarton> listPalletInventory(@Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND carton_mark=#{cartonMark} LIMIT 1")
ReturnInventoryCarton findCustomerMark(@Param("customerId") String customerId, @Param("cartonMark") String cartonMark);
@@ -3,6 +3,12 @@ package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnReceiptItemMapper extends BaseMapper<ReturnReceiptItem> { }
public interface ReturnReceiptItemMapper extends BaseMapper<ReturnReceiptItem> {
@Select("SELECT * FROM ll_return_receipt_item WHERE receipt_id=#{receiptId} ORDER BY id")
List<ReturnReceiptItem> listReceipt(@Param("receiptId") long receiptId);
}
@@ -58,11 +58,15 @@ public class ReturnInventoryService {
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper receiptMapper;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderDetailMapper lastlegOrderDetails;
@Resource private com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway physicalPallets;
@Transactional
public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt,
String requestedMark, List<ReceiptLine> lines) {
if (lines.isEmpty()) throw new IllegalArgumentException("退货签收库存明细不能为空");
if (lines.size() != 1) throw conflict("一个签收批次只能签收一个 SKU");
String normalizedPalletCode = required(receipt.getPalletCode(), "托盘码不能为空");
required(lines.get(0).skuCode(), "SKU 不能为空");
String cartonMark = text(requestedMark);
long cartonId = IdWorker.getId();
if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
@@ -73,6 +77,7 @@ public class ReturnInventoryService {
carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId());
carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo());
carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark);
carton.setPalletCode(normalizedPalletCode);
carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
for (ReceiptLine line : lines) {
@@ -296,6 +301,14 @@ public class ReturnInventoryService {
if (total <= 0) throw badRequest("至少填写一个大于 0 的 SKU 合箱数量");
long targetId = IdWorker.getId();
ReturnInventoryCarton target = new ReturnInventoryCarton();
// Stable across transaction rollback/retry; a generated target ID would leak a new resource on every retry.
String palletBusinessKey = "RETURN-MERGE:" + hash(List.of(identity.operatorKey(), key, requestHash));
var pallet = physicalPallets.ensureRelabelPallet(identity.overseasWarehouseId(), palletBusinessKey);
if (pallet == null || pallet.warehouseId() != identity.overseasWarehouseId()
|| pallet.palletId() <= 0 || text(pallet.palletCode()) == null) {
throw conflict("合箱目标托盘资源无效,请核对后重试");
}
target.setPalletCode(pallet.palletCode());
target.setId(targetId); target.setCustomerId(customerId); target.setOverseasWarehouseId(sources.get(0).getOverseasWarehouseId());
target.setCartonMark(numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId)); target.setSourceBatchNo(String.join(",", consumedBatchNos)); target.setSourceReturnNo(String.join(",", consumedReturnNos)); target.setStatus(AVAILABLE);
target.setSkuTypes(requested.size()); target.setTotalQuantity(total); target.setLength(request.length()); target.setWidth(request.width()); target.setHeight(request.height()); target.setWeight(request.weight()); target.setCreatedAt(now); target.setUpdatedAt(now); cartons.insert(target);
@@ -25,6 +25,7 @@ import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
@@ -55,6 +56,7 @@ import java.util.Set;
@Service
public class ReturnReceiptService {
public static final String PENDING_RECEIPT = "PENDING_RECEIPT";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
public static final String COMPLETED = "COMPLETED";
@@ -71,6 +73,8 @@ public class ReturnReceiptService {
@Resource private ReturnInventoryCartonItemMapper cartonItems;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway photoFiles;
@Resource private com.baiyue.lastleg.service.FbaWarehouseValidator fbaWarehouseValidator;
@Transactional
public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) {
@@ -92,7 +96,7 @@ public class ReturnReceiptService {
order.setWarehouseContactName(text(request.warehouseContactName()));
order.setWarehouseContactPhone(text(request.warehouseContactPhone()));
order.setReturnNo(numbers.next("RETURN", order.getOverseasWarehouseId(), order.getId()));
order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_RECEIPT);
order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_REVIEW);
order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L);
order.setRemark(text(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash);
order.setCreatedBy(text(operatorId)); order.setCreatedAt(now); order.setUpdatedAt(now);
@@ -121,8 +125,8 @@ public class ReturnReceiptService {
tail.setSourceRegion("CUSTOMER"); tail.setSourceInboundId(returnOrder.getReturnNo());
tail.setSourceOrderId(String.valueOf(returnOrder.getId()));
tail.setSourceOrderNo(returnOrder.getReturnNo()); tail.setOverseasWarehouseId(returnOrder.getOverseasWarehouseId());
tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_BINDING" : "RETURN_RELABEL:NOT_REQUIRED");
tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED");
tail.setCurrentStatus(PENDING_REVIEW); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_REVIEW" : "RETURN_RELABEL:NOT_REQUIRED");
tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? PENDING_REVIEW : "NOT_REQUIRED");
tail.setCustomerRemark(returnOrder.getRemark()); tail.setSourceEventId("RETURN_ORDER:" + returnOrder.getReturnNo());
tail.setSourceLoadingPlanId("RETURN_RELABEL"); tail.setCreatedAt(now); tail.setUpdatedAt(now);
tail.setSourcePayloadHash(returnOrder.getRequestHash());
@@ -132,6 +136,30 @@ public class ReturnReceiptService {
orders.updateById(returnOrder);
}
@Transactional
public ReturnOrderResponses.Detail updatePending(String customerId, String returnNo, ReturnOrderRequests.Create request) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货预报不存在");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("只有待审核退货预报可以修改");
Set<String> skus = new LinkedHashSet<>(); long expected = 0;
for (ReturnOrderRequests.Item input : request.items()) { if (!skus.add(norm(input.skuCode()))) throw badRequest("同一退货单不能重复预报相同 SKU"); expected = Math.addExact(expected, input.expectedQuantity()); }
order.setOverseasWarehouseId(text(request.overseasWarehouseId())); order.setWarehouseContactName(text(request.warehouseContactName()));
order.setWarehouseContactPhone(text(request.warehouseContactPhone())); order.setCustomerReferenceNo(text(request.customerReferenceNo()));
order.setNeedRelabel(request.needRelabel()); order.setRemark(text(request.remark())); order.setExpectedSkuTypes(skus.size()); order.setExpectedQuantity(expected); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
orderItems.delete(new LambdaQueryWrapper<ReturnOrderItem>().eq(ReturnOrderItem::getReturnOrderId, order.getId()));
for (ReturnOrderRequests.Item input : request.items()) { ReturnOrderItem item = new ReturnOrderItem(); item.setId(IdWorker.getId()); item.setReturnOrderId(order.getId()); item.setCustomerId(customerId); item.setSkuCode(text(input.skuCode())); item.setProductNameZh(text(input.productNameZh())); item.setExpectedQuantity(input.expectedQuantity()); item.setReceivedQuantity(0L); item.setCreatedAt(LocalDateTime.now()); item.setUpdatedAt(LocalDateTime.now()); orderItems.insert(item); }
return detail(customerId, returnNo);
}
@Transactional
public ReturnOrderResponses.Detail cancelPending(String customerId, String returnNo) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货预报不存在");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("只有待审核退货预报可以取消");
order.setStatus("CANCELLED"); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
return detail(customerId, returnNo);
}
@Transactional(readOnly = true)
public PageResult<ReturnOrderResponses.Summary> page(String customerId, ReturnOrderRequests.Query query) {
LambdaQueryWrapper<ReturnOrder> w = new LambdaQueryWrapper<ReturnOrder>()
@@ -166,11 +194,56 @@ public class ReturnReceiptService {
public ReturnOrderResponses.Detail detail(String customerId, String returnNo) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货单不存在");
List<ReturnOrderResponses.Item> items = orderItems.listOrder(order.getId()).stream().map(this::item).toList();
List<ReturnOrderResponses.Receipt> receiptViews = receipts.listOrder(order.getId()).stream().map(receipt ->
new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(), receipt.getTrackingNo(),
List<ReturnOrderItem> orderLines = orderItems.listOrder(order.getId());
List<ReturnOrderResponses.Item> items = orderLines.stream().map(this::item).toList();
List<ReturnReceipt> orderReceipts = receipts.listOrder(order.getId());
List<ReturnOrderResponses.Receipt> receiptViews = orderReceipts.stream().map(receipt ->
new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(), receipt.getTrackingNo(),
receipt.getBoundBy(), value(receipt.getReceivedQuantity()), receipt.getOperatorName(), receipt.getSignedAt())).toList();
return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews);
Map<String, Map<String, ReturnOrderResponses.PalletItem>> palletItems = new LinkedHashMap<>();
Map<String, String> productNames = new LinkedHashMap<>();
for (ReturnOrderItem line : orderLines) productNames.put(line.getSkuCode(), line.getProductNameZh());
for (ReturnReceipt receipt : orderReceipts) {
if (text(receipt.getPalletCode()) == null) continue;
Map<String, ReturnOrderResponses.PalletItem> bySku = palletItems.computeIfAbsent(receipt.getPalletCode(), ignored -> new LinkedHashMap<>());
for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) bySku.merge(line.getSkuCode(),
new ReturnOrderResponses.PalletItem(line.getSkuCode(), productNames.get(line.getSkuCode()), value(line.getReceivedQuantity())),
(left, right) -> new ReturnOrderResponses.PalletItem(left.skuCode(), text(left.productNameZh()) == null ? right.productNameZh() : left.productNameZh(), left.quantity() + right.quantity()));
}
List<ReturnOrderResponses.PalletDetail> palletViews = palletItems.entrySet().stream().map(entry -> {
List<ReturnOrderResponses.PalletItem> palletLines = List.copyOf(entry.getValue().values());
return new ReturnOrderResponses.PalletDetail(entry.getKey(), palletLines.size(), palletLines.stream().mapToLong(ReturnOrderResponses.PalletItem::quantity).sum(), palletLines);
}).toList();
List<ReturnOrderResponses.Photo> photos = photoList(order).stream()
.map(file -> new ReturnOrderResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList();
return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews, palletViews, photos);
}
public com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String returnNo, long fileId) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货单不存在");
return photoFiles.download(order.getCustomerId(), order.getId(), fileId);
}
@Transactional
public ReturnOrderResponses.Detail review(String customerId, String returnNo, ReturnOrderRequests.Review request) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货预报不存在");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("只有待审核退货预报可以审核");
String decision = norm(request.decision());
if (!Set.of("APPROVE", "REJECT").contains(decision)) throw badRequest("decision 必须为 APPROVE 或 REJECT");
if ("REJECT".equals(decision) && text(request.remark()) == null) throw badRequest("审核驳回必须填写原因");
order.setStatus("APPROVE".equals(decision) ? PENDING_RECEIPT : "REJECTED");
if ("REJECT".equals(decision)) order.setRemark(text(request.remark()));
order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
LastlegOrder tail = order.getLastlegOrderId() == null ? null : lastlegOrders.selectById(order.getLastlegOrderId());
if (tail != null) {
tail.setCurrentStatus("APPROVE".equals(decision) ? PENDING_RECEIPT : "REJECTED");
tail.setSubOrderStatus("RETURN_RELABEL:" + ("APPROVE".equals(decision) ? PENDING_RECEIPT : "REJECTED"));
tail.setRelabelStatus("APPROVE".equals(decision) && Boolean.TRUE.equals(order.getNeedRelabel()) ? "WAIT_INBOUND" : ("APPROVE".equals(decision) ? "NOT_REQUIRED" : "REJECTED"));
tail.setUpdatedAt(LocalDateTime.now()); lastlegOrders.updateById(tail);
}
return detail(customerId, returnNo);
}
@Transactional(readOnly = true)
@@ -195,44 +268,119 @@ public class ReturnReceiptService {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货单不存在");
if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收");
String fileScope = norm(request.fileScope());
if (!Set.of("CARTON_TOTAL", "SKU_TOTAL").contains(fileScope)) throw badRequest("贴标总文件类型必须为 CARTON_TOTAL 或 SKU_TOTAL");
String key = text(request.idempotencyKey());
String requestHash = hash(request);
RelabelApplication replay = relabelApplications.findKey(customerId, key);
if (replay != null) return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(), order.getReturnNo(), request.cartonMarks(), replay.getStatus());
List<ReturnInventoryCarton> selected = new ArrayList<>();
if (replay != null) return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(),
order.getReturnNo(), request.items().stream().map(ReturnOrderRequests.RelabelItem::skuCode).toList(), replay.getStatus());
if (text(order.getRelabelNo()) != null) throw conflict("当前退货单已经生成贴标申请");
var destination = fbaWarehouseValidator.required(request.destinationFbaWarehouseId(), request.destinationFbaWarehouseCode(), null);
Map<String, ReturnOrderItem> receivedBySku = orderItems.lockOrder(order.getId()).stream()
.collect(java.util.stream.Collectors.toMap(item -> norm(item.getSkuCode()), item -> item));
Set<String> seen = new LinkedHashSet<>();
for (String mark : request.cartonMarks()) {
String normalized = text(mark);
if (!seen.add(normalized.toUpperCase(java.util.Locale.ROOT))) throw badRequest("箱唛不能重复");
ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, normalized);
if (carton == null || !order.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())) throw notFound("退货箱不存在: " + normalized);
if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) throw conflict("退货箱不可申请换标: " + normalized);
if (carton.getSourceReturnNo() == null || !java.util.Arrays.asList(carton.getSourceReturnNo().split(",")).contains(order.getReturnNo())) throw conflict("退货箱不属于当前退货单: " + normalized);
selected.add(cartons.lockMark(normalized));
long totalQuantity = 0;
for (ReturnOrderRequests.RelabelItem requested : request.items()) {
String sku = norm(requested.skuCode());
if (!seen.add(sku)) throw badRequest("同一贴标申请不能重复选择 SKU: " + requested.skuCode());
ReturnOrderItem received = receivedBySku.get(sku);
if (received == null || requested.quantity() > value(received.getReceivedQuantity())) {
throw conflict("SKU " + requested.skuCode() + " 的贴标数量超过当前退货单实收数量");
}
totalQuantity = Math.addExact(totalQuantity, requested.quantity());
}
long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now();
String relabelNo = numbers.next("RELABEL", order.getOverseasWarehouseId(), appId);
LastlegOrder tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RELABEL", order.getOverseasWarehouseId(), tail.getId()));
tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER"); tail.setSourceOrderId(String.valueOf(appId)); tail.setSourceOrderNo(relabelNo);
tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus("RELABEL:RETURN_CARTON"); tail.setRelabelStatus("RETURN_CARTON"); tail.setPackageCount(selected.size());
tail.setProductQuantity(Math.toIntExact(selected.stream().mapToLong(c -> value(c.getTotalQuantity())).sum())); tail.setCreatedAt(now); tail.setUpdatedAt(now); lastlegOrders.insert(tail);
RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId());
app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId()); app.setContactName(text(request.contactName())); app.setContactPhone(text(request.contactPhone())); app.setDeliveryMethod(text(request.deliveryMethod()) == null ? "TRUCK" : text(request.deliveryMethod()));
app.setFulfillmentStatus("WAIT_RELABEL"); app.setRemark(text(request.remark())); app.setStatus("PENDING_PROCESSING"); app.setItemCount(selected.size()); app.setTotalPackageCount(selected.size()); app.setNotifiedPackageCount(selected.size()); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(hash(request)); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app);
for (ReturnInventoryCarton carton : selected) {
for (ReturnInventoryCartonItem ci : cartonItems.listCarton(carton.getId())) {
RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId); item.setSkuCode(ci.getSkuCode()); item.setProductNameZh(ci.getProductNameZh()); item.setTotalPackageCount(1); item.setNotifiedPackageCount(1); item.setShipmentNo(order.getReturnNo()); item.setCartonMark(carton.getCartonMark()); item.setFbaCartonMark(carton.getFbaCartonMark()); item.setOperationType("CARTON_LABEL"); item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark()); item.setCreatedAt(now); relabelItems.insert(item);
}
carton.setRelabelApplicationId(appId); carton.setRelabelNo(relabelNo); carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); cartons.updateById(carton);
LastlegOrder tail = order.getLastlegOrderId() == null ? null : lastlegOrders.selectById(order.getLastlegOrderId());
if (tail == null) {
tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RELABEL", order.getOverseasWarehouseId(), tail.getId()));
tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER");
tail.setSourceInboundId("RETURN_RELABEL:" + relabelNo);
tail.setSourceEventId("RETURN_RELABEL:" + relabelNo);
tail.setSourceLoadingPlanId("RETURN_RELABEL");
tail.setSourcePayloadHash("sha256:" + requestHash);
tail.setSourceOrderId(String.valueOf(appId)); tail.setSourceOrderNo(relabelNo);
tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setCurrentStatus("WAIT_RELABEL");
tail.setCreatedAt(now); tail.setPackageCount(Math.toIntExact(totalQuantity));
tail.setProductQuantity(Math.toIntExact(totalQuantity)); tail.setUpdatedAt(now); lastlegOrders.insert(tail);
}
order.setRelabelNo(relabelNo); order.setRelabelStatus("PENDING_PROCESSING"); orders.updateById(order);
return new ReturnOrderResponses.RelabelCreated(relabelNo, appId, order.getReturnNo(), selected.stream().map(ReturnInventoryCarton::getCartonMark).toList(), app.getStatus());
// A return relabel application belongs to the already received return tail-leg order.
// Do not create a second independent tail order or overwrite the inbound status.
tail.setRelabelOrderNo(relabelNo);
tail.setRelabelStatus("PENDING_REVIEW");
tail.setSubOrderStatus("RETURN_RELABEL:PENDING_REVIEW");
tail.setCurrentStatus("RETURN_RELABEL".equals(tail.getOrderType()) ? "INBOUND_COMPLETED" : tail.getCurrentStatus());
tail.setPackageCount(Math.toIntExact(totalQuantity));
tail.setProductQuantity(Math.toIntExact(totalQuantity));
tail.setUpdatedAt(now);
lastlegOrders.updateById(tail);
RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId());
app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId());
app.setLabelDeadline(now.plusDays(7));
app.setContactName(text(order.getWarehouseContactName())); app.setContactPhone(text(order.getWarehouseContactPhone()));
app.setFulfillmentStatus("WAIT_APPROVAL"); app.setRemark(text(request.cartonRemark())); app.setStatus("PENDING_REVIEW"); app.setItemCount(request.items().size()); app.setTotalPackageCount(Math.toIntExact(totalQuantity)); app.setNotifiedPackageCount(Math.toIntExact(totalQuantity)); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(requestHash); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app);
List<ReturnInventoryCarton> availableCartons = cartons.findAvailable(customerId, order.getOverseasWarehouseId());
Map<Long, ReturnInventoryCarton> selectedCartons = new LinkedHashMap<>();
for (ReturnOrderRequests.RelabelItem requested : request.items()) {
ReturnOrderItem source = receivedBySku.get(norm(requested.skuCode()));
long remaining = requested.quantity();
for (ReturnInventoryCarton candidate : availableCartons) {
if (remaining == 0) break;
if (!order.getReturnNo().equals(candidate.getSourceReturnNo())) continue;
long skuQuantity = cartonItems.listCarton(candidate.getId()).stream()
.filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode())))
.mapToLong(line -> value(line.getQuantity())).sum();
if (skuQuantity <= 0) continue;
ReturnInventoryCarton carton = selectedCartons.computeIfAbsent(candidate.getId(), ignored -> cartons.lockId(candidate.getId()));
if (carton == null || !"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null)
throw conflict("退货箱已被其他换标申请占用,请刷新后重试");
long allocated = Math.min(remaining, skuQuantity);
RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId);
item.setSkuCode(source.getSkuCode());
item.setProductNameZh(text(source.getProductNameZh()) == null ? source.getSkuCode() : text(source.getProductNameZh()));
item.setTotalPackageCount(Math.toIntExact(allocated)); item.setNotifiedPackageCount(Math.toIntExact(allocated));
item.setShipmentNo(order.getReturnNo()); item.setLabelRequirement(text(request.cartonRemark()));
item.setDestinationWarehouseId(String.valueOf(destination.id())); item.setDestinationFbaWarehouseCode(destination.code());
item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setReturnCartonId(carton.getId());
item.setCartonMark(carton.getCartonMark()); item.setSourceCartonMark(carton.getCartonMark());
item.setCreatedAt(now); relabelItems.insert(item);
remaining -= allocated;
}
if (remaining > 0) throw conflict("退货 SKU 可用实收数量不足: " + requested.skuCode());
}
for (ReturnInventoryCarton carton : selectedCartons.values()) {
carton.setRelabelApplicationId(appId); carton.setRelabelNo(relabelNo); carton.setRelabelStatus("PENDING");
carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); cartons.updateById(carton);
}
order.setRelabelApplicationId(appId); order.setRelabelNo(relabelNo); order.setRelabelStatus("PENDING_REVIEW"); orders.updateById(order);
return new ReturnOrderResponses.RelabelCreated(relabelNo, appId, order.getReturnNo(),
request.items().stream().map(ReturnOrderRequests.RelabelItem::skuCode).toList(), app.getStatus());
}
@Transactional(readOnly = true)
public PdaReturnReceiptResponses.Start start(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Start request) {
ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), request.returnNo());
List<PdaReturnReceiptResponses.CompletedPallet> completedPallets = receipts.listOrder(order.getId()).stream()
.filter(receipt -> text(receipt.getPalletCode()) != null)
.collect(java.util.stream.Collectors.toMap(ReturnReceipt::getPalletCode, receipt -> {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
ReturnInventoryCartonItem item = carton == null ? null : cartonItems.listCarton(carton.getId()).stream().findFirst().orElse(null);
return new PdaReturnReceiptResponses.CompletedPallet(receipt.getPalletCode(), item == null ? "" : item.getSkuCode(),
item == null ? 0 : value(item.getQuantity()), receipt.getReceiptBatchNo());
}, (left, right) -> new PdaReturnReceiptResponses.CompletedPallet(left.palletCode(), left.skuCode(),
left.quantity() + right.quantity(), right.receiptBatchNo()), java.util.LinkedHashMap::new))
.values().stream().toList();
List<PdaReturnReceiptResponses.Photo> photos = photoList(order).stream()
.map(file -> new PdaReturnReceiptResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList();
return new PdaReturnReceiptResponses.Start(order.getReturnNo(), order.getStatus(), orderItems.listOrder(order.getId()).stream()
.filter(item -> value(item.getReceivedQuantity()) < value(item.getExpectedQuantity())).map(this::pdaItem).toList());
.filter(item -> value(item.getReceivedQuantity()) < value(item.getExpectedQuantity())).map(this::pdaItem).toList(), completedPallets, photos);
}
@Transactional(readOnly = true)
public com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway.DownloadedFile downloadPhoto(PdaWarehouseIdentity identity, String returnNo, long fileId) {
ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), returnNo);
return photoFiles.download(order.getCustomerId(), order.getId(), fileId);
}
@Transactional(readOnly = true)
@@ -255,6 +403,16 @@ public class ReturnReceiptService {
return new PdaReturnReceiptResponses.SkuCandidates(skuCode, candidates);
}
@Transactional
public ReturnOrderResponses.Detail manualComplete(PdaWarehouseIdentity identity, String returnNo, PdaReturnReceiptRequests.ManualComplete request) {
ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), returnNo);
if (value(order.getReceivedQuantity()) >= value(order.getExpectedQuantity())) throw conflict("退货单已按预期数量签收完成,不需要手动完结");
order.setStatus("COMPLETED_WITH_DISCREPANCY"); order.setDiscrepancyReason(text(request.discrepancyReason()));
order.setManuallyCompletedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
syncReturnTail(order, "INBOUND_COMPLETED", true);
return detail(order.getCustomerId(), order.getReturnNo());
}
@Transactional
public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) {
String key = text(request.idempotencyKey());
@@ -289,6 +447,7 @@ public class ReturnReceiptService {
receipt.setReceiptNo(numbers.next("RETURN_RECEIPT", order.getOverseasWarehouseId(), receiptId));
receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH", order.getOverseasWarehouseId(), receiptId));
receipt.setTrackingNo(trackingNo); receipt.setBoundBy(startedBy); receipt.setStatus("SIGNED");
receipt.setPalletCode(text(request.palletCode()));
receipt.setReceivedQuantity(receiptQuantity); receipt.setOperatorId(identity.operatorKey());
receipt.setOperatorName(identity.operatorName()); receipt.setIdempotencyKey(key); receipt.setRequestHash(requestHash);
receipt.setSignedAt(now); receipt.setCreatedAt(now);
@@ -315,13 +474,20 @@ public class ReturnReceiptService {
left.skuCode(), left.productNameZh() == null ? right.productNameZh() : left.productNameZh(),
Math.addExact(left.quantity(), right.quantity())));
}
receipt.setCartonMark(inventory.createReceiptCarton(order, receipt, request.cartonMark(),
if (allocations.stream().map(allocation -> allocation.item().getSkuCode()).distinct().count() != 1) {
throw conflict("一个签收批次只能签收一个 SKU");
}
receipt.setCartonMark(inventory.createReceiptCarton(order, receipt, text(request.palletCode()),
new ArrayList<>(inventoryLines.values())).getCartonMark());
receipts.updateById(receipt);
List<ReturnOrderItem> finalItems = orderItems.listOrder(order.getId());
long received = finalItems.stream().mapToLong(item -> value(item.getReceivedQuantity())).sum();
boolean completed = finalItems.stream().allMatch(item -> value(item.getReceivedQuantity()) == value(item.getExpectedQuantity()));
if (completed && photoFiles != null && photoList(order).isEmpty()) {
throw conflict("本次将完成签收,请先拍摄并上传退货打托照片");
}
order.setReceivedQuantity(received); order.setStatus(completed ? COMPLETED : PARTIALLY_RECEIVED); order.setUpdatedAt(now); orders.updateById(order);
syncReturnTail(order, completed ? "INBOUND_COMPLETED" : "PARTIALLY_RECEIVED", completed || received > 0);
ReturnReceiptBatch batch = new ReturnReceiptBatch();
batch.setId(IdWorker.getId()); batch.setReturnOrderId(order.getId()); batch.setReceiptId(receiptId);
batch.setCustomerId(order.getCustomerId()); batch.setBatchNo(receipt.getReceiptBatchNo());
@@ -330,6 +496,21 @@ public class ReturnReceiptService {
return confirmed(order, receipt, completed);
}
private void syncReturnTail(ReturnOrder order, String currentStatus, boolean inboundStarted) {
if (order.getLastlegOrderId() == null) return;
LastlegOrder tail = lastlegOrders.selectById(order.getLastlegOrderId());
if (tail == null) return;
tail.setCurrentStatus(currentStatus);
tail.setSubOrderStatus("RETURN_RELABEL:" + currentStatus);
tail.setRelabelStatus(Boolean.TRUE.equals(order.getNeedRelabel()) && inboundStarted ? "PENDING_PROCESSING" : "NOT_REQUIRED");
tail.setUpdatedAt(LocalDateTime.now());
lastlegOrders.updateById(tail);
}
private List<RelabelPhotoFileGateway.UploadedFile> photoList(ReturnOrder order) {
return photoFiles == null ? List.of() : photoFiles.list(order.getCustomerId(), order.getId());
}
private ReturnOrder activeOrder(String warehouseId, String returnNo) {
ReturnOrder order = orders.findActiveNo(warehouseId, text(returnNo));
if (order == null) throw notFound("当前仓没有待签收或签收中的退货单");
@@ -367,13 +548,13 @@ public class ReturnReceiptService {
return confirmed(order, receipt, COMPLETED.equals(order.getStatus()));
}
private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) {
return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(),
return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(),
order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt());
}
private ReturnOrderResponses.Summary summary(ReturnOrder order) { return new ReturnOrderResponses.Summary(order.getReturnNo(), order.getLastlegOrderNo(),
order.getOverseasWarehouseId(), order.getWarehouseContactName(), order.getWarehouseContactPhone(), order.getCustomerReferenceNo(), order.getStatus(),
order.getExpectedSkuTypes() == null ? 0 : order.getExpectedSkuTypes(), value(order.getExpectedQuantity()),
value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getCreatedAt()); }
value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getDiscrepancyReason(), order.getCreatedAt()); }
private ReturnOrderResponses.Item item(ReturnOrderItem item) { return new ReturnOrderResponses.Item(item.getSkuCode(),
item.getProductNameZh(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity())); }
private PdaReturnReceiptResponses.Item pdaItem(ReturnOrderItem item) { return new PdaReturnReceiptResponses.Item(item.getSkuCode(),
@@ -33,7 +33,7 @@ public class FbaWarehouseValidator {
|| !"FBA".equals(warehouse.warehouseType())
|| !"ACTIVE".equals(warehouse.status())
|| !same(requestedCode, warehouse.warehouseCode())
|| !same(requestedName, warehouse.warehouseName())) {
|| !sameIfProvided(requestedName, warehouse.warehouseName())) {
throw invalid();
}
String region = firstRegion(response.data().addresses());
@@ -69,6 +69,10 @@ public class FbaWarehouseValidator {
&& requested.trim().equals(actual.trim());
}
private boolean sameIfProvided(String requested, String actual) {
return !StringUtils.hasText(requested) || same(requested, actual);
}
private String firstRegion(List<DomesticWarehouseDirectoryClient.Address> addresses) {
if (addresses == null) return null;
return addresses.stream().filter(Objects::nonNull)
@@ -478,6 +478,14 @@ public class WarehouseTransferService {
orderDetails.updateById(detail);
}
syncInboundPlanDestination(customerId, transfer);
if (StringUtils.hasText(transfer.getRelabelNo())) {
RelabelApplication relabel = relabelApplications.lockNo(customerId, transfer.getRelabelNo());
if (relabel != null && "WAIT_FBA_DESTINATION".equals(relabel.getFulfillmentStatus())) {
relabel.setFulfillmentStatus("WAIT_PRE_DISPATCH");
relabel.setUpdatedAt(now);
relabelApplications.updateById(relabel);
}
}
insertOrderHistory(customerId, order.getId(), order.getCurrentStatus(), order.getCurrentStatus(), transferNo,
"Warehouse transfer destination bound by " + trim(operatorId), now);
results.add(new WarehouseTransferResponses.DestinationResult(customerId, transferNo));
@@ -1160,7 +1168,7 @@ public class WarehouseTransferService {
throw conflict("Source inventory balance is missing for warehouse transfer receipt");
}
InventoryCommandResponse result = inventory.stockIn(customerId, operatorId,
new InventoryCommandRequests.StockIn(customerId, null,
new InventoryCommandRequests.StockIn(customerId, sourceBalance.getCustomerName(),
request.destinationOverseasWarehouseId().trim(), trim(request.destinationOverseasWarehouseCode()),
trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()),
request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()),
@@ -1957,6 +1957,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact (
result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION',
exception_code VARCHAR(32) NULL COMMENT '异常编码',
reason VARCHAR(1000) NULL COMMENT '跳过或异常原因',
quantity INT NOT NULL DEFAULT 1 COMMENT '本次事实处理件数,箱级作业固定为1',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识',
operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照',
occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间',
@@ -1965,6 +1966,8 @@ CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact (
KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id),
KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id)
) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_pda_unit_fact' AND column_name='quantity'), 'SELECT 1', 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity INT NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason'));
PREPARE ll_relabel_fact_quantity_stmt FROM @ddl; EXECUTE ll_relabel_fact_quantity_stmt; DEALLOCATE PREPARE ll_relabel_fact_quantity_stmt;
CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation (
id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID',
@@ -2616,6 +2619,8 @@ SET @ll_return_receipt_alter = 'ALTER TABLE ll_return_receipt MODIFY COLUMN trac
PREPARE ll_return_receipt_alter_stmt FROM @ll_return_receipt_alter;
EXECUTE ll_return_receipt_alter_stmt;
DEALLOCATE PREPARE ll_return_receipt_alter_stmt;
SET @ll_return_receipt_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''本次签收绑定的托盘码'' AFTER receipt_batch_no'));
PREPARE ll_return_receipt_add_pallet_stmt FROM @ll_return_receipt_add_pallet; EXECUTE ll_return_receipt_add_pallet_stmt; DEALLOCATE PREPARE ll_return_receipt_add_pallet_stmt;
CREATE TABLE IF NOT EXISTS ll_return_receipt_item (
id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量',
PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0)
@@ -2631,15 +2636,17 @@ PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛',
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'))
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
SET @ll_return_carton_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no'));
PREPARE ll_return_carton_add_pallet_stmt FROM @ll_return_carton_add_pallet; EXECUTE ll_return_carton_add_pallet_stmt; DEALLOCATE PREPARE ll_return_carton_add_pallet_stmt;
-- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
PREPARE return_inventory_carton_check_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_stmt;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
PREPARE return_inventory_carton_status_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_status_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_status_drop_stmt;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'));
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
ALTER TABLE ll_return_inventory_carton MODIFY COLUMN source_batch_no TEXT NULL COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', MODIFY COLUMN source_return_no TEXT NULL COMMENT '实际消耗来源退货单号,多个单号逗号分隔';
@@ -2679,3 +2686,8 @@ CREATE TABLE IF NOT EXISTS ll_container_loading_evidence (
UNIQUE KEY uk_ll_container_loading_evidence_type(source_container_booking_id,evidence_type),
UNIQUE KEY uk_ll_container_loading_evidence_file(source_container_booking_id,source_file_id)
) ENGINE=InnoDB COMMENT='海外拆柜只读国内装柜照片引用';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason VARCHAR(500) NULL COMMENT ''手动完结差异原因'' AFTER remark'));
PREPARE ll_return_order_discrepancy_stmt FROM @ddl; EXECUTE ll_return_order_discrepancy_stmt; DEALLOCATE PREPARE ll_return_order_discrepancy_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='manually_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at DATETIME(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason'));
PREPARE ll_return_order_manual_completed_stmt FROM @ddl; EXECUTE ll_return_order_manual_completed_stmt; DEALLOCATE PREPARE ll_return_order_manual_completed_stmt;
@@ -84,6 +84,17 @@ paths:
application/octet-stream: { schema: { type: string, format: binary } }
'403': { description: fileId 未绑定到当前操作员的当前柜会话三类签收照片 }
'404': { description: 当前柜会话或照片不存在 }
/api/pda/v1/last-leg/receiving-containers/{containerNo}/unload-photos/files/{fileId}/download:
get:
summary: 按柜号下载卸柜签收照片
description: 权限 lastleg:pda:operate;按当前 PDA 绑定仓和柜号读取历史或当前柜的签收照片,不要求存在未完成接柜会话。
security: [{ bearerAuth: [] }]
parameters:
- { name: containerNo, in: path, required: true, schema: { type: string } }
- { name: fileId, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,18}$' } }
responses:
'200': { description: 签收照片字节流, content: { image/jpeg: { schema: { type: string, format: binary } }, image/png: { schema: { type: string, format: binary } } } }
'404': { description: 当前仓没有该柜或照片不存在 }
/api/pda/v1/last-leg/receiving-containers/current/domestic-loading-photos/{evidenceType}/content:
get:
summary: 查看当前柜国内装柜照片
@@ -100,6 +111,17 @@ paths:
application/octet-stream: { schema: { type: string, format: binary } }
'404': { description: 当前柜或指定类型照片不存在 }
'503': { description: 国内照片服务暂不可用 }
/api/pda/v1/last-leg/receiving-containers/{containerNo}/domestic-loading-photos/{evidenceType}/content:
get:
summary: 按柜号查看国内装柜照片
description: 权限 lastleg:pda:operate;按当前 PDA 绑定仓和柜号读取历史或当前柜的国内装柜照片,不要求存在未完成接柜会话。
security: [{ bearerAuth: [] }]
parameters:
- { name: containerNo, in: path, required: true, schema: { type: string } }
- { name: evidenceType, in: path, required: true, schema: { type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL] } }
responses:
'200': { description: 国内装柜照片字节流, content: { image/jpeg: { schema: { type: string, format: binary } }, image/png: { schema: { type: string, format: binary } } } }
'404': { description: 当前仓没有该柜或指定类型照片不存在 }
/api/pda/v1/last-leg/receiving-containers/current/sign:
post:
summary: 签收当前柜
@@ -1516,6 +1538,17 @@ paths:
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } }
/api/merchant/v1/last-leg/return-orders/{returnNo}/photos/files/{fileId}/download:
get:
summary: 客户下载退货签收打托照片
description: 权限 merchant:overseas-warehouse:return-order:list;仅允许下载当前客户、当前退货单关联的签收照片,响应为原始文件且禁止缓存。
security: [{ bearerAuth: [] }]
parameters:
- { name: returnNo, in: path, required: true, schema: { type: string } }
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } }
responses:
'200': { description: 照片文件, content: { image/*: { schema: { type: string, format: binary } } } }
'404': { description: 当前客户下退货单或关联照片不存在 }
/api/merchant/v1/last-leg/return-orders/{returnNo}/relabel-candidates:
get:
summary: 查询退货单可转换标候选
@@ -66,6 +66,40 @@ class DeliveryCompletedOutboxServiceTest {
verifyNoInteractions(mapper);
}
@Test
void skipsWarehouseTransferWithoutDomesticSourceOrder() {
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService();
ReflectionTestUtils.setField(service, "outbox", mapper);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule()));
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(2L); delivery.setDeliveryNo("DL-WT"); delivery.setVersion(1L);
LastlegOrder transfer = new LastlegOrder();
transfer.setId(22L); transfer.setOrderType("WAREHOUSE_TRANSFER");
transfer.setCustomerId("88"); transfer.setSourceInboundId("WAREHOUSE_TRANSFER:WT-1");
service.publish(delivery, List.of(transfer), Instant.parse("2026-09-15T08:00:00Z"));
verifyNoInteractions(mapper);
}
@Test
void skipsOverseasLocalOrderWithoutDomesticSourceOrder() {
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService();
ReflectionTestUtils.setField(service, "outbox", mapper);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule()));
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(3L); delivery.setDeliveryNo("DL-LOCAL"); delivery.setVersion(1L);
LastlegOrder local = new LastlegOrder();
local.setId(23L); local.setOrderType("RETURN_RELABEL"); local.setSourceRegion("CUSTOMER");
local.setCustomerId("88"); local.setSourceInboundId("RETURN-1");
service.publish(delivery, List.of(local), Instant.parse("2026-09-15T08:00:00Z"));
verifyNoInteractions(mapper);
}
private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) {
LastlegOrder value = new LastlegOrder();
value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88");
@@ -140,6 +140,8 @@ class ReceivingContainerContractTest {
assertTrue(yaml.contains(prefix + "/current/receipts/pallet:"));
assertTrue(yaml.contains(prefix + "/current/complete:"));
assertTrue(yaml.contains(prefix + "/current/unload-photos/files/{fileId}/download:"));
assertTrue(yaml.contains(prefix + "/{containerNo}/unload-photos/files/{fileId}/download:"));
assertTrue(yaml.contains(prefix + "/{containerNo}/domestic-loading-photos/{evidenceType}/content:"));
assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}"));
String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:"));
List<String> successResponses = receivingPaths.lines().map(String::trim)
@@ -730,9 +730,9 @@ class ReceivingContainerServiceBehaviorTest {
.containsExactly("2092476334483931138", "2", "3");
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("downloadPath")
.containsExactly(
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/2092476334483931138/download",
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/2/download",
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/3/download");
"/api/pda/v1/last-leg/receiving-containers/CONT-A/unload-photos/files/2092476334483931138/download",
"/api/pda/v1/last-leg/receiving-containers/CONT-A/unload-photos/files/2/download",
"/api/pda/v1/last-leg/receiving-containers/CONT-A/unload-photos/files/3/download");
verify(unloadPhotos).validate("0", 8L, 2092476334483931138L);
verify(unloadPhotos).validate("0", 8L, 2L);
verify(unloadPhotos).validate("0", 8L, 3L);
@@ -87,6 +87,7 @@ class MerchantOverseasCustomerApiContractTest {
"create", OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE,
"page", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST,
"detail", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST,
"downloadPhoto", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST,
"relabelCandidates", OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL,
"createRelabel", OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL));
assertPermissions(MerchantRelabelApplicationController.class, Map.of(
@@ -32,7 +32,8 @@ class MerchantOverseasOpenApiContractTest {
"/api/merchant/v1/last-leg/local-looseboard-orders:",
"/api/merchant/v1/last-leg/local-looseboard-orders/{orderNo}:",
"/api/merchant/v1/last-leg/inbound-plans:",
"/api/merchant/v1/last-leg/inbound-plans/{planNo}:"}) {
"/api/merchant/v1/last-leg/inbound-plans/{planNo}:",
"/api/merchant/v1/last-leg/return-orders/{returnNo}/photos/files/{fileId}/download:"}) {
assertThat(yaml).contains(path);
assertThat(pathBlock(yaml, path)).doesNotContain("name: customerId");
}
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.palletops.controller;
import com.baiyue.lastleg.palletops.dto.PalletOperationResponses;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -8,6 +10,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
@@ -35,4 +38,21 @@ class PdaPalletOperationControllerContractTest {
assertFalse(sql.contains("&lt;"));
assertFalse(sql.contains("&gt;"));
}
@Test void relabelSourceAndResolvedPalletSerializeSnowflakeIdsAsStrings() throws Exception {
ObjectMapper mapper = new ObjectMapper();
var source = new PalletOperationResponses.RelabelSource(2100204001227157507L, 2L,
2100203890682015746L, "PLT-1", "LOC-1", "IN-1", 1L, 1L, List.of("C-1"));
var content = new PalletOperationResponses.PalletContent(2100203890682015746L, "PLT-1",
"OCCUPIED", 1, 2092783373163335682L, "LOC-1", null, 1L, List.of());
assertEquals("2100204001227157507", mapper.readTree(mapper.writeValueAsString(source))
.path("positionId").textValue());
assertEquals("2100203890682015746", mapper.readTree(mapper.writeValueAsString(source))
.path("palletId").textValue());
assertEquals("2100203890682015746", mapper.readTree(mapper.writeValueAsString(content))
.path("palletId").textValue());
assertEquals("2092783373163335682", mapper.readTree(mapper.writeValueAsString(content))
.path("locationId").textValue());
}
}
@@ -9,6 +9,7 @@ import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@@ -37,4 +38,23 @@ class HttpPalletPhysicalGatewayTest {
.hasMessageContaining("404");
server.verify();
}
@Test
void resolveIgnoresNewWarehousePalletMetadata() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-1"))
.andRespond(withSuccess("""
{"code":"200","message":"success","data":{"palletId":"30","palletCode":"PAL-1",
"warehouseId":"88","locationId":"40","status":"IDLE","fbaWarehouseCode":"XLX1",
"unitPrice":1.00,"version":2,"locationType":"VIRTUAL","locationStatus":"ACTIVE",
"reservable":true,"reservationBlockReason":null},
"timestamp":"2026-09-19T03:18:35.283336500Z"}
""", MediaType.APPLICATION_JSON));
var result = gateway.resolve("PAL-1");
assertThat(result.palletId()).isEqualTo(30L);
assertThat(result.palletCode()).isEqualTo("PAL-1");
assertThat(result.fbaWarehouseCode()).isEqualTo("XLX1");
server.verify();
}
}
@@ -18,6 +18,11 @@ import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.service.RelabelPalletMutationService;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -62,6 +67,9 @@ class PdaPalletOperationServiceTest {
@Mock private RelabelApplicationMapper relabelApplications;
@Mock private RelabelItemMapper relabelItems;
@Mock private RelabelPalletMutationService relabelPalletMutations;
@Mock private ReturnInventoryCartonMapper returnCartons;
@Mock private ReturnInventoryCartonItemMapper returnCartonItems;
@Mock private ReturnInventoryBalanceMapper returnBalances;
private PdaPalletOperationService service;
@BeforeEach void setUp() {
@@ -75,10 +83,44 @@ class PdaPalletOperationServiceTest {
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
ReflectionTestUtils.setField(service, "relabelPalletMutations", relabelPalletMutations);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
ReflectionTestUtils.setField(service, "returnCartonItems", returnCartonItems);
ReflectionTestUtils.setField(service, "returnBalances", returnBalances);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
service.configureTransactions(new NoOpTransactionManager());
}
@Test void partialReturnRelabelKeepsUnlabelledRemainderOnOriginalPallet() {
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); app.setRelabelNo("RL-1");
RelabelItem item = new RelabelItem(); item.setId(701L); item.setSkuCode("SKU-1");
item.setReturnCartonId(801L); item.setDestinationFbaWarehouseCode("XLX6");
ReturnInventoryCarton original = new ReturnInventoryCarton(); original.setId(801L);
original.setCustomerId("100"); original.setOverseasWarehouseId("88");
original.setTotalQuantity(100L); original.setPalletCode("OLD");
ReturnInventoryCartonItem originalLine = new ReturnInventoryCartonItem();
originalLine.setId(901L); originalLine.setSkuCode("SKU-1"); originalLine.setQuantity(100L);
when(relabelItems.selectList(any())).thenReturn(List.of(item));
when(returnCartonItems.lockCarton(801L)).thenReturn(List.of(originalLine));
when(returnCartonItems.updateById(any(ReturnInventoryCartonItem.class))).thenReturn(1);
when(returnCartons.releasePartial(801L, "100", 600L, 70L, 1)).thenReturn(1);
when(returnCartons.insert(any(ReturnInventoryCarton.class))).thenReturn(1);
when(returnCartonItems.insert(any(ReturnInventoryCartonItem.class))).thenReturn(1);
when(returnBalances.adjustCartonQuantity("100", "88", "SKU-1", 1)).thenReturn(1);
when(relabelItems.updateById(any(RelabelItem.class))).thenReturn(1);
ReflectionTestUtils.invokeMethod(service, "splitReturnRelabelCarton", app, item, original,
pallet(30L, "NEW-XLX6", 88L, 0), 30L);
assertEquals(70L, originalLine.getQuantity());
var moved = ArgumentCaptor.forClass(ReturnInventoryCarton.class);
verify(returnCartons).insert(moved.capture());
assertEquals(30L, moved.getValue().getTotalQuantity());
assertEquals("NEW-XLX6", moved.getValue().getPalletCode());
assertEquals("XLX6", moved.getValue().getDestinationWarehouseCode());
assertEquals(moved.getValue().getId(), item.getReturnCartonId());
verify(returnBalances).adjustCartonQuantity("100", "88", "SKU-1", 1);
}
@Test void resolveRejectsPalletOutsideTokenFrozenWarehouse() {
when(physical.resolve("P-1")).thenReturn(pallet(10L, "P-1", 99L, 1));
assertThrows(ResponseStatusException.class, () -> service.resolve(identity(),
@@ -86,6 +128,39 @@ class PdaPalletOperationServiceTest {
verifyNoInteractions(mapper);
}
@Test void mixedSkuSplitMovesOnlySelectedSkuAndPreservesOtherSkuOnSource() {
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100");
RelabelItem item = new RelabelItem(); item.setId(701L); item.setSkuCode("SKU-A");
item.setReturnCartonId(801L); item.setDestinationFbaWarehouseCode("SCK8");
ReturnInventoryCarton original = new ReturnInventoryCarton(); original.setId(801L);
original.setCustomerId("100"); original.setOverseasWarehouseId("88"); original.setTotalQuantity(2L);
original.setSourceReturnNo("RT-1"); original.setSourceBatchNo("RB-1,RB-2"); original.setPalletCode("OLD");
ReturnInventoryCartonItem a = new ReturnInventoryCartonItem(); a.setId(901L); a.setSkuCode("SKU-A"); a.setQuantity(1L);
ReturnInventoryCartonItem b = new ReturnInventoryCartonItem(); b.setId(902L); b.setSkuCode("SKU-B"); b.setQuantity(1L);
when(relabelItems.selectList(any())).thenReturn(List.of(item));
when(returnCartonItems.lockCarton(801L)).thenReturn(List.of(a, b));
when(returnCartonItems.deleteById(901L)).thenReturn(1);
when(returnCartons.releasePartial(801L, "100", 600L, 1L, 1)).thenReturn(1);
when(returnCartons.insert(any(ReturnInventoryCarton.class))).thenReturn(1);
when(returnCartonItems.insert(any(ReturnInventoryCartonItem.class))).thenReturn(1);
when(returnBalances.adjustCartonQuantity("100", "88", "SKU-A", 0)).thenReturn(1);
when(relabelItems.updateById(any(RelabelItem.class))).thenReturn(1);
ReflectionTestUtils.invokeMethod(service, "splitReturnRelabelCarton", app, item, original,
pallet(30L, "NEW", 88L, 0), 1L);
assertEquals(1L, b.getQuantity());
verify(returnCartonItems, never()).updateById(b);
var moved = ArgumentCaptor.forClass(ReturnInventoryCartonItem.class);
verify(returnCartonItems).insert(moved.capture());
assertEquals("SKU-A", moved.getValue().getSkuCode());
assertEquals(1L, moved.getValue().getQuantity());
var target = ArgumentCaptor.forClass(ReturnInventoryCarton.class);
verify(returnCartons).insert(target.capture());
assertEquals("RT-1", target.getValue().getSourceReturnNo());
assertEquals("RB-1,RB-2", target.getValue().getSourceBatchNo());
assertEquals("NEW", target.getValue().getPalletCode());
verify(returnBalances, never()).adjustCartonQuantity(eq("100"), eq("88"), eq("SKU-B"), anyLong());
}
@Test void resolveReportsMissingOnSitePalletAsNotFound() {
when(physical.resolve("MISSING")).thenReturn(null);
@@ -100,7 +175,7 @@ class PdaPalletOperationServiceTest {
RelabelApplication application = new RelabelApplication(); application.setRelabelNo("RL-1");
application.setStatus("READY_FOR_OUTBOUND"); application.setOverseasWarehouseId("88");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application);
when(physical.ensureRelabelPallet(88L, "RL-1:new-pallet-1")).thenReturn(
when(physical.ensureRelabelPallet(88L, "RL-1:new-pallet-1", null)).thenReturn(
new PalletPhysicalGateway.PalletReference(30L, "PAL-NEW-1", 88L, 1030L, "IDLE", null, 0));
var result = service.createRelabelPallet(identity(),
@@ -358,6 +433,27 @@ class PdaPalletOperationServiceTest {
assertEquals(java.util.Set.of(), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app));
}
@Test void relabelSourcesExposeAuthoritativePalletId() {
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100");
app.setRelabelNo("RL-1"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND");
app.setFulfillmentStatus("WAIT_PRE_DISPATCH");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
RelabelItem item = relabelItem(701L, "CARTON-A", 100L);
when(relabelItems.selectList(any())).thenReturn(List.of(item));
when(positions.selectById(100L)).thenReturn(position(100L, 20L, "DISPLAY-CODE", 1L, 3L));
PalletInventoryRow source = row(100L, 20L, 3L, 0L); source.setPalletNo("CANONICAL-CODE");
source.setHoldPackages(1L);
when(mapper.relabelSources("100", "88", 200L)).thenReturn(List.of(source));
var result = service.relabelSources(identity(), new PalletOperationRequests.RelabelSources("RL-1"));
assertEquals(1, result.size());
assertEquals(20L, result.get(0).palletId());
assertEquals("CANONICAL-CODE", result.get(0).palletCode());
}
@Test void createsPalletBeforeCustomerConfirmationOnlyForClaimingOperator() {
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
@@ -366,7 +462,7 @@ class PdaPalletOperationServiceTest {
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setStatus("PROCESSING"); task.setOperatorKey("7");
when(tasks.find("100", 600L)).thenReturn(task);
when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm")).thenReturn(pallet(30L, "NEW", 88L, 0));
when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm", null)).thenReturn(pallet(30L, "NEW", 88L, 0));
assertEquals("NEW", service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "before-confirm")).palletCode());
task.setOperatorKey("other");
assertThrows(ResponseStatusException.class, () -> service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "different")));
@@ -117,4 +117,20 @@ class RelabelContractTest {
assertTrue(service.contains("requiresOutbound && (!StringUtils.hasText(deliveryMethod)"
+ " || !DELIVERY_METHODS.contains(deliveryMethod))"));
}
@Test
void returnRelabelReviewStatusIsProjectedWithoutLosingPendingOrRejectedState() throws Exception {
String service = Files.readString(Path.of("src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java"),
StandardCharsets.UTF_8);
assertTrue(service.contains("Set.of(PENDING_REVIEW, PENDING_PROCESSING, \"PROCESSING\", \"REJECTED\", \"CANCELLED\")"));
assertTrue(service.contains("return workflowStatus;"));
}
@Test
void returnCartonConstraintsAllowRelabelReservationWithPositiveInventory() throws Exception {
String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8)
.replaceAll("\\s+", "").toUpperCase();
assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED')ANDSKU_TYPES>0ANDTOTAL_QUANTITY>0"));
assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')"));
}
}
@@ -264,6 +264,73 @@ class PdaRelabelServiceTest {
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
}
@Test
void returnSkuTotalCompletesOneSkuOnceAndAllocatesAcrossSourcePalletItems() {
application.setStatus("PROCESSING"); application.setNotifiedPackageCount(100);
item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("sku-fba100");
item.setNotifiedPackageCount(50); item.setCartonMark("PALLET-A");
RelabelItem second = item(); second.setId(11L); second.setOperationType("RETURN_BULK_SKU_LABEL");
second.setSkuCode("sku-fba100"); second.setNotifiedPackageCount(50); second.setCartonMark("PALLET-B");
PdaRelabelTask task = task("PROCESSING", 0); task.setExpectedPackages(100);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectList(any())).thenReturn(List.of(item, second));
var result = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.BulkSkuTotal("sku-fba100", 100, 0, "sku-total-1"));
assertEquals(100, result.relabelledPackages());
assertEquals(2, persistedFacts.size());
assertEquals(List.of(50, 50), persistedFacts.stream().map(PdaRelabelUnitFact::getQuantity).toList());
assertEquals(List.of(10L, 11L), persistedFacts.stream().map(PdaRelabelUnitFact::getItemId).toList());
assertTrue(persistedFacts.stream().allMatch(fact -> fact.getOldLabel() == null));
assertEquals(1, result.version());
}
@Test
void multiSkuPartialReturnRelabelRequiresBothSkuTotalsBeforeCompletion() {
verifyMultiSkuReturnCompletion(30, 15);
}
@Test
void multiSkuFullReturnRelabelRequiresBothSkuTotalsBeforeCompletion() {
verifyMultiSkuReturnCompletion(50, 40);
}
private void verifyMultiSkuReturnCompletion(int firstQuantity, int secondQuantity) {
application.setStatus("PROCESSING");
application.setNotifiedPackageCount(firstQuantity + secondQuantity);
item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("SKU-A");
item.setNotifiedPackageCount(firstQuantity); item.setCartonMark("RC-A");
RelabelItem second = item(); second.setId(11L);
second.setOperationType("RETURN_BULK_SKU_LABEL"); second.setSkuCode("SKU-B");
second.setNotifiedPackageCount(secondQuantity); second.setCartonMark("RC-B");
PdaRelabelTask task = task("PROCESSING", 0);
task.setExpectedPackages(firstQuantity + secondQuantity);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectList(any())).thenReturn(List.of(item, second));
var first = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.BulkSkuTotal("SKU-A", firstQuantity, 0, "multi-a"));
assertEquals(firstQuantity, first.relabelledPackages());
assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Complete(List.of(501L), 1, "multi-early", null)));
assertThrows(ResponseStatusException.class, () -> service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.BulkSkuTotal("SKU-A", 1, 1, "multi-over")));
var both = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.BulkSkuTotal("SKU-B", secondQuantity, 1, "multi-b"));
assertEquals(firstQuantity + secondQuantity, both.relabelledPackages());
assertEquals(List.of("SKU-A", "SKU-B"), persistedFacts.stream()
.map(fact -> fact.getItemId().equals(item.getId()) ? "SKU-A" : "SKU-B").toList());
assertEquals(List.of(firstQuantity, secondQuantity), persistedFacts.stream()
.map(PdaRelabelUnitFact::getQuantity).toList());
var completed = service.complete("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Complete(List.of(501L), 2, "multi-complete", "multi SKU complete"));
assertEquals(PdaRelabelService.WAIT_REVIEW, completed.taskStatus());
}
@Test
void completeRequiresEveryUnitAndMovesOnlyPdaTaskToWaitReview() {
application.setStatus("PROCESSING");
@@ -30,6 +30,8 @@ import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -75,6 +77,7 @@ class RelabelApplicationServiceTest {
@Mock OutboundPdaTaskMapper outboundTasks;
@Mock WarehouseTransferOrderMapper warehouseTransfers;
@Mock OverseasFeeSourceService feeSources;
@Mock ReturnOrderMapper returnOrders;
@Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
@@ -103,6 +106,7 @@ class RelabelApplicationServiceTest {
ReflectionTestUtils.setField(service, "outboundItems", outboundItems);
ReflectionTestUtils.setField(service, "outboundTasks", outboundTasks);
ReflectionTestUtils.setField(service, "warehouseTransfers", warehouseTransfers);
ReflectionTestUtils.setField(service, "returnOrders", returnOrders);
ReflectionTestUtils.setField(service, "objectMapper", mapper());
lenient().when(walletCommands.publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class))).thenReturn("wallet-event-1");
lenient().when(inventoryPositions.findTenantPosition(any(), any())).thenAnswer(invocation -> {
@@ -411,6 +415,10 @@ class RelabelApplicationServiceTest {
when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 1, 1)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order);
ReturnOrder returnOrder = new ReturnOrder();
returnOrder.setId(41L); returnOrder.setCustomerId("10001");
returnOrder.setRelabelApplicationId(application.getId()); returnOrder.setRelabelStatus("PROCESSING");
when(returnOrders.selectOne(any())).thenReturn(returnOrder);
service.changeStatus("10001", "FBR-REVIEW", "operator-2",
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted"));
@@ -419,6 +427,8 @@ class RelabelApplicationServiceTest {
assertEquals("WAIT_CUSTOMER_CONFIRMATION", application.getFulfillmentStatus());
assertEquals("COMPLETED", order.getRelabelStatus());
assertEquals("RELABEL:CUSTOMER_CONFIRM_PENDING", order.getSubOrderStatus());
assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, returnOrder.getRelabelStatus());
verify(returnOrders).updateById(returnOrder);
}
@Test
@@ -558,6 +568,7 @@ class RelabelApplicationServiceTest {
new RelabelRequests.CustomerConfirm("confirm-transfer", "accepted"));
assertEquals("READY_FOR_DISPATCH", transfer.getStatus());
assertEquals("WAIT_PRE_DISPATCH", application.getFulfillmentStatus());
assertNotNull(transfer.getRelabelCompletedAt());
assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus());
assertEquals("COMPLETED", transferOrder.getRelabelStatus());
@@ -610,6 +621,7 @@ class RelabelApplicationServiceTest {
assertEquals("ONT8", transfer.getFbaWarehouseCode());
assertEquals("READY_FOR_DISPATCH", transfer.getStatus());
assertEquals("WAIT_PRE_DISPATCH", application.getFulfillmentStatus());
assertEquals("ONT8", transferOrder.getFbaWarehouseCode());
assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus());
}
@@ -45,6 +45,7 @@ class RelabelReturnCartonCandidateTest {
assertEquals("RETURN_CARTON", result.get(0).sourceType());
assertEquals(9001L, result.get(0).returnCartonId());
assertEquals("RC-WH-0001", result.get(0).cartonMark());
assertEquals(12, result.get(0).availablePackages());
assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode());
assertEquals(12, result.get(0).skuItems().get(0).quantity());
}
@@ -50,6 +50,7 @@ class ReturnInventoryServiceTest {
@Mock LastlegOrderNoGenerator numbers;
@Mock ReturnOrderMapper orders;
@Mock ReturnReceiptMapper receipts;
@Mock com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway physicalPallets;
private ReturnInventoryService service;
@BeforeEach
@@ -64,6 +65,7 @@ class ReturnInventoryServiceTest {
ReflectionTestUtils.setField(service, "numbers", numbers);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "receiptMapper", receipts);
ReflectionTestUtils.setField(service, "physicalPallets", physicalPallets);
}
@Test
@@ -107,6 +109,7 @@ class ReturnInventoryServiceTest {
ReturnReceipt receipt = new ReturnReceipt();
receipt.setId(21L);
receipt.setReceiptBatchNo("RB-1");
receipt.setPalletCode("PAL-RETURN-1");
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-1");
service.createReceiptCarton(order, receipt, null,
@@ -141,6 +144,8 @@ class ReturnInventoryServiceTest {
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(skuA, skuB));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-MERGED");
when(physicalPallets.ensureRelabelPallet(org.mockito.ArgumentMatchers.eq(42L), anyString()))
.thenReturn(new com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway.PalletReference(91L, "PLT-MERGED", 42L, null, "EMPTY", null, 0));
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
service.merge(new PdaWarehouseIdentity("operator-1", "操作员", 42L),
@@ -157,6 +162,10 @@ class ReturnInventoryServiceTest {
verify(cartons).insert(target.capture());
assertThat(target.getValue().getSourceBatchNo()).isEqualTo("RB-1");
assertThat(target.getValue().getSourceReturnNo()).isEqualTo("RT-1");
assertThat(target.getValue().getPalletCode()).isEqualTo("PLT-MERGED");
ArgumentCaptor<String> businessKey = ArgumentCaptor.forClass(String.class);
verify(physicalPallets).ensureRelabelPallet(org.mockito.ArgumentMatchers.eq(42L), businessKey.capture());
assertThat(businessKey.getValue()).matches("RETURN-MERGE:[0-9a-f]{64}");
}
@Test
@@ -189,6 +198,8 @@ class ReturnInventoryServiceTest {
ReturnInventoryCarton locked = prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(physicalPallets.ensureRelabelPallet(org.mockito.ArgumentMatchers.eq(42L), anyString()))
.thenReturn(new com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway.PalletReference(92L, "PLT-TARGET", 42L, null, "EMPTY", null, 0));
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(2));
assertThat(locked.getTotalQuantity()).isZero();
@@ -223,7 +234,7 @@ class ReturnInventoryServiceTest {
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
verify(cartonItems, never()).insert(any(ReturnInventoryCartonItem.class));
verify(operations, never()).updateById(any(ReturnInventoryOperation.class));
verifyNoInteractions(orders, receipts, balances, numbers);
verifyNoInteractions(orders, receipts, balances, numbers, physicalPallets);
}
@Test
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrderItem;
@@ -14,6 +15,15 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptBatchMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.service.FbaWarehouseValidator;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
@@ -53,6 +63,12 @@ class ReturnReceiptServiceTest {
@Mock ReturnInventoryService inventory;
@Mock LastlegOrderNoGenerator numbers;
@Mock LastlegOrderMapper lastlegOrders;
@Mock ReturnInventoryCartonMapper cartons;
@Mock ReturnInventoryCartonItemMapper cartonItems;
@Mock RelabelApplicationMapper relabelApplications;
@Mock RelabelItemMapper relabelItems;
@Mock FbaWarehouseValidator fbaWarehouseValidator;
@Mock RelabelPhotoFileGateway photoFiles;
ReturnReceiptService service;
PdaWarehouseIdentity identity;
@@ -69,9 +85,100 @@ class ReturnReceiptServiceTest {
ReflectionTestUtils.setField(service, "numbers", numbers);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders);
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "cartonItems", cartonItems);
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouseValidator);
identity = new PdaWarehouseIdentity("operator-1", "仓管员", 10L);
}
@Test
void merchantPhotoDownloadUsesAuthenticatedCustomerAndResolvedReturnOrder() {
ReflectionTestUtils.setField(service, "photoFiles", photoFiles);
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.COMPLETED, 2, 2);
var downloaded = new RelabelPhotoFileGateway.DownloadedFile(new byte[] { 1, 2 }, "proof.jpg", "image/jpeg");
when(orders.findNo("customer-1", "RT-1")).thenReturn(order);
when(photoFiles.download("customer-1", 1L, 99L)).thenReturn(downloaded);
assertEquals(downloaded, service.downloadPhoto("customer-1", "RT-1", 99L));
verify(photoFiles).download("customer-1", 1L, 99L);
}
@Test
void merchantPhotoDownloadRejectsUnknownCustomerOrderBeforeFileAccess() {
ReflectionTestUtils.setField(service, "photoFiles", photoFiles);
when(orders.findNo("other-customer", "RT-1")).thenReturn(null);
ResponseStatusException error = assertThrows(ResponseStatusException.class,
() -> service.downloadPhoto("other-customer", "RT-1", 99L));
assertEquals(404, error.getStatusCode().value());
verifyNoInteractions(photoFiles);
}
@Test
void multiSkuPartialRelabelLocksOnlyRequestedQuantities() {
verifyMultiSkuRelabel(30, 15);
}
@Test
void multiSkuFullRelabelLocksAllReceivedQuantities() {
verifyMultiSkuRelabel(50, 40);
}
private void verifyMultiSkuRelabel(long firstQuantity, long secondQuantity) {
ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 90, 90);
order.setLastlegOrderId(99L);
LastlegOrder tail = new LastlegOrder(); tail.setId(99L);
ReturnInventoryCarton first = returnCarton(101L, "RC-A", "P-1", 50);
ReturnInventoryCarton second = returnCarton(102L, "RC-B", "P-2", 40);
when(orders.findNo("customer-1", "RT-MULTI")).thenReturn(order);
when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 50, 50), item(12L, "SKU-B", 40, 40)));
when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn(
new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA"));
when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-MULTI");
when(lastlegOrders.selectById(99L)).thenReturn(tail);
when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second));
when(cartons.lockId(101L)).thenReturn(first);
when(cartons.lockId(102L)).thenReturn(second);
when(cartonItems.listCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 50)));
when(cartonItems.listCarton(102L)).thenReturn(List.of(returnCartonItem("SKU-B", 40)));
var result = service.createRelabel("customer-1", "merchant-1", "RT-MULTI",
new ReturnOrderRequests.RelabelCreate(List.of(
new ReturnOrderRequests.RelabelItem("SKU-A", firstQuantity),
new ReturnOrderRequests.RelabelItem("SKU-B", secondQuantity)),
"SKU_TOTAL", "200", "XLX6", "multi-key", "multi SKU acceptance"));
assertEquals("RL-MULTI", result.relabelNo());
var application = ArgumentCaptor.forClass(RelabelApplication.class);
verify(relabelApplications).insert(application.capture());
assertEquals(firstQuantity + secondQuantity, application.getValue().getNotifiedPackageCount().longValue());
assertEquals(2, application.getValue().getItemCount());
var lines = ArgumentCaptor.forClass(RelabelItem.class);
verify(relabelItems, times(2)).insert(lines.capture());
assertEquals(List.of("SKU-A", "SKU-B"), lines.getAllValues().stream().map(RelabelItem::getSkuCode).toList());
assertEquals(List.of((int) firstQuantity, (int) secondQuantity),
lines.getAllValues().stream().map(RelabelItem::getNotifiedPackageCount).toList());
assertTrue(lines.getAllValues().stream().allMatch(line -> "RETURN_BULK_SKU_LABEL".equals(line.getOperationType())));
assertEquals("RELABEL_PENDING", first.getStatus());
assertEquals("RELABEL_PENDING", second.getStatus());
}
private static ReturnInventoryCarton returnCarton(long id, String mark, String pallet, long quantity) {
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(id); carton.setCustomerId("customer-1"); carton.setOverseasWarehouseId("10");
carton.setSourceReturnNo("RT-MULTI"); carton.setCartonMark(mark); carton.setPalletCode(pallet);
carton.setTotalQuantity(quantity); carton.setStatus("AVAILABLE");
return carton;
}
private static ReturnInventoryCartonItem returnCartonItem(String sku, long quantity) {
ReturnInventoryCartonItem line = new ReturnInventoryCartonItem();
line.setSkuCode(sku); line.setQuantity(quantity); return line;
}
@Test
void startReturnsOnlyRemainingSkuLinesForTheBoundWarehouse() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 8, 3);
@@ -110,6 +217,26 @@ class ReturnReceiptServiceTest {
assertEquals("RETU-1", result.order().lastlegOrderNo());
}
@Test
void rejectingForecastMarksReturnAndLinkedTailAsRejected() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_REVIEW, 2, 0);
order.setLastlegOrderId(99L);
LastlegOrder tail = new LastlegOrder();
tail.setId(99L);
when(orders.findNo("customer-1", "RT-1")).thenReturn(order);
when(lastlegOrders.selectById(99L)).thenReturn(tail);
service.review("customer-1", "RT-1", new ReturnOrderRequests.Review("REJECT", "资料不齐"));
assertEquals("REJECTED", order.getStatus());
assertEquals("资料不齐", order.getRemark());
assertEquals("REJECTED", tail.getCurrentStatus());
assertEquals("RETURN_RELABEL:REJECTED", tail.getSubOrderStatus());
assertEquals("REJECTED", tail.getRelabelStatus());
verify(orders).updateById(order);
verify(lastlegOrders).updateById(tail);
}
@Test
void skuLookupReturnsOnlyActiveCandidatesFromTheBoundWarehouse() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 5, 1);
@@ -162,7 +289,7 @@ class ReturnReceiptServiceTest {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1);
ReturnOrderItem line = item(11L, "SKU-A", 2, 1);
stubNewReceipt(order, List.of(line), "RC-AUTO");
var request = confirm("SKU", null, null, "key-final",
var request = confirm("SKU", null, "RC-AUTO", "key-final",
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
var first = service.confirm(identity, request);
@@ -175,7 +302,7 @@ class ReturnReceiptServiceTest {
assertTrue(first.fullyMatched());
assertEquals(ReturnReceiptService.COMPLETED, first.orderStatus());
assertEquals(first.receiptNo(), replay.receiptNo());
assertEquals("RC-AUTO", replay.cartonMark());
assertEquals("RC-AUTO", replay.palletCode());
verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any());
verify(batches, times(1)).insert(any(ReturnReceiptBatch.class));
verify(receipts, times(1)).insert(any(ReturnReceipt.class));
@@ -249,7 +376,7 @@ class ReturnReceiptServiceTest {
var request = confirm("RETURN_NO", null, null, "concurrent-key", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
ReturnOrder completed = order("RT-1", "10", ReturnReceiptService.COMPLETED, 1, 1);
ReturnReceipt committed = new ReturnReceipt();
committed.setReceiptNo("RR-COMMITTED"); committed.setReceiptBatchNo("RB-COMMITTED"); committed.setCartonMark("RC-COMMITTED");
committed.setReceiptNo("RR-COMMITTED"); committed.setReceiptBatchNo("RB-COMMITTED"); committed.setPalletCode("RC-COMMITTED");
committed.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(new ObjectMapper().writeValueAsBytes(request))));
when(orders.lockNo("RT-1")).thenReturn(completed);
@@ -301,8 +428,8 @@ class ReturnReceiptServiceTest {
return item;
}
private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String cartonMark,
private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String palletCode,
String key, List<PdaReturnReceiptRequests.Item> items) {
return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, cartonMark, items, key);
return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, palletCode, items, key);
}
}
@@ -46,6 +46,18 @@ class FbaWarehouseValidatorTest {
assertThat(result.region()).isEqualTo("CA");
}
@Test
void acceptsMatchingIdAndCodeWhenCallerDoesNotCarryWarehouseName() {
when(client.activeWarehouse(193000000000000001L, "internal-token"))
.thenReturn(ApiResponse.success(detail("FBA", "ACTIVE", "XLX1", "XLX1")));
var result = validator.required("193000000000000001", "XLX1", null);
assertThat(result.id()).isEqualTo(193000000000000001L);
assertThat(result.code()).isEqualTo("XLX1");
assertThat(result.name()).isEqualTo("XLX1");
}
@Test
void rejectsAnActualOverseasReceivingWarehouseAsFbaDestination() {
when(client.activeWarehouse(193000000000000001L, "internal-token"))
@@ -352,6 +352,42 @@ class WarehouseTransferServiceTest {
assertEquals("WAREHOUSE_PICKUP", transfer.getValue().getDeliveryMethod());
}
@Test
void bindingDestinationReleasesLinkedRelabelForPreDispatch() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_FBA_DESTINATION);
transfer.setNeedRelabel(true);
transfer.setRelabelNo("FBR-1");
transfer.setFbaWarehouseCode(null);
LastlegOrder order = tailOrder();
RelabelApplication relabel = new RelabelApplication();
relabel.setId(71L);
relabel.setCustomerId("TENANT-A");
relabel.setRelabelNo("FBR-1");
relabel.setFulfillmentStatus("WAIT_FBA_DESTINATION");
InboundPlan plan = new InboundPlan();
plan.setId(81L);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(transfer.getLastlegOrderId())).thenReturn(order);
when(orderDetails.selectList(any())).thenReturn(List.of());
when(inboundPlans.findBySourceOrder("TENANT-A", transfer.getLastlegOrderId())).thenReturn(plan);
when(inboundPlanLines.selectList(any())).thenReturn(List.of());
when(inboundPlanPallets.selectList(any())).thenReturn(List.of());
when(relabelApplications.lockNo("TENANT-A", "FBR-1")).thenReturn(relabel);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of());
service.bindDestination("OP-1", new WarehouseTransferRequests.BindDestination(
List.of(new WarehouseTransferRequests.DestinationSelection("TENANT-A", "WTR-1")),
"FBA", null, "ONT8", "ONT8", null, null, null, null, "BIND-1"));
assertEquals(WarehouseTransferService.READY_FOR_DISPATCH, transfer.getStatus());
assertEquals("WAIT_PRE_DISPATCH", relabel.getFulfillmentStatus());
verify(relabelApplications).updateById(relabel);
}
@Test
void newTransitInboundCreatesSnapshotAndImmutableCartonMarksWithoutHistoricalInventory() {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
@@ -645,6 +681,7 @@ class WarehouseTransferServiceTest {
ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class);
verify(inventory).stockIn(eq("TENANT-A"), eq("USER-1"), stockIn.capture());
assertEquals(5, stockIn.getValue().packages());
assertEquals("Customer A", stockIn.getValue().customerName());
assertEquals("SKU-TRANSFER", stockIn.getValue().skuCode());
assertEquals("WTR-1", stockIn.getValue().businessOrderNo());
assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey());
@@ -840,6 +877,7 @@ class WarehouseTransferServiceTest {
private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) {
InventoryBalance balance = new InventoryBalance();
balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId);
balance.setCustomerName("Customer A");
balance.setInboundCode(inboundCode); balance.setSkuCode("SKU-TRANSFER");
return balance;
}
+2 -1
View File
@@ -14,13 +14,14 @@
| 平台订单 API | `controller/PlatformFirstLegOrderController.java` |
| 平台批量审核预览 | `controller/PlatformFirstLegOrderController.java` 的 `/batch-review-preview`、`service/BatchOrderReviewPreviewService.java`、`dto/BatchOrderReviewPreviewResponse.java`;按目标客户和请求顺序一次返回订单、中文货物摘要、渠道、目的仓及逐项 `canApprove/blockReason`,实现固定两次批量查询,禁止前端逐票读取详情 |
| 统一订单查询、审核优先状态与稳定渠道编码 | `service/UnifiedOrderQueryService.java`、`dto/UnifiedOrderResponse.java`、`src/main/resources/openapi/order-v1.yaml`;`channelCode` 直接来自 Order/Firstleg 权威快照,只用于业务校验,不得由 `channelName` 推导。Order 为 `SUBMITTED` 时即使已有 Firstleg 入库投影,统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER` |
| Firstleg 智能装柜待审核候选 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`、`repository/OrderRepository.java`;内部入口 `/internal/order/v1/pending-review-orders` 只接受服务令牌,只分页返回未删除的 `SUBMITTED` 头程订单并固定投影为 `PENDING_REVIEW`,按 `Asia/Shanghai` 自然日过滤、`created_at DESC, id DESC` 稳定排序,不读取或修复 Firstleg 投影 |
| Firstleg 智能装柜待审核候选 | `controller/InternalPendingReviewOrderController.java`、`service/PendingReviewOrderQueryService.java`、`dto/PendingReviewOrderDtos.java`、`repository/OrderRepository.java`;内部入口 `/internal/order/v1/pending-review-orders` 只接受服务令牌,只分页返回未删除的 `SUBMITTED` 头程订单并固定投影为 `PENDING_REVIEW`,按 `Asia/Shanghai` 自然日过滤、`created_at DESC, id DESC` 稳定排序;`POST /batch` 按最多 100 个订单 ID 批量核验待审核状态,供同柜建组幂等准备 Firstleg `PREPARING` 投影 |
| 平台版本化修改客户原始申报 | `controller/PlatformUnifiedOrderController.java` 的 `PUT /api/platform/v1/orders/{orderId}/declaration`、`service/OrderDeclarationUpdateService.java`、`dto/OrderDeclarationDtos.java`;权限 `platform:order:declaration:edit`,货物以订单内 `itemNo` 定位并保持行集合完整,契约 `src/main/resources/openapi/order-v1.yaml` |
| 申报同步、渠道履约/计价门禁 | `service/OrderFulfillmentChannelChangeGuard.java`、`OrderPricingChannelGuard.java`、`OrderInboundOutboxPublisher.java`,内部客户端 `FirstlegInboundClient.java`、`FirstlegPricingClient.java`;审核后通过版本化 `ORDER_DECLARATION_UPDATED_V1` 同步 Firstleg |
| 入仓码业务关系摘要 | `service/InboundCodeAssociationService.java`、`dto/InboundCodeAssociationSummary.java`、平台/客户 `FirstLegOrderController` 的 `/inbound-code-associations/{inboundCode}`;客户入口必须按认证客户隔离 |
| 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` |
| 海外派送完成内部回写 | `controller/InternalOrderFulfillmentCompletionController.java`、`service/OrderFulfillmentCompletionService.java`;只接受 Firstleg 内部服务身份和已校验的来源订单/入仓码快照,幂等将对应订单推进为 `COMPLETED` 并写状态历史 |
| 商户订单列表、批量草稿与中文 XLSX 导入 | `service/MerchantInboundOrderListService.java`、`MerchantBatchDraftService.java`、`MerchantBatchDraftItemService.java`、`MerchantBatchImportService.java`;动态目录客户端 `client/MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`,客户地址由 `CustomerAddressService.java` 提供;接口见 `controller/MerchantFirstLegOrderController.java` 的 `/batch-imports/template`、`/batch-imports` |
| 平台旧系统订单导入 | `controller/PlatformFirstLegOrderController.java` 的 `/legacy-import`、`service/PlatformLegacyOrderImportService.java`;固定按名称精确解析启用客户“IT测试公司”,保留旧入仓码,按内部仓库目录和该客户当前价格簿逐行校验,成功行直接进入 `SUBMITTED` 待审核;一件代发缺 SKU 的行失败,普通 FBA 缺 SKU 时使用稳定 `LEGACY-{入仓码}` 测试占位码 |
| 入库号生成 | `config/InboundCodeModeProvider.java`、`mapper/InboundCodeSequenceMapper.java`、`repository/OrderRepository.java`、`service/OrderApplicationService.java` |
| 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` |
| 下单仓库主数据守卫 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 |
@@ -0,0 +1,20 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@FeignClient(name = "firstleg-service", contextId = "legacyImportPricingClient",
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/pricing")
public interface LegacyImportPricingClient {
@GetMapping("/channels")
ApiResponse<List<Channel>> channels(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam("tenantId") long tenantId,
@RequestParam("originWarehouseId") long originWarehouseId);
record Channel(String channelCode, String channelName) { }
}
@@ -0,0 +1,18 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "legacyImportWarehouseClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses")
public interface LegacyImportWarehouseClient {
@GetMapping("/order-catalog")
ApiResponse<List<Item>> catalog(@RequestHeader("X-Internal-Service-Token") String token);
record Item(Long id, String warehouseCode, String warehouseName, String warehouseType,
String countryCode) { }
}
@@ -22,6 +22,14 @@ public interface TenantEligibilityClient {
@RequestParam("size") long size,
@RequestParam("customerId") long customerId);
@GetMapping("/finance-customers")
ApiResponse<PageResult<CustomerSummary>> customerSummaryByKeyword(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestParam("page") long page,
@RequestParam("size") long size,
@RequestParam("keyword") String keyword,
@RequestParam("status") String status);
record EligibilityResponse(boolean eligible, String tenantStatus, String reason) { }
record CustomerSummary(Long id, String tenantCode, String tenantName, String status,
String contactName, String contactPhone) { }

Some files were not shown because too many files have changed in this diff Show More