feat(admin): 收口人工履约入口并修复履约筛选
This commit is contained in:
@@ -100,6 +100,44 @@ test('订单列表 platformOrderId 为拼多多 order_sn 时走索引快路径',
|
||||
assert.deepEqual(calls[0]?.params, [orderSn])
|
||||
})
|
||||
|
||||
test('订单列表按当前履约模式筛选,不再依赖任务执行器', async () => {
|
||||
const calls: Array<{ sql: string; params: unknown[] }> = []
|
||||
|
||||
await listOrdersWithExecutor(createExecutor(calls, 0), {
|
||||
fulfillmentMode: 'manual',
|
||||
})
|
||||
|
||||
assert.match(calls[0]?.sql || '', /oi\.fulfillment_mode = \$1/)
|
||||
assert.match(calls[0]?.sql || '', /oi\.fulfillment_mode <> \$1/)
|
||||
assert.doesNotMatch(calls[0]?.sql || '', /COUNT\(DISTINCT/)
|
||||
assert.doesNotMatch(calls[0]?.sql || '', /ft\.executor_key/)
|
||||
assert.deepEqual(calls[0]?.params, ['manual'])
|
||||
})
|
||||
|
||||
test('订单列表待判定筛选不扫描任务执行器', async () => {
|
||||
const calls: Array<{ sql: string; params: unknown[] }> = []
|
||||
|
||||
await listOrdersWithExecutor(createExecutor(calls, 0), {
|
||||
fulfillmentMode: 'pending',
|
||||
})
|
||||
|
||||
assert.match(calls[0]?.sql || '', /NOT EXISTS/)
|
||||
assert.match(calls[0]?.sql || '', /oi\.fulfillment_mode IN/)
|
||||
assert.doesNotMatch(calls[0]?.sql || '', /COUNT\(DISTINCT/)
|
||||
assert.deepEqual(calls[0]?.params, [])
|
||||
})
|
||||
|
||||
test('订单列表混合履约筛选按订单项聚合判定', async () => {
|
||||
const calls: Array<{ sql: string; params: unknown[] }> = []
|
||||
|
||||
await listOrdersWithExecutor(createExecutor(calls, 0), {
|
||||
fulfillmentMode: 'mixed',
|
||||
})
|
||||
|
||||
assert.match(calls[0]?.sql || '', /HAVING COUNT\(DISTINCT oi\.fulfillment_mode\) > 1/)
|
||||
assert.deepEqual(calls[0]?.params, [])
|
||||
})
|
||||
|
||||
function createExecutor(
|
||||
calls: Array<{ sql: string; params: unknown[] }>,
|
||||
total: number,
|
||||
|
||||
@@ -240,6 +240,7 @@ export async function listOrdersWithExecutor(
|
||||
keyword = '',
|
||||
provider = '',
|
||||
executorKey = '',
|
||||
fulfillmentMode = '',
|
||||
platformOrderId = '',
|
||||
payStatus = '',
|
||||
taskStatus = '',
|
||||
@@ -333,6 +334,38 @@ export async function listOrdersWithExecutor(
|
||||
)
|
||||
}
|
||||
|
||||
if (fulfillmentMode) {
|
||||
const knownModes = `('work_order', 'affiliate_dash', 'legacy_platform', 'manual')`
|
||||
if (fulfillmentMode === 'pending') {
|
||||
filters.push(`NOT EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
WHERE oi.order_id = o.id AND oi.fulfillment_mode IN ${knownModes}
|
||||
)`)
|
||||
} else if (fulfillmentMode === 'mixed') {
|
||||
filters.push(`o.id IN (
|
||||
SELECT oi.order_id FROM order_items oi
|
||||
WHERE oi.fulfillment_mode IN ${knownModes}
|
||||
GROUP BY oi.order_id
|
||||
HAVING COUNT(DISTINCT oi.fulfillment_mode) > 1
|
||||
)`)
|
||||
} else {
|
||||
params.push(fulfillmentMode)
|
||||
const modeParam = `$${params.length}`
|
||||
filters.push(`(
|
||||
EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
WHERE oi.order_id = o.id AND oi.fulfillment_mode = ${modeParam}
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
WHERE oi.order_id = o.id
|
||||
AND oi.fulfillment_mode IN ${knownModes}
|
||||
AND oi.fulfillment_mode <> ${modeParam}
|
||||
)
|
||||
)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (skuCode) {
|
||||
params.push(skuCode)
|
||||
filters.push(
|
||||
|
||||
@@ -11,10 +11,6 @@ import {
|
||||
listSalesDailyFacts,
|
||||
rebuildSalesDailyFacts,
|
||||
} from '../../services/finance/sales-facts-service.js'
|
||||
import {
|
||||
createManualOrderCost,
|
||||
listManualOrderCostItems,
|
||||
} from '../../services/finance/manual-order-cost-service.js'
|
||||
import {
|
||||
confirmManualFulfillment,
|
||||
listManualFulfillmentPending,
|
||||
@@ -118,45 +114,6 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/finance/manual-order-cost-items',
|
||||
requireAnyAdminPermission(['finance.view', 'fund.view']),
|
||||
createJsonHandler(() => listManualOrderCostItems(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取人工录单成本项失败',
|
||||
scope: '[admin/finance/manual-order-cost-items]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/finance/manual-order-costs',
|
||||
requireAnyAdminPermission(['fund.expense', 'finance.config']),
|
||||
createJsonHandler((req) => createManualOrderCost(bodyOf(req), sessionOf(req)), {
|
||||
successMessage: '订单人工成本已录入',
|
||||
errorMessage: '录入订单人工成本失败',
|
||||
scope: '[admin/finance/manual-order-costs]',
|
||||
audit: (req, data) => {
|
||||
const result = data as {
|
||||
expenseNo?: string
|
||||
amountFen?: number
|
||||
orderId?: number
|
||||
costSnapshotId?: number | null
|
||||
}
|
||||
return {
|
||||
action: 'manual_order_cost_created',
|
||||
targetType: 'fund_expense',
|
||||
targetId: result.expenseNo || '',
|
||||
data: {
|
||||
expenseNo: result.expenseNo || '',
|
||||
amountFen: result.amountFen || 0,
|
||||
orderId: result.orderId || req.body?.orderId || '',
|
||||
costSnapshotId: result.costSnapshotId || null,
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 人工履约待办与确认履约
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -28,11 +28,13 @@ export function mapAdminOrderListItem(
|
||||
orderItems = [],
|
||||
vouchers = [],
|
||||
workOrderCount = 0,
|
||||
manualConfig = { pending: 0, unconfigured: 0 },
|
||||
}: {
|
||||
tasks?: TaskRow[]
|
||||
orderItems?: OrderItemRow[]
|
||||
vouchers?: KuaishouIndustryVoucherRow[]
|
||||
workOrderCount?: number
|
||||
manualConfig?: { pending: number; unconfigured: number }
|
||||
} = {},
|
||||
): AdminOrderListItem {
|
||||
const fulfillmentProgress = buildOrderFulfillmentProgress(tasks)
|
||||
@@ -121,6 +123,8 @@ export function mapAdminOrderListItem(
|
||||
fulfillmentMode: fulfillment.fulfillmentMode,
|
||||
manualItemCount: fulfillment.manualItemCount,
|
||||
pendingItemCount: fulfillment.pendingItemCount,
|
||||
manualPendingCount: manualConfig.pending,
|
||||
manualUnconfiguredCount: manualConfig.unconfigured,
|
||||
hasVoucher: fulfillment.hasVoucher,
|
||||
voucherState: fulfillment.voucherState,
|
||||
canClaimH5: fulfillment.canClaimH5,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../../repositories/order-item-repo.js'
|
||||
import { getOrderById, listOrders } from '../../repositories/order-repo.js'
|
||||
import { listOrderRefunds } from '../finance/order-refund-service.js'
|
||||
import { getManualFulfillmentCountsByOrderIds } from '../finance/manual-fulfillment-service.js'
|
||||
import {
|
||||
getTaskById,
|
||||
listTasks,
|
||||
@@ -104,6 +105,7 @@ export async function getAdminOrders(
|
||||
keyword: String(query.keyword || '').trim(),
|
||||
provider: String(query.provider || '').trim(),
|
||||
executorKey: String(query.executorKey || '').trim(),
|
||||
fulfillmentMode: normalizeOrderFulfillmentModeFilter(query.fulfillmentMode),
|
||||
platformOrderId: String(query.platformOrderId || '').trim(),
|
||||
payStatus: String(query.payStatus || '').trim(),
|
||||
taskStatus: String(query.taskStatus || '').trim(),
|
||||
@@ -114,11 +116,12 @@ export async function getAdminOrders(
|
||||
|
||||
const orderIds = items.map((item) => Number(item.id)).filter((id) => id > 0)
|
||||
const oids = items.map((item) => item.platform_order_id).filter(Boolean)
|
||||
const [orderItems, tasks, vouchers, workOrders] = await Promise.all([
|
||||
const [orderItems, tasks, vouchers, workOrders, manualCounts] = await Promise.all([
|
||||
listOrderItemsByOrderIds(orderIds),
|
||||
listTasksByOrderIds(orderIds),
|
||||
listKuaishouIndustryVouchersByOids(oids),
|
||||
listWorkOrdersByOrderIds(orderIds),
|
||||
getManualFulfillmentCountsByOrderIds(orderIds),
|
||||
])
|
||||
const orderItemsByOrderId = groupRowsByOrderId(orderItems)
|
||||
const tasksByOrderId = groupRowsByOrderId(tasks)
|
||||
@@ -136,12 +139,29 @@ export async function getAdminOrders(
|
||||
tasks: orderTasks,
|
||||
vouchers: orderVouchers,
|
||||
workOrderCount: orderWorkOrders.length,
|
||||
manualConfig: manualCounts.get(orderId) || { pending: 0, unconfigured: 0 },
|
||||
})
|
||||
}),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
const ADMIN_ORDER_FULFILLMENT_MODES = new Set([
|
||||
'work_order',
|
||||
'affiliate_dash',
|
||||
'legacy_platform',
|
||||
'manual',
|
||||
'mixed',
|
||||
'pending',
|
||||
])
|
||||
|
||||
function normalizeOrderFulfillmentModeFilter(value: unknown): string {
|
||||
const mode = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return ADMIN_ORDER_FULFILLMENT_MODES.has(mode) ? mode : ''
|
||||
}
|
||||
|
||||
function groupRowsByOrderId<T extends { order_id?: number | string | null }>(
|
||||
rows: T[],
|
||||
): Map<number, T[]> {
|
||||
@@ -182,12 +202,14 @@ export async function getAdminOrderDetail(
|
||||
})
|
||||
}
|
||||
|
||||
const [items, tasks, workOrders, refunds] = await Promise.all([
|
||||
const [items, tasks, workOrders, refunds, manualCounts] = await Promise.all([
|
||||
listOrderItemsByOrderId(order.id),
|
||||
listTasksByOrderId(order.id),
|
||||
listWorkOrdersByOrderId(order.id),
|
||||
listOrderRefunds(order.id),
|
||||
getManualFulfillmentCountsByOrderIds([order.id]),
|
||||
])
|
||||
const manualConfig = manualCounts.get(Number(order.id)) || { pending: 0, unconfigured: 0 }
|
||||
const kuaishouIndustryVouchers = await listKuaishouIndustryVouchersByOid(order.platform_order_id)
|
||||
const itemSummary = summarizeOrderItems(items, order, kuaishouIndustryVouchers)
|
||||
const orderItemTitle = resolveOrderItemTitle(items[0], order, kuaishouIndustryVouchers)
|
||||
@@ -233,6 +255,8 @@ export async function getAdminOrderDetail(
|
||||
fulfillmentMode: fulfillment.fulfillmentMode,
|
||||
manualItemCount: fulfillment.manualItemCount,
|
||||
pendingItemCount: fulfillment.pendingItemCount,
|
||||
manualPendingCount: manualConfig.pending,
|
||||
manualUnconfiguredCount: manualConfig.unconfigured,
|
||||
hasVoucher: fulfillment.hasVoucher,
|
||||
voucherState: fulfillment.voucherState,
|
||||
workOrderCount: workOrders.length,
|
||||
|
||||
@@ -78,6 +78,12 @@ export async function listManualFulfillmentPending(
|
||||
} else if (status === 'fulfilled') {
|
||||
conditions.push('fulfilled.fulfilled_at IS NOT NULL')
|
||||
}
|
||||
// 订单页按单打开确认框:只取该订单的待办项。
|
||||
const orderId = Number(input.orderId ?? input.order_id ?? 0)
|
||||
if (Number.isFinite(orderId) && orderId > 0) {
|
||||
params.push(Math.trunc(orderId))
|
||||
conditions.push(`oi.order_id = $${params.length}`)
|
||||
}
|
||||
if (keyword) {
|
||||
params.push(`%${escapeLike(keyword)}%`)
|
||||
const placeholder = `$${params.length}`
|
||||
@@ -565,3 +571,52 @@ function optionalText(value: unknown, maxLength: number) {
|
||||
function escapeLike(value: string) {
|
||||
return value.replace(/[\\%_]/g, (char) => `\\${char}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单列表/详情用:按订单统计「可确认履约」与「未配置履约渠道」的数量。
|
||||
*
|
||||
* - pending:有人工履约配置且还没有 manual_case 快照;
|
||||
* - unconfigured:人工单但没有绑定履约配置(需要先到成本中心配 SKU)。
|
||||
*/
|
||||
export async function getManualFulfillmentCountsByOrderIds(
|
||||
orderIds: Array<number | string>,
|
||||
): Promise<Map<number, { pending: number; unconfigured: number }>> {
|
||||
const ids = orderIds.map(Number).filter((id) => Number.isFinite(id) && id > 0)
|
||||
if (ids.length === 0) return new Map()
|
||||
const result = await query<{
|
||||
order_id: number
|
||||
pending_count: number
|
||||
unconfigured_count: number
|
||||
}>(
|
||||
`
|
||||
WITH fulfilled AS (
|
||||
SELECT order_item_id, MAX(created_at) AS fulfilled_at
|
||||
FROM cost_snapshots
|
||||
WHERE subject_type = 'manual_case' AND superseded_at IS NULL
|
||||
GROUP BY order_item_id
|
||||
)
|
||||
SELECT
|
||||
oi.order_id,
|
||||
COUNT(*) FILTER (
|
||||
WHERE cfg.id IS NOT NULL AND fulfilled.fulfilled_at IS NULL
|
||||
)::int AS pending_count,
|
||||
COUNT(*) FILTER (WHERE cfg.id IS NULL)::int AS unconfigured_count
|
||||
FROM order_items oi
|
||||
LEFT JOIN manual_fulfillment_configs cfg ON cfg.id = oi.fulfillment_config_id
|
||||
LEFT JOIN fulfilled ON fulfilled.order_item_id = oi.id
|
||||
WHERE oi.order_id = ANY($1::bigint[])
|
||||
AND oi.fulfillment_mode = 'manual'
|
||||
GROUP BY oi.order_id
|
||||
`,
|
||||
[ids],
|
||||
)
|
||||
return new Map(
|
||||
result.rows.map((row) => [
|
||||
Number(row.order_id),
|
||||
{
|
||||
pending: Number(row.pending_count || 0),
|
||||
unconfigured: Number(row.unconfigured_count || 0),
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
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'
|
||||
import { getOwnAgentAccountId, writeFundExpense } from '../admin/admin-fund-service.js'
|
||||
|
||||
/**
|
||||
* 订单页「人工录单」服务。
|
||||
*
|
||||
* 口径(2026-10-23 确认):
|
||||
* * 人工成本逐单、按实际金额,不按工时、不按固定单量/固定单价;
|
||||
* * 成本从客服备用金账户直接扣款,客服角色只能用本人账户,管理员/财务可代录;
|
||||
* * **没有登记单、没有审批、没有报销**:录入即写 actual 成本快照;
|
||||
* * 资金台账唯一真源是 manual_expenses(客服备用金支出明细),本服务不另存支出表。
|
||||
*
|
||||
* 成本快照主体:subject_type='manual_case',subject_id=manual_expenses.id。
|
||||
*/
|
||||
|
||||
const PAYEE_TYPES = new Set(['worker', 'upstream', 'customer', 'platform', 'other'])
|
||||
const PAYMENT_METHODS = new Set(['wechat', 'alipay', 'bank', 'cash', 'platform_balance', 'other'])
|
||||
/** 人工录单的费用类型;与备用金分类的映射见 mapFundExpenseCategory()。 */
|
||||
const EXPENSE_CATEGORIES = new Set([
|
||||
'goods_purchase',
|
||||
'compensation',
|
||||
'refund_advance',
|
||||
'service_fee',
|
||||
'freight',
|
||||
'recharge',
|
||||
'other',
|
||||
])
|
||||
|
||||
type ManualOrderCostResult = {
|
||||
expenseNo: string
|
||||
fundExpenseId: number
|
||||
fundAccountId: number
|
||||
fundAccountNo: string
|
||||
amountFen: number
|
||||
orderId: number
|
||||
orderItemId: number
|
||||
costSnapshotId: number | null
|
||||
fulfillmentMode: string
|
||||
warning: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 录入一笔人工成本:扣客服备用金 + 写 actual 成本快照。
|
||||
*
|
||||
* 全流程在同一事务内:余额不足直接拒绝,不允许负余额。
|
||||
*/
|
||||
export async function createManualOrderCost(
|
||||
payload: JsonObject,
|
||||
session: AdminSession,
|
||||
): Promise<ManualOrderCostResult> {
|
||||
const orderId = normalizeId(payload.orderId ?? payload.order_id, '订单号无效')
|
||||
const amountFen = normalizeAmountFen(payload.amountFen ?? payload.amount_fen)
|
||||
const note = optionalText(payload.note, 500) || '订单页人工录单'
|
||||
const expenseCategory = normalizeStatus(
|
||||
payload.expenseCategory ?? payload.expense_category,
|
||||
EXPENSE_CATEGORIES,
|
||||
'service_fee',
|
||||
'费用类型无效',
|
||||
)
|
||||
const payeeName = optionalText(payload.payeeName ?? payload.payee_name, 120) || '内部人工'
|
||||
const payeeType = normalizeStatus(
|
||||
payload.payeeType ?? payload.payee_type,
|
||||
PAYEE_TYPES,
|
||||
'other',
|
||||
'收款方类型无效',
|
||||
)
|
||||
const paymentMethod = normalizeStatus(
|
||||
payload.paymentMethod ?? payload.payment_method,
|
||||
PAYMENT_METHODS,
|
||||
'other',
|
||||
'付款方式无效',
|
||||
)
|
||||
const evidenceJson = normalizeEvidence(payload.evidenceJson ?? payload.evidence_json)
|
||||
if (!hasEvidence(evidenceJson)) {
|
||||
evidenceJson.note = note
|
||||
}
|
||||
|
||||
const orderItemId = await resolveOrderItemId(
|
||||
orderId,
|
||||
normalizeOptionalId(payload.orderItemId ?? payload.order_item_id),
|
||||
)
|
||||
const fundAccountId = await resolveFundAccountId(
|
||||
normalizeOptionalId(payload.fundAccountId ?? payload.fund_account_id),
|
||||
session,
|
||||
)
|
||||
const costItemId = await resolveManualCostItemId()
|
||||
const paidAt = nowIso()
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
const fundWritten = await writeFundExpense(client, {
|
||||
agentAccountId: fundAccountId,
|
||||
amountFen,
|
||||
category: mapFundExpenseCategory(expenseCategory),
|
||||
payeeType: mapFundPayeeType(payeeType),
|
||||
payeeName,
|
||||
payeeAccount: optionalText(payload.payeeAccount ?? payload.payee_account, 120),
|
||||
orderId,
|
||||
orderItemId,
|
||||
paidAt,
|
||||
evidence: toFundEvidence(evidenceJson),
|
||||
note: buildFundExpenseNote(note, optionalText(evidenceJson.reference, 200)),
|
||||
actorUserId: session.userId,
|
||||
})
|
||||
const fundExpenseId = Number(fundWritten.expense.id)
|
||||
const fundAccountNo = await loadFundAccountNo(client, fundAccountId)
|
||||
|
||||
const context = await loadOrderItemContext(client, orderItemId)
|
||||
const versionId = await resolveCostVersionId(client, costItemId, context, paidAt)
|
||||
let costSnapshotId: number | null = null
|
||||
let warning = ''
|
||||
|
||||
if (!versionId) {
|
||||
// 匹配不到有效成本版本不允许静默记 0,必须进异常队列。
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO cost_exceptions (
|
||||
subject_type, subject_id, order_item_id, work_order_id,
|
||||
reason, status, detail_json, created_at
|
||||
) VALUES (
|
||||
'order_item', $1, $1, $2, 'manual_expense_cost_missing', 'open', $3::jsonb, NOW()
|
||||
)
|
||||
ON CONFLICT (subject_type, subject_id, reason) DO NOTHING
|
||||
`,
|
||||
[
|
||||
orderItemId,
|
||||
context.workOrderId,
|
||||
JSON.stringify({
|
||||
fundExpenseId,
|
||||
fundExpenseNo: fundWritten.expense.expense_no,
|
||||
amountFen,
|
||||
message: '人工录单已扣备用金,但该成本项没有匹配到有效成本版本',
|
||||
}),
|
||||
],
|
||||
)
|
||||
warning = '人工录单已扣备用金,但未匹配到有效成本版本,已进入异常队列'
|
||||
} else {
|
||||
const snapshotResult = await client.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
|
||||
`,
|
||||
[
|
||||
fundExpenseId,
|
||||
orderId,
|
||||
orderItemId,
|
||||
context.workOrderId,
|
||||
costItemId,
|
||||
versionId,
|
||||
context.canonicalProductId,
|
||||
amountFen,
|
||||
JSON.stringify({
|
||||
source: 'manual_order_cost',
|
||||
fundExpenseId,
|
||||
fundExpenseNo: fundWritten.expense.expense_no,
|
||||
fundAccountNo,
|
||||
paidByName: fundWritten.ownerName,
|
||||
payeeName,
|
||||
payeeType,
|
||||
paymentMethod,
|
||||
expenseCategory,
|
||||
evidence: evidenceJson,
|
||||
fulfillmentMode: context.fulfillmentMode,
|
||||
}),
|
||||
paidAt,
|
||||
nowIso(),
|
||||
],
|
||||
)
|
||||
costSnapshotId = snapshotResult.rows[0]?.id ?? null
|
||||
if (!costSnapshotId) {
|
||||
// 唯一索引冲突意味着同一主体+成本项已有有效快照。钱已经扣了,不能静默吞掉,
|
||||
// 否则会出现「钱扣了、成本没记」的账实不符。
|
||||
throw createHttpError(
|
||||
'该订单项已有同成本项的人工成本快照,本次未写入成本;备用金扣款已回滚,请改用新的支出单或先处理已有快照',
|
||||
{ statusCode: 409, errorCode: 'manual_order_cost_snapshot_conflict' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
expenseNo: fundWritten.expense.expense_no,
|
||||
fundExpenseId,
|
||||
fundAccountId,
|
||||
fundAccountNo,
|
||||
amountFen,
|
||||
orderId,
|
||||
orderItemId,
|
||||
costSnapshotId,
|
||||
fulfillmentMode: context.fulfillmentMode,
|
||||
warning,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 人工录单可用的成本项:人工客服履约成本。 */
|
||||
export async function listManualOrderCostItems() {
|
||||
const result = await query<{
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
cost_model: string
|
||||
pricing_mode: string
|
||||
}>(
|
||||
`
|
||||
SELECT id, code, name, cost_model, pricing_mode
|
||||
FROM cost_items
|
||||
WHERE status = 'active'
|
||||
AND cost_model = 'manual_actual'
|
||||
ORDER BY id
|
||||
`,
|
||||
)
|
||||
return {
|
||||
items: result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
costModel: row.cost_model,
|
||||
pricingMode: row.pricing_mode,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOrderItemId(orderId: number, requestedItemId: number | null) {
|
||||
const itemsResult = await query<{ id: number }>(
|
||||
'SELECT id FROM order_items WHERE order_id = $1 ORDER BY id ASC',
|
||||
[orderId],
|
||||
)
|
||||
if (itemsResult.rows.length === 0) {
|
||||
throw createHttpError('订单没有订单项,无法录入人工成本', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_items_missing',
|
||||
})
|
||||
}
|
||||
if (requestedItemId) {
|
||||
if (!itemsResult.rows.some((row) => Number(row.id) === requestedItemId)) {
|
||||
throw createHttpError('订单项不属于该订单', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_item_mismatch',
|
||||
})
|
||||
}
|
||||
return requestedItemId
|
||||
}
|
||||
if (itemsResult.rows.length === 1) {
|
||||
return Number(itemsResult.rows[0]?.id || 0)
|
||||
}
|
||||
throw createHttpError('订单有多个订单项,请选择要计入的订单项', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_item_required',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 备用金账户归属校验:客服只能用本人账户;管理员/财务可代录到指定客服账户。
|
||||
*/
|
||||
async function resolveFundAccountId(requestedFundAccountId: number | null, session: AdminSession) {
|
||||
if (session.role === 'support') {
|
||||
const ownAccountId = await getOwnAgentAccountId(session.userId)
|
||||
if (!ownAccountId) {
|
||||
throw createHttpError('当前账号还没有备用金账户', {
|
||||
statusCode: 404,
|
||||
errorCode: 'fund_agent_account_not_found',
|
||||
})
|
||||
}
|
||||
if (requestedFundAccountId && requestedFundAccountId !== ownAccountId) {
|
||||
throw createHttpError('只能使用自己的备用金账户', {
|
||||
statusCode: 403,
|
||||
errorCode: 'manual_order_cost_fund_account_forbidden',
|
||||
})
|
||||
}
|
||||
return ownAccountId
|
||||
}
|
||||
if (!requestedFundAccountId) {
|
||||
throw createHttpError('请选择备用金账户,人工录单必须从备用金扣款', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_fund_account_required',
|
||||
})
|
||||
}
|
||||
return requestedFundAccountId
|
||||
}
|
||||
|
||||
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 costItemId = Number(result.rows[0]?.id || 0)
|
||||
if (!costItemId) {
|
||||
throw createHttpError('人工客服履约成本项未启用', {
|
||||
statusCode: 409,
|
||||
errorCode: 'manual_order_cost_item_unavailable',
|
||||
})
|
||||
}
|
||||
return costItemId
|
||||
}
|
||||
|
||||
type OrderItemContext = {
|
||||
orderId: number
|
||||
canonicalProductId: number | null
|
||||
provider: string
|
||||
shopId: string
|
||||
workOrderId: number | null
|
||||
workProductRuleId: number | null
|
||||
fulfillmentMode: string
|
||||
}
|
||||
|
||||
async function loadFundAccountNo(client: PoolClient, fundAccountId: number) {
|
||||
const result = await client.query<{ account_no: string }>(
|
||||
'SELECT account_no FROM fund_accounts WHERE id = $1 LIMIT 1',
|
||||
[fundAccountId],
|
||||
)
|
||||
return result.rows[0]?.account_no || ''
|
||||
}
|
||||
|
||||
async function loadOrderItemContext(
|
||||
client: PoolClient,
|
||||
orderItemId: number,
|
||||
): Promise<OrderItemContext> {
|
||||
const result = await client.query<OrderItemContext>(
|
||||
`
|
||||
SELECT
|
||||
oi.order_id,
|
||||
oi.canonical_product_id,
|
||||
o.provider,
|
||||
o.shop_id,
|
||||
wo.id AS work_order_id,
|
||||
wo.product_rule_id AS work_product_rule_id,
|
||||
COALESCE(oi.fulfillment_mode, 'manual') AS fulfillment_mode
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
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
|
||||
WHERE oi.id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[orderItemId],
|
||||
)
|
||||
const row = result.rows[0]
|
||||
if (!row) {
|
||||
throw createHttpError('订单项不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'manual_order_cost_order_item_not_found',
|
||||
})
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析成本版本:优先级 rule > product > shop > provider > global,按业务发生时间匹配。
|
||||
* 人工成本项的 draft 占位版本也接受,避免未启用正式版本时人工单无法入账。
|
||||
*/
|
||||
async function resolveCostVersionId(
|
||||
client: PoolClient,
|
||||
costItemId: number,
|
||||
context: OrderItemContext,
|
||||
basisAt: string,
|
||||
) {
|
||||
const result = await client.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,
|
||||
context.provider,
|
||||
context.shopId,
|
||||
context.canonicalProductId,
|
||||
context.workProductRuleId,
|
||||
],
|
||||
)
|
||||
return result.rows[0]?.id ?? null
|
||||
}
|
||||
|
||||
/** 人工录单的费用类型映射到备用金支出分类。 */
|
||||
function mapFundExpenseCategory(category: string): string {
|
||||
switch (category) {
|
||||
case 'goods_purchase':
|
||||
return 'purchase'
|
||||
case 'compensation':
|
||||
return 'compensation'
|
||||
case 'freight':
|
||||
return 'freight'
|
||||
case 'recharge':
|
||||
return 'platform_recharge'
|
||||
default:
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
|
||||
function mapFundPayeeType(payeeType: string): string {
|
||||
switch (payeeType) {
|
||||
case 'worker':
|
||||
case 'upstream':
|
||||
return 'supplier'
|
||||
case 'customer':
|
||||
return 'customer'
|
||||
case 'platform':
|
||||
return 'platform'
|
||||
default:
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
|
||||
function toFundEvidence(evidence: JsonObject): JsonObject[] {
|
||||
const items: JsonObject[] = []
|
||||
const urls = Array.isArray(evidence.urls) ? evidence.urls : []
|
||||
for (const value of urls) {
|
||||
const url = String(value || '').trim()
|
||||
if (url) {
|
||||
items.push({ url, filename: '人工录单凭证', contentType: 'image/*' })
|
||||
}
|
||||
}
|
||||
const reference = String(evidence.reference || '').trim()
|
||||
if (/^https?:\/\//i.test(reference) && !items.some((item) => item.url === reference)) {
|
||||
items.push({ url: reference, filename: '人工录单凭证', contentType: 'image/*' })
|
||||
}
|
||||
return items.slice(0, 6)
|
||||
}
|
||||
|
||||
function buildFundExpenseNote(note: string, reference: string): string {
|
||||
const parts = ['人工录单']
|
||||
if (reference) parts.push(`凭证:${reference}`)
|
||||
if (note) parts.push(note)
|
||||
return parts.join(' · ').slice(0, 500)
|
||||
}
|
||||
|
||||
function normalizeEvidence(value: unknown): JsonObject {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as JsonObject
|
||||
}
|
||||
const text = String(value || '').trim()
|
||||
return text ? { reference: text } : {}
|
||||
}
|
||||
|
||||
function hasEvidence(evidence: JsonObject) {
|
||||
const urls = Array.isArray(evidence.urls) ? evidence.urls : []
|
||||
return (
|
||||
urls.length > 0 ||
|
||||
Boolean(String(evidence.reference || '').trim()) ||
|
||||
Boolean(String(evidence.note || '').trim())
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeAmountFen(value: unknown) {
|
||||
const amount = Math.trunc(Number(value) || 0)
|
||||
if (amount <= 0) {
|
||||
throw createHttpError('支出金额必须大于 0', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_amount_invalid',
|
||||
})
|
||||
}
|
||||
if (amount > 100_000_000_00) {
|
||||
throw createHttpError('支出金额超出允许范围', {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_amount_too_large',
|
||||
})
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
function normalizeOptionalId(value: unknown) {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
function normalizeId(value: unknown, message: string) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw createHttpError(message, {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_id_invalid',
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown, allowed: Set<string>, fallback: string, message: string) {
|
||||
const text = String(value || '').trim() || fallback
|
||||
if (!allowed.has(text)) {
|
||||
throw createHttpError(message, {
|
||||
statusCode: 400,
|
||||
errorCode: 'manual_order_cost_value_invalid',
|
||||
})
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function optionalText(value: unknown, maxLength: number) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export type AdminOrderListQueryInput = {
|
||||
keyword?: string
|
||||
provider?: string
|
||||
executorKey?: string
|
||||
fulfillmentMode?: string
|
||||
platformOrderId?: string
|
||||
payStatus?: string
|
||||
taskStatus?: string
|
||||
|
||||
@@ -61,6 +61,8 @@ export type AdminOrderListItem = {
|
||||
fulfillmentMode: AdminOrderFulfillmentMode
|
||||
manualItemCount: number
|
||||
pendingItemCount: number
|
||||
manualPendingCount: number
|
||||
manualUnconfiguredCount: number
|
||||
hasVoucher: boolean
|
||||
voucherState: AdminVoucherState
|
||||
workOrderCount?: number
|
||||
|
||||
@@ -4,6 +4,7 @@ export type OrderListQueryInput = {
|
||||
keyword?: string
|
||||
provider?: string
|
||||
executorKey?: string
|
||||
fulfillmentMode?: string
|
||||
platformOrderId?: string
|
||||
payStatus?: string
|
||||
taskStatus?: string
|
||||
|
||||
@@ -23,7 +23,6 @@ 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'))
|
||||
@@ -145,7 +144,6 @@ 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 />} />
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 确认人工履约弹窗(订单列表 / 订单详情共用)。
|
||||
*
|
||||
* 口径(2026-10-24):
|
||||
* * 渠道与消耗积分在成本中心提前配好,客服不选、不填;
|
||||
* * 固定成本 → 直接确认;
|
||||
* * 成本不固定(消耗积分 = 0)→ 只填本次实际成本;
|
||||
* * 确认后扣对应平台积分/备用金并写成本快照。
|
||||
*
|
||||
* 原「人工履约待办」页面已下线,入口收敛到订单界面。
|
||||
*/
|
||||
import { CheckOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { confirmAdminManualFulfillment, fetchAdminManualFulfillmentPending } from '@/services/admin'
|
||||
import type { ManualFulfillmentPendingItem } from '@/types/admin'
|
||||
|
||||
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 type ManualFulfillmentConfirmModalProps = {
|
||||
open: boolean
|
||||
orderId: number | null
|
||||
initialOrderItemId?: number | null
|
||||
onClose: () => void
|
||||
onConfirmed?: () => void
|
||||
}
|
||||
|
||||
export function ManualFulfillmentConfirmModal({
|
||||
open,
|
||||
orderId,
|
||||
initialOrderItemId = null,
|
||||
onClose,
|
||||
onConfirmed,
|
||||
}: ManualFulfillmentConfirmModalProps) {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<ConfirmFormValues>()
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
|
||||
const pendingQuery = useQuery({
|
||||
queryKey: ['admin-manual-fulfillment-pending', 'order', orderId],
|
||||
queryFn: () =>
|
||||
fetchAdminManualFulfillmentPending({
|
||||
orderId: orderId || undefined,
|
||||
status: 'pending',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
enabled: open && Boolean(orderId),
|
||||
})
|
||||
|
||||
const items = pendingQuery.data?.data.items || []
|
||||
const selected =
|
||||
items.find((item) => item.orderItemId === selectedId) ||
|
||||
items.find((item) => item.orderItemId === initialOrderItemId) ||
|
||||
items[0] ||
|
||||
null
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSelectedId(initialOrderItemId || null)
|
||||
form.resetFields()
|
||||
}, [open, initialOrderItemId, form])
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: (values: ConfirmFormValues) =>
|
||||
confirmAdminManualFulfillment({
|
||||
orderItemId: selected?.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)}`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-manual-fulfillment-pending'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-orders'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-my'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
|
||||
onConfirmed?.()
|
||||
onClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '确认履约失败')
|
||||
},
|
||||
})
|
||||
|
||||
const showCostInput = selected?.costFen === null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="确认人工履约"
|
||||
okText="确认履约"
|
||||
okButtonProps={{ icon: <CheckOutlined />, disabled: !selected }}
|
||||
cancelText="取消"
|
||||
confirmLoading={confirmMutation.isPending}
|
||||
onOk={() => {
|
||||
void form.validateFields().then((values) => confirmMutation.mutate(values))
|
||||
}}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
>
|
||||
{pendingQuery.isLoading ? (
|
||||
<Typography.Text type="secondary">正在读取待履约商品…</Typography.Text>
|
||||
) : items.length === 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该订单没有待确认的人工履约项"
|
||||
description="可能已履约,或该商品还没有在「商品与成本 → 人工 SKU 配置」里配置履约渠道。"
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{items.length > 1 ? (
|
||||
<Select
|
||||
value={selected?.orderItemId}
|
||||
onChange={(value) => setSelectedId(value)}
|
||||
options={items.map((item) => ({
|
||||
value: item.orderItemId,
|
||||
label: `${item.skuName || item.skuCode} · ${item.internalSku || item.skuCode}`,
|
||||
}))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<Typography.Text type="secondary">商品:</Typography.Text>
|
||||
<Typography.Text strong>
|
||||
{selected?.skuName || selected?.skuCode || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">内部 SKU:</Typography.Text>
|
||||
<Typography.Text copyable={{ text: selected?.internalSku || '' }}>
|
||||
{selected?.internalSku || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">履约渠道:</Typography.Text>
|
||||
<Tag color={PLATFORM_TAG_COLORS[selected?.platformKey || ''] || 'default'}>
|
||||
{selected?.platformLabel || '-'}
|
||||
</Tag>
|
||||
{selected && !selected.isFundCash ? (
|
||||
<Typography.Text>消耗 {selected.creditQuantity} 积分</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">订单金额:</Typography.Text>
|
||||
<Typography.Text>¥{((selected?.revenueFen || 0) / 100).toFixed(2)}</Typography.Text>
|
||||
</div>
|
||||
|
||||
{showCostInput ? (
|
||||
<Alert type="warning" showIcon message="该商品成本不固定,请填写本次实际成本" />
|
||||
) : (
|
||||
<Statistic
|
||||
title="本次成本"
|
||||
value={(selected?.costFen || 0) / 100}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { App, Alert, Form, Input, InputNumber, Modal, Select } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { createAdminManualOrderCost, fetchAdminFundAccounts } from '@/services/admin'
|
||||
import { getAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
export type ManualOrderCostItem = {
|
||||
orderItemId: number
|
||||
matchSku?: string
|
||||
skuName?: string
|
||||
skuCode?: string
|
||||
quantity?: number
|
||||
}
|
||||
|
||||
type ManualOrderCostModalProps = {
|
||||
open: boolean
|
||||
orderId: number
|
||||
orderNo: string
|
||||
items: ManualOrderCostItem[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ManualOrderCostFormValues = {
|
||||
orderItemId: number
|
||||
fundAccountId: number
|
||||
amountYuan: number
|
||||
expenseCategory: string
|
||||
payeeName?: string
|
||||
paymentMethod?: string
|
||||
evidence?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: 'service_fee', label: '人工服务费' },
|
||||
{ value: 'goods_purchase', label: '商品采购' },
|
||||
{ value: 'compensation', label: '赔付 / 补偿' },
|
||||
{ value: 'refund_advance', label: '垫付退款' },
|
||||
{ value: 'freight', label: '运费' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
const PAYMENT_METHOD_OPTIONS = [
|
||||
{ value: 'wechat', label: '微信' },
|
||||
{ value: 'alipay', label: '支付宝' },
|
||||
{ value: 'bank', label: '银行卡' },
|
||||
{ value: 'cash', label: '现金' },
|
||||
{ value: 'platform_balance', label: '平台余额' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
function itemLabel(item: ManualOrderCostItem) {
|
||||
// 只展示平台判断用的真实 SKU,引流长标题不进录单选项。
|
||||
const sku = item.matchSku || item.skuName || item.skuCode || `订单项 #${item.orderItemId}`
|
||||
return item.quantity && item.quantity > 1 ? `${sku} ×${item.quantity}` : sku
|
||||
}
|
||||
|
||||
export default function ManualOrderCostModal({
|
||||
open,
|
||||
orderId,
|
||||
orderNo,
|
||||
items,
|
||||
onClose,
|
||||
}: ManualOrderCostModalProps) {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<ManualOrderCostFormValues>()
|
||||
const fundAccountsQuery = useQuery({
|
||||
queryKey: ['admin-fund-accounts', 'agent'],
|
||||
queryFn: () =>
|
||||
fetchAdminFundAccounts({ accountType: 'agent', status: 'active', pageSize: 200 }),
|
||||
enabled: open,
|
||||
})
|
||||
const fundAccounts = fundAccountsQuery.data?.data.items || []
|
||||
const soleFundAccountId = fundAccounts.length === 1 ? fundAccounts[0]?.id : undefined
|
||||
const isSupport = getAdminRole() === 'support'
|
||||
const ownFundAccount = isSupport ? fundAccounts[0] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
orderItemId: items.length === 1 ? items[0].orderItemId : undefined,
|
||||
fundAccountId: soleFundAccountId,
|
||||
amountYuan: undefined,
|
||||
expenseCategory: 'service_fee',
|
||||
paymentMethod: 'other',
|
||||
})
|
||||
}, [form, items, open, soleFundAccountId])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: ManualOrderCostFormValues) =>
|
||||
createAdminManualOrderCost({
|
||||
orderId,
|
||||
orderItemId: values.orderItemId,
|
||||
fundAccountId: values.fundAccountId,
|
||||
amountFen: Math.max(1, Math.round(Number(values.amountYuan || 0) * 100)),
|
||||
expenseCategory: values.expenseCategory,
|
||||
payeeName: values.payeeName || '内部人工',
|
||||
paymentMethod: values.paymentMethod || 'other',
|
||||
note: values.note || '',
|
||||
evidenceJson: values.evidence
|
||||
? { reference: values.evidence }
|
||||
: { note: values.note || '订单页人工录单' },
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
if (result.warning) {
|
||||
message.warning(result.warning)
|
||||
} else {
|
||||
message.success(`已计入订单成本,支出单号 ${result.expenseNo}`)
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-sales-profit-overview'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-sales-facts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', String(orderId)] })
|
||||
onClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : '录入人工成本失败')
|
||||
},
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
const values = await form.validateFields()
|
||||
mutation.mutate(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="人工录单 / 录入订单成本"
|
||||
okText="确认录入并计入成本"
|
||||
cancelText="取消"
|
||||
confirmLoading={mutation.isPending}
|
||||
onOk={() => void submit()}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
width={620}
|
||||
>
|
||||
<Form<ManualOrderCostFormValues>
|
||||
form={form as FormInstance<ManualOrderCostFormValues>}
|
||||
layout="vertical"
|
||||
initialValues={{ expenseCategory: 'service_fee', paymentMethod: 'other' }}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="每一单单独录实际金额,不按小时、不按单量套模板"
|
||||
description="成本会从所选客服备用金账户扣减,并关联到订单号;只有一笔订单项时自动带入,多笔时需要选择具体订单项。录入即写入成本快照并计入销售毛利,没有审批与报销环节。"
|
||||
/>
|
||||
<Form.Item label="订单号">
|
||||
<Input value={orderNo} readOnly />
|
||||
</Form.Item>
|
||||
{isSupport ? (
|
||||
<>
|
||||
<Form.Item label="备用金账户" extra="只能使用自己的备用金账户">
|
||||
<Input
|
||||
readOnly
|
||||
value={
|
||||
ownFundAccount
|
||||
? `${ownFundAccount.ownerName} · ${ownFundAccount.accountNo} · 余额 ¥${(
|
||||
ownFundAccount.balanceFen / 100
|
||||
).toFixed(2)}`
|
||||
: '当前账号还没有备用金账户'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="fundAccountId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="fundAccountId"
|
||||
label="备用金账户"
|
||||
rules={[{ required: true, message: '请选择备用金账户' }]}
|
||||
extra="人工支出会直接从该客服的备用金余额扣减"
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
loading={fundAccountsQuery.isLoading}
|
||||
placeholder="请选择这笔支出从哪个客服备用金账户出"
|
||||
options={fundAccounts.map((account) => ({
|
||||
value: account.id,
|
||||
label: `${account.ownerName} · ${account.accountNo} · 余额 ¥${(
|
||||
account.balanceFen / 100
|
||||
).toFixed(2)}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!fundAccountsQuery.isLoading && fundAccounts.length === 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="没有可用的客服备用金账户"
|
||||
description="请先到「备用金」页为客服开通账户并拨付备用金,否则无法登记人工支出。"
|
||||
/>
|
||||
) : null}
|
||||
{items.length === 1 ? (
|
||||
<>
|
||||
<Form.Item label="计入订单项">
|
||||
<Input value={itemLabel(items[0])} readOnly />
|
||||
</Form.Item>
|
||||
<Form.Item name="orderItemId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Form.Item
|
||||
name="orderItemId"
|
||||
label="计入订单项"
|
||||
rules={[{ required: true, message: '请选择订单项' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="订单有多个商品,请选择计入哪一项"
|
||||
options={items.map((item) => ({
|
||||
value: item.orderItemId,
|
||||
label: itemLabel(item),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label="人工成本金额(元)"
|
||||
rules={[{ required: true, message: '请输入实际金额' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="例如 12.50"
|
||||
prefix="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="expenseCategory"
|
||||
label="费用类型"
|
||||
rules={[{ required: true, message: '请选择费用类型' }]}
|
||||
>
|
||||
<Select options={CATEGORY_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="payeeName" label="处理人 / 收款方">
|
||||
<Input placeholder="例如:客服小王、上游商家;留空记内部人工" maxLength={120} />
|
||||
</Form.Item>
|
||||
<Form.Item name="paymentMethod" label="付款方式">
|
||||
<Select options={PAYMENT_METHOD_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="evidence" label="凭证链接 / 流水号(可选)">
|
||||
<Input placeholder="转账截图链接、流水号等;留空则用备注作为凭证" maxLength={300} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="备注">
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
placeholder="这一单实际做了什么、为什么是这个金额"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
AuditOutlined,
|
||||
AccountBookOutlined,
|
||||
ApartmentOutlined,
|
||||
@@ -163,11 +162,6 @@ 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',
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
/**
|
||||
* 人工履约待办页:客服确认履约。
|
||||
*
|
||||
* 口径(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,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
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-fund-my'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
|
||||
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 : '确认履约失败')
|
||||
},
|
||||
})
|
||||
|
||||
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}>
|
||||
<PercentTable
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckOutlined,
|
||||
CopyOutlined,
|
||||
FileTextOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
RollbackOutlined,
|
||||
@@ -23,19 +23,20 @@ import {
|
||||
Skeleton,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
|
||||
import dayjs from '@/lib/dayjs'
|
||||
|
||||
import JsonPreview from '@/components/admin/JsonPreview'
|
||||
import ManualOrderCostModal from '@/components/admin/ManualOrderCostModal'
|
||||
import { ManualFulfillmentConfirmModal } from '@/components/admin/ManualFulfillmentConfirmModal'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import {
|
||||
@@ -57,9 +58,8 @@ export default function AdminOrderDetailPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { message, modal } = App.useApp()
|
||||
const { orderId = '' } = useParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [statusModalVisible, setStatusModalVisible] = useState(false)
|
||||
const [manualCostOpen, setManualCostOpen] = useState(false)
|
||||
const [fulfillOpen, setFulfillOpen] = useState(false)
|
||||
const [costPreviewItemId, setCostPreviewItemId] = useState<number | null>(null)
|
||||
const [refundModalOpen, setRefundModalOpen] = useState(false)
|
||||
const [refundScope, setRefundScope] = useState<'order' | 'item'>('order')
|
||||
@@ -75,21 +75,6 @@ export default function AdminOrderDetailPage() {
|
||||
const [targetStatus, setTargetStatus] = useState<'active' | 'paused' | 'closed'>('active')
|
||||
const [statusReason, setStatusReason] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('manualCost') === '1') {
|
||||
setManualCostOpen(true)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
function closeManualCost() {
|
||||
setManualCostOpen(false)
|
||||
if (searchParams.get('manualCost')) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('manualCost')
|
||||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-order-detail', orderId],
|
||||
queryFn: () => fetchAdminOrderDetail(orderId),
|
||||
@@ -314,9 +299,19 @@ export default function AdminOrderDetailPage() {
|
||||
退款登记
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<FileTextOutlined />} onClick={() => setManualCostOpen(true)}>
|
||||
人工录单
|
||||
</Button>
|
||||
{Number(detail.order.manualPendingCount || 0) > 0 ? (
|
||||
<Button icon={<CheckOutlined />} onClick={() => setFulfillOpen(true)}>
|
||||
确认履约
|
||||
</Button>
|
||||
) : Number(detail.order.manualUnconfiguredCount || 0) > 0 ? (
|
||||
<Tooltip
|
||||
title={`有 ${detail.order.manualUnconfiguredCount} 个人工商品未配置履约渠道,请先到「商品与成本 → 人工 SKU 配置」补全`}
|
||||
>
|
||||
<Tag color="orange" style={{ margin: 0 }}>
|
||||
待补全履约
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{isAutoClaimOrder ? (
|
||||
<>
|
||||
<Button icon={<CopyOutlined />} onClick={handleCopyH5Link}>
|
||||
@@ -620,18 +615,13 @@ export default function AdminOrderDetailPage() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ManualOrderCostModal
|
||||
open={manualCostOpen}
|
||||
<ManualFulfillmentConfirmModal
|
||||
open={fulfillOpen}
|
||||
orderId={detail.order.orderId}
|
||||
orderNo={detail.order.platformOrderId || String(detail.order.orderId)}
|
||||
items={detail.items.map((item) => ({
|
||||
orderItemId: item.orderItemId,
|
||||
matchSku: item.matchSku,
|
||||
skuName: item.skuName,
|
||||
skuCode: item.skuCode,
|
||||
quantity: item.quantity,
|
||||
}))}
|
||||
onClose={closeManualCost}
|
||||
onClose={() => setFulfillOpen(false)}
|
||||
onConfirmed={() =>
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', orderId] })
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
CheckOutlined,
|
||||
CopyOutlined,
|
||||
DollarOutlined,
|
||||
EyeOutlined,
|
||||
LinkOutlined,
|
||||
ReloadOutlined,
|
||||
@@ -23,10 +23,11 @@ import {
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
|
||||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||
import { ManualFulfillmentConfirmModal } from '@/components/admin/ManualFulfillmentConfirmModal'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
@@ -38,7 +39,7 @@ import {
|
||||
resolveVoucherStateLabel,
|
||||
} from '@/utils/admin-display'
|
||||
import {
|
||||
adminOrderExecutorOptions,
|
||||
adminOrderFulfillmentModeOptions,
|
||||
adminOrderPlatformSegmentedOptions,
|
||||
adminOrderProviderOptions,
|
||||
adminPayStatusOptions,
|
||||
@@ -54,7 +55,7 @@ import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
type OrderFilterForm = {
|
||||
keyword?: string
|
||||
provider?: string
|
||||
executorKey?: string
|
||||
fulfillmentMode?: string
|
||||
payStatus?: string
|
||||
taskStatus?: string
|
||||
dateRange?: unknown
|
||||
@@ -62,6 +63,8 @@ type OrderFilterForm = {
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
const navigate = useNavigate()
|
||||
const [fulfillOpen, setFulfillOpen] = useState(false)
|
||||
const [fulfillOrderId, setFulfillOrderId] = useState<number | null>(null)
|
||||
const { message } = App.useApp()
|
||||
const [form] = Form.useForm<OrderFilterForm>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
@@ -71,7 +74,7 @@ export default function AdminOrdersPage() {
|
||||
const filters = {
|
||||
keyword: searchParams.get('keyword') || '',
|
||||
provider: searchParams.get('provider') || '',
|
||||
executorKey: searchParams.get('executorKey') || '',
|
||||
fulfillmentMode: searchParams.get('fulfillmentMode') || '',
|
||||
payStatus: searchParams.get('payStatus') || '',
|
||||
taskStatus: searchParams.get('taskStatus') || '',
|
||||
dateFrom: searchParams.get('dateFrom') || '',
|
||||
@@ -83,7 +86,7 @@ export default function AdminOrdersPage() {
|
||||
[
|
||||
filters.dateFrom,
|
||||
filters.dateTo,
|
||||
filters.executorKey,
|
||||
filters.fulfillmentMode,
|
||||
filters.keyword,
|
||||
filters.payStatus,
|
||||
filters.provider,
|
||||
@@ -103,7 +106,7 @@ export default function AdminOrdersPage() {
|
||||
form.setFieldsValue({
|
||||
keyword: filters.keyword || undefined,
|
||||
provider: filters.provider || undefined,
|
||||
executorKey: filters.executorKey || undefined,
|
||||
fulfillmentMode: filters.fulfillmentMode || undefined,
|
||||
payStatus: filters.payStatus || undefined,
|
||||
taskStatus: filters.taskStatus || undefined,
|
||||
dateRange:
|
||||
@@ -114,7 +117,7 @@ export default function AdminOrdersPage() {
|
||||
}, [
|
||||
filters.dateFrom,
|
||||
filters.dateTo,
|
||||
filters.executorKey,
|
||||
filters.fulfillmentMode,
|
||||
filters.keyword,
|
||||
filters.payStatus,
|
||||
filters.provider,
|
||||
@@ -328,7 +331,8 @@ export default function AdminOrdersPage() {
|
||||
render: (_, row) => {
|
||||
const orderNo = row.platformOrderId || String(row.orderId)
|
||||
const h5Url = `${window.location.origin}/h5/${encodeURIComponent(orderNo)}`
|
||||
const isManualOrder = Number(row.manualItemCount || 0) > 0
|
||||
const manualPending = Number(row.manualPendingCount || 0)
|
||||
const manualUnconfigured = Number(row.manualUnconfiguredCount || 0)
|
||||
|
||||
return (
|
||||
<Space size={6} wrap>
|
||||
@@ -341,16 +345,27 @@ export default function AdminOrdersPage() {
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
{isManualOrder ? (
|
||||
<Tooltip title="人工录单:录入这一单的实际人工成本">
|
||||
{manualPending > 0 ? (
|
||||
<Tooltip title="人工履约:确认后扣积分/备用金并写入成本">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DollarOutlined />}
|
||||
onClick={() => navigate(`/admin/orders/${row.orderId}?manualCost=1`)}
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => {
|
||||
setFulfillOrderId(row.orderId)
|
||||
setFulfillOpen(true)
|
||||
}}
|
||||
>
|
||||
人工录单
|
||||
确认履约
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : manualUnconfigured > 0 ? (
|
||||
<Tooltip
|
||||
title={`有 ${manualUnconfigured} 个人工商品未配置履约渠道,请先到「商品与成本 → 人工 SKU 配置」补全`}
|
||||
>
|
||||
<Tag color="orange" style={{ margin: 0 }}>
|
||||
待补全履约
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{row.canClaimH5 ? (
|
||||
<Tooltip title="复制自动直充 H5 领取链接">
|
||||
@@ -398,7 +413,7 @@ export default function AdminOrdersPage() {
|
||||
form.setFieldsValue({
|
||||
keyword: undefined,
|
||||
provider: undefined,
|
||||
executorKey: undefined,
|
||||
fulfillmentMode: undefined,
|
||||
payStatus: undefined,
|
||||
taskStatus: undefined,
|
||||
dateRange: undefined,
|
||||
@@ -459,11 +474,11 @@ export default function AdminOrdersPage() {
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="executorKey">
|
||||
<Form.Item name="fulfillmentMode">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="履约模式"
|
||||
options={adminOrderExecutorOptions}
|
||||
options={adminOrderFulfillmentModeOptions}
|
||||
style={{ minWidth: 130 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -525,6 +540,13 @@ export default function AdminOrdersPage() {
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<ManualFulfillmentConfirmModal
|
||||
open={fulfillOpen}
|
||||
orderId={fulfillOrderId}
|
||||
onClose={() => setFulfillOpen(false)}
|
||||
onConfirmed={() => void query.refetch()}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -390,7 +390,13 @@ export function adjustAdminPlatformCredit(payload: {
|
||||
* 成本不固定时只填金额。
|
||||
*/
|
||||
export function fetchAdminManualFulfillmentPending(
|
||||
params: { page?: number; pageSize?: number; keyword?: string; status?: string } = {},
|
||||
params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
orderId?: number
|
||||
} = {},
|
||||
) {
|
||||
return apiGet<ManualFulfillmentPendingListResult>(
|
||||
'/api/v1/admin/finance/manual-fulfillment/pending',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * from './auth'
|
||||
export * from './dashboard'
|
||||
export * from './finance'
|
||||
export * from './manual-order-cost'
|
||||
export * from './cost-center'
|
||||
export * from './fund'
|
||||
export * from './users'
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
|
||||
/**
|
||||
* 人工录单:直接扣客服备用金 + 直接写成本快照。
|
||||
* 没有登记单、没有审批、没有报销环节。
|
||||
*/
|
||||
|
||||
export type AdminManualOrderCostResult = {
|
||||
expenseNo: string
|
||||
fundExpenseId: number
|
||||
fundAccountId: number
|
||||
fundAccountNo: string
|
||||
amountFen: number
|
||||
orderId: number
|
||||
orderItemId: number
|
||||
costSnapshotId: number | null
|
||||
fulfillmentMode: string
|
||||
warning: string
|
||||
}
|
||||
|
||||
export function fetchAdminManualOrderCostItems() {
|
||||
return apiGet<{
|
||||
items: Array<{
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
costModel: string
|
||||
pricingMode: string
|
||||
}>
|
||||
}>('/api/v1/admin/finance/manual-order-cost-items')
|
||||
}
|
||||
|
||||
export function createAdminManualOrderCost(payload: Record<string, unknown>) {
|
||||
return apiPost<AdminManualOrderCostResult>('/api/v1/admin/finance/manual-order-costs', payload)
|
||||
}
|
||||
@@ -63,6 +63,10 @@ export interface AdminOrderListItem {
|
||||
fulfillmentMode: AdminOrderFulfillmentMode
|
||||
manualItemCount: number
|
||||
pendingItemCount: number
|
||||
/** 可确认履约的人工项数量 */
|
||||
manualPendingCount: number
|
||||
/** 未配置履约渠道的人工项数量 */
|
||||
manualUnconfiguredCount: number
|
||||
hasVoucher: boolean
|
||||
voucherState: AdminVoucherState
|
||||
workOrderCount?: number
|
||||
@@ -85,6 +89,10 @@ export interface AdminOrderDetail {
|
||||
fulfillmentMode: AdminOrderFulfillmentMode
|
||||
manualItemCount: number
|
||||
pendingItemCount: number
|
||||
/** 可确认履约的人工项数量 */
|
||||
manualPendingCount: number
|
||||
/** 未配置履约渠道的人工项数量 */
|
||||
manualUnconfiguredCount: number
|
||||
hasVoucher: boolean
|
||||
voucherState: AdminVoucherState
|
||||
workOrderCount?: number
|
||||
|
||||
@@ -28,6 +28,16 @@ export const adminOrderExecutorOptions = [
|
||||
{ label: '人工履约', value: 'manual_dispatch' },
|
||||
]
|
||||
|
||||
/** 订单列表按 order_items.fulfillment_mode 聚合后的履约方式筛选。 */
|
||||
export const adminOrderFulfillmentModeOptions = [
|
||||
{ label: '自动发货', value: 'affiliate_dash' },
|
||||
{ label: '接单平台', value: 'work_order' },
|
||||
{ label: '历史平台', value: 'legacy_platform' },
|
||||
{ label: '人工履约', value: 'manual' },
|
||||
{ label: '混合履约', value: 'mixed' },
|
||||
{ label: '待判定', value: 'pending' },
|
||||
]
|
||||
|
||||
export const adminTaskStatusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待支付', value: 'pending_payment' },
|
||||
|
||||
Reference in New Issue
Block a user