diff --git a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java index bb60517ca..aae981b02 100644 --- a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java +++ b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java @@ -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 registration( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @PathVariable("authUserId") Long authUserId); + @DeleteMapping("/merchant-registrations/{authUserId}/compensation") ApiResponse compensate( @RequestHeader("X-Internal-Service-Token") String internalToken, diff --git a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java index 8203a100e..d0ca9eb4c 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java +++ b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java @@ -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, diff --git a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java index 1ce404873..e17c5410e 100644 --- a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java +++ b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java @@ -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; diff --git a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java index b09d43d33..7eb495634 100644 --- a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java @@ -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"); diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index 905c6050d..8c64ee40f 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -141,8 +141,18 @@ public record FirstLegLoadingPlanCompletedV2( } } - /** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */ - public record PalletSnapshot(String palletNo, List skuPackages) {} + /** 普通 FBA 只复用 palletNo;一件代发还携带同一物理 DP 的完整清单摘要。 */ + public record PalletSnapshot(String palletNo, List skuPackages, + Integer manifestVersion, Integer manifestPackageCount, + Integer manifestFragmentCount, String manifestHash) { + public PalletSnapshot { + skuPackages = skuPackages == null ? List.of() : List.copyOf(skuPackages); + } + + public PalletSnapshot(String palletNo, List skuPackages) { + this(palletNo, skuPackages, null, null, null, null); + } + } /** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */ public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName, diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index 12d49a4a2..33709eb4a 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -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: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 9f0c87d39..4b703a3c0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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: diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 4b1e979d5..32c670b3e 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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, diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index db717957a..87fcda2fd 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -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='柜级入库计划批量托盘明细与资源锁关联'; diff --git a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java index a0c63a603..181269c14 100644 --- a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java +++ b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java @@ -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, diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java index 44ed58565..e4694bede 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java @@ -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"); } diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java index d47487852..fd8cf524b 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java @@ -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"); } diff --git a/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java b/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java index eaffd1079..a8104f66e 100644 --- a/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java +++ b/file/src/main/java/com/baiyue/file/mapper/FileExportOutboxMapper.java @@ -44,12 +44,13 @@ public interface FileExportOutboxMapper extends BaseMapper { 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); diff --git a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java index 6d872dc02..8885e0be6 100644 --- a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java +++ b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java @@ -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 TYPES = Set.of("image/jpeg", "image/png", "application/pdf"); + private static final Set 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; diff --git a/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java b/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java index 63d2c00aa..5519973f0 100644 --- a/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java +++ b/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java @@ -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) diff --git a/file/src/main/resources/openapi/qualification-required-package-v1.yaml b/file/src/main/resources/openapi/qualification-required-package-v1.yaml index 1316db9eb..99e4d0112 100644 --- a/file/src/main/resources/openapi/qualification-required-package-v1.yaml +++ b/file/src/main/resources/openapi/qualification-required-package-v1.yaml @@ -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: 认证或授权失败,响应包含请求关联元数据 diff --git a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java index 948b3eef3..0717cd6ce 100644 --- a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java @@ -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]"); } diff --git a/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java b/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java index 3583f87d3..63c167e07 100644 --- a/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java +++ b/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java @@ -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")); } diff --git a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java index 9fd947bf1..e08d03c5c 100644 --- a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java +++ b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java @@ -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(); diff --git a/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java index 2157f6b76..43d1b1944 100644 --- a/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java @@ -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()); diff --git a/finance/AGENTS.md b/finance/AGENTS.md index a408b9497..87820bedc 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -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。 diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java index 476a75f6a..51aaf07f3 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinancePhase1Dtos.java @@ -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) {} diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java index 5a8d4fd92..9f48b1313 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceWalletDtos.java @@ -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(List 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, diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java index 6d9ecdc3f..c27ab8532 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceCustomerAccountService.java @@ -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 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 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) { } } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index 41f128c0f..1a64ca3a3 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -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 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 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 listDerivedCustomerAccountLedger(long customerId, int page, int pageSize, String businessType, LocalDate startDate, LocalDate endDate) { customer(customerId); page = page(page); pageSize = pageSize(pageSize); diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java index 29faab5a1..0d1f81a09 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java @@ -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 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"))); diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 4ec095e21..d704277da 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -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 ( diff --git a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java index 00981425f..89dbc7f59 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceCustomerAccountServiceTest.java @@ -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()) diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index b794badf7..7b826cd3b 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -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") diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java index d1182edb7..964d12bdd 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSqlCommentContractTest.java @@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest { } } assertThat(tableCount).isEqualTo(23); - assertThat(columnCount).isEqualTo(351); + assertThat(columnCount).isEqualTo(353); assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty(); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java index e2d340709..2b2ecd69c 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java @@ -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") diff --git a/finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceWalletBehaviorTest.java similarity index 59% rename from finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java rename to finance/src/test/java/com/baiyue/finance/service/FinanceWalletBehaviorTest.java index 456f67ed5..4a37ce725 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceWalletBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceWalletBehaviorTest.java @@ -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 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"); } } diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 3e6de5460..86599c08d 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -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`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index ee11c2d60..45387e73e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -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); } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java index 923ba2e5c..ed265fbca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java @@ -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 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