Merge test into dev and resolve conflicts

This commit is contained in:
2026-09-14 11:02:37 +08:00
342 changed files with 8454 additions and 1591 deletions
@@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@@ -16,6 +17,11 @@ public interface IamOnboardingClient {
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody OnboardingRequest request);
@GetMapping("/merchant-registrations/{authUserId}")
ApiResponse<OnboardingResponse> registration(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@PathVariable("authUserId") Long authUserId);
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
ApiResponse<Void> compensate(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@@ -12,7 +12,7 @@ public record MerchantRegisterRequest(
@NotBlank @Size(min = 8, max = 72) String password,
@NotBlank @Size(max = 64) String displayName,
@NotBlank @Size(max = 128) String companyName,
@NotBlank @Size(max = 64) String legalPerson,
@Size(max = 64) String legalPerson,
@Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护")
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
@Email @Size(max = 128) String contactEmail,
@@ -35,11 +35,20 @@ public class MerchantRegistrationService {
@BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT",
resourceId = "#request.username", description = "商户注册", captureAfter = false)
public MerchantRegisterResponse register(MerchantRegisterRequest request) {
if (accountRepository.existsByUsername(request.username())) {
AuthAccount usernameAccount = accountRepository.findByUsername(request.username());
AuthAccount phoneAccount = accountRepository.findByPhone(request.phone());
if (usernameAccount != null && phoneAccount != null
&& usernameAccount.getId().equals(phoneAccount.getId())
&& "MERCHANT".equals(usernameAccount.getDefaultAppCode())
&& "ACTIVE".equals(usernameAccount.getAccountStatus())
&& passwordEncoder.matches(request.password(), usernameAccount.getPasswordHash())) {
return existingRegistration(usernameAccount);
}
if (usernameAccount != null) {
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
}
if (accountRepository.existsByPhone(request.phone())) {
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
if (phoneAccount != null) {
throw new BizException(ResultCode.CONFLICT, "该手机号已注册,请直接登录后继续完善企业资质");
}
long accountId = IdWorker.getId();
String passwordHash = passwordEncoder.encode(request.password());
@@ -111,6 +120,24 @@ public class MerchantRegistrationService {
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
}
private MerchantRegisterResponse existingRegistration(AuthAccount account) {
try {
var response = onboardingClient.registration(internalToken, account.getId());
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
}
var onboarding = response.data();
if (!java.util.Set.of("PROFILE_REQUIRED", "REJECTED").contains(onboarding.tenantStatus())) {
throw new BizException(ResultCode.CONFLICT, "该账号已注册,请直接登录");
}
return new MerchantRegisterResponse(account.getId(), onboarding.tenantId(), onboarding.membershipId(),
onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(),
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
}
}
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
return;
@@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
@SpringJUnitConfig(MerchantRegistrationService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
@@ -61,6 +62,49 @@ class MerchantRegistrationServiceTest {
assertEquals("PENDING", result.dimensionInitializationStatus());
}
@Test
void exactIncompleteRegistrationRetryReturnsTheExistingRegistration() {
AuthAccount account = new AuthAccount();
account.setId(9001L);
account.setUsername("registered_owner");
account.setPhone("13800138000");
account.setPasswordHash("encoded-password");
account.setAccountStatus("ACTIVE");
account.setDefaultAppCode("MERCHANT");
when(accounts.findByUsername("registered_owner")).thenReturn(account);
when(accounts.findByPhone("13800138000")).thenReturn(account);
when(encoder.matches("Password123", "encoded-password")).thenReturn(true);
when(onboarding.registration("internal-token", 9001L)).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "READY")));
var result = service.register(new MerchantRegisterRequest(
"registered_owner", "Password123", "注册人", "测试企业", null,
"13800138000", "owner@example.com", "CN", "深圳市宝安区"));
assertEquals(9001L, result.accountId());
assertEquals(10001L, result.tenantId());
assertEquals("READY", result.dimensionInitializationStatus());
verify(onboarding, never()).register(eq("internal-token"), any());
verify(accounts, never()).insert(any());
}
@Test
void phoneOwnedByAnotherUsernameDirectsTheUserBackToLogin() {
AuthAccount account = new AuthAccount();
account.setId(9001L);
account.setUsername("registered_owner");
account.setPhone("13800138000");
when(accounts.findByPhone("13800138000")).thenReturn(account);
var failure = assertThrows(com.baiyue.common.core.exception.BizException.class,
() -> service.register(new MerchantRegisterRequest(
"another_owner", "Password123", "注册人", "测试企业", null,
"13800138000", "owner@example.com", "CN", "深圳市宝安区")));
assertEquals("该手机号已注册,请直接登录后继续完善企业资质", failure.getMessage());
verify(onboarding, never()).register(eq("internal-token"), any());
}
@Test
void failedAuthInsertCompensatesTheIamRegistration() {
when(encoder.encode("Password123")).thenReturn("encoded-password");
@@ -141,8 +141,18 @@ public record FirstLegLoadingPlanCompletedV2(
}
}
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
/** 普通 FBA 只复用 palletNo;一件代发还携带同一物理 DP 的完整清单摘要。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages,
Integer manifestVersion, Integer manifestPackageCount,
Integer manifestFragmentCount, String manifestHash) {
public PalletSnapshot {
skuPackages = skuPackages == null ? List.of() : List.copyOf(skuPackages);
}
public PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {
this(palletNo, skuPackages, null, null, null, null);
}
}
/** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */
public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName,
+2
View File
@@ -65,6 +65,8 @@ services:
FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312}
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
FILE_SERVICE_URL: ${FILE_SERVICE_URL}
# Print 注册在国内 Nacos namespace;海外服务通过共享 infra-network 的 Docker DNS 直连。
PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207}
WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305}
warehouse-overseas-service:
-1
View File
@@ -123,7 +123,6 @@ services:
FINANCE_DB_URL: ${FINANCE_DB_URL}
FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME}
FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD}
FINANCE_WALLET_DEFAULT_CREDIT_LIMIT: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:-0.00}
OVERSEAS_EVENT_BRIDGE_URL: ${OVERSEAS_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}
order:
+272 -34
View File
@@ -329,7 +329,7 @@ USE baiyue_iam;
CREATE TABLE IF NOT EXISTS iam_tenant (
id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域',
tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一客户编码',
tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM',
tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称',
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户',
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用',
@@ -364,6 +364,15 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
KEY idx_iam_tenant_status (status, created_at)
) ENGINE=InnoDB COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键';
CREATE TABLE IF NOT EXISTS iam_number_sequence (
sequence_key VARCHAR(64) NOT NULL COMMENT '业务编号类型',
next_value BIGINT UNSIGNED NOT NULL COMMENT '下一待分配数字',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (sequence_key)
) ENGINE=InnoDB COMMENT='IAM业务编号序列表';
INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1);
CREATE TABLE IF NOT EXISTS iam_tenant_review (
id BIGINT NOT NULL COMMENT '审核记录ID',
tenant_id BIGINT NOT NULL COMMENT '被审核客户ID和数据隔离键',
@@ -402,6 +411,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant_qualification (
qualification_type VARCHAR(32) NOT NULL COMMENT '客户资质类型',
certificate_no VARCHAR(128) NULL COMMENT '证书编号',
document_url VARCHAR(500) NULL COMMENT '资质文件存储地址',
original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名',
valid_from DATE NULL COMMENT '有效期开始日期',
valid_to DATE NULL COMMENT '有效期结束日期',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
@@ -1424,6 +1434,7 @@ CALL phase1_iam_add_column('iam_tenant', 'approved_at', "DATETIME(3) NULL COMMEN
CALL phase1_iam_add_column('iam_tenant', 'approved_by', "BIGINT NULL COMMENT 'Reviewer ID'");
CALL phase1_iam_add_column('iam_tenant', 'reject_reason', "VARCHAR(500) NULL COMMENT 'Rejection reason'");
CALL phase1_iam_add_column('iam_tenant', 'legal_representative', "VARCHAR(64) NULL COMMENT '法定代表人' AFTER legal_name");
CALL phase1_iam_add_column('iam_tenant_qualification', 'original_file_name', "VARCHAR(255) NULL COMMENT '上传时原始文件名' AFTER document_url");
CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment', "VARCHAR(500) NULL COMMENT 'Review comment'");
CALL phase1_iam_add_column('iam_membership', 'created_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Creator ID'");
CALL phase1_iam_add_column('iam_membership', 'updated_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Updater ID'");
@@ -1784,6 +1795,7 @@ CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NU
CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Shipment ID'");
CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Reference ID'");
CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'Product image logical file ID'");
CALL phase1_add_column('ord_order_cargo', 'packing_group_no', "VARCHAR(32) NULL COMMENT '混箱组合号;同组货物行共享同一批物理箱'");
CALL phase1_add_column('ord_order_cargo', 'wooden_product', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '木制品权威判定快照;1时订单必须商检'");
UPDATE ord_order_cargo cargo
@@ -2897,6 +2909,23 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label_sku (
id BIGINT UNSIGNED NOT NULL COMMENT '箱唛SKU快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '物理箱唛ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
expected_quantity INT UNSIGNED NOT NULL COMMENT '该物理箱内应收SKU数量',
line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU展示顺序',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carton_label_sku_detail (carton_label_id,inbound_detail_id),
KEY idx_carton_label_sku_code (tenant_id,inbound_id,sku_code),
CONSTRAINT fk_carton_label_sku_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
) ENGINE=InnoDB COMMENT='物理箱唛内的多SKU冻结快照';
-- 存量环境可能缺少稳定箱码映射兼容字段;CREATE TABLE IF NOT EXISTS 不会补列。
DROP PROCEDURE IF EXISTS unified_carton_label_add_column;
@@ -3843,7 +3872,7 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
source_sha256 CHAR(64) NULL COMMENT '原始文件SHA-256',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、FAILED解析失败',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、VOIDED已作废、FAILED解析失败',
blocking_error_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阻断错误数',
warning_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '警告数',
parse_issues_json JSON NULL COMMENT '上传解析问题及待确认重新校验结果',
@@ -3854,6 +3883,9 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
published_by BIGINT UNSIGNED NULL COMMENT '确认并发布价格版本的操作人ID',
published_at DATETIME(3) NULL COMMENT '确认并生效时间',
superseded_at DATETIME(3) NULL COMMENT '被下一版本替换时间',
voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID',
voided_at DATETIME(3) NULL COMMENT '作废时间',
void_reason VARCHAR(500) NULL COMMENT '作废原因',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除',
@@ -3864,6 +3896,39 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
KEY idx_price_book_source_hash (tenant_id, source_sha256)
) ENGINE=InnoDB COMMENT='平台公共船期价格整表版本';
-- Warehouse 增量段已在自身结束时删除同名过程;Firstleg 价格版本的存量补列需在当前库重新定义。
DROP PROCEDURE IF EXISTS add_column_if_missing;
DELIMITER $$
CREATE PROCEDURE add_column_if_missing(
IN p_table_name VARCHAR(64),
IN p_column_name VARCHAR(64),
IN p_column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = p_table_name
AND column_name = p_column_name
) THEN
SET @add_column_sql = CONCAT(
'ALTER TABLE `', REPLACE(p_table_name, '`', '``'),
'` ADD COLUMN `', REPLACE(p_column_name, '`', '``'),
'` ', p_column_definition
);
PREPARE add_column_statement FROM @add_column_sql;
EXECUTE add_column_statement;
DEALLOCATE PREPARE add_column_statement;
END IF;
END$$
DELIMITER ;
CALL add_column_if_missing('fl_price_book_version', 'voided_by', 'BIGINT UNSIGNED NULL COMMENT ''作废操作人ID'' AFTER superseded_at');
CALL add_column_if_missing('fl_price_book_version', 'voided_at', 'DATETIME(3) NULL COMMENT ''作废时间'' AFTER voided_by');
CALL add_column_if_missing('fl_price_book_version', 'void_reason', 'VARCHAR(500) NULL COMMENT ''作废原因'' AFTER voided_at');
DROP PROCEDURE IF EXISTS add_column_if_missing;
CREATE TABLE IF NOT EXISTS fl_price_book_channel (
id BIGINT UNSIGNED NOT NULL COMMENT '版本渠道快照ID',
price_book_version_id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
@@ -5060,6 +5125,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID',
channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留',
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
@@ -5168,11 +5234,14 @@ BEGIN
ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL
COMMENT '已关联真实订舱客户名称快照' AFTER customer_id;
END IF;
UPDATE fl_container_booking container_booking
JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE()
AND table_name='fl_ocean_booking') THEN
UPDATE fl_container_booking container_booking
JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN
ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL
@@ -5484,6 +5553,7 @@ BEGIN
DECLARE source_exists INT DEFAULT 0;
DECLARE source_total_columns INT DEFAULT 0;
DECLARE source_smart_columns INT DEFAULT 0;
DECLARE source_current_columns INT DEFAULT 0;
DECLARE source_inbound_columns INT DEFAULT 0;
DECLARE target_exists INT DEFAULT 0;
DECLARE target_total_columns INT DEFAULT 0;
@@ -5528,7 +5598,45 @@ BEGIN
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')
);
IF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
SELECT COUNT(*) INTO source_current_columns
FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'container_booking_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'cabinet_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'NO')
OR (column_name = 'container_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
OR (column_name = 'task_status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'LOADING')
OR (column_name = 'started_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'started_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
OR (column_name = 'started_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES')
OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES')
OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
);
IF source_total_columns = 13 AND source_current_columns = 13
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'uk_loading_pick_task_booking' AND non_unique = 0
AND seq_in_index = 1 AND column_name = 'container_booking_id') = 1
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'idx_loading_pick_container'
AND ((seq_in_index = 1 AND column_name = 'container_no')
OR (seq_in_index = 2 AND column_name = 'task_status')
OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3
AND (SELECT COUNT(*) FROM information_schema.key_column_usage
WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan'
AND referenced_column_name = 'id') = 1 THEN
SET source_exists = 1;
ELSEIF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
@@ -5842,6 +5950,29 @@ DELIMITER ;
CALL repair_phase3_loading_pick_task_foreign_keys();
DROP PROCEDURE repair_phase3_loading_pick_task_foreign_keys;
-- Existing deployments may predate simulated-container channel grouping. The
-- update trigger below references this column, so upgrade it before compiling
-- the trigger rather than waiting for the later channel-container data block.
DROP PROCEDURE IF EXISTS phase3_add_container_channel_group;
DELIMITER $$
CREATE PROCEDURE phase3_add_container_channel_group()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_container_booking'
AND column_name = 'channel_group_code'
) THEN
ALTER TABLE fl_container_booking
ADD COLUMN channel_group_code VARCHAR(64) NULL
COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留'
AFTER merged_into_container_booking_id;
END IF;
END$$
DELIMITER ;
CALL phase3_add_container_channel_group();
DROP PROCEDURE phase3_add_container_channel_group;
DROP TRIGGER IF EXISTS trg_container_booking_before_update;
DELIMITER $$
CREATE TRIGGER trg_container_booking_before_update
@@ -5854,6 +5985,7 @@ BEGIN
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code)
OR NOT (OLD.channel_name <=> NEW.channel_name)
OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)
@@ -6449,14 +6581,13 @@ FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:list',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:list',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
'platform:firstleg:loading-list:view')
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list')
WHERE role.tenant_id = 0
AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
@@ -6581,13 +6712,13 @@ BEGIN
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
AND permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:list',
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:assign',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list',
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
'platform:firstleg:preload:edit',
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 14 THEN
'platform:firstleg:loading-list:list')) <> 11 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
@@ -6597,14 +6728,13 @@ BEGIN
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
AND permission.tenant_id = 0
AND permission.permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:list',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:list',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
'platform:firstleg:loading-list:view')) <> 28 THEN
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list')) <> 22 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
END IF;
END$$
@@ -6754,8 +6884,8 @@ INSERT INTO tmp_wh_fixed_platform_warehouse
VALUES
(193000000000200001,193000000000300001,'CN-YIWU-01','义乌仓','DOMESTIC','CN','UTC+8','浙江省','义乌市','322000','义乌仓通用地址待维护'),
(193000000000200002,193000000000300002,'CN-DONGGUAN-01','东莞仓','DOMESTIC','CN','UTC+8','广东省','东莞市','523000','东莞仓通用地址待维护'),
(193000000000200003,193000000000300003,'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8','CA','Los Angeles','90001','Los Angeles warehouse generic address pending maintenance'),
(193000000000200004,193000000000300004,'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5','NY','New York','10001','New York warehouse generic address pending maintenance');
(193000000000200003,193000000000300003,'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8','California','Rancho Cucamonga','91730','10767 7th Street'),
(193000000000200004,193000000000300004,'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5','New Jersey','Edison','08817','Door 13-14, 75 Carter Dr');
DROP PROCEDURE IF EXISTS assert_wh_fixed_platform_warehouse_seed;
DELIMITER //
@@ -6926,7 +7056,8 @@ ON DUPLICATE KEY UPDATE
capacity_cbm = VALUES(capacity_cbm),
weight_kg = VALUES(weight_kg),
color = VALUES(color),
remark = VALUES(remark);
remark = VALUES(remark),
deleted = 0;
DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed;
DELIMITER //
@@ -6948,6 +7079,14 @@ BEGIN
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内作业仓木托盘、塑料托盘初始化不完整';
END IF;
IF (SELECT COUNT(*)
FROM wh_pallet
WHERE tenant_id = 0
AND deleted = 0
AND ((warehouse_id = 193000000000200001 AND pallet_code IN ('TP-001','YW-TP-002'))
OR (warehouse_id = 193000000000200002 AND pallet_code IN ('TP-002','DG-TP-002')))) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内作业仓固定可复用托盘编码初始化不完整';
END IF;
END//
DELIMITER ;
CALL assert_domestic_pallet_seed();
@@ -13771,6 +13910,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operatio
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'status', '处理状态:PROCESSING处理中、SUCCESS成功');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'id', '标准船节点事实ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'query_id', '所属查询任务ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'container_booking_id', '所属真实货柜ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'event_key', '供应商编码与供应商事件ID组成的幂等键');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_code', '轨迹供应商编码');
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_event_id', '供应商稳定事件ID');
@@ -14886,8 +15026,11 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'published
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'rate_count', '解析价格数');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'source_file_id', 'File服务原始文件ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'source_sha256', '原始文件SHA-256');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'status', '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、FAILED解析失败');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'status', '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、VOIDED已作废、FAILED解析失败');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'superseded_at', '被下一版本替换时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'voided_at', '作废时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'voided_by', '作废操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'void_reason', '作废原因');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'tenant_id', '平台公共价格作用域固定为0');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'updated_at', '最后更新时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'updated_by', '最后修改人ID');
@@ -15111,7 +15254,7 @@ CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'reject_reason', '客户
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'sales_owner_name', '销售负责人姓名');
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'service_package', '客户服务套餐');
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'status', '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用');
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_code', '全局唯一客户编码');
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_code', '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM');
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_name', '客户企业名称');
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_type', '主体类型:MERCHANT企业客户');
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'unified_social_credit_code', '统一社会信用代码');
@@ -15136,6 +15279,7 @@ CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'created_at'
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'deleted', '逻辑删除标记');
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'document_url', '资质文件存储地址');
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'id', '资质记录ID');
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'original_file_name', '上传时原始文件名');
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'qualification_type', '客户资质类型');
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'review_comment', '客户资质审核意见');
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'status', '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回');
@@ -15686,6 +15830,12 @@ BEGIN
'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_iam' AND table_name='iam_tenant_qualification'
AND column_name='original_file_name') <> 1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:IAM客户资质缺少原始文件名列';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_inbound_fee'
AND ((column_name='baseline_chargeable_quantity' AND is_nullable='NO'
@@ -15953,6 +16103,24 @@ BEGIN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户四箱规尺寸配置字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_customer_charge_rule'
AND column_name = 'initialization_key'
AND character_maximum_length = 128) <> 1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户默认计费规则初始化幂等字段未创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_customer_charge_rule'
AND index_name = 'idx_charge_rule_initialization'
AND non_unique = 1
AND ((seq_in_index = 1 AND column_name = 'tenant_id')
OR (seq_in_index = 2 AND column_name = 'initialization_key'))) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户默认计费规则初始化索引未完整创建';
END IF;
IF EXISTS (
SELECT tenant.id
FROM baiyue_iam.iam_tenant tenant
@@ -16397,6 +16565,9 @@ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_code') THEN
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_group_code') THEN
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留' AFTER merged_into_container_booking_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_name') THEN
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code;
END IF;
@@ -16453,6 +16624,7 @@ BEGIN
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)
OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)
@@ -18161,6 +18333,23 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton (
CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id),
CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
) ENGINE=InnoDB COMMENT='一件代发托盘内逐箱SKU和入仓单明细';
CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton_sku (
id BIGINT UNSIGNED NOT NULL COMMENT '清点箱SKU明细ID',
carton_id BIGINT UNSIGNED NOT NULL COMMENT '清点物理箱ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
expected_quantity INT UNSIGNED NOT NULL COMMENT '应收数量',
actual_quantity INT UNSIGNED NOT NULL COMMENT '实收数量',
discrepancy_status VARCHAR(24) NOT NULL COMMENT 'MATCH或ACCEPTED_VARIANCE',
discrepancy_remark VARCHAR(500) NULL COMMENT '差异处理说明',
line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU顺序',
PRIMARY KEY (id),
UNIQUE KEY uk_dropship_carton_sku_detail (carton_id,inbound_detail_id),
KEY idx_dropship_carton_sku_code (sku_code,carton_id),
CONSTRAINT fk_dropship_carton_sku_carton FOREIGN KEY (carton_id) REFERENCES fl_dropship_pallet_carton(id)
) ENGINE=InnoDB COMMENT='一件代发物理箱内逐SKU清点事实';
-- 已存在开发库直接收敛到逐箱清点与整托分柜结构;新建库由上面的最新定义一次完成。
DROP PROCEDURE IF EXISTS converge_dropship_palletization_schema;
@@ -18404,12 +18593,13 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY',
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,包含冻结资金',
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,授信消费可形成负余额',
frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额,包含实际资金与授信冻结',
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '总可用余额=实际余额+授信额度-已用授信-冻结总额',
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '当前总可用余额;授信失效后不再计入',
credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度',
credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信',
credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信有效期内累计已使用额度;到期后并入实际余额',
credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信',
credit_expires_on DATE NULL COMMENT '授信到期日,当天仍有效;次日失效',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本',
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后操作人ID',
@@ -18419,7 +18609,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
UNIQUE KEY uk_fin_wallet_account_customer (customer_id, currency_code),
CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance)
CONSTRAINT chk_fin_wallet_account_balance CHECK (frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen)
) ENGINE=InnoDB COMMENT='客户CNY资金账户主账';
CREATE TABLE IF NOT EXISTS fin_recharge_request (
@@ -18461,6 +18651,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
credit_limit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后授信额度',
credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信',
available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信',
credit_expires_on_after DATE NULL COMMENT '变动后授信到期日',
source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等',
source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号',
occurred_date DATE NOT NULL COMMENT '业务发生日期',
@@ -18475,8 +18666,8 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
KEY idx_fin_wallet_ledger_customer_time (customer_id, created_at, id),
KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no),
CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0),
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after)
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0 OR change_type='CREDIT_LIMIT'),
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (frozen_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0)
) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水';
CREATE TABLE IF NOT EXISTS fin_wallet_hold (
@@ -18506,19 +18697,42 @@ DROP PROCEDURE IF EXISTS migrate_fin_wallet_credit;
DELIMITER $$
CREATE PROCEDURE migrate_fin_wallet_credit()
BEGIN
DECLARE v_legacy_wallet_constraint BOOLEAN DEFAULT FALSE;
DECLARE v_credit_expiry_missing BOOLEAN DEFAULT FALSE;
SET v_legacy_wallet_constraint = EXISTS (
SELECT 1 FROM information_schema.CHECK_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA='baiyue_finance'
AND CONSTRAINT_NAME='chk_fin_wallet_account_balance'
AND CHECK_CLAUSE LIKE '%actual_balance%'
);
SET v_credit_expiry_missing = NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='baiyue_finance'
AND TABLE_NAME='fin_wallet_account'
AND COLUMN_NAME='credit_expires_on'
);
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_account' AND COLUMN_NAME='credit_limit') THEN
ALTER TABLE fin_wallet_account
ADD COLUMN credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度' AFTER available_balance,
ADD COLUMN credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信' AFTER credit_limit,
ADD COLUMN credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信' AFTER credit_used;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_account' AND COLUMN_NAME='credit_expires_on') THEN
ALTER TABLE fin_wallet_account ADD COLUMN credit_expires_on DATE NULL COMMENT '授信到期日,当天仍有效;次日失效' AFTER credit_frozen;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_account' AND CONSTRAINT_NAME='chk_fin_wallet_account_balance' AND CONSTRAINT_TYPE='CHECK') THEN
ALTER TABLE fin_wallet_account DROP CHECK chk_fin_wallet_account_balance;
END IF;
UPDATE fin_wallet_account
SET available_balance=actual_balance+credit_limit-credit_used-frozen_balance;
IF v_legacy_wallet_constraint OR v_credit_expiry_missing THEN
UPDATE fin_wallet_account
SET actual_balance=actual_balance-credit_used,
credit_used=0,
credit_expires_on=NULL;
UPDATE fin_wallet_account
SET available_balance=actual_balance-credit_used-frozen_balance;
END IF;
ALTER TABLE fin_wallet_account ADD CONSTRAINT chk_fin_wallet_account_balance
CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance);
CHECK (frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen);
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND COLUMN_NAME='credit_limit_after') THEN
ALTER TABLE fin_wallet_ledger
@@ -18526,11 +18740,19 @@ BEGIN
ADD COLUMN credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信' AFTER credit_limit_after,
ADD COLUMN available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信' AFTER credit_used_after;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND COLUMN_NAME='credit_expires_on_after') THEN
ALTER TABLE fin_wallet_ledger ADD COLUMN credit_expires_on_after DATE NULL COMMENT '变动后授信到期日' AFTER available_credit_after;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND CONSTRAINT_NAME='chk_fin_wallet_ledger_balance' AND CONSTRAINT_TYPE='CHECK') THEN
ALTER TABLE fin_wallet_ledger DROP CHECK chk_fin_wallet_ledger_balance;
END IF;
ALTER TABLE fin_wallet_ledger ADD CONSTRAINT chk_fin_wallet_ledger_balance
CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after);
CHECK (frozen_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0);
IF EXISTS (SELECT 1 FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND CONSTRAINT_NAME='chk_fin_wallet_ledger_amount' AND CONSTRAINT_TYPE='CHECK') THEN
ALTER TABLE fin_wallet_ledger DROP CHECK chk_fin_wallet_ledger_amount;
END IF;
ALTER TABLE fin_wallet_ledger ADD CONSTRAINT chk_fin_wallet_ledger_amount
CHECK (amount <> 0 OR change_type='CREDIT_LIMIT');
END$$
DELIMITER ;
CALL migrate_fin_wallet_credit();
@@ -19238,6 +19460,22 @@ PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
ALTER TABLE fl_carrier_schedule_query
ADD CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id >= 0);
-- 退货预报关联尾程订单业务编号(测试阶段直接收敛当前结构)
DROP PROCEDURE IF EXISTS baiyue_lastleg.converge_return_order_tail_reference;
DELIMITER $$
CREATE PROCEDURE baiyue_lastleg.converge_return_order_tail_reference()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order' AND column_name='lastleg_order_id') THEN
ALTER TABLE baiyue_lastleg.ll_return_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT '尾程订单ID' AFTER customer_reference_no;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order' AND column_name='lastleg_order_no') THEN
ALTER TABLE baiyue_lastleg.ll_return_order ADD COLUMN lastleg_order_no VARCHAR(32) NULL COMMENT '尾程订单号' AFTER lastleg_order_id;
END IF;
END$$
DELIMITER ;
CALL baiyue_lastleg.converge_return_order_tail_reference();
DROP PROCEDURE IF EXISTS baiyue_lastleg.converge_return_order_tail_reference;
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status,
+265 -20
View File
@@ -753,8 +753,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米',
allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)',
sequence_no INT NOT NULL COMMENT '计划内托盘序号',
version INT NOT NULL DEFAULT 0 COMMENT '收货累计值乐观锁版本',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no),
KEY idx_ll_inbound_pallet_no (pallet_no),
UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no),
KEY idx_ll_inbound_pallet_plan (plan_id),
KEY idx_ll_inbound_pallet_customer_plan (customer_id, plan_id)
@@ -858,6 +859,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0 COMMENT ''实收箱数'''));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''收货累计值乐观锁版本'''));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划分配箱数'''));
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0 COMMENT ''清点箱数'''));
@@ -1089,7 +1092,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', overseas_warehouse_code VARCHAR(128) NOT NULL COMMENT '海外接柜仓业务编码快照', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED、COMPLETED_WITH_DIFFERENCE',
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间',
completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -1112,6 +1115,16 @@ CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id)
) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度';
CREATE TABLE IF NOT EXISTS ll_receiving_fba_pallet_count (
id BIGINT NOT NULL COMMENT 'FBA托盘现场累计箱数ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
group_key VARCHAR(255) NOT NULL COMMENT '柜号与FBA仓组成的收货组键', pallet_code VARCHAR(128) NOT NULL COMMENT '现有FBA托盘码',
actual_packages INT NOT NULL COMMENT '该托现场累计实物箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_fba_count(session_id,group_key,pallet_code),
KEY idx_ll_receiving_fba_group(session_id,group_key)
) ENGINE=InnoDB COMMENT='海外PDA普通FBA托盘现场累计与超收标记';
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
@@ -1387,6 +1400,9 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)',
source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段',
carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照',
source_business_type VARCHAR(32) NULL COMMENT '来源业务类型',
source_business_no VARCHAR(128) NULL COMMENT '来源业务单号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id),
@@ -1395,6 +1411,10 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
KEY idx_ll_dispatch_item_pallet (customer_id,plan_pallet_id,warehouse_pallet_id)
) ENGINE=InnoDB COMMENT='派车货物冻结明细';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_type')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
id BIGINT NOT NULL COMMENT '主键ID',
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -1523,6 +1543,23 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pda_task (
PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no),
UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)
) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务';
SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key'
), 'SELECT 1',
'ALTER TABLE ll_outbound_pda_task ADD COLUMN completion_idempotency_key VARCHAR(255) NULL COMMENT ''完成命令幂等键'' AFTER status'));
PREPARE outbound_pda_completion_column_stmt FROM @ddl; EXECUTE outbound_pda_completion_column_stmt; DEALLOCATE PREPARE outbound_pda_completion_column_stmt;
SET @index_ready = (SELECT COUNT(*)=2 AND MIN(non_unique)=0
AND SUM(seq_in_index=1 AND column_name='customer_id')=1
AND SUM(seq_in_index=2 AND column_name='completion_idempotency_key')=1
FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key');
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key');
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)',
'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)'));
PREPARE outbound_pda_completion_index_stmt FROM @ddl; EXECUTE outbound_pda_completion_index_stmt; DEALLOCATE PREPARE outbound_pda_completion_index_stmt;
CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID',
@@ -1550,9 +1587,9 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照',
lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号',
operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '作业仓库ID', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码',
delivery_address VARCHAR(1000) NOT NULL COMMENT '派送地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '派送方式',
estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计到达时间', fba_shipment_no VARCHAR(128) NOT NULL COMMENT 'FBA货件编号',
operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码',
delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式',
estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号',
tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注',
need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
@@ -1570,6 +1607,9 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
KEY idx_ll_wh_transfer_list (customer_id,status,created_at), KEY idx_ll_wh_transfer_inbound (customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='客户转仓单';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码'', MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT ''可选FBA货件编号'', MODIFY COLUMN delivery_address VARCHAR(1000) NOT NULL COMMENT ''提货地址或中转仓收货地址'', MODIFY COLUMN delivery_method VARCHAR(64) NOT NULL COMMENT ''入仓方式'', MODIFY COLUMN estimated_arrival_at DATETIME(3) NOT NULL COMMENT ''预计入仓时间''', 'SELECT 1'));
PREPARE transfer_optional_fba_stmt FROM @ddl; EXECUTE transfer_optional_fba_stmt; DEALLOCATE PREPARE transfer_optional_fba_stmt;
-- 兼容已存在的旧版转仓单表:CREATE TABLE IF NOT EXISTS 不会补充新增字段。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_idempotency_key VARCHAR(255) NULL COMMENT ''库存分配幂等键'' AFTER status'));
PREPARE transfer_allocation_key_stmt FROM @ddl; EXECUTE transfer_allocation_key_stmt; DEALLOCATE PREPARE transfer_allocation_key_stmt;
@@ -1599,6 +1639,18 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item (
KEY idx_ll_wh_transfer_item_list (customer_id,transfer_order_id), KEY idx_ll_wh_transfer_item_sku (customer_id,sku_code)
) ENGINE=InnoDB COMMENT='转仓货物冻结明细';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton (
id BIGINT NOT NULL COMMENT '预报箱主键', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
carton_mark VARCHAR(255) NOT NULL COMMENT '系统生成的一箱一码箱唛', carton_index INT NOT NULL COMMENT '全单箱序号',
total_cartons INT NOT NULL COMMENT '首次生成时全单总箱数', status VARCHAR(32) NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_ll_wh_transfer_carton_mark (customer_id,carton_mark),
UNIQUE KEY uk_ll_wh_transfer_carton_index (transfer_order_id,carton_index),
KEY idx_ll_wh_transfer_carton_list (customer_id,transfer_order_id,status)
) ENGINE=InnoDB COMMENT='仓储转运系统箱唛预报';
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation (
id BIGINT NOT NULL COMMENT '雪花主键',
transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID',
@@ -1656,6 +1708,15 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application (
label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间',
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名',
contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话',
delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填',
print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要打印面单',
shipping_label_file_id VARCHAR(128) NULL COMMENT '客户上传面单文件ID',
shipping_label_file_name VARCHAR(255) NULL COMMENT '客户上传面单文件名',
shipping_label_file_url VARCHAR(1000) NULL COMMENT '客户上传面单下载地址',
fulfillment_status VARCHAR(32) NOT NULL DEFAULT 'WAIT_RELABEL' COMMENT '换标后履约状态',
outbound_order_no VARCHAR(64) NULL COMMENT '自提或快递生成的换标出库单号',
customer_confirmation_key VARCHAR(255) NULL COMMENT '客户确认完成幂等键',
customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认换标完成时间',
remark VARCHAR(1000) NULL COMMENT '备注',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PROCESSING' COMMENT '业务状态',
item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量',
@@ -1681,6 +1742,19 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application (
KEY idx_ll_relabel_application_warehouse (customer_id,overseas_warehouse_id)
) ENGINE=InnoDB COMMENT='客户换标申请单';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_id VARCHAR(128) NULL COMMENT ''总换标PDF文件ID'' AFTER remark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_name VARCHAR(255) NULL COMMENT ''总换标PDF文件名'' AFTER total_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_url VARCHAR(1000) NULL COMMENT ''总换标PDF预览地址'' AFTER total_file_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填'' AFTER contact_phone')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单'' AFTER delivery_method')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID'' AFTER print_shipping_label')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT ''客户上传面单文件名'' AFTER shipping_label_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT ''客户上传面单下载地址'' AFTER shipping_label_file_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT ''WAIT_RELABEL'' COMMENT ''换标后履约状态'' AFTER shipping_label_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT ''自提或快递生成的换标出库单号'' AFTER fulfillment_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmation_key'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT ''客户确认完成幂等键'' AFTER fulfillment_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_relabel_item (
id BIGINT NOT NULL COMMENT '主键ID',
application_id BIGINT NOT NULL COMMENT '申请单ID',
@@ -1692,7 +1766,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称',
total_package_count INT NOT NULL COMMENT '总箱数',
notified_package_count INT NOT NULL COMMENT '已通知箱数',
shipment_no VARCHAR(128) NOT NULL COMMENT '货件编号',
shipment_no VARCHAR(128) NULL COMMENT '货件编号(无FBA去向时为空)',
tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码',
new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码',
label_requirement VARCHAR(1000) NULL COMMENT '换标要求',
@@ -1703,6 +1777,17 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛',
fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛',
destination_warehouse_id VARCHAR(128) NULL COMMENT '该箱目的海外仓ID',
destination_fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码(与海外操作仓分离)',
operation_type VARCHAR(32) NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标',
sku_relabel_items_json JSON NULL COMMENT '同箱SKU换标子明细快照(数量按件)',
source_inventory_balance_id BIGINT NULL COMMENT '原库存余额ID(预入仓换标任务可为空)',
source_inventory_position_id BIGINT NULL COMMENT '原库存库位ID(预入仓换标任务可为空)',
source_inbound_order_id BIGINT NULL COMMENT '原入仓单ID(预入仓换标任务可为空)',
source_inbound_order_no VARCHAR(128) NULL COMMENT '原入仓单号快照',
source_inbound_code VARCHAR(128) NULL COMMENT '原进仓编码快照',
source_warehouse_id VARCHAR(128) NOT NULL COMMENT '原海外中转仓ID',
source_warehouse_code VARCHAR(128) NULL COMMENT '原海外中转仓编码快照',
source_warehouse_name VARCHAR(255) NULL COMMENT '原海外中转仓名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_ll_relabel_item_application (customer_id,application_id),
@@ -1710,6 +1795,13 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)
) ENGINE=InnoDB COMMENT='换标货物冻结明细';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_balance_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID(预入仓换标任务可为空)''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_position_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID(预入仓换标任务可为空)''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID(预入仓换标任务可为空)''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name IN ('shipment_no','destination_fba_warehouse_code','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id') AND is_nullable='NO'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号(无FBA去向时为空)'', MODIFY COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)'', MODIFY COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID(预入仓换标任务可为空)''', 'SELECT 1'));
PREPARE relabel_pre_inbound_stmt FROM @ddl; EXECUTE relabel_pre_inbound_stmt; DEALLOCATE PREPARE relabel_pre_inbound_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -1817,11 +1909,14 @@ CREATE TABLE IF NOT EXISTS ll_dropship_order_profile (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID',
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号',
source_pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照',
manifest_version INT NOT NULL COMMENT '整托清单版本', manifest_package_count INT NOT NULL COMMENT '整托总箱数',
manifest_fragment_count INT NOT NULL COMMENT '整托客户分片总数', manifest_hash CHAR(64) NOT NULL COMMENT '整托完整清单摘要',
inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态',
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no),
UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id)
UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id),
KEY idx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash)
) ENGINE=InnoDB COMMENT='一件代发托盘档案';
CREATE TABLE IF NOT EXISTS ll_dropship_carton (
@@ -2355,6 +2450,112 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation (
CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0)
) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊';
-- 客户退货预报、PDA 签收与仓储费起算批次
CREATE TABLE IF NOT EXISTS ll_return_order (
-- need_relabel is added below for existing deployments
id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照', warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总';
SET @ll_return_order_need_relabel = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='need_relabel'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN need_relabel TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要换标'' AFTER customer_reference_no')); PREPARE stmt FROM @ll_return_order_need_relabel; EXECUTE stmt; DEALLOCATE PREPARE 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='warehouse_contact_name'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT ''中转仓联系人快照'' AFTER overseas_warehouse_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT ''中转仓联系电话快照'' AFTER warehouse_contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_application_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_application_id BIGINT NULL COMMENT ''绑定换标申请单ID'' AFTER need_relabel')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_no VARCHAR(64) NULL COMMENT ''绑定换标单号'' AFTER relabel_application_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_status'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_status VARCHAR(32) NULL COMMENT ''换标状态'' AFTER relabel_no')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_return_order_item (
id BIGINT NOT NULL COMMENT '退货预报明细ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', expected_quantity BIGINT NOT NULL COMMENT 'SKU预报数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT 'SKU累计实收数量', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code), KEY idx_ll_return_item_sku(sku_code,received_quantity), CONSTRAINT chk_ll_return_item_quantity CHECK(expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
) ENGINE=InnoDB COMMENT='客户退货订单的SKU预报与实收明细';
CREATE TABLE IF NOT EXISTS ll_return_receipt (
id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', receipt_no VARCHAR(128) NOT NULL COMMENT '签收单号', receipt_batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', tracking_no VARCHAR(128) NULL COMMENT '本次签收包裹的可选快递单号', bound_by VARCHAR(16) NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', status VARCHAR(32) NOT NULL COMMENT '签收状态,当前SIGNED', received_quantity BIGINT NOT NULL COMMENT '本次实收数量', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', operator_name VARCHAR(128) NULL COMMENT 'PDA操作人名称', idempotency_key VARCHAR(255) NOT NULL COMMENT 'PDA确认幂等键', request_hash CHAR(64) NOT NULL COMMENT 'PDA确认请求SHA-256', signed_at DATETIME(3) NOT NULL COMMENT '签收时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_receipt_no(receipt_no), UNIQUE KEY uk_ll_return_receipt_batch(receipt_batch_no), UNIQUE KEY uk_ll_return_receipt_operator_key(operator_id,idempotency_key), KEY idx_ll_return_receipt_order(return_order_id,signed_at), CONSTRAINT chk_ll_return_receipt_quantity CHECK(received_quantity>0), CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN('RETURN_NO','SKU'))
) ENGINE=InnoDB COMMENT='PDA退货实收签收事实';
-- 已创建开发库升级为“客户只预报 SKU,快递单号仅属于签收批次”的最终模型。
SET @ll_return_item_drop_tracking_order_sku = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX uk_ll_return_item_order_tracking_sku', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_tracking_sku');
PREPARE ll_return_item_drop_tracking_order_sku_stmt FROM @ll_return_item_drop_tracking_order_sku;
EXECUTE ll_return_item_drop_tracking_order_sku_stmt;
DEALLOCATE PREPARE ll_return_item_drop_tracking_order_sku_stmt;
SET @ll_return_item_drop_tracking = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_tracking', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_tracking');
PREPARE ll_return_item_drop_tracking_stmt FROM @ll_return_item_drop_tracking;
EXECUTE ll_return_item_drop_tracking_stmt;
DEALLOCATE PREPARE ll_return_item_drop_tracking_stmt;
SET @ll_return_item_drop_order = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_order', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_order');
PREPARE ll_return_item_drop_order_stmt FROM @ll_return_item_drop_order;
EXECUTE ll_return_item_drop_order_stmt;
DEALLOCATE PREPARE ll_return_item_drop_order_stmt;
SET @ll_return_item_drop_tracking_no = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP COLUMN tracking_no', 'SELECT 1') FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND column_name='tracking_no');
PREPARE ll_return_item_drop_tracking_no_stmt FROM @ll_return_item_drop_tracking_no;
EXECUTE ll_return_item_drop_tracking_no_stmt;
DEALLOCATE PREPARE ll_return_item_drop_tracking_no_stmt;
SET @ll_return_item_add_order_sku = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_sku');
PREPARE ll_return_item_add_order_sku_stmt FROM @ll_return_item_add_order_sku;
EXECUTE ll_return_item_add_order_sku_stmt;
DEALLOCATE PREPARE ll_return_item_add_order_sku_stmt;
SET @ll_return_item_add_sku_idx = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD KEY idx_ll_return_item_sku(sku_code,received_quantity)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_sku');
PREPARE ll_return_item_add_sku_idx_stmt FROM @ll_return_item_add_sku_idx;
EXECUTE ll_return_item_add_sku_idx_stmt;
DEALLOCATE PREPARE ll_return_item_add_sku_idx_stmt;
UPDATE ll_return_receipt SET bound_by='RETURN_NO' WHERE bound_by='TRACKING';
SET @ll_return_receipt_drop_bound_check = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_receipt DROP CHECK chk_ll_return_receipt_bound', 'SELECT 1') FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_receipt' AND constraint_name='chk_ll_return_receipt_bound');
PREPARE ll_return_receipt_drop_bound_check_stmt FROM @ll_return_receipt_drop_bound_check;
EXECUTE ll_return_receipt_drop_bound_check_stmt;
DEALLOCATE PREPARE ll_return_receipt_drop_bound_check_stmt;
SET @ll_return_receipt_alter = 'ALTER TABLE ll_return_receipt MODIFY COLUMN tracking_no VARCHAR(128) NULL COMMENT ''本次签收包裹的可选快递单号'', MODIFY COLUMN bound_by VARCHAR(16) NOT NULL COMMENT ''开始签收方式,RETURN_NO退货单号或SKU候选'', ADD CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN(''RETURN_NO'',''SKU''))';
PREPARE ll_return_receipt_alter_stmt FROM @ll_return_receipt_alter;
EXECUTE ll_return_receipt_alter_stmt;
DEALLOCATE PREPARE ll_return_receipt_alter_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)
) ENGINE=InnoDB COMMENT='PDA退货签收SKU实际数量明细';
CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
id BIGINT NOT NULL COMMENT '退货仓储计费批次ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', storage_billing_status VARCHAR(32) NOT NULL COMMENT '仓储计费状态,初始PENDING_PRICING', storage_started_at DATETIME(3) NOT NULL COMMENT '仓储计费起算时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_batch_no(batch_no), UNIQUE KEY uk_ll_return_batch_receipt(receipt_id), KEY idx_ll_return_batch_billing(customer_id,overseas_warehouse_id,storage_billing_status,storage_started_at)
) ENGINE=InnoDB COMMENT='每次退货签收生成的仓储费起算批次';
-- 退货库存:签收即生成箱号,库存按客户、海外仓和SKU汇总;箱内明细保留给合箱与箱唛。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
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'))
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
-- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 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));
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'));
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 '实际消耗来源退货单号,多个单号逗号分隔';
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='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='destination_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称'' AFTER destination_warehouse_code')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_application_id'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_application_id BIGINT NULL COMMENT ''绑定换标申请单ID'' AFTER destination_warehouse_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_no VARCHAR(64) NULL COMMENT ''绑定换标单号'' AFTER relabel_application_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_status VARCHAR(32) NULL COMMENT ''换标状态'' AFTER relabel_no')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''换标完成时间'' AFTER relabel_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0)
) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量';
CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code), CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK(total_quantity>=available_quantity AND available_quantity>=0 AND carton_quantity>=0)
) ENGINE=InnoDB COMMENT='退货库存按客户海外仓SKU汇总余额';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_balance' AND column_name='total_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_balance ADD COLUMN total_quantity BIGINT NULL COMMENT ''实物总库存,仅实际出库扣减'' AFTER product_name_zh'));
PREPARE return_inventory_balance_total_stmt FROM @ddl; EXECUTE return_inventory_balance_total_stmt; DEALLOCATE PREPARE return_inventory_balance_total_stmt;
-- 旧数据的可用量原先包含已合箱库存;升级时一次性拆分为总库存和未合箱可用库存。
UPDATE ll_return_inventory_balance b LEFT JOIN (SELECT c.customer_id,c.overseas_warehouse_id,i.sku_code,SUM(i.quantity) merged_quantity FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED' GROUP BY c.customer_id,c.overseas_warehouse_id,i.sku_code) m ON m.customer_id=b.customer_id AND m.overseas_warehouse_id=b.overseas_warehouse_id AND m.sku_code=b.sku_code SET b.total_quantity=b.available_quantity,b.available_quantity=GREATEST(0,b.available_quantity-COALESCE(m.merged_quantity,0)) WHERE b.total_quantity IS NULL;
ALTER TABLE ll_return_inventory_balance MODIFY COLUMN total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减';
CREATE TABLE IF NOT EXISTS ll_return_inventory_operation (
id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id)
) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计';
-- END INLINE EMPTY-DATABASE BASELINE: LASTLEG
-- BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE
@@ -2469,7 +2670,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_business_binding (
business_type VARCHAR(32) NOT NULL COMMENT '业务类型', business_id VARCHAR(128) NOT NULL COMMENT '绑定业务记录ID',
pallet_id BIGINT NOT NULL COMMENT '托盘ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_binding (customer_id,warehouse_id,business_type,business_id),
UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id)
KEY idx_wh_pallet_binding_pallet (pallet_id)
) ENGINE=InnoDB COMMENT='托盘业务绑定关系';
CREATE TABLE IF NOT EXISTS wh_pallet_operation (
@@ -2525,7 +2726,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
PRIMARY KEY (id),
UNIQUE KEY uk_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key),
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no),
UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id),
KEY idx_wh_pallet_batch_item_pallet (pallet_id),
KEY idx_wh_pallet_batch_item_batch (batch_id,status)
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联';
@@ -3093,7 +3294,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED、COMPLETED_WITH_DIFFERENCE',
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间',
receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
@@ -3142,7 +3343,7 @@ PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receivin
DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable;
UPDATE ll_receiving_container_session
SET active_operator_key=NULL,active_container_key=NULL
WHERE status='COMPLETED' AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL);
WHERE status IN ('COMPLETED','COMPLETED_WITH_DIFFERENCE') AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL);
DELIMITER $$
CREATE PROCEDURE assert_receiving_global_locks_migratable()
BEGIN
@@ -3192,6 +3393,16 @@ CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id)
) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度';
CREATE TABLE IF NOT EXISTS ll_receiving_fba_pallet_count (
id BIGINT NOT NULL COMMENT 'FBA托盘现场累计箱数ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
group_key VARCHAR(255) NOT NULL COMMENT '柜号与FBA仓组成的收货组键', pallet_code VARCHAR(128) NOT NULL COMMENT '现有FBA托盘码',
actual_packages INT NOT NULL COMMENT '该托现场累计实物箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_fba_count(session_id,group_key,pallet_code),
KEY idx_ll_receiving_fba_group(session_id,group_key)
) ENGINE=InnoDB COMMENT='海外PDA普通FBA托盘现场累计与超收标记';
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
@@ -3646,6 +3857,21 @@ PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='source_pallet_no'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN source_pallet_no VARCHAR(128) NULL COMMENT ''来源托盘号快照'' AFTER pallet_no'));
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='manifest_version'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_version INT NOT NULL DEFAULT 1 COMMENT ''整托清单版本'' AFTER source_pallet_no'));
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='manifest_package_count'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_package_count INT NOT NULL DEFAULT 1 COMMENT ''整托总箱数'' AFTER manifest_version'));
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='manifest_fragment_count'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_fragment_count INT NOT NULL DEFAULT 1 COMMENT ''整托客户分片总数'' AFTER manifest_package_count'));
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='manifest_hash'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_hash CHAR(64) NOT NULL DEFAULT ''0000000000000000000000000000000000000000000000000000000000000000'' COMMENT ''整托完整清单摘要'' AFTER manifest_fragment_count'));
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
ALTER TABLE ll_dropship_order_profile
MODIFY COLUMN manifest_version INT NOT NULL COMMENT '整托清单版本',
MODIFY COLUMN manifest_package_count INT NOT NULL COMMENT '整托总箱数',
MODIFY COLUMN manifest_fragment_count INT NOT NULL COMMENT '整托客户分片总数',
MODIFY COLUMN manifest_hash CHAR(64) NOT NULL COMMENT '整托完整清单摘要';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='idx_ll_dropship_profile_manifest'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD KEY idx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash)'));
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_order'), 'ALTER TABLE ll_dropship_order_profile DROP INDEX uk_ll_dropship_profile_order', 'SELECT 1'));
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_order_pallet'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)'));
@@ -3700,9 +3926,9 @@ BEGIN
'll_dispatch_order','ll_dispatch_item','ll_dispatch_pallet_lock','ll_dispatch_draft','ll_dispatch_draft_container',
'll_dispatch_pda_task','ll_dispatch_pda_pick','ll_dispatch_pda_operation','ll_dispatch_pda_pick_order',
'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick',
'll_delivery_order','ll_delivery_fee','ll_delivery_operation',
'll_delivery_order','ll_delivery_operation',
'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger',
'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 24 THEN
'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 23 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期排车、出库、派送、一件代发、异常或回访表未完整部署';
END IF;
IF EXISTS (SELECT dispatch_no FROM ll_dispatch_order GROUP BY dispatch_no HAVING COUNT(*)>1) THEN
@@ -3816,9 +4042,9 @@ BEGIN
'll_dispatch_order','ll_dispatch_item','ll_dispatch_pallet_lock','ll_dispatch_draft','ll_dispatch_draft_container',
'll_dispatch_pda_task','ll_dispatch_pda_pick','ll_dispatch_pda_operation','ll_dispatch_pda_pick_order',
'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick',
'll_delivery_order','ll_delivery_fee','ll_delivery_operation',
'll_delivery_order','ll_delivery_operation',
'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger',
'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 24 THEN
'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 23 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期完整业务表集合未部署';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
@@ -3834,6 +4060,16 @@ BEGIN
AND column_type='varchar(128)' AND is_nullable='NO' AND column_comment='来源托盘号快照') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='一件代发来源托盘号字段未收敛';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
AND table_name='ll_dropship_order_profile'
AND column_name IN ('manifest_version','manifest_package_count','manifest_fragment_count','manifest_hash')
AND is_nullable='NO') <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='一件代发托盘清单字段未完整收敛';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
AND table_name='ll_dropship_order_profile' AND index_name='idx_ll_dropship_profile_manifest') <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='一件代发托盘清单索引未部署';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key'
AND column_type='varchar(255)' AND is_nullable='YES') <> 1 THEN
@@ -3968,7 +4204,7 @@ BEGIN
AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'
AND (JSON_LENGTH(roles_json) <> 1
OR NOT JSON_CONTAINS(roles_json, JSON_QUOTE('PLATFORM_ADMIN'))
OR JSON_LENGTH(authorities_json) <> 109
OR JSON_LENGTH(authorities_json) <> 105
OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-warehouse:last-mile-order:list'))
OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-warehouse:inbound-order:list'))
OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-warehouse:dispatch:list'))
@@ -4405,8 +4641,12 @@ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name='total_file_url' AND column_type='varchar(1000)') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少字段 ll_relabel_application.total_file_url';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_no' AND column_type='varchar(128)' AND is_nullable='NO') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入仓计划托盘码长度未就绪';
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
AND table_name='ll_inbound_plan_pallet' AND column_name IN ('pallet_no','version')) <> 2
OR NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_no'
AND column_type='varchar(128)' AND is_nullable='NO') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入仓计划托盘码或乐观锁版本字段未就绪';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_document_revision' AND index_name='uk_delivery_document_command') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_delivery_document_command';
@@ -4536,7 +4776,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE ll_delivery_order_bol_no_stmt FROM @ddl; EXECUTE ll_delivery_order_bol_no_stmt; DEALLOCATE PREPARE ll_delivery_order_bol_no_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='lastleg_order_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''关联尾程主订单ID'' AFTER customer_reference_no'));
PREPARE ll_return_order_lastleg_id_stmt FROM @ddl; EXECUTE ll_return_order_lastleg_id_stmt; DEALLOCATE PREPARE ll_return_order_lastleg_id_stmt;
UPDATE ll_return_order r JOIN ll_order o ON o.source_order_id = CAST(r.id AS CHAR) AND o.source_order_no = r.return_no AND o.order_type = 'RETURN_RELABEL' SET r.lastleg_order_id = o.id WHERE r.lastleg_order_id IS NULL;
UPDATE ll_return_order r JOIN ll_order o
ON o.source_order_id COLLATE utf8mb4_unicode_ci = CAST(r.id AS CHAR) COLLATE utf8mb4_unicode_ci
AND o.source_order_no COLLATE utf8mb4_unicode_ci = r.return_no COLLATE utf8mb4_unicode_ci
AND o.order_type = 'RETURN_RELABEL'
SET r.lastleg_order_id = o.id
WHERE r.lastleg_order_id IS NULL;
-- 柜级入库计划跨客户批量托盘预留、确认与失败补偿。
USE baiyue_lastlag_warehouse;
@@ -4562,7 +4807,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key),
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id),
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), KEY idx_wh_pallet_batch_item_pallet (pallet_id),
KEY idx_wh_pallet_batch_item_batch (batch_id,status)
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联';
@@ -31,6 +31,7 @@ public interface IamQualificationClient {
record QualificationRequest(Long tenantId, String qualificationType,
String certificateNo, String documentUrl,
String originalFileName,
java.time.LocalDate validTo, boolean replace) { }
record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus,
String replacedDocumentUrl) { }
@@ -38,12 +39,15 @@ public interface IamQualificationClient {
String replacedDocumentUrl) { }
record PlatformQualificationRequest(Long tenantId, String qualificationType,
String certificateNo, String documentUrl,
String originalFileName,
java.time.LocalDate validTo, boolean replace) { }
record RequiredPackageRequest(Long tenantId, String unifiedSocialCreditCode,
String businessLicenseDocumentUrl,
String businessLicenseOriginalFileName,
java.time.LocalDate businessLicenseValidTo,
boolean replaceBusinessLicense,
String legalPersonIdDocumentUrl,
String legalPersonIdOriginalFileName,
java.time.LocalDate legalPersonIdValidTo,
boolean replaceLegalPersonId) { }
record RequiredPackageResponse(Long businessLicenseQualificationId,
@@ -53,7 +53,8 @@ public class PlatformQualificationFileController {
try {
var response = iamClient.submitPlatform(internalToken,
new IamQualificationClient.PlatformQualificationRequest(
tenantId, qualificationType, certificateNo, stored.documentUrl(), validTo, replace));
tenantId, qualificationType, certificateNo, stored.documentUrl(),
file.getOriginalFilename(), validTo, replace));
if (response == null) {
throw new IllegalStateException("IAM qualification response is empty");
}
@@ -45,7 +45,7 @@ public class QualificationFileController {
try {
var response = iamClient.submit(internalToken,
new IamQualificationClient.QualificationRequest(tenantId, qualificationType,
certificateNo, stored.documentUrl(), validTo, replace));
certificateNo, stored.documentUrl(), file.getOriginalFilename(), validTo, replace));
if (response == null) {
throw new IllegalStateException("IAM qualification response is empty");
}
@@ -44,12 +44,13 @@ public interface FileExportOutboxMapper extends BaseMapper<FileExportOutbox> {
int releaseExpiredClaims();
@Update("""
UPDATE file_export_outbox o JOIN file_export_task t
ON t.id=o.task_id AND t.dispatch_version=o.dispatch_version
SET o.status='PENDING',o.next_retry_at=CURRENT_TIMESTAMP(3),o.lease_owner=NULL,o.lease_until=NULL
WHERE o.status='SENT' AND o.sent_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{staleSeconds} SECOND)
AND t.status='PENDING' AND t.next_retry_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{graceSeconds} SECOND)
LIMIT 20
UPDATE file_export_outbox
SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL
WHERE id IN (SELECT id FROM (SELECT o.id FROM file_export_outbox o JOIN file_export_task t
ON t.id=o.task_id AND t.dispatch_version=o.dispatch_version
WHERE o.status='SENT' AND o.sent_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{staleSeconds} SECOND)
AND t.status='PENDING' AND t.next_retry_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{graceSeconds} SECOND)
ORDER BY o.sent_at,o.id LIMIT 20) stale)
""")
int redispatchSent(@Param("staleSeconds") int staleSeconds, @Param("graceSeconds") int graceSeconds);
@@ -25,7 +25,8 @@ import java.util.UUID;
public class LocalQualificationStorage {
private static final Logger LOGGER = LoggerFactory.getLogger(LocalQualificationStorage.class);
private static final long MAX_SIZE = 5L * 1024 * 1024;
private static final Set<String> TYPES = Set.of("image/jpeg", "image/png", "application/pdf");
private static final Set<String> TYPES = Set.of(
"image/jpeg", "image/png", "image/bmp", "image/x-ms-bmp", "application/pdf");
@Value("${baiyue.file.storage-root}") private String configuredRoot;
private Path root;
private Path qualificationRoot;
@@ -53,11 +54,16 @@ public class LocalQualificationStorage {
if (file == null || file.isEmpty()) throw new BizException("One qualification file is required / 请选择资质文件");
if (file.getSize() > MAX_SIZE) throw new BizException("Qualification file must not exceed 5 MB / 资质文件不能超过 5MB");
String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG and PNG files are supported / 仅支持 PDF、JPG、PNG 文件");
if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG, PNG and BMP files are supported / 仅支持 PDF、JPG、PNG、BMP 文件");
if (!matchesSignature(file, contentType)) {
throw new BizException("The uploaded file content does not match its type / 文件内容与格式不匹配");
}
String extension = "image/png".equals(contentType) ? ".png" : ("application/pdf".equals(contentType) ? ".pdf" : ".jpg");
String extension = switch (contentType) {
case "image/png" -> ".png";
case "image/bmp", "image/x-ms-bmp" -> ".bmp";
case "application/pdf" -> ".pdf";
default -> ".jpg";
};
String fileName = UUID.randomUUID() + extension;
Path directory = tenantDirectory(tenantId);
Path target = directory.resolve(fileName).normalize();
@@ -92,6 +98,9 @@ public class LocalQualificationStorage {
return read >= 5 && header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44
&& header[3] == 0x46 && header[4] == 0x2d;
}
if ("image/bmp".equals(contentType) || "image/x-ms-bmp".equals(contentType)) {
return read >= 2 && header[0] == 0x42 && header[1] == 0x4d;
}
return read >= 8 && (header[0] & 0xff) == 0x89 && header[1] == 0x50
&& header[2] == 0x4e && header[3] == 0x47 && header[4] == 0x0d
&& header[5] == 0x0a && header[6] == 0x1a && header[7] == 0x0a;
@@ -101,7 +110,7 @@ public class LocalQualificationStorage {
}
public Resource load(long tenantId, String fileName) {
if (fileName == null || !fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) {
if (fileName == null || !fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|bmp|pdf)")) {
throw fileNotFound();
}
Path directory = tenantDirectory(tenantId);
@@ -124,7 +133,7 @@ public class LocalQualificationStorage {
String prefix = "/api/merchant/v1/files/qualifications/";
if (!documentUrl.startsWith(prefix)) return;
String fileName = documentUrl.substring(prefix.length());
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) return;
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|bmp|pdf)")) return;
Path directory = tenantDirectory(tenantId);
Path target = directory.resolve(fileName).normalize();
if (!target.startsWith(directory)) return;
@@ -45,8 +45,10 @@ public class QualificationPackageService {
String legalPersonIdDocumentUrl = storedLegalPersonId == null ? null : storedLegalPersonId.documentUrl();
var request = new IamQualificationClient.RequiredPackageRequest(
tenantId, normalizedCreditCode, storedBusinessLicense.documentUrl(), businessLicenseValidTo,
replaceBusinessLicense, legalPersonIdDocumentUrl, legalPersonIdValidTo, replaceLegalPersonId);
tenantId, normalizedCreditCode, storedBusinessLicense.documentUrl(), businessLicense.getOriginalFilename(),
businessLicenseValidTo, replaceBusinessLicense, legalPersonIdDocumentUrl,
legalPersonId == null ? null : legalPersonId.getOriginalFilename(),
legalPersonIdValidTo, replaceLegalPersonId);
try {
var response = platform
? iamClient.submitPlatformRequiredPackage(internalToken, request)
@@ -29,7 +29,7 @@ paths:
type: string
format: binary
maxLength: 5242880
description: PDF/JPG/PNG,最大5MB,声明媒体类型必须与文件签名一致
description: PDF/JPG/PNG/BMP,最大5MB,声明媒体类型必须与文件签名一致
responses:
'200':
description: 成功或业务失败,失败时按 code 判断并保留 requestId/traceId
@@ -139,12 +139,12 @@ components:
businessLicense:
type: string
format: binary
description: 营业执照,必填;PDF/JPG/PNG,最大5MB
description: 营业执照,必填;PDF/JPG/PNG/BMP,最大5MB
legalPersonId:
type: string
format: binary
nullable: true
description: 法人身份证,选填;PDF/JPG/PNG,最大5MB
description: 法人身份证,选填;PDF/JPG/PNG/BMP,最大5MB
responses:
Error:
description: 认证或授权失败,响应包含请求关联元数据
@@ -16,7 +16,7 @@ class QualificationAndOrderFileOpenApiContractTest {
assertThat(yaml)
.contains("/api/merchant/v1/files/qualifications:")
.contains("enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]")
.contains("merchant:qualification:upload", "maxLength: 5242880")
.contains("merchant:qualification:upload", "maxLength: 5242880", "PDF/JPG/PNG/BMP")
.contains("name: replace", "requestId:", "traceId:")
.doesNotContain("enum: [BUSINESS_LICENSE, LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]");
}
@@ -22,6 +22,9 @@ class ExportMapperContractTest {
String sql=update(FileExportOutboxMapper.class,"redispatchSent");
assertTrue(sql.contains("o.status='SENT'"));
assertTrue(sql.contains("t.dispatch_version=o.dispatch_version"));
assertTrue(sql.contains("WHERE id IN (SELECT id FROM (SELECT o.id"));
assertTrue(sql.contains("ORDER BY o.sent_at,o.id LIMIT 20"));
assertFalse(sql.contains("SET o.status"));
assertFalse(sql.contains("dispatch_version=dispatch_version+1"));
assertFalse(sql.contains("QUARANTINED"));
}
@@ -43,7 +43,7 @@ class LocalQualificationStorageTest {
Path qualificationRoot = storageRoot.resolve("qualifications");
assertTrue(Files.isDirectory(qualificationRoot));
try (var files = Files.list(qualificationRoot)) {
assertEquals(0, files.count());
assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith(".file-service-probe-")));
}
}
@@ -78,6 +78,47 @@ class LocalQualificationStorageTest {
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
}
@Test
void storesBmpQualificationWithEitherStandardMimeTypeAndKeepsTenantOwnership() throws Exception {
storage.initialize();
byte[] bmp = {0x42, 0x4d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00};
for (String contentType : new String[] {"image/bmp", "image/x-ms-bmp"}) {
var upload = new MockMultipartFile("file", "license.bmp", contentType, bmp);
var stored = storage.store(10001L, upload);
assertTrue(stored.fileName().endsWith(".bmp"));
assertEquals(stored.path().toAbsolutePath(),
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
BizException crossTenant = assertThrows(BizException.class,
() -> storage.load(10002L, stored.fileName()));
assertEquals(ResultCode.NOT_FOUND, crossTenant.getResultCode());
}
}
@Test
void rejectsBmpMimeTypeWhenTheFileSignatureIsNotBmp() {
storage.initialize();
var upload = new MockMultipartFile("file", "license.bmp", "image/bmp", "not-bmp".getBytes());
BizException exception = assertThrows(BizException.class,
() -> storage.store(10001L, upload));
assertTrue(exception.getMessage().contains("文件内容与格式不匹配"));
}
@Test
void deletesBmpQualificationByItsValidatedDocumentUrl() {
storage.initialize();
byte[] bmp = {0x42, 0x4d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00};
var stored = storage.store(10001L,
new MockMultipartFile("file", "license.bmp", "image/bmp", bmp));
storage.deleteByDocumentUrl(10001L, stored.documentUrl());
assertFalse(Files.exists(stored.path()));
}
@Test
void rejectsQualificationFilesLargerThanTheClientFiveMegabyteLimit() {
storage.initialize();
@@ -57,6 +57,8 @@ class QualificationPackageServiceTest {
var captor = ArgumentCaptor.forClass(IamQualificationClient.RequiredPackageRequest.class);
ordered.verify(fixture.iamClient).submitRequiredPackage(eq("internal-token"), captor.capture());
assertEquals("91310101ABCDE12345", captor.getValue().unifiedSocialCreditCode());
assertEquals("license.pdf", captor.getValue().businessLicenseOriginalFileName());
assertEquals("legal-person.png", captor.getValue().legalPersonIdOriginalFileName());
assertEquals(BUSINESS_URL, result.businessLicenseDocumentUrl());
assertEquals(LEGAL_PERSON_URL, result.legalPersonIdDocumentUrl());
}
@@ -74,6 +76,7 @@ class QualificationPackageServiceTest {
var captor = ArgumentCaptor.forClass(IamQualificationClient.RequiredPackageRequest.class);
verify(fixture.iamClient).submitRequiredPackage(eq("internal-token"), captor.capture());
assertNull(captor.getValue().legalPersonIdDocumentUrl());
assertNull(captor.getValue().legalPersonIdOriginalFileName());
assertNull(captor.getValue().legalPersonIdValidTo());
assertNull(result.legalPersonIdQualificationId());
assertNull(result.legalPersonIdDocumentUrl());
+1
View File
@@ -42,6 +42,7 @@
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
- 海外业务费用事件只接受非 `FIRSTLEG` 来源、`APPROVED` 审核快照和 `PUBLISHED` 活跃版本或明确 `VOIDED` 动作;按 `eventId + payloadHash` 防重放冲突,并以 `sourceSystem + sourceType + sourceFeeId + sourceVersion` 单调落库。事件携带完整客户、业务、方向、供应商、付款与来源哈希事实,Finance 不跨区同步回查 Lastleg。
@@ -33,6 +33,7 @@ public final class FinancePhase1Dtos {
BigDecimal actualBalance, BigDecimal frozenBalance,
BigDecimal availableBalance, BigDecimal creditLimit,
BigDecimal creditUsed, BigDecimal availableCredit,
LocalDate creditExpiresOn, boolean creditActive,
int accountVersion,
long pendingRechargeCount,
String status, LocalDateTime lastChangedAt) {}
@@ -12,6 +12,7 @@ public final class FinanceWalletDtos {
BigDecimal frozenBalance, BigDecimal availableBalance,
BigDecimal creditLimit, BigDecimal creditUsed,
BigDecimal creditFrozen, BigDecimal availableCredit,
LocalDate creditExpiresOn, boolean creditActive,
int version, LocalDateTime updatedAt) { }
public record WalletPage<T>(List<T> records, long total, int page, int size, int pages) {
@@ -26,6 +27,7 @@ public final class FinanceWalletDtos {
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
BigDecimal creditLimitAfter, BigDecimal creditUsedAfter,
BigDecimal availableCreditAfter,
LocalDate creditExpiresOnAfter,
String sourceType, String sourceNo, LocalDate occurredDate,
String paymentMethod, String referenceNo, String operatorName,
String remark, LocalDateTime createdAt) { }
@@ -45,7 +47,7 @@ public final class FinanceWalletDtos {
String paymentMethod, String referenceNo, String remark,
int expectedAccountVersion) { }
public record CreditLimitRequest(BigDecimal creditLimit, String remark,
public record CreditLimitRequest(BigDecimal creditLimit, LocalDate creditExpiresOn, String remark,
int expectedAccountVersion) { }
public record WalletFreezeRequest(BigDecimal amount, String businessType,
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -21,18 +22,12 @@ public class FinanceCustomerAccountService {
private final JdbcTemplate jdbc;
private final IamCustomerClient iamCustomerClient;
private final String internalToken;
private final BigDecimal defaultCreditLimit;
public FinanceCustomerAccountService(JdbcTemplate jdbc, IamCustomerClient iamCustomerClient,
@Value("${baiyue.security.internal-service-token:}") String internalToken,
@Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit) {
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
this.jdbc = jdbc;
this.iamCustomerClient = iamCustomerClient;
this.internalToken = internalToken;
if (defaultCreditLimit == null || defaultCreditLimit.signum() < 0 || defaultCreditLimit.scale() > 2) {
throw new IllegalArgumentException("默认授信额度不能为负数且最多保留两位小数");
}
this.defaultCreditLimit = defaultCreditLimit.setScale(2);
}
public PageEnvelope<AccountSummary> list(int page, int pageSize, String keyword, String status, Long customerId) {
@@ -60,6 +55,7 @@ public class FinanceCustomerAccountService {
value.outstandingPayable(),
value.actualBalance(), value.frozenBalance(), value.availableBalance(),
value.creditLimit(), value.creditUsed(), value.availableCredit(),
value.creditExpiresOn(), value.creditActive(),
value.accountVersion(), value.pendingRechargeCount(),
customer.status(), value.lastChangedAt());
}).toList();
@@ -70,7 +66,7 @@ public class FinanceCustomerAccountService {
if (customerIds.isEmpty()) return Map.of();
String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList());
Map<Long, MutableMetrics> result = new HashMap<>();
customerIds.forEach(id -> result.put(id, new MutableMetrics(defaultCreditLimit)));
customerIds.forEach(id -> result.put(id, new MutableMetrics()));
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "outstandingReceivable", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') GROUP BY customer_id", customerIds.toArray())
@@ -83,7 +79,7 @@ public class FinanceCustomerAccountService {
.forEach(row -> metric(result, row, "paidCost", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED')) payable_obligations GROUP BY customer_id", concat(customerIds, 2))
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
.forEach(row -> walletMetric(result, row));
jdbc.queryForList("SELECT customer_id,COUNT(*) pending_count FROM fin_recharge_request WHERE customer_id IN (" + placeholders + ") AND status='PENDING' GROUP BY customer_id", customerIds.toArray())
.forEach(row -> pendingMetric(result, row));
@@ -100,10 +96,22 @@ public class FinanceCustomerAccountService {
MutableMetrics target = result.get(number.longValue());
target.actualBalance = decimal(row.get("actual_balance"));
target.frozenBalance = decimal(row.get("frozen_balance"));
target.availableBalance = decimal(row.get("available_balance"));
target.creditLimit = decimal(row.get("credit_limit"));
BigDecimal creditFrozen = decimal(row.get("credit_frozen"));
target.creditExpiresOn = row.get("credit_expires_on") instanceof java.sql.Date value ? value.toLocalDate()
: row.get("credit_expires_on") instanceof LocalDate value ? value : null;
target.creditActive = target.creditLimit.signum() > 0 && target.creditExpiresOn != null
&& !target.creditExpiresOn.isBefore(LocalDate.now());
target.creditUsed = decimal(row.get("credit_used"));
target.availableCredit = target.creditLimit.subtract(target.creditUsed).subtract(decimal(row.get("credit_frozen")));
if (!target.creditActive && target.creditUsed.signum() > 0) {
target.actualBalance = target.actualBalance.subtract(target.creditUsed);
target.creditUsed = BigDecimal.ZERO;
}
target.availableCredit = target.creditActive
? target.creditLimit.subtract(target.creditUsed).subtract(creditFrozen) : BigDecimal.ZERO;
target.availableBalance = target.creditActive
? target.actualBalance.subtract(target.frozenBalance.subtract(creditFrozen)).add(target.availableCredit)
: target.actualBalance.subtract(target.frozenBalance);
target.accountVersion = row.get("version") instanceof Number value ? value.intValue() : 0;
}
@@ -158,13 +166,15 @@ public class FinanceCustomerAccountService {
private BigDecimal creditLimit;
private BigDecimal creditUsed = BigDecimal.ZERO;
private BigDecimal availableCredit;
private LocalDate creditExpiresOn;
private boolean creditActive;
private int accountVersion;
private long pendingRechargeCount;
private LocalDateTime lastChangedAt;
private MutableMetrics(BigDecimal defaultCreditLimit) {
this.creditLimit = defaultCreditLimit;
this.availableCredit = defaultCreditLimit;
this.availableBalance = defaultCreditLimit;
private MutableMetrics() {
this.creditLimit = BigDecimal.ZERO.setScale(2);
this.availableCredit = BigDecimal.ZERO.setScale(2);
this.availableBalance = BigDecimal.ZERO.setScale(2);
}
void set(String field, BigDecimal value) {
switch (field) {
@@ -179,6 +189,7 @@ public class FinanceCustomerAccountService {
}
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable,
actualBalance, frozenBalance, availableBalance, creditLimit, creditUsed, availableCredit,
creditExpiresOn, creditActive,
accountVersion, pendingRechargeCount, lastChangedAt); }
}
@@ -186,7 +197,8 @@ public class FinanceCustomerAccountService {
BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost,
BigDecimal outstandingPayable, BigDecimal actualBalance, BigDecimal frozenBalance,
BigDecimal availableBalance, BigDecimal creditLimit, BigDecimal creditUsed,
BigDecimal availableCredit, int accountVersion, long pendingRechargeCount,
BigDecimal availableCredit, LocalDate creditExpiresOn, boolean creditActive,
int accountVersion, long pendingRechargeCount,
LocalDateTime lastChangedAt) {
}
}
@@ -78,22 +78,46 @@ public class FinancePhase1OperationService {
"COALESCE((SELECT available_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_balance," +
"COALESCE((SELECT credit_limit FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_limit," +
"COALESCE((SELECT credit_used FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_used," +
"COALESCE((SELECT credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_frozen," +
"COALESCE((SELECT credit_limit-credit_used-credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_credit," +
"(SELECT credit_expires_on FROM fin_wallet_account w WHERE w.customer_id=c.customer_id) credit_expires_on," +
"COALESCE((SELECT version FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) account_version," +
"(SELECT COUNT(*) FROM fin_recharge_request rr WHERE rr.customer_id=c.customer_id AND rr.status='PENDING') pending_recharge_count," +
"GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), rs.getString("customer_name"), null, null,
money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"),
money(rs, "total_cost"), money(rs, "paid_cost"), money(rs, "outstanding_payable"),
money(rs, "actual_balance"), money(rs, "frozen_balance"), money(rs, "available_balance"),
money(rs, "credit_limit"), money(rs, "credit_used"), money(rs, "available_credit"),
rs.getInt("account_version"), rs.getLong("pending_recharge_count"),
rs.getString("account_status"), time(rs, "last_changed_at")), args);
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> accountSummaryRow(rs), args);
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
}
private static AccountSummary accountSummaryRow(ResultSet rs) throws SQLException {
BigDecimal actualBalance = money(rs, "actual_balance");
BigDecimal frozenBalance = money(rs, "frozen_balance");
BigDecimal creditLimit = money(rs, "credit_limit");
BigDecimal creditFrozen = money(rs, "credit_frozen");
LocalDate creditExpiresOn = rs.getDate("credit_expires_on") == null
? null : rs.getDate("credit_expires_on").toLocalDate();
boolean creditActive = creditLimit.signum() > 0 && creditExpiresOn != null
&& !creditExpiresOn.isBefore(LocalDate.now());
BigDecimal creditUsed = money(rs, "credit_used");
if (!creditActive && creditUsed.signum() > 0) {
actualBalance = actualBalance.subtract(creditUsed);
creditUsed = BigDecimal.ZERO;
}
BigDecimal availableCredit = creditActive
? creditLimit.subtract(creditUsed).subtract(creditFrozen) : BigDecimal.ZERO;
BigDecimal availableBalance = creditActive
? actualBalance.subtract(frozenBalance.subtract(creditFrozen)).add(availableCredit)
: actualBalance.subtract(frozenBalance);
return new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")),
rs.getString("customer_name"), null, null, money(rs, "outstanding_receivable"),
money(rs, "pending_billing"), money(rs, "received_period"), money(rs, "total_cost"),
money(rs, "paid_cost"), money(rs, "outstanding_payable"), actualBalance, frozenBalance,
availableBalance, creditLimit, creditUsed, availableCredit, creditExpiresOn, creditActive,
rs.getInt("account_version"), rs.getLong("pending_recharge_count"), rs.getString("account_status"),
time(rs, "last_changed_at"));
}
public PageEnvelope<LedgerEntry> listDerivedCustomerAccountLedger(long customerId, int page, int pageSize,
String businessType, LocalDate startDate, LocalDate endDate) {
customer(customerId); page = page(page); pageSize = pageSize(pageSize);
@@ -53,7 +53,6 @@ public class FinanceWalletService {
private final String internalToken;
private final String wechatQrCodeUrl;
private final String alipayQrCodeUrl;
private final BigDecimal defaultCreditLimit;
public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FinanceFileClient fileClient,
@@ -61,7 +60,6 @@ public class FinanceWalletService {
@Value("${baiyue.security.internal-service-token:}") String internalToken,
@Value("${baiyue.finance.recharge.wechat-qr-code-url:}") String wechatQrCodeUrl,
@Value("${baiyue.finance.recharge.alipay-qr-code-url:}") String alipayQrCodeUrl,
@Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit,
@Value("${spring.profiles.active:}") String activeProfiles,
@Value("${DEPLOY_ENV:}") String deployEnvironment) {
this.jdbc = jdbc;
@@ -69,7 +67,6 @@ public class FinanceWalletService {
this.fileClient = fileClient;
this.numberGenerator = numberGenerator;
this.internalToken = internalToken;
this.defaultCreditLimit = nonNegativeMoney(defaultCreditLimit, "默认授信额度");
boolean development = "development".equalsIgnoreCase(deployEnvironment.trim())
|| List.of(activeProfiles.split(",")).stream().map(String::trim).anyMatch("dev"::equalsIgnoreCase);
this.wechatQrCodeUrl = rechargeQrCode(wechatQrCodeUrl, development);
@@ -84,7 +81,7 @@ public class FinanceWalletService {
public WalletAccount account(long customerId) {
requirePositive(customerId, "客户");
List<WalletAccount> rows = jdbc.query(
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
(rs, n) -> accountRow(rs), customerId);
return rows.isEmpty() ? zeroAccount() : rows.get(0);
}
@@ -202,7 +199,7 @@ public class FinanceWalletService {
if (replay != null) return requiredLedger(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
if (before.version() != request.expectedAccountVersion()) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
if ("DEDUCTION".equals(type) && before.availableBalance().compareTo(amount) < 0) {
if ("DEDUCTION".equals(type) && before.creditActive() && before.availableBalance().compareTo(amount) < 0) {
throw error("FIN-WALLET-INSUFFICIENT", "扣款金额不能超过当前可用余额");
}
BigDecimal signed = "RECHARGE".equals(type) ? amount : amount.negate();
@@ -225,19 +222,26 @@ public class FinanceWalletService {
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
if (request == null || request.expectedAccountVersion() < 0) throw bad("授信额度请求无效");
BigDecimal limit = nonNegativeMoney(request.creditLimit(), "授信额度");
LocalDate expiresOn = request.creditExpiresOn();
if (expiresOn == null || !expiresOn.isAfter(LocalDate.now())) {
throw bad("授信到期日必须晚于今天");
}
Long replay = reserve(customerId, operatorId, "WALLET_CREDIT_LIMIT", idempotencyKey, hash(request));
if (replay != null) return requiredLedger(replay, customerId);
WalletAccount before = accountForUpdate(customerId);
if (before.version() != request.expectedAccountVersion()) {
throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
}
if (limit.compareTo(before.creditUsed().add(before.creditFrozen())) < 0) {
BigDecimal existingDebt = before.actualBalance().signum() < 0
? before.actualBalance().negate() : before.creditUsed();
if (limit.compareTo(existingDebt.add(before.creditFrozen())) < 0) {
throw error("FIN-WALLET-CREDIT-IN-USE", "授信额度不能低于已用与已冻结授信合计");
}
BigDecimal delta = limit.subtract(before.creditLimit());
if (delta.signum() == 0) throw bad("授信额度未发生变化");
WalletAccount after = accountState(before.actualBalance(), before.frozenBalance(), limit,
before.creditUsed(), before.creditFrozen(), before.version());
if (delta.signum() == 0 && expiresOn.equals(before.creditExpiresOn())) throw bad("授信额度和有效期均未发生变化");
BigDecimal carriedDebt = existingDebt;
WalletAccount after = accountState(before.actualBalance().max(BigDecimal.ZERO), before.frozenBalance(), limit,
carriedDebt, before.creditFrozen(), expiresOn, before.version());
updateAccount(customerId, after, before.version(), operatorId);
WalletLedgerEntry entry = insertLedger(customerId, "CREDIT_LIMIT", delta, after,
"CREDIT_LIMIT_ADJUSTMENT", null, LocalDate.now(), null, null,
@@ -273,11 +277,12 @@ public class FinanceWalletService {
throw error("FIN-WALLET-INSUFFICIENT", "可用余额和授信额度不足");
}
BigDecimal actualAvailable = before.actualBalance()
.subtract(before.frozenBalance().subtract(before.creditFrozen()));
.subtract(before.frozenBalance().subtract(before.creditFrozen())).max(BigDecimal.ZERO);
BigDecimal actualFrozen = amount.min(actualAvailable);
BigDecimal creditFrozen = amount.subtract(actualFrozen);
WalletAccount after = accountState(before.actualBalance(), before.frozenBalance().add(amount),
before.creditLimit(), before.creditUsed(), before.creditFrozen().add(creditFrozen), before.version());
before.creditLimit(), before.creditUsed(), before.creditFrozen().add(creditFrozen),
before.creditExpiresOn(), before.version());
updateAccount(customerId, after, before.version(), 0);
long holdId = insertHold(customerId, businessType, businessNo, amount, actualFrozen, creditFrozen,
clean(request.remark(), 500));
@@ -315,12 +320,18 @@ public class FinanceWalletService {
WalletHold hold = findHold(customerId, businessType, businessNo, true);
if (hold == null) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在");
if (!"FROZEN".equals(hold.status())) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理");
BigDecimal actual = settle ? before.actualBalance().subtract(hold.actualFrozenAmount()) : before.actualBalance();
BigDecimal creditUsed = settle ? before.creditUsed().add(hold.creditFrozenAmount()) : before.creditUsed();
BigDecimal actual = before.actualBalance();
BigDecimal creditUsed = before.creditUsed();
if (settle && before.creditActive()) {
actual = actual.subtract(hold.actualFrozenAmount());
creditUsed = creditUsed.add(hold.creditFrozenAmount());
} else if (settle) {
actual = actual.subtract(hold.amount());
}
BigDecimal frozen = before.frozenBalance().subtract(hold.amount());
BigDecimal creditFrozen = before.creditFrozen().subtract(hold.creditFrozenAmount());
WalletAccount after = accountState(actual, frozen, before.creditLimit(), creditUsed,
creditFrozen, before.version());
creditFrozen, before.creditExpiresOn(), before.version());
updateAccount(customerId, after, before.version(), 0);
String action = settle ? "SETTLED" : "RELEASED";
WalletLedgerEntry ledger = insertLedger(customerId, settle ? "CONSUMPTION" : "UNFREEZE",
@@ -346,17 +357,17 @@ public class FinanceWalletService {
}
private WalletAccount accountForUpdate(long customerId) {
jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,created_by,updated_by) VALUES(?,'CNY',0,0,?,?,0,0,0,0,0)",
customerId, defaultCreditLimit, defaultCreditLimit);
jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,created_by,updated_by) VALUES(?,'CNY',0,0,0,0,0,0,NULL,0,0,0)",
customerId);
return jdbc.queryForObject(
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
(rs, n) -> accountRow(rs), customerId);
}
private void updateAccount(long customerId, WalletAccount account, int expectedVersion, long operatorId) {
int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,credit_limit=?,credit_used=?,credit_frozen=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,credit_limit=?,credit_used=?,credit_frozen=?,credit_expires_on=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
account.actualBalance(), account.frozenBalance(), account.availableBalance(), account.creditLimit(),
account.creditUsed(), account.creditFrozen(), operatorId, customerId, expectedVersion);
account.creditUsed(), account.creditFrozen(), account.creditExpiresOn(), operatorId, customerId, expectedVersion);
if (updated != 1) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
}
@@ -369,17 +380,17 @@ public class FinanceWalletService {
KeyHolder holder = new GeneratedKeyHolder();
jdbc.update(connection -> {
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,credit_limit_after,credit_used_after,available_credit_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
"INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,credit_limit_after,credit_used_after,available_credit_after,credit_expires_on_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
Statement.RETURN_GENERATED_KEYS);
statement.setLong(1, customerId); statement.setString(2, businessNo); statement.setString(3, changeType);
statement.setBigDecimal(4, amount); statement.setBigDecimal(5, account.actualBalance());
statement.setBigDecimal(6, account.frozenBalance()); statement.setBigDecimal(7, account.availableBalance());
statement.setBigDecimal(8, account.creditLimit()); statement.setBigDecimal(9, account.creditUsed());
statement.setBigDecimal(10, account.availableCredit()); statement.setString(11, sourceType);
statement.setString(12, sourceNo); statement.setObject(13, occurredDate);
statement.setString(14, paymentMethod); statement.setString(15, referenceNo);
statement.setLong(16, operatorId); statement.setString(17, clean(operatorName, 128));
statement.setString(18, clean(remark, 500)); return statement;
statement.setBigDecimal(10, account.availableCredit()); statement.setObject(11, account.creditExpiresOn());
statement.setString(12, sourceType); statement.setString(13, sourceNo); statement.setObject(14, occurredDate);
statement.setString(15, paymentMethod); statement.setString(16, referenceNo);
statement.setLong(17, operatorId); statement.setString(18, clean(operatorName, 128));
statement.setString(19, clean(remark, 500)); return statement;
}, holder);
return requiredLedger(generatedId(holder), customerId);
}
@@ -392,48 +403,70 @@ public class FinanceWalletService {
}
WalletAccount rechargeAccount(WalletAccount before, BigDecimal amount) {
BigDecimal creditRepayment = amount.min(before.creditUsed());
return accountState(before.actualBalance().add(amount.subtract(creditRepayment)), before.frozenBalance(),
before.creditLimit(), before.creditUsed().subtract(creditRepayment), before.creditFrozen(), before.version());
BigDecimal repaidCredit = before.creditActive() ? amount.min(before.creditUsed()) : BigDecimal.ZERO;
return accountState(before.actualBalance().add(amount.subtract(repaidCredit)), before.frozenBalance(),
before.creditLimit(), before.creditUsed().subtract(repaidCredit), before.creditFrozen(),
before.creditExpiresOn(), before.version());
}
WalletAccount deductAccount(WalletAccount before, BigDecimal amount) {
BigDecimal actualAvailable = before.actualBalance()
.subtract(before.frozenBalance().subtract(before.creditFrozen()));
if (!before.creditActive()) {
return accountState(before.actualBalance().subtract(amount), before.frozenBalance(),
before.creditLimit(), BigDecimal.ZERO, before.creditFrozen(), before.creditExpiresOn(), before.version());
}
BigDecimal actualFrozen = before.frozenBalance().subtract(before.creditFrozen());
BigDecimal actualAvailable = before.actualBalance().subtract(actualFrozen).max(BigDecimal.ZERO);
BigDecimal actualDeduction = amount.min(actualAvailable);
BigDecimal creditDeduction = amount.subtract(actualDeduction);
return accountState(before.actualBalance().subtract(actualDeduction), before.frozenBalance(),
before.creditLimit(), before.creditUsed().add(creditDeduction), before.creditFrozen(), before.version());
before.creditLimit(), before.creditUsed().add(amount.subtract(actualDeduction)),
before.creditFrozen(), before.creditExpiresOn(), before.version());
}
WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
BigDecimal creditUsed, BigDecimal creditFrozen, int version) {
BigDecimal availableCredit = creditLimit.subtract(creditUsed).subtract(creditFrozen);
BigDecimal creditFrozen, LocalDate creditExpiresOn, int version) {
return accountState(actual, frozen, creditLimit, BigDecimal.ZERO, creditFrozen, creditExpiresOn, version);
}
WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
BigDecimal creditUsed, BigDecimal creditFrozen,
LocalDate creditExpiresOn, int version) {
boolean creditActive = creditLimit.signum() > 0 && creditExpiresOn != null
&& !creditExpiresOn.isBefore(LocalDate.now());
if (!creditActive && creditUsed.signum() > 0) {
actual = actual.subtract(creditUsed);
creditUsed = BigDecimal.ZERO.setScale(2);
}
BigDecimal availableCredit = creditActive
? creditLimit.subtract(creditUsed).subtract(creditFrozen) : BigDecimal.ZERO.setScale(2);
BigDecimal actualFrozen = frozen.subtract(creditFrozen);
BigDecimal available = actual.subtract(actualFrozen).add(availableCredit);
if (actual.signum() < 0 || frozen.signum() < 0 || creditLimit.signum() < 0 || creditUsed.signum() < 0
|| creditFrozen.signum() < 0 || actualFrozen.signum() < 0 || actual.compareTo(actualFrozen) < 0
|| availableCredit.signum() < 0 || available.signum() < 0) {
BigDecimal available = creditActive
? actual.subtract(actualFrozen).add(availableCredit)
: actual.subtract(frozen);
if (frozen.signum() < 0 || creditLimit.signum() < 0 || creditFrozen.signum() < 0
|| creditUsed.signum() < 0 || actualFrozen.signum() < 0
|| (creditActive && (actual.signum() < 0 || availableCredit.signum() < 0))) {
throw error("FIN-WALLET-409", "账户余额状态无效");
}
return new WalletAccount("CNY", actual, frozen, available, creditLimit, creditUsed,
creditFrozen, availableCredit, version, null);
creditFrozen, availableCredit, creditExpiresOn, creditActive, version, null);
}
private WalletAccount accountRow(java.sql.ResultSet rs) throws java.sql.SQLException {
BigDecimal creditLimit = rs.getBigDecimal("credit_limit");
BigDecimal creditUsed = rs.getBigDecimal("credit_used");
BigDecimal creditFrozen = rs.getBigDecimal("credit_frozen");
return new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), creditLimit, creditUsed,
creditFrozen, creditLimit.subtract(creditUsed).subtract(creditFrozen), rs.getInt("version"),
LocalDate expiresOn = rs.getDate("credit_expires_on") == null ? null : rs.getDate("credit_expires_on").toLocalDate();
WalletAccount calculated = accountState(rs.getBigDecimal("actual_balance"), rs.getBigDecimal("frozen_balance"),
creditLimit, rs.getBigDecimal("credit_used"), creditFrozen, expiresOn, rs.getInt("version"));
return new WalletAccount(calculated.currencyCode(), calculated.actualBalance(), calculated.frozenBalance(),
calculated.availableBalance(), calculated.creditLimit(), calculated.creditUsed(), calculated.creditFrozen(),
calculated.availableCredit(), calculated.creditExpiresOn(), calculated.creditActive(), calculated.version(),
timestamp(rs.getTimestamp("updated_at")));
}
private WalletAccount zeroAccount() {
BigDecimal zero = BigDecimal.ZERO.setScale(2);
return new WalletAccount("CNY", zero, zero, defaultCreditLimit, defaultCreditLimit, zero,
zero, defaultCreditLimit, 0, null);
return new WalletAccount("CNY", zero, zero, zero, zero, zero,
zero, zero, null, false, 0, null);
}
private long insertHold(long customerId, String businessType, String businessNo, BigDecimal amount,
@@ -546,6 +579,7 @@ public class FinanceWalletService {
rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"), rs.getBigDecimal("frozen_balance_after"),
rs.getBigDecimal("available_balance_after"), rs.getBigDecimal("credit_limit_after"),
rs.getBigDecimal("credit_used_after"), rs.getBigDecimal("available_credit_after"),
rs.getObject("credit_expires_on_after", LocalDate.class),
rs.getString("source_type"), rs.getString("source_no"),
rs.getObject("occurred_date", LocalDate.class), rs.getString("payment_method"), rs.getString("reference_no"),
rs.getString("operator_name"), rs.getString("remark"), timestamp(rs.getTimestamp("created_at")));
+8 -6
View File
@@ -443,12 +443,13 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY',
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,包含冻结资金',
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,授信消费可形成负余额',
frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额,包含实际资金与授信冻结',
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '总可用余额=实际余额+授信额度-已用授信-冻结总额',
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '当前总可用余额;授信失效后不再计入',
credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度',
credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信',
credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信有效期内累计已使用额度;到期后并入实际余额',
credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信',
credit_expires_on DATE NULL COMMENT '授信到期日,当天仍有效;次日失效',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本',
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后操作人ID',
@@ -458,7 +459,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
UNIQUE KEY uk_fin_wallet_account_customer (customer_id, currency_code),
CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'),
CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance)
CONSTRAINT chk_fin_wallet_account_balance CHECK (frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen)
) ENGINE=InnoDB COMMENT='客户CNY资金账户主账';
CREATE TABLE IF NOT EXISTS fin_recharge_request (
@@ -500,6 +501,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
credit_limit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后授信额度',
credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信',
available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信',
credit_expires_on_after DATE NULL COMMENT '变动后授信到期日',
source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等',
source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号',
occurred_date DATE NOT NULL COMMENT '业务发生日期',
@@ -514,8 +516,8 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
KEY idx_fin_wallet_ledger_customer_time (customer_id, created_at, id),
KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no),
CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0),
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0),
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after)
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0 OR change_type='CREDIT_LIMIT'),
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (frozen_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0)
) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水';
CREATE TABLE IF NOT EXISTS fin_wallet_hold (
@@ -23,7 +23,7 @@ import static org.mockito.Mockito.when;
class FinanceCustomerAccountServiceTest {
@Test
void listsMasterCustomersWithConfiguredInitialCreditAndForwardsSearchFields() {
void listsMasterCustomersWithoutImplicitPermanentCreditAndForwardsSearchFields() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
IamCustomerClient iam = mock(IamCustomerClient.class);
when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
@@ -32,7 +32,7 @@ class FinanceCustomerAccountServiceTest {
new IamCustomerClient.Customer(900000000000000001L, "TEN-001", "Alice Trading", "ACTIVE", "Alice", "13800138000")),
1, 20, 1, 1)));
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token", new BigDecimal("8000.00"))
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token")
.list(1, 20, " Alice ", "active", 900000000000000001L);
AccountSummary row = result.data().get(0);
@@ -41,9 +41,11 @@ class FinanceCustomerAccountServiceTest {
assertThat(row.contactPhone()).isEqualTo("13800138000");
assertThat(row.outstandingReceivable()).isZero();
assertThat(row.pendingBilling()).isZero();
assertThat(row.creditLimit()).isEqualByComparingTo("8000.00");
assertThat(row.availableCredit()).isEqualByComparingTo("8000.00");
assertThat(row.availableBalance()).isEqualByComparingTo("8000.00");
assertThat(row.creditLimit()).isZero();
assertThat(row.availableCredit()).isZero();
assertThat(row.availableBalance()).isZero();
assertThat(row.creditExpiresOn()).isNull();
assertThat(row.creditActive()).isFalse();
verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L);
}
@@ -56,7 +58,7 @@ class FinanceCustomerAccountServiceTest {
new PageResult<>(List.of(new IamCustomerClient.Customer(9L, "C-9", "客户9", "ACTIVE", null, null)),
1, 20, 1, 1)));
new FinanceCustomerAccountService(jdbc, iam, "internal-token", BigDecimal.ZERO).list(1, 20, null, null, 9L);
new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L);
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class));
@@ -72,7 +74,7 @@ class FinanceCustomerAccountServiceTest {
@Test
void rejectsUnsupportedFinanceAccountStatus() {
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token", BigDecimal.ZERO);
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token");
assertThatThrownBy(() -> service.list(1, 20, null, "PENDING", null))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code())
@@ -375,7 +375,7 @@ class FinancePhase1OperationBehaviorTest {
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, 0, 0,
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null, false, 0, 0,
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
}
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
}
}
assertThat(tableCount).isEqualTo(23);
assertThat(columnCount).isEqualTo(351);
assertThat(columnCount).isEqualTo(353);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@@ -9,17 +9,34 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceWalletContractTest {
@Test
void unifiedMigrationDropsTheLegacyBalanceConstraintBeforeRewritingBalances() throws Exception {
Path migration = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (!Files.exists(migration)) migration = Path.of("..").resolve(migration);
String sql = Files.readString(migration, StandardCharsets.UTF_8);
int procedure = sql.indexOf("CREATE PROCEDURE migrate_fin_wallet_credit()");
int dropConstraint = sql.indexOf("ALTER TABLE fin_wallet_account DROP CHECK chk_fin_wallet_account_balance", procedure);
int rewriteBalance = sql.indexOf("SET actual_balance=actual_balance-credit_used", procedure);
assertThat(procedure).isGreaterThanOrEqualTo(0);
assertThat(sql.substring(procedure, rewriteBalance))
.contains("CHECK_CLAUSE LIKE '%actual_balance%'", "v_legacy_wallet_constraint OR v_credit_expiry_missing");
assertThat(dropConstraint).isBetween(procedure, rewriteBalance - 1);
}
@Test
void schemaKeepsBalancesConsistentAndRechargeReviewTraceable() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_account")
.contains("available_balance = actual_balance+credit_limit-credit_used-frozen_balance")
.contains("credit_limit >= credit_used+credit_frozen")
.contains("credit_expires_on DATE NULL")
.contains("actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0")
.contains("frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0")
.contains("CREATE TABLE IF NOT EXISTS fin_recharge_request")
.contains("status IN ('PENDING','APPROVED','REJECTED')")
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_ledger")
.contains("amount <> 0")
.contains("amount <> 0 OR change_type='CREDIT_LIMIT'")
.contains("credit_expires_on_after DATE NULL")
.contains("uk_fin_wallet_ledger_no")
.contains("idx_fin_recharge_customer_status")
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_hold")
@@ -1,7 +1,6 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
@@ -9,8 +8,6 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.util.List;
@@ -48,7 +45,7 @@ class FinanceWalletBehaviorTest {
void developmentProfileUsesTheNonPayableSampleQrCode() {
var channels = new FinanceWalletService(mock(JdbcTemplate.class), new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "",
BigDecimal.ZERO, "cn", "development").channels();
"cn", "development").channels();
assertThat(channels).allSatisfy(channel -> {
assertThat(channel.enabled()).isTrue();
@@ -57,44 +54,29 @@ class FinanceWalletBehaviorTest {
}
@Test
void manualDeductionCannotExceedAvailableBalance() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForList(startsWith("SELECT request_hash"), any(Object[].class))).thenReturn(List.of());
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), any(Object[].class))).thenReturn(1);
when(jdbc.update(startsWith("INSERT IGNORE INTO fin_wallet_account"), any(Object[].class))).thenReturn(1);
when(jdbc.queryForObject(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getString("currency_code")).thenReturn("CNY");
when(rs.getBigDecimal("actual_balance")).thenReturn(new BigDecimal("10.00"));
when(rs.getBigDecimal("frozen_balance")).thenReturn(BigDecimal.ZERO);
when(rs.getBigDecimal("available_balance")).thenReturn(new BigDecimal("10.00"));
when(rs.getBigDecimal("credit_limit")).thenReturn(BigDecimal.ZERO);
when(rs.getBigDecimal("credit_used")).thenReturn(BigDecimal.ZERO);
when(rs.getBigDecimal("credit_frozen")).thenReturn(BigDecimal.ZERO);
when(rs.getInt("version")).thenReturn(2);
when(rs.getTimestamp("updated_at")).thenReturn(Timestamp.valueOf("2026-09-05 08:00:00"));
return mapper.mapRow(rs, 0);
});
void deductionMayMakeActualBalanceNegative() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
WalletAccount initial = service.accountState(new BigDecimal("10.00"), BigDecimal.ZERO,
BigDecimal.ZERO, BigDecimal.ZERO, null, 2);
assertThatThrownBy(() -> service(jdbc, "x", "y").adjust(9L, 7L, "财务员", "deduct-1",
new AccountAdjustmentRequest("DEDUCTION", new BigDecimal("10.01"), LocalDate.now(),
"BANK_TRANSFER", "REF-1", "人工扣款", 2)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-INSUFFICIENT");
verify(jdbc, never()).update(startsWith("UPDATE fin_wallet_account SET actual_balance="), any(Object[].class));
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("10.01"));
assertThat(deducted.actualBalance()).isEqualByComparingTo("-0.01");
assertThat(deducted.availableBalance()).isEqualByComparingTo("-0.01");
assertThat(deducted.creditActive()).isFalse();
}
@Test
void configuredCreditLimitIsUsedForAFirstAccountRead() {
void aMissingAccountNeverReceivesPermanentDefaultCredit() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
var account = service(jdbc, "x", "y", new BigDecimal("8000.00")).account(9L);
var account = service(jdbc, "x", "y").account(9L);
assertThat(account.creditLimit()).isEqualByComparingTo("8000.00");
assertThat(account.availableCredit()).isEqualByComparingTo("8000.00");
assertThat(account.availableBalance()).isEqualByComparingTo("8000.00");
assertThat(account.creditLimit()).isZero();
assertThat(account.availableCredit()).isZero();
assertThat(account.availableBalance()).isZero();
assertThat(account.creditExpiresOn()).isNull();
verify(jdbc, never()).update(anyString(), any(Object[].class));
}
@@ -102,7 +84,7 @@ class FinanceWalletBehaviorTest {
void deductionUsesCashBeforeCreditAndRechargeRepaysCreditFirst() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
WalletAccount initial = service.accountState(new BigDecimal("100.00"), BigDecimal.ZERO,
new BigDecimal("50.00"), BigDecimal.ZERO, BigDecimal.ZERO, 3);
new BigDecimal("50.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 3);
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("120.00"));
assertThat(deducted.actualBalance()).isEqualByComparingTo("0.00");
@@ -120,7 +102,7 @@ class FinanceWalletBehaviorTest {
void deductionNeverConsumesCashReservedByAnActiveHold() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
WalletAccount initial = service.accountState(new BigDecimal("100.00"), new BigDecimal("50.00"),
new BigDecimal("80.00"), BigDecimal.ZERO, new BigDecimal("20.00"), 4);
new BigDecimal("80.00"), new BigDecimal("20.00"), LocalDate.now().plusDays(7), 4);
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("80.00"));
@@ -135,37 +117,40 @@ class FinanceWalletBehaviorTest {
@Test
void rechargeRepaysUsedCreditWithoutReleasingFrozenCredit() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
WalletAccount initial = service.accountState(new BigDecimal("30.00"), new BigDecimal("25.00"),
new BigDecimal("100.00"), new BigDecimal("40.00"), new BigDecimal("20.00"), 5);
WalletAccount initial = service.accountState(new BigDecimal("5.00"), new BigDecimal("25.00"),
new BigDecimal("100.00"), new BigDecimal("40.00"), new BigDecimal("20.00"),
LocalDate.now().plusDays(7), 5);
WalletAccount recharged = service.rechargeAccount(initial, new BigDecimal("50.00"));
assertThat(recharged.actualBalance()).isEqualByComparingTo("40.00");
assertThat(recharged.actualBalance()).isEqualByComparingTo("15.00");
assertThat(recharged.creditUsed()).isEqualByComparingTo("0.00");
assertThat(recharged.creditFrozen()).isEqualByComparingTo("20.00");
assertThat(recharged.frozenBalance()).isEqualByComparingTo("25.00");
assertThat(recharged.availableCredit()).isEqualByComparingTo("80.00");
assertThat(recharged.availableBalance()).isEqualByComparingTo("115.00");
assertThat(recharged.availableBalance()).isEqualByComparingTo("90.00");
}
@Test
void accountStateRejectsCreditUsedAndFrozenAboveTheLimit() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
assertThatThrownBy(() -> service.accountState(new BigDecimal("10.00"), new BigDecimal("25.00"),
new BigDecimal("50.00"), new BigDecimal("31.00"), new BigDecimal("20.00"), 1))
assertThatThrownBy(() -> service.accountState(BigDecimal.ZERO, new BigDecimal("20.00"),
new BigDecimal("50.00"), new BigDecimal("31.00"), new BigDecimal("20.00"),
LocalDate.now().plusDays(7), 1))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409");
}
@Test
void accountStateRejectsFrozenCashAboveActualCash() {
void accountStateAllowsBalanceToBecomeNegativeWhileFundsRemainFrozen() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
assertThatThrownBy(() -> service.accountState(new BigDecimal("9.99"), new BigDecimal("10.00"),
new BigDecimal("20.00"), BigDecimal.ZERO, BigDecimal.ZERO, 1))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409");
WalletAccount account = service.accountState(new BigDecimal("9.99"), new BigDecimal("10.00"),
new BigDecimal("20.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 1);
assertThat(account.availableBalance()).isEqualByComparingTo("19.99");
assertThat(account.creditActive()).isTrue();
}
@Test
@@ -173,32 +158,27 @@ class FinanceWalletBehaviorTest {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
assertThatThrownBy(() -> service.accountState(new BigDecimal("100.00"), new BigDecimal("10.00"),
new BigDecimal("50.00"), BigDecimal.ZERO, new BigDecimal("10.01"), 1))
new BigDecimal("50.00"), new BigDecimal("10.01"), LocalDate.now().plusDays(7), 1))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409");
}
@Test
void invalidDefaultCreditConfigurationFailsClosed() {
assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("-0.01")))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("1.001")))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("1000000000000.00")))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
void expiredCreditNoLongerContributesToAvailableBalance() {
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
WalletAccount account = service.accountState(BigDecimal.ZERO, BigDecimal.ZERO,
new BigDecimal("50.00"), new BigDecimal("20.00"), BigDecimal.ZERO,
LocalDate.now().minusDays(1), 1);
assertThat(account.creditActive()).isFalse();
assertThat(account.availableCredit()).isZero();
assertThat(account.availableBalance()).isEqualByComparingTo("-20.00");
}
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay) {
return service(jdbc, wechat, alipay, BigDecimal.ZERO);
}
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay,
BigDecimal defaultCreditLimit) {
return new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay,
defaultCreditLimit, "test", "test");
"test", "test");
}
}
+7 -6
View File
@@ -19,8 +19,8 @@
| 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`、`client/InboundHoldFileClient.java`;凭证固定按客户、入库单和 `INBOUND_HOLD_EVIDENCE` 精确归属,只有进入不可变扣货历史的文件可下载 |
| Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 |
| 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`、`service/CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 |
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛,打印面显示 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 一件代发测量后 PDA 合托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量通过进入 `WAIT_PALLETIZE` 并下发独立任务,不创建国内库存,封托生成 `DP` 托盘号,全部箱数完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。一件代发箱唛以 `fl_inbound_carton_label` 表示物理箱,`fl_inbound_carton_label_sku` 冻结箱内一条或多条 SKU;生成时经 `client/OrderCargoClient.java` 获取。缺 SKU 时禁止生成,打印面显示箱内 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成 `DP` 托盘码并下发独立任务,不创建国内库存;托盘信息单固定为每托一页 A4,包含托盘 Code 128 一维码、入仓码、总箱数与 SKU/商品名称/箱数/实点数量,不展示作业状态。PDA 逐箱清点,支持先扫箱或先扫托、归托和逐托封托,预计托数与已生成托盘数不一致时禁止作业,全部箱和全部预生成托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
@@ -29,7 +29,7 @@
| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 对同柜同客户有效成员聚合实际计费量命中一个阶梯,再按成员计费量守恒分摊;装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
| 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` |
| 首页实时指标、客户运营统计/在途柜量 | 首页物理在途柜 TEU 与待清关票数从 `dashboard/PlatformDashboardMetricsController.java` 进入,契约见 `src/main/resources/openapi/dashboard-metrics-v1.yaml`;客户维度统计为 `customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/application/CustomerTransitStatisticsService.java`,契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` |
| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` |
| 客户入库结果与头程费用确认 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`;查询与确认接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing`、`/confirm`,契约 `src/main/resources/openapi/firstleg-v1.yaml` |
| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` |
@@ -65,7 +65,7 @@
- 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
- 一件代发以 `fl_inbound_carton_label.sku_code` 作为不可变 SKU 快照;PDA 仍只扫描稳定 `box_code`,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;历史箱唛仅能在同客户、同入库单范围内按 `inbound_detail_id` 回填,禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),拖车使用 `TRUCKING`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
@@ -85,9 +85,10 @@
## 国内货柜履约装柜与 PDA 资源结算
- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。装柜允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责;PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。
- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。
@@ -65,18 +65,18 @@ public interface OrderCargoClient {
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
String skuNo, Integer packageCount, Integer unitsPerCarton,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
String shipmentId) {
String shipmentId, String packingGroupNo) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, null,
packageCount, null, lengthCm, widthCm, heightCm, null);
packageCount, null, lengthCm, widthCm, heightCm, null, null);
}
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
String skuNo, Integer packageCount, BigDecimal lengthCm,
BigDecimal widthCm, BigDecimal heightCm, String shipmentId) {
this(cargoId, orderId, itemNo, cargoName, skuNo,
packageCount, null, lengthCm, widthCm, heightCm, shipmentId);
packageCount, null, lengthCm, widthCm, heightCm, shipmentId, null);
}
}
}
@@ -26,6 +26,12 @@ public interface PrintPdfClient {
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody FreightA4PdfRequest request);
@PostMapping(value = "/dropship-pallet-a4-documents", consumes = "application/json")
ResponseEntity<byte[]> generateDropshipPalletA4Documents(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody DropshipPalletA4PdfRequest request);
record InboundMarkPdfRequest(String templateCode, PageSpec pageSpec,
String downloadFileName, List<Label> labels) {
}
@@ -44,4 +50,15 @@ public interface PrintPdfClient {
record CargoRow(String marksAndNos, String quantity, String packaging,
String descriptionOfGoods, String grossWeight, String measurement) {}
record DropshipPalletA4PdfRequest(String templateCode, PageSpec pageSpec,
String downloadFileName, List<DropshipPalletDocument> documents) {}
record DropshipPalletDocument(String palletNo, String inboundCodes, int cartonCount,
int skuCount, BigDecimal weightKg, BigDecimal volumeCbm,
String barcodeContent, List<DropshipPalletSkuRow> skuRows) {}
record DropshipPalletSkuRow(String inboundCode, String skuCode, String cargoName,
int cartonCount, int skuQuantity) {}
}
@@ -6,6 +6,8 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
/** 获取平台仓库主数据,供虚拟柜校验国内中转仓使用。 */
@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseMasterDataClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses")
@@ -15,10 +17,17 @@ public interface WarehouseMasterDataClient {
@PathVariable long tenantId,
@PathVariable long warehouseId);
@GetMapping("/domestic")
ApiResponse<List<WarehouseOption>> domestic(
@RequestHeader("X-Internal-Service-Token") String token);
record Detail(Warehouse warehouse) {
}
record Warehouse(Long id, Long tenantId, String warehouseCode, String internalCode, String warehouseName,
String warehouseType, String status) {
}
record WarehouseOption(Long id, String warehouseCode, String warehouseName) {
}
}
@@ -39,9 +39,10 @@ public class PlatformDropshipPalletizationController {
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<DropshipPalletizationDtos.PendingTask>> pending(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Positive long warehouseId,
@RequestParam(required = false) @Size(max = 64) String keyword) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.pending(user.userId(), keyword));
return ApiResponse.success(service.pending(user.userId(), warehouseId, keyword));
}
@GetMapping("/tasks/{taskId}")
@@ -92,15 +93,6 @@ public class PlatformDropshipPalletizationController {
return ApiResponse.success(service.bindPending(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/pallets/create-and-bind")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> createAndBind(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@Valid @RequestBody DropshipPalletizationDtos.CreateAndBindRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.createAndBind(taskId, request, user.userId(), user.username()));
}
@PostMapping("/tasks/{taskId}/cartons/{cartonId}/move")
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
public ApiResponse<Task> moveCarton(@AuthenticationPrincipal AuthenticatedUser user,
@@ -121,6 +113,34 @@ public class PlatformDropshipPalletizationController {
return ApiResponse.success(service.seal(taskId, palletId, request, user.userId(), user.username()));
}
@GetMapping("/tasks/{taskId}/pallets/{palletId}/print")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ResponseEntity<byte[]> printTaskPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@PathVariable @Positive long palletId,
@RequestParam @Positive long warehouseId,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
FirstlegScopeGuard.platform(user);
Task task = service.task(taskId, warehouseId, user.userId());
Pallet pallet = task.pallets().stream()
.filter(item -> item.palletId() == palletId).findFirst()
.orElseThrow(() -> new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.NOT_FOUND, "托盘不存在"));
return printService.generateDropshipPallet(pallet, task.inboundCode(), requestId);
}
@GetMapping("/tasks/{taskId}/pallets/print")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ResponseEntity<byte[]> printTaskPallets(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long taskId,
@RequestParam @Positive long warehouseId,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
FirstlegScopeGuard.platform(user);
Task task = service.task(taskId, warehouseId, user.userId());
return printService.generateDropshipPallets(
service.generatedPallets(taskId, warehouseId, user.userId()), task.inboundCode(), requestId);
}
@GetMapping("/inbound-orders/{inboundId}/pallets")
@PreAuthorize("hasAuthority('firstleg:inbound:list')")
public ApiResponse<List<Pallet>> inboundPallets(@AuthenticationPrincipal AuthenticatedUser user,
@@ -142,6 +162,6 @@ public class PlatformDropshipPalletizationController {
.filter(item -> item.palletId() == palletId).findFirst()
.orElseThrow(() -> new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.NOT_FOUND, "托盘不存在"));
return printService.generateDropshipPallet(pallet, requestId);
return printService.generateDropshipPallet(pallet, service.inboundCode(accountId, inboundId), requestId);
}
}
@@ -20,6 +20,8 @@ import java.util.List;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.LoadingPickService;
import com.baiyue.firstleg.service.PdaWarehouseCatalogService;
import com.baiyue.firstleg.client.WarehouseMasterDataClient.WarehouseOption;
import com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService;
import org.springframework.http.ResponseEntity;
import org.springframework.http.MediaType;
@@ -51,6 +53,8 @@ public class PlatformLoadingPdaController {
@Resource
private LoadingPickService service;
@Resource
private PdaWarehouseCatalogService warehouseCatalog;
@Resource
private CabinetLoadingEvidenceService evidenceService;
@GetMapping("/tasks")
@@ -66,10 +70,18 @@ public class PlatformLoadingPdaController {
@GetMapping("/tasks/pending")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<PendingTask>> pendingTasks(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Positive long warehouseId,
@RequestParam(required = false)
@Size(max = 64) String keyword) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.pendingTasks(user.userId(), keyword));
return ApiResponse.success(service.pendingTasks(user.userId(), warehouseId, keyword));
}
@GetMapping("/warehouses")
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<List<WarehouseOption>> warehouses(@AuthenticationPrincipal AuthenticatedUser user) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(warehouseCatalog.activeDomesticWarehouses());
}
@GetMapping("/tasks/{taskId}")
@@ -75,6 +75,17 @@ public class PlatformPriceBookController {
return ApiResponse.success(service.publish(versionId, user.userId(), request));
}
@PostMapping("/{versionId}/void")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
@BusinessAudit(event = "FBA_PRICE_BOOK_VOID", resourceType = "PRICE_BOOK_VERSION", resourceId = "#versionId", description = "作废待确认船期价格表")
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> voidPending(
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
@PathVariable long versionId,
@Valid @RequestBody PriceBookDtos.PriceBookVoidRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.voidPending(versionId, user.userId(), request));
}
@GetMapping("/{versionId}/source")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
public ResponseEntity<byte[]> source(
@@ -0,0 +1,16 @@
package com.baiyue.firstleg.dashboard;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public final class DashboardMetricsDtos {
private DashboardMetricsDtos() {}
public record OperationalMetrics(
long inTransitContainerCount,
BigDecimal inTransitTeu,
long unknownContainerTypeCount,
long pendingCustomsOrderCount,
LocalDateTime asOf
) {}
}
@@ -0,0 +1,52 @@
package com.baiyue.firstleg.dashboard;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Mapper
public interface DashboardMetricsMapper {
@Select("""
SELECT transit.container_count inTransitContainerCount,
transit.total_teu inTransitTeu,
transit.unknown_container_type_count unknownContainerTypeCount,
customs.pending_customs_order_count pendingCustomsOrderCount,
CURRENT_TIMESTAMP(3) asOf
FROM (
SELECT COUNT(*) container_count,
COALESCE(SUM(container.teu), 0) total_teu,
COALESCE(SUM(CASE WHEN container.teu IS NULL THEN 1 ELSE 0 END), 0) unknown_container_type_count
FROM (
SELECT DISTINCT booking.id,
CASE
WHEN UPPER(TRIM(booking.container_type)) LIKE '20%' THEN 1.0
WHEN UPPER(TRIM(booking.container_type)) LIKE '40%' THEN 2.0
WHEN UPPER(TRIM(booking.container_type)) LIKE '45%' THEN 2.25
ELSE NULL
END teu
FROM fl_container_booking booking
JOIN fl_ocean_booking ocean ON ocean.id = booking.ocean_booking_id
WHERE booking.actual_sailing_at IS NOT NULL
AND booking.actual_warehouse_arrival_at IS NULL
AND booking.record_status = 'ACTIVE'
AND ocean.status <> 'CANCELLED'
) container
) transit
CROSS JOIN (
SELECT COUNT(DISTINCT inbound.order_id) pending_customs_order_count
FROM fl_customs_container customs_container
JOIN fl_loading_plan_item item
ON item.loading_plan_id = customs_container.loading_plan_id AND item.deleted = 0
JOIN fl_inbound_order inbound
ON inbound.id = item.inbound_id AND inbound.tenant_id = item.account_id AND inbound.deleted = 0
WHERE customs_container.status <> 'COMPLETED'
) customs
""")
MetricsProjection read();
record MetricsProjection(Long inTransitContainerCount, BigDecimal inTransitTeu,
Long unknownContainerTypeCount, Long pendingCustomsOrderCount,
LocalDateTime asOf) {}
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.dashboard;
import com.baiyue.firstleg.dashboard.DashboardMetricsDtos.OperationalMetrics;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class DashboardMetricsService {
@Resource private DashboardMetricsMapper mapper;
public OperationalMetrics read() {
var row = mapper.read();
if (row == null) return new OperationalMetrics(0, BigDecimal.ZERO, 0, 0, null);
return new OperationalMetrics(value(row.inTransitContainerCount()),
row.inTransitTeu() == null ? BigDecimal.ZERO : row.inTransitTeu(),
value(row.unknownContainerTypeCount()), value(row.pendingCustomsOrderCount()), row.asOf());
}
private static long value(Long value) {
return value == null ? 0 : value;
}
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.dashboard;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dashboard.DashboardMetricsDtos.OperationalMetrics;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/platform/v1/first-leg/dashboard")
public class PlatformDashboardMetricsController {
@Resource private DashboardMetricsService service;
@GetMapping("/operational-metrics")
@PreAuthorize("hasAuthority('platform:firstleg:container:list') and hasAuthority('platform:firstleg:customs:list')")
public ApiResponse<OperationalMetrics> metrics(@AuthenticationPrincipal AuthenticatedUser user) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.read());
}
}
@@ -1,12 +1,16 @@
package com.baiyue.firstleg.domain;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/** 首版固定渠道目录;上传名称只能映射到这些稳定编码。 */
public final class PriceBookChannelCatalog {
private PriceBookChannelCatalog() {}
private static final Set<String> SEA_RAIL_CHANNEL_CODES = Set.of(
"EXX_RAIL_NY", "NYEXX", "OA_CHICAGO_RAIL", "CPHT");
public static final List<ChannelDefinition> CHANNELS = List.of(
fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡", "ZBYHK", "ZBYHWK"),
fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡", "BYHK", "BYHWK"),
@@ -37,6 +41,11 @@ public final class PriceBookChannelCatalog {
inboundCodePrefix, overseasInboundCodePrefix);
}
public static boolean isSeaRail(String channelCode) {
return channelCode != null
&& SEA_RAIL_CHANNEL_CODES.contains(channelCode.trim().toUpperCase(Locale.ROOT));
}
public record ChannelDefinition(
String channelCode,
String displayName,
@@ -5,5 +5,6 @@ public enum PriceBookStatus {
PENDING_CONFIRMATION,
ACTIVE,
SUPERSEDED,
VOIDED,
FAILED
}
@@ -0,0 +1,64 @@
package com.baiyue.firstleg.domain;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/** Stable planning groups used by simulated containers and smart-loading filters. */
public final class SmartLoadingChannelGroupCatalog {
private SmartLoadingChannelGroupCatalog() {}
public static final List<Definition> GROUPS = List.of(
group("MATSON_CLX", "美森正班", "MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL", "ZBYHK", "ZBYHWK", "ZBYHP"),
group("MATSON_MAX", "美森加班", "MATSON_MAX_TRUCK", "MATSON_MAX_PARCEL", "BYHK", "BYHWK", "BYHP"),
group("EXX", "EXX", "EXX_TRUCK", "EXX_RAIL_NY", "EXXHK", "EXXHWK", "NYEXX"),
group("HEDE", "合德", "HEDE_TRUCK", "HEDE_PARCEL", "HDHK", "HDHWK", "HDHP"),
group("OA_LA", "洛杉矶普船", "OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_PARCEL", "PCHK", "PCHWK", "PCKK", "PCHP"),
group("OA_CHICAGO", "芝加哥普船", "OA_CHICAGO_RAIL", "CPHT"),
group("OA_HOUSTON", "休斯顿普船", "OA_HOUSTON_TRUCK", "HPHK"),
group("OA_SAVANNAH", "萨凡纳普船", "OA_SAVANNAH_TRUCK", "SPHK"),
group("OA_NEW_YORK_TRUCK", "纽约普船海卡", "OA_NEW_YORK_TRUCK", "NPHK", "NPHWK")
);
private static final Map<String, Definition> BY_CODE = indexByCode();
private static final Map<String, Definition> BY_CHANNEL = indexByChannel();
public static Definition require(String value) {
Definition result = BY_CODE.get(normalize(value));
if (result == null) throw new IllegalArgumentException("不支持的渠道分组: " + value);
return result;
}
public static Definition resolveChannel(String channelCode) {
return BY_CHANNEL.get(normalize(channelCode));
}
public static boolean contains(String groupCode, String channelCode) {
Definition group = BY_CODE.get(normalize(groupCode));
return group != null && group.channelCodes().contains(normalize(channelCode));
}
private static Definition group(String code, String name, String... channelCodes) {
return new Definition(code, name, Set.of(channelCodes));
}
private static String normalize(String value) {
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
}
private static Map<String, Definition> indexByCode() {
Map<String, Definition> result = new LinkedHashMap<>();
GROUPS.forEach(group -> result.put(group.code(), group));
return Map.copyOf(result);
}
private static Map<String, Definition> indexByChannel() {
Map<String, Definition> result = new LinkedHashMap<>();
GROUPS.forEach(group -> group.channelCodes().forEach(channel -> result.put(channel, group)));
return Map.copyOf(result);
}
public record Definition(String code, String name, Set<String> channelCodes) {}
}
@@ -83,7 +83,7 @@ public final class ContainerDtos {
) {}
@Schema(name = "ContainerSimulationCreateRequest", description = "新增内部模拟柜;内部柜号由服务端生成")
public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelCode,
public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelGroupCode,
@NotNull @Positive Long warehouseId, @NotBlank @Size(max = 32) String containerType,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
@@ -4,7 +4,10 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.Valid;
import java.time.LocalDateTime;
import java.util.List;
@@ -15,19 +18,20 @@ public final class DropshipPalletizationDtos {
public record ClaimRequest(@Positive long warehouseId) {}
public record CreateAndBindRequest(@Positive long warehouseId,
@NotBlank @Size(max = 128) String operationId) {}
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
public record CountCartonRequest(@Positive long warehouseId,
@NotBlank @Size(max = 80) String boxCode,
@Positive int actualSkuQuantity,
@NotNull DiscrepancyAction discrepancyAction,
@Size(max = 500) String discrepancyRemark,
@NotEmpty @Valid List<CountSkuLine> skuLines,
@Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo,
@NotBlank @Size(max = 128) String operationId) {}
public record CountSkuLine(@Positive long inboundDetailId,
@NotBlank @Size(max = 128) String skuCode,
@PositiveOrZero int actualQuantity,
@NotNull DiscrepancyAction discrepancyAction,
@Size(max = 500) String discrepancyRemark) {}
public record BindPendingRequest(@Positive long warehouseId,
@NotBlank @Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo,
@NotBlank @Size(max = 128) String operationId) {}
@@ -49,7 +53,9 @@ public final class DropshipPalletizationDtos {
public record Task(Long taskId, Long inboundId, String inboundCode, Long tenantId,
String customerName, Long warehouseId, String warehouseName,
Integer totalCartons, Integer countedCartons, Integer boundCartons,
Integer unboundCartons, Integer discrepancyCartons, String taskStatus,
Integer unboundCartons, Integer discrepancyCartons,
Integer expectedPalletCount, Integer generatedPalletCount,
Boolean palletSetupReady, String palletSetupMessage, String taskStatus,
Long assigneeId, String assigneeName, Integer version,
List<Pallet> pallets, List<Carton> unbound) {}
@@ -64,12 +70,14 @@ public final class DropshipPalletizationDtos {
List<String> cartonMarks) {}
public record Carton(Long cartonId, String boxCode, Long inboundId, String inboundCode,
Long inboundDetailId, String skuCode, String cargoName,
Integer expectedSkuQuantity, Integer actualSkuQuantity,
String discrepancyStatus, String discrepancyRemark,
List<CartonSkuLine> skuLines,
String countStatus, Long palletId, String palletNo,
Boolean canCount, String blockReason) {}
public record CartonSkuLine(Long inboundDetailId, String skuCode, String cargoName,
Integer expectedQuantity, Integer actualQuantity,
String discrepancyStatus, String discrepancyRemark) {}
public record CountResult(Carton carton, Integer countedCartons, Integer boundCartons,
Integer unboundCartons, Integer remainingCartons, Pallet pallet) {}
}
@@ -28,8 +28,14 @@ public final class InboundMeasurementRequests {
Long measurementId,
@NotNull @PositiveOrZero Integer version,
@Size(max = 255) String actualGoodsName,
@Positive @Max(1000) Integer expectedPalletCount,
@NotEmpty @Size(max = 500) List<@Valid ItemRequest> items
) {
public SaveDraftRequest(Long measurementId, Integer version, String actualGoodsName,
List<ItemRequest> items) {
this(measurementId, version, actualGoodsName, null, items);
}
@JsonAnySetter
public void rejectUnknownField(String fieldName, Object ignored) {
throw new IllegalArgumentException("测量草稿不接受字段: " + fieldName);
@@ -19,6 +19,8 @@ public final class InboundMeasurementResponses {
String companyName,
String channelCode,
String channelName,
String fulfillmentType,
Integer expectedPalletCount,
String processStatus,
String measureReviewStatus,
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
@@ -47,7 +47,7 @@ public final class LoadingPickDtos {
) {
}
/** PDA 在线草稿只在员工提交时发送一次,服务端原子处理整批明细。 */
/** PDA 已即时提交的明细不重复包含;收尾时服务端原子处理剩余全部待拣明细。 */
public record BatchRequest(
@NotNull @Min(1) Long warehouseId,
@NotNull @Min(1) Long loadingPlanId,
@@ -38,7 +38,16 @@ public final class PriceBookDtos {
LocalDateTime createdAt,
Long publishedBy,
LocalDateTime publishedAt,
LocalDateTime supersededAt
LocalDateTime supersededAt,
Long voidedBy,
LocalDateTime voidedAt,
String voidReason
) {}
public record PriceBookVoidRequest(
@NotBlank(message = "作废原因不能为空")
@Size(max = 500, message = "作废原因不能超过500个字符")
String reason
) {}
public record PriceBookVersionDetail(
@@ -49,7 +49,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
}
}
public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo,
Long warehouseId, String warehouseName, String channelCode, String channelName, String containerType,
Long warehouseId, String warehouseName, String channelGroupCode, String channelCode, String channelName, String containerType,
BigDecimal capacityCbm, BigDecimal maxWeightKg,
Long planId, String planNo, String planStatus, Integer version) {}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
@@ -24,6 +24,7 @@ public class ContainerBookingEntity {
private String sourceType;
private String recordStatus;
private Long mergedIntoContainerBookingId;
private String channelGroupCode;
private String channelCode;
private String channelName;
private Long oceanBookingId;
@@ -28,4 +28,7 @@ public class PriceBookVersionEntity extends BaseEntity {
private Long publishedBy;
private LocalDateTime publishedAt;
private LocalDateTime supersededAt;
private Long voidedBy;
private LocalDateTime voidedAt;
private String voidReason;
}
@@ -134,8 +134,7 @@ public class LoadingModelBridgeService {
var preload = repository.preload(tenantId, inboundId);
if (preload == null) throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在");
boolean running = "LOADING".equals(plan.status());
if (!Set.of("PENDING", "CONFIRMED").contains(preload.status())
&& !(running && "CONFIRMING".equals(preload.status()))) {
if (!Set.of("PENDING", "CONFIRMING", "CONFIRMED").contains(preload.status())) {
throw conflict("新增入库单预装状态不允许进入当前柜次");
}
PreloadLoadingPlanLinkEntity link = new PreloadLoadingPlanLinkEntity();
@@ -13,11 +13,6 @@ import java.util.List;
/** 一件代发逐箱清点、待绑托与封托事实。 */
@Mapper
public interface DropshipPalletizationMapper {
@Select("SELECT id FROM fl_dropship_pallet_task WHERE id=#{taskId} AND warehouse_id=#{warehouseId} FOR UPDATE")
Long lockTask(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId);
@Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}")
PalletRow palletByOpenOperation(@Param("operationId") String operationId);
@Insert("""
INSERT IGNORE INTO fl_dropship_pallet_task
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
@@ -30,13 +25,16 @@ public interface DropshipPalletizationMapper {
o.warehouse_name,t.total_cartons,t.counted_cartons,t.allocated_cartons bound_cartons,
t.counted_cartons-t.allocated_cartons unbound_cartons,t.discrepancy_cartons,t.task_status,t.created_at
FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id
WHERE t.task_status IN ('PENDING','CLAIMED') AND (t.assignee_id IS NULL OR t.assignee_id=#{operatorId})
WHERE t.task_status IN ('PENDING','CLAIMED') AND t.warehouse_id=#{warehouseId}
AND (t.assignee_id IS NULL OR t.assignee_id=#{operatorId})
AND (#{keyword} IS NULL OR o.inbound_code LIKE CONCAT('%',#{keyword},'%') OR o.customer_name LIKE CONCAT('%',#{keyword},'%'))
ORDER BY t.created_at,t.id""")
List<PendingTask> pending(@Param("operatorId") long operatorId,@Param("keyword") String keyword);
List<PendingTask> pending(@Param("operatorId") long operatorId,@Param("warehouseId") long warehouseId,
@Param("keyword") String keyword);
@Select("""
SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type
SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type,
o.pallet_count expected_pallet_count
FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id
WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId}""")
TaskRow task(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId);
@@ -58,12 +56,31 @@ public interface DropshipPalletizationMapper {
@Param("inboundId") long inboundId,@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo,
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("SELECT * FROM fl_dropship_pallet WHERE pallet_no=#{palletNo}") PalletRow palletByNo(@Param("palletNo") String palletNo);
@Select("SELECT * FROM fl_dropship_pallet WHERE pallet_no=#{palletNo} FOR UPDATE") PalletRow palletByNo(@Param("palletNo") String palletNo);
@Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId}") int palletCount(@Param("taskId") long taskId);
@Select("SELECT * FROM fl_dropship_pallet WHERE seal_operation_id=#{operationId}") PalletRow palletBySealOperation(@Param("operationId") String operationId);
@Select("SELECT * FROM fl_dropship_pallet WHERE id=#{palletId} AND task_id=#{taskId} AND warehouse_id=#{warehouseId}")
@Select("""
SELECT p.* FROM fl_dropship_pallet p
WHERE p.id=#{palletId} AND p.warehouse_id=#{warehouseId}
AND (p.task_id=#{taskId} OR EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=p.id AND c.task_id=#{taskId}))
""")
PalletRow pallet(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("warehouseId") long warehouseId);
@Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id") List<PalletRow> pallets(@Param("taskId") long taskId);
@Select("SELECT p.* FROM fl_dropship_pallet p JOIN fl_dropship_pallet_task t ON t.id=p.task_id WHERE t.tenant_id=#{tenantId} AND t.inbound_id=#{inboundId} ORDER BY p.id")
@Select("""
SELECT p.* FROM fl_dropship_pallet p
WHERE EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=p.id AND c.task_id=#{taskId})
ORDER BY p.id
""") List<PalletRow> pallets(@Param("taskId") long taskId);
@Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id")
List<PalletRow> generatedPallets(@Param("taskId") long taskId);
@Select("""
SELECT p.* FROM fl_dropship_pallet p
WHERE ((p.tenant_id=#{tenantId} AND p.inbound_id=#{inboundId})
AND EXISTS(SELECT 1 FROM fl_inbound_order own
WHERE own.id=#{inboundId} AND own.tenant_id=#{tenantId} AND own.process_status='WAIT_PALLETIZE'))
OR EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c
WHERE c.pallet_id=p.id AND c.tenant_id=#{tenantId} AND c.inbound_id=#{inboundId})
ORDER BY p.id
""")
List<PalletRow> palletsByInbound(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId);
@Select("SELECT inbound_code FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
String inboundCode(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId);
@@ -73,6 +90,12 @@ public interface DropshipPalletizationMapper {
FROM fl_inbound_carton_label l JOIN fl_dropship_pallet_task t ON t.inbound_id=l.inbound_id AND t.tenant_id=l.tenant_id
WHERE t.id=#{taskId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE'""")
LabelRow label(@Param("taskId") long taskId,@Param("boxCode") String boxCode);
@Select("""
SELECT s.inbound_detail_id,s.sku_code,s.cargo_name,s.expected_quantity,s.line_no
FROM fl_inbound_carton_label_sku s JOIN fl_inbound_carton_label l ON l.id=s.carton_label_id
JOIN fl_dropship_pallet_task t ON t.inbound_id=l.inbound_id AND t.tenant_id=l.tenant_id
WHERE t.id=#{taskId} AND l.id=#{labelId} ORDER BY s.line_no
""") List<LabelSkuRow> labelSkus(@Param("taskId") long taskId,@Param("labelId") long labelId);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.count_operation_id=#{operationId}") CartonRow cartonByCountOperation(@Param("operationId") String operationId);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.id=#{cartonId}") CartonRow carton(@Param("taskId") long taskId,@Param("cartonId") long cartonId);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.carton_label_id=#{labelId}")
@@ -94,6 +117,24 @@ public interface DropshipPalletizationMapper {
@Param("discrepancyStatus") String discrepancyStatus,@Param("discrepancyRemark") String discrepancyRemark,
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_dropship_pallet_carton_sku
(id,carton_id,inbound_detail_id,sku_code,cargo_name,expected_quantity,actual_quantity,
discrepancy_status,discrepancy_remark,line_no)
VALUES(#{id},#{cartonId},#{detailId},#{skuCode},#{cargoName},#{expectedQuantity},#{actualQuantity},
#{discrepancyStatus},#{discrepancyRemark},#{lineNo})
""")
int insertCartonSku(@Param("id") long id,@Param("cartonId") long cartonId,@Param("detailId") long detailId,
@Param("skuCode") String skuCode,@Param("cargoName") String cargoName,
@Param("expectedQuantity") int expectedQuantity,@Param("actualQuantity") int actualQuantity,
@Param("discrepancyStatus") String discrepancyStatus,@Param("discrepancyRemark") String discrepancyRemark,
@Param("lineNo") int lineNo);
@Select("""
SELECT inbound_detail_id,sku_code,cargo_name,expected_quantity,actual_quantity,discrepancy_status,discrepancy_remark,line_no
FROM fl_dropship_pallet_carton_sku WHERE carton_id=#{cartonId} ORDER BY line_no
""") List<CartonSkuRow> cartonSkus(@Param("cartonId") long cartonId);
@Update("""
UPDATE fl_dropship_pallet_task SET counted_cartons=counted_cartons+1,allocated_cartons=allocated_cartons+#{boundCount},
discrepancy_cartons=discrepancy_cartons+#{discrepancyCount},version=version+1,updated_at=CURRENT_TIMESTAMP(3)
@@ -123,65 +164,83 @@ public interface DropshipPalletizationMapper {
@Update("""
UPDATE fl_dropship_pallet SET pallet_status='SEALED',seal_operation_id=#{operationId},completed_by=#{operatorId},
completed_by_name=#{operatorName},completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{palletId} AND task_id=#{taskId} AND pallet_status='OPEN' AND version=#{expectedVersion}
WHERE id=#{palletId} AND warehouse_id=#{warehouseId} AND pallet_status='OPEN' AND version=#{expectedVersion}
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId})
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL)""")
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} AND c.task_id=#{taskId})""")
int seal(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("expectedVersion") int expectedVersion,
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId} AND pallet_status='OPEN'") int openPalletCount(@Param("taskId") long taskId);
@Param("warehouseId") long warehouseId,@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Select("""
SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet_carton c
JOIN fl_dropship_pallet p ON p.id=c.pallet_id
WHERE c.task_id=#{taskId} AND p.pallet_status='OPEN'
""") int openPalletCount(@Param("taskId") long taskId);
@Select("SELECT DISTINCT task_id FROM fl_dropship_pallet_carton WHERE pallet_id=#{palletId} ORDER BY task_id")
List<Long> taskIdsByPallet(@Param("palletId") long palletId);
@Update("""
UPDATE fl_dropship_pallet_task SET task_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{taskId} AND task_status='CLAIMED' AND counted_cartons=total_cartons AND allocated_cartons=total_cartons
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet p WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId);
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c JOIN fl_dropship_pallet p ON p.id=c.pallet_id
WHERE c.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId);
@Update("""
UPDATE fl_inbound_order o SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(*) FROM fl_dropship_pallet p
WHERE p.inbound_id=o.id AND p.pallet_status='SEALED'),o.updated_by=#{operatorId},o.version=o.version+1
UPDATE fl_inbound_order o SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(DISTINCT p.id)
FROM fl_dropship_pallet_carton c JOIN fl_dropship_pallet p ON p.id=c.pallet_id
WHERE c.tenant_id=o.tenant_id AND c.inbound_id=o.id AND p.pallet_status='SEALED'),o.updated_by=#{operatorId},o.version=o.version+1
WHERE o.id=#{inboundId} AND o.tenant_id=#{tenantId} AND o.process_status='WAIT_PALLETIZE'
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_task t WHERE t.id=#{taskId} AND t.task_status='COMPLETED')""")
int completeInbound(@Param("taskId") long taskId,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,@Param("operatorId") long operatorId);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.pallet_id=#{palletId} ORDER BY c.sku_code,c.inbound_detail_id,c.id") List<CartonRow> cartons(@Param("palletId") long palletId);
@Select("""
SELECT c.*,p.pallet_no,o.inbound_code FROM fl_dropship_pallet_carton c
LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
WHERE c.pallet_id=#{palletId} ORDER BY o.inbound_code,c.sku_code,c.inbound_detail_id,c.id
""") List<CartonRow> cartons(@Param("palletId") long palletId);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL ORDER BY c.counted_at,c.id") List<CartonRow> unboundCartons(@Param("taskId") long taskId);
@Select("""
SELECT COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg,
COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
LEFT JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId}
GROUP BY p.id,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count""")
SELECT COALESCE(SUM(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0)),0) weight_kg,
COALESCE(SUM(o.actual_volume_cbm/NULLIF(o.total_package_count,0)),0) volume_cbm
FROM fl_dropship_pallet_carton c
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
WHERE c.pallet_id=#{palletId}""")
PalletMetrics metrics(@Param("palletId") long palletId);
@Select("""
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(c.id) carton_count,
COUNT(DISTINCT c.sku_code) sku_count,
COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg,
COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
WHERE p.inbound_id=#{inboundId} AND p.pallet_status='SEALED'
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(DISTINCT c.id) carton_count,
(SELECT COUNT(DISTINCT sku.sku_code) FROM fl_dropship_pallet_carton pc
JOIN fl_dropship_pallet_carton_sku sku ON sku.carton_id=pc.id WHERE pc.pallet_id=p.id) sku_count,
COALESCE(SUM(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0)),0) weight_kg,
COALESCE(SUM(o.actual_volume_cbm/NULLIF(o.total_package_count,0)),0) volume_cbm
FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
WHERE EXISTS(SELECT 1 FROM fl_dropship_pallet_carton own WHERE own.pallet_id=p.id AND own.inbound_id=#{inboundId})
AND p.pallet_status='SEALED'
AND NOT EXISTS(SELECT 1 FROM fl_loading_plan_item i WHERE i.pallet_id=p.id AND i.deleted=0)
GROUP BY p.id,p.pallet_no,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count ORDER BY p.id
GROUP BY p.id,p.pallet_no ORDER BY p.id
""") List<PalletCandidate> palletCandidates(@Param("inboundId") long inboundId);
@Select("""
SELECT p.id pallet_id,p.pallet_no,p.pallet_status,p.tenant_id account_id,p.inbound_id,o.order_id,o.order_no,
SELECT p.id pallet_id,p.pallet_no,p.pallet_status,c.tenant_id account_id,c.inbound_id,o.order_id,o.order_no,
o.inbound_code,o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,
o.destination_code destination_warehouse_code,o.destination_name destination_warehouse_name,o.customer_name,
c.inbound_detail_id,c.sku_code,MAX(c.cargo_name) cargo_name,COUNT(c.id) carton_count,
SUM(c.actual_sku_quantity) actual_sku_quantity,
s.inbound_detail_id,s.sku_code,MAX(s.cargo_name) cargo_name,COUNT(DISTINCT c.id) carton_count,
SUM(s.actual_quantity) actual_sku_quantity,
COALESCE(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0),0) unit_gross_weight_kg,
COALESCE(o.actual_net_weight_kg/NULLIF(o.total_package_count,0),0) unit_net_weight_kg,
COALESCE(o.actual_volume_cbm/NULLIF(o.total_package_count,0),0) unit_volume_cbm
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId}
GROUP BY p.id,p.pallet_no,p.pallet_status,p.tenant_id,p.inbound_id,o.order_id,o.order_no,o.inbound_code,
FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
JOIN fl_dropship_pallet_carton_sku s ON s.carton_id=c.id
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id WHERE p.id=#{palletId}
GROUP BY p.id,p.pallet_no,p.pallet_status,c.tenant_id,c.inbound_id,o.order_id,o.order_no,o.inbound_code,
o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,o.destination_code,o.destination_name,
o.customer_name,c.inbound_detail_id,c.sku_code,o.actual_gross_weight_kg,o.actual_net_weight_kg,o.actual_volume_cbm,o.total_package_count
ORDER BY c.inbound_detail_id
o.customer_name,s.inbound_detail_id,s.sku_code,o.actual_gross_weight_kg,o.actual_net_weight_kg,o.actual_volume_cbm,o.total_package_count
ORDER BY s.inbound_detail_id
""") List<PalletLine> sealedPalletLines(@Param("palletId") long palletId);
@Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,version; }
@Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,expectedPalletCount,version; }
@Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; }
@Getter @Setter class LabelRow { private Long cartonLabelId,inboundDetailId; private String skuCode,cargoName,inboundCode,boxCode; private Integer unitsPerCarton; }
@Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; }
@Getter @Setter class LabelSkuRow { private Long inboundDetailId; private String skuCode,cargoName; private Integer expectedQuantity,lineNo; }
@Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,inboundCode,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; }
@Getter @Setter class CartonSkuRow { private Long inboundDetailId; private String skuCode,cargoName,discrepancyStatus,discrepancyRemark; private Integer expectedQuantity,actualQuantity,lineNo; }
record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {}
@Getter @Setter class PalletLine { private Long palletId,tenantId,accountId,inboundId,orderId,warehouseId,destinationWarehouseId,inboundDetailId; private String palletNo,palletStatus,orderNo,inboundCode,channelCode,channelName,destinationWarehouseCode,destinationWarehouseName,customerName,skuCode,cargoName; private Integer cartonCount,actualSkuQuantity; private BigDecimal unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm; }
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.mapper;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** A physical carton label may freeze more than one SKU line. */
@Mapper
public interface InboundCartonLabelSkuMapper {
@Insert("""
INSERT INTO fl_inbound_carton_label_sku
(id,tenant_id,inbound_id,carton_label_id,inbound_detail_id,sku_code,cargo_name,expected_quantity,line_no,created_at)
VALUES(#{id},#{tenantId},#{inboundId},#{labelId},#{detailId},#{skuCode},#{cargoName},#{expectedQuantity},#{lineNo},CURRENT_TIMESTAMP(3))
""")
int insert(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("labelId") long labelId,
@Param("detailId") long detailId, @Param("skuCode") String skuCode,
@Param("cargoName") String cargoName, @Param("expectedQuantity") int expectedQuantity,
@Param("lineNo") int lineNo);
@Select("""
SELECT inbound_detail_id,sku_code,cargo_name,expected_quantity,line_no
FROM fl_inbound_carton_label_sku WHERE carton_label_id=#{labelId} ORDER BY line_no
""")
List<SkuRow> findByLabel(@Param("labelId") long labelId);
@Select("""
SELECT carton_label_id,inbound_detail_id,sku_code,cargo_name,expected_quantity,line_no
FROM fl_inbound_carton_label_sku WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId}
ORDER BY carton_label_id,line_no
""")
List<SkuRow> findByInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Getter @Setter
class SkuRow {
private Long inboundDetailId;
private Long cartonLabelId;
private String skuCode;
private String cargoName;
private Integer expectedQuantity;
private Integer lineNo;
}
}
@@ -85,7 +85,8 @@ public interface LoadingCompletedEventMapper {
pallet.pallet_no AS pallet_code,NULL AS allocation_no,
COUNT(*) OVER (PARTITION BY pallet.id,carton.tenant_id,carton.inbound_id) AS allocated_package_count,
carton.inbound_detail_id AS source_inbound_detail_id,carton.sku_code,
item.product_name_zh,item.package_count,item.product_quantity,carton.box_code
item.product_name_zh,item.package_count,item.product_quantity,carton.box_code,
carton.actual_sku_quantity
FROM fl_dropship_pallet pallet
JOIN fl_dropship_pallet_carton carton ON carton.pallet_id=pallet.id
JOIN fl_loading_plan_item item ON item.loading_plan_id=#{planId}
@@ -98,15 +99,17 @@ public interface LoadingCompletedEventMapper {
@Select("""
SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p
JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id
AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='SEALED'
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=c.tenant_id
AND i.inbound_id=c.inbound_id AND i.deleted=0 WHERE p.pallet_status='SEALED'
""")
int workPalletCount(@Param("planId") long planId);
@Select("""
SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p
JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id
AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='OPEN'
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=c.tenant_id
AND i.inbound_id=c.inbound_id AND i.deleted=0 WHERE p.pallet_status='OPEN'
""")
int openWorkPalletCount(@Param("planId") long planId);
@@ -138,6 +141,6 @@ public interface LoadingCompletedEventMapper {
private Boolean actualBinding;
private Integer allocationNo; private Integer allocatedPackageCount;
private Long sourceInboundDetailId; private String skuCode; private String productNameZh;
private Integer packageCount; private Integer productQuantity; private String boxCode;
private Integer packageCount; private Integer productQuantity; private Integer actualSkuQuantity; private String boxCode;
}
}
@@ -23,6 +23,11 @@ public interface LoadingListQueryMapper {
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.channel_code,
(SELECT MIN(outbound.confirmed_at) FROM fl_outbound_dispatch outbound
WHERE outbound.container_booking_id = p.container_booking_id AND outbound.confirmed_at IS NOT NULL
AND outbound.reversed_at IS NULL AND outbound.status = 'WAITING_FOR_LOADING') AS outbound_confirmed_at,
b.actual_empty_reported_at,
b.vessel_name, b.voyage_no, b.container_type,
(SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link
WHERE preload_link.loading_plan_id = p.id) AS preload_link_count,
@@ -51,6 +56,7 @@ public interface LoadingListQueryMapper {
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
p.created_by, p.created_by_name, p.created_at, p.version,
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.channel_code, b.actual_empty_reported_at,
b.vessel_name, b.voyage_no, b.container_type
""";
@@ -394,6 +400,9 @@ public interface LoadingListQueryMapper {
private String cabinetNo;
private Long warehouseId;
private String warehouseName;
private String channelCode;
private LocalDateTime outboundConfirmedAt;
private LocalDateTime actualEmptyReportedAt;
private String vesselName;
private String voyageNo;
private String containerType;
@@ -199,7 +199,7 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
AND plan_item.deleted = 0)
AND item_status IN ('PENDING', 'PICKING')
AND picked_package_count < #{finalCount}
AND picked_package_count <= #{finalCount}
AND #{finalCount} < planned_package_count
""")
int partial(@Param("id") long id, @Param("finalCount") int finalCount,
@@ -49,6 +49,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE booking.container_no = #{containerNo} AND booking.record_status = 'ACTIVE'
AND booking.warehouse_id = #{warehouseId}
AND task.task_status = 'LOADING'
LIMIT 1
""")
@@ -58,12 +59,14 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
+ "AND booking.warehouse_id=#{warehouseId} "
+ "AND booking.cabinet_no=#{cabinetNo} LIMIT 1")
LoadingPickTaskEntity selectLoadingByCabinet(@Param("cabinetNo") String cabinetNo, @Param("warehouseId") long warehouseId);
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
+ "AND booking.warehouse_id=#{warehouseId} "
+ "AND task.loading_plan_id=#{loadingPlanId} LIMIT 1")
LoadingPickTaskEntity selectLoadingByPlan(@Param("loadingPlanId") long loadingPlanId, @Param("warehouseId") long warehouseId);
@@ -97,6 +100,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id = task.id
WHERE task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED') AND booking.record_status = 'ACTIVE'
AND booking.warehouse_id = #{warehouseId}
AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId})
<if test="keyword != null and keyword != ''">
AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%')
@@ -112,6 +116,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
</script>
""")
List<PendingTaskRow> selectPendingForOperator(@Param("operatorId") long operatorId,
@Param("warehouseId") long warehouseId,
@Param("keyword") String keyword);
class PendingTaskRow {
@@ -321,7 +321,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
@Select("""
<script>
SELECT b.id bookingId,b.booking_no bookingNo,b.cabinet_no cabinetNo,b.container_no containerNo,
b.warehouse_id warehouseId,b.warehouse_name warehouseName,b.channel_code channelCode,
b.warehouse_id warehouseId,b.warehouse_name warehouseName,b.channel_group_code channelGroupCode,b.channel_code channelCode,
b.channel_name channelName,b.container_type containerType,b.capacity_cbm capacityCbm,
b.max_weight_kg maxWeightKg,p.id planId,p.plan_no planNo,
CASE WHEN p.id IS NULL OR NOT EXISTS (SELECT 1 FROM fl_loading_plan_item state_item
@@ -331,8 +331,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
LEFT JOIN fl_loading_plan p ON p.container_booking_id=b.id
WHERE b.record_status='ACTIVE'
AND (p.id IS NULL OR p.plan_status IN ('EDITING','LOADING'))
AND b.channel_code IN
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach>
AND b.channel_group_code IN
<foreach collection="channelGroups" item="channelGroup" open="(" separator="," close=")">#{channelGroup}</foreach>
<if test="query.warehouseId != null">AND b.warehouse_id=#{query.warehouseId}</if>
<if test="query.customerId != null or query.inboundCode != null or query.createdStart != null or query.createdEnd != null">
AND EXISTS (
@@ -351,7 +351,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
""")
Page<WorkbenchContainer> workbenchContainers(Page<WorkbenchContainer> page,
@Param("query") WorkbenchContainerQuery query,
@Param("actualChannels") Set<String> actualChannels);
@Param("channelGroups") Set<String> channelGroups);
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
@@ -29,7 +29,7 @@ public final class OceanBookingDtos {
}
public record LinkCandidateQuery(@NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 64) String channelGroupCode,
@NotBlank @Size(max = 32) String containerType,
@Min(1) Long page,
@Min(1) @Max(200) Long size) {
@@ -54,6 +54,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
Page<OceanBookingEntity> selectPage(Page<OceanBookingEntity> page, @Param("query") Query query);
@Select("""
<script>
SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id,
requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg,
requirement.quantity required_quantity,
@@ -75,13 +76,16 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
AND allocated.requirement_id=requirement.id
WHERE booking.status='RELEASED'
AND booking.warehouse_id=#{query.warehouseId}
AND booking.channel_code=#{query.channelCode}
AND booking.channel_code IN
<foreach collection="channelCodes" item="channelCode" open="(" separator="," close=")">#{channelCode}</foreach>
AND requirement.container_type=#{query.containerType}
AND requirement.quantity>COALESCE(allocated.allocated_quantity,0)
ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC
</script>
""")
Page<LinkCandidate> selectLinkCandidates(Page<LinkCandidate> page,
@Param("query") LinkCandidateQuery query);
@Param("query") LinkCandidateQuery query,
@Param("channelCodes") java.util.Set<String> channelCodes);
@Update("""
UPDATE fl_ocean_booking SET status=#{toStatus},
@@ -33,8 +33,8 @@ public class OceanBookingRepository {
public PageResult<OceanBookingEntity> page(Query query) {
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
}
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query) {
return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query));
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query, java.util.Set<String> channelCodes) {
return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query, channelCodes));
}
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
public OceanBookingEntity findForUpdate(long id) { return bookings.selectForUpdate(id); }
@@ -15,7 +15,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
@@ -47,7 +46,7 @@ public class OceanBookingPrintService {
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
fields(template, detail), List.of(cargo(detail.containerRequirements())))));
fields(template, detail), List.of(emptyCargo()))));
try {
ResponseEntity<byte[]> response = print.generateFreightA4Documents(internalToken, requestId, request);
MediaType contentType = response.getHeaders().getContentType();
@@ -78,12 +77,12 @@ public class OceanBookingPrintService {
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
fields.put("serviceTerm", text(booking.getServiceTerm()));
fields.put("freightTerm", text(booking.getFreightTerm()));
fields.put("totalPackagesInWords", Integer.toString(totalContainers(detail.containerRequirements())));
fields.put("totalPackagesInWords", "");
if (SHIPPING_INSTRUCTION.equals(template)) {
fields.put("toCompany", text(booking.getBookingAgentName()));
fields.put("jobNumber", text(booking.getBookingNo()));
fields.put("etdDate", date(booking.getEstimatedSailingAt()));
fields.put("cargoQuantity", Integer.toString(totalContainers(detail.containerRequirements())));
fields.put("cargoQuantity", "");
fields.put("carrier", text(booking.getShippingCompanyName()));
fields.put("bookingAgent", text(booking.getBookingAgentName()));
fields.put("remark", text(booking.getRemark()));
@@ -99,18 +98,8 @@ public class OceanBookingPrintService {
return fields;
}
private static PrintPdfClient.CargoRow cargo(List<OceanBookingContainerRequirementEntity> requirements) {
BigDecimal gross = requirements.stream().map(value -> value.getMaxWeightKg()
.multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal volume = requirements.stream().map(value -> value.getCapacityCbm()
.multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add);
return new PrintPdfClient.CargoRow("N/M", Integer.toString(totalContainers(requirements)),
"CONTAINERS", requirementSummary(requirements), decimal(gross, 3) + " KGS",
decimal(volume, 4) + " CBM");
}
private static int totalContainers(List<OceanBookingContainerRequirementEntity> values) {
return values.stream().mapToInt(OceanBookingContainerRequirementEntity::getQuantity).sum();
private static PrintPdfClient.CargoRow emptyCargo() {
return new PrintPdfClient.CargoRow("—", "—", "—", "—", "—", "—");
}
private static String requirementSummary(List<OceanBookingContainerRequirementEntity> values) {
@@ -128,9 +117,6 @@ public class OceanBookingPrintService {
private static String date(java.time.LocalDateTime value) {
return value == null ? "" : value.format(DateTimeFormatter.ISO_LOCAL_DATE);
}
private static String decimal(BigDecimal value, int scale) {
return value.setScale(scale, RoundingMode.HALF_UP).toPlainString();
}
private static String first(String first, String fallback) { return !text(first).isEmpty() ? first.trim() : text(fallback); }
private static String join(String first, String second) { return List.of(text(first), text(second)).stream().filter(value -> !value.isEmpty()).collect(Collectors.joining(" / ")); }
private static String text(String value) { return value == null ? "" : value.trim(); }
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.client.WarehousePartnerClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
import com.baiyue.firstleg.oceanbooking.entity.*;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
@@ -71,8 +72,11 @@ public class OceanBookingService {
}
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query) {
SmartLoadingChannelGroupCatalog.Definition group;
try { group = SmartLoadingChannelGroupCatalog.require(query.channelGroupCode()); }
catch (IllegalArgumentException exception) { throw badRequest(exception.getMessage()); }
return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(),
query.channelCode().trim(), query.containerType().trim(), query.page(), query.size()));
group.code(), query.containerType().trim(), query.page(), query.size()), group.channelCodes());
}
public List<ChannelOption> channels(long originWarehouseId) {
@@ -47,6 +47,13 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Param("oceanBookingId") long oceanBookingId,
@Param("containerBookingId") long containerBookingId);
@Select("""
SELECT * FROM fl_carrier_schedule_query
WHERE container_booking_id=#{containerBookingId}
ORDER BY id DESC LIMIT 1
""")
CarrierScheduleQueryEntity findByContainerBookingId(@Param("containerBookingId") long containerBookingId);
@Select("""
<script>
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
@@ -41,6 +41,9 @@ public class CarrierScheduleQueryRepository {
public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) {
return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId);
}
public CarrierScheduleQueryEntity findByContainerBookingId(long containerBookingId) {
return queries.findByContainerBookingId(containerBookingId);
}
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
return queries.selectContainerActualFacts(containerBookingIds);
}
@@ -222,6 +222,7 @@ public class CarrierScheduleQueryService {
}
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
requireManualActualAdvance(value, node, request.actualAt());
CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
@@ -261,13 +262,27 @@ public class CarrierScheduleQueryService {
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (container == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
CarrierScheduleQueryEntity found = repository.findByContainerBookingId(containerBookingId);
CarrierScheduleQueryEntity query = found == null ? null : requiredLocked(found.getId());
String providerEventId = sha256("CONTAINER_MANUAL|" + containerBookingId + '|' + key);
String payloadHash = hash("CONTAINER_MANUAL_NODE", containerBookingId, request);
CarrierScheduleNodeFactEntity fact = addNode(null, containerBookingId, "MANUAL", providerEventId,
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
"MANUAL", providerEventId,
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash);
if (fact != null) nodeOutbox.publishContainerNode(container.getOceanBookingId(), containerBookingId,
fact, normalizedContainerNo(container));
if (fact != null && query != null) {
CarrierScheduleQueryEntity before = copy(query);
requireManualActualAdvance(query, node, request.actualAt());
boolean advanced = advanceActualNode(query, node, request.actualAt(), request.observedAt(),
operatorId, operatorName);
CarrierScheduleQueryEntity after = advanced ? required(query.getId()) : query;
addHistory(query.getId(), "MANUAL_NODE", before.getVersion(), after.getVersion(), request.reason(),
before, after, operatorId, operatorName);
nodeOutbox.publish(after, fact, normalizedContainerNo(container));
} else if (fact != null) {
nodeOutbox.publishContainerNode(container.getOceanBookingId(), containerBookingId,
fact, normalizedContainerNo(container));
}
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
}
@@ -306,6 +321,16 @@ public class CarrierScheduleQueryService {
return false;
}
private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
LocalDateTime actualAt) {
if (actualAt == null) return;
CarrierScheduleNode current = value.getLatestStandardNode() == null ? null
: CarrierScheduleNode.require(value.getLatestStandardNode());
if ("STOPPED".equals(value.getStatus()) || current != null && node.sequence() <= current.sequence()) {
throw conflict("实际船节点必须按业务顺序向后推进");
}
}
public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) {
CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
@@ -90,8 +90,8 @@ public class LoadingPickRepository {
return tasks.selectLoadingByPlan(loadingPlanId, warehouseId);
}
public List<PendingTaskRow> pendingTasks(long operatorId, String keyword) {
return tasks.selectPendingForOperator(operatorId, keyword);
public List<PendingTaskRow> pendingTasks(long operatorId, long warehouseId, String keyword) {
return tasks.selectPendingForOperator(operatorId, warehouseId, keyword);
}
public ContainerBookingEntity activeBookingByTask(long taskId) {
@@ -156,4 +156,15 @@ public class PriceBookRepository {
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId)
.set(CarrierFbaRateEntity::getUpdatedAt, at));
}
public void voidPending(PriceBookVersionEntity value, LocalDateTime at, long operatorId, String reason) {
value.setStatus("VOIDED"); value.setVoidedAt(at); value.setVoidedBy(operatorId); value.setVoidReason(reason);
value.setUpdatedBy(operatorId); value.setUpdatedAt(at); updateVersion(value);
rates.update(null, new LambdaUpdateWrapper<CarrierFbaRateEntity>()
.eq(CarrierFbaRateEntity::getPriceBookVersionId, value.getId())
.eq(CarrierFbaRateEntity::getStatus, "PENDING_CONFIRMATION")
.set(CarrierFbaRateEntity::getStatus, "VOIDED")
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId)
.set(CarrierFbaRateEntity::getUpdatedAt, at));
}
}
@@ -49,9 +49,9 @@ public class SmartLoadingRepository {
return units.isEmpty() ? List.of() : candidates.globalCandidatesByUnits(units);
}
public PageResult<WorkbenchContainer> workbenchContainers(WorkbenchContainerQuery query,
Set<String> actualChannels) {
Set<String> channelGroups) {
return PageResult.from(candidates.workbenchContainers(
new Page<>(query.page(), query.size()), query, actualChannels));
new Page<>(query.page(), query.size()), query, channelGroups));
}
public List<ChannelContainerCountRow> channelContainerCounts(long warehouseId) {
return candidates.channelContainerCounts(warehouseId);
@@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.domain.SmartLoadingStatus;
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
@@ -55,13 +56,16 @@ public class ContainerService {
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId());
Long versionId = priceBooks.activeVersionId();
if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本");
ChannelRow channel = priceBooks.channels(versionId, warehouse.id()).stream()
.filter(row -> request.channelCode().trim().equals(row.getChannelCode())).findFirst()
.orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择"));
SmartLoadingChannelGroupCatalog.Definition channelGroup;
try { channelGroup = SmartLoadingChannelGroupCatalog.require(request.channelGroupCode()); }
catch (IllegalArgumentException exception) { throw conflict(exception.getMessage()); }
boolean activeGroup = priceBooks.channels(versionId, warehouse.id()).stream()
.map(ChannelRow::getChannelCode).anyMatch(channelGroup.channelCodes()::contains);
if (!activeGroup) throw conflict("所选物流渠道分组在当前仓库已失效,请重新选择");
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.next("CAB"));
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
value.setChannelCode(channel.getChannelCode()); value.setChannelName(channel.getChannelName());
value.setChannelGroupCode(channelGroup.code()); value.setChannelCode(null); value.setChannelName(null);
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm());
value.setMaxWeightKg(specification.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
@@ -85,9 +89,9 @@ public class ContainerService {
String containerNo = requestedContainerNo == null ? null : Iso6346ContainerNumber.require(requestedContainerNo);
if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|| !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode())
|| !SmartLoadingChannelGroupCatalog.contains(container.getChannelGroupCode(), booking.getChannelCode())
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致");
throw conflict("模拟柜与真实订舱柜仓库、渠道分组或柜型不一致");
}
try {
if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), containerNo,
@@ -9,7 +9,6 @@ import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.*;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -24,38 +23,24 @@ public class DropshipPalletizationService {
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Transactional
public Task createAndBind(long taskId,CreateAndBindRequest request,long operatorId,String operatorName){
if(mapper.lockTask(taskId,request.warehouseId())==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务");
TaskRow task=requireTask(taskId,request.warehouseId());
if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员");
String operation=operation(request.operationId());
PalletRow replay=mapper.palletByOpenOperation(operation);
if(replay!=null){
if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())
||!Objects.equals(replay.getTenantId(),task.getTenantId())) throw conflict("创建托盘幂等号已用于不同任务");
return view(task);
}
if(!"CLAIMED".equals(task.getTaskStatus())||!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("当前任务不允许创建托盘");
int expected=mapper.unboundCount(taskId);
if(expected==0) throw conflict("当前没有待绑托箱,请先清点箱码");
long palletId=IdWorker.getId();
String palletNo=documentNumbers.next("DP");
mapper.insertPallet(palletId,taskId,task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName);
int bound=mapper.bindPending(taskId,palletId,operation,operatorId,operatorName);
if(bound!=expected||mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试");
return view(requireTask(taskId,request.warehouseId()));
}
public void createTask(InboundOrderEntity inbound) {
public void createTask(InboundOrderEntity inbound,long operatorId,String operatorName) {
if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return;
if (inbound.getTotalPackageCount()==null || inbound.getTotalPackageCount()<1) throw invalid("一件代发测量完成箱数无效,不能下发清点组托任务");
mapper.insertTask(IdWorker.getId(),inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount());
if (inbound.getPalletCount()==null || inbound.getPalletCount()<1) throw invalid("一件代发预计托数无效,不能生成托盘码");
if (inbound.getPalletCount()>inbound.getTotalPackageCount()) throw invalid("一件代发预计托数不能超过总箱数");
long taskId=IdWorker.getId();
if(mapper.insertTask(taskId,inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount())!=1) return;
for(int sequence=1;sequence<=inbound.getPalletCount();sequence++){
mapper.insertPallet(IdWorker.getId(),taskId,inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),
documentNumbers.next("DP"),"dropship-precreate-"+taskId+"-"+sequence,operatorId,operatorName);
}
}
public List<PendingTask> pending(long operatorId,String keyword){ return mapper.pending(operatorId,normalize(keyword)); }
public List<PendingTask> pending(long operatorId,long warehouseId,String keyword){ return mapper.pending(operatorId,warehouseId,normalize(keyword)); }
@Transactional
public Task claim(long taskId,long warehouseId,long operatorId,String operatorName){
requirePalletSetup(requireTask(taskId,warehouseId));
if(mapper.claim(taskId,warehouseId,operatorId,operatorName)!=1) throw conflict("任务已被其他人员领取或状态已变化");
return task(taskId,warehouseId,operatorId);
}
@@ -76,8 +61,11 @@ public class DropshipPalletizationService {
if(!"CLAIMED".equals(task.getTaskStatus())) block="请先领取任务";
else if(!Objects.equals(task.getAssigneeId(),operatorId)) block="任务不属于当前操作员";
else if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) block="入仓单已不在待清点组托状态";
return new Carton(null,label.getBoxCode(),task.getInboundId(),label.getInboundCode(),label.getInboundDetailId(),
label.getSkuCode(),label.getCargoName(),label.getUnitsPerCarton(),null,null,null,"UNCOUNTED",null,null,
else if(!palletSetupReady(task)) block=palletSetupMessage(task);
List<CartonSkuLine> skuLines=labelSkuLines(taskId,label).stream().map(line ->
new CartonSkuLine(line.getInboundDetailId(),line.getSkuCode(),line.getCargoName(),line.getExpectedQuantity(),
null,null,null)).toList();
return new Carton(null,label.getBoxCode(),task.getInboundId(),label.getInboundCode(),skuLines,"UNCOUNTED",null,null,
block==null,block);
}
@@ -88,23 +76,22 @@ public class DropshipPalletizationService {
CartonRow replay=mapper.cartonByCountOperation(operation);
if(replay!=null){
if(!Objects.equals(replay.getTaskId(),taskId) || !Objects.equals(replay.getBoxCode(),request.boxCode().trim())
|| !Objects.equals(replay.getActualSkuQuantity(),request.actualSkuQuantity())) throw conflict("清点幂等号已用于不同操作");
|| !sameCountLines(mapper.cartonSkus(replay.getId()),request.skuLines())) throw conflict("清点幂等号已用于不同操作");
return countResult(task,replay);
}
LabelRow label=requireLabel(taskId,request.boxCode());
if(mapper.countedByLabel(taskId,label.getCartonLabelId())!=null) throw conflict("箱码已清点,不能重复清点");
int expected=requireExpectedQuantity(label);
boolean mismatch=expected!=request.actualSkuQuantity();
String remark=normalize(request.discrepancyRemark());
if(!mismatch && request.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH");
if(mismatch && request.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果");
if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明");
PalletRow pallet=StringUtils.hasText(request.palletNo())
? getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName):null;
List<CountedLine> lines=validateCountLines(labelSkuLines(taskId,label),request.skuLines());
boolean mismatch=lines.stream().anyMatch(CountedLine::mismatch);
PalletRow pallet=StringUtils.hasText(request.palletNo())?requireAssignablePallet(task,request.palletNo().trim()):null;
long cartonId=IdWorker.getId();
CountedLine primary=lines.get(0);
mapper.insertCarton(cartonId,taskId,pallet==null?null:pallet.getId(),task.getTenantId(),task.getInboundId(),
label.getInboundDetailId(),label.getCartonLabelId(),label.getSkuCode(),label.getCargoName(),label.getBoxCode(),
expected,request.actualSkuQuantity(),mismatch?"ACCEPTED_VARIANCE":"MATCH",remark,operation,operatorId,operatorName);
primary.detailId(),label.getCartonLabelId(),primary.skuCode(),primary.cargoName(),label.getBoxCode(),
primary.expected(),primary.actual(),mismatch?"ACCEPTED_VARIANCE":"MATCH",null,operation,operatorId,operatorName);
int lineNo=0;
for(CountedLine line:lines) mapper.insertCartonSku(IdWorker.getId(),cartonId,line.detailId(),line.skuCode(),line.cargoName(),
line.expected(),line.actual(),line.mismatch()?"ACCEPTED_VARIANCE":"MATCH",line.remark(),++lineNo);
if(mapper.addCounted(taskId,operatorId,pallet==null?0:1,mismatch?1:0)!=1) throw conflict("任务清点数量已变化,请刷新后重试");
TaskRow refreshed=requireTask(taskId,request.warehouseId());
return countResult(refreshed,mapper.carton(taskId,cartonId));
@@ -115,7 +102,7 @@ public class DropshipPalletizationService {
TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
String operation=operation(request.operationId());
if(mapper.bindOperationCount(taskId,operation)>0) return view(requireTask(taskId,request.warehouseId()));
PalletRow pallet=getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName);
PalletRow pallet=requireAssignablePallet(task,request.palletNo().trim());
int expected=mapper.unboundCount(taskId);
if(expected==0) throw conflict("当前没有待绑托箱");
int bound=mapper.bindPending(taskId,pallet.getId(),operation,operatorId,operatorName);
@@ -130,7 +117,7 @@ public class DropshipPalletizationService {
if(carton==null) throw new BizException(ResultCode.NOT_FOUND,"清点箱不存在");
if(carton.getPalletId()!=null) requireOpenPallet(taskId,carton.getPalletId(),request.warehouseId());
PalletRow target=StringUtils.hasText(request.targetPalletNo())
? getOrCreatePallet(task,request.targetPalletNo().trim(),operation(request.operationId()),operatorId,operatorName):null;
? requireAssignablePallet(task,request.targetPalletNo().trim()):null;
if(Objects.equals(carton.getPalletId(),target==null?null:target.getId())) return view(task);
if(mapper.moveCarton(taskId,cartonId,target==null?null:target.getId(),operation(request.operationId()),operatorId,operatorName)!=1)
throw conflict("清点箱已被其他操作修改,请刷新后重试");
@@ -143,16 +130,17 @@ public class DropshipPalletizationService {
public Task seal(long taskId,long palletId,SealPalletRequest request,long operatorId,String operatorName){
TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
PalletRow replay=mapper.palletBySealOperation(operation(request.operationId()));
if(replay!=null){ if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getId(),palletId)) throw conflict("封托幂等号已用于其他托盘"); return view(requireTask(taskId,request.warehouseId())); }
if(replay!=null){ if(!Objects.equals(replay.getId(),palletId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())) throw conflict("封托幂等号已用于其他托盘"); return view(requireTask(taskId,request.warehouseId())); }
requireOpenPallet(taskId,palletId,request.warehouseId());
if(mapper.unboundCount(taskId)>0) throw conflict("仍有已清点待绑托箱,不能封托");
if(mapper.seal(taskId,palletId,request.expectedVersion(),operation(request.operationId()),operatorId,operatorName)!=1)
if(mapper.seal(taskId,palletId,request.expectedVersion(),request.warehouseId(),operation(request.operationId()),operatorId,operatorName)!=1)
throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试");
TaskRow refreshed=requireTask(taskId,request.warehouseId());
if(Objects.equals(refreshed.getCountedCartons(),refreshed.getTotalCartons()) &&
Objects.equals(refreshed.getAllocatedCartons(),refreshed.getTotalCartons()) && mapper.openPalletCount(taskId)==0){
if(mapper.completeTask(taskId)!=1 || mapper.completeInbound(taskId,refreshed.getTenantId(),refreshed.getInboundId(),operatorId)!=1)
throw conflict("组托完成状态已变化,请刷新后重试");
for(Long participatingTaskId:mapper.taskIdsByPallet(palletId)){
TaskRow participating=requireTask(participatingTaskId,request.warehouseId());
if(Objects.equals(participating.getCountedCartons(),participating.getTotalCartons()) &&
Objects.equals(participating.getAllocatedCartons(),participating.getTotalCartons()) && mapper.openPalletCount(participatingTaskId)==0){
if(mapper.completeTask(participatingTaskId)!=1 || mapper.completeInbound(participatingTaskId,participating.getTenantId(),participating.getInboundId(),operatorId)!=1)
throw conflict("组托完成状态已变化,请刷新后重试");
}
}
return view(requireTask(taskId,request.warehouseId()));
}
@@ -162,18 +150,23 @@ public class DropshipPalletizationService {
if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在");
return mapper.palletsByInbound(tenantId,inboundId).stream().map(p->palletView(p,code)).toList();
}
public String inboundCode(long tenantId,long inboundId){
String code=mapper.inboundCode(tenantId,inboundId);
if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在");
return code;
}
private PalletRow getOrCreatePallet(TaskRow task,String palletNo,String operation,long operatorId,String operatorName){
public List<Pallet> generatedPallets(long taskId,long warehouseId,long operatorId){
TaskRow task=requireTask(taskId,warehouseId);
if(task.getAssigneeId()!=null && task.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取");
return mapper.generatedPallets(taskId).stream().map(p->palletView(p,task.getInboundCode())).toList();
}
private PalletRow requireAssignablePallet(TaskRow task,String palletNo){
PalletRow existing=mapper.palletByNo(palletNo);
if(existing!=null){
if(!Objects.equals(existing.getTaskId(),task.getId()) || !Objects.equals(existing.getTenantId(),task.getTenantId())) throw conflict("托盘码已被其他客户或入仓任务占用");
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
return existing;
}
try{ mapper.insertPallet(IdWorker.getId(),task.getId(),task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); }
catch(DuplicateKeyException e){ existing=mapper.palletByNo(palletNo); if(existing==null) throw conflict("托盘码已被并发占用"); }
existing=mapper.palletByNo(palletNo);
if(existing==null || !Objects.equals(existing.getTaskId(),task.getId())) throw conflict("托盘码已被其他任务占用");
if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不存在,请扫描测量审核后生成的 DP 托盘码");
if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码不属于当前作业仓库");
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
return existing;
}
@@ -184,37 +177,73 @@ public class DropshipPalletizationService {
}
private Task view(TaskRow row){
List<Pallet> pallets=mapper.pallets(row.getId()).stream().map(p->palletView(p,row.getInboundCode())).toList();
int expected=row.getExpectedPalletCount()==null?0:row.getExpectedPalletCount();
int generated=mapper.palletCount(row.getId());
boolean palletSetupReady=expected>0 && generated==expected;
return new Task(row.getId(),row.getInboundId(),row.getInboundCode(),row.getTenantId(),row.getCustomerName(),row.getWarehouseId(),row.getWarehouseName(),
row.getTotalCartons(),row.getCountedCartons(),row.getAllocatedCartons(),row.getCountedCartons()-row.getAllocatedCartons(),row.getDiscrepancyCartons(),
row.getTaskStatus(),row.getAssigneeId(),row.getAssigneeName(),row.getVersion(),mapper.pallets(row.getId()).stream().map(p->palletView(p,row.getInboundCode())).toList(),
expected,generated,palletSetupReady,palletSetupReady?null:palletSetupMessage(expected,generated),
row.getTaskStatus(),row.getAssigneeId(),row.getAssigneeName(),row.getVersion(),pallets,
mapper.unboundCartons(row.getId()).stream().map(c->cartonView(c,row.getInboundCode(),false,null)).toList());
}
private Pallet palletView(PalletRow row,String inboundCode){
List<CartonRow> cartons=mapper.cartons(row.getId()); Map<String,List<CartonRow>> groups=new LinkedHashMap<>();
for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c);
List<SkuItem> items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),inboundCode,
first.getInboundDetailId(),first.getSkuCode(),first.getCargoName(),values.size(),values.stream().mapToInt(v->v.getActualSkuQuantity()==null?0:v.getActualSkuQuantity()).sum(),
values.stream().map(CartonRow::getBoxCode).toList());}).toList();
List<CartonRow> cartons=mapper.cartons(row.getId()); Map<String,List<CartonSkuWithBox>> groups=new LinkedHashMap<>();
for(CartonRow c:cartons) for(CartonSkuRow line:mapper.cartonSkus(c.getId()))
groups.computeIfAbsent(c.getInboundId()+"|"+line.getInboundDetailId()+"|"+line.getSkuCode(),ignored->new ArrayList<>())
.add(new CartonSkuWithBox(c,line));
List<SkuItem> items=groups.values().stream().map(values->{CartonSkuWithBox first=values.get(0);return new SkuItem(first.carton().getInboundId(),first.carton().getInboundCode(),
first.line().getInboundDetailId(),first.line().getSkuCode(),first.line().getCargoName(),values.size(),
values.stream().mapToInt(v->v.line().getActualQuantity()==null?0:v.line().getActualQuantity()).sum(),
values.stream().map(v->v.carton().getBoxCode()).toList());}).toList();
int discrepancies=(int)cartons.stream().filter(c->"ACCEPTED_VARIANCE".equals(c.getDiscrepancyStatus())).count();
PalletMetrics metrics=mapper.metrics(row.getId());
return new Pallet(row.getId(),row.getPalletNo(),row.getPalletStatus(),cartons.size(),items.size(),discrepancies,
metrics==null?BigDecimal.ZERO:metrics.weightKg(),metrics==null?BigDecimal.ZERO:metrics.volumeCbm(),row.getVersion(),items,
cartons.stream().map(c->cartonView(c,inboundCode,false,null)).toList(),row.getCompletedAt());
cartons.stream().map(c->cartonView(c,c.getInboundCode(),false,null)).toList(),row.getCompletedAt());
}
private Carton cartonView(CartonRow row,String inboundCode,boolean canCount,String blockReason){
return new Carton(row.getId(),row.getBoxCode(),row.getInboundId(),inboundCode,row.getInboundDetailId(),row.getSkuCode(),row.getCargoName(),
row.getExpectedSkuQuantity(),row.getActualSkuQuantity(),row.getDiscrepancyStatus(),row.getDiscrepancyRemark(),
List<CartonSkuLine> lines=mapper.cartonSkus(row.getId()).stream().map(line -> new CartonSkuLine(line.getInboundDetailId(),
line.getSkuCode(),line.getCargoName(),line.getExpectedQuantity(),line.getActualQuantity(),line.getDiscrepancyStatus(),line.getDiscrepancyRemark())).toList();
return new Carton(row.getId(),row.getBoxCode(),row.getInboundId(),inboundCode,lines,
row.getPalletId()==null?"COUNTED_UNBOUND":"BOUND",row.getPalletId(),row.getPalletNo(),canCount,blockReason);
}
private LabelRow requireLabel(long taskId,String boxCode){ LabelRow label=mapper.label(taskId,boxCode.trim()); if(label==null) throw new BizException(ResultCode.NOT_FOUND,"箱码不属于当前一件代发入仓单"); if(!StringUtils.hasText(label.getSkuCode())) throw conflict("箱码缺少SKU快照,不能清点"); requireExpectedQuantity(label); return label; }
private int requireExpectedQuantity(LabelRow label){ if(label.getUnitsPerCarton()==null||label.getUnitsPerCarton()<1) throw conflict("箱码缺少每箱SKU数量快照,不能清点"); return label.getUnitsPerCarton(); }
private TaskRow requireWorkingTask(long taskId,long warehouseId,long operatorId){ TaskRow task=requireTask(taskId,warehouseId); if(!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待清点组托任务"); if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员"); if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待清点组托状态"); return task; }
private LabelRow requireLabel(long taskId,String boxCode){ LabelRow label=mapper.label(taskId,boxCode.trim()); if(label==null) throw new BizException(ResultCode.NOT_FOUND,"箱码不属于当前一件代发入仓单"); if(labelSkuLines(taskId,label).isEmpty()) throw conflict("箱码缺少SKU快照,不能清点"); return label; }
private List<LabelSkuRow> labelSkuLines(long taskId,LabelRow label){ return mapper.labelSkus(taskId,label.getCartonLabelId()); }
private List<CountedLine> validateCountLines(List<LabelSkuRow> expected,List<CountSkuLine> actual){
Map<Long,CountSkuLine> submitted=new LinkedHashMap<>();
for(CountSkuLine line:actual) if(submitted.put(line.inboundDetailId(),line)!=null) throw invalid("同一SKU明细不能重复清点");
if(submitted.size()!=expected.size()) throw invalid("必须清点当前箱内全部SKU");
List<CountedLine> result=new ArrayList<>();
for(LabelSkuRow line:expected){
CountSkuLine input=submitted.get(line.getInboundDetailId());
if(input==null || !Objects.equals(line.getSkuCode(),input.skuCode().trim())) throw invalid("清点SKU与箱唛不一致");
boolean mismatch=!Objects.equals(line.getExpectedQuantity(),input.actualQuantity()); String remark=normalize(input.discrepancyRemark());
if(!mismatch && input.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH");
if(mismatch && input.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果");
if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明");
result.add(new CountedLine(line.getInboundDetailId(),line.getSkuCode(),line.getCargoName(),line.getExpectedQuantity(),input.actualQuantity(),remark,mismatch));
}
return result;
}
private boolean sameCountLines(List<CartonSkuRow> saved,List<CountSkuLine> submitted){
if(saved.size()!=submitted.size()) return false;
Map<Long,Integer> quantities=new HashMap<>(); for(CountSkuLine line:submitted) quantities.put(line.inboundDetailId(),line.actualQuantity());
return saved.stream().allMatch(line->Objects.equals(line.getActualQuantity(),quantities.get(line.getInboundDetailId())));
}
private TaskRow requireWorkingTask(long taskId,long warehouseId,long operatorId){ TaskRow task=requireTask(taskId,warehouseId); if(!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待清点组托任务"); if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员"); if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待清点组托状态"); requirePalletSetup(task); return task; }
private void requirePalletSetup(TaskRow task){ if(!palletSetupReady(task)) throw conflict(palletSetupMessage(task)); }
private boolean palletSetupReady(TaskRow task){ return task.getExpectedPalletCount()!=null && task.getExpectedPalletCount()>0 && mapper.palletCount(task.getId())==task.getExpectedPalletCount(); }
private String palletSetupMessage(TaskRow task){ int expected=task.getExpectedPalletCount()==null?0:task.getExpectedPalletCount(); return palletSetupMessage(expected,mapper.palletCount(task.getId())); }
private static String palletSetupMessage(int expected,int generated){ return "预计 "+expected+" 托,当前仅生成 "+generated+" 个托盘码,请联系后台处理后刷新"; }
private TaskRow requireTask(long taskId,long warehouseId){ TaskRow task=mapper.task(taskId,warehouseId); if(task==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务"); return task; }
private PalletRow requireOpenPallet(long taskId,long palletId,long warehouseId){ PalletRow p=mapper.pallet(taskId,palletId,warehouseId); if(p==null) throw new BizException(ResultCode.NOT_FOUND,"托盘不存在"); if(!"OPEN".equals(p.getPalletStatus())) throw conflict("托盘已封托,不能修改"); return p; }
private static String operation(String v){String n=normalize(v);if(!StringUtils.hasText(n))throw invalid("操作幂等号不能为空");return n;}
private static String normalize(String v){return StringUtils.hasText(v)?v.trim():null;}
private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);}
private static BizException invalid(String m){return new BizException(ResultCode.VALIDATION_FAILED,m);}
private record CountedLine(long detailId,String skuCode,String cargoName,int expected,int actual,String remark,boolean mismatch){}
private record CartonSkuWithBox(CartonRow carton,CartonSkuRow line){}
}
@@ -98,8 +98,8 @@ public class FbaPricingService {
if (published == null || !"SENT".equals(published.getFeeStatus())) {
throw conflict("费用状态已变化,请刷新后重试");
}
if (feeRevisionLocked(inbound, published)) {
throw conflict("客户已确认且该票已装柜完成,不允许再修改或重新发送费用");
if (feeRevisionLocked(published)) {
throw conflict("客户已确认,不允许再修改或重新发送费用");
}
}
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
@@ -490,8 +490,8 @@ public class FbaPricingService {
if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
throw conflict("入库单没有可修改的初算或已发布费用");
}
if (feeRevisionLocked(inbound, current)) {
throw conflict("客户已确认且该票已装柜完成,不允许再修改费用");
if (feeRevisionLocked(current)) {
throw conflict("客户已确认,不允许再修改费用");
}
if (!request.expectedFeeVersion().equals(current.getFeeVersion())) {
throw conflict("费用版本已变化,请刷新后重试");
@@ -613,9 +613,8 @@ public class FbaPricingService {
.contains(processStatus);
}
private static boolean feeRevisionLocked(InboundPricingEntity inbound, InboundFeeEntity fee) {
return fee != null && fee.getCustomerConfirmedAt() != null
&& List.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
private static boolean feeRevisionLocked(InboundFeeEntity fee) {
return fee != null && fee.getCustomerConfirmedAt() != null;
}
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
@@ -25,6 +25,8 @@ import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet;
@Service
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 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");
@@ -113,28 +115,51 @@ public class InboundCartonLabelPrintService {
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
}
/** 一件代发托盘标签:托盘号、入仓码、SKU箱数和托盘二维码。 */
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String requestId) {
if (pallet == null || !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "只有已完成且包含货物的一件代发托盘可以打印");
/** 一件代发 A4 托盘信息单:托盘号、Code 128 一维码和 SKU 归托明细。 */
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String inboundCode, String requestId) {
return generateDropshipPallets(List.of(pallet), inboundCode, requestId);
}
/** 一票预生成托盘码合并为一个多页 PDF,避免 PDA 展开或逐个下载大量空托盘。 */
public ResponseEntity<byte[]> generateDropshipPallets(List<Pallet> pallets, String inboundCode, String requestId) {
if (pallets == null || pallets.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印");
}
String inboundCodes = pallet.items().stream().map(item -> item.inboundCode())
.filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining("/"));
String skuSummary = pallet.items().stream()
.map(item -> item.skuCode() + "×" + item.cartonCount())
.collect(java.util.stream.Collectors.joining(" / "));
PrintPdfClient.Label label = new PrintPdfClient.Label(pallet.palletNo(), inboundCodes,
"CTNS:" + pallet.cartonCount(), skuSummary, pallet.palletNo(), null);
PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest(
"INBOUND_MARK_V1", new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM),
pallet.palletNo() + "-托盘唛.pdf", List.of(label));
List<PrintPdfClient.DropshipPalletDocument> documents = pallets.stream()
.map(pallet -> dropshipPalletDocument(pallet, inboundCode)).toList();
String fileName = pallets.size() == 1
? pallets.get(0).palletNo() + "-A4托盘信息单.pdf"
: inboundCode + "-" + pallets.size() + "托-A4托盘信息单.pdf";
PrintPdfClient.DropshipPalletA4PdfRequest request = new PrintPdfClient.DropshipPalletA4PdfRequest(
"DROPSHIP_PALLET_A4_V1", new PrintPdfClient.PageSpec(A4_WIDTH_MM, A4_HEIGHT_MM),
fileName, documents);
try {
return requirePdf(printClient.generateInboundMarks(internalToken, requestId, request));
return requirePdf(printClient.generateDropshipPalletA4Documents(internalToken, requestId, request));
} catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
}
}
private PrintPdfClient.DropshipPalletDocument dropshipPalletDocument(Pallet pallet, String inboundCode) {
if (pallet == null || !StringUtils.hasText(pallet.palletNo())) {
throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印");
}
String inboundCodes = pallet.items().stream().map(item -> item.inboundCode())
.filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining("/"));
if (!StringUtils.hasText(inboundCodes)) inboundCodes = inboundCode;
List<PrintPdfClient.DropshipPalletSkuRow> skuRows = pallet.items().stream()
.map(item -> new PrintPdfClient.DropshipPalletSkuRow(item.inboundCode(), item.skuCode(),
item.cargoName(), number(item.cartonCount()), number(item.skuQuantity())))
.toList();
return new PrintPdfClient.DropshipPalletDocument(
pallet.palletNo(), inboundCodes, number(pallet.cartonCount()), number(pallet.skuCount()),
pallet.weightKg(), pallet.volumeCbm(), pallet.palletNo(), skuRows);
}
private int number(Integer value) {
return value == null ? 0 : value;
}
private ResponseEntity<byte[]> generatePdf(String fileName, List<InboundCartonLabels.Label> labels,
String requestId) {
PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest(
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
import com.baiyue.firstleg.mapper.InboundCartonLabelSkuMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
@@ -24,6 +25,8 @@ import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.LinkedHashMap;
import java.util.Map;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -36,6 +39,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
private static final Set<String> ALLOCATABLE_STATUSES = Set.of("PREPARING", "WAIT_MEASURE", "MEASURING");
@Resource private InboundCartonLabelRepository repository;
@Resource private InboundCartonLabelSkuMapper labelSkus;
@Resource private OrderCargoClient orderCargoClient;
@Resource private PrintableOrderInboundClient printableOrderInboundClient;
@Resource private InboundOrderCreationService inboundOrderCreationService;
@@ -76,7 +80,8 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
if (existingFingerprint.equals(fingerprint)) return batch(inbound, consistent);
return batch(inbound, consistent);
}
int total = cargoes.stream().mapToInt(this::requirePackageCount).sum();
List<CargoGroup> groups = cartonGroups(cargoes, inbound.getFulfillmentType());
int total = groups.stream().mapToInt(CargoGroup::packageCount).sum();
if (total < 1 || total > MAX_LABELS) {
throw new BizException(ResultCode.VALIDATION_FAILED, "唛头箱数必须为1至2000箱");
}
@@ -86,12 +91,20 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
int cartonIndex = 0;
LocalDateTime now = LocalDateTime.now();
for (OrderCargoClient.CargoLabelBinding cargo : cargoes) {
int detailCount = requirePackageCount(cargo);
for (CargoGroup group : groups) {
OrderCargoClient.CargoLabelBinding cargo = group.cargoes().get(0);
int detailCount = group.packageCount();
for (int offset = 0; offset < detailCount; offset++) {
cartonIndex++;
InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, fingerprint, now);
repository.insert(label);
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) {
int lineNo = 0;
for (OrderCargoClient.CargoLabelBinding line : group.cargoes()) {
labelSkus.insert(IdWorker.getId(), inbound.getTenantId(), inbound.getId(), label.getId(),
line.cargoId(), line.skuNo().trim(), line.cargoName(), line.unitsPerCarton(), ++lineNo);
}
}
existing.add(label);
}
}
@@ -195,6 +208,27 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
return cargo.packageCount();
}
private List<CargoGroup> cartonGroups(List<OrderCargoClient.CargoLabelBinding> cargoes, String fulfillmentType) {
if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType)) {
return cargoes.stream().map(cargo -> new CargoGroup(null, requirePackageCount(cargo), List.of(cargo))).toList();
}
Map<String, List<OrderCargoClient.CargoLabelBinding>> grouped = new LinkedHashMap<>();
int single = 0;
for (OrderCargoClient.CargoLabelBinding cargo : cargoes) {
requirePackageCount(cargo);
String group = StringUtils.hasText(cargo.packingGroupNo()) ? cargo.packingGroupNo().trim() : "__single_" + (++single);
grouped.computeIfAbsent(group, ignored -> new ArrayList<>()).add(cargo);
}
return grouped.entrySet().stream().map(entry -> {
List<OrderCargoClient.CargoLabelBinding> lines = entry.getValue();
int count = lines.get(0).packageCount();
if (lines.stream().anyMatch(line -> line.packageCount() != count)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "同一混箱组合的箱数必须一致:" + entry.getKey());
}
return new CargoGroup(entry.getKey(), count, List.copyOf(lines));
}).toList();
}
private void requirePrintableInboundCode(String inboundCode, int total) {
if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码不能生成可打印二维码");
@@ -276,6 +310,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
cargo.cargoName() == null ? "" : cargo.cargoName().trim(),
cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()),
String.valueOf(cargo.unitsPerCarton()),
cargo.packingGroupNo() == null ? "" : cargo.packingGroupNo().trim(),
decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm())))
.collect(java.util.stream.Collectors.joining("\n"));
try {
@@ -296,11 +331,16 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
private InboundCartonLabels.Batch batch(InboundOrderEntity inbound,
List<InboundCartonLabelEntity> entities) {
List<InboundCartonLabelSkuMapper.SkuRow> skuRows = labelSkus.findByInbound(inbound.getTenantId(), inbound.getId());
Map<Long, List<InboundCartonLabelSkuMapper.SkuRow>> skuRowsByLabel = skuRows == null ? Map.of()
: skuRows.stream().collect(java.util.stream.Collectors.groupingBy(
InboundCartonLabelSkuMapper.SkuRow::getCartonLabelId, LinkedHashMap::new,
java.util.stream.Collectors.toList()));
List<InboundCartonLabels.Label> labels = entities.stream().map(entity ->
new InboundCartonLabels.Label(entity.getId(), entity.getInboundDetailId(), entity.getCargoItemNo(),
entity.getCargoName(), entity.getDetailPackageCount(), entity.getBoxCode(),
entity.getTotalCartonCount(), entity.getCartonIndex(), entity.getBoxSpecification(),
entity.getLengthCm(), entity.getWidthCm(), entity.getHeightCm(), entity.getSkuCode(), inbound.getInboundCode(),
entity.getLengthCm(), entity.getWidthCm(), entity.getHeightCm(), displaySkuCodes(entity, skuRowsByLabel), inbound.getInboundCode(),
fallback(inbound.getWarehouseName(), "WAREHOUSE"),
"CTNS:" + entity.getTotalCartonCount() + "-" + entity.getCartonIndex(),
fallback(inbound.getChannelName(), fallback(inbound.getChannelCode(), "ROUTE"))))
@@ -314,9 +354,30 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
return StringUtils.hasText(value) ? value.trim() : fallback;
}
private String displaySkuCodes(InboundCartonLabelEntity label,
Map<Long, List<InboundCartonLabelSkuMapper.SkuRow>> skuRowsByLabel) {
List<InboundCartonLabelSkuMapper.SkuRow> lines = skuRowsByLabel.get(label.getId());
if (lines == null || lines.isEmpty()) return label.getSkuCode();
List<String> skuCodes = lines.stream().map(InboundCartonLabelSkuMapper.SkuRow::getSkuCode)
.filter(StringUtils::hasText).map(String::trim).distinct().toList();
if (skuCodes.isEmpty()) return label.getSkuCode();
String joined = String.join(" / ", skuCodes);
if (joined.codePointCount(0, joined.length()) <= 64) return joined;
String suffix = " / +" + Math.max(0, skuCodes.size() - 1) + " SKU";
int firstLimit = 64 - suffix.codePointCount(0, suffix.length());
String first = skuCodes.get(0);
if (first.codePointCount(0, first.length()) > firstLimit) {
first = first.substring(0, first.offsetByCodePoints(0, firstLimit));
}
return first + suffix;
}
private void requireIds(long tenantId, long inboundId) {
if (tenantId <= 0 || inboundId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "客户ID和入库单ID必须大于0");
}
}
private record CargoGroup(String groupNo, int packageCount,
List<OrderCargoClient.CargoLabelBinding> cargoes) {}
}
@@ -131,6 +131,15 @@ public class InboundMeasurementService {
throw new BizException(ResultCode.CONFLICT,
"测量已提交待审核,不能再保存整批测量数据;如需修改请先退回后重新提交");
}
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())
&& (request.expectedPalletCount() == null || request.expectedPalletCount() < 1)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发必须录入预计托数");
}
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())
&& inbound.getTotalPackageCount() != null
&& request.expectedPalletCount() > inbound.getTotalPackageCount()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发预计托数不能超过总箱数");
}
validateInboundDetails(tenantId, inbound, request.items());
request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId)
.filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> {
@@ -162,6 +171,11 @@ public class InboundMeasurementService {
item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(),
item.chargeRuleId(), item.coefficientMode(), item.remark())).toList());
InboundMeasurementEntity saved = batchService.saveBatch(command);
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) {
inbound.setPalletCount(request.expectedPalletCount());
inbound.setUpdatedBy(operatorId);
if (!repository.updateInbound(inbound)) throw concurrentModification();
}
return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus());
}
@@ -247,7 +261,7 @@ public class InboundMeasurementService {
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
if (confirmedTotals.palletCount() == 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"请至少上传并确认一张称重图片后再提交测量审核");
"即使只有一箱,也必须上传并确认至少一张称重图片后再提交测量审核");
}
requireConfirmedPackageTotal(inbound, items);
@@ -261,7 +275,13 @@ public class InboundMeasurementService {
inbound.setMeasureCompletedAt(LocalDateTime.now());
inbound.setActualGrossWeightKg(confirmedTotals.grossWeightKg());
inbound.setActualNetWeightKg(confirmedTotals.netWeightKg());
inbound.setPalletCount(confirmedTotals.palletCount());
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) {
if (inbound.getPalletCount() == null || inbound.getPalletCount() < 1) {
throw new BizException(ResultCode.VALIDATION_FAILED, "请先录入预计托数并保存,再提交测量审核");
}
} else {
inbound.setPalletCount(confirmedTotals.palletCount());
}
if (measurement.getTotalVolumeCbm() != null) {
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
}
@@ -547,7 +567,8 @@ public class InboundMeasurementService {
InboundMeasurementResponses.DeclarationSnapshot declaration = latestDeclaration.snapshot();
return new InboundMeasurementResponses.Detail(
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
inbound.getChannelName(), inbound.getFulfillmentType(), inbound.getPalletCount(),
inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
RECOMMENDED_PALLET_TYPE,
review == null ? null : review.getReviewReason(), review == null ? null : review.getReviewerId(),
review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(),
@@ -67,12 +67,14 @@ public class LoadingCompletedOutboxService {
palletLabelService.prepare(planId, rows.get(0).getWarehouseId());
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
List<PalletRow> palletRows = palletRows(planId);
Map<String, PalletManifest> palletManifests = palletManifests(palletRows);
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels = palletLabelMapper.labels(planId);
return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
java.util.TreeMap::new, java.util.stream.Collectors.toList()))
.entrySet().stream().map(entry -> envelope(entry.getKey(), entry.getValue(),
palletRows.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(),
standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks))
standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks,
palletManifests))
.toList();
}
@@ -98,14 +100,16 @@ public class LoadingCompletedOutboxService {
private EventEnvelope envelope(long accountId, List<Row> rows, List<PalletRow> palletRows,
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
Map<Long, List<String>> cartonMarks) {
Map<Long, List<String>> cartonMarks,
Map<String, PalletManifest> palletManifests) {
Row first = rows.get(0);
Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy(
Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList()));
List<FirstLegLoadingPlanCompletedV2.Order> orders = byInbound.values().stream()
.map(value -> order(value, palletRows.stream()
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(),
standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(),
cartonMarks, palletManifests)).toList();
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()),
ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(),
@@ -123,7 +127,8 @@ public class LoadingCompletedOutboxService {
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
Map<Long, List<String>> cartonMarks) {
Map<Long, List<String>> cartonMarks,
Map<String, PalletManifest> palletManifests) {
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
Row first = sorted.get(0);
List<FirstLegLoadingPlanCompletedV2.Item> items = sorted.stream().map(row ->
@@ -138,7 +143,7 @@ public class LoadingCompletedOutboxService {
String fulfillmentType = fulfillmentType(sorted);
String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(fulfillmentType)
? palletSnapshots(sorted, palletRows) : standardLabels.stream()
? palletSnapshots(sorted, palletRows, palletManifests) : standardLabels.stream()
.map(label -> new FirstLegLoadingPlanCompletedV2.PalletSnapshot(label.getPalletCode(), List.of())).toList();
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
@@ -257,10 +262,11 @@ public class LoadingCompletedOutboxService {
private record LabelPoolKey(long accountId, long inboundId) { }
private record InboundPalletKey(long accountId, long inboundId) { }
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source) {
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source,
Map<String, PalletManifest> manifests) {
if (source.isEmpty()) return List.of();
if (source.stream().anyMatch(item -> Boolean.TRUE.equals(item.getActualBinding()))) {
return actualPalletSnapshots(values, source);
return actualPalletSnapshots(values, source, manifests);
}
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(),
@@ -314,7 +320,7 @@ public class LoadingCompletedOutboxService {
/** 现场逐箱扫描已给出精确箱到托关系,不再使用旧的按托盘容量顺序推算。 */
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> actualPalletSnapshots(
List<Row> values, List<PalletRow> source) {
List<Row> values, List<PalletRow> source, Map<String, PalletManifest> manifests) {
Map<Long, Row> details = values.stream().collect(java.util.stream.Collectors.toMap(
Row::getSourceInboundDetailId, value -> value, (left, right) -> left, LinkedHashMap::new));
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
@@ -334,14 +340,17 @@ public class LoadingCompletedOutboxService {
if (marks.size() != detailRows.getValue().size()) {
throw new BizException(ResultCode.CONFLICT, "现场托盘包含空箱唛或重复箱唛");
}
int quantity = detail.getProductQuantity() == null ? marks.size()
: (int) Math.round((double) detail.getProductQuantity() * marks.size()
/ Math.max(1, detail.getPackageCount()));
int quantity = detailRows.getValue().stream().map(PalletRow::getActualSkuQuantity)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
if (quantity <= 0) throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘缺少实点 SKU 数量");
skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSourceInboundDetailId().toString(), detail.getSkuCode(),
detail.getProductNameZh(), marks.size(), quantity, marks));
actualPackages += marks.size();
}
result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus));
PalletManifest manifest = manifests.get(pallet.getKey());
if (manifest == null) throw new BizException(ResultCode.CONFLICT, "一件代发托盘缺少完整清单摘要");
result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus,
manifest.version(), manifest.packageCount(), manifest.fragmentCount(), manifest.hash()));
}
if (actualPackages != sumInt(values, Row::getPackageCount)) {
throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘箱唛数与实装箱数不一致");
@@ -349,12 +358,37 @@ public class LoadingCompletedOutboxService {
return result;
}
private Map<String, PalletManifest> palletManifests(List<PalletRow> rows) {
Map<String, List<PalletRow>> grouped = rows.stream().filter(row -> Boolean.TRUE.equals(row.getActualBinding()))
.collect(java.util.stream.Collectors.groupingBy(PalletRow::getPalletCode, LinkedHashMap::new,
java.util.stream.Collectors.toList()));
Map<String, PalletManifest> result = new LinkedHashMap<>();
grouped.forEach((palletNo, values) -> {
List<PalletRow> sorted = values.stream().sorted(Comparator
.comparing(PalletRow::getAccountId).thenComparing(PalletRow::getSourceInboundId)
.thenComparing(PalletRow::getSourceInboundDetailId).thenComparing(PalletRow::getBoxCode)).toList();
String canonical = sorted.stream().map(value -> value.getAccountId() + "|" + value.getSourceInboundId()
+ "|" + value.getSourceInboundDetailId() + "|" + value.getSkuCode() + "|" + value.getBoxCode()
+ "|" + value.getActualSkuQuantity()).collect(java.util.stream.Collectors.joining("\n"));
int fragments = (int) sorted.stream().map(PalletRow::getAccountId).distinct().count();
result.put(palletNo, new PalletManifest(1, sorted.size(), fragments,
manifestHash(canonical)));
});
return result;
}
private String manifestHash(String canonical) {
String value = EventPayloadHash.sha256(canonical.getBytes(StandardCharsets.UTF_8));
return value.substring("sha256:".length());
}
private static boolean dropship(String value) {
return "ONE_CLICK_DROPSHIP".equals(value);
}
private record DetailSnapshot(Long detailId, String skuCode, String productName,
Integer packageCount, Integer productQuantity, List<String> marks) {}
private record PalletManifest(int version, int packageCount, int fragmentCount, String hash) {}
private void insert(EventEnvelope envelope) {
InboundOutboxEntity value = new InboundOutboxEntity();
@@ -26,6 +26,7 @@ import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
import com.baiyue.firstleg.domain.PriceBookChannelCatalog;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -138,7 +139,9 @@ public class LoadingListService {
}
public BusinessDocument containerLogisticsDocument(long bookingId,String type){ requireLogisticsType(type); requireBookingPlan(bookingId); return document(repository.document(bookingId,type)); }
public BusinessDocument uploadContainerLogisticsDocument(long bookingId,String type,long operatorId,String operatorName,String key,int expectedVersion,String reason,MultipartFile file){
requireLogisticsType(type); String businessType=fileType(type); return uploadDocument(requireBookingPlan(bookingId),type,businessType,bookingId,Long.toString(bookingId),operatorId,operatorName,key,expectedVersion,reason,file);
requireLogisticsType(type); String businessType=fileType(type); AggregateRow plan=requireBookingPlan(bookingId);
requireDeliveryOrderUploadAllowed(plan);
return uploadDocument(plan,type,businessType,bookingId,Long.toString(bookingId),operatorId,operatorName,key,expectedVersion,reason,file);
}
public DocumentDownload downloadContainerDocument(long bookingId,String type,long fileId){
requireLogisticsType(type); String businessType=fileType(type); requireBookingPlan(bookingId); ContainerDocumentEntity d=requireDocumentEntity(bookingId,type); if(!Long.valueOf(fileId).equals(d.getFileId())) throw notFound("货柜资料不存在");
@@ -190,6 +193,17 @@ public class LoadingListService {
public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {}
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER"); }
private static String fileType(String type){ if (DELIVERY_ORDER.equals(type)) return DO_FILE_TYPE; throw badRequest("documentType只能是DELIVERY_ORDER"); }
private static void requireDeliveryOrderUploadAllowed(AggregateRow plan) {
if (!"COMPLETED".equals(plan.getPlanStatus()) || plan.getOutboundConfirmedAt() == null) {
throw conflict("货柜进入海外履约后才可上传DO资料");
}
if (plan.getActualEmptyReportedAt() != null) {
throw conflict("货柜已还柜,不可再上传DO资料");
}
if (PriceBookChannelCatalog.isSeaRail(plan.getChannelCode())) {
throw conflict("海铁渠道不适用DO资料");
}
}
@Transactional
@BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER",
@@ -233,9 +233,9 @@ public class LoadingPickService {
return view(requiredTask(taskId), warehouseId);
}
public List<PendingTask> pendingTasks(long operatorId, String keyword) {
public List<PendingTask> pendingTasks(long operatorId, long warehouseId, String keyword) {
String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim() : null;
return repository.pendingTasks(operatorId, normalizedKeyword).stream()
return repository.pendingTasks(operatorId, warehouseId, normalizedKeyword).stream()
.map(row -> new PendingTask(row.getTaskId(), row.getLoadingPlanId(), row.getContainerBookingId(),
row.getCabinetNo(), row.getContainerNo(),
row.getSourceType(), row.getRecordStatus(), row.getMergedIntoContainerBookingId(),
@@ -405,7 +405,7 @@ public class LoadingPickService {
requirePackageUnit(item);
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(),
request.warehouseId()) != 1) {
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
throw conflict("部分拣货最终箱数不能小于已拣箱数且必须小于计划箱数,或明细版本已变化");
}
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
request.finalPickedPackageCount(), null, operatorId, operatorName);
@@ -1068,9 +1068,9 @@ public class LoadingPickService {
int current = count(item.getPickedPackageCount());
int planned = count(item.getPlannedPackageCount());
if (requestItem.finalPickedPackageCount() == null
|| requestItem.finalPickedPackageCount() <= current
|| requestItem.finalPickedPackageCount() < current
|| requestItem.finalPickedPackageCount() >= planned) {
throw conflict("PARTIAL最终箱数必须大于已拣且小于计划箱数");
throw conflict("PARTIAL最终箱数不能小于已拣且必须小于计划箱数");
}
} else if ("SKIP".equals(action)) {
if (requestItem.pickedPackageCount() != null || requestItem.finalPickedPackageCount() != null) {
@@ -92,14 +92,18 @@ public class MeasurementReviewService {
"人工确认箱数合计与入库单总箱数不一致,不能审核通过");
}
var weights = measurements.listWeights(tenantId, inboundId);
if (weights.isEmpty() || weights.stream().anyMatch(weight ->
if (weights.isEmpty()) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"该票测量已提交但没有称重图片;请先退回测量,上传并确认至少一张称重图片后重新提交(即使只有一箱也必须称重)");
}
if (weights.stream().anyMatch(weight ->
!"CONFIRMED".equals(weight.getRecognitionStatus())
|| weight.getGrossWeightKg() == null
|| weight.getGrossWeightKg().signum() <= 0
|| weight.getNetWeightKg() == null
|| weight.getNetWeightKg().signum() <= 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"所有称重结果必须已确认且包含大于零的毛重和货物净重,才能审核通过");
"该票存在未确认或重量无效的称重图片;请先在审核弹框逐张修正并确认后再审核");
}
BigDecimal grossWeight = weights.stream()
.map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getGrossWeightKg)
@@ -139,7 +143,7 @@ public class MeasurementReviewService {
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
request.reason(), false, null, false, operatorId, operatorName));
if (approved && "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())) {
dropshipPalletization.createTask(order);
dropshipPalletization.createTask(order, operatorId, operatorName);
}
String approvedStatus = "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())
? "WAIT_PALLETIZE" : "WAIT_SPLIT";
@@ -179,7 +183,7 @@ public class MeasurementReviewService {
throw concurrentConflict();
}
if ("ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())) {
dropshipPalletization.createTask(order);
dropshipPalletization.createTask(order, operatorId, operatorName);
}
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), "REJECTED",
null, true, request.reason(), true, operatorId, operatorName));
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.client.WarehouseMasterDataClient.WarehouseOption;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.List;
/** 为国内 PDA 提供启用中的国内仓选择项,主档仍由 Warehouse Service 唯一维护。 */
@Service
public class PdaWarehouseCatalogService {
@Resource private WarehouseMasterDataClient warehouses;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public List<WarehouseOption> activeDomesticWarehouses() {
try {
ApiResponse<List<WarehouseOption>> response = warehouses.domestic(internalToken);
if (response == null || !response.successful() || response.data() == null) {
throw unavailable();
}
return response.data();
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw unavailable();
}
}
private static BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内仓目录暂时不可用,请稍后重试");
}
}
@@ -158,6 +158,11 @@ public class PlatformPriceBookService {
if (candidate.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE) repository.lockPlatformVersions();
else repository.lockVersions(candidate.getTenantId());
PriceBookVersionEntity version = requirePendingConfirmation(versionId);
List<PriceBookVersionEntity> activeVersions = version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.listActiveForUpdate() : repository.listActiveForUpdate(version.getTenantId());
if (activeVersions.stream().anyMatch(active -> active.getVersionNo() >= version.getVersionNo())) {
throw new BizException(ResultCode.CONFLICT, "该待确认版本早于当前生效版本,请作废后重新上传");
}
List<PriceBookDtos.ParsedChannel> channels = repository.listChannels(versionId).stream().map(this::channelDto).toList();
List<PriceBookDtos.ParsedRate> rates = repository.listRates(versionId).stream().map(this::rateDto).toList();
MappingResult mapped = applyMappings(rates, confirmation);
@@ -174,14 +179,25 @@ public class PlatformPriceBookService {
version.setChannelCount(channels.size());
version.setRateCount(mapped.rates().size());
LocalDateTime now = LocalDateTime.now();
(version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE
? repository.listActiveForUpdate() : repository.listActiveForUpdate(version.getTenantId())).stream()
activeVersions.stream()
.filter(active -> !Objects.equals(active.getId(), versionId))
.forEach(active -> repository.supersede(active, now, operatorId));
repository.activate(version, now, operatorId);
return detail(versionId);
}
@Transactional
public PriceBookDtos.PriceBookVersionDetail voidPending(long versionId, long operatorId,
PriceBookDtos.PriceBookVoidRequest request) {
PriceBookVersionEntity candidate = requireVersion(versionId);
if (candidate.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE) repository.lockPlatformVersions();
else repository.lockVersions(candidate.getTenantId());
PriceBookVersionEntity version = requirePendingConfirmation(versionId);
String reason = request.reason().trim();
repository.voidPending(version, LocalDateTime.now(), operatorId, reason);
return detail(versionId);
}
public ResponseEntity<byte[]> source(long versionId, long operatorId) {
PriceBookVersionEntity version = requireVersion(versionId);
if (version.getSourceFileId() == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在");
@@ -562,7 +578,7 @@ public class PlatformPriceBookService {
value.getSourceFileId(), value.getSourceSha256(), status,
value.getChannelCount(), value.getRateCount(), value.getBlockingErrorCount(), value.getWarningCount(),
value.getParseIssuesJson(), value.getCreatedBy(), value.getCreatedAt(), value.getPublishedBy(),
value.getPublishedAt(), value.getSupersededAt());
value.getPublishedAt(), value.getSupersededAt(), value.getVoidedBy(), value.getVoidedAt(), value.getVoidReason());
}
private static void requireScope(long customerId) {
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PendingReviewOrderClient;
import com.baiyue.firstleg.entity.*;
@@ -42,17 +43,6 @@ import java.util.*;
@Service
public class SmartLoadingService {
private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.50");
private static final Map<String, Set<String>> WORKBENCH_CHANNEL_GROUPS = Map.of(
"MATSON_CLX", Set.of("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL"),
"MATSON_MAX", Set.of("MATSON_MAX_TRUCK", "MATSON_MAX_PARCEL"),
"EXX", Set.of("EXX_TRUCK", "EXX_RAIL_NY"),
"HEDE", Set.of("HEDE_TRUCK", "HEDE_PARCEL"),
"OA_LA", Set.of("OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_PARCEL"),
"OA_CHICAGO", Set.of("OA_CHICAGO_RAIL"),
"OA_HOUSTON", Set.of("OA_HOUSTON_TRUCK"),
"OA_SAVANNAH", Set.of("OA_SAVANNAH_TRUCK"),
"OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK")
);
@Resource private SmartLoadingRepository repository;
@Resource private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPallets;
@Resource private OrderCargoClient orderCargoClient;
@@ -102,7 +92,7 @@ public class SmartLoadingService {
public List<Candidate> candidates(long bookingId, long warehouseId) {
ContainerBookingEntity booking = requireBooking(bookingId);
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致");
if (!StringUtils.hasText(booking.getChannelCode())) throw validation("柜次缺少渠道快照,请先完成存量数据回填");
if (!StringUtils.hasText(booking.getChannelGroupCode())) throw validation("柜次缺少计划渠道,请重新创建模拟柜");
List<Candidate> values = candidates(warehouseId).stream()
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values);
@@ -266,7 +256,7 @@ public class SmartLoadingService {
&& query.createdStart().isAfter(query.createdEnd())) {
throw validation("创建开始日期不能晚于结束日期");
}
return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes()));
return repository.workbenchContainers(query, normalizedGroups(query.channelGroupCodes()));
}
private List<InboundCandidate> inboundCandidates(List<Candidate> details) {
@@ -384,13 +374,17 @@ public class SmartLoadingService {
private static Set<String> actualChannels(List<String> groups) {
Set<String> actual = new LinkedHashSet<>();
for (String value : groups) {
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
Set<String> mapped = WORKBENCH_CHANNEL_GROUPS.get(normalized);
if (mapped == null) throw validation("不支持的渠道分组: " + value);
actual.addAll(mapped);
try { actual.addAll(SmartLoadingChannelGroupCatalog.require(value).channelCodes()); }
catch (IllegalArgumentException exception) { throw validation(exception.getMessage()); }
}
return actual;
}
private static Set<String> normalizedGroups(List<String> groups) {
return groups.stream().map(value -> {
try { return SmartLoadingChannelGroupCatalog.require(value).code(); }
catch (IllegalArgumentException exception) { throw validation(exception.getMessage()); }
}).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
}
@Transactional(readOnly = true)
public Plan draftByBooking(long bookingId) {
@@ -1689,8 +1683,10 @@ public class SmartLoadingService {
}
private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;}
private static boolean sameChannel(ContainerBookingEntity booking, Candidate candidate) {
return StringUtils.hasText(booking.getChannelCode()) && StringUtils.hasText(candidate.channelCode())
&& booking.getChannelCode().equals(candidate.channelCode());
SmartLoadingChannelGroupCatalog.Definition candidateGroup =
SmartLoadingChannelGroupCatalog.resolveChannel(candidate.channelCode());
return StringUtils.hasText(booking.getChannelGroupCode()) && candidateGroup != null
&& booking.getChannelGroupCode().equals(candidateGroup.code());
}
private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId());
@@ -82,8 +82,8 @@ public class SplitPreloadService {
}
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
if (plan != null) {
if (!PreloadStatus.PENDING.equals(plan.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配");
if (!reusableForLoadingAllocation(plan.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装计划当前状态不能修改智能装柜分配");
}
return;
}
@@ -96,17 +96,13 @@ public class SplitPreloadService {
repository.createPlan(plan);
} catch (DuplicateKeyException exception) {
PreloadPlanEntity concurrent = repository.findPlan(tenantId, inboundId);
if (concurrent == null || !PreloadStatus.PENDING.equals(concurrent.getStatus())) {
if (concurrent == null || !reusableForLoadingAllocation(concurrent.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装计划已被并发修改,请刷新后重试");
}
}
}
/**
* A running PDA task may restore or add an unpicked allocation after the plan-level preload
* confirmation. A running PDA task owns the mutation lock, so CONFIRMING and CONFIRMED
* lifecycles may be reused while the restored loading item is synchronised atomically.
*/
/** Reuses the confirmed preload lifecycle while a running PDA task owns the mutation lock. */
@Transactional
public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId,
String operatorName) {
@@ -126,10 +122,8 @@ public class SplitPreloadService {
throw new BizException(ResultCode.CONFLICT, "装柜中的订单缺少既有预装计划,不能新增分配");
}
if (plan != null) {
if (!PreloadStatus.PENDING.equals(plan.getStatus())
&& !(running && Set.of(PreloadStatus.CONFIRMING, PreloadStatus.CONFIRMED)
.contains(plan.getStatus()))) {
throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配");
if (!reusableForLoadingAllocation(plan.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装计划当前状态不能修改智能装柜分配");
}
return;
}
@@ -142,12 +136,17 @@ public class SplitPreloadService {
repository.createPlan(plan);
} catch (DuplicateKeyException exception) {
PreloadPlanEntity concurrent = repository.findPlan(tenantId, inboundId);
if (concurrent == null || !PreloadStatus.PENDING.equals(concurrent.getStatus())) {
if (concurrent == null || !reusableForLoadingAllocation(concurrent.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "预装计划已被并发修改,请刷新后重试");
}
}
}
private static boolean reusableForLoadingAllocation(String status) {
return Set.of(PreloadStatus.PENDING, PreloadStatus.CONFIRMING, PreloadStatus.CONFIRMED)
.contains(status);
}
public PreloadPlanResponse detail(long tenantId, long inboundId) {
requireInbound(tenantId, inboundId);
return response(requirePlan(tenantId, inboundId));
@@ -33,3 +33,19 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label_sku (
id BIGINT UNSIGNED NOT NULL COMMENT '箱唛SKU快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '物理箱唛ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
expected_quantity INT UNSIGNED NOT NULL COMMENT '该物理箱内应收SKU数量',
line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU展示顺序',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carton_label_sku_detail (carton_label_id,inbound_detail_id),
KEY idx_carton_label_sku_code (tenant_id,inbound_id,sku_code),
CONSTRAINT fk_carton_label_sku_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
) ENGINE=InnoDB COMMENT='物理箱唛内的多SKU冻结快照';
@@ -230,6 +230,23 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label_sku (
id BIGINT UNSIGNED NOT NULL COMMENT '箱唛SKU快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '物理箱唛ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
expected_quantity INT UNSIGNED NOT NULL COMMENT '该物理箱内应收SKU数量',
line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU展示顺序',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carton_label_sku_detail (carton_label_id,inbound_detail_id),
KEY idx_carton_label_sku_code (tenant_id,inbound_id,sku_code),
CONSTRAINT fk_carton_label_sku_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
) ENGINE=InnoDB COMMENT='物理箱唛内的多SKU冻结快照';
CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
@@ -541,7 +558,7 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
source_sha256 CHAR(64) NULL COMMENT '原始文件SHA-256',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、FAILED解析失败',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、VOIDED已作废、FAILED解析失败',
blocking_error_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阻断错误数',
warning_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '警告数',
parse_issues_json JSON NULL COMMENT '上传解析问题及待确认重新校验结果',
@@ -552,6 +569,9 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
published_by BIGINT UNSIGNED NULL COMMENT '确认并发布价格版本的操作人ID',
published_at DATETIME(3) NULL COMMENT '确认并生效时间',
superseded_at DATETIME(3) NULL COMMENT '被下一版本替换时间',
voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID',
voided_at DATETIME(3) NULL COMMENT '作废时间',
void_reason VARCHAR(500) NULL COMMENT '作废原因',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除',
@@ -1030,3 +1050,19 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton (
CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id),
CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
) ENGINE=InnoDB COMMENT='一件代发托盘内逐箱SKU和入仓单明细';
CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton_sku (
id BIGINT UNSIGNED NOT NULL COMMENT '清点箱SKU明细ID',
carton_id BIGINT UNSIGNED NOT NULL COMMENT '清点物理箱ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
expected_quantity INT UNSIGNED NOT NULL COMMENT '应收数量',
actual_quantity INT UNSIGNED NOT NULL COMMENT '实收数量',
discrepancy_status VARCHAR(24) NOT NULL COMMENT 'MATCH或ACCEPTED_VARIANCE',
discrepancy_remark VARCHAR(500) NULL COMMENT '差异处理说明',
line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU顺序',
PRIMARY KEY (id),
UNIQUE KEY uk_dropship_carton_sku_detail (carton_id,inbound_detail_id),
KEY idx_dropship_carton_sku_code (sku_code,carton_id),
CONSTRAINT fk_dropship_carton_sku_carton FOREIGN KEY (carton_id) REFERENCES fl_dropship_pallet_carton(id)
) ENGINE=InnoDB COMMENT='一件代发物理箱内逐SKU清点事实';
@@ -34,6 +34,9 @@ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_code') THEN
ALTER TABLE fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_group_code') THEN
ALTER TABLE fl_container_booking ADD COLUMN channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留' AFTER merged_into_container_booking_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_name') THEN
ALTER TABLE fl_container_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code;
END IF;
@@ -93,6 +96,7 @@ BEGIN
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)
OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID',
channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留',
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
@@ -149,11 +150,14 @@ BEGIN
ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL
COMMENT '已关联真实订舱客户名称快照' AFTER customer_id;
END IF;
UPDATE fl_container_booking container_booking
JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE()
AND table_name='fl_ocean_booking') THEN
UPDATE fl_container_booking container_booking
JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id
SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id),
container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name)
WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN
ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL
@@ -465,6 +469,7 @@ BEGIN
DECLARE source_exists INT DEFAULT 0;
DECLARE source_total_columns INT DEFAULT 0;
DECLARE source_smart_columns INT DEFAULT 0;
DECLARE source_current_columns INT DEFAULT 0;
DECLARE source_inbound_columns INT DEFAULT 0;
DECLARE target_exists INT DEFAULT 0;
DECLARE target_total_columns INT DEFAULT 0;
@@ -509,7 +514,45 @@ BEGIN
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')
);
IF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
SELECT COUNT(*) INTO source_current_columns
FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'container_booking_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'cabinet_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'NO')
OR (column_name = 'container_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
OR (column_name = 'task_status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'LOADING')
OR (column_name = 'started_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'started_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
OR (column_name = 'started_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES')
OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES')
OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
);
IF source_total_columns = 13 AND source_current_columns = 13
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'uk_loading_pick_task_booking' AND non_unique = 0
AND seq_in_index = 1 AND column_name = 'container_booking_id') = 1
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'idx_loading_pick_container'
AND ((seq_in_index = 1 AND column_name = 'container_no')
OR (seq_in_index = 2 AND column_name = 'task_status')
OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3
AND (SELECT COUNT(*) FROM information_schema.key_column_usage
WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan'
AND referenced_column_name = 'id') = 1 THEN
SET source_exists = 1;
ELSEIF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
@@ -1430,14 +1473,13 @@ FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id = 0
AND permission.permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:list',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:list',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
'platform:firstleg:loading-list:view')
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list')
WHERE role.tenant_id = 0
AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
@@ -1562,13 +1604,13 @@ BEGIN
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
AND permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:list',
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:assign',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list',
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
'platform:firstleg:preload:edit',
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 14 THEN
'platform:firstleg:loading-list:list')) <> 11 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
@@ -1578,14 +1620,13 @@ BEGIN
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
AND permission.tenant_id = 0
AND permission.permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:list',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:list',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
'platform:firstleg:loading-list:view')) <> 28 THEN
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list')) <> 22 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
END IF;
END$$
@@ -0,0 +1,64 @@
openapi: 3.0.3
info:
title: Platform dashboard operational metrics
version: 1.0.0
paths:
/api/platform/v1/first-leg/dashboard/operational-metrics:
get:
summary: 查询首页货柜与清关实时指标
description: >-
在途柜量按已实际开船且尚未到达海外仓的有效物理柜去重,TEU 按 20*=1、40*=2、45*=2.25
折算,未知箱型不计入 TEU;待清关票数按已进入整柜清关且清关未完成的去重订单统计。
本接口只返回当前时点事实,不提供历史环比。
responses:
'200':
description: 查询成功
content:
application/json:
schema:
$ref: '#/components/schemas/OperationalMetricsEnvelope'
'401': {description: 需要登录}
'403': {description: 需要货柜列表与报关列表权限}
components:
schemas:
OperationalMetricsEnvelope:
type: object
required: [code, message, data]
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/OperationalMetrics'}
OperationalMetrics:
type: object
required:
- inTransitContainerCount
- inTransitTeu
- unknownContainerTypeCount
- pendingCustomsOrderCount
properties:
inTransitContainerCount:
type: integer
format: int64
minimum: 0
description: 已开船未到海外仓的物理柜数
inTransitTeu:
type: number
format: decimal
multipleOf: 0.01
minimum: 0
description: 已知箱型的在途柜 TEU 合计
unknownContainerTypeCount:
type: integer
format: int64
minimum: 0
description: 未计入 TEU 的未知箱型在途柜数
pendingCustomsOrderCount:
type: integer
format: int64
minimum: 0
description: 已进入整柜清关且清关未完成的去重订单数
asOf:
type: string
format: date-time
nullable: true
description: 数据库统计时点
@@ -1,13 +1,14 @@
openapi: 3.0.3
info:
title: 一件代发国内清点组托 API
version: 2.0.0
description: 测量、计费后独立下发。箱码逐箱确认 SKU 实收数量;托盘码可先扫或后扫,封托后作为不可拆分柜单元。
version: 3.0.0
description: 测量环节录入预计托数,审核通过后预生成全部 DP 托盘码并可合并打印。PDA 支持先扫箱或先扫托,同一国内作业仓可跨票共用开放托盘,实物装满后再封托。
paths:
/api/platform/v1/first-leg/pda-dropship/tasks/pending:
get:
summary: 查询待领取或本人已领取任务
x-required-authority: platform:firstleg:pda:view
parameters: [{$ref: '#/components/parameters/WarehouseId'}, {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}}]
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}:
get:
@@ -33,8 +34,8 @@ paths:
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/count:
post:
summary: 逐箱确认 SKU 实收数量,可同时绑定当前托盘
description: palletNo 为空时进入 COUNTED_UNBOUND;不为空时幂等获取或创建 OPEN 托盘并绑定。数量不一致必须 ACCEPT_VARIANCE 且填写说明。
summary: 逐箱确认 SKU 实收数量
description: 未传 palletNo 时进入待归托区;传入同一国内作业仓的 OPEN 托盘码时直接归托。数量不一致必须 ACCEPT_VARIANCE 且填写说明。
x-required-authority: platform:firstleg:pda:work
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CountCartonRequest'}}}}
@@ -46,14 +47,6 @@ paths:
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindPendingRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/create-and-bind:
post:
summary: 系统生成 DP 托盘码并原子绑定全部待绑箱
description: 仅当前仓库本人已领取的待组托任务可执行。没有待绑箱时不发码、不建空托。operationId 在同任务和仓库重放不重复创建;不同任务使用同幂等号拒绝。返回当前 Task。
x-required-authority: platform:firstleg:pda:work
parameters: [{$ref: '#/components/parameters/TaskId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateAndBindRequest'}}}}
responses: {'200': {description: Task}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/{cartonId}/move:
post:
summary: 换托,targetPalletNo 为空时退回待绑区
@@ -65,13 +58,32 @@ paths:
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/seal:
post:
summary: 封托;禁止空托、待绑箱、版本冲突和重复归托
summary: 封托;禁止空托、版本冲突和重复归托
x-required-authority: platform:firstleg:pda:work
parameters:
- {$ref: '#/components/parameters/TaskId'}
- {name: palletId, in: path, required: true, schema: {type: integer, format: int64}}
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}}}
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/print:
get:
summary: PDA 获取预生成托盘码的 A4 托盘信息单
description: 测量审核通过并生成托盘码后即可获取;每个托盘固定一页 A4,包含托盘号及其 Code 128 一维码、入仓码、箱数以及已归托 SKU、商品名称和实点数量,不展示作业状态。空托显示暂无已归托 SKU。仅当前仓库、当前操作员可访问。
x-required-authority: platform:firstleg:pda:view
parameters:
- {$ref: '#/components/parameters/TaskId'}
- {name: palletId, in: path, required: true, schema: {type: integer, format: int64}}
- {$ref: '#/components/parameters/WarehouseId'}
responses:
'200': {description: A4 托盘信息单 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/print:
get:
summary: 合并获取本票全部预生成托盘码的多页 A4 PDF
description: 每个预生成托盘固定一页,PDA 无需展开托盘列表或逐个下载,最多按本票预计托数生成。
x-required-authority: platform:firstleg:pda:view
parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/WarehouseId'}]
responses:
'200': {description: 多页 A4 托盘信息单 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}}
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets:
get:
summary: Admin 查询入仓单权威托盘结果
@@ -79,7 +91,7 @@ paths:
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print:
get:
summary: 打印托盘码、入仓码和 SKU 实收汇总
summary: 获取包含托盘码、入仓码和 SKU 实收汇总的 A4 托盘信息单
x-required-authority: firstleg:inbound:list
responses: {'200': {description: PDF}}
components:
@@ -87,6 +99,22 @@ components:
TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64}}
WarehouseId: {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64}}
schemas:
Task:
type: object
description: 一件代发清点组托任务;palletSetupReady=false 时禁止领取、解析箱码和提交清点。
required: [taskId, totalCartons, countedCartons, boundCartons, unboundCartons, expectedPalletCount, generatedPalletCount, palletSetupReady, taskStatus, pallets]
properties:
taskId: {type: integer, format: int64}
totalCartons: {type: integer, minimum: 1}
countedCartons: {type: integer, minimum: 0}
boundCartons: {type: integer, minimum: 0}
unboundCartons: {type: integer, minimum: 0}
expectedPalletCount: {type: integer, minimum: 1}
generatedPalletCount: {type: integer, minimum: 0}
palletSetupReady: {type: boolean}
palletSetupMessage: {type: string, nullable: true}
taskStatus: {type: string, enum: [PENDING, CLAIMED, COMPLETED]}
pallets: {type: array, items: {$ref: '#/components/schemas/Pallet'}}
Carton:
type: object
properties:
@@ -94,18 +122,23 @@ components:
boxCode: {type: string}
inboundId: {type: integer, format: int64}
inboundCode: {type: string}
inboundDetailId: {type: integer, format: int64}
skuCode: {type: string}
cargoName: {type: string, nullable: true}
expectedSkuQuantity: {type: integer, minimum: 1}
actualSkuQuantity: {type: integer, minimum: 1, nullable: true}
discrepancyStatus: {type: string, enum: [MATCH, ACCEPTED_VARIANCE], nullable: true}
discrepancyRemark: {type: string, nullable: true}
skuLines: {type: array, minItems: 1, items: {$ref: '#/components/schemas/CartonSkuLine'}, description: 当前物理箱内全部SKU清点行}
countStatus: {type: string, enum: [UNCOUNTED, COUNTED_UNBOUND, BOUND]}
palletId: {type: integer, format: int64, nullable: true}
palletNo: {$ref: '#/components/schemas/NullablePalletNo'}
canCount: {type: boolean}
blockReason: {type: string, nullable: true}
CartonSkuLine:
type: object
required: [inboundDetailId, skuCode, expectedQuantity]
properties:
inboundDetailId: {type: integer, format: int64}
skuCode: {type: string}
cargoName: {type: string, nullable: true}
expectedQuantity: {type: integer, minimum: 1}
actualQuantity: {type: integer, minimum: 0, nullable: true}
discrepancyStatus: {type: string, enum: [MATCH, ACCEPTED_VARIANCE], nullable: true}
discrepancyRemark: {type: string, nullable: true}
Pallet:
type: object
properties:
@@ -127,14 +160,23 @@ components:
properties: {warehouseId: {type: integer, format: int64}}
CountCartonRequest:
type: object
required: [warehouseId, boxCode, actualSkuQuantity, discrepancyAction, operationId]
required: [warehouseId, boxCode, skuLines, operationId]
properties:
warehouseId: {type: integer, format: int64}
boxCode: {type: string, maxLength: 80}
actualSkuQuantity: {type: integer, minimum: 1}
discrepancyAction: {type: string, enum: [MATCH, ACCEPT_VARIANCE]}
discrepancyRemark: {type: string, maxLength: 500, nullable: true}
palletNo: {$ref: '#/components/schemas/NullablePalletNo'}
skuLines:
type: array
minItems: 1
items:
type: object
required: [inboundDetailId, skuCode, actualQuantity, discrepancyAction]
properties:
inboundDetailId: {type: integer, format: int64}
skuCode: {type: string, maxLength: 128}
actualQuantity: {type: integer, minimum: 0}
discrepancyAction: {type: string, enum: [MATCH, ACCEPT_VARIANCE]}
discrepancyRemark: {type: string, maxLength: 500, nullable: true}
operationId: {type: string, maxLength: 128}
BindPendingRequest:
type: object
@@ -158,10 +200,4 @@ components:
expectedVersion: {type: integer, minimum: 1}
operationId: {type: string, maxLength: 128}
PalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$'}
CreateAndBindRequest:
type: object
required: [warehouseId, operationId]
properties:
warehouseId: {type: integer, format: int64, minimum: 1}
operationId: {type: string, minLength: 1, maxLength: 128}
NullablePalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$', nullable: true}
@@ -112,6 +112,13 @@ paths:
parameters: [{$ref: '#/components/parameters/VersionId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookConfirmationRequest'}}}}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/void:
post:
summary: 作废待确认船期价格表版本
description: 仅允许作废 PENDING_CONFIRMATION 版本;保留版本、原始文件和审计记录,作废后不可再确认生效。需要 firstleg:fba-pricing:rate:edit 权限。
parameters: [{$ref: '#/components/parameters/VersionId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookVoidRequest'}}}}
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source:
get:
summary: 下载船期价格表原始文件
@@ -528,7 +535,7 @@ components:
effectiveTo: {type: string, format: date-time, nullable: true}
PriceBookStatus:
type: string
enum: [PENDING_CONFIRMATION, ACTIVE, SUPERSEDED, FAILED]
enum: [PENDING_CONFIRMATION, ACTIVE, SUPERSEDED, VOIDED, FAILED]
PriceModel:
type: string
enum: [FBA_WAREHOUSE_RATE, REGION_WEIGHT_TIER]
@@ -564,6 +571,9 @@ components:
publishedBy: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
publishedAt: {type: string, format: date-time, nullable: true}
supersededAt: {type: string, format: date-time, nullable: true}
voidedBy: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
voidedAt: {type: string, format: date-time, nullable: true}
voidReason: {type: string, maxLength: 500, nullable: true}
PriceBookVersionDetail:
type: object
required: [version, channels, rates, issues]
@@ -600,6 +610,11 @@ components:
type: array
maxItems: 2
items: {$ref: '#/components/schemas/PriceBookOverseasMapping'}
PriceBookVoidRequest:
type: object
required: [reason]
properties:
reason: {type: string, minLength: 1, maxLength: 500, description: 人工作废原因}
PriceBookOriginMapping:
type: object
required: [originWarehouseCode, warehouseId]
@@ -1224,7 +1224,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/approve:
post:
summary: 审核通过测量
description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和后续履约状态。
description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。审核通过前必须存在至少一张已确认的称重图片;历史异常数据若已提交但没有称重图片,必须先退回测量后补录。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和后续履约状态。
parameters:
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1245,7 +1245,7 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/submit:
post:
summary: 提交测量完成
description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。
description: 即使只有一箱,也必须先上传并确认至少一张称重图片。提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -1605,6 +1605,8 @@ components:
lxCode: {type: string, nullable: true, description: 一件代发线路或履约产品编码快照}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
fulfillmentType: {type: string, nullable: true, enum: [STANDARD, ONE_CLICK_DROPSHIP]}
expectedPalletCount: {type: integer, minimum: 1, maximum: 1000, nullable: true, description: 一件代发预计托数;测量审核通过后按此数量预生成 DP 托盘码}
taxMode: {type: string, nullable: true}
pricingTaxMode: {type: string, nullable: true}
containerMode: {type: string, nullable: true}
@@ -2000,6 +2002,7 @@ components:
measurementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
version: {type: integer, minimum: 0}
actualGoodsName: {type: string, maxLength: 255, nullable: true}
expectedPalletCount: {type: integer, minimum: 1, maximum: 1000, nullable: true, description: 一件代发必填;普通入库可不传}
items:
type: array
minItems: 1
@@ -2174,7 +2177,7 @@ components:
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
ContainerBooking:
type: object
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
@@ -2185,8 +2188,9 @@ components:
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回}
mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空}
channelCode: {type: string, maxLength: 64, nullable: true, description: Missing legacy snapshots are blocked from loading until explicitly backfilled}
channelName: {type: string, maxLength: 128, nullable: true}
channelGroupCode: {type: string, maxLength: 64, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK], description: 模拟柜创建时确定的九类计划渠道分组,关联真实订舱后继续保留}
channelCode: {type: string, maxLength: 64, nullable: true, description: 关联真实订舱后冻结的实际物流渠道编码}
channelName: {type: string, maxLength: 128, nullable: true, description: 关联真实订舱后冻结的实际物流渠道名称}
oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
oceanBookingRequirementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -2444,8 +2448,8 @@ components:
pages: {type: string, pattern: '^[0-9]+$'}
SmartLoadingWorkbenchContainer:
type: object
required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelCode,
channelName, containerType, capacityCbm, maxWeightKg, planStatus, version]
required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelGroupCode,
containerType, capacityCbm, maxWeightKg, planStatus, version]
properties:
bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联}
bookingNo: {type: string, description: 货柜登记业务号}
@@ -2453,8 +2457,9 @@ components:
containerNo: {type: string, nullable: true, description: 真实集装箱号,允许在装柜前后补}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
warehouseName: {type: string}
channelCode: {type: string}
channelName: {type: string}
channelGroupCode: {type: string, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK], description: 智能装柜匹配使用的计划渠道分组}
channelCode: {type: string, nullable: true, description: 关联真实订舱后冻结的实际物流渠道编码}
channelName: {type: string, nullable: true, description: 关联真实订舱后冻结的实际物流渠道名称}
containerType: {type: string}
capacityCbm: {type: number, nullable: true, description: 柜型基础容积;装柜上限按正式规则计算}
maxWeightKg: {type: number, nullable: true, description: 柜型基础载重;装柜上限按正式规则计算}
@@ -2706,9 +2711,9 @@ components:
reason: {type: string, maxLength: 500}
ContainerSimulationCreateRequest:
type: object
required: [channelCode, warehouseId, containerType, capacityCbm, maxWeightKg]
required: [channelGroupCode, warehouseId, containerType, capacityCbm, maxWeightKg]
properties:
channelCode: {type: string, maxLength: 64, description: 当前 ACTIVE 价格簿中的真实物流渠道}
channelGroupCode: {type: string, maxLength: 64, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK], description: 智能装柜使用的九类计划渠道分组}
warehouseId: {type: integer, format: int64, minimum: 1}
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 与订舱共用的平台固定柜型}
capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定容量}
@@ -89,7 +89,7 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
get:
summary: Render the current saved booking as an export shipping instruction PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF.
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Container requirements are not treated as actual cargo quantity, weight, volume, marks, packaging, or goods descriptions.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
@@ -97,7 +97,7 @@ paths:
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
get:
summary: Render the current saved booking as a bill-of-lading confirmation PDF
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF.
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Container requirements remain a container summary and are not treated as actual cargo facts.
x-permission: platform:firstleg:ocean-booking:list
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses:
@@ -107,7 +107,8 @@ paths:
summary: Page released booking requirement candidates for one container
description: >-
Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates:
RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType.
RELEASED, remainingQuantity greater than zero, exact warehouseId and containerType, and a concrete
booking channel belonging to channelGroupCode.
Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity
counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one
bounded set query; booking detail calls are not used. An empty result is a normal successful page.
@@ -115,7 +116,7 @@ paths:
x-permission: platform:firstleg:ocean-booking:list
parameters:
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: channelCode, in: query, required: true, schema: {type: string, maxLength: 64}}
- {name: channelGroupCode, in: query, required: true, schema: {type: string, maxLength: 64}}
- {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}}
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
- {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}}
@@ -39,10 +39,10 @@ paths:
/api/platform/v1/containers/{id}/documents/{documentType}/upload:
post:
operationId: uploadContainerDocument
description: '权限:platform:firstleg:terminal-drayage:document:edit;仅国外工作台DO,无完成动作'
description: '权限:platform:firstleg:terminal-drayage:document:edit;仅已进入海外履约且未还柜的非海铁渠道可上传DO,无完成动作'
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: Idempotency-Key, in: header, required: true, schema: {type: string}}]
requestBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file, expectedVersion], properties: {file: {type: string, format: binary}, expectedVersion: {type: integer, minimum: 0}, operationReason: {type: string, maxLength: 500}}}}}}
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}, '409': {description: '尚未进入海外履约、海铁渠道不适用DO或货柜已还柜'}}
/api/platform/v1/containers/{id}/documents/{documentType}/{fileId}/download:
get:
operationId: downloadContainerDocument
@@ -3,6 +3,17 @@ info:
title: Phase 3 Container PDA Picking API
version: 1.0.0
paths:
/api/platform/v1/first-leg/pda-loading/warehouses:
get:
summary: List active domestic warehouses available to the PDA operator
responses:
'200':
description: Warehouse master-data options used to scope PDA pending-task lists
content:
application/json:
schema:
type: array
items: {$ref: '#/components/schemas/WarehouseOption'}
/api/platform/v1/first-leg/pda-loading/tasks:
get:
summary: Find the active container picking task in one platform warehouse
@@ -18,6 +29,7 @@ paths:
get:
summary: List active tasks available to the authenticated PDA operator
parameters:
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}}
responses:
'200':
@@ -175,6 +187,13 @@ components:
TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
schemas:
WarehouseOption:
type: object
required: [id, warehouseCode, warehouseName]
properties:
id: {type: integer, format: int64}
warehouseCode: {type: string}
warehouseName: {type: string}
Task:
type: object
required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items]
@@ -27,7 +27,11 @@ class InboundMeasurementEvidenceOpenApiContractTest {
Map<String, Object> submitOperation = stringMap(stringMap(paths.get(
"/api/platform/v1/inbounds/{inboundId}/measurements/submit")).get("post"));
assertThat(submitOperation.get("description").toString())
.contains("只校验并复用", "不会首次分配");
.contains("即使只有一箱", "上传并确认至少一张称重图片", "只校验并复用", "不会首次分配");
Map<String, Object> approveOperation = stringMap(stringMap(paths.get(
"/api/platform/v1/inbounds/{inboundId}/measurements/approve")).get("post"));
assertThat(approveOperation.get("description").toString())
.contains("已确认的称重图片", "历史异常数据", "先退回测量");
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> detailProperties = properties(schemas, "InboundMeasurementDetail");

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