diff --git a/apps/backend/src/routes/admin/cost-center.ts b/apps/backend/src/routes/admin/cost-center.ts index 3cc7afff..3799a038 100644 --- a/apps/backend/src/routes/admin/cost-center.ts +++ b/apps/backend/src/routes/admin/cost-center.ts @@ -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, 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 diff --git a/apps/backend/src/routes/admin/finance.ts b/apps/backend/src/routes/admin/finance.ts index 45c8abae..7c80bf3c 100644 --- a/apps/backend/src/routes/admin/finance.ts +++ b/apps/backend/src/routes/admin/finance.ts @@ -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, 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 diff --git a/apps/backend/src/services/cost-center/platform-credit-service.ts b/apps/backend/src/services/cost-center/platform-credit-service.ts new file mode 100644 index 00000000..0a90c3ae --- /dev/null +++ b/apps/backend/src/services/cost-center/platform-credit-service.ts @@ -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 = { + 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> { + const result = await query<{ profile_key: string; config_json: Record }>( + `SELECT profile_key, config_json FROM fulfillment_profiles WHERE profile_key LIKE 'manual_%'`, + ) + const prices: Record = { 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( + ` + 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 { + 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( + `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( + ` + 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( + ` + 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( + ` + 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, + 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( + ` + 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( + ` + 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) { + 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) { + 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}`) +} diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts new file mode 100644 index 00000000..dc870484 --- /dev/null +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -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 = { + 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 = {}, + 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( + `${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}`) +} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 2596bd49..e3c85226 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> }> } /> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 2fee48de..baf0ced4 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -1,4 +1,5 @@ import { + CheckCircleOutlined, AuditOutlined, AccountBookOutlined, ApartmentOutlined, @@ -162,6 +163,11 @@ export default function AdminLayout() { }, ]), { key: '/admin/tasks', icon: , label: '任务' }, + { + key: '/admin/manual-fulfillment', + icon: , + label: '人工履约', + }, { key: '/admin/worker-platform', icon: , label: '接单平台' }, { key: '/admin/kuaishou-industry', diff --git a/apps/frontend/src/pages/admin/AdminManualFulfillmentPage.tsx b/apps/frontend/src/pages/admin/AdminManualFulfillmentPage.tsx new file mode 100644 index 00000000..83143b40 --- /dev/null +++ b/apps/frontend/src/pages/admin/AdminManualFulfillmentPage.tsx @@ -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 = { + nanhang: 'blue', + lewan: 'purple', + rock: 'cyan', + fund_cash: 'orange', +} + +export default function AdminManualFulfillmentPage() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + + 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(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 = [ + { + title: '商品', + key: 'sku', + width: 260, + render: (_, row) => ( +
+ + {row.skuName || row.skuCode} + + + {row.internalSku || row.skuCode} + +
+ ), + }, + { + title: '平台单号', + dataIndex: 'platformOrderId', + width: 170, + render: (value: string) => ( + + {value} + + ), + }, + { + title: '履约渠道', + key: 'platform', + width: 110, + render: (_, row) => ( + {row.platformLabel} + ), + }, + { + title: '消耗', + key: 'credit', + width: 120, + render: (_, row) => + row.isFundCash ? ( + — + ) : ( + {row.creditQuantity} 积分 + ), + }, + { + title: '成本', + key: 'cost', + width: 120, + align: 'right', + render: (_, row) => + row.costFen === null ? ( + 待填写 + ) : ( + ¥{(row.costFen / 100).toFixed(2)} + ), + }, + { + 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 ? ( + 已履约 + ) : ( + + ), + }, + ] + + const total = listQuery.data?.data.pagination.total ?? 0 + + return ( +
+ + + + + + + + + + + + + { + setPage(nextPage) + if (nextPageSize !== pageSize) setPageSize(nextPageSize) + }, + })} + /> + + + { + void form.validateFields().then((values) => confirmMutation.mutate(values)) + }} + onCancel={() => { + setConfirming(null) + setShowCostInput(false) + }} + destroyOnHidden + width={520} + > + {confirming ? ( + +
+ 商品: + {confirming.skuName || confirming.skuCode} +
+
+ 内部 SKU: + + {confirming.internalSku} + +
+
+ 履约渠道: + + {confirming.platformLabel} + + {!confirming.isFundCash ? ( + 消耗 {confirming.creditQuantity} 积分 + ) : null} +
+ {showCostInput ? ( + + ) : ( + + )} +
+ ) : null} + + form={form} layout="vertical"> + {showCostInput ? ( + + + + ) : null} + + + + + + + +
+ + ) +} diff --git a/apps/frontend/src/pages/admin/fund/FundManagementView.tsx b/apps/frontend/src/pages/admin/fund/FundManagementView.tsx index b47a2007..8f7a2454 100644 --- a/apps/frontend/src/pages/admin/fund/FundManagementView.tsx +++ b/apps/frontend/src/pages/admin/fund/FundManagementView.tsx @@ -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 }) { ), }, + { + key: 'platform-credits', + label: '平台积分', + children: , + }, { key: 'expenses', label: '支出明细', diff --git a/apps/frontend/src/pages/admin/fund/FundSupportView.tsx b/apps/frontend/src/pages/admin/fund/FundSupportView.tsx index 546b0803..952a62ef 100644 --- a/apps/frontend/src/pages/admin/fund/FundSupportView.tsx +++ b/apps/frontend/src/pages/admin/fund/FundSupportView.tsx @@ -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: , + }, ]} /> diff --git a/apps/frontend/src/pages/admin/fund/PlatformCreditPanel.tsx b/apps/frontend/src/pages/admin/fund/PlatformCreditPanel.tsx new file mode 100644 index 00000000..dddd7cda --- /dev/null +++ b/apps/frontend/src/pages/admin/fund/PlatformCreditPanel.tsx @@ -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 = { + nanhang: 'blue', + lewan: 'purple', + rock: 'cyan', +} + +const ENTRY_TYPE_LABELS: Record = { + grant: '充值', + consume: '消耗', + adjust: '调整', + return: '退回', +} + +const ENTRY_TYPE_COLORS: Record = { + 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() + const [adjustForm] = Form.useForm() + + 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 = [ + { + title: '客服', + dataIndex: 'ownerName', + width: 110, + render: (value: string) => value || '-', + }, + { + title: '平台', + dataIndex: 'platformKey', + width: 100, + render: (value: string, row) => ( + {row.platformLabel} + ), + }, + { + title: '类型', + dataIndex: 'entryType', + width: 80, + render: (value: string) => ( + {ENTRY_TYPE_LABELS[value] || value} + ), + }, + { + title: '变动', + dataIndex: 'quantityDelta', + width: 100, + align: 'right', + render: (value: number) => + value > 0 ? ( + +{value} + ) : ( + {value} + ), + }, + { + 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 ( + + + + + {platforms.map((platform) => { + const total = accounts + .filter((item) => item.platformKey === platform.key) + .reduce((sum, item) => sum + item.balanceQuantity, 0) + return ( + + + + 1积分={platform.creditUnitPriceFen >= 100 ? '1元' : '1分'} + + } + /> + + + ) + })} + + + + {canManage ? ( + <> + + + + ) : null} +
+ + { + void grantForm.validateFields().then((values) => grantMutation.mutate(values)) + }} + onCancel={() => setGrantOpen(false)} + destroyOnHidden + width={480} + > + form={grantForm} layout="vertical"> + + + + + + + + + + + + + { + void adjustForm.validateFields().then((values) => adjustMutation.mutate(values)) + }} + onCancel={() => setAdjustOpen(false)} + destroyOnHidden + width={480} + > + form={adjustForm} layout="vertical"> + + + + +