From f4d32a7c07f6d4dfdde2053395f82c29fa2c0294 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 21 Aug 2026 14:30:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=94=AE=E5=90=8E=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E7=9A=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../052_worker_after_sales_cases.sql | 71 +++ .../worker-platform/after-sales-repo.ts | 544 ++++++++++++++++++ .../src/repositories/worker-platform/index.ts | 1 + .../src/repositories/worker-platform/types.ts | 56 ++ .../worker-platform/work-order-repo.ts | 94 ++- .../src/routes/admin/worker-platform.ts | 72 +++ apps/backend/src/routes/worker.ts | 30 + .../services/worker-platform/admin-service.ts | 203 +++++++ .../src/services/worker-platform/mappers.ts | 78 +++ .../worker-platform/worker-service.ts | 65 +++ .../src/components/WorkOrderEventTimeline.tsx | 23 + apps/frontend/src/layouts/AdminLayout.tsx | 1 + apps/frontend/src/layouts/WorkerLayout.tsx | 1 + .../pages/admin/panels/WorkOrdersPanel.tsx | 406 +++++++++++++ .../src/pages/worker/WorkerOrdersPage.tsx | 160 ++++++ .../src/services/admin/worker-platform.ts | 44 ++ apps/frontend/src/services/worker.ts | 15 + apps/frontend/src/types/worker-platform.ts | 42 ++ 18 files changed, 1903 insertions(+), 3 deletions(-) create mode 100644 apps/backend/src/db/migrations/052_worker_after_sales_cases.sql create mode 100644 apps/backend/src/repositories/worker-platform/after-sales-repo.ts diff --git a/apps/backend/src/db/migrations/052_worker_after_sales_cases.sql b/apps/backend/src/db/migrations/052_worker_after_sales_cases.sql new file mode 100644 index 00000000..0cbc734f --- /dev/null +++ b/apps/backend/src/db/migrations/052_worker_after_sales_cases.sql @@ -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 '售后问题单不可篡改的操作记录'; diff --git a/apps/backend/src/repositories/worker-platform/after-sales-repo.ts b/apps/backend/src/repositories/worker-platform/after-sales-repo.ts new file mode 100644 index 00000000..bf5d2c6c --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/after-sales-repo.ts @@ -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( + `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 = { + 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( + `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( + ` + 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 { + const result = await query( + `${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( + `${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 { + const result = await query( + `${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 { + const result = await query( + `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( + `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( + ` + 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( + `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( + ` + 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> { + let remaining = Math.max(0, input.amount) + if (remaining <= 0) return [] + const debtsResult = await client.query( + ` + 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( + ` + 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], + ) +} diff --git a/apps/backend/src/repositories/worker-platform/index.ts b/apps/backend/src/repositories/worker-platform/index.ts index bbadf8dd..a4463121 100644 --- a/apps/backend/src/repositories/worker-platform/index.ts +++ b/apps/backend/src/repositories/worker-platform/index.ts @@ -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' diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index e89e5c06..9b38d1a6 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -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 | unknown[] + acceptance_snapshot_json: string | Record + status: string + worker_response: string + worker_response_files_json: string | Record | 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 + 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 diff --git a/apps/backend/src/repositories/worker-platform/work-order-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-repo.ts index 7e695d99..879fce3a 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -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( diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 376bbaf3..d575d0af 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -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) : {}, + }), + }, + ), +) + +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) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/sharing', requireAdminRoles(['admin', 'operator']), diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index 113a927a..01645f4a 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -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, diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 94bf4697..f92048dc 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -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) diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index d4b1de5e..1060a56a 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -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 diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 2c84c6ae..7cc91b79 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -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 = {}, diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index 70fafbaf..6418e64e 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -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': diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 734fa077..37ce7f05 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -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 }) diff --git a/apps/frontend/src/layouts/WorkerLayout.tsx b/apps/frontend/src/layouts/WorkerLayout.tsx index 12b6b242..4ca92a80 100644 --- a/apps/frontend/src/layouts/WorkerLayout.tsx +++ b/apps/frontend/src/layouts/WorkerLayout.tsx @@ -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 }) diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index db97e9e5..d759adfa 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -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(null) const [deductOrder, setDeductOrder] = useState(null) const [cancelOrder, setCancelOrder] = useState(null) + const [afterSalesOrder, setAfterSalesOrder] = useState(null) + const [afterSalesCase, setAfterSalesCase] = useState(null) + const [afterSalesFiles, setAfterSalesFiles] = useState([]) const [problemForm] = Form.useForm() const [resolutionForm] = Form.useForm() const [materialForm] = Form.useForm() const [cancelForm] = Form.useForm() const [cancelShareForm] = Form.useForm() const [deductForm] = Form.useForm<{ amount?: number; note?: string }>() + const [afterSalesCreateForm] = Form.useForm() + const [afterSalesResolutionForm] = Form.useForm() 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() { 扣押金 ) : null} + {row.status === 'accepted' ? ( + + ) : null} {row.capabilities?.canMarkProblem ? ( + } + bordered={false} + > + + rowKey="caseId" + loading={afterSalesCasesQuery.isLoading} + dataSource={afterSalesCasesQuery.data?.data.items || []} + pagination={false} + scroll={{ x: 1160 }} + columns={[ + { + title: '售后单', + width: 190, + render: (_, item) => ( +
+ {item.caseNo} + {item.workOrderNo || '-'} + + {formatAdminDateTime(item.createdAt)} + +
+ ), + }, + { + title: '责任打手', + width: 150, + render: (_, item) => ( +
+ {item.workerName || `打手#${item.workerId}`} + {item.shareQuantity ? ( + 拼单 {item.shareQuantity} 份 + ) : null} +
+ ), + }, + { + title: '投诉与证据', + width: 260, + render: (_, item) => ( +
+ {formatAfterSalesProblemType(item.problemType)} + + {item.complaintNote || '-'} + + {item.complaintFiles.length > 0 ? ( + + ) : null} + {getAfterSalesSnapshotFiles(item).length > 0 ? ( + + + 固化验收图 + + + + ) : null} +
+ ), + }, + { + title: '打手说明', + width: 250, + render: (_, item) => ( +
+ + {item.workerResponse || '暂未提交'} + + {item.workerResponseFiles.length > 0 ? ( + + ) : null} + {item.respondedAt ? ( + + {formatAdminDateTime(item.respondedAt)} + + ) : null} +
+ ), + }, + { + title: '处置与追缴', + width: 240, + render: (_, item) => ( +
+ + {formatAfterSalesStatus(item.status)} + + {item.resolutionAction ? ( + + {formatAfterSalesResolution(item.resolutionAction)} ·{' '} + {item.resolutionNote || '-'} + + ) : null} + {item.resolutionAction === 'upheld' ? ( + 0 ? 'danger' : undefined}> + 追缴 {formatMoney(item.recoveryAmount)},待还 {formatMoney(item.debtAmount)} + + ) : null} +
+ ), + }, + { + title: '操作', + width: 120, + render: (_, item) => + ['awaiting_worker_response', 'under_review'].includes(item.status) ? ( + + ) : ( + 已完成 + ), + }, + ]} + /> + + { + setAfterSalesOrder(null) + setAfterSalesFiles([]) + afterSalesCreateForm.resetFields() + }} + onOk={() => afterSalesCreateForm.submit()} + destroyOnHidden + > + {afterSalesOrder ? ( +
+ + {(afterSalesSharingQuery.data?.data.shares || []).some( + (share) => share.status === 'accepted', + ) ? ( + + + + + + + + + + + ) : null} +
+ + { + setAfterSalesCase(null) + afterSalesResolutionForm.resetFields() + }} + onOk={() => afterSalesResolutionForm.submit()} + destroyOnHidden + > + {afterSalesCase ? ( +
+ +