feat(finance): 统一履约事实并改造人工账单分类
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { classifyFulfillmentMode } from './fulfillment-mode-service.js'
|
||||
|
||||
test('履约判定优先级:工单 > 自动发货 > 历史平台 > 人工', () => {
|
||||
assert.equal(
|
||||
classifyFulfillmentMode({
|
||||
hasWorkOrder: true,
|
||||
hasAffiliateDashTask: true,
|
||||
hasLegacyPlatformTask: true,
|
||||
}),
|
||||
'work_order',
|
||||
)
|
||||
assert.equal(
|
||||
classifyFulfillmentMode({
|
||||
hasWorkOrder: false,
|
||||
hasAffiliateDashTask: true,
|
||||
hasLegacyPlatformTask: true,
|
||||
}),
|
||||
'affiliate_dash',
|
||||
)
|
||||
assert.equal(
|
||||
classifyFulfillmentMode({
|
||||
hasWorkOrder: false,
|
||||
hasAffiliateDashTask: false,
|
||||
hasLegacyPlatformTask: true,
|
||||
}),
|
||||
'legacy_platform',
|
||||
)
|
||||
assert.equal(
|
||||
classifyFulfillmentMode({
|
||||
hasWorkOrder: false,
|
||||
hasAffiliateDashTask: false,
|
||||
hasLegacyPlatformTask: false,
|
||||
}),
|
||||
'manual',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
|
||||
export type OrderFulfillmentMode = 'work_order' | 'affiliate_dash' | 'legacy_platform' | 'manual'
|
||||
|
||||
export type FulfillmentModeDecisionInput = {
|
||||
hasWorkOrder: boolean
|
||||
hasAffiliateDashTask: boolean
|
||||
hasLegacyPlatformTask: boolean
|
||||
}
|
||||
|
||||
export type ReconcileFulfillmentModesOptions = {
|
||||
batchSize?: number
|
||||
maxBatches?: number
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type ReconcileFulfillmentModesResult = {
|
||||
updatedCount: number
|
||||
remainingCount: number
|
||||
batches: number
|
||||
reason: string
|
||||
finishedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一履约判定(纯函数,供单测与文档对照)。
|
||||
*
|
||||
* 优先级固定:工单 → 自动发货任务 → 历史平台任务 → 人工。
|
||||
* 核销码、provider、商品 carrier_type 都不参与判定。
|
||||
*/
|
||||
export function classifyFulfillmentMode({
|
||||
hasWorkOrder,
|
||||
hasAffiliateDashTask,
|
||||
hasLegacyPlatformTask,
|
||||
}: FulfillmentModeDecisionInput): OrderFulfillmentMode {
|
||||
if (hasWorkOrder) return 'work_order'
|
||||
if (hasAffiliateDashTask) return 'affiliate_dash'
|
||||
if (hasLegacyPlatformTask) return 'legacy_platform'
|
||||
return 'manual'
|
||||
}
|
||||
|
||||
const FULFILLMENT_MODE_CASE_SQL = `
|
||||
CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM work_orders wo
|
||||
WHERE wo.order_item_id = oi.id
|
||||
) THEN 'work_order'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id
|
||||
AND ft.executor_key = 'affiliate_dash'
|
||||
) THEN 'affiliate_dash'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM fulfillment_tasks ft
|
||||
WHERE ft.order_item_id = oi.id
|
||||
AND ft.executor_key IN ('kuaishou_ct_assisted', 'kuaishou_feifei')
|
||||
) THEN 'legacy_platform'
|
||||
ELSE 'manual'
|
||||
END
|
||||
`
|
||||
|
||||
/**
|
||||
* 重新判定指定订单项的履约方式。
|
||||
*
|
||||
* 只在模式发生变化时更新 fulfillment_decided_at / fulfillment_reason;
|
||||
* 同一订单项重复调用是幂等的。
|
||||
*/
|
||||
export async function decideOrderItemsFulfillmentModes(
|
||||
orderItemIds: Array<number | string>,
|
||||
{ reason = 'order_sync' }: { reason?: string } = {},
|
||||
): Promise<{ updatedCount: number }> {
|
||||
const ids = Array.from(
|
||||
new Set(
|
||||
orderItemIds
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isInteger(value) && value > 0),
|
||||
),
|
||||
)
|
||||
if (ids.length === 0) return { updatedCount: 0 }
|
||||
|
||||
const result = await query<{ fulfillment_mode: OrderFulfillmentMode }>(
|
||||
`
|
||||
WITH decided AS (
|
||||
SELECT oi.id, ${FULFILLMENT_MODE_CASE_SQL} AS mode
|
||||
FROM order_items oi
|
||||
WHERE oi.id = ANY($1::bigint[])
|
||||
)
|
||||
UPDATE order_items oi
|
||||
SET
|
||||
fulfillment_mode = d.mode,
|
||||
fulfillment_decided_at = NOW(),
|
||||
fulfillment_reason = $2
|
||||
FROM decided d
|
||||
WHERE oi.id = d.id
|
||||
AND (
|
||||
oi.fulfillment_mode IS NULL
|
||||
OR oi.fulfillment_mode IS DISTINCT FROM d.mode
|
||||
)
|
||||
RETURNING oi.fulfillment_mode
|
||||
`,
|
||||
[ids, String(reason || 'order_sync')],
|
||||
)
|
||||
|
||||
// 已有的 open 成本异常必须跟着履约方式一起改,否则历史平台会继续显示成人工。
|
||||
if ((result.rowCount || 0) > 0) {
|
||||
await syncOpenCostExceptionCarrierTypes(ids)
|
||||
}
|
||||
|
||||
return { updatedCount: result.rowCount || 0 }
|
||||
}
|
||||
|
||||
export async function syncOpenCostExceptionCarrierTypes(
|
||||
orderItemIds?: Array<number | string>,
|
||||
): Promise<{ updatedCount: number }> {
|
||||
const ids = Array.isArray(orderItemIds)
|
||||
? Array.from(
|
||||
new Set(
|
||||
orderItemIds
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isInteger(value) && value > 0),
|
||||
),
|
||||
)
|
||||
: null
|
||||
if (ids && ids.length === 0) return { updatedCount: 0 }
|
||||
|
||||
const result = await query(
|
||||
`
|
||||
UPDATE cost_exceptions ce
|
||||
SET carrier_type = oi.fulfillment_mode
|
||||
FROM order_items oi
|
||||
WHERE ce.subject_type = 'order_item'
|
||||
AND ce.order_item_id = oi.id
|
||||
AND ce.status = 'open'
|
||||
AND oi.fulfillment_mode IS NOT NULL
|
||||
AND ce.carrier_type IS DISTINCT FROM oi.fulfillment_mode
|
||||
${ids ? 'AND oi.id = ANY($1::bigint[])' : ''}
|
||||
`,
|
||||
ids ? [ids] : [],
|
||||
)
|
||||
|
||||
return { updatedCount: result.rowCount || 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底归集:分批处理 fulfillment_mode 为 NULL 的订单项。
|
||||
*
|
||||
* 定时任务只需处理新单;历史回填用同一个函数,把 maxBatches 调大即可。
|
||||
*/
|
||||
export async function reconcilePendingFulfillmentModes({
|
||||
batchSize = 2000,
|
||||
maxBatches = 10,
|
||||
reason = 'reconcile',
|
||||
}: ReconcileFulfillmentModesOptions = {}): Promise<ReconcileFulfillmentModesResult> {
|
||||
const normalizedBatchSize = clampInteger(batchSize, 2000, 1, 50_000)
|
||||
const normalizedMaxBatches = clampInteger(maxBatches, 10, 1, 10_000)
|
||||
const normalizedReason = String(reason || 'reconcile').trim() || 'reconcile'
|
||||
let updatedCount = 0
|
||||
let batches = 0
|
||||
|
||||
for (let index = 0; index < normalizedMaxBatches; index += 1) {
|
||||
const result = await query<{ fulfillment_mode: OrderFulfillmentMode }>(
|
||||
`
|
||||
WITH pending AS (
|
||||
SELECT oi.id
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment_mode IS NULL
|
||||
ORDER BY oi.id
|
||||
LIMIT $1
|
||||
),
|
||||
decided AS (
|
||||
SELECT oi.id, ${FULFILLMENT_MODE_CASE_SQL} AS mode
|
||||
FROM order_items oi
|
||||
JOIN pending p ON p.id = oi.id
|
||||
)
|
||||
UPDATE order_items oi
|
||||
SET
|
||||
fulfillment_mode = d.mode,
|
||||
fulfillment_decided_at = NOW(),
|
||||
fulfillment_reason = $2
|
||||
FROM decided d
|
||||
WHERE oi.id = d.id
|
||||
AND oi.fulfillment_mode IS NULL
|
||||
RETURNING oi.fulfillment_mode
|
||||
`,
|
||||
[normalizedBatchSize, normalizedReason],
|
||||
)
|
||||
|
||||
const updated = result.rowCount || 0
|
||||
updatedCount += updated
|
||||
batches += 1
|
||||
if (updated < normalizedBatchSize) break
|
||||
}
|
||||
|
||||
const remaining = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM order_items WHERE fulfillment_mode IS NULL`,
|
||||
)
|
||||
|
||||
return {
|
||||
updatedCount,
|
||||
remainingCount: Number(remaining.rows[0]?.count || 0),
|
||||
batches,
|
||||
reason: normalizedReason,
|
||||
finishedAt: nowIso(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFulfillmentModeCounts(): Promise<
|
||||
Array<{ fulfillmentMode: OrderFulfillmentMode | null; count: number }>
|
||||
> {
|
||||
const result = await query<{ fulfillment_mode: OrderFulfillmentMode | null; count: number }>(
|
||||
`
|
||||
SELECT fulfillment_mode, COUNT(*)::int AS count
|
||||
FROM order_items
|
||||
GROUP BY fulfillment_mode
|
||||
ORDER BY COUNT(*) DESC
|
||||
`,
|
||||
)
|
||||
return result.rows.map((row) => ({
|
||||
fulfillmentMode: row.fulfillment_mode || null,
|
||||
count: Number(row.count || 0),
|
||||
}))
|
||||
}
|
||||
|
||||
function clampInteger(value: unknown, fallback: number, min: number, max: number) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
Reference in New Issue
Block a user