增加售后相关的
This commit is contained in:
@@ -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 = {},
|
||||
|
||||
Reference in New Issue
Block a user