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.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 d939bf45c..a61546f79 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5169,11 +5169,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 @@ -18473,12 +18476,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', @@ -18488,7 +18492,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 ( @@ -18530,6 +18534,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 '业务发生日期', @@ -18544,8 +18549,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 ( @@ -18581,13 +18586,20 @@ BEGIN 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; + UPDATE fin_wallet_account + SET available_balance=actual_balance-credit_used-frozen_balance; + UPDATE fin_wallet_account + SET actual_balance=actual_balance-credit_used, + credit_used=0, + credit_expires_on=NULL; + 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; 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 @@ -18595,11 +18607,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(); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index f7f4c93c5..953b4c5e7 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) @@ -1089,7 +1090,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 +1113,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 +1398,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 +1409,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 +1541,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 +1585,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 +1605,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 +1637,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 +1706,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 +1740,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 +1764,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 +1775,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 +1793,9 @@ 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 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 +1903,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 +2444,111 @@ 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; +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 +2663,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 +2719,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 +3287,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 +3336,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 +3386,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 '客户端幂等键', @@ -3700,9 +3904,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 +4020,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' @@ -4562,7 +4766,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/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..db0ceee41 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceWalletContractTest.java @@ -14,12 +14,14 @@ class FinanceWalletContractTest { 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/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java index 77dc5e25f..4e08fb76b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -60,10 +60,22 @@ public interface DropshipPalletizationMapper { @Select("SELECT * FROM fl_dropship_pallet WHERE pallet_no=#{palletNo}") PalletRow palletByNo(@Param("palletNo") String palletNo); @Select("SELECT * FROM fl_dropship_pallet WHERE seal_operation_id=#{operationId}") PalletRow palletBySealOperation(@Param("operationId") String operationId); - @Select("SELECT * FROM fl_dropship_pallet WHERE id=#{palletId} AND task_id=#{taskId} AND warehouse_id=#{warehouseId}") + @Select(""" + SELECT p.* FROM fl_dropship_pallet p + WHERE p.id=#{palletId} AND p.warehouse_id=#{warehouseId} + AND (p.task_id=#{taskId} OR EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=p.id AND c.task_id=#{taskId})) + """) PalletRow pallet(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("warehouseId") long warehouseId); - @Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id") List pallets(@Param("taskId") long taskId); - @Select("SELECT p.* FROM fl_dropship_pallet p JOIN fl_dropship_pallet_task t ON t.id=p.task_id WHERE t.tenant_id=#{tenantId} AND t.inbound_id=#{inboundId} ORDER BY p.id") + @Select(""" + SELECT DISTINCT p.* FROM fl_dropship_pallet p + JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + WHERE c.task_id=#{taskId} ORDER BY p.id + """) List pallets(@Param("taskId") long taskId); + @Select(""" + SELECT DISTINCT p.* FROM fl_dropship_pallet p + JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + WHERE c.tenant_id=#{tenantId} AND c.inbound_id=#{inboundId} ORDER BY p.id + """) List palletsByInbound(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId); @Select("SELECT inbound_code FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0") String inboundCode(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId); @@ -123,46 +135,66 @@ public interface DropshipPalletizationMapper { @Update(""" UPDATE fl_dropship_pallet SET pallet_status='SEALED',seal_operation_id=#{operationId},completed_by=#{operatorId}, completed_by_name=#{operatorName},completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3) - WHERE id=#{palletId} AND task_id=#{taskId} AND pallet_status='OPEN' AND version=#{expectedVersion} + WHERE id=#{palletId} AND warehouse_id=#{warehouseId} AND pallet_status='OPEN' AND version=#{expectedVersion} AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId}) - AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL)""") + AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} AND c.task_id=#{taskId}) + AND NOT EXISTS( + SELECT 1 FROM fl_dropship_pallet_task t + WHERE EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} AND c.task_id=t.id) + AND (t.counted_cartons<>t.total_cartons OR t.allocated_cartons<>t.total_cartons + OR EXISTS(SELECT 1 FROM fl_dropship_pallet_carton pending WHERE pending.task_id=t.id AND pending.pallet_id IS NULL)) + )""") int seal(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("expectedVersion") int expectedVersion, - @Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); - @Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId} AND pallet_status='OPEN'") int openPalletCount(@Param("taskId") long taskId); + @Param("warehouseId") long warehouseId,@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + @Select(""" + SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p + JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + WHERE c.task_id=#{taskId} AND p.pallet_status='OPEN' + """) int openPalletCount(@Param("taskId") long taskId); + @Select("SELECT DISTINCT task_id FROM fl_dropship_pallet_carton WHERE pallet_id=#{palletId} ORDER BY task_id") + List taskIdsByPallet(@Param("palletId") long palletId); @Update(""" UPDATE fl_dropship_pallet_task SET task_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND task_status='CLAIMED' AND counted_cartons=total_cartons AND allocated_cartons=total_cartons - AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet p WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId); + AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c JOIN fl_dropship_pallet p ON p.id=c.pallet_id + WHERE c.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId); @Update(""" - UPDATE fl_inbound_order o SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(*) FROM fl_dropship_pallet p - WHERE p.inbound_id=o.id AND p.pallet_status='SEALED'),o.updated_by=#{operatorId},o.version=o.version+1 + UPDATE fl_inbound_order o SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(DISTINCT p.id) + FROM fl_dropship_pallet_carton c JOIN fl_dropship_pallet p ON p.id=c.pallet_id + WHERE c.tenant_id=o.tenant_id AND c.inbound_id=o.id AND p.pallet_status='SEALED'),o.updated_by=#{operatorId},o.version=o.version+1 WHERE o.id=#{inboundId} AND o.tenant_id=#{tenantId} AND o.process_status='WAIT_PALLETIZE' AND EXISTS(SELECT 1 FROM fl_dropship_pallet_task t WHERE t.id=#{taskId} AND t.task_status='COMPLETED')""") int completeInbound(@Param("taskId") long taskId,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,@Param("operatorId") long operatorId); - @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.pallet_id=#{palletId} ORDER BY c.sku_code,c.inbound_detail_id,c.id") List cartons(@Param("palletId") long palletId); + @Select(""" + SELECT c.*,p.pallet_no,o.inbound_code FROM fl_dropship_pallet_carton c + LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id + WHERE c.pallet_id=#{palletId} ORDER BY o.inbound_code,c.sku_code,c.inbound_detail_id,c.id + """) List cartons(@Param("palletId") long palletId); @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL ORDER BY c.counted_at,c.id") List unboundCartons(@Param("taskId") long taskId); @Select(""" - SELECT COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg, - COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm - FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id - LEFT JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId} - GROUP BY p.id,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count""") + SELECT COALESCE(SUM(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0)),0) weight_kg, + COALESCE(SUM(o.actual_volume_cbm/NULLIF(o.total_package_count,0)),0) volume_cbm + FROM fl_dropship_pallet_carton c + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id + WHERE c.pallet_id=#{palletId}""") PalletMetrics metrics(@Param("palletId") long palletId); @Select(""" SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(c.id) carton_count, COUNT(DISTINCT c.sku_code) sku_count, - COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg, - COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm - FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id - JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id - WHERE p.inbound_id=#{inboundId} AND p.pallet_status='SEALED' + COALESCE(SUM(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0)),0) weight_kg, + COALESCE(SUM(o.actual_volume_cbm/NULLIF(o.total_package_count,0)),0) volume_cbm + FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id + WHERE EXISTS(SELECT 1 FROM fl_dropship_pallet_carton own WHERE own.pallet_id=p.id AND own.inbound_id=#{inboundId}) + AND p.pallet_status='SEALED' AND NOT EXISTS(SELECT 1 FROM fl_loading_plan_item i WHERE i.pallet_id=p.id AND i.deleted=0) - GROUP BY p.id,p.pallet_no,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count ORDER BY p.id + GROUP BY p.id,p.pallet_no ORDER BY p.id """) List palletCandidates(@Param("inboundId") long inboundId); @Select(""" - SELECT p.id pallet_id,p.pallet_no,p.pallet_status,p.tenant_id account_id,p.inbound_id,o.order_id,o.order_no, + SELECT p.id pallet_id,p.pallet_no,p.pallet_status,c.tenant_id account_id,c.inbound_id,o.order_id,o.order_no, o.inbound_code,o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id, o.destination_code destination_warehouse_code,o.destination_name destination_warehouse_name,o.customer_name, c.inbound_detail_id,c.sku_code,MAX(c.cargo_name) cargo_name,COUNT(c.id) carton_count, @@ -170,9 +202,9 @@ public interface DropshipPalletizationMapper { COALESCE(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0),0) unit_gross_weight_kg, COALESCE(o.actual_net_weight_kg/NULLIF(o.total_package_count,0),0) unit_net_weight_kg, COALESCE(o.actual_volume_cbm/NULLIF(o.total_package_count,0),0) unit_volume_cbm - FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id - JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId} - GROUP BY p.id,p.pallet_no,p.pallet_status,p.tenant_id,p.inbound_id,o.order_id,o.order_no,o.inbound_code, + FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id WHERE p.id=#{palletId} + GROUP BY p.id,p.pallet_no,p.pallet_status,c.tenant_id,c.inbound_id,o.order_id,o.order_no,o.inbound_code, o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,o.destination_code,o.destination_name, o.customer_name,c.inbound_detail_id,c.sku_code,o.actual_gross_weight_kg,o.actual_net_weight_kg,o.actual_volume_cbm,o.total_package_count ORDER BY c.inbound_detail_id @@ -181,7 +213,7 @@ public interface DropshipPalletizationMapper { @Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,version; } @Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; } @Getter @Setter class LabelRow { private Long cartonLabelId,inboundDetailId; private String skuCode,cargoName,inboundCode,boxCode; private Integer unitsPerCarton; } - @Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; } + @Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,inboundCode,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; } record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {} @Getter @Setter class PalletLine { private Long palletId,tenantId,accountId,inboundId,orderId,warehouseId,destinationWarehouseId,inboundDetailId; private String palletNo,palletStatus,orderNo,inboundCode,channelCode,channelName,destinationWarehouseCode,destinationWarehouseName,customerName,skuCode,cargoName; private Integer cartonCount,actualSkuQuantity; private BigDecimal unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm; } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index 9d4493422..947107039 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -85,7 +85,8 @@ public interface LoadingCompletedEventMapper { pallet.pallet_no AS pallet_code,NULL AS allocation_no, COUNT(*) OVER (PARTITION BY pallet.id,carton.tenant_id,carton.inbound_id) AS allocated_package_count, carton.inbound_detail_id AS source_inbound_detail_id,carton.sku_code, - item.product_name_zh,item.package_count,item.product_quantity,carton.box_code + item.product_name_zh,item.package_count,item.product_quantity,carton.box_code, + carton.actual_sku_quantity FROM fl_dropship_pallet pallet JOIN fl_dropship_pallet_carton carton ON carton.pallet_id=pallet.id JOIN fl_loading_plan_item item ON item.loading_plan_id=#{planId} @@ -98,15 +99,17 @@ public interface LoadingCompletedEventMapper { @Select(""" SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p - JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id - AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='SEALED' + JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=c.tenant_id + AND i.inbound_id=c.inbound_id AND i.deleted=0 WHERE p.pallet_status='SEALED' """) int workPalletCount(@Param("planId") long planId); @Select(""" SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p - JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id - AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='OPEN' + JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=c.tenant_id + AND i.inbound_id=c.inbound_id AND i.deleted=0 WHERE p.pallet_status='OPEN' """) int openWorkPalletCount(@Param("planId") long planId); @@ -138,6 +141,6 @@ public interface LoadingCompletedEventMapper { private Boolean actualBinding; private Integer allocationNo; private Integer allocatedPackageCount; private Long sourceInboundDetailId; private String skuCode; private String productNameZh; - private Integer packageCount; private Integer productQuantity; private String boxCode; + private Integer packageCount; private Integer productQuantity; private Integer actualSkuQuantity; private String boxCode; } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java index 513ab7594..f8efa803c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java @@ -143,16 +143,18 @@ public class DropshipPalletizationService { public Task seal(long taskId,long palletId,SealPalletRequest request,long operatorId,String operatorName){ TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId); PalletRow replay=mapper.palletBySealOperation(operation(request.operationId())); - if(replay!=null){ if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getId(),palletId)) throw conflict("封托幂等号已用于其他托盘"); return view(requireTask(taskId,request.warehouseId())); } + if(replay!=null){ if(!Objects.equals(replay.getId(),palletId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())) throw conflict("封托幂等号已用于其他托盘"); return view(requireTask(taskId,request.warehouseId())); } requireOpenPallet(taskId,palletId,request.warehouseId()); if(mapper.unboundCount(taskId)>0) throw conflict("仍有已清点待绑托箱,不能封托"); - if(mapper.seal(taskId,palletId,request.expectedVersion(),operation(request.operationId()),operatorId,operatorName)!=1) + if(mapper.seal(taskId,palletId,request.expectedVersion(),request.warehouseId(),operation(request.operationId()),operatorId,operatorName)!=1) throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试"); - TaskRow refreshed=requireTask(taskId,request.warehouseId()); - if(Objects.equals(refreshed.getCountedCartons(),refreshed.getTotalCartons()) && - Objects.equals(refreshed.getAllocatedCartons(),refreshed.getTotalCartons()) && mapper.openPalletCount(taskId)==0){ - if(mapper.completeTask(taskId)!=1 || mapper.completeInbound(taskId,refreshed.getTenantId(),refreshed.getInboundId(),operatorId)!=1) - throw conflict("组托完成状态已变化,请刷新后重试"); + for(Long participatingTaskId:mapper.taskIdsByPallet(palletId)){ + TaskRow participating=requireTask(participatingTaskId,request.warehouseId()); + if(Objects.equals(participating.getCountedCartons(),participating.getTotalCartons()) && + Objects.equals(participating.getAllocatedCartons(),participating.getTotalCartons()) && mapper.openPalletCount(participatingTaskId)==0){ + if(mapper.completeTask(participatingTaskId)!=1 || mapper.completeInbound(participatingTaskId,participating.getTenantId(),participating.getInboundId(),operatorId)!=1) + throw conflict("组托完成状态已变化,请刷新后重试"); + } } return view(requireTask(taskId,request.warehouseId())); } @@ -166,14 +168,14 @@ public class DropshipPalletizationService { private PalletRow getOrCreatePallet(TaskRow task,String palletNo,String operation,long operatorId,String operatorName){ PalletRow existing=mapper.palletByNo(palletNo); if(existing!=null){ - if(!Objects.equals(existing.getTaskId(),task.getId()) || !Objects.equals(existing.getTenantId(),task.getTenantId())) throw conflict("托盘码已被其他客户或入仓任务占用"); + if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码属于其他仓库"); if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱"); return existing; } try{ mapper.insertPallet(IdWorker.getId(),task.getId(),task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); } catch(DuplicateKeyException e){ existing=mapper.palletByNo(palletNo); if(existing==null) throw conflict("托盘码已被并发占用"); } existing=mapper.palletByNo(palletNo); - if(existing==null || !Objects.equals(existing.getTaskId(),task.getId())) throw conflict("托盘码已被其他任务占用"); + if(existing==null || !Objects.equals(existing.getWarehouseId(),task.getWarehouseId()) || !"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘码已被其他仓库占用或已经封托"); return existing; } @@ -192,15 +194,15 @@ public class DropshipPalletizationService { private Pallet palletView(PalletRow row,String inboundCode){ List cartons=mapper.cartons(row.getId()); Map> groups=new LinkedHashMap<>(); - for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c); - List items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),inboundCode, + for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundId()+"|"+c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c); + List items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),first.getInboundCode(), first.getInboundDetailId(),first.getSkuCode(),first.getCargoName(),values.size(),values.stream().mapToInt(v->v.getActualSkuQuantity()==null?0:v.getActualSkuQuantity()).sum(), values.stream().map(CartonRow::getBoxCode).toList());}).toList(); int discrepancies=(int)cartons.stream().filter(c->"ACCEPTED_VARIANCE".equals(c.getDiscrepancyStatus())).count(); PalletMetrics metrics=mapper.metrics(row.getId()); return new Pallet(row.getId(),row.getPalletNo(),row.getPalletStatus(),cartons.size(),items.size(),discrepancies, metrics==null?BigDecimal.ZERO:metrics.weightKg(),metrics==null?BigDecimal.ZERO:metrics.volumeCbm(),row.getVersion(),items, - cartons.stream().map(c->cartonView(c,inboundCode,false,null)).toList(),row.getCompletedAt()); + cartons.stream().map(c->cartonView(c,c.getInboundCode(),false,null)).toList(),row.getCompletedAt()); } private Carton cartonView(CartonRow row,String inboundCode,boolean canCount,String blockReason){ diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 1934f8a59..b8765e06b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -67,12 +67,14 @@ public class LoadingCompletedOutboxService { palletLabelService.prepare(planId, rows.get(0).getWarehouseId()); Map> cartonMarks = cartonLabelPools(rows); List palletRows = palletRows(planId); + Map palletManifests = palletManifests(palletRows); List standardLabels = palletLabelMapper.labels(planId); return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, java.util.TreeMap::new, java.util.stream.Collectors.toList())) .entrySet().stream().map(entry -> envelope(entry.getKey(), entry.getValue(), palletRows.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), - standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks)) + standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks, + palletManifests)) .toList(); } @@ -98,14 +100,16 @@ public class LoadingCompletedOutboxService { private EventEnvelope envelope(long accountId, List rows, List palletRows, List standardLabels, - Map> cartonMarks) { + Map> cartonMarks, + Map palletManifests) { Row first = rows.get(0); Map> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy( Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList())); List orders = byInbound.values().stream() .map(value -> order(value, palletRows.stream() .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), - standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList(); + standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(), + cartonMarks, palletManifests)).toList(); var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()), first.getPlanNo(), string(first.getContainerBookingId()), ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(), @@ -123,7 +127,8 @@ public class LoadingCompletedOutboxService { private FirstLegLoadingPlanCompletedV2.Order order(List values, List palletRows, List standardLabels, - Map> cartonMarks) { + Map> cartonMarks, + Map palletManifests) { List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); Row first = sorted.get(0); List items = sorted.stream().map(row -> @@ -138,7 +143,7 @@ public class LoadingCompletedOutboxService { String fulfillmentType = fulfillmentType(sorted); String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; List pallets = dropship(fulfillmentType) - ? palletSnapshots(sorted, palletRows) : standardLabels.stream() + ? palletSnapshots(sorted, palletRows, palletManifests) : standardLabels.stream() .map(label -> new FirstLegLoadingPlanCompletedV2.PalletSnapshot(label.getPalletCode(), List.of())).toList(); if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) { throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照"); @@ -257,10 +262,11 @@ public class LoadingCompletedOutboxService { private record LabelPoolKey(long accountId, long inboundId) { } private record InboundPalletKey(long accountId, long inboundId) { } - private List palletSnapshots(List values, List source) { + private List palletSnapshots(List values, List source, + Map manifests) { if (source.isEmpty()) return List.of(); if (source.stream().anyMatch(item -> Boolean.TRUE.equals(item.getActualBinding()))) { - return actualPalletSnapshots(values, source); + return actualPalletSnapshots(values, source, manifests); } Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(), @@ -314,7 +320,7 @@ public class LoadingCompletedOutboxService { /** 现场逐箱扫描已给出精确箱到托关系,不再使用旧的按托盘容量顺序推算。 */ private List actualPalletSnapshots( - List values, List source) { + List values, List source, Map manifests) { Map details = values.stream().collect(java.util.stream.Collectors.toMap( Row::getSourceInboundDetailId, value -> value, (left, right) -> left, LinkedHashMap::new)); Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( @@ -334,14 +340,17 @@ public class LoadingCompletedOutboxService { if (marks.size() != detailRows.getValue().size()) { throw new BizException(ResultCode.CONFLICT, "现场托盘包含空箱唛或重复箱唛"); } - int quantity = detail.getProductQuantity() == null ? marks.size() - : (int) Math.round((double) detail.getProductQuantity() * marks.size() - / Math.max(1, detail.getPackageCount())); + int quantity = detailRows.getValue().stream().map(PalletRow::getActualSkuQuantity) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + if (quantity <= 0) throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘缺少实点 SKU 数量"); skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSourceInboundDetailId().toString(), detail.getSkuCode(), detail.getProductNameZh(), marks.size(), quantity, marks)); actualPackages += marks.size(); } - result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus)); + PalletManifest manifest = manifests.get(pallet.getKey()); + if (manifest == null) throw new BizException(ResultCode.CONFLICT, "一件代发托盘缺少完整清单摘要"); + result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus, + manifest.version(), manifest.packageCount(), manifest.fragmentCount(), manifest.hash())); } if (actualPackages != sumInt(values, Row::getPackageCount)) { throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘箱唛数与实装箱数不一致"); @@ -349,12 +358,32 @@ public class LoadingCompletedOutboxService { return result; } + private Map palletManifests(List rows) { + Map> grouped = rows.stream().filter(row -> Boolean.TRUE.equals(row.getActualBinding())) + .collect(java.util.stream.Collectors.groupingBy(PalletRow::getPalletCode, LinkedHashMap::new, + java.util.stream.Collectors.toList())); + Map result = new LinkedHashMap<>(); + grouped.forEach((palletNo, values) -> { + List sorted = values.stream().sorted(Comparator + .comparing(PalletRow::getAccountId).thenComparing(PalletRow::getSourceInboundId) + .thenComparing(PalletRow::getSourceInboundDetailId).thenComparing(PalletRow::getBoxCode)).toList(); + String canonical = sorted.stream().map(value -> value.getAccountId() + "|" + value.getSourceInboundId() + + "|" + value.getSourceInboundDetailId() + "|" + value.getSkuCode() + "|" + value.getBoxCode() + + "|" + value.getActualSkuQuantity()).collect(java.util.stream.Collectors.joining("\n")); + int fragments = (int) sorted.stream().map(PalletRow::getAccountId).distinct().count(); + result.put(palletNo, new PalletManifest(1, sorted.size(), fragments, + EventPayloadHash.sha256(canonical.getBytes(StandardCharsets.UTF_8)))); + }); + return result; + } + private static boolean dropship(String value) { return "ONE_CLICK_DROPSHIP".equals(value); } private record DetailSnapshot(Long detailId, String skuCode, String productName, Integer packageCount, Integer productQuantity, List marks) {} + private record PalletManifest(int version, int packageCount, int fragmentCount, String hash) {} private void insert(EventEnvelope envelope) { InboundOutboxEntity value = new InboundOutboxEntity(); diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 8245dbde0..5d20f8dcf 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -150,11 +150,14 @@ BEGIN ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL COMMENT '已关联真实订舱客户名称快照' AFTER customer_id; END IF; - UPDATE fl_container_booking container_booking - JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id - SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id), - container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name) - WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL; + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() + AND table_name='fl_ocean_booking') THEN + UPDATE fl_container_booking container_booking + JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id + SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id), + container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name) + WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL; + END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 76adbe620..07ce90b3b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -290,8 +290,8 @@ class LoadingCompletedOutboxServiceTest { if (actualBinding) { when(snapshots.workPalletCount(90L)).thenReturn(1); when(snapshots.actualPalletRows(90L)).thenReturn(List.of( - actualPallet(11,100,"DP-260909-0001",102,"SAME-SKU","BOX-102"), - actualPallet(11,100,"DP-260909-0001",101,"SAME-SKU","BOX-101"))); + actualPallet(11,100,"DP-260909-0001",102,"SAME-SKU","BOX-102",20), + actualPallet(11,100,"DP-260909-0001",101,"SAME-SKU","BOX-101",10))); } else { when(snapshots.palletRows(90L)).thenReturn(List.of( pallet(11,100,1,"DP-260909-0001",2,102,"SAME-SKU",1,20,"BOX-102"), @@ -348,10 +348,10 @@ class LoadingCompletedOutboxServiceTest { when(snapshots.rows(90L)).thenReturn(List.of(first, second)); when(snapshots.workPalletCount(90L)).thenReturn(2); when(snapshots.actualPalletRows(90L)).thenReturn(List.of( - actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-A2"), - actualPallet(11,100,"PLT-01",102,"SKU-B","BOX-B1"), - actualPallet(11,100,"PLT-02",101,"SKU-A","BOX-A1"), - actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-B2"))); + actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-A2",10), + actualPallet(11,100,"PLT-01",102,"SKU-B","BOX-B1",5), + actualPallet(11,100,"PLT-02",101,"SKU-A","BOX-A1",10), + actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-B2",5))); service.publish(90L); @@ -422,9 +422,10 @@ class LoadingCompletedOutboxServiceTest { } private PalletRow actualPallet(long account,long inbound,String palletNo,long detail, - String sku,String boxCode) { + String sku,String boxCode,int actualSkuQuantity) { PalletRow row = pallet(account,inbound,0,palletNo,1,detail,sku,1,1,boxCode); row.setActualBinding(true); + row.setActualSkuQuantity(actualSkuQuantity); return row; } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 4a6119858..202920809 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -9,6 +9,13 @@ ## 快速索引 +退货预报、签收、库存、合箱与退货出库: + +- 客户预报 `returnreceipt/controller/MerchantReturnOrderController.java` → `returnreceipt/service/ReturnReceiptService.java`,路径 `/api/merchant/v1/last-leg/return-orders`;客户范围取认证身份,预报按 SKU 保存期望数量。状态 `PENDING_RECEIPT → PARTIALLY_RECEIVED → COMPLETED` 表示签收进度,不等于换标或出库完成。 +- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量,幂等重放不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。 +- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。 +- 退货出库继续进入 `delivery/service/DeliveryTransferService.java` 的 `RETURN_OUTBOUND`,按退货库存可用箱唛创建与拣货;不能与 `exceptionorder/` 的派送退回恢复库存混用。契约见 `src/main/resources/openapi/overseas-business-v1.yaml`,表入口 `src/main/resources/db/lastleg.sql` 的 `ll_return_*`。 + 人工计划自动生成托盘与资源重试:`inboundplan/controller/InboundPlanController.java` → `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;保存单票后重读全柜预览,全员为 `PLANNED/RESOURCE_PENDING`、预览就绪且存在待绑定资源时自动调用柜级 `confirm`。一票柜立即触发,多票柜在最后一票齐全后触发;资源异常沿 `resourcePendingPlanCount` 和原柜级预览/确认入口重试,不新增独立正常人工步骤。回归入口为 `src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java`。 头程来源托盘快照:`service/LoadingCompletedConsumer.java`、`mapper/LastlegSourcePalletMapper.java`;来源标签随装柜完成事件落入 `ll_order_source_pallet`,后续由 `inboundplan/service/InboundPlanService.java` 与 `ContainerInboundPlanConversionCoordinator.java` 传递给海外资源批量预留。排查标签跨区丢失时沿此链路核对,不能在尾程页面重新生成头程托盘码。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java index 1f16c8c93..98054de3f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java @@ -8,7 +8,11 @@ public final class DropshipRequests { private DropshipRequests() {} public record Receive(@NotBlank String customerId, @NotNull Long orderId, @NotNull Long warehouseId, @NotBlank String idempotencyKey, @NotEmpty List<@Valid Pallet> pallets) {} - public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages) {} + public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages, + @NotNull @Positive Integer manifestVersion, + @NotNull @Positive Integer manifestPackageCount, + @NotNull @Positive Integer manifestFragmentCount, + @NotBlank @Size(max = 64) String manifestHash) {} public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount, @Positive long productQuantity, @NotEmpty List<@NotBlank String> cartonMarks) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java index 15bc5046a..c91c0aff7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java @@ -13,6 +13,10 @@ public class DropshipOrderProfile { private String palletNo; /** Immutable pallet number snapshot supplied by the first-leg handover event. */ private String sourcePalletNo; + private Integer manifestVersion; + private Integer manifestPackageCount; + private Integer manifestFragmentCount; + private String manifestHash; private Long inventoryPositionId; private String status; private String idempotencyKey; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java index 383c34665..624db797d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java @@ -17,6 +17,14 @@ public interface DropshipCartonMapper extends BaseMapper { @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence") List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); + @Select(""" + SELECT DISTINCT p.source_pallet_no FROM ll_dropship_carton c + JOIN ll_dropship_order_profile p ON p.id=c.profile_id AND p.customer_id=c.customer_id + JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id + WHERE o.container_no=#{containerNo} AND c.carton_mark=#{cartonMark} + """) + List sourcePalletsInContainer(@Param("containerNo") String containerNo, + @Param("cartonMark") String cartonMark); @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND status='AVAILABLE' ORDER BY carton_sequence LIMIT #{packages} FOR UPDATE") List lockAvailable(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages); @Update("UPDATE ll_dropship_carton SET status='AVAILABLE', outbound_order_no=NULL, updated_at=NOW(3) WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} AND status='SHIPPED'") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java index 0d3bf6f05..d881788e1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java @@ -39,6 +39,14 @@ public interface DropshipOrderProfileMapper extends BaseMapper listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); @Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} LIMIT 1") DropshipOrderProfile findPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); + @Select(""" + SELECT p.* FROM ll_dropship_order_profile p + JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id + WHERE o.container_no=#{containerNo} AND p.source_pallet_no=#{sourcePalletNo} + ORDER BY p.customer_id,p.order_id,p.id + """) + List listContainerPallet(@Param("containerNo") String containerNo, + @Param("sourcePalletNo") String sourcePalletNo); @Select("