feat(finance): 完成财务控制、L1 钱包硬化与迁移收敛

- P0/P1:影子总账追加调整、关账/退款/资金/积分幂等、成本版本追溯、对账订单项维度
- 迁移收敛:原 103–114 合并为 103_finance_foundation、104_finance_controls、105_work_order_consignment
- 数据:L1 钱包 BIGINT/CHECK/奖励幂等;重复奖励审计口径修正与人工核对清单
- 报表:销售毛利缺失成本收入提示,旧收支页明确现金流口径
- 文档:同步迁移基线、合并评估、代码复核与上线说明
This commit is contained in:
yml2213
2026-10-27 16:08:16 +08:00
parent a53321ef30
commit 9d5af359fa
47 changed files with 4527 additions and 1410 deletions
@@ -1,3 +1,169 @@
-- 103_finance_foundation.sql
-- 2026-10-27 迁移收敛:由以下补丁迁移合并,按原顺序执行,全部语句保持幂等。
-- ===========================================================================
-- 103_product_catalog.sql —— 商品目录与商品映射基础
-- ===========================================================================
-- 103_product_catalog.sql —— 标准商品主数据 + 渠道 SKU 映射。
--
-- 目标:把「平台原始 SKU / 商品名」归一到内部标准商品,后续成本、销量、
-- 店铺日报和订单毛利都只认 canonical_product_id。
--
-- 设计要点(2026-10-23 重写):
-- 1. 商品表不存「履约载体」。履约方式只由 order_items.fulfillment_mode 决定,
-- 商品的来源由 canonical_sku 前缀表达(aff: 自动发货 / tpl: 接单模板 /
-- wo: 历史工单证据 / man: 人工),避免两处口径互相打架。
-- 2. 映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧。
-- 3. normalized_quantity 不在这里预填:预填会把「5 个」类 SKU 永久锁死成 1,
-- 必须由回填脚本按 SKU 名称解析后写入。
--
-- 约束:
-- 1. canonical_sku 是内部稳定编码,创建后不随平台改名;
-- 2. shop_id 为空串表示该来源的全局映射,具体店铺映射优先;
-- 3. 任何一层匹配出现多个候选都不自动选择,交由冲突清单人工处理。
CREATE TABLE IF NOT EXISTS product_catalog (
id BIGSERIAL PRIMARY KEY,
canonical_sku TEXT NOT NULL,
canonical_name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT '',
unit_name TEXT NOT NULL DEFAULT '件',
status TEXT NOT NULL DEFAULT 'active',
attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_product_catalog_canonical_sku
ON product_catalog (canonical_sku);
CREATE INDEX IF NOT EXISTS idx_product_catalog_status_category
ON product_catalog (status, category, canonical_name);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_status') THEN
ALTER TABLE product_catalog
ADD CONSTRAINT chk_product_catalog_status
CHECK (status IN ('active', 'archived'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_sku') THEN
ALTER TABLE product_catalog
ADD CONSTRAINT chk_product_catalog_sku
CHECK (length(trim(canonical_sku)) > 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_name') THEN
ALTER TABLE product_catalog
ADD CONSTRAINT chk_product_catalog_name
CHECK (length(trim(canonical_name)) > 0);
END IF;
END $$;
COMMENT ON TABLE product_catalog IS '标准商品/皮肤主数据:报表、成本和销量统计的唯一商品维度';
COMMENT ON COLUMN product_catalog.canonical_sku IS
'内部稳定编码;前缀表达来源:aff: 自动发货、tpl: 接单模板、wo: 工单证据、man: 人工';
COMMENT ON COLUMN product_catalog.unit_name IS '计量单位:件/个/颗等,配合 normalized_quantity 使用';
CREATE TABLE IF NOT EXISTS channel_product_mappings (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL,
shop_id TEXT NOT NULL DEFAULT '',
platform_sku_code TEXT NOT NULL DEFAULT '',
platform_sku_name TEXT NOT NULL DEFAULT '',
platform_sku_name_key TEXT NOT NULL DEFAULT '',
canonical_product_id BIGINT NOT NULL REFERENCES product_catalog(id) ON DELETE RESTRICT,
match_type TEXT NOT NULL DEFAULT 'sku_code',
quantity_multiplier INTEGER NOT NULL DEFAULT 1,
confidence TEXT NOT NULL DEFAULT 'manual',
source TEXT NOT NULL DEFAULT 'manual',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_product_mappings_match_key
ON channel_product_mappings (
provider,
shop_id,
platform_sku_code,
match_type,
platform_sku_name_key
);
CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_product
ON channel_product_mappings (canonical_product_id, enabled);
CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_lookup
ON channel_product_mappings (
provider,
shop_id,
platform_sku_code,
match_type,
platform_sku_name_key,
enabled
);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_confidence') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_confidence
CHECK (confidence IN ('exact', 'rule', 'manual'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_source') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_source
CHECK (source IN ('manual', 'import', 'rule'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_multiplier') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_multiplier
CHECK (quantity_multiplier > 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_type') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_match_type
CHECK (match_type IN ('sku_code', 'sku_code_name', 'sku_code_series', 'sku_name_series'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_key') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_match_key
CHECK (platform_sku_code <> '' OR platform_sku_name_key <> '');
END IF;
END $$;
COMMENT ON TABLE channel_product_mappings IS '来源 + 店铺 + 平台 SKU -> 标准商品映射';
COMMENT ON COLUMN channel_product_mappings.shop_id IS '空串=该来源的全局映射;具体店铺映射优先';
COMMENT ON COLUMN channel_product_mappings.match_type IS
'匹配方式:sku_code / sku_code_name / sku_code_series / sku_name_series';
COMMENT ON COLUMN channel_product_mappings.platform_sku_name_key IS
'归一后的 SKU 名称或数量系列键;sku_code 模式为空串';
COMMENT ON COLUMN channel_product_mappings.quantity_multiplier IS 'SKU 名称内含数量时的换算系数,例如「5个」=5';
ALTER TABLE order_items
ADD COLUMN IF NOT EXISTS canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS normalized_quantity INTEGER,
ADD COLUMN IF NOT EXISTS normalized_sku_name TEXT,
ADD COLUMN IF NOT EXISTS normalized_sku_series TEXT;
CREATE INDEX IF NOT EXISTS idx_order_items_canonical_product
ON order_items (canonical_product_id);
CREATE INDEX IF NOT EXISTS idx_order_items_normalized_sku_lookup
ON order_items (normalized_sku_name, normalized_sku_series);
COMMENT ON COLUMN order_items.canonical_product_id IS '归一到标准商品;由渠道映射或人工指定';
COMMENT ON COLUMN order_items.normalized_quantity IS
'归一化件数:从 SKU 名称解析并固化,禁止直接用 quantity 做销量;未解析时保持 NULL';
COMMENT ON COLUMN order_items.normalized_sku_name IS
'NFKC、小写、去空白后的 SKU 名称,用于编码 + 名称消歧';
COMMENT ON COLUMN order_items.normalized_sku_series IS
'剥离数量并归一同义字后的 SKU 名称系列,用于数量系列匹配';
-- ===========================================================================
-- 104_cost_center.sql —— 成本中心、成本项、成本版本与快照
-- ===========================================================================
-- 104_cost_center.sql —— 成本项、成本版本、成本快照、成本异常与订单项收入固化。
--
-- 目标:所有成本金额只在 cost_versions 维护;订单项/工单/人工单只保存快照行,
@@ -477,3 +643,219 @@ WHERE ci.id = v.cost_item_id
INSERT INTO admin_role_permissions (role_key, permission_key)
VALUES ('finance', 'finance.config')
ON CONFLICT (role_key, permission_key) DO NOTHING;
-- ===========================================================================
-- 105_sales_daily_facts.sql —— 销售事实与订单项收入口径
-- ===========================================================================
-- 105_sales_daily_facts.sql —— 统一履约事实 + 店铺 × 日期 × 标准商品 × 履约方式的销售事实。
--
-- 目标:订单页、成本快照、销售事实只读 order_items.fulfillment_mode,
-- 不再各自用 provider / voucher / task 临时推断。
--
-- 设计要点(2026-10-23 重写):
-- 1. 履约方式只有一个真源,就是 order_items.fulfillment_mode;
-- 2. 商品表、成本项表、快照表都不再存载体字段,避免四处口径分叉;
-- 3. 核销码只是状态,不参与履约判定;
-- 4. 字段允许短暂为 NULL(订单项创建时通常还没有工单/任务),
-- 由统一决策服务 + 定时兜底在 24 小时内补齐。
--
-- 销售事实口径:
-- * 销售日 = orders.paid_at 的 Asia/Shanghai 日期;没有 paid_at 时退回 created_at;
-- * 收入 = order_items.revenue_amount_fen(迁移 104 固化),不再直接拿整单金额;
-- * 履约方式 = order_items.fulfillment_mode,四种:work_order / affiliate_dash /
-- legacy_platform / manual;未判定的订单项先归 manual 并由定时任务补齐;
-- * 未归一商品允许 canonical_product_id 为空,单独成「未归一」行,保证总收入对平;
-- * 成本来自 cost_snapshots 当前有效行;没有快照的订单项计入 cost_missing_count;
-- * 退款金额来自 order_refunds(迁移 106),本表只保存冲减后的净额。
--
-- 2026-10-25 合并:原 107(履约方式列与索引)并入本迁移。履约方式是销售事实的
-- 维度键,两者必须同一批次上线,单独成文件只会让口径分散在两处。
-- ---------------------------------------------------------------------------
-- 1. 履约方式:唯一真源
-- ---------------------------------------------------------------------------
ALTER TABLE order_items
ADD COLUMN IF NOT EXISTS fulfillment_mode TEXT,
ADD COLUMN IF NOT EXISTS fulfillment_decided_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS fulfillment_reason TEXT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_fulfillment_mode'
) THEN
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_fulfillment_mode
CHECK (
fulfillment_mode IS NULL
OR fulfillment_mode IN (
'work_order', 'affiliate_dash', 'legacy_platform', 'manual'
)
);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_mode
ON order_items (fulfillment_mode, order_id);
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_pending
ON order_items (order_id)
WHERE fulfillment_mode IS NULL;
-- 按履约方式筛异常时走半连接,这条索引让 order_items 侧只扫目标履约方式的行。
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_id
ON order_items (fulfillment_mode, id);
COMMENT ON COLUMN order_items.fulfillment_mode IS
'履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台(已下线)/ manual 人工;唯一真源,报表与后台只读此字段';
COMMENT ON COLUMN order_items.fulfillment_decided_at IS
'履约方式最后一次判定时间';
COMMENT ON COLUMN order_items.fulfillment_reason IS
'履约方式判定来源:订单同步 / 定时归集 / 历史回填等';
-- 本迁移只做 DDL;历史行回填由 scripts/backfill-fulfillment-mode.ts 分批执行。
-- ---------------------------------------------------------------------------
-- 2. 销售事实
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sales_daily_facts (
id BIGSERIAL PRIMARY KEY,
stat_date DATE NOT NULL,
provider TEXT NOT NULL,
shop_id TEXT NOT NULL DEFAULT '',
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
fulfillment_mode TEXT NOT NULL,
order_count INTEGER NOT NULL DEFAULT 0,
item_count INTEGER NOT NULL DEFAULT 0,
quantity NUMERIC(14, 3) NOT NULL DEFAULT 0,
gross_revenue_fen BIGINT NOT NULL DEFAULT 0,
refund_amount_fen BIGINT NOT NULL DEFAULT 0,
net_revenue_fen BIGINT NOT NULL DEFAULT 0,
cost_amount_fen BIGINT NOT NULL DEFAULT 0,
cost_missing_count INTEGER NOT NULL DEFAULT 0,
cost_stage TEXT NOT NULL DEFAULT 'missing',
gross_profit_fen BIGINT NOT NULL DEFAULT 0,
computed_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
-- canonical_product_id 为空时唯一索引仍要生效,因此用 COALESCE(..., 0) 作为「未归一」桶。
CREATE UNIQUE INDEX IF NOT EXISTS uq_sales_daily_facts_key
ON sales_daily_facts (
stat_date,
provider,
shop_id,
COALESCE(canonical_product_id, 0),
fulfillment_mode
);
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_date
ON sales_daily_facts (stat_date DESC);
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_shop
ON sales_daily_facts (provider, shop_id, stat_date DESC);
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_product
ON sales_daily_facts (canonical_product_id, stat_date DESC);
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_mode
ON sales_daily_facts (fulfillment_mode, stat_date DESC);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_fulfillment_mode'
) THEN
ALTER TABLE sales_daily_facts
ADD CONSTRAINT chk_sales_daily_facts_fulfillment_mode
CHECK (fulfillment_mode IN (
'work_order', 'affiliate_dash', 'legacy_platform', 'manual'
));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_cost_stage'
) THEN
ALTER TABLE sales_daily_facts
ADD CONSTRAINT chk_sales_daily_facts_cost_stage
CHECK (cost_stage IN ('missing', 'partial', 'estimated', 'actual'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_amounts'
) THEN
ALTER TABLE sales_daily_facts
ADD CONSTRAINT chk_sales_daily_facts_amounts
CHECK (
order_count >= 0
AND item_count >= 0
AND quantity >= 0
AND gross_revenue_fen >= 0
AND refund_amount_fen >= 0
AND net_revenue_fen >= 0
AND cost_amount_fen >= 0
AND cost_missing_count >= 0
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_profit'
) THEN
ALTER TABLE sales_daily_facts
ADD CONSTRAINT chk_sales_daily_facts_profit
CHECK (gross_profit_fen = net_revenue_fen - cost_amount_fen);
END IF;
END $$;
COMMENT ON TABLE sales_daily_facts IS
'店铺×日期(Asia/Shanghai)×标准商品×履约方式的销售与成本事实;报表直接读这张表';
COMMENT ON COLUMN sales_daily_facts.canonical_product_id IS
'标准商品;为空表示渠道映射未命中,报表单独展示「未归一」占比';
COMMENT ON COLUMN sales_daily_facts.fulfillment_mode IS
'履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台 / manual 人工';
COMMENT ON COLUMN sales_daily_facts.cost_stage IS
'missing=全部无成本快照;partial=部分无快照;estimated=全部预估;actual=全部实际';
COMMENT ON COLUMN sales_daily_facts.cost_missing_count IS
'没有当前有效成本快照的订单项数;>0 时该行毛利不可直接用于决策';
-- ===========================================================================
-- 106_order_refunds.sql —— 退款单与退款外部单号
-- ===========================================================================
-- 106_order_refunds.sql —— 订单退款冲减(手动录入先行,后续接平台退款 API)。
--
-- 一条记录 = 一个订单项的退款金额;整单退款会按订单项拆成多条,用 batch_no 归组。
-- 退款写入后重算当天销售事实,净收入与毛利立即冲减(见 services/finance/sales-facts-service.ts)。
--
-- 2026-10-25 合并:原 112 迁移。cost_exceptions.resolution_note 列已随迁移 104
-- 落到异常表定义里,这里只保留退款主体。
CREATE TABLE IF NOT EXISTS order_refunds (
id bigserial PRIMARY KEY,
refund_no text NOT NULL UNIQUE,
batch_no text NOT NULL DEFAULT '',
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
order_item_id bigint NOT NULL REFERENCES order_items(id) ON DELETE CASCADE,
amount_fen bigint NOT NULL CHECK (amount_fen > 0),
reason text NOT NULL DEFAULT '',
note text NOT NULL DEFAULT '',
source text NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'api', 'platform', 'legacy')),
status text NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'voided')),
refunded_at timestamp with time zone NOT NULL,
external_ref text NOT NULL DEFAULT '',
operator_admin_user_id bigint REFERENCES admin_users(id) ON DELETE SET NULL,
voided_at timestamp with time zone,
void_reason text NOT NULL DEFAULT '',
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_order_refunds_order
ON order_refunds (order_id, status);
CREATE INDEX IF NOT EXISTS idx_order_refunds_item
ON order_refunds (order_item_id, status);
CREATE INDEX IF NOT EXISTS idx_order_refunds_time
ON order_refunds (refunded_at DESC);
COMMENT ON TABLE order_refunds IS '订单退款记录:手动录入先行,后续平台退款 API 写 source=api/platform';
COMMENT ON COLUMN order_refunds.batch_no IS '同一次操作的分组号;整单退款会按订单项拆成多条';
COMMENT ON COLUMN order_refunds.source IS 'manual=后台手工登记;api/platform=平台退款接口;legacy=历史数据导入';
@@ -1,152 +0,0 @@
-- 103_product_catalog.sql —— 标准商品主数据 + 渠道 SKU 映射。
--
-- 目标:把「平台原始 SKU / 商品名」归一到内部标准商品,后续成本、销量、
-- 店铺日报和订单毛利都只认 canonical_product_id。
--
-- 设计要点(2026-10-23 重写):
-- 1. 商品表不存「履约载体」。履约方式只由 order_items.fulfillment_mode 决定,
-- 商品的来源由 canonical_sku 前缀表达(aff: 自动发货 / tpl: 接单模板 /
-- wo: 历史工单证据 / man: 人工),避免两处口径互相打架。
-- 2. 映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧。
-- 3. normalized_quantity 不在这里预填:预填会把「5 个」类 SKU 永久锁死成 1,
-- 必须由回填脚本按 SKU 名称解析后写入。
--
-- 约束:
-- 1. canonical_sku 是内部稳定编码,创建后不随平台改名;
-- 2. shop_id 为空串表示该来源的全局映射,具体店铺映射优先;
-- 3. 任何一层匹配出现多个候选都不自动选择,交由冲突清单人工处理。
CREATE TABLE IF NOT EXISTS product_catalog (
id BIGSERIAL PRIMARY KEY,
canonical_sku TEXT NOT NULL,
canonical_name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT '',
unit_name TEXT NOT NULL DEFAULT '件',
status TEXT NOT NULL DEFAULT 'active',
attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_product_catalog_canonical_sku
ON product_catalog (canonical_sku);
CREATE INDEX IF NOT EXISTS idx_product_catalog_status_category
ON product_catalog (status, category, canonical_name);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_status') THEN
ALTER TABLE product_catalog
ADD CONSTRAINT chk_product_catalog_status
CHECK (status IN ('active', 'archived'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_sku') THEN
ALTER TABLE product_catalog
ADD CONSTRAINT chk_product_catalog_sku
CHECK (length(trim(canonical_sku)) > 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_product_catalog_name') THEN
ALTER TABLE product_catalog
ADD CONSTRAINT chk_product_catalog_name
CHECK (length(trim(canonical_name)) > 0);
END IF;
END $$;
COMMENT ON TABLE product_catalog IS '标准商品/皮肤主数据:报表、成本和销量统计的唯一商品维度';
COMMENT ON COLUMN product_catalog.canonical_sku IS
'内部稳定编码;前缀表达来源:aff: 自动发货、tpl: 接单模板、wo: 工单证据、man: 人工';
COMMENT ON COLUMN product_catalog.unit_name IS '计量单位:件/个/颗等,配合 normalized_quantity 使用';
CREATE TABLE IF NOT EXISTS channel_product_mappings (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL,
shop_id TEXT NOT NULL DEFAULT '',
platform_sku_code TEXT NOT NULL DEFAULT '',
platform_sku_name TEXT NOT NULL DEFAULT '',
platform_sku_name_key TEXT NOT NULL DEFAULT '',
canonical_product_id BIGINT NOT NULL REFERENCES product_catalog(id) ON DELETE RESTRICT,
match_type TEXT NOT NULL DEFAULT 'sku_code',
quantity_multiplier INTEGER NOT NULL DEFAULT 1,
confidence TEXT NOT NULL DEFAULT 'manual',
source TEXT NOT NULL DEFAULT 'manual',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_product_mappings_match_key
ON channel_product_mappings (
provider,
shop_id,
platform_sku_code,
match_type,
platform_sku_name_key
);
CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_product
ON channel_product_mappings (canonical_product_id, enabled);
CREATE INDEX IF NOT EXISTS idx_channel_product_mappings_lookup
ON channel_product_mappings (
provider,
shop_id,
platform_sku_code,
match_type,
platform_sku_name_key,
enabled
);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_confidence') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_confidence
CHECK (confidence IN ('exact', 'rule', 'manual'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_source') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_source
CHECK (source IN ('manual', 'import', 'rule'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_multiplier') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_multiplier
CHECK (quantity_multiplier > 0);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_type') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_match_type
CHECK (match_type IN ('sku_code', 'sku_code_name', 'sku_code_series', 'sku_name_series'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_channel_product_mappings_match_key') THEN
ALTER TABLE channel_product_mappings
ADD CONSTRAINT chk_channel_product_mappings_match_key
CHECK (platform_sku_code <> '' OR platform_sku_name_key <> '');
END IF;
END $$;
COMMENT ON TABLE channel_product_mappings IS '来源 + 店铺 + 平台 SKU -> 标准商品映射';
COMMENT ON COLUMN channel_product_mappings.shop_id IS '空串=该来源的全局映射;具体店铺映射优先';
COMMENT ON COLUMN channel_product_mappings.match_type IS
'匹配方式:sku_code / sku_code_name / sku_code_series / sku_name_series';
COMMENT ON COLUMN channel_product_mappings.platform_sku_name_key IS
'归一后的 SKU 名称或数量系列键;sku_code 模式为空串';
COMMENT ON COLUMN channel_product_mappings.quantity_multiplier IS 'SKU 名称内含数量时的换算系数,例如「5个」=5';
ALTER TABLE order_items
ADD COLUMN IF NOT EXISTS canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS normalized_quantity INTEGER,
ADD COLUMN IF NOT EXISTS normalized_sku_name TEXT,
ADD COLUMN IF NOT EXISTS normalized_sku_series TEXT;
CREATE INDEX IF NOT EXISTS idx_order_items_canonical_product
ON order_items (canonical_product_id);
CREATE INDEX IF NOT EXISTS idx_order_items_normalized_sku_lookup
ON order_items (normalized_sku_name, normalized_sku_series);
COMMENT ON COLUMN order_items.canonical_product_id IS '归一到标准商品;由渠道映射或人工指定';
COMMENT ON COLUMN order_items.normalized_quantity IS
'归一化件数:从 SKU 名称解析并固化,禁止直接用 quantity 做销量;未解析时保持 NULL';
COMMENT ON COLUMN order_items.normalized_sku_name IS
'NFKC、小写、去空白后的 SKU 名称,用于编码 + 名称消歧';
COMMENT ON COLUMN order_items.normalized_sku_series IS
'剥离数量并归一同义字后的 SKU 名称系列,用于数量系列匹配';
File diff suppressed because it is too large Load Diff
@@ -1,166 +0,0 @@
-- 105_sales_daily_facts.sql —— 统一履约事实 + 店铺 × 日期 × 标准商品 × 履约方式的销售事实。
--
-- 目标:订单页、成本快照、销售事实只读 order_items.fulfillment_mode,
-- 不再各自用 provider / voucher / task 临时推断。
--
-- 设计要点(2026-10-23 重写):
-- 1. 履约方式只有一个真源,就是 order_items.fulfillment_mode;
-- 2. 商品表、成本项表、快照表都不再存载体字段,避免四处口径分叉;
-- 3. 核销码只是状态,不参与履约判定;
-- 4. 字段允许短暂为 NULL(订单项创建时通常还没有工单/任务),
-- 由统一决策服务 + 定时兜底在 24 小时内补齐。
--
-- 销售事实口径:
-- * 销售日 = orders.paid_at 的 Asia/Shanghai 日期;没有 paid_at 时退回 created_at;
-- * 收入 = order_items.revenue_amount_fen(迁移 104 固化),不再直接拿整单金额;
-- * 履约方式 = order_items.fulfillment_mode,四种:work_order / affiliate_dash /
-- legacy_platform / manual;未判定的订单项先归 manual 并由定时任务补齐;
-- * 未归一商品允许 canonical_product_id 为空,单独成「未归一」行,保证总收入对平;
-- * 成本来自 cost_snapshots 当前有效行;没有快照的订单项计入 cost_missing_count;
-- * 退款金额来自 order_refunds(迁移 106),本表只保存冲减后的净额。
--
-- 2026-10-25 合并:原 107(履约方式列与索引)并入本迁移。履约方式是销售事实的
-- 维度键,两者必须同一批次上线,单独成文件只会让口径分散在两处。
-- ---------------------------------------------------------------------------
-- 1. 履约方式:唯一真源
-- ---------------------------------------------------------------------------
ALTER TABLE order_items
ADD COLUMN IF NOT EXISTS fulfillment_mode TEXT,
ADD COLUMN IF NOT EXISTS fulfillment_decided_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS fulfillment_reason TEXT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_fulfillment_mode'
) THEN
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_fulfillment_mode
CHECK (
fulfillment_mode IS NULL
OR fulfillment_mode IN (
'work_order', 'affiliate_dash', 'legacy_platform', 'manual'
)
);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_mode
ON order_items (fulfillment_mode, order_id);
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_pending
ON order_items (order_id)
WHERE fulfillment_mode IS NULL;
-- 按履约方式筛异常时走半连接,这条索引让 order_items 侧只扫目标履约方式的行。
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_id
ON order_items (fulfillment_mode, id);
COMMENT ON COLUMN order_items.fulfillment_mode IS
'履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台(已下线)/ manual 人工;唯一真源,报表与后台只读此字段';
COMMENT ON COLUMN order_items.fulfillment_decided_at IS
'履约方式最后一次判定时间';
COMMENT ON COLUMN order_items.fulfillment_reason IS
'履约方式判定来源:订单同步 / 定时归集 / 历史回填等';
-- 本迁移只做 DDL;历史行回填由 scripts/backfill-fulfillment-mode.ts 分批执行。
-- ---------------------------------------------------------------------------
-- 2. 销售事实
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sales_daily_facts (
id BIGSERIAL PRIMARY KEY,
stat_date DATE NOT NULL,
provider TEXT NOT NULL,
shop_id TEXT NOT NULL DEFAULT '',
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE RESTRICT,
fulfillment_mode TEXT NOT NULL,
order_count INTEGER NOT NULL DEFAULT 0,
item_count INTEGER NOT NULL DEFAULT 0,
quantity NUMERIC(14, 3) NOT NULL DEFAULT 0,
gross_revenue_fen BIGINT NOT NULL DEFAULT 0,
refund_amount_fen BIGINT NOT NULL DEFAULT 0,
net_revenue_fen BIGINT NOT NULL DEFAULT 0,
cost_amount_fen BIGINT NOT NULL DEFAULT 0,
cost_missing_count INTEGER NOT NULL DEFAULT 0,
cost_stage TEXT NOT NULL DEFAULT 'missing',
gross_profit_fen BIGINT NOT NULL DEFAULT 0,
computed_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
-- canonical_product_id 为空时唯一索引仍要生效,因此用 COALESCE(..., 0) 作为「未归一」桶。
CREATE UNIQUE INDEX IF NOT EXISTS uq_sales_daily_facts_key
ON sales_daily_facts (
stat_date,
provider,
shop_id,
COALESCE(canonical_product_id, 0),
fulfillment_mode
);
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_date
ON sales_daily_facts (stat_date DESC);
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_shop
ON sales_daily_facts (provider, shop_id, stat_date DESC);
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_product
ON sales_daily_facts (canonical_product_id, stat_date DESC);
CREATE INDEX IF NOT EXISTS idx_sales_daily_facts_mode
ON sales_daily_facts (fulfillment_mode, stat_date DESC);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_fulfillment_mode'
) THEN
ALTER TABLE sales_daily_facts
ADD CONSTRAINT chk_sales_daily_facts_fulfillment_mode
CHECK (fulfillment_mode IN (
'work_order', 'affiliate_dash', 'legacy_platform', 'manual'
));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_cost_stage'
) THEN
ALTER TABLE sales_daily_facts
ADD CONSTRAINT chk_sales_daily_facts_cost_stage
CHECK (cost_stage IN ('missing', 'partial', 'estimated', 'actual'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_amounts'
) THEN
ALTER TABLE sales_daily_facts
ADD CONSTRAINT chk_sales_daily_facts_amounts
CHECK (
order_count >= 0
AND item_count >= 0
AND quantity >= 0
AND gross_revenue_fen >= 0
AND refund_amount_fen >= 0
AND net_revenue_fen >= 0
AND cost_amount_fen >= 0
AND cost_missing_count >= 0
);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_profit'
) THEN
ALTER TABLE sales_daily_facts
ADD CONSTRAINT chk_sales_daily_facts_profit
CHECK (gross_profit_fen = net_revenue_fen - cost_amount_fen);
END IF;
END $$;
COMMENT ON TABLE sales_daily_facts IS
'店铺×日期(Asia/Shanghai)×标准商品×履约方式的销售与成本事实;报表直接读这张表';
COMMENT ON COLUMN sales_daily_facts.canonical_product_id IS
'标准商品;为空表示渠道映射未命中,报表单独展示「未归一」占比';
COMMENT ON COLUMN sales_daily_facts.fulfillment_mode IS
'履约方式:work_order 接单平台 / affiliate_dash 自动发货 / legacy_platform 历史平台 / manual 人工';
COMMENT ON COLUMN sales_daily_facts.cost_stage IS
'missing=全部无成本快照;partial=部分无快照;estimated=全部预估;actual=全部实际';
COMMENT ON COLUMN sales_daily_facts.cost_missing_count IS
'没有当前有效成本快照的订单项数;>0 时该行毛利不可直接用于决策';
@@ -1,3 +1,11 @@
-- 105_work_order_consignment.sql
-- 2026-10-27 迁移收敛:由以下补丁迁移合并,按原顺序执行,全部语句保持幂等。
-- ===========================================================================
-- 109_work_order_share_participation.sql —— 拼单多次参与与份额字段
-- ===========================================================================
-- 109_work_order_share_participation.sql
-- 工单拼单份额模型:多次参与 + 份额级问题标记 + 撤单原因/份数。
-- (由原 110/111/113/114 四个增量迁移合并重写,纯新增列/索引,不改历史结构。)
@@ -99,3 +107,91 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_work_order_shares_worker_request
CREATE INDEX IF NOT EXISTS idx_work_order_shares_problem_worker
ON work_order_shares (worker_id, work_order_id)
WHERE problem_marked_at IS NOT NULL;
-- ===========================================================================
-- 110_work_order_hall_and_shop_indexes.sql —— 大厅/店铺查询索引
-- ===========================================================================
-- 110_work_order_hall_and_shop_indexes.sql
-- 工单相关热查询的部分索引(由原 103/106 两个索引迁移合并重写)。
--
-- 两处查询都已经有明确的 SQL 口径,这里只补与口径完全一致的索引:
-- 1. 后台接单工单「按店铺筛选」:BTRIM(o.shop_name) = ANY($n::text[]),
-- 普通 btree 无法命中表达式,必须建同表达式索引;
-- 2. 抢单大厅候选集:FIFO 窗口之外还要无条件并入全部置顶单,
-- 置顶单通常只有个位数,用「open 且 pinned_at 非空」的部分索引即可。
-- 全部 IF NOT EXISTS,可安全重跑。
-- 后台「接单工单 → 店铺」筛选(可多选):表达式索引 + 部分索引省掉空店铺条目。
CREATE INDEX IF NOT EXISTS idx_orders_shop_name_trimmed
ON orders (BTRIM(shop_name))
WHERE BTRIM(shop_name) <> '';
COMMENT ON INDEX idx_orders_shop_name_trimmed IS
'支持后台接单工单按订单店铺(orders.shop_name)精确多选筛选';
-- 抢单大厅置顶候选集:只索引处于 open 且已置顶的工单 id,避免扫全表。
CREATE INDEX IF NOT EXISTS idx_work_orders_hall_pinned
ON work_orders (id)
WHERE status = 'open' AND pinned_at IS NOT NULL;
COMMENT ON INDEX idx_work_orders_hall_pinned IS
'抢单大厅置顶候选集:open 且 pinned_at 非空的工单 id';
-- ===========================================================================
-- 111_work_order_share_cancel_reason_comment.sql —— 撤份额原因注释
-- ===========================================================================
-- 111_work_order_share_cancel_reason_comment.sql
-- 「取消部分」把被撤销的份额标记为 admin_partial,校准 cancelled_reason 的枚举注释。
-- 纯注释变更,无结构/数据改动;重复执行无副作用。
COMMENT ON COLUMN work_order_shares.cancelled_reason IS
'取消来源:timeout/worker_request/admin/admin_partial/problem/settlement';
-- ===========================================================================
-- 112_consignment_contact_supporter.sql —— 寄卖对接客服名单
-- ===========================================================================
-- 112_consignment_contact_supporter.sql
-- 原临时编号 116;108–115 已收敛为 108–111,本补丁顺延为 112。
-- 寄卖「对接客服」独立名单:发布寄卖时必选,后台按人统计提成与快速联系。
-- 该名单不复用 admin_users,避免后台账号变动影响历史寄卖归属。
-- 全部语句幂等,可重复执行。
CREATE TABLE IF NOT EXISTS consignment_contact_supporters (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 打手端下拉只取启用项,按 sort_order、id 排序,正好走这条索引
CREATE INDEX IF NOT EXISTS idx_consignment_contact_supporters_order
ON consignment_contact_supporters (enabled, sort_order, id);
ALTER TABLE consignment_submissions
ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT
REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL;
ALTER TABLE consignment_inventories
ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT
REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_consignment_submissions_contact_supporter
ON consignment_submissions (contact_supporter_id);
CREATE INDEX IF NOT EXISTS idx_consignment_inventories_contact_supporter
ON consignment_inventories (contact_supporter_id);
-- 首次部署给一个可用默认项:打手端「必填 + 默认选第一个」不会因名单为空而无法发布。
-- 后台可直接改名/补充备注,不会重复插入。
INSERT INTO consignment_contact_supporters (name, note, sort_order)
SELECT '默认客服', '系统初始化,请在「配置 - 对接客服」中修改', 0
WHERE NOT EXISTS (SELECT 1 FROM consignment_contact_supporters);
@@ -1,38 +0,0 @@
-- 106_order_refunds.sql —— 订单退款冲减(手动录入先行,后续接平台退款 API)。
--
-- 一条记录 = 一个订单项的退款金额;整单退款会按订单项拆成多条,用 batch_no 归组。
-- 退款写入后重算当天销售事实,净收入与毛利立即冲减(见 services/finance/sales-facts-service.ts)。
--
-- 2026-10-25 合并:原 112 迁移。cost_exceptions.resolution_note 列已随迁移 104
-- 落到异常表定义里,这里只保留退款主体。
CREATE TABLE IF NOT EXISTS order_refunds (
id bigserial PRIMARY KEY,
refund_no text NOT NULL UNIQUE,
batch_no text NOT NULL DEFAULT '',
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
order_item_id bigint NOT NULL REFERENCES order_items(id) ON DELETE CASCADE,
amount_fen bigint NOT NULL CHECK (amount_fen > 0),
reason text NOT NULL DEFAULT '',
note text NOT NULL DEFAULT '',
source text NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'api', 'platform', 'legacy')),
status text NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'voided')),
refunded_at timestamp with time zone NOT NULL,
external_ref text NOT NULL DEFAULT '',
operator_admin_user_id bigint REFERENCES admin_users(id) ON DELETE SET NULL,
voided_at timestamp with time zone,
void_reason text NOT NULL DEFAULT '',
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_order_refunds_order
ON order_refunds (order_id, status);
CREATE INDEX IF NOT EXISTS idx_order_refunds_item
ON order_refunds (order_item_id, status);
CREATE INDEX IF NOT EXISTS idx_order_refunds_time
ON order_refunds (refunded_at DESC);
COMMENT ON TABLE order_refunds IS '订单退款记录:手动录入先行,后续平台退款 API 写 source=api/platform';
COMMENT ON COLUMN order_refunds.batch_no IS '同一次操作的分组号;整单退款会按订单项拆成多条';
COMMENT ON COLUMN order_refunds.source IS 'manual=后台手工登记;api/platform=平台退款接口;legacy=历史数据导入';
@@ -1,292 +0,0 @@
-- 107_manual_fulfillment.sql —— 客服人工履约:SKU 配置清单 + 三平台积分钱包 + 备用金编号。
--
-- 背景(2026-10-24 确认):
-- 客服人工履约有四个资金/额度钱包,每个客服各一个:
-- 南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金
-- 三个积分平台的积分**互不通用**,所以每个平台一套账户。
-- 换算率按平台不同,真源是履约档案 config_json.rechargeUnitPriceFen:
-- 南航 1 积分 = 1 分钱;乐玩 1 积分 = 1 分钱;ROCK 1 积分 = 1 元(100 分)。
--
-- 配置链路(无匹配引擎、无打分):
-- 1. 扫描「走到人工履约」的内部 SKU,登记到 manual_sku_registry;
-- 2. 运营在成本中心配置每个 SKU 的「履约渠道 + 消耗积分 + 成本模式」;
-- 3. 点「重新匹配」把配置回填到历史人工订单项;
-- 4. 客服确认履约时直接读配置,固定成本零输入。
--
-- 关键口径:
-- * 配置主键是**内部 SKU**(快手 skuNick / 拼多多 outSkuSn),不是商品标题;
-- * 同名 SKU 算同一个商品,配置全局唯一(跨渠道共用);
-- * 积分 0 = 成本不固定,由客服在确认履约时手填实际成本;
-- * 备用金现金不建账户行,走现有 fund_accounts / fund_entries。
--
-- 2026-10-25 合并:原 106(备用金账户编号可读化)并入本迁移。编号规则与人工履约
-- 共用同一套钱包语义,单独成文件会让「资金台账」散落在两个迁移里。
-- ---------------------------------------------------------------------------
-- 1. 人工 SKU 登记表:扫描到的「走到人工履约」的内部 SKU
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS manual_sku_registry (
id BIGSERIAL PRIMARY KEY,
sku_key TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
shop_count INTEGER NOT NULL DEFAULT 0,
order_count INTEGER NOT NULL DEFAULT 0,
item_count INTEGER NOT NULL DEFAULT 0,
revenue_fen BIGINT NOT NULL DEFAULT 0,
first_seen_date DATE,
last_seen_date DATE,
scan_from_date DATE,
scan_to_date DATE,
config_id BIGINT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_sku_registry_sku_key
ON manual_sku_registry (sku_key);
CREATE INDEX IF NOT EXISTS idx_manual_sku_registry_config
ON manual_sku_registry (config_id);
CREATE INDEX IF NOT EXISTS idx_manual_sku_registry_item_count
ON manual_sku_registry (item_count DESC);
COMMENT ON TABLE manual_sku_registry IS
'人工履约 SKU 登记表:扫描到的内部 SKU(快手 skuNick / 拼多多 outSkuSn),供运营集中配置';
COMMENT ON COLUMN manual_sku_registry.sku_key IS
'内部 SKU,配置主键;同名 SKU 视为同一商品,跨渠道共用一份配置';
COMMENT ON COLUMN manual_sku_registry.display_name IS
'商品名称,仅用于展示识别,不是配置键';
-- ---------------------------------------------------------------------------
-- 2. 人工履约配置:每个内部 SKU 的渠道、积分与成本模式
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS manual_fulfillment_configs (
id BIGSERIAL PRIMARY KEY,
sku_key TEXT NOT NULL,
canonical_product_id BIGINT REFERENCES product_catalog(id) ON DELETE SET NULL,
platform_key TEXT NOT NULL,
credit_quantity BIGINT NOT NULL DEFAULT 0,
cost_mode TEXT NOT NULL DEFAULT 'fixed',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_fulfillment_configs_sku_key
ON manual_fulfillment_configs (sku_key);
CREATE INDEX IF NOT EXISTS idx_manual_fulfillment_configs_platform
ON manual_fulfillment_configs (platform_key);
COMMENT ON TABLE manual_fulfillment_configs IS
'人工履约配置:内部 SKU → 履约渠道 + 消耗积分 + 成本模式';
COMMENT ON COLUMN manual_fulfillment_configs.credit_quantity IS
'每单消耗积分(整数)。换算率按平台配置;0 表示成本不固定,客服确认履约时手填';
COMMENT ON COLUMN manual_fulfillment_configs.cost_mode IS
'fixed=成本按积分固定(cost_fen = credit_quantity × 平台换算率);variable=成本不固定,客服确认履约时手填';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_fulfillment_configs_platform'
) THEN
ALTER TABLE manual_fulfillment_configs
ADD CONSTRAINT chk_manual_fulfillment_configs_platform
CHECK (platform_key IN ('nanhang', 'lewan', 'rock', 'fund_cash'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_fulfillment_configs_cost_mode'
) THEN
ALTER TABLE manual_fulfillment_configs
ADD CONSTRAINT chk_manual_fulfillment_configs_cost_mode
CHECK (cost_mode IN ('fixed', 'variable'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_fulfillment_configs_credit'
) THEN
ALTER TABLE manual_fulfillment_configs
ADD CONSTRAINT chk_manual_fulfillment_configs_credit
CHECK (credit_quantity >= 0);
END IF;
END $$;
-- ---------------------------------------------------------------------------
-- 3. 三平台积分钱包:每个客服在每个积分平台一个账户
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS platform_credit_accounts (
id BIGSERIAL PRIMARY KEY,
platform_key TEXT NOT NULL,
owner_admin_user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE RESTRICT,
balance_quantity BIGINT NOT NULL DEFAULT 0,
unit_name TEXT NOT NULL DEFAULT '积分',
recharge_unit_price_fen BIGINT NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_platform_credit_accounts_owner_platform
ON platform_credit_accounts (owner_admin_user_id, platform_key);
CREATE INDEX IF NOT EXISTS idx_platform_credit_accounts_platform_status
ON platform_credit_accounts (platform_key, status);
COMMENT ON TABLE platform_credit_accounts IS
'客服平台积分钱包:南航/乐玩/ROCK 三平台积分互不通用,每平台每客服一个账户;换算率按平台配置(南航/乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元)';
COMMENT ON COLUMN platform_credit_accounts.recharge_unit_price_fen IS
'积分充值单价(分):1 积分 = ? 分。按平台不同,默认取档案 config_json.rechargeUnitPriceFen';
CREATE TABLE IF NOT EXISTS platform_credit_entries (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES platform_credit_accounts(id) ON DELETE RESTRICT,
entry_type TEXT NOT NULL,
quantity_delta BIGINT NOT NULL,
balance_after BIGINT NOT NULL,
amount_fen BIGINT NOT NULL DEFAULT 0,
platform_ref TEXT NOT NULL DEFAULT '',
related_task_id BIGINT,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_platform_credit_entries_account_time
ON platform_credit_entries (account_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_platform_credit_entries_type_time
ON platform_credit_entries (entry_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_platform_credit_entries_task
ON platform_credit_entries (related_task_id)
WHERE related_task_id IS NOT NULL;
COMMENT ON TABLE platform_credit_entries IS
'平台积分流水:充值/消耗/调整/退回,只追加,不修改不删除';
COMMENT ON COLUMN platform_credit_entries.amount_fen IS
'对应人民币金额(分)= quantity_delta × recharge_unit_price_fen';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_accounts_platform'
) THEN
ALTER TABLE platform_credit_accounts
ADD CONSTRAINT chk_platform_credit_accounts_platform
CHECK (platform_key IN ('nanhang', 'lewan', 'rock'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_accounts_status'
) THEN
ALTER TABLE platform_credit_accounts
ADD CONSTRAINT chk_platform_credit_accounts_status
CHECK (status IN ('active', 'frozen', 'closed'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_accounts_balance'
) THEN
ALTER TABLE platform_credit_accounts
ADD CONSTRAINT chk_platform_credit_accounts_balance
CHECK (balance_quantity >= 0);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_entries_type'
) THEN
ALTER TABLE platform_credit_entries
ADD CONSTRAINT chk_platform_credit_entries_type
CHECK (entry_type IN ('grant', 'consume', 'adjust', 'return'));
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_platform_credit_entries_delta'
) THEN
ALTER TABLE platform_credit_entries
ADD CONSTRAINT chk_platform_credit_entries_delta
CHECK (quantity_delta <> 0);
END IF;
END $$;
-- ---------------------------------------------------------------------------
-- 4. 订单项挂配置:客服确认履约时直接读这里
-- ---------------------------------------------------------------------------
ALTER TABLE order_items
ADD COLUMN IF NOT EXISTS fulfillment_config_id BIGINT
REFERENCES manual_fulfillment_configs(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_order_items_fulfillment_config
ON order_items (fulfillment_config_id)
WHERE fulfillment_config_id IS NOT NULL;
-- 内部 SKU 表达式索引:人工 SKU 扫描与「重新匹配」回填都靠它。
-- 没有这个索引时两处都是全表扫(实测扫描 2.4s / 回填 5.4s),
-- 建索引后回填降到 1.7s。
CREATE INDEX IF NOT EXISTS idx_order_items_manual_internal_sku
ON order_items (COALESCE(NULLIF(spec_json->>'skuNick', ''), NULLIF(spec_json->>'outSkuSn', '')))
WHERE fulfillment_mode = 'manual';
COMMENT ON COLUMN order_items.fulfillment_config_id IS
'人工履约配置(人工渠道+消耗积分+成本模式);由「重新匹配」回填';
-- ---------------------------------------------------------------------------
-- 5. 四个客服钱包的履约档案
-- ---------------------------------------------------------------------------
-- 三个积分平台 + 备用金现金。executor_key 统一 manual_dispatch,
-- 靠 config_json.platformKey 区分平台;**不复用** kuaishou_ct_assisted /
-- kuaishou_feifei(那是已下线的历史自动发货通道)。
INSERT INTO fulfillment_profiles (
profile_key, name, executor_key, requires_claim, auto_dispatch,
inventory_strategy, config_json, created_at, updated_at
)
VALUES
('manual_nanhang', '南航平台人工', 'manual_dispatch', FALSE, FALSE, 'external_platform',
'{"platformKey":"nanhang","platformName":"南航平台","unitName":"积分","creditManaged":true,"cashAdapter":false,"rechargeUnitPriceFen":1}'::jsonb,
NOW(), NOW()),
('manual_lewan', '乐玩后台人工', 'manual_dispatch', FALSE, FALSE, 'external_platform',
'{"platformKey":"lewan","platformName":"乐玩后台","unitName":"积分","creditManaged":true,"cashAdapter":false,"rechargeUnitPriceFen":1}'::jsonb,
NOW(), NOW()),
('manual_rock', 'ROCK 后台人工', 'manual_dispatch', FALSE, FALSE, 'external_platform',
'{"platformKey":"rock","platformName":"ROCK 后台","unitName":"积分","creditManaged":true,"cashAdapter":false,"rechargeUnitPriceFen":100}'::jsonb,
NOW(), NOW()),
('manual_fund', '备用金现金人工', 'manual_dispatch', FALSE, FALSE, 'external_platform',
'{"platformKey":"fund_cash","platformName":"备用金现金","unitName":"元","creditManaged":false,"cashAdapter":true,"rechargeUnitPriceFen":100}'::jsonb,
NOW(), NOW())
ON CONFLICT (profile_key) DO UPDATE
SET name = EXCLUDED.name,
config_json = EXCLUDED.config_json,
updated_at = NOW();
-- ---------------------------------------------------------------------------
-- 6. 备用金账户编号可读化
-- ---------------------------------------------------------------------------
-- 历史编号是 FA + 10 位随机十六进制(如 FA7683D6B58B),在「备用金」列表里
-- 无法一眼对应到具体客服。新规则:FA-<大写用户名>,重名追加 -2、-3。
--
-- 幂等:编号由 admin_users.username 重新推导,与当前 account_no 无关,
-- 重复执行结果一致。
WITH base AS (
SELECT
fa.id,
'FA-' || COALESCE(
NULLIF(
trim(
both '_' FROM upper(
regexp_replace(COALESCE(NULLIF(au.username, ''), 'account-' || fa.id), '[^A-Za-z0-9_]+', '_', 'g')
)
),
''
),
'ACCOUNT-' || fa.id
) AS base_no
FROM fund_accounts fa
LEFT JOIN admin_users au ON au.id = fa.owner_admin_user_id
),
numbered AS (
SELECT
id,
base_no,
row_number() OVER (PARTITION BY base_no ORDER BY id) AS rn
FROM base
)
UPDATE fund_accounts fa
SET account_no = CASE WHEN n.rn = 1 THEN n.base_no ELSE n.base_no || '-' || n.rn END
FROM numbered n
WHERE fa.id = n.id;
@@ -1,301 +0,0 @@
-- 108_finance_cost_integrity.sql
-- 财务/成本完整性与影子总账(合并原 108/111/112/113/114)。
--
-- 合并原因:2026-10-26 之前这几块是分批补丁,生产基线 101 升级时会产生
-- 5 个只差索引/约束/注释的迁移,顺序和回滚都难维护。合并后:
-- 1. 成本异常归档表
-- 2. 人工成本订单项级唯一与 subject 语义
-- 3. 影子总账/期间/对账
-- 4. 商品页/模板预览查询索引
-- 全部语句保持幂等;新库从 101 升级与空库初始化同一路径。
-- ---------------------------------------------------------------------------
-- 成本异常归档(原 108_cost_exception_archive.sql)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS cost_exceptions_archive (
id BIGINT PRIMARY KEY,
subject_type TEXT NOT NULL,
subject_id BIGINT NOT NULL,
order_item_id BIGINT,
work_order_id BIGINT,
reason TEXT NOT NULL,
status TEXT NOT NULL,
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ,
resolved_by_admin_user_id BIGINT,
resolution_note TEXT NOT NULL DEFAULT '',
archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_archived_at
ON cost_exceptions_archive (archived_at DESC);
CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_subject
ON cost_exceptions_archive (subject_type, subject_id);
CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_reason
ON cost_exceptions_archive (reason, archived_at DESC);
COMMENT ON TABLE cost_exceptions_archive IS
'已处理成本异常的归档:data-retention 任务把超过保留期(默认 60 天)的 resolved/ignored 行搬到这里,open 行不归档';
COMMENT ON COLUMN cost_exceptions_archive.archived_at IS
'归档时间;原表 created_at / resolved_at 原样保留,便于按原时间追溯';
-- 热表上补一个「待归档」用的部分索引,避免每日任务扫全表。
CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archivable
ON cost_exceptions (COALESCE(resolved_at, created_at))
WHERE status <> 'open';
-- ---------------------------------------------------------------------------
-- 人工成本完整性(原 111_manual_cost_integrity.sql)
-- ---------------------------------------------------------------------------
UPDATE cost_snapshots
SET subject_id = order_item_id
WHERE subject_type = 'manual_case'
AND order_item_id IS NOT NULL
AND subject_id > 0
AND subject_id <> order_item_id;
-- 2. 同一订单项存在重复有效人工快照时,只保留最新一条(历史脏数据兜底)。
UPDATE cost_snapshots c
SET superseded_at = NOW()
WHERE c.subject_type = 'manual_case'
AND c.superseded_at IS NULL
AND c.subject_id > 0
AND EXISTS (
SELECT 1
FROM cost_snapshots newer
WHERE newer.subject_type = 'manual_case'
AND newer.order_item_id = c.order_item_id
AND newer.superseded_at IS NULL
AND newer.id > c.id
);
-- 3. 数据库级唯一:一个订单项只有一条有效人工成本快照。
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_manual_order_item
ON cost_snapshots (order_item_id)
WHERE subject_type = 'manual_case' AND superseded_at IS NULL AND subject_id > 0;
-- 4. 语义约束:新写入的 manual_case 必须 subject_id = order_item_id。
-- NOT VALID 只约束新写入,负数历史孤儿快照(迁移 104 约定)保持原样。
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_manual_subject'
) THEN
ALTER TABLE cost_snapshots
ADD CONSTRAINT chk_cost_snapshots_manual_subject
CHECK (subject_type <> 'manual_case' OR subject_id = order_item_id)
NOT VALID;
END IF;
END $$;
-- 5. 人工成本监控 / 对账按订单项反查备用金支出。
CREATE INDEX IF NOT EXISTS idx_manual_expenses_order_item
ON manual_expenses (order_item_id)
WHERE order_item_id IS NOT NULL;
-- 6. 人工 SKU 匹配键索引:spec_json.skuNick -> outSkuSn -> sku_code -> sku_name。
-- 旧索引只覆盖前两级,缺 SKU 键的人工单会走顺序扫描;这里替换为同一套兜底表达式。
DROP INDEX IF EXISTS idx_order_items_manual_internal_sku;
CREATE INDEX IF NOT EXISTS idx_order_items_manual_sku_key
ON order_items (
COALESCE(
NULLIF(spec_json->>'skuNick', ''),
NULLIF(spec_json->>'outSkuSn', ''),
NULLIF(sku_code, ''),
NULLIF(sku_name, '')
)
)
WHERE fulfillment_mode = 'manual';
COMMENT ON INDEX uq_cost_snapshots_manual_order_item IS
'人工成本完整性:一个订单项最多一条有效 manual_case 快照(并发重复确认的数据库兜底)';
-- ---------------------------------------------------------------------------
-- 影子总账(原 112_finance_ledger.sql)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS finance_accounts (
account_code TEXT PRIMARY KEY,
account_name TEXT NOT NULL,
account_type TEXT NOT NULL
CHECK (account_type IN ('asset', 'liability', 'equity', 'revenue', 'expense')),
normal_side TEXT NOT NULL CHECK (normal_side IN ('debit', 'credit')),
parent_code TEXT REFERENCES finance_accounts(account_code) ON DELETE RESTRICT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS finance_journal_entries (
id BIGSERIAL PRIMARY KEY,
entry_no TEXT NOT NULL UNIQUE,
event_type TEXT NOT NULL,
ref_type TEXT NOT NULL,
ref_id TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
period_key TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'posted' CHECK (status IN ('posted', 'reversed')),
memo TEXT NOT NULL DEFAULT '',
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE (event_type, ref_type, ref_id)
);
CREATE INDEX IF NOT EXISTS idx_finance_entries_period
ON finance_journal_entries (period_key, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_finance_entries_ref
ON finance_journal_entries (ref_type, ref_id);
CREATE TABLE IF NOT EXISTS finance_journal_lines (
id BIGSERIAL PRIMARY KEY,
entry_id BIGINT NOT NULL REFERENCES finance_journal_entries(id) ON DELETE CASCADE,
line_no INTEGER NOT NULL,
account_code TEXT NOT NULL REFERENCES finance_accounts(account_code) ON DELETE RESTRICT,
debit_fen BIGINT NOT NULL DEFAULT 0,
credit_fen BIGINT NOT NULL DEFAULT 0,
memo TEXT NOT NULL DEFAULT '',
dimensions_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
CHECK (debit_fen >= 0 AND credit_fen >= 0),
CHECK ((debit_fen > 0) <> (credit_fen > 0)),
UNIQUE (entry_id, line_no)
);
CREATE INDEX IF NOT EXISTS idx_finance_lines_account
ON finance_journal_lines (account_code);
CREATE TABLE IF NOT EXISTS finance_periods (
period_key TEXT PRIMARY KEY,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),
summary_json JSONB NOT NULL DEFAULT '{}'::jsonb,
closed_at TIMESTAMPTZ,
closed_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS finance_reconciliations (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL,
period_from DATE NOT NULL,
period_to DATE NOT NULL,
source TEXT NOT NULL DEFAULT 'manual_import',
status TEXT NOT NULL DEFAULT 'imported'
CHECK (status IN ('imported', 'matched', 'differences', 'resolved')),
expected_fen BIGINT NOT NULL DEFAULT 0,
actual_fen BIGINT NOT NULL DEFAULT 0,
diff_fen BIGINT NOT NULL DEFAULT 0,
summary_json JSONB NOT NULL DEFAULT '{}'::jsonb,
imported_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_finance_reconciliations_period
ON finance_reconciliations (provider, period_from DESC, id DESC);
CREATE TABLE IF NOT EXISTS finance_reconciliation_items (
id BIGSERIAL PRIMARY KEY,
reconciliation_id BIGINT NOT NULL
REFERENCES finance_reconciliations(id) ON DELETE CASCADE,
line_no INTEGER NOT NULL,
provider TEXT NOT NULL,
platform_order_id TEXT NOT NULL DEFAULT '',
external_ref TEXT NOT NULL DEFAULT '',
entry_type TEXT NOT NULL CHECK (entry_type IN ('sale', 'refund')),
amount_fen BIGINT NOT NULL,
occurred_at TIMESTAMPTZ,
matched_order_id BIGINT,
matched_refund_id BIGINT,
expected_fen BIGINT,
diff_fen BIGINT,
match_status TEXT NOT NULL
CHECK (match_status IN (
'matched', 'amount_mismatch', 'missing_order', 'missing_refund', 'duplicate'
)),
detail_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (reconciliation_id, line_no)
);
CREATE INDEX IF NOT EXISTS idx_finance_reconciliation_items_status
ON finance_reconciliation_items (reconciliation_id, match_status);
-- 科目表(可按业务裁剪;借贷方向先定死)
INSERT INTO finance_accounts (
account_code, account_name, account_type, normal_side, description, created_at, updated_at
)
VALUES
('1001', '银行存款', 'asset', 'debit', '公司银行/支付通道资金', NOW(), NOW()),
('1002', '客服备用金', 'asset', 'debit', '已拨付给客服的备用金', NOW(), NOW()),
('1101', '应收账款-平台', 'asset', 'debit', '平台待结算/待收款', NOW(), NOW()),
('1201', '预付账款-客服积分', 'asset', 'debit', '已充值到平台的客服积分', NOW(), NOW()),
('2001', '应付履约款', 'liability', 'credit', '应付打手/人工履约成本', NOW(), NOW()),
('2005', '其他应付-垫付人', 'liability', 'credit', '客服垫付待核销', NOW(), NOW()),
('3001', '投入资金/权益', 'equity', 'credit', '股东投入与期初权益', NOW(), NOW()),
('4001', '商品销售收入', 'revenue', 'credit', '按用户实付确认的收入', NOW(), NOW()),
('4101', '退款冲减', 'revenue', 'debit', '退款冲减收入(借方)', NOW(), NOW()),
('5001', '履约成本', 'expense', 'debit', '打手报酬/人工/自动发货成本', NOW(), NOW()),
('5101', '人工履约成本', 'expense', 'debit', '人工客服实际垫付成本', NOW(), NOW())
ON CONFLICT (account_code) DO UPDATE
SET account_name = EXCLUDED.account_name,
account_type = EXCLUDED.account_type,
normal_side = EXCLUDED.normal_side,
description = EXCLUDED.description,
updated_at = NOW();
COMMENT ON TABLE finance_accounts IS '会计科目表;影子总账使用,不接管业务钱包余额';
COMMENT ON TABLE finance_journal_entries IS '凭证头;event_type + ref_type + ref_id 保证业务幂等';
COMMENT ON TABLE finance_journal_lines IS '凭证行;每行只能借或贷,单张凭证借贷必须平衡';
COMMENT ON TABLE finance_periods IS '会计期间;closed 期间禁止新增凭证';
COMMENT ON TABLE finance_reconciliations IS '平台对账批次:导入平台账单后与订单/退款匹配';
COMMENT ON TABLE finance_reconciliation_items IS '对账明细:matched / amount_mismatch / missing_* / duplicate';
-- ---------------------------------------------------------------------------
-- 成本中心查询索引(原 113_cost_center_query_indexes.sql)
-- ---------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_order_items_unmapped_mode
ON order_items (fulfillment_mode)
WHERE canonical_product_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_affiliate_recent
ON fulfillment_tasks (created_at)
WHERE executor_key = 'affiliate_dash';
COMMENT ON INDEX idx_order_items_unmapped_mode IS
'成本中心总览:按履约方式统计未归一订单项,避免全表扫 order_items';
COMMENT ON INDEX idx_fulfillment_tasks_affiliate_recent IS
'自动发货任务 30 天聚合:只索引 affiliate_dash,减少商品列表聚合扫描';
-- 新索引建好后立刻更新统计信息,避免参数化查询在旧统计下继续选全表扫描。
ANALYZE order_items;
ANALYZE product_catalog;
ANALYZE cost_exceptions;
ANALYZE fulfillment_tasks;
-- ---------------------------------------------------------------------------
-- 模板证据索引(原 114_template_evidence_indexes.sql)
-- ---------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_orders_basis_date
ON orders (((COALESCE(paid_at, created_at) AT TIME ZONE 'Asia/Shanghai')::date));
CREATE INDEX IF NOT EXISTS idx_order_items_order_sku_cover
ON order_items (order_id) INCLUDE (id, sku_code, sku_name);
COMMENT ON INDEX idx_orders_basis_date IS
'模板初始化/时间筛选:按 Asia/Shanghai 订单日期过滤,避免全表扫 orders';
COMMENT ON INDEX idx_order_items_order_sku_cover IS
'模板初始化的 SKU 证据覆盖索引,避免回表取 sku_code/sku_name';
ANALYZE orders;
ANALYZE order_items;
@@ -1,25 +0,0 @@
-- 110_work_order_hall_and_shop_indexes.sql
-- 工单相关热查询的部分索引(由原 103/106 两个索引迁移合并重写)。
--
-- 两处查询都已经有明确的 SQL 口径,这里只补与口径完全一致的索引:
-- 1. 后台接单工单「按店铺筛选」:BTRIM(o.shop_name) = ANY($n::text[]),
-- 普通 btree 无法命中表达式,必须建同表达式索引;
-- 2. 抢单大厅候选集:FIFO 窗口之外还要无条件并入全部置顶单,
-- 置顶单通常只有个位数,用「open 且 pinned_at 非空」的部分索引即可。
-- 全部 IF NOT EXISTS,可安全重跑。
-- 后台「接单工单 → 店铺」筛选(可多选):表达式索引 + 部分索引省掉空店铺条目。
CREATE INDEX IF NOT EXISTS idx_orders_shop_name_trimmed
ON orders (BTRIM(shop_name))
WHERE BTRIM(shop_name) <> '';
COMMENT ON INDEX idx_orders_shop_name_trimmed IS
'支持后台接单工单按订单店铺(orders.shop_name)精确多选筛选';
-- 抢单大厅置顶候选集:只索引处于 open 且已置顶的工单 id,避免扫全表。
CREATE INDEX IF NOT EXISTS idx_work_orders_hall_pinned
ON work_orders (id)
WHERE status = 'open' AND pinned_at IS NOT NULL;
COMMENT ON INDEX idx_work_orders_hall_pinned IS
'抢单大厅置顶候选集:open 且 pinned_at 非空的工单 id';
@@ -1,6 +0,0 @@
-- 111_work_order_share_cancel_reason_comment.sql
-- 「取消部分」把被撤销的份额标记为 admin_partial,校准 cancelled_reason 的枚举注释。
-- 纯注释变更,无结构/数据改动;重复执行无副作用。
COMMENT ON COLUMN work_order_shares.cancelled_reason IS
'取消来源:timeout/worker_request/admin/admin_partial/problem/settlement';
@@ -1,39 +0,0 @@
-- 112_consignment_contact_supporter.sql
-- 原临时编号 116;108–115 已收敛为 108–111,本补丁顺延为 112。
-- 寄卖「对接客服」独立名单:发布寄卖时必选,后台按人统计提成与快速联系。
-- 该名单不复用 admin_users,避免后台账号变动影响历史寄卖归属。
-- 全部语句幂等,可重复执行。
CREATE TABLE IF NOT EXISTS consignment_contact_supporters (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 打手端下拉只取启用项,按 sort_order、id 排序,正好走这条索引
CREATE INDEX IF NOT EXISTS idx_consignment_contact_supporters_order
ON consignment_contact_supporters (enabled, sort_order, id);
ALTER TABLE consignment_submissions
ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT
REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL;
ALTER TABLE consignment_inventories
ADD COLUMN IF NOT EXISTS contact_supporter_id BIGINT
REFERENCES consignment_contact_supporters(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_consignment_submissions_contact_supporter
ON consignment_submissions (contact_supporter_id);
CREATE INDEX IF NOT EXISTS idx_consignment_inventories_contact_supporter
ON consignment_inventories (contact_supporter_id);
-- 首次部署给一个可用默认项:打手端「必填 + 默认选第一个」不会因名单为空而无法发布。
-- 后台可直接改名/补充备注,不会重复插入。
INSERT INTO consignment_contact_supporters (name, note, sort_order)
SELECT '默认客服', '系统初始化,请在「配置 - 对接客服」中修改', 0
WHERE NOT EXISTS (SELECT 1 FROM consignment_contact_supporters);
@@ -1444,7 +1444,7 @@ export function buildWorkOrderWhere({
if (normalizedShopNames.length > 0) {
// 店铺口径是「订单归属店铺」:工单只存 order_id,必须回查 orders.shop_name。
// 下拉选项本身就是真实值,这里做精确匹配,并用 BTRIM 兼容历史脏数据的前后空格
// (表达式索引见 110_work_order_hall_and_shop_indexes.sql)。
// (表达式索引见 105_work_order_consignment.sql)。
params.push(normalizedShopNames)
filters.push(`wo.order_id IN (
SELECT shop_order.id
+15 -11
View File
@@ -22,6 +22,7 @@ import {
resolveFinanceReconciliation,
} from '../../services/finance/finance-reconciliation-service.js'
import type { JsonObject } from '../../types/json.js'
import { formatShanghaiDate } from '../../utils/finance-date-range.js'
import { bodyOf, idOf, sessionOf } from './request-helpers.js'
import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js'
@@ -45,7 +46,7 @@ router.get(
createJsonHandler(
(req) => {
const query = req.query as Record<string, unknown>
const today = new Date().toISOString().slice(0, 10)
const today = formatShanghaiDate(new Date())
return getTrialBalance({
dateFrom: String(query.dateFrom || query.date_from || `${today.slice(0, 7)}-01`),
dateTo: String(query.dateTo || query.date_to || today),
@@ -71,11 +72,11 @@ router.get(
router.post(
'/finance/ledger/sync',
requireAdminPermission('finance.config'),
requireAdminPermission('finance.post'),
createJsonHandler(
(req) => {
const body = bodyOf(req)
const today = new Date().toISOString().slice(0, 10)
const today = formatShanghaiDate(new Date())
return syncFinanceLedgerFromFacts({
dateFrom: String(body.dateFrom || body.date_from || `${today.slice(0, 7)}-01`),
dateTo: String(body.dateTo || body.date_to || today),
@@ -97,7 +98,7 @@ router.post(
router.post(
'/finance/ledger/manual-voucher',
requireAdminPermission('finance.config'),
requireAdminPermission('finance.post'),
createJsonHandler((req) => postManualVoucher(bodyOf(req) as never, sessionOf(req)), {
successMessage: '凭证已创建',
errorMessage: '创建凭证失败',
@@ -123,13 +124,16 @@ router.get(
router.post(
'/finance/periods/:periodKey/close',
requireAdminPermission('finance.config'),
requireAdminPermission('finance.close'),
createJsonHandler(
(req) =>
closeFinancePeriod(
(req) => {
const body = bodyOf(req)
return closeFinancePeriod(
String((req.params as Record<string, string>).periodKey || ''),
sessionOf(req),
),
String(body.reason || ''),
)
},
{
successMessage: '期间已关账',
errorMessage: '关账失败',
@@ -146,7 +150,7 @@ router.post(
router.post(
'/finance/periods/:periodKey/reopen',
requireAdminPermission('finance.config'),
requireAdminPermission('finance.close'),
createJsonHandler(
(req) => reopenFinancePeriod(String((req.params as Record<string, string>).periodKey || '')),
{
@@ -175,7 +179,7 @@ router.get(
router.post(
'/finance/reconciliations/import',
requireAdminPermission('finance.config'),
requireAdminPermission('finance.reconcile'),
createJsonHandler((req) => importFinanceReconciliation(bodyOf(req), sessionOf(req)), {
successMessage: '账单已导入',
errorMessage: '导入账单失败',
@@ -201,7 +205,7 @@ router.get(
router.post(
'/finance/reconciliations/:id/resolve',
requireAdminPermission('finance.config'),
requireAdminPermission('finance.reconcile'),
createJsonHandler(
(req) => {
const body = bodyOf(req)
+3 -3
View File
@@ -68,7 +68,7 @@ router.get(
router.post(
'/finance/sales-facts/rebuild',
requireAdminRoles(['admin', 'operator', 'finance']),
requireAdminPermission('finance.config'),
requireAdminPermission('finance.post'),
createJsonHandler(
(req) =>
rebuildSalesDailyFacts(
@@ -106,7 +106,7 @@ router.post(
router.get(
'/finance/manual-fulfillment/pending',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminRoles(['admin', 'operator', 'support', 'finance']),
createJsonHandler(
(req) => listManualFulfillmentPending(req.query as Record<string, unknown>, sessionOf(req)),
{
@@ -119,7 +119,7 @@ router.get(
router.post(
'/finance/manual-fulfillment/confirm',
requireAdminRoles(['admin', 'operator', 'support']),
requireAdminRoles(['admin', 'operator', 'support', 'finance']),
createJsonHandler((req) => confirmManualFulfillment(bodyOf(req), sessionOf(req)), {
successMessage: '履约已确认',
errorMessage: '确认履约失败',
@@ -30,7 +30,7 @@ const router = Router()
router.post(
'/kuaishou-industry/orders/:orderId/refunds/approve-and-record',
requireAdminPermission('finance.config'),
requireAdminPermission('finance.post'),
createJsonHandler(
(req) => {
const body = req.body as Record<string, unknown>
+3 -3
View File
@@ -16,7 +16,7 @@ import type { AdminOrderRouteParams, AdminOrderRouteQuery } from '../../types/ad
const router = Router()
router.use('/orders', requireAdminRoles(['admin', 'operator', 'support']))
router.use('/orders', requireAdminRoles(['admin', 'operator', 'support', 'finance']))
router.get(
'/orders',
@@ -105,7 +105,7 @@ router.get(
router.post(
'/orders/:orderId/refunds',
requireAdminPermission('finance.config'),
requireAdminPermission('finance.post'),
createJsonHandler(
(req) =>
createManualRefund(
@@ -135,7 +135,7 @@ router.post(
router.post(
'/orders/:orderId/refunds/:refundId/void',
requireAdminPermission('finance.config'),
requireAdminPermission('finance.post'),
createJsonHandler(
(req) =>
voidOrderRefund(
@@ -263,6 +263,10 @@ function normalizePayeeType(value: unknown): string {
return normalized
}
function normalizeRequestId(value: unknown): string {
return text(value).slice(0, 80)
}
function normalizeReconciliationStatus(value: unknown): string {
const normalized = text(value)
if (!RECONCILIATION_STATUSES.has(normalized)) {
@@ -1215,34 +1219,46 @@ async function insertEntry(
evidence: JsonObject[]
note: string
createdAt: string
requestId?: string
},
) {
const result = await client.query<FundEntryRow>(
`
INSERT INTO fund_entries (
entry_no, account_id, entry_type, amount_fen, balance_after_fen,
transfer_no, counterparty_account_id, reconciliation_id, occurred_at,
created_by_admin_user_id, evidence_json, note, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13)
RETURNING *
`,
[
generateNo('FE'),
input.accountId,
input.entryType,
input.amountFen,
input.balanceAfterFen,
input.transferNo || '',
input.counterpartyAccountId || null,
input.reconciliationId || null,
input.occurredAt,
input.actorUserId,
JSON.stringify(input.evidence),
input.note,
input.createdAt,
],
)
return result.rows[0]
try {
const result = await client.query<FundEntryRow>(
`
INSERT INTO fund_entries (
entry_no, account_id, entry_type, amount_fen, balance_after_fen,
transfer_no, counterparty_account_id, reconciliation_id, occurred_at,
created_by_admin_user_id, evidence_json, note, created_at, request_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $14)
RETURNING *
`,
[
generateNo('FE'),
input.accountId,
input.entryType,
input.amountFen,
input.balanceAfterFen,
input.transferNo || '',
input.counterpartyAccountId || null,
input.reconciliationId || null,
input.occurredAt,
input.actorUserId,
JSON.stringify(input.evidence),
input.note,
input.createdAt,
input.requestId || '',
],
)
return result.rows[0]
} catch (error) {
if ((error as { code?: string }).code === '23505') {
throw createHttpError('该请求已处理,请勿重复提交', {
statusCode: 409,
errorCode: 'fund_request_duplicate',
})
}
throw error
}
}
export async function createAdminFundFunding(payload: JsonObject, session: AdminSession) {
@@ -1259,6 +1275,7 @@ export async function createAdminFundFunding(payload: JsonObject, session: Admin
const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles)
const note = text(payload.note)
assertEvidenceOrNote(evidence, note)
const requestId = normalizeRequestId(payload.requestId ?? payload.request_id)
const now = nowIso()
return withTransaction(async (client) => {
@@ -1293,6 +1310,7 @@ export async function createAdminFundFunding(payload: JsonObject, session: Admin
evidence,
note,
createdAt: now,
requestId,
})
return {
@@ -1315,6 +1333,7 @@ export async function createAdminFundIssue(payload: JsonObject, session: AdminSe
const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles)
const note = text(payload.note)
assertEvidenceOrNote(evidence, note)
const requestId = normalizeRequestId(payload.requestId ?? payload.request_id)
const supervisorAccountId = await resolveSupervisorAccountId(payload, session)
const now = nowIso()
@@ -1374,6 +1393,7 @@ export async function createAdminFundIssue(payload: JsonObject, session: AdminSe
evidence,
note,
createdAt: now,
requestId,
})
const agentEntry = await insertEntry(client, {
accountId: agentAccountId,
@@ -1387,6 +1407,7 @@ export async function createAdminFundIssue(payload: JsonObject, session: AdminSe
evidence,
note,
createdAt: now,
requestId,
})
return {
@@ -1435,6 +1456,7 @@ export async function createAdminFundReturn(payload: JsonObject, session: AdminS
const evidence = normalizeEvidence(payload.evidence || payload.evidenceFiles)
const note = text(payload.note)
assertEvidenceOrNote(evidence, note)
const requestId = normalizeRequestId(payload.requestId ?? payload.request_id)
const agent = await selectAccountRow(agentAccountId)
if (!agent || agent.account_type !== 'agent') {
@@ -1513,6 +1535,7 @@ export async function createAdminFundReturn(payload: JsonObject, session: AdminS
evidence,
note,
createdAt: now,
requestId,
})
const supervisorEntry = await insertEntry(client, {
accountId: supervisorAccountId,
@@ -1526,6 +1549,7 @@ export async function createAdminFundReturn(payload: JsonObject, session: AdminS
evidence,
note,
createdAt: now,
requestId,
})
return {
@@ -1588,6 +1612,7 @@ export type FundExpenseWriteInput = {
note: string
actorUserId: number
allowDuplicate?: boolean
requestId?: string
}
/**
@@ -1667,6 +1692,7 @@ export async function writeFundExpense(
evidence: input.evidence,
note: input.note,
createdAt: now,
requestId: input.requestId || '',
})
const entryRow = requiredRow(entry, '写入支出流水失败')
@@ -1767,6 +1793,7 @@ export async function createAdminFundExpense(payload: JsonObject, session: Admin
}
const allowDuplicate = payload.allowDuplicate === true
const requestId = normalizeRequestId(payload.requestId ?? payload.request_id)
return withTransaction(async (client) => {
const written = await writeFundExpense(client, {
@@ -1785,6 +1812,7 @@ export async function createAdminFundExpense(payload: JsonObject, session: Admin
note,
actorUserId: session.userId,
allowDuplicate,
requestId,
})
return { expense: mapManualExpense(written.expense) }
})
@@ -2084,22 +2112,8 @@ export async function confirmAdminFundReconciliation(
})
}
const differenceFen = toNumber(reconciliation.difference_fen)
const reason = text(payload.reason) || reconciliation.reason
if (differenceFen !== 0) {
if (!hasFundPermission(session, 'fund.adjust')) {
throw createHttpError('有差异的对账需要财务或管理员调整', {
statusCode: 403,
errorCode: 'fund_adjust_forbidden',
})
}
if (!reason) {
throw createHttpError('有差异时必须填写差异原因', {
statusCode: 400,
errorCode: 'fund_reconciliation_reason_required',
})
}
}
const actualBalanceFen = toNumber(reconciliation.actual_balance_fen)
const now = nowIso()
return withTransaction(async (client) => {
@@ -2112,8 +2126,28 @@ export async function confirmAdminFundReconciliation(
})
}
// 关键:用“确认时点的当前余额”重算差额,而不是创建对账时的旧差额。
// 否则创建后发生的发放/支出/缴回会让调整后的余额仍然不等于实际盘点。
const currentBalanceFen = toNumber(account.balance_fen)
const differenceFen = actualBalanceFen - currentBalanceFen
if (differenceFen !== 0) {
const nextBalance = toNumber(account.balance_fen) + differenceFen
if (!hasFundPermission(session, 'fund.adjust')) {
throw createHttpError('有差异的对账需要财务或管理员调整', {
statusCode: 403,
errorCode: 'fund_adjust_forbidden',
})
}
if (!reason) {
throw createHttpError('有差异时必须填写差异原因', {
statusCode: 400,
errorCode: 'fund_reconciliation_reason_required',
})
}
}
if (differenceFen !== 0) {
const nextBalance = currentBalanceFen + differenceFen
if (nextBalance < 0) {
throw createHttpError('调整后余额不能为负', {
statusCode: 409,
@@ -2129,7 +2163,7 @@ export async function confirmAdminFundReconciliation(
entryType: 'adjustment',
amountFen: differenceFen,
balanceAfterFen: nextBalance,
reconciliationId: reconciliationId,
reconciliationId,
occurredAt: now,
actorUserId: session.userId,
evidence: normalizeEvidence(payload.evidence || reconciliation.evidence_json),
@@ -2141,11 +2175,16 @@ export async function confirmAdminFundReconciliation(
await client.query(
`
UPDATE fund_reconciliations
SET status = 'confirmed', reason = $1, confirmed_by_admin_user_id = $2,
confirmed_at = $3, updated_at = $3
SET status = 'confirmed',
reason = $1,
system_balance_fen = $5,
difference_fen = $6,
confirmed_by_admin_user_id = $2,
confirmed_at = $3,
updated_at = $3
WHERE id = $4
`,
[reason, session.userId, now, reconciliationId],
[reason, session.userId, now, reconciliationId, currentBalanceFen, differenceFen],
)
const updated = await client.query<ReconciliationRow>(
@@ -6,6 +6,9 @@ export const ADMIN_PERMISSION_KEYS = [
'finance.review',
'finance.config',
'finance.export',
'finance.post',
'finance.close',
'finance.reconcile',
'fund.view',
'fund.issue',
'fund.expense',
@@ -342,18 +342,49 @@ export async function setAdminAffiliateProductPrice(
}
}
// 2. 追溯改价或相同生效时间改价:直接就地更新当前 active 版本,避免破坏 effective_to > effective_from 约束或触发唯一索引冲突
if (existing && existingTime !== null && targetTime <= existingTime) {
// 2. 追溯改价:退休旧版本并新增一个从 targetTime 起生效的版本,旧行保留可追溯。
if (existing && existingTime !== null && targetTime < existingTime) {
await query("UPDATE cost_versions SET status = 'retired', updated_at = $1 WHERE id = $2", [
now,
existing.id,
])
const inserted = await query<{ id: number }>(
`
INSERT INTO cost_versions (
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
source, status, change_reason, created_by_admin_user_id,
approved_by_admin_user_id, created_at, updated_at
) VALUES (
$1, 'product', '', '', $2,
NULL, $3, 0, $4, NULL,
'manual', 'active', $5, $6, $6, $7, $7
)
RETURNING id
`,
[costItem.id, product.id, amountFen, effectiveFrom, changeReason, session.userId, now],
)
return {
success: true,
product,
costVersionId: Number(inserted.rows[0]?.id || 0),
amountFen,
effectiveFrom,
message: '采购成本已更新(新增追溯版本)',
}
}
// 2.1 相同生效时间的更正:没有可插入的时间区间,只能就地更正并保留变更原因。
if (existing && existingTime !== null && targetTime === existingTime) {
await query(
`UPDATE cost_versions
SET amount_fen = $1,
effective_from = $2,
change_reason = $3,
created_by_admin_user_id = $4,
approved_by_admin_user_id = $4,
updated_at = $5
WHERE id = $6`,
[amountFen, effectiveFrom, changeReason, session.userId, now, existing.id],
change_reason = $2,
created_by_admin_user_id = $3,
approved_by_admin_user_id = $3,
updated_at = $4
WHERE id = $5`,
[amountFen, changeReason, session.userId, now, existing.id],
)
return {
success: true,
@@ -178,6 +178,7 @@ export async function grantPlatformCredit(payload: JsonObject, session: AdminSes
const quantity = normalizePositiveQuantity(payload.quantity)
const platformRef = optionalText(payload.platformRef ?? payload.platform_ref, 100)
const note = optionalText(payload.note, 300)
const requestId = optionalText(payload.requestId ?? payload.request_id, 80)
const unitPrices = await loadUnitPrices()
const unitPriceFen = unitPrices[platformKey]
@@ -194,16 +195,36 @@ export async function grantPlatformCredit(payload: JsonObject, session: AdminSes
WHERE id = $1`,
[account.id, nextBalance, now],
)
const entryResult = await client.query<CreditEntryRow>(
`
INSERT INTO platform_credit_entries (
account_id, entry_type, quantity_delta, balance_after, amount_fen,
platform_ref, note, created_at
) VALUES ($1, 'grant', $2, $3, $4, $5, $6, $7)
RETURNING *
`,
[account.id, quantity, nextBalance, quantity * unitPriceFen, platformRef, note, now],
)
let entryResult
try {
entryResult = await client.query<CreditEntryRow>(
`
INSERT INTO platform_credit_entries (
account_id, entry_type, quantity_delta, balance_after, amount_fen,
platform_ref, note, request_id, created_at
) VALUES ($1, 'grant', $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`,
[
account.id,
quantity,
nextBalance,
quantity * unitPriceFen,
platformRef,
note,
requestId,
now,
],
)
} catch (error) {
if ((error as { code?: string }).code === '23505') {
throw createHttpError('该请求已处理,请勿重复提交', {
statusCode: 409,
errorCode: 'platform_credit_request_duplicate',
})
}
throw error
}
return {
accountId: Number(account.id),
platformKey,
@@ -236,6 +257,7 @@ export async function adjustPlatformCredit(payload: JsonObject, session: AdminSe
errorCode: 'platform_credit_adjust_note_required',
})
}
const requestId = optionalText(payload.requestId ?? payload.request_id, 80)
const unitPrices = await loadUnitPrices()
const unitPriceFen = unitPrices[platformKey]
const now = nowIso()
@@ -256,16 +278,27 @@ export async function adjustPlatformCredit(payload: JsonObject, session: AdminSe
WHERE id = $1`,
[account.id, nextBalance, now],
)
const entryResult = await client.query<CreditEntryRow>(
`
INSERT INTO platform_credit_entries (
account_id, entry_type, quantity_delta, balance_after, amount_fen,
note, created_at
) VALUES ($1, 'adjust', $2, $3, $4, $5, $6)
RETURNING *
`,
[account.id, quantity, nextBalance, quantity * unitPriceFen, note, now],
)
let entryResult
try {
entryResult = await client.query<CreditEntryRow>(
`
INSERT INTO platform_credit_entries (
account_id, entry_type, quantity_delta, balance_after, amount_fen,
note, request_id, created_at
) VALUES ($1, 'adjust', $2, $3, $4, $5, $6, $7)
RETURNING *
`,
[account.id, quantity, nextBalance, quantity * unitPriceFen, note, requestId, now],
)
} catch (error) {
if ((error as { code?: string }).code === '23505') {
throw createHttpError('该请求已处理,请勿重复提交', {
statusCode: 409,
errorCode: 'platform_credit_request_duplicate',
})
}
throw error
}
return {
accountId: Number(account.id),
platformKey,
@@ -1391,30 +1391,49 @@ export async function setAdminProductPrice(
let costVersionId = existing?.id || 0
if (existing && existingTime !== null && targetTime <= existingTime) {
// 追溯改价或相同生效时间改价:就地更新 active 版本
await query(
`UPDATE cost_versions
SET amount_fen = $1,
effective_from = $2,
canonical_product_id = $3,
work_product_rule_id = $4,
change_reason = $5,
created_by_admin_user_id = $6,
approved_by_admin_user_id = $6,
updated_at = $7
WHERE id = $8`,
if (existing && existingTime !== null && targetTime < existingTime) {
// 追溯改价:退休旧版本并新增一个从 targetTime 起生效的版本,旧行保留可追溯。
// 不能就地改金额,否则 cost_snapshots.cost_version_id 指向的版本内容会被篡改。
await query("UPDATE cost_versions SET status = 'retired', updated_at = $1 WHERE id = $2", [
now,
existing.id,
])
const inserted = await query<{ id: number }>(
`INSERT INTO cost_versions (
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
source, status, change_reason, created_by_admin_user_id,
approved_by_admin_user_id, created_at, updated_at
) VALUES (
$1, 'rule', '', '', $2,
$3, $4, 0, $5, NULL,
'manual', 'active', $6, $7,
$7, $8, $8
) RETURNING id`,
[
amountFen,
effectiveFrom,
costItemId,
product.id,
rule?.id ?? null,
amountFen,
effectiveFrom,
changeReason,
session.userId,
now,
existing.id,
],
)
costVersionId = inserted.rows[0]?.id || 0
} else if (existing && existingTime !== null && targetTime === existingTime) {
// 相同生效时间的更正:没有可插入的时间区间,只能就地更正并保留变更原因。
await query(
`UPDATE cost_versions
SET amount_fen = $1,
change_reason = $2,
created_by_admin_user_id = $3,
approved_by_admin_user_id = $3,
updated_at = $4
WHERE id = $5`,
[amountFen, changeReason, session.userId, now, existing.id],
)
} else {
if (existing) {
await query(
@@ -1510,17 +1529,36 @@ export async function setAdminProductPrice(
const targetTime = Date.parse(effectiveFrom)
let costVersionId = existing?.id || 0
if (existing && existingTime !== null && targetTime <= existingTime) {
if (existing && existingTime !== null && targetTime < existingTime) {
await query("UPDATE cost_versions SET status = 'retired', updated_at = $1 WHERE id = $2", [
now,
existing.id,
])
const inserted = await query<{ id: number }>(
`INSERT INTO cost_versions (
cost_item_id, scope_type, provider, shop_id, canonical_product_id,
work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to,
source, status, change_reason, created_by_admin_user_id,
approved_by_admin_user_id, created_at, updated_at
) VALUES (
$1, 'product', '', '', $2,
NULL, $3, 0, $4, NULL,
'manual', 'active', $5, $6,
$6, $7, $7
) RETURNING id`,
[costItemId, product.id, amountFen, effectiveFrom, changeReason, session.userId, now],
)
costVersionId = inserted.rows[0]?.id || 0
} else if (existing && existingTime !== null && targetTime === existingTime) {
await query(
`UPDATE cost_versions
SET amount_fen = $1,
effective_from = $2,
change_reason = $3,
created_by_admin_user_id = $4,
approved_by_admin_user_id = $4,
updated_at = $5
WHERE id = $6`,
[amountFen, effectiveFrom, changeReason, session.userId, now, existing.id],
change_reason = $2,
created_by_admin_user_id = $3,
approved_by_admin_user_id = $3,
updated_at = $4
WHERE id = $5`,
[amountFen, changeReason, session.userId, now, existing.id],
)
} else {
if (existing) {
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
const read = (path: URL) => readFile(path, 'utf8')
test('销售事实重算必须冻结已关账期间', async () => {
const source = await read(new URL('./sales-facts-service.ts', import.meta.url))
assert.ok(source.includes('finance_periods'), '重算必须读取会计期间')
assert.ok(source.includes("p.status = 'closed'"), '必须识别已关账期间')
assert.ok(source.includes('skippedClosedPeriods'), '必须返回被跳过的关账期间')
})
test('人工确认/退款不得静默改已关账期间', async () => {
const [manual, refund] = await Promise.all([
read(new URL('./manual-fulfillment-service.ts', import.meta.url)),
read(new URL('./order-refund-service.ts', import.meta.url)),
])
assert.ok(manual.includes('manual_fulfillment_period_closed'))
assert.ok(refund.includes('order_refund_period_closed'))
})
test('总账同步必须追加调整凭证,不能删除重建', async () => {
const source = await read(new URL('./finance-ledger-service.ts', import.meta.url))
assert.ok(!source.includes('DELETE FROM finance_journal_entries'), '已过账凭证不得删除重建')
assert.ok(source.includes('sales_revenue_adjustment'), '收入变化必须走调整凭证')
assert.ok(source.includes('sales_cost_adjustment'), '成本变化必须走调整凭证')
assert.ok(source.includes('finance_sales_sync:'), '按日同步必须加 advisory lock')
})
test('关账必须具备门槛、期间锁和重复关账保护', async () => {
const source = await read(new URL('./finance-ledger-service.ts', import.meta.url))
assert.ok(source.includes('assertPeriodCloseable'), '关账必须校验门槛')
assert.ok(source.includes('finance_period_close_blocked'), '关账失败必须给出明确错误码')
assert.ok(source.includes('finance_period_already_closed'), '重复关账必须拒绝')
assert.ok(source.includes('finance_period:'), '凭证写入与关账必须共用期间锁')
assert.ok(
source.includes('manual_cost_missing') || source.includes('未确认成本'),
'必须拦人工缺成本',
)
})
test('平台退款必须先校验可退金额并落退款意图', async () => {
const source = await read(new URL('./order-refund-service.ts', import.meta.url))
const platformStart = source.indexOf('export async function approveAndRecordKuaishouRefund')
const platformEnd = source.indexOf('type PlatformRefundIntentRow', platformStart)
const body = source.slice(platformStart, platformEnd)
assert.ok(
body.indexOf('getRefundableFen') < body.indexOf('approveKuaishouIndustryRefund'),
'可退金额校验必须发生在平台调用之前',
)
assert.ok(body.includes('beginPlatformRefundIntent'), '必须先落退款意图')
assert.ok(source.includes('finance_refund_intents'), '必须有平台退款状态机')
assert.ok(source.includes('order_refund_external_ref_exists'), '外部退款单号必须幂等')
assert.ok(source.includes('nextPayStatus'), '全额退款撤销必须恢复订单支付状态')
assert.ok(source.includes('订单已退款,成本异常自动关闭'), '撤销必须重新打开成本异常')
})
test('备用金对账确认必须按当前余额重算差额', async () => {
const source = await read(new URL('../admin/admin-fund-service.ts', import.meta.url))
const start = source.indexOf('export async function confirmAdminFundReconciliation')
const end = source.indexOf('export async function getAdminFundSupervisorOptions', start)
const body = source.slice(start, end)
assert.ok(body.includes('actualBalanceFen - currentBalanceFen'), '必须用确认时点余额重算')
assert.ok(body.includes('system_balance_fen = $5'), '必须回写确认时点的系统余额')
assert.ok(body.includes('difference_fen = $6'), '必须回写确认时点的差额')
})
test('迁移 104 提供退款/支出幂等与凭证完整性约束', async () => {
const migration = await read(
new URL('../../db/migrations/104_finance_controls.sql', import.meta.url),
)
assert.ok(migration.includes('uq_order_refunds_source_external_ref'))
assert.ok(migration.includes('uq_manual_expenses_order_item'))
assert.ok(migration.includes('finance_refund_intents'))
assert.ok(migration.includes('trg_finance_journal_balanced'))
assert.ok(migration.includes('trg_finance_journal_immutable'))
assert.ok(migration.includes('trg_fund_entries_balance'))
assert.ok(migration.includes('trg_fund_accounts_balance'))
assert.ok(migration.includes("'finance.post'"))
assert.ok(migration.includes("'finance.close'"))
assert.ok(migration.includes("'finance.reconcile'"))
})
test('财务写路由使用拆分后的权限,人工履约包含 finance', async () => {
const [ledgerRoute, financeRoute, orderRoute] = await Promise.all([
read(new URL('../../routes/admin/finance-ledger.ts', import.meta.url)),
read(new URL('../../routes/admin/finance.ts', import.meta.url)),
read(new URL('../../routes/admin/orders.ts', import.meta.url)),
])
assert.ok(ledgerRoute.includes("requireAdminPermission('finance.post')"))
assert.ok(ledgerRoute.includes("requireAdminPermission('finance.close')"))
assert.ok(ledgerRoute.includes("requireAdminPermission('finance.reconcile')"))
assert.ok(
financeRoute.includes("requireAdminRoles(['admin', 'operator', 'support', 'finance'])"),
'文档承诺财务可代录,路由必须允许 finance',
)
assert.ok(orderRoute.includes("requireAdminPermission('finance.post')"))
})
test('P1:资金/积分写命令支持 requestId 幂等', async () => {
const [fund, credit, migration] = await Promise.all([
read(new URL('../admin/admin-fund-service.ts', import.meta.url)),
read(new URL('../cost-center/platform-credit-service.ts', import.meta.url)),
read(new URL('../../db/migrations/104_finance_controls.sql', import.meta.url)),
])
assert.ok(fund.includes('requestId') && fund.includes('fund_request_duplicate'))
assert.ok(credit.includes('platform_credit_request_duplicate'))
assert.ok(migration.includes('uq_fund_entries_account_request'))
assert.ok(migration.includes('uq_platform_credit_entries_account_request'))
})
test('P1:成本版本追溯改价退休旧版本而不是覆盖金额', async () => {
const [product, affiliate] = await Promise.all([
read(new URL('../cost-center/product-catalog-service.ts', import.meta.url)),
read(new URL('../cost-center/affiliate-product-service.ts', import.meta.url)),
])
assert.ok(product.includes('新增一个从 targetTime 起生效的版本'))
assert.ok(product.includes("status = 'retired'"))
assert.ok(affiliate.includes('新增追溯版本'))
assert.ok(affiliate.includes("status = 'retired'"))
})
test('P1:平台对账支持订单项/平台明细维度', async () => {
const [service, migration] = await Promise.all([
read(new URL('./finance-reconciliation-service.ts', import.meta.url)),
read(new URL('../../db/migrations/104_finance_controls.sql', import.meta.url)),
])
assert.ok(service.includes('orderItemId') && service.includes('platformItemId'))
assert.ok(service.includes('order_item_id'))
assert.ok(migration.includes('platform_item_id'))
assert.ok(migration.includes('order_item_id'))
})
test('L1:钱包金额 BIGINT、非负约束与奖励流水幂等', async () => {
const migration = await read(
new URL('../../db/migrations/104_finance_controls.sql', import.meta.url),
)
assert.ok(migration.includes('ALTER COLUMN total_amount TYPE BIGINT'))
assert.ok(migration.includes('ALTER COLUMN available_amount TYPE BIGINT'))
assert.ok(migration.includes('chk_worker_wallets_nonnegative'))
assert.ok(migration.includes('chk_worker_wallet_ledgers_nonnegative_after'))
assert.ok(migration.includes('worker_wallet_ledger_event_key'))
assert.ok(migration.includes('uq_worker_wallet_ledger_event_key'))
assert.ok(migration.includes('v_worker_wallet_duplicate_rewards'))
assert.ok(migration.includes("NOT (NEW.payload_json ? 'inventoryId')"))
assert.ok(migration.includes('GROUP BY worker_id, related_work_order_id'))
})
test('销售毛利必须同时暴露缺失成本收入,避免只看毛利绝对值', async () => {
const source = await read(new URL('./sales-facts-service.ts', import.meta.url))
assert.ok(source.includes('cost_missing_revenue_fen'))
assert.ok(source.includes('costMissingRevenueFen'))
})
@@ -34,6 +34,8 @@ export type PostJournalInput = {
memo?: string
metadata?: JsonObject
lines: JournalLineInput[]
/** 允许命中已有幂等键时直接返回(默认抛 409,避免“以为新建了、实际复用了旧凭证”)。 */
allowExisting?: boolean
}
export type JournalEntry = {
@@ -68,6 +70,7 @@ export async function postJournalEntry(
const occurredDate = normalizeTimestampDate(input.occurredAt)
const occurredAt = occurredDate.toISOString()
const periodKey = formatShanghaiDate(occurredDate).slice(0, 7)
await acquireFinancePeriodLock(client, periodKey)
await assertPeriodOpen(client, periodKey)
const entryNo = `FJ-${periodKey.replace('-', '')}-${randomId('', 8)}`
@@ -100,6 +103,12 @@ export async function postJournalEntry(
LIMIT 1`,
[input.eventType, input.refType, input.refId],
)
if (!input.allowExisting) {
throw createHttpError('该业务事件已生成过凭证,不能重复记账', {
statusCode: 409,
errorCode: 'finance_journal_duplicate_reference',
})
}
return {
created: false,
entryId: Number(existing.rows[0]?.id || 0),
@@ -189,81 +198,179 @@ export async function syncFinanceLedgerFromFacts(input: { dateFrom: string; date
)
let created = 0
let replaced = 0
let adjusted = 0
let skipped = 0
for (const row of rows.rows) {
const occurredAt = `${row.stat_date}T12:00:00+08:00`
const periodKey = formatShanghaiDate(new Date(occurredAt)).slice(0, 7)
const period = await query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
[periodKey],
)
if (period.rows[0]?.status === 'closed') {
// 已关账期间不可改账;即使销售事实后来变化,也保留关账时点的凭证。
skipped += 1
continue
}
// 未关账期间:销售事实可能因退款/收入回填而变化,按日整体重建,
// 避免旧的幂等凭证一直停留在旧金额;删除与重建放在同一事务内。
const revenue = Number(row.gross_revenue_fen || 0)
const refund = Number(row.refund_amount_fen || 0)
const cost = Number(row.cost_amount_fen || 0)
const entries: PostJournalInput[] = []
if (revenue > 0) {
entries.push({
eventType: 'sales_revenue',
refType: 'sales_fact',
refId: row.stat_date,
occurredAt,
memo: `${row.stat_date} 销售收入`,
lines: [
{ accountCode: '1101', debitFen: revenue, memo: '应收平台' },
{ accountCode: '4001', creditFen: revenue, memo: '商品销售收入' },
],
})
}
if (refund > 0) {
entries.push({
eventType: 'sales_refund',
refType: 'sales_fact',
refId: row.stat_date,
occurredAt,
memo: `${row.stat_date} 退款冲减`,
lines: [
{ accountCode: '4101', debitFen: refund, memo: '退款冲减收入' },
{ accountCode: '1101', creditFen: refund, memo: '应收平台冲减' },
],
})
}
if (cost > 0) {
entries.push({
eventType: 'sales_cost',
refType: 'sales_fact',
refId: row.stat_date,
occurredAt,
memo: `${row.stat_date} 履约成本`,
lines: [
{ accountCode: '5001', debitFen: cost, memo: '履约成本' },
{ accountCode: '2001', creditFen: cost, memo: '应付履约款' },
],
})
}
const dayCreated = await withTransaction(async (client) => {
const deleted = await client.query(
`DELETE FROM finance_journal_entries WHERE ref_type = 'sales_fact' AND ref_id = $1`,
// 按日串行化:避免两个同步任务同时算出同一个 delta 并重复补记。
// 锁在事务内,事务提交/回滚自动释放。
const dayResult = await withTransaction(async (client) => {
await client.query(
`SELECT pg_advisory_xact_lock(hashtext('finance_sales_sync:' || $1)::bigint)`,
[row.stat_date],
)
replaced += deleted.rowCount || 0
let count = 0
for (const entry of entries) {
const result = await postJournalEntry(entry, client)
if (result.created) count += 1
const period = await client.query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
[periodKey],
)
if (period.rows[0]?.status === 'closed') {
// 已关账期间不可改账;销售事实层也已在 rebuild 时冻结该期间。
return { created: 0, adjusted: 0, skipped: 1 }
}
return count
const existing = await readSalesFactJournalTotals(client, row.stat_date)
let dayCreated = 0
let dayAdjusted = 0
const postDelta = async (input: {
eventType: string
adjustmentType: string
hasExisting: boolean
delta: number
normalLines: (amount: number) => JournalLineInput[]
reverseLines: (amount: number) => JournalLineInput[]
memo: string
}) => {
if (input.delta === 0) return
const isAdjustment = input.hasExisting
const eventType = isAdjustment ? input.adjustmentType : input.eventType
const refId = isAdjustment
? `${row.stat_date}:${input.adjustmentType}:${randomId('', 6)}`
: row.stat_date
const amount = Math.abs(input.delta)
const result = await postJournalEntry(
{
eventType,
refType: 'sales_fact',
refId,
occurredAt,
memo: input.memo,
metadata: {
statDate: row.stat_date,
deltaFen: input.delta,
adjustment: isAdjustment,
},
lines: input.delta > 0 ? input.normalLines(amount) : input.reverseLines(amount),
},
client,
)
if (result.created) {
if (isAdjustment) dayAdjusted += 1
else dayCreated += 1
}
}
await postDelta({
eventType: 'sales_revenue',
adjustmentType: 'sales_revenue_adjustment',
hasExisting: existing.hasRevenue,
delta: revenue - existing.revenue,
normalLines: (amount) => [
{ accountCode: '1101', debitFen: amount, memo: '应收平台' },
{ accountCode: '4001', creditFen: amount, memo: '商品销售收入' },
],
reverseLines: (amount) => [
{ accountCode: '4001', debitFen: amount, memo: '商品销售收入冲减' },
{ accountCode: '1101', creditFen: amount, memo: '应收平台冲减' },
],
memo: `${row.stat_date} 销售收入`,
})
await postDelta({
eventType: 'sales_refund',
adjustmentType: 'sales_refund_adjustment',
hasExisting: existing.hasRefund,
delta: refund - existing.refund,
normalLines: (amount) => [
{ accountCode: '4101', debitFen: amount, memo: '退款冲减收入' },
{ accountCode: '1101', creditFen: amount, memo: '应收平台冲减' },
],
reverseLines: (amount) => [
{ accountCode: '1101', debitFen: amount, memo: '应收平台恢复' },
{ accountCode: '4101', creditFen: amount, memo: '退款冲减恢复' },
],
memo: `${row.stat_date} 退款冲减`,
})
await postDelta({
eventType: 'sales_cost',
adjustmentType: 'sales_cost_adjustment',
hasExisting: existing.hasCost,
delta: cost - existing.cost,
normalLines: (amount) => [
{ accountCode: '5001', debitFen: amount, memo: '履约成本' },
{ accountCode: '2001', creditFen: amount, memo: '应付履约款' },
],
reverseLines: (amount) => [
{ accountCode: '2001', debitFen: amount, memo: '应付履约款冲减' },
{ accountCode: '5001', creditFen: amount, memo: '履约成本冲减' },
],
memo: `${row.stat_date} 履约成本`,
})
return { created: dayCreated, adjusted: dayAdjusted, skipped: 0 }
})
created += dayCreated
created += dayResult.created
adjusted += dayResult.adjusted
skipped += dayResult.skipped
}
return {
range: { fromDate: dateFrom, toDate: dateTo },
created,
adjusted,
skipped,
}
}
type SalesFactJournalTotalRow = {
event_type: string
account_code: string
debit_fen: string | number
credit_fen: string | number
}
async function readSalesFactJournalTotals(client: PoolClient, statDate: string) {
const result = await client.query<SalesFactJournalTotalRow>(
`
SELECT
e.event_type,
l.account_code,
COALESCE(SUM(l.debit_fen), 0)::bigint AS debit_fen,
COALESCE(SUM(l.credit_fen), 0)::bigint AS credit_fen
FROM finance_journal_entries e
JOIN finance_journal_lines l ON l.entry_id = e.id
WHERE e.ref_type = 'sales_fact'
AND e.ref_id = $1
AND e.status = 'posted'
GROUP BY e.event_type, l.account_code
`,
[statDate],
)
const rows = result.rows
const revenueTypes = new Set(['sales_revenue', 'sales_revenue_adjustment'])
const refundTypes = new Set(['sales_refund', 'sales_refund_adjustment'])
const costTypes = new Set(['sales_cost', 'sales_cost_adjustment'])
const net = (types: Set<string>, accountCode: string) =>
rows
.filter((row) => types.has(row.event_type) && row.account_code === accountCode)
.reduce((sum, row) => sum + Number(row.debit_fen || 0) - Number(row.credit_fen || 0), 0)
return {
revenue: net(revenueTypes, '1101'),
refund: net(refundTypes, '4101'),
cost: net(costTypes, '5001'),
hasRevenue: rows.some((row) => revenueTypes.has(row.event_type)),
hasRefund: rows.some((row) => refundTypes.has(row.event_type)),
hasCost: rows.some((row) => costTypes.has(row.event_type)),
}
return { range: { fromDate: dateFrom, toDate: dateTo }, created, replaced, skipped }
}
export async function listJournalEntries(input: Record<string, unknown> = {}) {
@@ -349,11 +456,13 @@ export async function listJournalEntries(input: Record<string, unknown> = {}) {
}
}
export async function getTrialBalance(input: { dateFrom: string; dateTo: string }) {
export async function getTrialBalance(
input: { dateFrom: string; dateTo: string },
client?: PoolClient,
) {
const dateFrom = normalizeDate(input.dateFrom)
const dateTo = normalizeDate(input.dateTo)
const rows = await query<Record<string, unknown>>(
`
const sql = `
SELECT
a.account_code, a.account_name, a.account_type, a.normal_side,
COALESCE(SUM(l.debit_fen), 0)::bigint AS debit_fen,
@@ -373,10 +482,11 @@ export async function getTrialBalance(input: { dateFrom: string; dateTo: string
AND (e.id IS NOT NULL OR (l.id IS NULL))
GROUP BY a.account_code, a.account_name, a.account_type, a.normal_side
ORDER BY a.account_code
`,
[dateFrom, dateTo],
)
const items = rows.rows.map((row) => ({
`
const result = client
? await client.query<Record<string, unknown>>(sql, [dateFrom, dateTo])
: await query<Record<string, unknown>>(sql, [dateFrom, dateTo])
const items = result.rows.map((row) => ({
accountCode: String(row.account_code || ''),
accountName: String(row.account_name || ''),
accountType: String(row.account_type || ''),
@@ -410,42 +520,254 @@ export async function listFinancePeriods() {
}
}
export async function closeFinancePeriod(periodKeyInput: unknown, session: AdminSession) {
export async function closeFinancePeriod(
periodKeyInput: unknown,
session: AdminSession,
reason = '',
) {
const periodKey = normalizePeriodKey(periodKeyInput)
const existing = await query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
[periodKey],
)
if (existing.rows[0]?.status === 'closed') {
throw createHttpError(`会计期间 ${periodKey} 已关账,不能重复关账`, {
statusCode: 409,
errorCode: 'finance_period_already_closed',
})
}
const { startsAt, endsAt, dateFrom, dateTo } = periodRange(periodKey)
// 先补齐该期间的凭证,避免漏记。
await syncFinanceLedgerFromFacts({ dateFrom, dateTo })
const trial = await getTrialBalance({ dateFrom, dateTo })
const now = nowIso()
await query(
const result = await withTransaction(async (client) => {
// 与 postJournalEntry 使用同一把期间锁:关账检查与凭证写入不能并发穿透。
await acquireFinancePeriodLock(client, periodKey)
const current = await client.query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
[periodKey],
)
if (current.rows[0]?.status === 'closed') {
throw createHttpError(`会计期间 ${periodKey} 已关账,不能重复关账`, {
statusCode: 409,
errorCode: 'finance_period_already_closed',
})
}
const trial = await getTrialBalance({ dateFrom, dateTo }, client)
await assertPeriodCloseable(client, periodKey, dateFrom, dateTo, trial)
const now = nowIso()
await client.query(
`
INSERT INTO finance_periods (
period_key, starts_at, ends_at, status, summary_json,
closed_at, closed_by_admin_user_id, created_at, updated_at
) VALUES ($1, $2, $3, 'closed', $4::jsonb, $5, $6, $5, $5)
ON CONFLICT (period_key) DO UPDATE
SET status = 'closed',
starts_at = EXCLUDED.starts_at,
ends_at = EXCLUDED.ends_at,
summary_json = EXCLUDED.summary_json,
closed_at = EXCLUDED.closed_at,
closed_by_admin_user_id = EXCLUDED.closed_by_admin_user_id,
updated_at = EXCLUDED.updated_at
`,
[
periodKey,
startsAt,
endsAt,
JSON.stringify({
totals: trial.totals,
balanced: trial.balanced,
itemCount: trial.items.length,
closeReason: String(reason || '').slice(0, 500),
}),
now,
Number(session.userId || 0) || null,
],
)
return { trial }
})
return { periodKey, trial: result.trial }
}
async function assertPeriodCloseable(
client: PoolClient,
periodKey: string,
dateFrom: string,
dateTo: string,
trial: { balanced: boolean },
) {
const issues: string[] = []
if (!trial.balanced) {
issues.push('凭证借贷不平衡')
}
// 上线后人工单必须全覆盖:从第一次人工配置起,未确认的人工订单项不允许关账。
const manualMissing = await client.query<{ total: number }>(
`
INSERT INTO finance_periods (
period_key, starts_at, ends_at, status, summary_json,
closed_at, closed_by_admin_user_id, created_at, updated_at
) VALUES ($1, $2, $3, 'closed', $4::jsonb, $5, $6, $5, $5)
ON CONFLICT (period_key) DO UPDATE
SET status = 'closed',
starts_at = EXCLUDED.starts_at,
ends_at = EXCLUDED.ends_at,
summary_json = EXCLUDED.summary_json,
closed_at = EXCLUDED.closed_at,
closed_by_admin_user_id = EXCLUDED.closed_by_admin_user_id,
updated_at = EXCLUDED.updated_at
SELECT COUNT(*)::int AS total
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_mode = 'manual'
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
AND oi.created_at >= (SELECT MIN(created_at) FROM manual_fulfillment_configs)
AND NOT EXISTS (
SELECT 1 FROM cost_snapshots cs
WHERE cs.subject_type = 'manual_case'
AND cs.order_item_id = oi.id
AND cs.superseded_at IS NULL
)
`,
[
periodKey,
startsAt,
endsAt,
JSON.stringify({
totals: trial.totals,
balanced: trial.balanced,
itemCount: trial.items.length,
}),
now,
Number(session.userId || 0) || null,
],
[dateFrom, dateTo],
)
return { periodKey, trial }
if (Number(manualMissing.rows[0]?.total || 0) > 0) {
issues.push(`存在 ${Number(manualMissing.rows[0]?.total)} 个人工订单项未确认成本`)
}
// 人工成本必须能找到资金流水:现金 fundExpenseId 或积分 creditEntryId。
const manualWithoutFund = await client.query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM cost_snapshots cs
JOIN order_items oi ON oi.id = cs.order_item_id
JOIN orders o ON o.id = oi.order_id
WHERE cs.subject_type = 'manual_case'
AND cs.superseded_at IS NULL
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
AND cs.created_at >= (SELECT MIN(created_at) FROM manual_fulfillment_configs)
AND cs.metadata_json->>'fundExpenseId' IS NULL
AND cs.metadata_json->>'creditEntryId' IS NULL
`,
[dateFrom, dateTo],
)
if (Number(manualWithoutFund.rows[0]?.total || 0) > 0) {
issues.push(`存在 ${Number(manualWithoutFund.rows[0]?.total)} 个人工成本快照没有资金流水`)
}
// 现金人工成本金额必须等于备用金支出金额。
const manualAmountMismatch = await client.query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM cost_snapshots cs
JOIN manual_expenses me ON me.order_item_id = cs.order_item_id
JOIN order_items oi ON oi.id = cs.order_item_id
JOIN orders o ON o.id = oi.order_id
WHERE cs.subject_type = 'manual_case'
AND cs.superseded_at IS NULL
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
AND cs.created_at >= (SELECT MIN(created_at) FROM manual_fulfillment_configs)
AND cs.total_amount_fen <> me.amount_fen
`,
[dateFrom, dateTo],
)
if (Number(manualAmountMismatch.rows[0]?.total || 0) > 0) {
issues.push(
`存在 ${Number(manualAmountMismatch.rows[0]?.total)} 个人工成本与备用金支出金额不一致`,
)
}
// 未解决的平台对账差异不允许关账。
const unresolvedReconciliations = await client.query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM finance_reconciliations
WHERE status = 'differences'
AND period_from <= $2::date
AND period_to >= $1::date
`,
[dateFrom, dateTo],
)
if (Number(unresolvedReconciliations.rows[0]?.total || 0) > 0) {
issues.push(`存在 ${Number(unresolvedReconciliations.rows[0]?.total)} 个未解决的对账差异批次`)
}
// 账表一致:销售事实与 sales_fact 凭证必须逐项相等,防止事实层被并发重算后总账未跟上。
const ledgerVsFacts = await client.query<{
facts_revenue_fen: string
facts_refund_fen: string
facts_cost_fen: string
ledger_revenue_fen: string
ledger_refund_fen: string
ledger_cost_fen: string
}>(
`
WITH facts AS (
SELECT
COALESCE(SUM(gross_revenue_fen), 0)::bigint AS revenue_fen,
COALESCE(SUM(refund_amount_fen), 0)::bigint AS refund_fen,
COALESCE(SUM(cost_amount_fen), 0)::bigint AS cost_fen
FROM sales_daily_facts
WHERE stat_date BETWEEN $1::date AND $2::date
),
ledger AS (
SELECT
COALESCE(SUM(l.debit_fen) FILTER (
WHERE l.account_code = '1101'
AND e.event_type IN ('sales_revenue', 'sales_revenue_adjustment')
), 0)::bigint
- COALESCE(SUM(l.credit_fen) FILTER (
WHERE l.account_code = '1101'
AND e.event_type IN ('sales_revenue', 'sales_revenue_adjustment')
), 0)::bigint AS revenue_fen,
COALESCE(SUM(l.debit_fen) FILTER (
WHERE l.account_code = '4101'
AND e.event_type IN ('sales_refund', 'sales_refund_adjustment')
), 0)::bigint
- COALESCE(SUM(l.credit_fen) FILTER (
WHERE l.account_code = '4101'
AND e.event_type IN ('sales_refund', 'sales_refund_adjustment')
), 0)::bigint AS refund_fen,
COALESCE(SUM(l.debit_fen) FILTER (
WHERE l.account_code = '5001'
AND e.event_type IN ('sales_cost', 'sales_cost_adjustment')
), 0)::bigint
- COALESCE(SUM(l.credit_fen) FILTER (
WHERE l.account_code = '5001'
AND e.event_type IN ('sales_cost', 'sales_cost_adjustment')
), 0)::bigint AS cost_fen
FROM finance_journal_entries e
JOIN finance_journal_lines l ON l.entry_id = e.id
WHERE e.ref_type = 'sales_fact'
AND e.status = 'posted'
AND e.occurred_at >= ($1::date::timestamp AT TIME ZONE 'Asia/Shanghai')
AND e.occurred_at < (($2::date + interval '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai')
)
SELECT
facts.revenue_fen AS facts_revenue_fen,
facts.refund_fen AS facts_refund_fen,
facts.cost_fen AS facts_cost_fen,
ledger.revenue_fen AS ledger_revenue_fen,
ledger.refund_fen AS ledger_refund_fen,
ledger.cost_fen AS ledger_cost_fen
FROM facts, ledger
`,
[dateFrom, dateTo],
)
const compare = ledgerVsFacts.rows[0]
if (
compare &&
(Number(compare.facts_revenue_fen) !== Number(compare.ledger_revenue_fen) ||
Number(compare.facts_refund_fen) !== Number(compare.ledger_refund_fen) ||
Number(compare.facts_cost_fen) !== Number(compare.ledger_cost_fen))
) {
issues.push('销售事实与总账凭证不一致')
}
if (issues.length > 0) {
throw createHttpError(`期间 ${periodKey} 不满足关账条件:${issues.join(';')}`, {
statusCode: 409,
errorCode: 'finance_period_close_blocked',
})
}
}
export async function reopenFinancePeriod(periodKeyInput: unknown) {
@@ -454,15 +776,15 @@ export async function reopenFinancePeriod(periodKeyInput: unknown) {
`
UPDATE finance_periods
SET status = 'open', closed_at = NULL, closed_by_admin_user_id = NULL, updated_at = $2
WHERE period_key = $1
WHERE period_key = $1 AND status = 'closed'
RETURNING *
`,
[periodKey, nowIso()],
)
if (!result.rows[0]) {
throw createHttpError('期间不存在或尚未关账', {
statusCode: 404,
errorCode: 'finance_period_not_found',
statusCode: 409,
errorCode: 'finance_period_not_closed',
})
}
return mapPeriod(result.rows[0])
@@ -500,11 +822,13 @@ function normalizeLines(lines: JournalLineInput[]) {
errorCode: 'finance_journal_account_required',
})
}
const debitFen = Math.trunc(Number(line.debitFen || 0))
const creditFen = Math.trunc(Number(line.creditFen || 0))
const debitFen = Number(line.debitFen || 0)
const creditFen = Number(line.creditFen || 0)
if (
!Number.isFinite(debitFen) ||
!Number.isFinite(creditFen) ||
!Number.isInteger(debitFen) ||
!Number.isInteger(creditFen) ||
debitFen < 0 ||
creditFen < 0 ||
(debitFen > 0 && creditFen > 0) ||
@@ -534,6 +858,12 @@ function normalizeLines(lines: JournalLineInput[]) {
return normalized
}
async function acquireFinancePeriodLock(client: PoolClient, periodKey: string) {
await client.query(`SELECT pg_advisory_xact_lock(hashtext('finance_period:' || $1)::bigint)`, [
periodKey,
])
}
async function assertPeriodOpen(client: PoolClient, periodKey: string) {
const result = await client.query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
@@ -16,6 +16,10 @@ import type { AdminSession } from '../admin/admin-auth-service.js'
type ReconciliationRowInput = {
platformOrderId?: unknown
platform_order_id?: unknown
platformItemId?: unknown
platform_item_id?: unknown
orderItemId?: unknown
order_item_id?: unknown
entryType?: unknown
entry_type?: unknown
amountFen?: unknown
@@ -75,13 +79,19 @@ export async function importFinanceReconciliation(payload: JsonObject, session:
for (const [index, row] of rows.entries()) {
const duplicateKey =
row.entryType === 'sale'
? `${row.platformOrderId}|sale`
: `${row.platformOrderId}|refund|${row.externalRef || row.amountFen}`
? `${row.platformOrderId}|sale|${row.platformItemId || row.orderItemId || row.amountFen}`
: `${row.platformOrderId}|refund|${row.externalRef || row.orderItemId || row.amountFen}`
const isDuplicate = seen.has(duplicateKey)
seen.add(duplicateKey)
const match = isDuplicate
? { status: 'duplicate' as const, orderId: null, refundId: null, expected: null }
? {
status: 'duplicate' as const,
orderId: null,
orderItemId: null,
refundId: null,
expected: null,
}
: await matchRow(client, provider, row)
const expected = match.expected
const diff = expected === null ? null : row.amountFen - expected
@@ -93,12 +103,13 @@ export async function importFinanceReconciliation(payload: JsonObject, session:
await client.query(
`
INSERT INTO finance_reconciliation_items (
reconciliation_id, line_no, provider, platform_order_id, external_ref,
entry_type, amount_fen, occurred_at, matched_order_id, matched_refund_id,
expected_fen, diff_fen, match_status, detail_json, created_at
reconciliation_id, line_no, provider, platform_order_id, platform_item_id,
external_ref, entry_type, amount_fen, occurred_at, matched_order_id,
order_item_id, matched_refund_id, expected_fen, diff_fen, match_status,
detail_json, created_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8::timestamptz, $9, $10,
$11, $12, $13, $14::jsonb, $15
$1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz, $10,
$11, $12, $13, $14, $15, $16::jsonb, $17
)
`,
[
@@ -106,11 +117,13 @@ export async function importFinanceReconciliation(payload: JsonObject, session:
index + 1,
provider,
row.platformOrderId,
row.platformItemId,
row.externalRef,
row.entryType,
row.amountFen,
row.occurredAt,
match.orderId,
match.orderItemId,
match.refundId,
expected,
diff,
@@ -227,6 +240,8 @@ export async function getFinanceReconciliation(idInput: unknown) {
id: Number(row.id),
lineNo: Number(row.line_no),
platformOrderId: String(row.platform_order_id || ''),
platformItemId: String(row.platform_item_id || ''),
orderItemId: row.order_item_id ? Number(row.order_item_id) : null,
externalRef: String(row.external_ref || ''),
entryType: String(row.entry_type || ''),
amountFen: Number(row.amount_fen || 0),
@@ -275,10 +290,51 @@ async function matchRow(
): Promise<{
status: 'matched' | 'amount_mismatch' | 'missing_order' | 'missing_refund'
orderId: number | null
orderItemId: number | null
refundId: number | null
expected: number | null
}> {
if (row.entryType === 'sale') {
if (row.orderItemId) {
const item = await client.query<{
id: number
order_id: number
revenue_fen: number
}>(
`
SELECT
oi.id,
oi.order_id,
GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)::bigint AS revenue_fen
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.id = $3
AND o.provider = $1
AND o.platform_order_id = $2
LIMIT 1
`,
[provider, row.platformOrderId, row.orderItemId],
)
const foundItem = item.rows[0]
if (!foundItem) {
return {
status: 'missing_order',
orderId: null,
orderItemId: null,
refundId: null,
expected: null,
}
}
const expected = Number(foundItem.revenue_fen || 0)
return {
status: expected === row.amountFen ? 'matched' : 'amount_mismatch',
orderId: Number(foundItem.order_id),
orderItemId: Number(foundItem.id),
refundId: null,
expected,
}
}
const order = await client.query<{ id: number; total_amount: number }>(
`SELECT id, total_amount FROM orders
WHERE provider = $1 AND platform_order_id = $2
@@ -287,30 +343,50 @@ async function matchRow(
)
const found = order.rows[0]
if (!found) {
return { status: 'missing_order', orderId: null, refundId: null, expected: null }
return {
status: 'missing_order',
orderId: null,
orderItemId: null,
refundId: null,
expected: null,
}
}
const expected = Number(found.total_amount || 0)
return {
status: expected === row.amountFen ? 'matched' : 'amount_mismatch',
orderId: Number(found.id),
orderItemId: null,
refundId: null,
expected,
}
}
const params: unknown[] = [provider, row.platformOrderId]
let externalClause = ''
const clauses: string[] = []
if (row.externalRef) {
params.push(row.externalRef)
externalClause = ` AND r.external_ref = $${params.length}`
clauses.push(`r.external_ref = $${params.length}`)
} else {
// 没有平台退款单号时,至少要求金额相同,避免把任意一笔退款误配到账单行。
params.push(row.amountFen)
clauses.push(`r.amount_fen = $${params.length}`)
}
const refund = await client.query<{ id: number; amount_fen: number }>(
if (row.orderItemId) {
params.push(row.orderItemId)
clauses.push(`r.order_item_id = $${params.length}`)
}
const refund = await client.query<{
id: number
amount_fen: number
order_item_id: number
}>(
`
SELECT r.id, r.amount_fen
SELECT r.id, r.amount_fen, r.order_item_id
FROM order_refunds r
JOIN orders o ON o.id = r.order_id
WHERE o.provider = $1 AND o.platform_order_id = $2
AND r.status = 'confirmed' ${externalClause}
AND r.status = 'confirmed'
AND ${clauses.join(' AND ')}
ORDER BY r.id DESC
LIMIT 1
`,
@@ -318,12 +394,19 @@ async function matchRow(
)
const found = refund.rows[0]
if (!found) {
return { status: 'missing_refund', orderId: null, refundId: null, expected: null }
return {
status: 'missing_refund',
orderId: null,
orderItemId: null,
refundId: null,
expected: null,
}
}
const expected = Number(found.amount_fen || 0)
return {
status: expected === row.amountFen ? 'matched' : 'amount_mismatch',
orderId: null,
orderItemId: Number(found.order_item_id),
refundId: Number(found.id),
expected,
}
@@ -337,6 +420,23 @@ function normalizeRow(row: ReconciliationRowInput, provider: string, index: numb
errorCode: 'finance_reconciliation_order_id_required',
})
}
const platformItemId = String(row.platformItemId ?? row.platform_item_id ?? '').trim()
const rawOrderItemId = row.orderItemId ?? row.order_item_id
let orderItemId: number | null = null
if (
rawOrderItemId !== undefined &&
rawOrderItemId !== null &&
String(rawOrderItemId).trim() !== ''
) {
const parsed = Number(rawOrderItemId)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw createHttpError(`第 ${index + 1} 行订单项 ID 无效`, {
statusCode: 400,
errorCode: 'finance_reconciliation_order_item_invalid',
})
}
orderItemId = parsed
}
const entryType = String(row.entryType ?? row.entry_type ?? 'sale').trim()
if (entryType !== 'sale' && entryType !== 'refund') {
throw createHttpError(`第 ${index + 1} 行类型只能是 sale/refund`, {
@@ -362,6 +462,8 @@ function normalizeRow(row: ReconciliationRowInput, provider: string, index: numb
return {
provider,
platformOrderId,
platformItemId,
orderItemId,
entryType,
amountFen,
externalRef: String(row.externalRef ?? row.external_ref ?? '').trim(),
@@ -127,9 +127,9 @@ test('人工成本缺失必须进入异常队列,且只监控首次配置之
)
})
test('迁移 108 提供订单项级人工成本唯一约束与 subject 语义约束', async () => {
test('迁移 104 提供订单项级人工成本唯一约束与 subject 语义约束', async () => {
const source = await readFile(
new URL('../../db/migrations/108_finance_cost_integrity.sql', import.meta.url),
new URL('../../db/migrations/104_finance_controls.sql', import.meta.url),
'utf8',
)
assert.ok(source.includes('uq_cost_snapshots_manual_order_item'), '缺少订单项级人工快照唯一索引')
@@ -146,10 +146,7 @@ test('迁移 108 提供订单项级人工成本唯一约束与 subject 语义约
test('总账/对账/关账/平台退款能力已接入', async () => {
const [migration, ledger, reconciliation, refund, manual] = await Promise.all([
readFile(
new URL('../../db/migrations/108_finance_cost_integrity.sql', import.meta.url),
'utf8',
),
readFile(new URL('../../db/migrations/104_finance_controls.sql', import.meta.url), 'utf8'),
readFile(new URL('./finance-ledger-service.ts', import.meta.url), 'utf8'),
readFile(new URL('./finance-reconciliation-service.ts', import.meta.url), 'utf8'),
readFile(new URL('./order-refund-service.ts', import.meta.url), 'utf8'),
@@ -65,6 +65,7 @@ type ConfirmContextRow = {
sku_code: string
sku_name: string
revenue_fen: string
basis_at: string
canonical_product_id: number | null
fulfillment_mode: string
config_id: number | null
@@ -216,6 +217,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
oi.sku_code,
oi.sku_name,
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
COALESCE(o.paid_at, o.created_at) AS basis_at,
oi.canonical_product_id,
oi.fulfillment_mode,
oi.fulfillment_config_id AS config_id,
@@ -254,6 +256,21 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
errorCode: 'manual_fulfillment_not_manual',
})
}
// 已关账期间不能再改成本事实;需要先反关账,避免“扣了钱但销售事实/总账没记”。
const period = await client.query<{ status: string }>(
`SELECT p.status
FROM finance_periods p
WHERE p.period_key = to_char(($1::timestamptz AT TIME ZONE 'Asia/Shanghai'), 'YYYY-MM')
LIMIT 1`,
[row.basis_at],
)
if (period.rows[0]?.status === 'closed') {
throw createHttpError('该订单所属期间已关账,请先反关账后再确认履约', {
statusCode: 409,
errorCode: 'manual_fulfillment_period_closed',
})
}
if (!row.config_id) {
throw createHttpError('该商品还没有配置履约渠道,请先在成本中心配置', {
statusCode: 409,
@@ -19,7 +19,7 @@ test(
{ skip: !TEST_DATABASE_URL ? '未设置 MANUAL_FULFILLMENT_TEST_DATABASE_URL' : false },
async () => {
process.env.DATABASE_URL = TEST_DATABASE_URL
const { query, closeDb } = await import('../../db/client.js')
const { query, withTransaction, closeDb } = await import('../../db/client.js')
const { confirmManualFulfillment } = await import('./manual-fulfillment-service.js')
const { syncEstimatedOrderItemCostSnapshots } =
await import('../cost-center/cost-snapshot-sync-service.js')
@@ -57,13 +57,23 @@ test(
)
const adminId = Number(admin.rows[0]?.id)
adminIds.push(adminId)
const fundAccount = await query<{ id: number }>(
`INSERT INTO fund_accounts (
account_no, account_type, owner_admin_user_id, balance_fen, status, created_at, updated_at
) VALUES ($1, 'agent', $2, 10000, 'active', NOW(), NOW()) RETURNING id`,
[`FA-IT-${suffix}`, adminId],
)
const fundAccountId = Number(fundAccount.rows[0]?.id)
const fundAccountId = await withTransaction(async (client) => {
const account = await client.query<{ id: number }>(
`INSERT INTO fund_accounts (
account_no, account_type, owner_admin_user_id, balance_fen, status, created_at, updated_at
) VALUES ($1, 'agent', $2, 10000, 'active', NOW(), NOW()) RETURNING id`,
[`FA-IT-${suffix}`, adminId],
)
const id = Number(account.rows[0]?.id)
await client.query(
`INSERT INTO fund_entries (
entry_no, account_id, entry_type, amount_fen, balance_after_fen,
occurred_at, created_by_admin_user_id, evidence_json, note, created_at
) VALUES ($1, $2, 'opening', 10000, 10000, NOW(), NULL, '[]'::jsonb, '集成测试期初', NOW())`,
[`FE-IT-${suffix}`, id],
)
return id
})
fundAccountIds.push(fundAccountId)
const session = {
@@ -420,6 +430,13 @@ test(
)
const periodKey = todayDate.slice(0, 7)
// 故意制造的快照失败单尚未确认,关账门槛必须先拦住。
await assert.rejects(
() => closeFinancePeriod(periodKey, session),
/不满足关账条件.*人工订单项未确认成本/,
)
// 清掉失败单后,关账才允许通过。
await confirmManualFulfillment({ orderItemId: failItem.orderItemId, costFen: 700 }, session)
await closeFinancePeriod(periodKey, session)
await assert.rejects(
() =>
@@ -492,10 +509,15 @@ test(
])
}
if (fundAccountIds.length > 0) {
await query(`DELETE FROM fund_entries WHERE account_id = ANY($1::bigint[])`, [
fundAccountIds,
])
await query(`DELETE FROM fund_accounts WHERE id = ANY($1::bigint[])`, [fundAccountIds])
await withTransaction(async (client) => {
await client.query(`SET LOCAL app.fund_balance_check = 'off'`)
await client.query(`DELETE FROM fund_entries WHERE account_id = ANY($1::bigint[])`, [
fundAccountIds,
])
await client.query(`DELETE FROM fund_accounts WHERE id = ANY($1::bigint[])`, [
fundAccountIds,
])
})
}
if (creditAccountIds.length > 0) {
await query(`DELETE FROM platform_credit_entries WHERE account_id = ANY($1::bigint[])`, [
@@ -7,6 +7,8 @@
*
* 后续接平台退款 API 时,只需写 source='api'/'platform' 的记录,其余逻辑复用。
*/
import type { PoolClient } from 'pg'
import { query, withTransaction } from '../../db/client.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
import { createHttpError } from '../../utils/http.js'
@@ -140,6 +142,7 @@ export async function createManualRefund(
errorCode: 'order_refund_order_not_found',
})
}
await assertBasisPeriodOpen(client, order.basis_at, '退款')
const itemsResult = await client.query<{
id: number
@@ -203,38 +206,63 @@ export async function createManualRefund(
})
}
if (externalRef) {
const duplicate = await client.query<{ id: number }>(
`SELECT id FROM order_refunds
WHERE source = $1 AND external_ref = $2
LIMIT 1`,
[source, externalRef],
)
if (duplicate.rows[0]) {
throw createHttpError('该平台退款单号已登记过,不能重复退款', {
statusCode: 409,
errorCode: 'order_refund_external_ref_exists',
})
}
}
const batchNo = generateRefundNo(new Date(refundedAt))
const insertedIds: number[] = []
for (const [index, row] of rows.entries()) {
const inserted = await client.query<{ id: number }>(
`
INSERT INTO order_refunds (
refund_no, batch_no, order_id, order_item_id, amount_fen,
reason, note, source, status, refunded_at, external_ref,
operator_admin_user_id, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, 'confirmed', $9::timestamptz, $10,
$11, $12::timestamptz, $12::timestamptz
)
RETURNING id
`,
[
`${batchNo}-${String(index + 1).padStart(2, '0')}`,
batchNo,
orderId,
row.itemId,
row.amountFen,
reason,
note,
source,
refundedAt,
externalRef,
session?.userId || null,
now,
],
)
insertedIds.push(Number(inserted.rows[0]?.id || 0))
try {
const inserted = await client.query<{ id: number }>(
`
INSERT INTO order_refunds (
refund_no, batch_no, order_id, order_item_id, amount_fen,
reason, note, source, status, refunded_at, external_ref,
operator_admin_user_id, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, 'confirmed', $9::timestamptz, $10,
$11, $12::timestamptz, $12::timestamptz
)
RETURNING id
`,
[
`${batchNo}-${String(index + 1).padStart(2, '0')}`,
batchNo,
orderId,
row.itemId,
row.amountFen,
reason,
note,
source,
refundedAt,
externalRef,
session?.userId || null,
now,
],
)
insertedIds.push(Number(inserted.rows[0]?.id || 0))
} catch (error) {
if ((error as { code?: string }).code === '23505') {
throw createHttpError('该平台退款单号已登记过,不能重复退款', {
statusCode: 409,
errorCode: 'order_refund_external_ref_exists',
})
}
throw error
}
}
// 订单项都退完 → 订单级 pay_status 同步为 refunded(与平台全额退款口径一致)。
@@ -327,10 +355,11 @@ export async function voidOrderRefund(
id: number
order_id: number
status: string
pay_status: string
basis_at: string
}>(
`
SELECT r.id, r.order_id, r.status, COALESCE(o.paid_at, o.created_at) AS basis_at
SELECT r.id, r.order_id, r.status, o.pay_status, COALESCE(o.paid_at, o.created_at) AS basis_at
FROM order_refunds r
JOIN orders o ON o.id = r.order_id
WHERE r.id = $1
@@ -351,6 +380,7 @@ export async function voidOrderRefund(
errorCode: 'order_refund_already_voided',
})
}
await assertBasisPeriodOpen(client, row.basis_at, '撤销退款')
await client.query(
`
UPDATE order_refunds
@@ -363,6 +393,50 @@ export async function voidOrderRefund(
`,
[now, reason, session?.userId || null, id],
)
// 撤销后按“剩余 confirmed 退款”重算订单支付状态:
// 全额退款撤销必须把订单从 refunded 恢复为 paid,否则销售事实仍会按全额退款冲减。
const totals = await client.query<{ revenue_fen: number; refunded_fen: number }>(
`
SELECT
COALESCE(SUM(GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)), 0)::bigint AS revenue_fen,
COALESCE((
SELECT SUM(r.amount_fen)
FROM order_refunds r
JOIN order_items ri ON ri.id = r.order_item_id
WHERE ri.order_id = $1 AND r.status = 'confirmed'
), 0)::bigint AS refunded_fen
FROM order_items oi
WHERE oi.order_id = $1
`,
[row.order_id],
)
const totalRevenue = Number(totals.rows[0]?.revenue_fen || 0)
const totalRefunded = Number(totals.rows[0]?.refunded_fen || 0)
let nextPayStatus = row.pay_status
if (totalRevenue > 0) {
nextPayStatus = totalRefunded >= totalRevenue ? 'refunded' : 'paid'
}
await client.query(
`UPDATE orders SET pay_status = $1, updated_at = $2::timestamptz WHERE id = $3`,
[nextPayStatus, now, row.order_id],
)
// 退款时自动关闭的成本异常,撤销后重新打开,避免成本缺口被永久忽略。
await client.query(
`
UPDATE cost_exceptions
SET status = 'open',
resolved_at = NULL,
resolved_by_admin_user_id = NULL,
resolution_note = ''
WHERE status = 'ignored'
AND resolution_note = '订单已退款,成本异常自动关闭'
AND order_item_id IN (SELECT id FROM order_items WHERE order_id = $1)
`,
[row.order_id],
)
return { orderId: Number(row.order_id), basisAt: row.basis_at }
})
@@ -404,6 +478,26 @@ export function mapOrderRefund(row: OrderRefundRow): OrderRefund {
}
}
async function assertBasisPeriodOpen(
client: Pick<PoolClient, 'query'>,
basisAt: string,
action: string,
) {
const basisDate = toChinaDate(basisAt)
if (!basisDate) return
const periodKey = basisDate.slice(0, 7)
const period = await client.query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
[periodKey],
)
if (period.rows[0]?.status === 'closed') {
throw createHttpError(`订单所属期间 ${periodKey} 已关账,请先反关账后再${action}`, {
statusCode: 409,
errorCode: 'order_refund_period_closed',
})
}
}
function normalizeId(value: unknown, label: string): number {
const id = Number(value || 0)
if (!Number.isFinite(id) || id <= 0) {
@@ -484,16 +578,6 @@ export async function approveAndRecordKuaishouRefund(
errorCode: 'platform_refund_order_item_required',
})
}
const itemExist = await query<{ id: number }>(
`SELECT id FROM order_items WHERE id = $1 AND order_id = $2 LIMIT 1`,
[orderItemId, orderId],
)
if (!itemExist.rows[0]) {
throw createHttpError('订单项不存在或不属于该订单', {
statusCode: 404,
errorCode: 'platform_refund_order_item_not_found',
})
}
const order = await query<{ provider: string; platform_order_id: string }>(
`SELECT provider, platform_order_id FROM orders WHERE id = $1 LIMIT 1`,
[orderId],
@@ -519,27 +603,228 @@ export async function approveAndRecordKuaishouRefund(
errorCode: 'platform_refund_amount_invalid',
})
}
const approval = await approveKuaishouIndustryRefund({
refundId: platformRefundId,
refundAmount: amountFen,
desc: input.desc || input.note || '',
refundHandingWay: input.refundHandingWay,
...(input.sellerId ? { sellerId: input.sellerId } : {}),
})
const refund = await createManualRefund(
{
orderId,
orderItemId,
amountFen,
reason: input.reason || '平台退款',
note: input.note,
refundedAt: input.refundedAt,
source: 'platform',
externalRef: platformRefundId,
},
session,
const existingItem = await query<{ id: number }>(
`SELECT id FROM order_items WHERE id = $1 AND order_id = $2 LIMIT 1`,
[orderItemId, orderId],
)
return { approval, ...refund }
if (!existingItem.rows[0]) {
throw createHttpError('订单项不存在或不属于该订单', {
statusCode: 404,
errorCode: 'platform_refund_order_item_not_found',
})
}
// 已关账期间不能直接改原期间事实;必须先反关账,否则平台退款后本地无法正确冲减。
const basis = await query<{ basis_at: string }>(
`SELECT COALESCE(paid_at, created_at) AS basis_at FROM orders WHERE id = $1 LIMIT 1`,
[orderId],
)
if (basis.rows[0]?.basis_at) {
const basisDate = toChinaDate(String(basis.rows[0].basis_at))
const periodKey = basisDate.slice(0, 7)
const period = await query<{ status: string }>(
`SELECT status FROM finance_periods WHERE period_key = $1 LIMIT 1`,
[periodKey],
)
if (period.rows[0]?.status === 'closed') {
throw createHttpError(`订单所属期间 ${periodKey} 已关账,请先反关账后再退款`, {
statusCode: 409,
errorCode: 'order_refund_period_closed',
})
}
}
// 已经登记过的平台退款必须幂等返回,不能因为剩余可退为 0 被误判为超额。
const recorded = await findRecordedPlatformRefund(orderId, platformRefundId)
if (recorded) {
return {
approval: { idempotent: true, platformRefundId },
batchNo: recorded.batchNo,
refunds: [recorded],
fullRefunded: false,
closedExceptionCount: 0,
}
}
// 先做可退金额校验,再落退款意图;平台调用之后不再做“才发现金额超了”的校验。
const remainingFen = await getRefundableFen(orderId, orderItemId)
if (amountFen > remainingFen) {
throw createHttpError(`退款金额超出可退金额(剩余 ${formatFen(remainingFen)})`, {
statusCode: 409,
errorCode: 'order_refund_amount_exceeds',
})
}
const intent = await beginPlatformRefundIntent({
provider: '91kaquan',
externalRef: platformRefundId,
orderId,
orderItemId,
amountFen,
actorUserId: session?.userId || null,
})
const needsPlatformCall =
intent.status === 'pending' ||
(intent.status === 'failed' && !String(intent.error_message || '').startsWith('record_failed:'))
let approval: unknown = { idempotent: true, platformRefundId }
if (needsPlatformCall) {
try {
approval = await approveKuaishouIndustryRefund({
refundId: platformRefundId,
refundAmount: amountFen,
desc: input.desc || input.note || '',
refundHandingWay: input.refundHandingWay,
...(input.sellerId ? { sellerId: input.sellerId } : {}),
})
await updatePlatformRefundIntent(intent.id, 'platform_approved')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await updatePlatformRefundIntent(intent.id, 'failed', `platform_call_failed:${message}`)
throw error
}
}
try {
const refund = await createManualRefund(
{
orderId,
orderItemId,
amountFen,
reason: input.reason || '平台退款',
note: input.note,
refundedAt: input.refundedAt,
source: 'platform',
externalRef: platformRefundId,
},
session,
)
await updatePlatformRefundIntent(intent.id, 'recorded')
return { approval, ...refund }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await updatePlatformRefundIntent(intent.id, 'failed', `record_failed:${message}`)
throw createHttpError(
`平台已同意退款,但本地记账失败:${message}。已保留退款意图,可用同一 platformRefundId 重试`,
{
statusCode: 502,
errorCode: 'platform_refund_record_failed',
},
)
}
}
type PlatformRefundIntentRow = {
id: number
status: string
error_message: string
}
async function beginPlatformRefundIntent(input: {
provider: string
externalRef: string
orderId: number
orderItemId: number
amountFen: number
actorUserId: number | null
}): Promise<PlatformRefundIntentRow> {
return withTransaction(async (client) => {
const existing = await client.query<PlatformRefundIntentRow>(
`SELECT id, status, error_message
FROM finance_refund_intents
WHERE provider = $1 AND external_ref = $2
FOR UPDATE`,
[input.provider, input.externalRef],
)
if (existing.rows[0]) return existing.rows[0]
try {
const inserted = await client.query<PlatformRefundIntentRow>(
`
INSERT INTO finance_refund_intents (
provider, external_ref, order_id, order_item_id, amount_fen,
status, error_message, created_by_admin_user_id, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, 'pending', '', $6, $7, $7)
RETURNING id, status, error_message
`,
[
input.provider,
input.externalRef,
input.orderId,
input.orderItemId,
input.amountFen,
input.actorUserId,
nowIso(),
],
)
return inserted.rows[0] as PlatformRefundIntentRow
} catch (error) {
// 并发请求同时插入时,唯一键冲突后读取已存在的意图,由调用方按状态决定是否重试。
if ((error as { code?: string }).code === '23505') {
const again = await client.query<PlatformRefundIntentRow>(
`SELECT id, status, error_message
FROM finance_refund_intents
WHERE provider = $1 AND external_ref = $2
LIMIT 1`,
[input.provider, input.externalRef],
)
return again.rows[0] as PlatformRefundIntentRow
}
throw error
}
})
}
async function updatePlatformRefundIntent(
id: number,
status: 'platform_approved' | 'recorded' | 'failed',
errorMessage = '',
) {
await query(
`UPDATE finance_refund_intents
SET status = $2, error_message = $3, updated_at = $4
WHERE id = $1`,
[id, status, errorMessage.slice(0, 1000), nowIso()],
)
}
async function getRefundableFen(orderId: number, orderItemId: number): Promise<number> {
const result = await query<{ remaining_fen: string }>(
`
SELECT
(GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)
- COALESCE((
SELECT SUM(r.amount_fen)
FROM order_refunds r
WHERE r.order_item_id = oi.id AND r.status = 'confirmed'
), 0))::bigint AS remaining_fen
FROM order_items oi
WHERE oi.id = $1 AND oi.order_id = $2
LIMIT 1
`,
[orderItemId, orderId],
)
if (!result.rows[0]) {
throw createHttpError('订单项不存在或不属于该订单', {
statusCode: 404,
errorCode: 'platform_refund_order_item_not_found',
})
}
return Number(result.rows[0].remaining_fen || 0)
}
async function findRecordedPlatformRefund(orderId: number, externalRef: string) {
const result = await query<{ id: number }>(
`SELECT id FROM order_refunds
WHERE order_id = $1 AND source = 'platform' AND external_ref = $2 AND status = 'confirmed'
LIMIT 1`,
[orderId, externalRef],
)
if (!result.rows[0]) return null
const refunds = await listOrderRefunds(orderId)
return refunds.find((item) => item.id === Number(result.rows[0]?.id)) || null
}
function normalizeRefundSource(value: unknown): 'manual' | 'api' | 'platform' | 'legacy' {
@@ -14,6 +14,7 @@ export type SalesFactsRebuildResult = {
range: { fromDate: string; toDate: string }
deletedCount: number
insertedCount: number
skippedClosedPeriods: string[]
computedAt: string
}
@@ -50,9 +51,26 @@ export async function rebuildSalesDailyFacts(
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })
const computedAt = nowIso()
// 已关账期间必须冻结:只重算开放期间,避免“总账已关账、报表事实还在变”。
const closedPeriods = await query<{ period_key: string }>(
`SELECT period_key
FROM finance_periods
WHERE status = 'closed'
AND period_key BETWEEN $1 AND $2
ORDER BY period_key`,
[range.fromDate.slice(0, 7), range.toDate.slice(0, 7)],
)
const skippedClosedPeriods = closedPeriods.rows.map((row) => row.period_key)
return withTransaction(async (client) => {
const deleted = await client.query(
`DELETE FROM sales_daily_facts WHERE stat_date BETWEEN $1::date AND $2::date`,
`DELETE FROM sales_daily_facts f
WHERE f.stat_date BETWEEN $1::date AND $2::date
AND NOT EXISTS (
SELECT 1 FROM finance_periods p
WHERE p.status = 'closed'
AND p.period_key = to_char(f.stat_date, 'YYYY-MM')
)`,
[range.fromDate, range.toDate],
)
@@ -103,6 +121,14 @@ export async function rebuildSalesDailyFacts(
WHERE o.pay_status IN ('paid', 'refunded')
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
BETWEEN $1::date AND $2::date
AND NOT EXISTS (
SELECT 1 FROM finance_periods p
WHERE p.status = 'closed'
AND p.period_key = to_char(
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date,
'YYYY-MM'
)
)
)
INSERT INTO sales_daily_facts (
stat_date, provider, shop_id, canonical_product_id, fulfillment_mode,
@@ -146,6 +172,7 @@ export async function rebuildSalesDailyFacts(
range,
deletedCount: deleted.rowCount || 0,
insertedCount: inserted.rowCount || 0,
skippedClosedPeriods,
computedAt,
}
})
@@ -163,6 +190,7 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
net_revenue_fen: number
cost_amount_fen: number
cost_missing_count: number
cost_missing_revenue_fen: number
gross_profit_fen: number
unmapped_revenue_fen: number
}>(
@@ -174,6 +202,9 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
COALESCE(SUM(net_revenue_fen), 0)::bigint AS net_revenue_fen,
COALESCE(SUM(cost_amount_fen), 0)::bigint AS cost_amount_fen,
COALESCE(SUM(cost_missing_count), 0)::int AS cost_missing_count,
COALESCE(
SUM(net_revenue_fen) FILTER (WHERE cost_missing_count > 0), 0
)::bigint AS cost_missing_revenue_fen,
COALESCE(SUM(gross_profit_fen), 0)::bigint AS gross_profit_fen,
COALESCE(SUM(gross_revenue_fen) FILTER (WHERE canonical_product_id IS NULL), 0)::bigint
AS unmapped_revenue_fen
@@ -301,6 +332,7 @@ export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
netRevenueFen: Number(summary?.net_revenue_fen || 0),
costAmountFen: Number(summary?.cost_amount_fen || 0),
costMissingCount: Number(summary?.cost_missing_count || 0),
costMissingRevenueFen: Number(summary?.cost_missing_revenue_fen || 0),
grossProfitFen: Number(summary?.gross_profit_fen || 0),
unmappedRevenueFen: unmappedRevenue,
unmappedRevenuePct:
@@ -1701,12 +1701,12 @@ test('拼单份数全部承接后不再显示在抢单大厅', () => {
/**
* 同一打手可以多次拼单(需求),所以「取消后重新加入」不再靠复用原份额行:
* 迁移 109 删掉了「一人一条」唯一键,每次参与都插入新份额,取消的历史份额保留追溯。
* 迁移 105 删掉了「一人一条」唯一键,每次参与都插入新份额,取消的历史份额保留追溯。
*/
test('拼单份额取消后原打手可以重新参与(一人一条唯一键已移除)', async () => {
const { readFile } = await import('node:fs/promises')
const migration = await readFile(
new URL('../../db/migrations/109_work_order_share_participation.sql', import.meta.url),
new URL('../../db/migrations/105_work_order_consignment.sql', import.meta.url),
'utf8',
)
assert.ok(