增加售后相关的
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
-- 验收后的售后追责单:不改变已验收工单的生命周期和历史结算。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_after_sales_cases (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
case_no TEXT NOT NULL UNIQUE,
|
||||
work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||
work_order_share_id BIGINT REFERENCES work_order_shares(id) ON DELETE SET NULL,
|
||||
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE RESTRICT,
|
||||
problem_type TEXT NOT NULL DEFAULT 'fake_evidence',
|
||||
complaint_note TEXT NOT NULL DEFAULT '',
|
||||
complaint_files_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
acceptance_snapshot_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
status TEXT NOT NULL DEFAULT 'awaiting_worker_response',
|
||||
worker_response TEXT NOT NULL DEFAULT '',
|
||||
worker_response_files_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
responded_at TIMESTAMPTZ,
|
||||
resolution_action TEXT NOT NULL DEFAULT '',
|
||||
resolution_note TEXT NOT NULL DEFAULT '',
|
||||
recovery_amount INTEGER NOT NULL DEFAULT 0,
|
||||
pending_deposit_deduction_amount INTEGER NOT NULL DEFAULT 0,
|
||||
available_deduction_amount INTEGER NOT NULL DEFAULT 0,
|
||||
debt_amount INTEGER NOT NULL DEFAULT 0,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
resolved_by TEXT NOT NULL DEFAULT '',
|
||||
resolved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_after_sales_cases_active_target
|
||||
ON worker_after_sales_cases(work_order_id, worker_id, COALESCE(work_order_share_id, 0))
|
||||
WHERE status IN ('awaiting_worker_response', 'under_review', 'recovery_pending');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_after_sales_cases_status_created
|
||||
ON worker_after_sales_cases(status, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_after_sales_cases_worker_created
|
||||
ON worker_after_sales_cases(worker_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_after_sales_debts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
case_id BIGINT NOT NULL UNIQUE REFERENCES worker_after_sales_cases(id) ON DELETE CASCADE,
|
||||
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE RESTRICT,
|
||||
original_amount INTEGER NOT NULL,
|
||||
outstanding_amount INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
settled_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_after_sales_debts_worker_open
|
||||
ON worker_after_sales_debts(worker_id, status, created_at ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_after_sales_case_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
case_id BIGINT NOT NULL REFERENCES worker_after_sales_cases(id) ON DELETE CASCADE,
|
||||
actor_type TEXT NOT NULL,
|
||||
actor_id TEXT NOT NULL DEFAULT '',
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_after_sales_case_events_case_created
|
||||
ON worker_after_sales_case_events(case_id, created_at ASC, id ASC);
|
||||
|
||||
COMMENT ON TABLE worker_after_sales_cases IS '已验收工单的独立售后追责单,不回退原工单状态';
|
||||
COMMENT ON COLUMN worker_after_sales_cases.acceptance_snapshot_json IS '创建售后单时固化的验收证据快照';
|
||||
COMMENT ON TABLE worker_after_sales_debts IS '售后扣款不足时形成的打手追缴欠款';
|
||||
COMMENT ON TABLE worker_after_sales_case_events IS '售后问题单不可篡改的操作记录';
|
||||
@@ -0,0 +1,544 @@
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
|
||||
import type {
|
||||
WorkerAfterSalesCaseEventRow,
|
||||
WorkerAfterSalesCaseRow,
|
||||
WorkerAfterSalesDebtRow,
|
||||
WorkerDepositUnfreezeRow,
|
||||
WorkerWalletRow,
|
||||
WorkOrderRow,
|
||||
WorkOrderShareRow,
|
||||
} from './types.js'
|
||||
|
||||
const AFTER_SALES_CASE_SELECT = `
|
||||
SELECT
|
||||
ac.*,
|
||||
COALESCE(NULLIF(BTRIM(wo.platform_order_id), ''), wo.work_order_no) AS work_order_no,
|
||||
wo.platform_order_id,
|
||||
wo.product_name,
|
||||
COALESCE(
|
||||
NULLIF(BTRIM(o.shop_name), ''),
|
||||
NULLIF(BTRIM(o.shop_id), ''),
|
||||
NULLIF(BTRIM(ft.shop_name), ''),
|
||||
NULLIF(BTRIM(ft.shop_id), ''),
|
||||
''
|
||||
) AS shop_name,
|
||||
wu.username AS worker_username,
|
||||
wu.display_name AS worker_display_name,
|
||||
wos.quantity AS share_quantity
|
||||
FROM worker_after_sales_cases ac
|
||||
INNER JOIN work_orders wo ON wo.id = ac.work_order_id
|
||||
LEFT JOIN orders o ON o.id = wo.order_id
|
||||
LEFT JOIN fulfillment_tasks ft ON ft.id = wo.task_id
|
||||
INNER JOIN worker_users wu ON wu.id = ac.worker_id
|
||||
LEFT JOIN work_order_shares wos ON wos.id = ac.work_order_share_id
|
||||
`
|
||||
|
||||
type AfterSalesCaseFailureReason =
|
||||
| 'work_order_not_accepted'
|
||||
| 'work_order_share_not_accepted'
|
||||
| 'worker_missing'
|
||||
| 'active_case_exists'
|
||||
| null
|
||||
|
||||
export async function createAfterSalesCase(input: {
|
||||
caseNo: string
|
||||
workOrderId: number
|
||||
workOrderShareId?: number | null
|
||||
problemType: string
|
||||
complaintNote: string
|
||||
complaintFilesJson: string
|
||||
createdBy: string
|
||||
now: string
|
||||
}): Promise<{ case: WorkerAfterSalesCaseRow | null; failureReason: AfterSalesCaseFailureReason }> {
|
||||
return withTransaction(async (client) => {
|
||||
const workOrderResult = await client.query<WorkOrderRow>(
|
||||
`SELECT * FROM work_orders WHERE id = $1 AND status = 'accepted' FOR UPDATE`,
|
||||
[input.workOrderId],
|
||||
)
|
||||
const workOrder = workOrderResult.rows[0] || null
|
||||
if (!workOrder) {
|
||||
return { case: null, failureReason: 'work_order_not_accepted' }
|
||||
}
|
||||
|
||||
let workerId = Number(workOrder.assigned_worker_id || 0)
|
||||
let acceptanceSnapshot: Record<string, unknown> = {
|
||||
capturedAt: input.now,
|
||||
acceptedAt: workOrder.accepted_at,
|
||||
acceptance: workOrder.acceptance_json,
|
||||
}
|
||||
const shareId = Number(input.workOrderShareId || 0)
|
||||
if (shareId > 0) {
|
||||
const shareResult = await client.query<WorkOrderShareRow>(
|
||||
`SELECT * FROM work_order_shares WHERE id = $1 AND work_order_id = $2 FOR UPDATE`,
|
||||
[shareId, input.workOrderId],
|
||||
)
|
||||
const share = shareResult.rows[0] || null
|
||||
if (!share || share.status !== 'accepted') {
|
||||
return { case: null, failureReason: 'work_order_share_not_accepted' }
|
||||
}
|
||||
workerId = Number(share.worker_id || 0)
|
||||
acceptanceSnapshot = {
|
||||
capturedAt: input.now,
|
||||
acceptedAt: share.accepted_at,
|
||||
acceptance: share.acceptance_json,
|
||||
shareId,
|
||||
shareQuantity: Number(share.quantity || 0),
|
||||
}
|
||||
}
|
||||
if (workerId <= 0) {
|
||||
return { case: null, failureReason: 'worker_missing' }
|
||||
}
|
||||
|
||||
const createdResult = await client.query<WorkerAfterSalesCaseRow>(
|
||||
`
|
||||
INSERT INTO worker_after_sales_cases (
|
||||
case_no, work_order_id, work_order_share_id, worker_id,
|
||||
problem_type, complaint_note, complaint_files_json, acceptance_snapshot_json,
|
||||
status, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, NULLIF($3, 0), $4, $5, $6, $7::jsonb, $8::jsonb,
|
||||
'awaiting_worker_response', $9, $10, $10)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
input.caseNo,
|
||||
input.workOrderId,
|
||||
shareId,
|
||||
workerId,
|
||||
input.problemType,
|
||||
input.complaintNote,
|
||||
input.complaintFilesJson,
|
||||
JSON.stringify(acceptanceSnapshot),
|
||||
input.createdBy,
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
const created = createdResult.rows[0] || null
|
||||
if (!created) {
|
||||
return { case: null, failureReason: 'active_case_exists' }
|
||||
}
|
||||
await createAfterSalesCaseEventWithClient(client, {
|
||||
caseId: Number(created.id),
|
||||
actorType: 'admin',
|
||||
actorId: input.createdBy,
|
||||
eventType: 'created',
|
||||
payloadJson: JSON.stringify({
|
||||
problemType: input.problemType,
|
||||
complaintNote: input.complaintNote,
|
||||
workOrderShareId: shareId || null,
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
return { case: created, failureReason: null }
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAfterSalesCaseById(
|
||||
caseId: number | string,
|
||||
): Promise<WorkerAfterSalesCaseRow | null> {
|
||||
const result = await query<WorkerAfterSalesCaseRow>(
|
||||
`${AFTER_SALES_CASE_SELECT} WHERE ac.id = $1 LIMIT 1`,
|
||||
[Number(caseId)],
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listAfterSalesCases(
|
||||
input: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
keyword?: string
|
||||
workOrderId?: number
|
||||
} = {},
|
||||
): Promise<{ items: WorkerAfterSalesCaseRow[]; total: number }> {
|
||||
const params: unknown[] = []
|
||||
const conditions: string[] = []
|
||||
if (String(input.status || '').trim()) {
|
||||
params.push(String(input.status).trim())
|
||||
conditions.push(`ac.status = $${params.length}`)
|
||||
}
|
||||
if (Number(input.workOrderId || 0) > 0) {
|
||||
params.push(Number(input.workOrderId))
|
||||
conditions.push(`ac.work_order_id = $${params.length}`)
|
||||
}
|
||||
if (String(input.keyword || '').trim()) {
|
||||
params.push(`%${String(input.keyword).trim()}%`)
|
||||
conditions.push(
|
||||
`(ac.case_no ILIKE $${params.length} OR wo.work_order_no ILIKE $${params.length} OR wo.platform_order_id ILIKE $${params.length} OR wu.username ILIKE $${params.length} OR wu.display_name ILIKE $${params.length})`,
|
||||
)
|
||||
}
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const totalResult = await query<{ total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM worker_after_sales_cases ac
|
||||
INNER JOIN work_orders wo ON wo.id = ac.work_order_id
|
||||
INNER JOIN worker_users wu ON wu.id = ac.worker_id
|
||||
${whereClause}`,
|
||||
params,
|
||||
)
|
||||
const page = Math.max(1, Number(input.page || 1))
|
||||
const pageSize = Math.min(100, Math.max(1, Number(input.pageSize || 20)))
|
||||
const offset = (page - 1) * pageSize
|
||||
params.push(pageSize, offset)
|
||||
const itemsResult = await query<WorkerAfterSalesCaseRow>(
|
||||
`${AFTER_SALES_CASE_SELECT}
|
||||
${whereClause}
|
||||
ORDER BY ac.created_at DESC, ac.id DESC
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||
params,
|
||||
)
|
||||
return { items: itemsResult.rows, total: Number(totalResult.rows[0]?.total || 0) }
|
||||
}
|
||||
|
||||
export async function listWorkerAfterSalesCases(
|
||||
workerId: number,
|
||||
): Promise<WorkerAfterSalesCaseRow[]> {
|
||||
const result = await query<WorkerAfterSalesCaseRow>(
|
||||
`${AFTER_SALES_CASE_SELECT}
|
||||
WHERE ac.worker_id = $1
|
||||
ORDER BY CASE WHEN ac.status IN ('awaiting_worker_response', 'under_review', 'recovery_pending') THEN 0 ELSE 1 END,
|
||||
ac.created_at DESC, ac.id DESC`,
|
||||
[workerId],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function listAfterSalesCaseEvents(
|
||||
caseId: number | string,
|
||||
): Promise<WorkerAfterSalesCaseEventRow[]> {
|
||||
const result = await query<WorkerAfterSalesCaseEventRow>(
|
||||
`SELECT * FROM worker_after_sales_case_events WHERE case_id = $1 ORDER BY created_at ASC, id ASC`,
|
||||
[Number(caseId)],
|
||||
)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function submitWorkerAfterSalesResponse(input: {
|
||||
caseId: number
|
||||
workerId: number
|
||||
response: string
|
||||
filesJson: string
|
||||
now: string
|
||||
}): Promise<{
|
||||
case: WorkerAfterSalesCaseRow | null
|
||||
failureReason: 'not_found' | 'not_pending' | null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const currentResult = await client.query<WorkerAfterSalesCaseRow>(
|
||||
`SELECT * FROM worker_after_sales_cases WHERE id = $1 AND worker_id = $2 FOR UPDATE`,
|
||||
[input.caseId, input.workerId],
|
||||
)
|
||||
const current = currentResult.rows[0] || null
|
||||
if (!current) return { case: null, failureReason: 'not_found' }
|
||||
if (current.status !== 'awaiting_worker_response') {
|
||||
return { case: current, failureReason: 'not_pending' }
|
||||
}
|
||||
const updatedResult = await client.query<WorkerAfterSalesCaseRow>(
|
||||
`
|
||||
UPDATE worker_after_sales_cases
|
||||
SET status = 'under_review', worker_response = $1, worker_response_files_json = $2::jsonb,
|
||||
responded_at = $3, updated_at = $3
|
||||
WHERE id = $4
|
||||
RETURNING *
|
||||
`,
|
||||
[input.response, input.filesJson, input.now, input.caseId],
|
||||
)
|
||||
const updated = updatedResult.rows[0] || null
|
||||
if (updated) {
|
||||
await createAfterSalesCaseEventWithClient(client, {
|
||||
caseId: input.caseId,
|
||||
actorType: 'worker',
|
||||
actorId: String(input.workerId),
|
||||
eventType: 'worker_responded',
|
||||
payloadJson: JSON.stringify({ response: input.response }),
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
return { case: updated, failureReason: null }
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveAfterSalesCase(input: {
|
||||
caseId: number
|
||||
action: 'not_upheld' | 'remedied' | 'upheld'
|
||||
resolutionNote: string
|
||||
recoveryAmount: number
|
||||
resolvedBy: string
|
||||
now: string
|
||||
}): Promise<{
|
||||
case: WorkerAfterSalesCaseRow | null
|
||||
failureReason: 'not_found' | 'not_resolvable' | null
|
||||
}> {
|
||||
return withTransaction(async (client) => {
|
||||
const currentResult = await client.query<WorkerAfterSalesCaseRow>(
|
||||
`SELECT * FROM worker_after_sales_cases WHERE id = $1 FOR UPDATE`,
|
||||
[input.caseId],
|
||||
)
|
||||
const current = currentResult.rows[0] || null
|
||||
if (!current) return { case: null, failureReason: 'not_found' }
|
||||
if (!['awaiting_worker_response', 'under_review'].includes(current.status)) {
|
||||
return { case: current, failureReason: 'not_resolvable' }
|
||||
}
|
||||
|
||||
let pendingDepositDeductionAmount = 0
|
||||
let availableDeductionAmount = 0
|
||||
let debtAmount = 0
|
||||
let nextStatus = 'closed'
|
||||
if (input.action === 'upheld' && input.recoveryAmount > 0) {
|
||||
const recovery = await recoverAfterSalesAmountWithClient(client, {
|
||||
caseId: Number(current.id),
|
||||
workOrderId: Number(current.work_order_id),
|
||||
workerId: Number(current.worker_id),
|
||||
amount: input.recoveryAmount,
|
||||
note: input.resolutionNote,
|
||||
now: input.now,
|
||||
})
|
||||
pendingDepositDeductionAmount = recovery.pendingDepositDeductionAmount
|
||||
availableDeductionAmount = recovery.availableDeductionAmount
|
||||
debtAmount = recovery.debtAmount
|
||||
nextStatus = debtAmount > 0 ? 'recovery_pending' : 'closed'
|
||||
}
|
||||
|
||||
const updatedResult = await client.query<WorkerAfterSalesCaseRow>(
|
||||
`
|
||||
UPDATE worker_after_sales_cases
|
||||
SET
|
||||
status = $1,
|
||||
resolution_action = $2,
|
||||
resolution_note = $3,
|
||||
recovery_amount = $4,
|
||||
pending_deposit_deduction_amount = $5,
|
||||
available_deduction_amount = $6,
|
||||
debt_amount = $7,
|
||||
resolved_by = $8,
|
||||
resolved_at = $9,
|
||||
updated_at = $9
|
||||
WHERE id = $10
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
nextStatus,
|
||||
input.action,
|
||||
input.resolutionNote,
|
||||
input.action === 'upheld' ? input.recoveryAmount : 0,
|
||||
pendingDepositDeductionAmount,
|
||||
availableDeductionAmount,
|
||||
debtAmount,
|
||||
input.resolvedBy,
|
||||
input.now,
|
||||
input.caseId,
|
||||
],
|
||||
)
|
||||
const updated = updatedResult.rows[0] || null
|
||||
if (updated) {
|
||||
await createAfterSalesCaseEventWithClient(client, {
|
||||
caseId: Number(updated.id),
|
||||
actorType: 'admin',
|
||||
actorId: input.resolvedBy,
|
||||
eventType: `resolved_${input.action}`,
|
||||
payloadJson: JSON.stringify({
|
||||
recoveryAmount: input.action === 'upheld' ? input.recoveryAmount : 0,
|
||||
pendingDepositDeductionAmount,
|
||||
availableDeductionAmount,
|
||||
debtAmount,
|
||||
resolutionNote: input.resolutionNote,
|
||||
}),
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
return { case: updated, failureReason: null }
|
||||
})
|
||||
}
|
||||
|
||||
/** 在验收结算同一事务中抵扣未结清售后欠款,返回本次各售后单的抵扣明细。 */
|
||||
export async function offsetWorkerAfterSalesDebtsWithClient(
|
||||
client: PoolClient,
|
||||
input: { workerId: number; amount: number; workOrderId: number; now: string },
|
||||
): Promise<Array<{ caseId: number; amount: number }>> {
|
||||
let remaining = Math.max(0, input.amount)
|
||||
if (remaining <= 0) return []
|
||||
const debtsResult = await client.query<WorkerAfterSalesDebtRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM worker_after_sales_debts
|
||||
WHERE worker_id = $1 AND status = 'open' AND outstanding_amount > 0
|
||||
ORDER BY created_at ASC, id ASC
|
||||
FOR UPDATE
|
||||
`,
|
||||
[input.workerId],
|
||||
)
|
||||
const offsets: Array<{ caseId: number; amount: number }> = []
|
||||
for (const debt of debtsResult.rows) {
|
||||
if (remaining <= 0) break
|
||||
const outstanding = Number(debt.outstanding_amount || 0)
|
||||
const offsetAmount = Math.min(remaining, outstanding)
|
||||
const nextOutstanding = outstanding - offsetAmount
|
||||
const settled = nextOutstanding <= 0
|
||||
await client.query(
|
||||
`
|
||||
UPDATE worker_after_sales_debts
|
||||
SET outstanding_amount = $1, status = $2,
|
||||
settled_at = CASE WHEN $2 = 'settled' THEN $3 ELSE NULL END,
|
||||
updated_at = $3
|
||||
WHERE id = $4
|
||||
`,
|
||||
[nextOutstanding, settled ? 'settled' : 'open', input.now, debt.id],
|
||||
)
|
||||
await client.query(
|
||||
`
|
||||
UPDATE worker_after_sales_cases
|
||||
SET debt_amount = $1,
|
||||
status = CASE WHEN $1 = 0 AND status = 'recovery_pending' THEN 'closed' ELSE status END,
|
||||
updated_at = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
[nextOutstanding, input.now, debt.case_id],
|
||||
)
|
||||
await createAfterSalesCaseEventWithClient(client, {
|
||||
caseId: Number(debt.case_id),
|
||||
actorType: 'system',
|
||||
actorId: '',
|
||||
eventType: settled ? 'debt_settled' : 'debt_offset',
|
||||
payloadJson: JSON.stringify({ workOrderId: input.workOrderId, amount: offsetAmount }),
|
||||
now: input.now,
|
||||
})
|
||||
offsets.push({ caseId: Number(debt.case_id), amount: offsetAmount })
|
||||
remaining -= offsetAmount
|
||||
}
|
||||
return offsets
|
||||
}
|
||||
|
||||
async function recoverAfterSalesAmountWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
caseId: number
|
||||
workOrderId: number
|
||||
workerId: number
|
||||
amount: number
|
||||
note: string
|
||||
now: string
|
||||
},
|
||||
): Promise<{
|
||||
pendingDepositDeductionAmount: number
|
||||
availableDeductionAmount: number
|
||||
debtAmount: number
|
||||
}> {
|
||||
let remaining = Math.max(0, input.amount)
|
||||
let pendingDepositDeductionAmount = 0
|
||||
const pendingResult = await client.query<WorkerDepositUnfreezeRow>(
|
||||
`
|
||||
SELECT * FROM worker_deposit_unfreezes
|
||||
WHERE work_order_id = $1 AND worker_id = $2 AND status = 'pending'
|
||||
ORDER BY unfreeze_at ASC, id ASC
|
||||
FOR UPDATE
|
||||
`,
|
||||
[input.workOrderId, input.workerId],
|
||||
)
|
||||
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
||||
let wallet: WorkerWalletRow | null = await getWorkerWalletWithClient(client, input.workerId)
|
||||
for (const row of pendingResult.rows) {
|
||||
if (remaining <= 0) break
|
||||
const rowAmount = Number(row.amount || 0)
|
||||
const deduction = Math.min(rowAmount, remaining)
|
||||
if (deduction <= 0) continue
|
||||
if (deduction >= rowAmount) {
|
||||
await client.query(
|
||||
`UPDATE worker_deposit_unfreezes SET status = 'deducted', updated_at = $1 WHERE id = $2`,
|
||||
[input.now, row.id],
|
||||
)
|
||||
} else {
|
||||
await client.query(
|
||||
`UPDATE worker_deposit_unfreezes SET amount = amount - $1, updated_at = $2 WHERE id = $3`,
|
||||
[deduction, input.now, row.id],
|
||||
)
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE worker_wallets
|
||||
SET pending_unfreeze_amount = GREATEST(0, pending_unfreeze_amount - $1), updated_at = $2
|
||||
WHERE worker_id = $3`,
|
||||
[deduction, input.now, input.workerId],
|
||||
)
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, 'after_sales_deposit_deduction', $2, $3, $4, $5, '售后问题单扣减待解冻押金', $6::jsonb, $7)
|
||||
`,
|
||||
[
|
||||
input.workerId,
|
||||
-deduction,
|
||||
Number(wallet?.available_amount || 0),
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
input.workOrderId,
|
||||
JSON.stringify({ caseId: input.caseId, note: input.note, unfreezeId: row.id }),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
pendingDepositDeductionAmount += deduction
|
||||
remaining -= deduction
|
||||
}
|
||||
|
||||
wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||
const availableDeductionAmount = Math.min(remaining, Number(wallet?.available_amount || 0))
|
||||
if (availableDeductionAmount > 0) {
|
||||
const nextAvailable = Number(wallet?.available_amount || 0) - availableDeductionAmount
|
||||
await client.query(
|
||||
`UPDATE worker_wallets SET available_amount = $1, updated_at = $2 WHERE worker_id = $3`,
|
||||
[nextAvailable, input.now, input.workerId],
|
||||
)
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, 'after_sales_balance_deduction', $2, $3, $4, $5, '售后问题单扣减可用余额', $6::jsonb, $7)
|
||||
`,
|
||||
[
|
||||
input.workerId,
|
||||
-availableDeductionAmount,
|
||||
nextAvailable,
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
input.workOrderId,
|
||||
JSON.stringify({ caseId: input.caseId, note: input.note }),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
remaining -= availableDeductionAmount
|
||||
}
|
||||
if (remaining > 0) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO worker_after_sales_debts (
|
||||
case_id, worker_id, original_amount, outstanding_amount, status, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $3, 'open', $4, $4)
|
||||
`,
|
||||
[input.caseId, input.workerId, remaining, input.now],
|
||||
)
|
||||
}
|
||||
return { pendingDepositDeductionAmount, availableDeductionAmount, debtAmount: remaining }
|
||||
}
|
||||
|
||||
async function createAfterSalesCaseEventWithClient(
|
||||
client: PoolClient,
|
||||
input: {
|
||||
caseId: number
|
||||
actorType: string
|
||||
actorId: string
|
||||
eventType: string
|
||||
payloadJson: string
|
||||
now: string
|
||||
},
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO worker_after_sales_case_events (
|
||||
case_id, actor_type, actor_id, event_type, payload_json, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5::jsonb, $6)
|
||||
`,
|
||||
[input.caseId, input.actorType, input.actorId, input.eventType, input.payloadJson, input.now],
|
||||
)
|
||||
}
|
||||
@@ -2,5 +2,6 @@ export * from './types.js'
|
||||
export * from './shared.js'
|
||||
export * from './worker-repo.js'
|
||||
export * from './work-order-repo.js'
|
||||
export * from './after-sales-repo.js'
|
||||
export * from './product-match-repo.js'
|
||||
export * from './sms-code-repo.js'
|
||||
|
||||
@@ -137,6 +137,62 @@ export type WorkerWalletLedgerRow = {
|
||||
related_platform_order_id?: string | null
|
||||
}
|
||||
|
||||
export type WorkerAfterSalesCaseRow = {
|
||||
id: number
|
||||
case_no: string
|
||||
work_order_id: number
|
||||
work_order_share_id: number | null
|
||||
worker_id: number
|
||||
problem_type: string
|
||||
complaint_note: string
|
||||
complaint_files_json: string | Record<string, unknown> | unknown[]
|
||||
acceptance_snapshot_json: string | Record<string, unknown>
|
||||
status: string
|
||||
worker_response: string
|
||||
worker_response_files_json: string | Record<string, unknown> | unknown[]
|
||||
responded_at: string | null
|
||||
resolution_action: string
|
||||
resolution_note: string
|
||||
recovery_amount: number
|
||||
pending_deposit_deduction_amount: number
|
||||
available_deduction_amount: number
|
||||
debt_amount: number
|
||||
created_by: string
|
||||
resolved_by: string
|
||||
resolved_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
work_order_no?: string
|
||||
platform_order_id?: string
|
||||
product_name?: string
|
||||
shop_name?: string
|
||||
worker_username?: string
|
||||
worker_display_name?: string
|
||||
share_quantity?: number | null
|
||||
}
|
||||
|
||||
export type WorkerAfterSalesCaseEventRow = {
|
||||
id: number
|
||||
case_id: number
|
||||
actor_type: string
|
||||
actor_id: string
|
||||
event_type: string
|
||||
payload_json: string | Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type WorkerAfterSalesDebtRow = {
|
||||
id: number
|
||||
case_id: number
|
||||
worker_id: number
|
||||
original_amount: number
|
||||
outstanding_amount: number
|
||||
status: string
|
||||
created_at: string
|
||||
settled_at: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type WorkerFinanceRequestRow = {
|
||||
id: number
|
||||
worker_id: number
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { query, withTransaction } from '../../db/client.js'
|
||||
import { offsetWorkerAfterSalesDebtsWithClient } from './after-sales-repo.js'
|
||||
import {
|
||||
ensureWorkerWalletWithClient,
|
||||
getWorkerWalletWithClient,
|
||||
@@ -2956,11 +2957,19 @@ export async function acceptWorkOrderAndSettle(input: {
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
const shareReward = Number(share.share_reward || 0)
|
||||
const debtOffsets = await offsetWorkerAfterSalesDebtsWithClient(client, {
|
||||
workerId: shareWorkerId,
|
||||
amount: shareReward,
|
||||
workOrderId: input.workOrderId,
|
||||
now: input.now,
|
||||
})
|
||||
const debtOffsetAmount = debtOffsets.reduce((total, item) => total + item.amount, 0)
|
||||
const shouldDelayUnfreeze = input.unfreezeDays > 0
|
||||
const nextAvailable =
|
||||
Number(wallet?.available_amount || 0) +
|
||||
shareReward +
|
||||
(shouldDelayUnfreeze ? 0 : releaseAmount)
|
||||
(shouldDelayUnfreeze ? 0 : releaseAmount) -
|
||||
debtOffsetAmount
|
||||
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount)
|
||||
|
||||
await client.query(
|
||||
@@ -3059,6 +3068,29 @@ export async function acceptWorkOrderAndSettle(input: {
|
||||
],
|
||||
)
|
||||
}
|
||||
for (const debtOffset of debtOffsets) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, 'after_sales_debt_offset', $2, $3, $4, $5, '验收报酬自动抵扣售后欠款', $6::jsonb, $7)
|
||||
`,
|
||||
[
|
||||
shareWorkerId,
|
||||
-debtOffset.amount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
JSON.stringify({
|
||||
caseId: debtOffset.caseId,
|
||||
workOrderId: input.workOrderId,
|
||||
shareId: Number(share.id),
|
||||
}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
@@ -3081,11 +3113,19 @@ export async function acceptWorkOrderAndSettle(input: {
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
const rewardAmount = Number(workOrder.reward_amount || 0)
|
||||
const debtOffsets = await offsetWorkerAfterSalesDebtsWithClient(client, {
|
||||
workerId,
|
||||
amount: rewardAmount,
|
||||
workOrderId: input.workOrderId,
|
||||
now: input.now,
|
||||
})
|
||||
const debtOffsetAmount = debtOffsets.reduce((total, item) => total + item.amount, 0)
|
||||
const shouldDelayUnfreeze = input.unfreezeDays > 0
|
||||
const nextAvailable =
|
||||
Number(wallet?.available_amount || 0) +
|
||||
rewardAmount +
|
||||
(shouldDelayUnfreeze ? 0 : releaseAmount)
|
||||
(shouldDelayUnfreeze ? 0 : releaseAmount) -
|
||||
debtOffsetAmount
|
||||
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount)
|
||||
|
||||
await client.query(
|
||||
@@ -3162,6 +3202,25 @@ export async function acceptWorkOrderAndSettle(input: {
|
||||
[workerId, rewardAmount, nextAvailable, nextFrozen, input.workOrderId, input.now],
|
||||
)
|
||||
}
|
||||
for (const debtOffset of debtOffsets) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, 'after_sales_debt_offset', $2, $3, $4, $5, '验收报酬自动抵扣售后欠款', $6::jsonb, $7)
|
||||
`,
|
||||
[
|
||||
workerId,
|
||||
-debtOffset.amount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
JSON.stringify({ caseId: debtOffset.caseId, workOrderId: input.workOrderId }),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await createWorkOrderEventWithClient(client, {
|
||||
@@ -3230,11 +3289,19 @@ export async function acceptWorkOrderShareAndSettle(input: {
|
||||
Number(wallet?.frozen_deposit_amount || 0),
|
||||
)
|
||||
const shareReward = Number(share.share_reward || 0)
|
||||
const debtOffsets = await offsetWorkerAfterSalesDebtsWithClient(client, {
|
||||
workerId,
|
||||
amount: shareReward,
|
||||
workOrderId: input.workOrderId,
|
||||
now: input.now,
|
||||
})
|
||||
const debtOffsetAmount = debtOffsets.reduce((total, item) => total + item.amount, 0)
|
||||
const shouldDelayUnfreeze = input.unfreezeDays > 0
|
||||
const nextAvailable =
|
||||
Number(wallet?.available_amount || 0) +
|
||||
shareReward +
|
||||
(shouldDelayUnfreeze ? 0 : releaseAmount)
|
||||
(shouldDelayUnfreeze ? 0 : releaseAmount) -
|
||||
debtOffsetAmount
|
||||
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount)
|
||||
await client.query(
|
||||
`UPDATE worker_wallets
|
||||
@@ -3298,6 +3365,27 @@ export async function acceptWorkOrderShareAndSettle(input: {
|
||||
],
|
||||
)
|
||||
}
|
||||
for (const debtOffset of debtOffsets) {
|
||||
await client.query(
|
||||
`INSERT INTO worker_wallet_ledgers (
|
||||
worker_id, ledger_type, amount, balance_after, frozen_after,
|
||||
related_work_order_id, note, payload_json, created_at
|
||||
) VALUES ($1, 'after_sales_debt_offset', $2, $3, $4, $5, '验收报酬自动抵扣售后欠款', $6::jsonb, $7)`,
|
||||
[
|
||||
workerId,
|
||||
-debtOffset.amount,
|
||||
nextAvailable,
|
||||
nextFrozen,
|
||||
input.workOrderId,
|
||||
JSON.stringify({
|
||||
caseId: debtOffset.caseId,
|
||||
workOrderId: input.workOrderId,
|
||||
shareId: input.shareId,
|
||||
}),
|
||||
input.now,
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await client.query(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
unassignAdminWorkOrder,
|
||||
creditAdminWorkerWallet,
|
||||
createAdminMockWorkOrder,
|
||||
createAdminAfterSalesCase,
|
||||
deductAdminWorkOrderPendingDeposit,
|
||||
deleteAdminWorkCategory,
|
||||
deleteAdminWorkProductRule,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
getAdminWorkerPlatformNotificationConfig,
|
||||
getAdminWorkerPlatformSummary,
|
||||
getAdminWorkOrderEvents,
|
||||
getAdminAfterSalesCaseEvents,
|
||||
getAdminWorkOrderSharing,
|
||||
listAdminWorkerFinanceRequests,
|
||||
listAdminWorkerWithdrawalAccounts,
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
listAdminWorkerLevels,
|
||||
listAdminWorkerUsers,
|
||||
listAdminWorkOrders,
|
||||
listAdminAfterSalesCases,
|
||||
listAdminWorkerCancelRequests,
|
||||
listAdminWorkerOrderFeedbacks,
|
||||
markAdminWorkOrderProblem,
|
||||
@@ -44,6 +47,7 @@ import {
|
||||
reviewAdminWorkerCancelRequest,
|
||||
resolveAdminWorkerOrderFeedback,
|
||||
resolveAdminProblemWorkOrder,
|
||||
resolveAdminAfterSalesCase,
|
||||
reviewAdminWorkerUser,
|
||||
resetAdminWorkerPassword,
|
||||
saveAdminWorkerFinanceConfig,
|
||||
@@ -908,6 +912,74 @@ router.get(
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/worker-platform/after-sales-cases',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler((req) => listAdminAfterSalesCases(req.query), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取售后问题单失败',
|
||||
scope: '[admin/worker-platform/after-sales-cases]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/worker-platform/after-sales-cases/:caseId/events',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler((req) => getAdminAfterSalesCaseEvents(String(req.params.caseId || '')), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取售后问题单记录失败',
|
||||
scope: '[admin/worker-platform/after-sales-cases/:caseId/events]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/after-sales-cases',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
createAdminAfterSalesCase(
|
||||
String(req.params.workOrderId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '售后问题单已创建',
|
||||
errorMessage: '创建售后问题单失败',
|
||||
scope: '[admin/worker-platform/orders/:workOrderId/after-sales-cases]',
|
||||
audit: (req, data) => ({
|
||||
action: 'after_sales_case_created',
|
||||
targetType: 'work_order',
|
||||
targetId: String(req.params.workOrderId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/after-sales-cases/:caseId/resolve',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
resolveAdminAfterSalesCase(
|
||||
String(req.params.caseId || ''),
|
||||
req.body || {},
|
||||
req.adminSession?.username || '',
|
||||
),
|
||||
{
|
||||
successMessage: '售后问题单已处置',
|
||||
errorMessage: '处置售后问题单失败',
|
||||
scope: '[admin/worker-platform/after-sales-cases/:caseId/resolve]',
|
||||
audit: (req, data) => ({
|
||||
action: 'after_sales_case_resolved',
|
||||
targetType: 'after_sales_case',
|
||||
targetId: String(req.params.caseId || ''),
|
||||
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/worker-platform/orders/:workOrderId/sharing',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
|
||||
@@ -39,9 +39,11 @@ import {
|
||||
supplementWorkerAcceptedOrderEvidence,
|
||||
submitWorkerOrderAcceptance,
|
||||
getWorkerCancelRequestSummary,
|
||||
getWorkerAfterSalesCaseSummary,
|
||||
getWorkerAnnouncement,
|
||||
getWorkerOrderFeedbackSummary,
|
||||
submitWorkerCancelRequest,
|
||||
submitWorkerAfterSalesCaseResponse,
|
||||
submitWorkerOrderFeedback,
|
||||
} from '../services/worker-platform/index.js'
|
||||
import { buildNotFoundPayload, createRouteHandler } from '../utils/http.js'
|
||||
@@ -489,6 +491,34 @@ router.get(
|
||||
}),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/orders/after-sales-cases',
|
||||
requireActiveWorker,
|
||||
createRouteHandler((req) => getWorkerAfterSalesCaseSummary(getRequiredWorkerSession(req)), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取售后问题单失败',
|
||||
scope: '[worker/orders/after-sales-cases]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/orders/after-sales-cases/:caseId/response',
|
||||
requireActiveWorker,
|
||||
createRouteHandler(
|
||||
(req) =>
|
||||
submitWorkerAfterSalesCaseResponse(
|
||||
String(req.params.caseId || ''),
|
||||
req.body || {},
|
||||
getRequiredWorkerSession(req),
|
||||
),
|
||||
{
|
||||
successMessage: '售后说明已提交,等待客服处理',
|
||||
errorMessage: '提交售后说明失败',
|
||||
scope: '[worker/orders/after-sales-cases/:caseId/response]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/orders/:workOrderId/feedbacks',
|
||||
requireActiveWorker,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
countTimeoutEventsByWorkerIds,
|
||||
createWorkProductMatchLog,
|
||||
createWorkOrder,
|
||||
createAfterSalesCase,
|
||||
createWorkOrderEvent,
|
||||
deductPendingDepositUnfreeze,
|
||||
deleteWorkCategory,
|
||||
@@ -31,10 +32,13 @@ import {
|
||||
getWorkerUserById,
|
||||
getWorkOrderStatistics,
|
||||
getWorkOrderByOrderItemId,
|
||||
getAfterSalesCaseById,
|
||||
getWorkProductRuleById,
|
||||
listAllWorkCategories,
|
||||
listWorkerFinanceRequests,
|
||||
listWorkOrders,
|
||||
listAfterSalesCaseEvents,
|
||||
listAfterSalesCases,
|
||||
listWorkOrderEventsByOrderId,
|
||||
listOpenWorkOrdersByProductRuleId,
|
||||
listWorkProductRules,
|
||||
@@ -50,6 +54,7 @@ import {
|
||||
sumPendingUnfreezeByOrderIds,
|
||||
syncOpenWorkOrderPriceFromRule,
|
||||
resolveProblemWorkOrder,
|
||||
resolveAfterSalesCase,
|
||||
reviewWorkerFinanceRequest,
|
||||
reopenCancelledWorkOrder,
|
||||
listAdminWorkerCancelRequests as listWorkerCancelRequestsForAdmin,
|
||||
@@ -122,6 +127,7 @@ import {
|
||||
mapWorkerLevel,
|
||||
mapWorkerUser,
|
||||
mapWorkerCancelRequest,
|
||||
mapWorkerAfterSalesCase,
|
||||
mapWorkerOrderFeedback,
|
||||
normalizeAdminFinanceReviewStatus,
|
||||
normalizeAmountFen,
|
||||
@@ -1699,6 +1705,203 @@ export async function deductAdminWorkOrderPendingDeposit(
|
||||
return { deductedAmount: result.deductedAmount }
|
||||
}
|
||||
|
||||
export async function listAdminAfterSalesCases(query: JsonObject = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const workOrderId = normalizeOptionalId(query.workOrderId ?? query.work_order_id)
|
||||
const result = await listAfterSalesCases({
|
||||
page,
|
||||
pageSize,
|
||||
status: String(query.status || '').trim(),
|
||||
keyword: String(query.keyword || '').trim(),
|
||||
...(workOrderId ? { workOrderId } : {}),
|
||||
})
|
||||
return {
|
||||
items: result.items.map(mapWorkerAfterSalesCase),
|
||||
pagination: { page, pageSize, total: result.total },
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminAfterSalesCaseEvents(caseId: number | string) {
|
||||
const afterSalesCase = await getAfterSalesCaseById(caseId)
|
||||
if (!afterSalesCase) {
|
||||
throw createHttpError('售后问题单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'after_sales_case_not_found',
|
||||
})
|
||||
}
|
||||
return {
|
||||
items: (await listAfterSalesCaseEvents(afterSalesCase.id)).map((event) => ({
|
||||
eventId: Number(event.id),
|
||||
actorType: event.actor_type,
|
||||
actorId: event.actor_id,
|
||||
eventType: event.event_type,
|
||||
payload: safeParseJson(event.payload_json),
|
||||
createdAt: event.created_at,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAdminAfterSalesCase(
|
||||
workOrderId: number | string,
|
||||
payload: JsonObject = {},
|
||||
actorName = '',
|
||||
) {
|
||||
const complaintNote = String(payload.complaintNote || payload.note || '').trim()
|
||||
if (!complaintNote) {
|
||||
throw createHttpError('请填写用户投诉说明', {
|
||||
statusCode: 400,
|
||||
errorCode: 'after_sales_case_complaint_required',
|
||||
})
|
||||
}
|
||||
if (complaintNote.length > 2_000) {
|
||||
throw createHttpError('用户投诉说明不能超过 2000 个字符', {
|
||||
statusCode: 400,
|
||||
errorCode: 'after_sales_case_complaint_too_long',
|
||||
})
|
||||
}
|
||||
const problemType = String(payload.problemType || 'fake_evidence').trim() || 'fake_evidence'
|
||||
if (problemType.length > 64) {
|
||||
throw createHttpError('问题类型不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'after_sales_case_problem_type_invalid',
|
||||
})
|
||||
}
|
||||
const shareId = normalizeOptionalId(payload.shareId ?? payload.workOrderShareId)
|
||||
const now = nowIso()
|
||||
const result = await createAfterSalesCase({
|
||||
caseNo: randomId('AS'),
|
||||
workOrderId: Number(workOrderId),
|
||||
workOrderShareId: shareId || null,
|
||||
problemType,
|
||||
complaintNote,
|
||||
complaintFilesJson: JSON.stringify(normalizeProofFiles(payload.files || payload.proofFiles)),
|
||||
createdBy: actorName,
|
||||
now,
|
||||
})
|
||||
if (result.failureReason === 'work_order_not_accepted') {
|
||||
throw createHttpError('只有已验收的工单可以发起售后问题单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'after_sales_case_work_order_status_invalid',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'work_order_share_not_accepted') {
|
||||
throw createHttpError('该拼单份额尚未验收,不能发起售后问题单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'after_sales_case_share_status_invalid',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'worker_missing') {
|
||||
throw createHttpError('请指定已验收的责任打手或拼单份额', {
|
||||
statusCode: 409,
|
||||
errorCode: 'after_sales_case_worker_required',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'active_case_exists' || !result.case) {
|
||||
throw createHttpError('该责任打手已有处理中售后问题单', {
|
||||
statusCode: 409,
|
||||
errorCode: 'after_sales_case_active_exists',
|
||||
})
|
||||
}
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: Number(result.case.work_order_id),
|
||||
actorType: 'admin',
|
||||
actorId: actorName,
|
||||
eventType: 'after_sales_case_created',
|
||||
fromStatus: WORK_ORDER_STATUS.ACCEPTED,
|
||||
toStatus: WORK_ORDER_STATUS.ACCEPTED,
|
||||
payloadJson: JSON.stringify({
|
||||
caseId: Number(result.case.id),
|
||||
caseNo: result.case.case_no,
|
||||
workerId: Number(result.case.worker_id),
|
||||
shareId: result.case.work_order_share_id ? Number(result.case.work_order_share_id) : null,
|
||||
problemType,
|
||||
}),
|
||||
now,
|
||||
})
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(result.case.work_order_id),
|
||||
workerIds: [Number(result.case.worker_id)],
|
||||
})
|
||||
return { case: mapWorkerAfterSalesCase(result.case) }
|
||||
}
|
||||
|
||||
export async function resolveAdminAfterSalesCase(
|
||||
caseId: number | string,
|
||||
payload: JsonObject = {},
|
||||
actorName = '',
|
||||
) {
|
||||
const action = String(payload.action || '').trim()
|
||||
if (!['not_upheld', 'remedied', 'upheld'].includes(action)) {
|
||||
throw createHttpError('售后处置方式不正确', {
|
||||
statusCode: 400,
|
||||
errorCode: 'after_sales_case_resolution_action_invalid',
|
||||
})
|
||||
}
|
||||
const resolutionNote = String(payload.resolutionNote || payload.note || '').trim()
|
||||
if (!resolutionNote) {
|
||||
throw createHttpError('请填写售后处置备注', {
|
||||
statusCode: 400,
|
||||
errorCode: 'after_sales_case_resolution_note_required',
|
||||
})
|
||||
}
|
||||
if (resolutionNote.length > 2_000) {
|
||||
throw createHttpError('售后处置备注不能超过 2000 个字符', {
|
||||
statusCode: 400,
|
||||
errorCode: 'after_sales_case_resolution_note_too_long',
|
||||
})
|
||||
}
|
||||
const recoveryAmount = normalizeAmountFen(payload.recoveryAmount ?? payload.amount, 0)
|
||||
if (action === 'upheld' && recoveryAmount <= 0) {
|
||||
throw createHttpError('问题成立时请填写大于 0 的追缴金额', {
|
||||
statusCode: 400,
|
||||
errorCode: 'after_sales_case_recovery_amount_required',
|
||||
})
|
||||
}
|
||||
const result = await resolveAfterSalesCase({
|
||||
caseId: Number(caseId),
|
||||
action: action as 'not_upheld' | 'remedied' | 'upheld',
|
||||
resolutionNote,
|
||||
recoveryAmount: action === 'upheld' ? recoveryAmount : 0,
|
||||
resolvedBy: actorName,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (result.failureReason === 'not_found') {
|
||||
throw createHttpError('售后问题单不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'after_sales_case_not_found',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'not_resolvable' || !result.case) {
|
||||
throw createHttpError('该售后问题单已处置,请刷新后重试', {
|
||||
statusCode: 409,
|
||||
errorCode: 'after_sales_case_not_resolvable',
|
||||
})
|
||||
}
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: Number(result.case.work_order_id),
|
||||
actorType: 'admin',
|
||||
actorId: actorName,
|
||||
eventType: 'after_sales_case_resolved',
|
||||
fromStatus: WORK_ORDER_STATUS.ACCEPTED,
|
||||
toStatus: WORK_ORDER_STATUS.ACCEPTED,
|
||||
payloadJson: JSON.stringify({
|
||||
caseId: Number(result.case.id),
|
||||
action,
|
||||
recoveryAmount: Number(result.case.recovery_amount || 0),
|
||||
debtAmount: Number(result.case.debt_amount || 0),
|
||||
}),
|
||||
now: nowIso(),
|
||||
})
|
||||
const workerId = Number(result.case.worker_id)
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(result.case.work_order_id),
|
||||
workerIds: workerId > 0 ? [workerId] : [],
|
||||
})
|
||||
if (workerId > 0) publishWorkerWalletRealtimeChange(workerId)
|
||||
return { case: mapWorkerAfterSalesCase(result.case) }
|
||||
}
|
||||
|
||||
export async function getAdminWorkOrderSharing(workOrderId: number | string) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const shares = await listWorkOrderShares(workOrder.id)
|
||||
|
||||
@@ -345,6 +345,81 @@ export function mapWorkerOrderFeedback(feedback: {
|
||||
}
|
||||
}
|
||||
|
||||
export function mapWorkerAfterSalesCase(afterSalesCase: {
|
||||
id: number
|
||||
case_no: string
|
||||
work_order_id: number
|
||||
work_order_share_id: number | null
|
||||
worker_id: number
|
||||
problem_type: string
|
||||
complaint_note: string
|
||||
complaint_files_json: unknown
|
||||
acceptance_snapshot_json: unknown
|
||||
status: string
|
||||
worker_response: string
|
||||
worker_response_files_json: unknown
|
||||
responded_at: string | null
|
||||
resolution_action: string
|
||||
resolution_note: string
|
||||
recovery_amount: number
|
||||
pending_deposit_deduction_amount: number
|
||||
available_deduction_amount: number
|
||||
debt_amount: number
|
||||
created_by: string
|
||||
resolved_by: string
|
||||
resolved_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
work_order_no?: string
|
||||
platform_order_id?: string
|
||||
product_name?: string
|
||||
shop_name?: string
|
||||
worker_username?: string
|
||||
worker_display_name?: string
|
||||
share_quantity?: number | null
|
||||
}) {
|
||||
const snapshot = safeParseJson(afterSalesCase.acceptance_snapshot_json)
|
||||
return {
|
||||
caseId: Number(afterSalesCase.id),
|
||||
caseNo: String(afterSalesCase.case_no || ''),
|
||||
workOrderId: Number(afterSalesCase.work_order_id),
|
||||
shareId: afterSalesCase.work_order_share_id ? Number(afterSalesCase.work_order_share_id) : null,
|
||||
workerId: Number(afterSalesCase.worker_id),
|
||||
workOrderNo: String(afterSalesCase.work_order_no || ''),
|
||||
platformOrderId: String(afterSalesCase.platform_order_id || ''),
|
||||
productName: String(afterSalesCase.product_name || ''),
|
||||
shopName: String(afterSalesCase.shop_name || ''),
|
||||
workerName: String(afterSalesCase.worker_display_name || afterSalesCase.worker_username || ''),
|
||||
shareQuantity: afterSalesCase.share_quantity ? Number(afterSalesCase.share_quantity) : null,
|
||||
problemType: String(afterSalesCase.problem_type || ''),
|
||||
complaintNote: String(afterSalesCase.complaint_note || ''),
|
||||
complaintFiles: normalizeUploadedFiles(safeParseJson(afterSalesCase.complaint_files_json)).map(
|
||||
refreshUploadedFileUrls,
|
||||
),
|
||||
acceptanceSnapshot: {
|
||||
...snapshot,
|
||||
acceptance: mapAcceptanceForResponse(snapshot.acceptance),
|
||||
},
|
||||
status: String(afterSalesCase.status || ''),
|
||||
workerResponse: String(afterSalesCase.worker_response || ''),
|
||||
workerResponseFiles: normalizeUploadedFiles(
|
||||
safeParseJson(afterSalesCase.worker_response_files_json),
|
||||
).map(refreshUploadedFileUrls),
|
||||
respondedAt: afterSalesCase.responded_at,
|
||||
resolutionAction: String(afterSalesCase.resolution_action || ''),
|
||||
resolutionNote: String(afterSalesCase.resolution_note || ''),
|
||||
recoveryAmount: Number(afterSalesCase.recovery_amount || 0),
|
||||
pendingDepositDeductionAmount: Number(afterSalesCase.pending_deposit_deduction_amount || 0),
|
||||
availableDeductionAmount: Number(afterSalesCase.available_deduction_amount || 0),
|
||||
debtAmount: Number(afterSalesCase.debt_amount || 0),
|
||||
createdBy: String(afterSalesCase.created_by || ''),
|
||||
resolvedBy: String(afterSalesCase.resolved_by || ''),
|
||||
resolvedAt: afterSalesCase.resolved_at,
|
||||
createdAt: afterSalesCase.created_at,
|
||||
updatedAt: afterSalesCase.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapWalletLedger(ledger: WorkerWalletLedgerRow | null | undefined) {
|
||||
if (!ledger) return null
|
||||
return {
|
||||
@@ -1487,6 +1562,9 @@ export function normalizeWalletLedgerType(value: unknown) {
|
||||
'deposit_unfreeze',
|
||||
'deposit_deduction',
|
||||
'reward_settlement',
|
||||
'after_sales_deposit_deduction',
|
||||
'after_sales_balance_deduction',
|
||||
'after_sales_debt_offset',
|
||||
].includes(ledgerType)
|
||||
) {
|
||||
return ledgerType
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
countWorkerAcceptedOrders,
|
||||
createWorkerCancelRequest,
|
||||
createWorkerOrderFeedback,
|
||||
listWorkerAfterSalesCases,
|
||||
countWorkerActiveOrders,
|
||||
countWorkerTimeoutEvents,
|
||||
countWorkerWithdrawRequestsOnDay,
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
releaseDepositUnfreeze,
|
||||
settleOverdueWorkOrder,
|
||||
submitWorkOrderShareAcceptance,
|
||||
submitWorkerAfterSalesResponse,
|
||||
updateWorkOrder,
|
||||
updateWorkOrderAcceptanceRecord,
|
||||
updateWorkOrderDraftAcceptance,
|
||||
@@ -122,6 +124,7 @@ import {
|
||||
mapWorkOrderEvents,
|
||||
mapWorkOrderShare,
|
||||
mapWorkerCancelRequest,
|
||||
mapWorkerAfterSalesCase,
|
||||
mapWorkerOrderFeedback,
|
||||
mapWorkerUser,
|
||||
normalizeAmountFen,
|
||||
@@ -1855,6 +1858,68 @@ export async function getWorkerOrderFeedbackSummary(session: WorkerSession) {
|
||||
return { items: (await listWorkerOrderFeedbacks(session.workerId)).map(mapWorkerOrderFeedback) }
|
||||
}
|
||||
|
||||
export async function getWorkerAfterSalesCaseSummary(session: WorkerSession) {
|
||||
requireActiveWorkerSession(session)
|
||||
return { items: (await listWorkerAfterSalesCases(session.workerId)).map(mapWorkerAfterSalesCase) }
|
||||
}
|
||||
|
||||
export async function submitWorkerAfterSalesCaseResponse(
|
||||
caseId: number | string,
|
||||
payload: JsonObject = {},
|
||||
session: WorkerSession,
|
||||
) {
|
||||
requireActiveWorkerSession(session)
|
||||
const response = String(payload.response || payload.note || '').trim()
|
||||
const files = normalizeUploadedFiles(payload.files || payload.proofFiles)
|
||||
if (!response && files.length === 0) {
|
||||
throw createHttpError('请填写说明或上传补救材料', {
|
||||
statusCode: 400,
|
||||
errorCode: 'after_sales_case_response_required',
|
||||
})
|
||||
}
|
||||
if (response.length > 2_000) {
|
||||
throw createHttpError('售后说明不能超过 2000 个字符', {
|
||||
statusCode: 400,
|
||||
errorCode: 'after_sales_case_response_too_long',
|
||||
})
|
||||
}
|
||||
const now = nowIso()
|
||||
const result = await submitWorkerAfterSalesResponse({
|
||||
caseId: Number(caseId),
|
||||
workerId: Number(session.workerId),
|
||||
response,
|
||||
filesJson: JSON.stringify(files),
|
||||
now,
|
||||
})
|
||||
if (result.failureReason === 'not_found') {
|
||||
throw createHttpError('售后问题单不存在或不属于当前账号', {
|
||||
statusCode: 404,
|
||||
errorCode: 'after_sales_case_not_found',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'not_pending' || !result.case) {
|
||||
throw createHttpError('该售后问题单已提交说明或已处置', {
|
||||
statusCode: 409,
|
||||
errorCode: 'after_sales_case_response_status_invalid',
|
||||
})
|
||||
}
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: Number(result.case.work_order_id),
|
||||
actorType: 'worker',
|
||||
actorId: String(session.workerId),
|
||||
eventType: 'after_sales_case_responded',
|
||||
fromStatus: WORK_ORDER_STATUS.ACCEPTED,
|
||||
toStatus: WORK_ORDER_STATUS.ACCEPTED,
|
||||
payloadJson: JSON.stringify({ caseId: Number(result.case.id), fileCount: files.length }),
|
||||
now,
|
||||
})
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(result.case.work_order_id),
|
||||
workerIds: [Number(session.workerId)],
|
||||
})
|
||||
return { case: mapWorkerAfterSalesCase(result.case) }
|
||||
}
|
||||
|
||||
export async function submitWorkerOrderFeedback(
|
||||
workOrderId: number | string,
|
||||
payload: JsonObject = {},
|
||||
|
||||
@@ -214,6 +214,29 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
|
||||
return { title: '问题单已处置:取消订单并释放押金', color: 'orange' }
|
||||
case 'problem_cancel_deduct':
|
||||
return { title: '问题单已处置:取消订单并扣除押金', color: 'orange' }
|
||||
case 'after_sales_case_created': {
|
||||
const caseNo = readPayload(event, 'caseNo')
|
||||
return {
|
||||
title: `已发起验收后售后问题单${caseNo ? `:${caseNo}` : ''}`,
|
||||
color: 'red',
|
||||
}
|
||||
}
|
||||
case 'after_sales_case_responded':
|
||||
return { title: '打手已提交售后说明,等待客服处理', color: 'orange' }
|
||||
case 'after_sales_case_resolved': {
|
||||
const action = readPayload(event, 'action')
|
||||
const recoveryAmount = Number(readPayload(event, 'recoveryAmount') || 0)
|
||||
if (action === 'upheld') {
|
||||
return {
|
||||
title: `售后问题成立,追缴 ${recoveryAmount > 0 ? `${(recoveryAmount / 100).toFixed(2)} 元` : '0 元'}`,
|
||||
color: 'red',
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: action === 'remedied' ? '售后已补救完成' : '售后问题不成立,已关闭',
|
||||
color: 'green',
|
||||
}
|
||||
}
|
||||
|
||||
// —— 取消节点 ——
|
||||
case 'reopened_from_cancelled':
|
||||
|
||||
@@ -354,6 +354,7 @@ function applyAdminRealtimeEvent(
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-order-sharing', event.entityId],
|
||||
})
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-after-sales-cases'] })
|
||||
if (isRecord(event.data)) {
|
||||
const data = event.data
|
||||
patchAdminWorkOrderCaches(queryClient, { ...event, data })
|
||||
|
||||
@@ -164,6 +164,7 @@ function applyWorkerRealtimeEvent(
|
||||
const data = event.data
|
||||
if (event.scopes.includes('worker_my_orders')) {
|
||||
patchWorkerMyOrderCaches(queryClient, { ...event, data })
|
||||
void queryClient.invalidateQueries({ queryKey: ['worker-after-sales-cases'] })
|
||||
}
|
||||
if (event.scopes.includes('worker_hall')) {
|
||||
patchWorkerHallCaches(queryClient, { ...event, data })
|
||||
|
||||
@@ -51,10 +51,12 @@ import {
|
||||
cancelAdminWorkOrder,
|
||||
cancelAdminWorkOrderShare,
|
||||
createAdminMockWorkOrder,
|
||||
createAdminAfterSalesCase,
|
||||
deductAdminWorkOrderPendingDeposit,
|
||||
deleteAdminWorkOrder,
|
||||
fetchAdminWorkCategories,
|
||||
fetchAdminWorkOrderSharing,
|
||||
fetchAdminAfterSalesCases,
|
||||
fetchAdminWorkerLevels,
|
||||
fetchAdminWorkerUsers,
|
||||
fetchAdminWorkOrders,
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
publishAdminWorkOrder,
|
||||
reopenAdminCancelledWorkOrder,
|
||||
resolveAdminProblemWorkOrder,
|
||||
resolveAdminAfterSalesCase,
|
||||
submitAdminWorkOrderMaterial,
|
||||
unpublishAdminWorkOrder,
|
||||
updateAdminWorkOrder,
|
||||
@@ -75,6 +78,7 @@ import type {
|
||||
WorkOrder,
|
||||
WorkOrderShare,
|
||||
WorkOrderStatistics,
|
||||
WorkerAfterSalesCase,
|
||||
} from '@/types/worker-platform'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
@@ -129,6 +133,18 @@ type CancelShareFormValues = {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
type AfterSalesCreateFormValues = {
|
||||
shareId?: number
|
||||
problemType?: string
|
||||
complaintNote?: string
|
||||
}
|
||||
|
||||
type AfterSalesResolutionFormValues = {
|
||||
action?: 'not_upheld' | 'remedied' | 'upheld'
|
||||
resolutionNote?: string
|
||||
recoveryAmount?: number
|
||||
}
|
||||
|
||||
export default function WorkOrdersPanel() {
|
||||
const { message, modal } = App.useApp()
|
||||
const navigate = useNavigate()
|
||||
@@ -160,12 +176,17 @@ export default function WorkOrdersPanel() {
|
||||
const [editOrder, setEditOrder] = useState<WorkOrder | null>(null)
|
||||
const [deductOrder, setDeductOrder] = useState<WorkOrder | null>(null)
|
||||
const [cancelOrder, setCancelOrder] = useState<WorkOrder | null>(null)
|
||||
const [afterSalesOrder, setAfterSalesOrder] = useState<WorkOrder | null>(null)
|
||||
const [afterSalesCase, setAfterSalesCase] = useState<WorkerAfterSalesCase | null>(null)
|
||||
const [afterSalesFiles, setAfterSalesFiles] = useState<UploadedFile[]>([])
|
||||
const [problemForm] = Form.useForm()
|
||||
const [resolutionForm] = Form.useForm()
|
||||
const [materialForm] = Form.useForm<MaterialFormValues>()
|
||||
const [cancelForm] = Form.useForm<CancelOrderFormValues>()
|
||||
const [cancelShareForm] = Form.useForm<CancelShareFormValues>()
|
||||
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
|
||||
const [afterSalesCreateForm] = Form.useForm<AfterSalesCreateFormValues>()
|
||||
const [afterSalesResolutionForm] = Form.useForm<AfterSalesResolutionFormValues>()
|
||||
const [editForm] = Form.useForm<{
|
||||
productName?: string
|
||||
platformOrderId?: string
|
||||
@@ -258,6 +279,15 @@ export default function WorkOrdersPanel() {
|
||||
queryFn: () => fetchAdminWorkOrderSharing(Number(sharingOrder?.workOrderId)),
|
||||
enabled: Boolean(sharingOrder),
|
||||
})
|
||||
const afterSalesSharingQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-after-sales-sharing', afterSalesOrder?.workOrderId],
|
||||
queryFn: () => fetchAdminWorkOrderSharing(Number(afterSalesOrder?.workOrderId)),
|
||||
enabled: Boolean(afterSalesOrder?.sharing?.enabled),
|
||||
})
|
||||
const afterSalesCasesQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-after-sales-cases'],
|
||||
queryFn: () => fetchAdminAfterSalesCases({ page: 1, pageSize: 100 }),
|
||||
})
|
||||
const assignWorkersQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-workers', 'assign', assignWorkerKeyword],
|
||||
queryFn: () =>
|
||||
@@ -414,6 +444,9 @@ export default function WorkOrdersPanel() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-summary'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-after-sales-cases'],
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -625,6 +658,60 @@ export default function WorkOrdersPanel() {
|
||||
})
|
||||
}
|
||||
|
||||
function openAfterSalesCreateModal(row: WorkOrder) {
|
||||
setAfterSalesOrder(row)
|
||||
setAfterSalesFiles([])
|
||||
afterSalesCreateForm.setFieldsValue({
|
||||
shareId: undefined,
|
||||
problemType: 'fake_evidence',
|
||||
complaintNote: '',
|
||||
})
|
||||
}
|
||||
|
||||
async function submitAfterSalesCreate(values: AfterSalesCreateFormValues) {
|
||||
if (!afterSalesOrder) return
|
||||
const succeeded = await runAction(
|
||||
() =>
|
||||
createAdminAfterSalesCase(afterSalesOrder.workOrderId, {
|
||||
shareId: values.shareId || null,
|
||||
problemType: values.problemType || 'fake_evidence',
|
||||
complaintNote: String(values.complaintNote || '').trim(),
|
||||
files: afterSalesFiles,
|
||||
}),
|
||||
'售后问题单已创建,已通知责任打手说明',
|
||||
)
|
||||
if (!succeeded) return
|
||||
setAfterSalesOrder(null)
|
||||
setAfterSalesFiles([])
|
||||
afterSalesCreateForm.resetFields()
|
||||
}
|
||||
|
||||
function openAfterSalesResolutionModal(item: WorkerAfterSalesCase) {
|
||||
setAfterSalesCase(item)
|
||||
afterSalesResolutionForm.setFieldsValue({
|
||||
action: 'upheld',
|
||||
resolutionNote: '',
|
||||
recoveryAmount: Number(item.recoveryAmount || 0) / 100,
|
||||
})
|
||||
}
|
||||
|
||||
async function submitAfterSalesResolution(values: AfterSalesResolutionFormValues) {
|
||||
const action = values.action
|
||||
if (!afterSalesCase || !action) return
|
||||
const succeeded = await runAction(
|
||||
() =>
|
||||
resolveAdminAfterSalesCase(afterSalesCase.caseId, {
|
||||
action,
|
||||
resolutionNote: String(values.resolutionNote || '').trim(),
|
||||
recoveryAmount: action === 'upheld' ? Number(values.recoveryAmount || 0) : 0,
|
||||
}),
|
||||
'售后问题单已处置',
|
||||
)
|
||||
if (!succeeded) return
|
||||
setAfterSalesCase(null)
|
||||
afterSalesResolutionForm.resetFields()
|
||||
}
|
||||
|
||||
async function submitDeduct(values: { amount?: number; note?: string }) {
|
||||
if (!deductOrder) return
|
||||
const succeeded = await runAction(
|
||||
@@ -983,6 +1070,15 @@ export default function WorkOrdersPanel() {
|
||||
扣押金
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'accepted' ? (
|
||||
<Button
|
||||
icon={<WarningOutlined />}
|
||||
danger
|
||||
onClick={() => openAfterSalesCreateModal(row)}
|
||||
>
|
||||
售后
|
||||
</Button>
|
||||
) : null}
|
||||
{row.capabilities?.canMarkProblem ? (
|
||||
<Button icon={<WarningOutlined />} danger onClick={() => setProblemOrder(row)}>
|
||||
问题单
|
||||
@@ -1154,6 +1250,138 @@ export default function WorkOrdersPanel() {
|
||||
scroll={{ x: 1240 }}
|
||||
/>
|
||||
</Card>
|
||||
<Card
|
||||
title="验收后售后问题单"
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={afterSalesCasesQuery.isFetching}
|
||||
onClick={() => afterSalesCasesQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
bordered={false}
|
||||
>
|
||||
<Table<WorkerAfterSalesCase>
|
||||
rowKey="caseId"
|
||||
loading={afterSalesCasesQuery.isLoading}
|
||||
dataSource={afterSalesCasesQuery.data?.data.items || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 1160 }}
|
||||
columns={[
|
||||
{
|
||||
title: '售后单',
|
||||
width: 190,
|
||||
render: (_, item) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong>{item.caseNo}</Typography.Text>
|
||||
<Typography.Text type="secondary">{item.workOrderNo || '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatAdminDateTime(item.createdAt)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '责任打手',
|
||||
width: 150,
|
||||
render: (_, item) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text>{item.workerName || `打手#${item.workerId}`}</Typography.Text>
|
||||
{item.shareQuantity ? (
|
||||
<Tag color="purple">拼单 {item.shareQuantity} 份</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '投诉与证据',
|
||||
width: 260,
|
||||
render: (_, item) => (
|
||||
<div className="cell-stack">
|
||||
<Tag color="red">{formatAfterSalesProblemType(item.problemType)}</Tag>
|
||||
<Typography.Text ellipsis={{ tooltip: item.complaintNote }}>
|
||||
{item.complaintNote || '-'}
|
||||
</Typography.Text>
|
||||
{item.complaintFiles.length > 0 ? (
|
||||
<ImagePreviewList files={item.complaintFiles} size={36} maxVisible={3} />
|
||||
) : null}
|
||||
{getAfterSalesSnapshotFiles(item).length > 0 ? (
|
||||
<Space size={6} align="center">
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
固化验收图
|
||||
</Typography.Text>
|
||||
<ImagePreviewList
|
||||
files={getAfterSalesSnapshotFiles(item)}
|
||||
size={36}
|
||||
maxVisible={3}
|
||||
/>
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '打手说明',
|
||||
width: 250,
|
||||
render: (_, item) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text ellipsis={{ tooltip: item.workerResponse }}>
|
||||
{item.workerResponse || '暂未提交'}
|
||||
</Typography.Text>
|
||||
{item.workerResponseFiles.length > 0 ? (
|
||||
<ImagePreviewList files={item.workerResponseFiles} size={36} maxVisible={3} />
|
||||
) : null}
|
||||
{item.respondedAt ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatAdminDateTime(item.respondedAt)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '处置与追缴',
|
||||
width: 240,
|
||||
render: (_, item) => (
|
||||
<div className="cell-stack">
|
||||
<Tag color={resolveAfterSalesStatusColor(item.status)}>
|
||||
{formatAfterSalesStatus(item.status)}
|
||||
</Tag>
|
||||
{item.resolutionAction ? (
|
||||
<Typography.Text type="secondary" ellipsis={{ tooltip: item.resolutionNote }}>
|
||||
{formatAfterSalesResolution(item.resolutionAction)} ·{' '}
|
||||
{item.resolutionNote || '-'}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{item.resolutionAction === 'upheld' ? (
|
||||
<Typography.Text type={item.debtAmount > 0 ? 'danger' : undefined}>
|
||||
追缴 {formatMoney(item.recoveryAmount)},待还 {formatMoney(item.debtAmount)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, item) =>
|
||||
['awaiting_worker_response', 'under_review'].includes(item.status) ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => openAfterSalesResolutionModal(item)}
|
||||
>
|
||||
处置
|
||||
</Button>
|
||||
) : (
|
||||
<Typography.Text type="secondary">已完成</Typography.Text>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Drawer
|
||||
className="worker-order-detail-drawer"
|
||||
title={
|
||||
@@ -1486,6 +1714,132 @@ export default function WorkOrdersPanel() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`发起验收后售后 · ${getDisplayOrderNo(afterSalesOrder)}`}
|
||||
open={Boolean(afterSalesOrder)}
|
||||
onCancel={() => {
|
||||
setAfterSalesOrder(null)
|
||||
setAfterSalesFiles([])
|
||||
afterSalesCreateForm.resetFields()
|
||||
}}
|
||||
onOk={() => afterSalesCreateForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
{afterSalesOrder ? (
|
||||
<Form form={afterSalesCreateForm} layout="vertical" onFinish={submitAfterSalesCreate}>
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="售后不会回退已验收工单;原验收资料会在创建时固化。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
{(afterSalesSharingQuery.data?.data.shares || []).some(
|
||||
(share) => share.status === 'accepted',
|
||||
) ? (
|
||||
<Form.Item
|
||||
label="责任拼单份额"
|
||||
name="shareId"
|
||||
rules={[{ required: true, message: '请选择责任打手的已验收份额' }]}
|
||||
>
|
||||
<Select
|
||||
loading={afterSalesSharingQuery.isLoading}
|
||||
placeholder="请选择责任拼单份额"
|
||||
options={(afterSalesSharingQuery.data?.data.shares || [])
|
||||
.filter((share) => share.status === 'accepted')
|
||||
.map((share) => ({
|
||||
value: share.shareId,
|
||||
label: `${share.worker?.displayName || share.worker?.username || `打手#${share.workerId}`} · ${share.quantity} 份`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item label="问题类型" name="problemType" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'fake_evidence', label: '疑似 P 图 / 伪造验收' },
|
||||
{ value: 'result_not_met', label: '实际结果未达标' },
|
||||
{ value: 'other', label: '其他售后问题' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="用户投诉说明"
|
||||
name="complaintNote"
|
||||
rules={[{ required: true, message: '请填写用户投诉说明' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="记录用户反馈、核查结论及需打手说明的事项" />
|
||||
</Form.Item>
|
||||
<Form.Item label="用户证据图片">
|
||||
<ImageUpload
|
||||
scene="collect-material"
|
||||
scope="admin"
|
||||
value={afterSalesFiles}
|
||||
onChange={setAfterSalesFiles}
|
||||
maxCount={10}
|
||||
showPasteHint
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`处置售后问题单 · ${afterSalesCase?.caseNo || ''}`}
|
||||
open={Boolean(afterSalesCase)}
|
||||
onCancel={() => {
|
||||
setAfterSalesCase(null)
|
||||
afterSalesResolutionForm.resetFields()
|
||||
}}
|
||||
onOk={() => afterSalesResolutionForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
{afterSalesCase ? (
|
||||
<Form
|
||||
form={afterSalesResolutionForm}
|
||||
layout="vertical"
|
||||
onFinish={submitAfterSalesResolution}
|
||||
>
|
||||
<Form.Item
|
||||
label="处置结论"
|
||||
name="action"
|
||||
rules={[{ required: true, message: '请选择处置结论' }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'upheld', label: '问题成立,执行追缴' },
|
||||
{ value: 'remedied', label: '已补救完成,不追缴' },
|
||||
{ value: 'not_upheld', label: '问题不成立,关闭售后' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(previous, current) => previous.action !== current.action}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('action') === 'upheld' ? (
|
||||
<Form.Item
|
||||
label="追缴金额"
|
||||
name="recoveryAmount"
|
||||
rules={[{ required: true, message: '请输入追缴金额' }]}
|
||||
extra="系统依次扣减该责任打手的本单待解冻押金、可用余额;不足部分转为欠款,并从后续验收报酬自动抵扣。"
|
||||
>
|
||||
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="处置备注"
|
||||
name="resolutionNote"
|
||||
rules={[{ required: true, message: '请填写处置备注' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="处置问题单"
|
||||
open={Boolean(resolutionOrder)}
|
||||
@@ -2294,6 +2648,58 @@ function formatRequirementFieldsText(fields: CollectField[]): string {
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function formatAfterSalesProblemType(value: string): string {
|
||||
if (value === 'fake_evidence') return '疑似 P 图 / 伪造验收'
|
||||
if (value === 'result_not_met') return '实际结果未达标'
|
||||
return '其他售后问题'
|
||||
}
|
||||
|
||||
function formatAfterSalesStatus(value: string): string {
|
||||
if (value === 'awaiting_worker_response') return '待打手说明'
|
||||
if (value === 'under_review') return '客服处理中'
|
||||
if (value === 'recovery_pending') return '追缴中'
|
||||
return '已关闭'
|
||||
}
|
||||
|
||||
function resolveAfterSalesStatusColor(value: string): string {
|
||||
if (value === 'awaiting_worker_response') return 'orange'
|
||||
if (value === 'under_review') return 'blue'
|
||||
if (value === 'recovery_pending') return 'red'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
function formatAfterSalesResolution(value: string): string {
|
||||
if (value === 'upheld') return '问题成立'
|
||||
if (value === 'remedied') return '补救完成'
|
||||
if (value === 'not_upheld') return '问题不成立'
|
||||
return value || '-'
|
||||
}
|
||||
|
||||
function getAfterSalesSnapshotFiles(item: WorkerAfterSalesCase): UploadedFile[] {
|
||||
const snapshot = asRecord(item.acceptanceSnapshot)
|
||||
const acceptance = asRecord(snapshot.acceptance)
|
||||
const files = Array.isArray(acceptance.files) ? acceptance.files : []
|
||||
const imageUrls = Array.isArray(acceptance.imageUrls) ? acceptance.imageUrls : []
|
||||
return [
|
||||
...files.map((value) => asRecord(value)),
|
||||
...imageUrls.map((url) => asRecord({ url: String(url || '') })),
|
||||
]
|
||||
.map((file) => {
|
||||
const url = String(file.url || '').trim()
|
||||
if (!url) return null
|
||||
return {
|
||||
objectKey: String(file.objectKey || file.object_key || ''),
|
||||
url,
|
||||
thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || url),
|
||||
mediumUrl: String(file.mediumUrl || file.medium_url || url),
|
||||
filename: String(file.filename || '验收图片'),
|
||||
contentType: String(file.contentType || ''),
|
||||
size: Number(file.size || 0),
|
||||
} satisfies UploadedFile
|
||||
})
|
||||
.filter((file): file is UploadedFile => Boolean(file))
|
||||
}
|
||||
|
||||
function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
||||
const { message } = App.useApp()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
App,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
@@ -36,6 +37,7 @@ import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import {
|
||||
fetchWorkerCancelRequests,
|
||||
fetchWorkerAfterSalesCases,
|
||||
fetchWorkerOrderFeedbacks,
|
||||
fetchWorkerMyOrderOverview,
|
||||
fetchWorkerMyOrders,
|
||||
@@ -47,6 +49,7 @@ import {
|
||||
supplementWorkerAcceptedOrderEvidence,
|
||||
submitWorkerAcceptance,
|
||||
submitWorkerCancelRequest,
|
||||
submitWorkerAfterSalesCaseResponse,
|
||||
submitWorkerOrderFeedback,
|
||||
} from '@/services/worker'
|
||||
import type {
|
||||
@@ -54,6 +57,7 @@ import type {
|
||||
UploadedFile,
|
||||
WorkOrder,
|
||||
WorkerAcceptance,
|
||||
WorkerAfterSalesCase,
|
||||
} from '@/types/worker-platform'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import { getImageIdentity } from '@/utils/image-identity'
|
||||
@@ -87,6 +91,10 @@ type FeedbackFormValues = {
|
||||
content?: string
|
||||
}
|
||||
|
||||
type AfterSalesResponseFormValues = {
|
||||
response?: string
|
||||
}
|
||||
|
||||
type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default'
|
||||
|
||||
const PRESET_NOTE_TAGS: Array<{ label: string; theme: NoteTheme }> = [
|
||||
@@ -201,11 +209,14 @@ export default function WorkerOrdersPage() {
|
||||
const [noteOrder, setNoteOrder] = useState<WorkOrder | null>(null)
|
||||
const [cancelRequestOrder, setCancelRequestOrder] = useState<WorkOrder | null>(null)
|
||||
const [feedbackOrder, setFeedbackOrder] = useState<WorkOrder | null>(null)
|
||||
const [afterSalesCase, setAfterSalesCase] = useState<WorkerAfterSalesCase | null>(null)
|
||||
const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([])
|
||||
const [afterSalesResponseFiles, setAfterSalesResponseFiles] = useState<UploadedFile[]>([])
|
||||
const [form] = Form.useForm<AcceptanceFormValues>()
|
||||
const [noteForm] = Form.useForm<WorkerNoteFormValues>()
|
||||
const [cancelRequestForm] = Form.useForm<CancelRequestFormValues>()
|
||||
const [feedbackForm] = Form.useForm<FeedbackFormValues>()
|
||||
const [afterSalesResponseForm] = Form.useForm<AfterSalesResponseFormValues>()
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['worker-my-orders', status, keyword, page, pageSize],
|
||||
@@ -240,9 +251,15 @@ export default function WorkerOrdersPage() {
|
||||
queryFn: fetchWorkerOrderFeedbacks,
|
||||
retry: false,
|
||||
})
|
||||
const afterSalesCasesQuery = useQuery({
|
||||
queryKey: ['worker-after-sales-cases'],
|
||||
queryFn: fetchWorkerAfterSalesCases,
|
||||
retry: false,
|
||||
})
|
||||
const overviewByStatus = new Map(
|
||||
(overviewQuery.data?.data.items || []).map((item) => [item.status, item]),
|
||||
)
|
||||
const afterSalesCases = afterSalesCasesQuery.data?.data.items || []
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([
|
||||
@@ -250,6 +267,7 @@ export default function WorkerOrdersPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['worker-cancel-requests'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['worker-order-feedbacks'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['worker-after-sales-cases'] }),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -377,6 +395,29 @@ export default function WorkerOrdersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openAfterSalesResponseModal(item: WorkerAfterSalesCase) {
|
||||
setAfterSalesCase(item)
|
||||
setAfterSalesResponseFiles([])
|
||||
afterSalesResponseForm.resetFields()
|
||||
}
|
||||
|
||||
async function submitAfterSalesResponse(values: AfterSalesResponseFormValues) {
|
||||
if (!afterSalesCase) return
|
||||
try {
|
||||
await submitWorkerAfterSalesCaseResponse(afterSalesCase.caseId, {
|
||||
response: String(values.response || '').trim(),
|
||||
files: afterSalesResponseFiles,
|
||||
})
|
||||
message.success('售后说明已提交,等待客服处理')
|
||||
setAfterSalesCase(null)
|
||||
setAfterSalesResponseFiles([])
|
||||
afterSalesResponseForm.resetFields()
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '提交售后说明失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOrderNote(values: WorkerNoteFormValues) {
|
||||
if (!noteOrder) return
|
||||
try {
|
||||
@@ -727,6 +768,65 @@ export default function WorkerOrdersPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{afterSalesCases.length > 0 ? (
|
||||
<Card
|
||||
className="worker-after-sales-card"
|
||||
title="售后问题单"
|
||||
size="small"
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={afterSalesCasesQuery.isFetching}
|
||||
onClick={() => afterSalesCasesQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{afterSalesCases.map((item) => (
|
||||
<div key={item.caseId} className="worker-order-detail-value">
|
||||
<Space wrap size={[8, 6]}>
|
||||
<Tag color={resolveWorkerAfterSalesStatusColor(item.status)}>
|
||||
{formatWorkerAfterSalesStatus(item.status)}
|
||||
</Tag>
|
||||
<Typography.Text strong>
|
||||
{item.productName || item.workOrderNo || item.caseNo}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">{item.caseNo}</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Paragraph style={{ margin: '8px 0 4px' }}>
|
||||
{item.complaintNote || '-'}
|
||||
</Typography.Paragraph>
|
||||
{item.complaintFiles.length > 0 ? (
|
||||
<ImagePreviewList files={item.complaintFiles} size={52} maxVisible={4} />
|
||||
) : null}
|
||||
{item.status === 'awaiting_worker_response' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => openAfterSalesResponseModal(item)}
|
||||
>
|
||||
提交说明
|
||||
</Button>
|
||||
) : item.status === 'under_review' ? (
|
||||
<Typography.Text type="secondary">已提交说明,等待客服处理</Typography.Text>
|
||||
) : item.status === 'recovery_pending' ? (
|
||||
<Typography.Text type="danger">
|
||||
待追缴 {formatMoney(item.debtAmount)},后续验收报酬将自动抵扣
|
||||
</Typography.Text>
|
||||
) : item.resolutionNote ? (
|
||||
<Typography.Text type="secondary">
|
||||
处理结果:{item.resolutionNote}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
{isMobile ? (
|
||||
<div className="worker-orders-mobile-page">
|
||||
{/* 顶部水平滑动状态切换胶囊条 */}
|
||||
@@ -1406,6 +1506,52 @@ export default function WorkerOrdersPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={`售后说明 · ${afterSalesCase?.caseNo || ''}`}
|
||||
open={Boolean(afterSalesCase)}
|
||||
width={isMobile ? '92%' : 560}
|
||||
onCancel={() => {
|
||||
setAfterSalesCase(null)
|
||||
setAfterSalesResponseFiles([])
|
||||
afterSalesResponseForm.resetFields()
|
||||
}}
|
||||
onOk={() => afterSalesResponseForm.submit()}
|
||||
okText="提交说明"
|
||||
destroyOnHidden
|
||||
>
|
||||
{afterSalesCase ? (
|
||||
<>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="请针对售后问题说明情况或提交补救材料,原验收资料不可在此修改。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{afterSalesCase.complaintNote}
|
||||
</Typography.Paragraph>
|
||||
<Form
|
||||
form={afterSalesResponseForm}
|
||||
layout="vertical"
|
||||
onFinish={submitAfterSalesResponse}
|
||||
>
|
||||
<Form.Item label="说明" name="response">
|
||||
<Input.TextArea rows={4} maxLength={2000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item label="补救材料图片">
|
||||
<ImageUpload
|
||||
scene="worker-acceptance"
|
||||
scope="worker"
|
||||
value={afterSalesResponseFiles}
|
||||
onChange={setAfterSalesResponseFiles}
|
||||
maxCount={10}
|
||||
showPasteHint
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
<Modal
|
||||
title={`订单备注 · ${getDisplayOrderNo(noteOrder)}`}
|
||||
open={Boolean(noteOrder)}
|
||||
@@ -1815,6 +1961,20 @@ function getSettlementLabel(status: string) {
|
||||
return '预计'
|
||||
}
|
||||
|
||||
function formatWorkerAfterSalesStatus(status: string) {
|
||||
if (status === 'awaiting_worker_response') return '待说明'
|
||||
if (status === 'under_review') return '处理中'
|
||||
if (status === 'recovery_pending') return '追缴中'
|
||||
return '已关闭'
|
||||
}
|
||||
|
||||
function resolveWorkerAfterSalesStatusColor(status: string) {
|
||||
if (status === 'awaiting_worker_response') return 'orange'
|
||||
if (status === 'under_review') return 'blue'
|
||||
if (status === 'recovery_pending') return 'red'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
function formatStatus(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
open: '已超时',
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
WorkerFinanceRequest,
|
||||
WorkerCancelRequest,
|
||||
WorkerOrderFeedback,
|
||||
WorkerAfterSalesCase,
|
||||
WorkerAfterSalesCaseEvent,
|
||||
WorkerPlatformNotificationConfig,
|
||||
WorkerProductMatchConfig,
|
||||
WorkerAnnouncementConfig,
|
||||
@@ -432,6 +434,48 @@ export function fetchAdminWorkOrders(params?: Record<string, unknown>) {
|
||||
>('/api/v1/admin/worker-platform/orders', params)
|
||||
}
|
||||
|
||||
export function fetchAdminAfterSalesCases(params?: Record<string, unknown>) {
|
||||
return apiGet<WorkerListResponse<WorkerAfterSalesCase>>(
|
||||
'/api/v1/admin/worker-platform/after-sales-cases',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminAfterSalesCaseEvents(caseId: number) {
|
||||
return apiGet<{ items: WorkerAfterSalesCaseEvent[] }>(
|
||||
`/api/v1/admin/worker-platform/after-sales-cases/${caseId}/events`,
|
||||
)
|
||||
}
|
||||
|
||||
export function createAdminAfterSalesCase(
|
||||
workOrderId: number,
|
||||
payload: {
|
||||
shareId?: number | null
|
||||
problemType?: string
|
||||
complaintNote: string
|
||||
files?: UploadedFile[]
|
||||
},
|
||||
) {
|
||||
return apiPost<{ case: WorkerAfterSalesCase }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/after-sales-cases`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveAdminAfterSalesCase(
|
||||
caseId: number,
|
||||
payload: {
|
||||
action: 'not_upheld' | 'remedied' | 'upheld'
|
||||
resolutionNote: string
|
||||
recoveryAmount?: number
|
||||
},
|
||||
) {
|
||||
return apiPost<{ case: WorkerAfterSalesCase }>(
|
||||
`/api/v1/admin/worker-platform/after-sales-cases/${caseId}/resolve`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminWorkOrderEvents(workOrderId: number) {
|
||||
return apiGet<{ items: WorkOrderEvent[] }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/events`,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
WorkerCancelRequestRisk,
|
||||
WorkerAnnouncementConfig,
|
||||
WorkerOrderFeedback,
|
||||
WorkerAfterSalesCase,
|
||||
WorkerHallOrdersResponse,
|
||||
WorkerLeaderboardResponse,
|
||||
WorkerLeaderboardPeriod,
|
||||
@@ -241,6 +242,20 @@ export function fetchWorkerOrderFeedbacks() {
|
||||
return apiGet<{ items: WorkerOrderFeedback[] }>('/api/v1/worker/orders/feedbacks')
|
||||
}
|
||||
|
||||
export function fetchWorkerAfterSalesCases() {
|
||||
return apiGet<{ items: WorkerAfterSalesCase[] }>('/api/v1/worker/orders/after-sales-cases')
|
||||
}
|
||||
|
||||
export function submitWorkerAfterSalesCaseResponse(
|
||||
caseId: number,
|
||||
payload: { response?: string; files?: UploadedFile[] },
|
||||
) {
|
||||
return apiPost<{ case: WorkerAfterSalesCase }>(
|
||||
`/api/v1/worker/orders/after-sales-cases/${caseId}/response`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function submitWorkerOrderFeedback(workOrderId: number, payload: { content: string }) {
|
||||
return apiPost<{ feedback: WorkerOrderFeedback }>(
|
||||
`/api/v1/worker/orders/${workOrderId}/feedbacks`,
|
||||
|
||||
@@ -151,6 +151,48 @@ export type WorkerOrderFeedback = {
|
||||
handledAt: string | null
|
||||
}
|
||||
|
||||
export type WorkerAfterSalesCase = {
|
||||
caseId: number
|
||||
caseNo: string
|
||||
workOrderId: number
|
||||
shareId: number | null
|
||||
workerId: number
|
||||
workOrderNo: string
|
||||
platformOrderId: string
|
||||
productName: string
|
||||
shopName: string
|
||||
workerName: string
|
||||
shareQuantity: number | null
|
||||
problemType: string
|
||||
complaintNote: string
|
||||
complaintFiles: UploadedFile[]
|
||||
acceptanceSnapshot: Record<string, unknown>
|
||||
status: 'awaiting_worker_response' | 'under_review' | 'recovery_pending' | 'closed' | string
|
||||
workerResponse: string
|
||||
workerResponseFiles: UploadedFile[]
|
||||
respondedAt: string | null
|
||||
resolutionAction: 'not_upheld' | 'remedied' | 'upheld' | string
|
||||
resolutionNote: string
|
||||
recoveryAmount: number
|
||||
pendingDepositDeductionAmount: number
|
||||
availableDeductionAmount: number
|
||||
debtAmount: number
|
||||
createdBy: string
|
||||
resolvedBy: string
|
||||
resolvedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type WorkerAfterSalesCaseEvent = {
|
||||
eventId: number
|
||||
actorType: string
|
||||
actorId: string
|
||||
eventType: string
|
||||
payload: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type WorkerFinanceConfig = {
|
||||
depositUnfreezeDays: number
|
||||
recharge: {
|
||||
|
||||
Reference in New Issue
Block a user