增加售后相关的

This commit is contained in:
yml2213
2026-08-21 14:30:00 +08:00
parent 874b093a4b
commit f4d32a7c07
18 changed files with 1903 additions and 3 deletions
@@ -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']),
+30
View File
@@ -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 = {},