feat(finance): 积分钱包操作 + 客服「确认履约」闭环
把「配置 → 待办 → 确认履约 → 扣额度 → 写成本快照」整条链路打通, 客服点一下就完成履约,不需要填复杂的东西。 积分钱包(P1.3): - platform-credit-service.ts:充值 / 调整 / 消耗 / 流水查询。 - 三平台积分互不通用,每客服每平台一个账户;余额只能由流水产生, 消耗不足直接拒绝(409),不允许负余额,与备用金口径一致。 - 换算率按平台:南航、乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元。 - 充值 / 调整只对 admin/operator/finance 开放;客服只能看自己的钱包。 - 备用金现金不在这里,走现有 fund_* 链路。 确认履约(P1.4): - manual-fulfillment-service.ts:待办列表 + 确认履约。 - 待办口径:order_items.fulfillment_mode='manual' 且已配 fulfillment_config_id。 人工单**不建 fulfillment_tasks**——那是自动发货通道的体系,人工单历来不走它 (本地 102,955 个人工单的任务行数为 0)。 - 「已履约」判定:该订单项存在有效 manual_case 成本快照。 - 固定成本(消耗积分 > 0)→ 零输入;成本不固定(= 0)→ 只填实际成本金额。 - 确认时:积分平台扣积分钱包、备用金现金走 writeFundExpense, 随后写 actual 成本快照(主体 = 积分流水 id 或备用金支出单 id)。 - 渠道与消耗积分永远提前配好,客服不选;重复履约直接拒绝。 前端: - 新增「人工履约」页(客服菜单):待确认 / 已履约 / 全部,确认履约弹窗。 - 备用金页新增「平台积分」Tab(客服端与管理端各一份): 余额卡片、积分流水、管理员充值与调整。 E2E 实测:配置 ROCK 100 积分 → 成本 ¥100.00(1 积分 = 1 元);充值 5000 积分 = ¥5000;确认履约后积分 5000 → 4900,成本快照 actual ¥100.00 写入; 重复履约被拒绝。测试数据已清理,登记表保留 357 条扫描结果供运营使用。 校验:后端 765 项 / 0 失败,前端 143 项全通过(含 tsc -b 与 build)。 Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
4bcb4e6b57
commit
d07d88e25c
@@ -31,6 +31,13 @@ import {
|
||||
updateAdminChannelMapping,
|
||||
updateAdminProduct,
|
||||
} from '../../services/cost-center/product-catalog-service.js'
|
||||
import {
|
||||
adjustPlatformCredit,
|
||||
grantPlatformCredit,
|
||||
listPlatformCreditAccounts,
|
||||
listPlatformCreditEntries,
|
||||
listPlatformCreditOwners,
|
||||
} from '../../services/cost-center/platform-credit-service.js'
|
||||
import {
|
||||
deleteManualFulfillmentConfig,
|
||||
getManualFulfillmentConfig,
|
||||
@@ -745,4 +752,95 @@ router.post(
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 客服平台积分钱包(南航 / 乐玩 / ROCK)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/cost-center/platform-credits/accounts',
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler((req) => listPlatformCreditAccounts(sessionOf(req)), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取积分钱包失败',
|
||||
scope: '[admin/cost-center/platform-credits/accounts]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/cost-center/platform-credits/entries',
|
||||
requireAdminPermission('finance.view'),
|
||||
createJsonHandler(
|
||||
(req) => listPlatformCreditEntries(req.query as Record<string, unknown>, sessionOf(req)),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取积分流水失败',
|
||||
scope: '[admin/cost-center/platform-credits/entries]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/cost-center/platform-credits/owners',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler(() => listPlatformCreditOwners(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取客服列表失败',
|
||||
scope: '[admin/cost-center/platform-credits/owners]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cost-center/platform-credits/grant',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler((req) => grantPlatformCredit(bodyOf(req), sessionOf(req)), {
|
||||
successMessage: '积分已充值',
|
||||
errorMessage: '积分充值失败',
|
||||
scope: '[admin/cost-center/platform-credits/grant]',
|
||||
audit: (req, data) => {
|
||||
const result = data as {
|
||||
platformKey?: string
|
||||
quantity?: number
|
||||
amountFen?: number
|
||||
}
|
||||
return {
|
||||
action: 'platform_credit_granted',
|
||||
targetType: 'platform_credit_account',
|
||||
targetId: String(req.body?.ownerAdminUserId || ''),
|
||||
data: {
|
||||
platformKey: result.platformKey || '',
|
||||
quantity: result.quantity || 0,
|
||||
amountFen: result.amountFen || 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cost-center/platform-credits/adjust',
|
||||
requireAdminPermission('finance.config'),
|
||||
createJsonHandler((req) => adjustPlatformCredit(bodyOf(req), sessionOf(req)), {
|
||||
successMessage: '积分已调整',
|
||||
errorMessage: '积分调整失败',
|
||||
scope: '[admin/cost-center/platform-credits/adjust]',
|
||||
audit: (req, data) => {
|
||||
const result = data as {
|
||||
platformKey?: string
|
||||
quantity?: number
|
||||
amountFen?: number
|
||||
}
|
||||
return {
|
||||
action: 'platform_credit_adjusted',
|
||||
targetType: 'platform_credit_account',
|
||||
targetId: String(req.body?.ownerAdminUserId || ''),
|
||||
data: {
|
||||
platformKey: result.platformKey || '',
|
||||
quantity: result.quantity || 0,
|
||||
amountFen: result.amountFen || 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
createManualOrderCost,
|
||||
listManualOrderCostItems,
|
||||
} from '../../services/finance/manual-order-cost-service.js'
|
||||
import {
|
||||
confirmManualFulfillment,
|
||||
listManualFulfillmentPending,
|
||||
} from '../../services/finance/manual-fulfillment-service.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import {
|
||||
createJsonHandler,
|
||||
@@ -153,4 +157,51 @@ router.post(
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 人工履约待办与确认履约
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get(
|
||||
'/finance/manual-fulfillment/pending',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) => listManualFulfillmentPending(req.query as Record<string, unknown>, sessionOf(req)),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工履约待办失败',
|
||||
scope: '[admin/finance/manual-fulfillment/pending]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-fulfillment/confirm',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler((req) => confirmManualFulfillment(bodyOf(req), sessionOf(req)), {
|
||||
successMessage: '履约已确认',
|
||||
errorMessage: '确认履约失败',
|
||||
scope: '[admin/finance/manual-fulfillment/confirm]',
|
||||
audit: (req, data) => {
|
||||
const result = data as {
|
||||
orderItemId?: number
|
||||
costSnapshotId?: number
|
||||
platformKey?: string
|
||||
creditQuantity?: number
|
||||
costFen?: number
|
||||
}
|
||||
return {
|
||||
action: 'manual_fulfillment_confirmed',
|
||||
targetType: 'order_item',
|
||||
targetId: String(result.orderItemId || ''),
|
||||
data: {
|
||||
costSnapshotId: result.costSnapshotId || null,
|
||||
platformKey: result.platformKey || '',
|
||||
creditQuantity: result.creditQuantity || 0,
|
||||
costFen: result.costFen || 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import type { AdminSession } from '../admin/admin-auth-service.js'
|
||||
|
||||
/**
|
||||
* 客服平台积分钱包服务(南航 / 乐玩 / ROCK)。
|
||||
*
|
||||
* 口径(2026-10-24 确认):
|
||||
* * 三平台积分**互不通用**,每客服每平台一个账户;
|
||||
* * 换算率按平台不同:南航 1 积分 = 1 分,乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元;
|
||||
* 真源是 fulfillment_profiles.config_json.rechargeUnitPriceFen;
|
||||
* * 余额只能由流水产生,不允许手工改;流水只追加,不修改不删除;
|
||||
* * 消耗不足直接拒绝,不允许负余额(与备用金一致);
|
||||
* * 备用金现金**不走这里**,它复用现有的 fund_accounts / fund_entries。
|
||||
*/
|
||||
|
||||
export const PLATFORM_CREDIT_KEYS = ['nanhang', 'lewan', 'rock'] as const
|
||||
export type PlatformCreditKey = (typeof PLATFORM_CREDIT_KEYS)[number]
|
||||
|
||||
const PLATFORM_LABELS: Record<PlatformCreditKey, string> = {
|
||||
nanhang: '南航平台',
|
||||
lewan: '乐玩后台',
|
||||
rock: 'ROCK 后台',
|
||||
}
|
||||
|
||||
type CreditAccountRow = {
|
||||
id: number
|
||||
platform_key: string
|
||||
owner_admin_user_id: number
|
||||
balance_quantity: string | number
|
||||
unit_name: string
|
||||
recharge_unit_price_fen: string | number
|
||||
status: string
|
||||
owner_name?: string | null
|
||||
username?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
type CreditEntryRow = {
|
||||
id: number
|
||||
account_id: number
|
||||
entry_type: string
|
||||
quantity_delta: string | number
|
||||
balance_after: string | number
|
||||
amount_fen: string | number
|
||||
platform_ref: string
|
||||
related_task_id: number | null
|
||||
note: string
|
||||
created_at: string
|
||||
platform_key?: string
|
||||
owner_name?: string | null
|
||||
}
|
||||
|
||||
/** 读取平台档案里配置的换算率(分/积分)。 */
|
||||
async function loadUnitPrices(): Promise<Record<PlatformCreditKey, number>> {
|
||||
const result = await query<{ profile_key: string; config_json: Record<string, unknown> }>(
|
||||
`SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`,
|
||||
)
|
||||
const prices: Record<PlatformCreditKey, number> = { nanhang: 1, lewan: 1, rock: 100 }
|
||||
for (const row of result.rows) {
|
||||
const raw = Number(row.config_json?.rechargeUnitPriceFen)
|
||||
const key = row.profile_key.replace(/^manual_/, '') as PlatformCreditKey
|
||||
if (key in prices && Number.isInteger(raw) && raw > 0) {
|
||||
prices[key] = raw
|
||||
}
|
||||
}
|
||||
return prices
|
||||
}
|
||||
|
||||
/** 客服只能看自己的积分钱包;管理员/财务可看全部。 */
|
||||
function isPrivileged(session: AdminSession) {
|
||||
return session.role === 'admin' || session.role === 'finance' || session.role === 'operator'
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出积分钱包。客服返回自己的三个平台账户(没有就补建空账户)。
|
||||
*/
|
||||
export async function listPlatformCreditAccounts(session: AdminSession) {
|
||||
const unitPrices = await loadUnitPrices()
|
||||
const params: unknown[] = []
|
||||
const clauses: string[] = []
|
||||
|
||||
if (!isPrivileged(session)) {
|
||||
params.push(session.userId)
|
||||
clauses.push(`pca.owner_admin_user_id = $${params.length}`)
|
||||
}
|
||||
|
||||
const where = clauses.length > 0 ? `AND ${clauses.join(' AND ')}` : ''
|
||||
const result = await query<CreditAccountRow>(
|
||||
`
|
||||
SELECT
|
||||
pca.id, pca.platform_key, pca.owner_admin_user_id, pca.balance_quantity,
|
||||
pca.unit_name, pca.recharge_unit_price_fen, pca.status,
|
||||
pca.created_at, pca.updated_at,
|
||||
COALESCE(NULLIF(au.display_name, ''), au.username) AS owner_name,
|
||||
au.username
|
||||
FROM platform_credit_accounts pca
|
||||
LEFT JOIN admin_users au ON au.id = pca.owner_admin_user_id
|
||||
WHERE 1 = 1 ${where}
|
||||
ORDER BY pca.platform_key, pca.owner_admin_user_id
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
return {
|
||||
items: result.rows.map((row) => mapAccount(row, unitPrices)),
|
||||
platforms: PLATFORM_CREDIT_KEYS.map((key) => ({
|
||||
key,
|
||||
label: PLATFORM_LABELS[key],
|
||||
creditUnitPriceFen: unitPrices[key],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** 给客服补建三个平台的空积分账户(没有才建)。 */
|
||||
export async function ensurePlatformCreditAccounts(ownerAdminUserId: number): Promise<void> {
|
||||
const unitPrices = await loadUnitPrices()
|
||||
const now = nowIso()
|
||||
for (const key of PLATFORM_CREDIT_KEYS) {
|
||||
await query(
|
||||
`
|
||||
INSERT INTO platform_credit_accounts (
|
||||
platform_key, owner_admin_user_id, balance_quantity, unit_name,
|
||||
recharge_unit_price_fen, status, created_at, updated_at
|
||||
) VALUES ($1, $2, 0, '积分', $3, 'active', $4, $4)
|
||||
ON CONFLICT (owner_admin_user_id, platform_key) DO NOTHING
|
||||
`,
|
||||
[key, ownerAdminUserId, unitPrices[key], now],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 某个客服在某个平台的积分余额(0 = 没账户)。 */
|
||||
export async function getPlatformCreditBalance(
|
||||
ownerAdminUserId: number,
|
||||
platformKey: PlatformCreditKey,
|
||||
) {
|
||||
const result = await query<CreditAccountRow>(
|
||||
`SELECT * FROM platform_credit_accounts
|
||||
WHERE owner_admin_user_id = $1 AND platform_key = $2
|
||||
LIMIT 1`,
|
||||
[ownerAdminUserId, platformKey],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
if (!row) return null
|
||||
const unitPrices = await loadUnitPrices()
|
||||
return mapAccount(row, unitPrices)
|
||||
}
|
||||
|
||||
/**
|
||||
* 充值:公司往客服的平台积分账户加额度。
|
||||
*
|
||||
* 只有管理员/财务能充值;客服角色只能查看。
|
||||
*/
|
||||
export async function grantPlatformCredit(payload: JsonObject, session: AdminSession) {
|
||||
if (!isPrivileged(session)) {
|
||||
throw createHttpError('只有管理员或财务可以充值积分', {
|
||||
statusCode: 403,
|
||||
errorCode: 'platform_credit_grant_forbidden',
|
||||
})
|
||||
}
|
||||
|
||||
const platformKey = normalizePlatformKey(payload.platformKey ?? payload.platform_key)
|
||||
const ownerAdminUserId = normalizeRequiredId(
|
||||
payload.ownerAdminUserId ?? payload.owner_admin_user_id,
|
||||
'请选择客服',
|
||||
)
|
||||
const quantity = normalizePositiveQuantity(payload.quantity)
|
||||
const platformRef = optionalText(payload.platformRef ?? payload.platform_ref, 100)
|
||||
const note = optionalText(payload.note, 300)
|
||||
|
||||
const unitPrices = await loadUnitPrices()
|
||||
const unitPriceFen = unitPrices[platformKey]
|
||||
const now = nowIso()
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
await ensureAccountRow(client, ownerAdminUserId, platformKey, unitPriceFen, now)
|
||||
const account = await lockAccount(client, ownerAdminUserId, platformKey)
|
||||
const nextBalance = Number(account.balance_quantity) + quantity
|
||||
|
||||
await client.query(
|
||||
`UPDATE platform_credit_accounts
|
||||
SET balance_quantity = $2, updated_at = $3
|
||||
WHERE id = $1`,
|
||||
[account.id, nextBalance, now],
|
||||
)
|
||||
const entryResult = await client.query<CreditEntryRow>(
|
||||
`
|
||||
INSERT INTO platform_credit_entries (
|
||||
account_id, entry_type, quantity_delta, balance_after, amount_fen,
|
||||
platform_ref, note, created_at
|
||||
) VALUES ($1, 'grant', $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
`,
|
||||
[account.id, quantity, nextBalance, quantity * unitPriceFen, platformRef, note, now],
|
||||
)
|
||||
return {
|
||||
accountId: Number(account.id),
|
||||
platformKey,
|
||||
quantity,
|
||||
balanceAfter: nextBalance,
|
||||
amountFen: quantity * unitPriceFen,
|
||||
entryId: Number(entryResult.rows[0]?.id || 0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 人工调整余额(正负都允许,但不能把余额压成负数)。 */
|
||||
export async function adjustPlatformCredit(payload: JsonObject, session: AdminSession) {
|
||||
if (!isPrivileged(session)) {
|
||||
throw createHttpError('只有管理员或财务可以调整积分', {
|
||||
statusCode: 403,
|
||||
errorCode: 'platform_credit_adjust_forbidden',
|
||||
})
|
||||
}
|
||||
const platformKey = normalizePlatformKey(payload.platformKey ?? payload.platform_key)
|
||||
const ownerAdminUserId = normalizeRequiredId(
|
||||
payload.ownerAdminUserId ?? payload.owner_admin_user_id,
|
||||
'请选择客服',
|
||||
)
|
||||
const quantity = normalizeNonZeroQuantity(payload.quantity)
|
||||
const note = optionalText(payload.note, 300)
|
||||
if (!note) {
|
||||
throw createHttpError('调整积分必须填写原因', {
|
||||
statusCode: 400,
|
||||
errorCode: 'platform_credit_adjust_note_required',
|
||||
})
|
||||
}
|
||||
const unitPrices = await loadUnitPrices()
|
||||
const unitPriceFen = unitPrices[platformKey]
|
||||
const now = nowIso()
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
await ensureAccountRow(client, ownerAdminUserId, platformKey, unitPriceFen, now)
|
||||
const account = await lockAccount(client, ownerAdminUserId, platformKey)
|
||||
const nextBalance = Number(account.balance_quantity) + quantity
|
||||
if (nextBalance < 0) {
|
||||
throw createHttpError('调整后积分余额不能为负', {
|
||||
statusCode: 409,
|
||||
errorCode: 'platform_credit_balance_negative',
|
||||
})
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE platform_credit_accounts
|
||||
SET balance_quantity = $2, updated_at = $3
|
||||
WHERE id = $1`,
|
||||
[account.id, nextBalance, now],
|
||||
)
|
||||
const entryResult = await client.query<CreditEntryRow>(
|
||||
`
|
||||
INSERT INTO platform_credit_entries (
|
||||
account_id, entry_type, quantity_delta, balance_after, amount_fen,
|
||||
note, created_at
|
||||
) VALUES ($1, 'adjust', $2, $3, $4, $5, $6)
|
||||
RETURNING *
|
||||
`,
|
||||
[account.id, quantity, nextBalance, quantity * unitPriceFen, note, now],
|
||||
)
|
||||
return {
|
||||
accountId: Number(account.id),
|
||||
platformKey,
|
||||
quantity,
|
||||
balanceAfter: nextBalance,
|
||||
amountFen: quantity * unitPriceFen,
|
||||
entryId: Number(entryResult.rows[0]?.id || 0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 消耗积分:客服履约时扣自己的某个平台钱包。
|
||||
*
|
||||
* 余额不足直接拒绝(409),不允许负余额。
|
||||
*/
|
||||
export async function consumePlatformCredit(input: {
|
||||
ownerAdminUserId: number
|
||||
platformKey: PlatformCreditKey
|
||||
quantity: number
|
||||
relatedTaskId?: number | null
|
||||
platformRef?: string
|
||||
note?: string
|
||||
}): Promise<{ entryId: number; balanceAfter: number; amountFen: number }> {
|
||||
const unitPrices = await loadUnitPrices()
|
||||
const unitPriceFen = unitPrices[input.platformKey]
|
||||
const now = nowIso()
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
await ensureAccountRow(client, input.ownerAdminUserId, input.platformKey, unitPriceFen, now)
|
||||
const account = await lockAccount(client, input.ownerAdminUserId, input.platformKey)
|
||||
const balance = Number(account.balance_quantity)
|
||||
if (balance < input.quantity) {
|
||||
throw createHttpError(
|
||||
`${PLATFORM_LABELS[input.platformKey]}积分不足(当前 ${balance},需要 ${input.quantity}),请联系主管充值`,
|
||||
{
|
||||
statusCode: 409,
|
||||
errorCode: 'platform_credit_balance_insufficient',
|
||||
},
|
||||
)
|
||||
}
|
||||
const nextBalance = balance - input.quantity
|
||||
await client.query(
|
||||
`UPDATE platform_credit_accounts
|
||||
SET balance_quantity = $2, updated_at = $3
|
||||
WHERE id = $1`,
|
||||
[account.id, nextBalance, now],
|
||||
)
|
||||
const entryResult = await client.query<CreditEntryRow>(
|
||||
`
|
||||
INSERT INTO platform_credit_entries (
|
||||
account_id, entry_type, quantity_delta, balance_after, amount_fen,
|
||||
platform_ref, related_task_id, note, created_at
|
||||
) VALUES ($1, 'consume', $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
account.id,
|
||||
-input.quantity,
|
||||
nextBalance,
|
||||
input.quantity * unitPriceFen,
|
||||
input.platformRef || '',
|
||||
input.relatedTaskId ?? null,
|
||||
input.note || '',
|
||||
now,
|
||||
],
|
||||
)
|
||||
return {
|
||||
entryId: Number(entryResult.rows[0]?.id || 0),
|
||||
balanceAfter: nextBalance,
|
||||
amountFen: input.quantity * unitPriceFen,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 积分流水列表。 */
|
||||
export async function listPlatformCreditEntries(
|
||||
input: Record<string, unknown>,
|
||||
session: AdminSession,
|
||||
) {
|
||||
const unitPrices = await loadUnitPrices()
|
||||
const page = normalizePositiveInteger(input.page, 1, '页码')
|
||||
const pageSize = Math.min(
|
||||
200,
|
||||
normalizePositiveInteger(input.pageSize ?? input.page_size, 20, '每页数量'),
|
||||
)
|
||||
const params: unknown[] = []
|
||||
const clauses: string[] = []
|
||||
|
||||
if (!isPrivileged(session)) {
|
||||
params.push(session.userId)
|
||||
clauses.push(`pca.owner_admin_user_id = $${params.length}`)
|
||||
}
|
||||
const platformKey = String(input.platformKey || '').trim()
|
||||
if (platformKey) {
|
||||
params.push(platformKey)
|
||||
clauses.push(`pca.platform_key = $${params.length}`)
|
||||
}
|
||||
const entryType = String(input.entryType || '').trim()
|
||||
if (entryType) {
|
||||
params.push(entryType)
|
||||
clauses.push(`pce.entry_type = $${params.length}`)
|
||||
}
|
||||
const keyword = String(input.keyword || '').trim()
|
||||
if (keyword) {
|
||||
params.push(`%${escapeLike(keyword)}%`)
|
||||
const placeholder = `$${params.length}`
|
||||
clauses.push(`(pce.note ILIKE ${placeholder} OR pce.platform_ref ILIKE ${placeholder})`)
|
||||
}
|
||||
|
||||
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
|
||||
const countResult = await query<{ total: number }>(
|
||||
`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM platform_credit_entries pce
|
||||
JOIN platform_credit_accounts pca ON pca.id = pce.account_id
|
||||
${where}
|
||||
`,
|
||||
params,
|
||||
)
|
||||
const listParams = [...params, pageSize, (page - 1) * pageSize]
|
||||
const result = await query<CreditEntryRow>(
|
||||
`
|
||||
SELECT
|
||||
pce.*, pca.platform_key,
|
||||
COALESCE(NULLIF(au.display_name, ''), au.username) AS owner_name
|
||||
FROM platform_credit_entries pce
|
||||
JOIN platform_credit_accounts pca ON pca.id = pce.account_id
|
||||
LEFT JOIN admin_users au ON au.id = pca.owner_admin_user_id
|
||||
${where}
|
||||
ORDER BY pce.created_at DESC, pce.id DESC
|
||||
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}
|
||||
`,
|
||||
listParams,
|
||||
)
|
||||
|
||||
return {
|
||||
items: result.rows.map((row) => mapEntry(row, unitPrices)),
|
||||
pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) },
|
||||
}
|
||||
}
|
||||
|
||||
/** 可选客服列表(充值/调整时选人)。 */
|
||||
export async function listPlatformCreditOwners() {
|
||||
const result = await query<{
|
||||
id: number
|
||||
username: string
|
||||
display_name: string
|
||||
}>(
|
||||
`
|
||||
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
|
||||
`,
|
||||
)
|
||||
return {
|
||||
items: result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
username: row.username,
|
||||
displayName: row.display_name,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAccountRow(
|
||||
client: PoolClient,
|
||||
ownerAdminUserId: number,
|
||||
platformKey: PlatformCreditKey,
|
||||
unitPriceFen: number,
|
||||
now: string,
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO platform_credit_accounts (
|
||||
platform_key, owner_admin_user_id, balance_quantity, unit_name,
|
||||
recharge_unit_price_fen, status, created_at, updated_at
|
||||
) VALUES ($1, $2, 0, '积分', $3, 'active', $4, $4)
|
||||
ON CONFLICT (owner_admin_user_id, platform_key) DO NOTHING
|
||||
`,
|
||||
[platformKey, ownerAdminUserId, unitPriceFen, now],
|
||||
)
|
||||
}
|
||||
|
||||
async function lockAccount(
|
||||
client: PoolClient,
|
||||
ownerAdminUserId: number,
|
||||
platformKey: PlatformCreditKey,
|
||||
) {
|
||||
const result = await client.query<CreditAccountRow>(
|
||||
`
|
||||
SELECT * FROM platform_credit_accounts
|
||||
WHERE owner_admin_user_id = $1 AND platform_key = $2
|
||||
FOR UPDATE
|
||||
`,
|
||||
[ownerAdminUserId, platformKey],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
if (!row) {
|
||||
throw createHttpError('积分账户不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'platform_credit_account_not_found',
|
||||
})
|
||||
}
|
||||
if (row.status !== 'active') {
|
||||
throw createHttpError('积分账户已冻结或关闭', {
|
||||
statusCode: 409,
|
||||
errorCode: 'platform_credit_account_inactive',
|
||||
})
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function mapAccount(row: CreditAccountRow, unitPrices: Record<PlatformCreditKey, number>) {
|
||||
const platformKey = row.platform_key as PlatformCreditKey
|
||||
const unitPriceFen = Number(row.recharge_unit_price_fen || unitPrices[platformKey] || 1)
|
||||
return {
|
||||
id: Number(row.id),
|
||||
platformKey,
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || row.platform_key,
|
||||
ownerAdminUserId: Number(row.owner_admin_user_id),
|
||||
ownerName: row.owner_name || row.username || '',
|
||||
balanceQuantity: Number(row.balance_quantity || 0),
|
||||
balanceAmountFen: Number(row.balance_quantity || 0) * unitPriceFen,
|
||||
unitName: row.unit_name || '积分',
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapEntry(row: CreditEntryRow, unitPrices: Record<PlatformCreditKey, number>) {
|
||||
const platformKey = (row.platform_key || '') as PlatformCreditKey
|
||||
return {
|
||||
id: Number(row.id),
|
||||
accountId: Number(row.account_id),
|
||||
platformKey,
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
|
||||
ownerName: row.owner_name || '',
|
||||
entryType: row.entry_type,
|
||||
quantityDelta: Number(row.quantity_delta || 0),
|
||||
balanceAfter: Number(row.balance_after || 0),
|
||||
amountFen: Number(row.amount_fen || 0),
|
||||
platformRef: row.platform_ref || '',
|
||||
relatedTaskId: row.related_task_id ? Number(row.related_task_id) : null,
|
||||
note: row.note || '',
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlatformKey(value: unknown): PlatformCreditKey {
|
||||
const text = String(value || '').trim()
|
||||
if (!PLATFORM_CREDIT_KEYS.includes(text as PlatformCreditKey)) {
|
||||
throw createHttpError('积分平台无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'platform_credit_platform_invalid',
|
||||
})
|
||||
}
|
||||
return text as PlatformCreditKey
|
||||
}
|
||||
|
||||
function normalizeRequiredId(value: unknown, message: string) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw createHttpError(message, {
|
||||
statusCode: 400,
|
||||
errorCode: 'platform_credit_owner_invalid',
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizePositiveQuantity(value: unknown) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw createHttpError('积分数必须是正整数', {
|
||||
statusCode: 400,
|
||||
errorCode: 'platform_credit_quantity_invalid',
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizeNonZeroQuantity(value: unknown) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed === 0) {
|
||||
throw createHttpError('调整积分数必须是非零整数', {
|
||||
statusCode: 400,
|
||||
errorCode: 'platform_credit_quantity_invalid',
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number, label: string) {
|
||||
const parsed = Number(value)
|
||||
if (!value || !Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
|
||||
if (!value) return fallback
|
||||
throw createHttpError(`${label}必须是正整数`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'platform_credit_pagination_invalid',
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function optionalText(value: unknown, maxLength: number) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
}
|
||||
|
||||
function escapeLike(value: string) {
|
||||
return value.replace(/[\\%_]/g, (char) => `\\${char}`)
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
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 {
|
||||
consumePlatformCredit,
|
||||
type PlatformCreditKey,
|
||||
} from '../cost-center/platform-credit-service.js'
|
||||
|
||||
/**
|
||||
* 人工履约待办与「确认履约」。
|
||||
*
|
||||
* 口径(2026-10-24 确认):
|
||||
* * 客服人工履约有四个钱包:南航积分 / 乐玩积分 / ROCK 积分 / 备用金现金;
|
||||
* * 每个内部 SKU 的「渠道 + 消耗积分 + 成本」在成本中心提前配好,客服不选;
|
||||
* * 客服点「确认履约」:固定成本 → 零输入;成本不固定 → 只填金额;
|
||||
* * 消耗积分从对应平台的积分钱包扣,备用金现金走现有 fund_* 链路;
|
||||
* * 同时写 cost_snapshots(actual),成本 = 消耗积分 × 平台换算率。
|
||||
*
|
||||
* 「已履约」的判定:order_items 上存在该订单项的有效成本快照。
|
||||
* 人工单不建 fulfillment_tasks —— 那是自动发货通道的体系,人工单历来不走它。
|
||||
*/
|
||||
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
nanhang: '南航平台',
|
||||
lewan: '乐玩后台',
|
||||
rock: 'ROCK 后台',
|
||||
fund_cash: '备用金现金',
|
||||
}
|
||||
|
||||
type PendingRow = {
|
||||
order_item_id: number
|
||||
order_id: number
|
||||
platform_order_id: string
|
||||
provider: string
|
||||
shop_id: string
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
internal_sku: string
|
||||
revenue_fen: string
|
||||
paid_at: string
|
||||
config_id: number
|
||||
platform_key: string
|
||||
credit_quantity: string
|
||||
cost_mode: string
|
||||
credit_unit_price_fen: string
|
||||
fulfilled_at: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出客服的人工履约待办。
|
||||
*
|
||||
* 只看「已配好渠道」的人工订单项;未配置的 SKU 不出现在待办里
|
||||
* (那是配置缺口,不是客服能处理的)。
|
||||
*/
|
||||
export async function listManualFulfillmentPending(
|
||||
input: Record<string, unknown> = {},
|
||||
session: AdminSession,
|
||||
) {
|
||||
const page = normalizePositiveInteger(input.page, 1, '页码')
|
||||
const pageSize = Math.min(
|
||||
100,
|
||||
normalizePositiveInteger(input.pageSize ?? input.page_size, 20, '每页数量'),
|
||||
)
|
||||
const keyword = String(input.keyword || '').trim()
|
||||
const status = String(input.status || '').trim()
|
||||
|
||||
const conditions = ["oi.fulfillment_mode = 'manual'", 'cfg.id IS NOT NULL']
|
||||
const params: unknown[] = []
|
||||
|
||||
if (status === 'pending') {
|
||||
conditions.push('fulfilled.fulfilled_at IS NULL')
|
||||
} else if (status === 'fulfilled') {
|
||||
conditions.push('fulfilled.fulfilled_at IS NOT NULL')
|
||||
}
|
||||
if (keyword) {
|
||||
params.push(`%${escapeLike(keyword)}%`)
|
||||
const placeholder = `$${params.length}`
|
||||
conditions.push(
|
||||
`(oi.sku_name ILIKE ${placeholder}
|
||||
OR oi.sku_code ILIKE ${placeholder}
|
||||
OR o.platform_order_id ILIKE ${placeholder}
|
||||
OR COALESCE(NULLIF(oi.spec_json->>'skuNick',''), NULLIF(oi.spec_json->>'outSkuSn','')) ILIKE ${placeholder})`,
|
||||
)
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ')
|
||||
const baseCte = `
|
||||
WITH fulfilled AS (
|
||||
SELECT
|
||||
cs.order_item_id,
|
||||
MAX(cs.created_at) AS fulfilled_at
|
||||
FROM cost_snapshots cs
|
||||
WHERE cs.subject_type = 'manual_case' AND cs.superseded_at IS NULL
|
||||
GROUP BY cs.order_item_id
|
||||
)
|
||||
`
|
||||
const countResult = await query<{ total: number }>(
|
||||
`${baseCte}
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM order_items 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
|
||||
WHERE ${where}`,
|
||||
params,
|
||||
)
|
||||
|
||||
const listParams = [...params, pageSize, (page - 1) * pageSize]
|
||||
const result = await query<PendingRow>(
|
||||
`${baseCte}
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
oi.order_id,
|
||||
o.platform_order_id,
|
||||
o.provider,
|
||||
o.shop_id,
|
||||
oi.sku_code,
|
||||
oi.sku_name,
|
||||
COALESCE(NULLIF(oi.spec_json->>'skuNick',''), NULLIF(oi.spec_json->>'outSkuSn','')) AS internal_sku,
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
COALESCE(o.paid_at, o.created_at) AS paid_at,
|
||||
cfg.id AS config_id,
|
||||
cfg.platform_key,
|
||||
cfg.credit_quantity,
|
||||
cfg.cost_mode,
|
||||
COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen,
|
||||
fulfilled.fulfilled_at
|
||||
FROM order_items 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
|
||||
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
|
||||
WHERE ${where}
|
||||
ORDER BY fulfilled.fulfilled_at NULLS FIRST, oi.id DESC
|
||||
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}`,
|
||||
listParams,
|
||||
)
|
||||
|
||||
return {
|
||||
items: result.rows.map(mapPending),
|
||||
pagination: { page, pageSize, total: Number(countResult.rows[0]?.total || 0) },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认履约:扣额度 + 写成本快照。
|
||||
*
|
||||
* 固定成本(消耗积分 > 0):直接用配置值,零输入。
|
||||
* 成本不固定(消耗积分 = 0):客服必须填实际成本金额。
|
||||
*/
|
||||
export async function confirmManualFulfillment(payload: JsonObject, session: AdminSession) {
|
||||
const orderItemId = normalizeRequiredId(
|
||||
payload.orderItemId ?? payload.order_item_id,
|
||||
'请选择订单项',
|
||||
)
|
||||
const now = nowIso()
|
||||
|
||||
const context = await query<{
|
||||
order_item_id: number
|
||||
order_id: number
|
||||
order_id_resolved: number
|
||||
platform_order_id: string
|
||||
provider: string
|
||||
shop_id: string
|
||||
sku_code: string
|
||||
sku_name: string
|
||||
revenue_fen: string
|
||||
canonical_product_id: number | null
|
||||
fulfillment_mode: string
|
||||
config_id: number | null
|
||||
platform_key: string
|
||||
credit_quantity: string
|
||||
cost_mode: string
|
||||
credit_unit_price_fen: string
|
||||
work_order_id: number | null
|
||||
work_product_rule_id: number | null
|
||||
fulfilled_at: string | null
|
||||
}>(
|
||||
`
|
||||
WITH fulfilled AS (
|
||||
SELECT cs.order_item_id, MAX(cs.created_at) AS fulfilled_at
|
||||
FROM cost_snapshots cs
|
||||
WHERE cs.subject_type = 'manual_case' AND cs.superseded_at IS NULL
|
||||
GROUP BY cs.order_item_id
|
||||
)
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
oi.order_id,
|
||||
o.id AS order_id_resolved,
|
||||
o.platform_order_id,
|
||||
o.provider,
|
||||
o.shop_id,
|
||||
oi.sku_code,
|
||||
oi.sku_name,
|
||||
COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen,
|
||||
oi.canonical_product_id,
|
||||
oi.fulfillment_mode,
|
||||
oi.fulfillment_config_id AS config_id,
|
||||
COALESCE(cfg.platform_key, '') AS platform_key,
|
||||
COALESCE(cfg.credit_quantity, 0)::bigint AS credit_quantity,
|
||||
COALESCE(cfg.cost_mode, 'variable') AS cost_mode,
|
||||
COALESCE((fp.config_json->>'rechargeUnitPriceFen')::bigint, 1) AS credit_unit_price_fen,
|
||||
wo.id AS work_order_id,
|
||||
wo.product_rule_id AS work_product_rule_id,
|
||||
fulfilled.fulfilled_at
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
|
||||
LEFT JOIN fulfillment_profiles fp ON fp.profile_key = 'manual_' || cfg.platform_key
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, product_rule_id FROM work_orders
|
||||
WHERE order_item_id = oi.id ORDER BY id DESC LIMIT 1
|
||||
) wo ON TRUE
|
||||
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
|
||||
WHERE oi.id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[orderItemId],
|
||||
)
|
||||
|
||||
const row = context.rows[0]
|
||||
if (!row) {
|
||||
throw createHttpError('订单项不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'manual_fulfillment_order_item_not_found',
|
||||
})
|
||||
}
|
||||
if (row.fulfillment_mode !== 'manual') {
|
||||
throw createHttpError('该订单项不是人工履约', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_not_manual',
|
||||
})
|
||||
}
|
||||
if (row.fulfilled_at) {
|
||||
throw createHttpError('该订单项已确认过履约,不能重复操作', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_already_done',
|
||||
})
|
||||
}
|
||||
if (!row.config_id) {
|
||||
throw createHttpError('该商品还没有配置履约渠道,请先在成本中心配置', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_config_missing',
|
||||
})
|
||||
}
|
||||
|
||||
const platformKey = row.platform_key as PlatformCreditKey | 'fund_cash'
|
||||
const creditQuantity = Number(row.credit_quantity || 0)
|
||||
const unitPriceFen = Number(row.credit_unit_price_fen || 1)
|
||||
|
||||
// 成本:固定成本用配置值;成本不固定必须客服手填。
|
||||
let costFen: number
|
||||
if (creditQuantity > 0) {
|
||||
costFen = creditQuantity * unitPriceFen
|
||||
} else {
|
||||
const provided = payload.costFen ?? payload.cost_fen ?? payload.amountFen
|
||||
costFen = normalizeCostFen(provided)
|
||||
if (costFen === 0) {
|
||||
throw createHttpError('该商品成本不固定,请填写本次实际成本', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_fulfillment_cost_required',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const note = optionalText(payload.note, 300)
|
||||
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120)
|
||||
|
||||
// 1. 先扣额度(积分平台走积分钱包;备用金现金走现有 fund_* 链路)。
|
||||
let creditEntryId: number | null = null
|
||||
let fundExpenseId: number | null = null
|
||||
let balanceAfter: number | null = null
|
||||
|
||||
if (platformKey === 'fund_cash') {
|
||||
// 备用金现金:复用现有客服备用金支出链路(扣余额 + 写 fund_entries + manual_expenses)。
|
||||
const { writeFundExpense } = await import('../admin/admin-fund-service.js')
|
||||
const fundAccountId = await resolveFundAccountId(session)
|
||||
const written = await withTransaction(async (client) => {
|
||||
return writeFundExpense(client, {
|
||||
agentAccountId: fundAccountId,
|
||||
amountFen: costFen,
|
||||
category: 'other',
|
||||
payeeType: 'other',
|
||||
payeeName: payeeName || '人工履约',
|
||||
orderId: Number(row.order_id_resolved),
|
||||
orderItemId,
|
||||
paidAt: now,
|
||||
evidence: [],
|
||||
note: buildFundNote(row.sku_name, note),
|
||||
actorUserId: session.userId,
|
||||
})
|
||||
})
|
||||
fundExpenseId = Number(written.expense.id)
|
||||
balanceAfter = written.balanceAfterFen
|
||||
} else if (creditQuantity > 0) {
|
||||
const consumed = await consumePlatformCredit({
|
||||
ownerAdminUserId: session.userId,
|
||||
platformKey: platformKey as PlatformCreditKey,
|
||||
quantity: creditQuantity,
|
||||
relatedTaskId: null,
|
||||
note: buildCreditNote(row.sku_name, note),
|
||||
})
|
||||
creditEntryId = consumed.entryId
|
||||
balanceAfter = consumed.balanceAfter
|
||||
}
|
||||
|
||||
// 2. 写 actual 成本快照(主体 = 备用金支出单 / 积分流水)。
|
||||
const subjectId = fundExpenseId ?? creditEntryId ?? orderItemId
|
||||
const costItemId = await resolveManualCostItemId()
|
||||
const versionId = await resolveCostVersionId(costItemId, row, now)
|
||||
|
||||
if (!versionId) {
|
||||
throw createHttpError('该成本项没有可用的成本版本,请先在成本中心配置后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_cost_version_missing',
|
||||
})
|
||||
}
|
||||
|
||||
const snapshot = await query<{ id: number }>(
|
||||
`
|
||||
INSERT INTO cost_snapshots (
|
||||
subject_type, subject_id, order_id, order_item_id, work_order_id,
|
||||
cost_item_id, cost_version_id, canonical_product_id,
|
||||
quantity, unit_amount_fen, total_amount_fen, snapshot_stage, source,
|
||||
metadata_json, cost_basis_at, resolved_at, created_at
|
||||
) VALUES (
|
||||
'manual_case', $1, $2, $3, $4,
|
||||
$5, $6, $7,
|
||||
1, $8, $8, 'actual', 'manual_input',
|
||||
$9::jsonb, $10, $11, $11
|
||||
)
|
||||
ON CONFLICT (subject_type, subject_id, cost_item_id)
|
||||
WHERE superseded_at IS NULL
|
||||
DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
subjectId,
|
||||
row.order_id_resolved,
|
||||
orderItemId,
|
||||
row.work_order_id,
|
||||
costItemId,
|
||||
versionId,
|
||||
row.canonical_product_id,
|
||||
costFen,
|
||||
JSON.stringify({
|
||||
source: 'manual_fulfillment_confirm',
|
||||
platformKey,
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
|
||||
creditQuantity,
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
costMode: creditQuantity > 0 ? 'fixed' : 'variable',
|
||||
costFen,
|
||||
fundExpenseId,
|
||||
creditEntryId,
|
||||
payeeName,
|
||||
note,
|
||||
fulfilledBy: {
|
||||
userId: Number(session.userId || 0),
|
||||
displayName: String(session.displayName || ''),
|
||||
role: String(session.role || ''),
|
||||
},
|
||||
}),
|
||||
now,
|
||||
nowIso(),
|
||||
],
|
||||
)
|
||||
const snapshotId = snapshot.rows[0]?.id ?? null
|
||||
if (!snapshotId) {
|
||||
throw createHttpError('该订单项已有人工成本快照,不能重复录入', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_snapshot_conflict',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
orderItemId,
|
||||
orderId: Number(row.order_id_resolved),
|
||||
costSnapshotId: snapshotId,
|
||||
platformKey,
|
||||
platformLabel: PLATFORM_LABELS[platformKey] || platformKey,
|
||||
creditQuantity,
|
||||
costFen,
|
||||
fundExpenseId,
|
||||
creditEntryId,
|
||||
balanceAfter,
|
||||
}
|
||||
}
|
||||
|
||||
/** 客服自己的备用金账户(管理员代录时用指定账户)。 */
|
||||
async function resolveFundAccountId(session: AdminSession) {
|
||||
const { getOwnAgentAccountId } = await import('../admin/admin-fund-service.js')
|
||||
const own = await getOwnAgentAccountId(session.userId)
|
||||
if (own) return own
|
||||
throw createHttpError('当前账号还没有备用金账户', {
|
||||
statusCode: 404,
|
||||
errorCode: 'fund_agent_account_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveManualCostItemId() {
|
||||
const result = await query<{ id: number }>(
|
||||
`SELECT id FROM cost_items WHERE code = 'sys_manual_service' AND status = 'active' LIMIT 1`,
|
||||
)
|
||||
const id = Number(result.rows[0]?.id || 0)
|
||||
if (!id) {
|
||||
throw createHttpError('人工客服履约成本项未启用', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_fulfillment_cost_item_unavailable',
|
||||
})
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
async function resolveCostVersionId(
|
||||
costItemId: number,
|
||||
row: {
|
||||
order_id_resolved: number
|
||||
provider: string
|
||||
shop_id: string
|
||||
canonical_product_id: number | null
|
||||
work_product_rule_id: number | null
|
||||
credit_unit_price_fen: string
|
||||
},
|
||||
basisAt: string,
|
||||
) {
|
||||
const result = await query<{ id: number }>(
|
||||
`
|
||||
SELECT v.id
|
||||
FROM cost_versions v
|
||||
WHERE v.cost_item_id = $1
|
||||
AND (
|
||||
v.status = 'active'
|
||||
OR (v.status = 'draft' AND EXISTS (
|
||||
SELECT 1 FROM cost_items ci
|
||||
WHERE ci.id = v.cost_item_id AND ci.code = 'sys_manual_service'
|
||||
))
|
||||
)
|
||||
AND v.effective_from <= $2
|
||||
AND (v.effective_to IS NULL OR v.effective_to > $2)
|
||||
AND (
|
||||
(v.scope_type = 'rule' AND $6::bigint IS NOT NULL AND v.work_product_rule_id = $6)
|
||||
OR (v.scope_type = 'product' AND $5::bigint IS NOT NULL AND v.canonical_product_id = $5)
|
||||
OR (v.scope_type = 'shop' AND v.provider = $3 AND v.shop_id = $4)
|
||||
OR (v.scope_type = 'provider' AND v.provider = $3)
|
||||
OR (v.scope_type = 'global')
|
||||
)
|
||||
ORDER BY
|
||||
CASE v.scope_type
|
||||
WHEN 'rule' THEN 1
|
||||
WHEN 'product' THEN 2
|
||||
WHEN 'shop' THEN 3
|
||||
WHEN 'provider' THEN 4
|
||||
ELSE 5
|
||||
END,
|
||||
v.effective_from DESC,
|
||||
v.id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[
|
||||
costItemId,
|
||||
basisAt,
|
||||
row.provider,
|
||||
row.shop_id,
|
||||
row.canonical_product_id,
|
||||
row.work_product_rule_id,
|
||||
],
|
||||
)
|
||||
return result.rows[0]?.id ?? null
|
||||
}
|
||||
|
||||
function mapPending(row: PendingRow) {
|
||||
const creditQuantity = Number(row.credit_quantity || 0)
|
||||
const unitPriceFen = Number(row.credit_unit_price_fen || 1)
|
||||
const isFundCash = row.platform_key === 'fund_cash'
|
||||
return {
|
||||
orderItemId: Number(row.order_item_id),
|
||||
orderId: Number(row.order_id),
|
||||
platformOrderId: row.platform_order_id || '',
|
||||
provider: row.provider || '',
|
||||
shopId: row.shop_id || '',
|
||||
skuCode: row.sku_code || '',
|
||||
skuName: row.sku_name || '',
|
||||
internalSku: row.internal_sku || '',
|
||||
revenueFen: Number(row.revenue_fen || 0),
|
||||
paidAt: row.paid_at,
|
||||
configId: Number(row.config_id),
|
||||
platformKey: row.platform_key,
|
||||
platformLabel: PLATFORM_LABELS[row.platform_key] || row.platform_key,
|
||||
isFundCash,
|
||||
creditQuantity,
|
||||
creditUnitPriceFen: unitPriceFen,
|
||||
costMode: row.cost_mode || 'variable',
|
||||
/** 固定成本的金额;成本不固定时为 null,客服要手填。 */
|
||||
costFen: creditQuantity > 0 ? creditQuantity * unitPriceFen : null,
|
||||
fulfilled: Boolean(row.fulfilled_at),
|
||||
fulfilledAt: row.fulfilled_at || null,
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
throw createHttpError('实际成本金额无效', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_fulfillment_cost_invalid',
|
||||
})
|
||||
}
|
||||
return Math.trunc(parsed)
|
||||
}
|
||||
|
||||
function normalizeRequiredId(value: unknown, message: string) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw createHttpError(message, {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_fulfillment_id_invalid',
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number, label: string) {
|
||||
const parsed = Number(value)
|
||||
if (!value || !Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
|
||||
if (!value) return fallback
|
||||
throw createHttpError(`${label}必须是正整数`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_fulfillment_pagination_invalid',
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function optionalText(value: unknown, maxLength: number) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
}
|
||||
|
||||
function escapeLike(value: string) {
|
||||
return value.replace(/[\\%_]/g, (char) => `\\${char}`)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage')
|
||||
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
|
||||
const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage'))
|
||||
const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage'))
|
||||
const AdminManualFulfillmentPage = lazy(() => import('@/pages/admin/AdminManualFulfillmentPage'))
|
||||
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
|
||||
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
|
||||
const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage'))
|
||||
@@ -144,6 +145,7 @@ function AdminApplication() {
|
||||
<Route path="orders/:orderId" element={<AdminOrderDetailPage />} />
|
||||
<Route path="tasks" element={<AdminTasksPage />} />
|
||||
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
|
||||
<Route path="manual-fulfillment" element={<AdminManualFulfillmentPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireRole roles={['admin', 'operator', 'finance']} />}>
|
||||
<Route path="finance" element={<AdminFinancePage />} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
AuditOutlined,
|
||||
AccountBookOutlined,
|
||||
ApartmentOutlined,
|
||||
@@ -162,6 +163,11 @@ export default function AdminLayout() {
|
||||
},
|
||||
]),
|
||||
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
|
||||
{
|
||||
key: '/admin/manual-fulfillment',
|
||||
icon: <CheckCircleOutlined />,
|
||||
label: '人工履约',
|
||||
},
|
||||
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '接单平台' },
|
||||
{
|
||||
key: '/admin/kuaishou-industry',
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* 人工履约待办页:客服确认履约。
|
||||
*
|
||||
* 口径(2026-10-24 确认):
|
||||
* * 渠道与消耗积分在成本中心提前配好,客服不选、不填;
|
||||
* * 固定成本 → 点「确认履约」零输入;
|
||||
* * 成本不固定(消耗积分 = 0)→ 只填本次实际成本;
|
||||
* * 确认后自动扣对应平台的积分并写成本快照。
|
||||
*/
|
||||
import { CheckOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import { confirmAdminManualFulfillment, fetchAdminManualFulfillmentPending } from '@/services/admin'
|
||||
import type { ManualFulfillmentPendingItem } from '@/types/admin'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type ConfirmFormValues = {
|
||||
costYuan?: number
|
||||
payeeName?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
const PLATFORM_TAG_COLORS: Record<string, string> = {
|
||||
nanhang: 'blue',
|
||||
lewan: 'purple',
|
||||
rock: 'cyan',
|
||||
fund_cash: 'orange',
|
||||
}
|
||||
|
||||
export default function AdminManualFulfillmentPage() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<ConfirmFormValues>()
|
||||
|
||||
const [status, setStatus] = useState<'pending' | 'fulfilled' | ''>('pending')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||||
const [confirming, setConfirming] = useState<ManualFulfillmentPendingItem | null>(null)
|
||||
const [showCostInput, setShowCostInput] = useState(false)
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ['admin-manual-fulfillment-pending', status, keyword, page, pageSize],
|
||||
queryFn: () =>
|
||||
fetchAdminManualFulfillmentPending({
|
||||
status: status || undefined,
|
||||
keyword: keyword || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
})
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: (values: ConfirmFormValues) =>
|
||||
confirmAdminManualFulfillment({
|
||||
orderItemId: confirming?.orderItemId || 0,
|
||||
costFen: values.costYuan === undefined ? undefined : Math.round(values.costYuan * 100),
|
||||
payeeName: values.payeeName || '',
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
message.success(
|
||||
`履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
|
||||
)
|
||||
setConfirming(null)
|
||||
setShowCostInput(false)
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin-manual-fulfillment-pending'],
|
||||
})
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '确认履约失败')
|
||||
},
|
||||
})
|
||||
|
||||
function openConfirm(item: ManualFulfillmentPendingItem) {
|
||||
setConfirming(item)
|
||||
setShowCostInput(item.costFen === null)
|
||||
form.resetFields()
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<ManualFulfillmentPendingItem> = [
|
||||
{
|
||||
title: '商品',
|
||||
key: 'sku',
|
||||
width: 260,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis={{ tooltip: row.skuName }}>
|
||||
{row.skuName || row.skuCode}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.internalSku || row.skuCode}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '平台单号',
|
||||
dataIndex: 'platformOrderId',
|
||||
width: 170,
|
||||
render: (value: string) => (
|
||||
<Typography.Text copyable={{ text: value }} style={{ fontSize: 12 }}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '履约渠道',
|
||||
key: 'platform',
|
||||
width: 110,
|
||||
render: (_, row) => (
|
||||
<Tag color={PLATFORM_TAG_COLORS[row.platformKey] || 'default'}>{row.platformLabel}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '消耗',
|
||||
key: 'credit',
|
||||
width: 120,
|
||||
render: (_, row) =>
|
||||
row.isFundCash ? (
|
||||
<Typography.Text type="secondary">—</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text>{row.creditQuantity} 积分</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '成本',
|
||||
key: 'cost',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: (_, row) =>
|
||||
row.costFen === null ? (
|
||||
<Tag color="orange">待填写</Tag>
|
||||
) : (
|
||||
<Typography.Text strong>¥{(row.costFen / 100).toFixed(2)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单金额',
|
||||
key: 'revenue',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (_, row) => `¥${(row.revenueFen / 100).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付时间',
|
||||
dataIndex: 'paidAt',
|
||||
width: 150,
|
||||
render: (value: string) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 110,
|
||||
fixed: 'right',
|
||||
render: (_, row) =>
|
||||
row.fulfilled ? (
|
||||
<Tag color="green">已履约</Tag>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => openConfirm(row)}
|
||||
>
|
||||
确认履约
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const total = listQuery.data?.data.pagination.total ?? 0
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PageHeader
|
||||
title="人工履约待办"
|
||||
description="渠道与消耗积分已提前配好,确认即扣额度并计入成本。"
|
||||
/>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="确认履约会从对应平台的积分钱包扣减,并写入成本快照"
|
||||
description="固定成本的商品直接确认即可;成本不固定的商品需要填写本次实际成本。"
|
||||
/>
|
||||
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void listQuery.refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
type={status === 'pending' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setStatus('pending')
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
待确认
|
||||
</Button>
|
||||
<Button
|
||||
type={status === 'fulfilled' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setStatus('fulfilled')
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
已履约
|
||||
</Button>
|
||||
<Button
|
||||
type={status === '' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setStatus('')
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Card bordered={false}>
|
||||
<Table
|
||||
rowKey="orderItemId"
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={listQuery.data?.data.items || []}
|
||||
loading={listQuery.isLoading}
|
||||
scroll={{ x: 1080 }}
|
||||
pagination={buildAdminTablePagination({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
if (nextPageSize !== pageSize) setPageSize(nextPageSize)
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={Boolean(confirming)}
|
||||
title="确认履约"
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
confirmLoading={confirmMutation.isPending}
|
||||
onOk={() => {
|
||||
void form.validateFields().then((values) => confirmMutation.mutate(values))
|
||||
}}
|
||||
onCancel={() => {
|
||||
setConfirming(null)
|
||||
setShowCostInput(false)
|
||||
}}
|
||||
destroyOnHidden
|
||||
width={520}
|
||||
>
|
||||
{confirming ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Text type="secondary">商品:</Typography.Text>
|
||||
<Typography.Text strong>{confirming.skuName || confirming.skuCode}</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">内部 SKU:</Typography.Text>
|
||||
<Typography.Text copyable={{ text: confirming.internalSku }}>
|
||||
{confirming.internalSku}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">履约渠道:</Typography.Text>
|
||||
<Tag color={PLATFORM_TAG_COLORS[confirming.platformKey] || 'default'}>
|
||||
{confirming.platformLabel}
|
||||
</Tag>
|
||||
{!confirming.isFundCash ? (
|
||||
<Typography.Text>消耗 {confirming.creditQuantity} 积分</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
{showCostInput ? (
|
||||
<Alert type="warning" showIcon message="该商品成本不固定,请填写本次实际成本" />
|
||||
) : (
|
||||
<Statistic
|
||||
title="本次成本"
|
||||
value={(confirming.costFen || 0) / 100}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
) : null}
|
||||
|
||||
<Form<ConfirmFormValues> form={form} layout="vertical">
|
||||
{showCostInput ? (
|
||||
<Form.Item
|
||||
name="costYuan"
|
||||
label="实际成本(元)"
|
||||
rules={[{ required: true, message: '请填写实际成本' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="例如 12.50"
|
||||
prefix="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item name="payeeName" label="收款方 / 处理人">
|
||||
<Input placeholder="例如:客服小王、上游商家" maxLength={120} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import dayjs from 'dayjs'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { PlatformCreditPanel } from './PlatformCreditPanel'
|
||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
@@ -1073,6 +1074,11 @@ export default function FundManagementView({ role }: { role: ManagementRole }) {
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'platform-credits',
|
||||
label: '平台积分',
|
||||
children: <PlatformCreditPanel />,
|
||||
},
|
||||
{
|
||||
key: 'expenses',
|
||||
label: '支出明细',
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
} from '@/types/admin'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { PlatformCreditPanel } from './PlatformCreditPanel'
|
||||
import {
|
||||
FUND_ACCOUNT_STATUS_LABELS,
|
||||
FUND_CATEGORY_LABELS,
|
||||
@@ -409,6 +410,11 @@ export default function FundSupportView() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'platform-credits',
|
||||
label: '平台积分',
|
||||
children: <PlatformCreditPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 平台积分钱包面板:南航 / 乐玩 / ROCK 三平台的客服积分。
|
||||
*
|
||||
* 口径(2026-10-24 确认):
|
||||
* * 三平台积分**互不通用**,每客服每平台一个钱包;
|
||||
* * 换算率按平台不同:南航、乐玩 1 积分 = 1 分,ROCK 1 积分 = 1 元;
|
||||
* * 余额只能由流水产生,不允许手工改;消耗不足直接拒绝。
|
||||
*
|
||||
* 客服看自己的积分余额与流水;管理员/财务可以充值、调整、看全部。
|
||||
*/
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
adjustAdminPlatformCredit,
|
||||
fetchAdminPlatformCreditAccounts,
|
||||
fetchAdminPlatformCreditEntries,
|
||||
grantAdminPlatformCredit,
|
||||
} from '@/services/admin'
|
||||
import type { PlatformCreditAccount, PlatformCreditEntry } from '@/types/admin'
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const PLATFORM_TAG_COLORS: Record<string, string> = {
|
||||
nanhang: 'blue',
|
||||
lewan: 'purple',
|
||||
rock: 'cyan',
|
||||
}
|
||||
|
||||
const ENTRY_TYPE_LABELS: Record<string, string> = {
|
||||
grant: '充值',
|
||||
consume: '消耗',
|
||||
adjust: '调整',
|
||||
return: '退回',
|
||||
}
|
||||
|
||||
const ENTRY_TYPE_COLORS: Record<string, string> = {
|
||||
grant: 'green',
|
||||
consume: 'red',
|
||||
adjust: 'gold',
|
||||
return: 'orange',
|
||||
}
|
||||
|
||||
type GrantFormValues = {
|
||||
ownerAdminUserId: number
|
||||
platformKey: string
|
||||
quantity: number
|
||||
platformRef?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
type AdjustFormValues = {
|
||||
ownerAdminUserId: number
|
||||
platformKey: string
|
||||
quantity: number
|
||||
note: string
|
||||
}
|
||||
|
||||
export function PlatformCreditPanel() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [grantForm] = Form.useForm<GrantFormValues>()
|
||||
const [adjustForm] = Form.useForm<AdjustFormValues>()
|
||||
|
||||
const [grantOpen, setGrantOpen] = useState(false)
|
||||
const [adjustOpen, setAdjustOpen] = useState(false)
|
||||
const [platformFilter, setPlatformFilter] = useState('')
|
||||
|
||||
const canManage = hasAdminPermission('finance.config')
|
||||
|
||||
const accountsQuery = useQuery({
|
||||
queryKey: ['admin-platform-credit-accounts'],
|
||||
queryFn: fetchAdminPlatformCreditAccounts,
|
||||
})
|
||||
const entriesQuery = useQuery({
|
||||
queryKey: ['admin-platform-credit-entries', platformFilter],
|
||||
queryFn: () =>
|
||||
fetchAdminPlatformCreditEntries({
|
||||
platformKey: platformFilter || undefined,
|
||||
pageSize: 50,
|
||||
}),
|
||||
})
|
||||
|
||||
const grantMutation = useMutation({
|
||||
mutationFn: (values: GrantFormValues) =>
|
||||
grantAdminPlatformCredit({
|
||||
ownerAdminUserId: values.ownerAdminUserId,
|
||||
platformKey: values.platformKey,
|
||||
quantity: values.quantity,
|
||||
platformRef: values.platformRef || '',
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
message.success(
|
||||
`已充值 ${result.quantity} 积分,折合 ¥${(result.amountFen / 100).toFixed(2)}`,
|
||||
)
|
||||
setGrantOpen(false)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '充值失败')
|
||||
},
|
||||
})
|
||||
|
||||
const adjustMutation = useMutation({
|
||||
mutationFn: (values: AdjustFormValues) =>
|
||||
adjustAdminPlatformCredit({
|
||||
ownerAdminUserId: values.ownerAdminUserId,
|
||||
platformKey: values.platformKey,
|
||||
quantity: values.quantity,
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
message.success(`已调整,余额 ${result.balanceAfter} 积分`)
|
||||
setAdjustOpen(false)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '调整失败')
|
||||
},
|
||||
})
|
||||
|
||||
const accounts = accountsQuery.data?.data.items || []
|
||||
const platforms = accountsQuery.data?.data.platforms || []
|
||||
const entries = entriesQuery.data?.data.items || []
|
||||
|
||||
const entryColumns: TableColumnsType<PlatformCreditEntry> = [
|
||||
{
|
||||
title: '客服',
|
||||
dataIndex: 'ownerName',
|
||||
width: 110,
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '平台',
|
||||
dataIndex: 'platformKey',
|
||||
width: 100,
|
||||
render: (value: string, row) => (
|
||||
<Tag color={PLATFORM_TAG_COLORS[value] || 'default'}>{row.platformLabel}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'entryType',
|
||||
width: 80,
|
||||
render: (value: string) => (
|
||||
<Tag color={ENTRY_TYPE_COLORS[value] || 'default'}>{ENTRY_TYPE_LABELS[value] || value}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '变动',
|
||||
dataIndex: 'quantityDelta',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value: number) =>
|
||||
value > 0 ? (
|
||||
<Typography.Text style={{ color: '#389e0d' }}>+{value}</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text style={{ color: '#cf1322' }}>{value}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '余额',
|
||||
dataIndex: 'balanceAfter',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amountFen',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: (value: number) => `¥${(Number(value || 0) / 100).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 150,
|
||||
render: (value: string) => formatAdminDateTime(value),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card bordered={false} title="平台积分钱包">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="南航 / 乐玩 / ROCK 三平台积分互不通用,每平台一个钱包"
|
||||
description="换算率按平台:南航、乐玩 1 积分 = 1 分;ROCK 1 积分 = 1 元。余额只能由流水产生,消耗不足会直接拒绝,不允许负余额。备用金现金不在这里,走「我的备用金」。"
|
||||
/>
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 12 }}>
|
||||
{platforms.map((platform) => {
|
||||
const total = accounts
|
||||
.filter((item) => item.platformKey === platform.key)
|
||||
.reduce((sum, item) => sum + item.balanceQuantity, 0)
|
||||
return (
|
||||
<Col span={8} key={platform.key}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={`${platform.label}总积分`}
|
||||
value={total}
|
||||
suffix="积分"
|
||||
prefix={
|
||||
<Tag color={PLATFORM_TAG_COLORS[platform.key] || 'default'}>
|
||||
1积分={platform.creditUnitPriceFen >= 100 ? '1元' : '1分'}
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
{canManage ? (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
grantForm.resetFields()
|
||||
setGrantOpen(true)
|
||||
}}
|
||||
>
|
||||
充值积分
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
adjustForm.resetFields()
|
||||
setAdjustOpen(true)
|
||||
}}
|
||||
>
|
||||
调整余额
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Select
|
||||
value={platformFilter}
|
||||
allowClear
|
||||
placeholder="全部平台"
|
||||
style={{ width: 140 }}
|
||||
onChange={(value) => setPlatformFilter(value || '')}
|
||||
options={platforms.map((item) => ({ value: item.key, label: item.label }))}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
void accountsQuery.refetch()
|
||||
void entriesQuery.refetch()
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={entryColumns}
|
||||
dataSource={entries}
|
||||
loading={entriesQuery.isLoading}
|
||||
scroll={{ x: 980 }}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={grantOpen}
|
||||
title="充值积分"
|
||||
okText="确认充值"
|
||||
cancelText="取消"
|
||||
confirmLoading={grantMutation.isPending}
|
||||
onOk={() => {
|
||||
void grantForm.validateFields().then((values) => grantMutation.mutate(values))
|
||||
}}
|
||||
onCancel={() => setGrantOpen(false)}
|
||||
destroyOnHidden
|
||||
width={480}
|
||||
>
|
||||
<Form<GrantFormValues> form={grantForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="ownerAdminUserId"
|
||||
label="客服"
|
||||
rules={[{ required: true, message: '请选择客服' }]}
|
||||
extra="客服 ID(管理员用户 id)"
|
||||
>
|
||||
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="platformKey"
|
||||
label="平台"
|
||||
rules={[{ required: true, message: '请选择平台' }]}
|
||||
>
|
||||
<Select
|
||||
options={platforms.map((item) => ({
|
||||
value: item.key,
|
||||
label: `${item.label}(1积分=${item.creditUnitPriceFen >= 100 ? '1元' : '1分'})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="quantity"
|
||||
label="积分数"
|
||||
rules={[{ required: true, message: '请输入积分数' }]}
|
||||
>
|
||||
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="platformRef" label="平台流水号">
|
||||
<Input placeholder="平台侧充值流水号" maxLength={100} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={adjustOpen}
|
||||
title="调整积分余额"
|
||||
okText="确认调整"
|
||||
cancelText="取消"
|
||||
confirmLoading={adjustMutation.isPending}
|
||||
onOk={() => {
|
||||
void adjustForm.validateFields().then((values) => adjustMutation.mutate(values))
|
||||
}}
|
||||
onCancel={() => setAdjustOpen(false)}
|
||||
destroyOnHidden
|
||||
width={480}
|
||||
>
|
||||
<Form<AdjustFormValues> form={adjustForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="ownerAdminUserId"
|
||||
label="客服"
|
||||
rules={[{ required: true, message: '请选择客服' }]}
|
||||
>
|
||||
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="platformKey"
|
||||
label="平台"
|
||||
rules={[{ required: true, message: '请选择平台' }]}
|
||||
>
|
||||
<Select options={platforms.map((item) => ({ value: item.key, label: item.label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="quantity"
|
||||
label="调整积分数"
|
||||
rules={[{ required: true, message: '请输入调整值' }]}
|
||||
extra="正数增加,负数扣减;调整后余额不能为负。"
|
||||
>
|
||||
<InputNumber precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="note"
|
||||
label="调整原因"
|
||||
rules={[{ required: true, message: '请填写调整原因' }]}
|
||||
>
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,11 @@ import type {
|
||||
CostVersion,
|
||||
CostVersionListResult,
|
||||
ManualFulfillmentConfig,
|
||||
ManualFulfillmentConfirmResult,
|
||||
ManualFulfillmentPendingListResult,
|
||||
ManualPlatformOption,
|
||||
PlatformCreditAccountListResult,
|
||||
PlatformCreditEntryListResult,
|
||||
ManualRematchResult,
|
||||
ManualSkuListResult,
|
||||
ManualSkuOverview,
|
||||
@@ -283,3 +287,87 @@ export function deleteAdminManualConfig(skuKey: string) {
|
||||
export function rematchAdminManualFulfillments(payload: { skuKey?: string } = {}) {
|
||||
return apiPost<ManualRematchResult>('/api/v1/admin/cost-center/manual-configs/rematch', payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 客服平台积分钱包(南航 / 乐玩 / ROCK)。
|
||||
* 三平台积分互不通用,每客服每平台一个账户;备用金现金走备用金页。
|
||||
*/
|
||||
export function fetchAdminPlatformCreditAccounts() {
|
||||
return apiGet<PlatformCreditAccountListResult>(
|
||||
'/api/v1/admin/cost-center/platform-credits/accounts',
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminPlatformCreditEntries(
|
||||
params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
platformKey?: string
|
||||
entryType?: string
|
||||
keyword?: string
|
||||
} = {},
|
||||
) {
|
||||
return apiGet<PlatformCreditEntryListResult>(
|
||||
'/api/v1/admin/cost-center/platform-credits/entries',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export function grantAdminPlatformCredit(payload: {
|
||||
ownerAdminUserId: number
|
||||
platformKey: string
|
||||
quantity: number
|
||||
platformRef?: string
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<{
|
||||
accountId: number
|
||||
platformKey: string
|
||||
quantity: number
|
||||
balanceAfter: number
|
||||
amountFen: number
|
||||
entryId: number
|
||||
}>('/api/v1/admin/cost-center/platform-credits/grant', payload)
|
||||
}
|
||||
|
||||
export function adjustAdminPlatformCredit(payload: {
|
||||
ownerAdminUserId: number
|
||||
platformKey: string
|
||||
quantity: number
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<{
|
||||
accountId: number
|
||||
platformKey: string
|
||||
quantity: number
|
||||
balanceAfter: number
|
||||
amountFen: number
|
||||
entryId: number
|
||||
}>('/api/v1/admin/cost-center/platform-credits/adjust', payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* 人工履约待办与确认履约。
|
||||
* 客服点「确认履约」即扣额度 + 写成本快照;固定成本零输入,
|
||||
* 成本不固定时只填金额。
|
||||
*/
|
||||
export function fetchAdminManualFulfillmentPending(
|
||||
params: { page?: number; pageSize?: number; keyword?: string; status?: string } = {},
|
||||
) {
|
||||
return apiGet<ManualFulfillmentPendingListResult>(
|
||||
'/api/v1/admin/finance/manual-fulfillment/pending',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export function confirmAdminManualFulfillment(payload: {
|
||||
orderItemId: number
|
||||
costFen?: number
|
||||
payeeName?: string
|
||||
note?: string
|
||||
}) {
|
||||
return apiPost<ManualFulfillmentConfirmResult>(
|
||||
'/api/v1/admin/finance/manual-fulfillment/confirm',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -389,3 +389,95 @@ export interface ManualRematchResult {
|
||||
updatedCount: number
|
||||
skuKey: string
|
||||
}
|
||||
|
||||
/** 积分平台:南航 / 乐玩 / ROCK,三平台积分互不通用(不含备用金现金)。 */
|
||||
export type PlatformCreditKey = 'nanhang' | 'lewan' | 'rock'
|
||||
|
||||
/** 客服平台积分钱包(南航 / 乐玩 / ROCK,三平台积分互不通用)。 */
|
||||
export interface PlatformCreditAccount {
|
||||
id: number
|
||||
platformKey: PlatformCreditKey
|
||||
platformLabel: string
|
||||
ownerAdminUserId: number
|
||||
ownerName: string
|
||||
balanceQuantity: number
|
||||
balanceAmountFen: number
|
||||
unitName: string
|
||||
creditUnitPriceFen: number
|
||||
status: 'active' | 'frozen' | 'closed'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface PlatformCreditAccountListResult {
|
||||
items: PlatformCreditAccount[]
|
||||
platforms: Array<{
|
||||
key: PlatformCreditKey
|
||||
label: string
|
||||
creditUnitPriceFen: number
|
||||
}>
|
||||
}
|
||||
|
||||
export interface PlatformCreditEntry {
|
||||
id: number
|
||||
accountId: number
|
||||
platformKey: PlatformCreditKey
|
||||
platformLabel: string
|
||||
ownerName: string
|
||||
entryType: 'grant' | 'consume' | 'adjust' | 'return'
|
||||
quantityDelta: number
|
||||
balanceAfter: number
|
||||
amountFen: number
|
||||
platformRef: string
|
||||
relatedTaskId: number | null
|
||||
note: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PlatformCreditEntryListResult {
|
||||
items: PlatformCreditEntry[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
/** 人工履约待办:客服确认履约用。 */
|
||||
export interface ManualFulfillmentPendingItem {
|
||||
orderItemId: number
|
||||
orderId: number
|
||||
platformOrderId: string
|
||||
provider: string
|
||||
shopId: string
|
||||
skuCode: string
|
||||
skuName: string
|
||||
internalSku: string
|
||||
revenueFen: number
|
||||
paidAt: string
|
||||
configId: number
|
||||
platformKey: PlatformCreditKey | 'fund_cash'
|
||||
platformLabel: string
|
||||
isFundCash: boolean
|
||||
creditQuantity: number
|
||||
creditUnitPriceFen: number
|
||||
costMode: ManualCostMode
|
||||
/** 固定成本的金额;成本不固定时为 null,客服要手填。 */
|
||||
costFen: number | null
|
||||
fulfilled: boolean
|
||||
fulfilledAt: string | null
|
||||
}
|
||||
|
||||
export interface ManualFulfillmentPendingListResult {
|
||||
items: ManualFulfillmentPendingItem[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
export interface ManualFulfillmentConfirmResult {
|
||||
orderItemId: number
|
||||
orderId: number
|
||||
costSnapshotId: number
|
||||
platformKey: string
|
||||
platformLabel: string
|
||||
creditQuantity: number
|
||||
costFen: number
|
||||
fundExpenseId: number | null
|
||||
creditEntryId: number | null
|
||||
balanceAfter: number | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user