feat(finance): 人工成本单事务/幂等 + 配置自动匹配 + 缺成本监控
- 确认履约改为单事务:锁订单项 → 复查快照 → 扣积分/备用金 → 写成本快照; 快照失败整体回滚,不再出现「钱扣了、成本没记」 - manual_case 的 subject_id 统一为 order_item_id,新增订单项级唯一索引(迁移 111), 消除 manual_expenses.id / platform_credit_entries.id 跨表撞车 - 人工 SKU 匹配增加 sku_code / sku_name 兜底;保存配置立即回填最近 30 天, 定时任务按窗口 rematch,确认时再按 SKU 兜底匹配 - 新增 manual_cost_missing 异常队列:从首次人工配置起算,确认履约立即收口 - 补单元测试与真实 PostgreSQL 集成测试(并发、重复、强制快照失败回滚、双资金链路) - 校准财务文档并新增上线初始化/验收手册(历史人工不补录,只保证配置后的新单) 验证:backend typecheck/lint/format/check:sql/test 843(839 pass / 4 skip); frontend typecheck/lint/test 172;迁移 111 在 scratch 库全量执行 + 重跑幂等。
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
-- 111_manual_cost_integrity.sql
|
||||
-- 人工成本完整性:一个订单项只允许一条有效的人工成本快照。
|
||||
--
|
||||
-- 背景:
|
||||
-- 1. 旧实现把 cost_snapshots.subject_id 写成 manual_expenses.id 或
|
||||
-- platform_credit_entries.id(两张表各自 BIGSERIAL),与唯一索引
|
||||
-- (subject_type, subject_id, cost_item_id) 叠加后,两条链路会互相撞 id:
|
||||
-- 第二次合法确认被当成「已有人工成本快照」拒绝,而钱已经扣了。
|
||||
-- 2. 人工确认原先是「先扣款、后写快照」两个事务,快照失败会留下已扣款未记成本。
|
||||
-- 现在改为同一事务,并由本迁移提供数据库级兜底:
|
||||
-- 一个 order_item 只允许一条有效 (superseded_at IS NULL) 的 manual_case 快照。
|
||||
--
|
||||
-- 语义调整:
|
||||
-- * subject_type = 'manual_case' 的 subject_id 统一为 order_item_id;
|
||||
-- 资金来源(manual_expenses.id / platform_credit_entries.id)只写 metadata_json,
|
||||
-- 不再作为 subject_id。
|
||||
-- * 正数 subject_id 的历史行(旧实现写入的支出单/积分流水 id)修正为 order_item_id;
|
||||
-- 负数 subject_id 的历史孤儿快照按迁移 104 的约定保留,不参与新链路。
|
||||
-- * 重复正数行保留最新一条,其余标记 superseded,避免唯一索引创建失败。
|
||||
--
|
||||
-- 全部语句幂等,可安全重跑。
|
||||
|
||||
-- 1. 历史人工快照的 subject_id 归一为 order_item_id。
|
||||
UPDATE cost_snapshots
|
||||
SET subject_id = order_item_id
|
||||
WHERE subject_type = 'manual_case'
|
||||
AND order_item_id IS NOT NULL
|
||||
AND subject_id > 0
|
||||
AND subject_id <> order_item_id;
|
||||
|
||||
-- 2. 同一订单项存在重复有效人工快照时,只保留最新一条(历史脏数据兜底)。
|
||||
UPDATE cost_snapshots c
|
||||
SET superseded_at = NOW()
|
||||
WHERE c.subject_type = 'manual_case'
|
||||
AND c.superseded_at IS NULL
|
||||
AND c.subject_id > 0
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM cost_snapshots newer
|
||||
WHERE newer.subject_type = 'manual_case'
|
||||
AND newer.order_item_id = c.order_item_id
|
||||
AND newer.superseded_at IS NULL
|
||||
AND newer.id > c.id
|
||||
);
|
||||
|
||||
-- 3. 数据库级唯一:一个订单项只有一条有效人工成本快照。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_cost_snapshots_manual_order_item
|
||||
ON cost_snapshots (order_item_id)
|
||||
WHERE subject_type = 'manual_case' AND superseded_at IS NULL AND subject_id > 0;
|
||||
|
||||
-- 4. 语义约束:新写入的 manual_case 必须 subject_id = order_item_id。
|
||||
-- NOT VALID 只约束新写入,负数历史孤儿快照(迁移 104 约定)保持原样。
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_cost_snapshots_manual_subject'
|
||||
) THEN
|
||||
ALTER TABLE cost_snapshots
|
||||
ADD CONSTRAINT chk_cost_snapshots_manual_subject
|
||||
CHECK (subject_type <> 'manual_case' OR subject_id = order_item_id)
|
||||
NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 5. 人工成本监控 / 对账按订单项反查备用金支出。
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_expenses_order_item
|
||||
ON manual_expenses (order_item_id)
|
||||
WHERE order_item_id IS NOT NULL;
|
||||
|
||||
-- 6. 人工 SKU 匹配键索引:spec_json.skuNick -> outSkuSn -> sku_code -> sku_name。
|
||||
-- 旧索引只覆盖前两级,缺 SKU 键的人工单会走顺序扫描;这里替换为同一套兜底表达式。
|
||||
DROP INDEX IF EXISTS idx_order_items_manual_internal_sku;
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_manual_sku_key
|
||||
ON order_items (
|
||||
COALESCE(
|
||||
NULLIF(spec_json->>'skuNick', ''),
|
||||
NULLIF(spec_json->>'outSkuSn', ''),
|
||||
NULLIF(sku_code, ''),
|
||||
NULLIF(sku_name, '')
|
||||
)
|
||||
)
|
||||
WHERE fulfillment_mode = 'manual';
|
||||
|
||||
COMMENT ON INDEX uq_cost_snapshots_manual_order_item IS
|
||||
'人工成本完整性:一个订单项最多一条有效 manual_case 快照(并发重复确认的数据库兜底)';
|
||||
@@ -1538,11 +1538,15 @@ export async function createAdminFundReturn(payload: JsonObject, session: AdminS
|
||||
})
|
||||
}
|
||||
|
||||
export async function getOwnAgentAccountId(userId: number): Promise<number> {
|
||||
const own = await query<FundAccountRow>(
|
||||
"SELECT * FROM fund_accounts WHERE owner_admin_user_id = $1 AND account_type = 'agent' AND status <> 'closed' ORDER BY id LIMIT 1",
|
||||
[userId],
|
||||
)
|
||||
export async function getOwnAgentAccountId(
|
||||
userId: number,
|
||||
client?: Pick<PoolClient, 'query'>,
|
||||
): Promise<number> {
|
||||
const sql =
|
||||
"SELECT * FROM fund_accounts WHERE owner_admin_user_id = $1 AND account_type = 'agent' AND status <> 'closed' ORDER BY id LIMIT 1"
|
||||
const own = client
|
||||
? await client.query<FundAccountRow>(sql, [userId])
|
||||
: await query<FundAccountRow>(sql, [userId])
|
||||
return own.rows[0] ? Number(own.rows[0].id) : 0
|
||||
}
|
||||
|
||||
|
||||
@@ -672,6 +672,7 @@ async function insertAndResolveCostMissingExceptions(
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
oi.canonical_product_id,
|
||||
oi.fulfillment_config_id,
|
||||
COALESCE(o.paid_at, o.created_at) AS basis_at,
|
||||
wo.id AS work_order_id,
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode
|
||||
@@ -714,6 +715,58 @@ async function insertAndResolveCostMissingExceptions(
|
||||
[fromDate, toDate],
|
||||
)
|
||||
|
||||
const manualInserted = await client.query(
|
||||
`
|
||||
WITH cutoff AS (
|
||||
SELECT MIN(created_at) AS enforce_from
|
||||
FROM manual_fulfillment_configs
|
||||
),
|
||||
item_scope AS (
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
oi.fulfillment_config_id,
|
||||
COALESCE(o.paid_at, o.created_at) AS basis_at
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE o.pay_status IN ('paid', 'refunded')
|
||||
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
BETWEEN $1::date AND $2::date
|
||||
AND oi.fulfillment_mode = 'manual'
|
||||
)
|
||||
INSERT INTO cost_exceptions (
|
||||
subject_type, subject_id, order_item_id, work_order_id,
|
||||
reason, status, detail_json, created_at
|
||||
)
|
||||
SELECT
|
||||
'order_item', s.order_item_id, s.order_item_id, NULL,
|
||||
'manual_cost_missing', 'open',
|
||||
jsonb_build_object(
|
||||
'fulfillmentMode', 'manual',
|
||||
'basisAt', s.basis_at,
|
||||
'configMissing', s.fulfillment_config_id IS NULL,
|
||||
'message', CASE
|
||||
WHEN s.fulfillment_config_id IS NULL
|
||||
THEN '人工订单项未配置履约渠道,请先在成本中心配置人工 SKU'
|
||||
ELSE '人工订单项尚未确认履约并记录成本'
|
||||
END
|
||||
),
|
||||
NOW()
|
||||
FROM item_scope s
|
||||
CROSS JOIN cutoff c
|
||||
WHERE c.enforce_from IS NOT NULL
|
||||
-- 只监控「第一次配置人工履约之后」的订单项:历史人工单按既定口径不补录。
|
||||
AND s.basis_at >= c.enforce_from
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cost_snapshots cs
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.order_item_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
|
||||
@@ -730,5 +783,24 @@ async function insertAndResolveCostMissingExceptions(
|
||||
`,
|
||||
)
|
||||
|
||||
return { created: inserted.rowCount || 0, resolved: resolved.rowCount || 0 }
|
||||
const manualResolved = 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 = 'manual_cost_missing'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM cost_snapshots cs
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.order_item_id = ce.subject_id
|
||||
AND cs.superseded_at IS NULL
|
||||
)
|
||||
`,
|
||||
)
|
||||
|
||||
return {
|
||||
created: (inserted.rowCount || 0) + (manualInserted.rowCount || 0),
|
||||
resolved: (resolved.rowCount || 0) + (manualResolved.rowCount || 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
|
||||
/**
|
||||
* 人工履约 SKU 配置服务。
|
||||
*
|
||||
* 口径(2026-10-24 确认):
|
||||
* * 配置主键是**内部 SKU**(快手 spec_json.skuNick / 拼多多 spec_json.outSkuSn),
|
||||
* 不是商品标题;同名 SKU 算同一个商品,跨渠道共用一份配置;
|
||||
* * 配置主键优先取内部 SKU(快手 spec_json.skuNick / 拼多多 spec_json.outSkuSn),
|
||||
* 缺失时按 sku_code → sku_name 兜底;同名/同码算同一个商品,跨渠道共用一份配置;
|
||||
* * 配置项:履约渠道(南航/乐玩/ROCK/备用金现金)+ 消耗积分 + 成本模式;
|
||||
* * 换算率固定:1 积分 = 1 分钱;
|
||||
* * 消耗积分 0 = 成本不固定,由客服在确认履约时手填实际成本;
|
||||
@@ -125,7 +126,9 @@ export async function scanManualSkus(options: ScanOptions) {
|
||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
|
||||
COALESCE(
|
||||
NULLIF(oi.spec_json->>'skuNick', ''),
|
||||
NULLIF(oi.spec_json->>'outSkuSn', '')
|
||||
NULLIF(oi.spec_json->>'outSkuSn', ''),
|
||||
NULLIF(oi.sku_code, ''),
|
||||
NULLIF(oi.sku_name, '')
|
||||
) AS sku_key
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
@@ -380,6 +383,15 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) {
|
||||
|
||||
const config = requiredRow(result.rows[0], '保存人工履约配置失败')
|
||||
await syncRegistryConfigId(config.sku_key, config.id)
|
||||
// 配置保存后立刻回填该 SKU 最近 30 天的订单项,避免必须再点一次「重新匹配」。
|
||||
// 历史更早的订单项不自动回填(历史人工成本不补录);需要时再显式点「重新匹配」。
|
||||
const today = formatShanghaiDate(new Date())
|
||||
await rematchManualFulfillments({
|
||||
skuKey: config.sku_key,
|
||||
onlyUnmatched: true,
|
||||
dateFrom: shiftDate(today, -29),
|
||||
dateTo: today,
|
||||
})
|
||||
return mapManualConfig(config, unitPrices)
|
||||
}
|
||||
|
||||
@@ -408,6 +420,9 @@ export async function deleteManualFulfillmentConfig(skuKey: string) {
|
||||
export async function rematchManualFulfillments(input: Record<string, unknown> = {}) {
|
||||
const skuKey = String(input.skuKey ?? input.sku_key ?? '').trim()
|
||||
const onlyUnmatched = input.onlyUnmatched !== false
|
||||
// 定时任务只回填最近窗口,避免一次性把 10 万历史人工单变成「待履约」。
|
||||
const dateFrom = normalizeOptionalDate(input.dateFrom ?? input.date_from, '开始日期')
|
||||
const dateTo = normalizeOptionalDate(input.dateTo ?? input.date_to, '结束日期')
|
||||
|
||||
const now = nowIso()
|
||||
const result = await query<{ updated_count: number }>(
|
||||
@@ -422,22 +437,32 @@ export async function rematchManualFulfillments(input: Record<string, unknown> =
|
||||
JOIN manual_fulfillment_configs cfg
|
||||
ON cfg.sku_key = COALESCE(
|
||||
NULLIF(oi.spec_json->>'skuNick', ''),
|
||||
NULLIF(oi.spec_json->>'outSkuSn', '')
|
||||
NULLIF(oi.spec_json->>'outSkuSn', ''),
|
||||
NULLIF(oi.sku_code, ''),
|
||||
NULLIF(oi.sku_name, '')
|
||||
)
|
||||
WHERE oi.fulfillment_mode = 'manual'
|
||||
AND cfg.enabled = TRUE
|
||||
AND ($1 = '' OR cfg.sku_key = $1)
|
||||
AND ($2 = FALSE OR oi.fulfillment_config_id IS NULL)
|
||||
AND (
|
||||
$3::date IS NULL
|
||||
OR (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date >= $3::date
|
||||
)
|
||||
AND (
|
||||
$4::date IS NULL
|
||||
OR (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date <= $4::date
|
||||
)
|
||||
)
|
||||
UPDATE order_items oi
|
||||
SET fulfillment_config_id = target.config_id,
|
||||
canonical_product_id = COALESCE(target.canonical_product_id, oi.canonical_product_id),
|
||||
updated_at = $3
|
||||
updated_at = $5
|
||||
FROM target
|
||||
WHERE oi.id = target.order_item_id
|
||||
RETURNING 1
|
||||
`,
|
||||
[skuKey, onlyUnmatched, now],
|
||||
[skuKey, onlyUnmatched, dateFrom, dateTo, now],
|
||||
)
|
||||
|
||||
// 配置生效后,把 SKU 对应的成本版本挂上(若已配置商品)。
|
||||
@@ -446,6 +471,8 @@ export async function rematchManualFulfillments(input: Record<string, unknown> =
|
||||
return {
|
||||
updatedCount: result.rowCount || 0,
|
||||
skuKey,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,6 +656,12 @@ function normalizeCreditQuantity(value: unknown) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizeOptionalDate(value: unknown, label: string) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return null
|
||||
return normalizeDate(text, label)
|
||||
}
|
||||
|
||||
function normalizeDate(value: unknown, label: string) {
|
||||
const text = String(value || '').trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
|
||||
|
||||
@@ -290,55 +290,73 @@ export async function consumePlatformCredit(input: {
|
||||
platformRef?: string
|
||||
note?: string
|
||||
}): Promise<{ entryId: number; balanceAfter: number; amountFen: number }> {
|
||||
return withTransaction((client) => consumePlatformCreditWithClient(client, input))
|
||||
}
|
||||
|
||||
/**
|
||||
* 在调用方事务内消耗积分。
|
||||
*
|
||||
* 人工履约的「扣积分 + 写成本快照」必须在同一事务里完成,
|
||||
* 否则快照失败会留下已扣积分、未记成本的资金洞。
|
||||
*/
|
||||
export async function consumePlatformCreditWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
ownerAdminUserId: number
|
||||
platformKey: PlatformCreditKey
|
||||
quantity: number
|
||||
relatedTaskId?: number | null
|
||||
platformRef?: string
|
||||
note?: string
|
||||
},
|
||||
): Promise<{ entryId: number; balanceAfter: number; amountFen: number }> {
|
||||
const unitPrices = await loadUnitPrices()
|
||||
const unitPriceFen = unitPrices[input.platformKey]
|
||||
const now = nowIso()
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
await ensureAccountRow(client, input.ownerAdminUserId, input.platformKey, unitPriceFen, now)
|
||||
const account = await lockAccount(client, input.ownerAdminUserId, input.platformKey)
|
||||
const balance = Number(account.balance_quantity)
|
||||
if (balance < input.quantity) {
|
||||
throw createHttpError(
|
||||
`${PLATFORM_LABELS[input.platformKey]}积分不足(当前 ${balance},需要 ${input.quantity}),请联系主管充值`,
|
||||
{
|
||||
statusCode: 409,
|
||||
errorCode: 'platform_credit_balance_insufficient',
|
||||
},
|
||||
)
|
||||
}
|
||||
const nextBalance = balance - input.quantity
|
||||
await client.query(
|
||||
`UPDATE platform_credit_accounts
|
||||
SET balance_quantity = $2, updated_at = $3
|
||||
WHERE id = $1`,
|
||||
[account.id, nextBalance, now],
|
||||
await ensureAccountRow(client, input.ownerAdminUserId, input.platformKey, unitPriceFen, now)
|
||||
const account = await lockAccount(client, input.ownerAdminUserId, input.platformKey)
|
||||
const balance = Number(account.balance_quantity)
|
||||
if (balance < input.quantity) {
|
||||
throw createHttpError(
|
||||
`${PLATFORM_LABELS[input.platformKey]}积分不足(当前 ${balance},需要 ${input.quantity}),请联系主管充值`,
|
||||
{
|
||||
statusCode: 409,
|
||||
errorCode: 'platform_credit_balance_insufficient',
|
||||
},
|
||||
)
|
||||
const entryResult = await client.query<CreditEntryRow>(
|
||||
`
|
||||
INSERT INTO platform_credit_entries (
|
||||
account_id, entry_type, quantity_delta, balance_after, amount_fen,
|
||||
platform_ref, related_task_id, note, created_at
|
||||
) VALUES ($1, 'consume', $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
account.id,
|
||||
-input.quantity,
|
||||
nextBalance,
|
||||
input.quantity * unitPriceFen,
|
||||
input.platformRef || '',
|
||||
input.relatedTaskId ?? null,
|
||||
input.note || '',
|
||||
now,
|
||||
],
|
||||
)
|
||||
return {
|
||||
entryId: Number(entryResult.rows[0]?.id || 0),
|
||||
balanceAfter: nextBalance,
|
||||
amountFen: input.quantity * unitPriceFen,
|
||||
}
|
||||
})
|
||||
}
|
||||
const nextBalance = balance - input.quantity
|
||||
await client.query(
|
||||
`UPDATE platform_credit_accounts
|
||||
SET balance_quantity = $2, updated_at = $3
|
||||
WHERE id = $1`,
|
||||
[account.id, nextBalance, now],
|
||||
)
|
||||
const entryResult = await client.query<CreditEntryRow>(
|
||||
`
|
||||
INSERT INTO platform_credit_entries (
|
||||
account_id, entry_type, quantity_delta, balance_after, amount_fen,
|
||||
platform_ref, related_task_id, note, created_at
|
||||
) VALUES ($1, 'consume', $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
account.id,
|
||||
-input.quantity,
|
||||
nextBalance,
|
||||
input.quantity * unitPriceFen,
|
||||
input.platformRef || '',
|
||||
input.relatedTaskId ?? null,
|
||||
input.note || '',
|
||||
now,
|
||||
],
|
||||
)
|
||||
return {
|
||||
entryId: Number(entryResult.rows[0]?.id || 0),
|
||||
balanceAfter: nextBalance,
|
||||
amountFen: input.quantity * unitPriceFen,
|
||||
}
|
||||
}
|
||||
|
||||
/** 积分流水列表。 */
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolveManualFulfillmentCost } from './manual-fulfillment-service.js'
|
||||
|
||||
test('人工成本:固定成本按配置积分 × 平台换算率', () => {
|
||||
assert.deepEqual(
|
||||
resolveManualFulfillmentCost({
|
||||
creditQuantity: 4,
|
||||
unitPriceFen: 100,
|
||||
providedCostFen: undefined,
|
||||
}),
|
||||
{ costFen: 400, costMode: 'fixed' },
|
||||
)
|
||||
// 固定成本时手填金额不生效(客服零输入)。
|
||||
assert.equal(
|
||||
resolveManualFulfillmentCost({ creditQuantity: 2, unitPriceFen: 100, providedCostFen: 999 })
|
||||
.costFen,
|
||||
200,
|
||||
)
|
||||
})
|
||||
|
||||
test('人工成本:成本不固定必须手填正数金额', () => {
|
||||
assert.deepEqual(
|
||||
resolveManualFulfillmentCost({
|
||||
creditQuantity: 0,
|
||||
unitPriceFen: 1,
|
||||
providedCostFen: 1234,
|
||||
}),
|
||||
{ costFen: 1234, costMode: 'variable' },
|
||||
)
|
||||
assert.throws(
|
||||
() => resolveManualFulfillmentCost({ creditQuantity: 0, unitPriceFen: 1 }),
|
||||
/成本不固定/,
|
||||
)
|
||||
assert.throws(
|
||||
() => resolveManualFulfillmentCost({ creditQuantity: 0, unitPriceFen: 1, providedCostFen: 0 }),
|
||||
/成本不固定/,
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveManualFulfillmentCost({
|
||||
creditQuantity: 0,
|
||||
unitPriceFen: 1,
|
||||
providedCostFen: -1,
|
||||
}),
|
||||
/金额无效/,
|
||||
)
|
||||
})
|
||||
|
||||
test('人工确认必须单事务:扣款与快照同事务,且使用 with-client 版本', async () => {
|
||||
const source = await readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8')
|
||||
const start = source.indexOf('export async function confirmManualFulfillment')
|
||||
const end = source.indexOf('async function resolveFundAccountId')
|
||||
const body = source.slice(start, end)
|
||||
|
||||
assert.ok(body.includes('withTransaction('), '人工确认必须包在事务里')
|
||||
assert.ok(
|
||||
body.indexOf('withTransaction(') < body.indexOf('writeFundExpense(client'),
|
||||
'备用金扣款必须在同一事务内执行',
|
||||
)
|
||||
assert.ok(
|
||||
body.includes('consumePlatformCreditWithClient(client'),
|
||||
'积分扣减必须复用调用方事务(不能再用自带事务的 consumePlatformCredit)',
|
||||
)
|
||||
assert.ok(
|
||||
!body.includes('consumePlatformCredit('),
|
||||
'confirm 内不得调用自带事务的 consumePlatformCredit',
|
||||
)
|
||||
assert.ok(
|
||||
body.includes('FOR UPDATE OF oi') && body.includes("subject_type = 'manual_case'"),
|
||||
'必须先锁订单项并复查快照,避免并发重复扣款',
|
||||
)
|
||||
assert.ok(
|
||||
body.includes("'manual_case', $1") && body.includes('subjectSource'),
|
||||
'manual_case 的 subject_id 必须是 order_item_id,资金来源只写 metadata',
|
||||
)
|
||||
})
|
||||
|
||||
test('人工确认前必须自动匹配履约配置,配置保存/定时任务也要回填', async () => {
|
||||
const [manualSource, configSource, jobSource] = await Promise.all([
|
||||
readFile(new URL('./manual-fulfillment-service.ts', import.meta.url), 'utf8'),
|
||||
readFile(
|
||||
new URL('../cost-center/manual-fulfillment-config-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
readFile(new URL('../scheduler/cost-finance-sync-job.ts', import.meta.url), 'utf8'),
|
||||
])
|
||||
assert.ok(
|
||||
manualSource.includes('matchManualFulfillmentConfigWithClient(client, orderItemId, now)'),
|
||||
'确认时必须按 SKU 自动挂渠道',
|
||||
)
|
||||
assert.ok(
|
||||
configSource.includes('rematchManualFulfillments({') &&
|
||||
configSource.includes('skuKey: config.sku_key') &&
|
||||
configSource.includes('dateFrom: shiftDate(today, -29)'),
|
||||
'保存人工 SKU 配置后必须立即回填最近 30 天订单项',
|
||||
)
|
||||
assert.ok(
|
||||
jobSource.includes('rematchManualFulfillments({') &&
|
||||
jobSource.includes('onlyUnmatched: true') &&
|
||||
jobSource.includes('dateFrom:'),
|
||||
'定时同步必须自动回填未匹配的人工订单项,并限制在最近窗口',
|
||||
)
|
||||
assert.ok(
|
||||
manualSource.includes('NULLIF(oi.sku_code') && configSource.includes('NULLIF(oi.sku_code'),
|
||||
'SKU 匹配必须有 sku_code / sku_name 兜底,不能只认 spec_json',
|
||||
)
|
||||
})
|
||||
|
||||
test('人工成本缺失必须进入异常队列,且只监控首次配置之后的订单项', async () => {
|
||||
const source = await readFile(
|
||||
new URL('../cost-center/cost-snapshot-sync-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes("'manual_cost_missing'"), '缺少人工成本缺失异常')
|
||||
assert.ok(
|
||||
source.includes('FROM manual_fulfillment_configs') &&
|
||||
source.includes('s.basis_at >= c.enforce_from'),
|
||||
'人工异常必须以首次配置时间为起点,避免历史人工单刷屏',
|
||||
)
|
||||
assert.ok(
|
||||
source.includes("cs.subject_type = 'manual_case'") &&
|
||||
source.includes('cs.order_item_id = s.order_item_id'),
|
||||
'人工异常回填/收口必须按订单项关联人工快照',
|
||||
)
|
||||
})
|
||||
|
||||
test('迁移 111 提供订单项级人工成本唯一约束与 subject 语义约束', async () => {
|
||||
const source = await readFile(
|
||||
new URL('../../db/migrations/111_manual_cost_integrity.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes('uq_cost_snapshots_manual_order_item'), '缺少订单项级人工快照唯一索引')
|
||||
assert.ok(
|
||||
source.includes("WHERE subject_type = 'manual_case' AND superseded_at IS NULL"),
|
||||
'唯一索引必须只约束有效人工快照',
|
||||
)
|
||||
assert.ok(
|
||||
source.includes('subject_id <> order_item_id') &&
|
||||
source.includes('chk_cost_snapshots_manual_subject'),
|
||||
'必须把历史 subject_id 归一并加语义约束',
|
||||
)
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
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'
|
||||
@@ -8,7 +10,8 @@ import {
|
||||
buildManualFulfillmentReference,
|
||||
} from './manual-fulfillment-note.js'
|
||||
import {
|
||||
consumePlatformCredit,
|
||||
consumePlatformCreditWithClient,
|
||||
PLATFORM_CREDIT_KEYS,
|
||||
type PlatformCreditKey,
|
||||
} from '../cost-center/platform-credit-service.js'
|
||||
|
||||
@@ -52,6 +55,28 @@ type PendingRow = {
|
||||
fulfilled_at: string | null
|
||||
}
|
||||
|
||||
type ConfirmContextRow = {
|
||||
order_item_id: number
|
||||
order_id: number
|
||||
order_id_resolved: number
|
||||
platform_order_id: string
|
||||
provider: string
|
||||
shop_id: string
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
revenue_fen: string
|
||||
canonical_product_id: number | null
|
||||
fulfillment_mode: string
|
||||
config_id: number | null
|
||||
platform_key: string
|
||||
credit_quantity: string
|
||||
cost_mode: string
|
||||
credit_unit_price_fen: string
|
||||
work_order_id: number | null
|
||||
work_order_no: string | null
|
||||
work_product_rule_id: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出客服的人工履约待办。
|
||||
*
|
||||
@@ -128,7 +153,12 @@ export async function listManualFulfillmentPending(
|
||||
o.shop_id,
|
||||
oi.sku_code,
|
||||
oi.sku_name,
|
||||
COALESCE(NULLIF(oi.spec_json->>'skuNick',''), NULLIF(oi.spec_json->>'outSkuSn','')) AS internal_sku,
|
||||
COALESCE(
|
||||
NULLIF(oi.spec_json->>'skuNick',''),
|
||||
NULLIF(oi.spec_json->>'outSkuSn',''),
|
||||
NULLIF(oi.sku_code,''),
|
||||
NULLIF(oi.sku_name,'')
|
||||
) AS internal_sku,
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
COALESCE(o.paid_at, o.created_at) AS paid_at,
|
||||
cfg.id AS config_id,
|
||||
@@ -167,135 +197,134 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
)
|
||||
const now = nowIso()
|
||||
|
||||
const context = await query<{
|
||||
order_item_id: number
|
||||
order_id: number
|
||||
order_id_resolved: number
|
||||
platform_order_id: string
|
||||
provider: string
|
||||
shop_id: string
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
revenue_fen: string
|
||||
canonical_product_id: number | null
|
||||
fulfillment_mode: string
|
||||
config_id: number | null
|
||||
platform_key: string
|
||||
credit_quantity: string
|
||||
cost_mode: string
|
||||
credit_unit_price_fen: string
|
||||
work_order_id: number | null
|
||||
work_order_no: string | null
|
||||
work_product_rule_id: number | null
|
||||
fulfilled_at: string | null
|
||||
}>(
|
||||
`
|
||||
WITH fulfilled AS (
|
||||
SELECT cs.order_item_id, MAX(cs.created_at) AS fulfilled_at
|
||||
FROM cost_snapshots cs
|
||||
WHERE cs.subject_type = 'manual_case' AND cs.superseded_at IS NULL
|
||||
GROUP BY cs.order_item_id
|
||||
)
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
oi.order_id,
|
||||
o.id AS order_id_resolved,
|
||||
o.platform_order_id,
|
||||
o.provider,
|
||||
o.shop_id,
|
||||
oi.sku_code,
|
||||
oi.sku_name,
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
oi.canonical_product_id,
|
||||
oi.fulfillment_mode,
|
||||
oi.fulfillment_config_id AS config_id,
|
||||
COALESCE(cfg.platform_key, '') AS platform_key,
|
||||
COALESCE(cfg.credit_quantity, 0)::bigint AS credit_quantity,
|
||||
COALESCE(cfg.cost_mode, 'variable') AS cost_mode,
|
||||
COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen,
|
||||
wo.id AS work_order_id,
|
||||
wo.work_order_no,
|
||||
wo.product_rule_id AS work_product_rule_id,
|
||||
fulfilled.fulfilled_at
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
|
||||
LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, product_rule_id, work_order_no FROM work_orders
|
||||
WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1
|
||||
) wo ON TRUE
|
||||
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
|
||||
WHERE oi.id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[orderItemId],
|
||||
)
|
||||
// 钱与成本必须同一事务:先锁订单项、复查未履约,再扣款、最后写快照。
|
||||
// 任何一步失败(余额不足、成本版本缺失、重复快照)都整体回滚,不会留下「扣了钱没记成本」。
|
||||
return withTransaction(async (client) => {
|
||||
// 配置可能晚于订单创建:确认前按 SKU 再匹配一次(与定时 rematch 同口径),
|
||||
// 保证「配置好之后的新单」不需要人工再点一次「重新匹配」。
|
||||
await matchManualFulfillmentConfigWithClient(client, orderItemId, now)
|
||||
|
||||
const row = context.rows[0]
|
||||
if (!row) {
|
||||
throw createHttpError('订单项不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'manual_fulfillment_order_item_not_found',
|
||||
})
|
||||
}
|
||||
if (row.fulfillment_mode !== 'manual') {
|
||||
throw createHttpError('该订单项不是人工履约', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_not_manual',
|
||||
})
|
||||
}
|
||||
if (row.fulfilled_at) {
|
||||
throw createHttpError('该订单项已确认过履约,不能重复操作', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_already_done',
|
||||
})
|
||||
}
|
||||
if (!row.config_id) {
|
||||
throw createHttpError('该商品还没有配置履约渠道,请先在成本中心配置', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_config_missing',
|
||||
})
|
||||
}
|
||||
const context = await client.query<ConfirmContextRow>(
|
||||
`
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
oi.order_id,
|
||||
o.id AS order_id_resolved,
|
||||
o.platform_order_id,
|
||||
o.provider,
|
||||
o.shop_id,
|
||||
oi.sku_code,
|
||||
oi.sku_name,
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
oi.canonical_product_id,
|
||||
oi.fulfillment_mode,
|
||||
oi.fulfillment_config_id AS config_id,
|
||||
COALESCE(cfg.platform_key, '') AS platform_key,
|
||||
COALESCE(cfg.credit_quantity, 0)::bigint AS credit_quantity,
|
||||
COALESCE(cfg.cost_mode, 'variable') AS cost_mode,
|
||||
COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen,
|
||||
wo.id AS work_order_id,
|
||||
wo.work_order_no,
|
||||
wo.product_rule_id AS work_product_rule_id
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
|
||||
LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, product_rule_id, work_order_no FROM work_orders
|
||||
WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1
|
||||
) wo ON TRUE
|
||||
WHERE oi.id = $1
|
||||
LIMIT 1
|
||||
FOR UPDATE OF oi
|
||||
`,
|
||||
[orderItemId],
|
||||
)
|
||||
|
||||
const platformKey = row.platform_key as PlatformCreditKey | 'fund_cash'
|
||||
const creditQuantity = Number(row.credit_quantity || 0)
|
||||
const unitPriceFen = Number(row.credit_unit_price_fen || 1)
|
||||
|
||||
// 成本:固定成本用配置值;成本不固定必须客服手填。
|
||||
let costFen: number
|
||||
if (creditQuantity > 0) {
|
||||
costFen = creditQuantity * unitPriceFen
|
||||
} else {
|
||||
const provided = payload.costFen ?? payload.cost_fen ?? payload.amountFen
|
||||
costFen = normalizeCostFen(provided)
|
||||
if (costFen === 0) {
|
||||
throw createHttpError('该商品成本不固定,请填写本次实际成本', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_fulfillment_cost_required',
|
||||
const row = context.rows[0]
|
||||
if (!row) {
|
||||
throw createHttpError('订单项不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'manual_fulfillment_order_item_not_found',
|
||||
})
|
||||
}
|
||||
if (row.fulfillment_mode !== 'manual') {
|
||||
throw createHttpError('该订单项不是人工履约', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_not_manual',
|
||||
})
|
||||
}
|
||||
if (!row.config_id) {
|
||||
throw createHttpError('该商品还没有配置履约渠道,请先在成本中心配置', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_config_missing',
|
||||
})
|
||||
}
|
||||
const platformKey = row.platform_key as PlatformCreditKey | 'fund_cash'
|
||||
if (platformKey !== 'fund_cash' && !PLATFORM_CREDIT_KEYS.includes(platformKey)) {
|
||||
throw createHttpError('该商品的人工履约平台配置无效,请重新配置', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_config_invalid',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const note = optionalText(payload.note, 300)
|
||||
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120)
|
||||
const fulfillmentReference = buildManualFulfillmentReference({
|
||||
workOrderNo: row.work_order_no,
|
||||
platformOrderId: row.platform_order_id,
|
||||
orderItemId,
|
||||
})
|
||||
// 锁内复查:并发/双击的第二个请求会等锁,醒来后看到快照直接拒绝,不会重复扣款。
|
||||
const existing = await client.query<{ id: number }>(
|
||||
`SELECT id FROM cost_snapshots
|
||||
WHERE subject_type = 'manual_case' AND order_item_id = $1 AND superseded_at IS NULL
|
||||
LIMIT 1`,
|
||||
[orderItemId],
|
||||
)
|
||||
if (existing.rows[0]) {
|
||||
throw createHttpError('该订单项已确认过履约,不能重复操作', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_already_done',
|
||||
})
|
||||
}
|
||||
|
||||
// 1. 先扣额度(积分平台走积分钱包;备用金现金走现有 fund_* 链路)。
|
||||
let creditEntryId: number | null = null
|
||||
let fundExpenseId: number | null = null
|
||||
let balanceAfter: number | null = null
|
||||
const creditQuantity = Number(row.credit_quantity || 0)
|
||||
const unitPriceFen = Number(row.credit_unit_price_fen || 1)
|
||||
if (platformKey !== 'fund_cash' && creditQuantity <= 0) {
|
||||
throw createHttpError('该人工 SKU 的积分配置无效,请先在成本中心重新配置', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_config_invalid',
|
||||
})
|
||||
}
|
||||
|
||||
if (platformKey === 'fund_cash') {
|
||||
// 备用金现金:复用现有客服备用金支出链路(扣余额 + 写 fund_entries + manual_expenses)。
|
||||
const { writeFundExpense } = await import('../admin/admin-fund-service.js')
|
||||
const fundAccountId = await resolveFundAccountId(session)
|
||||
const written = await withTransaction(async (client) => {
|
||||
return writeFundExpense(client, {
|
||||
const costPlan = resolveManualFulfillmentCost({
|
||||
creditQuantity,
|
||||
unitPriceFen,
|
||||
providedCostFen: payload.costFen ?? payload.cost_fen ?? payload.amountFen,
|
||||
})
|
||||
const costFen = costPlan.costFen
|
||||
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120)
|
||||
const note = optionalText(payload.note, 300)
|
||||
const fulfillmentReference = buildManualFulfillmentReference({
|
||||
workOrderNo: row.work_order_no,
|
||||
platformOrderId: row.platform_order_id,
|
||||
orderItemId,
|
||||
})
|
||||
|
||||
// 先把成本项/版本解析完再扣钱:版本缺失不能发生在扣款之后。
|
||||
const costItemId = await resolveManualCostItemIdWithClient(client)
|
||||
const versionId = await resolveCostVersionIdWithClient(client, costItemId, row, now)
|
||||
if (!versionId) {
|
||||
throw createHttpError('该成本项没有可用的成本版本,请先在成本中心配置后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_cost_version_missing',
|
||||
})
|
||||
}
|
||||
|
||||
let fundExpenseId: number | null = null
|
||||
let creditEntryId: number | null = null
|
||||
let balanceAfter: number | null = null
|
||||
let subjectSource: 'manual_expense' | 'platform_credit'
|
||||
|
||||
if (platformKey === 'fund_cash') {
|
||||
// 备用金现金:复用唯一写入口,但把 client 传进去,和快照共用同一个事务。
|
||||
const fundAccountId = await resolveFundAccountId(session, client)
|
||||
const { writeFundExpense } = await import('../admin/admin-fund-service.js')
|
||||
const written = await writeFundExpense(client, {
|
||||
agentAccountId: fundAccountId,
|
||||
amountFen: costFen,
|
||||
category: 'other',
|
||||
@@ -312,115 +341,122 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
maxLength: 500,
|
||||
}),
|
||||
actorUserId: session.userId,
|
||||
// 同一订单可能有多个同价人工项:幂等由订单项锁 + 快照唯一索引保证,
|
||||
// 这里放开 24 小时同金额同收款方的通用防重,避免误拦第二个订单项。
|
||||
allowDuplicate: true,
|
||||
})
|
||||
})
|
||||
fundExpenseId = Number(written.expense.id)
|
||||
balanceAfter = written.balanceAfterFen
|
||||
} else if (creditQuantity > 0) {
|
||||
const consumed = await consumePlatformCredit({
|
||||
ownerAdminUserId: session.userId,
|
||||
platformKey: platformKey as PlatformCreditKey,
|
||||
quantity: creditQuantity,
|
||||
relatedTaskId: null,
|
||||
platformRef: row.work_order_no || row.platform_order_id,
|
||||
note: buildManualFulfillmentNote({
|
||||
skuName: row.sku_name,
|
||||
reference: fulfillmentReference,
|
||||
note,
|
||||
maxLength: 300,
|
||||
}),
|
||||
})
|
||||
creditEntryId = consumed.entryId
|
||||
balanceAfter = consumed.balanceAfter
|
||||
}
|
||||
|
||||
// 2. 写 actual 成本快照(主体 = 备用金支出单 / 积分流水)。
|
||||
const subjectId = fundExpenseId ?? creditEntryId ?? orderItemId
|
||||
const costItemId = await resolveManualCostItemId()
|
||||
const versionId = await resolveCostVersionId(costItemId, row, now)
|
||||
|
||||
if (!versionId) {
|
||||
throw createHttpError('该成本项没有可用的成本版本,请先在成本中心配置后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_cost_version_missing',
|
||||
})
|
||||
}
|
||||
|
||||
const snapshot = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO cost_snapshots (
|
||||
subject_type, subject_id, order_id, order_item_id, work_order_id,
|
||||
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,
|
||||
1, $8, $8, 'actual', 'manual_input',
|
||||
$9::jsonb, $10, $11, $11
|
||||
)
|
||||
ON CONFLICT (subject_type, subject_id, cost_item_id)
|
||||
WHERE superseded_at IS NULL
|
||||
DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
subjectId,
|
||||
row.order_id_resolved,
|
||||
orderItemId,
|
||||
row.work_order_id,
|
||||
costItemId,
|
||||
versionId,
|
||||
row.canonical_product_id,
|
||||
costFen,
|
||||
JSON.stringify({
|
||||
source: 'manual_fulfillment_confirm',
|
||||
fundExpenseId = Number(written.expense.id)
|
||||
balanceAfter = written.balanceAfterFen
|
||||
subjectSource = 'manual_expense'
|
||||
} else {
|
||||
const consumed = await consumePlatformCreditWithClient(client, {
|
||||
ownerAdminUserId: session.userId,
|
||||
platformKey,
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
|
||||
creditQuantity,
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
costMode: creditQuantity > 0 ? 'fixed' : 'variable',
|
||||
costFen,
|
||||
fundExpenseId,
|
||||
creditEntryId,
|
||||
payeeName,
|
||||
note,
|
||||
fulfilledBy: {
|
||||
userId: Number(session.userId || 0),
|
||||
displayName: String(session.displayName || ''),
|
||||
role: String(session.role || ''),
|
||||
},
|
||||
}),
|
||||
now,
|
||||
nowIso(),
|
||||
],
|
||||
)
|
||||
const snapshotId = snapshot.rows[0]?.id ?? null
|
||||
if (!snapshotId) {
|
||||
throw createHttpError('该订单项已有人工成本快照,不能重复录入', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_snapshot_conflict',
|
||||
})
|
||||
}
|
||||
quantity: creditQuantity,
|
||||
relatedTaskId: null,
|
||||
platformRef: row.work_order_no || row.platform_order_id,
|
||||
note: buildManualFulfillmentNote({
|
||||
skuName: row.sku_name,
|
||||
reference: fulfillmentReference,
|
||||
note,
|
||||
maxLength: 300,
|
||||
}),
|
||||
})
|
||||
creditEntryId = consumed.entryId
|
||||
balanceAfter = consumed.balanceAfter
|
||||
subjectSource = 'platform_credit'
|
||||
}
|
||||
|
||||
return {
|
||||
orderItemId,
|
||||
orderId: Number(row.order_id_resolved),
|
||||
costSnapshotId: snapshotId,
|
||||
platformKey,
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
|
||||
creditQuantity,
|
||||
costFen,
|
||||
fundExpenseId,
|
||||
creditEntryId,
|
||||
balanceAfter,
|
||||
}
|
||||
// subject_id 固定为 order_item_id:不再与 manual_expenses.id /
|
||||
// platform_credit_entries.id 混用,避免两张表序列撞车。
|
||||
// subject_type 仍为 manual_case,资金流水只放在 metadata 里。
|
||||
const snapshot = await client.query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO cost_snapshots (
|
||||
subject_type, subject_id, order_id, order_item_id, work_order_id,
|
||||
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, $1, $3,
|
||||
$4, $5, $6,
|
||||
1, $7, $7, 'actual', 'manual_input',
|
||||
$8::jsonb, $9, $9, $9
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
orderItemId,
|
||||
row.order_id_resolved,
|
||||
row.work_order_id,
|
||||
costItemId,
|
||||
versionId,
|
||||
row.canonical_product_id,
|
||||
costFen,
|
||||
JSON.stringify({
|
||||
source: 'manual_fulfillment_confirm',
|
||||
subjectSource,
|
||||
configId: Number(row.config_id),
|
||||
platformKey,
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
|
||||
creditQuantity,
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
costMode: costPlan.costMode,
|
||||
costFen,
|
||||
fundExpenseId,
|
||||
creditEntryId,
|
||||
balanceAfter,
|
||||
payeeName,
|
||||
note,
|
||||
fulfilledBy: {
|
||||
userId: Number(session.userId || 0),
|
||||
displayName: String(session.displayName || ''),
|
||||
role: String(session.role || ''),
|
||||
},
|
||||
}),
|
||||
now,
|
||||
],
|
||||
)
|
||||
const snapshotId = snapshot.rows[0]?.id ?? null
|
||||
if (!snapshotId) {
|
||||
// 订单项唯一索引兜底:任何竞态都会走到这里,抛错回滚本次扣款。
|
||||
throw createHttpError('该订单项已有人工成本快照,不能重复录入', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_snapshot_conflict',
|
||||
})
|
||||
}
|
||||
|
||||
// 人工缺成本异常立即收口,不等下一轮定时任务。
|
||||
await client.query(
|
||||
`UPDATE cost_exceptions
|
||||
SET status = 'resolved', resolved_at = $2
|
||||
WHERE subject_type = 'order_item'
|
||||
AND subject_id = $1
|
||||
AND reason = 'manual_cost_missing'
|
||||
AND status = 'open'`,
|
||||
[orderItemId, now],
|
||||
)
|
||||
|
||||
return {
|
||||
orderItemId,
|
||||
orderId: Number(row.order_id_resolved),
|
||||
costSnapshotId: snapshotId,
|
||||
platformKey,
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
|
||||
creditQuantity,
|
||||
costFen,
|
||||
fundExpenseId,
|
||||
creditEntryId,
|
||||
balanceAfter,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 客服自己的备用金账户(管理员代录时用指定账户)。 */
|
||||
async function resolveFundAccountId(session: AdminSession) {
|
||||
async function resolveFundAccountId(session: AdminSession, client?: Pick<PoolClient, 'query'>) {
|
||||
const { getOwnAgentAccountId } = await import('../admin/admin-fund-service.js')
|
||||
const own = await getOwnAgentAccountId(session.userId)
|
||||
const own = await getOwnAgentAccountId(session.userId, client)
|
||||
if (own) return own
|
||||
throw createHttpError('当前账号还没有备用金账户', {
|
||||
statusCode: 404,
|
||||
@@ -428,8 +464,67 @@ async function resolveFundAccountId(session: AdminSession) {
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveManualCostItemId() {
|
||||
const result = await query<{ id: number }>(
|
||||
/**
|
||||
* 确认前按 SKU 匹配人工履约配置。
|
||||
*
|
||||
* 与 `rematchManualFulfillments` 使用同一套键:
|
||||
* spec_json.skuNick -> spec_json.outSkuSn -> sku_code -> sku_name。
|
||||
* 这样即使配置晚于订单创建,新单在确认时也能自动挂上渠道。
|
||||
*/
|
||||
async function matchManualFulfillmentConfigWithClient(
|
||||
client: PoolClient,
|
||||
orderItemId: number,
|
||||
now: string,
|
||||
) {
|
||||
const matched = await client.query<{
|
||||
id: number
|
||||
canonical_product_id: number | null
|
||||
}>(
|
||||
`
|
||||
SELECT cfg.id, cfg.canonical_product_id
|
||||
FROM order_items oi
|
||||
JOIN manual_fulfillment_configs cfg ON cfg.enabled = TRUE
|
||||
AND (
|
||||
cfg.sku_key = COALESCE(
|
||||
NULLIF(oi.spec_json->>'skuNick', ''),
|
||||
NULLIF(oi.spec_json->>'outSkuSn', ''),
|
||||
NULLIF(oi.sku_code, ''),
|
||||
NULLIF(oi.sku_name, '')
|
||||
)
|
||||
OR (
|
||||
cfg.canonical_product_id IS NOT NULL
|
||||
AND cfg.canonical_product_id = oi.canonical_product_id
|
||||
)
|
||||
)
|
||||
WHERE oi.id = $1
|
||||
AND oi.fulfillment_mode = 'manual'
|
||||
AND oi.fulfillment_config_id IS NULL
|
||||
ORDER BY
|
||||
(cfg.canonical_product_id IS NOT NULL
|
||||
AND cfg.canonical_product_id = oi.canonical_product_id) DESC,
|
||||
cfg.id ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[orderItemId],
|
||||
)
|
||||
const match = matched.rows[0]
|
||||
if (!match) return false
|
||||
await client.query(
|
||||
`
|
||||
UPDATE order_items
|
||||
SET fulfillment_config_id = $2,
|
||||
canonical_product_id = COALESCE($3, canonical_product_id),
|
||||
updated_at = $4
|
||||
WHERE id = $1
|
||||
AND fulfillment_config_id IS NULL
|
||||
`,
|
||||
[orderItemId, match.id, match.canonical_product_id, now],
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
async function resolveManualCostItemIdWithClient(client: PoolClient) {
|
||||
const result = await client.query<{ id: number }>(
|
||||
`SELECT id FROM cost_items WHERE code = 'sys_manual_service' AND status = 'active' LIMIT 1`,
|
||||
)
|
||||
const id = Number(result.rows[0]?.id || 0)
|
||||
@@ -442,7 +537,8 @@ async function resolveManualCostItemId() {
|
||||
return id
|
||||
}
|
||||
|
||||
async function resolveCostVersionId(
|
||||
async function resolveCostVersionIdWithClient(
|
||||
client: PoolClient,
|
||||
costItemId: number,
|
||||
row: {
|
||||
order_id_resolved: number
|
||||
@@ -454,7 +550,7 @@ async function resolveCostVersionId(
|
||||
},
|
||||
basisAt: string,
|
||||
) {
|
||||
const result = await query<{ id: number }>(
|
||||
const result = await client.query<{ id: number }>(
|
||||
`
|
||||
SELECT v.id
|
||||
FROM cost_versions v
|
||||
@@ -499,6 +595,40 @@ async function resolveCostVersionId(
|
||||
return result.rows[0]?.id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工履约的本次成本:固定成本按「配置积分 × 平台换算率」,成本不固定必须客服手填。
|
||||
* 抽成纯函数,便于单测覆盖固定/手填/非法金额三种分支。
|
||||
*/
|
||||
export function resolveManualFulfillmentCost(input: {
|
||||
creditQuantity: number
|
||||
unitPriceFen: number
|
||||
providedCostFen?: unknown
|
||||
}): { costFen: number; costMode: 'fixed' | 'variable' } {
|
||||
const creditQuantity = Math.max(0, Math.trunc(Number(input.creditQuantity) || 0))
|
||||
if (creditQuantity > 0) {
|
||||
const unitPriceFen = Math.max(0, Math.trunc(Number(input.unitPriceFen) || 0))
|
||||
return { costFen: creditQuantity * unitPriceFen, costMode: 'fixed' }
|
||||
}
|
||||
if (
|
||||
input.providedCostFen === undefined ||
|
||||
input.providedCostFen === null ||
|
||||
input.providedCostFen === ''
|
||||
) {
|
||||
throw createHttpError('该商品成本不固定,请填写本次实际成本', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_fulfillment_cost_required',
|
||||
})
|
||||
}
|
||||
const costFen = normalizeCostFen(input.providedCostFen)
|
||||
if (costFen <= 0) {
|
||||
throw createHttpError('该商品成本不固定,请填写本次实际成本', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_fulfillment_cost_required',
|
||||
})
|
||||
}
|
||||
return { costFen, costMode: 'variable' }
|
||||
}
|
||||
|
||||
function mapPending(row: PendingRow) {
|
||||
const creditQuantity = Number(row.credit_quantity || 0)
|
||||
const unitPriceFen = Number(row.credit_unit_price_fen || 1)
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* 人工履约资金链路集成测试(需要真实 PostgreSQL)。
|
||||
*
|
||||
* 运行方式:
|
||||
* MANUAL_FULFILLMENT_TEST_DATABASE_URL=postgres://.../scratch npm test -- --test-name-pattern ...
|
||||
* 或直接:
|
||||
* MANUAL_FULFILLMENT_TEST_DATABASE_URL=... npx tsx --test src/services/finance/manual-fulfillment.integration.test.ts
|
||||
*
|
||||
* 约定:目标库必须已执行到迁移 111;测试只创建带唯一前缀的临时数据,结束后按依赖顺序清理。
|
||||
* 未设置环境变量时整个文件跳过,因此普通 `npm test` 不依赖数据库。
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const TEST_DATABASE_URL = String(process.env.MANUAL_FULFILLMENT_TEST_DATABASE_URL || '').trim()
|
||||
|
||||
test(
|
||||
'人工履约:单事务、防重复扣款、subject 不再跨表撞车、缺成本入异常队列',
|
||||
{ skip: !TEST_DATABASE_URL ? '未设置 MANUAL_FULFILLMENT_TEST_DATABASE_URL' : false },
|
||||
async () => {
|
||||
process.env.DATABASE_URL = TEST_DATABASE_URL
|
||||
const { query, closeDb } = await import('../../db/client.js')
|
||||
const { confirmManualFulfillment } = await import('./manual-fulfillment-service.js')
|
||||
const { syncEstimatedOrderItemCostSnapshots } =
|
||||
await import('../cost-center/cost-snapshot-sync-service.js')
|
||||
const { rematchManualFulfillments } =
|
||||
await import('../cost-center/manual-fulfillment-config-service.js')
|
||||
|
||||
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
|
||||
const skuKey = `IT-MANUAL-${suffix}`
|
||||
const creditSkuKey = `IT-CREDIT-${suffix}`
|
||||
const unconfirmedSkuKey = `IT-PENDING-${suffix}`
|
||||
const orderIds: number[] = []
|
||||
const orderItemIds: number[] = []
|
||||
const fundAccountIds: number[] = []
|
||||
const adminIds: number[] = []
|
||||
const creditAccountIds: number[] = []
|
||||
const configIds: number[] = []
|
||||
let triggerName = ''
|
||||
let fnName = ''
|
||||
|
||||
try {
|
||||
// 1. 客服 + 备用金账户(余额 100 元)
|
||||
const admin = await query<{ id: number }>(
|
||||
`INSERT INTO admin_users (username, password_hash, role, status, created_at, updated_at)
|
||||
VALUES ($1, 'x', 'support', 'active', NOW(), NOW()) RETURNING id`,
|
||||
[`it_mf_${suffix}`],
|
||||
)
|
||||
const adminId = Number(admin.rows[0]?.id)
|
||||
adminIds.push(adminId)
|
||||
const fundAccount = await query<{ id: number }>(
|
||||
`INSERT INTO fund_accounts (
|
||||
account_no, account_type, owner_admin_user_id, balance_fen, status, created_at, updated_at
|
||||
) VALUES ($1, 'agent', $2, 10000, 'active', NOW(), NOW()) RETURNING id`,
|
||||
[`FA-IT-${suffix}`, adminId],
|
||||
)
|
||||
const fundAccountId = Number(fundAccount.rows[0]?.id)
|
||||
fundAccountIds.push(fundAccountId)
|
||||
|
||||
const session = {
|
||||
userId: adminId,
|
||||
role: 'support',
|
||||
displayName: '集成测试客服',
|
||||
} as never
|
||||
|
||||
// 2. 现金人工项:配置在 1 小时前创建,订单项尚未挂 config(验证确认时自动匹配)
|
||||
const cashSku = `${skuKey}-CASH`
|
||||
const cashConfig = await query<{ id: number }>(
|
||||
`INSERT INTO manual_fulfillment_configs (
|
||||
sku_key, platform_key, credit_quantity, cost_mode, enabled, note, created_at, updated_at
|
||||
) VALUES ($1, 'fund_cash', 0, 'variable', TRUE, '', NOW() - interval '1 hour', NOW())
|
||||
RETURNING id`,
|
||||
[cashSku],
|
||||
)
|
||||
configIds.push(Number(cashConfig.rows[0]?.id))
|
||||
const cashItem = await createManualItem(query, cashSku)
|
||||
orderIds.push(cashItem.orderId)
|
||||
orderItemIds.push(cashItem.orderItemId)
|
||||
|
||||
// 配置晚于下单:rematch(最近窗口)应把订单项挂上配置
|
||||
const today = shanghaiDate()
|
||||
const rematched = await rematchManualFulfillments({
|
||||
onlyUnmatched: true,
|
||||
dateFrom: today,
|
||||
dateTo: today,
|
||||
})
|
||||
assert.ok(rematched.updatedCount >= 1)
|
||||
const matchedConfig = await query<{ fulfillment_config_id: number | null }>(
|
||||
`SELECT fulfillment_config_id FROM order_items WHERE id = $1`,
|
||||
[cashItem.orderItemId],
|
||||
)
|
||||
assert.ok(Number(matchedConfig.rows[0]?.fulfillment_config_id) > 0, 'rematch 必须挂上配置')
|
||||
|
||||
const cashResult = await confirmManualFulfillment(
|
||||
{ orderItemId: cashItem.orderItemId, costFen: 1234 },
|
||||
session,
|
||||
)
|
||||
assert.ok(cashResult.costSnapshotId > 0)
|
||||
assert.equal(cashResult.costFen, 1234)
|
||||
|
||||
const cashSnapshot = await query<{
|
||||
subject_id: number
|
||||
order_item_id: number
|
||||
total_amount_fen: number
|
||||
metadata_json: Record<string, unknown>
|
||||
}>(
|
||||
`SELECT subject_id, order_item_id, total_amount_fen, metadata_json
|
||||
FROM cost_snapshots WHERE id = $1`,
|
||||
[cashResult.costSnapshotId],
|
||||
)
|
||||
assert.equal(
|
||||
Number(cashSnapshot.rows[0]?.subject_id),
|
||||
cashItem.orderItemId,
|
||||
'manual_case 的 subject_id 必须是 order_item_id',
|
||||
)
|
||||
assert.equal(Number(cashSnapshot.rows[0]?.order_item_id), cashItem.orderItemId)
|
||||
assert.equal(Number(cashSnapshot.rows[0]?.total_amount_fen), 1234)
|
||||
assert.equal(cashSnapshot.rows[0]?.metadata_json?.fundExpenseId, cashResult.fundExpenseId)
|
||||
|
||||
const cashExpense = await query<{ amount_fen: number; order_item_id: number }>(
|
||||
`SELECT amount_fen, order_item_id FROM manual_expenses WHERE id = $1`,
|
||||
[cashResult.fundExpenseId],
|
||||
)
|
||||
assert.equal(Number(cashExpense.rows[0]?.amount_fen), 1234)
|
||||
assert.equal(Number(cashExpense.rows[0]?.order_item_id), cashItem.orderItemId)
|
||||
|
||||
const balanceAfterCash = await query<{ balance_fen: number }>(
|
||||
`SELECT balance_fen FROM fund_accounts WHERE id = $1`,
|
||||
[fundAccountId],
|
||||
)
|
||||
assert.equal(Number(balanceAfterCash.rows[0]?.balance_fen), 10000 - 1234)
|
||||
|
||||
// 3. 重复确认:必须拒绝,且不再扣款
|
||||
await assert.rejects(
|
||||
() =>
|
||||
confirmManualFulfillment({ orderItemId: cashItem.orderItemId, costFen: 1234 }, session),
|
||||
/已确认过履约/,
|
||||
)
|
||||
const cashExpenseCount = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM manual_expenses WHERE order_item_id = $1`,
|
||||
[cashItem.orderItemId],
|
||||
)
|
||||
assert.equal(Number(cashExpenseCount.rows[0]?.count), 1, '重复确认不得产生第二条支出')
|
||||
const balanceStill = await query<{ balance_fen: number }>(
|
||||
`SELECT balance_fen FROM fund_accounts WHERE id = $1`,
|
||||
[fundAccountId],
|
||||
)
|
||||
assert.equal(Number(balanceStill.rows[0]?.balance_fen), 10000 - 1234)
|
||||
|
||||
// 4. 并发确认:只有一个成功,扣款也只发生一次
|
||||
const concurrentItem = await createManualItem(query, cashSku)
|
||||
orderIds.push(concurrentItem.orderId)
|
||||
orderItemIds.push(concurrentItem.orderItemId)
|
||||
const concurrent = await Promise.allSettled([
|
||||
confirmManualFulfillment(
|
||||
{ orderItemId: concurrentItem.orderItemId, costFen: 500 },
|
||||
session,
|
||||
),
|
||||
confirmManualFulfillment(
|
||||
{ orderItemId: concurrentItem.orderItemId, costFen: 500 },
|
||||
session,
|
||||
),
|
||||
])
|
||||
assert.equal(concurrent.filter((item) => item.status === 'fulfilled').length, 1)
|
||||
assert.equal(concurrent.filter((item) => item.status === 'rejected').length, 1)
|
||||
const concurrentExpense = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM manual_expenses WHERE order_item_id = $1`,
|
||||
[concurrentItem.orderItemId],
|
||||
)
|
||||
assert.equal(Number(concurrentExpense.rows[0]?.count), 1, '并发确认只能扣一次')
|
||||
const balanceAfterConcurrent = await query<{ balance_fen: number }>(
|
||||
`SELECT balance_fen FROM fund_accounts WHERE id = $1`,
|
||||
[fundAccountId],
|
||||
)
|
||||
assert.equal(Number(balanceAfterConcurrent.rows[0]?.balance_fen), 10000 - 1234 - 500)
|
||||
|
||||
// 5. 积分人工项:与现金项同时存在,subject_id 都是 order_item_id,不撞唯一键
|
||||
const creditAccount = await query<{ id: number }>(
|
||||
`INSERT INTO platform_credit_accounts (
|
||||
platform_key, owner_admin_user_id, balance_quantity, unit_name,
|
||||
recharge_unit_price_fen, status, created_at, updated_at
|
||||
) VALUES ('nanhang', $1, 10, '积分', 1, 'active', NOW(), NOW()) RETURNING id`,
|
||||
[adminId],
|
||||
)
|
||||
creditAccountIds.push(Number(creditAccount.rows[0]?.id))
|
||||
const creditSku = `${creditSkuKey}-NANHANG`
|
||||
const creditConfig = await query<{ id: number }>(
|
||||
`INSERT INTO manual_fulfillment_configs (
|
||||
sku_key, platform_key, credit_quantity, cost_mode, enabled, note, created_at, updated_at
|
||||
) VALUES ($1, 'nanhang', 2, 'fixed', TRUE, '', NOW() - interval '1 hour', NOW())
|
||||
RETURNING id`,
|
||||
[creditSku],
|
||||
)
|
||||
configIds.push(Number(creditConfig.rows[0]?.id))
|
||||
const creditItem = await createManualItem(query, creditSku)
|
||||
orderIds.push(creditItem.orderId)
|
||||
orderItemIds.push(creditItem.orderItemId)
|
||||
|
||||
const creditResult = await confirmManualFulfillment(
|
||||
{ orderItemId: creditItem.orderItemId },
|
||||
session,
|
||||
)
|
||||
assert.equal(creditResult.costFen, 2)
|
||||
assert.ok(creditResult.creditEntryId)
|
||||
const creditSnapshot = await query<{
|
||||
subject_id: number
|
||||
metadata_json: Record<string, unknown>
|
||||
}>(`SELECT subject_id, metadata_json FROM cost_snapshots WHERE id = $1`, [
|
||||
creditResult.costSnapshotId,
|
||||
])
|
||||
assert.equal(Number(creditSnapshot.rows[0]?.subject_id), creditItem.orderItemId)
|
||||
assert.equal(creditSnapshot.rows[0]?.metadata_json?.subjectSource, 'platform_credit')
|
||||
const creditBalance = await query<{ balance_quantity: number }>(
|
||||
`SELECT balance_quantity FROM platform_credit_accounts WHERE id = $1`,
|
||||
[creditAccountIds[0]],
|
||||
)
|
||||
assert.equal(Number(creditBalance.rows[0]?.balance_quantity), 8)
|
||||
const creditExpenseCount = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM manual_expenses WHERE order_item_id = $1`,
|
||||
[creditItem.orderItemId],
|
||||
)
|
||||
assert.equal(Number(creditExpenseCount.rows[0]?.count), 0, '积分路径不得写备用金支出')
|
||||
|
||||
// 6. 快照写入失败必须回滚已扣款:用临时触发器强制 cost_snapshots 插入失败,
|
||||
// 验证「钱与成本同一事务」不是只写在注释里。
|
||||
const failSku = `${skuKey}-FAIL`
|
||||
const failConfig = await query<{ id: number }>(
|
||||
`INSERT INTO manual_fulfillment_configs (
|
||||
sku_key, platform_key, credit_quantity, cost_mode, enabled, note, created_at, updated_at
|
||||
) VALUES ($1, 'fund_cash', 0, 'variable', TRUE, '', NOW() - interval '1 hour', NOW())
|
||||
RETURNING id`,
|
||||
[failSku],
|
||||
)
|
||||
configIds.push(Number(failConfig.rows[0]?.id))
|
||||
const failItem = await createManualItem(query, failSku)
|
||||
orderIds.push(failItem.orderId)
|
||||
orderItemIds.push(failItem.orderItemId)
|
||||
triggerName = `it_mf_fail_${suffix}`
|
||||
fnName = `it_mf_fail_fn_${suffix}`
|
||||
await query(
|
||||
`CREATE OR REPLACE FUNCTION ${fnName}() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.order_item_id = ${Number(failItem.orderItemId)} THEN
|
||||
RAISE EXCEPTION 'forced snapshot failure for test';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$ LANGUAGE plpgsql`,
|
||||
)
|
||||
await query(
|
||||
`CREATE TRIGGER ${triggerName} BEFORE INSERT ON cost_snapshots
|
||||
FOR EACH ROW EXECUTE FUNCTION ${fnName}()`,
|
||||
)
|
||||
const balanceBeforeFail = await query<{ balance_fen: number }>(
|
||||
`SELECT balance_fen FROM fund_accounts WHERE id = $1`,
|
||||
[fundAccountId],
|
||||
)
|
||||
await assert.rejects(
|
||||
() =>
|
||||
confirmManualFulfillment({ orderItemId: failItem.orderItemId, costFen: 700 }, session),
|
||||
/forced snapshot failure/,
|
||||
)
|
||||
const balanceAfterFail = await query<{ balance_fen: number }>(
|
||||
`SELECT balance_fen FROM fund_accounts WHERE id = $1`,
|
||||
[fundAccountId],
|
||||
)
|
||||
assert.equal(
|
||||
Number(balanceAfterFail.rows[0]?.balance_fen),
|
||||
Number(balanceBeforeFail.rows[0]?.balance_fen),
|
||||
'快照失败必须回滚扣款',
|
||||
)
|
||||
const failExpense = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM manual_expenses WHERE order_item_id = $1`,
|
||||
[failItem.orderItemId],
|
||||
)
|
||||
assert.equal(Number(failExpense.rows[0]?.count), 0, '快照失败不得留下支出')
|
||||
const failSnapshot = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM cost_snapshots WHERE order_item_id = $1`,
|
||||
[failItem.orderItemId],
|
||||
)
|
||||
assert.equal(Number(failSnapshot.rows[0]?.count), 0, '快照失败不得留下成本')
|
||||
await query(`DROP TRIGGER ${triggerName} ON cost_snapshots`)
|
||||
await query(`DROP FUNCTION ${fnName}()`)
|
||||
|
||||
// 7. 人工缺成本:首次配置之后、尚未确认的订单项进入 manual_cost_missing;确认后自动收口
|
||||
const pendingSku = `${unconfirmedSkuKey}-CASH`
|
||||
const pendingConfig = await query<{ id: number }>(
|
||||
`INSERT INTO manual_fulfillment_configs (
|
||||
sku_key, platform_key, credit_quantity, cost_mode, enabled, note, created_at, updated_at
|
||||
) VALUES ($1, 'fund_cash', 0, 'variable', TRUE, '', NOW() - interval '1 hour', NOW())
|
||||
RETURNING id`,
|
||||
[pendingSku],
|
||||
)
|
||||
configIds.push(Number(pendingConfig.rows[0]?.id))
|
||||
const pendingItem = await createManualItem(query, pendingSku)
|
||||
orderIds.push(pendingItem.orderId)
|
||||
orderItemIds.push(pendingItem.orderItemId)
|
||||
|
||||
await syncEstimatedOrderItemCostSnapshots({ dateFrom: today, dateTo: today })
|
||||
const openManualException = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM cost_exceptions
|
||||
WHERE reason = 'manual_cost_missing' AND status = 'open' AND subject_id = $1`,
|
||||
[pendingItem.orderItemId],
|
||||
)
|
||||
assert.equal(Number(openManualException.rows[0]?.count), 1, '人工缺成本必须进异常队列')
|
||||
|
||||
await confirmManualFulfillment(
|
||||
{ orderItemId: pendingItem.orderItemId, costFen: 100 },
|
||||
session,
|
||||
)
|
||||
const openAfterConfirm = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM cost_exceptions
|
||||
WHERE reason = 'manual_cost_missing' AND status = 'open' AND subject_id = $1`,
|
||||
[pendingItem.orderItemId],
|
||||
)
|
||||
assert.equal(Number(openAfterConfirm.rows[0]?.count), 0, '确认后应立即收口人工异常')
|
||||
await syncEstimatedOrderItemCostSnapshots({ dateFrom: today, dateTo: today })
|
||||
const resolvedManualException = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM cost_exceptions
|
||||
WHERE reason = 'manual_cost_missing' AND status = 'resolved' AND subject_id = $1`,
|
||||
[pendingItem.orderItemId],
|
||||
)
|
||||
assert.equal(Number(resolvedManualException.rows[0]?.count), 1, '确认后异常必须自动收口')
|
||||
} finally {
|
||||
// 按依赖顺序清理,避免污染测试库。
|
||||
if (triggerName) {
|
||||
await query(`DROP TRIGGER IF EXISTS ${triggerName} ON cost_snapshots`)
|
||||
}
|
||||
if (fnName) {
|
||||
await query(`DROP FUNCTION IF EXISTS ${fnName}()`)
|
||||
}
|
||||
if (orderItemIds.length > 0) {
|
||||
await query(`DELETE FROM cost_exceptions WHERE subject_id = ANY($1::bigint[])`, [
|
||||
orderItemIds,
|
||||
])
|
||||
await query(`DELETE FROM cost_snapshots WHERE order_item_id = ANY($1::bigint[])`, [
|
||||
orderItemIds,
|
||||
])
|
||||
await query(`DELETE FROM manual_expenses WHERE order_item_id = ANY($1::bigint[])`, [
|
||||
orderItemIds,
|
||||
])
|
||||
}
|
||||
if (fundAccountIds.length > 0) {
|
||||
await query(`DELETE FROM fund_entries WHERE account_id = ANY($1::bigint[])`, [
|
||||
fundAccountIds,
|
||||
])
|
||||
await query(`DELETE FROM fund_accounts WHERE id = ANY($1::bigint[])`, [fundAccountIds])
|
||||
}
|
||||
if (creditAccountIds.length > 0) {
|
||||
await query(`DELETE FROM platform_credit_entries WHERE account_id = ANY($1::bigint[])`, [
|
||||
creditAccountIds,
|
||||
])
|
||||
await query(`DELETE FROM platform_credit_accounts WHERE id = ANY($1::bigint[])`, [
|
||||
creditAccountIds,
|
||||
])
|
||||
}
|
||||
if (orderItemIds.length > 0) {
|
||||
await query(`DELETE FROM order_items WHERE id = ANY($1::bigint[])`, [orderItemIds])
|
||||
}
|
||||
if (orderIds.length > 0) {
|
||||
await query(`DELETE FROM orders WHERE id = ANY($1::bigint[])`, [orderIds])
|
||||
}
|
||||
if (configIds.length > 0) {
|
||||
await query(`DELETE FROM manual_fulfillment_configs WHERE id = ANY($1::bigint[])`, [
|
||||
configIds,
|
||||
])
|
||||
}
|
||||
if (adminIds.length > 0) {
|
||||
await query(`DELETE FROM admin_users WHERE id = ANY($1::bigint[])`, [adminIds])
|
||||
}
|
||||
await closeDb()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function createManualItem(
|
||||
query: (sql: string, params?: unknown[]) => Promise<{ rows: Array<Record<string, unknown>> }>,
|
||||
skuNick: string,
|
||||
) {
|
||||
const order = await query(
|
||||
`INSERT INTO orders (
|
||||
provider, platform, platform_order_id, pay_status, total_amount, paid_at,
|
||||
created_at, updated_at
|
||||
) VALUES ('91kaquan', 'kuaishou-91', $1, 'paid', 50, NOW(), NOW(), NOW())
|
||||
RETURNING id`,
|
||||
[`IT-MF-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`],
|
||||
)
|
||||
const orderId = Number(order.rows[0]?.id)
|
||||
const item = await query(
|
||||
`INSERT INTO order_items (
|
||||
order_id, sku_code, sku_name, fulfillment_mode, spec_json,
|
||||
revenue_amount_fen, revenue_basis, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, 'manual', $4::jsonb, 5000, 'order_total', NOW(), NOW())
|
||||
RETURNING id`,
|
||||
[orderId, skuNick, `集成测试SKU-${skuNick}`, JSON.stringify({ skuNick })],
|
||||
)
|
||||
return { orderId, orderItemId: Number(item.rows[0]?.id) }
|
||||
}
|
||||
|
||||
function shanghaiDate(date = new Date()) {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
@@ -152,7 +152,7 @@ function normalizeCostFinanceSyncJob(rawValue: JsonObject) {
|
||||
enabled: rawValue.enabled !== false,
|
||||
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 3600),
|
||||
config: {
|
||||
days: normalizeRangeInteger(config.days, 7, 1, 90),
|
||||
days: normalizeRangeInteger(config.days, 30, 1, 90),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -227,7 +227,7 @@ function createDefaultCostFinanceSyncJob() {
|
||||
enabled: true,
|
||||
intervalSeconds: 300,
|
||||
config: {
|
||||
days: 7,
|
||||
days: 30,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
syncEstimatedOrderItemCostSnapshots,
|
||||
} from '../cost-center/cost-snapshot-sync-service.js'
|
||||
import { normalizeManualProducts } from '../cost-center/manual-product-normalization-service.js'
|
||||
import { rematchManualFulfillments } from '../cost-center/manual-fulfillment-config-service.js'
|
||||
import { rebuildSalesDailyFacts } from '../finance/sales-facts-service.js'
|
||||
import { reconcilePendingFulfillmentModes } from '../fulfillment/fulfillment-mode-service.js'
|
||||
|
||||
@@ -26,6 +27,13 @@ export async function runCostFinanceSyncJob(job: JsonObject = {}) {
|
||||
dateTo: today(),
|
||||
resolveExceptions: false,
|
||||
})
|
||||
// 人工 SKU 配置可能在订单之后才补:每次同步都按配置回填未匹配订单项,
|
||||
// 保证「配置好人工之后的新单」自动挂上渠道,不依赖管理员手动点「重新匹配」。
|
||||
const manualMatches = await rematchManualFulfillments({
|
||||
onlyUnmatched: true,
|
||||
dateFrom: shiftDays(-(days - 1)),
|
||||
dateTo: today(),
|
||||
})
|
||||
|
||||
const estimated = await syncEstimatedOrderItemCostSnapshots({
|
||||
dateFrom: shiftDays(-(days - 1)),
|
||||
@@ -45,6 +53,7 @@ export async function runCostFinanceSyncJob(job: JsonObject = {}) {
|
||||
message:
|
||||
`履约方式 ${fulfillment.updatedCount} 项 / ` +
|
||||
`人工商品 ${manualProducts.updatedItemCount} 项 / ` +
|
||||
`人工渠道匹配 ${manualMatches.updatedCount} 项 / ` +
|
||||
`成本快照 ${estimated.createdCount} 商品预估 / ` +
|
||||
`${actual.directRewardCount + actual.upgradedCount} 工单实际 / ` +
|
||||
`${actual.estimatedRewardCount} 工单在途,销售事实 ${facts.insertedCount} 行`,
|
||||
@@ -53,6 +62,7 @@ export async function runCostFinanceSyncJob(job: JsonObject = {}) {
|
||||
fulfillmentModeRemaining: fulfillment.remainingCount,
|
||||
manualItemsNormalized: manualProducts.updatedItemCount,
|
||||
manualProductsCreated: manualProducts.createdProductCount,
|
||||
manualItemsMatched: manualMatches.updatedCount,
|
||||
estimatedCreated: estimated.createdCount,
|
||||
actualCreated: actual.directRewardCount + actual.upgradedCount,
|
||||
estimatedRewardCreated: actual.estimatedRewardCount,
|
||||
|
||||
@@ -54,6 +54,7 @@ const FULFILLMENT_MODE_LABELS: Record<string, string> = {
|
||||
const REASON_LABELS: Record<string, string> = {
|
||||
product_mapping_missing: '商品未归一',
|
||||
cost_missing: '成本缺失',
|
||||
manual_cost_missing: '人工成本未记录',
|
||||
}
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
|
||||
+11
-8
@@ -1,7 +1,7 @@
|
||||
# order_site 文档索引
|
||||
|
||||
> 最后校准:2026-10-24,代码基线 `dev @ 790de676 + 本地未提交修复`(**未推送、未部署生产**)。
|
||||
> 数据基线:生产 dump `backups/postgres/order_site-20261022-092601.dump`(数据 2026-07-10 ~ 2026-10-22);本地已按真实升级路径从生产迁移 101 升到 107,并完成商品、成本快照、销售事实初始化。
|
||||
> 最后校准:2026-10-26,代码基线 `dev`(合并 `dev1023` + 人工成本完整性修复,迁移到 `111`;**未推送、未部署生产**)。
|
||||
> 数据基线:生产 dump `backups/postgres/order_site-20261022-092601.dump`(数据 2026-07-10 ~ 2026-10-22);本地生产副本已升级到 111,只跑了近 30 天增量。历史全量数字仅作基线,**上线验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准**。
|
||||
> 本文是 `docs/` 的唯一入口。**财务与履约口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;数据模型看 [成本中心与履约模型.md](成本中心与履约模型.md),接口看 [成本中心API契约.md](成本中心API契约.md)。
|
||||
> 新增文档必须在下表登记状态;过期文档不再直接改写结论,统一移入 `docs/archive/`。
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
| 名称 | 业务含义 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| `manual_expenses` | **客服备用金支出明细**:客服从自己的备用金里充值/采购/赔偿,减少客服余额 | 已实施(迁移 100);确认履约的现金支出也写这里 |
|
||||
| `cost_snapshots` | **人工成本的唯一落点**:`subject_type='manual_case'`,`subject_id=manual_expenses.id` | 已实施(迁移 104) |
|
||||
| `cost_snapshots` | **人工成本的唯一落点**:`subject_type='manual_case'`,`subject_id=order_item_id`(资金来源写 metadata) | 已实施(迁移 104 + 111) |
|
||||
|
||||
**没有独立的登记单,也没有审批与报销环节。** `manual_expense_records` / `manual_expense_events` 已随迁移 `104` 归并下线(这两张表在生产 102 上从未创建)。后续做财务系统时,人工成本的钱与账都从这条链路走,不要再另造一张人工支出表。
|
||||
|
||||
@@ -81,14 +81,16 @@
|
||||
| 商品主数据 + 渠道 SKU 映射 | **已实施 P0/P1** | 2,017 个标准商品、4,897 条映射:`tpl:` 710、`man:` 1,195、`aff:` 49、`wo:` 63;订单项已按履约证据/人工内部 SKU 归一 |
|
||||
| 成本中心 | **已实施 P0** | 迁移 `104`;710 个规则级版本 + 2 个全局占位版本;工单成本快照 151,939 条,其中 actual 129,430、estimated 22,509 |
|
||||
| 销售事实 / 订单毛利 | **已实施 P0** | 迁移 `105`;375,447 项、收入 ¥3,143.61 万、成本 ¥572.95 万、毛利 ¥2,570.66 万;`sales_daily_facts.fulfillment_mode` |
|
||||
| 人工成本录入(确认履约) | **已实施 P0(2026-10-24 入口收敛到订单页)** | 人工履约页已下线;确认即扣积分/备用金并写 `actual` 快照;无登记单/审批/报销 |
|
||||
| 人工成本录入(确认履约) | **已实施 P0(2026-10-26 单事务 + 防重复)** | 确认即扣积分/备用金并写 `actual` 快照;钱与成本同事务、订单项级唯一;无登记单/审批/报销 |
|
||||
| 人工 SKU 配置自动匹配 | **已实施(2026-10-26)** | 保存配置立即回填 + 定时 rematch + 确认时按 SKU 兜底匹配 |
|
||||
| 人工缺成本监控 | **已实施(2026-10-26)** | `manual_cost_missing`:首次人工配置之后的订单项未确认即入队,确认后自动收口 |
|
||||
| 履约方式统一 | **已实施 P0** | 迁移 `105`;375,447 项全部回填:接单平台 153,148、人工 115,194、历史平台 53,901、自动发货 53,204 |
|
||||
| 人工商品归一 | **已实施 P1(本次快照恢复执行)** | 人工 115,194 项已归一为 1,195 个 `man:<内部SKU>` 商品;历史人工支出仍不补录 |
|
||||
| 影子总账 | 未实施 | 现有财务页仍是 `UNION ALL` 业务事件拼装,不是复式总账 |
|
||||
| 成本异常归档 | **已实施** | 迁移 `108`;已处理异常超 60 天搬入 `cost_exceptions_archive`,open 行不归档;`COST_EXCEPTION_RETENTION_DAYS` 可调 |
|
||||
| 退款冲减 / 平台对账 / 期间关账 | 未实施 | 仅方案 |
|
||||
|
||||
> 分支状态(2026-10-25):`dev @ 465a9f65` 之上完成迁移收敛(10 → 5,`103`–`107`),并已与 `main` 的 worker-hall 改动合并。本地库按「清 `schema_migrations` 103–112 后重跑」对齐,升级结果与全新构建的 schema 完全一致;生产 101 → 107 的路径已验证。**未推送、未部署生产**;生产口径仍以 `main` 为准。
|
||||
> 分支状态(2026-10-26):`dev` 已合并 `dev1023` 与人工成本完整性修复,迁移到 `111`;本地生产副本已验证 101 → 111(109/110 索引在生产规模数据上实测 < 0.3s),临时库全量执行与重跑幂等通过。**未推送、未部署生产**;生产口径仍以 `main` 为准。上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。
|
||||
> 财务与履约口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准。
|
||||
|
||||
## 三、统一实施顺序
|
||||
@@ -98,8 +100,8 @@
|
||||
1. 商品主数据 + 渠道 SKU 映射 已完成 P0(仅自动发货/接单平台)
|
||||
2. 成本中心 + 销售事实 已完成 P0
|
||||
2.5 人工成本链路收敛(确认履约 → 扣积分/备用金 → 写快照) 已完成 P0(迁移 104/107)
|
||||
3. 人工商品归一与上线后人工成本全覆盖 未实施(下一阶段)
|
||||
4. 退款冲减与异常队列 未实施
|
||||
3. 人工商品归一与上线后人工成本全覆盖 已完成代码;生产数据需上线后配置 SKU(历史不补录)
|
||||
4. 退款冲减与异常队列 已完成手工退款;平台退款 API 未实施;人工缺成本已入队
|
||||
5. 影子总账(订单、工单、押金、提现、寄卖、售后) 未实施
|
||||
6. 平台对账、报表、期间关账 未实施
|
||||
```
|
||||
@@ -126,9 +128,10 @@
|
||||
| 文档 | 状态 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) | **现行唯一口径** | 履约方式、成本模型、人工全覆盖、历史策略、实施顺序、运营日检与验收 SQL |
|
||||
| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`107`;履约回填;迁移基线与本地库同步;索引与性能 |
|
||||
| [成本中心与履约模型.md](成本中心与履约模型.md) | 现行 | 商品、成本版本、快照、异常、销售事实的数据模型;迁移 `103`–`111`;履约回填;迁移基线与本地库同步;索引与性能 |
|
||||
| [成本中心API契约.md](成本中心API契约.md) | 现行 | 成本中心接口字段替换总表、枚举、模块边界(后端 5 模块 + 前端 4 个固定 Tab) |
|
||||
| [人工支出与备用金.md](人工支出与备用金.md) | 现行(P0 已实施) | 确认履约流程/权限、客服备用金台账、本人账户与编号规则 |
|
||||
| [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) | **现行上线手册** | 迁移后资金/人工 SKU 初始化、验收 SQL、日常监控 |
|
||||
| [财务系统集成方案.md](财务系统集成方案.md) | 现行方案(总账未实施) | 复式记账、总账表、对账、关账 |
|
||||
| [影子总账说明.md](影子总账说明.md) | 现行说明(未实施) | 影子总账是什么、和钱包流水的区别、对账与转正标准 |
|
||||
| [项目全量分析报告-2026-10-17.md](项目全量分析报告-2026-10-17.md) | 历史参考 | 2026-10-17 架构与风险;财务口径以总纲为准 |
|
||||
|
||||
+55
-59
@@ -1,7 +1,7 @@
|
||||
# 人工支出与客服备用金
|
||||
|
||||
> 状态:**现行(P0 已实施,未部署生产)**。
|
||||
> 最后校准:2026-10-24
|
||||
> 状态:**现行(P0 已实施,待部署生产)**。
|
||||
> 最后校准:2026-10-26(人工确认改单事务、subject_id 归一、配置自动匹配、人工缺成本入队列)
|
||||
> **2026-10-24 变更**:独立「人工履约」页面已下线,订单列表/详情里的「人工录单」改为「确认履约」;`/finance/manual-order-cost*` 接口已删除。资金链路不变(扣积分/备用金 → 写 `manual_case` 快照)。
|
||||
> 代码基线:`dev @ 43f402cf + 迁移 100/101/104/107`(已合并到 `dev`,**未推送、未部署生产**)
|
||||
> **业务口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文只负责人工录单的字段、流程、权限与备用金台账。
|
||||
@@ -16,13 +16,13 @@
|
||||
```text
|
||||
订单列表/详情「确认履约」
|
||||
→ 扣客服积分或备用金余额(写 fund_entries / platform_credit_entries)
|
||||
→ 写 cost_snapshots(subject_type='manual_case',subject_id=manual_expenses.id)
|
||||
→ 写 cost_snapshots(subject_type='manual_case',subject_id=order_item_id)
|
||||
```
|
||||
|
||||
| 表 | 业务含义 | 状态 | 关键字段 |
|
||||
| --- | --- | --- | --- |
|
||||
| `manual_expenses` | **客服备用金支出**:客服从自己的备用金账户里充值/采购/赔偿 | 已实施(迁移 `100`) | `account_id` / `fund_entry_id` / `category` / `payee_*` / `order_item_id` / `evidence_json` |
|
||||
| `cost_snapshots` | **人工成本的唯一落点** | 已实施(迁移 `104`) | `subject_type='manual_case'` / `subject_id=manual_expenses.id` / `total_amount_fen` / `snapshot_stage='actual'` |
|
||||
| `cost_snapshots` | **人工成本的唯一落点** | 已实施(迁移 `104` + 完整性兜底 `111`) | `subject_type='manual_case'` / `subject_id=order_item_id` / `total_amount_fen` / `snapshot_stage='actual'`;资金来源写 `metadata_json.fundExpenseId / creditEntryId` |
|
||||
|
||||
结论:
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
1. **人工成本逐单、按实际金额。** 不按小时、不按固定单量、不按固定单价。
|
||||
2. **人工支出从客服备用金账户扣款。** 余额不足直接拒绝,不允许负余额。
|
||||
3. **客服角色只能使用自己的备用金账户。** 后端越权直接 403;管理员/财务可代录到指定客服账户。
|
||||
4. **历史人工支出找不回来,直接放弃。** 不补录、不做样本校准;历史缺口标记 `historical_missing`,不阻塞上线。
|
||||
4. **历史人工支出找不回来,直接放弃。** 不补录、不做样本校准;历史缺口只计入 `cost_missing_count`,不阻塞上线。
|
||||
5. **上线后每一笔人工单必须 100% 有成本记录**,否则不允许关账。
|
||||
6. **录入必须"发生在垫付当天"才有监管价值**;事后补记只能算账,不能算监管。
|
||||
7. **客服备用金是周转金台账,不是报销系统。** P0 只做"账户 + 流水 + 支出 + 对账",不做复杂审批和总账。
|
||||
@@ -89,75 +89,71 @@
|
||||
|
||||
批量发放/对账、导出、低余额提醒、大额支出主管确认;支出凭证目前只校验数组非空,未校验对象存储 key 是否真实存在。
|
||||
|
||||
> 原「异常队列」指人工支出登记单的审批异常队列,随登记链下线而取消。现在的人工异常只有一类:人工录单匹配不到有效成本版本时写 `manual_expense_cost_missing`,由成本中心的异常面板统一处理。
|
||||
> 原「异常队列」指人工支出登记单的审批异常队列,随登记链下线而取消。现在的人工异常统一为 `manual_cost_missing`:**首次创建人工履约配置之后**的订单项,只要还没有人工成本快照就进入队列;确认履约写入快照后自动 `resolved`。历史人工单不进入该队列。
|
||||
|
||||
---
|
||||
|
||||
## 三、人工录单
|
||||
## 三、确认履约
|
||||
|
||||
### 3.1 落点
|
||||
|
||||
- 服务:`apps/backend/src/services/finance/manual-order-cost-service.ts`;备用金扣款唯一写入口 `admin-fund-service.ts` 的 `writeFundExpense`
|
||||
- 接口:`apps/backend/src/routes/admin/finance.ts` 的 `POST /finance/manual-order-costs`;成本项选项 `GET /finance/manual-order-cost-items`
|
||||
- 组件:`apps/frontend/src/components/admin/ManualOrderCostModal.tsx`
|
||||
- 订单入口:订单列表/详情「人工录单」打开弹窗,锁定订单号、选择订单项与备用金账户、填写本单实际金额;支持深链 `/admin/orders/:orderId?manualCost=1`
|
||||
- 权限:登记 `fund.expense` / `finance.config`;客服角色自动锁定本人账户
|
||||
- **已删除**:迁移 `106_manual_expense_records.sql` 建的登记单表(迁移 `104` 归并下线)、`manual-expense-service.ts`、`AdminManualExpensePage.tsx`、`/admin/manual-expenses` 页面与菜单
|
||||
- 服务:`apps/backend/src/services/finance/manual-fulfillment-service.ts`
|
||||
- 接口:`apps/backend/src/routes/admin/finance.ts` 的 `POST /finance/manual-fulfillment/confirm`;待办 `GET /finance/manual-fulfillment/pending`
|
||||
- 组件:`apps/frontend/src/components/admin/ManualFulfillmentConfirmModal.tsx`
|
||||
- 订单入口:订单列表/详情「确认履约」
|
||||
- 权限:`admin / operator / support`;客服只能使用本人备用金/积分账户
|
||||
- 配置前置:商品与成本 → 人工 SKU 配置(`manual_fulfillment_configs`)
|
||||
|
||||
### 3.2 资金流与成本流(同一事务)
|
||||
|
||||
```text
|
||||
锁定 fund_accounts 行
|
||||
→ 校验余额
|
||||
→ 扣减余额
|
||||
→ 写 fund_entries
|
||||
→ 写 manual_expenses(order_id / order_item_id 已带上)
|
||||
→ 解析成本版本
|
||||
→ 写 cost_snapshots(actual)
|
||||
BEGIN
|
||||
锁定 order_items 行(FOR UPDATE OF oi)
|
||||
→ 复查是否已有 manual_case 快照(有则 409,不扣款)
|
||||
→ 按 SKU 自动匹配人工履约配置(配置晚于下单也能挂上)
|
||||
→ 解析成本项/成本版本(版本缺失在扣款前失败)
|
||||
→ 扣积分(platform_credit_entries)或扣备用金(fund_entries + manual_expenses)
|
||||
→ 写 cost_snapshots(actual,subject_id = order_item_id)
|
||||
COMMIT -- 任一步失败整体回滚
|
||||
```
|
||||
|
||||
余额不足直接拒绝,**不允许负余额**。扣钱与记成本在同一个事务里,要么都成功要么都回滚。
|
||||
- 余额不足直接拒绝,**不允许负余额**;
|
||||
- 重复点击/并发请求:订单项行锁 + `uq_cost_snapshots_manual_order_item` 唯一索引双重兜底,只有一个请求能成功扣款;
|
||||
- 同一个订单项最多一条有效人工成本快照;不同订单项(包括现金与积分两条链路)不会因为 id 撞车互相覆盖。
|
||||
|
||||
### 3.3 成本快照
|
||||
|
||||
以 `subject_type='manual_case'`、`subject_id=manual_expenses.id` 写 `actual` 快照,金额 = 录入的实际金额。
|
||||
|
||||
同一订单项多笔支出可累加,不会互相覆盖(唯一键是 `subject_type + subject_id + cost_item_id`,每笔支出单一个主体)。快照 `metadata_json` 里记录处理人、收款方、付款方式、费用类型、凭证、履约方式。
|
||||
|
||||
没有匹配到有效成本版本时,写 `manual_expense_cost_missing` 异常(`status='open'`),**不允许静默记 0**;此时备用金已经扣了,界面会提示"已扣备用金,但未匹配到有效成本版本,已进入异常队列"。
|
||||
- `subject_type='manual_case'`、`subject_id=order_item_id`、`order_item_id` 同值;
|
||||
- 金额:固定成本 = `credit_quantity × 平台换算率`;成本不固定 = 客服填写的实际金额;
|
||||
- `metadata_json` 记录 `fundExpenseId / creditEntryId / subjectSource / platformKey / creditQuantity / costFen / paidBy` 等;
|
||||
- `uq_cost_snapshots_manual_order_item` 保证一个订单项只有一条有效快照(迁移 `111`)。
|
||||
|
||||
### 3.4 录入字段
|
||||
|
||||
```text
|
||||
order_id, order_item_id -- 订单与订单项(锁定订单号,不允许改)
|
||||
fund_account_id -- 备用金账户;客服只能本人
|
||||
amount_fen -- 实际金额(分),> 0
|
||||
expense_category -- goods_purchase / compensation / refund_advance /
|
||||
-- service_fee / freight / recharge / other
|
||||
payee_type, payee_name, payee_account
|
||||
payment_method -- wechat / alipay / bank / cash / platform_balance / other
|
||||
note -- 备注兼凭证(无附件时)
|
||||
evidenceJson -- 凭证(对象存储 key / 流水号 / 聊天记录 key)
|
||||
order_item_id -- 订单项(必填;弹窗内锁定)
|
||||
costFen -- 仅成本不固定的 SKU 必填,> 0(分)
|
||||
payeeName -- 收款方(选填,默认「人工履约」)
|
||||
note -- 备注(选填)
|
||||
```
|
||||
|
||||
费用类型到备用金分类的映射:`goods_purchase→purchase`、`compensation→compensation`、`freight→freight`、`recharge→platform_recharge`,其余 → `other`。
|
||||
平台、消耗积分与固定成本都来自人工 SKU 配置,客服不选账户、不改平台。
|
||||
|
||||
### 3.5 凭证
|
||||
### 3.5 配置与监控
|
||||
|
||||
凭证**至少留一项**可追溯信息(对象存储 key、流水号、聊天记录 key,或备注文字),否则后端拒绝。
|
||||
- 人工 SKU 的匹配键:`spec_json.skuNick → spec_json.outSkuSn → sku_code → sku_name`;没有前两级的历史单也能按后两级配置。
|
||||
- 保存配置后立即回填该 SKU 最近 30 天的订单项;定时任务每 5 分钟对最近窗口内未匹配项兜底;更早历史需显式「重新匹配」。
|
||||
- 未配置 SKU 的订单项进入 `manual_cost_missing`(`detail.configMissing=true`),配置后自动匹配;确认履约后异常自动收口。
|
||||
|
||||
---
|
||||
|
||||
## 四、订单页「确认履约」
|
||||
|
||||
订单里的数据足够预填 90%:店铺、商品、规格、数量、用户实付、支付时间、平台单号、履约标签、状态,后台订单列表/详情已经全部具备。客服实际只需要补 **4 件事**:渠道/项目、金额、收款方、凭证(外加默认带出的「谁、什么时候」)。
|
||||
|
||||
弹窗规则:
|
||||
|
||||
- 锁定订单号,不允许改;
|
||||
- 必须选择备用金账户(客服角色自动带入本人账户且不可改);
|
||||
- 金额按本单实际金额填写,**没有小时单价或固定单量**;
|
||||
- 录入即生效:扣备用金与写成本快照一次完成,不再有"提交审核"步骤。
|
||||
- 弹窗只展示已配置履约渠道的人工项;固定成本零输入,成本不固定才要求填金额;
|
||||
- 锁定订单与订单项,不允许改平台/账户;
|
||||
- 确认即扣钱并写成本,没有审核/报销步骤;
|
||||
- 若 SKU 还没配置,弹窗会提示先去「商品与成本 → 人工 SKU 配置」补配置;
|
||||
- 重复确认会被拒绝,不会重复扣款。
|
||||
|
||||
---
|
||||
|
||||
@@ -165,16 +161,16 @@ evidenceJson -- 凭证(对象存储 key / 流水号 / 聊天记
|
||||
|
||||
| 指标 | 目标 |
|
||||
| --- | ---: |
|
||||
| 人工订单项无备用金支出单 | 0 |
|
||||
| 人工订单项无成本快照 | 0 |
|
||||
| 人工支出金额与备用金流水不一致 | 0 |
|
||||
| 成本异常 open 中人工项 | 0(历史标记除外) |
|
||||
| 人工订单项无资金流水(现金备用金支出 / 积分消耗流水) | 0 |
|
||||
| 人工订单项无有效成本快照 | 0 |
|
||||
| 现金人工成本金额与 manual_expenses 不一致 | 0 |
|
||||
| `manual_cost_missing` open(上线后) | 0(历史人工单不计入) |
|
||||
|
||||
关账门槛:
|
||||
|
||||
- 存在人工单无成本记录:**不允许关账**;
|
||||
- 存在人工单未扣备用金:**不允许关账**;
|
||||
- 存在人工支出与备用金流水金额不一致:**不允许关账**。
|
||||
- 存在上线后人工单无成本记录:**不允许关账**;
|
||||
- 存在上线后人工单没有对应资金流水(现金或积分):**不允许关账**;
|
||||
- 存在现金人工支出与备用金流水金额不一致:**不允许关账**。
|
||||
|
||||
核对 SQL 见 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) §八。
|
||||
|
||||
@@ -182,11 +178,11 @@ evidenceJson -- 凭证(对象存储 key / 流水号 / 聊天记
|
||||
|
||||
## 六、异常处理
|
||||
|
||||
- 发现人工单无成本:当天补录;补录走人工录单,仍然从备用金扣款,不允许直接改余额。
|
||||
- 备用金余额不足:拒绝录入,由主管补拨后再录;不允许负余额。
|
||||
- 人工录单匹配不到成本版本:备用金已扣,成本异常进队列;补齐 `sys_manual_service` 的有效版本后重新解析。
|
||||
- 同一订单项多笔支出:允许累加(渠道费、加急费、补差价),但每笔都必须有独立备用金流水。
|
||||
- 历史人工单:标记 `historical_missing`,找不回来、**直接放弃**,不补录、不计入上线后缺口。
|
||||
- 发现人工单无成本:确认履约;扣款与写成本同事务,失败整体回滚,不会出现「扣了钱没记成本」。
|
||||
- 备用金/积分不足:拒绝确认,由主管补拨/充值后再确认;不允许负余额。
|
||||
- 人工 SKU 未配置:进入 `manual_cost_missing` 队列,配置后自动回填订单项,再确认即可。
|
||||
- 确认后需要更正:当前不提供反审核;只能通过后续冲销/调整处理(P1),不要直接改库。
|
||||
- 历史人工单:找不回来、**直接放弃**,不补录、不计入上线后监控缺口。
|
||||
|
||||
---
|
||||
|
||||
@@ -196,7 +192,7 @@ evidenceJson -- 凭证(对象存储 key / 流水号 / 聊天记
|
||||
|
||||
- 不补录、不回填 `cost_snapshots`;
|
||||
- 不做样本抽取与均价估算;
|
||||
- 不阻塞上线,历史缺口在销售事实里标记 `historical_missing`;
|
||||
- 不阻塞上线,历史缺口计入销售事实的 `cost_missing_count`;
|
||||
- 只保证**上线后**的新单不丢:人工单必须有备用金支出与成本快照。
|
||||
|
||||
历史平台 53,901 项同理,只有拿到旧平台结算数据才允许按历史成本入账。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 多发货平台与电子凭证整体链路
|
||||
|
||||
> 状态:现行。最后校准:2026-10-23(代码基线 `dev @ 5d1ba115 + 迁移 `103`–`107`;履约方式已统一落库)。
|
||||
> 状态:现行。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`111`;履约方式已统一落库)。
|
||||
> 财务与履约总口径见 [财务与履约口径总纲-2026-10-23.md](../财务与履约口径总纲-2026-10-23.md)。
|
||||
> 来源口径:本系统只有三个订单来源——`kuaishou-91`、`pdd`、`douyin-mashida`。**91 回调与快手 send-code 是同一个 `kuaishou-91` 来源的两条数据通道**(91=骨架权威源,快手 send-code=补全/兜底源),不是一个来源加另一个来源。
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:**现行说明(未实施)**
|
||||
> 最后校准:2026-10-23
|
||||
> 代码基线:`dev @ 43f402cf + 迁移 `103`–`107`(已合并到 `dev`,**未推送、未部署生产**)
|
||||
> 代码基线:`dev`(合并 dev1023 后)+ 迁移 `103`–`111`(已合并到 `dev`,**未推送、未部署生产**)
|
||||
> 配套方案:[财务系统集成方案.md](财务系统集成方案.md)
|
||||
> **总口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准。**
|
||||
> 前置:统一履约事实、商品主数据、渠道映射、成本中心与销售事实;实施顺序以总纲第六节为准。
|
||||
|
||||
+6
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:**现行**。后端服务与前端页面都必须严格按此实现,不得各自发挥。
|
||||
> 最后校准:2026-10-25
|
||||
> 代码基线:`dev @ 465a9f65` + 迁移 `103`–`107`(已合并到 `dev`,**未推送、未部署生产**)
|
||||
> 代码基线:`dev`(合并 dev1023 后)+ 迁移 `103`–`111`(已合并到 `dev`,**未推送、未部署生产**)
|
||||
> 详细业务口径见 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md);数据模型见 [成本中心与履约模型.md](成本中心与履约模型.md)。
|
||||
|
||||
## 一、字段替换总表
|
||||
@@ -204,6 +204,11 @@ AND (
|
||||
}
|
||||
```
|
||||
|
||||
`reason` 取值:`product_mapping_missing`(商品未归一)、`cost_missing`(非人工项缺成本版本)、
|
||||
`manual_cost_missing`(人工项尚未确认履约/未配置渠道)。`manual_cost_missing` 只监控
|
||||
「第一次创建人工履约配置之后」的订单项;确认履约写入 `manual_case` 快照后自动 `resolved`。
|
||||
人工成本快照的 `subjectId` / `subject_id` 统一为 `order_item_id`。
|
||||
|
||||
## 七、销售事实接口
|
||||
|
||||
`GET /api/v1/admin/sales-profit` 的分组维度字段名从 `carrierType` 改为 `fulfillmentMode`:
|
||||
|
||||
+26
-14
@@ -1,9 +1,10 @@
|
||||
# 成本中心与履约模型
|
||||
|
||||
> 状态:**现行**。商品主数据、成本中心、成本快照、销售事实、履约事实均已落地。
|
||||
> 最后校准:2026-10-25
|
||||
> 代码基线:`dev @ 465a9f65 + 迁移合并`(**未推送、未部署生产**)
|
||||
> 数据基线:生产 dump `order_site-20261022-092601.dump`(2026-07-10 ~ 2026-10-22);本地已升级到迁移 107,并完成商品、映射、成本快照与销售事实初始化
|
||||
> 最后校准:2026-10-26
|
||||
> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移到 `111`)
|
||||
> 数据基线:生产 dump `order_site-20261022-092601.dump`(2026-07-10 ~ 2026-10-22);本地只跑了近 30 天增量,历史数字仅作基线。
|
||||
> 上线初始化/验收见 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md)。
|
||||
> **业务口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文只负责数据模型、迁移与运维。
|
||||
> 接口字段见 [成本中心API契约.md](成本中心API契约.md)。
|
||||
|
||||
@@ -36,9 +37,9 @@ orders ──┬── order_items ──── fulfillment_mode (唯一履约
|
||||
|
||||
原 `dev` 分支上,商品、成本中心、人工履约用 10 个迁移(`103`–`112`,1,275 行)分批落地,字段反复摇摆。其中:
|
||||
|
||||
- `108`(下线人工支出登记)与 `109`(隔离历史快照主体)在「生产 102 → 当前」的唯一升级路径上是**空操作**;
|
||||
- `111` 是给 `104` 自身种子打的补丁(draft 占位改 active 占位);
|
||||
- `112` 的 `resolution_note` 列本就属于异常表。
|
||||
- 原 `108`(下线人工支出登记)与原 `109`(隔离历史快照主体)在「生产 102 → 当前」的唯一升级路径上是**空操作**;
|
||||
- 原 `111` 是给 `104` 自身种子打的补丁(draft 占位改 active 占位);
|
||||
- 原 `112` 的 `resolution_note` 列本就属于异常表。
|
||||
|
||||
2026-10-25 收敛为 5 个(合计 1,151 行):
|
||||
|
||||
@@ -50,9 +51,18 @@ orders ──┬── order_items ──── fulfillment_mode (唯一履约
|
||||
| `106_order_refunds.sql` | `order_refunds` 退款表 | 38 |
|
||||
| `107_manual_fulfillment.sql` | 人工 SKU 登记/配置 + 三平台积分钱包 + 备用金账户编号可读化 | 292 |
|
||||
|
||||
后续增量(不参与「10 合 5」的收敛):
|
||||
|
||||
| 迁移 | 内容 |
|
||||
| --- | --- |
|
||||
| `108_cost_exception_archive.sql` | 成本异常 60 天归档 |
|
||||
| `109_work_order_share_participation.sql` | 拼单多次参与份额模型(与本模块无关,同批上线) |
|
||||
| `110_work_order_hall_and_shop_indexes.sql` | 大厅置顶/店铺筛选索引(与本模块无关) |
|
||||
| `111_manual_cost_integrity.sql` | 人工成本单事务兜底:订单项级唯一、`subject_id=order_item_id`、人工 SKU 匹配索引、`manual_cost_missing` 监控所需索引 |
|
||||
|
||||
**合并原则**:同一批上线的结构性 DDL 才合并;数据回填与业务口径注释跟着所属表走。`103` 保持独立(商品主数据是成本与销售事实的共同上游),`106` 保持独立(退款是独立业务线)。
|
||||
|
||||
**关于历史人工快照的负数 `subject_id`(重要坑,已固化在 `104` 的列注释里)**:人工成本快照的主体从「人工支出登记单 id」改成「客服备用金支出单 id」(`manual_expenses.id`),两套 id 空间各自从 1 开始会撞车。撞上唯一索引 `uq_cost_snapshots_active_subject_item` 后,`INSERT ... ON CONFLICT DO NOTHING` 会静默跳过——**钱从备用金扣了、成本快照却没写成**。历史孤儿快照的主体一律用负数,与正数的 `manual_expenses.id` 彻底隔离;新建链路不再产生负数主体。
|
||||
**人工成本快照主体(2026-10-26 定稿)**:`manual_case` 的 `subject_id` 统一为 `order_item_id`,不再使用 `manual_expenses.id` / `platform_credit_entries.id`(两张表各自 BIGSERIAL,必然撞 id)。资金来源只放 `metadata_json.fundExpenseId / creditEntryId`。迁移 `111` 增加订单项级唯一索引 `uq_cost_snapshots_manual_order_item`,一个订单项最多一条有效人工快照;`subject_id` 为正数的历史行归一到订单项,负数历史孤儿快照按迁移 `104` 约定保留、不参与新链路。
|
||||
|
||||
### 1.1 字段替换对照
|
||||
|
||||
@@ -256,8 +266,9 @@ resolved_at, superseded_at, created_at
|
||||
- 预估快照代表支付时点的成本,**不因后续改价重写**;只有实际成本允许替代当前有效行;
|
||||
- 匹配不到成本时写 `cost_missing` 异常,**不能默认 0**;
|
||||
- 履约方式不存快照里,需要时 join `order_items.fulfillment_mode`;
|
||||
- 人工单的快照主体是 `subject_type='manual_case'` + `subject_id=manual_expenses.id`(客服备用金支出单),**没有独立的登记单表**;
|
||||
- 负数 `subject_id` 表示历史孤儿快照(约定固化在迁移 `104` 的列注释里),金额保留供毛利参考,不与新链路争主体 id。
|
||||
- 人工单的快照主体是 `subject_type='manual_case'` + `subject_id=order_item_id`;资金流水 id 只写 `metadata_json`,**没有独立的登记单表**;
|
||||
- 负数 `subject_id` 表示历史孤儿快照(迁移 `104` 约定),迁移 `111` 保留不动,不参与新链路;
|
||||
- 一个订单项最多一条有效人工快照(`uq_cost_snapshots_manual_order_item`)。
|
||||
|
||||
### 3.5 `cost_exceptions`
|
||||
|
||||
@@ -265,7 +276,7 @@ resolved_at, superseded_at, created_at
|
||||
id
|
||||
subject_type, subject_id
|
||||
order_item_id, work_order_id
|
||||
reason -- product_mapping_missing / cost_missing / manual_expense_cost_missing
|
||||
reason -- product_mapping_missing / cost_missing / manual_cost_missing
|
||||
status -- open / resolved / ignored
|
||||
detail_json
|
||||
created_at, resolved_at, resolved_by
|
||||
@@ -386,7 +397,8 @@ WHERE oi.id = d.id
|
||||
AND oi.fulfillment_mode IS NULL;
|
||||
```
|
||||
|
||||
本地验收(2026-10-23 重写后重跑):`work_order` 118,053 / `manual` 102,955 / `legacy_platform` 53,901 / `affiliate_dash` 41,676,四项相加 = 316,585,`NULL = 0`。
|
||||
> 上述计数是 2026-10-23 历史回填的结果,仅作迁移正确性参照;当前本地生产副本只跑了近 30 天增量,**不要用这些数字判断上线是否完成**。
|
||||
> 本地验收(2026-10-23 重写后重跑):`work_order` 118,053 / `manual` 102,955 / `legacy_platform` 53,901 / `affiliate_dash` 41,676,四项相加 = 316,585,`NULL = 0`。
|
||||
|
||||
### 6.3 决策边界(写进代码注释与测试)
|
||||
|
||||
@@ -419,11 +431,11 @@ WHERE oi.id = d.id
|
||||
|
||||
生产尚未执行过 `103` 及之后的迁移。本地库与代码对齐有两条路径:
|
||||
|
||||
1. **全新升级**:从生产 dump 恢复到 `101` / `102`,直接 `npm run db:migrate` 升到 `107`;
|
||||
1. **全新升级**:从生产 dump 恢复到 `101` / `102`,直接 `npm run db:migrate` 升到 `111`;
|
||||
2. **已有本地库**:清掉 `schema_migrations` 里 `103`–`112` 的记录后重跑。所有迁移都是 `IF NOT EXISTS`,数据步骤幂等:
|
||||
|
||||
```sql
|
||||
DELETE FROM schema_migrations WHERE name ~ '^(10[3-9]|11[0-2])_';
|
||||
DELETE FROM schema_migrations WHERE name ~ '^(10[3-9]|11[0-1])_';
|
||||
```
|
||||
|
||||
2026-10-25 实测:按第 2 条路径升级后的库,与全新构建的库 **schema 完全一致**(`pg_dump --schema-only` 仅随机 token 不同),种子数据(3 个系统成本项、2 个全局占位版本、4 个人工履约档案)也一致。
|
||||
@@ -486,7 +498,7 @@ npm run revenue:backfill -- --batch-size=2000 --max-batches=100
|
||||
|
||||
归档不删除审计痕迹,但热表查询与页面上不会再出现这些历史行;需要追溯时查归档表。
|
||||
|
||||
> **2026-10-25 已执行一次性清理**:`--days=0` 口径归档 151,939 条 resolved 异常(152 批,3.9 秒)。
|
||||
> **2026-10-25 已执行一次性清理(历史库)**:`--days=0` 口径归档 151,939 条 resolved 异常(152 批,3.9 秒)。该数字只作历史记录。
|
||||
> 热表 260,253 → 108,314 行(全部为 open),`VACUUM FULL` 后体积 155 MB → 51 MB(heap 38 / idx 13),
|
||||
> 归档表 61 MB;主键重叠校验 0 条。生产环境不要在业务时段跑 `VACUUM FULL`,日常由 autovacuum 复用空间即可。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 接单模板匹配引擎重构设计
|
||||
|
||||
> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-23(代码基线 `dev @ 43f402cf + 迁移 `103`–`107`;匹配业务逻辑未变)。
|
||||
> 目标读者:后端 / 后台运营。状态:**P0 + P1 已实施;P2 / P3 待办**。最后校准:2026-10-23(代码基线 `dev`(合并 dev1023 后)+ 迁移 `103`–`111`;匹配业务逻辑未变)。
|
||||
>
|
||||
> 一句话:把「订单信息 → 接单模板(价格/拼单/要求)」的匹配,从散落的多个消费点、平台判断和"店铺必填"的绑定表里,收敛为**一个平台无关的引擎**:匹配条件完全由**订单信息**表达,**店铺只是可选条件之一**,新增来源只加一个适配器 + 一组配置。
|
||||
>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# 财务/成本上线初始化与验收(2026-10-26)
|
||||
|
||||
> 状态:现行操作手册。适用于 `dev` 合并后的财务/成本模块上线;**历史人工成本不补录**。
|
||||
> 代码基线:迁移 `103`–`111`(本手册写于人工成本完整性与配置自动匹配修复之后)。
|
||||
> 数据基线:生产 dump `2026-10-22`(迁移 `101`)。历史人工支出找不回来,只保证**首次配置人工履约之后**的新单可记录、可监控。
|
||||
|
||||
---
|
||||
|
||||
## 〇、上线范围与边界
|
||||
|
||||
本次上线的是「备用金 + 人工成本 + 成本中心 + 销售事实」模块,不是复式总账:
|
||||
|
||||
| 能力 | 本次是否上线 |
|
||||
| --- | --- |
|
||||
| 客服备用金账户/流水/支出/对账 | 是(数据需上线后初始化) |
|
||||
| 人工 SKU 配置 + 确认履约(扣积分/备用金 + 写 actual 快照) | 是(配置需上线后设置) |
|
||||
| 商品目录、渠道映射、成本版本、成本快照、销售事实 | 是 |
|
||||
| 手工退款登记(冲减收入、关闭成本异常) | 是 |
|
||||
| 平台退款 API、影子总账、对账中心、期间关账 | **否**(后续 P3) |
|
||||
|
||||
明确不做:
|
||||
- 历史人工支出不补录、不按均价估算;
|
||||
- 历史人工单的 `cost_missing` 不进入上线后监控口径;
|
||||
- 人工成本监控从**第一次创建人工履约配置的时间**开始计算(`manual_fulfillment_configs.created_at`)。
|
||||
|
||||
---
|
||||
|
||||
## 一、上线顺序(必须按序)
|
||||
|
||||
### 1. 备份 + 迁移
|
||||
|
||||
```bash
|
||||
# 生产机
|
||||
cd /home/order_site
|
||||
bash deploy/ubuntu-deploy.sh --backup --dry-run # 先看计划
|
||||
bash deploy/ubuntu-deploy.sh --backup # 迁移前 pg_dump + OSS,再重建/重启
|
||||
```
|
||||
|
||||
后端启动会自动执行 `102`–`111`。确认:
|
||||
|
||||
```sql
|
||||
SELECT name FROM schema_migrations ORDER BY id DESC LIMIT 5;
|
||||
-- 期望包含:111_manual_cost_integrity / 110_work_order_hall_and_shop_indexes / 109_work_order_share_participation
|
||||
```
|
||||
|
||||
> 迁移 `109`/`110`/`111` 都是增量、幂等;`111` 会给 `orders(375k)` 建表达式索引,实测写阻塞约 200–300ms。
|
||||
|
||||
### 2. 初始化客服资金(备用金 + 积分)
|
||||
|
||||
上线后由财务/主管在后台完成:
|
||||
|
||||
1. **备用金账户**:钱包管理 → 客服账户 → 「补建客服账户」(或调用 `POST /api/v1/admin/fund/accounts/ensure-agents`)。
|
||||
2. **拨付备用金**:钱包管理 → 对应账户 → 「发放」,把周转金拨给客服;余额不足时人工确认会直接拒绝。
|
||||
3. **积分充值**:商品与成本 → 积分钱包 → 选择客服/平台 → 「发放」(南航/乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元,以 `fulfillment_profiles.config_json.rechargeUnitPriceFen` 为准)。
|
||||
4. 要求:客服只能使用**本人**备用金账户和积分账户;管理员代录也使用自己的账户(当前实现如此)。
|
||||
|
||||
校验:
|
||||
|
||||
```sql
|
||||
-- 每个客服都应有 agent 账户;余额按实际拨付为准
|
||||
SELECT au.username, fa.account_no, fa.balance_fen, fa.status
|
||||
FROM admin_users au
|
||||
LEFT JOIN fund_accounts fa
|
||||
ON fa.owner_admin_user_id = au.id AND fa.account_type = 'agent'
|
||||
WHERE au.role = 'support'
|
||||
ORDER BY au.username;
|
||||
|
||||
-- 三平台积分账户余额
|
||||
SELECT pca.platform_key, au.username, pca.balance_quantity
|
||||
FROM platform_credit_accounts pca
|
||||
JOIN admin_users au ON au.id = pca.owner_admin_user_id
|
||||
ORDER BY pca.platform_key, au.username;
|
||||
```
|
||||
|
||||
### 3. 扫描并配置人工 SKU
|
||||
|
||||
入口:商品与成本 → 人工 SKU 配置。
|
||||
|
||||
1. 选择日期范围(建议上线前 30 天)→ 「扫描」;没有 `spec_json.skuNick/outSkuSn` 的单会按 `sku_code → sku_name` 兜底,不再漏扫。
|
||||
2. 逐项配置:平台(南航/乐玩/ROCK/备用金现金)+ 消耗积分(0 = 成本不固定,客服确认时手填)+ 启用。
|
||||
3. **保存后会自动重新匹配该 SKU 最近 30 天的订单项**,不需要再点「重新匹配」;定时任务每 5 分钟会对最近窗口内未匹配人工项做一次兜底回填;更早历史不自动回填。
|
||||
4. 未配置的 SKU 会进入成本异常队列(`manual_cost_missing`,`detail.configMissing=true`),按队列把配置补齐即可。
|
||||
|
||||
批量检查缺口:
|
||||
|
||||
```sql
|
||||
-- 已配置 / 未配置的人工 SKU(按首次配置之后的订单项)
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE oi.fulfillment_config_id IS NULL) AS unconfigured_items,
|
||||
COUNT(*) AS manual_items
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_mode = 'manual'
|
||||
AND oi.created_at >= (SELECT MIN(created_at) FROM manual_fulfillment_configs);
|
||||
|
||||
-- 需要补配置的 SKU(按订单量排序)
|
||||
SELECT
|
||||
COALESCE(NULLIF(oi.spec_json->>'skuNick',''), NULLIF(oi.spec_json->>'outSkuSn',''),
|
||||
NULLIF(oi.sku_code,''), NULLIF(oi.sku_name,'')) AS sku_key,
|
||||
MAX(oi.sku_name) AS sku_name,
|
||||
COUNT(*) AS items
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_mode = 'manual'
|
||||
AND oi.fulfillment_config_id IS NULL
|
||||
AND oi.created_at >= (SELECT MIN(created_at) FROM manual_fulfillment_configs)
|
||||
GROUP BY 1
|
||||
ORDER BY items DESC;
|
||||
```
|
||||
|
||||
> 配置是全量生效的:保存后历史未匹配项也会被回填(只填空值),但历史成本仍不会补录。
|
||||
|
||||
### 4. 确认人工履约(日常)
|
||||
|
||||
订单列表/详情 → 「确认履约」:
|
||||
|
||||
- 固定成本 SKU:零输入,直接扣对应积分/备用金;
|
||||
- 成本不固定 SKU:填写本次实际成本(分);
|
||||
- 扣款、写 `manual_expenses`/积分流水、写 `manual_case` 成本快照在**同一个事务**里;
|
||||
- 重复点击/并发请求只能成功一次(订单项行锁 + 唯一索引兜底),失败不会重复扣款;
|
||||
- 成功后可重复确认会被拒绝。
|
||||
|
||||
监控:
|
||||
|
||||
```sql
|
||||
-- 上线后人工项:已配置但未确认(客服待办)
|
||||
SELECT COUNT(*) AS pending_confirmed
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_mode = 'manual'
|
||||
AND oi.fulfillment_config_id IS NOT NULL
|
||||
AND oi.created_at >= :launch_at
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cost_snapshots cs
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.order_item_id = oi.id
|
||||
AND cs.superseded_at IS NULL
|
||||
);
|
||||
|
||||
-- 人工成本缺失异常(队列)
|
||||
SELECT status, COUNT(*) FROM cost_exceptions
|
||||
WHERE reason = 'manual_cost_missing'
|
||||
GROUP BY status;
|
||||
```
|
||||
|
||||
`manual_cost_missing` 只在「首次配置之后」生成;确认履约写入快照后自动置为 `resolved`。
|
||||
|
||||
### 5. 历史回填(可选,不阻塞上线)
|
||||
|
||||
本次上线不要求补历史人工成本;如需历史商品归一/成本快照/销售事实用于报表,另跑:
|
||||
|
||||
```bash
|
||||
npm run revenue:backfill -- --dry-run # 应为 unknown=0
|
||||
npm run fulfillment:backfill # 只填 NULL
|
||||
npm run finance:sync -- --mode=all --from=2026-07-10 --to=2026-10-22
|
||||
```
|
||||
|
||||
长区间按 30 天窗口分段执行(默认 `DATABASE_STATEMENT_TIMEOUT_MS=15000`)。
|
||||
注意:人工历史成本按既定口径**不补录**,历史人工毛利仍不可用。
|
||||
|
||||
---
|
||||
|
||||
## 二、上线验收 SQL(替换 `:launch_at` 为实际上线时间)
|
||||
|
||||
```sql
|
||||
-- 1. 履约方式覆盖:四项相加 = order_items 总数,NULL = 0
|
||||
SELECT COALESCE(fulfillment_mode, '(null)') AS mode, COUNT(*)
|
||||
FROM order_items GROUP BY 1 ORDER BY 2 DESC;
|
||||
|
||||
-- 2. 上线后人工项必须有履约配置(允许配置跟进期)
|
||||
SELECT COUNT(*) FROM order_items
|
||||
WHERE fulfillment_mode = 'manual'
|
||||
AND created_at >= :launch_at
|
||||
AND fulfillment_config_id IS NULL;
|
||||
|
||||
-- 3. 上线后人工项必须有成本快照(关账门槛)
|
||||
SELECT COUNT(*) FROM order_items oi
|
||||
WHERE oi.fulfillment_mode = 'manual'
|
||||
AND oi.created_at >= :launch_at
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM cost_snapshots cs
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.order_item_id = oi.id
|
||||
AND cs.superseded_at IS NULL
|
||||
);
|
||||
|
||||
-- 4. 人工成本快照金额 = 对应备用金支出(仅现金路径)
|
||||
SELECT cs.id, me.expense_no, me.amount_fen, cs.total_amount_fen
|
||||
FROM cost_snapshots cs
|
||||
JOIN manual_expenses me ON me.order_item_id = cs.order_item_id
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.superseded_at IS NULL
|
||||
AND cs.created_at >= :launch_at
|
||||
AND cs.total_amount_fen <> me.amount_fen;
|
||||
|
||||
-- 5. 一个订单项最多一条有效人工快照(应为 0)
|
||||
SELECT order_item_id, COUNT(*)
|
||||
FROM cost_snapshots
|
||||
WHERE subject_type = 'manual_case' AND superseded_at IS NULL
|
||||
GROUP BY 1 HAVING COUNT(*) > 1;
|
||||
|
||||
-- 6. 销售事实与实时聚合对平(同一日期区间)
|
||||
-- 净收入 = Σ订单项收入 − 已确认退款;成本 = Σ有效快照
|
||||
WITH r AS (SELECT MIN(stat_date) a, MAX(stat_date) b FROM sales_daily_facts)
|
||||
SELECT
|
||||
(SELECT SUM(net_revenue_fen) FROM sales_daily_facts) AS facts_revenue,
|
||||
(SELECT SUM(cost_amount_fen) FROM sales_daily_facts) AS facts_cost;
|
||||
-- 与独立聚合对比,允许 0 差异。
|
||||
|
||||
-- 7. 人工成本异常:上线后应能被确认动作收口
|
||||
SELECT reason, status, COUNT(*) FROM cost_exceptions
|
||||
WHERE created_at >= :launch_at
|
||||
GROUP BY 1,2 ORDER BY 3 DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、常见问题
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| 确认时报「还没有配置履约渠道」 | SKU 未配置,或配置的 `sku_key` 与订单项不匹配 | 在人工 SKU 配置里按兜底键配置;保存后自动回填 |
|
||||
| 确认时报「当前账号还没有备用金账户」 | 该客服未补建账户 | 钱包管理 → 补建客服账户 |
|
||||
| 确认时报「积分不足」 | 未充值/充值到别的客服 | 积分钱包 → 给本人账户发放 |
|
||||
| 确认成功但列表仍有「待确认」 | 前端缓存 | 刷新;后端以快照为准 |
|
||||
| `manual_cost_missing` 不减少 | 未确认履约,或确认失败回滚 | 看订单详情确认;异常只能由快照自动收口 |
|
||||
|
||||
---
|
||||
|
||||
## 四、已知边界(不要对外承诺)
|
||||
|
||||
1. 没有复式总账、平台对账、期间关账;现有「收支明细」是业务事件拼装,不是会计账。
|
||||
2. 平台退款 API 未接入,退款只支持后台手工登记(整单/指定商品,可撤销)。
|
||||
3. 平台服务费/支付通道费/寄卖货款成本确认时点仍待定;相关成本项配置为空时不参与毛利。
|
||||
4. 历史人工成本、历史平台成本、历史未归一商品不补齐;历史区间毛利只能作为参考。
|
||||
5. 管理员/财务代录目前仍使用**本人**备用金/积分账户,不能指定客服账户。
|
||||
@@ -1,9 +1,10 @@
|
||||
# 财务与履约口径总纲(2026-10-23 重写版)
|
||||
|
||||
> 状态:**现行唯一口径**。本文是财务、成本、履约分类的总入口;其他文档与本文件冲突时,以本文为准。
|
||||
> 最后校准:2026-10-24
|
||||
> 代码基线:`dev @ 790de676 + 本地未提交修复`(**未推送、未部署生产**)
|
||||
> 数据基线:生产 dump `order_site-20261022-092601.dump`(2026-07-10 ~ 2026-10-22);本地已升级到迁移 107,并完成履约方式、商品、成本快照与销售事实初始化
|
||||
> 最后校准:2026-10-26
|
||||
> 代码基线:`dev`(合并 dev1023 + 人工成本完整性修复,迁移到 `111`)
|
||||
> 数据基线:§二/§七 的数字来自 2026-10-22 生产 dump 的**历史快照**,只用于理解历史缺口,**不再作为上线验收依据**;
|
||||
> 上线初始化与验收以 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 为准。
|
||||
> 适用范围:订单接入、履约判定、商品归一、成本快照、销售事实、人工录单、备用金、后续总账与对账。
|
||||
|
||||
---
|
||||
@@ -41,7 +42,9 @@
|
||||
|
||||
---
|
||||
|
||||
## 二、当前真实履约构成(本地库,2026-07-10 ~ 2026-10-22)
|
||||
## 二、历史履约构成(2026-10-22 dump,仅作历史基线)
|
||||
|
||||
> 本节数字是 2026-10-22 的历史快照。当前库只跑了近 30 天增量,**不要拿本节数字做上线验收**;上线验收用新手册的 SQL。
|
||||
|
||||
| 履约方式 | 订单项 | 收入 | 当前成本情况 |
|
||||
| --- | ---: | ---: | --- |
|
||||
@@ -131,8 +134,8 @@
|
||||
## 五、历史数据处理
|
||||
|
||||
- 历史人工支出**找不回来,直接放弃**:不补录、不做样本回填、不做均价估算;
|
||||
- 历史人工单在销售事实中保留收入,成本标记为 `historical_missing`,不计入"上线后监控缺口";
|
||||
- 历史平台 `legacy_platform` 单独成行,成本未采集的标记 `legacy_missing`;
|
||||
- 历史人工单在销售事实里保留收入,成本缺口计入 `cost_missing_count`;**不做特殊 stage、不补录**,不计入「上线后监控缺口」;
|
||||
- 历史平台 `legacy_platform` 单独成行,成本缺口同样计入 `cost_missing_count`;`cost_stage` 由快照覆盖情况决定(actual / estimated / partial / missing);
|
||||
- 自动发货、接单平台历史成本不回写、不重算版本,沿用现有快照。
|
||||
|
||||
---
|
||||
@@ -165,8 +168,11 @@ P0 的具体迁移、回填、代码清单与验收 SQL 见 [成本中心与履
|
||||
| `order_items.fulfillment_mode` 持久化 | **已实施** | 迁移 `105` + 统一决策服务 |
|
||||
| 订单页 / 成本快照 / 销售事实统一读字段 | **已实施** | `admin-order-read-helpers`、`sales-facts-service`、`cost-snapshot-sync-service` |
|
||||
| 本地 316,585 项回填 | **已实施** | `fulfillment:backfill`,四项计数与 §二 一致 |
|
||||
| 成本模型与履约方式正交(去 `carrier_type`) | **已实施(本地库已改造)** | 迁移 `103`–`107` 重写;`cost_items.cost_model` |
|
||||
| 10 个迁移收敛为 5 个 | **已实施** | `103_product_catalog` ~ `107_manual_fulfillment` |
|
||||
| 成本模型与履约方式正交(去 `carrier_type`) | **已实施(本地库已改造)** | 迁移 `103`–`108`;`cost_items.cost_model` |
|
||||
| 10 个迁移收敛为 5 个 | **已实施** | `103_product_catalog` ~ `107_manual_fulfillment`(后续新增 `108`–`111` 增量) |
|
||||
| 人工确认原子性/防重复 | **已实施(2026-10-26)** | 单事务 + 订单项行锁 + `uq_cost_snapshots_manual_order_item`(迁移 `111`);集成测试覆盖并发/回滚 |
|
||||
| 人工 SKU 配置自动匹配 | **已实施(2026-10-26)** | 保存配置立即回填 + 定时任务 rematch + 确认时按 SKU 兜底匹配 |
|
||||
| 人工缺成本监控 | **已实施(2026-10-26)** | `manual_cost_missing`,从首次人工配置时间起算,确认后自动收口 |
|
||||
| 人工成本链路收敛为「人工录单 → 扣备用金 → 写快照」 | **已实施(本地库已改造)** | 迁移 `104`;登记单与审批报销已下线,口径固化在成本中心迁移 |
|
||||
| 人工商品归一 `man:<skuId>` | **已实施(本地库已归一)** | `manual-product-normalization-service` + `POST /cost-center/manual-products/normalize`;1,116 个 `man:` 商品、91,606 个人工订单项归一,人工未归一 = 0 |
|
||||
| 人工商品归一后的销售事实 | **已实施** | 94 天分 30 天窗口重算:26,507 行;人工 ¥1,219.20 万收入全部按 `man:` 商品归集 |
|
||||
@@ -175,7 +181,7 @@ P0 的具体迁移、回填、代码清单与验收 SQL 见 [成本中心与履
|
||||
| 影子总账、对账、关账 | **未实施** | §六 P3 |
|
||||
|
||||
> 历史平台仍有 53,901 个订单项(¥389.12 万)未归一,不属于 P1 范围;人工历史成本按既定口径不补录,因此人工毛利当前是“收入已归商品、成本仅新单有”的状态。
|
||||
> 分支事实:以上实现已快进合并到 `dev`;迁移 `103`–`107` **未推送远端、未部署生产**。生产口径仍以 `main` 为准。
|
||||
> 分支事实:以上实现已合并到 `dev`;迁移 `103`–`111` **未推送远端、未部署生产**。生产口径仍以 `main` 为准;上线前按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 初始化资金与人工 SKU。
|
||||
|
||||
---
|
||||
|
||||
@@ -209,20 +215,25 @@ WHERE oi.fulfillment_mode = 'manual'
|
||||
WHERE cs.order_item_id = oi.id AND cs.superseded_at IS NULL
|
||||
);
|
||||
|
||||
-- 4. 人工订单项没有备用金支出单:上线后应为 0
|
||||
-- 4. 人工订单项没有资金流水(现金 manual_expenses 或积分 creditEntryId):上线后应为 0
|
||||
SELECT COUNT(*)
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_mode = 'manual'
|
||||
AND oi.created_at >= DATE '2026-11-01'
|
||||
AND NOT EXISTS (SELECT 1 FROM manual_expenses me WHERE me.order_item_id = oi.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM manual_expenses me
|
||||
WHERE me.order_item_id = oi.id
|
||||
SELECT 1 FROM cost_snapshots cs
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.order_item_id = oi.id
|
||||
AND cs.superseded_at IS NULL
|
||||
AND cs.metadata_json ? 'creditEntryId'
|
||||
);
|
||||
|
||||
-- 5. 人工成本快照金额与备用金支出金额不一致:应为 0
|
||||
-- 5. 现金人工成本快照金额与备用金支出金额不一致:应为 0
|
||||
-- subject_id 已统一为 order_item_id,资金来源按 order_item_id 关联。
|
||||
SELECT cs.id, me.expense_no, me.amount_fen, cs.total_amount_fen
|
||||
FROM cost_snapshots cs
|
||||
JOIN manual_expenses me ON me.id = cs.subject_id
|
||||
JOIN manual_expenses me ON me.order_item_id = cs.order_item_id
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.superseded_at IS NULL
|
||||
AND cs.total_amount_fen <> me.amount_fen
|
||||
@@ -256,7 +267,12 @@ WHERE oi.fulfillment_mode = 'manual'
|
||||
AND ft.executor_key IN ('kuaishou_ct_assisted', 'kuaishou_feifei')
|
||||
);
|
||||
|
||||
-- 9. 人工项误挂接单模板商品:P1 完成后应为 0
|
||||
-- 9. 人工成本缺失异常:上线后应随确认履约自动收口
|
||||
SELECT status, COUNT(*) FROM cost_exceptions
|
||||
WHERE reason = 'manual_cost_missing'
|
||||
GROUP BY status;
|
||||
|
||||
-- 10. 人工项误挂接单模板商品:P1 完成后应为 0
|
||||
SELECT COUNT(*)
|
||||
FROM order_items oi
|
||||
JOIN product_catalog pc ON pc.id = oi.canonical_product_id
|
||||
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
# 财务系统集成方案
|
||||
|
||||
> 状态:**现行方案(总账层未实施)**。本文只描述 `finance_*` 总账层;当前总账表尚未建立,现有财务页仍是 `UNION ALL` 业务事件拼装。
|
||||
> 最后校准:2026-10-23
|
||||
> 代码基线:`dev @ 43f402cf + 迁移 `103`–`107`(已合并到 `dev`,**未推送、未部署生产**)
|
||||
> 最后校准:2026-10-26
|
||||
> 代码基线:`dev`(迁移到 `111`,**未推送、未部署生产**)
|
||||
> 说明:本文是后续 P3 总账/对账/关账方案,**尚未实施**;本次上线范围(备用金、人工成本、成本中心、销售事实)与初始化步骤见 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md)。
|
||||
> **履约方式、成本模型、人工全覆盖口径以 [财务与履约口径总纲-2026-10-23.md](财务与履约口径总纲-2026-10-23.md) 为准**;本文不重复分类设计。
|
||||
> 目标:在**不破坏现有抢单、押金、结算、寄卖、提现链路**的前提下,增加可对账、可关账、可追溯的复式记账底座。
|
||||
> 结论先说:**不要直接在 `worker_wallets` / `worker_wallet_ledgers` 上继续加字段。** 现有表是业务钱包与流水,不是会计总账;正确做法是新增复式记账底座,让现有钱包成为它的“余额投影”。
|
||||
|
||||
@@ -219,6 +219,21 @@ docker compose ps
|
||||
docker compose logs -f backend
|
||||
```
|
||||
|
||||
## 财务/成本上线后的初始化(迁移 102–111)
|
||||
|
||||
代码与迁移只负责建表;**备用金账户、积分余额、人工 SKU 配置不会自动创建**。
|
||||
首次上线后按 [财务上线初始化与验收-2026-10-26.md](财务上线初始化与验收-2026-10-26.md) 执行:
|
||||
|
||||
1. 钱包管理 → 补建客服备用金账户 → 按需拨付;
|
||||
2. 商品与成本 → 积分钱包 → 给客服发放三平台积分;
|
||||
3. 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置(保存后自动回填订单项);
|
||||
4. 用手册 §二 的验收 SQL 检查:配置覆盖、人工成本快照、`manual_cost_missing`、销售事实对平。
|
||||
|
||||
> 历史人工成本不补录;人工成本监控从第一次创建人工履约配置的时间开始。
|
||||
> 定时任务 `cost-finance-sync` 每 5 分钟运行,负责履约归集、人工 SKU 回填、成本快照与销售事实。
|
||||
|
||||
---
|
||||
|
||||
## 服务器日常更新
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user