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:
+382
@@ -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 时该行毛利不可直接用于决策';
|
||||
+96
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user