feat: complete finance and overseas receiving workflow
This commit is contained in:
+12
-2
@@ -141,8 +141,18 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
}
|
||||
}
|
||||
|
||||
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */
|
||||
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
|
||||
/** 普通 FBA 只复用 palletNo;一件代发还携带同一物理 DP 的完整清单摘要。 */
|
||||
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages,
|
||||
Integer manifestVersion, Integer manifestPackageCount,
|
||||
Integer manifestFragmentCount, String manifestHash) {
|
||||
public PalletSnapshot {
|
||||
skuPackages = skuPackages == null ? List.of() : List.copyOf(skuPackages);
|
||||
}
|
||||
|
||||
public PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {
|
||||
this(palletNo, skuPackages, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */
|
||||
public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
+220
-16
@@ -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='柜级入库计划批量托盘明细与资源锁关联';
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
|
||||
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
|
||||
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
|
||||
- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
|
||||
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
|
||||
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
|
||||
- 海外业务费用事件只接受非 `FIRSTLEG` 来源、`APPROVED` 审核快照和 `PUBLISHED` 活跃版本或明确 `VOIDED` 动作;按 `eventId + payloadHash` 防重放冲突,并以 `sourceSystem + sourceType + sourceFeeId + sourceVersion` 单调落库。事件携带完整客户、业务、方向、供应商、付款与来源哈希事实,Finance 不跨区同步回查 Lastleg。
|
||||
|
||||
@@ -33,6 +33,7 @@ public final class FinancePhase1Dtos {
|
||||
BigDecimal actualBalance, BigDecimal frozenBalance,
|
||||
BigDecimal availableBalance, BigDecimal creditLimit,
|
||||
BigDecimal creditUsed, BigDecimal availableCredit,
|
||||
LocalDate creditExpiresOn, boolean creditActive,
|
||||
int accountVersion,
|
||||
long pendingRechargeCount,
|
||||
String status, LocalDateTime lastChangedAt) {}
|
||||
|
||||
@@ -12,6 +12,7 @@ public final class FinanceWalletDtos {
|
||||
BigDecimal frozenBalance, BigDecimal availableBalance,
|
||||
BigDecimal creditLimit, BigDecimal creditUsed,
|
||||
BigDecimal creditFrozen, BigDecimal availableCredit,
|
||||
LocalDate creditExpiresOn, boolean creditActive,
|
||||
int version, LocalDateTime updatedAt) { }
|
||||
|
||||
public record WalletPage<T>(List<T> records, long total, int page, int size, int pages) {
|
||||
@@ -26,6 +27,7 @@ public final class FinanceWalletDtos {
|
||||
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
|
||||
BigDecimal creditLimitAfter, BigDecimal creditUsedAfter,
|
||||
BigDecimal availableCreditAfter,
|
||||
LocalDate creditExpiresOnAfter,
|
||||
String sourceType, String sourceNo, LocalDate occurredDate,
|
||||
String paymentMethod, String referenceNo, String operatorName,
|
||||
String remark, LocalDateTime createdAt) { }
|
||||
@@ -45,7 +47,7 @@ public final class FinanceWalletDtos {
|
||||
String paymentMethod, String referenceNo, String remark,
|
||||
int expectedAccountVersion) { }
|
||||
|
||||
public record CreditLimitRequest(BigDecimal creditLimit, String remark,
|
||||
public record CreditLimitRequest(BigDecimal creditLimit, LocalDate creditExpiresOn, String remark,
|
||||
int expectedAccountVersion) { }
|
||||
|
||||
public record WalletFreezeRequest(BigDecimal amount, String businessType,
|
||||
|
||||
+28
-16
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -21,18 +22,12 @@ public class FinanceCustomerAccountService {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final IamCustomerClient iamCustomerClient;
|
||||
private final String internalToken;
|
||||
private final BigDecimal defaultCreditLimit;
|
||||
|
||||
public FinanceCustomerAccountService(JdbcTemplate jdbc, IamCustomerClient iamCustomerClient,
|
||||
@Value("${baiyue.security.internal-service-token:}") String internalToken,
|
||||
@Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit) {
|
||||
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
|
||||
this.jdbc = jdbc;
|
||||
this.iamCustomerClient = iamCustomerClient;
|
||||
this.internalToken = internalToken;
|
||||
if (defaultCreditLimit == null || defaultCreditLimit.signum() < 0 || defaultCreditLimit.scale() > 2) {
|
||||
throw new IllegalArgumentException("默认授信额度不能为负数且最多保留两位小数");
|
||||
}
|
||||
this.defaultCreditLimit = defaultCreditLimit.setScale(2);
|
||||
}
|
||||
|
||||
public PageEnvelope<AccountSummary> list(int page, int pageSize, String keyword, String status, Long customerId) {
|
||||
@@ -60,6 +55,7 @@ public class FinanceCustomerAccountService {
|
||||
value.outstandingPayable(),
|
||||
value.actualBalance(), value.frozenBalance(), value.availableBalance(),
|
||||
value.creditLimit(), value.creditUsed(), value.availableCredit(),
|
||||
value.creditExpiresOn(), value.creditActive(),
|
||||
value.accountVersion(), value.pendingRechargeCount(),
|
||||
customer.status(), value.lastChangedAt());
|
||||
}).toList();
|
||||
@@ -70,7 +66,7 @@ public class FinanceCustomerAccountService {
|
||||
if (customerIds.isEmpty()) return Map.of();
|
||||
String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList());
|
||||
Map<Long, MutableMetrics> result = new HashMap<>();
|
||||
customerIds.forEach(id -> result.put(id, new MutableMetrics(defaultCreditLimit)));
|
||||
customerIds.forEach(id -> result.put(id, new MutableMetrics()));
|
||||
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
|
||||
.forEach(row -> metric(result, row, "outstandingReceivable", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') GROUP BY customer_id", customerIds.toArray())
|
||||
@@ -83,7 +79,7 @@ public class FinanceCustomerAccountService {
|
||||
.forEach(row -> metric(result, row, "paidCost", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED')) payable_obligations GROUP BY customer_id", concat(customerIds, 2))
|
||||
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
|
||||
jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
|
||||
.forEach(row -> walletMetric(result, row));
|
||||
jdbc.queryForList("SELECT customer_id,COUNT(*) pending_count FROM fin_recharge_request WHERE customer_id IN (" + placeholders + ") AND status='PENDING' GROUP BY customer_id", customerIds.toArray())
|
||||
.forEach(row -> pendingMetric(result, row));
|
||||
@@ -100,10 +96,22 @@ public class FinanceCustomerAccountService {
|
||||
MutableMetrics target = result.get(number.longValue());
|
||||
target.actualBalance = decimal(row.get("actual_balance"));
|
||||
target.frozenBalance = decimal(row.get("frozen_balance"));
|
||||
target.availableBalance = decimal(row.get("available_balance"));
|
||||
target.creditLimit = decimal(row.get("credit_limit"));
|
||||
BigDecimal creditFrozen = decimal(row.get("credit_frozen"));
|
||||
target.creditExpiresOn = row.get("credit_expires_on") instanceof java.sql.Date value ? value.toLocalDate()
|
||||
: row.get("credit_expires_on") instanceof LocalDate value ? value : null;
|
||||
target.creditActive = target.creditLimit.signum() > 0 && target.creditExpiresOn != null
|
||||
&& !target.creditExpiresOn.isBefore(LocalDate.now());
|
||||
target.creditUsed = decimal(row.get("credit_used"));
|
||||
target.availableCredit = target.creditLimit.subtract(target.creditUsed).subtract(decimal(row.get("credit_frozen")));
|
||||
if (!target.creditActive && target.creditUsed.signum() > 0) {
|
||||
target.actualBalance = target.actualBalance.subtract(target.creditUsed);
|
||||
target.creditUsed = BigDecimal.ZERO;
|
||||
}
|
||||
target.availableCredit = target.creditActive
|
||||
? target.creditLimit.subtract(target.creditUsed).subtract(creditFrozen) : BigDecimal.ZERO;
|
||||
target.availableBalance = target.creditActive
|
||||
? target.actualBalance.subtract(target.frozenBalance.subtract(creditFrozen)).add(target.availableCredit)
|
||||
: target.actualBalance.subtract(target.frozenBalance);
|
||||
target.accountVersion = row.get("version") instanceof Number value ? value.intValue() : 0;
|
||||
}
|
||||
|
||||
@@ -158,13 +166,15 @@ public class FinanceCustomerAccountService {
|
||||
private BigDecimal creditLimit;
|
||||
private BigDecimal creditUsed = BigDecimal.ZERO;
|
||||
private BigDecimal availableCredit;
|
||||
private LocalDate creditExpiresOn;
|
||||
private boolean creditActive;
|
||||
private int accountVersion;
|
||||
private long pendingRechargeCount;
|
||||
private LocalDateTime lastChangedAt;
|
||||
private MutableMetrics(BigDecimal defaultCreditLimit) {
|
||||
this.creditLimit = defaultCreditLimit;
|
||||
this.availableCredit = defaultCreditLimit;
|
||||
this.availableBalance = defaultCreditLimit;
|
||||
private MutableMetrics() {
|
||||
this.creditLimit = BigDecimal.ZERO.setScale(2);
|
||||
this.availableCredit = BigDecimal.ZERO.setScale(2);
|
||||
this.availableBalance = BigDecimal.ZERO.setScale(2);
|
||||
}
|
||||
void set(String field, BigDecimal value) {
|
||||
switch (field) {
|
||||
@@ -179,6 +189,7 @@ public class FinanceCustomerAccountService {
|
||||
}
|
||||
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable,
|
||||
actualBalance, frozenBalance, availableBalance, creditLimit, creditUsed, availableCredit,
|
||||
creditExpiresOn, creditActive,
|
||||
accountVersion, pendingRechargeCount, lastChangedAt); }
|
||||
}
|
||||
|
||||
@@ -186,7 +197,8 @@ public class FinanceCustomerAccountService {
|
||||
BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost,
|
||||
BigDecimal outstandingPayable, BigDecimal actualBalance, BigDecimal frozenBalance,
|
||||
BigDecimal availableBalance, BigDecimal creditLimit, BigDecimal creditUsed,
|
||||
BigDecimal availableCredit, int accountVersion, long pendingRechargeCount,
|
||||
BigDecimal availableCredit, LocalDate creditExpiresOn, boolean creditActive,
|
||||
int accountVersion, long pendingRechargeCount,
|
||||
LocalDateTime lastChangedAt) {
|
||||
}
|
||||
}
|
||||
|
||||
+31
-7
@@ -78,22 +78,46 @@ public class FinancePhase1OperationService {
|
||||
"COALESCE((SELECT available_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_balance," +
|
||||
"COALESCE((SELECT credit_limit FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_limit," +
|
||||
"COALESCE((SELECT credit_used FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_used," +
|
||||
"COALESCE((SELECT credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_frozen," +
|
||||
"COALESCE((SELECT credit_limit-credit_used-credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_credit," +
|
||||
"(SELECT credit_expires_on FROM fin_wallet_account w WHERE w.customer_id=c.customer_id) credit_expires_on," +
|
||||
"COALESCE((SELECT version FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) account_version," +
|
||||
"(SELECT COUNT(*) FROM fin_recharge_request rr WHERE rr.customer_id=c.customer_id AND rr.status='PENDING') pending_recharge_count," +
|
||||
"GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
|
||||
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
|
||||
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
|
||||
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), rs.getString("customer_name"), null, null,
|
||||
money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"),
|
||||
money(rs, "total_cost"), money(rs, "paid_cost"), money(rs, "outstanding_payable"),
|
||||
money(rs, "actual_balance"), money(rs, "frozen_balance"), money(rs, "available_balance"),
|
||||
money(rs, "credit_limit"), money(rs, "credit_used"), money(rs, "available_credit"),
|
||||
rs.getInt("account_version"), rs.getLong("pending_recharge_count"),
|
||||
rs.getString("account_status"), time(rs, "last_changed_at")), args);
|
||||
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> accountSummaryRow(rs), args);
|
||||
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
|
||||
}
|
||||
|
||||
private static AccountSummary accountSummaryRow(ResultSet rs) throws SQLException {
|
||||
BigDecimal actualBalance = money(rs, "actual_balance");
|
||||
BigDecimal frozenBalance = money(rs, "frozen_balance");
|
||||
BigDecimal creditLimit = money(rs, "credit_limit");
|
||||
BigDecimal creditFrozen = money(rs, "credit_frozen");
|
||||
LocalDate creditExpiresOn = rs.getDate("credit_expires_on") == null
|
||||
? null : rs.getDate("credit_expires_on").toLocalDate();
|
||||
boolean creditActive = creditLimit.signum() > 0 && creditExpiresOn != null
|
||||
&& !creditExpiresOn.isBefore(LocalDate.now());
|
||||
BigDecimal creditUsed = money(rs, "credit_used");
|
||||
if (!creditActive && creditUsed.signum() > 0) {
|
||||
actualBalance = actualBalance.subtract(creditUsed);
|
||||
creditUsed = BigDecimal.ZERO;
|
||||
}
|
||||
BigDecimal availableCredit = creditActive
|
||||
? creditLimit.subtract(creditUsed).subtract(creditFrozen) : BigDecimal.ZERO;
|
||||
BigDecimal availableBalance = creditActive
|
||||
? actualBalance.subtract(frozenBalance.subtract(creditFrozen)).add(availableCredit)
|
||||
: actualBalance.subtract(frozenBalance);
|
||||
return new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")),
|
||||
rs.getString("customer_name"), null, null, money(rs, "outstanding_receivable"),
|
||||
money(rs, "pending_billing"), money(rs, "received_period"), money(rs, "total_cost"),
|
||||
money(rs, "paid_cost"), money(rs, "outstanding_payable"), actualBalance, frozenBalance,
|
||||
availableBalance, creditLimit, creditUsed, availableCredit, creditExpiresOn, creditActive,
|
||||
rs.getInt("account_version"), rs.getLong("pending_recharge_count"), rs.getString("account_status"),
|
||||
time(rs, "last_changed_at"));
|
||||
}
|
||||
|
||||
public PageEnvelope<LedgerEntry> listDerivedCustomerAccountLedger(long customerId, int page, int pageSize,
|
||||
String businessType, LocalDate startDate, LocalDate endDate) {
|
||||
customer(customerId); page = page(page); pageSize = pageSize(pageSize);
|
||||
|
||||
@@ -53,7 +53,6 @@ public class FinanceWalletService {
|
||||
private final String internalToken;
|
||||
private final String wechatQrCodeUrl;
|
||||
private final String alipayQrCodeUrl;
|
||||
private final BigDecimal defaultCreditLimit;
|
||||
|
||||
public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
FinanceFileClient fileClient,
|
||||
@@ -61,7 +60,6 @@ public class FinanceWalletService {
|
||||
@Value("${baiyue.security.internal-service-token:}") String internalToken,
|
||||
@Value("${baiyue.finance.recharge.wechat-qr-code-url:}") String wechatQrCodeUrl,
|
||||
@Value("${baiyue.finance.recharge.alipay-qr-code-url:}") String alipayQrCodeUrl,
|
||||
@Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit,
|
||||
@Value("${spring.profiles.active:}") String activeProfiles,
|
||||
@Value("${DEPLOY_ENV:}") String deployEnvironment) {
|
||||
this.jdbc = jdbc;
|
||||
@@ -69,7 +67,6 @@ public class FinanceWalletService {
|
||||
this.fileClient = fileClient;
|
||||
this.numberGenerator = numberGenerator;
|
||||
this.internalToken = internalToken;
|
||||
this.defaultCreditLimit = nonNegativeMoney(defaultCreditLimit, "默认授信额度");
|
||||
boolean development = "development".equalsIgnoreCase(deployEnvironment.trim())
|
||||
|| List.of(activeProfiles.split(",")).stream().map(String::trim).anyMatch("dev"::equalsIgnoreCase);
|
||||
this.wechatQrCodeUrl = rechargeQrCode(wechatQrCodeUrl, development);
|
||||
@@ -84,7 +81,7 @@ public class FinanceWalletService {
|
||||
public WalletAccount account(long customerId) {
|
||||
requirePositive(customerId, "客户");
|
||||
List<WalletAccount> rows = jdbc.query(
|
||||
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
|
||||
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
|
||||
(rs, n) -> accountRow(rs), customerId);
|
||||
return rows.isEmpty() ? zeroAccount() : rows.get(0);
|
||||
}
|
||||
@@ -202,7 +199,7 @@ public class FinanceWalletService {
|
||||
if (replay != null) return requiredLedger(replay, customerId);
|
||||
WalletAccount before = accountForUpdate(customerId);
|
||||
if (before.version() != request.expectedAccountVersion()) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
|
||||
if ("DEDUCTION".equals(type) && before.availableBalance().compareTo(amount) < 0) {
|
||||
if ("DEDUCTION".equals(type) && before.creditActive() && before.availableBalance().compareTo(amount) < 0) {
|
||||
throw error("FIN-WALLET-INSUFFICIENT", "扣款金额不能超过当前可用余额");
|
||||
}
|
||||
BigDecimal signed = "RECHARGE".equals(type) ? amount : amount.negate();
|
||||
@@ -225,19 +222,26 @@ public class FinanceWalletService {
|
||||
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
|
||||
if (request == null || request.expectedAccountVersion() < 0) throw bad("授信额度请求无效");
|
||||
BigDecimal limit = nonNegativeMoney(request.creditLimit(), "授信额度");
|
||||
LocalDate expiresOn = request.creditExpiresOn();
|
||||
if (expiresOn == null || !expiresOn.isAfter(LocalDate.now())) {
|
||||
throw bad("授信到期日必须晚于今天");
|
||||
}
|
||||
Long replay = reserve(customerId, operatorId, "WALLET_CREDIT_LIMIT", idempotencyKey, hash(request));
|
||||
if (replay != null) return requiredLedger(replay, customerId);
|
||||
WalletAccount before = accountForUpdate(customerId);
|
||||
if (before.version() != request.expectedAccountVersion()) {
|
||||
throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
|
||||
}
|
||||
if (limit.compareTo(before.creditUsed().add(before.creditFrozen())) < 0) {
|
||||
BigDecimal existingDebt = before.actualBalance().signum() < 0
|
||||
? before.actualBalance().negate() : before.creditUsed();
|
||||
if (limit.compareTo(existingDebt.add(before.creditFrozen())) < 0) {
|
||||
throw error("FIN-WALLET-CREDIT-IN-USE", "授信额度不能低于已用与已冻结授信合计");
|
||||
}
|
||||
BigDecimal delta = limit.subtract(before.creditLimit());
|
||||
if (delta.signum() == 0) throw bad("授信额度未发生变化");
|
||||
WalletAccount after = accountState(before.actualBalance(), before.frozenBalance(), limit,
|
||||
before.creditUsed(), before.creditFrozen(), before.version());
|
||||
if (delta.signum() == 0 && expiresOn.equals(before.creditExpiresOn())) throw bad("授信额度和有效期均未发生变化");
|
||||
BigDecimal carriedDebt = existingDebt;
|
||||
WalletAccount after = accountState(before.actualBalance().max(BigDecimal.ZERO), before.frozenBalance(), limit,
|
||||
carriedDebt, before.creditFrozen(), expiresOn, before.version());
|
||||
updateAccount(customerId, after, before.version(), operatorId);
|
||||
WalletLedgerEntry entry = insertLedger(customerId, "CREDIT_LIMIT", delta, after,
|
||||
"CREDIT_LIMIT_ADJUSTMENT", null, LocalDate.now(), null, null,
|
||||
@@ -273,11 +277,12 @@ public class FinanceWalletService {
|
||||
throw error("FIN-WALLET-INSUFFICIENT", "可用余额和授信额度不足");
|
||||
}
|
||||
BigDecimal actualAvailable = before.actualBalance()
|
||||
.subtract(before.frozenBalance().subtract(before.creditFrozen()));
|
||||
.subtract(before.frozenBalance().subtract(before.creditFrozen())).max(BigDecimal.ZERO);
|
||||
BigDecimal actualFrozen = amount.min(actualAvailable);
|
||||
BigDecimal creditFrozen = amount.subtract(actualFrozen);
|
||||
WalletAccount after = accountState(before.actualBalance(), before.frozenBalance().add(amount),
|
||||
before.creditLimit(), before.creditUsed(), before.creditFrozen().add(creditFrozen), before.version());
|
||||
before.creditLimit(), before.creditUsed(), before.creditFrozen().add(creditFrozen),
|
||||
before.creditExpiresOn(), before.version());
|
||||
updateAccount(customerId, after, before.version(), 0);
|
||||
long holdId = insertHold(customerId, businessType, businessNo, amount, actualFrozen, creditFrozen,
|
||||
clean(request.remark(), 500));
|
||||
@@ -315,12 +320,18 @@ public class FinanceWalletService {
|
||||
WalletHold hold = findHold(customerId, businessType, businessNo, true);
|
||||
if (hold == null) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在");
|
||||
if (!"FROZEN".equals(hold.status())) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理");
|
||||
BigDecimal actual = settle ? before.actualBalance().subtract(hold.actualFrozenAmount()) : before.actualBalance();
|
||||
BigDecimal creditUsed = settle ? before.creditUsed().add(hold.creditFrozenAmount()) : before.creditUsed();
|
||||
BigDecimal actual = before.actualBalance();
|
||||
BigDecimal creditUsed = before.creditUsed();
|
||||
if (settle && before.creditActive()) {
|
||||
actual = actual.subtract(hold.actualFrozenAmount());
|
||||
creditUsed = creditUsed.add(hold.creditFrozenAmount());
|
||||
} else if (settle) {
|
||||
actual = actual.subtract(hold.amount());
|
||||
}
|
||||
BigDecimal frozen = before.frozenBalance().subtract(hold.amount());
|
||||
BigDecimal creditFrozen = before.creditFrozen().subtract(hold.creditFrozenAmount());
|
||||
WalletAccount after = accountState(actual, frozen, before.creditLimit(), creditUsed,
|
||||
creditFrozen, before.version());
|
||||
creditFrozen, before.creditExpiresOn(), before.version());
|
||||
updateAccount(customerId, after, before.version(), 0);
|
||||
String action = settle ? "SETTLED" : "RELEASED";
|
||||
WalletLedgerEntry ledger = insertLedger(customerId, settle ? "CONSUMPTION" : "UNFREEZE",
|
||||
@@ -346,17 +357,17 @@ public class FinanceWalletService {
|
||||
}
|
||||
|
||||
private WalletAccount accountForUpdate(long customerId) {
|
||||
jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,created_by,updated_by) VALUES(?,'CNY',0,0,?,?,0,0,0,0,0)",
|
||||
customerId, defaultCreditLimit, defaultCreditLimit);
|
||||
jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,created_by,updated_by) VALUES(?,'CNY',0,0,0,0,0,0,NULL,0,0,0)",
|
||||
customerId);
|
||||
return jdbc.queryForObject(
|
||||
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
|
||||
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
|
||||
(rs, n) -> accountRow(rs), customerId);
|
||||
}
|
||||
|
||||
private void updateAccount(long customerId, WalletAccount account, int expectedVersion, long operatorId) {
|
||||
int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,credit_limit=?,credit_used=?,credit_frozen=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
|
||||
int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,credit_limit=?,credit_used=?,credit_frozen=?,credit_expires_on=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
|
||||
account.actualBalance(), account.frozenBalance(), account.availableBalance(), account.creditLimit(),
|
||||
account.creditUsed(), account.creditFrozen(), operatorId, customerId, expectedVersion);
|
||||
account.creditUsed(), account.creditFrozen(), account.creditExpiresOn(), operatorId, customerId, expectedVersion);
|
||||
if (updated != 1) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
|
||||
}
|
||||
|
||||
@@ -369,17 +380,17 @@ public class FinanceWalletService {
|
||||
KeyHolder holder = new GeneratedKeyHolder();
|
||||
jdbc.update(connection -> {
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,credit_limit_after,credit_used_after,available_credit_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
"INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,credit_limit_after,credit_used_after,available_credit_after,credit_expires_on_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
Statement.RETURN_GENERATED_KEYS);
|
||||
statement.setLong(1, customerId); statement.setString(2, businessNo); statement.setString(3, changeType);
|
||||
statement.setBigDecimal(4, amount); statement.setBigDecimal(5, account.actualBalance());
|
||||
statement.setBigDecimal(6, account.frozenBalance()); statement.setBigDecimal(7, account.availableBalance());
|
||||
statement.setBigDecimal(8, account.creditLimit()); statement.setBigDecimal(9, account.creditUsed());
|
||||
statement.setBigDecimal(10, account.availableCredit()); statement.setString(11, sourceType);
|
||||
statement.setString(12, sourceNo); statement.setObject(13, occurredDate);
|
||||
statement.setString(14, paymentMethod); statement.setString(15, referenceNo);
|
||||
statement.setLong(16, operatorId); statement.setString(17, clean(operatorName, 128));
|
||||
statement.setString(18, clean(remark, 500)); return statement;
|
||||
statement.setBigDecimal(10, account.availableCredit()); statement.setObject(11, account.creditExpiresOn());
|
||||
statement.setString(12, sourceType); statement.setString(13, sourceNo); statement.setObject(14, occurredDate);
|
||||
statement.setString(15, paymentMethod); statement.setString(16, referenceNo);
|
||||
statement.setLong(17, operatorId); statement.setString(18, clean(operatorName, 128));
|
||||
statement.setString(19, clean(remark, 500)); return statement;
|
||||
}, holder);
|
||||
return requiredLedger(generatedId(holder), customerId);
|
||||
}
|
||||
@@ -392,48 +403,70 @@ public class FinanceWalletService {
|
||||
}
|
||||
|
||||
WalletAccount rechargeAccount(WalletAccount before, BigDecimal amount) {
|
||||
BigDecimal creditRepayment = amount.min(before.creditUsed());
|
||||
return accountState(before.actualBalance().add(amount.subtract(creditRepayment)), before.frozenBalance(),
|
||||
before.creditLimit(), before.creditUsed().subtract(creditRepayment), before.creditFrozen(), before.version());
|
||||
BigDecimal repaidCredit = before.creditActive() ? amount.min(before.creditUsed()) : BigDecimal.ZERO;
|
||||
return accountState(before.actualBalance().add(amount.subtract(repaidCredit)), before.frozenBalance(),
|
||||
before.creditLimit(), before.creditUsed().subtract(repaidCredit), before.creditFrozen(),
|
||||
before.creditExpiresOn(), before.version());
|
||||
}
|
||||
|
||||
WalletAccount deductAccount(WalletAccount before, BigDecimal amount) {
|
||||
BigDecimal actualAvailable = before.actualBalance()
|
||||
.subtract(before.frozenBalance().subtract(before.creditFrozen()));
|
||||
if (!before.creditActive()) {
|
||||
return accountState(before.actualBalance().subtract(amount), before.frozenBalance(),
|
||||
before.creditLimit(), BigDecimal.ZERO, before.creditFrozen(), before.creditExpiresOn(), before.version());
|
||||
}
|
||||
BigDecimal actualFrozen = before.frozenBalance().subtract(before.creditFrozen());
|
||||
BigDecimal actualAvailable = before.actualBalance().subtract(actualFrozen).max(BigDecimal.ZERO);
|
||||
BigDecimal actualDeduction = amount.min(actualAvailable);
|
||||
BigDecimal creditDeduction = amount.subtract(actualDeduction);
|
||||
return accountState(before.actualBalance().subtract(actualDeduction), before.frozenBalance(),
|
||||
before.creditLimit(), before.creditUsed().add(creditDeduction), before.creditFrozen(), before.version());
|
||||
before.creditLimit(), before.creditUsed().add(amount.subtract(actualDeduction)),
|
||||
before.creditFrozen(), before.creditExpiresOn(), before.version());
|
||||
}
|
||||
|
||||
WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
|
||||
BigDecimal creditUsed, BigDecimal creditFrozen, int version) {
|
||||
BigDecimal availableCredit = creditLimit.subtract(creditUsed).subtract(creditFrozen);
|
||||
BigDecimal creditFrozen, LocalDate creditExpiresOn, int version) {
|
||||
return accountState(actual, frozen, creditLimit, BigDecimal.ZERO, creditFrozen, creditExpiresOn, version);
|
||||
}
|
||||
|
||||
WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
|
||||
BigDecimal creditUsed, BigDecimal creditFrozen,
|
||||
LocalDate creditExpiresOn, int version) {
|
||||
boolean creditActive = creditLimit.signum() > 0 && creditExpiresOn != null
|
||||
&& !creditExpiresOn.isBefore(LocalDate.now());
|
||||
if (!creditActive && creditUsed.signum() > 0) {
|
||||
actual = actual.subtract(creditUsed);
|
||||
creditUsed = BigDecimal.ZERO.setScale(2);
|
||||
}
|
||||
BigDecimal availableCredit = creditActive
|
||||
? creditLimit.subtract(creditUsed).subtract(creditFrozen) : BigDecimal.ZERO.setScale(2);
|
||||
BigDecimal actualFrozen = frozen.subtract(creditFrozen);
|
||||
BigDecimal available = actual.subtract(actualFrozen).add(availableCredit);
|
||||
if (actual.signum() < 0 || frozen.signum() < 0 || creditLimit.signum() < 0 || creditUsed.signum() < 0
|
||||
|| creditFrozen.signum() < 0 || actualFrozen.signum() < 0 || actual.compareTo(actualFrozen) < 0
|
||||
|| availableCredit.signum() < 0 || available.signum() < 0) {
|
||||
BigDecimal available = creditActive
|
||||
? actual.subtract(actualFrozen).add(availableCredit)
|
||||
: actual.subtract(frozen);
|
||||
if (frozen.signum() < 0 || creditLimit.signum() < 0 || creditFrozen.signum() < 0
|
||||
|| creditUsed.signum() < 0 || actualFrozen.signum() < 0
|
||||
|| (creditActive && (actual.signum() < 0 || availableCredit.signum() < 0))) {
|
||||
throw error("FIN-WALLET-409", "账户余额状态无效");
|
||||
}
|
||||
return new WalletAccount("CNY", actual, frozen, available, creditLimit, creditUsed,
|
||||
creditFrozen, availableCredit, version, null);
|
||||
creditFrozen, availableCredit, creditExpiresOn, creditActive, version, null);
|
||||
}
|
||||
|
||||
private WalletAccount accountRow(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||
BigDecimal creditLimit = rs.getBigDecimal("credit_limit");
|
||||
BigDecimal creditUsed = rs.getBigDecimal("credit_used");
|
||||
BigDecimal creditFrozen = rs.getBigDecimal("credit_frozen");
|
||||
return new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
|
||||
rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), creditLimit, creditUsed,
|
||||
creditFrozen, creditLimit.subtract(creditUsed).subtract(creditFrozen), rs.getInt("version"),
|
||||
LocalDate expiresOn = rs.getDate("credit_expires_on") == null ? null : rs.getDate("credit_expires_on").toLocalDate();
|
||||
WalletAccount calculated = accountState(rs.getBigDecimal("actual_balance"), rs.getBigDecimal("frozen_balance"),
|
||||
creditLimit, rs.getBigDecimal("credit_used"), creditFrozen, expiresOn, rs.getInt("version"));
|
||||
return new WalletAccount(calculated.currencyCode(), calculated.actualBalance(), calculated.frozenBalance(),
|
||||
calculated.availableBalance(), calculated.creditLimit(), calculated.creditUsed(), calculated.creditFrozen(),
|
||||
calculated.availableCredit(), calculated.creditExpiresOn(), calculated.creditActive(), calculated.version(),
|
||||
timestamp(rs.getTimestamp("updated_at")));
|
||||
}
|
||||
|
||||
private WalletAccount zeroAccount() {
|
||||
BigDecimal zero = BigDecimal.ZERO.setScale(2);
|
||||
return new WalletAccount("CNY", zero, zero, defaultCreditLimit, defaultCreditLimit, zero,
|
||||
zero, defaultCreditLimit, 0, null);
|
||||
return new WalletAccount("CNY", zero, zero, zero, zero, zero,
|
||||
zero, zero, null, false, 0, null);
|
||||
}
|
||||
|
||||
private long insertHold(long customerId, String businessType, String businessNo, BigDecimal amount,
|
||||
@@ -546,6 +579,7 @@ public class FinanceWalletService {
|
||||
rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"), rs.getBigDecimal("frozen_balance_after"),
|
||||
rs.getBigDecimal("available_balance_after"), rs.getBigDecimal("credit_limit_after"),
|
||||
rs.getBigDecimal("credit_used_after"), rs.getBigDecimal("available_credit_after"),
|
||||
rs.getObject("credit_expires_on_after", LocalDate.class),
|
||||
rs.getString("source_type"), rs.getString("source_no"),
|
||||
rs.getObject("occurred_date", LocalDate.class), rs.getString("payment_method"), rs.getString("reference_no"),
|
||||
rs.getString("operator_name"), rs.getString("remark"), timestamp(rs.getTimestamp("created_at")));
|
||||
|
||||
@@ -443,12 +443,13 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY',
|
||||
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,包含冻结资金',
|
||||
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,授信消费可形成负余额',
|
||||
frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额,包含实际资金与授信冻结',
|
||||
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '总可用余额=实际余额+授信额度-已用授信-冻结总额',
|
||||
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '当前总可用余额;授信失效后不再计入',
|
||||
credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度',
|
||||
credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信',
|
||||
credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信有效期内累计已使用额度;到期后并入实际余额',
|
||||
credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信',
|
||||
credit_expires_on DATE NULL COMMENT '授信到期日,当天仍有效;次日失效',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本',
|
||||
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID',
|
||||
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后操作人ID',
|
||||
@@ -458,7 +459,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
|
||||
UNIQUE KEY uk_fin_wallet_account_customer (customer_id, currency_code),
|
||||
CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0),
|
||||
CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance)
|
||||
CONSTRAINT chk_fin_wallet_account_balance CHECK (frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen)
|
||||
) ENGINE=InnoDB COMMENT='客户CNY资金账户主账';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
||||
@@ -500,6 +501,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
||||
credit_limit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后授信额度',
|
||||
credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信',
|
||||
available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信',
|
||||
credit_expires_on_after DATE NULL COMMENT '变动后授信到期日',
|
||||
source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等',
|
||||
source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号',
|
||||
occurred_date DATE NOT NULL COMMENT '业务发生日期',
|
||||
@@ -514,8 +516,8 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
||||
KEY idx_fin_wallet_ledger_customer_time (customer_id, created_at, id),
|
||||
KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no),
|
||||
CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0),
|
||||
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0),
|
||||
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after)
|
||||
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0 OR change_type='CREDIT_LIMIT'),
|
||||
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (frozen_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0)
|
||||
) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_wallet_hold (
|
||||
|
||||
@@ -23,7 +23,7 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
class FinanceCustomerAccountServiceTest {
|
||||
@Test
|
||||
void listsMasterCustomersWithConfiguredInitialCreditAndForwardsSearchFields() {
|
||||
void listsMasterCustomersWithoutImplicitPermanentCreditAndForwardsSearchFields() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
IamCustomerClient iam = mock(IamCustomerClient.class);
|
||||
when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
|
||||
@@ -32,7 +32,7 @@ class FinanceCustomerAccountServiceTest {
|
||||
new IamCustomerClient.Customer(900000000000000001L, "TEN-001", "Alice Trading", "ACTIVE", "Alice", "13800138000")),
|
||||
1, 20, 1, 1)));
|
||||
|
||||
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token", new BigDecimal("8000.00"))
|
||||
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token")
|
||||
.list(1, 20, " Alice ", "active", 900000000000000001L);
|
||||
|
||||
AccountSummary row = result.data().get(0);
|
||||
@@ -41,9 +41,11 @@ class FinanceCustomerAccountServiceTest {
|
||||
assertThat(row.contactPhone()).isEqualTo("13800138000");
|
||||
assertThat(row.outstandingReceivable()).isZero();
|
||||
assertThat(row.pendingBilling()).isZero();
|
||||
assertThat(row.creditLimit()).isEqualByComparingTo("8000.00");
|
||||
assertThat(row.availableCredit()).isEqualByComparingTo("8000.00");
|
||||
assertThat(row.availableBalance()).isEqualByComparingTo("8000.00");
|
||||
assertThat(row.creditLimit()).isZero();
|
||||
assertThat(row.availableCredit()).isZero();
|
||||
assertThat(row.availableBalance()).isZero();
|
||||
assertThat(row.creditExpiresOn()).isNull();
|
||||
assertThat(row.creditActive()).isFalse();
|
||||
verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L);
|
||||
}
|
||||
|
||||
@@ -56,7 +58,7 @@ class FinanceCustomerAccountServiceTest {
|
||||
new PageResult<>(List.of(new IamCustomerClient.Customer(9L, "C-9", "客户9", "ACTIVE", null, null)),
|
||||
1, 20, 1, 1)));
|
||||
|
||||
new FinanceCustomerAccountService(jdbc, iam, "internal-token", BigDecimal.ZERO).list(1, 20, null, null, 9L);
|
||||
new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L);
|
||||
|
||||
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class));
|
||||
@@ -72,7 +74,7 @@ class FinanceCustomerAccountServiceTest {
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedFinanceAccountStatus() {
|
||||
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token", BigDecimal.ZERO);
|
||||
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token");
|
||||
assertThatThrownBy(() -> service.list(1, 20, null, "PENDING", null))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code())
|
||||
|
||||
@@ -375,7 +375,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, 0, 0,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null, false, 0, 0,
|
||||
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
|
||||
}
|
||||
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
|
||||
|
||||
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
|
||||
}
|
||||
}
|
||||
assertThat(tableCount).isEqualTo(23);
|
||||
assertThat(columnCount).isEqualTo(351);
|
||||
assertThat(columnCount).isEqualTo(353);
|
||||
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+44
-64
@@ -1,7 +1,6 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -9,8 +8,6 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@@ -48,7 +45,7 @@ class FinanceWalletBehaviorTest {
|
||||
void developmentProfileUsesTheNonPayableSampleQrCode() {
|
||||
var channels = new FinanceWalletService(mock(JdbcTemplate.class), new ObjectMapper().findAndRegisterModules(),
|
||||
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "",
|
||||
BigDecimal.ZERO, "cn", "development").channels();
|
||||
"cn", "development").channels();
|
||||
|
||||
assertThat(channels).allSatisfy(channel -> {
|
||||
assertThat(channel.enabled()).isTrue();
|
||||
@@ -57,44 +54,29 @@ class FinanceWalletBehaviorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualDeductionCannotExceedAvailableBalance() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), any(Object[].class))).thenReturn(List.of());
|
||||
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), any(Object[].class))).thenReturn(1);
|
||||
when(jdbc.update(startsWith("INSERT IGNORE INTO fin_wallet_account"), any(Object[].class))).thenReturn(1);
|
||||
when(jdbc.queryForObject(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getString("currency_code")).thenReturn("CNY");
|
||||
when(rs.getBigDecimal("actual_balance")).thenReturn(new BigDecimal("10.00"));
|
||||
when(rs.getBigDecimal("frozen_balance")).thenReturn(BigDecimal.ZERO);
|
||||
when(rs.getBigDecimal("available_balance")).thenReturn(new BigDecimal("10.00"));
|
||||
when(rs.getBigDecimal("credit_limit")).thenReturn(BigDecimal.ZERO);
|
||||
when(rs.getBigDecimal("credit_used")).thenReturn(BigDecimal.ZERO);
|
||||
when(rs.getBigDecimal("credit_frozen")).thenReturn(BigDecimal.ZERO);
|
||||
when(rs.getInt("version")).thenReturn(2);
|
||||
when(rs.getTimestamp("updated_at")).thenReturn(Timestamp.valueOf("2026-09-05 08:00:00"));
|
||||
return mapper.mapRow(rs, 0);
|
||||
});
|
||||
void deductionMayMakeActualBalanceNegative() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
WalletAccount initial = service.accountState(new BigDecimal("10.00"), BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, null, 2);
|
||||
|
||||
assertThatThrownBy(() -> service(jdbc, "x", "y").adjust(9L, 7L, "财务员", "deduct-1",
|
||||
new AccountAdjustmentRequest("DEDUCTION", new BigDecimal("10.01"), LocalDate.now(),
|
||||
"BANK_TRANSFER", "REF-1", "人工扣款", 2)))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-INSUFFICIENT");
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_wallet_account SET actual_balance="), any(Object[].class));
|
||||
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("10.01"));
|
||||
|
||||
assertThat(deducted.actualBalance()).isEqualByComparingTo("-0.01");
|
||||
assertThat(deducted.availableBalance()).isEqualByComparingTo("-0.01");
|
||||
assertThat(deducted.creditActive()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void configuredCreditLimitIsUsedForAFirstAccountRead() {
|
||||
void aMissingAccountNeverReceivesPermanentDefaultCredit() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
||||
|
||||
var account = service(jdbc, "x", "y", new BigDecimal("8000.00")).account(9L);
|
||||
var account = service(jdbc, "x", "y").account(9L);
|
||||
|
||||
assertThat(account.creditLimit()).isEqualByComparingTo("8000.00");
|
||||
assertThat(account.availableCredit()).isEqualByComparingTo("8000.00");
|
||||
assertThat(account.availableBalance()).isEqualByComparingTo("8000.00");
|
||||
assertThat(account.creditLimit()).isZero();
|
||||
assertThat(account.availableCredit()).isZero();
|
||||
assertThat(account.availableBalance()).isZero();
|
||||
assertThat(account.creditExpiresOn()).isNull();
|
||||
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
||||
}
|
||||
|
||||
@@ -102,7 +84,7 @@ class FinanceWalletBehaviorTest {
|
||||
void deductionUsesCashBeforeCreditAndRechargeRepaysCreditFirst() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
WalletAccount initial = service.accountState(new BigDecimal("100.00"), BigDecimal.ZERO,
|
||||
new BigDecimal("50.00"), BigDecimal.ZERO, BigDecimal.ZERO, 3);
|
||||
new BigDecimal("50.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 3);
|
||||
|
||||
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("120.00"));
|
||||
assertThat(deducted.actualBalance()).isEqualByComparingTo("0.00");
|
||||
@@ -120,7 +102,7 @@ class FinanceWalletBehaviorTest {
|
||||
void deductionNeverConsumesCashReservedByAnActiveHold() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
WalletAccount initial = service.accountState(new BigDecimal("100.00"), new BigDecimal("50.00"),
|
||||
new BigDecimal("80.00"), BigDecimal.ZERO, new BigDecimal("20.00"), 4);
|
||||
new BigDecimal("80.00"), new BigDecimal("20.00"), LocalDate.now().plusDays(7), 4);
|
||||
|
||||
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("80.00"));
|
||||
|
||||
@@ -135,37 +117,40 @@ class FinanceWalletBehaviorTest {
|
||||
@Test
|
||||
void rechargeRepaysUsedCreditWithoutReleasingFrozenCredit() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
WalletAccount initial = service.accountState(new BigDecimal("30.00"), new BigDecimal("25.00"),
|
||||
new BigDecimal("100.00"), new BigDecimal("40.00"), new BigDecimal("20.00"), 5);
|
||||
WalletAccount initial = service.accountState(new BigDecimal("5.00"), new BigDecimal("25.00"),
|
||||
new BigDecimal("100.00"), new BigDecimal("40.00"), new BigDecimal("20.00"),
|
||||
LocalDate.now().plusDays(7), 5);
|
||||
|
||||
WalletAccount recharged = service.rechargeAccount(initial, new BigDecimal("50.00"));
|
||||
|
||||
assertThat(recharged.actualBalance()).isEqualByComparingTo("40.00");
|
||||
assertThat(recharged.actualBalance()).isEqualByComparingTo("15.00");
|
||||
assertThat(recharged.creditUsed()).isEqualByComparingTo("0.00");
|
||||
assertThat(recharged.creditFrozen()).isEqualByComparingTo("20.00");
|
||||
assertThat(recharged.frozenBalance()).isEqualByComparingTo("25.00");
|
||||
assertThat(recharged.availableCredit()).isEqualByComparingTo("80.00");
|
||||
assertThat(recharged.availableBalance()).isEqualByComparingTo("115.00");
|
||||
assertThat(recharged.availableBalance()).isEqualByComparingTo("90.00");
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountStateRejectsCreditUsedAndFrozenAboveTheLimit() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
|
||||
assertThatThrownBy(() -> service.accountState(new BigDecimal("10.00"), new BigDecimal("25.00"),
|
||||
new BigDecimal("50.00"), new BigDecimal("31.00"), new BigDecimal("20.00"), 1))
|
||||
assertThatThrownBy(() -> service.accountState(BigDecimal.ZERO, new BigDecimal("20.00"),
|
||||
new BigDecimal("50.00"), new BigDecimal("31.00"), new BigDecimal("20.00"),
|
||||
LocalDate.now().plusDays(7), 1))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409");
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountStateRejectsFrozenCashAboveActualCash() {
|
||||
void accountStateAllowsBalanceToBecomeNegativeWhileFundsRemainFrozen() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
|
||||
assertThatThrownBy(() -> service.accountState(new BigDecimal("9.99"), new BigDecimal("10.00"),
|
||||
new BigDecimal("20.00"), BigDecimal.ZERO, BigDecimal.ZERO, 1))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409");
|
||||
WalletAccount account = service.accountState(new BigDecimal("9.99"), new BigDecimal("10.00"),
|
||||
new BigDecimal("20.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 1);
|
||||
|
||||
assertThat(account.availableBalance()).isEqualByComparingTo("19.99");
|
||||
assertThat(account.creditActive()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -173,32 +158,27 @@ class FinanceWalletBehaviorTest {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
|
||||
assertThatThrownBy(() -> service.accountState(new BigDecimal("100.00"), new BigDecimal("10.00"),
|
||||
new BigDecimal("50.00"), BigDecimal.ZERO, new BigDecimal("10.01"), 1))
|
||||
new BigDecimal("50.00"), new BigDecimal("10.01"), LocalDate.now().plusDays(7), 1))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidDefaultCreditConfigurationFailsClosed() {
|
||||
assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("-0.01")))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
||||
assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("1.001")))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
||||
assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("1000000000000.00")))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
||||
void expiredCreditNoLongerContributesToAvailableBalance() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
|
||||
WalletAccount account = service.accountState(BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
new BigDecimal("50.00"), new BigDecimal("20.00"), BigDecimal.ZERO,
|
||||
LocalDate.now().minusDays(1), 1);
|
||||
|
||||
assertThat(account.creditActive()).isFalse();
|
||||
assertThat(account.availableCredit()).isZero();
|
||||
assertThat(account.availableBalance()).isEqualByComparingTo("-20.00");
|
||||
}
|
||||
|
||||
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay) {
|
||||
return service(jdbc, wechat, alipay, BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay,
|
||||
BigDecimal defaultCreditLimit) {
|
||||
return new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay,
|
||||
defaultCreditLimit, "test", "test");
|
||||
"test", "test");
|
||||
}
|
||||
}
|
||||
+59
-27
@@ -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<PalletRow> pallets(@Param("taskId") long taskId);
|
||||
@Select("SELECT p.* FROM fl_dropship_pallet p JOIN fl_dropship_pallet_task t ON t.id=p.task_id WHERE t.tenant_id=#{tenantId} AND t.inbound_id=#{inboundId} ORDER BY p.id")
|
||||
@Select("""
|
||||
SELECT 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<PalletRow> 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<PalletRow> palletsByInbound(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId);
|
||||
@Select("SELECT inbound_code FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
|
||||
String inboundCode(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId);
|
||||
@@ -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<Long> taskIdsByPallet(@Param("palletId") long palletId);
|
||||
@Update("""
|
||||
UPDATE fl_dropship_pallet_task SET task_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE id=#{taskId} AND task_status='CLAIMED' AND counted_cartons=total_cartons AND allocated_cartons=total_cartons
|
||||
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet p WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId);
|
||||
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c JOIN fl_dropship_pallet p ON p.id=c.pallet_id
|
||||
WHERE c.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId);
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order o SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(*) FROM fl_dropship_pallet p
|
||||
WHERE p.inbound_id=o.id AND p.pallet_status='SEALED'),o.updated_by=#{operatorId},o.version=o.version+1
|
||||
UPDATE fl_inbound_order o SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(DISTINCT p.id)
|
||||
FROM fl_dropship_pallet_carton c JOIN fl_dropship_pallet p ON p.id=c.pallet_id
|
||||
WHERE c.tenant_id=o.tenant_id AND c.inbound_id=o.id AND p.pallet_status='SEALED'),o.updated_by=#{operatorId},o.version=o.version+1
|
||||
WHERE o.id=#{inboundId} AND o.tenant_id=#{tenantId} AND o.process_status='WAIT_PALLETIZE'
|
||||
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_task t WHERE t.id=#{taskId} AND t.task_status='COMPLETED')""")
|
||||
int completeInbound(@Param("taskId") long taskId,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,@Param("operatorId") long operatorId);
|
||||
|
||||
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.pallet_id=#{palletId} ORDER BY c.sku_code,c.inbound_detail_id,c.id") List<CartonRow> cartons(@Param("palletId") long palletId);
|
||||
@Select("""
|
||||
SELECT c.*,p.pallet_no,o.inbound_code FROM fl_dropship_pallet_carton c
|
||||
LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
|
||||
WHERE c.pallet_id=#{palletId} ORDER BY o.inbound_code,c.sku_code,c.inbound_detail_id,c.id
|
||||
""") List<CartonRow> cartons(@Param("palletId") long palletId);
|
||||
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL ORDER BY c.counted_at,c.id") List<CartonRow> unboundCartons(@Param("taskId") long taskId);
|
||||
@Select("""
|
||||
SELECT COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg,
|
||||
COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm
|
||||
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
|
||||
LEFT JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId}
|
||||
GROUP BY p.id,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count""")
|
||||
SELECT COALESCE(SUM(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0)),0) weight_kg,
|
||||
COALESCE(SUM(o.actual_volume_cbm/NULLIF(o.total_package_count,0)),0) volume_cbm
|
||||
FROM fl_dropship_pallet_carton c
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
|
||||
WHERE c.pallet_id=#{palletId}""")
|
||||
PalletMetrics metrics(@Param("palletId") long palletId);
|
||||
@Select("""
|
||||
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(c.id) carton_count,
|
||||
COUNT(DISTINCT c.sku_code) sku_count,
|
||||
COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg,
|
||||
COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm
|
||||
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
|
||||
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
|
||||
WHERE p.inbound_id=#{inboundId} AND p.pallet_status='SEALED'
|
||||
COALESCE(SUM(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0)),0) weight_kg,
|
||||
COALESCE(SUM(o.actual_volume_cbm/NULLIF(o.total_package_count,0)),0) volume_cbm
|
||||
FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
|
||||
WHERE EXISTS(SELECT 1 FROM fl_dropship_pallet_carton own WHERE own.pallet_id=p.id AND own.inbound_id=#{inboundId})
|
||||
AND p.pallet_status='SEALED'
|
||||
AND NOT EXISTS(SELECT 1 FROM fl_loading_plan_item i WHERE i.pallet_id=p.id AND i.deleted=0)
|
||||
GROUP BY p.id,p.pallet_no,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count ORDER BY p.id
|
||||
GROUP BY p.id,p.pallet_no ORDER BY p.id
|
||||
""") List<PalletCandidate> palletCandidates(@Param("inboundId") long inboundId);
|
||||
|
||||
@Select("""
|
||||
SELECT p.id pallet_id,p.pallet_no,p.pallet_status,p.tenant_id account_id,p.inbound_id,o.order_id,o.order_no,
|
||||
SELECT p.id pallet_id,p.pallet_no,p.pallet_status,c.tenant_id account_id,c.inbound_id,o.order_id,o.order_no,
|
||||
o.inbound_code,o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,
|
||||
o.destination_code destination_warehouse_code,o.destination_name destination_warehouse_name,o.customer_name,
|
||||
c.inbound_detail_id,c.sku_code,MAX(c.cargo_name) cargo_name,COUNT(c.id) carton_count,
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+14
-12
@@ -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<CartonRow> cartons=mapper.cartons(row.getId()); Map<String,List<CartonRow>> groups=new LinkedHashMap<>();
|
||||
for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c);
|
||||
List<SkuItem> items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),inboundCode,
|
||||
for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundId()+"|"+c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c);
|
||||
List<SkuItem> items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),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){
|
||||
|
||||
+41
-12
@@ -67,12 +67,14 @@ public class LoadingCompletedOutboxService {
|
||||
palletLabelService.prepare(planId, rows.get(0).getWarehouseId());
|
||||
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
|
||||
List<PalletRow> palletRows = palletRows(planId);
|
||||
Map<String, PalletManifest> palletManifests = palletManifests(palletRows);
|
||||
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels = palletLabelMapper.labels(planId);
|
||||
return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
|
||||
java.util.TreeMap::new, java.util.stream.Collectors.toList()))
|
||||
.entrySet().stream().map(entry -> envelope(entry.getKey(), entry.getValue(),
|
||||
palletRows.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(),
|
||||
standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks))
|
||||
standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks,
|
||||
palletManifests))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -98,14 +100,16 @@ public class LoadingCompletedOutboxService {
|
||||
|
||||
private EventEnvelope envelope(long accountId, List<Row> rows, List<PalletRow> palletRows,
|
||||
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
|
||||
Map<Long, List<String>> cartonMarks) {
|
||||
Map<Long, List<String>> cartonMarks,
|
||||
Map<String, PalletManifest> palletManifests) {
|
||||
Row first = rows.get(0);
|
||||
Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList()));
|
||||
List<FirstLegLoadingPlanCompletedV2.Order> orders = byInbound.values().stream()
|
||||
.map(value -> order(value, palletRows.stream()
|
||||
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(),
|
||||
standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
|
||||
standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(),
|
||||
cartonMarks, palletManifests)).toList();
|
||||
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
|
||||
first.getPlanNo(), string(first.getContainerBookingId()),
|
||||
ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(),
|
||||
@@ -123,7 +127,8 @@ public class LoadingCompletedOutboxService {
|
||||
|
||||
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
|
||||
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
|
||||
Map<Long, List<String>> cartonMarks) {
|
||||
Map<Long, List<String>> cartonMarks,
|
||||
Map<String, PalletManifest> palletManifests) {
|
||||
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
|
||||
Row first = sorted.get(0);
|
||||
List<FirstLegLoadingPlanCompletedV2.Item> items = sorted.stream().map(row ->
|
||||
@@ -138,7 +143,7 @@ public class LoadingCompletedOutboxService {
|
||||
String fulfillmentType = fulfillmentType(sorted);
|
||||
String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
|
||||
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(fulfillmentType)
|
||||
? palletSnapshots(sorted, palletRows) : standardLabels.stream()
|
||||
? palletSnapshots(sorted, palletRows, palletManifests) : standardLabels.stream()
|
||||
.map(label -> new FirstLegLoadingPlanCompletedV2.PalletSnapshot(label.getPalletCode(), List.of())).toList();
|
||||
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
|
||||
@@ -257,10 +262,11 @@ public class LoadingCompletedOutboxService {
|
||||
private record LabelPoolKey(long accountId, long inboundId) { }
|
||||
private record InboundPalletKey(long accountId, long inboundId) { }
|
||||
|
||||
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source) {
|
||||
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source,
|
||||
Map<String, PalletManifest> manifests) {
|
||||
if (source.isEmpty()) return List.of();
|
||||
if (source.stream().anyMatch(item -> Boolean.TRUE.equals(item.getActualBinding()))) {
|
||||
return actualPalletSnapshots(values, source);
|
||||
return actualPalletSnapshots(values, source, manifests);
|
||||
}
|
||||
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(),
|
||||
@@ -314,7 +320,7 @@ public class LoadingCompletedOutboxService {
|
||||
|
||||
/** 现场逐箱扫描已给出精确箱到托关系,不再使用旧的按托盘容量顺序推算。 */
|
||||
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> actualPalletSnapshots(
|
||||
List<Row> values, List<PalletRow> source) {
|
||||
List<Row> values, List<PalletRow> source, Map<String, PalletManifest> manifests) {
|
||||
Map<Long, Row> details = values.stream().collect(java.util.stream.Collectors.toMap(
|
||||
Row::getSourceInboundDetailId, value -> value, (left, right) -> left, LinkedHashMap::new));
|
||||
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
@@ -334,14 +340,17 @@ public class LoadingCompletedOutboxService {
|
||||
if (marks.size() != detailRows.getValue().size()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "现场托盘包含空箱唛或重复箱唛");
|
||||
}
|
||||
int quantity = detail.getProductQuantity() == null ? marks.size()
|
||||
: (int) Math.round((double) detail.getProductQuantity() * marks.size()
|
||||
/ Math.max(1, detail.getPackageCount()));
|
||||
int quantity = detailRows.getValue().stream().map(PalletRow::getActualSkuQuantity)
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||
if (quantity <= 0) throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘缺少实点 SKU 数量");
|
||||
skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSourceInboundDetailId().toString(), detail.getSkuCode(),
|
||||
detail.getProductNameZh(), marks.size(), quantity, marks));
|
||||
actualPackages += marks.size();
|
||||
}
|
||||
result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus));
|
||||
PalletManifest manifest = manifests.get(pallet.getKey());
|
||||
if (manifest == null) throw new BizException(ResultCode.CONFLICT, "一件代发托盘缺少完整清单摘要");
|
||||
result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus,
|
||||
manifest.version(), manifest.packageCount(), manifest.fragmentCount(), manifest.hash()));
|
||||
}
|
||||
if (actualPackages != sumInt(values, Row::getPackageCount)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘箱唛数与实装箱数不一致");
|
||||
@@ -349,12 +358,32 @@ public class LoadingCompletedOutboxService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, PalletManifest> palletManifests(List<PalletRow> rows) {
|
||||
Map<String, List<PalletRow>> grouped = rows.stream().filter(row -> Boolean.TRUE.equals(row.getActualBinding()))
|
||||
.collect(java.util.stream.Collectors.groupingBy(PalletRow::getPalletCode, LinkedHashMap::new,
|
||||
java.util.stream.Collectors.toList()));
|
||||
Map<String, PalletManifest> result = new LinkedHashMap<>();
|
||||
grouped.forEach((palletNo, values) -> {
|
||||
List<PalletRow> sorted = values.stream().sorted(Comparator
|
||||
.comparing(PalletRow::getAccountId).thenComparing(PalletRow::getSourceInboundId)
|
||||
.thenComparing(PalletRow::getSourceInboundDetailId).thenComparing(PalletRow::getBoxCode)).toList();
|
||||
String canonical = sorted.stream().map(value -> value.getAccountId() + "|" + value.getSourceInboundId()
|
||||
+ "|" + value.getSourceInboundDetailId() + "|" + value.getSkuCode() + "|" + value.getBoxCode()
|
||||
+ "|" + value.getActualSkuQuantity()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
int fragments = (int) sorted.stream().map(PalletRow::getAccountId).distinct().count();
|
||||
result.put(palletNo, new PalletManifest(1, sorted.size(), fragments,
|
||||
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<String> marks) {}
|
||||
private record PalletManifest(int version, int packageCount, int fragmentCount, String hash) {}
|
||||
|
||||
private void insert(EventEnvelope envelope) {
|
||||
InboundOutboxEntity value = new InboundOutboxEntity();
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-7
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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` 传递给海外资源批量预留。排查标签跨区丢失时沿此链路核对,不能在尾程页面重新生成头程托盘码。
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
+4
@@ -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;
|
||||
|
||||
+8
@@ -17,6 +17,14 @@ public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
|
||||
|
||||
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence")
|
||||
List<DropshipCarton> 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<String> 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<DropshipCarton> 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'")
|
||||
|
||||
+8
@@ -39,6 +39,14 @@ public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProf
|
||||
List<DropshipOrderProfile> 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<DropshipOrderProfile> listContainerPallet(@Param("containerNo") String containerNo,
|
||||
@Param("sourcePalletNo") String sourcePalletNo);
|
||||
|
||||
@Select("<script>" + OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE +
|
||||
"<if test='warehouseId != null and warehouseId != \"\"'>AND pos.overseas_warehouse_id=#{warehouseId} </if>" +
|
||||
|
||||
+15
@@ -51,6 +51,17 @@ public class DropshipOrderService {
|
||||
Set<String> requestedPallets = request.pallets().stream().map(value -> value.palletNo().trim())
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
if (!existingPallets.equals(requestedPallets)) throw conflict("Dropship order pallet snapshot conflicts with existing data");
|
||||
Map<String, DropshipOrderProfile> byPallet = existing.stream().collect(Collectors.toMap(
|
||||
DropshipOrderProfile::getSourcePalletNo, value -> value));
|
||||
for (DropshipRequests.Pallet requested : request.pallets()) {
|
||||
DropshipOrderProfile stored = byPallet.get(requested.palletNo().trim());
|
||||
if (!java.util.Objects.equals(stored.getManifestVersion(), requested.manifestVersion())
|
||||
|| !java.util.Objects.equals(stored.getManifestPackageCount(), requested.manifestPackageCount())
|
||||
|| !java.util.Objects.equals(stored.getManifestFragmentCount(), requested.manifestFragmentCount())
|
||||
|| !java.util.Objects.equals(stored.getManifestHash(), requested.manifestHash())) {
|
||||
throw conflict("Dropship pallet manifest conflicts with existing data: " + requested.palletNo());
|
||||
}
|
||||
}
|
||||
return Math.toIntExact(totalPackages);
|
||||
}
|
||||
|
||||
@@ -104,6 +115,10 @@ public class DropshipOrderService {
|
||||
DropshipOrderProfile profile = new DropshipOrderProfile();
|
||||
profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId());
|
||||
profile.setSourcePalletNo(input.palletNo().trim());
|
||||
profile.setManifestVersion(input.manifestVersion());
|
||||
profile.setManifestPackageCount(input.manifestPackageCount());
|
||||
profile.setManifestFragmentCount(input.manifestFragmentCount());
|
||||
profile.setManifestHash(input.manifestHash().trim().toLowerCase(java.util.Locale.ROOT));
|
||||
profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L);
|
||||
profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim());
|
||||
profile.setCreatedAt(now); profile.setUpdatedAt(now);
|
||||
|
||||
+3
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.entity;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.annotation.Version;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -31,4 +32,6 @@ public class InboundPlanPallet {
|
||||
private LocalDateTime lockedAt;
|
||||
private BigDecimal actualVolumeCbm;
|
||||
private Integer actualPackageCount;
|
||||
@Version
|
||||
private Integer version;
|
||||
}
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ public class ReceivingContainerController {
|
||||
|
||||
@PostMapping("/current/complete")
|
||||
public ApiResponse<ReceivingContainerResponses.Session> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.Action request) {
|
||||
@Valid @RequestBody ReceivingContainerRequests.Complete request) {
|
||||
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||
return ApiResponse.success(service.complete(identity.operatorKey(), request));
|
||||
}
|
||||
|
||||
+9
-1
@@ -13,14 +13,22 @@ public final class ReceivingContainerRequests {
|
||||
private ReceivingContainerRequests() { }
|
||||
|
||||
public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record Complete(boolean allowDifference,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record SignedPhoto(@NotNull ReceivingPhotoRole role,
|
||||
@NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { }
|
||||
public record CartonResolve(@NotBlank String cartonMark) { }
|
||||
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
|
||||
/** packageCount 是该托当前累计实收箱数,不是本次增量。 */
|
||||
public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode,
|
||||
@Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
@Positive int packageCount, Integer expectedPalletVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) {
|
||||
public BulkReceive(String inboundCode, String palletCode, int packageCount, String idempotencyKey) {
|
||||
this(inboundCode, palletCode, packageCount, null, idempotencyKey);
|
||||
}
|
||||
}
|
||||
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record WholePalletReceive(@NotBlank String sourcePalletNo,
|
||||
|
||||
+47
-7
@@ -35,7 +35,7 @@ public final class ReceivingContainerResponses {
|
||||
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
|
||||
LocalDateTime receivingAt, LocalDateTime completedAt,
|
||||
int totalPackages, int receivedPackages, int pendingPackages,
|
||||
List<Inbound> inbounds) {
|
||||
boolean hasDifference, List<Inbound> inbounds) {
|
||||
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
|
||||
String status, String operatorName, int version, List<SignedPhoto> photos,
|
||||
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt,
|
||||
@@ -43,7 +43,17 @@ public final class ReceivingContainerResponses {
|
||||
int pendingPackages, List<Inbound> inbounds) {
|
||||
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
|
||||
version, photos, preparedAt, signedAt, receivingAt, completedAt, totalPackages,
|
||||
receivedPackages, pendingPackages, inbounds);
|
||||
receivedPackages, pendingPackages, false, inbounds);
|
||||
}
|
||||
|
||||
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
|
||||
String overseasWarehouseName, String status, String operatorName, int version,
|
||||
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
|
||||
LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages,
|
||||
int receivedPackages, int pendingPackages, List<Inbound> inbounds) {
|
||||
this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
|
||||
status, operatorName, version, photos, preparedAt, signedAt, receivingAt,
|
||||
completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +75,20 @@ public final class ReceivingContainerResponses {
|
||||
String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
|
||||
int receivedPackages, int pendingPackages,
|
||||
boolean assigned, boolean mergeAllowed, boolean urgent,
|
||||
String mergeHint) {
|
||||
String mergeHint, String operationMode, String groupKey,
|
||||
List<String> inboundCodes, String sourcePalletNo) {
|
||||
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String fbaWarehouseCode,
|
||||
int plannedPalletCount, int totalPackages, int receivedPackages,
|
||||
int pendingPackages, boolean assigned, boolean mergeAllowed,
|
||||
boolean urgent, String mergeHint) {
|
||||
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
|
||||
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
|
||||
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages, pendingPackages,
|
||||
assigned, mergeAllowed, urgent, mergeHint, null, null, List.of(inboundCode), null);
|
||||
}
|
||||
public CartonResolved(String cartonMark, String inboundCode, String destinationType,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
@@ -75,24 +98,41 @@ public final class ReceivingContainerResponses {
|
||||
this(cartonMark, inboundCode, destinationType, overseasWarehouseId, overseasWarehouseName,
|
||||
destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
|
||||
fbaWarehouseCode, plannedPalletCount, totalPackages, receivedPackages,
|
||||
pendingPackages, false, false, false, null);
|
||||
pendingPackages, false, false, false, null, null, null, List.of(inboundCode), null);
|
||||
}
|
||||
}
|
||||
|
||||
public record PalletResolved(String palletCode, long warehouseId, String status,
|
||||
String fbaWarehouseCode, String inboundCode,
|
||||
String destinationType, boolean compatible) { }
|
||||
String destinationType, boolean compatible, int version,
|
||||
int currentReceivedPackages) {
|
||||
public PalletResolved(String palletCode, long warehouseId, String status,
|
||||
String fbaWarehouseCode, String inboundCode,
|
||||
String destinationType, boolean compatible) {
|
||||
this(palletCode, warehouseId, status, fbaWarehouseCode, inboundCode,
|
||||
destinationType, compatible, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public record Receipt(String result, String containerNo, String inboundCode,
|
||||
String destinationType, String palletCode, int acceptedPackages,
|
||||
int receivedPackages, int pendingPackages, String sessionStatus,
|
||||
boolean assigned, boolean mergeAllowed, boolean urgent,
|
||||
String mergeHint) {
|
||||
String mergeHint, String groupKey, List<String> inboundCodes) {
|
||||
public Receipt(String result, String containerNo, String inboundCode,
|
||||
String destinationType, String palletCode, int acceptedPackages,
|
||||
int receivedPackages, int pendingPackages, String sessionStatus,
|
||||
boolean assigned, boolean mergeAllowed, boolean urgent, String mergeHint) {
|
||||
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
|
||||
receivedPackages, pendingPackages, sessionStatus, assigned, mergeAllowed, urgent,
|
||||
mergeHint, null, List.of(inboundCode));
|
||||
}
|
||||
public Receipt(String result, String containerNo, String inboundCode,
|
||||
String destinationType, String palletCode, int acceptedPackages,
|
||||
int receivedPackages, int pendingPackages, String sessionStatus) {
|
||||
this(result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages,
|
||||
receivedPackages, pendingPackages, sessionStatus, false, false, false, null);
|
||||
receivedPackages, pendingPackages, sessionStatus, false, false, false, null,
|
||||
null, List.of(inboundCode));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_receiving_fba_pallet_count")
|
||||
public class ReceivingFbaPalletCount {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long sessionId;
|
||||
private String groupKey;
|
||||
private String palletCode;
|
||||
private Integer actualPackages;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface ReceivingFbaPalletCountMapper extends BaseMapper<ReceivingFbaPalletCount> {
|
||||
@Select("SELECT * FROM ll_receiving_fba_pallet_count WHERE session_id=#{sessionId} " +
|
||||
"AND group_key=#{groupKey} AND pallet_code=#{palletCode} LIMIT 1")
|
||||
ReceivingFbaPalletCount find(@Param("sessionId") long sessionId, @Param("groupKey") String groupKey,
|
||||
@Param("palletCode") String palletCode);
|
||||
|
||||
@Select("SELECT COALESCE(SUM(actual_packages),0) FROM ll_receiving_fba_pallet_count " +
|
||||
"WHERE session_id=#{sessionId} AND group_key=#{groupKey}")
|
||||
int sumActual(@Param("sessionId") long sessionId, @Param("groupKey") String groupKey);
|
||||
}
|
||||
+6
@@ -23,6 +23,7 @@ import org.springframework.stereotype.Repository;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Repository
|
||||
public class PdaWorkflowRepository {
|
||||
@@ -91,6 +92,11 @@ public class PdaWorkflowRepository {
|
||||
return pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>().eq(InboundPlanPallet::getPlanId, planId));
|
||||
}
|
||||
|
||||
/** FBA destination groups may allocate several customers onto one logical pallet owned by another plan. */
|
||||
public List<InboundPlanPallet> palletsByIds(Set<Long> palletIds) {
|
||||
return palletIds == null || palletIds.isEmpty() ? List.of() : pallets.selectBatchIds(palletIds);
|
||||
}
|
||||
|
||||
public List<InboundPlanPalletAllocation> allocations(Long planId) {
|
||||
return allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
|
||||
.eq(InboundPlanPalletAllocation::getPlanId, planId));
|
||||
|
||||
+65
-2
@@ -45,6 +45,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class InboundPdaWorkflowService {
|
||||
@@ -578,6 +579,58 @@ public class InboundPdaWorkflowService {
|
||||
return contexts.stream().map(context -> task(context.task(), context.plan().getPlanNo())).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 现场允许带差异结束:核平计划正常入库;差异计划只冻结为待核平,继续占用实物托盘和库位,
|
||||
* 不生成客户可用库存。柜级卸货事实仍只发布一次。
|
||||
*/
|
||||
@Transactional
|
||||
public void completeContainerWithDifferences(List<InboundPlan> containerPlans,
|
||||
List<InboundPlan> reconciledPlans) {
|
||||
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
|
||||
Set<Long> reconciledIds = reconciledPlans.stream().map(InboundPlan::getId).collect(java.util.stream.Collectors.toSet());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<InboundPdaTask> allTasks = new ArrayList<>();
|
||||
for (InboundPlan plan : containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList()) {
|
||||
InboundPdaTask task = repository.lockTask(plan.getCustomerId(), plan.getId());
|
||||
if (task == null || unloadPhotoFileIds(task).isEmpty() || task.getUnloadPhotoUploadedAt() == null) {
|
||||
throw conflict("Every plan must have the signed unload evidence before onsite completion");
|
||||
}
|
||||
if (reconciledIds.contains(plan.getId())) {
|
||||
CompletionContext context = completionContext(plan.getCustomerId(), plan);
|
||||
completePlan(plan.getCustomerId(), context, now);
|
||||
allTasks.add(context.task());
|
||||
} else {
|
||||
task.setStatus("PENDING_RECONCILIATION"); task.setCompletedAt(now); task.setUpdatedAt(now);
|
||||
task.setVersion(value(task.getVersion()) + 1); repository.update(task);
|
||||
plan.setStatus("PENDING_RECONCILIATION"); plan.setUpdatedAt(now); repository.update(plan);
|
||||
allTasks.add(task);
|
||||
}
|
||||
}
|
||||
String containerNo = containerPlans.get(0).getContainerNo();
|
||||
if (containerPlans.stream().anyMatch(plan -> !Objects.equals(containerNo, plan.getContainerNo()))) {
|
||||
throw conflict("Inbound plans must belong to one physical container");
|
||||
}
|
||||
String sourceContainerBookingId = sourceContainerBookingIdForPlans(containerPlans);
|
||||
LocalDateTime photoUploadedAt = allTasks.stream().map(InboundPdaTask::getUnloadPhotoUploadedAt)
|
||||
.max(LocalDateTime::compareTo).orElseThrow();
|
||||
long aggregateVersion = allTasks.stream().map(InboundPdaTask::getVersion).filter(Objects::nonNull)
|
||||
.mapToLong(Integer::longValue).max().orElse(1L);
|
||||
unloadCompletedOutbox.publishContainerCompleted(sourceContainerBookingId, containerNo,
|
||||
now.toInstant(ZoneOffset.UTC), photoUploadedAt.toInstant(ZoneOffset.UTC), aggregateVersion);
|
||||
}
|
||||
|
||||
private String sourceContainerBookingIdForPlans(List<InboundPlan> plans) {
|
||||
List<String> values = plans.stream().map(plan -> {
|
||||
LastlegOrder order = orders.selectById(plan.getSourceOrderId());
|
||||
if (order == null || order.getSourceContainerBookingId() == null || order.getSourceContainerBookingId().isBlank()) {
|
||||
throw new IllegalStateException("Inbound plan has no source container booking ID");
|
||||
}
|
||||
return order.getSourceContainerBookingId();
|
||||
}).distinct().toList();
|
||||
if (values.size() != 1) throw conflict("Container plans reference different source container bookings");
|
||||
return values.get(0);
|
||||
}
|
||||
|
||||
private String sourceContainerBookingId(List<CompletionContext> contexts) {
|
||||
List<String> bookingIds = contexts.stream().map(context -> {
|
||||
LastlegOrder order = orders.selectById(context.plan().getSourceOrderId());
|
||||
@@ -628,8 +681,15 @@ public class InboundPdaWorkflowService {
|
||||
private void completePlan(String customerId, CompletionContext context, LocalDateTime now) {
|
||||
InboundPlan plan = context.plan();
|
||||
InboundPdaTask task = context.task();
|
||||
List<InboundPlanPallet> receiptPallets = new ArrayList<>(repository.pallets(plan.getId()));
|
||||
Set<Long> presentPalletIds = receiptPallets.stream().map(InboundPlanPallet::getId)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
Set<Long> sharedPalletIds = context.allocations().stream().map(InboundPlanPalletAllocation::getPalletId)
|
||||
.filter(Objects::nonNull).filter(id -> !presentPalletIds.contains(id))
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
receiptPallets.addAll(repository.palletsByIds(sharedPalletIds));
|
||||
stockReceipts.stockIn(customerId, plan, task, context.lines(), context.allocations(),
|
||||
repository.pallets(plan.getId()), now);
|
||||
receiptPallets, now);
|
||||
for (InboundPlanCarton carton : context.cartons()) {
|
||||
carton.setStatus("ON_PALLET".equals(carton.getStatus()) ? "AVAILABLE" : "BULK_RECEIVED");
|
||||
carton.setReceivedAt(now); carton.setUpdatedAt(now);
|
||||
@@ -1164,7 +1224,10 @@ public class InboundPdaWorkflowService {
|
||||
List<InboundPlanCarton> cartons) { }
|
||||
|
||||
private static int value(Integer value) { return value == null ? 0 : value; }
|
||||
private static boolean terminal(String status) { return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status); }
|
||||
private static boolean terminal(String status) {
|
||||
return "COMPLETED".equals(status) || "PARTIAL_INBOUND".equals(status)
|
||||
|| "PENDING_RECONCILIATION".equals(status);
|
||||
}
|
||||
private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; }
|
||||
private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
|
||||
+318
-55
@@ -20,15 +20,20 @@ import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
@@ -74,8 +79,11 @@ public class ReceivingContainerService {
|
||||
@Resource private LastlegOrderDetailMapper orderDetails;
|
||||
@Resource private ReceivingContainerSessionMapper sessions;
|
||||
@Resource private ReceivingContainerOperationMapper operations;
|
||||
@Resource private ReceivingFbaPalletCountMapper fbaPalletCounts;
|
||||
@Resource private ReceivingSkuReceiptMapper skuReceipts;
|
||||
@Resource private ContainerShippingGateMapper shippingGates;
|
||||
@Resource private DropshipCartonMapper dropshipCartons;
|
||||
@Resource private DropshipOrderProfileMapper dropshipProfiles;
|
||||
@Resource private WarehouseResourceGateway warehouse;
|
||||
@Resource private UnloadPhotoFileGateway unloadPhotos;
|
||||
@Resource private InboundPdaWorkflowService workflow;
|
||||
@@ -246,13 +254,34 @@ public class ReceivingContainerService {
|
||||
public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey,
|
||||
ReceivingContainerRequests.CartonResolve request) {
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
List<InboundRef> currentRefs = refs(session.getContainerNo());
|
||||
String sourcePalletNo = dropshipSourcePallet(currentRefs, session.getContainerNo(), request.cartonMark());
|
||||
if (sourcePalletNo != null) {
|
||||
List<DropshipPalletRef> palletRefs = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo);
|
||||
requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
|
||||
List<InboundRef> group = palletRefs.stream().map(DropshipPalletRef::ref).distinct().toList();
|
||||
InboundRef first = group.get(0);
|
||||
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
|
||||
int total = palletRefs.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
|
||||
int received = palletRefs.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum();
|
||||
ReceivingContainerResponses.Inbound inbound = inbound(first);
|
||||
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(),
|
||||
inboundCodes.get(0), inbound.destinationType(), inbound.overseasWarehouseId(),
|
||||
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(),
|
||||
inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), 1, total, received,
|
||||
Math.max(0, total-received), true, false, urgent(first.plan()), null,
|
||||
"DROPSHIP_PALLET", "DP:" + sourcePalletNo, inboundCodes, sourcePalletNo);
|
||||
}
|
||||
String inboundCode = cartonInboundCode(request.cartonMark());
|
||||
List<InboundRef> matches = refs(session.getContainerNo()).stream()
|
||||
List<InboundRef> matches = currentRefs.stream()
|
||||
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
|
||||
CartonRef carton;
|
||||
if (matches.size() > 1) {
|
||||
throw conflict("Carton mark inbound code is ambiguous in the current container");
|
||||
} else if (matches.size() == 1 && "FBA".equals(destinationType(matches.get(0).line(), matches.get(0).order()))) {
|
||||
if (request.cartonMark().trim().equals(inboundCode)) {
|
||||
throw conflict("Scan one FBA carton code, not the inbound code");
|
||||
}
|
||||
carton = new CartonRef(matches.get(0));
|
||||
} else if (matches.size() == 1) {
|
||||
carton = requireCartonRef(matches.get(0).order().getCustomerId(), session.getContainerNo(), request.cartonMark());
|
||||
@@ -260,6 +289,22 @@ public class ReceivingContainerService {
|
||||
carton = requireUnmatchedFbaCartonPrefix(inboundCode);
|
||||
}
|
||||
InboundRef ref = carton.ref();
|
||||
if ("FBA".equals(destinationType(ref.line(), ref.order()))
|
||||
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
|
||||
List<InboundRef> group = requireFbaPlanRefs(session.getContainerNo(), ref);
|
||||
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
|
||||
int total = group.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
|
||||
int received = group.stream().mapToInt(this::receivedLinePackages).sum();
|
||||
ReceivingContainerResponses.Inbound inbound = inbound(ref);
|
||||
int palletCount = (int) group.stream().flatMap(value -> pallets.selectList(
|
||||
new LambdaQueryWrapper<InboundPlanPallet>().eq(InboundPlanPallet::getPlanId, value.plan().getId())).stream())
|
||||
.map(InboundPlanPallet::getWarehousePalletId).filter(Objects::nonNull).distinct().count();
|
||||
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(), inboundCodes.get(0),
|
||||
"FBA", inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
|
||||
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
|
||||
palletCount, total, received, Math.max(0,total-received), true, false, urgent(ref.plan()), null,
|
||||
"FBA_GROUP", fbaGroupKey(session.getContainerNo(), ref), inboundCodes, null);
|
||||
}
|
||||
ReceivingContainerResponses.Inbound inbound = inbound(ref);
|
||||
return new ReceivingContainerResponses.CartonResolved(request.cartonMark().trim(),
|
||||
inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(),
|
||||
@@ -273,10 +318,26 @@ public class ReceivingContainerService {
|
||||
ReceivingContainerRequests.PalletResolve request) {
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
|
||||
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false);
|
||||
List<InboundRef> group = "FBA".equals(destinationType(ref.line(), ref.order()))
|
||||
&& !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())
|
||||
? requireFbaPlanRefs(session.getContainerNo(), ref) : List.of(ref);
|
||||
PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order()))
|
||||
? resolveReceivingPallet(ref, request.palletCode(), false) : resolveFbaGroupPallet(group, request.palletCode());
|
||||
WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource();
|
||||
int received = selection.pallet() == null ? 0 : group.stream().mapToInt(value -> allocations.selectList(
|
||||
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
|
||||
.eq(InboundPlanPalletAllocation::getPlanId, value.plan().getId())
|
||||
.eq(InboundPlanPalletAllocation::getLineId, value.line().getId())
|
||||
.eq(InboundPlanPalletAllocation::getPalletId, selection.pallet().getId())).stream()
|
||||
.mapToInt(item -> value(item.getPickedPackageCount())).sum()).sum();
|
||||
if (selection.pallet() != null && "FBA".equals(destinationType(ref.line(), ref.order()))) {
|
||||
ReceivingFbaPalletCount physical = fbaPalletCounts.find(session.getId(),
|
||||
fbaGroupKey(session.getContainerNo(), ref), pallet.palletNo());
|
||||
if (physical != null) received = value(physical.getActualPackages());
|
||||
}
|
||||
return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(),
|
||||
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true);
|
||||
pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true,
|
||||
selection.pallet() == null ? 0 : value(selection.pallet().getVersion()), received);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -286,7 +347,6 @@ public class ReceivingContainerService {
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
|
||||
String customerId = ref.order().getCustomerId();
|
||||
if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
|
||||
throw conflict("One-click dropship inbound must be operated by complete source pallet");
|
||||
}
|
||||
@@ -294,31 +354,70 @@ public class ReceivingContainerService {
|
||||
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
|
||||
}
|
||||
List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(session.getContainerNo(), ref);
|
||||
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount());
|
||||
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(),
|
||||
allocation.ref().line(), allocation.packages()));
|
||||
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true);
|
||||
PalletSelection selection = resolveFbaGroupPallet(fbaPlanRefs, request.palletCode());
|
||||
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
|
||||
InboundPlanPallet pallet = selection.pallet();
|
||||
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
|
||||
if (request.expectedPalletVersion() != null && !Objects.equals(request.expectedPalletVersion(), pallet.getVersion())) {
|
||||
throw conflict("Pallet receiving version changed; refresh the group before saving");
|
||||
}
|
||||
int allocatedOnPallet = fbaPlanRefs.stream().mapToInt(value -> allocations.selectList(
|
||||
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
|
||||
.eq(InboundPlanPalletAllocation::getPlanId, value.plan().getId())
|
||||
.eq(InboundPlanPalletAllocation::getLineId, value.line().getId())
|
||||
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId())).stream()
|
||||
.mapToInt(item -> value(item.getPickedPackageCount())).sum()).sum();
|
||||
String groupKey = fbaGroupKey(session.getContainerNo(), ref);
|
||||
ReceivingFbaPalletCount physicalCount = fbaPalletCounts.find(session.getId(), groupKey, resource.palletNo());
|
||||
int previous = physicalCount == null ? allocatedOnPallet : value(physicalCount.getActualPackages());
|
||||
if (request.packageCount() < previous) {
|
||||
throw conflict("Cumulative pallet quantity cannot be reduced after saving; use correction operation");
|
||||
}
|
||||
int physicalDelta = request.packageCount() - previous;
|
||||
List<String> inboundCodes = fbaPlanRefs.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
|
||||
if (physicalDelta == 0) {
|
||||
int total = fbaPlanRefs.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
|
||||
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("ALREADY_RECEIVED",
|
||||
session.getContainerNo(), inboundCodes.get(0), "FBA", resource.palletNo(), 0,
|
||||
fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum(),
|
||||
Math.max(0,total-fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum()), session.getStatus(),
|
||||
true, mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()), groupKey, inboundCodes);
|
||||
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
|
||||
return result;
|
||||
}
|
||||
int groupTotal = fbaPlanRefs.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
|
||||
int groupReceived = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum();
|
||||
int allocatable = Math.min(physicalDelta, Math.max(0, groupTotal - groupReceived));
|
||||
List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(fbaPlanRefs, allocatable);
|
||||
skuAllocations.forEach(allocation -> workflow.validateBulkResolved(allocation.ref().order().getCustomerId(), allocation.ref().plan(),
|
||||
allocation.ref().line(), allocation.packages()));
|
||||
String palletCustomerId = pallet.getCustomerId();
|
||||
InboundRef palletOwner = fbaPlanRefs.stream().filter(value -> Objects.equals(value.plan().getId(), pallet.getPlanId()))
|
||||
.findFirst().orElseThrow(() -> conflict("FBA pallet does not belong to the current destination group"));
|
||||
Long resourceLockId = allocatable == 0 ? null
|
||||
: workflow.assignResolvedPallet(palletCustomerId, palletOwner.plan(), pallet, resource);
|
||||
try {
|
||||
int accepted = 0;
|
||||
for (FbaSkuAllocation allocation : skuAllocations) {
|
||||
accepted += workflow.receiveBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), pallet,
|
||||
String allocationCustomerId = allocation.ref().order().getCustomerId();
|
||||
accepted += workflow.receiveBulkResolved(allocationCustomerId, allocation.ref().plan(), allocation.ref().line(), pallet,
|
||||
allocation.packages(), operatorKey, operatorName);
|
||||
recordSkuReceipt(session, allocation, pallet);
|
||||
}
|
||||
savePhysicalFbaCount(session.getId(), groupKey, resource.palletNo(), request.packageCount(), physicalCount);
|
||||
markReceiving(session);
|
||||
int received = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum();
|
||||
int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum();
|
||||
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
|
||||
session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted,
|
||||
boolean overReceived = fbaPalletCounts.sumActual(session.getId(), groupKey) > total;
|
||||
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt(
|
||||
overReceived ? "DIFFERENCE_RECORDED" : "RECEIVED",
|
||||
session.getContainerNo(), inboundCodes.get(0), "FBA", resource.palletNo(), physicalDelta,
|
||||
received, Math.max(0, total - received), session.getStatus(), assigned(ref.plan()),
|
||||
mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()));
|
||||
mergeAllowed(ref.plan()), urgent(ref.plan()), mergeHint(ref.plan()),
|
||||
groupKey, inboundCodes);
|
||||
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
|
||||
return result;
|
||||
} catch (RuntimeException failure) {
|
||||
workflow.compensateReservation(customerId, resourceLockId, failure);
|
||||
workflow.compensateReservation(palletCustomerId, resourceLockId, failure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
@@ -370,59 +469,89 @@ public class ReceivingContainerService {
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
List<DropshipPalletRef> matches = new ArrayList<>();
|
||||
for (InboundRef ref : refs(session.getContainerNo())) {
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
|
||||
String customerId = ref.order().getCustomerId();
|
||||
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||
.eq(InboundPlanPallet::getCustomerId, customerId)
|
||||
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
|
||||
.eq(InboundPlanPallet::getSourcePalletNo, request.sourcePalletNo()));
|
||||
for (InboundPlanPallet pallet : sourcePallets) {
|
||||
List<InboundPlanPalletAllocation> palletAllocations = allocations.selectList(
|
||||
new LambdaQueryWrapper<InboundPlanPalletAllocation>()
|
||||
.eq(InboundPlanPalletAllocation::getCustomerId, customerId)
|
||||
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
|
||||
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())
|
||||
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId()));
|
||||
palletAllocations.forEach(allocation -> matches.add(new DropshipPalletRef(ref, pallet, allocation)));
|
||||
String sourcePalletNo = dropshipSourcePallet(session.getContainerNo(), request.sourcePalletNo());
|
||||
if (sourcePalletNo == null) throw notFound("DP or carton is not part of the current container");
|
||||
requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
|
||||
List<DropshipPalletRef> matches = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo);
|
||||
boolean alreadyReceived = matches.stream().allMatch(match ->
|
||||
value(match.allocation().getPickedPackageCount()) == value(match.allocation().getPlannedPackageCount()));
|
||||
int accepted = 0;
|
||||
if (!alreadyReceived) {
|
||||
if (matches.stream().anyMatch(match -> value(match.allocation().getPickedPackageCount()) != 0)) {
|
||||
throw conflict("DP pallet is only partially received; reconcile it before retrying");
|
||||
}
|
||||
for (DropshipPalletRef match : matches) {
|
||||
int packages = value(match.allocation().getPlannedPackageCount());
|
||||
workflow.pickWholePallet(match.ref().order().getCustomerId(), match.ref().plan().getPlanNo(),
|
||||
match.allocation().getId(), new PdaRequests.WholePalletOperation(
|
||||
request.idempotencyKey() + ":PICK:" + match.allocation().getId(), operatorKey, operatorName));
|
||||
accepted += packages;
|
||||
match.allocation().setPickedPackageCount(packages);
|
||||
}
|
||||
}
|
||||
if (matches.isEmpty()) throw notFound("Source pallet is not part of the current container");
|
||||
if (matches.size() != 1) throw conflict("Source pallet is duplicated inside the current container");
|
||||
DropshipPalletRef match = matches.get(0);
|
||||
String customerId = match.ref().order().getCustomerId();
|
||||
int accepted = value(match.allocation().getPlannedPackageCount());
|
||||
workflow.pickWholePallet(customerId, match.ref().plan().getPlanNo(), match.allocation().getId(),
|
||||
new PdaRequests.WholePalletOperation(request.idempotencyKey() + ":PICK", operatorKey, operatorName));
|
||||
markReceiving(session);
|
||||
ReceivingContainerResponses.Inbound inbound = inbound(match.ref());
|
||||
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
|
||||
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), match.pallet().getPalletNo(),
|
||||
accepted, inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus(),
|
||||
inbound.assigned(), inbound.mergeAllowed(), inbound.urgent(), inbound.mergeHint());
|
||||
List<InboundRef> group = matches.stream().map(DropshipPalletRef::ref).distinct().toList();
|
||||
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
|
||||
int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
|
||||
int received = matches.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum();
|
||||
InboundRef first = group.get(0);
|
||||
ReceivingContainerResponses.Inbound inbound = inbound(first);
|
||||
ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt(
|
||||
alreadyReceived ? "ALREADY_RECEIVED" : "RECEIVED", session.getContainerNo(), inboundCodes.get(0),
|
||||
inbound.destinationType(), sourcePalletNo, accepted, received, Math.max(0,total-received), session.getStatus(),
|
||||
true, false, urgent(first.plan()), null, "DP:" + sourcePalletNo, inboundCodes);
|
||||
save(session, "PALLET_RECEIVE", request.idempotencyKey(), request, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||
public ReceivingContainerResponses.Session complete(String operatorKey,
|
||||
ReceivingContainerRequests.Action request) {
|
||||
ReceivingContainerRequests.Complete request) {
|
||||
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
|
||||
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
|
||||
ReceivingContainerSession session = requireSigned(operatorKey);
|
||||
List<InboundPlan> containerPlans = requirePlans(session.getContainerNo());
|
||||
Totals totals = totals(containerPlans);
|
||||
if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container");
|
||||
validateFbaSkuBalance(session.getContainerNo());
|
||||
workflow.completeContainer(containerPlans);
|
||||
boolean hasDifference = totals.total() != totals.received() || hasFbaOverReceipt(session, containerPlans);
|
||||
if (hasDifference && !request.allowDifference()) {
|
||||
throw conflict("Container has a receiving difference; confirm onsite completion with difference");
|
||||
}
|
||||
if (!hasDifference) {
|
||||
validateFbaSkuBalance(session.getContainerNo());
|
||||
workflow.completeContainer(containerPlans);
|
||||
} else {
|
||||
workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(containerPlans));
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
session.setStatus("COMPLETED"); session.setCompletedAt(now); session.setUpdatedAt(now);
|
||||
session.setStatus(hasDifference ? "COMPLETED_WITH_DIFFERENCE" : "COMPLETED");
|
||||
session.setCompletedAt(now); session.setUpdatedAt(now);
|
||||
session.setActiveOperatorKey(null); session.setActiveContainerKey(null); update(session);
|
||||
ReceivingContainerResponses.Session result = snapshot(session, containerPlans);
|
||||
save(session, "COMPLETE", request.idempotencyKey(), request, result); return result;
|
||||
}
|
||||
|
||||
private List<InboundPlan> reconciledPlans(List<InboundPlan> containerPlans) {
|
||||
Set<Long> complete = refs(containerPlans.get(0).getContainerNo()).stream()
|
||||
.collect(Collectors.groupingBy(value -> value.plan().getId(), LinkedHashMap::new, Collectors.toList()))
|
||||
.entrySet().stream().filter(entry -> entry.getValue().stream().allMatch(ref ->
|
||||
receivedLinePackages(ref) == value(ref.line().getRequiredPackageCount())))
|
||||
.map(Map.Entry::getKey).collect(Collectors.toSet());
|
||||
Set<String> incompleteFbaGroups = refs(containerPlans.get(0).getContainerNo()).stream()
|
||||
.filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()))
|
||||
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
|
||||
.filter(ref -> !complete.contains(ref.plan().getId()))
|
||||
.map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).collect(Collectors.toSet());
|
||||
refs(containerPlans.get(0).getContainerNo()).stream()
|
||||
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
|
||||
.filter(ref -> fbaGroupHasOverReceipt(findSession(containerPlans.get(0).getContainerNo()), ref))
|
||||
.map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).forEach(incompleteFbaGroups::add);
|
||||
return containerPlans.stream().filter(plan -> complete.contains(plan.getId()))
|
||||
.filter(plan -> refs(plan.getContainerNo()).stream().filter(ref -> Objects.equals(ref.plan().getId(), plan.getId()))
|
||||
.noneMatch(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))
|
||||
&& incompleteFbaGroups.contains(normalizedFba(ref.line().getFbaWarehouseCode()))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) {
|
||||
String key = activeOperatorKey(operatorKey);
|
||||
ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key)
|
||||
@@ -503,7 +632,8 @@ public class ReceivingContainerService {
|
||||
List<InboundPlan> containerPlans = planFacts.stream()
|
||||
.filter(plan -> containerNo.equals(plan.getContainerNo())).toList();
|
||||
boolean allPlansCompleted = !containerPlans.isEmpty()
|
||||
&& containerPlans.stream().allMatch(plan -> "COMPLETED".equals(plan.getStatus()));
|
||||
&& containerPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
|
||||
.contains(plan.getStatus()));
|
||||
boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1;
|
||||
boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent;
|
||||
int pending = Math.max(0, totalOrders - plannedOrders);
|
||||
@@ -565,7 +695,8 @@ public class ReceivingContainerService {
|
||||
if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) {
|
||||
throw conflict("Every inbound plan in the receiving container must have an owning customer");
|
||||
}
|
||||
if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) {
|
||||
if (values.stream().allMatch(value -> List.of("COMPLETED", "PENDING_RECONCILIATION")
|
||||
.contains(value.getStatus()))) {
|
||||
throw conflict("Receiving container is already completed");
|
||||
}
|
||||
return values;
|
||||
@@ -634,16 +765,20 @@ public class ReceivingContainerService {
|
||||
|
||||
private List<InboundRef> requireFbaPlanRefs(String containerNo, InboundRef selected) {
|
||||
List<InboundRef> values = refs(containerNo).stream()
|
||||
.filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId()))
|
||||
.sorted(Comparator.comparing(value -> value.line().getId())).toList();
|
||||
.filter(value -> !"ONE_CLICK_DROPSHIP".equals(value.order().getOrderType()))
|
||||
.filter(value -> "FBA".equals(destinationType(value.line(), value.order())))
|
||||
.filter(value -> Objects.equals(normalizedFba(value.line().getFbaWarehouseCode()),
|
||||
normalizedFba(selected.line().getFbaWarehouseCode())))
|
||||
.sorted(Comparator.comparing((InboundRef value) -> value.line().getInboundCode())
|
||||
.thenComparing(value -> value.line().getId())).toList();
|
||||
if (values.isEmpty() || values.stream().anyMatch(value ->
|
||||
!"FBA".equals(destinationType(value.line(), value.order())))) {
|
||||
throw conflict("FBA bulk receiving requires every line in the selected inbound plan to be FBA");
|
||||
throw conflict("FBA receiving group is invalid");
|
||||
}
|
||||
Set<String> destinations = values.stream().map(value -> trim(value.line().getFbaWarehouseCode()))
|
||||
.collect(Collectors.toSet());
|
||||
if (destinations.size() != 1 || destinations.contains(null)) {
|
||||
throw conflict("FBA bulk receiving requires one destination warehouse per inbound plan");
|
||||
throw conflict("FBA receiving group requires one destination warehouse");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@@ -694,6 +829,29 @@ public class ReceivingContainerService {
|
||||
return new PalletSelection(pallet, resource);
|
||||
}
|
||||
|
||||
private PalletSelection resolveFbaGroupPallet(List<InboundRef> refs, String palletCode) {
|
||||
String normalized = palletCode.trim();
|
||||
List<InboundPlanPallet> matches = refs.stream().flatMap(ref -> pallets.selectList(
|
||||
new LambdaQueryWrapper<InboundPlanPallet>()
|
||||
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
|
||||
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream())
|
||||
.filter(value -> matchesPlannedPalletCode(value, normalized)).filter(this::hasConfirmedReservation)
|
||||
.collect(Collectors.toMap(InboundPlanPallet::getId, value -> value, (left,right)->left,
|
||||
LinkedHashMap::new)).values().stream().toList();
|
||||
if (matches.isEmpty()) throw notFound("Pallet code is not part of the current FBA destination group");
|
||||
if (matches.size() != 1) throw conflict("Pallet code is duplicated in the current FBA destination group");
|
||||
InboundPlanPallet pallet = matches.get(0);
|
||||
InboundRef owner = refs.stream().filter(ref -> Objects.equals(ref.plan().getId(), pallet.getPlanId()))
|
||||
.findFirst().orElseThrow(() -> conflict("Pallet owner plan is not part of the FBA destination group"));
|
||||
long warehouseId = owner.plan().getOverseasWarehouseId() == null
|
||||
? Long.parseLong(owner.order().getOverseasWarehouseId()) : owner.plan().getOverseasWarehouseId();
|
||||
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolveReservedPallet(
|
||||
pallet.getPalletNo(), owner.order().getCustomerId(), warehouseId, pallet.getWarehousePalletId(),
|
||||
pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId());
|
||||
validatePallet(owner, resource);
|
||||
return new PalletSelection(pallet, resource);
|
||||
}
|
||||
|
||||
private boolean matchesPlannedPalletCode(InboundPlanPallet pallet, String scannedCode) {
|
||||
return scannedCode.equals(trim(pallet.getPalletNo()))
|
||||
|| scannedCode.equals(trim(pallet.getSourcePalletNo()));
|
||||
@@ -750,6 +908,68 @@ public class ReceivingContainerService {
|
||||
return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode());
|
||||
}
|
||||
|
||||
private String dropshipSourcePallet(String containerNo, String scanCode) {
|
||||
return dropshipSourcePallet(refs(containerNo), containerNo, scanCode);
|
||||
}
|
||||
|
||||
private String dropshipSourcePallet(List<InboundRef> currentRefs, String containerNo, String scanCode) {
|
||||
String normalized = trim(scanCode);
|
||||
if (normalized == null) return null;
|
||||
List<InboundRef> dropshipRefs = currentRefs.stream()
|
||||
.filter(ref -> "ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())).toList();
|
||||
if (dropshipRefs.isEmpty()) return null;
|
||||
boolean direct = normalized.toUpperCase(java.util.Locale.ROOT).startsWith("DP") && dropshipRefs.stream()
|
||||
.anyMatch(ref -> pallets.selectCount(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
|
||||
.eq(InboundPlanPallet::getSourcePalletNo, normalized)) > 0);
|
||||
if (direct) return normalized;
|
||||
List<String> resolved = dropshipCartons.sourcePalletsInContainer(containerNo, normalized);
|
||||
if (resolved.size() > 1) throw conflict("Carton belongs to more than one DP in the current container");
|
||||
return resolved.isEmpty() ? null : resolved.get(0);
|
||||
}
|
||||
|
||||
private List<DropshipPalletRef> dropshipPalletRefs(String containerNo, String sourcePalletNo) {
|
||||
List<DropshipPalletRef> result = new ArrayList<>();
|
||||
for (InboundRef ref : refs(containerNo)) {
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue;
|
||||
List<InboundPlanPallet> sourcePallets = pallets.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
|
||||
.eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId())
|
||||
.eq(InboundPlanPallet::getPlanId, ref.plan().getId())
|
||||
.eq(InboundPlanPallet::getSourcePalletNo, sourcePalletNo));
|
||||
for (InboundPlanPallet pallet : sourcePallets) {
|
||||
allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
|
||||
.eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId())
|
||||
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
|
||||
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())
|
||||
.eq(InboundPlanPalletAllocation::getPalletId, pallet.getId()))
|
||||
.forEach(allocation -> result.add(new DropshipPalletRef(ref, pallet, allocation)));
|
||||
}
|
||||
}
|
||||
if (result.isEmpty()) throw notFound("DP is not part of the current container");
|
||||
Set<Long> physicalPallets = result.stream().map(value -> value.pallet().getWarehousePalletId())
|
||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
if (physicalPallets.size() != 1) throw conflict("DP customer fragments do not share one physical pallet");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void requireCompleteManifest(String containerNo, String sourcePalletNo) {
|
||||
List<DropshipOrderProfile> profiles = dropshipProfiles.listContainerPallet(containerNo, sourcePalletNo);
|
||||
if (profiles.isEmpty()) throw notFound("DP manifest was not found in the current container");
|
||||
Set<Integer> versions = profiles.stream().map(DropshipOrderProfile::getManifestVersion).collect(Collectors.toSet());
|
||||
Set<Integer> totals = profiles.stream().map(DropshipOrderProfile::getManifestPackageCount).collect(Collectors.toSet());
|
||||
Set<Integer> fragments = profiles.stream().map(DropshipOrderProfile::getManifestFragmentCount).collect(Collectors.toSet());
|
||||
Set<String> hashes = profiles.stream().map(DropshipOrderProfile::getManifestHash).collect(Collectors.toSet());
|
||||
if (versions.size()!=1 || totals.size()!=1 || fragments.size()!=1 || hashes.size()!=1) {
|
||||
throw conflict("DP manifest fragments are inconsistent");
|
||||
}
|
||||
long customerFragments = profiles.stream().map(DropshipOrderProfile::getCustomerId).distinct().count();
|
||||
long cartons = profiles.stream().mapToLong(profile ->
|
||||
dropshipCartons.listProfile(profile.getCustomerId(), profile.getId()).size()).sum();
|
||||
if (customerFragments != fragments.iterator().next() || cartons != totals.iterator().next()) {
|
||||
throw conflict("DP manifest fragments are incomplete; wait for all customer fragments");
|
||||
}
|
||||
}
|
||||
|
||||
private record PalletSelection(InboundPlanPallet pallet,
|
||||
WarehouseResourceGateway.WarehousePalletReference resource) { }
|
||||
|
||||
@@ -764,7 +984,40 @@ public class ReceivingContainerService {
|
||||
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
|
||||
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
|
||||
value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(),
|
||||
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds);
|
||||
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received),
|
||||
hasFbaOverReceipt(session, containerPlans), inbounds);
|
||||
}
|
||||
|
||||
private void savePhysicalFbaCount(long sessionId, String groupKey, String palletCode, int actualPackages,
|
||||
ReceivingFbaPalletCount existing) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (existing == null) {
|
||||
ReceivingFbaPalletCount value = new ReceivingFbaPalletCount();
|
||||
value.setId(IdWorker.getId()); value.setSessionId(sessionId); value.setGroupKey(groupKey);
|
||||
value.setPalletCode(palletCode); value.setActualPackages(actualPackages);
|
||||
value.setCreatedAt(now); value.setUpdatedAt(now); fbaPalletCounts.insert(value);
|
||||
} else {
|
||||
existing.setActualPackages(actualPackages); existing.setUpdatedAt(now); fbaPalletCounts.updateById(existing);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasFbaOverReceipt(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
|
||||
if (session == null || containerPlans == null || containerPlans.isEmpty()) return false;
|
||||
return refs(session.getContainerNo()).stream()
|
||||
.filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()))
|
||||
.filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order())))
|
||||
.anyMatch(ref -> fbaGroupHasOverReceipt(session, ref));
|
||||
}
|
||||
|
||||
private boolean fbaGroupHasOverReceipt(ReceivingContainerSession session, InboundRef ref) {
|
||||
if (session == null || session.getId() == null) return false;
|
||||
int planned = requireFbaPlanRefs(session.getContainerNo(), ref).stream()
|
||||
.mapToInt(value -> value(value.line().getRequiredPackageCount())).sum();
|
||||
return fbaPalletCounts.sumActual(session.getId(), fbaGroupKey(session.getContainerNo(), ref)) > planned;
|
||||
}
|
||||
|
||||
private ReceivingContainerSession findSession(String containerNo) {
|
||||
return sessions.findActiveByContainer(activeContainerKey(containerNo));
|
||||
}
|
||||
|
||||
private ReceivingContainerResponses.Inbound inbound(InboundRef ref) {
|
||||
@@ -844,10 +1097,11 @@ public class ReceivingContainerService {
|
||||
throw conflict("Unload photo file ID must be a positive decimal string");
|
||||
}
|
||||
|
||||
private List<FbaSkuAllocation> allocateFbaSkuReceipts(String customerId, List<InboundRef> refs, int packageCount) {
|
||||
private List<FbaSkuAllocation> allocateFbaSkuReceipts(List<InboundRef> refs, int packageCount) {
|
||||
int remaining = packageCount;
|
||||
List<FbaSkuAllocation> result = new ArrayList<>();
|
||||
for (InboundRef ref : refs) {
|
||||
String customerId = ref.order().getCustomerId();
|
||||
int linePending = value(ref.line().getRequiredPackageCount()) - receivedLinePackages(ref);
|
||||
List<SkuExpectation> expectations = skuExpectations(ref);
|
||||
int skuPending = expectations.stream().mapToInt(expectation -> expectation.expectedPackages()
|
||||
@@ -958,6 +1212,15 @@ public class ReceivingContainerService {
|
||||
return value;
|
||||
}
|
||||
|
||||
private String fbaGroupKey(String containerNo, InboundRef ref) {
|
||||
return "FBA:" + containerNo + ":" + normalizedFba(ref.line().getFbaWarehouseCode());
|
||||
}
|
||||
|
||||
private String normalizedFba(String value) {
|
||||
String normalized = trim(value);
|
||||
return normalized == null ? null : normalized.toUpperCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
private void markReceiving(ReceivingContainerSession session) {
|
||||
if (!"RECEIVING".equals(session.getStatus())) {
|
||||
session.setStatus("RECEIVING"); session.setReceivingAt(LocalDateTime.now());
|
||||
|
||||
+18
-5
@@ -253,7 +253,7 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "计划托数不能少于已生成的头程托盘码数量");
|
||||
}
|
||||
String fbaCode = trimToNull(order.fbaWarehouseCode());
|
||||
if (fbaCode != null) {
|
||||
if (fbaCode != null && !"ONE_CLICK_DROPSHIP".equals(order.orderType())) {
|
||||
for (String palletCode : sourcePalletNos) {
|
||||
PalletCodeParts parts = palletCodeParts(preview.containerNo(), palletCode);
|
||||
String previousPrefix = prefixByFba.putIfAbsent(fbaCode, parts.prefix());
|
||||
@@ -329,10 +329,7 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次包含重复业务键");
|
||||
}
|
||||
}
|
||||
if (response.items().stream().map(WarehouseResourceGateway.PalletBatchItem::palletId).distinct().count() != response.items().size()
|
||||
|| response.items().stream().map(WarehouseResourceGateway.PalletBatchItem::resourceLockId).distinct().count() != response.items().size()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次包含重复托盘或资源锁");
|
||||
}
|
||||
validateSharedPallets(preparation.items(), byKey);
|
||||
return preparation.items().stream().collect(Collectors.groupingBy(PreparedItem::orderId)).values().stream()
|
||||
.map(items -> {
|
||||
PreparedItem first = items.get(0);
|
||||
@@ -356,6 +353,22 @@ public class ContainerInboundPlanConversionCoordinator {
|
||||
return value;
|
||||
}
|
||||
|
||||
private void validateSharedPallets(List<PreparedItem> expected,
|
||||
Map<String, WarehouseResourceGateway.PalletBatchItem> actual) {
|
||||
Map<Long, List<PreparedItem>> byPallet = expected.stream().collect(Collectors.groupingBy(item ->
|
||||
requiredReservation(actual.get(item.businessKey()), item).palletId()));
|
||||
for (List<PreparedItem> values : byPallet.values()) {
|
||||
if (values.size() == 1) continue;
|
||||
Set<String> codes = values.stream().map(PreparedItem::palletCode).collect(Collectors.toSet());
|
||||
Set<Long> locks = values.stream().map(item -> requiredReservation(actual.get(item.businessKey()), item).resourceLockId())
|
||||
.collect(Collectors.toSet());
|
||||
if (codes.size() != 1 || codes.iterator().next() == null
|
||||
|| !codes.iterator().next().toUpperCase(java.util.Locale.ROOT).startsWith("DP") || locks.size() != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "只有同一码的一件代发 DP 才能共享实物托盘");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size,
|
||||
String... acceptedStatuses) {
|
||||
if (response == null || response.batchId() <= 0 || response.attemptNo() <= 0
|
||||
|
||||
+3
-1
@@ -842,7 +842,9 @@ public class InboundPlanService {
|
||||
InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper<InboundPlan>()
|
||||
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
|
||||
if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found");
|
||||
if ("COMPLETED".equals(plan.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets");
|
||||
if (List.of("COMPLETED", "PENDING_RECONCILIATION").contains(plan.getStatus())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets");
|
||||
}
|
||||
List<InboundPlanLine> lines = lineMapper.selectList(new LambdaQueryWrapper<InboundPlanLine>()
|
||||
.eq(InboundPlanLine::getPlanId, plan.getId()));
|
||||
boolean destinationExists = lines.stream().anyMatch(line -> request.destinationWarehouseId().equals(line.getDestinationWarehouseId()));
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@ public interface InventoryCommandMapper {
|
||||
@Param("balanceId") Long balanceId);
|
||||
|
||||
@Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " +
|
||||
"FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId}")
|
||||
long palletPackages(@Param("customerId") String customerId, @Param("palletId") Long palletId);
|
||||
"FROM ll_inventory_position WHERE pallet_id=#{palletId}")
|
||||
long palletPackages(@Param("palletId") Long palletId);
|
||||
|
||||
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
|
||||
"AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} " +
|
||||
|
||||
+1
-1
@@ -239,7 +239,7 @@ public class InventoryCommandService {
|
||||
balance.setPickedPackages(value(balance.getPickedPackages()) - request.packages());
|
||||
touch(balance);
|
||||
balances.updateById(balance);
|
||||
boolean palletEmpty = commands.palletPackages(customerId, position.getPalletId()) == 0;
|
||||
boolean palletEmpty = commands.palletPackages(position.getPalletId()) == 0;
|
||||
if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId());
|
||||
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "SHIP",
|
||||
"PICKED", "SHIPPED", before, -request.packages(), before - request.packages(),
|
||||
|
||||
+31
-5
@@ -459,7 +459,10 @@ public class LoadingCompletedConsumer {
|
||||
|
||||
private List<DropshipRequests.Pallet> pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
|
||||
if (source.pallets() != null && !source.pallets().isEmpty()) {
|
||||
return source.pallets().stream().map(pallet -> {
|
||||
List<DropshipRequests.Pallet> result = new ArrayList<>();
|
||||
JsonNode rawPallets = rawSource.path("pallets");
|
||||
for (int palletIndex = 0; palletIndex < source.pallets().size(); palletIndex++) {
|
||||
FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet = source.pallets().get(palletIndex);
|
||||
Map<String, DropshipRequests.SkuPackages> inventorySkus = new java.util.LinkedHashMap<>();
|
||||
for (var sku : pallet.skuPackages()) {
|
||||
inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(),
|
||||
@@ -470,8 +473,13 @@ public class LoadingCompletedConsumer {
|
||||
Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks));
|
||||
});
|
||||
}
|
||||
return new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()));
|
||||
}).toList();
|
||||
JsonNode rawPallet = rawPallets.isArray() && palletIndex < rawPallets.size()
|
||||
? rawPallets.get(palletIndex) : objectMapper.createObjectNode();
|
||||
result.add(new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()),
|
||||
positiveManifest(rawPallet, "manifestVersion"), positiveManifest(rawPallet, "manifestPackageCount"),
|
||||
positiveManifest(rawPallet, "manifestFragmentCount"), requiredManifestHash(rawPallet)));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
return legacyPallets(rawSource);
|
||||
}
|
||||
@@ -484,7 +492,9 @@ public class LoadingCompletedConsumer {
|
||||
String palletNo = pallet.path("palletNo").asText(pallet.path("sourcePalletNo").asText(null));
|
||||
List<DropshipRequests.SkuPackages> skuRows = skuPackages(pallet);
|
||||
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
|
||||
result.add(new DropshipRequests.Pallet(palletNo, skuRows));
|
||||
result.add(new DropshipRequests.Pallet(palletNo, skuRows,
|
||||
positiveManifest(pallet, "manifestVersion"), positiveManifest(pallet, "manifestPackageCount"),
|
||||
positiveManifest(pallet, "manifestFragmentCount"), requiredManifestHash(pallet)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -492,11 +502,27 @@ public class LoadingCompletedConsumer {
|
||||
String palletNo = source.path("palletNo").asText(source.path("sourcePalletNo").asText(null));
|
||||
List<DropshipRequests.SkuPackages> skuRows = skuPackages(source);
|
||||
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
|
||||
result.add(new DropshipRequests.Pallet(palletNo, skuRows));
|
||||
result.add(new DropshipRequests.Pallet(palletNo, skuRows,
|
||||
positiveManifest(source, "manifestVersion"), positiveManifest(source, "manifestPackageCount"),
|
||||
positiveManifest(source, "manifestFragmentCount"), requiredManifestHash(source)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private int positiveManifest(JsonNode source, String field) {
|
||||
int value = source.path(field).asInt(0);
|
||||
if (value <= 0) throw new IllegalArgumentException("One-click dropship pallet is missing " + field);
|
||||
return value;
|
||||
}
|
||||
|
||||
private String requiredManifestHash(JsonNode source) {
|
||||
String value = source.path("manifestHash").asText(null);
|
||||
if (value == null || !value.matches("(?i)[0-9a-f]{64}")) {
|
||||
throw new IllegalArgumentException("One-click dropship pallet manifestHash is invalid");
|
||||
}
|
||||
return value.toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
private List<DropshipRequests.SkuPackages> skuPackages(JsonNode source) {
|
||||
List<DropshipRequests.SkuPackages> result = new ArrayList<>();
|
||||
JsonNode nodes = source.path("skuPackages");
|
||||
|
||||
@@ -660,8 +660,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)
|
||||
@@ -996,7 +997,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 '创建时间',
|
||||
@@ -1019,6 +1020,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 '客户端幂等键',
|
||||
@@ -1799,11 +1810,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 (
|
||||
|
||||
@@ -96,24 +96,24 @@ paths:
|
||||
/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve:
|
||||
post:
|
||||
summary: 解析当前柜箱唛
|
||||
description: 权限 lastleg:pda:operate;FBA 从完整 cartonMark 的第一个 # 前提取固定入仓码,并精确匹配当前锁定柜唯一 FBA line;不要求逐箱 manifest,不校验 SKU 或箱序号。OVERSEAS 必须命中真实 manifest。两者均不返回 SKU,也不登记收货。
|
||||
description: 权限 lastleg:pda:operate;一件代发扫描 DP 或任一箱码解析整托并校验跨客户完整清单;普通 FBA 扫任一箱码后按当前柜+FBA目的仓聚合所有入仓码;海外仓中转仍逐箱解析。响应不返回客户信息。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } }
|
||||
responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '404': { description: 入仓码未命中当前柜,或 OVERSEAS 箱码未命中真实 manifest }, '409': { description: '缺少 #、空入仓码前缀、跨柜或当前柜多重命中' } }
|
||||
/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve:
|
||||
post:
|
||||
summary: 解析当前柜实物托盘码
|
||||
description: 权限 lastleg:pda:operate;托盘固定从 Warehouse Overseas 公共资源作用域解析并校验实际仓与目的仓。
|
||||
description: 权限 lastleg:pda:operate;普通 FBA 只解析当前柜该 FBA 目的仓已经生成的“柜号-FBA仓-序号”托盘,返回版本与该托现场累计箱数。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [palletCode, inboundCode], properties: { palletCode: { type: string }, inboundCode: { type: string } } } } } }
|
||||
responses: { '200': { description: 可用托盘与目的匹配结果, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingPalletResolvedApiResponse' } } } }, '409': { description: 仓库、目的仓或托盘状态不符 } }
|
||||
/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk:
|
||||
post:
|
||||
summary: FBA 入仓码按箱数收货
|
||||
description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;服务端按当前入仓码所属计划的 line.id ASC 和冻结 SKU 明细顺序拆分箱数并记录真实 SKU 收货;不接收 SKU、customerId、planNo、allocationId、warehouseId、locationId。
|
||||
description: 权限 lastleg:pda:operate;只允许普通 FBA。packageCount 是该托现场累计箱数,服务端按当前柜+FBA目的仓稳定分摊到全部关联入仓码;超出计划部分只记差异,不生成可用库存。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } }
|
||||
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 海外仓批量、超收或托盘不匹配 } }
|
||||
responses: { '200': { description: 本次与累计实收进度,超收返回 DIFFERENCE_RECORDED, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 非普通FBA、累计数回退、版本变化或托盘不匹配 } }
|
||||
/api/pda/v1/last-leg/receiving-containers/current/receipts/carton:
|
||||
post:
|
||||
summary: 海外仓入仓码逐箱收货
|
||||
@@ -124,17 +124,17 @@ paths:
|
||||
/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet:
|
||||
post:
|
||||
summary: 一件代发按完整来源托盘收货
|
||||
description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描国内封托后冻结的来源托盘号,一次接收该托全部箱和 SKU 快照;不允许逐箱或拆分接收。
|
||||
description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描 DP 或该托任一箱码,一次接收跨客户完整 DP;所有客户事件分片清单一致且齐全后才允许收货。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWholePallet' } } } }
|
||||
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '404': { description: 来源托盘不属于当前柜 }, '409': { description: 非一件代发、来源托盘重复或已操作 } }
|
||||
/api/pda/v1/last-leg/receiving-containers/current/complete:
|
||||
post:
|
||||
summary: 完成当前柜收货并入库存
|
||||
description: 权限 lastleg:pda:operate;仅柜内每个入仓码实收总数精确相等后完成,库存、计划、尾程状态与卸柜 Outbox 同事务落地。
|
||||
description: 权限 lastleg:pda:operate;无差异时正常入库;allowDifference=true 时允许短少或超收现场结束,差异计划进入 PENDING_RECONCILIATION 且不生成客户可用库存。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } }
|
||||
responses: { '200': { description: COMPLETED 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 数量未核平或状态冲突 } }
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingComplete' } } } }
|
||||
responses: { '200': { description: COMPLETED 或 COMPLETED_WITH_DIFFERENCE 柜号会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 未确认差异或状态冲突 } }
|
||||
/api/pda/v1/last-leg/inventory/resolve:
|
||||
post:
|
||||
summary: PDA按SKU、箱唛或入仓码扫码查询库存
|
||||
@@ -1781,6 +1781,12 @@ components:
|
||||
type: object
|
||||
required: [idempotencyKey]
|
||||
properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } }
|
||||
ReceivingComplete:
|
||||
type: object
|
||||
required: [allowDifference, idempotencyKey]
|
||||
properties:
|
||||
allowDifference: { type: boolean }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
ReceivingSign:
|
||||
type: object
|
||||
required: [photos, idempotencyKey]
|
||||
@@ -1807,6 +1813,7 @@ components:
|
||||
inboundCode: { type: string, minLength: 1 }
|
||||
palletCode: { type: string, minLength: 1 }
|
||||
packageCount: { type: integer, minimum: 1 }
|
||||
expectedPalletVersion: { type: integer, minimum: 0, nullable: true }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
ReceivingCarton:
|
||||
type: object
|
||||
@@ -1883,13 +1890,13 @@ components:
|
||||
currentAccount: { type: boolean }
|
||||
ReceivingSession:
|
||||
type: object
|
||||
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, inbounds]
|
||||
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds]
|
||||
properties:
|
||||
containerNo: { type: string }
|
||||
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
|
||||
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码快照 }
|
||||
overseasWarehouseName: { type: string, description: Warehouse Overseas服务校验后的共享实际海外收货仓名称快照 }
|
||||
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
|
||||
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED, COMPLETED_WITH_DIFFERENCE] }
|
||||
operatorName: { type: string, nullable: true }
|
||||
version: { type: integer }
|
||||
photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedSessionPhoto' } }
|
||||
@@ -1900,6 +1907,7 @@ components:
|
||||
totalPackages: { type: integer }
|
||||
receivedPackages: { type: integer }
|
||||
pendingPackages: { type: integer }
|
||||
hasDifference: { type: boolean, description: 是否存在短少或普通FBA超收差异 }
|
||||
inbounds: { type: array, items: { $ref: '#/components/schemas/ReceivingInbound' } }
|
||||
ReceivingPhoto:
|
||||
type: object
|
||||
@@ -1913,12 +1921,16 @@ components:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ReceivingInbound' }
|
||||
- type: object
|
||||
required: [cartonMark]
|
||||
required: [cartonMark, operationMode, groupKey, inboundCodes]
|
||||
properties:
|
||||
cartonMark: { type: string }
|
||||
operationMode: { type: string, enum: [DROPSHIP_PALLET, FBA_GROUP], nullable: true }
|
||||
groupKey: { type: string, nullable: true }
|
||||
inboundCodes: { type: array, items: { type: string } }
|
||||
sourcePalletNo: { type: string, nullable: true }
|
||||
ReceivingPalletResolved:
|
||||
type: object
|
||||
required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible]
|
||||
required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible, version, currentReceivedPackages]
|
||||
properties:
|
||||
palletCode: { type: string }
|
||||
warehouseId: { type: integer, format: int64 }
|
||||
@@ -1927,6 +1939,8 @@ components:
|
||||
inboundCode: { type: string }
|
||||
destinationType: { type: string, enum: [FBA, OVERSEAS] }
|
||||
compatible: { type: boolean }
|
||||
version: { type: integer, minimum: 0 }
|
||||
currentReceivedPackages: { type: integer, minimum: 0 }
|
||||
ReceivingReceipt:
|
||||
type: object
|
||||
required: [result, containerNo, inboundCode, destinationType, palletCode, acceptedPackages, receivedPackages, pendingPackages, sessionStatus, assigned, mergeAllowed, urgent]
|
||||
@@ -1946,6 +1960,8 @@ components:
|
||||
mergeHint: { type: string, nullable: true }
|
||||
orderType: { type: string, nullable: true }
|
||||
wholePalletInbound: { type: boolean, description: 一件代发必须按完整来源托盘扫描收货 }
|
||||
groupKey: { type: string, nullable: true }
|
||||
inboundCodes: { type: array, items: { type: string } }
|
||||
ReceivingCandidateListApiResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
|
||||
|
||||
+1
-1
@@ -69,6 +69,6 @@ class DropshipOrderControllerContractTest {
|
||||
return new DropshipRequests.Receive("100", 10L, 20L, "event-1",
|
||||
List.of(new DropshipRequests.Pallet("PALLET-1",
|
||||
List.of(new DropshipRequests.SkuPackages("SKU-1", "Product", 1, 2,
|
||||
List.of("CARTON-1"))))));
|
||||
List.of("CARTON-1"))), 1, 1, 1, "0".repeat(64))));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -52,10 +52,10 @@ class DropshipOrderServiceTest {
|
||||
List.of(
|
||||
new DropshipRequests.Pallet("SOURCE-P-1", List.of(
|
||||
new DropshipRequests.SkuPackages("SKU-A", "Product A", 2, 20,
|
||||
List.of("MARK-1", "MARK-2")))),
|
||||
List.of("MARK-1", "MARK-2"))), 1, 2, 1, "0".repeat(64)),
|
||||
new DropshipRequests.Pallet("SOURCE-P-2", List.of(
|
||||
new DropshipRequests.SkuPackages("SKU-A", "Product A", 1, 10,
|
||||
List.of("MARK-3"))))));
|
||||
List.of("MARK-3"))), 1, 1, 1, "1".repeat(64))));
|
||||
|
||||
assertThat(service.receive(request)).isEqualTo(3);
|
||||
verifyNoInteractions(warehouse);
|
||||
|
||||
+10
-4
@@ -38,8 +38,9 @@ class ReceivingContainerContractTest {
|
||||
.map(RecordComponent::getName).collect(Collectors.toSet());
|
||||
assertTrue(java.util.Collections.disjoint(fields, FORBIDDEN_CALLER_SCOPE), type.getSimpleName());
|
||||
}
|
||||
for (Class<?> type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Sign.class,
|
||||
ReceivingContainerRequests.BulkReceive.class, ReceivingContainerRequests.CartonReceive.class)) {
|
||||
for (Class<?> type : Set.of(ReceivingContainerRequests.Action.class, ReceivingContainerRequests.Complete.class,
|
||||
ReceivingContainerRequests.Sign.class, ReceivingContainerRequests.BulkReceive.class,
|
||||
ReceivingContainerRequests.CartonReceive.class, ReceivingContainerRequests.WholePalletReceive.class)) {
|
||||
RecordComponent key = Arrays.stream(type.getRecordComponents())
|
||||
.filter(component -> "idempotencyKey".equals(component.getName())).findFirst().orElseThrow();
|
||||
Size size = key.getAccessor().getAnnotation(Size.class);
|
||||
@@ -67,7 +68,7 @@ class ReceivingContainerContractTest {
|
||||
@Test
|
||||
void inventoryCreatingEntryTransactionsAvoidMissingDimensionGapLockDeadlocks() throws NoSuchMethodException {
|
||||
Transactional pda = ReceivingContainerService.class
|
||||
.getMethod("complete", String.class, ReceivingContainerRequests.Action.class)
|
||||
.getMethod("complete", String.class, ReceivingContainerRequests.Complete.class)
|
||||
.getAnnotation(Transactional.class);
|
||||
Transactional bridge = LoadingCompletedConsumer.class.getMethod("consume", EventEnvelope.class)
|
||||
.getAnnotation(Transactional.class);
|
||||
@@ -103,6 +104,10 @@ class ReceivingContainerContractTest {
|
||||
assertTrue(unified.contains("alter table ll_container_shipping_gate drop column customer_id"));
|
||||
assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt"));
|
||||
assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)"));
|
||||
assertTrue(schema.contains("create table if not exists ll_receiving_fba_pallet_count"));
|
||||
assertTrue(schema.contains("unique key uk_ll_receiving_fba_count(session_id,group_key,pallet_code)"));
|
||||
assertTrue(tableBlock(schema, "ll_inbound_plan_pallet").contains("key idx_ll_inbound_pallet_no (pallet_no)"));
|
||||
assertFalse(tableBlock(schema, "ll_inbound_plan_pallet").contains("unique key uk_ll_inbound_pallet_no"));
|
||||
String order = tableBlock(schema, "ll_order");
|
||||
assertTrue(order.contains("overseas_warehouse_id varchar(128) null"));
|
||||
assertTrue(order.contains("overseas_warehouse_code varchar(128) null"));
|
||||
@@ -113,6 +118,7 @@ class ReceivingContainerContractTest {
|
||||
assertTrue(receivingSession.contains("overseas_warehouse_name varchar(255) not null"));
|
||||
assertFalse(receivingSession.contains("photo_file_ids json"));
|
||||
assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt"));
|
||||
assertTrue(unified.contains("create table if not exists ll_receiving_fba_pallet_count"));
|
||||
assertTrue(unified.contains("column_name='sign_photos'"));
|
||||
assertTrue(schema.contains("wait_sign、signed、receiving、completed"));
|
||||
assertTrue(unified.contains("customer_id is null or l.customer_id=''"));
|
||||
@@ -120,7 +126,7 @@ class ReceivingContainerContractTest {
|
||||
assertTrue(unified.contains("customer_id is null or a.customer_id=''"));
|
||||
assertTrue(unified.contains("signal sqlstate '45000'"));
|
||||
assertTrue(unified.contains("assert_receiving_global_locks_migratable"));
|
||||
assertTrue(unified.contains("where status='completed' and (active_operator_key is not null or active_container_key is not null)"));
|
||||
assertTrue(unified.contains("where status in ('completed','completed_with_difference') and (active_operator_key is not null or active_container_key is not null)"));
|
||||
assertTrue(unified.contains("concat('operator:',operator_key)"));
|
||||
assertTrue(unified.contains("concat('container:',upper(trim(container_no)))"));
|
||||
assertTrue(unified.contains("assert_receiving_operation_global_key_migratable"));
|
||||
|
||||
+19
@@ -30,6 +30,7 @@ import org.springframework.mock.web.MockMultipartFile;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -596,6 +597,24 @@ class InboundPdaWorkflowServiceTest {
|
||||
any(), any(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionLoadsSharedFbaPalletOwnedByAnotherCustomerPlan() {
|
||||
activeTask();
|
||||
target.setPickedPackageCount(5);
|
||||
InboundPlanPallet sharedPallet = pallet(40L, "WH-A", 5, "1.0000", 400L);
|
||||
sharedPallet.setPlanId(99L);
|
||||
target.setPalletId(sharedPallet.getId());
|
||||
when(repository.allocations(plan.getId())).thenReturn(List.of(target));
|
||||
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
|
||||
when(repository.pallets(plan.getId())).thenReturn(List.of());
|
||||
when(repository.palletsByIds(Set.of(sharedPallet.getId()))).thenReturn(List.of(sharedPallet));
|
||||
|
||||
service.complete("100", "LIP1");
|
||||
|
||||
verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)),
|
||||
eq(List.of(target)), eq(List.of(sharedPallet)), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedTaskReplayDoesNotRunStockInAgain() {
|
||||
task.setStatus("COMPLETED");
|
||||
|
||||
+91
-28
@@ -16,11 +16,17 @@ import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
|
||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingFbaPalletCount;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerOperationMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingFbaPalletCountMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||
@@ -58,8 +64,11 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
@Mock LastlegOrderDetailMapper orderDetails;
|
||||
@Mock ReceivingContainerSessionMapper sessions;
|
||||
@Mock ReceivingContainerOperationMapper operations;
|
||||
@Mock ReceivingFbaPalletCountMapper fbaPalletCounts;
|
||||
@Mock ReceivingSkuReceiptMapper skuReceipts;
|
||||
@Mock ContainerShippingGateMapper shippingGates;
|
||||
@Mock DropshipCartonMapper dropshipCartons;
|
||||
@Mock DropshipOrderProfileMapper dropshipProfiles;
|
||||
@Mock WarehouseResourceGateway warehouse;
|
||||
@Mock UnloadPhotoFileGateway unloadPhotos;
|
||||
@Mock InboundPdaWorkflowService workflow;
|
||||
@@ -77,8 +86,11 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
|
||||
ReflectionTestUtils.setField(service, "sessions", sessions);
|
||||
ReflectionTestUtils.setField(service, "operations", operations);
|
||||
ReflectionTestUtils.setField(service, "fbaPalletCounts", fbaPalletCounts);
|
||||
ReflectionTestUtils.setField(service, "skuReceipts", skuReceipts);
|
||||
ReflectionTestUtils.setField(service, "shippingGates", shippingGates);
|
||||
ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons);
|
||||
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
|
||||
ReflectionTestUtils.setField(service, "warehouse", warehouse);
|
||||
ReflectionTestUtils.setField(service, "unloadPhotos", unloadPhotos);
|
||||
ReflectionTestUtils.setField(service, "workflow", workflow);
|
||||
@@ -414,11 +426,10 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
|
||||
org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse, pallets, workflow);
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
stubSku();
|
||||
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
|
||||
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
|
||||
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "pallet-key")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||
error -> assertThat(error.getStatusCode().value()).isEqualTo(404));
|
||||
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@@ -426,7 +437,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
void overReceiptAndDuplicateCartonFailBeforeRemoteReservation() {
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
stubSku();
|
||||
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
|
||||
stubReservedFbaPallet();
|
||||
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
|
||||
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
|
||||
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
|
||||
@@ -442,6 +453,30 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fbaOverReceiptAllocatesOnlyPlannedBalanceAndRecordsPhysicalDifference() {
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
stubSku();
|
||||
stubReservedFbaPallet();
|
||||
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
|
||||
.thenAnswer(invocation -> invocation.getArgument(4));
|
||||
when(fbaPalletCounts.insert(any(ReceivingFbaPalletCount.class))).thenReturn(1);
|
||||
when(fbaPalletCounts.sumActual(8L, "FBA:CONT-A:FBA-1")).thenReturn(3);
|
||||
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
|
||||
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
|
||||
|
||||
var result = service.receiveBulk("7", "receiver",
|
||||
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 3, "over-mark-key"));
|
||||
|
||||
assertThat(result.result()).isEqualTo("DIFFERENCE_RECORDED");
|
||||
assertThat(result.acceptedPackages()).isEqualTo(3);
|
||||
verify(workflow).receiveBulkResolved(any(), any(), any(), any(),
|
||||
org.mockito.ArgumentMatchers.eq(2), any(), any());
|
||||
ArgumentCaptor<ReceivingFbaPalletCount> count = ArgumentCaptor.forClass(ReceivingFbaPalletCount.class);
|
||||
verify(fbaPalletCounts).insert(count.capture());
|
||||
assertThat(count.getValue().getActualPackages()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasPalletUsesActualReceivingWarehouseInsteadOfBusinessDestinationId() {
|
||||
stubCurrent("OVERSEAS", "CONT-A");
|
||||
@@ -464,7 +499,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation();
|
||||
partial.setLineId(10L); partial.setPickedPackageCount(1);
|
||||
when(allocations.selectList(any())).thenReturn(List.of(partial));
|
||||
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key")))
|
||||
assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Complete(false, "complete-key")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
verify(workflow, never()).completeContainer(any());
|
||||
}
|
||||
@@ -480,7 +515,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
when(allocations.selectList(any())).thenReturn(List.of(received));
|
||||
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
|
||||
|
||||
var completed = service.complete("7", new ReceivingContainerRequests.Action("dropship-complete"));
|
||||
var completed = service.complete("7", new ReceivingContainerRequests.Complete(false, "dropship-complete"));
|
||||
|
||||
assertThat(completed.status()).isEqualTo("COMPLETED");
|
||||
verifyNoInteractions(skuReceipts);
|
||||
@@ -491,11 +526,11 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
void completedRequestReplaysWithoutAnActiveCustomerSession() {
|
||||
ReceivingContainerOperation operation = new ReceivingContainerOperation();
|
||||
operation.setOperatorKey("7"); operation.setAction("COMPLETE"); operation.setIdempotencyKey("complete-replay");
|
||||
operation.setRequestHash(hash(new ReceivingContainerRequests.Action("complete-replay")));
|
||||
operation.setRequestHash(hash(new ReceivingContainerRequests.Complete(false, "complete-replay")));
|
||||
operation.setResultJson("{\"containerNo\":\"CONT-A\",\"status\":\"COMPLETED\",\"operatorName\":\"receiver\",\"version\":1,\"photos\":[],\"totalPackages\":2,\"receivedPackages\":2,\"pendingPackages\":0,\"inbounds\":[]}");
|
||||
when(operations.find("7", "complete-replay")).thenReturn(operation);
|
||||
|
||||
assertThat(service.complete("7", new ReceivingContainerRequests.Action("complete-replay")).status())
|
||||
assertThat(service.complete("7", new ReceivingContainerRequests.Complete(false, "complete-replay")).status())
|
||||
.isEqualTo("COMPLETED");
|
||||
verifyNoInteractions(sessions);
|
||||
}
|
||||
@@ -504,8 +539,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
void localFailureAfterRemoteReservationTriggersReleaseCompensation() {
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
stubSku();
|
||||
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
|
||||
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
|
||||
stubReservedFbaPallet();
|
||||
when(workflow.assignResolvedPallet(any(), any(), any(), any())).thenReturn(55L);
|
||||
ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race");
|
||||
doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any());
|
||||
@@ -675,9 +709,10 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkRejectsAggregateSkuOverReceiptBeforeWarehouseLookup() {
|
||||
void bulkRejectsInconsistentFrozenSkuProgressBeforeAnyReceiptWrite() {
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
stubSku();
|
||||
stubReservedFbaPallet();
|
||||
com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt received =
|
||||
new com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt();
|
||||
received.setReceivedPackages(2);
|
||||
@@ -686,7 +721,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
|
||||
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "sku-over")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
verifyNoInteractions(warehouse);
|
||||
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -707,7 +742,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void cartonResolutionReturnsAssignmentOptionalMergeAndUrgencyFromInboundPlan() {
|
||||
void fbaGroupResolutionKeepsUrgencyWithoutPerPlanMergeChoices() {
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
InboundPlan flagged = plan("CONT-A");
|
||||
flagged.setAssignedFlag(true);
|
||||
@@ -719,9 +754,9 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
|
||||
assertThat(resolved.inboundCode()).isEqualTo("IN-1");
|
||||
assertThat(resolved.assigned()).isTrue();
|
||||
assertThat(resolved.mergeAllowed()).isTrue();
|
||||
assertThat(resolved.mergeAllowed()).isFalse();
|
||||
assertThat(resolved.urgent()).isTrue();
|
||||
assertThat(resolved.mergeHint()).contains("可选择", "同目的仓", "新托盘码", "不强制");
|
||||
assertThat(resolved.mergeHint()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -788,8 +823,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
when(orders.selectById(2L)).thenReturn(secondOrder);
|
||||
when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-A", 1), detail("SKU-B", 1)),
|
||||
List.of(detail("SKU-C", 2)));
|
||||
when(warehouse.resolveAvailablePallet("PAL-1")).thenReturn(resource(100L, "FBA-1"));
|
||||
when(pallets.selectList(any())).thenReturn(List.of(planPallet()));
|
||||
stubReservedFbaPallet();
|
||||
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
|
||||
.thenAnswer(invocation -> invocation.getArgument(4));
|
||||
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
|
||||
@@ -813,22 +847,27 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasDropshipResolvesOnlyFrozenCartonsAndNeverCreatesLabelsWhileResolving() {
|
||||
void dropshipCartonResolvesItsCompleteDpInsteadOfAPlanOrCustomer() {
|
||||
stubCurrent("OVERSEAS", "CONT-A");
|
||||
LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP");
|
||||
when(orders.selectById(1L)).thenReturn(dropship);
|
||||
InboundPlanCarton frozen = carton(1L, "IN-1#2-1");
|
||||
frozen.setCustomerId("100"); frozen.setSourceInboundDetailId("d-1"); frozen.setSkuCode("SKU-1");
|
||||
when(cartons.selectOne(any())).thenReturn(frozen);
|
||||
InboundPlanPallet pallet = planPallet(); pallet.setSourcePalletNo("DP-260909-0001");
|
||||
pallet.setWarehousePalletId(30L);
|
||||
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation();
|
||||
allocation.setId(30L); allocation.setPlanId(2L); allocation.setLineId(10L);
|
||||
allocation.setPalletId(20L); allocation.setPlannedPackageCount(2); allocation.setPickedPackageCount(0);
|
||||
when(dropshipCartons.sourcePalletsInContainer("CONT-A", "IN-1#2-1"))
|
||||
.thenReturn(List.of("DP-260909-0001"));
|
||||
when(pallets.selectList(any())).thenReturn(List.of(pallet));
|
||||
when(allocations.selectList(any())).thenReturn(List.of(allocation));
|
||||
stubCompleteManifest("DP-260909-0001", 2);
|
||||
var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-1"));
|
||||
assertThat(resolved.inboundCode()).isEqualTo("IN-1");
|
||||
assertThat(resolved.destinationType()).isEqualTo("OVERSEAS");
|
||||
assertThat(resolved.operationMode()).isEqualTo("DROPSHIP_PALLET");
|
||||
assertThat(resolved.sourcePalletNo()).isEqualTo("DP-260909-0001");
|
||||
assertThat(resolved.totalPackages()).isEqualTo(2);
|
||||
verify(cartons, never()).insert(any(InboundPlanCarton.class));
|
||||
verifyNoInteractions(warehouse);
|
||||
when(cartons.selectOne(any())).thenReturn(null);
|
||||
assertThatThrownBy(() -> service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-9")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("404");
|
||||
verify(cartons, never()).insert(any(InboundPlanCarton.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -837,7 +876,8 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP");
|
||||
when(orders.selectById(1L)).thenReturn(dropship);
|
||||
InboundPlanPallet pallet = planPallet();
|
||||
pallet.setPalletNo("PAL-PHYSICAL-1"); pallet.setSourcePalletNo("DP-260909-0001");
|
||||
pallet.setPalletNo("DP-260909-0001"); pallet.setSourcePalletNo("DP-260909-0001");
|
||||
pallet.setWarehousePalletId(30L);
|
||||
InboundPlanPalletAllocation pending = new InboundPlanPalletAllocation();
|
||||
pending.setId(30L); pending.setCustomerId("100"); pending.setPlanId(2L);
|
||||
pending.setLineId(10L); pending.setPalletId(20L); pending.setPlannedPackageCount(2);
|
||||
@@ -847,6 +887,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
received.setPickedPackageCount(2);
|
||||
when(pallets.selectList(any())).thenReturn(List.of(pallet));
|
||||
when(allocations.selectList(any())).thenReturn(List.of(pending), List.of(received));
|
||||
stubCompleteManifest("DP-260909-0001", 2);
|
||||
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
|
||||
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
|
||||
|
||||
@@ -856,7 +897,7 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
assertThat(result.acceptedPackages()).isEqualTo(2);
|
||||
assertThat(result.receivedPackages()).isEqualTo(2);
|
||||
assertThat(result.pendingPackages()).isZero();
|
||||
assertThat(result.palletCode()).isEqualTo("PAL-PHYSICAL-1");
|
||||
assertThat(result.palletCode()).isEqualTo("DP-260909-0001");
|
||||
verify(workflow).pickWholePallet(org.mockito.ArgumentMatchers.eq("100"),
|
||||
org.mockito.ArgumentMatchers.eq("PLAN-1"), org.mockito.ArgumentMatchers.eq(30L),
|
||||
any(com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests.WholePalletOperation.class));
|
||||
@@ -940,6 +981,28 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
return value;
|
||||
}
|
||||
|
||||
private InboundPlanPallet stubReservedFbaPallet() {
|
||||
InboundPlanPallet value = planPallet();
|
||||
value.setPalletNo("PAL-1"); value.setWarehousePalletId(30L); value.setWarehouseLocationId(40L);
|
||||
value.setWarehouseResourceLockId(50L); value.setAssignmentStatus("RESERVED"); value.setVersion(0);
|
||||
when(pallets.selectList(any())).thenReturn(List.of(value));
|
||||
when(warehouse.resolveReservedPallet("PAL-1", "100", 100L, 30L, 40L, 50L))
|
||||
.thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
|
||||
30L, "PAL-1", 100L, 40L, "OCCUPIED", "FBA-1", 0));
|
||||
return value;
|
||||
}
|
||||
|
||||
private void stubCompleteManifest(String sourcePalletNo, int packages) {
|
||||
DropshipOrderProfile profile = new DropshipOrderProfile();
|
||||
profile.setId(60L); profile.setCustomerId("100"); profile.setSourcePalletNo(sourcePalletNo);
|
||||
profile.setManifestVersion(1); profile.setManifestPackageCount(packages);
|
||||
profile.setManifestFragmentCount(1); profile.setManifestHash("manifest-hash");
|
||||
when(dropshipProfiles.listContainerPallet("CONT-A", sourcePalletNo)).thenReturn(List.of(profile));
|
||||
List<DropshipCarton> values = new java.util.ArrayList<>();
|
||||
for (int index = 0; index < packages; index++) values.add(new DropshipCarton());
|
||||
when(dropshipCartons.listProfile("100", 60L)).thenReturn(values);
|
||||
}
|
||||
|
||||
private static WarehouseResourceGateway.WarehousePalletReference resource(long warehouseId, String fbaCode) {
|
||||
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L,
|
||||
"AVAILABLE", fbaCode, 0);
|
||||
|
||||
+1
-1
@@ -304,7 +304,7 @@ class InventoryCommandServiceTest {
|
||||
position.setPickedPackages(5L);
|
||||
when(commands.lockPosition("100", 201L)).thenReturn(position);
|
||||
when(commands.lockBalance("100", 200L)).thenReturn(balance);
|
||||
when(commands.palletPackages("100", 300L)).thenReturn(0L);
|
||||
when(commands.palletPackages(300L)).thenReturn(0L);
|
||||
|
||||
var result = service.ship("100", "7", new InventoryCommandRequests.Move(
|
||||
201L, 5, "OUT-1", "OUT-1", null, "ship-1", null));
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ class Phase5OverseasUpgradeSqlContractTest {
|
||||
assertThat(overseas.indexOf(table)).as(table + " first reference")
|
||||
.isEqualTo(overseas.indexOf(create) + "CREATE TABLE IF NOT EXISTS ".length());
|
||||
}
|
||||
assertThat(overseas).contains("<> 24", "五期完整业务表集合未部署");
|
||||
assertThat(overseas).contains("<> 23", "五期完整业务表集合未部署");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+5
-3
@@ -26,7 +26,7 @@ class LoadingCompletedConsumerTest {
|
||||
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
|
||||
}
|
||||
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
|
||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||
@Test void freezesReceivingWarehouseInInitialInTransitInventory(){
|
||||
service.consume(event());
|
||||
ArgumentCaptor<InventoryCommandRequests.InTransit> captured=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class);
|
||||
@@ -160,6 +160,7 @@ class LoadingCompletedConsumerTest {
|
||||
secondSku.put("sourceInboundDetailId","d-2");
|
||||
var marks=secondSku.putArray("cartonMarks"); for(int n=6;n<=10;n++)marks.add("RK1#10-"+n);
|
||||
skus.add(secondSku);
|
||||
addManifest(order.path("pallets").get(0),10);
|
||||
service.consume(rehash(original,node));
|
||||
var captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class);
|
||||
verify(cartons,times(10)).insert(captured.capture());
|
||||
@@ -193,7 +194,7 @@ class LoadingCompletedConsumerTest {
|
||||
sourceOrder.put("orderType","ONE_CLICK_DROPSHIP").put("destinationType","OVERSEAS");
|
||||
var sku=json.createObjectNode().put("sourceInboundDetailId","d-1").put("skuCode","SKU-1").put("productName","产品").put("packageCount",5).put("productQuantity",50);
|
||||
var marks=sku.putArray("cartonMarks"); for(int n=1;n<=5;n++)marks.add("RK1#5-"+n);
|
||||
var pallet=json.createObjectNode().put("palletNo","DP-260909-0143"); pallet.putArray("skuPackages").add(sku);
|
||||
var pallet=json.createObjectNode().put("palletNo","DP-260909-0143"); pallet.putArray("skuPackages").add(sku); addManifest(pallet,5);
|
||||
sourceOrder.putArray("pallets").add(pallet);
|
||||
if(duplicateSku) ((com.fasterxml.jackson.databind.node.ArrayNode)sourceOrder.path("items")).add(sourceOrder.path("items").get(0).deepCopy());
|
||||
node.putArray("orders").add(sourceOrder);
|
||||
@@ -202,5 +203,6 @@ class LoadingCompletedConsumerTest {
|
||||
}
|
||||
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
|
||||
private JsonNode v2Payload(EventEnvelope source){ com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); node.set("sourceContainerBookingId",node.remove("containerBookingId")); return node; }
|
||||
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
|
||||
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); for(JsonNode order:node.path("orders")) for(JsonNode pallet:order.path("pallets")) addManifest(pallet,order.path("packageCount").asInt()); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
|
||||
private void addManifest(JsonNode pallet,int packageCount){ var object=(com.fasterxml.jackson.databind.node.ObjectNode)pallet; object.put("manifestVersion",1).put("manifestPackageCount",packageCount).put("manifestFragmentCount",1).put("manifestHash","a".repeat(64)); }
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
- 所有客户查询在 Repository/Mapper 显式带 `tenant_id`;状态变更保留幂等、历史、审计和并发控制。
|
||||
- `inbound_code` 是跨客户全局唯一业务码;生成模式由 `InboundCodeModeProvider` 控制,序列与唯一约束不得退化为客户内唯一。修改格式、长度或生成时机时同步检查 Order SQL、Firstleg 入库快照、箱唛箱码、前端展示和全局唯一性契约测试。
|
||||
- 下单和后续关键状态操作不得信任客户端仓库名称、类型或启用状态;公共仓库必须通过 Warehouse 内部接口按 ID 实时校验。私人地址不得伪装为公共目的仓,非私人目的地必须引用类型匹配的公共仓库;Warehouse 不可用时失败关闭,不回退到旧快照或模拟数据。
|
||||
- 客户提交订单时除 IAM 审核资格外,还必须通过 Finance 资金账户校验;实际余额小于 0 时拒绝提交并提示充值。草稿保存不触发资金门禁,Finance 不可用或响应无效时提交失败关闭。
|
||||
- Warehouse 往来单位、Firstleg 入库协作只通过客户端 DTO/内部令牌;修改契约需同步消费者。
|
||||
- 智能装柜待审核候选只返回 Order 权威 `SUBMITTED` 订单,并按服务身份、客户/仓库/渠道、入仓码、创建日期、预计到货日期和分页条件查询;Firstleg 可据此提前占位,但不得把该内部查询或申报数量当作审核、测量或可物理装柜完成的事实。
|
||||
- 中文批量模板按登录客户生成取货/私人地址、国内收货仓、目的仓和渠道下拉;仓库目录必须翻页读取全部候选,不得只取第一页。导入时重新读取并校验动态目录、客户归属和启用状态,不信任模板隐藏页;文件上限 5MB、有效数据最多 50 行,仅创建草稿,`PARTIAL` 允许单行失败、`ATOMIC` 任一行失败不得落单。
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.order.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@FeignClient(name = "finance-service", contextId = "orderFinanceWalletClient",
|
||||
url = "${baiyue.clients.finance.url:}", path = "/internal/finance/v1/wallet")
|
||||
public interface FinanceWalletClient {
|
||||
@GetMapping("/accounts/{customerId}")
|
||||
ApiResponse<WalletAccount> account(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@PathVariable("customerId") long customerId);
|
||||
|
||||
record WalletAccount(BigDecimal actualBalance, BigDecimal availableBalance,
|
||||
BigDecimal creditLimit, BigDecimal availableCredit,
|
||||
LocalDate creditExpiresOn, boolean creditActive) { }
|
||||
}
|
||||
@@ -3,14 +3,18 @@ package com.baiyue.order.service;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.TenantEligibilityClient;
|
||||
import com.baiyue.order.client.FinanceWalletClient;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Service
|
||||
public class OrderEligibilityGuard {
|
||||
@Resource private TenantEligibilityClient client;
|
||||
@Resource private FinanceWalletClient financeWalletClient;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
public void requireApproved(long tenantId) {
|
||||
@@ -32,6 +36,7 @@ public class OrderEligibilityGuard {
|
||||
throw new BizException(ResultCode.FORBIDDEN,
|
||||
response.data().reason() == null ? "Enterprise review approval is required" : response.data().reason());
|
||||
}
|
||||
requireNonNegativeActualBalance(tenantId);
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM eligibility service is unavailable");
|
||||
} catch (BizException exception) {
|
||||
@@ -41,4 +46,23 @@ public class OrderEligibilityGuard {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 下单资格响应无效");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireNonNegativeActualBalance(long tenantId) {
|
||||
try {
|
||||
var response = financeWalletClient.account(internalToken, tenantId);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| response.data().actualBalance() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Unable to verify customer account balance");
|
||||
}
|
||||
if (response.data().actualBalance().compareTo(BigDecimal.ZERO) < 0) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "账户实际余额为负,请充值后再下单");
|
||||
}
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Finance account service is unavailable");
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Finance account response is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,15 @@ package com.baiyue.order.service;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.FinanceWalletClient;
|
||||
import com.baiyue.order.client.TenantEligibilityClient;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -48,4 +52,46 @@ class OrderEligibilityGuardTest {
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void negativeActualBalanceBlocksNewOrderSubmission() {
|
||||
TenantEligibilityClient client = approvedClient();
|
||||
FinanceWalletClient finance = mock(FinanceWalletClient.class);
|
||||
when(finance.account("internal-token", 10001L)).thenReturn(ApiResponse.success(
|
||||
new FinanceWalletClient.WalletAccount(new BigDecimal("-0.01"), new BigDecimal("99.99"),
|
||||
new BigDecimal("100.00"), new BigDecimal("99.99"), LocalDate.now().plusDays(7), true)));
|
||||
OrderEligibilityGuard guard = guard(client, finance);
|
||||
|
||||
assertThatThrownBy(() -> guard.requireApproved(10001L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("账户实际余额为负,请充值后再下单")
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroActualBalanceAllowsOrderWhileCreditIsActive() {
|
||||
TenantEligibilityClient client = approvedClient();
|
||||
FinanceWalletClient finance = mock(FinanceWalletClient.class);
|
||||
when(finance.account("internal-token", 10001L)).thenReturn(ApiResponse.success(
|
||||
new FinanceWalletClient.WalletAccount(BigDecimal.ZERO, new BigDecimal("100.00"),
|
||||
new BigDecimal("100.00"), new BigDecimal("100.00"), LocalDate.now().plusDays(7), true)));
|
||||
|
||||
guard(client, finance).requireApproved(10001L);
|
||||
}
|
||||
|
||||
private static TenantEligibilityClient approvedClient() {
|
||||
TenantEligibilityClient client = mock(TenantEligibilityClient.class);
|
||||
when(client.check("internal-token", 10001L)).thenReturn(ApiResponse.success(
|
||||
new TenantEligibilityClient.EligibilityResponse(true, "ACTIVE", null)));
|
||||
return client;
|
||||
}
|
||||
|
||||
private static OrderEligibilityGuard guard(TenantEligibilityClient client, FinanceWalletClient finance) {
|
||||
OrderEligibilityGuard guard = new OrderEligibilityGuard();
|
||||
ReflectionTestUtils.setField(guard, "client", client);
|
||||
ReflectionTestUtils.setField(guard, "financeWalletClient", finance);
|
||||
ReflectionTestUtils.setField(guard, "internalToken", "internal-token");
|
||||
return guard;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () =>
|
||||
assert.match(sql, /193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00',[\s\S]*?'纽约仓海外接柜入仓收货能力'/)
|
||||
assert.match(sql, /固定平台作业仓收货能力写入校验失败/)
|
||||
|
||||
const palletSeed = sql.match(/INSERT INTO wh_pallet \([\s\S]*?remark = VALUES\(remark\);/)?.[0]
|
||||
const palletSeed = sql.match(/INSERT INTO wh_pallet \([\s\S]*?deleted = 0;/)?.[0]
|
||||
assert.ok(palletSeed, 'missing pallet initialization seed')
|
||||
assert.doesNotMatch(palletSeed, /\bstatus\b|'AVAILABLE'/)
|
||||
})
|
||||
|
||||
@@ -273,7 +273,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
|
||||
actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '实际入托体积(立方米)',
|
||||
actual_package_count 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)
|
||||
) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托';
|
||||
@@ -1265,10 +1265,16 @@ PREPARE inventory_carton_qty_020 FROM @ddl; EXECUTE inventory_carton_qty_020; DE
|
||||
|
||||
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 NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
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(customer_id,order_id), UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key)
|
||||
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),
|
||||
KEY idx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash)
|
||||
) ENGINE=InnoDB COMMENT='一件代发托盘档案';
|
||||
CREATE TABLE IF NOT EXISTS ll_dropship_carton (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
|
||||
@@ -3928,7 +3934,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='柜级入库计划批量托盘明细与资源锁关联';
|
||||
USE baiyue_lastleg;
|
||||
|
||||
+7
@@ -21,11 +21,18 @@ public interface WarehouseResourceLockMapper extends BaseMapper<WarehouseResourc
|
||||
@Select("SELECT * FROM wh_resource_lock WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
|
||||
WarehouseResourceLockEntity lockById(@Param("customerId") long customerId, @Param("id") long id);
|
||||
|
||||
@Select("SELECT * FROM wh_resource_lock WHERE id=#{id} LIMIT 1 FOR UPDATE")
|
||||
WarehouseResourceLockEntity lockAnyById(@Param("id") long id);
|
||||
|
||||
@Select("SELECT * FROM wh_resource_lock WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " +
|
||||
"AND status='ACTIVE' ORDER BY locked_at DESC LIMIT 1")
|
||||
WarehouseResourceLockEntity findActiveByPallet(@Param("customerId") long customerId,
|
||||
@Param("palletId") long palletId);
|
||||
|
||||
@Select("SELECT * FROM wh_resource_lock WHERE pallet_id=#{palletId} AND status='ACTIVE' " +
|
||||
"ORDER BY locked_at DESC LIMIT 1")
|
||||
WarehouseResourceLockEntity findAnyActiveByPallet(@Param("palletId") long palletId);
|
||||
|
||||
@Update("""
|
||||
UPDATE wh_pallet
|
||||
SET status = 'OCCUPIED', location_id = #{locationId}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
|
||||
+6
@@ -26,10 +26,16 @@ public class WarehouseResourceLockRepository {
|
||||
return locks.lockById(customerId, lockId);
|
||||
}
|
||||
|
||||
public WarehouseResourceLockEntity findAny(long lockId) { return locks.lockAnyById(lockId); }
|
||||
|
||||
public WarehouseResourceLockEntity findActiveByPallet(long customerId, long palletId) {
|
||||
return locks.findActiveByPallet(customerId, palletId);
|
||||
}
|
||||
|
||||
public WarehouseResourceLockEntity findAnyActiveByPallet(long palletId) {
|
||||
return locks.findAnyActiveByPallet(palletId);
|
||||
}
|
||||
|
||||
public WarehousePalletEntity findPallet(long customerId, long palletId) {
|
||||
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
|
||||
.eq(WarehousePalletEntity::getCustomerId, PUBLIC_RESOURCE_SCOPE)
|
||||
|
||||
+46
-17
@@ -32,6 +32,8 @@ import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -81,16 +83,24 @@ public class WarehousePalletBatchService {
|
||||
}
|
||||
|
||||
List<WarehousePalletBatchItemEntity> created = new ArrayList<>();
|
||||
Map<String, SharedReservation> sharedDropship = new LinkedHashMap<>();
|
||||
for (WarehousePalletBatchRequests.Item requested : normalized.items()) {
|
||||
Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
||||
normalized.warehouseId(), new PalletRequests.SaveRequest(requested.palletCode(), "WOOD",
|
||||
AUTO_PALLET_CAPACITY_CBM, AUTO_PALLET_UNIT_PRICE_USD, "IDLE", null, AUTO_PALLET_REMARK,
|
||||
requested.fbaWarehouseCode()));
|
||||
WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
||||
normalized.warehouseId(), palletId);
|
||||
WarehouseResourceLockResponse lock = locks.reserve(new WarehouseResourceLockRequests.ReserveRequest(
|
||||
requested.customerId(), normalized.warehouseId(), pallet.getId(), pallet.getLocationId(),
|
||||
BUSINESS_TYPE, requested.businessKey(), reservationKey(batch, requested.sequenceNo())));
|
||||
SharedReservation shared = isDropshipPallet(requested.palletCode())
|
||||
? sharedDropship.get(requested.palletCode()) : null;
|
||||
if (shared == null) {
|
||||
Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
||||
normalized.warehouseId(), new PalletRequests.SaveRequest(requested.palletCode(), "WOOD",
|
||||
AUTO_PALLET_CAPACITY_CBM, AUTO_PALLET_UNIT_PRICE_USD, "IDLE", null, AUTO_PALLET_REMARK,
|
||||
requested.fbaWarehouseCode()));
|
||||
WarehousePalletEntity createdPallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
||||
normalized.warehouseId(), palletId);
|
||||
WarehouseResourceLockResponse lock = locks.reserve(new WarehouseResourceLockRequests.ReserveRequest(
|
||||
requested.customerId(), normalized.warehouseId(), createdPallet.getId(), createdPallet.getLocationId(),
|
||||
BUSINESS_TYPE, requested.businessKey(), reservationKey(batch, requested.sequenceNo())));
|
||||
shared = new SharedReservation(createdPallet, lock.lockId());
|
||||
if (isDropshipPallet(requested.palletCode())) sharedDropship.put(requested.palletCode(), shared);
|
||||
}
|
||||
WarehousePalletEntity pallet = shared.pallet();
|
||||
|
||||
WarehousePalletBatchItemEntity item = new WarehousePalletBatchItemEntity();
|
||||
item.setId(IdWorker.getId());
|
||||
@@ -103,7 +113,7 @@ public class WarehousePalletBatchService {
|
||||
item.setPalletId(pallet.getId());
|
||||
item.setPalletCode(pallet.getPalletCode());
|
||||
item.setLocationId(pallet.getLocationId());
|
||||
item.setResourceLockId(lock.lockId());
|
||||
item.setResourceLockId(shared.lockId());
|
||||
item.setStatus("RESERVED");
|
||||
item.setCreatedAt(LocalDateTime.now());
|
||||
item.setUpdatedAt(item.getCreatedAt());
|
||||
@@ -127,13 +137,14 @@ public class WarehousePalletBatchService {
|
||||
throw conflict("Only a RESERVED pallet batch can be confirmed");
|
||||
}
|
||||
for (WarehousePalletBatchItemEntity item : batchItems) {
|
||||
var lock = lockRecords.find(item.getCustomerId(), item.getResourceLockId());
|
||||
var lock = isDropshipPallet(item.getPalletCode())
|
||||
? lockRecords.findAny(item.getResourceLockId())
|
||||
: lockRecords.find(item.getCustomerId(), item.getResourceLockId());
|
||||
if (lock == null || !"ACTIVE".equals(lock.getStatus())
|
||||
|| !Objects.equals(lock.getWarehouseId(), batch.getWarehouseId())
|
||||
|| !Objects.equals(lock.getPalletId(), item.getPalletId())
|
||||
|| !Objects.equals(lock.getLocationId(), item.getLocationId())
|
||||
|| !BUSINESS_TYPE.equals(lock.getBusinessType())
|
||||
|| !item.getBusinessKey().equals(lock.getBusinessId())) {
|
||||
|| !BUSINESS_TYPE.equals(lock.getBusinessType())) {
|
||||
throw conflict("Inbound plan pallet reservation is no longer active");
|
||||
}
|
||||
WarehousePalletBindingEntity binding = repository.lockPalletBinding(item.getCustomerId(),
|
||||
@@ -177,13 +188,22 @@ public class WarehousePalletBatchService {
|
||||
if (!"RESERVED".equals(batch.getStatus())) {
|
||||
throw conflict("Confirmed or subsequently occupied pallets cannot be compensated");
|
||||
}
|
||||
Set<Long> releasedLocks = new HashSet<>();
|
||||
for (WarehousePalletBatchItemEntity item : batchItems) {
|
||||
WarehousePalletBindingEntity binding = repository.lockPalletBinding(item.getCustomerId(),
|
||||
batch.getWarehouseId(), BUSINESS_TYPE, item.getBusinessKey());
|
||||
if (binding != null && Objects.equals(binding.getPalletId(), item.getPalletId())) {
|
||||
throw conflict("A bound inbound plan pallet cannot be compensated");
|
||||
}
|
||||
locks.release(item.getCustomerId(), item.getResourceLockId());
|
||||
if (releasedLocks.add(item.getResourceLockId())) {
|
||||
long lockCustomerId = item.getCustomerId();
|
||||
if (isDropshipPallet(item.getPalletCode())) {
|
||||
var lock = lockRecords.findAny(item.getResourceLockId());
|
||||
if (lock == null) throw conflict("Inbound plan pallet resource lock was not found");
|
||||
lockCustomerId = lock.getCustomerId();
|
||||
}
|
||||
locks.release(lockCustomerId, item.getResourceLockId());
|
||||
}
|
||||
item.setStatus("RELEASED");
|
||||
item.setUpdatedAt(LocalDateTime.now());
|
||||
items.updateById(item);
|
||||
@@ -208,12 +228,17 @@ public class WarehousePalletBatchService {
|
||||
throw conflict("Inbound plan pallet business key became bound after compensation");
|
||||
}
|
||||
}
|
||||
Map<Long, WarehouseResourceLockResponse> reactivated = new LinkedHashMap<>();
|
||||
for (WarehousePalletBatchItemEntity item : batchItems) {
|
||||
WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
||||
batch.getWarehouseId(), item.getPalletId());
|
||||
WarehouseResourceLockResponse lock = locks.reserve(new WarehouseResourceLockRequests.ReserveRequest(
|
||||
item.getCustomerId(), batch.getWarehouseId(), pallet.getId(), item.getLocationId(), BUSINESS_TYPE,
|
||||
item.getBusinessKey(), reservationKey(batch.getId(), attempt, item.getSequenceNo())));
|
||||
WarehouseResourceLockResponse lock = reactivated.get(pallet.getId());
|
||||
if (lock == null) {
|
||||
lock = locks.reserve(new WarehouseResourceLockRequests.ReserveRequest(
|
||||
item.getCustomerId(), batch.getWarehouseId(), pallet.getId(), item.getLocationId(), BUSINESS_TYPE,
|
||||
item.getBusinessKey(), reservationKey(batch.getId(), attempt, item.getSequenceNo())));
|
||||
reactivated.put(pallet.getId(), lock);
|
||||
}
|
||||
item.setResourceLockId(lock.lockId());
|
||||
item.setStatus("RESERVED");
|
||||
item.setUpdatedAt(LocalDateTime.now());
|
||||
@@ -341,6 +366,10 @@ public class WarehousePalletBatchService {
|
||||
|
||||
private static int number(Integer value) { return value == null ? 0 : value; }
|
||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||
private static boolean isDropshipPallet(String palletCode) {
|
||||
return palletCode != null && palletCode.trim().toUpperCase(java.util.Locale.ROOT).startsWith("DP");
|
||||
}
|
||||
private record SharedReservation(WarehousePalletEntity pallet, long lockId) { }
|
||||
private static String required(String value, String message) {
|
||||
String normalized = trim(value);
|
||||
if (normalized == null) throw badRequest(message);
|
||||
|
||||
+5
-1
@@ -106,8 +106,12 @@ public class WarehouseResourceLockService {
|
||||
throw new BizException(ResultCode.CONFLICT, "Only an OCCUPIED pallet can be scrapped after shipment");
|
||||
}
|
||||
WarehouseResourceLockEntity lock = repository.findActiveByPallet(customerId, palletId);
|
||||
if (lock == null && pallet.getPalletCode() != null
|
||||
&& pallet.getPalletCode().toUpperCase(java.util.Locale.ROOT).startsWith("DP-")) {
|
||||
lock = repository.findAnyActiveByPallet(palletId);
|
||||
}
|
||||
if (lock != null) {
|
||||
scrap(customerId, lock.getId());
|
||||
scrap(lock.getCustomerId(), lock.getId());
|
||||
} else if (repository.scrapPalletWithoutLock(customerId, palletId) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Pallet state changed concurrently");
|
||||
}
|
||||
|
||||
@@ -106,7 +106,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 (
|
||||
@@ -162,7 +162,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='柜级入库计划批量托盘明细与资源锁关联';
|
||||
|
||||
|
||||
+3
-1
@@ -21,7 +21,9 @@ class WarehousePalletBatchContractTest {
|
||||
.contains("confirm_command_hash CHAR(64)")
|
||||
.contains("compensate_command_hash CHAR(64)")
|
||||
.contains("business_key VARCHAR(100)")
|
||||
.contains("pallet_code VARCHAR(64)");
|
||||
.contains("pallet_code VARCHAR(64)")
|
||||
.contains("KEY idx_wh_pallet_batch_item_pallet (pallet_id)")
|
||||
.doesNotContain("UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id)");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
@@ -86,6 +86,26 @@ class WarehousePalletBatchServiceTest {
|
||||
.containsExactly("container:plan-a:1", "container:plan-b:2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneDropshipPalletSharedByMultipleCustomersUsesOnePhysicalResourceAndLock() {
|
||||
when(batches.insertIgnore(any())).thenReturn(1);
|
||||
when(resources.createPallet(anyLong(), anyLong(), any())).thenReturn(301L);
|
||||
when(resources.pallet(0L, WAREHOUSE, 301L)).thenReturn(pallet(301L, 401L, "DP-260910-0001"));
|
||||
when(locks.reserve(any())).thenReturn(lockResponse(CUSTOMER_A, 501L));
|
||||
var request = new WarehousePalletBatchRequests.EnsureReserved(WAREHOUSE, "dropship-container", List.of(
|
||||
new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:dropship-a:1", "PLAN-A", 1,
|
||||
"STANDARD", null, "DP-260910-0001"),
|
||||
new WarehousePalletBatchRequests.Item(CUSTOMER_B, "container:dropship-b:2", "PLAN-B", 2,
|
||||
"STANDARD", null, "DP-260910-0001")));
|
||||
|
||||
var response = service.ensureReserved(request);
|
||||
|
||||
assertThat(response.items()).extracting(value -> value.palletId()).containsOnly(301L);
|
||||
assertThat(response.items()).extracting(value -> value.resourceLockId()).containsOnly(501L);
|
||||
verify(resources, times(1)).createPallet(anyLong(), anyLong(), any());
|
||||
verify(locks, times(1)).reserve(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSourcePalletCodeUsesWarehouseGeneratedPhysicalCode() {
|
||||
when(batches.insertIgnore(any())).thenReturn(1);
|
||||
|
||||
+23
@@ -219,6 +219,29 @@ class WarehouseResourceLockServiceTest {
|
||||
verify(repository).scrapPalletAtVirtualLocation(lock);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedDropshipPalletIsScrappedThroughItsActualLockOwner() {
|
||||
WarehousePalletEntity pallet = pallet("OCCUPIED");
|
||||
pallet.setPalletCode("DP-260910-0001");
|
||||
pallet.setWarehouseId(WAREHOUSE);
|
||||
pallet.setLocationId(LOCATION);
|
||||
WarehouseResourceLockEntity lock = activeLock();
|
||||
lock.setCustomerId(TENANT + 1);
|
||||
when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet);
|
||||
when(repository.findAnyActiveByPallet(PALLET)).thenReturn(lock);
|
||||
when(repository.find(TENANT + 1, lock.getId())).thenReturn(lock);
|
||||
WarehouseLocationEntity virtual = location("ACTIVE");
|
||||
virtual.setLocationType("VIRTUAL");
|
||||
when(repository.findLocation(TENANT + 1, WAREHOUSE, LOCATION)).thenReturn(virtual);
|
||||
when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1);
|
||||
when(repository.close(lock, "SCRAPPED")).thenReturn(1);
|
||||
|
||||
assertThat(service.scrapByPallet(TENANT, PALLET).status()).isEqualTo("SCRAPPED");
|
||||
|
||||
verify(repository).findAnyActiveByPallet(PALLET);
|
||||
verify(repository).find(TENANT + 1, lock.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scrapByPalletIsIdempotentWhenAlreadyScrapped() {
|
||||
WarehousePalletEntity pallet = pallet("SCRAPPED");
|
||||
|
||||
Reference in New Issue
Block a user