重构:硬切履约状态唯一真源
This commit is contained in:
@@ -93,7 +93,7 @@ async function loadRecentDuplicateAttempts(): Promise<CountRow[]> {
|
||||
return result.rows
|
||||
}
|
||||
|
||||
async function loadShadowModelDiffs(): Promise<CountRow[]> {
|
||||
async function loadTruthIntegrityChecks(): Promise<CountRow[]> {
|
||||
const result = await query<{
|
||||
missing_fulfillments: number
|
||||
fulfillment_state_diffs: number
|
||||
@@ -116,7 +116,7 @@ async function loadShadowModelDiffs(): Promise<CountRow[]> {
|
||||
fulfillment.issue_category
|
||||
) IS DISTINCT FROM ROW(
|
||||
CASE WHEN state.route_status = 'configured' THEN 'configured' ELSE 'unrouted' END,
|
||||
state.intended_channel,
|
||||
state.stored_channel,
|
||||
state.carrier_status,
|
||||
state.current_attempt_type,
|
||||
NULLIF(state.current_attempt_id, 0),
|
||||
@@ -162,8 +162,6 @@ async function loadShadowModelDiffs(): Promise<CountRow[]> {
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM fulfillment_state_events event
|
||||
JOIN v_fulfillment_item_state active_state
|
||||
ON active_state.order_item_id = event.order_item_id
|
||||
WHERE event.fulfillment_id IS NULL
|
||||
) AS unlinked_events
|
||||
FROM v_fulfillment_item_state state
|
||||
@@ -173,20 +171,20 @@ async function loadShadowModelDiffs(): Promise<CountRow[]> {
|
||||
)
|
||||
const row = result.rows[0]
|
||||
return [
|
||||
{ label: '影子履约缺失', count: Number(row?.missing_fulfillments || 0) },
|
||||
{ label: '影子履约状态差异', count: Number(row?.fulfillment_state_diffs || 0) },
|
||||
{ label: '影子执行尝试缺失', count: Number(row?.missing_attempts || 0) },
|
||||
{ label: '影子开放异常缺失', count: Number(row?.missing_open_issues || 0) },
|
||||
{ label: '履约事件未关联影子记录', count: Number(row?.unlinked_events || 0) },
|
||||
{ label: '正式履约缺失', count: Number(row?.missing_fulfillments || 0) },
|
||||
{ label: '正式履约状态差异', count: Number(row?.fulfillment_state_diffs || 0) },
|
||||
{ label: '执行尝试缺失', count: Number(row?.missing_attempts || 0) },
|
||||
{ label: '开放异常缺失', count: Number(row?.missing_open_issues || 0) },
|
||||
{ label: '履约事件未关联正式记录', count: Number(row?.unlinked_events || 0) },
|
||||
]
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [counts, issues, duplicates, shadowDiffs] = await Promise.all([
|
||||
const [counts, issues, duplicates, integrityChecks] = await Promise.all([
|
||||
loadCounts(),
|
||||
loadIssueBreakdown(),
|
||||
loadRecentDuplicateAttempts(),
|
||||
loadShadowModelDiffs(),
|
||||
loadTruthIntegrityChecks(),
|
||||
])
|
||||
|
||||
console.info('[fulfillment-audit] 总览')
|
||||
@@ -208,20 +206,23 @@ async function main(): Promise<void> {
|
||||
console.info(` ${row.label}: ${row.count}`)
|
||||
}
|
||||
|
||||
console.info('[fulfillment-audit] 影子模型差异')
|
||||
for (const row of shadowDiffs) {
|
||||
console.info('[fulfillment-audit] 正式履约完整性')
|
||||
for (const row of integrityChecks) {
|
||||
console.info(` ${row.label}: ${row.count}`)
|
||||
}
|
||||
|
||||
const critical = new Map(counts.map((row) => [row.label, Number(row.count || 0)]))
|
||||
const duplicateCount = Number(duplicates[0]?.count || 0)
|
||||
const shadowDiffCount = shadowDiffs.reduce((sum, row) => sum + Number(row.count || 0), 0)
|
||||
const integrityDifferenceCount = integrityChecks.reduce(
|
||||
(sum, row) => sum + Number(row.count || 0),
|
||||
0,
|
||||
)
|
||||
const hasCritical =
|
||||
(critical.get('状态不一致') || 0) > 0 ||
|
||||
(critical.get('售后关闭仍进入履约队列') || 0) > 0 ||
|
||||
(critical.get('已分配但无有效载体') || 0) > 0 ||
|
||||
duplicateCount > 0 ||
|
||||
shadowDiffCount > 0
|
||||
integrityDifferenceCount > 0
|
||||
|
||||
if (strict && hasCritical) {
|
||||
process.exitCode = 1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
-- 164_fulfillment_event_cascade.sql
|
||||
--
|
||||
-- 履约事件已经由 fulfillment_id 直接归属正式履约记录。
|
||||
-- 订单项删除时事件应随履约记录级联清理,不能先 SET NULL 再触发 append-only 更新保护。
|
||||
|
||||
ALTER TABLE fulfillment_state_events
|
||||
DROP CONSTRAINT IF EXISTS fk_fulfillment_state_events_fulfillment;
|
||||
|
||||
ALTER TABLE fulfillment_state_events
|
||||
ADD CONSTRAINT fk_fulfillment_state_events_fulfillment
|
||||
FOREIGN KEY (fulfillment_id)
|
||||
REFERENCES fulfillments(id)
|
||||
ON DELETE CASCADE;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 165_fulfillment_event_append_only_update.sql
|
||||
--
|
||||
-- 事件语义是“应用不可改”,父记录删除时允许数据库级联清理。
|
||||
-- 旧环境里曾存在 BEFORE DELETE OR UPDATE 的漂移定义,这里统一收回为只阻止 UPDATE。
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_fulfillment_state_events_append_only
|
||||
ON fulfillment_state_events;
|
||||
|
||||
CREATE TRIGGER trg_fulfillment_state_events_append_only
|
||||
BEFORE UPDATE ON fulfillment_state_events
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reject_fulfillment_state_event_mutation();
|
||||
|
||||
COMMENT ON TRIGGER trg_fulfillment_state_events_append_only
|
||||
ON fulfillment_state_events IS
|
||||
'禁止应用修改履约事件;数据库外键级联删除不受影响';
|
||||
@@ -0,0 +1,59 @@
|
||||
-- 166_fulfillment_insert_resolved_sku.sql
|
||||
--
|
||||
-- resolve_internal_sku 可能返回 NULL。正式履约行的 internal_sku 是不带 NULL 的稳定键,
|
||||
-- 无法解析时统一落空字符串,并立即进入 missing_internal_sku 异常口径。
|
||||
|
||||
CREATE OR REPLACE FUNCTION ensure_fulfillment_for_order_item()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO fulfillments (
|
||||
order_item_id,
|
||||
order_id,
|
||||
internal_sku,
|
||||
route_status,
|
||||
channel,
|
||||
carrier_status,
|
||||
current_attempt_type,
|
||||
current_attempt_source_id,
|
||||
current_attempt_status,
|
||||
lock_reason,
|
||||
issue_code,
|
||||
issue_category,
|
||||
retryable,
|
||||
owner_role,
|
||||
created_at,
|
||||
updated_at,
|
||||
status,
|
||||
source,
|
||||
locked,
|
||||
channel_switch_reason
|
||||
) VALUES (
|
||||
NEW.id,
|
||||
NEW.order_id,
|
||||
COALESCE(resolve_internal_sku(NEW.manual_sku_key, NEW.sku_name, NEW.sku_code), ''),
|
||||
'unrouted',
|
||||
NULL,
|
||||
'missing',
|
||||
'',
|
||||
NULL,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
FALSE,
|
||||
'',
|
||||
NEW.created_at,
|
||||
NOW(),
|
||||
'pending',
|
||||
'',
|
||||
FALSE,
|
||||
''
|
||||
)
|
||||
ON CONFLICT (order_item_id) DO NOTHING;
|
||||
|
||||
PERFORM refresh_fulfillment_derived(NEW.id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
@@ -355,10 +355,10 @@ export async function listKuaishouIndustryVouchersForAdmin(
|
||||
COALESCE(ft.task_no, '') AS task_no
|
||||
FROM kuaishou_industry_vouchers v
|
||||
LEFT JOIN fulfillment_tasks ft ON ft.id = v.task_id
|
||||
LEFT JOIN order_items oi_task ON oi_task.id = ft.order_item_id
|
||||
LEFT JOIN v_order_item_current oi_task ON oi_task.id = ft.order_item_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT oi.sku_code, oi.sku_name
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE v.order_id IS NOT NULL
|
||||
AND oi.order_id = v.order_id
|
||||
ORDER BY oi.id ASC
|
||||
|
||||
@@ -186,7 +186,7 @@ test('已分配订单重推写库时不会用平台新 SKU 覆盖冻结值', asy
|
||||
let selectCount = 0
|
||||
|
||||
const executor = async (sql: string, params: unknown[] = []) => {
|
||||
if (sql.includes('SELECT *') && sql.includes('FROM order_items')) {
|
||||
if (sql.includes('SELECT *') && sql.includes('FROM v_order_item_current')) {
|
||||
selectCount += 1
|
||||
return { rows: selectCount === 1 ? [existing] : [] }
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function listOrderItemsByOrderIds(orderIds: number[]): Promise<Orde
|
||||
const result = await query<OrderItemRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM order_items
|
||||
FROM v_order_item_current
|
||||
WHERE order_id = ANY($1::bigint[])
|
||||
ORDER BY order_id ASC, id ASC
|
||||
`,
|
||||
@@ -316,7 +316,7 @@ async function listDeletableOrderItemIdsWithExecutor(
|
||||
const result = await executor(
|
||||
`
|
||||
SELECT oi.id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
LEFT JOIN fulfillment_tasks ft ON ft.order_item_id = oi.id
|
||||
WHERE oi.id = ANY($1::bigint[])
|
||||
GROUP BY oi.id
|
||||
@@ -336,7 +336,7 @@ async function listOrderItemsByOrderIdWithExecutor(
|
||||
const result = await executor(
|
||||
`
|
||||
SELECT *
|
||||
FROM order_items
|
||||
FROM v_order_item_current
|
||||
WHERE order_id = $1
|
||||
ORDER BY id ASC
|
||||
`,
|
||||
@@ -347,9 +347,10 @@ async function listOrderItemsByOrderIdWithExecutor(
|
||||
}
|
||||
|
||||
export async function getOrderItemById(orderItemId: number | string): Promise<OrderItemRow | null> {
|
||||
const result = await query<OrderItemRow>('SELECT * FROM order_items WHERE id = $1 LIMIT 1', [
|
||||
Number(orderItemId),
|
||||
])
|
||||
const result = await query<OrderItemRow>(
|
||||
'SELECT * FROM v_order_item_current WHERE id = $1 LIMIT 1',
|
||||
[Number(orderItemId)],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
@@ -358,9 +359,17 @@ export async function updateOrderItemSourceSnapshot(
|
||||
input: { skuName: string; itemSnapshotJson: string; updatedAt: string; specJson?: string },
|
||||
): Promise<OrderItemRow | null> {
|
||||
return withTransaction(async (client) => {
|
||||
const id = Number(orderItemId)
|
||||
const orderItemLock = await client.query<{ id: number }>(
|
||||
`SELECT id FROM order_items WHERE id = $1 FOR UPDATE`,
|
||||
[id],
|
||||
)
|
||||
if (!orderItemLock.rows[0]) return null
|
||||
await client.query(`SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE`, [id])
|
||||
|
||||
const selected = await client.query<OrderItemRow>(
|
||||
`SELECT * FROM order_items WHERE id = $1 FOR UPDATE`,
|
||||
[Number(orderItemId)],
|
||||
`SELECT * FROM v_order_item_current WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
const existing = selected.rows[0]
|
||||
if (!existing) return null
|
||||
|
||||
@@ -107,8 +107,8 @@ test('订单列表按当前履约模式筛选,不再依赖任务执行器', as
|
||||
channel: 'manual',
|
||||
})
|
||||
|
||||
assert.match(calls[0]?.sql || '', /oi\.fulfillment_channel = \$1/)
|
||||
assert.match(calls[0]?.sql || '', /oi\.fulfillment_channel IS DISTINCT FROM \$1/)
|
||||
assert.match(calls[0]?.sql || '', /fulfillment\.channel = \$1/)
|
||||
assert.match(calls[0]?.sql || '', /fulfillment\.channel IS DISTINCT FROM \$1/)
|
||||
assert.doesNotMatch(calls[0]?.sql || '', /COUNT\(DISTINCT/)
|
||||
assert.doesNotMatch(calls[0]?.sql || '', /ft\.executor_key/)
|
||||
assert.deepEqual(calls[0]?.params, ['manual'])
|
||||
@@ -122,7 +122,7 @@ test('订单列表待判定筛选不扫描任务执行器', async () => {
|
||||
})
|
||||
|
||||
assert.match(calls[0]?.sql || '', /EXISTS/)
|
||||
assert.match(calls[0]?.sql || '', /oi\.fulfillment_status = 'pending'/)
|
||||
assert.match(calls[0]?.sql || '', /fulfillment\.status = 'pending'/)
|
||||
assert.doesNotMatch(calls[0]?.sql || '', /COUNT\(DISTINCT/)
|
||||
assert.deepEqual(calls[0]?.params, [])
|
||||
})
|
||||
@@ -134,7 +134,7 @@ test('订单列表历史平台筛选映射为 archived', async () => {
|
||||
channel: 'legacy_platform',
|
||||
})
|
||||
|
||||
assert.match(calls[0]?.sql || '', /oi\.fulfillment_status = 'archived'/)
|
||||
assert.match(calls[0]?.sql || '', /fulfillment\.status = 'archived'/)
|
||||
assert.deepEqual(calls[0]?.params, [])
|
||||
})
|
||||
|
||||
@@ -145,7 +145,7 @@ test('订单列表混合履约筛选按订单项聚合判定', async () => {
|
||||
channel: 'mixed',
|
||||
})
|
||||
|
||||
assert.match(calls[0]?.sql || '', /HAVING COUNT\(DISTINCT oi\.fulfillment_channel\) > 1/)
|
||||
assert.match(calls[0]?.sql || '', /HAVING COUNT\(DISTINCT fulfillment\.channel\) > 1/)
|
||||
assert.deepEqual(calls[0]?.params, [])
|
||||
})
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ export async function listOrdersWithExecutor(
|
||||
) ILIKE ${keywordParam}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.order_id = o.id
|
||||
AND (
|
||||
(
|
||||
@@ -339,36 +339,36 @@ export async function listOrdersWithExecutor(
|
||||
const normalizedMode = channel === 'legacy_platform' ? 'archived' : channel
|
||||
if (normalizedMode === 'pending') {
|
||||
filters.push(`EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
WHERE oi.order_id = o.id AND oi.fulfillment_status = 'pending'
|
||||
SELECT 1 FROM fulfillments fulfillment
|
||||
WHERE fulfillment.order_id = o.id AND fulfillment.status = 'pending'
|
||||
)`)
|
||||
} else if (normalizedMode === 'archived') {
|
||||
filters.push(`EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
WHERE oi.order_id = o.id AND oi.fulfillment_status = 'archived'
|
||||
SELECT 1 FROM fulfillments fulfillment
|
||||
WHERE fulfillment.order_id = o.id AND fulfillment.status = 'archived'
|
||||
)`)
|
||||
} else if (normalizedMode === 'mixed') {
|
||||
filters.push(`o.id IN (
|
||||
SELECT oi.order_id FROM order_items oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
GROUP BY oi.order_id
|
||||
HAVING COUNT(DISTINCT oi.fulfillment_channel) > 1
|
||||
SELECT fulfillment.order_id FROM fulfillments fulfillment
|
||||
WHERE fulfillment.status = 'assigned'
|
||||
GROUP BY fulfillment.order_id
|
||||
HAVING COUNT(DISTINCT fulfillment.channel) > 1
|
||||
)`)
|
||||
} else {
|
||||
params.push(normalizedMode)
|
||||
const modeParam = `$${params.length}`
|
||||
filters.push(`(
|
||||
EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
WHERE oi.order_id = o.id
|
||||
AND oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = ${modeParam}
|
||||
SELECT 1 FROM fulfillments fulfillment
|
||||
WHERE fulfillment.order_id = o.id
|
||||
AND fulfillment.status = 'assigned'
|
||||
AND fulfillment.channel = ${modeParam}
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
WHERE oi.order_id = o.id
|
||||
AND oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel IS DISTINCT FROM ${modeParam}
|
||||
SELECT 1 FROM fulfillments fulfillment
|
||||
WHERE fulfillment.order_id = o.id
|
||||
AND fulfillment.status = 'assigned'
|
||||
AND fulfillment.channel IS DISTINCT FROM ${modeParam}
|
||||
)
|
||||
)`)
|
||||
}
|
||||
@@ -377,7 +377,7 @@ export async function listOrdersWithExecutor(
|
||||
if (skuCode) {
|
||||
params.push(skuCode)
|
||||
filters.push(
|
||||
`EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.sku_code = $${params.length})`,
|
||||
`EXISTS (SELECT 1 FROM v_order_item_current oi WHERE oi.order_id = o.id AND oi.sku_code = $${params.length})`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -167,10 +167,10 @@ export async function listRedeemCertificatesForAdmin(
|
||||
ORDER BY ft.id ASC
|
||||
LIMIT 1
|
||||
) ft ON true
|
||||
LEFT JOIN order_items oi_task ON oi_task.id = ft.order_item_id
|
||||
LEFT JOIN v_order_item_current oi_task ON oi_task.id = ft.order_item_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT oi.sku_code, oi.sku_name
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE c.local_order_id IS NOT NULL
|
||||
AND oi.order_id = c.local_order_id
|
||||
ORDER BY oi.id ASC
|
||||
|
||||
@@ -96,7 +96,9 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
|
||||
return withTransaction(async (client) => {
|
||||
// 同一订单项单元的创建必须串行,否则并发调度会同时算出同一尝试号,
|
||||
// 再一起绕过“仅一个当前尝试”的唯一索引。
|
||||
await client.query(`SELECT id FROM order_items WHERE id = $1 FOR UPDATE`, [input.orderItemId])
|
||||
await client.query(`SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE`, [
|
||||
input.orderItemId,
|
||||
])
|
||||
const active = await client.query<{ id: number }>(
|
||||
`SELECT id
|
||||
FROM fulfillment_tasks
|
||||
@@ -483,7 +485,7 @@ export async function listTasks({
|
||||
const itemsParams = [...params, pageSize, offset]
|
||||
const itemsResult = await query<TaskRow>(
|
||||
`${buildTaskSelect('oi.sku_code, oi.sku_name, oi.quantity')}
|
||||
LEFT JOIN order_items oi ON oi.id = ft.order_item_id
|
||||
LEFT JOIN v_order_item_current oi ON oi.id = ft.order_item_id
|
||||
${whereClause}
|
||||
ORDER BY ft.id DESC
|
||||
LIMIT $${itemsParams.length - 1} OFFSET $${itemsParams.length}`,
|
||||
@@ -504,7 +506,7 @@ export async function listTasks({
|
||||
const needsJoinKcts = whereClause.includes('kcts.')
|
||||
const joinClauses: string[] = []
|
||||
if (needsJoinOi) {
|
||||
joinClauses.push('LEFT JOIN order_items oi ON oi.id = ft.order_item_id')
|
||||
joinClauses.push('LEFT JOIN v_order_item_current oi ON oi.id = ft.order_item_id')
|
||||
}
|
||||
if (needsJoinCtx) {
|
||||
joinClauses.push('LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id')
|
||||
|
||||
@@ -202,7 +202,7 @@ export function resolveVoucherSummary(vouchers: KuaishouIndustryVoucherRow[] = [
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单级履约摘要:只读 order_items.fulfillment_channel / fulfillment_status。
|
||||
* 订单级履约摘要:只读 fulfillments.channel / status 的订单项投影。
|
||||
* 多商品订单出现不同模式时显示 mixed;尚未判定的订单项单独计数。
|
||||
*/
|
||||
export function resolveOrderFulfillmentSummary({
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function cleanupDirtyAffiliateDashProducts() {
|
||||
oi.id AS order_item_id,
|
||||
clean.id AS clean_product_id,
|
||||
dirty.id AS expected_product_id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN product_catalog dirty ON dirty.id = oi.canonical_product_id
|
||||
JOIN fulfillment_tasks ft ON ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'
|
||||
JOIN product_catalog clean ON clean.canonical_sku = 'aff:' || COALESCE(
|
||||
@@ -58,7 +58,7 @@ export async function cleanupDirtyAffiliateDashProducts() {
|
||||
await query(`
|
||||
DELETE FROM product_catalog
|
||||
WHERE canonical_sku ~ '^aff:[^a-z0-9_]'
|
||||
AND NOT EXISTS (SELECT 1 FROM order_items WHERE canonical_product_id = product_catalog.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM fulfillments WHERE canonical_product_id = product_catalog.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM cost_versions WHERE canonical_product_id = product_catalog.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM cost_snapshots WHERE canonical_product_id = product_catalog.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM sales_daily_facts WHERE canonical_product_id = product_catalog.id)
|
||||
@@ -207,7 +207,7 @@ export async function backfillAdminAffiliateDashSkus() {
|
||||
FROM affiliate_item ai
|
||||
JOIN product_catalog p
|
||||
ON p.canonical_sku = 'aff:' || ai.sku_code
|
||||
JOIN order_items oi ON oi.id = ai.id
|
||||
JOIN v_order_item_current oi ON oi.id = ai.id
|
||||
)
|
||||
SELECT id, canonical_product_id, expected_product_id
|
||||
FROM target
|
||||
@@ -240,7 +240,7 @@ export async function backfillAdminAffiliateDashSkus() {
|
||||
'YYYY-MM-DD'
|
||||
)
|
||||
FROM affiliate_item
|
||||
JOIN order_items oi ON oi.id = affiliate_item.id
|
||||
JOIN v_order_item_current oi ON oi.id = affiliate_item.id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
) AS from_date,
|
||||
(
|
||||
@@ -249,7 +249,7 @@ export async function backfillAdminAffiliateDashSkus() {
|
||||
'YYYY-MM-DD'
|
||||
)
|
||||
FROM affiliate_item
|
||||
JOIN order_items oi ON oi.id = affiliate_item.id
|
||||
JOIN v_order_item_current oi ON oi.id = affiliate_item.id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
) AS to_date
|
||||
`,
|
||||
@@ -275,7 +275,7 @@ export async function backfillAdminAffiliateDashSkus() {
|
||||
const remainingResult = await query<{ total: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.canonical_product_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
|
||||
@@ -58,7 +58,7 @@ export const COST_MODEL_CODE_BASE: Record<string, string> = {
|
||||
other: 'other_cost',
|
||||
}
|
||||
|
||||
/** 履约渠道:唯一真源是 order_items.fulfillment_channel + fulfillment_status。 */
|
||||
/** 履约渠道:唯一真源是 fulfillments.channel + status。 */
|
||||
export type FulfillmentChannel =
|
||||
| 'work_order'
|
||||
| 'affiliate_dash'
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM cost_exceptions ce
|
||||
JOIN order_items oi ON oi.id = ce.order_item_id
|
||||
JOIN v_order_item_current oi ON oi.id = ce.order_item_id
|
||||
WHERE ce.status = 'open'
|
||||
AND (
|
||||
$1::text IS NULL
|
||||
@@ -89,7 +89,7 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {}
|
||||
) AS open_exception_count,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.canonical_product_id IS NULL
|
||||
-- 只统计已定渠道的单:待分类单还没有渠道,不能算「未映射」。
|
||||
AND oi.fulfillment_status = 'assigned'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 成本中心共享层入口。
|
||||
*
|
||||
* 口径(2026-10-23 重写):
|
||||
* 1. 履约渠道只有 order_items.fulfillment_channel/status 一个真源;
|
||||
* 1. 履约渠道只有 fulfillments.channel/status 一个真源;
|
||||
* 2. 成本项用 cost_model 表达「成本怎么算」,与履约方式正交;
|
||||
* 3. 商品来源由 canonical_sku 前缀推导,商品表不再存履约载体;
|
||||
* 4. 快照与异常表都不再存履约载体,join order_items 即可拿到。
|
||||
|
||||
@@ -332,7 +332,7 @@ test('成本中心总览异常统计必须走异常表关联,不能全表扫
|
||||
new URL('./cost-center-overview-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes('JOIN order_items oi ON oi.id = ce.order_item_id'))
|
||||
assert.ok(source.includes('JOIN v_order_item_current oi ON oi.id = ce.order_item_id'))
|
||||
assert.ok(!source.includes('ce.order_item_id IN ('))
|
||||
assert.ok(source.includes('productChannelFilterSql(1)'))
|
||||
})
|
||||
|
||||
@@ -343,7 +343,7 @@ export async function listAdminCostVersions(payload: JsonObject = {}) {
|
||||
ROUND(AVG(wo.reward_amount))::bigint AS reward_avg_fen,
|
||||
SUM(wo.reward_amount)::bigint AS reward_fen
|
||||
FROM work_orders wo
|
||||
JOIN order_items oi ON oi.id = wo.order_item_id
|
||||
JOIN v_order_item_current oi ON oi.id = wo.order_item_id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE o.pay_status IN ('paid', 'refunded')
|
||||
GROUP BY wo.product_rule_id
|
||||
@@ -583,7 +583,7 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) {
|
||||
params.push(channel)
|
||||
clauses.push(
|
||||
`ce.order_item_id IN (
|
||||
SELECT oi.id FROM order_items oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length}
|
||||
SELECT oi.id FROM v_order_item_current oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length}
|
||||
)`,
|
||||
)
|
||||
}
|
||||
@@ -600,7 +600,7 @@ export async function listAdminCostExceptions(payload: JsonObject = {}) {
|
||||
ce.*,
|
||||
COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS channel
|
||||
FROM cost_exceptions ce
|
||||
LEFT JOIN order_items oi ON oi.id = ce.order_item_id
|
||||
LEFT JOIN v_order_item_current oi ON oi.id = ce.order_item_id
|
||||
${where}
|
||||
ORDER BY ce.status ASC, ce.created_at DESC, ce.id DESC
|
||||
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
|
||||
@@ -642,7 +642,7 @@ export async function summarizeAdminCostExceptions(
|
||||
params.push(channel)
|
||||
clauses.push(
|
||||
`ce.order_item_id IN (
|
||||
SELECT oi.id FROM order_items oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length}
|
||||
SELECT oi.id FROM v_order_item_current oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${params.length}
|
||||
)`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ async function resolveManualCostHint(orderItemId: number | null) {
|
||||
cfg.credit_quantity,
|
||||
cfg.cost_mode,
|
||||
COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS unit_price_fen
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
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
|
||||
WHERE oi.id = $1
|
||||
@@ -317,7 +317,7 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
|
||||
wo.id AS work_order_id,
|
||||
wo.product_rule_id AS work_product_rule_id,
|
||||
COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS channel
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, product_rule_id
|
||||
@@ -394,7 +394,7 @@ async function resolveCostContext(payload: JsonObject): Promise<OrderItemCostCon
|
||||
}
|
||||
|
||||
/**
|
||||
* 履约渠道只读 order_items.fulfillment_channel / fulfillment_status。
|
||||
* 履约渠道只读 fulfillments.channel / status。
|
||||
* 订单项还没判定时按人工处理,不允许用 provider 或核销码兜底。
|
||||
*/
|
||||
function resolveFulfillmentMode(value: unknown): FulfillmentChannel {
|
||||
|
||||
@@ -317,7 +317,7 @@ const CANDIDATE_SQL = `
|
||||
wpr.pricing_mode AS work_product_rule_pricing_mode,
|
||||
wo.status AS work_order_status,
|
||||
wo.reward_amount
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
|
||||
|
||||
@@ -156,7 +156,7 @@ export async function scanManualSkus(options: ScanOptions) {
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date,
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
|
||||
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
@@ -273,7 +273,7 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
|
||||
if (provider) {
|
||||
params.push(provider)
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
SELECT 1 FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
|
||||
AND o.provider = $${params.length}
|
||||
@@ -314,7 +314,7 @@ export async function listManualSkus(input: Record<string, unknown> = {}) {
|
||||
AS first_seen_date,
|
||||
MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date)
|
||||
AS last_seen_date
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = 'manual'
|
||||
@@ -381,7 +381,7 @@ export async function getManualSkuOverview() {
|
||||
COUNT(*)::int AS item_count,
|
||||
MAX((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date)
|
||||
AS last_seen_date
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = 'manual'
|
||||
@@ -602,7 +602,7 @@ async function retryPendingManualItems(skuKey: string): Promise<number> {
|
||||
if (!key) return 0
|
||||
const candidates = await query<{ id: number }>(
|
||||
`SELECT oi.id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1
|
||||
ORDER BY oi.id
|
||||
@@ -688,7 +688,7 @@ export async function deleteManualSkuClassification(
|
||||
oi.id,
|
||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text
|
||||
AS basis_date
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = 'manual'
|
||||
@@ -867,7 +867,7 @@ export async function rematchManualFulfillments(input: Record<string, unknown> =
|
||||
oi.id AS order_item_id,
|
||||
cfg.id AS config_id,
|
||||
cfg.canonical_product_id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN manual_fulfillment_configs cfg
|
||||
ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
|
||||
|
||||
@@ -410,7 +410,7 @@ export async function deleteAdminProduct(productId: number | string) {
|
||||
(SELECT COUNT(*)::int FROM channel_product_mappings WHERE canonical_product_id = $1) AS mapping_count,
|
||||
(SELECT COUNT(*)::int FROM cost_versions WHERE canonical_product_id = $1) AS version_count,
|
||||
(SELECT COUNT(*)::int FROM cost_snapshots WHERE canonical_product_id = $1) AS snapshot_count,
|
||||
(SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id = $1) AS order_item_count
|
||||
(SELECT COUNT(*)::int FROM fulfillments WHERE canonical_product_id = $1) AS order_item_count
|
||||
`,
|
||||
[id],
|
||||
)
|
||||
@@ -474,7 +474,7 @@ export async function deleteAdminVoucherProduct(productId: number | string) {
|
||||
)
|
||||
)
|
||||
AS snapshot_count,
|
||||
(SELECT COUNT(*)::int FROM order_items WHERE canonical_product_id = $1)
|
||||
(SELECT COUNT(*)::int FROM fulfillments WHERE canonical_product_id = $1)
|
||||
AS order_item_count
|
||||
`,
|
||||
[id],
|
||||
@@ -779,7 +779,7 @@ export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {}
|
||||
COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) AS channel,
|
||||
COUNT(*)::int AS item_count
|
||||
FROM cost_exceptions ce
|
||||
JOIN order_items oi ON oi.id = ce.order_item_id
|
||||
JOIN v_order_item_current oi ON oi.id = ce.order_item_id
|
||||
WHERE ce.reason = 'product_mapping_missing'
|
||||
AND ce.status = 'open'
|
||||
AND COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $1
|
||||
@@ -874,7 +874,7 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) {
|
||||
COALESCE(ce.detail_json->>'skuCode', '') AS sku_code,
|
||||
COUNT(*)::int AS item_count
|
||||
FROM cost_exceptions ce
|
||||
JOIN order_items oi ON oi.id = ce.order_item_id
|
||||
JOIN v_order_item_current oi ON oi.id = ce.order_item_id
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
AND COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $${totalParams.length}
|
||||
GROUP BY 1, 2, 3
|
||||
@@ -927,7 +927,7 @@ export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) {
|
||||
MAX(NULLIF(ce.detail_json->>'basisAt', '')) AS last_seen_at,
|
||||
COUNT(*)::int AS item_count
|
||||
FROM cost_exceptions ce
|
||||
JOIN order_items oi ON oi.id = ce.order_item_id
|
||||
JOIN v_order_item_current oi ON oi.id = ce.order_item_id
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
${modeClause}
|
||||
GROUP BY 1, 2, 3, 4
|
||||
@@ -1192,7 +1192,7 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) {
|
||||
oi.sku_code,
|
||||
oi.normalized_sku_name,
|
||||
oi.normalized_sku_series
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.canonical_product_id IS NULL
|
||||
AND oi.id > $1
|
||||
@@ -1241,7 +1241,7 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) {
|
||||
AND ce.subject_type = 'order_item'
|
||||
AND ce.reason = 'product_mapping_missing'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
SELECT 1 FROM v_order_item_current oi
|
||||
WHERE oi.id = ce.subject_id AND oi.canonical_product_id IS NOT NULL
|
||||
)
|
||||
`,
|
||||
@@ -1252,7 +1252,7 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) {
|
||||
const remainingResult = await query<{ total: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.canonical_product_id IS NULL
|
||||
AND ($1 = '' OR o.provider = $1)
|
||||
@@ -1354,7 +1354,7 @@ export async function backfillAdminNormalizedQuantities(payload: JsonObject = {}
|
||||
`
|
||||
SELECT oi.id, oi.sku_name, oi.sku_code, oi.normalized_sku_name,
|
||||
oi.normalized_sku_series, oi.normalized_quantity
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
${range ? 'JOIN orders o ON o.id = oi.order_id' : ''}
|
||||
WHERE (oi.normalized_quantity IS NULL
|
||||
OR oi.normalized_sku_name IS NULL
|
||||
@@ -1404,7 +1404,7 @@ export async function backfillAdminNormalizedQuantities(payload: JsonObject = {}
|
||||
|
||||
const remainingResult = await query<{ total: number }>(
|
||||
`SELECT COUNT(*)::int AS total
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
${range ? 'JOIN orders o ON o.id = oi.order_id' : ''}
|
||||
WHERE (oi.normalized_quantity IS NULL
|
||||
OR oi.normalized_sku_name IS NULL
|
||||
@@ -1758,7 +1758,7 @@ export async function setAdminProductPrice(
|
||||
const earliest = await query<{ basis_date: string | null }>(
|
||||
`
|
||||
SELECT MIN((COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date)::text AS basis_date
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.canonical_product_id = $1
|
||||
AND oi.fulfillment_status = 'assigned'
|
||||
@@ -1840,7 +1840,7 @@ export async function setAdminProductPrice(
|
||||
const missingItems = await query<{ id: number }>(
|
||||
`
|
||||
SELECT oi.id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.canonical_product_id = $1
|
||||
AND oi.fulfillment_status = 'assigned'
|
||||
AND NOT EXISTS (
|
||||
@@ -1866,7 +1866,7 @@ export async function setAdminProductPrice(
|
||||
const coveredItems = await query<{ id: number }>(
|
||||
`
|
||||
SELECT oi.id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.canonical_product_id = $1
|
||||
AND oi.fulfillment_status = 'assigned'
|
||||
AND EXISTS (
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
/**
|
||||
* 管道 B:商品归一。
|
||||
*
|
||||
* 只写 `order_items.canonical_product_id`,不改渠道、不碰成本;
|
||||
* 只写 `fulfillments.canonical_product_id`,不改渠道、不碰成本;
|
||||
* 例外:三方卡券缺商品时按内部中文 SKU 自动建 `tpv:` 商品并补渠道规则。
|
||||
*
|
||||
* 解析顺序:
|
||||
@@ -339,7 +339,7 @@ export async function normalizeOrderItemProducts(
|
||||
wpr.rule_key AS work_rule_key,
|
||||
cfg.canonical_product_id AS manual_config_product_id,
|
||||
cfg_product.canonical_sku AS manual_config_product_sku
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
|
||||
@@ -529,7 +529,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{
|
||||
wpr.rule_key AS work_rule_key,
|
||||
cfg.canonical_product_id AS manual_config_product_id,
|
||||
cfg_product.canonical_sku AS manual_config_product_sku
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
|
||||
|
||||
@@ -626,7 +626,7 @@ async function resetImportedTemplateProductBootstrap(range: { fromDate: string;
|
||||
`
|
||||
WITH candidates AS (
|
||||
SELECT oi.id, oi.canonical_product_id AS expected_product_id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN channel_product_mappings m
|
||||
ON m.provider = o.provider
|
||||
@@ -680,7 +680,7 @@ async function resetImportedTemplateProductBootstrap(range: { fromDate: string;
|
||||
AND m.note LIKE '接单模板 %自动初始化%'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.canonical_product_id = m.canonical_product_id
|
||||
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
@@ -732,7 +732,7 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat
|
||||
wo.product_rule_id,
|
||||
r.rule_key,
|
||||
COUNT(DISTINCT oi.id)::int AS item_count
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
JOIN work_product_rules r ON r.id = wo.product_rule_id AND r.enabled = TRUE
|
||||
|
||||
@@ -8,7 +8,8 @@ test('卡券 SKU 一个入口完成商品、规则、成本版本和订单回填
|
||||
assert.ok(service.includes('withTransaction('))
|
||||
assert.ok(service.includes("channel: 'third_party_voucher'"))
|
||||
assert.ok(service.includes('sys_third_party_voucher_cost'))
|
||||
assert.ok(service.includes('UPDATE order_items oi'))
|
||||
assert.ok(service.includes('attachFulfillmentConfigurationsWithExecutor'))
|
||||
assert.ok(!service.includes('UPDATE order_items oi'))
|
||||
assert.ok(service.includes('syncCostSnapshotsForItems'))
|
||||
assert.ok(service.includes('refreshFactsForDates'))
|
||||
assert.ok(service.includes("resolveExceptionsForItemsByReason(result.itemIds, 'cost_missing')"))
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AdminSession } from '../admin/admin-auth-service.js'
|
||||
import { orderItemBasisDates, refreshFactsForDates } from '../finance/refresh-facts.js'
|
||||
import { upsertInternalSkuChannelWithClient } from '../fulfillment/internal-sku-channel-service.js'
|
||||
import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js'
|
||||
import { attachFulfillmentConfigurationsWithExecutor } from '../fulfillment/fulfillment-state-service.js'
|
||||
import { resolvePriceEffectiveFrom } from './cost-center-normalizers.js'
|
||||
import {
|
||||
resolveExceptionsForItemsByReason,
|
||||
@@ -103,24 +104,31 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad
|
||||
replacePriority: true,
|
||||
})
|
||||
|
||||
const repairedItems = await client.query<VoucherProductItemRow>(
|
||||
const repairCandidates = await client.query<VoucherProductItemRow>(
|
||||
`
|
||||
UPDATE order_items oi
|
||||
SET canonical_product_id = $2,
|
||||
updated_at = $3
|
||||
FROM orders o
|
||||
WHERE o.id = oi.order_id
|
||||
AND oi.fulfillment_channel = 'third_party_voucher'
|
||||
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1
|
||||
AND oi.canonical_product_id IS DISTINCT FROM $2
|
||||
RETURNING
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text
|
||||
AS basis_date
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE TRUE
|
||||
AND oi.fulfillment_channel = 'third_party_voucher'
|
||||
AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = $1
|
||||
AND oi.canonical_product_id IS DISTINCT FROM $2
|
||||
`,
|
||||
[skuKey, productId, now],
|
||||
[skuKey, productId],
|
||||
)
|
||||
const itemIds = repairedItems.rows.map((row) => Number(row.order_item_id))
|
||||
const itemIds = repairCandidates.rows.map((row) => Number(row.order_item_id))
|
||||
if (itemIds.length > 0) {
|
||||
await attachFulfillmentConfigurationsWithExecutor(client, {
|
||||
mappings: itemIds.map((orderItemId) => ({
|
||||
orderItemId,
|
||||
canonicalProductId: productId,
|
||||
})),
|
||||
onlyWhenCanonicalProductDifferent: true,
|
||||
})
|
||||
}
|
||||
if (itemIds.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE cost_snapshots
|
||||
@@ -145,13 +153,13 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad
|
||||
}
|
||||
|
||||
let effectiveFrom = initialEffectiveFrom
|
||||
if (!requestedEffectiveFrom && repairedItems.rows.length > 0) {
|
||||
if (!requestedEffectiveFrom && repairCandidates.rows.length > 0) {
|
||||
const missingDates = await client.query<{ basis_date: string }>(
|
||||
`
|
||||
SELECT MIN(
|
||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
)::text AS basis_date
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.id = ANY($1::bigint[])
|
||||
AND NOT EXISTS (
|
||||
@@ -272,7 +280,7 @@ export async function upsertAdminVoucherProduct(payload: JsonObject, session: Ad
|
||||
return {
|
||||
product: mapProduct(product),
|
||||
itemIds,
|
||||
dates: [...new Set(repairedItems.rows.map((row) => row.basis_date).filter(Boolean))],
|
||||
dates: [...new Set(repairCandidates.rows.map((row) => row.basis_date).filter(Boolean))],
|
||||
costVersionId,
|
||||
effectiveFrom,
|
||||
amountFen,
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record<string,
|
||||
'YYYY-MM-DD'
|
||||
) AS to_date
|
||||
FROM work_orders wo
|
||||
JOIN order_items oi ON oi.id = wo.order_item_id
|
||||
JOIN v_order_item_current oi ON oi.id = wo.order_item_id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE wo.order_item_id IS NOT NULL
|
||||
AND wo.status <> 'cancelled'
|
||||
@@ -77,7 +77,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record<string,
|
||||
wo.product_rule_id,
|
||||
oi.canonical_product_id AS expected_product_id
|
||||
FROM work_orders wo
|
||||
JOIN order_items oi ON oi.id = wo.order_item_id
|
||||
JOIN v_order_item_current oi ON oi.id = wo.order_item_id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE wo.order_item_id IS NOT NULL
|
||||
AND wo.status <> 'cancelled'
|
||||
@@ -120,7 +120,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record<string,
|
||||
SELECT
|
||||
oi.normalized_sku_name AS name,
|
||||
MIN(oi.sku_name) AS sample_name
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.canonical_product_id IS NULL
|
||||
AND btrim(COALESCE(oi.normalized_sku_name, '')) <> ''
|
||||
@@ -174,7 +174,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record<string,
|
||||
oi.sku_code,
|
||||
MIN(oi.sku_name) AS sku_name,
|
||||
oi.normalized_sku_name
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.canonical_product_id IS NULL
|
||||
AND btrim(COALESCE(oi.normalized_sku_name, '')) <> ''
|
||||
@@ -219,7 +219,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record<string,
|
||||
`
|
||||
WITH tail AS (
|
||||
SELECT oi.id, oi.normalized_sku_name
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.canonical_product_id IS NULL
|
||||
AND btrim(COALESCE(oi.normalized_sku_name, '')) <> ''
|
||||
@@ -267,7 +267,7 @@ export async function backfillAdminWorkOrderProductSkus(payload: Record<string,
|
||||
const remainingResult = await query<{ total: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.canonical_product_id IS NULL
|
||||
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
|
||||
@@ -411,10 +411,10 @@ async function loadRefundContext(workOrderIdValue: number | string): Promise<Ref
|
||||
FROM order_refunds r
|
||||
WHERE r.order_item_id = oi.id AND r.status = 'confirmed'
|
||||
)), 0)
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.order_id = o.id
|
||||
), 0)::bigint AS remaining_fen,
|
||||
(SELECT COUNT(*)::int FROM order_items oi WHERE oi.order_id = o.id) AS item_count,
|
||||
(SELECT COUNT(*)::int FROM v_order_item_current oi WHERE oi.order_id = o.id) AS item_count,
|
||||
(SELECT COUNT(*)::int FROM work_orders w WHERE w.order_id = o.id) AS work_order_count
|
||||
FROM work_orders wo
|
||||
JOIN orders o ON o.id = wo.order_id
|
||||
|
||||
@@ -349,8 +349,9 @@ async function readSalesFactJournalTotals(client: PoolClient, statDate: string)
|
||||
FROM finance_journal_entries e
|
||||
JOIN finance_journal_lines l ON l.entry_id = e.id
|
||||
WHERE e.ref_type = 'sales_fact'
|
||||
AND e.ref_id = $1
|
||||
AND e.status = 'posted'
|
||||
AND e.occurred_at >= ($1::date::timestamp AT TIME ZONE 'Asia/Shanghai')
|
||||
AND e.occurred_at < (($1::date + interval '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai')
|
||||
GROUP BY e.event_type, l.account_code
|
||||
`,
|
||||
[statDate],
|
||||
@@ -612,7 +613,7 @@ async function assertPeriodCloseable(
|
||||
const manualMissing = await client.query<{ total: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual'
|
||||
AND (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date
|
||||
@@ -636,7 +637,7 @@ async function assertPeriodCloseable(
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM cost_snapshots cs
|
||||
JOIN order_items oi ON oi.id = cs.order_item_id
|
||||
JOIN v_order_item_current oi ON oi.id = cs.order_item_id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.superseded_at IS NULL AND reversed_at IS NULL
|
||||
@@ -658,7 +659,7 @@ async function assertPeriodCloseable(
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM cost_snapshots cs
|
||||
JOIN manual_expenses me ON me.order_item_id = cs.order_item_id
|
||||
JOIN order_items oi ON oi.id = cs.order_item_id
|
||||
JOIN v_order_item_current oi ON oi.id = cs.order_item_id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE cs.subject_type = 'manual_case'
|
||||
AND cs.superseded_at IS NULL AND reversed_at IS NULL
|
||||
|
||||
@@ -306,7 +306,7 @@ async function matchRow(
|
||||
oi.id,
|
||||
oi.order_id,
|
||||
GREATEST(COALESCE(oi.revenue_amount_fen, 0), 0)::bigint AS revenue_fen
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.id = $3
|
||||
AND o.provider = $1
|
||||
|
||||
@@ -242,7 +242,7 @@ export async function listManualFulfillmentPending(
|
||||
const countResult = await query<{ total: number }>(
|
||||
`${baseCte}
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
|
||||
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
|
||||
@@ -276,7 +276,7 @@ export async function listManualFulfillmentPending(
|
||||
cfg.channels_json AS config_channels_json,
|
||||
COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen,
|
||||
fulfilled.fulfilled_at
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
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
|
||||
@@ -339,7 +339,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
|
||||
wo.id AS work_order_id,
|
||||
wo.work_order_no,
|
||||
wo.product_rule_id AS work_product_rule_id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current 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
|
||||
@@ -736,7 +736,7 @@ export async function refreshFactsForOrderItem(orderItemId: number): Promise<voi
|
||||
`
|
||||
SELECT (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text
|
||||
AS stat_date
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.id = $1
|
||||
`,
|
||||
@@ -838,7 +838,7 @@ async function matchManualFulfillmentConfigWithClient(client: PoolClient, orderI
|
||||
}>(
|
||||
`
|
||||
SELECT cfg.id, cfg.canonical_product_id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN manual_fulfillment_configs cfg ON cfg.enabled = TRUE
|
||||
AND (
|
||||
canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
|
||||
@@ -1097,7 +1097,7 @@ export async function getManualFulfillmentCountsByOrderIds(
|
||||
WHERE cfg.id IS NOT NULL AND fulfilled.fulfilled_at IS NULL
|
||||
)::int AS pending_count,
|
||||
COUNT(*) FILTER (WHERE cfg.id IS NULL)::int AS unconfigured_count
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
|
||||
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
|
||||
WHERE oi.order_id = ANY($1::bigint[])
|
||||
|
||||
@@ -30,13 +30,13 @@ test(
|
||||
reopenFinancePeriod,
|
||||
syncFinanceLedgerFromFacts,
|
||||
} = await import('./finance-ledger-service.js')
|
||||
const { rebuildSalesDailyFacts } = await import('./sales-facts-service.js')
|
||||
const { rebuildChannelSalesDailyFacts } = await import('./sales-facts-channel-service.js')
|
||||
const { importFinanceReconciliation } = await import('./finance-reconciliation-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 skuKey = `人工履约-${suffix}`
|
||||
const creditSkuKey = `人工额度-${suffix}`
|
||||
const unconfirmedSkuKey = `人工待确认-${suffix}`
|
||||
const orderIds: number[] = []
|
||||
const orderItemIds: number[] = []
|
||||
const fundAccountIds: number[] = []
|
||||
@@ -103,7 +103,7 @@ test(
|
||||
})
|
||||
assert.ok(rematched.updatedCount >= 1)
|
||||
const matchedConfig = await query<{ fulfillment_config_id: number | null }>(
|
||||
`SELECT fulfillment_config_id FROM order_items WHERE id = $1`,
|
||||
`SELECT fulfillment_config_id FROM v_order_item_current WHERE id = $1`,
|
||||
[cashItem.orderItemId],
|
||||
)
|
||||
assert.ok(Number(matchedConfig.rows[0]?.fulfillment_config_id) > 0, 'rematch 必须挂上配置')
|
||||
@@ -375,12 +375,28 @@ test(
|
||||
|
||||
// 7. 总账:销售事实同步、试算平衡、关账阻断、平台对账匹配
|
||||
const todayDate = shanghaiDate()
|
||||
await rebuildSalesDailyFacts({ dateFrom: todayDate, dateTo: todayDate })
|
||||
await rebuildChannelSalesDailyFacts({ dateFrom: todayDate, dateTo: todayDate })
|
||||
const salesFacts = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM sales_daily_facts
|
||||
WHERE stat_date = $1::date`,
|
||||
[todayDate],
|
||||
)
|
||||
assert.ok(Number(salesFacts.rows[0]?.count || 0) > 0, '必须先从统一履约投影生成销售事实')
|
||||
const ledgerSync = await syncFinanceLedgerFromFacts({
|
||||
dateFrom: todayDate,
|
||||
dateTo: todayDate,
|
||||
})
|
||||
assert.ok(ledgerSync.created > 0, '销售事实必须生成总账凭证')
|
||||
const journalEntries = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM finance_journal_entries
|
||||
WHERE period_key = $1`,
|
||||
[todayDate.slice(0, 7)],
|
||||
)
|
||||
assert.ok(
|
||||
ledgerSync.created > 0 || Number(journalEntries.rows[0]?.count || 0) > 0,
|
||||
'销售事实必须生成总账凭证',
|
||||
)
|
||||
const trial = await getTrialBalance({ dateFrom: todayDate, dateTo: todayDate })
|
||||
assert.equal(trial.balanced, true, '总账必须借贷平衡')
|
||||
|
||||
@@ -420,6 +436,8 @@ test(
|
||||
)
|
||||
// 清掉失败单后,关账才允许通过。
|
||||
await confirmManualFulfillment({ orderItemId: failItem.orderItemId, costFen: 700 }, session)
|
||||
await rebuildChannelSalesDailyFacts({ dateFrom: todayDate, dateTo: todayDate })
|
||||
await syncFinanceLedgerFromFacts({ dateFrom: todayDate, dateTo: todayDate })
|
||||
await closeFinancePeriod(periodKey, session)
|
||||
await assert.rejects(
|
||||
() =>
|
||||
@@ -544,13 +562,23 @@ async function createManualItem(
|
||||
const orderId = Number(order.rows[0]?.id)
|
||||
const item = await query(
|
||||
`INSERT INTO order_items (
|
||||
order_id, sku_code, sku_name, channel, spec_json,
|
||||
order_id, sku_code, sku_name, spec_json,
|
||||
revenue_amount_fen, revenue_basis, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, 'manual', $4::jsonb, 5000, 'order_total', NOW(), NOW())
|
||||
) VALUES ($1, $2, $3, $4::jsonb, 5000, 'order_total', NOW(), NOW())
|
||||
RETURNING id`,
|
||||
[orderId, skuNick, `集成测试SKU-${skuNick}`, JSON.stringify({ skuNick })],
|
||||
[orderId, skuNick, skuNick, JSON.stringify({ skuNick })],
|
||||
)
|
||||
return { orderId, orderItemId: Number(item.rows[0]?.id) }
|
||||
const orderItemId = Number(item.rows[0]?.id)
|
||||
await query(
|
||||
`UPDATE fulfillments
|
||||
SET status = 'assigned',
|
||||
channel = 'manual',
|
||||
source = 'carrier_evidence',
|
||||
updated_at = NOW()
|
||||
WHERE order_item_id = $1`,
|
||||
[orderItemId],
|
||||
)
|
||||
return { orderId, orderItemId }
|
||||
}
|
||||
|
||||
function shanghaiDate(date = new Date()) {
|
||||
|
||||
@@ -81,7 +81,7 @@ export async function listOrderRefunds(orderId: number | string): Promise<OrderR
|
||||
oi.sku_name,
|
||||
COALESCE(NULLIF(u.display_name, ''), u.username, '') AS operator_name
|
||||
FROM order_refunds r
|
||||
LEFT JOIN order_items oi ON oi.id = r.order_item_id
|
||||
LEFT JOIN v_order_item_current oi ON oi.id = r.order_item_id
|
||||
LEFT JOIN admin_users u ON u.id = r.operator_admin_user_id
|
||||
WHERE r.order_id = $1
|
||||
ORDER BY r.refunded_at DESC, r.id DESC
|
||||
@@ -144,7 +144,7 @@ export async function createManualRefund(
|
||||
})
|
||||
}
|
||||
const activeItems = await client.query<{ total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM order_items
|
||||
`SELECT COUNT(*)::int AS total FROM v_order_item_current
|
||||
WHERE order_id = $1 AND fulfillment_status <> 'archived'`,
|
||||
[orderId],
|
||||
)
|
||||
@@ -170,7 +170,7 @@ export async function createManualRefund(
|
||||
FROM order_refunds r
|
||||
WHERE r.order_item_id = oi.id AND r.status = 'confirmed'
|
||||
), 0)::bigint AS refunded_fen
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.order_id = $1
|
||||
ORDER BY oi.id
|
||||
`,
|
||||
@@ -286,7 +286,7 @@ export async function createManualRefund(
|
||||
JOIN order_items ri ON ri.id = r.order_item_id
|
||||
WHERE ri.order_id = $1 AND r.status = 'confirmed'
|
||||
), 0)::bigint AS refunded_fen
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.order_id = $1
|
||||
`,
|
||||
[orderId],
|
||||
@@ -407,7 +407,7 @@ export async function voidOrderRefund(
|
||||
})
|
||||
}
|
||||
const activeItems = await client.query<{ total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM order_items
|
||||
`SELECT COUNT(*)::int AS total FROM v_order_item_current
|
||||
WHERE order_id = $1 AND fulfillment_status <> 'archived'`,
|
||||
[row.order_id],
|
||||
)
|
||||
@@ -440,7 +440,7 @@ export async function voidOrderRefund(
|
||||
JOIN order_items ri ON ri.id = r.order_item_id
|
||||
WHERE ri.order_id = $1 AND r.status = 'confirmed'
|
||||
), 0)::bigint AS refunded_fen
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.order_id = $1
|
||||
`,
|
||||
[row.order_id],
|
||||
@@ -605,7 +605,7 @@ async function refreshActiveRefundFacts(itemIds: number[], basisDate: string): P
|
||||
const ids = [...new Set(itemIds.filter((id) => Number.isInteger(id) && id > 0))]
|
||||
if (ids.length === 0 || !basisDate) return
|
||||
const active = await query<{ total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM order_items
|
||||
`SELECT COUNT(*)::int AS total FROM v_order_item_current
|
||||
WHERE id = ANY($1::bigint[]) AND fulfillment_status <> 'archived'`,
|
||||
[ids],
|
||||
)
|
||||
@@ -684,7 +684,10 @@ export async function approveAndRecordKuaishouRefund(
|
||||
}
|
||||
|
||||
const existingItem = await query<{ id: number; fulfillment_status: string }>(
|
||||
`SELECT id, fulfillment_status FROM order_items WHERE id = $1 AND order_id = $2 LIMIT 1`,
|
||||
`SELECT id, fulfillment_status
|
||||
FROM v_order_item_current
|
||||
WHERE id = $1 AND order_id = $2
|
||||
LIMIT 1`,
|
||||
[orderItemId, orderId],
|
||||
)
|
||||
if (!existingItem.rows[0]) {
|
||||
@@ -897,7 +900,7 @@ async function getRefundableFen(orderId: number, orderItemId: number): Promise<n
|
||||
FROM order_refunds r
|
||||
WHERE r.order_item_id = oi.id AND r.status = 'confirmed'
|
||||
), 0))::bigint AS remaining_fen
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.id = $1 AND oi.order_id = $2
|
||||
LIMIT 1
|
||||
`,
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function orderItemBasisDates(itemIds: number[]): Promise<string[]>
|
||||
SELECT DISTINCT
|
||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text
|
||||
AS basis_date
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.id = ANY($1::bigint[])
|
||||
`,
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function rebuildChannelSalesDailyFacts(input: {
|
||||
WHEN oi.fulfillment_channel = 'work_order' THEN 'sys_work_order_reward'
|
||||
ELSE NULL
|
||||
END AS channel_cost_code
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status IN ('assigned', 'pending')
|
||||
AND o.pay_status IN ('paid', 'refunded')
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function runAffiliateFallback(
|
||||
cfg.id AS config_id,
|
||||
cfg.canonical_product_id AS config_product_id,
|
||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN manual_fulfillment_configs cfg
|
||||
ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function loadActiveCarrierEvidence(
|
||||
AND ft.executor_key = 'manual_dispatch'
|
||||
AND ft.task_status <> ALL($2::text[])
|
||||
) AS has_manual_task
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.id = ANY($1::bigint[])
|
||||
`,
|
||||
[ids, INACTIVE_CARRIER_TASK_STATUSES],
|
||||
|
||||
@@ -105,7 +105,7 @@ async function loadCandidateItems(input: {
|
||||
oi.order_id,
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku,
|
||||
capability.channels
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN LATERAL (
|
||||
SELECT COALESCE(
|
||||
jsonb_agg(
|
||||
@@ -296,9 +296,10 @@ export async function reconcilePendingCarriers(
|
||||
for (const [orderId, candidates] of byOrder) {
|
||||
const order = await getOrderById(orderId)
|
||||
if (!order) continue
|
||||
const itemRows = await query<OrderItemRow>(`SELECT * FROM order_items WHERE order_id = $1`, [
|
||||
orderId,
|
||||
])
|
||||
const itemRows = await query<OrderItemRow>(
|
||||
`SELECT * FROM v_order_item_current WHERE order_id = $1`,
|
||||
[orderId],
|
||||
)
|
||||
const itemById = new Map(itemRows.rows.map((item) => [Number(item.id), item]))
|
||||
|
||||
for (const candidate of candidates) {
|
||||
@@ -424,7 +425,7 @@ export async function reconcilePendingCarriers(
|
||||
const remainingResult = await query<{ count: number }>(
|
||||
`
|
||||
SELECT count(*)::int AS count
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.fulfillment_status IN ('pending', 'assigned')
|
||||
AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
|
||||
AND NOT EXISTS (
|
||||
|
||||
@@ -50,8 +50,8 @@ import {
|
||||
/**
|
||||
* 管道 A:渠道判定。
|
||||
*
|
||||
* 只写 `fulfillment_channel / fulfillment_status / fulfillment_source /
|
||||
* fulfillment_decided_at`,绝不写商品、成本、事实,也绝不改旧 channel。
|
||||
* 只写 `fulfillments.channel / status / source / decided_at`,
|
||||
* 绝不写商品、成本、事实,也绝不改冻结的订单项旧列。
|
||||
*
|
||||
* 判定顺序:
|
||||
* 1. 运营锁定 → 跳过
|
||||
@@ -335,7 +335,7 @@ export async function assignPendingOrderItemChannels(
|
||||
WHERE task.order_item_id = oi.id AND task.executor_key = 'manual_dispatch'
|
||||
AND ${ACTIVE_CARRIER_TASK_SQL}
|
||||
) AS has_manual_task
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
${options.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'}
|
||||
@@ -493,7 +493,9 @@ export async function assignOrderItemsAfterCarrierReady(input: {
|
||||
}
|
||||
|
||||
const items = (
|
||||
await query<OrderItemRow>(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids])
|
||||
await query<OrderItemRow>(`SELECT * FROM v_order_item_current WHERE id = ANY($1::bigint[])`, [
|
||||
ids,
|
||||
])
|
||||
).rows
|
||||
const orderIds = [...new Set(items.map((item) => Number(item.order_id)))]
|
||||
const orders = new Map(
|
||||
@@ -712,9 +714,13 @@ async function syncAssignedChannelSnapshots(
|
||||
|| jsonb_build_object('channel', $3)
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE id = ANY($1::bigint[])
|
||||
AND fulfillment_status = 'assigned'
|
||||
AND fulfillment_channel = $3`,
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM v_order_item_current
|
||||
WHERE id = ANY($1::bigint[])
|
||||
AND fulfillment_status = 'assigned'
|
||||
AND fulfillment_channel = $3
|
||||
)`,
|
||||
[ids, JSON.stringify(decision), channel],
|
||||
)
|
||||
}
|
||||
@@ -759,7 +765,7 @@ export async function switchChannelForOrderItems(input: {
|
||||
|
||||
const skuRows = await query<{ internal_sku: string }>(
|
||||
`SELECT DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.id = ANY($1::bigint[])`,
|
||||
[ids],
|
||||
)
|
||||
@@ -783,7 +789,9 @@ export async function switchChannelForOrderItems(input: {
|
||||
}
|
||||
|
||||
const items = (
|
||||
await query<OrderItemRow>(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids])
|
||||
await query<OrderItemRow>(`SELECT * FROM v_order_item_current WHERE id = ANY($1::bigint[])`, [
|
||||
ids,
|
||||
])
|
||||
).rows
|
||||
const orderIds = [...new Set(items.map((item) => Number(item.order_id)))]
|
||||
const orders = new Map(
|
||||
@@ -839,7 +847,10 @@ export async function switchChannelForOrderItems(input: {
|
||||
const now = nowIso()
|
||||
await withTransaction(async (client) => {
|
||||
const locked = await client.query<OrderItemRow>(
|
||||
`SELECT * FROM order_items WHERE id = $1 FOR UPDATE`,
|
||||
`SELECT status AS fulfillment_status
|
||||
FROM fulfillments
|
||||
WHERE order_item_id = $1
|
||||
FOR UPDATE`,
|
||||
[item.id],
|
||||
)
|
||||
if (!locked.rows[0] || locked.rows[0].fulfillment_status === 'archived') {
|
||||
@@ -983,7 +994,7 @@ export async function assignChannelToOrderItems(input: {
|
||||
}
|
||||
const rows = await query<{ internal_sku: string }>(
|
||||
`SELECT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.id = ANY($1::bigint[])`,
|
||||
[ids],
|
||||
)
|
||||
@@ -1453,7 +1464,7 @@ export async function retryPendingCarrierGroup(input: {
|
||||
oi.id AS order_item_id,
|
||||
cfg.id AS config_id,
|
||||
cfg.canonical_product_id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN manual_fulfillment_configs cfg
|
||||
ON canonicalize_internal_sku(cfg.sku_key) =
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
|
||||
@@ -1489,7 +1500,7 @@ export async function retryPendingCarrierGroup(input: {
|
||||
`SELECT
|
||||
count(*) FILTER (WHERE fulfillment_status = 'assigned')::int AS assigned,
|
||||
count(*) FILTER (WHERE fulfillment_status = 'pending')::int AS remaining
|
||||
FROM order_items
|
||||
FROM v_order_item_current
|
||||
WHERE id = ANY($1::bigint[])`,
|
||||
[ids],
|
||||
)
|
||||
@@ -1611,7 +1622,7 @@ export async function deletePendingChannelGroup(input: {
|
||||
|
||||
const result = await withTransaction(async (client) => {
|
||||
const itemParams: unknown[] = []
|
||||
const itemWhere = ["oi.fulfillment_status = 'pending'"]
|
||||
const itemWhere = ["fulfillment.status = 'pending'"]
|
||||
if (scope.type === 'internal_sku') {
|
||||
itemParams.push(scope.internalSku)
|
||||
itemWhere.push(`${INTERNAL_SKU_SQL} = $${itemParams.length}`)
|
||||
@@ -1637,10 +1648,11 @@ export async function deletePendingChannelGroup(input: {
|
||||
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text
|
||||
AS basis_date
|
||||
FROM order_items oi
|
||||
JOIN fulfillments fulfillment ON fulfillment.order_item_id = oi.id
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE ${itemWhere.join(' AND ')}
|
||||
ORDER BY oi.id
|
||||
FOR UPDATE OF oi
|
||||
FOR UPDATE OF fulfillment
|
||||
`,
|
||||
itemParams,
|
||||
)
|
||||
@@ -1783,7 +1795,12 @@ export async function assignPendingChannelGroup(input: {
|
||||
AND o.provider = $1
|
||||
AND o.shop_id = $2
|
||||
AND oi.sku_code = $3
|
||||
AND oi.fulfillment_status = 'pending'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM fulfillments fulfillment
|
||||
WHERE fulfillment.order_item_id = oi.id
|
||||
AND fulfillment.status = 'pending'
|
||||
)
|
||||
${issueCategoryFilter}
|
||||
RETURNING oi.id
|
||||
`,
|
||||
@@ -1943,7 +1960,7 @@ export async function assignChannelBySku(input: {
|
||||
`
|
||||
SELECT oi.id, ${INTERNAL_SKU_SQL} AS internal_sku
|
||||
FROM orders o
|
||||
JOIN order_items oi ON oi.order_id = o.id
|
||||
JOIN v_order_item_current oi ON oi.order_id = o.id
|
||||
WHERE o.id = oi.order_id
|
||||
AND o.provider = $1
|
||||
AND o.shop_id = $2
|
||||
|
||||
@@ -57,7 +57,7 @@ test('创建新履约尝试时自动退休旧当前尝试', () => {
|
||||
assert.ok(taskRepo.includes('MAX(attempt_no)'))
|
||||
assert.ok(taskRepo.includes('SET is_current = FALSE'))
|
||||
assert.ok(taskRepo.includes('$26, TRUE'))
|
||||
assert.ok(taskRepo.includes('SELECT id FROM order_items WHERE id = $1 FOR UPDATE'))
|
||||
assert.ok(taskRepo.includes('SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE'))
|
||||
})
|
||||
|
||||
test('当前尝试必须是同单元最新任务,统一视图不能读到已退休尝试', () => {
|
||||
|
||||
@@ -206,7 +206,7 @@ test(
|
||||
`UPDATE orders
|
||||
SET created_at = '2019-01-01T00:00:00+08:00',
|
||||
updated_at = '2019-01-01T00:00:00+08:00'
|
||||
WHERE id = (SELECT order_id FROM order_items WHERE id = $1)`,
|
||||
WHERE id = (SELECT order_id FROM v_order_item_current WHERE id = $1)`,
|
||||
[cutoffOldItemId],
|
||||
)
|
||||
await setRoute({ sku: cutoffTestSku, channel: 'third_party_voucher' })
|
||||
@@ -220,7 +220,7 @@ test(
|
||||
assert.equal(cutoffAssignment.assigned, 1)
|
||||
const cutoffStates = await query<{ id: number; fulfillment_status: string }>(
|
||||
`SELECT id, fulfillment_status
|
||||
FROM order_items
|
||||
FROM v_order_item_current
|
||||
WHERE id = ANY($1::bigint[])
|
||||
ORDER BY id DESC`,
|
||||
[[cutoffOldItemId, cutoffNewItemId]],
|
||||
@@ -307,7 +307,7 @@ test(
|
||||
fulfillment_config_id: number | null
|
||||
}>(
|
||||
`SELECT fulfillment_status, fulfillment_channel, fulfillment_config_id
|
||||
FROM order_items WHERE id = $1`,
|
||||
FROM v_order_item_current WHERE id = $1`,
|
||||
[manualItemId],
|
||||
)
|
||||
assert.equal(manualItem.rows[0]?.fulfillment_status, 'assigned')
|
||||
@@ -341,7 +341,7 @@ test(
|
||||
}>(
|
||||
`SELECT oi.fulfillment_status, oi.fulfillment_channel, oi.original_channel,
|
||||
oi.canonical_product_id, product.canonical_sku, oi.item_snapshot_json
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
LEFT JOIN product_catalog product ON product.id = oi.canonical_product_id
|
||||
WHERE oi.id = $1`,
|
||||
[manualItemId],
|
||||
@@ -370,7 +370,7 @@ test(
|
||||
fulfillment_pending_reason: string | null
|
||||
}>(
|
||||
`SELECT fulfillment_status, fulfillment_channel, fulfillment_pending_reason
|
||||
FROM order_items WHERE id = $1`,
|
||||
FROM v_order_item_current WHERE id = $1`,
|
||||
[missingAffiliateItemId],
|
||||
)
|
||||
assert.equal(pendingItem.rows[0]?.fulfillment_status, 'pending')
|
||||
@@ -402,7 +402,7 @@ test(
|
||||
canonical_product_id: number | null
|
||||
}>(
|
||||
`SELECT fulfillment_status, fulfillment_channel, fulfillment_config_id, canonical_product_id
|
||||
FROM order_items WHERE id = $1`,
|
||||
FROM v_order_item_current WHERE id = $1`,
|
||||
[autoManualItemId],
|
||||
)
|
||||
assert.equal(autoManualItem.rows[0]?.fulfillment_status, 'assigned')
|
||||
@@ -455,7 +455,7 @@ test(
|
||||
work_rule_key: string | null
|
||||
}>(
|
||||
`SELECT oi.fulfillment_status, oi.fulfillment_channel, wpr.rule_key AS work_rule_key
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id
|
||||
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
|
||||
WHERE oi.id = $1`,
|
||||
@@ -470,11 +470,11 @@ test(
|
||||
[retryWorkOrderItemId],
|
||||
)
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_status = 'pending', fulfillment_channel = NULL,
|
||||
fulfillment_source = 'auto_mapping', fulfillment_pending_reason = 'no_carrier',
|
||||
fulfillment_pending_detail = $2::jsonb, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
`UPDATE fulfillments
|
||||
SET status = 'pending', channel = NULL,
|
||||
source = 'auto_mapping', pending_reason = 'no_carrier',
|
||||
pending_detail = $2::jsonb, updated_at = NOW()
|
||||
WHERE order_item_id = $1`,
|
||||
[
|
||||
retryWorkOrderItemId,
|
||||
JSON.stringify({ carrierIssue: 'carrier_task_closed', channel: 'work_order' }),
|
||||
@@ -569,11 +569,11 @@ test(
|
||||
shopName: '测试店铺 A',
|
||||
})
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_status = 'assigned', fulfillment_channel = 'manual',
|
||||
fulfillment_source = 'carrier_evidence', fulfillment_config_id = NULL,
|
||||
`UPDATE fulfillments
|
||||
SET status = 'assigned', channel = 'manual',
|
||||
source = 'carrier_evidence', fulfillment_config_id = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
WHERE order_item_id = $1`,
|
||||
[manualRemoveItemId],
|
||||
)
|
||||
await query(
|
||||
@@ -596,7 +596,7 @@ test(
|
||||
const removedManualItem = await query<{
|
||||
fulfillment_status: string
|
||||
fulfillment_channel: string | null
|
||||
}>(`SELECT fulfillment_status, fulfillment_channel FROM order_items WHERE id = $1`, [
|
||||
}>(`SELECT fulfillment_status, fulfillment_channel FROM v_order_item_current WHERE id = $1`, [
|
||||
manualRemoveItemId,
|
||||
])
|
||||
assert.equal(removedManualItem.rows[0]?.fulfillment_status, 'pending')
|
||||
@@ -617,11 +617,11 @@ test(
|
||||
await setRoute({ sku: fallbackSku, channel: 'affiliate_dash' })
|
||||
await setRoute({ sku: fallbackSku, channel: 'manual', role: 'fallback' })
|
||||
const fallbackItem = await query<{ order_id: number }>(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_status = 'assigned', fulfillment_channel = 'affiliate_dash',
|
||||
fulfillment_source = 'operator_assigned', fulfillment_locked = FALSE,
|
||||
`UPDATE fulfillments
|
||||
SET status = 'assigned', channel = 'affiliate_dash',
|
||||
source = 'operator_assigned', locked = FALSE,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
WHERE order_item_id = $1
|
||||
RETURNING order_id`,
|
||||
[fallbackItemId],
|
||||
)
|
||||
@@ -672,7 +672,7 @@ test(
|
||||
assert.equal(
|
||||
(
|
||||
await query<{ fulfillment_channel: string }>(
|
||||
`SELECT fulfillment_channel FROM order_items WHERE id = $1`,
|
||||
`SELECT fulfillment_channel FROM v_order_item_current WHERE id = $1`,
|
||||
[fallbackItemId],
|
||||
)
|
||||
).rows[0]?.fulfillment_channel,
|
||||
@@ -694,7 +694,7 @@ test(
|
||||
item_snapshot_json: Record<string, unknown>
|
||||
}>(
|
||||
`SELECT fulfillment_channel, fulfillment_config_id, item_snapshot_json
|
||||
FROM order_items WHERE id = $1`,
|
||||
FROM v_order_item_current WHERE id = $1`,
|
||||
[fallbackItemId],
|
||||
)
|
||||
assert.equal(fallbackAssigned.rows[0]?.fulfillment_channel, 'manual')
|
||||
@@ -715,7 +715,7 @@ test(
|
||||
platform_order_id: string
|
||||
}>(
|
||||
`SELECT oi.order_id, o.provider, o.platform, o.shop_id, o.shop_name, o.platform_order_id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.id = $1`,
|
||||
[retryTaskItemId],
|
||||
@@ -926,14 +926,22 @@ test(
|
||||
configJson: { affiliateSku: retryPendingAffiliateSku },
|
||||
})
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_status = 'assigned',
|
||||
fulfillment_channel = 'affiliate_dash',
|
||||
fulfillment_source = 'carrier_evidence',
|
||||
`UPDATE fulfillments
|
||||
SET status = 'assigned',
|
||||
channel = 'affiliate_dash',
|
||||
source = 'carrier_evidence',
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
WHERE order_item_id = $1`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
const existingRetryTasks = await query<{ id: number; task_status: string }>(
|
||||
`SELECT id, task_status
|
||||
FROM fulfillment_tasks
|
||||
WHERE order_item_id = $1
|
||||
ORDER BY id`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
assert.deepEqual(existingRetryTasks.rows, [], `测试订单项已有任务:${retryPendingItemId}`)
|
||||
await query(
|
||||
`INSERT INTO fulfillment_tasks (
|
||||
order_id, order_item_id, unit_index, task_no, provider, platform,
|
||||
@@ -943,16 +951,23 @@ test(
|
||||
SELECT oi.order_id, oi.id, 1, $2, o.provider, o.platform,
|
||||
o.shop_id, o.shop_name, o.platform_order_id, profile.id, 'affiliate_dash',
|
||||
'retry_pending', NOW(), NOW()
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN LATERAL (
|
||||
SELECT id FROM fulfillment_profiles
|
||||
WHERE profile_key = 'affiliate_dash' AND executor_key = 'affiliate_dash'
|
||||
ORDER BY id LIMIT 1
|
||||
) profile ON TRUE
|
||||
JOIN fulfillment_profiles profile
|
||||
ON profile.id = $3
|
||||
AND profile.executor_key = 'affiliate_dash'
|
||||
WHERE oi.id = $1`,
|
||||
[retryPendingItemId, `IT-RETRY-PENDING-${suffix}`],
|
||||
[retryPendingItemId, `IT-RETRY-PENDING-${suffix}`, fallbackProfileId],
|
||||
)
|
||||
const retryPendingTask = await query<{ task_status: string }>(
|
||||
`SELECT task_status
|
||||
FROM fulfillment_tasks
|
||||
WHERE order_item_id = $1 AND executor_key = 'affiliate_dash'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
assert.equal(retryPendingTask.rows[0]?.task_status, 'retry_pending')
|
||||
const blockedRetry = await reconcilePendingCarriers({
|
||||
orderItemIds: [retryPendingItemId],
|
||||
includeAssignedMissingCarrier: true,
|
||||
@@ -972,7 +987,7 @@ test(
|
||||
oi.fulfillment_pending_reason,
|
||||
oi.fulfillment_pending_detail,
|
||||
(SELECT count(*)::int FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS task_count
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.id = $1`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
@@ -985,7 +1000,7 @@ test(
|
||||
)
|
||||
assert.equal(retryPendingState.rows[0]?.task_count, 1)
|
||||
await query(
|
||||
`UPDATE order_items SET fulfillment_locked = TRUE, updated_at = NOW() WHERE id = $1`,
|
||||
`UPDATE fulfillments SET locked = TRUE, updated_at = NOW() WHERE order_item_id = $1`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
const unlockedPendingGroup = await unlockPendingChannelGroup({
|
||||
@@ -993,7 +1008,7 @@ test(
|
||||
})
|
||||
assert.equal(unlockedPendingGroup.updated, 1)
|
||||
const unlockedPendingItem = await query<{ fulfillment_locked: boolean }>(
|
||||
`SELECT fulfillment_locked FROM order_items WHERE id = $1`,
|
||||
`SELECT fulfillment_locked FROM v_order_item_current WHERE id = $1`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
assert.equal(unlockedPendingItem.rows[0]?.fulfillment_locked, false)
|
||||
@@ -1007,14 +1022,14 @@ test(
|
||||
[retryPendingItemId],
|
||||
)
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_status = 'pending',
|
||||
fulfillment_channel = NULL,
|
||||
fulfillment_source = 'auto_mapping',
|
||||
fulfillment_pending_reason = NULL,
|
||||
fulfillment_pending_detail = NULL,
|
||||
`UPDATE fulfillments
|
||||
SET status = 'pending',
|
||||
channel = NULL,
|
||||
source = 'auto_mapping',
|
||||
pending_reason = NULL,
|
||||
pending_detail = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
WHERE order_item_id = $1`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
await assert.rejects(
|
||||
@@ -1035,7 +1050,7 @@ test(
|
||||
oi.fulfillment_status,
|
||||
oi.fulfillment_pending_detail,
|
||||
(SELECT count(*)::int FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS task_count
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.id = $1`,
|
||||
[retryPendingItemId],
|
||||
)
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const migration = readFileSync(
|
||||
new URL('../../db/migrations/141_fulfillment_shadow_model.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const auditScript = readFileSync(
|
||||
new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configSyncMigration = readFileSync(
|
||||
new URL('../../db/migrations/144_fulfillment_shadow_configuration_sync.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const routeCutoverMigration = readFileSync(
|
||||
new URL('../../db/migrations/161_move_shadow_sync_to_sku_routes.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('正式履约影子模型包含履约、尝试和异常三张表', () => {
|
||||
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillments'))
|
||||
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_attempts'))
|
||||
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_issues'))
|
||||
assert.ok(migration.includes('uq_fulfillment_attempts_current_unit'))
|
||||
assert.ok(migration.includes('UNIQUE (fulfillment_id, issue_code)'))
|
||||
})
|
||||
|
||||
test('影子模型从现有真源回填并持续双写', () => {
|
||||
assert.ok(migration.includes('SELECT sync_fulfillment_shadow(state.order_item_id)'))
|
||||
assert.ok(migration.includes('trg_order_items_fulfillment_shadow'))
|
||||
assert.ok(migration.includes('trg_fulfillment_tasks_shadow'))
|
||||
assert.ok(migration.includes('trg_work_orders_fulfillment_shadow'))
|
||||
assert.ok(migration.includes('assign_fulfillment_event_shadow'))
|
||||
})
|
||||
|
||||
test('人工配置和统一渠道规则变化会刷新履约影子', () => {
|
||||
assert.ok(configSyncMigration.includes('trg_manual_fulfillment_config_shadow'))
|
||||
assert.ok(configSyncMigration.includes('sync_fulfillment_shadow(item.id)'))
|
||||
assert.ok(routeCutoverMigration.includes('trg_sku_route_fulfillment_shadow'))
|
||||
assert.ok(routeCutoverMigration.includes('sync_fulfillment_shadow_for_sku_route'))
|
||||
assert.ok(
|
||||
routeCutoverMigration.includes('DROP TRIGGER IF EXISTS trg_internal_sku_channel_shadow'),
|
||||
)
|
||||
})
|
||||
|
||||
test('人工履约配置不能关联其他来源商品', () => {
|
||||
assert.ok(configSyncMigration.includes('trg_manual_config_product_source'))
|
||||
assert.ok(configSyncMigration.includes("product.canonical_sku LIKE 'man:%'"))
|
||||
assert.ok(configSyncMigration.includes("ERRCODE = '23514'"))
|
||||
assert.ok(configSyncMigration.includes('BEFORE INSERT OR UPDATE ON manual_fulfillment_configs'))
|
||||
assert.ok(configSyncMigration.includes('config.enabled = TRUE'))
|
||||
assert.ok(configSyncMigration.includes("SET status = 'active'"))
|
||||
})
|
||||
|
||||
test('明确渠道即视为已路由,不用渠道配置缺失覆盖已选渠道', () => {
|
||||
assert.ok(migration.includes('v_fulfillment_item_state_raw'))
|
||||
assert.ok(migration.includes("WHEN raw.intended_channel IS NOT NULL THEN 'configured'"))
|
||||
assert.ok(
|
||||
migration.includes(
|
||||
"WHEN raw.route_status = 'missing_channel'\n AND raw.intended_channel IS NOT NULL\n THEN ''",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('审计脚本核对影子表、执行尝试、开放异常和事件关联', () => {
|
||||
assert.ok(auditScript.includes('影子履约缺失'))
|
||||
assert.ok(auditScript.includes('影子履约状态差异'))
|
||||
assert.ok(auditScript.includes('影子执行尝试缺失'))
|
||||
assert.ok(auditScript.includes('影子开放异常缺失'))
|
||||
assert.ok(auditScript.includes('履约事件未关联影子记录'))
|
||||
})
|
||||
@@ -116,10 +116,10 @@ test(
|
||||
assert.equal((await listFulfillmentStateEvents(orderItemId)).length, 1)
|
||||
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_pending_detail = jsonb_build_object('legacyWrite', TRUE),
|
||||
`UPDATE fulfillments
|
||||
SET pending_detail = jsonb_build_object('legacyWrite', TRUE),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
WHERE order_item_id = $1`,
|
||||
[orderItemId],
|
||||
)
|
||||
events = await listFulfillmentStateEvents(orderItemId)
|
||||
@@ -128,10 +128,10 @@ test(
|
||||
assert.equal(events[0]?.actor_type, 'system')
|
||||
|
||||
await query(
|
||||
`UPDATE order_items
|
||||
SET fulfillment_pending_detail = jsonb_build_object('legacyWrite', TRUE),
|
||||
`UPDATE fulfillments
|
||||
SET pending_detail = jsonb_build_object('legacyWrite', TRUE),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
WHERE order_item_id = $1`,
|
||||
[orderItemId],
|
||||
)
|
||||
events = await listFulfillmentStateEvents(orderItemId)
|
||||
@@ -193,7 +193,7 @@ test(
|
||||
attributes_json, created_at, updated_at
|
||||
) VALUES ($1, $2, '测试', '件', 'active', '{}'::jsonb, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
[`test:ff-state-${suffix}`, `状态事件商品-${suffix}`],
|
||||
[`man:ff-state-${suffix}`, `状态事件商品-${suffix}`],
|
||||
)
|
||||
productId = Number(product.rows[0]!.id)
|
||||
const config = await query<{ id: number }>(
|
||||
|
||||
@@ -7,6 +7,7 @@ function read(relativePath: string) {
|
||||
}
|
||||
|
||||
const migration = read('../../db/migrations/140_fulfillment_state_events.sql')
|
||||
const cutoverMigration = read('../../db/migrations/163_cutover_fulfillment_truth.sql')
|
||||
const stateService = read('./fulfillment-state-service.ts')
|
||||
const channelAssign = read('./channel-assign-service.ts')
|
||||
const carrierReconciliation = read('./carrier-reconciliation-service.ts')
|
||||
@@ -32,8 +33,10 @@ test('履约状态事件表记录前后状态并禁止应用修改', () => {
|
||||
assert.ok(migration.includes('fulfillment_state_events is append-only'))
|
||||
})
|
||||
|
||||
test('状态写入口串行锁订单项,并在写入前校验期望状态', () => {
|
||||
assert.ok(stateService.includes('SELECT * FROM order_items WHERE id = $1 FOR UPDATE'))
|
||||
test('状态写入口串行锁正式履约记录,并在写入前校验期望状态', () => {
|
||||
assert.ok(stateService.includes('FROM fulfillments'))
|
||||
assert.ok(stateService.includes('FOR UPDATE'))
|
||||
assert.ok(!stateService.includes('SELECT * FROM order_items WHERE id = $1 FOR UPDATE'))
|
||||
assert.ok(stateService.includes('expected.status'))
|
||||
assert.ok(stateService.includes('expected.channel'))
|
||||
assert.ok(stateService.includes('sourceNotIn'))
|
||||
@@ -69,8 +72,18 @@ test('履约配置和标准商品关联统一通过状态服务的映射接口
|
||||
assert.ok(stateService.includes('onlyWhenCanonicalProductNull'))
|
||||
})
|
||||
|
||||
test('任务创建先锁订单项并复用已有活跃尝试', () => {
|
||||
assert.ok(taskRepo.includes('SELECT id FROM order_items WHERE id = $1 FOR UPDATE'))
|
||||
test('任务创建先锁正式履约记录并复用已有活跃尝试', () => {
|
||||
assert.ok(taskRepo.includes('SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE'))
|
||||
assert.ok(taskRepo.includes('task_status NOT IN ('))
|
||||
assert.ok(taskRepo.includes('ORDER BY is_current DESC, id DESC'))
|
||||
})
|
||||
|
||||
test('订单项状态列冻结,读模型直接投影正式履约表', () => {
|
||||
assert.ok(
|
||||
cutoverMigration.includes('RENAME COLUMN fulfillment_status TO legacy_fulfillment_status'),
|
||||
)
|
||||
assert.ok(cutoverMigration.includes('CREATE VIEW v_order_item_current'))
|
||||
assert.ok(cutoverMigration.includes('FROM fulfillments fulfillment'))
|
||||
assert.ok(cutoverMigration.includes('DROP FUNCTION IF EXISTS sync_fulfillment_shadow(BIGINT)'))
|
||||
assert.ok(cutoverMigration.includes('CREATE TRIGGER trg_fulfillments_state_event'))
|
||||
})
|
||||
|
||||
@@ -97,7 +97,6 @@ type StateColumn = {
|
||||
column: string
|
||||
value: unknown
|
||||
cast?: 'jsonb'
|
||||
mergeSnapshot?: boolean
|
||||
coalesceCurrent?: boolean
|
||||
preserveCarrierIssue?: boolean
|
||||
}
|
||||
@@ -119,46 +118,46 @@ function buildPatchColumns(patch: FulfillmentStatePatch): StateColumn[] {
|
||||
if (!['pending', 'assigned', 'archived'].includes(patch.status)) {
|
||||
throw new TypeError(`invalid fulfillment status: ${patch.status}`)
|
||||
}
|
||||
columns.push({ column: 'fulfillment_status', value: patch.status })
|
||||
columns.push({ column: 'status', value: patch.status })
|
||||
}
|
||||
if (patch.channel !== undefined) {
|
||||
if (patch.channel !== null && !isFulfillmentChannel(patch.channel)) {
|
||||
throw new TypeError(`invalid fulfillment channel: ${patch.channel}`)
|
||||
}
|
||||
columns.push({ column: 'fulfillment_channel', value: patch.channel })
|
||||
columns.push({ column: 'channel', value: patch.channel })
|
||||
}
|
||||
if (patch.source !== undefined) {
|
||||
columns.push({ column: 'fulfillment_source', value: patch.source })
|
||||
columns.push({ column: 'source', value: patch.source })
|
||||
}
|
||||
if (patch.locked !== undefined) {
|
||||
columns.push({ column: 'fulfillment_locked', value: Boolean(patch.locked) })
|
||||
columns.push({ column: 'locked', value: Boolean(patch.locked) })
|
||||
}
|
||||
if (patch.pendingReason !== undefined) {
|
||||
columns.push({ column: 'fulfillment_pending_reason', value: patch.pendingReason || null })
|
||||
columns.push({ column: 'pending_reason', value: patch.pendingReason || null })
|
||||
}
|
||||
if (patch.pendingDetail !== undefined) {
|
||||
columns.push({
|
||||
column: 'fulfillment_pending_detail',
|
||||
column: 'pending_detail',
|
||||
value: patch.pendingDetail,
|
||||
cast: 'jsonb',
|
||||
})
|
||||
}
|
||||
if (patch.pendingDetailUnlessCarrierIssue !== undefined) {
|
||||
columns.push({
|
||||
column: 'fulfillment_pending_detail',
|
||||
column: 'pending_detail',
|
||||
value: patch.pendingDetailUnlessCarrierIssue,
|
||||
cast: 'jsonb',
|
||||
preserveCarrierIssue: true,
|
||||
})
|
||||
}
|
||||
if (patch.reason !== undefined) {
|
||||
columns.push({ column: 'fulfillment_reason', value: patch.reason || null })
|
||||
columns.push({ column: 'reason', value: patch.reason || null })
|
||||
}
|
||||
if (patch.decidedBy !== undefined) {
|
||||
columns.push({ column: 'fulfillment_decided_by', value: patch.decidedBy })
|
||||
columns.push({ column: 'decided_by', value: patch.decidedBy })
|
||||
}
|
||||
if (patch.decidedAt !== undefined) {
|
||||
columns.push({ column: 'fulfillment_decided_at', value: patch.decidedAt })
|
||||
columns.push({ column: 'decided_at', value: patch.decidedAt })
|
||||
}
|
||||
if (patch.originalChannelIfNull !== undefined) {
|
||||
if (!isFulfillmentChannel(patch.originalChannelIfNull)) {
|
||||
@@ -197,26 +196,49 @@ function buildPatchColumns(patch: FulfillmentStatePatch): StateColumn[] {
|
||||
})
|
||||
}
|
||||
if (patch.itemSnapshotJson !== undefined) {
|
||||
columns.push({
|
||||
column: 'item_snapshot_json',
|
||||
value: patch.itemSnapshotJson,
|
||||
cast: 'jsonb',
|
||||
})
|
||||
// 原订单项快照属于订单事实,不进入履约状态表。
|
||||
}
|
||||
if (patch.itemSnapshotPatch !== undefined) {
|
||||
columns.push({
|
||||
column: 'item_snapshot_json',
|
||||
value: patch.itemSnapshotPatch,
|
||||
cast: 'jsonb',
|
||||
mergeSnapshot: true,
|
||||
})
|
||||
// 同上,在状态更新后单独合并。
|
||||
}
|
||||
if (columns.length === 0) {
|
||||
if (
|
||||
columns.length === 0 &&
|
||||
patch.itemSnapshotJson === undefined &&
|
||||
patch.itemSnapshotPatch === undefined
|
||||
) {
|
||||
throw new TypeError('fulfillment state patch must change at least one column')
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
async function applyOrderItemSnapshotPatch(
|
||||
executor: FulfillmentStateExecutor,
|
||||
orderItemId: number,
|
||||
patch: FulfillmentStatePatch,
|
||||
) {
|
||||
if (patch.itemSnapshotJson === undefined && patch.itemSnapshotPatch === undefined) {
|
||||
return
|
||||
}
|
||||
if (patch.itemSnapshotJson !== undefined) {
|
||||
await executor.query(
|
||||
`UPDATE order_items
|
||||
SET item_snapshot_json = $2::jsonb,
|
||||
updated_at = $3::timestamptz
|
||||
WHERE id = $1`,
|
||||
[orderItemId, patch.itemSnapshotJson, new Date().toISOString()],
|
||||
)
|
||||
return
|
||||
}
|
||||
await executor.query(
|
||||
`UPDATE order_items
|
||||
SET item_snapshot_json =
|
||||
COALESCE(item_snapshot_json, '{}'::jsonb) || $2::jsonb,
|
||||
updated_at = $3::timestamptz
|
||||
WHERE id = $1`,
|
||||
[orderItemId, patch.itemSnapshotPatch, new Date().toISOString()],
|
||||
)
|
||||
}
|
||||
|
||||
async function setEventContext(
|
||||
executor: FulfillmentStateExecutor,
|
||||
transition: Pick<FulfillmentStateTransition, 'eventType'> & {
|
||||
@@ -261,71 +283,84 @@ export async function transitionFulfillmentStateWithExecutor(
|
||||
const columns = buildPatchColumns(input.patch)
|
||||
await setEventContext(executor, input)
|
||||
try {
|
||||
const lockResult = await executor.query<OrderItemRow>(
|
||||
`SELECT * FROM order_items WHERE id = $1 FOR UPDATE`,
|
||||
const lockResult = await executor.query<{
|
||||
id: number
|
||||
order_item_id: number
|
||||
status: string
|
||||
channel: string | null
|
||||
source: string
|
||||
locked: boolean
|
||||
}>(
|
||||
`SELECT id, order_item_id, status, channel, source, locked
|
||||
FROM fulfillments
|
||||
WHERE order_item_id = $1
|
||||
FOR UPDATE`,
|
||||
[orderItemId],
|
||||
)
|
||||
const current = lockResult.rows[0]
|
||||
if (!current) return null
|
||||
|
||||
if (
|
||||
input.expected?.status !== undefined &&
|
||||
current.fulfillment_status !== input.expected.status
|
||||
) {
|
||||
if (input.expected?.status !== undefined && current.status !== input.expected.status) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
input.expected?.channel !== undefined &&
|
||||
(current.fulfillment_channel ?? null) !== input.expected.channel
|
||||
(current.channel ?? null) !== input.expected.channel
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
input.expected?.source !== undefined &&
|
||||
(current.fulfillment_source || '') !== (input.expected.source || '')
|
||||
(current.source || '') !== (input.expected.source || '')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
input.expected?.sourceNotIn &&
|
||||
input.expected.sourceNotIn.includes(String(current.fulfillment_source || ''))
|
||||
input.expected.sourceNotIn.includes(String(current.source || ''))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
input.expected?.locked !== undefined &&
|
||||
Boolean(current.fulfillment_locked) !== Boolean(input.expected.locked)
|
||||
Boolean(current.locked) !== Boolean(input.expected.locked)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const params: unknown[] = []
|
||||
const assignments = columns.map((entry) => {
|
||||
params.push(entry.value)
|
||||
let expression = `$${params.length}`
|
||||
if (entry.cast) expression += `::${entry.cast}`
|
||||
if (entry.mergeSnapshot) {
|
||||
expression = `COALESCE(item_snapshot_json, '{}'::jsonb) || ${expression}`
|
||||
} else if (entry.preserveCarrierIssue) {
|
||||
expression =
|
||||
`CASE WHEN COALESCE(fulfillment_pending_detail->>'carrierIssue', '') ` +
|
||||
`NOT IN ('', 'carrier_not_attempted') ` +
|
||||
`THEN fulfillment_pending_detail ELSE ${expression} END`
|
||||
} else if (entry.coalesceCurrent) {
|
||||
expression = `COALESCE(${entry.column}, ${expression})`
|
||||
}
|
||||
return `${entry.column} = ${expression}`
|
||||
})
|
||||
params.push(new Date().toISOString())
|
||||
assignments.push(`updated_at = $${params.length}::timestamptz`)
|
||||
params.push(orderItemId)
|
||||
if (columns.length > 0) {
|
||||
const params: unknown[] = []
|
||||
const assignments = columns.map((entry) => {
|
||||
params.push(entry.value)
|
||||
let expression = `$${params.length}`
|
||||
if (entry.cast) expression += `::${entry.cast}`
|
||||
if (entry.preserveCarrierIssue) {
|
||||
expression =
|
||||
`CASE WHEN COALESCE(pending_detail->>'carrierIssue', '') ` +
|
||||
`NOT IN ('', 'carrier_not_attempted') ` +
|
||||
`THEN pending_detail ELSE ${expression} END`
|
||||
} else if (entry.coalesceCurrent) {
|
||||
expression = `COALESCE(${entry.column}, ${expression})`
|
||||
}
|
||||
return `${entry.column} = ${expression}`
|
||||
})
|
||||
params.push(new Date().toISOString())
|
||||
assignments.push(`updated_at = $${params.length}::timestamptz`)
|
||||
params.push(current.id)
|
||||
|
||||
await executor.query(
|
||||
`UPDATE fulfillments
|
||||
SET ${assignments.join(', ')}
|
||||
WHERE id = $${params.length}`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
await applyOrderItemSnapshotPatch(executor, orderItemId, input.patch)
|
||||
await executor.query(`SELECT refresh_fulfillment_derived($1)`, [orderItemId])
|
||||
const updated = await executor.query<OrderItemRow>(
|
||||
`UPDATE order_items
|
||||
SET ${assignments.join(', ')}
|
||||
WHERE id = $${params.length}
|
||||
RETURNING *`,
|
||||
params,
|
||||
`SELECT * FROM v_order_item_current WHERE id = $1`,
|
||||
[orderItemId],
|
||||
)
|
||||
return updated.rows[0] || null
|
||||
} finally {
|
||||
@@ -409,21 +444,21 @@ export async function assignFulfillmentChannelsWithExecutor(
|
||||
...(input.reason ? { reason: input.reason } : {}),
|
||||
})
|
||||
try {
|
||||
const result = await executor.query<OrderItemRow>(
|
||||
`UPDATE order_items item
|
||||
SET fulfillment_channel = input.channel,
|
||||
fulfillment_status = 'assigned',
|
||||
fulfillment_source = input.source,
|
||||
fulfillment_decided_at = NOW(),
|
||||
fulfillment_pending_reason = NULL,
|
||||
fulfillment_pending_detail = NULL,
|
||||
const result = await executor.query<{ order_item_id: number }>(
|
||||
`UPDATE fulfillments fulfillment
|
||||
SET channel = input.channel,
|
||||
status = 'assigned',
|
||||
source = input.source,
|
||||
decided_at = NOW(),
|
||||
pending_reason = NULL,
|
||||
pending_detail = NULL,
|
||||
updated_at = NOW()
|
||||
FROM unnest($1::bigint[], $2::text[], $3::text[])
|
||||
AS input(order_item_id, channel, source)
|
||||
WHERE item.id = input.order_item_id
|
||||
AND item.fulfillment_status = 'pending'
|
||||
AND (item.fulfillment_locked = FALSE OR $4::boolean)
|
||||
RETURNING item.*`,
|
||||
WHERE fulfillment.order_item_id = input.order_item_id
|
||||
AND fulfillment.status = 'pending'
|
||||
AND (fulfillment.locked = FALSE OR $4::boolean)
|
||||
RETURNING fulfillment.order_item_id`,
|
||||
[
|
||||
assignments.map((row) => row.orderItemId),
|
||||
assignments.map((row) => row.channel),
|
||||
@@ -431,7 +466,21 @@ export async function assignFulfillmentChannelsWithExecutor(
|
||||
input.includeLocked === true,
|
||||
],
|
||||
)
|
||||
return result.rows
|
||||
const updatedIds = result.rows.map((row) => Number(row.order_item_id))
|
||||
if (updatedIds.length === 0) return []
|
||||
await executor.query(
|
||||
`SELECT refresh_fulfillment_derived(order_item_id)
|
||||
FROM fulfillments
|
||||
WHERE order_item_id = ANY($1::bigint[])`,
|
||||
[updatedIds],
|
||||
)
|
||||
const updated = await executor.query<OrderItemRow>(
|
||||
`SELECT * FROM v_order_item_current
|
||||
WHERE id = ANY($1::bigint[])
|
||||
ORDER BY id`,
|
||||
[updatedIds],
|
||||
)
|
||||
return updated.rows
|
||||
} finally {
|
||||
await clearEventContext(executor)
|
||||
}
|
||||
@@ -487,14 +536,14 @@ export async function attachFulfillmentConfigurationsWithExecutor(
|
||||
mappings.map((row) => row.requireExpectedProduct),
|
||||
mappings.map((row) => row.expectedCanonicalProductId),
|
||||
]
|
||||
const where = ['item.id = input.order_item_id']
|
||||
const where = ['item.order_item_id = input.order_item_id']
|
||||
if (input.expected?.status !== undefined) {
|
||||
params.push(input.expected.status)
|
||||
where.push(`item.fulfillment_status IS NOT DISTINCT FROM $${params.length}`)
|
||||
where.push(`item.status IS NOT DISTINCT FROM $${params.length}`)
|
||||
}
|
||||
if (input.expected?.channel !== undefined) {
|
||||
params.push(input.expected.channel)
|
||||
where.push(`item.fulfillment_channel IS NOT DISTINCT FROM $${params.length}`)
|
||||
where.push(`item.channel IS NOT DISTINCT FROM $${params.length}`)
|
||||
}
|
||||
if (input.onlyWhenConfigNull) {
|
||||
where.push('item.fulfillment_config_id IS NULL')
|
||||
@@ -514,8 +563,8 @@ export async function attachFulfillmentConfigurationsWithExecutor(
|
||||
params.push(new Date().toISOString())
|
||||
const updatedAtParam = params.length
|
||||
|
||||
const result = await executor.query<OrderItemRow>(
|
||||
`UPDATE order_items item
|
||||
const updatedIds = await executor.query<{ order_item_id: number }>(
|
||||
`UPDATE fulfillments item
|
||||
SET fulfillment_config_id = CASE
|
||||
WHEN input.set_config THEN input.config_id
|
||||
ELSE item.fulfillment_config_id
|
||||
@@ -545,9 +594,23 @@ export async function attachFulfillmentConfigurationsWithExecutor(
|
||||
expected_canonical_product_id
|
||||
)
|
||||
WHERE ${where.join(' AND ')}
|
||||
RETURNING item.*`,
|
||||
RETURNING item.order_item_id`,
|
||||
params,
|
||||
)
|
||||
const ids = updatedIds.rows.map((row) => Number(row.order_item_id))
|
||||
if (ids.length === 0) return []
|
||||
await executor.query(
|
||||
`SELECT refresh_fulfillment_derived(order_item_id)
|
||||
FROM fulfillments
|
||||
WHERE order_item_id = ANY($1::bigint[])`,
|
||||
[ids],
|
||||
)
|
||||
const result = await executor.query<OrderItemRow>(
|
||||
`SELECT * FROM v_order_item_current
|
||||
WHERE id = ANY($1::bigint[])
|
||||
ORDER BY id`,
|
||||
[ids],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const migration = readFileSync(
|
||||
new URL('../../db/migrations/141_fulfillment_shadow_model.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const auditScript = readFileSync(
|
||||
new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const configSyncMigration = readFileSync(
|
||||
new URL('../../db/migrations/144_fulfillment_shadow_configuration_sync.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const routeCutoverMigration = readFileSync(
|
||||
new URL('../../db/migrations/161_move_shadow_sync_to_sku_routes.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const truthCutoverMigration = readFileSync(
|
||||
new URL('../../db/migrations/163_cutover_fulfillment_truth.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const insertSkuMigration = readFileSync(
|
||||
new URL('../../db/migrations/166_fulfillment_insert_resolved_sku.sql', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('正式履约模型包含履约、尝试和异常三张表', () => {
|
||||
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillments'))
|
||||
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_attempts'))
|
||||
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_issues'))
|
||||
assert.ok(migration.includes('uq_fulfillment_attempts_current_unit'))
|
||||
assert.ok(migration.includes('UNIQUE (fulfillment_id, issue_code)'))
|
||||
})
|
||||
|
||||
test('迁移 163 删除影子双写并直接维护正式履约真源', () => {
|
||||
assert.ok(
|
||||
truthCutoverMigration.includes('DROP TRIGGER IF EXISTS trg_order_items_fulfillment_shadow'),
|
||||
)
|
||||
assert.ok(
|
||||
truthCutoverMigration.includes('DROP FUNCTION IF EXISTS sync_fulfillment_shadow(BIGINT)'),
|
||||
)
|
||||
assert.ok(truthCutoverMigration.includes('CREATE TRIGGER trg_fulfillments_state_event'))
|
||||
assert.ok(truthCutoverMigration.includes('CREATE TRIGGER trg_fulfillment_tasks_attempt'))
|
||||
assert.ok(truthCutoverMigration.includes('CREATE TRIGGER trg_work_orders_attempt'))
|
||||
assert.ok(truthCutoverMigration.includes('CREATE VIEW v_order_item_current'))
|
||||
})
|
||||
|
||||
test('人工配置和路由变化直接刷新正式履约投影', () => {
|
||||
assert.ok(truthCutoverMigration.includes('trg_manual_config_refresh_fulfillments'))
|
||||
assert.ok(truthCutoverMigration.includes('trg_sku_route_refresh_fulfillments'))
|
||||
assert.ok(truthCutoverMigration.includes('refresh_fulfillment_derived'))
|
||||
assert.ok(
|
||||
routeCutoverMigration.includes('DROP TRIGGER IF EXISTS trg_internal_sku_channel_shadow'),
|
||||
)
|
||||
})
|
||||
|
||||
test('人工履约配置不能关联其他来源商品', () => {
|
||||
assert.ok(configSyncMigration.includes('trg_manual_config_product_source'))
|
||||
assert.ok(configSyncMigration.includes("product.canonical_sku LIKE 'man:%'"))
|
||||
assert.ok(configSyncMigration.includes("ERRCODE = '23514'"))
|
||||
assert.ok(configSyncMigration.includes('BEFORE INSERT OR UPDATE ON manual_fulfillment_configs'))
|
||||
assert.ok(configSyncMigration.includes('config.enabled = TRUE'))
|
||||
assert.ok(configSyncMigration.includes("SET status = 'active'"))
|
||||
})
|
||||
|
||||
test('履约读模型直接投影正式表,订单项旧列冻结', () => {
|
||||
assert.ok(
|
||||
truthCutoverMigration.includes('RENAME COLUMN fulfillment_status TO legacy_fulfillment_status'),
|
||||
)
|
||||
assert.ok(
|
||||
truthCutoverMigration.includes(
|
||||
'RENAME COLUMN canonical_product_id TO legacy_canonical_product_id',
|
||||
),
|
||||
)
|
||||
assert.ok(truthCutoverMigration.includes('FROM fulfillments fulfillment'))
|
||||
assert.ok(truthCutoverMigration.includes('FROM fulfillment_attempts attempt'))
|
||||
assert.ok(insertSkuMigration.includes('COALESCE(resolve_internal_sku'))
|
||||
})
|
||||
|
||||
test('审计脚本核对正式履约、执行尝试、开放异常和事件关联', () => {
|
||||
assert.ok(auditScript.includes('正式履约缺失'))
|
||||
assert.ok(auditScript.includes('正式履约状态差异'))
|
||||
assert.ok(auditScript.includes('执行尝试缺失'))
|
||||
assert.ok(auditScript.includes('开放异常缺失'))
|
||||
assert.ok(auditScript.includes('履约事件未关联正式记录'))
|
||||
})
|
||||
@@ -30,7 +30,7 @@ test(
|
||||
WHERE trigger_name IN (
|
||||
'trg_sync_sku_channel_routes',
|
||||
'trg_internal_sku_channel_shadow',
|
||||
'trg_sku_route_fulfillment_shadow'
|
||||
'trg_sku_route_refresh_fulfillments'
|
||||
)
|
||||
ORDER BY event_object_table, trigger_name
|
||||
`,
|
||||
@@ -38,7 +38,7 @@ test(
|
||||
assert.deepEqual(triggers.rows, [
|
||||
{
|
||||
event_object_table: 'sku_channel_routes',
|
||||
trigger_name: 'trg_sku_route_fulfillment_shadow',
|
||||
trigger_name: 'trg_sku_route_refresh_fulfillments',
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function retryPendingWorkOrderRouting(
|
||||
if (internalSku) params.push(internalSku)
|
||||
const pending = await query<OrderItemRow>(
|
||||
`SELECT oi.*
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.fulfillment_status = 'pending'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
@@ -98,7 +98,7 @@ export async function retryPendingWorkOrderRouting(
|
||||
})
|
||||
const remaining = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM order_items
|
||||
FROM v_order_item_current
|
||||
WHERE id = ANY($1::bigint[]) AND fulfillment_status = 'pending'`,
|
||||
[ids],
|
||||
)
|
||||
|
||||
@@ -212,7 +212,7 @@ export async function scheduleVoucherConsumeForOrderItems(
|
||||
o.provider,
|
||||
o.platform,
|
||||
o.platform_order_id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.id = ANY($1::bigint[])
|
||||
AND oi.fulfillment_status = 'assigned'
|
||||
@@ -275,7 +275,7 @@ export async function scheduleVoucherConsumeFallbackForOrderItems(
|
||||
o.provider,
|
||||
o.platform,
|
||||
o.platform_order_id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.id = ANY($1::bigint[])
|
||||
AND oi.fulfillment_status = 'pending'
|
||||
|
||||
@@ -317,7 +317,7 @@ export async function listOpen91Orders({
|
||||
'quantity', oi.quantity,
|
||||
'snapshot', oi.item_snapshot_json
|
||||
) ORDER BY oi.id ASC) AS items
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.order_id = o.id
|
||||
) item_summary ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
|
||||
@@ -131,7 +131,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
SELECT oi.id,
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key,
|
||||
cfg.id AS config_id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN manual_fulfillment_configs cfg
|
||||
ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
|
||||
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
||||
@@ -169,7 +169,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
`
|
||||
SELECT oi.id,
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = 'manual'
|
||||
AND oi.fulfillment_config_id IS NULL
|
||||
@@ -210,7 +210,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
`
|
||||
SELECT oi.id,
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel = 'manual'
|
||||
AND oi.fulfillment_config_id IS NOT NULL
|
||||
@@ -257,7 +257,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
`
|
||||
SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code,
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled'
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
|
||||
@@ -316,7 +316,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) {
|
||||
`
|
||||
SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code,
|
||||
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
WHERE oi.fulfillment_status = 'assigned'
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled')
|
||||
@@ -407,7 +407,7 @@ async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promis
|
||||
SELECT DISTINCT ON (oi.id)
|
||||
oi.id AS order_item_id,
|
||||
cfg.id AS config_id
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN manual_fulfillment_configs cfg
|
||||
ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
|
||||
oi.manual_sku_key, oi.sku_name, oi.sku_code
|
||||
@@ -456,7 +456,7 @@ async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promis
|
||||
SELECT DISTINCT i.order_item_id
|
||||
FROM cost_exception_items i
|
||||
JOIN cost_exceptions ce ON ce.id = i.exception_id
|
||||
JOIN order_items oi ON oi.id = i.order_item_id
|
||||
JOIN v_order_item_current oi ON oi.id = i.order_item_id
|
||||
WHERE ce.reason = 'carrier_missing'
|
||||
AND ce.status <> 'resolved'
|
||||
AND (
|
||||
@@ -479,7 +479,7 @@ async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promis
|
||||
SELECT DISTINCT i.order_item_id
|
||||
FROM cost_exception_items i
|
||||
JOIN cost_exceptions ce ON ce.id = i.exception_id
|
||||
JOIN order_items oi ON oi.id = i.order_item_id
|
||||
JOIN v_order_item_current oi ON oi.id = i.order_item_id
|
||||
WHERE ce.reason = 'manual_config_missing'
|
||||
AND ce.status <> 'resolved'
|
||||
AND oi.fulfillment_config_id IS NOT NULL
|
||||
@@ -493,7 +493,7 @@ async function resolveCarrierDispatchExceptions(cutoffCreatedAt: string): Promis
|
||||
SELECT DISTINCT i.order_item_id
|
||||
FROM cost_exception_items i
|
||||
JOIN cost_exceptions ce ON ce.id = i.exception_id
|
||||
JOIN order_items oi ON oi.id = i.order_item_id
|
||||
JOIN v_order_item_current oi ON oi.id = i.order_item_id
|
||||
WHERE ce.reason = 'manual_cost_missing'
|
||||
AND ce.status <> 'resolved'
|
||||
AND EXISTS (
|
||||
|
||||
@@ -210,7 +210,7 @@ export async function listAdminKuaishouMatchSources(payload: JsonObject = {}) {
|
||||
oi.item_snapshot_json,
|
||||
oi.created_at,
|
||||
oi.updated_at
|
||||
FROM order_items oi
|
||||
FROM v_order_item_current oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
ORDER BY oi.id DESC
|
||||
LIMIT $1
|
||||
|
||||
+72
-30
@@ -3,7 +3,7 @@ type: 权威架构与实施计划
|
||||
description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。
|
||||
tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移]
|
||||
status: authoritative
|
||||
version: 1.0
|
||||
version: 2.0
|
||||
updated_at: 2026-11-08
|
||||
---
|
||||
|
||||
@@ -12,7 +12,7 @@ updated_at: 2026-11-08
|
||||
> 状态:**唯一权威文档**。
|
||||
> 最后校准:2026-11-08。
|
||||
> 生产基线:迁移 `146`。
|
||||
> 开发基线:迁移 `162`(仅本地,尚未部署生产)。
|
||||
> 开发基线:迁移 `166`(仅本地,尚未部署生产)。
|
||||
>
|
||||
> 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现,
|
||||
> 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。
|
||||
@@ -76,7 +76,7 @@ updated_at: 2026-11-08
|
||||
成本、事实和总账不得反向修改订单、路由、载体或商品来源。
|
||||
|
||||
7. **派生数据必须可重建。**
|
||||
成本快照、销售事实、总账、异常队列、投影和影子履约模型都必须能够从原始订单、
|
||||
成本快照、销售事实、总账、异常队列和读模型都必须能够从原始订单、
|
||||
输入配置和事件重算。
|
||||
|
||||
8. **页面与统计使用同一读模型。**
|
||||
@@ -106,8 +106,10 @@ updated_at: 2026-11-08
|
||||
- 计算成本。
|
||||
- 决定标准商品。
|
||||
|
||||
订单项上的 `fulfillment_*`、`canonical_product_id` 等字段在过渡期保留,
|
||||
但只能由对应领域服务通过兼容投影维护,不允许入单服务自行猜测。
|
||||
`order_items` 只保存订单项与平台商品事实。迁移 `163` 后,重复的履约状态、
|
||||
渠道、配置关联和商品归一字段已改名为 `legacy_*` 冻结留档;
|
||||
运行时不得读取或写入这些旧列。订单项履约状态统一从 `v_order_item_current` 或
|
||||
`fulfillments` 读取。
|
||||
|
||||
### 3.2 商品身份域
|
||||
|
||||
@@ -185,18 +187,29 @@ on_account
|
||||
|
||||
### 3.4 履约执行域
|
||||
|
||||
当前 `order_items.fulfillment_channel/status` 和 `v_fulfillment_item_state`
|
||||
属于过渡读模型。目标模型使用:
|
||||
唯一真源为 `fulfillments`、`fulfillment_attempts`、`fulfillment_issues`。
|
||||
`v_order_item_current` 是订单项与正式履约的一对一读投影;
|
||||
`v_fulfillment_item_state` 是履约队列专用读模型。两者都不得回退 `order_items.legacy_*`。
|
||||
|
||||
```text
|
||||
fulfillments
|
||||
id
|
||||
order_item_id
|
||||
internal_sku_id
|
||||
route_status -- unrouted / configured / locked
|
||||
order_id
|
||||
internal_sku
|
||||
status -- pending / assigned / archived
|
||||
channel
|
||||
source
|
||||
locked
|
||||
pending_reason
|
||||
pending_detail
|
||||
fulfillment_config_id
|
||||
canonical_product_id
|
||||
route_status -- unrouted / configured
|
||||
carrier_status -- not_required / missing / creating / ready / failed / terminal
|
||||
current_attempt_id
|
||||
current_attempt_type -- task / work_order / 空
|
||||
current_attempt_source_id
|
||||
current_attempt_status
|
||||
issue_code
|
||||
issue_category
|
||||
retryable
|
||||
@@ -208,7 +221,7 @@ fulfillment_attempts
|
||||
id
|
||||
fulfillment_id
|
||||
attempt_no
|
||||
attempt_type -- task / work_order / manual
|
||||
attempt_type -- task / work_order
|
||||
source_id
|
||||
status
|
||||
result_code
|
||||
@@ -234,6 +247,14 @@ fulfillment_issues
|
||||
resolved_at
|
||||
```
|
||||
|
||||
状态写入规则:
|
||||
|
||||
- 状态服务锁 `fulfillments`,不再锁或更新 `order_items` 履约状态。
|
||||
- 配置关联写入 `fulfillments.fulfillment_config_id / canonical_product_id`。
|
||||
- 任务和工单只追加 `fulfillment_attempts`,由触发器刷新当前尝试摘要。
|
||||
- 状态变化写入 `fulfillment_state_events`;事件只禁止 UPDATE,父记录删除时允许级联清理。
|
||||
- `v_order_item_current` 以正式表字段覆盖订单项旧名,保证所有业务查询只有一个读口径。
|
||||
|
||||
状态必须正交:
|
||||
|
||||
```text
|
||||
@@ -311,7 +332,7 @@ finance_journal_* -- 总账凭证,可重建
|
||||
|
||||
2026-11-08 生产只读核对结果:
|
||||
|
||||
- 生产迁移 `146`,本地开发基线迁移 `162`。
|
||||
- 生产迁移 `146`,本地开发基线迁移 `166`。
|
||||
- 启用标准商品 `1127`:`aff:` 30、`tpl:` 903、`tpv:` 46、`man:` 148。
|
||||
- `man:` 商品中只有 105 个当前主渠道为人工;41 个已走其他主渠道,2 个无主渠道。
|
||||
- 人工页头部显示 `107`,商品列表显示 `148`,相差的 41 个就是跨渠道旧商品。
|
||||
@@ -348,6 +369,8 @@ finance_journal_* -- 总账凭证,可重建
|
||||
- 平台售后、退款、支付事实
|
||||
- `work_orders`、`work_order_shares`、`work_order_events`
|
||||
- `fulfillment_tasks` 中真实执行与结果记录
|
||||
- `fulfillments`、`fulfillment_attempts`、`fulfillment_issues`
|
||||
- `fulfillment_state_events`
|
||||
- `worker_wallet_ledgers`、`fund_accounts` 的真实资金流水
|
||||
- `admin_audit_logs`
|
||||
- 财务期间、已确认退款和真实资金账户
|
||||
@@ -373,7 +396,6 @@ A 级数据只能追加新事件或做字段兼容迁移,不能为了“模型
|
||||
- `cost_exceptions`
|
||||
- `sales_daily_facts`
|
||||
- `finance_journal_entries` / `finance_journal_lines`
|
||||
- `fulfillments` / `fulfillment_attempts` / `fulfillment_issues`
|
||||
- `v_fulfillment_item_state` 及其依赖投影
|
||||
- 商品统计投影、渠道统计投影、页面汇总缓存
|
||||
- 管道水位与失败重试状态
|
||||
@@ -386,7 +408,7 @@ A 级数据只能追加新事件或做字段兼容迁移,不能为了“模型
|
||||
- 已冻结的 `internal_sku_channels`
|
||||
- `sku_channel_rules` 中由 `internal_sku_mappings` 和 `sku_channel_routes` 取代的规则
|
||||
- `manual_sku_registry` 中可由配置重建的登记行
|
||||
- 订单项上的重复履约结果字段,在正式模型切换后删除
|
||||
- 订单项上的 `legacy_*` 重复履约结果字段,在审计留存期结束后删除
|
||||
- 商品表中的历史来源前缀判断代码
|
||||
- 旧路由补丁脚本和一次性迁移工具
|
||||
|
||||
@@ -505,12 +527,12 @@ has_active_cost
|
||||
| 阶段 | 状态 | 是否改生产数据 |
|
||||
| --- | --- | --- |
|
||||
| 0 口径冻结与文档收口 | 已完成 | 否 |
|
||||
| 1 只读审计与领域约束 | 本地进行中 | 否 |
|
||||
| 2 建立目标身份与投影 | 商品侧本地完成 | 只新增兼容结构 |
|
||||
| 1 只读审计与领域约束 | 本地完成 | 否 |
|
||||
| 2 建立目标身份与投影 | 本地完成 | 只新增结构 |
|
||||
| 3 迁移路由与商品配置 | 本地完成,生产未执行 | 是,需备份 |
|
||||
| 4 重建履约模型 | 本地准备中 | 是,需断代 |
|
||||
| 4 重建履约模型 | 本地完成,生产未执行 | 是,需备份 |
|
||||
| 5 重建财务派生数据 | 未开始 | 是,需备份与对账 |
|
||||
| 6 停止旧写路径并删除兼容层 | 未开始 | 是,需观察期结束 |
|
||||
| 6 停止旧写路径并删除兼容层 | 履约侧已完成,全局未开始 | 是,需观察期结束 |
|
||||
|
||||
### 阶段 0:口径冻结与文档收口
|
||||
|
||||
@@ -580,7 +602,13 @@ assigned 且 carrier_status=missing 必须进入异常队列
|
||||
- 迁移 `160`:商品身份全部补齐,运行硬切到 `sku_channel_routes`,删除旧同步触发器;
|
||||
`v_product_current` 不再回退 `source_kind`,`v_fulfillment_item_state` 不再读取旧表。
|
||||
- 迁移 `161`:履约影子同步触发器从旧表迁移到 `sku_channel_routes`。
|
||||
- 迁移 `162`:删除旧履约只读视图和旧表最后一个触发器。
|
||||
- 迁移 `162`:删除旧路由只读视图和旧表最后一个触发器。
|
||||
- 迁移 `163`:履约状态硬切到 `fulfillments`;新增正式状态、配置关联和商品字段;
|
||||
任务/工单只维护尝试历史;订单项旧状态列改名冻结;建立 `v_order_item_current`。
|
||||
- 迁移 `164`:履约事件外键改为随正式履约记录级联删除。
|
||||
- 迁移 `165`:履约事件只阻止 UPDATE,数据库级联删除不再被 append-only 触发器阻断。
|
||||
- 迁移 `166`:无法解析内部 SKU 时,正式履约行的 `internal_sku` 统一落空字符串,
|
||||
并进入 `missing_internal_sku` 异常,而不是触发非空约束。
|
||||
- 商品列表、总览、渠道映射和商品下拉统一读取商品当前渠道。
|
||||
- 运行期代码停止写入 `product_channel_capabilities`,该表仅保留到兼容期结束。
|
||||
- 只读审计:`npm run audit:product-routing -- --strict`。
|
||||
@@ -588,8 +616,7 @@ assigned 且 carrier_status=missing 必须进入异常队列
|
||||
- 自动/卡券/接单主渠道商品的人工配置均保留为显式 `fallback`,没有被误升为人工主渠道。
|
||||
- 1 个无主路由、无人工配置、无启用映射的 `man:` 商品已归档,未删除历史引用。
|
||||
- 旧 `internal_sku_channels` 已冻结:运行时不再读、不再写,也不再有视图或触发器依赖。
|
||||
- 空库从迁移 `001` 顺序执行到 `162` 已通过;新库只保留
|
||||
`sku_channel_routes` 影子同步触发器,旧表同步触发器不存在。
|
||||
- 空库从迁移 `001` 顺序执行到 `166` 已通过;新库不再保留任何旧履约影子同步触发器。
|
||||
- 无主路由的活动商品会进入“未路由”,不自动猜测渠道。
|
||||
|
||||
当前本地仍需业务处理的数据:
|
||||
@@ -630,20 +657,26 @@ assigned 且 carrier_status=missing 必须进入异常队列
|
||||
|
||||
### 阶段 4:重建履约模型
|
||||
|
||||
状态:本地完成,生产未执行。
|
||||
|
||||
目标:
|
||||
|
||||
- 以 A 级原始订单和真实载体为输入,重建 `fulfillments`、`fulfillment_attempts`、
|
||||
`fulfillment_issues`。
|
||||
- 停止写入过渡字段。
|
||||
- 旧订单项履约状态字段已停止读写并冻结留档。
|
||||
|
||||
工作:
|
||||
本地产出:
|
||||
|
||||
1. 清空旧影子履约和派生异常。
|
||||
2. 回填当前履约单。
|
||||
3. 回填当前尝试与历史尝试。
|
||||
4. 生成结构化 issue。
|
||||
5. 对比新旧读模型。
|
||||
6. 切换后台与调度读路径。
|
||||
1. `fulfillments` 增加 `status / source / locked / pending_* / config / canonical_product`
|
||||
等正式字段,并从订单项存量状态一次性回填。
|
||||
2. 历史任务和工单补齐到 `fulfillment_attempts`,不再从订单项回调状态。
|
||||
3. `transitionFulfillmentStateWithExecutor`、渠道分配和配置关联全部改写正式表。
|
||||
4. `v_fulfillment_item_state` 只投影 `fulfillments + fulfillment_attempts +
|
||||
fulfillment_issues + sku_channel_routes`。
|
||||
5. 全部业务读 SQL 切到 `v_order_item_current`,禁止直接读取订单项旧状态列。
|
||||
6. 状态事件、任务尝试、工单尝试和配置变化触发器统一围绕正式履约表工作。
|
||||
7. 真实 PostgreSQL 集成测试覆盖状态写入、幂等、事件留痕、当前尝试和配置关联。
|
||||
8. 本地存量库与空库 `001 → 166` 均完成硬切验证。
|
||||
|
||||
验收:
|
||||
|
||||
@@ -651,9 +684,16 @@ assigned 且 carrier_status=missing 必须进入异常队列
|
||||
- 所有 `assigned` 都有明确 `carrier_status` 或开放 issue。
|
||||
- 退款、取消进入售后队列。
|
||||
- 页面按钮作用域与后端动作一致。
|
||||
- 运行时对 `order_items.legacy_fulfillment_*` 和 `legacy_canonical_product_id`
|
||||
的读写点均为 `0`。
|
||||
- 旧 `sync_fulfillment_shadow*` 函数和旧影子同步触发器均为 `0`。
|
||||
|
||||
### 阶段 5:重建财务派生数据
|
||||
|
||||
本地提前发现并修复:销售凭证增量同步读取当日余额时,必须统计同一天全部
|
||||
`sales_revenue_adjustment / sales_refund_adjustment / sales_cost_adjustment`,
|
||||
不能只按原始凭证的 `ref_id = 日期` 读取,否则重复同步会累计重复补记。
|
||||
|
||||
目标:
|
||||
|
||||
- 保留 A 级订单、退款事实和 B 级配置。
|
||||
@@ -679,11 +719,13 @@ product normalization
|
||||
|
||||
### 阶段 6:停止旧写路径并删除兼容层
|
||||
|
||||
状态:履约域已在本地停止旧写路径;商品、成本、人工配置的全局兼容清理仍需后续完成。
|
||||
|
||||
只有前五个阶段持续稳定后才允许:
|
||||
|
||||
- 删除 `product_channel_capabilities`。
|
||||
- 删除 `manual_sku_registry` 的派生行。
|
||||
- 删除订单项重复渠道字段。
|
||||
- 删除订单项已冻结的 `legacy_*` 重复渠道字段。
|
||||
- 删除商品前缀业务判断。
|
||||
- 删除旧管道、旧同步脚本和兼容 API。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user