feat(finance): 完善客服钱包与人工履约成本流水

This commit is contained in:
yml2213
2026-10-24 14:34:28 +08:00
parent f0f8c92e67
commit a96229a106
11 changed files with 359 additions and 78 deletions
@@ -419,8 +419,8 @@ export async function listPlatformCreditOwners() {
SELECT
au.id, au.username, COALESCE(NULLIF(au.display_name, ''), au.username) AS display_name
FROM admin_users au
WHERE au.status = 'active'
ORDER BY au.id
WHERE au.status = 'active' AND au.role = 'support'
ORDER BY display_name, au.id
`,
)
return {
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildManualFulfillmentNote,
buildManualFulfillmentReference,
} from './manual-fulfillment-note.js'
test('人工履约编号同时带工单号与平台单号', () => {
assert.equal(
buildManualFulfillmentReference({
workOrderNo: 'WK26102414033739',
platformOrderId: '2628501763808879',
orderItemId: 10,
}),
'工单 WK26102414033739 · 平台单号 2628501763808879',
)
})
test('人工履约编号在没有工单号时回退平台单号', () => {
assert.equal(
buildManualFulfillmentReference({
workOrderNo: null,
platformOrderId: '2628501763808879',
orderItemId: 10,
}),
'平台单号 2628501763808879',
)
})
test('人工履约编号完全没有单号时回退订单项', () => {
assert.equal(
buildManualFulfillmentReference({
workOrderNo: null,
platformOrderId: '',
orderItemId: 10,
}),
'订单项 #10',
)
})
test('人工履约备注包含商品、编号与客服备注并按上限截断', () => {
const note = buildManualFulfillmentNote({
skuName: '幸运币2个',
reference: '工单 WK26102414033739 · 平台单号 2628501763808879',
note: '已发放',
maxLength: 300,
})
assert.equal(
note,
'人工履约 幸运币2个 · 工单 WK26102414033739 · 平台单号 2628501763808879 · 已发放',
)
assert.equal(
buildManualFulfillmentNote({
skuName: '幸运币2个',
reference: '工单 WK26102414033739',
note: '',
maxLength: 12,
}),
'人工履约 幸运币2个 ·',
)
})
@@ -0,0 +1,23 @@
/** 人工履约流水备注:商品、可追溯编号与客服备注。 */
export function buildManualFulfillmentReference(input: {
workOrderNo: string | null
platformOrderId: string
orderItemId: number
}) {
const parts: string[] = []
if (input.workOrderNo) parts.push(`工单 ${input.workOrderNo}`)
if (input.platformOrderId) parts.push(`平台单号 ${input.platformOrderId}`)
if (parts.length === 0) parts.push(`订单项 #${input.orderItemId}`)
return parts.join(' · ')
}
export function buildManualFulfillmentNote(input: {
skuName: string
reference: string
note: string
maxLength: number
}) {
const parts = [`人工履约 ${input.skuName}`.trim(), input.reference]
if (input.note) parts.push(input.note)
return parts.filter(Boolean).join(' · ').slice(0, input.maxLength)
}
@@ -3,6 +3,10 @@ import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import type { AdminSession } from '../admin/admin-auth-service.js'
import {
buildManualFulfillmentNote,
buildManualFulfillmentReference,
} from './manual-fulfillment-note.js'
import {
consumePlatformCredit,
type PlatformCreditKey,
@@ -175,6 +179,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
cost_mode: string
credit_unit_price_fen: string
work_order_id: number | null
work_order_no: string | null
work_product_rule_id: number | null
fulfilled_at: string | null
}>(
@@ -203,6 +208,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
COALESCE(cfg.cost_mode, 'variable') AS cost_mode,
COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen,
wo.id AS work_order_id,
wo.work_order_no,
wo.product_rule_id AS work_product_rule_id,
fulfilled.fulfilled_at
FROM order_items oi
@@ -210,7 +216,7 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key
LEFT JOIN LATERAL (
SELECT id, product_rule_id FROM work_orders
SELECT id, product_rule_id, work_order_no FROM work_orders
WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1
) wo ON TRUE
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
@@ -267,6 +273,11 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
const note = optionalText(payload.note, 300)
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120)
const fulfillmentReference = buildManualFulfillmentReference({
workOrderNo: row.work_order_no,
platformOrderId: row.platform_order_id,
orderItemId,
})
// 1. 先扣额度(积分平台走积分钱包;备用金现金走现有 fund_* 链路)。
let creditEntryId: number | null = null
@@ -288,7 +299,12 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
orderItemId,
paidAt: now,
evidence: [],
note: buildFundNote(row.sku_name, note),
note: buildManualFulfillmentNote({
skuName: row.sku_name,
reference: fulfillmentReference,
note,
maxLength: 500,
}),
actorUserId: session.userId,
})
})
@@ -300,7 +316,13 @@ export async function confirmManualFulfillment(payload: JsonObject, session: Adm
platformKey: platformKey as PlatformCreditKey,
quantity: creditQuantity,
relatedTaskId: null,
note: buildCreditNote(row.sku_name, note),
platformRef: row.work_order_no || row.platform_order_id,
note: buildManualFulfillmentNote({
skuName: row.sku_name,
reference: fulfillmentReference,
note,
maxLength: 300,
}),
})
creditEntryId = consumed.entryId
balanceAfter = consumed.balanceAfter
@@ -500,18 +522,6 @@ function mapPending(row: PendingRow) {
}
}
function buildFundNote(skuName: string, note: string) {
const parts = [`人工履约 ${skuName}`.trim()]
if (note) parts.push(note)
return parts.join(' · ').slice(0, 500)
}
function buildCreditNote(skuName: string, note: string) {
const parts = [`人工履约 ${skuName}`.trim()]
if (note) parts.push(note)
return parts.join(' · ').slice(0, 300)
}
function normalizeCostFen(value: unknown) {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed < 0) {