feat(finance): 完成成本快照运行层、销售事实与人工支出登记
迁移 108-111:快照业务时间与预估转实际、销售事实表、系统成本项、公司人工支出登记。接单平台按工单实际 reward 入账;人工支出支持登记/审批/驳回/报销并写 actual 快照。新增销售毛利页、人工支出页与订单入口。
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
-- 108_cost_snapshot_runtime.sql —— 成本快照进入运行层。
|
||||
--
|
||||
-- 目标:
|
||||
-- 1. 成本版本按业务发生时间(订单支付/工单验收)解析,而不是迁移或回填时的 now();
|
||||
-- 2. 预估值可以在结算时被实际值替代,替代后旧快照保留但不再参与汇总;
|
||||
-- 3. 订单项固化收入金额,销售事实可以按订单项归集,不再直接拿整单金额。
|
||||
--
|
||||
-- 口径(2026-10-22 确认):
|
||||
-- * 收入按 paid_at 归属销售日;成本版本按 paid_at / accepted_at 解析;
|
||||
-- * 一单一品时订单收入全部归该订单项;多订单项按等额拆分,余数给最后一项,
|
||||
-- 并把 revenue_basis 标成 equal_split,后续有平台明细时再替换;
|
||||
-- * 历史快照不删除,只把被替代行标记 superseded_at。
|
||||
|
||||
ALTER TABLE cost_snapshots
|
||||
ADD COLUMN IF NOT EXISTS cost_basis_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS superseded_at TIMESTAMPTZ;
|
||||
|
||||
-- 已存在的快照发生在旧逻辑的 now(),没有更好的业务时间,先用 resolved_at 兜底。
|
||||
UPDATE cost_snapshots
|
||||
SET cost_basis_at = resolved_at
|
||||
WHERE cost_basis_at IS NULL;
|
||||
|
||||
ALTER TABLE cost_snapshots
|
||||
ALTER COLUMN cost_basis_at SET DEFAULT NOW();
|
||||
|
||||
-- 同一订单项/工单/人工单的同一成本项,只允许一条“当前有效”快照。
|
||||
DROP INDEX IF EXISTS uq_cost_snapshots_subject_item;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_active_subject_item
|
||||
ON cost_snapshots (subject_type, subject_id, cost_item_id)
|
||||
WHERE superseded_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cost_snapshots_active_subject
|
||||
ON cost_snapshots (subject_type, subject_id)
|
||||
WHERE superseded_at IS NULL;
|
||||
|
||||
COMMENT ON COLUMN cost_snapshots.cost_basis_at IS
|
||||
'解析成本版本时使用的业务时间:订单支付时间/工单验收时间/人工完成时间';
|
||||
COMMENT ON COLUMN cost_snapshots.superseded_at IS
|
||||
'被更新的实际成本替代的时间;非空行只保留历史,不再参与成本汇总';
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN IF NOT EXISTS revenue_amount_fen BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS revenue_basis TEXT NOT NULL DEFAULT 'unknown';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_revenue_amount'
|
||||
) THEN
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_revenue_amount
|
||||
CHECK (revenue_amount_fen >= 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_order_items_revenue_basis'
|
||||
) THEN
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_revenue_basis
|
||||
CHECK (revenue_basis IN ('order_total', 'equal_split', 'platform_item', 'manual', 'unknown'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 历史订单回填:当前生产快照全部是一单一品,仍按通用规则处理多订单项。
|
||||
WITH item_counts AS (
|
||||
SELECT order_id, COUNT(*)::int AS item_count
|
||||
FROM order_items
|
||||
GROUP BY order_id
|
||||
),
|
||||
ranked AS (
|
||||
SELECT
|
||||
oi.id,
|
||||
GREATEST(COALESCE(o.total_amount, 0), 0)::bigint AS order_revenue_fen,
|
||||
ic.item_count,
|
||||
ROW_NUMBER() OVER (PARTITION BY oi.order_id ORDER BY oi.id ASC) AS item_rank
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN item_counts ic ON ic.order_id = oi.order_id
|
||||
)
|
||||
UPDATE order_items oi
|
||||
SET
|
||||
revenue_amount_fen = CASE
|
||||
WHEN r.item_count = 1 THEN r.order_revenue_fen
|
||||
ELSE (r.order_revenue_fen / r.item_count)
|
||||
+ CASE WHEN r.item_rank = r.item_count THEN (r.order_revenue_fen % r.item_count) ELSE 0 END
|
||||
END,
|
||||
revenue_basis = CASE WHEN r.item_count = 1 THEN 'order_total' ELSE 'equal_split' END,
|
||||
updated_at = NOW()
|
||||
FROM ranked r
|
||||
WHERE oi.id = r.id
|
||||
AND oi.revenue_basis = 'unknown';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_revenue_basis
|
||||
ON order_items (revenue_basis);
|
||||
|
||||
COMMENT ON COLUMN order_items.revenue_amount_fen IS
|
||||
'订单项收入(分):一单一品=订单实付;多品=当前按等额拆分,basis 标明口径';
|
||||
COMMENT ON COLUMN order_items.revenue_basis IS
|
||||
'order_total=整单归属于唯一订单项;equal_split=多订单项等额拆分;platform_item=平台明细;manual/unknown=未定';
|
||||
@@ -0,0 +1,96 @@
|
||||
-- 109_sales_daily_facts.sql —— 店铺 × 日期 × 标准商品 × 履约载体的销售事实。
|
||||
--
|
||||
-- 口径(2026-10-22 确认):
|
||||
-- * 销售日 = orders.paid_at 的 Asia/Shanghai 日期;没有 paid_at 时退回 created_at;
|
||||
-- * 收入 = order_items.revenue_amount_fen(迁移 108 固化),不再直接拿整单金额;
|
||||
-- * 未归一商品允许 canonical_product_id 为空,单独成“未归一”行,保证总收入能对平;
|
||||
-- * 成本来自 cost_snapshots 当前有效行;没有快照的订单项计入 cost_missing_count;
|
||||
-- * 退款暂未接入,refund_amount_fen 先按 pay_status=refunded 预留。
|
||||
|
||||
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,
|
||||
carrier_type 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),
|
||||
carrier_type
|
||||
);
|
||||
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);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_sales_daily_facts_carrier'
|
||||
) THEN
|
||||
ALTER TABLE sales_daily_facts
|
||||
ADD CONSTRAINT chk_sales_daily_facts_carrier
|
||||
CHECK (carrier_type IN ('affiliate_dash', 'work_order', 'manual', 'shared', 'unknown'));
|
||||
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.cost_stage IS
|
||||
'missing=全部无成本快照;partial=部分无快照;estimated=全部预估;actual=全部实际';
|
||||
COMMENT ON COLUMN sales_daily_facts.cost_missing_count IS
|
||||
'没有当前有效成本快照的订单项数;>0 时该行毛利不可直接用于决策';
|
||||
@@ -0,0 +1,95 @@
|
||||
-- 110_system_cost_items.sql —— 内置接单平台实际报酬与人工客服成本项。
|
||||
--
|
||||
-- 背景:接单平台的成本金额在验收时已经由 work_orders.reward_amount 确定,
|
||||
-- 不需要财务再按模板猜一个预估价;人工客服成本则必须由人工支出登记给出。
|
||||
--
|
||||
-- 口径:
|
||||
-- 1. sys_work_order_reward 由系统维护,预估阶段不参与;工单验收后按实际
|
||||
-- reward_amount 直接写 actual 快照。版本金额 0 只是占位,不参与汇总。
|
||||
-- 2. sys_manual_service 由人工支出登记使用,先建草稿版本占位;
|
||||
-- 财务填写真实按单金额并启用后,才参与成本解析。
|
||||
|
||||
INSERT INTO cost_items (
|
||||
code, name, cost_type, carrier_type, pricing_mode, unit_name, currency,
|
||||
status, note, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'sys_work_order_reward',
|
||||
'接单平台打手报酬(实际结算)',
|
||||
'work_order_reward',
|
||||
'work_order',
|
||||
'per_order',
|
||||
'单',
|
||||
'CNY',
|
||||
'active',
|
||||
'系统内置:实际金额取工单 reward_amount;版本金额仅占位,不参与预估成本',
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET
|
||||
name = EXCLUDED.name,
|
||||
cost_type = EXCLUDED.cost_type,
|
||||
carrier_type = EXCLUDED.carrier_type,
|
||||
pricing_mode = EXCLUDED.pricing_mode,
|
||||
note = EXCLUDED.note,
|
||||
updated_at = NOW();
|
||||
|
||||
INSERT INTO cost_items (
|
||||
code, name, cost_type, carrier_type, pricing_mode, unit_name, currency,
|
||||
status, note, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'sys_manual_service',
|
||||
'人工客服履约成本',
|
||||
'manual_service',
|
||||
'manual',
|
||||
'per_order',
|
||||
'单',
|
||||
'CNY',
|
||||
'active',
|
||||
'系统内置:由公司人工支出登记写入 actual 快照;草稿版本待财务填写实际单价',
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET
|
||||
name = EXCLUDED.name,
|
||||
cost_type = EXCLUDED.cost_type,
|
||||
carrier_type = EXCLUDED.carrier_type,
|
||||
pricing_mode = EXCLUDED.pricing_mode,
|
||||
note = EXCLUDED.note,
|
||||
updated_at = NOW();
|
||||
|
||||
-- 接单平台:active 占位版本,供 cost_snapshots.cost_version_id 引用。
|
||||
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, source,
|
||||
status, change_reason, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
ci.id, 'global', '', '', NULL, NULL, 0, 0,
|
||||
'1970-01-01T00:00:00+08:00', 'manual', 'active',
|
||||
'系统占位:接单平台实际金额取工单 reward_amount',
|
||||
NOW(), NOW()
|
||||
FROM cost_items ci
|
||||
WHERE ci.code = 'sys_work_order_reward'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 人工客服:draft 版本,避免在财务未填写金额时静默按 0 计成本。
|
||||
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, source,
|
||||
status, change_reason, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
ci.id, 'global', '', '', NULL, NULL, 0, 0,
|
||||
'1970-01-01T00:00:00+08:00', 'manual', 'draft',
|
||||
'草稿占位:待财务填写人工客服实际按单成本后启用',
|
||||
NOW(), NOW()
|
||||
FROM cost_items ci
|
||||
WHERE ci.code = 'sys_manual_service'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMENT ON COLUMN cost_items.code IS
|
||||
'成本项编码;sys_ 前缀为系统内置项,业务侧不要删除或改编码';
|
||||
@@ -0,0 +1,161 @@
|
||||
-- 111_manual_expense_records.sql —— 公司人工支出/垫付登记。
|
||||
--
|
||||
-- 与迁移 100 的 `manual_expenses` 严格区分:
|
||||
-- manual_expenses = 客服备用金支出明细(减少客服余额)
|
||||
-- manual_expense_records = 公司口径人工支出/垫付登记(谁为哪个订单付了多少钱)
|
||||
-- 两者通过 fund_expense_id 关联,不合并、不改名、不共用索引名。
|
||||
--
|
||||
-- 本轮只做登记、审批、报销与冲正预留;影子总账分录在 finance_* 阶段接入。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_expense_records (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
expense_no TEXT NOT NULL,
|
||||
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
||||
order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL,
|
||||
work_order_id BIGINT REFERENCES work_orders(id) ON DELETE SET NULL,
|
||||
fund_expense_id BIGINT REFERENCES manual_expenses(id) ON DELETE SET NULL,
|
||||
cost_item_id BIGINT REFERENCES cost_items(id) ON DELETE RESTRICT,
|
||||
expense_category TEXT NOT NULL DEFAULT 'other',
|
||||
amount_fen BIGINT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'CNY',
|
||||
paid_by_admin_user_id BIGINT REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
paid_by_name TEXT NOT NULL DEFAULT '',
|
||||
payee_type TEXT NOT NULL DEFAULT 'other',
|
||||
payee_name TEXT NOT NULL DEFAULT '',
|
||||
payee_account TEXT NOT NULL DEFAULT '',
|
||||
payment_method TEXT NOT NULL DEFAULT 'other',
|
||||
paid_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'submitted',
|
||||
approved_by TEXT NOT NULL DEFAULT '',
|
||||
approved_at TIMESTAMPTZ,
|
||||
rejected_by TEXT NOT NULL DEFAULT '',
|
||||
rejected_at TIMESTAMPTZ,
|
||||
reject_reason TEXT NOT NULL DEFAULT '',
|
||||
reimbursed_by TEXT NOT NULL DEFAULT '',
|
||||
reimbursed_at TIMESTAMPTZ,
|
||||
evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
cost_snapshot_id BIGINT REFERENCES cost_snapshots(id) ON DELETE RESTRICT,
|
||||
reversal_of_id BIGINT REFERENCES manual_expense_records(id) ON DELETE RESTRICT,
|
||||
expense_batch_no TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expense_records_expense_no
|
||||
ON manual_expense_records (expense_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_order
|
||||
ON manual_expense_records (order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_item
|
||||
ON manual_expense_records (order_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_work_order
|
||||
ON manual_expense_records (work_order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_paid_at
|
||||
ON manual_expense_records (paid_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_status
|
||||
ON manual_expense_records (status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_cost_item
|
||||
ON manual_expense_records (cost_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_payer
|
||||
ON manual_expense_records (paid_by_admin_user_id, paid_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_manual_expense_records_fund_expense
|
||||
ON manual_expense_records (fund_expense_id)
|
||||
WHERE fund_expense_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_records_batch
|
||||
ON manual_expense_records (expense_batch_no)
|
||||
WHERE expense_batch_no <> '';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_amount'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_amount CHECK (amount_fen > 0);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_status'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_status
|
||||
CHECK (status IN ('draft', 'submitted', 'approved', 'rejected', 'reimbursed', 'reversed'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_payee_type'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_payee_type
|
||||
CHECK (payee_type IN ('worker', 'upstream', 'customer', 'platform', 'other'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_method'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_method
|
||||
CHECK (payment_method IN ('wechat', 'alipay', 'bank', 'cash', 'platform_balance', 'other'));
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_category'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_category
|
||||
CHECK (
|
||||
expense_category IN (
|
||||
'goods_purchase', 'compensation', 'refund_advance', 'service_fee',
|
||||
'freight', 'recharge', 'other'
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_approved'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_approved
|
||||
CHECK (
|
||||
status NOT IN ('approved', 'reimbursed')
|
||||
OR (approved_at IS NOT NULL AND approved_by <> '')
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_manual_expense_records_rejected'
|
||||
) THEN
|
||||
ALTER TABLE manual_expense_records
|
||||
ADD CONSTRAINT chk_manual_expense_records_rejected
|
||||
CHECK (
|
||||
status <> 'rejected'
|
||||
OR (rejected_at IS NOT NULL AND rejected_by <> '' AND reject_reason <> '')
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON TABLE manual_expense_records IS
|
||||
'公司人工支出登记:付款项目/金额/订单号/付款人/收款人/凭证;与客服备用金 manual_expenses 通过 fund_expense_id 关联';
|
||||
COMMENT ON COLUMN manual_expense_records.fund_expense_id IS
|
||||
'来源客服备用金支出(manual_expenses.id);公司直付为空';
|
||||
COMMENT ON COLUMN manual_expense_records.cost_item_id IS
|
||||
'付款项目 -> cost_items;为空表示未归类,报表必须暴露未归类占比';
|
||||
COMMENT ON COLUMN manual_expense_records.evidence_json IS
|
||||
'凭证:支付截图对象 key、流水号、聊天记录 key 等';
|
||||
COMMENT ON COLUMN manual_expense_records.reversal_of_id IS
|
||||
'预留:退款/仲裁接入后按反向登记冲正,禁止 UPDATE 已通过记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_expense_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
expense_id BIGINT NOT NULL REFERENCES manual_expense_records(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
from_status TEXT NOT NULL DEFAULT '',
|
||||
to_status TEXT NOT NULL DEFAULT '',
|
||||
actor_type TEXT NOT NULL DEFAULT 'admin',
|
||||
actor_id TEXT NOT NULL DEFAULT '',
|
||||
actor_name TEXT NOT NULL DEFAULT '',
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_events_expense
|
||||
ON manual_expense_events (expense_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expense_events_type
|
||||
ON manual_expense_events (event_type, created_at DESC);
|
||||
|
||||
COMMENT ON TABLE manual_expense_events IS
|
||||
'人工支出状态变更审计:只追加,不更新、不删除';
|
||||
@@ -47,6 +47,39 @@ export async function replaceOrderItems(
|
||||
)
|
||||
}
|
||||
|
||||
export async function applyOrderItemRevenueAllocationWithExecutor(
|
||||
executor: QueryExecutor,
|
||||
orderId: number | string,
|
||||
totalAmountFen: number,
|
||||
): Promise<number> {
|
||||
const revenue = Math.max(0, Math.trunc(Number(totalAmountFen) || 0))
|
||||
const result = await executor(
|
||||
`
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
ROW_NUMBER() OVER (ORDER BY id ASC) AS item_rank,
|
||||
(SELECT COUNT(*) FROM order_items WHERE order_id = $1) AS item_count
|
||||
FROM order_items
|
||||
WHERE order_id = $1
|
||||
)
|
||||
UPDATE order_items oi
|
||||
SET
|
||||
revenue_amount_fen = CASE
|
||||
WHEN r.item_count = 1 THEN $2::bigint
|
||||
ELSE ($2::bigint / r.item_count)
|
||||
+ CASE WHEN r.item_rank = r.item_count THEN ($2::bigint % r.item_count) ELSE 0 END
|
||||
END,
|
||||
revenue_basis = CASE WHEN r.item_count = 1 THEN 'order_total' ELSE 'equal_split' END,
|
||||
updated_at = $3
|
||||
FROM ranked r
|
||||
WHERE oi.id = r.id
|
||||
`,
|
||||
[orderId, revenue, new Date().toISOString()],
|
||||
)
|
||||
return result.rowCount || 0
|
||||
}
|
||||
|
||||
export async function replaceOrderItemsWithExecutor(
|
||||
executor: QueryExecutor,
|
||||
orderId: number | string,
|
||||
|
||||
@@ -31,6 +31,10 @@ import {
|
||||
updateAdminProduct,
|
||||
} from '../../services/admin/admin-cost-center-service.js'
|
||||
import type { AdminSession } from '../../services/admin/admin-auth-service.js'
|
||||
import {
|
||||
syncActualWorkOrderCostSnapshots,
|
||||
syncEstimatedOrderItemCostSnapshots,
|
||||
} from '../../services/cost-center/cost-snapshot-sync-service.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createJsonHandler, requireAdminPermission, requireAdminRoles } from './session.js'
|
||||
|
||||
@@ -490,4 +494,43 @@ router.post(
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cost-center/snapshots/sync',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler(
|
||||
async (req) => {
|
||||
const body = bodyOf(req)
|
||||
const mode = String(body.mode || 'both').trim()
|
||||
const estimated = mode === 'actual' ? null : await syncEstimatedOrderItemCostSnapshots(body)
|
||||
const actual = mode === 'estimated' ? null : await syncActualWorkOrderCostSnapshots(body)
|
||||
return { mode, estimated, actual }
|
||||
},
|
||||
{
|
||||
successMessage: '成本快照回填完成',
|
||||
errorMessage: '回填成本快照失败',
|
||||
scope: '[admin/cost-center/snapshots/sync]',
|
||||
audit: (req, data) => {
|
||||
const result = data as {
|
||||
mode?: string
|
||||
estimated?: { createdCount?: number; exceptionCreatedCount?: number } | null
|
||||
actual?: { createdCount?: number } | null
|
||||
}
|
||||
return {
|
||||
action: 'cost_snapshot_synced',
|
||||
targetType: 'cost_snapshot',
|
||||
targetId: '',
|
||||
data: {
|
||||
mode: result.mode || '',
|
||||
estimatedCreated: result.estimated?.createdCount || 0,
|
||||
actualCreated: result.actual?.createdCount || 0,
|
||||
exceptions: result.estimated?.exceptionCreatedCount || 0,
|
||||
dateFrom: req.body?.dateFrom || '',
|
||||
dateTo: req.body?.dateTo || '',
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,13 +1,48 @@
|
||||
import type { Request } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
getAdminFinanceSummary,
|
||||
listAdminFinanceTransactions,
|
||||
} from '../../services/admin/admin-finance-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
import type { AdminSession } from '../../services/admin/admin-auth-service.js'
|
||||
import {
|
||||
getSalesProfitOverview,
|
||||
listSalesDailyFacts,
|
||||
rebuildSalesDailyFacts,
|
||||
} from '../../services/finance/sales-facts-service.js'
|
||||
import {
|
||||
approveManualExpense,
|
||||
createManualExpense,
|
||||
getManualExpense,
|
||||
getManualExpenseOverview,
|
||||
listManualExpenseCostItems,
|
||||
listManualExpenses,
|
||||
rejectManualExpense,
|
||||
reimburseManualExpense,
|
||||
} from '../../services/finance/manual-expense-service.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import {
|
||||
createJsonHandler,
|
||||
requireAdminPermission,
|
||||
requireAdminRoles,
|
||||
requireAnyAdminPermission,
|
||||
} from './session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function sessionOf(req: Request): AdminSession {
|
||||
return req.adminSession as AdminSession
|
||||
}
|
||||
|
||||
function idOf(req: Request, key: string) {
|
||||
return Number(req.params[key])
|
||||
}
|
||||
|
||||
function bodyOf(req: Request): JsonObject {
|
||||
return req.body && typeof req.body === 'object' ? (req.body as JsonObject) : {}
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/finance/summary',
|
||||
requireAdminRoles(['admin', 'operator', 'finance']),
|
||||
@@ -28,4 +63,179 @@ router.get(
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/sales-profit/overview',
|
||||
requireAdminRoles(['admin', 'operator', 'finance']),
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler((req) => getSalesProfitOverview(req.query as Record<string, unknown>), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取销售毛利失败',
|
||||
scope: '[admin/finance/sales-profit/overview]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/sales-facts',
|
||||
requireAdminRoles(['admin', 'operator', 'finance']),
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler((req) => listSalesDailyFacts(req.query as Record<string, unknown>), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取销售事实失败',
|
||||
scope: '[admin/finance/sales-facts]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/sales-facts/rebuild',
|
||||
requireAdminRoles(['admin', 'operator', 'finance']),
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
rebuildSalesDailyFacts(
|
||||
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {},
|
||||
),
|
||||
{
|
||||
successMessage: '销售事实已重算',
|
||||
errorMessage: '重算销售事实失败',
|
||||
scope: '[admin/finance/sales-facts/rebuild]',
|
||||
audit: (req, data) => {
|
||||
const result = data as {
|
||||
range?: { fromDate?: string; toDate?: string }
|
||||
insertedCount?: number
|
||||
deletedCount?: number
|
||||
}
|
||||
return {
|
||||
action: 'sales_daily_facts_rebuilt',
|
||||
targetType: 'sales_daily_facts',
|
||||
targetId: '',
|
||||
data: {
|
||||
dateFrom: result.range?.fromDate || req.body?.dateFrom || '',
|
||||
dateTo: result.range?.toDate || req.body?.dateTo || '',
|
||||
insertedCount: result.insertedCount || 0,
|
||||
deletedCount: result.deletedCount || 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/manual-expenses/overview',
|
||||
requireAnyAdminPermission(['finance.view', 'fund.view']),
|
||||
createJsonHandler((req) => getManualExpenseOverview(req.query as Record<string, unknown>), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工支出汇总失败',
|
||||
scope: '[admin/finance/manual-expenses/overview]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/manual-expenses',
|
||||
requireAnyAdminPermission(['finance.view', 'fund.view']),
|
||||
createJsonHandler((req) => listManualExpenses(req.query as Record<string, unknown>), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工支出失败',
|
||||
scope: '[admin/finance/manual-expenses]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/manual-expenses/cost-items',
|
||||
requireAnyAdminPermission(['finance.view', 'fund.view']),
|
||||
createJsonHandler(() => listManualExpenseCostItems(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工支出成本项失败',
|
||||
scope: '[admin/finance/manual-expenses/cost-items]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/manual-expenses/:expenseId',
|
||||
requireAnyAdminPermission(['finance.view', 'fund.view']),
|
||||
createJsonHandler((req) => getManualExpense(idOf(req, 'expenseId')), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工支出详情失败',
|
||||
scope: '[admin/finance/manual-expenses/:expenseId]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-expenses',
|
||||
requireAnyAdminPermission(['fund.expense', 'finance.config']),
|
||||
createJsonHandler((req) => createManualExpense(bodyOf(req), sessionOf(req)), {
|
||||
successMessage: '人工支出已登记',
|
||||
errorMessage: '登记人工支出失败',
|
||||
scope: '[admin/finance/manual-expenses]',
|
||||
audit: (req, data) => {
|
||||
const record = data as { id?: number; expenseNo?: string; amountFen?: number }
|
||||
return {
|
||||
action: 'manual_expense_created',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String(record.id || ''),
|
||||
data: {
|
||||
expenseNo: record.expenseNo || '',
|
||||
amountFen: record.amountFen || 0,
|
||||
orderId: req.body?.orderId || '',
|
||||
orderItemId: req.body?.orderItemId || '',
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-expenses/:expenseId/approve',
|
||||
requireAnyAdminPermission(['finance.review']),
|
||||
createJsonHandler(
|
||||
(req) => approveManualExpense(idOf(req, 'expenseId'), bodyOf(req), sessionOf(req)),
|
||||
{
|
||||
successMessage: '人工支出已通过',
|
||||
errorMessage: '审核人工支出失败',
|
||||
scope: '[admin/finance/manual-expenses/:expenseId/approve]',
|
||||
audit: (req, data) => ({
|
||||
action: 'manual_expense_approved',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String((data as { id?: number })?.id || req.params.expenseId || ''),
|
||||
data: { note: req.body?.note || '' },
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-expenses/:expenseId/reject',
|
||||
requireAnyAdminPermission(['finance.review']),
|
||||
createJsonHandler(
|
||||
(req) => rejectManualExpense(idOf(req, 'expenseId'), bodyOf(req), sessionOf(req)),
|
||||
{
|
||||
successMessage: '人工支出已驳回',
|
||||
errorMessage: '驳回人工支出失败',
|
||||
scope: '[admin/finance/manual-expenses/:expenseId/reject]',
|
||||
audit: (req, data) => ({
|
||||
action: 'manual_expense_rejected',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String((data as { id?: number })?.id || req.params.expenseId || ''),
|
||||
data: { reason: req.body?.rejectReason || req.body?.reject_reason || '' },
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-expenses/:expenseId/reimburse',
|
||||
requireAnyAdminPermission(['finance.review']),
|
||||
createJsonHandler((req) => reimburseManualExpense(idOf(req, 'expenseId'), sessionOf(req)), {
|
||||
successMessage: '人工支出已标记报销',
|
||||
errorMessage: '标记报销失败',
|
||||
scope: '[admin/finance/manual-expenses/:expenseId/reimburse]',
|
||||
audit: (req, data) => ({
|
||||
action: 'manual_expense_reimbursed',
|
||||
targetType: 'manual_expense',
|
||||
targetId: String((data as { id?: number })?.id || req.params.expenseId || ''),
|
||||
data: {},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -200,6 +200,7 @@ type OrderItemCostContext = {
|
||||
normalizedQuantity: number
|
||||
canonicalProductId: number | null
|
||||
revenueFen: number
|
||||
basisAt: string
|
||||
workOrderId: number | null
|
||||
workProductRuleId: number | null
|
||||
hasAffiliateDashTask: boolean
|
||||
@@ -2632,6 +2633,8 @@ export async function previewAdminCost(payload: JsonObject = {}) {
|
||||
const mapping = await findMappingForContext(context)
|
||||
const productId = context.canonicalProductId || mapping?.canonical_product_id || null
|
||||
const product = productId ? mapProduct(await getProductRow(productId)) : null
|
||||
const asOf =
|
||||
normalizeIsoDate(payload.asOf ?? payload.costBasisAt, '成本基准时间', true) || context.basisAt
|
||||
const resolution: CostResolution = productId
|
||||
? await resolveCostLines({
|
||||
provider: context.provider,
|
||||
@@ -2641,6 +2644,7 @@ export async function previewAdminCost(payload: JsonObject = {}) {
|
||||
workProductRuleId: context.workProductRuleId,
|
||||
quantity: context.normalizedQuantity,
|
||||
revenueFen: context.revenueFen,
|
||||
asOf,
|
||||
})
|
||||
: { lines: [], missingCostItems: [], applicableCostItemCount: 0 }
|
||||
|
||||
@@ -2677,6 +2681,7 @@ export async function previewAdminCost(payload: JsonObject = {}) {
|
||||
return {
|
||||
context: {
|
||||
...context,
|
||||
basisAt: asOf,
|
||||
carrierType,
|
||||
},
|
||||
mapping: mapping ? mapMapping(mapping) : null,
|
||||
@@ -2698,23 +2703,61 @@ export async function createAdminCostSnapshot(payload: JsonObject = {}, _session
|
||||
})
|
||||
}
|
||||
const now = nowIso()
|
||||
const stage = payload.snapshotStage === 'actual' ? 'actual' : 'estimated'
|
||||
const snapshots: Array<Record<string, unknown>> = []
|
||||
let exceptionCreatedCount = 0
|
||||
let supersededCount = 0
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
for (const line of preview.lines) {
|
||||
const currentResult = await client.query<{
|
||||
id: number
|
||||
snapshot_stage: string
|
||||
total_amount_fen: number
|
||||
}>(
|
||||
`
|
||||
SELECT id, snapshot_stage, total_amount_fen
|
||||
FROM cost_snapshots
|
||||
WHERE subject_type = 'order_item'
|
||||
AND subject_id = $1
|
||||
AND cost_item_id = $2
|
||||
AND superseded_at IS NULL
|
||||
FOR UPDATE
|
||||
`,
|
||||
[orderItemId, line.costItemId],
|
||||
)
|
||||
const current = currentResult.rows[0]
|
||||
if (current) {
|
||||
// 预估快照一旦生成就代表支付时点的成本,不因后续改价重写;
|
||||
// 只有实际成本允许替代当前有效行。
|
||||
if (stage === 'estimated') continue
|
||||
if (
|
||||
current.snapshot_stage === 'actual' &&
|
||||
Number(current.total_amount_fen) === line.totalAmountFen
|
||||
) {
|
||||
continue
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE cost_snapshots SET superseded_at = $1 WHERE id = $2 AND superseded_at IS NULL`,
|
||||
[now, current.id],
|
||||
)
|
||||
supersededCount += 1
|
||||
}
|
||||
|
||||
const result = await client.query<{ [column: string]: unknown }>(
|
||||
`
|
||||
INSERT INTO cost_snapshots (
|
||||
subject_type, subject_id, order_id, order_item_id, work_order_id,
|
||||
carrier_type, cost_item_id, cost_version_id, canonical_product_id,
|
||||
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
|
||||
metadata_json, resolved_at, created_at
|
||||
metadata_json, cost_basis_at, resolved_at, created_at
|
||||
) VALUES (
|
||||
'order_item', $1, $2, $1, $3, $4, $5, $6, $7,
|
||||
$8, $9, $10, $11, 'resolved', $12::jsonb, $13, $13
|
||||
$8, $9, $10, $11, 'resolved', $12::jsonb, $13, $14, $14
|
||||
)
|
||||
ON CONFLICT (subject_type, subject_id, cost_item_id) DO NOTHING
|
||||
ON CONFLICT (subject_type, subject_id, cost_item_id)
|
||||
WHERE superseded_at IS NULL
|
||||
DO NOTHING
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
@@ -2728,14 +2771,16 @@ export async function createAdminCostSnapshot(payload: JsonObject = {}, _session
|
||||
line.quantity,
|
||||
line.unitAmountFen,
|
||||
line.totalAmountFen,
|
||||
payload.snapshotStage === 'actual' ? 'actual' : 'estimated',
|
||||
stage,
|
||||
JSON.stringify({
|
||||
skuCode: preview.context.skuCode,
|
||||
skuName: preview.context.skuName,
|
||||
provider: preview.context.provider,
|
||||
shopId: preview.context.shopId,
|
||||
normalizedQuantity: preview.context.normalizedQuantity,
|
||||
basisAt: preview.context.basisAt,
|
||||
}),
|
||||
preview.context.basisAt,
|
||||
now,
|
||||
],
|
||||
)
|
||||
@@ -2768,7 +2813,13 @@ export async function createAdminCostSnapshot(payload: JsonObject = {}, _session
|
||||
}
|
||||
})
|
||||
|
||||
return { preview, snapshots, createdCount: snapshots.length, exceptionCreatedCount }
|
||||
return {
|
||||
preview,
|
||||
snapshots,
|
||||
createdCount: snapshots.length,
|
||||
supersededCount,
|
||||
exceptionCreatedCount,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostContext> {
|
||||
@@ -2787,6 +2838,7 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
|
||||
normalized_quantity: number | null
|
||||
canonical_product_id: number | null
|
||||
revenue_fen: number
|
||||
basis_at: string
|
||||
work_order_id: number | null
|
||||
work_product_rule_id: number | null
|
||||
has_affiliate_dash_task: boolean
|
||||
@@ -2804,7 +2856,8 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
|
||||
oi.quantity,
|
||||
oi.normalized_quantity,
|
||||
oi.canonical_product_id,
|
||||
o.total_amount AS revenue_fen,
|
||||
oi.revenue_amount_fen AS revenue_fen,
|
||||
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
||||
wo.id AS work_order_id,
|
||||
wo.product_rule_id AS work_product_rule_id,
|
||||
EXISTS (
|
||||
@@ -2813,7 +2866,13 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
|
||||
) AS has_affiliate_dash_task
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, product_rule_id
|
||||
FROM work_orders
|
||||
WHERE order_item_id = oi.id
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
) wo ON TRUE
|
||||
WHERE oi.id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
@@ -2844,6 +2903,7 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
|
||||
: Math.max(1, resolveSkuNameQuantity(row.sku_name || row.sku_code)),
|
||||
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
|
||||
revenueFen: Math.max(0, Number(row.revenue_fen) || 0),
|
||||
basisAt: new Date(row.basis_at).toISOString(),
|
||||
workOrderId: row.work_order_id ? Number(row.work_order_id) : null,
|
||||
workProductRuleId: row.work_product_rule_id ? Number(row.work_product_rule_id) : null,
|
||||
hasAffiliateDashTask: Boolean(row.has_affiliate_dash_task),
|
||||
@@ -2869,6 +2929,8 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
|
||||
payload.canonicalProductId ?? payload.canonical_product_id,
|
||||
),
|
||||
revenueFen: Math.max(0, Math.trunc(Number(payload.revenueFen ?? payload.revenue_fen) || 0)),
|
||||
basisAt:
|
||||
normalizeIsoDate(payload.asOf ?? payload.costBasisAt, '成本基准时间', true) || nowIso(),
|
||||
workOrderId: null,
|
||||
workProductRuleId: normalizeOptionalId(
|
||||
payload.workProductRuleId ?? payload.work_product_rule_id,
|
||||
@@ -2933,6 +2995,7 @@ async function resolveCostLines(input: {
|
||||
workProductRuleId: number | null
|
||||
quantity: number
|
||||
revenueFen: number
|
||||
asOf?: string
|
||||
}): Promise<CostResolution> {
|
||||
const result = await query<
|
||||
CostItemRow & {
|
||||
@@ -2981,7 +3044,7 @@ async function resolveCostLines(input: {
|
||||
ORDER BY ci.id
|
||||
`,
|
||||
[
|
||||
nowIso(),
|
||||
input.asOf || nowIso(),
|
||||
input.provider,
|
||||
input.shopId,
|
||||
input.canonicalProductId,
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { resolveFinanceDateRange } from '../../utils/finance-date-range.js'
|
||||
|
||||
export type CostSnapshotSyncInput = {
|
||||
dateFrom?: unknown
|
||||
dateTo?: unknown
|
||||
from?: unknown
|
||||
to?: unknown
|
||||
}
|
||||
|
||||
export type CostSnapshotSyncResult = {
|
||||
range: { fromDate: string; toDate: string }
|
||||
createdCount: number
|
||||
exceptionCreatedCount: number
|
||||
resolvedExceptionCount: number
|
||||
}
|
||||
|
||||
export type ActualCostSnapshotSyncResult = {
|
||||
range: { fromDate: string; toDate: string }
|
||||
createdCount: number
|
||||
upgradedCount: number
|
||||
directRewardCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 按订单项批量生成预估成本快照。
|
||||
*
|
||||
* 只处理已有 canonical_product_id 的订单项;未归一订单项进入
|
||||
* product_mapping_missing 异常,不在这里猜测商品。
|
||||
*/
|
||||
export async function syncEstimatedOrderItemCostSnapshots(
|
||||
input: CostSnapshotSyncInput = {},
|
||||
): Promise<CostSnapshotSyncResult> {
|
||||
const range = resolveFinanceDateRange(input, { defaultDays: 7, maxDays: 400 })
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
const inserted = await client.query(
|
||||
`
|
||||
WITH item_scope AS (
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
oi.order_id,
|
||||
o.provider,
|
||||
o.shop_id,
|
||||
oi.sku_code,
|
||||
oi.sku_name,
|
||||
oi.canonical_product_id,
|
||||
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
||||
GREATEST(COALESCE(oi.normalized_quantity, oi.quantity, 1), 1)::numeric AS quantity,
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
wo.id AS work_order_id,
|
||||
wo.product_rule_id AS work_product_rule_id,
|
||||
CASE
|
||||
WHEN wo.id IS NOT NULL THEN 'work_order'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id
|
||||
AND ft.executor_key = 'affiliate_dash'
|
||||
) THEN 'affiliate_dash'
|
||||
ELSE 'manual'
|
||||
END AS carrier_type
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, product_rule_id
|
||||
FROM work_orders
|
||||
WHERE order_item_id = oi.id
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
) wo ON TRUE
|
||||
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
|
||||
),
|
||||
resolved AS (
|
||||
SELECT
|
||||
s.*,
|
||||
ci.id AS cost_item_id,
|
||||
ci.pricing_mode,
|
||||
v.id AS cost_version_id,
|
||||
v.amount_fen,
|
||||
v.rate_bps
|
||||
FROM item_scope s
|
||||
JOIN cost_items ci ON ci.status = 'active'
|
||||
AND ci.carrier_type IN (s.carrier_type, 'shared')
|
||||
AND ci.pricing_mode IN ('per_item', 'per_order', 'percent_of_revenue')
|
||||
AND ci.code <> 'sys_work_order_reward'
|
||||
JOIN LATERAL (
|
||||
SELECT v.id, v.amount_fen, v.rate_bps
|
||||
FROM cost_versions v
|
||||
WHERE v.cost_item_id = ci.id
|
||||
AND v.status = 'active'
|
||||
AND v.effective_from <= s.basis_at
|
||||
AND (v.effective_to IS NULL OR v.effective_to > s.basis_at)
|
||||
AND (
|
||||
(v.scope_type = 'rule' AND s.work_product_rule_id IS NOT NULL
|
||||
AND v.work_product_rule_id = s.work_product_rule_id)
|
||||
OR (v.scope_type = 'product' AND s.canonical_product_id IS NOT NULL
|
||||
AND v.canonical_product_id = s.canonical_product_id)
|
||||
OR (v.scope_type = 'shop' AND v.provider = s.provider AND v.shop_id = s.shop_id)
|
||||
OR (v.scope_type = 'provider' AND v.provider = s.provider)
|
||||
OR (v.scope_type = 'global')
|
||||
)
|
||||
ORDER BY
|
||||
CASE v.scope_type
|
||||
WHEN 'rule' THEN 1
|
||||
WHEN 'product' THEN 2
|
||||
WHEN 'shop' THEN 3
|
||||
WHEN 'provider' THEN 4
|
||||
ELSE 5
|
||||
END,
|
||||
v.effective_from DESC,
|
||||
v.id DESC
|
||||
LIMIT 1
|
||||
) v ON TRUE
|
||||
WHERE s.canonical_product_id IS NOT NULL
|
||||
),
|
||||
calculated AS (
|
||||
SELECT
|
||||
r.*,
|
||||
CASE r.pricing_mode
|
||||
WHEN 'per_item' THEN ROUND(r.amount_fen::numeric * r.quantity)::bigint
|
||||
WHEN 'per_order' THEN r.amount_fen
|
||||
WHEN 'percent_of_revenue'
|
||||
THEN ROUND((r.revenue_fen::numeric * r.rate_bps::numeric) / 10000.0)::bigint
|
||||
ELSE r.amount_fen
|
||||
END AS total_amount_fen
|
||||
FROM resolved r
|
||||
)
|
||||
INSERT INTO cost_snapshots (
|
||||
subject_type, subject_id, order_id, order_item_id, work_order_id,
|
||||
carrier_type, cost_item_id, cost_version_id, canonical_product_id,
|
||||
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
|
||||
metadata_json, cost_basis_at, resolved_at, created_at
|
||||
)
|
||||
SELECT
|
||||
'order_item', c.order_item_id, c.order_id, c.order_item_id, c.work_order_id,
|
||||
c.carrier_type, c.cost_item_id, c.cost_version_id, c.canonical_product_id,
|
||||
CASE WHEN c.pricing_mode = 'per_item' THEN c.quantity ELSE 1 END,
|
||||
CASE WHEN c.pricing_mode = 'percent_of_revenue' THEN c.total_amount_fen ELSE c.amount_fen END,
|
||||
c.total_amount_fen,
|
||||
'estimated', 'resolved',
|
||||
jsonb_build_object(
|
||||
'provider', c.provider,
|
||||
'shopId', c.shop_id,
|
||||
'skuCode', c.sku_code,
|
||||
'skuName', c.sku_name,
|
||||
'pricingMode', c.pricing_mode,
|
||||
'revenueFen', c.revenue_fen,
|
||||
'normalizedQuantity', c.quantity,
|
||||
'basisAt', c.basis_at
|
||||
),
|
||||
c.basis_at, NOW(), NOW()
|
||||
FROM calculated c
|
||||
ON CONFLICT (subject_type, subject_id, cost_item_id)
|
||||
WHERE superseded_at IS NULL
|
||||
DO NOTHING
|
||||
`,
|
||||
[range.fromDate, range.toDate],
|
||||
)
|
||||
|
||||
const productExceptions = await insertAndResolveProductMappingExceptions(
|
||||
client,
|
||||
range.fromDate,
|
||||
range.toDate,
|
||||
)
|
||||
const costExceptions = await insertAndResolveCostMissingExceptions(
|
||||
client,
|
||||
range.fromDate,
|
||||
range.toDate,
|
||||
)
|
||||
|
||||
return {
|
||||
range,
|
||||
createdCount: inserted.rowCount || 0,
|
||||
exceptionCreatedCount: productExceptions.created + costExceptions.created,
|
||||
resolvedExceptionCount: productExceptions.resolved + costExceptions.resolved,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单验收后,把订单项当前预估快照升级为实际快照。
|
||||
*
|
||||
* 如果配置了 work_order_reward 成本项,则用工单实际 reward_amount 覆盖金额;
|
||||
* 否则仍冻结当前版本金额,但把阶段标为 actual。旧快照保留 superseded_at。
|
||||
*/
|
||||
export async function syncActualWorkOrderCostSnapshots(
|
||||
input: CostSnapshotSyncInput = {},
|
||||
): Promise<ActualCostSnapshotSyncResult> {
|
||||
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
const directReward = await syncDirectWorkOrderRewardSnapshots(
|
||||
client,
|
||||
range.fromDate,
|
||||
range.toDate,
|
||||
)
|
||||
const upgraded = await client.query(
|
||||
`
|
||||
WITH accepted AS (
|
||||
SELECT DISTINCT ON (wo.order_item_id)
|
||||
wo.id AS work_order_id,
|
||||
wo.order_item_id,
|
||||
GREATEST(COALESCE(wo.reward_amount, 0), 0)::bigint AS reward_amount,
|
||||
COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at, NOW()) AS accepted_at
|
||||
FROM work_orders wo
|
||||
JOIN order_items oi ON oi.id = wo.order_item_id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE wo.status = 'accepted'
|
||||
AND wo.order_item_id IS NOT NULL
|
||||
AND (COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at)
|
||||
AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date
|
||||
ORDER BY wo.order_item_id, wo.id DESC
|
||||
),
|
||||
active AS (
|
||||
SELECT
|
||||
cs.*,
|
||||
a.work_order_id AS actual_work_order_id,
|
||||
a.reward_amount,
|
||||
a.accepted_at,
|
||||
ci.cost_type
|
||||
FROM cost_snapshots cs
|
||||
JOIN accepted a ON cs.subject_type = 'order_item' AND cs.subject_id = a.order_item_id
|
||||
JOIN cost_items ci ON ci.id = cs.cost_item_id
|
||||
WHERE cs.superseded_at IS NULL
|
||||
AND cs.snapshot_stage IN ('estimated', 'actual')
|
||||
AND ci.code <> 'sys_work_order_reward'
|
||||
),
|
||||
target AS (
|
||||
SELECT *
|
||||
FROM active
|
||||
WHERE snapshot_stage = 'estimated'
|
||||
OR total_amount_fen <> CASE
|
||||
WHEN cost_type = 'work_order_reward' AND reward_amount > 0 THEN reward_amount
|
||||
ELSE total_amount_fen
|
||||
END
|
||||
),
|
||||
superseded AS (
|
||||
UPDATE cost_snapshots cs
|
||||
SET superseded_at = NOW()
|
||||
FROM target t
|
||||
WHERE cs.id = t.id
|
||||
AND cs.superseded_at IS NULL
|
||||
RETURNING
|
||||
cs.id, cs.subject_id, cs.order_id, cs.order_item_id, cs.carrier_type,
|
||||
cs.cost_item_id, cs.cost_version_id, cs.canonical_product_id,
|
||||
cs.quantity, cs.unit_amount_fen, cs.total_amount_fen, cs.metadata_json
|
||||
)
|
||||
INSERT INTO cost_snapshots (
|
||||
subject_type, subject_id, order_id, order_item_id, work_order_id,
|
||||
carrier_type, cost_item_id, cost_version_id, canonical_product_id,
|
||||
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
|
||||
metadata_json, cost_basis_at, resolved_at, created_at
|
||||
)
|
||||
SELECT
|
||||
'order_item', s.subject_id, s.order_id, s.order_item_id, a.work_order_id,
|
||||
s.carrier_type, s.cost_item_id, s.cost_version_id, s.canonical_product_id,
|
||||
CASE WHEN ci.cost_type = 'work_order_reward' AND a.reward_amount > 0 THEN 1
|
||||
ELSE s.quantity END,
|
||||
CASE WHEN ci.cost_type = 'work_order_reward' AND a.reward_amount > 0
|
||||
THEN a.reward_amount ELSE s.unit_amount_fen END,
|
||||
CASE WHEN ci.cost_type = 'work_order_reward' AND a.reward_amount > 0
|
||||
THEN a.reward_amount ELSE s.total_amount_fen END,
|
||||
'actual', 'resolved',
|
||||
s.metadata_json || jsonb_build_object(
|
||||
'actualWorkOrderId', a.work_order_id,
|
||||
'actualRewardFen', a.reward_amount,
|
||||
'basisAt', a.accepted_at
|
||||
),
|
||||
a.accepted_at, NOW(), NOW()
|
||||
FROM superseded s
|
||||
JOIN accepted a ON a.order_item_id = s.subject_id
|
||||
JOIN cost_items ci ON ci.id = s.cost_item_id
|
||||
ON CONFLICT (subject_type, subject_id, cost_item_id)
|
||||
WHERE superseded_at IS NULL
|
||||
DO NOTHING
|
||||
`,
|
||||
[range.fromDate, range.toDate],
|
||||
)
|
||||
|
||||
return {
|
||||
range,
|
||||
createdCount: directReward.inserted + (upgraded.rowCount || 0),
|
||||
upgradedCount: upgraded.rowCount || 0,
|
||||
directRewardCount: directReward.inserted,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 接单平台的实际成本不依赖预估快照:工单验收后直接按 reward_amount 写 actual。
|
||||
* 系统成本项 sys_work_order_reward 的版本金额只是占位,不会进入预估阶段。
|
||||
*/
|
||||
async function syncDirectWorkOrderRewardSnapshots(
|
||||
client: PoolClient,
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
) {
|
||||
const superseded = await client.query(
|
||||
`
|
||||
WITH system_item AS (
|
||||
SELECT ci.id AS cost_item_id, v.id AS cost_version_id
|
||||
FROM cost_items ci
|
||||
JOIN LATERAL (
|
||||
SELECT id
|
||||
FROM cost_versions
|
||||
WHERE cost_item_id = ci.id
|
||||
AND status = 'active'
|
||||
AND effective_from <= NOW()
|
||||
ORDER BY effective_from DESC, id DESC
|
||||
LIMIT 1
|
||||
) v ON TRUE
|
||||
WHERE ci.code = 'sys_work_order_reward'
|
||||
AND ci.status = 'active'
|
||||
),
|
||||
accepted AS (
|
||||
SELECT DISTINCT ON (wo.order_item_id)
|
||||
wo.id AS work_order_id,
|
||||
wo.order_item_id,
|
||||
GREATEST(COALESCE(wo.reward_amount, 0), 0)::bigint AS reward_amount,
|
||||
COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at, NOW()) AS accepted_at
|
||||
FROM work_orders wo
|
||||
JOIN order_items oi ON oi.id = wo.order_item_id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE wo.status = 'accepted'
|
||||
AND wo.order_item_id IS NOT NULL
|
||||
AND COALESCE(wo.reward_amount, 0) > 0
|
||||
AND (COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at)
|
||||
AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date
|
||||
ORDER BY wo.order_item_id, wo.id DESC
|
||||
)
|
||||
UPDATE cost_snapshots cs
|
||||
SET superseded_at = NOW()
|
||||
FROM accepted a
|
||||
JOIN system_item si ON TRUE
|
||||
JOIN LATERAL (
|
||||
SELECT id, snapshot_stage, total_amount_fen
|
||||
FROM cost_snapshots
|
||||
WHERE subject_type = 'order_item'
|
||||
AND subject_id = a.order_item_id
|
||||
AND cost_item_id = si.cost_item_id
|
||||
AND superseded_at IS NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
) current ON TRUE
|
||||
WHERE cs.id = current.id
|
||||
AND cs.superseded_at IS NULL
|
||||
AND (current.snapshot_stage <> 'actual' OR current.total_amount_fen <> a.reward_amount)
|
||||
`,
|
||||
[fromDate, toDate],
|
||||
)
|
||||
|
||||
const inserted = await client.query(
|
||||
`
|
||||
WITH system_item AS (
|
||||
SELECT ci.id AS cost_item_id, v.id AS cost_version_id
|
||||
FROM cost_items ci
|
||||
JOIN LATERAL (
|
||||
SELECT id
|
||||
FROM cost_versions
|
||||
WHERE cost_item_id = ci.id
|
||||
AND status = 'active'
|
||||
AND effective_from <= NOW()
|
||||
ORDER BY effective_from DESC, id DESC
|
||||
LIMIT 1
|
||||
) v ON TRUE
|
||||
WHERE ci.code = 'sys_work_order_reward'
|
||||
AND ci.status = 'active'
|
||||
),
|
||||
accepted AS (
|
||||
SELECT DISTINCT ON (wo.order_item_id)
|
||||
wo.id AS work_order_id,
|
||||
wo.order_item_id,
|
||||
GREATEST(COALESCE(wo.reward_amount, 0), 0)::bigint AS reward_amount,
|
||||
COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at, NOW()) AS accepted_at
|
||||
FROM work_orders wo
|
||||
JOIN order_items oi ON oi.id = wo.order_item_id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE wo.status = 'accepted'
|
||||
AND wo.order_item_id IS NOT NULL
|
||||
AND COALESCE(wo.reward_amount, 0) > 0
|
||||
AND (COALESCE(wo.accepted_at, wo.updated_at, o.paid_at, o.created_at)
|
||||
AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date
|
||||
ORDER BY wo.order_item_id, wo.id DESC
|
||||
)
|
||||
INSERT INTO cost_snapshots (
|
||||
subject_type, subject_id, order_id, order_item_id, work_order_id,
|
||||
carrier_type, cost_item_id, cost_version_id, canonical_product_id,
|
||||
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
|
||||
metadata_json, cost_basis_at, resolved_at, created_at
|
||||
)
|
||||
SELECT
|
||||
'order_item', a.order_item_id, oi.order_id, a.order_item_id, a.work_order_id,
|
||||
'work_order', si.cost_item_id, si.cost_version_id, oi.canonical_product_id,
|
||||
1, a.reward_amount, a.reward_amount, 'actual', 'resolved',
|
||||
jsonb_build_object(
|
||||
'source', 'system_work_order_reward',
|
||||
'actualWorkOrderId', a.work_order_id,
|
||||
'actualRewardFen', a.reward_amount,
|
||||
'basisAt', a.accepted_at
|
||||
),
|
||||
a.accepted_at, NOW(), NOW()
|
||||
FROM accepted a
|
||||
CROSS JOIN system_item si
|
||||
JOIN order_items oi ON oi.id = a.order_item_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM cost_snapshots cs
|
||||
WHERE cs.subject_type = 'order_item'
|
||||
AND cs.subject_id = a.order_item_id
|
||||
AND cs.cost_item_id = si.cost_item_id
|
||||
AND cs.superseded_at IS NULL
|
||||
AND cs.snapshot_stage = 'actual'
|
||||
AND cs.total_amount_fen = a.reward_amount
|
||||
)
|
||||
ON CONFLICT (subject_type, subject_id, cost_item_id)
|
||||
WHERE superseded_at IS NULL
|
||||
DO NOTHING
|
||||
`,
|
||||
[fromDate, toDate],
|
||||
)
|
||||
|
||||
return {
|
||||
inserted: inserted.rowCount || 0,
|
||||
superseded: superseded.rowCount || 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function insertAndResolveProductMappingExceptions(
|
||||
client: PoolClient,
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
) {
|
||||
const inserted = await client.query(
|
||||
`
|
||||
WITH item_scope AS (
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
o.provider,
|
||||
o.shop_id,
|
||||
oi.sku_code,
|
||||
oi.sku_name,
|
||||
oi.canonical_product_id,
|
||||
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
||||
wo.id AS work_order_id,
|
||||
CASE
|
||||
WHEN wo.id IS NOT NULL THEN 'work_order'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
) THEN 'affiliate_dash'
|
||||
ELSE 'manual'
|
||||
END AS carrier_type
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM work_orders WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1
|
||||
) wo ON TRUE
|
||||
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
|
||||
)
|
||||
INSERT INTO cost_exceptions (
|
||||
subject_type, subject_id, order_item_id, work_order_id,
|
||||
carrier_type, reason, status, detail_json, created_at
|
||||
)
|
||||
SELECT
|
||||
'order_item', s.order_item_id, s.order_item_id, s.work_order_id,
|
||||
s.carrier_type, 'product_mapping_missing', 'open',
|
||||
jsonb_build_object(
|
||||
'provider', s.provider,
|
||||
'shopId', s.shop_id,
|
||||
'skuCode', s.sku_code,
|
||||
'skuName', s.sku_name,
|
||||
'basisAt', s.basis_at,
|
||||
'message', '订单项没有归一到标准商品,销售事实会进入未归一桶'
|
||||
),
|
||||
NOW()
|
||||
FROM item_scope s
|
||||
WHERE s.canonical_product_id IS NULL
|
||||
ON CONFLICT (subject_type, subject_id, reason) DO NOTHING
|
||||
`,
|
||||
[fromDate, toDate],
|
||||
)
|
||||
|
||||
const resolved = await client.query(
|
||||
`
|
||||
UPDATE cost_exceptions ce
|
||||
SET status = 'resolved', resolved_at = NOW()
|
||||
WHERE ce.status = 'open'
|
||||
AND ce.subject_type = 'order_item'
|
||||
AND ce.reason = 'product_mapping_missing'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
WHERE oi.id = ce.subject_id AND oi.canonical_product_id IS NOT NULL
|
||||
)
|
||||
`,
|
||||
)
|
||||
|
||||
return { created: inserted.rowCount || 0, resolved: resolved.rowCount || 0 }
|
||||
}
|
||||
|
||||
async function insertAndResolveCostMissingExceptions(
|
||||
client: PoolClient,
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
) {
|
||||
const inserted = await client.query(
|
||||
`
|
||||
WITH item_scope AS (
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
oi.canonical_product_id,
|
||||
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
||||
wo.id AS work_order_id,
|
||||
CASE
|
||||
WHEN wo.id IS NOT NULL THEN 'work_order'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
) THEN 'affiliate_dash'
|
||||
ELSE 'manual'
|
||||
END AS carrier_type
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM work_orders WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1
|
||||
) wo ON TRUE
|
||||
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
|
||||
)
|
||||
INSERT INTO cost_exceptions (
|
||||
subject_type, subject_id, order_item_id, work_order_id,
|
||||
carrier_type, reason, status, detail_json, created_at
|
||||
)
|
||||
SELECT
|
||||
'order_item', s.order_item_id, s.order_item_id, s.work_order_id,
|
||||
s.carrier_type, 'cost_missing', 'open',
|
||||
jsonb_build_object(
|
||||
'canonicalProductId', s.canonical_product_id,
|
||||
'carrierType', s.carrier_type,
|
||||
'basisAt', s.basis_at,
|
||||
'message', '订单项没有匹配到任何成本版本'
|
||||
),
|
||||
NOW()
|
||||
FROM item_scope s
|
||||
WHERE s.canonical_product_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cost_snapshots cs
|
||||
WHERE cs.subject_type = 'order_item'
|
||||
AND cs.subject_id = s.order_item_id
|
||||
AND cs.superseded_at IS NULL
|
||||
)
|
||||
ON CONFLICT (subject_type, subject_id, reason) DO NOTHING
|
||||
`,
|
||||
[fromDate, toDate],
|
||||
)
|
||||
|
||||
const resolved = await client.query(
|
||||
`
|
||||
UPDATE cost_exceptions ce
|
||||
SET status = 'resolved', resolved_at = NOW()
|
||||
WHERE ce.status = 'open'
|
||||
AND ce.subject_type = 'order_item'
|
||||
AND ce.reason = 'cost_missing'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM cost_snapshots cs
|
||||
WHERE cs.subject_type = 'order_item'
|
||||
AND cs.subject_id = ce.subject_id
|
||||
AND cs.superseded_at IS NULL
|
||||
)
|
||||
`,
|
||||
)
|
||||
|
||||
return { created: inserted.rowCount || 0, resolved: resolved.rowCount || 0 }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,443 @@
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { resolveFinanceDateRange } from '../../utils/finance-date-range.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
|
||||
export type SalesFactsInput = {
|
||||
dateFrom?: unknown
|
||||
dateTo?: unknown
|
||||
from?: unknown
|
||||
to?: unknown
|
||||
}
|
||||
|
||||
export type SalesFactsRebuildResult = {
|
||||
range: { fromDate: string; toDate: string }
|
||||
deletedCount: number
|
||||
insertedCount: number
|
||||
computedAt: string
|
||||
}
|
||||
|
||||
type SalesFactRow = {
|
||||
[column: string]: unknown
|
||||
id: number
|
||||
stat_date: string | Date
|
||||
provider: string
|
||||
shop_id: string
|
||||
canonical_product_id: number | null
|
||||
canonical_sku: string | null
|
||||
canonical_name: string | null
|
||||
carrier_type: string
|
||||
order_count: number
|
||||
item_count: number
|
||||
quantity: string | number
|
||||
gross_revenue_fen: number
|
||||
refund_amount_fen: number
|
||||
net_revenue_fen: number
|
||||
cost_amount_fen: number
|
||||
cost_missing_count: number
|
||||
cost_stage: string
|
||||
gross_profit_fen: number
|
||||
computed_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 重算指定日期范围的销售事实。日期统一按 Asia/Shanghai 归属。
|
||||
* 未归一商品允许 canonical_product_id 为空,汇总时形成“未归一”桶。
|
||||
*/
|
||||
export async function rebuildSalesDailyFacts(
|
||||
input: SalesFactsInput = {},
|
||||
): Promise<SalesFactsRebuildResult> {
|
||||
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })
|
||||
const computedAt = nowIso()
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
const deleted = await client.query(
|
||||
`DELETE FROM sales_daily_facts WHERE stat_date BETWEEN $1::date AND $2::date`,
|
||||
[range.fromDate, range.toDate],
|
||||
)
|
||||
|
||||
const inserted = await client.query(
|
||||
`
|
||||
WITH item_base AS (
|
||||
SELECT
|
||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
|
||||
o.provider,
|
||||
o.shop_id,
|
||||
oi.canonical_product_id,
|
||||
CASE
|
||||
WHEN wo.id IS NOT NULL THEN 'work_order'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id
|
||||
AND ft.executor_key = 'affiliate_dash'
|
||||
) THEN 'affiliate_dash'
|
||||
ELSE 'manual'
|
||||
END AS carrier_type,
|
||||
oi.order_id,
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
CASE
|
||||
WHEN o.pay_status = 'refunded' THEN COALESCE(oi.revenue_amount_fen, 0)::bigint
|
||||
ELSE 0::bigint
|
||||
END AS refund_fen,
|
||||
GREATEST(COALESCE(oi.normalized_quantity, oi.quantity, 1), 1)::numeric AS quantity,
|
||||
COALESCE(cost.cost_fen, 0)::bigint AS cost_fen,
|
||||
COALESCE(cost.snapshot_count, 0)::int AS snapshot_count,
|
||||
COALESCE(cost.actual_count, 0)::int AS actual_count
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM work_orders WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1
|
||||
) wo ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
SUM(cs.total_amount_fen)::bigint AS cost_fen,
|
||||
COUNT(*)::int AS snapshot_count,
|
||||
COUNT(*) FILTER (WHERE cs.snapshot_stage = 'actual')::int AS actual_count
|
||||
FROM cost_snapshots cs
|
||||
WHERE cs.subject_type = 'order_item'
|
||||
AND cs.subject_id = oi.id
|
||||
AND cs.superseded_at IS NULL
|
||||
) cost ON TRUE
|
||||
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
|
||||
)
|
||||
INSERT INTO sales_daily_facts (
|
||||
stat_date, provider, shop_id, canonical_product_id, carrier_type,
|
||||
order_count, item_count, quantity,
|
||||
gross_revenue_fen, refund_amount_fen, net_revenue_fen,
|
||||
cost_amount_fen, cost_missing_count, cost_stage, gross_profit_fen,
|
||||
computed_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
stat_date,
|
||||
provider,
|
||||
shop_id,
|
||||
canonical_product_id,
|
||||
carrier_type,
|
||||
COUNT(DISTINCT order_id)::int,
|
||||
COUNT(*)::int,
|
||||
SUM(quantity),
|
||||
SUM(revenue_fen),
|
||||
SUM(refund_fen),
|
||||
SUM(revenue_fen - refund_fen),
|
||||
SUM(cost_fen),
|
||||
COUNT(*) FILTER (WHERE snapshot_count = 0)::int,
|
||||
CASE
|
||||
WHEN COUNT(*) FILTER (WHERE snapshot_count = 0) = COUNT(*) THEN 'missing'
|
||||
WHEN COUNT(*) FILTER (WHERE snapshot_count = 0) > 0 THEN 'partial'
|
||||
WHEN COUNT(*) FILTER (WHERE snapshot_count > 0 AND actual_count < snapshot_count) > 0
|
||||
THEN 'estimated'
|
||||
ELSE 'actual'
|
||||
END,
|
||||
SUM(revenue_fen - refund_fen) - SUM(cost_fen),
|
||||
$3::timestamptz,
|
||||
$3::timestamptz,
|
||||
$3::timestamptz
|
||||
FROM item_base
|
||||
GROUP BY stat_date, provider, shop_id, canonical_product_id, carrier_type
|
||||
`,
|
||||
[range.fromDate, range.toDate, computedAt],
|
||||
)
|
||||
|
||||
return {
|
||||
range,
|
||||
deletedCount: deleted.rowCount || 0,
|
||||
insertedCount: inserted.rowCount || 0,
|
||||
computedAt,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSalesProfitOverview(input: SalesFactsInput = {}) {
|
||||
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })
|
||||
|
||||
const [summaryResult, dailyResult, carrierResult, productResult, orderResult] = await Promise.all(
|
||||
[
|
||||
query<{
|
||||
item_count: number
|
||||
gross_revenue_fen: number
|
||||
refund_amount_fen: number
|
||||
net_revenue_fen: number
|
||||
cost_amount_fen: number
|
||||
cost_missing_count: number
|
||||
gross_profit_fen: number
|
||||
unmapped_revenue_fen: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
COALESCE(SUM(item_count), 0)::int AS item_count,
|
||||
COALESCE(SUM(gross_revenue_fen), 0)::bigint AS gross_revenue_fen,
|
||||
COALESCE(SUM(refund_amount_fen), 0)::bigint AS refund_amount_fen,
|
||||
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(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
|
||||
FROM sales_daily_facts
|
||||
WHERE stat_date BETWEEN $1::date AND $2::date
|
||||
`,
|
||||
[range.fromDate, range.toDate],
|
||||
),
|
||||
query<{
|
||||
stat_date: string
|
||||
item_count: number
|
||||
gross_revenue_fen: number
|
||||
refund_amount_fen: number
|
||||
net_revenue_fen: number
|
||||
cost_amount_fen: number
|
||||
cost_missing_count: number
|
||||
gross_profit_fen: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
stat_date::text,
|
||||
SUM(item_count)::int AS item_count,
|
||||
SUM(gross_revenue_fen)::bigint AS gross_revenue_fen,
|
||||
SUM(refund_amount_fen)::bigint AS refund_amount_fen,
|
||||
SUM(net_revenue_fen)::bigint AS net_revenue_fen,
|
||||
SUM(cost_amount_fen)::bigint AS cost_amount_fen,
|
||||
SUM(cost_missing_count)::int AS cost_missing_count,
|
||||
SUM(gross_profit_fen)::bigint AS gross_profit_fen
|
||||
FROM sales_daily_facts
|
||||
WHERE stat_date BETWEEN $1::date AND $2::date
|
||||
GROUP BY stat_date
|
||||
ORDER BY stat_date ASC
|
||||
`,
|
||||
[range.fromDate, range.toDate],
|
||||
),
|
||||
query<{
|
||||
carrier_type: string
|
||||
item_count: number
|
||||
gross_revenue_fen: number
|
||||
cost_amount_fen: number
|
||||
cost_missing_count: number
|
||||
gross_profit_fen: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
carrier_type,
|
||||
SUM(item_count)::int AS item_count,
|
||||
SUM(gross_revenue_fen)::bigint AS gross_revenue_fen,
|
||||
SUM(cost_amount_fen)::bigint AS cost_amount_fen,
|
||||
SUM(cost_missing_count)::int AS cost_missing_count,
|
||||
SUM(gross_profit_fen)::bigint AS gross_profit_fen
|
||||
FROM sales_daily_facts
|
||||
WHERE stat_date BETWEEN $1::date AND $2::date
|
||||
GROUP BY carrier_type
|
||||
ORDER BY gross_revenue_fen DESC
|
||||
`,
|
||||
[range.fromDate, range.toDate],
|
||||
),
|
||||
query<{
|
||||
canonical_product_id: number | null
|
||||
canonical_sku: string | null
|
||||
canonical_name: string | null
|
||||
item_count: number
|
||||
gross_revenue_fen: number
|
||||
cost_amount_fen: number
|
||||
cost_missing_count: number
|
||||
gross_profit_fen: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
f.canonical_product_id,
|
||||
p.canonical_sku,
|
||||
p.canonical_name,
|
||||
SUM(f.item_count)::int AS item_count,
|
||||
SUM(f.gross_revenue_fen)::bigint AS gross_revenue_fen,
|
||||
SUM(f.cost_amount_fen)::bigint AS cost_amount_fen,
|
||||
SUM(f.cost_missing_count)::int AS cost_missing_count,
|
||||
SUM(f.gross_profit_fen)::bigint AS gross_profit_fen
|
||||
FROM sales_daily_facts f
|
||||
LEFT JOIN product_catalog p ON p.id = f.canonical_product_id
|
||||
WHERE f.stat_date BETWEEN $1::date AND $2::date
|
||||
GROUP BY f.canonical_product_id, p.canonical_sku, p.canonical_name
|
||||
ORDER BY gross_revenue_fen DESC
|
||||
LIMIT 30
|
||||
`,
|
||||
[range.fromDate, range.toDate],
|
||||
),
|
||||
query<{
|
||||
order_count: number
|
||||
orders_without_items: number
|
||||
revenue_without_items: number
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*)::int AS order_count,
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = orders.id)
|
||||
)::int AS orders_without_items,
|
||||
COALESCE(SUM(total_amount) FILTER (
|
||||
WHERE NOT EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = orders.id)
|
||||
), 0)::bigint AS revenue_without_items
|
||||
FROM orders
|
||||
WHERE pay_status IN ('paid', 'refunded')
|
||||
AND (COALESCE(paid_at, created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
BETWEEN $1::date AND $2::date
|
||||
`,
|
||||
[range.fromDate, range.toDate],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
const summary = summaryResult.rows[0]
|
||||
const grossRevenue = Number(summary?.gross_revenue_fen || 0)
|
||||
const unmappedRevenue = Number(summary?.unmapped_revenue_fen || 0)
|
||||
|
||||
return {
|
||||
range,
|
||||
overview: {
|
||||
orderCount: Number(orderResult.rows[0]?.order_count || 0),
|
||||
ordersWithoutItems: Number(orderResult.rows[0]?.orders_without_items || 0),
|
||||
revenueWithoutItemsFen: Number(orderResult.rows[0]?.revenue_without_items || 0),
|
||||
itemCount: Number(summary?.item_count || 0),
|
||||
grossRevenueFen: grossRevenue,
|
||||
refundAmountFen: Number(summary?.refund_amount_fen || 0),
|
||||
netRevenueFen: Number(summary?.net_revenue_fen || 0),
|
||||
costAmountFen: Number(summary?.cost_amount_fen || 0),
|
||||
costMissingCount: Number(summary?.cost_missing_count || 0),
|
||||
grossProfitFen: Number(summary?.gross_profit_fen || 0),
|
||||
unmappedRevenueFen: unmappedRevenue,
|
||||
unmappedRevenuePct:
|
||||
grossRevenue > 0 ? Math.round((unmappedRevenue / grossRevenue) * 10000) / 100 : 0,
|
||||
},
|
||||
daily: dailyResult.rows.map((row) => ({
|
||||
statDate: row.stat_date,
|
||||
itemCount: Number(row.item_count || 0),
|
||||
grossRevenueFen: Number(row.gross_revenue_fen || 0),
|
||||
refundAmountFen: Number(row.refund_amount_fen || 0),
|
||||
netRevenueFen: Number(row.net_revenue_fen || 0),
|
||||
costAmountFen: Number(row.cost_amount_fen || 0),
|
||||
costMissingCount: Number(row.cost_missing_count || 0),
|
||||
grossProfitFen: Number(row.gross_profit_fen || 0),
|
||||
})),
|
||||
byCarrier: carrierResult.rows.map((row) => ({
|
||||
carrierType: row.carrier_type,
|
||||
itemCount: Number(row.item_count || 0),
|
||||
grossRevenueFen: Number(row.gross_revenue_fen || 0),
|
||||
costAmountFen: Number(row.cost_amount_fen || 0),
|
||||
costMissingCount: Number(row.cost_missing_count || 0),
|
||||
grossProfitFen: Number(row.gross_profit_fen || 0),
|
||||
})),
|
||||
byProduct: productResult.rows.map((row) => ({
|
||||
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
|
||||
canonicalSku: row.canonical_sku || '',
|
||||
canonicalName: row.canonical_name || '未归一',
|
||||
itemCount: Number(row.item_count || 0),
|
||||
grossRevenueFen: Number(row.gross_revenue_fen || 0),
|
||||
costAmountFen: Number(row.cost_amount_fen || 0),
|
||||
costMissingCount: Number(row.cost_missing_count || 0),
|
||||
grossProfitFen: Number(row.gross_profit_fen || 0),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSalesDailyFacts(input: Record<string, unknown> = {}) {
|
||||
const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 })
|
||||
const page = normalizePositiveInteger(input.page, 1, '页码')
|
||||
const pageSize = Math.min(
|
||||
200,
|
||||
normalizePositiveInteger(input.pageSize ?? input.page_size, 20, '每页数量'),
|
||||
)
|
||||
const provider = String(input.provider || '').trim()
|
||||
const carrierType = String(input.carrierType || input.carrier_type || '').trim()
|
||||
const canonicalProductId = Number(input.canonicalProductId || input.canonical_product_id || 0)
|
||||
|
||||
const conditions = ['f.stat_date BETWEEN $1::date AND $2::date']
|
||||
const params: unknown[] = [range.fromDate, range.toDate]
|
||||
if (provider) {
|
||||
params.push(provider)
|
||||
conditions.push(`f.provider = $${params.length}`)
|
||||
}
|
||||
if (carrierType) {
|
||||
params.push(carrierType)
|
||||
conditions.push(`f.carrier_type = $${params.length}`)
|
||||
}
|
||||
if (Number.isInteger(canonicalProductId) && canonicalProductId > 0) {
|
||||
params.push(canonicalProductId)
|
||||
conditions.push(`f.canonical_product_id = $${params.length}`)
|
||||
}
|
||||
const where = conditions.join(' AND ')
|
||||
|
||||
const countResult = await query<{ total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM sales_daily_facts f WHERE ${where}`,
|
||||
params,
|
||||
)
|
||||
const listParams = [...params, pageSize, (page - 1) * pageSize]
|
||||
const result = await query<SalesFactRow>(
|
||||
`
|
||||
SELECT
|
||||
f.*,
|
||||
p.canonical_sku,
|
||||
p.canonical_name
|
||||
FROM sales_daily_facts f
|
||||
LEFT JOIN product_catalog p ON p.id = f.canonical_product_id
|
||||
WHERE ${where}
|
||||
ORDER BY f.stat_date DESC, f.gross_revenue_fen DESC, f.id DESC
|
||||
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
|
||||
`,
|
||||
listParams,
|
||||
)
|
||||
|
||||
return {
|
||||
range,
|
||||
items: result.rows.map(mapSalesFact),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total: Number(countResult.rows[0]?.total || 0),
|
||||
},
|
||||
computedAt: result.rows[0]?.computed_at || '',
|
||||
}
|
||||
}
|
||||
|
||||
function mapSalesFact(row: SalesFactRow) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
statDate: formatDateOnly(row.stat_date),
|
||||
provider: row.provider,
|
||||
shopId: row.shop_id,
|
||||
canonicalProductId: row.canonical_product_id ? Number(row.canonical_product_id) : null,
|
||||
canonicalSku: row.canonical_sku || '',
|
||||
canonicalName: row.canonical_name || '未归一',
|
||||
carrierType: row.carrier_type,
|
||||
orderCount: Number(row.order_count || 0),
|
||||
itemCount: Number(row.item_count || 0),
|
||||
quantity: Number(row.quantity || 0),
|
||||
grossRevenueFen: Number(row.gross_revenue_fen || 0),
|
||||
refundAmountFen: Number(row.refund_amount_fen || 0),
|
||||
netRevenueFen: Number(row.net_revenue_fen || 0),
|
||||
costAmountFen: Number(row.cost_amount_fen || 0),
|
||||
costMissingCount: Number(row.cost_missing_count || 0),
|
||||
costStage: row.cost_stage,
|
||||
grossProfitFen: Number(row.gross_profit_fen || 0),
|
||||
computedAt: row.computed_at,
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateOnly(value: string | Date): string {
|
||||
if (value instanceof Date) {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(value)
|
||||
}
|
||||
return String(value || '').slice(0, 10)
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number, label: string) {
|
||||
const parsed = Number(value)
|
||||
if (!value || !Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
|
||||
if (!value) return fallback
|
||||
throw createHttpError(`${label}必须是正整数`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'finance_pagination_invalid',
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
findLatestOrderByPlatformOrderIdWithExecutor,
|
||||
updateOrderWithExecutor,
|
||||
} from '../../repositories/order-repo.js'
|
||||
import { replaceOrderItemsWithExecutor } from '../../repositories/order-item-repo.js'
|
||||
import {
|
||||
applyOrderItemRevenueAllocationWithExecutor,
|
||||
replaceOrderItemsWithExecutor,
|
||||
} from '../../repositories/order-item-repo.js'
|
||||
import { withTransaction } from '../../db/client.js'
|
||||
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
|
||||
import { resolveOrderItemForFulfillment } from '../fulfillment/product-resolution-service.js'
|
||||
@@ -182,6 +185,11 @@ export async function upsertOrderFromSource(
|
||||
updatedAt: now,
|
||||
})),
|
||||
)
|
||||
await applyOrderItemRevenueAllocationWithExecutor(
|
||||
executor,
|
||||
order.id,
|
||||
Number(order.total_amount) || 0,
|
||||
)
|
||||
|
||||
return { order, orderItems }
|
||||
})
|
||||
|
||||
@@ -41,4 +41,5 @@ test('normalizeScheduledJobsConfig ensures default jobs exist', () => {
|
||||
assert.ok(ids.includes('deposit-unfreeze'))
|
||||
assert.ok(ids.includes('data-retention'))
|
||||
assert.ok(ids.includes('pdd_order_sync'))
|
||||
assert.ok(ids.includes('cost-finance-sync'))
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout'
|
||||
const DEPOSIT_UNFREEZE_JOB_ID = 'deposit-unfreeze'
|
||||
const DATA_RETENTION_JOB_ID = 'data-retention'
|
||||
const PDD_ORDER_SYNC_JOB_ID = 'pdd_order_sync'
|
||||
const COST_FINANCE_SYNC_JOB_ID = 'cost-finance-sync'
|
||||
|
||||
export function getScheduledJobsFilePath() {
|
||||
return SCHEDULED_JOBS_FILE_PATH
|
||||
@@ -75,6 +76,7 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
|
||||
const hasDepositUnfreeze = jobs.some((item) => item.id === DEPOSIT_UNFREEZE_JOB_ID)
|
||||
const hasDataRetention = jobs.some((item) => item.id === DATA_RETENTION_JOB_ID)
|
||||
const hasPddOrderSync = jobs.some((item) => item.id === PDD_ORDER_SYNC_JOB_ID)
|
||||
const hasCostFinanceSync = jobs.some((item) => item.id === COST_FINANCE_SYNC_JOB_ID)
|
||||
|
||||
if (!hasWorkOrderTimeout) {
|
||||
jobs.push(createDefaultWorkOrderTimeoutJob())
|
||||
@@ -88,6 +90,9 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
|
||||
if (!hasPddOrderSync) {
|
||||
jobs.push(createDefaultPddOrderSyncJob())
|
||||
}
|
||||
if (!hasCostFinanceSync) {
|
||||
jobs.push(createDefaultCostFinanceSyncJob())
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
|
||||
@@ -117,6 +122,9 @@ function normalizeScheduledJob(rawValue: unknown) {
|
||||
if (type === 'pdd_order_sync') {
|
||||
return normalizePddOrderSyncJob(rawValue)
|
||||
}
|
||||
if (type === 'cost_finance_sync') {
|
||||
return normalizeCostFinanceSyncJob(rawValue)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -135,6 +143,20 @@ function normalizePddOrderSyncJob(rawValue: JsonObject) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCostFinanceSyncJob(rawValue: JsonObject) {
|
||||
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
||||
|
||||
return {
|
||||
id: COST_FINANCE_SYNC_JOB_ID,
|
||||
type: 'cost_finance_sync',
|
||||
enabled: rawValue.enabled !== false,
|
||||
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 3600),
|
||||
config: {
|
||||
days: normalizeRangeInteger(config.days, 7, 1, 90),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDepositUnfreezeJob(rawValue: JsonObject) {
|
||||
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
||||
|
||||
@@ -181,6 +203,7 @@ function createDefaultScheduledJobsConfig() {
|
||||
createDefaultDepositUnfreezeJob(),
|
||||
createDefaultDataRetentionJob(),
|
||||
createDefaultPddOrderSyncJob(),
|
||||
createDefaultCostFinanceSyncJob(),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -197,6 +220,18 @@ function createDefaultPddOrderSyncJob() {
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultCostFinanceSyncJob() {
|
||||
return {
|
||||
id: COST_FINANCE_SYNC_JOB_ID,
|
||||
type: 'cost_finance_sync',
|
||||
enabled: true,
|
||||
intervalSeconds: 300,
|
||||
config: {
|
||||
days: 7,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultDataRetentionJob() {
|
||||
return {
|
||||
id: DATA_RETENTION_JOB_ID,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import {
|
||||
syncActualWorkOrderCostSnapshots,
|
||||
syncEstimatedOrderItemCostSnapshots,
|
||||
} from '../cost-center/cost-snapshot-sync-service.js'
|
||||
import { rebuildSalesDailyFacts } from '../finance/sales-facts-service.js'
|
||||
|
||||
/**
|
||||
* 财务闭环同步:预估成本快照 -> 工单实际快照 -> 销售事实重算。
|
||||
* 只处理最近 N 天,避免定时任务扫全量历史。
|
||||
*/
|
||||
export async function runCostFinanceSyncJob(job: JsonObject = {}) {
|
||||
const config = job.config && typeof job.config === 'object' ? (job.config as JsonObject) : {}
|
||||
const days = clampInteger(config.days, 7, 1, 90)
|
||||
|
||||
const estimated = await syncEstimatedOrderItemCostSnapshots({
|
||||
dateFrom: shiftDays(-(days - 1)),
|
||||
dateTo: today(),
|
||||
})
|
||||
const actual = await syncActualWorkOrderCostSnapshots({
|
||||
dateFrom: shiftDays(-(days - 1)),
|
||||
dateTo: today(),
|
||||
})
|
||||
const facts = await rebuildSalesDailyFacts({
|
||||
dateFrom: shiftDays(-(days - 1)),
|
||||
dateTo: today(),
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
message: `成本快照 ${estimated.createdCount} 预估 / ${actual.createdCount} 实际,销售事实 ${facts.insertedCount} 行`,
|
||||
days,
|
||||
estimatedCreated: estimated.createdCount,
|
||||
actualCreated: actual.createdCount,
|
||||
exceptionsCreated: estimated.exceptionCreatedCount,
|
||||
factsInserted: facts.insertedCount,
|
||||
dateFrom: facts.range.fromDate,
|
||||
dateTo: facts.range.toDate,
|
||||
}
|
||||
}
|
||||
|
||||
function today() {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date())
|
||||
}
|
||||
|
||||
function shiftDays(days: number) {
|
||||
const [year, month, day] = today().split('-').map(Number)
|
||||
const date = new Date(Date.UTC(year || 1970, (month || 1) - 1, day || 1))
|
||||
date.setUTCDate(date.getUTCDate() + days)
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function clampInteger(value: unknown, fallback: number, min: number, max: number) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { runDepositUnfreezeJob } from './deposit-unfreeze-job.js'
|
||||
import { runWorkOrderTimeoutJob } from './work-order-timeout-job.js'
|
||||
import { runDataRetentionJob } from './data-retention-job.js'
|
||||
import { runPddOrderSyncJob } from './pdd-order-sync-job.js'
|
||||
import { runCostFinanceSyncJob } from './cost-finance-sync-job.js'
|
||||
|
||||
const timers = new Map<string, NodeJS.Timeout>()
|
||||
const jobStates = new Map<string, JsonObject>()
|
||||
@@ -204,6 +205,9 @@ function dispatchJob(job: JsonObject) {
|
||||
if (job.type === 'pdd_order_sync') {
|
||||
return runPddOrderSyncJob(job)
|
||||
}
|
||||
if (job.type === 'cost_finance_sync') {
|
||||
return runCostFinanceSyncJob(job)
|
||||
}
|
||||
|
||||
throw createHttpError(`不支持的定时任务类型:${job.type}`, {
|
||||
statusCode: 400,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { diffDays, resolveFinanceDateRange, shiftDate } from './finance-date-range.js'
|
||||
|
||||
test('resolveFinanceDateRange uses the requested date range', () => {
|
||||
const range = resolveFinanceDateRange({ dateFrom: '2026-10-01', dateTo: '2026-10-14' })
|
||||
assert.deepEqual(range, { fromDate: '2026-10-01', toDate: '2026-10-14' })
|
||||
})
|
||||
|
||||
test('resolveFinanceDateRange rejects reversed and oversized ranges', () => {
|
||||
assert.throws(() => resolveFinanceDateRange({ dateFrom: '2026-10-14', dateTo: '2026-10-01' }))
|
||||
assert.throws(() =>
|
||||
resolveFinanceDateRange({ dateFrom: '2025-01-01', dateTo: '2026-10-01' }, { maxDays: 400 }),
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveFinanceDateRange defaults to the recent N calendar days', () => {
|
||||
const range = resolveFinanceDateRange({ dateTo: '2026-10-22' }, { defaultDays: 7 })
|
||||
assert.deepEqual(range, { fromDate: '2026-10-16', toDate: '2026-10-22' })
|
||||
assert.equal(diffDays(range.fromDate, range.toDate) + 1, 7)
|
||||
})
|
||||
|
||||
test('shiftDate crosses months without timezone drift', () => {
|
||||
assert.equal(shiftDate('2026-03-01', -1), '2026-02-28')
|
||||
assert.equal(shiftDate('2026-12-31', 1), '2027-01-01')
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createHttpError } from './http.js'
|
||||
|
||||
export type FinanceDateRange = {
|
||||
fromDate: string
|
||||
toDate: string
|
||||
}
|
||||
|
||||
type ResolveFinanceDateRangeOptions = {
|
||||
defaultDays?: number
|
||||
maxDays?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务日期范围统一按 Asia/Shanghai 的日历日处理。
|
||||
* 入参可以是 YYYY-MM-DD,也可以带时间;只取日期部分。
|
||||
*/
|
||||
export function resolveFinanceDateRange(
|
||||
input: Record<string, unknown> = {},
|
||||
{ defaultDays = 7, maxDays = 400 }: ResolveFinanceDateRangeOptions = {},
|
||||
): FinanceDateRange {
|
||||
const today = formatShanghaiDate(new Date())
|
||||
const rawFrom = normalizeDateInput(input.dateFrom ?? input.from)
|
||||
const rawTo = normalizeDateInput(input.dateTo ?? input.to)
|
||||
const toDate = rawTo || today
|
||||
const fromDate = rawFrom || shiftDate(toDate, -(Math.max(1, defaultDays) - 1))
|
||||
|
||||
if (fromDate > toDate) {
|
||||
throw createHttpError('开始日期不能晚于结束日期', {
|
||||
statusCode: 400,
|
||||
errorCode: 'finance_date_range_invalid',
|
||||
})
|
||||
}
|
||||
const spanDays = diffDays(fromDate, toDate) + 1
|
||||
if (spanDays > maxDays) {
|
||||
throw createHttpError(`日期范围不能超过 ${maxDays} 天`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'finance_date_range_too_large',
|
||||
})
|
||||
}
|
||||
return { fromDate, toDate }
|
||||
}
|
||||
|
||||
export function formatShanghaiDate(value: Date): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export function shiftDate(date: string, days: number): string {
|
||||
const [year, month, day] = date.split('-').map(Number)
|
||||
const base = new Date(Date.UTC(year || 1970, (month || 1) - 1, day || 1))
|
||||
base.setUTCDate(base.getUTCDate() + days)
|
||||
return base.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function diffDays(fromDate: string, toDate: string): number {
|
||||
const from = Date.parse(`${fromDate}T00:00:00Z`)
|
||||
const to = Date.parse(`${toDate}T00:00:00Z`)
|
||||
if (!Number.isFinite(from) || !Number.isFinite(to)) {
|
||||
throw createHttpError('日期格式必须为 YYYY-MM-DD', {
|
||||
statusCode: 400,
|
||||
errorCode: 'finance_date_invalid',
|
||||
})
|
||||
}
|
||||
return Math.floor((to - from) / 86_400_000)
|
||||
}
|
||||
|
||||
function normalizeDateInput(value: unknown): string {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
const datePart = raw.slice(0, 10)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) {
|
||||
throw createHttpError('日期格式必须为 YYYY-MM-DD', {
|
||||
statusCode: 400,
|
||||
errorCode: 'finance_date_invalid',
|
||||
})
|
||||
}
|
||||
const parsed = Date.parse(`${datePart}T00:00:00Z`)
|
||||
if (!Number.isFinite(parsed) || new Date(parsed).toISOString().slice(0, 10) !== datePart) {
|
||||
throw createHttpError('日期不是有效日期', {
|
||||
statusCode: 400,
|
||||
errorCode: 'finance_date_invalid',
|
||||
})
|
||||
}
|
||||
return datePart
|
||||
}
|
||||
@@ -29,6 +29,19 @@ export function generateWorkOrderNo(date: Date = new Date()): string {
|
||||
return `WK${yy}${mm}${dd}${hh}${min}${rand}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成公司人工支出登记号,形如 `ME26102215301234`。
|
||||
*/
|
||||
export function generateManualExpenseNo(date: Date = new Date()): string {
|
||||
const yy = String(date.getFullYear()).slice(-2)
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const min = String(date.getMinutes()).padStart(2, '0')
|
||||
const rand = Math.floor(1000 + Math.random() * 9000)
|
||||
return `ME${yy}${mm}${dd}${hh}${min}${rand}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate formatted consignment inventory number
|
||||
* 生成系统标准寄售库存编号,如:INV260913123456
|
||||
|
||||
Reference in New Issue
Block a user