feat(finance): 完成成本快照运行层、销售事实与人工支出登记

迁移 108-111:快照业务时间与预估转实际、销售事实表、系统成本项、公司人工支出登记。接单平台按工单实际 reward 入账;人工支出支持登记/审批/驳回/报销并写 actual 快照。新增销售毛利页、人工支出页与订单入口。
This commit is contained in:
yml2213
2026-10-22 16:41:03 +08:00
parent 15989af87c
commit 0889235870
39 changed files with 4824 additions and 143 deletions
+1
View File
@@ -9,6 +9,7 @@
"db:migrate": "tsx src/db/migrate.ts",
"db:migrate:create": "tsx scripts/create-migration.ts",
"db:migrate:status": "tsx scripts/migration-status.ts",
"finance:sync": "tsx scripts/sync-cost-finance.ts",
"storage:migrate": "tsx scripts/migrate-storage.ts",
"storage:migrate:built": "node dist/storage-migrate.js",
"check:sql": "tsx scripts/check-sql-guard.ts",
+88
View File
@@ -0,0 +1,88 @@
import process from 'node:process'
import { closeDb } from '../src/db/client.js'
import {
syncActualWorkOrderCostSnapshots,
syncEstimatedOrderItemCostSnapshots,
} from '../src/services/cost-center/cost-snapshot-sync-service.js'
import { rebuildSalesDailyFacts } from '../src/services/finance/sales-facts-service.js'
import { resolveFinanceDateRange, shiftDate } from '../src/utils/finance-date-range.js'
import { nowIso } from '../src/utils/time.js'
type Mode = 'estimated' | 'actual' | 'facts' | 'all'
const options = parseOptions(process.argv.slice(2))
const mode = normalizeMode(options.mode)
const days = normalizeDays(options.days)
const fallbackTo = options.to || formatShanghaiDate(new Date())
const fallbackFrom = options.from || shiftDate(fallbackTo, -(days - 1))
async function main() {
const input = { dateFrom: fallbackFrom, dateTo: fallbackTo }
const range = resolveFinanceDateRange(input, { defaultDays: days, maxDays: 400 })
const startedAt = Date.now()
const result: Record<string, unknown> = { mode, range }
if (mode === 'estimated' || mode === 'all') {
result.estimated = await syncEstimatedOrderItemCostSnapshots(input)
}
if (mode === 'actual' || mode === 'all') {
result.actual = await syncActualWorkOrderCostSnapshots(input)
}
if (mode === 'facts' || mode === 'all') {
result.facts = await rebuildSalesDailyFacts(input)
}
result.durationMs = Date.now() - startedAt
result.finishedAt = nowIso()
console.log(JSON.stringify(result, null, 2))
}
main()
.catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
.finally(async () => {
await closeDb()
})
function parseOptions(args: string[]) {
const result: Record<string, string> = {}
for (const arg of args) {
if (!arg.startsWith('--')) continue
const body = arg.slice(2)
const index = body.indexOf('=')
if (index === -1) {
result[body] = 'true'
} else {
result[body.slice(0, index)] = body.slice(index + 1)
}
}
return result
}
function normalizeMode(value: unknown): Mode {
const mode = String(value || 'all').trim()
if (mode === 'estimated' || mode === 'actual' || mode === 'facts' || mode === 'all') {
return mode
}
throw new Error('--mode 只能是 estimated / actual / facts / all')
}
function normalizeDays(value: unknown) {
const parsed = Number(value || 7)
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 400) {
throw new Error('--days 必须是 1-400 的整数')
}
return parsed
}
function formatShanghaiDate(value: Date) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(value)
}
@@ -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
+211 -1
View File
@@ -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
}
+13
View File
@@ -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
+4
View File
@@ -21,6 +21,8 @@ import WorkerLayout from '@/layouts/WorkerLayout'
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage'))
const AdminManualExpensePage = lazy(() => import('@/pages/admin/AdminManualExpensePage'))
const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage'))
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
@@ -146,10 +148,12 @@ function AdminApplication() {
</Route>
<Route element={<RequireRole roles={['admin', 'operator', 'finance']} />}>
<Route path="finance" element={<AdminFinancePage />} />
<Route path="sales-profit" element={<AdminSalesProfitPage />} />
<Route path="cost-center" element={<AdminCostCenterPage />} />
</Route>
<Route element={<RequireRole roles={['admin', 'operator', 'support', 'finance']} />}>
<Route path="fund" element={<AdminFundPage />} />
<Route path="manual-expenses" element={<AdminManualExpensePage />} />
</Route>
<Route path="worker-platform" element={<AdminWorkerPlatformPage />} />
<Route element={<RequireRole roles={['admin', 'operator', 'support']} />}>
+14
View File
@@ -6,7 +6,9 @@ import {
BugOutlined,
DashboardOutlined,
FileSearchOutlined,
FileTextOutlined,
LogoutOutlined,
LineChartOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
OrderedListOutlined,
@@ -132,6 +134,8 @@ export default function AdminLayout() {
role === 'finance'
? [
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
{ key: '/admin/sales-profit', icon: <LineChartOutlined />, label: '销售毛利' },
{ key: '/admin/manual-expenses', icon: <FileTextOutlined />, label: '人工支出' },
{ key: '/admin/cost-center', icon: <ApartmentOutlined />, label: '商品与成本' },
{ key: '/admin/fund', icon: <WalletOutlined />, label: '备用金' },
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '资金审核' },
@@ -144,10 +148,20 @@ export default function AdminLayout() {
icon: <WalletOutlined />,
label: role === 'support' ? '我的备用金' : '备用金',
},
{
key: '/admin/manual-expenses',
icon: <FileTextOutlined />,
label: '人工支出',
},
...(role === 'support'
? []
: [
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
{
key: '/admin/sales-profit',
icon: <LineChartOutlined />,
label: '销售毛利',
},
{
key: '/admin/cost-center',
icon: <ApartmentOutlined />,
@@ -0,0 +1,692 @@
import {
CheckOutlined,
CloseOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
WalletOutlined,
} from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Col,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import type { Dayjs } from 'dayjs'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import PageHeader from '@/components/admin/PageHeader'
import dayjs from '@/lib/dayjs'
import {
approveAdminManualExpense,
createAdminManualExpense,
fetchAdminManualExpenseCostItems,
fetchAdminManualExpenseOverview,
fetchAdminManualExpenses,
rejectAdminManualExpense,
reimburseAdminManualExpense,
} from '@/services/admin'
import type { AdminManualExpense, AdminManualExpenseStatus } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import { formatMoney } from './panels/shared'
type FilterForm = { dateRange?: unknown; status?: string; keyword?: string }
type ExpenseForm = {
orderId?: number
orderItemId?: number
costItemId?: number
amountYuan?: number
expenseCategory: string
payeeType: string
payeeName: string
payeeAccount?: string
paymentMethod: string
paidAt?: unknown
evidenceReference: string
note?: string
}
const STATUS_META: Record<AdminManualExpenseStatus, { label: string; color: string }> = {
draft: { label: '草稿', color: 'default' },
submitted: { label: '待审核', color: 'orange' },
approved: { label: '已通过', color: 'green' },
rejected: { label: '已驳回', color: 'red' },
reimbursed: { label: '已报销', color: 'blue' },
reversed: { label: '已冲正', color: 'purple' },
}
const CATEGORY_OPTIONS = [
{ value: 'goods_purchase', label: '采购拿货' },
{ value: 'compensation', label: '客户赔偿' },
{ value: 'refund_advance', label: '退款垫付' },
{ value: 'service_fee', label: '服务费' },
{ value: 'freight', label: '运费' },
{ value: 'recharge', label: '平台充值' },
{ value: 'other', label: '其他' },
]
const PAYEE_TYPE_OPTIONS = [
{ value: 'worker', label: '打手' },
{ value: 'upstream', label: '上游/供货' },
{ value: 'customer', label: '客户' },
{ value: 'platform', label: '平台' },
{ value: 'other', label: '其他' },
]
const PAYMENT_METHOD_OPTIONS = [
{ value: 'wechat', label: '微信' },
{ value: 'alipay', label: '支付宝' },
{ value: 'bank', label: '银行卡' },
{ value: 'cash', label: '现金' },
{ value: 'platform_balance', label: '平台余额' },
{ value: 'other', label: '其他' },
]
export default function AdminManualExpensePage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [searchParams] = useSearchParams()
const [filterForm] = Form.useForm<FilterForm>()
const [expenseForm] = Form.useForm<ExpenseForm>()
const canReview = hasAdminPermission('finance.review')
const canCreate = hasAdminPermission('fund.expense') || hasAdminPermission('finance.config')
const [filters, setFilters] = useState({
dateFrom: '',
dateTo: '',
status: '',
keyword: '',
})
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [createOpen, setCreateOpen] = useState(false)
const [creating, setCreating] = useState(false)
const [actionId, setActionId] = useState<number | null>(null)
const [rejectTarget, setRejectTarget] = useState<AdminManualExpense | null>(null)
const [rejectReason, setRejectReason] = useState('')
const listQuery = useQuery({
queryKey: ['admin-manual-expenses', filters, page, pageSize],
queryFn: () => fetchAdminManualExpenses({ ...filters, page, pageSize }),
})
const overviewQuery = useQuery({
queryKey: ['admin-manual-expense-overview', filters],
queryFn: () => fetchAdminManualExpenseOverview(filters),
})
const costItemsQuery = useQuery({
queryKey: ['admin-manual-expense-cost-items'],
queryFn: fetchAdminManualExpenseCostItems,
})
const list = listQuery.data?.data
const overview = overviewQuery.data?.data?.overview
const costItems = costItemsQuery.data?.data.items || []
useEffect(() => {
const orderId = Number(searchParams.get('orderId') || 0)
if (Number.isInteger(orderId) && orderId > 0) {
setCreateOpen(true)
expenseForm.setFieldsValue({ orderId })
}
}, [expenseForm, searchParams])
useEffect(() => {
if (!createOpen) return
const current = expenseForm.getFieldValue('costItemId')
if (current) return
const systemItem = costItems.find(
(item: { code: string; id: number }) => item.code === 'sys_manual_service',
)
if (systemItem) expenseForm.setFieldValue('costItemId', systemItem.id)
}, [costItems, createOpen, expenseForm])
function refreshAll() {
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expenses'] })
void queryClient.invalidateQueries({ queryKey: ['admin-manual-expense-overview'] })
}
function applyFilters(values: FilterForm) {
const range = Array.isArray(values.dateRange) ? values.dateRange : []
setPage(1)
setFilters({
dateFrom: range[0] ? dayjs(range[0]).format('YYYY-MM-DD') : '',
dateTo: range[1] ? dayjs(range[1]).format('YYYY-MM-DD') : '',
status: values.status || '',
keyword: String(values.keyword || '').trim(),
})
}
function resetFilters() {
filterForm.resetFields()
setPage(1)
setFilters({ dateFrom: '', dateTo: '', status: '', keyword: '' })
}
async function submitExpense() {
const values = await expenseForm.validateFields()
setCreating(true)
try {
const amountFen = Math.round(Number(values.amountYuan || 0) * 100)
const paidAt = values.paidAt
? dayjs(values.paidAt as Dayjs).format('YYYY-MM-DD HH:mm:ss')
: dayjs().format('YYYY-MM-DD HH:mm:ss')
const response = await createAdminManualExpense({
orderId: values.orderId || undefined,
orderItemId: values.orderItemId || undefined,
costItemId: values.costItemId || undefined,
amountFen,
expenseCategory: values.expenseCategory,
payeeType: values.payeeType,
payeeName: values.payeeName,
payeeAccount: values.payeeAccount || '',
paymentMethod: values.paymentMethod,
paidAt,
evidenceJson: { reference: values.evidenceReference },
note: values.note || '',
})
message.success(`已登记 ${response.data.expenseNo}`)
setCreateOpen(false)
expenseForm.resetFields()
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '登记人工支出失败')
} finally {
setCreating(false)
}
}
function approve(row: AdminManualExpense) {
modal.confirm({
title: '通过人工支出',
content: `确认通过 ${row.expenseNo},金额 ${formatMoney(row.amountFen)}?通过后会计入成本。`,
okText: '通过',
cancelText: '取消',
onOk: async () => {
setActionId(row.id)
try {
await approveAdminManualExpense(row.id)
message.success('人工支出已通过')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '审核失败')
} finally {
setActionId(null)
}
},
})
}
function openReject(row: AdminManualExpense) {
setRejectTarget(row)
setRejectReason('')
}
async function submitReject() {
if (!rejectTarget) return
if (!rejectReason.trim()) {
message.warning('请填写驳回原因')
return
}
setActionId(rejectTarget.id)
try {
await rejectAdminManualExpense(rejectTarget.id, rejectReason.trim())
message.success('人工支出已驳回')
setRejectTarget(null)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '驳回失败')
} finally {
setActionId(null)
}
}
function reimburse(row: AdminManualExpense) {
modal.confirm({
title: '标记已报销',
content: `确认 ${row.expenseNo} 已完成报销?`,
okText: '标记已报销',
cancelText: '取消',
onOk: async () => {
setActionId(row.id)
try {
await reimburseAdminManualExpense(row.id)
message.success('已标记报销')
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '标记报销失败')
} finally {
setActionId(null)
}
},
})
}
const columns: TableColumnsType<AdminManualExpense> = [
{
title: '登记号 / 状态',
dataIndex: 'expenseNo',
width: 190,
render: (value: string, row) => {
const meta = STATUS_META[row.status]
return (
<Space direction="vertical" size={2}>
<Typography.Text copyable>{value}</Typography.Text>
<Tag color={meta.color}>{meta.label}</Tag>
</Space>
)
},
},
{
title: '付款时间',
dataIndex: 'paidAt',
width: 165,
render: (value: string) => formatAdminDateTime(value),
},
{
title: '订单 / 商品',
key: 'order',
width: 260,
render: (_, row) => (
<Space direction="vertical" size={2}>
<span>{row.platformOrderId || (row.orderId ? `#${row.orderId}` : '无关联订单')}</span>
<Typography.Text type="secondary" ellipsis>
{row.skuName || row.workOrderNo || '-'}
</Typography.Text>
</Space>
),
},
{
title: '付款项目',
key: 'costItem',
width: 180,
render: (_, row) => (
<Space direction="vertical" size={2}>
<span>{row.costItemName || '未归类'}</span>
<Typography.Text type="secondary">
{CATEGORY_OPTIONS.find((item) => item.value === row.expenseCategory)?.label ||
row.expenseCategory}
</Typography.Text>
</Space>
),
},
{
title: '金额',
dataIndex: 'amountFen',
width: 120,
render: (value: number) => <Typography.Text strong>{formatMoney(value)}</Typography.Text>,
},
{
title: '付款人 → 收款人',
key: 'parties',
width: 190,
render: (_, row) => (
<Space direction="vertical" size={2}>
<span>{row.paidByName || '-'}</span>
<Typography.Text type="secondary">→ {row.payeeName || '-'}</Typography.Text>
</Space>
),
},
{
title: '凭证',
dataIndex: 'evidenceJson',
width: 160,
ellipsis: true,
render: (value: Record<string, unknown>) => {
const reference = typeof value?.reference === 'string' ? value.reference : ''
return (
<Typography.Text ellipsis title={reference}>
{reference || '-'}
</Typography.Text>
)
},
},
{
title: '成本快照',
dataIndex: 'costSnapshotId',
width: 100,
render: (value: number | null) =>
value ? <Tag color="green">#{value}</Tag> : <Tag color="orange">未生成</Tag>,
},
{
title: '操作',
key: 'actions',
width: 190,
fixed: 'right',
render: (_, row) => {
if (!canReview) return <Typography.Text type="secondary">仅查看</Typography.Text>
return (
<Space size={4}>
{row.status === 'submitted' ? (
<>
<Button
size="small"
type="primary"
icon={<CheckOutlined />}
loading={actionId === row.id}
onClick={() => approve(row)}
>
通过
</Button>
<Button
size="small"
danger
icon={<CloseOutlined />}
disabled={actionId === row.id}
onClick={() => openReject(row)}
>
驳回
</Button>
</>
) : null}
{row.status === 'approved' ? (
<Button
size="small"
icon={<WalletOutlined />}
loading={actionId === row.id}
onClick={() => reimburse(row)}
>
已报销
</Button>
) : null}
{row.status === 'reimbursed' ? (
<Typography.Text type="secondary">已完成</Typography.Text>
) : null}
</Space>
)
},
},
]
return (
<section className="page-stack">
<PageHeader
title="人工支出"
description="登记公司实际付出的人工/垫付支出:项目、金额、订单、付款人、收款人与凭证。"
/>
<Card>
<Form<FilterForm>
form={filterForm}
layout="inline"
className="filter-form"
onFinish={applyFilters}
initialValues={{
dateRange: [dayjs().subtract(29, 'day'), dayjs()],
status: '',
keyword: '',
}}
>
<Form.Item name="dateRange" label="付款日期">
<AdminRangePicker format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
style={{ width: 140 }}
options={[
{ value: '', label: '全部状态' },
...Object.entries(STATUS_META).map(([value, meta]) => ({
value,
label: meta.label,
})),
]}
/>
</Form.Item>
<Form.Item name="keyword" label="关键词">
<Input allowClear placeholder="登记号 / 订单号 / 收款人" style={{ width: 220 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
查询
</Button>
<Button onClick={resetFilters}>重置</Button>
{canCreate ? (
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
登记人工支出
</Button>
) : null}
<Button icon={<ReloadOutlined />} onClick={refreshAll}>
刷新
</Button>
</Space>
</Form.Item>
</Form>
</Card>
{overview && overview.unclassifiedCount > 0 ? (
<Alert
type="warning"
showIcon
message={`有 ${overview.unclassifiedCount} 条支出未归类,金额 ${formatMoney(overview.unclassifiedAmountFen)}`}
description="未归类的支出无法进入成本报表,请补齐付款项目。"
/>
) : null}
{overview && overview.noOrderCount > 0 ? (
<Alert
type="info"
showIcon
message={`有 ${overview.noOrderCount} 条支出没有关联订单,金额 ${formatMoney(overview.noOrderAmountFen)}`}
description="平台充值、批量拿货、办公支出可以没有订单,但建议在备注里写清用途。"
/>
) : null}
<Row gutter={[12, 12]}>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic
title="待审核"
value={overview?.submittedAmountFen || 0}
formatter={() => formatMoney(overview?.submittedAmountFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={5}>
<Card>
<Statistic
title="已通过"
value={overview?.approvedAmountFen || 0}
formatter={() => formatMoney(overview?.approvedAmountFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={5}>
<Card>
<Statistic
title="已报销"
value={overview?.reimbursedAmountFen || 0}
formatter={() => formatMoney(overview?.reimbursedAmountFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={5}>
<Card>
<Statistic title="待审核笔数" value={overview?.submittedCount || 0} />
</Card>
</Col>
<Col xs={12} md={8} xl={5}>
<Card>
<Statistic title="已通过笔数" value={overview?.approvedCount || 0} />
</Card>
</Col>
</Row>
<Card>
<Table<AdminManualExpense>
rowKey="id"
size="small"
columns={columns}
dataSource={list?.items || []}
loading={listQuery.isLoading}
scroll={{ x: 1650 }}
pagination={buildAdminTablePagination({
current: page,
pageSize,
total: list?.pagination.total || 0,
showTotal: (total) => `共 ${total} 条支出`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
/>
</Card>
<Modal
title="登记人工支出"
open={createOpen}
onCancel={() => {
setCreateOpen(false)
expenseForm.resetFields()
}}
onOk={submitExpense}
confirmLoading={creating}
okText="提交登记"
width={720}
>
<Form<ExpenseForm>
form={expenseForm}
layout="vertical"
initialValues={{
expenseCategory: 'service_fee',
payeeType: 'upstream',
paymentMethod: 'wechat',
paidAt: dayjs(),
}}
>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="orderId" label="订单 ID(可选)">
<InputNumber min={1} style={{ width: '100%' }} placeholder="有订单时填写" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="orderItemId" label="订单项 ID(可选)">
<InputNumber
min={1}
style={{ width: '100%' }}
placeholder="按订单项归集成本时填写"
/>
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={12}>
<Form.Item
name="costItemId"
label="付款项目"
rules={[{ required: true, message: '请选择付款项目' }]}
>
<Select
showSearch
optionFilterProp="label"
loading={costItemsQuery.isLoading}
options={costItems.map((item: { id: number; code: string; name: string }) => ({
value: item.id,
label: `${item.name}(${item.code})`,
}))}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
name="amountYuan"
label="金额(元)"
rules={[{ required: true, message: '请填写金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="expenseCategory" label="支出分类" rules={[{ required: true }]}>
<Select options={CATEGORY_OPTIONS} />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={6}>
<Form.Item name="payeeType" label="收款方类型" rules={[{ required: true }]}>
<Select options={PAYEE_TYPE_OPTIONS} />
</Form.Item>
</Col>
<Col span={9}>
<Form.Item
name="payeeName"
label="收款方"
rules={[{ required: true, message: '请填写收款方' }]}
>
<Input placeholder="微信号 / 支付宝 / 姓名" />
</Form.Item>
</Col>
<Col span={9}>
<Form.Item name="payeeAccount" label="收款账号(可选)">
<Input placeholder="账号、手机号或备注" />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={6}>
<Form.Item name="paymentMethod" label="付款方式" rules={[{ required: true }]}>
<Select options={PAYMENT_METHOD_OPTIONS} />
</Form.Item>
</Col>
<Col span={9}>
<Form.Item name="paidAt" label="付款时间" rules={[{ required: true }]}>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={9}>
<Form.Item
name="evidenceReference"
label="凭证链接 / 流水号"
rules={[{ required: true, message: '至少留一项可追溯凭证' }]}
>
<Input placeholder="转账截图链接、微信流水号或聊天记录说明" />
</Form.Item>
</Col>
</Row>
<Form.Item name="note" label="备注 / 原因">
<Input.TextArea rows={3} placeholder="赔偿、加急、补差价等必须写清原因" />
</Form.Item>
</Form>
</Modal>
<Modal
title={`驳回 ${rejectTarget?.expenseNo || ''}`}
open={Boolean(rejectTarget)}
onCancel={() => setRejectTarget(null)}
onOk={submitReject}
confirmLoading={Boolean(rejectTarget && actionId === rejectTarget.id)}
okText="确认驳回"
okButtonProps={{ danger: true }}
>
<Input.TextArea
rows={3}
value={rejectReason}
onChange={(event) => setRejectReason(event.target.value)}
placeholder="必须填写驳回原因"
/>
</Modal>
</section>
)
}
@@ -1,6 +1,7 @@
import {
ArrowLeftOutlined,
CopyOutlined,
FileTextOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
StopOutlined,
@@ -181,42 +182,50 @@ export default function AdminOrderDetailPage() {
<Card
title="基础信息"
extra={
isAutoClaimOrder ? (
<Space>
<Button icon={<CopyOutlined />} onClick={handleCopyH5Link}>
复制 H5 领取链接
</Button>
{detail.order.claimStatus === 'paused' || detail.order.claimStatus === 'closed' ? (
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => openStatusModal('active')}
loading={statusMutation.isPending}
>
恢复启用
<Space>
<Button
icon={<FileTextOutlined />}
onClick={() => navigate(`/admin/manual-expenses?orderId=${detail.order.orderId}`)}
>
登记人工支出
</Button>
{isAutoClaimOrder ? (
<>
<Button icon={<CopyOutlined />} onClick={handleCopyH5Link}>
复制 H5 领取链接
</Button>
) : canPauseOrClose ? (
<Button
danger
icon={<PauseCircleOutlined />}
onClick={() => openStatusModal('paused')}
loading={statusMutation.isPending}
>
暂停兑换
</Button>
) : null}
{canPauseOrClose && detail.order.claimStatus !== 'closed' ? (
<Button
danger
icon={<StopOutlined />}
onClick={() => openStatusModal('closed')}
loading={statusMutation.isPending}
>
关闭兑换
</Button>
) : null}
</Space>
) : null
{detail.order.claimStatus === 'paused' || detail.order.claimStatus === 'closed' ? (
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => openStatusModal('active')}
loading={statusMutation.isPending}
>
恢复启用
</Button>
) : canPauseOrClose ? (
<Button
danger
icon={<PauseCircleOutlined />}
onClick={() => openStatusModal('paused')}
loading={statusMutation.isPending}
>
暂停兑换
</Button>
) : null}
{canPauseOrClose && detail.order.claimStatus !== 'closed' ? (
<Button
danger
icon={<StopOutlined />}
onClick={() => openStatusModal('closed')}
loading={statusMutation.isPending}
>
关闭兑换
</Button>
) : null}
</>
) : null}
</Space>
}
>
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
@@ -0,0 +1,458 @@
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Col,
Form,
Row,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import PageHeader from '@/components/admin/PageHeader'
import dayjs from '@/lib/dayjs'
import {
fetchAdminSalesDailyFacts,
fetchAdminSalesProfitOverview,
rebuildAdminSalesDailyFacts,
} from '@/services/admin'
import type { AdminSalesDailyFact } from '@/types/admin/finance'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatMoney } from './panels/shared'
type FilterForm = { dateRange?: unknown; provider?: string; carrierType?: string }
const CARRIER_OPTIONS = [
{ value: '', label: '全部载体' },
{ value: 'affiliate_dash', label: '自动发货' },
{ value: 'work_order', label: '接单平台' },
{ value: 'manual', label: '人工客服' },
{ value: 'shared', label: '共用' },
]
const CARRIER_LABEL: Record<string, string> = {
affiliate_dash: '自动发货',
work_order: '接单平台',
manual: '人工客服',
shared: '共用',
unknown: '未知',
}
const COST_STAGE_LABEL: Record<string, { label: string; color: string }> = {
missing: { label: '无成本', color: 'red' },
partial: { label: '部分缺失', color: 'orange' },
estimated: { label: '预估成本', color: 'blue' },
actual: { label: '实际成本', color: 'green' },
}
export default function AdminSalesProfitPage() {
const { message } = App.useApp()
const [form] = Form.useForm<FilterForm>()
const canConfig = hasAdminPermission('finance.config')
const [filters, setFilters] = useState<{
dateFrom: string
dateTo: string
provider: string
carrierType: string
}>({ dateFrom: '', dateTo: '', provider: '', carrierType: '' })
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [rebuilding, setRebuilding] = useState(false)
const overviewQuery = useQuery({
queryKey: ['admin-sales-profit-overview', filters],
queryFn: () => fetchAdminSalesProfitOverview(filters),
})
const factsQuery = useQuery({
queryKey: ['admin-sales-daily-facts', filters, page, pageSize],
queryFn: () => fetchAdminSalesDailyFacts({ ...filters, page, pageSize }),
})
const overview = overviewQuery.data?.data
const facts = factsQuery.data?.data
function applyFilters(values: FilterForm) {
const range = Array.isArray(values.dateRange) ? values.dateRange : []
setPage(1)
setFilters({
dateFrom: range[0] ? dayjs(range[0]).format('YYYY-MM-DD') : '',
dateTo: range[1] ? dayjs(range[1]).format('YYYY-MM-DD') : '',
provider: values.provider || '',
carrierType: values.carrierType || '',
})
}
function resetFilters() {
form.resetFields()
setPage(1)
setFilters({ dateFrom: '', dateTo: '', provider: '', carrierType: '' })
}
async function rebuildFacts() {
setRebuilding(true)
try {
const response = await rebuildAdminSalesDailyFacts({
dateFrom: filters.dateFrom || undefined,
dateTo: filters.dateTo || undefined,
})
message.success(
`销售事实已重算:${response.data.insertedCount} 行,范围 ${response.data.range.fromDate} ~ ${response.data.range.toDate}`,
)
await Promise.all([overviewQuery.refetch(), factsQuery.refetch()])
} catch (error) {
message.error(error instanceof Error ? error.message : '重算销售事实失败')
} finally {
setRebuilding(false)
}
}
const summary = overview?.overview
const dailyColumns: TableColumnsType<NonNullable<typeof overview>['daily'][number]> = [
{ title: '日期', dataIndex: 'statDate', width: 120 },
{ title: '订单项', dataIndex: 'itemCount', width: 90 },
{ title: '销售收入', dataIndex: 'grossRevenueFen', render: formatMoney },
{ title: '退款', dataIndex: 'refundAmountFen', render: formatMoney },
{ title: '成本', dataIndex: 'costAmountFen', render: formatMoney },
{ title: '缺失成本', dataIndex: 'costMissingCount', width: 100 },
{
title: '毛利',
dataIndex: 'grossProfitFen',
render: (value: number) => (
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
{formatMoney(value)}
</Typography.Text>
),
},
]
const productColumns: TableColumnsType<NonNullable<typeof overview>['byProduct'][number]> = [
{
title: '标准商品',
dataIndex: 'canonicalName',
width: 320,
ellipsis: true,
render: (value: string, row) => (
<Space size={4}>
<span>{value}</span>
{row.canonicalProductId ? (
<Tag>{row.canonicalSku}</Tag>
) : (
<Tag color="orange">未归一</Tag>
)}
</Space>
),
},
{ title: '订单项', dataIndex: 'itemCount', width: 90 },
{ title: '销售收入', dataIndex: 'grossRevenueFen', render: formatMoney },
{ title: '成本', dataIndex: 'costAmountFen', render: formatMoney },
{ title: '缺失成本', dataIndex: 'costMissingCount', width: 100 },
{
title: '毛利',
dataIndex: 'grossProfitFen',
render: (value: number) => (
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
{formatMoney(value)}
</Typography.Text>
),
},
]
const carrierColumns: TableColumnsType<NonNullable<typeof overview>['byCarrier'][number]> = [
{
title: '履约载体',
dataIndex: 'carrierType',
render: (value: string) => CARRIER_LABEL[value] || value,
},
{ title: '订单项', dataIndex: 'itemCount', width: 90 },
{ title: '销售收入', dataIndex: 'grossRevenueFen', render: formatMoney },
{ title: '成本', dataIndex: 'costAmountFen', render: formatMoney },
{ title: '缺失成本', dataIndex: 'costMissingCount', width: 100 },
{
title: '毛利',
dataIndex: 'grossProfitFen',
render: (value: number) => (
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
{formatMoney(value)}
</Typography.Text>
),
},
]
const factColumns: TableColumnsType<AdminSalesDailyFact> = [
{ title: '日期', dataIndex: 'statDate', width: 110 },
{ title: '来源', dataIndex: 'provider', width: 120 },
{
title: '店铺',
dataIndex: 'shopId',
width: 140,
ellipsis: true,
render: (value) => value || '全局',
},
{
title: '商品',
dataIndex: 'canonicalName',
width: 260,
ellipsis: true,
render: (value: string, row) =>
row.canonicalProductId ? value : <Tag color="orange">未归一</Tag>,
},
{
title: '载体',
dataIndex: 'carrierType',
width: 100,
render: (value: string) => CARRIER_LABEL[value] || value,
},
{ title: '订单项', dataIndex: 'itemCount', width: 80 },
{ title: '销售收入', dataIndex: 'grossRevenueFen', width: 120, render: formatMoney },
{ title: '成本', dataIndex: 'costAmountFen', width: 120, render: formatMoney },
{
title: '成本状态',
dataIndex: 'costStage',
width: 110,
render: (value: string) => {
const meta = COST_STAGE_LABEL[value] || { label: value, color: 'default' }
return <Tag color={meta.color}>{meta.label}</Tag>
},
},
{
title: '毛利',
dataIndex: 'grossProfitFen',
width: 130,
render: (value: number) => (
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
{formatMoney(value)}
</Typography.Text>
),
},
]
return (
<section className="page-stack">
<PageHeader
title="销售毛利"
description="按订单项归集收入与成本;未归一商品单独成行,成本缺失时毛利不直接用于决策。"
/>
<Card>
<Form<FilterForm>
form={form}
layout="inline"
className="filter-form"
onFinish={applyFilters}
initialValues={{
dateRange: [dayjs().subtract(29, 'day'), dayjs()],
provider: '',
carrierType: '',
}}
>
<Form.Item name="dateRange" label="销售日期">
<AdminRangePicker format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="provider" label="来源">
<Select
style={{ width: 150 }}
options={[
{ value: '', label: '全部来源' },
{ value: '91kaquan', label: '91卡券' },
{ value: 'pdd', label: '拼多多' },
{ value: 'douyin_mashida', label: '抖音码速达' },
]}
/>
</Form.Item>
<Form.Item name="carrierType" label="载体">
<Select style={{ width: 150 }} options={CARRIER_OPTIONS} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
查询
</Button>
<Button onClick={resetFilters}>重置</Button>
{canConfig ? (
<Button icon={<ReloadOutlined />} loading={rebuilding} onClick={rebuildFacts}>
重算销售事实
</Button>
) : null}
</Space>
</Form.Item>
</Form>
</Card>
{overviewQuery.error ? (
<Alert
type="error"
showIcon
message="读取销售毛利失败"
description={
overviewQuery.error instanceof Error ? overviewQuery.error.message : '请稍后重试'
}
/>
) : null}
{summary && summary.costMissingCount > 0 ? (
<Alert
type="warning"
showIcon
message={`有 ${summary.costMissingCount} 个订单项没有成本快照,当前毛利偏高`}
description="请先在「商品与成本」中补齐成本项/版本并回填快照,再使用毛利数据做经营决策。"
/>
) : null}
{summary && summary.unmappedRevenuePct > 0 ? (
<Alert
type="info"
showIcon
message={`未归一商品收入占 ${summary.unmappedRevenuePct.toFixed(2)}%`}
description={`未归一收入 ${formatMoney(summary.unmappedRevenueFen)},已单独成行;建议补渠道 SKU 映射。`}
/>
) : null}
{summary && summary.ordersWithoutItems > 0 ? (
<Alert
type="info"
showIcon
message={`有 ${summary.ordersWithoutItems} 个已支付订单没有订单项`}
description={`涉及 ${formatMoney(summary.revenueWithoutItemsFen)},无法归集到商品与成本,未进入销售事实。`}
/>
) : null}
<Row gutter={[12, 12]}>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic
title="销售收入"
value={summary?.grossRevenueFen || 0}
formatter={() => formatMoney(summary?.grossRevenueFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic
title="净收入"
value={summary?.netRevenueFen || 0}
formatter={() => formatMoney(summary?.netRevenueFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic
title="成本"
value={summary?.costAmountFen || 0}
formatter={() => formatMoney(summary?.costAmountFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic
title="毛利"
value={summary?.grossProfitFen || 0}
valueStyle={{ color: (summary?.grossProfitFen || 0) >= 0 ? '#3f8600' : '#cf1322' }}
formatter={() => formatMoney(summary?.grossProfitFen || 0)}
/>
</Card>
</Col>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic title="订单数" value={summary?.orderCount || 0} />
</Card>
</Col>
<Col xs={12} md={8} xl={4}>
<Card>
<Statistic title="订单项" value={summary?.itemCount || 0} />
</Card>
</Col>
</Row>
<Card>
<Tabs
items={[
{
key: 'daily',
label: '日报',
children: (
<Table
rowKey="statDate"
size="small"
columns={dailyColumns}
dataSource={overview?.daily || []}
loading={overviewQuery.isLoading}
pagination={false}
scroll={{ x: 900 }}
/>
),
},
{
key: 'product',
label: '商品',
children: (
<Table
rowKey={(row) => `${row.canonicalProductId || 'unmapped'}`}
size="small"
columns={productColumns}
dataSource={overview?.byProduct || []}
loading={overviewQuery.isLoading}
pagination={false}
scroll={{ x: 900 }}
/>
),
},
{
key: 'carrier',
label: '载体',
children: (
<Table
rowKey="carrierType"
size="small"
columns={carrierColumns}
dataSource={overview?.byCarrier || []}
loading={overviewQuery.isLoading}
pagination={false}
scroll={{ x: 800 }}
/>
),
},
{
key: 'facts',
label: '事实明细',
children: (
<Table
rowKey="id"
size="small"
columns={factColumns}
dataSource={facts?.items || []}
loading={factsQuery.isLoading}
pagination={buildAdminTablePagination({
current: page,
pageSize,
total: facts?.pagination.total || 0,
showTotal: (total) => `共 ${total} 条事实`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
scroll={{ x: 1400 }}
/>
),
},
]}
/>
</Card>
</section>
)
}
+33 -2
View File
@@ -1,5 +1,10 @@
import { apiGet } from '@/lib/http'
import type { AdminFinanceSummary, AdminFinanceTransactionResult } from '@/types/admin/finance'
import { apiGet, apiPost } from '@/lib/http'
import type {
AdminFinanceSummary,
AdminFinanceTransactionResult,
AdminSalesFactsResult,
AdminSalesProfitOverview,
} from '@/types/admin/finance'
export type AdminFinanceDateType = 'occurred' | 'created' | 'reviewed'
@@ -22,3 +27,29 @@ export function fetchAdminFinanceTransactions(
) {
return apiGet<AdminFinanceTransactionResult>('/api/v1/admin/finance/transactions', params)
}
export type AdminSalesProfitParams = {
dateFrom?: string
dateTo?: string
provider?: string
carrierType?: string
page?: number
pageSize?: number
}
export function fetchAdminSalesProfitOverview(params: AdminSalesProfitParams = {}) {
return apiGet<AdminSalesProfitOverview>('/api/v1/admin/finance/sales-profit/overview', params)
}
export function fetchAdminSalesDailyFacts(params: AdminSalesProfitParams = {}) {
return apiGet<AdminSalesFactsResult>('/api/v1/admin/finance/sales-facts', params)
}
export function rebuildAdminSalesDailyFacts(payload: { dateFrom?: string; dateTo?: string }) {
return apiPost<{
range: { fromDate: string; toDate: string }
deletedCount: number
insertedCount: number
computedAt: string
}>('/api/v1/admin/finance/sales-facts/rebuild', payload)
}
@@ -1,6 +1,7 @@
export * from './auth'
export * from './dashboard'
export * from './finance'
export * from './manual-expense'
export * from './cost-center'
export * from './fund'
export * from './users'
@@ -0,0 +1,70 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
AdminManualExpense,
AdminManualExpenseListResult,
AdminManualExpenseOverview,
} from '@/types/admin/manual-expense'
export type AdminManualExpenseParams = {
dateFrom?: string
dateTo?: string
status?: string
keyword?: string
costItemId?: number
paidByAdminUserId?: number
orderId?: number
page?: number
pageSize?: number
}
export function fetchAdminManualExpenses(params: AdminManualExpenseParams = {}) {
return apiGet<AdminManualExpenseListResult>('/api/v1/admin/finance/manual-expenses', params)
}
export function fetchAdminManualExpenseOverview(params: AdminManualExpenseParams = {}) {
return apiGet<AdminManualExpenseOverview>(
'/api/v1/admin/finance/manual-expenses/overview',
params,
)
}
export function fetchAdminManualExpenseCostItems() {
return apiGet<{
items: Array<{
id: number
code: string
name: string
costType: string
carrierType: string
pricingMode: string
}>
}>('/api/v1/admin/finance/manual-expenses/cost-items')
}
export function fetchAdminManualExpense(expenseId: number) {
return apiGet<AdminManualExpense>(`/api/v1/admin/finance/manual-expenses/${expenseId}`)
}
export function createAdminManualExpense(payload: Record<string, unknown>) {
return apiPost<AdminManualExpense>('/api/v1/admin/finance/manual-expenses', payload)
}
export function approveAdminManualExpense(
expenseId: number,
payload: Record<string, unknown> = {},
) {
return apiPost<AdminManualExpense>(
`/api/v1/admin/finance/manual-expenses/${expenseId}/approve`,
payload,
)
}
export function rejectAdminManualExpense(expenseId: number, rejectReason: string) {
return apiPost<AdminManualExpense>(`/api/v1/admin/finance/manual-expenses/${expenseId}/reject`, {
rejectReason,
})
}
export function reimburseAdminManualExpense(expenseId: number) {
return apiPost<AdminManualExpense>(`/api/v1/admin/finance/manual-expenses/${expenseId}/reimburse`)
}
+80
View File
@@ -60,3 +60,83 @@ export type AdminFinanceTransactionResult = {
pagination: { page: number; pageSize: number; total: number }
range: { from: string; to: string }
}
export type AdminSalesProfitRange = {
fromDate: string
toDate: string
}
export type AdminSalesProfitOverview = {
range: AdminSalesProfitRange
overview: {
orderCount: number
ordersWithoutItems: number
revenueWithoutItemsFen: number
itemCount: number
grossRevenueFen: number
refundAmountFen: number
netRevenueFen: number
costAmountFen: number
costMissingCount: number
grossProfitFen: number
unmappedRevenueFen: number
unmappedRevenuePct: number
}
daily: Array<{
statDate: string
itemCount: number
grossRevenueFen: number
refundAmountFen: number
netRevenueFen: number
costAmountFen: number
costMissingCount: number
grossProfitFen: number
}>
byCarrier: Array<{
carrierType: string
itemCount: number
grossRevenueFen: number
costAmountFen: number
costMissingCount: number
grossProfitFen: number
}>
byProduct: Array<{
canonicalProductId: number | null
canonicalSku: string
canonicalName: string
itemCount: number
grossRevenueFen: number
costAmountFen: number
costMissingCount: number
grossProfitFen: number
}>
}
export type AdminSalesDailyFact = {
id: number
statDate: string
provider: string
shopId: string
canonicalProductId: number | null
canonicalSku: string
canonicalName: string
carrierType: string
orderCount: number
itemCount: number
quantity: number
grossRevenueFen: number
refundAmountFen: number
netRevenueFen: number
costAmountFen: number
costMissingCount: number
costStage: 'missing' | 'partial' | 'estimated' | 'actual'
grossProfitFen: number
computedAt: string
}
export type AdminSalesFactsResult = {
range: AdminSalesProfitRange
items: AdminSalesDailyFact[]
pagination: { page: number; pageSize: number; total: number }
computedAt: string
}
+12
View File
@@ -26,8 +26,20 @@ export type {
AdminFinanceTransaction,
AdminFinanceSummary,
AdminFinanceTransactionResult,
AdminSalesDailyFact,
AdminSalesFactsResult,
AdminSalesProfitOverview,
AdminSalesProfitRange,
} from './finance'
// Manual expense types
export type {
AdminManualExpense,
AdminManualExpenseListResult,
AdminManualExpenseOverview,
AdminManualExpenseStatus,
} from './manual-expense'
// Fund / imprest account types
export type {
AdminFundAccount,
@@ -0,0 +1,82 @@
export type AdminManualExpenseStatus =
| 'draft'
| 'submitted'
| 'approved'
| 'rejected'
| 'reimbursed'
| 'reversed'
export type AdminManualExpense = {
id: number
expenseNo: string
orderId: number | null
orderItemId: number | null
workOrderId: number | null
fundExpenseId: number | null
costItemId: number | null
costItemName: string
costItemCode: string
expenseCategory: string
amountFen: number
currency: string
paidByAdminUserId: number | null
paidByName: string
payeeType: string
payeeName: string
payeeAccount: string
paymentMethod: string
paidAt: string
status: AdminManualExpenseStatus
approvedBy: string
approvedAt: string | null
rejectedBy: string
rejectedAt: string | null
rejectReason: string
reimbursedBy: string
reimbursedAt: string | null
evidenceJson: Record<string, unknown>
costSnapshotId: number | null
reversalOfId: number | null
expenseBatchNo: string
note: string
createdAt: string
updatedAt: string
platformOrderId: string
provider: string
shopId: string
skuCode: string
skuName: string
workOrderNo: string
}
export type AdminManualExpenseListResult = {
range: { fromDate: string; toDate: string }
items: AdminManualExpense[]
pagination: { page: number; pageSize: number; total: number }
}
export type AdminManualExpenseOverview = {
range: { fromDate: string; toDate: string }
overview: {
submittedCount: number
submittedAmountFen: number
approvedCount: number
approvedAmountFen: number
reimbursedCount: number
reimbursedAmountFen: number
rejectedCount: number
rejectedAmountFen: number
noOrderCount: number
noOrderAmountFen: number
unclassifiedCount: number
unclassifiedAmountFen: number
}
byPayer: Array<{ paidByName: string; recordCount: number; amountFen: number }>
byCostItem: Array<{
costItemId: number | null
costItemName: string
expenseCategory: string
recordCount: number
amountFen: number
}>
}
+15 -15
View File
@@ -1,6 +1,6 @@
# order_site 文档索引
> 最后校准:2026-10-18,代码基线 `main @ 7542d547`。
> 最后校准:2026-10-22,代码基线 `dev @ 15989af8 + 财务 103–111`。
> 本文是 `docs/` 的唯一入口。新增文档必须在上表登记状态;过期文档不再直接改写结论,统一移入 `docs/archive/`。
## 一、先记住五条口径
@@ -35,7 +35,7 @@
| 名称 | 业务含义 | 当前状态 |
| --- | --- | --- |
| `manual_expenses` | **客服备用金支出明细**:客服从自己的备用金里充值/采购/赔偿,减少客服余额 | 已实施(迁移 100) |
| `manual_expense_records` | **公司人工支出/垫付登记**:按订单/成本项记录公司实际付出去的钱、付款人、收款人、凭证、审批 | 仅草案,未实施 |
| `manual_expense_records` | **公司人工支出/垫付登记**:按订单/成本项记录公司实际付出去的钱、付款人、收款人、凭证、审批 | 已实施 P0(迁移 111) |
后续做财务系统时,**不要把 `manual_expenses` 直接改名或复用为 `manual_expense_records`**。两者业务含义、审批链和报表口径不同,应通过 `fund_expense_id` 建立关联。
@@ -54,22 +54,22 @@
| --- | --- | --- |
| 客服备用金台账 | **已实施 P0** | `fund_accounts` / `fund_entries` / `manual_expenses` / `fund_reconciliations`;后台页面、权限、审计已完成 |
| 后台用户中文显示名 | **已实施** | `admin_users.display_name`,备用金姓名触发器同步;审计、登录记录、任务处理人优先显示中文名 |
| 商品主数据 + 渠道 SKU 映射 | **已实施 P0(feat 分支)** | 迁移 `103`(映射消歧在 `106`);`product_catalog` / `channel_product_mappings`,后台「商品与成本」页支持维护、历史订单项回填与按载体分区 |
| 成本中心 | **已实施 P0(feat 分支)** | 迁移 `104` + 修正 `105`;`107` 增加商品履约载体。`cost_items` / `cost_versions` / `cost_snapshots` / `cost_exceptions` 支持版本、预览、快照、异常队列、自动发货价格导入 |
| 销售事实 / 订单毛利 | 未实施 | `sales_daily_facts` 仍是 DDL 草案;现有财务页不能按商品/店铺/载体算真实毛利 |
| 公司人工支出登记 | 未实施 | 与已实施的客服备用金支出不是同一张表 |
| 商品主数据 + 渠道 SKU 映射 | **已实施 P0(dev 分支)** | 迁移 `103`(映射消歧在 `106`);`product_catalog` / `channel_product_mappings`,后台「商品与成本」页支持维护、历史订单项回填与按载体分区 |
| 成本中心 | **已实施 P0(dev 分支)** | 迁移 `104/105/107/108/110`;`cost_items` / `cost_versions` / `cost_snapshots` / `cost_exceptions` 支持版本、预览、快照、异常队列、自动发货价格导入;接单平台按工单实际报酬、人工按支出登记写入实际快照 |
| 销售事实 / 订单毛利 | **已实施 P0(dev 分支)** | 迁移 `109`;`sales_daily_facts` 按店铺×日期×标准商品×载体汇总,未归一商品单独成行;后台新增「销售毛利」页,成本缺失会显式告警 |
| 公司人工支出登记 | **已实施 P0(dev 分支)** | 迁移 `111`;`manual_expense_records` / `manual_expense_events`,后台「人工支出」页支持登记、审批、驳回、报销;与客服备用金 `manual_expenses` 通过 `fund_expense_id` 关联 |
| 影子总账 | 未实施 | 现有财务页仍是 `UNION ALL` 业务事件拼装,不是复式总账 |
| 平台对账 / 期间关账 | 未实施 | 仅方案 |
> 分支状态(2026-10-22):商品主数据、渠道映射、成本中心与自动发货价格在 `feat/cost-center-wip`,尚未合并 `main`、未部署生产;生产口径仍以 `main` 为准。
> 分支状态(2026-10-22):商品主数据、渠道映射、成本中心、成本快照运行层、销售事实与公司人工支出登记在 `dev` 分支(原 `feat/cost-center-wip` 已合并),**尚未合并 `main`、未部署生产**;生产口径仍以 `main` 为准。
## 三、统一实施顺序
```text
1. 商品主数据 + 渠道 SKU 映射 已完成 P0
2. 成本中心 + 销售事实(先接收入与标准成本) 成本中心已完成 P0;销售事实未实施
3. 订单毛利 / 店铺日报 未实施
4. 公司人工支出登记(桥接现有客服备用金支出) 未实施
2. 成本中心 + 销售事实(先接收入与标准成本) 已完成 P0(迁移 108/109)
3. 订单毛利 / 店铺日报 已完成 P0(销售毛利页,成本缺失显式告警)
4. 公司人工支出登记(桥接现有客服备用金支出) 已完成 P0(迁移 111)
5. 影子总账(订单、工单、押金、提现、寄卖、售后) 未实施
6. 平台对账、报表、期间关账 未实施
```
@@ -81,7 +81,7 @@
| # | 问题 | 当前结论 | 影响 |
| --- | --- | --- | --- |
| 1 | 收入金额口径 | ✅ 用户实付(`orders.total_amount`) | 收入基数 |
| 2 | 收入确认时点 | ⏳ 待定:付款 / 发货 / 验收 / 售后期结束 | 利润表与预收账款 |
| 2 | 收入确认时点 | ✅ 先按 `paid_at` 归属销售日;成本在快照生成后回填 | 利润表与预收账款 |
| 3 | 打手报酬 | ✅ 支出/成本,总额法 | 履约成本 |
| 4 | 退款/仲裁 | ✅ 暂不实现,只预留反向凭证 | 后续冲减 |
| 5 | 人工支出如何入账 | ✅ 以人工支出登记为入口;需与客服备用金支出桥接 | 人工成本与应付垫付人 |
@@ -95,8 +95,8 @@
| --- | --- | --- |
| [项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md) | 参考 | 当前架构、业务链路、风险与改进路线;代码基线早于备用金实现,涉及财务部分以本索引为准 |
| [财务系统集成方案.md](财务系统集成方案.md) | 现行方案(未实施) | 财务系统边界、复式记账模型、核心表、API、分期落地 |
| [成本中心与三块履约模型.md](成本中心与三块履约模型.md) | 现行(P0 已实施) | 自动发货/接单平台/人工客服三块、商品映射与成本中心;销售事实仍为方案 |
| [人工支出登记落地方案-2026-10-17.md](人工支出登记落地方案-2026-10-17.md) | 现行方案(未实施) | 公司人工支出登记:字段、入口、状态机、权限、与备用金/成本中心/总账的衔接 |
| [成本中心与三块履约模型.md](成本中心与三块履约模型.md) | 现行(P0 已实施) | 自动发货/接单平台/人工客服三块、商品映射、成本中心与销售事实 |
| [人工支出登记落地方案-2026-10-17.md](人工支出登记落地方案-2026-10-17.md) | 现行(P0 已实施) | 公司人工支出登记:字段、入口、状态机、权限、与备用金/成本中心/总账的衔接 |
| [影子总账说明.md](影子总账说明.md) | 现行说明(未实施) | 影子总账是什么、和钱包流水的区别、对账与转正标准 |
| [客服备用金台账实施方案-2026-10-18.md](客服备用金台账实施方案-2026-10-18.md) | **已实施 P0** | 客服备用金账户、流水、支出、对账、权限与上线记录 |
| [接单模板匹配引擎重构设计.md](接单模板匹配引擎重构设计.md) | 现行(P0/P1 已实施) | `kuaishou-91 / pdd / douyin-mashida` 三来源匹配引擎 |
@@ -117,8 +117,8 @@
| 文件 | 用途 | 接入前必须处理 |
| --- | --- | --- |
| [drafts/2026-10-17-载体判定与人工块量化.sql](drafts/2026-10-17-载体判定与人工块量化.sql) | 载体分布/人工块量化查询、`carrier_type` 判定与回填 | 只读查询可直接用;DDL 部分按当前迁移号顺延,不占用 100/101 |
| [drafts/2026-10-17-成本中心与销售事实-DDL草案.sql](drafts/2026-10-17-成本中心与销售事实-DDL草案.sql) | `product_catalog`、`channel_product_mappings`、`cost_items/versions/snapshots/exceptions`、`sales_daily_facts` | 商品与成本部分已落地为 `102/103/104`;剩余销售事实按新迁移号继续,草案不再作为建表依据 |
| [drafts/2026-10-17-人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql) | `manual_fulfillment_cases`、`manual_expense_records`、`manual_expense_events` | 已改用 `*_manual_expense_records_*` 命名,并补 `fund_expense_id`;接入前仍需复核与迁移号 |
| [drafts/2026-10-17-成本中心与销售事实-DDL草案.sql](drafts/2026-10-17-成本中心与销售事实-DDL草案.sql) | `product_catalog`、`channel_product_mappings`、`cost_items/versions/snapshots/exceptions`、`sales_daily_facts` | 商品、成本、销售事实均已落地(`103–109`);系统成本项在 `110`;草案仅作设计追溯,不再作为建表依据 |
| [drafts/2026-10-17-人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql) | `manual_fulfillment_cases`、`manual_expense_records`、`manual_expense_events` | 人工支出登记已落地为 `111`;轻量人工 case 仍为草案 |
## 六、厂商协议与外部资料
@@ -1,12 +1,10 @@
-- ============================================================================
-- 人工支出登记 + 轻量人工处理记录(建表草案)
-- ----------------------------------------------------------------------------
-- 状态:草案(DRAFT)。**未接入** apps/backend/src/db/migrations。
-- 依赖:本文引用 product_catalog / cost_items / cost_snapshots / manual_expenses,
-- 必须**在** `2026-10-17-成本中心与销售事实-DDL草案.sql` **之后**应用;
-- `manual_expenses` 已由迁移 100 创建(客服备用金支出)。
-- 当前已使用迁移至 107(成本中心落在 103–107);本文建议顺延为
-- `108_manual_cases.sql`、`109_manual_expenses.sql`,接入时按实际最大号调整并先跑 `npm run check:sql`。
-- 状态:已实施 P0。`manual_expense_records` / `manual_expense_events` 已接入
-- `111_manual_expense_records.sql`;`manual_fulfillment_cases` 仍未实施。
-- 依赖:product_catalog / cost_items / cost_snapshots / manual_expenses。
-- 当前已使用迁移至 111(成本中心 103–108,系统成本项 110,人工支出 111)。
-- 基线:main @ 7542d547;依据:2026-10-17 业务口径确认 + 2026-10-18 备用金落地复核。
--
-- 重要命名边界:
@@ -1,13 +1,14 @@
-- ============================================================================
-- 通用成本中心 + 标准商品 + 销售事实(建表草案)
-- ----------------------------------------------------------------------------
-- 状态:部分落地。商品主数据 + 渠道映射 + 成本中心已接入 `103_product_catalog.sql`、
-- `104_cost_center.sql`、`105_cost_center_bootstrap_fix.sql`;本文件仅保留设计追溯。
-- 尚未实施的 `sales_daily_facts` 等后续内容,接入时从新迁移号继续,并先跑 `npm run check:sql`。
-- 状态:商品、成本、销售事实均已落地。商品主数据 + 渠道映射接入 `103_product_catalog.sql`,
-- 成本中心接入 `104_cost_center.sql` / `105_cost_center_bootstrap_fix.sql` / `108_cost_snapshot_runtime.sql`,
-- 销售事实接入 `109_sales_daily_facts.sql`;本文件仅保留设计追溯。
-- 注意:实际 `sales_daily_facts.canonical_product_id` 允许为空(未归一桶),与下方草案的 NOT NULL 不同。
-- 基线:main @ 7542d547;数据依据:本地只读克隆(生产快照,orders 316,597,数据截至 2026-10-14)。
-- 金额单位:分(BIGINT)。本文件所有 *_fen 列均为分。
-- 设计依据:docs/成本中心与三块履约模型.md(三块载体、成本版本与快照)、docs/财务系统集成方案.md。
-- 当前状态:商品主数据、渠道映射、成本中心已实施 P0;销售事实未实施。客服备用金支出已实施,但不是本文件的成本中心。
-- 当前状态:商品主数据、渠道映射、成本中心、销售事实均已实施 P0。
--
-- 目标(业务原话):以后改成本只在一个地方。
-- -> 所有成本金额只写 cost_versions;业务表只保存 cost_snapshot_id 与派生金额,永不回写历史。
@@ -1,11 +1,11 @@
# 人工支出登记落地方案(2026-10-18 修订)
> 状态:**现行方案(未实施)**
> 最后校准:2026-10-18
> 代码基线:`main @ 7542d547`
> 状态:**已实施 P0(`dev` 分支,迁移 111)**
> 最后校准:2026-10-22
> 代码基线:`dev @ 15989af8 + 财务 108/109/110/111`
> 依据:业务口径「人工块当前不以 SLA 监管为目标,先把流程搞清楚:哪个客服处理了哪些东西、付了多少钱、什么项目、关联哪个订单」。
> 数据来源:本地只读克隆(生产快照,数据截至 2026-10-14)+ 现有代码盘点。
> 重要:**客服备用金支出已经实施**(`manual_expenses`),但本文的**公司人工支出登记**(`manual_expense_records`)尚未实施;两张表不是一回事,不能直接复用或改名。
> 重要:**客服备用金支出已经实施**(`manual_expenses`);**公司人工支出登记**(`manual_expense_records`)也已实施,两张表通过 `fund_expense_id` 关联,不是同一张表。
> 相关文档:[成本中心与三块履约模型.md](成本中心与三块履约模型.md) §8 · [财务系统集成方案.md](财务系统集成方案.md) §八 · [客服备用金台账实施方案](客服备用金台账实施方案-2026-10-18.md) ·
> DDL 草案 [drafts/2026-10-17-人工支出登记-DDL草案.sql](drafts/2026-10-17-人工支出登记-DDL草案.sql)
@@ -16,22 +16,32 @@
| 表 | 业务含义 | 当前状态 | 关键字段 |
| --- | --- | --- | --- |
| `manual_expenses` | **客服备用金支出**:客服从自己的备用金账户里充值/采购/赔偿 | **已实施** | `account_id` / `fund_entry_id` / `category` / `payee_*` / `evidence_json` |
| `manual_expense_records` | **公司人工支出/垫付登记**:按订单/成本项记录公司实际付出去的钱 | 仅草案 | `order_id` / `order_item_id` / `cost_item_id` / `paid_by_admin_user_id` / `status` / `cost_snapshot_id` |
| `manual_expense_records` | **公司人工支出/垫付登记**:按订单/成本项记录公司实际付出去的钱 | **已实施 P0(迁移 111)** | `order_id` / `order_item_id` / `cost_item_id` / `paid_by_admin_user_id` / `status` / `cost_snapshot_id` |
结论:
1. 客服侧已经收口的是“备用金余额与用途”,解决“客服手里有多少钱、这笔钱花到哪”。
2. 财务侧还缺的是“公司口径的实际支出/垫付”,解决“公司为哪个订单、哪个成本项、付给谁、谁垫的、有没有审批和凭证”。
2. **公司口径的实际支出/垫付已落地**:后台「人工支出」页(`/admin/manual-expenses`)支持登记、审批、驳回、报销;通过后按成本项写 `cost_snapshots`,没有有效成本版本时进入异常队列。
3. 两张表通过 `fund_expense_id` 关联:如果这笔公司支出来源于客服备用金,就填;公司直付则留空。
4. **不要**把 `manual_expenses` 直接改名为 `manual_expense_records`,也不要让两张表共用索引/约束名。
实现落点(2026-10-22):
- 迁移:`apps/backend/src/db/migrations/111_manual_expense_records.sql`
- 服务:`apps/backend/src/services/finance/manual-expense-service.ts`
- 接口:`apps/backend/src/routes/admin/finance.ts` 的 `/finance/manual-expenses*`
- 页面:`apps/frontend/src/pages/admin/AdminManualExpensePage.tsx`,入口 `/admin/manual-expenses`
- 订单入口:订单详情「登记人工支出」按钮,自动带入订单 ID
- 权限:查看 `finance.view` / `fund.view`,登记 `fund.expense` / `finance.config`,审核 `finance.review`
- 成本快照:通过后以 `subject_type='manual_case'`、`subject_id=expense.id` 写 `actual` 快照;同一订单项多笔支出可累加,不会互相覆盖
---
## 一、结论先行
1. **订单里的数据足够预填 90%**:店铺、商品、规格、数量、用户实付、支付时间、平台单号、履约标签、状态,后台订单列表/详情已经全部具备。客服实际只需要补 **4 件事**:渠道/项目、金额、收款方、凭证(外加默认带出的「谁、什么时候」)。
2. **现有财务页没有“支出”这一侧**。财务页现有 7 个账目类别(订单收款、订单退款、打手报酬、打手提现、打手充值、打手资金回收、售后追缴退还)**全部是收款或打手侧资金**;客服备用金支出已经落在 `fund` 模块,但还没有汇总进财务页,也没有公司口径的人工支出/垫付登记。
2. **原有财务页没有“支出”这一侧**:7 个账目类别全部是收款或打手侧资金。现在公司人工支出已有独立「人工支出」页;客服备用金支出仍在 `fund` 模块,两者通过 `fund_expense_id` 桥接。
3. **落地成本低、风险小**:不需要改订单主表,不需要新角色体系(现有 `admin/finance/operator/support` 四角色 + `finance.*` 权限即可覆盖),只需新增「公司人工支出登记」表 + 订单侧入口 + 财务侧新类别。
4. **登记必须“发生在垫付当天”**才有监管价值;事后补记只能算账,不能算监管。
5. **本轮只补公司口径**:客服备用金台账已完成,不重复做一套客服余额系统;通过 `fund_expense_id` 关联即可。
+1 -1
View File
@@ -9,7 +9,7 @@
>
> 边界:影子总账解决“钱怎么记、怎么对”,不负责“商品是什么、成本是多少、人工谁处理”。商品与成本见 [成本中心与三块履约模型.md](成本中心与三块履约模型.md),人工履约见同一文档第八节。
> 当前进度:客服备用金已实施;`finance_*` 表、分录、对账、转正均未实施。
> 当前进度:客服备用金、商品与成本中心、成本快照运行层、销售事实、公司人工支出登记已实施;`finance_*` 表、分录、对账、转正均未实施。
---
+61 -54
View File
@@ -1,8 +1,8 @@
# 成本中心与三块履约模型
> 状态:**现行(P0 已实施)**。商品主数据、渠道映射、成本项目/版本/快照/异常已落地;销售事实仍为后续方案。
> 最后校准:2026-10-18
> 代码基线:`main @ 7542d547` + 未提交的商品/成本中心 P0
> 状态:**现行(P0 已实施)**。商品主数据、渠道映射、成本中心、成本快照运行层、销售事实与公司人工支出登记已落地;轻量人工 case 与影子总账仍为后续方案。
> 最后校准:2026-10-22
> 代码基线:`dev @ 15989af8 + 财务 108/109/110/111`
> 数据基线:2026-10-17 生产库只读聚合(`default_transaction_read_only=on`,仅 SELECT,无写入)
> 实施顺序以 [README.md](README.md) §三 为准;本文负责“商品、成本、销售事实”的数据模型,不重复总账与页面设计。
> 关联:
@@ -18,32 +18,33 @@
| 能力 | 状态 |
| --- | --- |
| 客服备用金支出(`manual_expenses`) | **已实施**;只解决客服备用金余额与用途,不等于公司人工支出登记 |
| 商品主数据 / 渠道 SKU 映射 | **已实施 P0**;迁移 `102` + 匹配键修正 `105`,后台「商品与成本」页可批量初始化、维护与回填历史订单项 |
| `cost_items` / `cost_versions` / `cost_snapshots` / `cost_exceptions` | **已实施 P0**;迁移 `103` + 修正 `104`,支持版本解析、预览、快照、异常队列 |
| 销售事实 / 订单毛利 | 未实施;`sales_daily_facts` 仍为草案 |
| 公司人工支出登记(`manual_expense_records`) | 未实施 |
| 商品主数据 / 渠道 SKU 映射 | **已实施 P0**;迁移 `103` + 匹配键修正 `106`,后台「商品与成本」页可批量初始化、维护与回填历史订单项 |
| `cost_items` / `cost_versions` / `cost_snapshots` / `cost_exceptions` | **已实施 P0**;迁移 `104` + 修正 `105`,`107` 增加商品载体,`108` 增加业务时间与预估/实际替代 |
| 销售事实 / 订单毛利 | **已实施 P0**;迁移 `109`,`sales_daily_facts` + 后台「销售毛利」页,未归一商品单独成行 |
| 公司人工支出登记(`manual_expense_records`) | **已实施 P0**;迁移 `111`,支持登记/审批/驳回/报销;轻量人工 case 尚未实施 |
| 影子总账 | 未实施;必须在本文件的成本与销售事实之后 |
### 0.1 本期已实现边界
已实现:
- `product_catalog`、`channel_product_mappings`,以及 `order_items.canonical_product_id` / `normalized_quantity`;
- `product_catalog`、`channel_product_mappings`,以及 `order_items.canonical_product_id` / `normalized_quantity` / `revenue_amount_fen` / `revenue_basis`;
- 按 `provider + shop_id + platform_sku_code` 建映射,空 `shop_id` 为来源级映射、具体店铺优先;`provider` 必须与 `orders.provider` 一致(91 为 `91kaquan`,不是匹配层的 `kuaishou-91`,服务层会归一别名);
- 映射回填与 SKU 文本数量回填;
- 映射回填、SKU 文本数量回填与订单项收入固化(一单一品归整单,多品按等额拆分并标 `equal_split`);
- `cost_items` / `cost_versions` / `cost_snapshots` / `cost_exceptions`;
- 成本版本优先级 `rule > product > shop > provider > global`;
- 订单项成本预览、成本快照、缺失成本异常;
- 成本版本优先级 `rule > product > shop > provider > global`,按 `paid_at` / `accepted_at` 等业务时间解析;
- 订单项成本预览、预估快照、工单验收实际快照、缺失成本异常;
- `sync-cost-finance` 脚本与定时任务:批量回填快照并重算销售事实;
- `sales_daily_facts` 与后台「销售毛利」页,未归一商品单独成行,成本缺失显式告警;
- 后台「商品与成本」页、`finance.view` / `finance.config` 权限与审计。
本期不做:
- `sales_daily_facts` 与订单毛利报表;
- 订单进单时自动写 `canonical_product_id` / `normalized_quantity`;
- 自动发货、工单验收、人工 case 在业务事务内自动生成成本快照;
- 上游 `cost_amount` 自动同步为 `cost_versions`;
- 多订单项订单的收入分摊(当前按订单总额传给成本解析器);
- 公司人工支出登记与影子总账。
- 订单进单时自动写 `canonical_product_id` / `normalized_quantity`(目前用后台回填);
- 上游 `cost_amount` 实时自动同步为 `cost_versions`(目前用价格导入脚本);
- 人工 case 在业务事务内自动生成成本快照;
- 退款按日冲减与退款金额字段;
- 影子总账。
---
@@ -141,6 +142,11 @@ product_catalog
上游自动发货商品价格用 `apps/backend/scripts/sync-affiliate-dash-prices.ts` 批量导入(价格清单见 `apps/backend/scripts/data/affiliate-dash-prices.json`):按 `aff:<skuCode>` 补建/更新商品,并给指定成本项生成 `scope=product` 的按件版本;金额取上游 `cost_amount`(分),价格变化时先停用旧版本再插入新版本,历史快照不变。示例:`npx tsx scripts/sync-affiliate-dash-prices.ts --cost-item-id=5`。
接单平台与人工客服各有一个系统成本项(迁移 `110_system_cost_items.sql`):
- `sys_work_order_reward`:工单验收后直接按 `work_orders.reward_amount` 写 `actual` 快照,版本金额 0 只是占位,不进入预估阶段;本地最近 30 天已回填 38,023 条实际快照,金额与工单 reward 逐条一致。
- `sys_manual_service`:人工支出登记通过后按登记金额写 `actual` 快照;成本版本先为 `draft` 占位,不参与预估,但允许人工支出使用实际金额入账。财务填写正式按单单价后可将版本改为 `active`。
### 3.2 渠道 SKU 映射
```text
@@ -311,54 +317,54 @@ cost_snapshots
```text
sales_daily_facts
stat_date -- Asia/Shanghai
stat_date -- Asia/Shanghai,按 paid_at 归属
provider
shop_id
canonical_product_id
sales_channel -- auto / work_order / manual
canonical_product_id -- 允许为空:未归一桶
carrier_type -- affiliate_dash / work_order / manual / shared / unknown
order_count
item_count
quantity -- 归一化件数(SKU 文本解析后)
gross_revenue_fen
refund_fen
refund_amount_fen
net_revenue_fen
auto_cost_fen
work_order_cost_fen
manual_cost_fen
platform_fee_fen
after_sales_loss_fen
gross_profit_fen
cost_amount_fen
cost_missing_count
cost_stage -- missing / partial / estimated / actual
gross_profit_fen
```
### 5.2 三个必须统一的字段
1. **商品**:`canonical_product_id`,不是原始 `sku_code`。
1. **商品**:`canonical_product_id`,不是原始 `sku_code`;为空表示未归一,报表单独成行,不能丢。
2. **数量**:`normalized_quantity`,不是简单的 `order_items.quantity`。
3. **收入**:`revenue_amount_fen` + `revenue_basis`,不是直接 `orders.total_amount`。
3. **收入**:`revenue_amount_fen` + `revenue_basis`,不是直接 `orders.total_amount`。一单一品归整单;多品当前按等额拆分,余数给最后一项。
收入口径建议:
收入口径(2026-10-22 已确认):
| 来源 | 当前字段 | 建议 |
| 来源 | 当前字段 | 口径 |
| --- | --- | --- |
| 91 | `maxAmount` | 明确是结算收入还是成本上限,再归一 |
| PDD | `order_amount` | 作为订单收入候选 |
| 抖音 | `pay_amount` | 作为买家支付收入候选 |
| 91 | `orders.total_amount` | 用户实付,已与快手 `paymentFen` 核验一致 |
| PDD | `orders.total_amount` | 用户实付 |
| 抖音 | `orders.total_amount` | 用户实付 |
### 5.3 退款日期
现在只有 `pay_status=refunded` 和 `updated_at`,不足以做按日净销售。建议补 `refunded_at` / `refund_amount_fen`,或从退款事件表聚合。
现在只有 `pay_status=refunded` 和 `updated_at`,不足以做按日净销售。`sales_daily_facts` 已预留 `refund_amount_fen`,但退款逻辑仍未接入;后续补 `refunded_at` / `refund_amount_fen` 或退款事件表。
### 5.4 统计实现
第一版可以用物化视图:
当前是增量重算表,不是物化视图:
```text
orders + order_items + channel_product_mappings + cost_snapshots
-> sales_daily_facts
-> sales_daily_facts(按日期范围 DELETE + INSERT)
```
数据量目前约 35 万订单、每天 4k+ 单,物化视图 + 索引足够;后续再改增量表。
后台入口:`/admin/sales-profit`。回填脚本:`npm run finance:sync -- --days=14 --mode=all`;定时任务 `cost-finance-sync` 每 5 分钟处理最近 7 天。成本缺失会写入 `cost_missing_count`,毛利在补齐成本前只作参考。
数据校验:本地 2026-07-10 ~ 2026-10-14 共 316,597 个已支付订单,其中 12 单没有订单项(¥2,480.60),无法归集到商品/成本,销售事实会排除并在页面单独提示;其余 316,585 个订单项收入与订单总额完全对平。
最近 30 天回填后,接单平台实际成本快照 38,023 条、金额 1,633,917.74 元;其中落在销售事实日期范围内的成本 1,563,232.98 元,与工单 reward 逐条一致;自动发货与人工客服仍因缺少映射/正式单价显示成本缺失。
---
@@ -548,22 +554,22 @@ flowchart LR
## 十、分期落地
### P0:商品与销售事实(先能做报表)
### P0:商品与销售事实(先能做报表)——已实施
- `product_catalog`
- `channel_product_mappings`
- `sales_daily_facts`(可先物化视图)
- 数量归一、收入口径、退款日期补齐
- `product_catalog`(迁移 `103/107`)
- `channel_product_mappings`(迁移 `103/106`)
- `sales_daily_facts`(迁移 `109`,日期范围重算表)
- 数量归一、收入固化;退款日期字段仍待补
### P1:成本中心影子期
### P1:成本中心影子期——已实施运行层
- `cost_items` / `cost_versions` / `cost_snapshots`
- 自动发货先接固定成本
- 工单先接现有规则金额
- 人工先接按单/按小时成本
- `cost_items` / `cost_versions` / `cost_snapshots`(迁移 `104/105/108`)
- 自动发货接固定成本与价格导入
- 接单平台:系统成本项 `sys_work_order_reward` 在工单验收后按 `reward_amount` 直接写实际快照
- 人工客服:系统成本项 `sys_manual_service` + 人工支出登记通过后写实际快照;正式单价版本待财务填写
- 缺失成本进异常队列
### P2:公司人工支出登记 + 轻量处理记录
### P2:公司人工支出登记 + 轻量处理记录——支出登记已完成,轻量 case 未实施
- 载体判定(`carrier_type`)
- 无载体自动建轻量 case(谁在处理哪个订单项)
@@ -590,14 +596,15 @@ flowchart LR
| 2 | 打手报酬是成本还是代收代付? | **支出/成本**:工单结算后打手会提现,我方实际付款给他们 → 按**总额法**(收入记用户实付全额,报酬记履约成本) | 业务确认 |
| 3 | 人工块的目标是什么? | **先做流程可见与支出可追溯**(谁处理了哪些、付了多少钱、什么项目、关联订单号、凭证),SLA/监管指标延后 | 业务确认 |
| 4 | 退款/仲裁怎么冲减? | **暂未接入,先预留**:字段、反向冲正关系与事件类型先留好,本轮不实现逻辑 | 业务确认 |
| 5 | 收入确认时点? | **2026-10-22 确认:先按 `paid_at` 归属销售日**;成本在快照生成后回填到同一销售日,后续再评估验收/售后期口径 | 业务确认 |
| 6 | 未归一商品怎么进报表? | **2026-10-22 确认:允许 `canonical_product_id` 为空,单独成“未归一”行**,保证总收入与订单总额对平 | 业务确认 |
### 11.2 仍待确认
1. 收入确认时点:付款 / 发货 / 验收 / 售后期结束?(金额口径已定 = 用户实付)
2. 自动发货的 `cost_amount` 是否稳定、是否含平台积分/汇率?
3. 人工支出按单还是按小时归集?(先按单登记,工时字段后续再加)
4. 平台服务费/支付通道费由谁承担、结算周期与账单口径?
5. 寄卖货款的采购成本确认时点?
1. 自动发货的 `cost_amount` 是否稳定、是否含平台积分/汇率?
2. 人工支出按单还是按小时归集?(先按单登记,工时字段后续再加)
3. 平台服务费/支付通道费由谁承担、结算周期与账单口径?
4. 寄卖货款的采购成本确认时点?
---
+25 -9
View File
@@ -1,9 +1,9 @@
# 财务系统集成方案
> 状态:**现行方案(未实施)**。本文描述 `finance_*` 总账层;当前代码尚未建表,现有财务页仍是 `UNION ALL` 业务事件拼装。
> 最后校准:2026-10-18
> 代码基线:`main @ 7542d547`
> 前置:商品主数据、渠道 SKU 映射、成本中心与销售事实必须先完成;商品映射与成本中心已完成 P0,销售事实仍待实施。实施顺序以 [README.md](README.md) §三 为准。
> 状态:**现行方案(总账层未实施)**。本文描述 `finance_*` 总账层;当前 `finance_*` 尚未建表,现有财务页仍是 `UNION ALL` 业务事件拼装。商品、成本中心、销售事实与公司人工支出登记已完成 P0。
> 最后校准:2026-10-22
> 代码基线:`dev @ 15989af8 + 财务 108/109/110/111`
> 前置:商品主数据、渠道 SKU 映射、成本中心、销售事实与公司人工支出登记必须先完成;这些前置已完成 P0。实施顺序以 [README.md](README.md) §三 为准。
> 目标:在**不破坏现有抢单、押金、结算、寄卖、提现链路**的前提下,为 order_site 增加一套可对账、可关账、可追溯的财务系统。
> 结论先说:**不要直接在 `worker_wallets` / `worker_wallet_ledgers` 上继续加字段。** 现有表是业务钱包与流水,不是会计总账;正确做法是新增复式记账底座,让现有钱包成为它的“余额投影”。
>
@@ -16,10 +16,11 @@
| 能力 | 状态 |
| --- | --- |
| 客服备用金台账(`fund_*` / 备用金 `manual_expenses`) | **已实施 P0**,见 [客服备用金台账实施方案](客服备用金台账实施方案-2026-10-18.md) |
| 商品主数据 / 渠道 SKU 映射 / 成本中心 | **已实施 P0(`feat/cost-center-wip`)**,迁移 `103/104/105/106/107`,含商品履约载体与自动发货价格版本,见 [成本中心与三块履约模型](成本中心与三块履约模型.md) |
| 商品主数据 / 渠道 SKU 映射 / 成本中心 | **已实施 P0(`dev` 分支)**,迁移 `103/104/105/106/107/108`,含商品履约载体、自动发货价格版本与成本快照运行层,见 [成本中心与三块履约模型](成本中心与三块履约模型.md) |
| `sales_daily_facts` / 订单级毛利 | **已实施 P0(`dev` 分支)**,迁移 `109`;后台「销售毛利」页按店铺/商品/载体汇总,未归一商品单独成行 |
| 公司人工支出登记 `manual_expense_records` | **已实施 P0(`dev` 分支)**,迁移 `111`;后台「人工支出」页支持登记、审批、驳回、报销,通过后写 `cost_snapshots` |
| `finance_accounts` / `finance_business_events` / `finance_journal_*` | 未实施 |
| `finance_settlements` / `finance_reconciliations` | 未实施 |
| 订单级收入/成本/毛利 | 未实施;成本中心已完成,仍需销售事实与收入分摊 |
| 现有 `admin-finance-service.ts` | 仍是业务事件 `UNION ALL`,不是总账 |
**不要复用客服备用金的 `manual_expenses` 作为公司人工支出表。** 两者口径不同;公司人工支出应使用 `manual_expense_records`,并通过 `fund_expense_id` 关联备用金支出。
@@ -384,7 +385,22 @@ postBusinessEvent({
### 5.2 API
建议新增前缀 `/api/v1/admin/finance`:
已实现(P0,销售事实、成本快照与人工支出):
```text
GET /api/v1/admin/finance/sales-profit/overview
GET /api/v1/admin/finance/sales-facts
POST /api/v1/admin/finance/sales-facts/rebuild
POST /api/v1/admin/cost-center/snapshots/sync
GET /api/v1/admin/finance/manual-expenses
GET /api/v1/admin/finance/manual-expenses/overview
POST /api/v1/admin/finance/manual-expenses
POST /api/v1/admin/finance/manual-expenses/:id/approve
POST /api/v1/admin/finance/manual-expenses/:id/reject
POST /api/v1/admin/finance/manual-expenses/:id/reimburse
```
总账层建议新增前缀 `/api/v1/admin/finance`:
```text
GET /accounts
@@ -474,12 +490,12 @@ POST /periods/:key/close
## 八、需要业务拍板的问题
> 2026-10-17 已确认 4 条口径,明细见 [成本中心与三块履约模型.md](成本中心与三块履约模型.md) §11。
> 2026-10-17 已确认 4 条口径,2026-10-22 追加确认收入时点与未归一商品口径;明细见 [成本中心与三块履约模型.md](成本中心与三块履约模型.md) §11。
| # | 问题 | 状态 | 影响 |
| --- | --- | --- | --- |
| 1 | 收入金额口径 | ✅ **已确认:收入 = 用户实付(`orders.total_amount`)**,与快手 `paymentFen` 数据核验 100% 一致 | 收入基数 |
| 2 | 收入确认时点:付款 / 发货 / 验收 / 售后期结束? | ⏳ 待定(金额口径已定) | 决定利润表与预收账款 |
| 2 | 收入确认时点:付款 / 发货 / 验收 / 售后期结束? | ✅ **先按 `paid_at` 归属销售日**;成本快照生成后回填到同一销售日 | 决定利润表与预收账款 |
| 3 | 平台结算周期与账单口径(91、快手、PDD、抖音、affiliate-dash) | ⏳ 待定 | 决定对账与应收 |
| 4 | 打手报酬是“成本”还是“代收代付”? | ✅ **已确认:支出/成本**(最终提现打款),按**总额法** | 收入总额法/净额法 |
| 5 | 寄卖货款的采购成本确认时点 | ⏳ 待定 | 决定存货/成本 |