diff --git a/apps/backend/package.json b/apps/backend/package.json index cd08c51e..5509c079 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/apps/backend/scripts/sync-cost-finance.ts b/apps/backend/scripts/sync-cost-finance.ts new file mode 100644 index 00000000..8ef8043e --- /dev/null +++ b/apps/backend/scripts/sync-cost-finance.ts @@ -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 = { 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 = {} + 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) +} diff --git a/apps/backend/src/db/migrations/108_cost_snapshot_runtime.sql b/apps/backend/src/db/migrations/108_cost_snapshot_runtime.sql new file mode 100644 index 00000000..4302579f --- /dev/null +++ b/apps/backend/src/db/migrations/108_cost_snapshot_runtime.sql @@ -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=未定'; diff --git a/apps/backend/src/db/migrations/109_sales_daily_facts.sql b/apps/backend/src/db/migrations/109_sales_daily_facts.sql new file mode 100644 index 00000000..502aa470 --- /dev/null +++ b/apps/backend/src/db/migrations/109_sales_daily_facts.sql @@ -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 时该行毛利不可直接用于决策'; diff --git a/apps/backend/src/db/migrations/110_system_cost_items.sql b/apps/backend/src/db/migrations/110_system_cost_items.sql new file mode 100644 index 00000000..260a79ca --- /dev/null +++ b/apps/backend/src/db/migrations/110_system_cost_items.sql @@ -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_ 前缀为系统内置项,业务侧不要删除或改编码'; diff --git a/apps/backend/src/db/migrations/111_manual_expense_records.sql b/apps/backend/src/db/migrations/111_manual_expense_records.sql new file mode 100644 index 00000000..067cb985 --- /dev/null +++ b/apps/backend/src/db/migrations/111_manual_expense_records.sql @@ -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 + '人工支出状态变更审计:只追加,不更新、不删除'; diff --git a/apps/backend/src/repositories/order-item-repo.ts b/apps/backend/src/repositories/order-item-repo.ts index 2fa902a7..cc687faa 100644 --- a/apps/backend/src/repositories/order-item-repo.ts +++ b/apps/backend/src/repositories/order-item-repo.ts @@ -47,6 +47,39 @@ export async function replaceOrderItems( ) } +export async function applyOrderItemRevenueAllocationWithExecutor( + executor: QueryExecutor, + orderId: number | string, + totalAmountFen: number, +): Promise { + 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, diff --git a/apps/backend/src/routes/admin/cost-center.ts b/apps/backend/src/routes/admin/cost-center.ts index 75178baf..1406b28d 100644 --- a/apps/backend/src/routes/admin/cost-center.ts +++ b/apps/backend/src/routes/admin/cost-center.ts @@ -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 diff --git a/apps/backend/src/routes/admin/finance.ts b/apps/backend/src/routes/admin/finance.ts index 7502a042..6e2b7473 100644 --- a/apps/backend/src/routes/admin/finance.ts +++ b/apps/backend/src/routes/admin/finance.ts @@ -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), { + 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), { + 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) : {}, + ), + { + 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), { + 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), { + 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 diff --git a/apps/backend/src/services/admin/admin-cost-center-service.ts b/apps/backend/src/services/admin/admin-cost-center-service.ts index 010256e4..4f7cb5f4 100644 --- a/apps/backend/src/services/admin/admin-cost-center-service.ts +++ b/apps/backend/src/services/admin/admin-cost-center-service.ts @@ -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> = [] 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 { @@ -2787,6 +2838,7 @@ async function resolveCostContext(payload: JsonObject): Promise { 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, diff --git a/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts b/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts new file mode 100644 index 00000000..9d313b5a --- /dev/null +++ b/apps/backend/src/services/cost-center/cost-snapshot-sync-service.ts @@ -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 { + 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 { + 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 } +} diff --git a/apps/backend/src/services/finance/manual-expense-service.ts b/apps/backend/src/services/finance/manual-expense-service.ts new file mode 100644 index 00000000..019a5c82 --- /dev/null +++ b/apps/backend/src/services/finance/manual-expense-service.ts @@ -0,0 +1,1046 @@ +import type { PoolClient } from 'pg' + +import { query, withTransaction } from '../../db/client.js' +import type { JsonObject } from '../../types/json.js' +import { createHttpError } from '../../utils/http.js' +import { resolveFinanceDateRange } from '../../utils/finance-date-range.js' +import { generateManualExpenseNo } from '../../utils/random.js' +import { nowIso } from '../../utils/time.js' +import type { AdminSession } from '../admin/admin-auth-service.js' + +const EXPENSE_STATUSES = new Set([ + 'draft', + 'submitted', + 'approved', + 'rejected', + 'reimbursed', + 'reversed', +]) +const PAYEE_TYPES = new Set(['worker', 'upstream', 'customer', 'platform', 'other']) +const PAYMENT_METHODS = new Set(['wechat', 'alipay', 'bank', 'cash', 'platform_balance', 'other']) +const EXPENSE_CATEGORIES = new Set([ + 'goods_purchase', + 'compensation', + 'refund_advance', + 'service_fee', + 'freight', + 'recharge', + 'other', +]) + +type ManualExpenseRow = { + [column: string]: unknown + id: number + expense_no: string + order_id: number | null + order_item_id: number | null + work_order_id: number | null + fund_expense_id: number | null + cost_item_id: number | null + cost_item_name: string | null + cost_item_code: string | null + expense_category: string + amount_fen: number + currency: string + paid_by_admin_user_id: number | null + paid_by_name: string + payee_type: string + payee_name: string + payee_account: string + payment_method: string + paid_at: string + status: string + approved_by: string + approved_at: string | null + rejected_by: string + rejected_at: string | null + reject_reason: string + reimbursed_by: string + reimbursed_at: string | null + evidence_json: unknown + cost_snapshot_id: number | null + reversal_of_id: number | null + expense_batch_no: string + note: string + created_at: string + updated_at: string + platform_order_id: string | null + provider: string | null + shop_id: string | null + sku_code: string | null + sku_name: string | null + work_order_no: string | null +} + +export async function listManualExpenses(input: Record = {}) { + 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 status = String(input.status || '').trim() + const keyword = String(input.keyword || input.q || '').trim() + const costItemId = normalizeOptionalId(input.costItemId ?? input.cost_item_id) + const paidBy = normalizeOptionalId(input.paidByAdminUserId ?? input.paid_by_admin_user_id) + const orderId = normalizeOptionalId(input.orderId ?? input.order_id) + + const conditions = [ + `(r.paid_at AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date`, + ] + const params: unknown[] = [range.fromDate, range.toDate] + + if (status) { + assertOneOf(status, EXPENSE_STATUSES, '支出状态无效') + params.push(status) + conditions.push(`r.status = $${params.length}`) + } + if (costItemId) { + params.push(costItemId) + conditions.push(`r.cost_item_id = $${params.length}`) + } + if (paidBy) { + params.push(paidBy) + conditions.push(`r.paid_by_admin_user_id = $${params.length}`) + } + if (orderId) { + params.push(orderId) + conditions.push(`r.order_id = $${params.length}`) + } + if (keyword) { + params.push(`%${escapeLike(keyword)}%`) + const placeholder = `$${params.length}` + conditions.push(`( + r.expense_no ILIKE ${placeholder} + OR r.payee_name ILIKE ${placeholder} + OR r.paid_by_name ILIKE ${placeholder} + OR o.platform_order_id ILIKE ${placeholder} + OR oi.sku_name ILIKE ${placeholder} + OR wo.work_order_no ILIKE ${placeholder} + )`) + } + + const where = conditions.join(' AND ') + const countResult = await query<{ total: number }>( + ` + SELECT COUNT(*)::int AS total + FROM manual_expense_records r + LEFT JOIN orders o ON o.id = r.order_id + LEFT JOIN order_items oi ON oi.id = r.order_item_id + LEFT JOIN work_orders wo ON wo.id = r.work_order_id + WHERE ${where} + `, + params, + ) + const listParams = [...params, pageSize, (page - 1) * pageSize] + const result = await query( + ` + SELECT + r.*, + o.platform_order_id, + o.provider, + o.shop_id, + oi.sku_code, + oi.sku_name, + ci.name AS cost_item_name, + ci.code AS cost_item_code, + wo.work_order_no + FROM manual_expense_records r + LEFT JOIN orders o ON o.id = r.order_id + LEFT JOIN order_items oi ON oi.id = r.order_item_id + LEFT JOIN cost_items ci ON ci.id = r.cost_item_id + LEFT JOIN work_orders wo ON wo.id = r.work_order_id + WHERE ${where} + ORDER BY r.paid_at DESC, r.id DESC + LIMIT $${listParams.length - 1} OFFSET $${listParams.length} + `, + listParams, + ) + + return { + range, + items: result.rows.map(mapManualExpense), + pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) }, + } +} + +export async function listManualExpenseCostItems() { + const result = await query<{ + id: number + code: string + name: string + cost_type: string + carrier_type: string + pricing_mode: string + }>( + ` + SELECT id, code, name, cost_type, carrier_type, pricing_mode + FROM cost_items + WHERE status = 'active' + AND code <> 'sys_work_order_reward' + ORDER BY + CASE WHEN code = 'sys_manual_service' THEN 0 ELSE 1 END, + carrier_type, + id + `, + ) + return { + items: result.rows.map((row) => ({ + id: Number(row.id), + code: row.code, + name: row.name, + costType: row.cost_type, + carrierType: row.carrier_type, + pricingMode: row.pricing_mode, + })), + } +} + +export async function getManualExpenseOverview(input: Record = {}) { + const range = resolveFinanceDateRange(input, { defaultDays: 30, maxDays: 400 }) + const [summaryResult, payerResult, itemResult] = await Promise.all([ + query<{ + submitted_count: number + submitted_amount_fen: number + approved_count: number + approved_amount_fen: number + reimbursed_count: number + reimbursed_amount_fen: number + rejected_count: number + rejected_amount_fen: number + no_order_count: number + no_order_amount_fen: number + unclassified_count: number + unclassified_amount_fen: number + }>( + ` + SELECT + COUNT(*) FILTER (WHERE status = 'submitted')::int AS submitted_count, + COALESCE(SUM(amount_fen) FILTER (WHERE status = 'submitted'), 0)::bigint + AS submitted_amount_fen, + COUNT(*) FILTER (WHERE status = 'approved')::int AS approved_count, + COALESCE(SUM(amount_fen) FILTER (WHERE status = 'approved'), 0)::bigint + AS approved_amount_fen, + COUNT(*) FILTER (WHERE status = 'reimbursed')::int AS reimbursed_count, + COALESCE(SUM(amount_fen) FILTER (WHERE status = 'reimbursed'), 0)::bigint + AS reimbursed_amount_fen, + COUNT(*) FILTER (WHERE status = 'rejected')::int AS rejected_count, + COALESCE(SUM(amount_fen) FILTER (WHERE status = 'rejected'), 0)::bigint + AS rejected_amount_fen, + COUNT(*) FILTER (WHERE order_id IS NULL AND order_item_id IS NULL)::int + AS no_order_count, + COALESCE(SUM(amount_fen) FILTER (WHERE order_id IS NULL AND order_item_id IS NULL), 0)::bigint + AS no_order_amount_fen, + COUNT(*) FILTER (WHERE cost_item_id IS NULL AND status <> 'draft')::int + AS unclassified_count, + COALESCE(SUM(amount_fen) FILTER (WHERE cost_item_id IS NULL AND status <> 'draft'), 0)::bigint + AS unclassified_amount_fen + FROM manual_expense_records + WHERE (paid_at AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date + `, + [range.fromDate, range.toDate], + ), + query<{ paid_by_name: string; record_count: number; amount_fen: number }>( + ` + SELECT paid_by_name, COUNT(*)::int AS record_count, SUM(amount_fen)::bigint AS amount_fen + FROM manual_expense_records + WHERE status IN ('approved', 'reimbursed') + AND (paid_at AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date + GROUP BY paid_by_name + ORDER BY amount_fen DESC + LIMIT 20 + `, + [range.fromDate, range.toDate], + ), + query<{ + cost_item_id: number | null + cost_item_name: string | null + expense_category: string + record_count: number + amount_fen: number + }>( + ` + SELECT + r.cost_item_id, + ci.name AS cost_item_name, + r.expense_category, + COUNT(*)::int AS record_count, + SUM(r.amount_fen)::bigint AS amount_fen + FROM manual_expense_records r + LEFT JOIN cost_items ci ON ci.id = r.cost_item_id + WHERE r.status IN ('approved', 'reimbursed') + AND (r.paid_at AT TIME ZONE 'Asia/Shanghai')::date BETWEEN $1::date AND $2::date + GROUP BY r.cost_item_id, ci.name, r.expense_category + ORDER BY amount_fen DESC + LIMIT 20 + `, + [range.fromDate, range.toDate], + ), + ]) + + const summary = summaryResult.rows[0] + return { + range, + overview: { + submittedCount: Number(summary?.submitted_count || 0), + submittedAmountFen: Number(summary?.submitted_amount_fen || 0), + approvedCount: Number(summary?.approved_count || 0), + approvedAmountFen: Number(summary?.approved_amount_fen || 0), + reimbursedCount: Number(summary?.reimbursed_count || 0), + reimbursedAmountFen: Number(summary?.reimbursed_amount_fen || 0), + rejectedCount: Number(summary?.rejected_count || 0), + rejectedAmountFen: Number(summary?.rejected_amount_fen || 0), + noOrderCount: Number(summary?.no_order_count || 0), + noOrderAmountFen: Number(summary?.no_order_amount_fen || 0), + unclassifiedCount: Number(summary?.unclassified_count || 0), + unclassifiedAmountFen: Number(summary?.unclassified_amount_fen || 0), + }, + byPayer: payerResult.rows.map((row) => ({ + paidByName: row.paid_by_name || '未填写', + recordCount: Number(row.record_count || 0), + amountFen: Number(row.amount_fen || 0), + })), + byCostItem: itemResult.rows.map((row) => ({ + costItemId: row.cost_item_id ? Number(row.cost_item_id) : null, + costItemName: row.cost_item_name || '未归类', + expenseCategory: row.expense_category, + recordCount: Number(row.record_count || 0), + amountFen: Number(row.amount_fen || 0), + })), + } +} + +export async function createManualExpense(payload: JsonObject, session: AdminSession) { + const amountFen = normalizeAmountFen(payload.amountFen ?? payload.amount_fen) + const payeeName = requiredText(payload.payeeName ?? payload.payee_name, '收款方', 120) + const payeeType = normalizeStatus( + payload.payeeType ?? payload.payee_type, + PAYEE_TYPES, + 'other', + '收款方类型无效', + ) + const paymentMethod = normalizeStatus( + payload.paymentMethod ?? payload.payment_method, + PAYMENT_METHODS, + 'other', + '付款方式无效', + ) + const status = payload.status === 'draft' ? 'draft' : 'submitted' + const evidenceJson = normalizeEvidence(payload.evidenceJson ?? payload.evidence_json) + if (!hasEvidence(evidenceJson)) { + throw createHttpError('请填写凭证链接/流水号,至少留一项可追溯凭证', { + statusCode: 400, + errorCode: 'manual_expense_evidence_required', + }) + } + + const orderItemId = normalizeOptionalId(payload.orderItemId ?? payload.order_item_id) + const orderIdInput = normalizeOptionalId(payload.orderId ?? payload.order_id) + const orderContext = await resolveOrderContext(orderItemId, orderIdInput) + const orderId = orderContext?.orderId || orderIdInput + const paidAt = normalizeIsoDate(payload.paidAt ?? payload.paid_at, '付款时间', nowIso()) + if (Date.parse(paidAt) > Date.now() + 5 * 60 * 1000) { + throw createHttpError('付款时间不能晚于当前时间', { + statusCode: 400, + errorCode: 'manual_expense_paid_at_future', + }) + } + if (orderContext?.paidAt && Date.parse(paidAt) < Date.parse(orderContext.paidAt)) { + throw createHttpError('人工支出时间不能早于订单支付时间', { + statusCode: 400, + errorCode: 'manual_expense_paid_at_before_order', + }) + } + + const costItem = await resolveCostItem( + normalizeOptionalId(payload.costItemId ?? payload.cost_item_id), + ) + const expenseCategory = normalizeStatus( + payload.expenseCategory ?? payload.expense_category, + EXPENSE_CATEGORIES, + inferExpenseCategory(costItem?.costType), + '支出分类无效', + ) + const paidByAdminUserId = + normalizeOptionalId(payload.paidByAdminUserId ?? payload.paid_by_admin_user_id) || + session.userId + const paidByName = + optionalText(payload.paidByName ?? payload.paid_by_name, 80) || session.displayName + const expenseNo = generateManualExpenseNo() + const now = nowIso() + + return withTransaction(async (client) => { + const result = await client.query( + ` + INSERT INTO manual_expense_records ( + expense_no, order_id, order_item_id, work_order_id, fund_expense_id, + cost_item_id, expense_category, amount_fen, currency, + paid_by_admin_user_id, paid_by_name, payee_type, payee_name, payee_account, + payment_method, paid_at, status, evidence_json, expense_batch_no, note, + created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, 'CNY', + $9, $10, $11, $12, $13, + $14, $15, $16, $17::jsonb, $18, $19, + $20, $20 + ) + RETURNING * + `, + [ + expenseNo, + orderId, + orderItemId, + orderContext?.workOrderId || + normalizeOptionalId(payload.workOrderId ?? payload.work_order_id), + normalizeOptionalId(payload.fundExpenseId ?? payload.fund_expense_id), + costItem?.id || null, + expenseCategory, + amountFen, + paidByAdminUserId, + paidByName, + payeeType, + payeeName, + optionalText(payload.payeeAccount ?? payload.payee_account, 120), + paymentMethod, + paidAt, + status, + JSON.stringify(evidenceJson), + optionalText(payload.expenseBatchNo ?? payload.expense_batch_no, 64), + optionalText(payload.note, 500), + now, + ], + ) + const record = requiredRow(result.rows[0], '人工支出登记失败') + await insertManualExpenseEvent(client, { + expenseId: Number(record.id), + eventType: 'created', + fromStatus: '', + toStatus: status, + actor: session, + payload: { amountFen, costItemId: costItem?.id || null }, + now, + }) + return getManualExpenseByIdWithClient(client, Number(record.id)) + }) +} + +export async function approveManualExpense( + expenseId: number | string, + payload: JsonObject, + session: AdminSession, +) { + const id = normalizeId(expenseId) + const now = nowIso() + return withTransaction(async (client) => { + const current = await lockManualExpense(client, id) + if (current.status === 'approved' || current.status === 'reimbursed') { + return getManualExpenseByIdWithClient(client, id) + } + if (current.status !== 'submitted') { + throw createHttpError('只有待审核的人工支出可以通过', { + statusCode: 409, + errorCode: 'manual_expense_approve_status_invalid', + }) + } + await client.query( + ` + UPDATE manual_expense_records + SET status = 'approved', approved_by = $1, approved_at = $2, updated_at = $2 + WHERE id = $3 + `, + [session.displayName, now, id], + ) + await attachCostSnapshotForExpense(client, current, session) + await insertManualExpenseEvent(client, { + expenseId: id, + eventType: 'approved', + fromStatus: current.status, + toStatus: 'approved', + actor: session, + payload: { note: optionalText(payload.note, 500) }, + now, + }) + return getManualExpenseByIdWithClient(client, id) + }) +} + +export async function rejectManualExpense( + expenseId: number | string, + payload: JsonObject, + session: AdminSession, +) { + const id = normalizeId(expenseId) + const reason = requiredText(payload.rejectReason ?? payload.reject_reason, '驳回原因', 300) + const now = nowIso() + return withTransaction(async (client) => { + const current = await lockManualExpense(client, id) + if (current.status !== 'submitted') { + throw createHttpError('只有待审核的人工支出可以驳回', { + statusCode: 409, + errorCode: 'manual_expense_reject_status_invalid', + }) + } + await client.query( + ` + UPDATE manual_expense_records + SET status = 'rejected', rejected_by = $1, rejected_at = $2, + reject_reason = $3, updated_at = $2 + WHERE id = $4 + `, + [session.displayName, now, reason, id], + ) + await insertManualExpenseEvent(client, { + expenseId: id, + eventType: 'rejected', + fromStatus: current.status, + toStatus: 'rejected', + actor: session, + payload: { reason }, + now, + }) + return getManualExpenseByIdWithClient(client, id) + }) +} + +export async function reimburseManualExpense(expenseId: number | string, session: AdminSession) { + const id = normalizeId(expenseId) + const now = nowIso() + return withTransaction(async (client) => { + const current = await lockManualExpense(client, id) + if (current.status === 'reimbursed') { + return getManualExpenseByIdWithClient(client, id) + } + if (current.status !== 'approved') { + throw createHttpError('只有已通过的人工支出可以标记已报销', { + statusCode: 409, + errorCode: 'manual_expense_reimburse_status_invalid', + }) + } + await client.query( + ` + UPDATE manual_expense_records + SET status = 'reimbursed', reimbursed_by = $1, reimbursed_at = $2, updated_at = $2 + WHERE id = $3 + `, + [session.displayName, now, id], + ) + await insertManualExpenseEvent(client, { + expenseId: id, + eventType: 'reimbursed', + fromStatus: current.status, + toStatus: 'reimbursed', + actor: session, + payload: {}, + now, + }) + return getManualExpenseByIdWithClient(client, id) + }) +} + +export async function getManualExpense(expenseId: number | string) { + return getManualExpenseByIdWithClient(null, normalizeId(expenseId)) +} + +async function getManualExpenseByIdWithClient(client: PoolClient | null, expenseId: number) { + const sql = ` + SELECT + r.*, + o.platform_order_id, + o.provider, + o.shop_id, + oi.sku_code, + oi.sku_name, + ci.name AS cost_item_name, + ci.code AS cost_item_code, + wo.work_order_no + FROM manual_expense_records r + LEFT JOIN orders o ON o.id = r.order_id + LEFT JOIN order_items oi ON oi.id = r.order_item_id + LEFT JOIN cost_items ci ON ci.id = r.cost_item_id + LEFT JOIN work_orders wo ON wo.id = r.work_order_id + WHERE r.id = $1 + LIMIT 1 + ` + const result = client + ? await client.query(sql, [expenseId]) + : await query(sql, [expenseId]) + return mapManualExpense(requiredRow(result.rows[0], '人工支出记录不存在')) +} + +async function lockManualExpense(client: PoolClient, expenseId: number) { + const result = await client.query( + `SELECT * FROM manual_expense_records WHERE id = $1 FOR UPDATE`, + [expenseId], + ) + return requiredRow(result.rows[0], '人工支出记录不存在') +} + +async function attachCostSnapshotForExpense( + client: PoolClient, + expense: ManualExpenseRow, + session: AdminSession, +) { + if (!expense.order_item_id || !expense.cost_item_id || Number(expense.amount_fen) <= 0) { + return null + } + const contextResult = await client.query<{ + order_id: number + canonical_product_id: number | null + provider: string + shop_id: string + work_order_id: number | null + work_product_rule_id: number | null + carrier_type: string + }>( + ` + SELECT + oi.order_id, + oi.canonical_product_id, + o.provider, + o.shop_id, + wo.id AS work_order_id, + wo.product_rule_id AS work_product_rule_id, + ci.carrier_type + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN cost_items ci ON ci.id = $2 + 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 + `, + [expense.order_item_id, expense.cost_item_id], + ) + const context = contextResult.rows[0] + if (!context) return null + + const versionResult = await client.query<{ id: number }>( + ` + SELECT v.id + FROM cost_versions v + WHERE v.cost_item_id = $1 + AND ( + v.status = 'active' + OR ( + v.status = 'draft' + AND EXISTS ( + SELECT 1 FROM cost_items ci + WHERE ci.id = v.cost_item_id AND ci.code = 'sys_manual_service' + ) + ) + ) + AND v.effective_from <= $2 + AND (v.effective_to IS NULL OR v.effective_to > $2) + AND ( + (v.scope_type = 'rule' AND $6::bigint IS NOT NULL AND v.work_product_rule_id = $6) + OR (v.scope_type = 'product' AND $5::bigint IS NOT NULL AND v.canonical_product_id = $5) + OR (v.scope_type = 'shop' AND v.provider = $3 AND v.shop_id = $4) + OR (v.scope_type = 'provider' AND v.provider = $3) + 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 + `, + [ + expense.cost_item_id, + expense.paid_at, + context.provider, + context.shop_id, + context.canonical_product_id, + context.work_product_rule_id, + ], + ) + const versionId = versionResult.rows[0]?.id + if (!versionId) { + await client.query( + ` + INSERT INTO cost_exceptions ( + subject_type, subject_id, order_item_id, work_order_id, + carrier_type, reason, status, detail_json, created_at + ) VALUES ( + 'order_item', $1, $1, $2, $3, 'manual_expense_cost_missing', 'open', $4::jsonb, NOW() + ) + ON CONFLICT (subject_type, subject_id, reason) DO NOTHING + `, + [ + expense.order_item_id, + context.work_order_id, + context.carrier_type, + JSON.stringify({ + expenseId: expense.id, + expenseNo: expense.expense_no, + message: '人工支出已通过,但该成本项没有匹配到有效成本版本', + }), + ], + ) + return null + } + + const snapshotResult = await client.query<{ id: number }>( + ` + 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 + ) VALUES ( + 'manual_case', $1, $2, $3, $4, + $5, $6, $7, $8, + 1, $9, $9, 'actual', 'manual_input', + $10::jsonb, $11, $12, $12 + ) + ON CONFLICT (subject_type, subject_id, cost_item_id) + WHERE superseded_at IS NULL + DO NOTHING + RETURNING id + `, + [ + expense.id, + context.order_id, + expense.order_item_id, + context.work_order_id, + context.carrier_type, + expense.cost_item_id, + versionId, + context.canonical_product_id, + expense.amount_fen, + JSON.stringify({ + source: 'manual_expense_record', + expenseId: expense.id, + expenseNo: expense.expense_no, + paidByName: expense.paid_by_name, + payeeName: expense.payee_name, + approvedBy: session.displayName, + }), + expense.paid_at, + nowIso(), + ], + ) + const snapshotId = snapshotResult.rows[0]?.id + if (snapshotId) { + await client.query( + ` + UPDATE manual_expense_records + SET cost_snapshot_id = $1, updated_at = $2 + WHERE id = $3 + `, + [snapshotId, nowIso(), expense.id], + ) + } + return snapshotId || null +} + +async function insertManualExpenseEvent( + client: PoolClient, + input: { + expenseId: number + eventType: string + fromStatus: string + toStatus: string + actor: AdminSession + payload: JsonObject + now: string + }, +) { + await client.query( + ` + INSERT INTO manual_expense_events ( + expense_id, event_type, from_status, to_status, + actor_type, actor_id, actor_name, payload_json, created_at + ) VALUES ($1, $2, $3, $4, 'admin', $5, $6, $7::jsonb, $8) + `, + [ + input.expenseId, + input.eventType, + input.fromStatus, + input.toStatus, + String(input.actor.userId), + input.actor.displayName, + JSON.stringify(input.payload), + input.now, + ], + ) +} + +async function resolveOrderContext(orderItemId: number | null, orderId: number | null) { + if (orderItemId) { + const result = await query<{ + order_id: number + work_order_id: number | null + paid_at: string | null + }>( + ` + SELECT + oi.order_id, + ( + SELECT wo.id FROM work_orders wo + WHERE wo.order_item_id = oi.id + ORDER BY wo.id DESC + LIMIT 1 + ) AS work_order_id, + COALESCE(o.paid_at, o.created_at) AS paid_at + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.id = $1 + LIMIT 1 + `, + [orderItemId], + ) + const row = result.rows[0] + if (!row) { + throw createHttpError('订单项不存在', { + statusCode: 404, + errorCode: 'manual_expense_order_item_not_found', + }) + } + if (orderId && Number(row.order_id) !== orderId) { + throw createHttpError('订单项不属于该订单', { + statusCode: 400, + errorCode: 'manual_expense_order_mismatch', + }) + } + return { + orderId: Number(row.order_id), + workOrderId: row.work_order_id ? Number(row.work_order_id) : null, + paidAt: row.paid_at, + } + } + if (orderId) { + const result = await query<{ paid_at: string | null }>( + `SELECT COALESCE(paid_at, created_at) AS paid_at FROM orders WHERE id = $1 LIMIT 1`, + [orderId], + ) + if (!result.rows[0]) { + throw createHttpError('订单不存在', { + statusCode: 404, + errorCode: 'manual_expense_order_not_found', + }) + } + return { orderId, workOrderId: null, paidAt: result.rows[0].paid_at } + } + return null +} + +async function resolveCostItem(costItemId: number | null) { + if (!costItemId) return null + const result = await query<{ + id: number + code: string + cost_type: string + carrier_type: string + status: string + }>(`SELECT id, code, cost_type, carrier_type, status FROM cost_items WHERE id = $1 LIMIT 1`, [ + costItemId, + ]) + const row = result.rows[0] + if (!row) { + throw createHttpError('成本项目不存在', { + statusCode: 404, + errorCode: 'manual_expense_cost_item_not_found', + }) + } + if (row.code === 'sys_work_order_reward') { + throw createHttpError('接单平台实际报酬由工单验收自动入账,不能用于人工支出', { + statusCode: 409, + errorCode: 'manual_expense_cost_item_reserved', + }) + } + if (row.status !== 'active') { + throw createHttpError('成本项目已归档,不能用于人工支出登记', { + statusCode: 409, + errorCode: 'manual_expense_cost_item_archived', + }) + } + return { id: Number(row.id), costType: row.cost_type, carrierType: row.carrier_type } +} + +function mapManualExpense(row: ManualExpenseRow) { + return { + id: Number(row.id), + expenseNo: row.expense_no, + orderId: row.order_id ? Number(row.order_id) : null, + orderItemId: row.order_item_id ? Number(row.order_item_id) : null, + workOrderId: row.work_order_id ? Number(row.work_order_id) : null, + fundExpenseId: row.fund_expense_id ? Number(row.fund_expense_id) : null, + costItemId: row.cost_item_id ? Number(row.cost_item_id) : null, + costItemName: row.cost_item_name || '', + costItemCode: row.cost_item_code || '', + expenseCategory: row.expense_category, + amountFen: Number(row.amount_fen || 0), + currency: row.currency, + paidByAdminUserId: row.paid_by_admin_user_id ? Number(row.paid_by_admin_user_id) : null, + paidByName: row.paid_by_name, + payeeType: row.payee_type, + payeeName: row.payee_name, + payeeAccount: row.payee_account, + paymentMethod: row.payment_method, + paidAt: row.paid_at, + status: row.status, + approvedBy: row.approved_by, + approvedAt: row.approved_at, + rejectedBy: row.rejected_by, + rejectedAt: row.rejected_at, + rejectReason: row.reject_reason, + reimbursedBy: row.reimbursed_by, + reimbursedAt: row.reimbursed_at, + evidenceJson: row.evidence_json || {}, + costSnapshotId: row.cost_snapshot_id ? Number(row.cost_snapshot_id) : null, + reversalOfId: row.reversal_of_id ? Number(row.reversal_of_id) : null, + expenseBatchNo: row.expense_batch_no, + note: row.note, + createdAt: row.created_at, + updatedAt: row.updated_at, + platformOrderId: row.platform_order_id || '', + provider: row.provider || '', + shopId: row.shop_id || '', + skuCode: row.sku_code || '', + skuName: row.sku_name || '', + workOrderNo: row.work_order_no || '', + } +} + +function inferExpenseCategory(costType: string | undefined) { + if (costType === 'auto_purchase') return 'goods_purchase' + if (costType === 'manual_service') return 'service_fee' + if (costType === 'shipping_other') return 'freight' + if (costType === 'payment_fee' || costType === 'platform_fee') return 'service_fee' + return 'other' +} + +function normalizeEvidence(value: unknown): JsonObject { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as JsonObject + } + const text = String(value || '').trim() + return text ? { reference: text } : {} +} + +function hasEvidence(evidence: JsonObject) { + const urls = Array.isArray(evidence.urls) ? evidence.urls : [] + return ( + urls.length > 0 || + Boolean(String(evidence.reference || '').trim()) || + Boolean(String(evidence.note || '').trim()) + ) +} + +function normalizeAmountFen(value: unknown) { + const amount = Math.trunc(Number(value) || 0) + if (amount <= 0) { + throw createHttpError('支出金额必须大于 0', { + statusCode: 400, + errorCode: 'manual_expense_amount_invalid', + }) + } + if (amount > 100_000_000_00) { + throw createHttpError('支出金额超出允许范围', { + statusCode: 400, + errorCode: 'manual_expense_amount_too_large', + }) + } + return amount +} + +function normalizeIsoDate(value: unknown, label: string, fallback: string) { + const raw = String(value || '').trim() + if (!raw) return fallback + let normalized = raw.includes('T') ? raw : raw.replace(' ', 'T') + if (!normalized.includes('T')) normalized = `${normalized}T00:00:00` + if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)) normalized = `${normalized}+08:00` + const timestamp = Date.parse(normalized) + if (!Number.isFinite(timestamp)) { + throw createHttpError(`${label}格式无效`, { + statusCode: 400, + errorCode: 'manual_expense_date_invalid', + }) + } + return new Date(timestamp).toISOString() +} + +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: 'manual_expense_pagination_invalid', + }) + } + return parsed +} + +function normalizeOptionalId(value: unknown) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +function normalizeId(value: unknown) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw createHttpError('记录 ID 无效', { + statusCode: 400, + errorCode: 'manual_expense_id_invalid', + }) + } + return parsed +} + +function normalizeStatus(value: unknown, allowed: Set, fallback: string, message: string) { + const text = String(value || '').trim() || fallback + assertOneOf(text, allowed, message) + return text +} + +function assertOneOf(value: string, allowed: Set, message: string) { + if (!allowed.has(value)) { + throw createHttpError(message, { + statusCode: 400, + errorCode: 'manual_expense_value_invalid', + }) + } +} + +function requiredText(value: unknown, label: string, maxLength: number) { + const text = optionalText(value, maxLength) + if (!text) { + throw createHttpError(`${label}不能为空`, { + statusCode: 400, + errorCode: 'manual_expense_field_required', + }) + } + return text +} + +function optionalText(value: unknown, maxLength: number) { + return String(value || '') + .trim() + .slice(0, maxLength) +} + +function escapeLike(value: string) { + return value.replace(/[\\%_]/g, (char) => `\\${char}`) +} + +function requiredRow(row: T | undefined, message: string): T { + if (!row) { + throw createHttpError(message, { + statusCode: 404, + errorCode: 'manual_expense_not_found', + }) + } + return row +} diff --git a/apps/backend/src/services/finance/sales-facts-service.ts b/apps/backend/src/services/finance/sales-facts-service.ts new file mode 100644 index 00000000..965d92c1 --- /dev/null +++ b/apps/backend/src/services/finance/sales-facts-service.ts @@ -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 { + 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 = {}) { + 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( + ` + 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 +} diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index 1c30dc9c..82ca5166 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -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 } }) diff --git a/apps/backend/src/services/scheduler/config-service.test.ts b/apps/backend/src/services/scheduler/config-service.test.ts index f1b71ed8..e756522c 100644 --- a/apps/backend/src/services/scheduler/config-service.test.ts +++ b/apps/backend/src/services/scheduler/config-service.test.ts @@ -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')) }) diff --git a/apps/backend/src/services/scheduler/config-service.ts b/apps/backend/src/services/scheduler/config-service.ts index b097f1da..ad34e1bb 100644 --- a/apps/backend/src/services/scheduler/config-service.ts +++ b/apps/backend/src/services/scheduler/config-service.ts @@ -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, diff --git a/apps/backend/src/services/scheduler/cost-finance-sync-job.ts b/apps/backend/src/services/scheduler/cost-finance-sync-job.ts new file mode 100644 index 00000000..6068b8e1 --- /dev/null +++ b/apps/backend/src/services/scheduler/cost-finance-sync-job.ts @@ -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)) +} diff --git a/apps/backend/src/services/scheduler/scheduler-service.ts b/apps/backend/src/services/scheduler/scheduler-service.ts index f15f8ab3..ceddd183 100644 --- a/apps/backend/src/services/scheduler/scheduler-service.ts +++ b/apps/backend/src/services/scheduler/scheduler-service.ts @@ -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() const jobStates = new Map() @@ -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, diff --git a/apps/backend/src/utils/finance-date-range.test.ts b/apps/backend/src/utils/finance-date-range.test.ts new file mode 100644 index 00000000..d38ad8b8 --- /dev/null +++ b/apps/backend/src/utils/finance-date-range.test.ts @@ -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') +}) diff --git a/apps/backend/src/utils/finance-date-range.ts b/apps/backend/src/utils/finance-date-range.ts new file mode 100644 index 00000000..10ba7e6a --- /dev/null +++ b/apps/backend/src/utils/finance-date-range.ts @@ -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 = {}, + { 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 +} diff --git a/apps/backend/src/utils/random.ts b/apps/backend/src/utils/random.ts index adcfe68b..e277f545 100644 --- a/apps/backend/src/utils/random.ts +++ b/apps/backend/src/utils/random.ts @@ -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 diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index db61e781..661026af 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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() { }> } /> + } /> } /> }> } /> + } /> } /> }> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 7b743e65..bb69d0ae 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -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: , label: '财务' }, + { key: '/admin/sales-profit', icon: , label: '销售毛利' }, + { key: '/admin/manual-expenses', icon: , label: '人工支出' }, { key: '/admin/cost-center', icon: , label: '商品与成本' }, { key: '/admin/fund', icon: , label: '备用金' }, { key: '/admin/worker-platform', icon: , label: '资金审核' }, @@ -144,10 +148,20 @@ export default function AdminLayout() { icon: , label: role === 'support' ? '我的备用金' : '备用金', }, + { + key: '/admin/manual-expenses', + icon: , + label: '人工支出', + }, ...(role === 'support' ? [] : [ { key: '/admin/finance', icon: , label: '财务' }, + { + key: '/admin/sales-profit', + icon: , + label: '销售毛利', + }, { key: '/admin/cost-center', icon: , diff --git a/apps/frontend/src/pages/admin/AdminManualExpensePage.tsx b/apps/frontend/src/pages/admin/AdminManualExpensePage.tsx new file mode 100644 index 00000000..457639d7 --- /dev/null +++ b/apps/frontend/src/pages/admin/AdminManualExpensePage.tsx @@ -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 = { + 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() + const [expenseForm] = Form.useForm() + 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(null) + const [rejectTarget, setRejectTarget] = useState(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 = [ + { + title: '登记号 / 状态', + dataIndex: 'expenseNo', + width: 190, + render: (value: string, row) => { + const meta = STATUS_META[row.status] + return ( + + {value} + {meta.label} + + ) + }, + }, + { + title: '付款时间', + dataIndex: 'paidAt', + width: 165, + render: (value: string) => formatAdminDateTime(value), + }, + { + title: '订单 / 商品', + key: 'order', + width: 260, + render: (_, row) => ( + + {row.platformOrderId || (row.orderId ? `#${row.orderId}` : '无关联订单')} + + {row.skuName || row.workOrderNo || '-'} + + + ), + }, + { + title: '付款项目', + key: 'costItem', + width: 180, + render: (_, row) => ( + + {row.costItemName || '未归类'} + + {CATEGORY_OPTIONS.find((item) => item.value === row.expenseCategory)?.label || + row.expenseCategory} + + + ), + }, + { + title: '金额', + dataIndex: 'amountFen', + width: 120, + render: (value: number) => {formatMoney(value)}, + }, + { + title: '付款人 → 收款人', + key: 'parties', + width: 190, + render: (_, row) => ( + + {row.paidByName || '-'} + → {row.payeeName || '-'} + + ), + }, + { + title: '凭证', + dataIndex: 'evidenceJson', + width: 160, + ellipsis: true, + render: (value: Record) => { + const reference = typeof value?.reference === 'string' ? value.reference : '' + return ( + + {reference || '-'} + + ) + }, + }, + { + title: '成本快照', + dataIndex: 'costSnapshotId', + width: 100, + render: (value: number | null) => + value ? #{value} : 未生成, + }, + { + title: '操作', + key: 'actions', + width: 190, + fixed: 'right', + render: (_, row) => { + if (!canReview) return 仅查看 + return ( + + {row.status === 'submitted' ? ( + <> + + + + ) : null} + {row.status === 'approved' ? ( + + ) : null} + {row.status === 'reimbursed' ? ( + 已完成 + ) : null} + + ) + }, + }, + ] + + return ( +
+ + + + + form={filterForm} + layout="inline" + className="filter-form" + onFinish={applyFilters} + initialValues={{ + dateRange: [dayjs().subtract(29, 'day'), dayjs()], + status: '', + keyword: '', + }} + > + + + + + + + + + + + {canCreate ? ( + + ) : null} + + + + + + + {overview && overview.unclassifiedCount > 0 ? ( + + ) : null} + + {overview && overview.noOrderCount > 0 ? ( + + ) : null} + + + + + formatMoney(overview?.submittedAmountFen || 0)} + /> + + + + + formatMoney(overview?.approvedAmountFen || 0)} + /> + + + + + formatMoney(overview?.reimbursedAmountFen || 0)} + /> + + + + + + + + + + + + + + + + + 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) + }, + })} + /> + + + { + setCreateOpen(false) + expenseForm.resetFields() + }} + onOk={submitExpense} + confirmLoading={creating} + okText="提交登记" + width={720} + > + + form={expenseForm} + layout="vertical" + initialValues={{ + expenseCategory: 'service_fee', + payeeType: 'upstream', + paymentMethod: 'wechat', + paidAt: dayjs(), + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setRejectTarget(null)} + onOk={submitReject} + confirmLoading={Boolean(rejectTarget && actionId === rejectTarget.id)} + okText="确认驳回" + okButtonProps={{ danger: true }} + > + setRejectReason(event.target.value)} + placeholder="必须填写驳回原因" + /> + +
+ ) +} diff --git a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx index 704b70af..c132b889 100644 --- a/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminOrderDetailPage.tsx @@ -1,6 +1,7 @@ import { ArrowLeftOutlined, CopyOutlined, + FileTextOutlined, PauseCircleOutlined, PlayCircleOutlined, StopOutlined, @@ -181,42 +182,50 @@ export default function AdminOrderDetailPage() { - - {detail.order.claimStatus === 'paused' || detail.order.claimStatus === 'closed' ? ( - + {isAutoClaimOrder ? ( + <> + - ) : canPauseOrClose ? ( - - ) : null} - {canPauseOrClose && detail.order.claimStatus !== 'closed' ? ( - - ) : null} - - ) : null + {detail.order.claimStatus === 'paused' || detail.order.claimStatus === 'closed' ? ( + + ) : canPauseOrClose ? ( + + ) : null} + {canPauseOrClose && detail.order.claimStatus !== 'closed' ? ( + + ) : null} + + ) : null} + } > diff --git a/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx b/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx new file mode 100644 index 00000000..f39a8a9c --- /dev/null +++ b/apps/frontend/src/pages/admin/AdminSalesProfitPage.tsx @@ -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 = { + affiliate_dash: '自动发货', + work_order: '接单平台', + manual: '人工客服', + shared: '共用', + unknown: '未知', +} + +const COST_STAGE_LABEL: Record = { + 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() + 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['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) => ( + = 0 ? 'success' : 'danger'}> + {formatMoney(value)} + + ), + }, + ] + + const productColumns: TableColumnsType['byProduct'][number]> = [ + { + title: '标准商品', + dataIndex: 'canonicalName', + width: 320, + ellipsis: true, + render: (value: string, row) => ( + + {value} + {row.canonicalProductId ? ( + {row.canonicalSku} + ) : ( + 未归一 + )} + + ), + }, + { 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) => ( + = 0 ? 'success' : 'danger'}> + {formatMoney(value)} + + ), + }, + ] + + const carrierColumns: TableColumnsType['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) => ( + = 0 ? 'success' : 'danger'}> + {formatMoney(value)} + + ), + }, + ] + + const factColumns: TableColumnsType = [ + { 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 : 未归一, + }, + { + 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 {meta.label} + }, + }, + { + title: '毛利', + dataIndex: 'grossProfitFen', + width: 130, + render: (value: number) => ( + = 0 ? 'success' : 'danger'}> + {formatMoney(value)} + + ), + }, + ] + + return ( +
+ + + + + form={form} + layout="inline" + className="filter-form" + onFinish={applyFilters} + initialValues={{ + dateRange: [dayjs().subtract(29, 'day'), dayjs()], + provider: '', + carrierType: '', + }} + > + + + + + + + + + + + {canConfig ? ( + + ) : null} + + + + + + {overviewQuery.error ? ( + + ) : null} + + {summary && summary.costMissingCount > 0 ? ( + + ) : null} + + {summary && summary.unmappedRevenuePct > 0 ? ( + + ) : null} + + {summary && summary.ordersWithoutItems > 0 ? ( + + ) : null} + + + + + formatMoney(summary?.grossRevenueFen || 0)} + /> + + + + + formatMoney(summary?.netRevenueFen || 0)} + /> + + + + + formatMoney(summary?.costAmountFen || 0)} + /> + + + + + = 0 ? '#3f8600' : '#cf1322' }} + formatter={() => formatMoney(summary?.grossProfitFen || 0)} + /> + + + + + + + + + + + + + + + + + ), + }, + { + key: 'product', + label: '商品', + children: ( + `${row.canonicalProductId || 'unmapped'}`} + size="small" + columns={productColumns} + dataSource={overview?.byProduct || []} + loading={overviewQuery.isLoading} + pagination={false} + scroll={{ x: 900 }} + /> + ), + }, + { + key: 'carrier', + label: '载体', + children: ( +
+ ), + }, + { + key: 'facts', + label: '事实明细', + children: ( +
`共 ${total} 条事实`, + onChange: (nextPage, nextPageSize) => { + setPage(nextPage) + setPageSize(nextPageSize) + }, + })} + scroll={{ x: 1400 }} + /> + ), + }, + ]} + /> + + + ) +} diff --git a/apps/frontend/src/services/admin/finance.ts b/apps/frontend/src/services/admin/finance.ts index afadcb32..6bf895c3 100644 --- a/apps/frontend/src/services/admin/finance.ts +++ b/apps/frontend/src/services/admin/finance.ts @@ -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('/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('/api/v1/admin/finance/sales-profit/overview', params) +} + +export function fetchAdminSalesDailyFacts(params: AdminSalesProfitParams = {}) { + return apiGet('/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) +} diff --git a/apps/frontend/src/services/admin/index.ts b/apps/frontend/src/services/admin/index.ts index 962dcab5..58458eba 100644 --- a/apps/frontend/src/services/admin/index.ts +++ b/apps/frontend/src/services/admin/index.ts @@ -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' diff --git a/apps/frontend/src/services/admin/manual-expense.ts b/apps/frontend/src/services/admin/manual-expense.ts new file mode 100644 index 00000000..134554cc --- /dev/null +++ b/apps/frontend/src/services/admin/manual-expense.ts @@ -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('/api/v1/admin/finance/manual-expenses', params) +} + +export function fetchAdminManualExpenseOverview(params: AdminManualExpenseParams = {}) { + return apiGet( + '/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(`/api/v1/admin/finance/manual-expenses/${expenseId}`) +} + +export function createAdminManualExpense(payload: Record) { + return apiPost('/api/v1/admin/finance/manual-expenses', payload) +} + +export function approveAdminManualExpense( + expenseId: number, + payload: Record = {}, +) { + return apiPost( + `/api/v1/admin/finance/manual-expenses/${expenseId}/approve`, + payload, + ) +} + +export function rejectAdminManualExpense(expenseId: number, rejectReason: string) { + return apiPost(`/api/v1/admin/finance/manual-expenses/${expenseId}/reject`, { + rejectReason, + }) +} + +export function reimburseAdminManualExpense(expenseId: number) { + return apiPost(`/api/v1/admin/finance/manual-expenses/${expenseId}/reimburse`) +} diff --git a/apps/frontend/src/types/admin/finance.ts b/apps/frontend/src/types/admin/finance.ts index c7ad13a5..4fb61fe7 100644 --- a/apps/frontend/src/types/admin/finance.ts +++ b/apps/frontend/src/types/admin/finance.ts @@ -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 +} diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index c5ac7747..6dd53987 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -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, diff --git a/apps/frontend/src/types/admin/manual-expense.ts b/apps/frontend/src/types/admin/manual-expense.ts new file mode 100644 index 00000000..62bdf74a --- /dev/null +++ b/apps/frontend/src/types/admin/manual-expense.ts @@ -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 + 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 + }> +} diff --git a/docs/README.md b/docs/README.md index 49050f6f..289cb5e0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 仍为草案 | ## 六、厂商协议与外部资料 diff --git a/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql b/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql index 92dc744f..46de73d1 100644 --- a/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql +++ b/docs/drafts/2026-10-17-人工支出登记-DDL草案.sql @@ -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 备用金落地复核。 -- -- 重要命名边界: diff --git a/docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql b/docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql index d7d49645..fed1314b 100644 --- a/docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql +++ b/docs/drafts/2026-10-17-成本中心与销售事实-DDL草案.sql @@ -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 与派生金额,永不回写历史。 diff --git a/docs/人工支出登记落地方案-2026-10-17.md b/docs/人工支出登记落地方案-2026-10-17.md index b9f6deee..131bb7f3 100644 --- a/docs/人工支出登记落地方案-2026-10-17.md +++ b/docs/人工支出登记落地方案-2026-10-17.md @@ -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` 关联即可。 diff --git a/docs/影子总账说明.md b/docs/影子总账说明.md index 73270a78..2afa4366 100644 --- a/docs/影子总账说明.md +++ b/docs/影子总账说明.md @@ -9,7 +9,7 @@ > > 边界:影子总账解决“钱怎么记、怎么对”,不负责“商品是什么、成本是多少、人工谁处理”。商品与成本见 [成本中心与三块履约模型.md](成本中心与三块履约模型.md),人工履约见同一文档第八节。 -> 当前进度:客服备用金已实施;`finance_*` 表、分录、对账、转正均未实施。 +> 当前进度:客服备用金、商品与成本中心、成本快照运行层、销售事实、公司人工支出登记已实施;`finance_*` 表、分录、对账、转正均未实施。 --- diff --git a/docs/成本中心与三块履约模型.md b/docs/成本中心与三块履约模型.md index 27229a30..069382c7 100644 --- a/docs/成本中心与三块履约模型.md +++ b/docs/成本中心与三块履约模型.md @@ -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:` 补建/更新商品,并给指定成本项生成 `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. 寄卖货款的采购成本确认时点? --- diff --git a/docs/财务系统集成方案.md b/docs/财务系统集成方案.md index f7b08216..4c005338 100644 --- a/docs/财务系统集成方案.md +++ b/docs/财务系统集成方案.md @@ -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 | 寄卖货款的采购成本确认时点 | ⏳ 待定 | 决定存货/成本 |