fix(worker-platform): 修复售后两次点击并支持拼单份数联动追缴

This commit is contained in:
2026-11-05 12:51:49 +08:00
parent 517d763f17
commit f94b914ab1
9 changed files with 393 additions and 19 deletions
@@ -33,7 +33,12 @@ const AFTER_SALES_CASE_SELECT = `
) AS shop_name,
wu.username AS worker_username,
wu.display_name AS worker_display_name,
wos.quantity AS share_quantity
wos.quantity AS share_quantity,
-- 售后追缴口径:整单取工单发单金额,拼单取责任份额的一份价格与合计金额
wo.reward_amount AS work_order_reward_amount,
wo.sharing_enabled AS work_order_sharing_enabled,
wos.unit_reward AS share_unit_reward,
wos.share_reward AS share_reward_amount
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
@@ -201,6 +201,10 @@ export type WorkerAfterSalesCaseRow = {
worker_username?: string
worker_display_name?: string
share_quantity?: number | null
work_order_reward_amount?: number | string | null
work_order_sharing_enabled?: boolean | null
share_unit_reward?: number | string | null
share_reward_amount?: number | string | null
}
export type WorkerAfterSalesCaseEventRow = {
@@ -0,0 +1,24 @@
/**
* 售后默认追缴金额口径(单位:分):
* - 整单售后(没有责任份额):取工单发单金额 work_orders.reward_amount;
* - 拼单售后:取该责任份额的一份价格 work_order_shares.unit_reward,
* 历史上 unit_reward 可能为 0,此时回退为 share_reward / quantity(至少 1 份)。
*
* 注意:这里只给前端一个建议默认值,最终追缴金额仍由管理员在弹窗里确认/修改。
*/
export function resolveAfterSalesSuggestedRecoveryFen(input: {
shareId: number | null
shareUnitReward: number
shareRewardAmount: number
shareQuantity: number | null
workOrderRewardAmount: number
}): number {
const workOrderRewardAmount = Math.max(0, Math.round(Number(input.workOrderRewardAmount || 0)))
if (!input.shareId) return workOrderRewardAmount
const unitReward = Math.max(0, Math.round(Number(input.shareUnitReward || 0)))
if (unitReward > 0) return unitReward
const quantity = Math.max(1, Math.round(Number(input.shareQuantity || 1)))
return Math.max(0, Math.round(Number(input.shareRewardAmount || 0) / quantity))
}
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { resolveAfterSalesSuggestedRecoveryFen } from './after-sales-recovery-amount.js'
test('整单售后:默认追缴金额取工单发单金额', () => {
assert.equal(
resolveAfterSalesSuggestedRecoveryFen({
shareId: null,
shareUnitReward: 0,
shareRewardAmount: 0,
shareQuantity: null,
workOrderRewardAmount: 12_800,
}),
12_800,
)
})
test('拼单售后:默认追缴金额取该责任份额的一份价格', () => {
assert.equal(
resolveAfterSalesSuggestedRecoveryFen({
shareId: 99,
shareUnitReward: 3_200,
shareRewardAmount: 9_600,
shareQuantity: 3,
workOrderRewardAmount: 12_800,
}),
3_200,
)
})
test('拼单售后:一份价格缺失时回退为 合计 / 份数', () => {
assert.equal(
resolveAfterSalesSuggestedRecoveryFen({
shareId: 99,
shareUnitReward: 0,
shareRewardAmount: 9_600,
shareQuantity: 3,
workOrderRewardAmount: 12_800,
}),
3_200,
)
})
test('拼单售后:份数缺失时按 1 份处理,不出现除零', () => {
assert.equal(
resolveAfterSalesSuggestedRecoveryFen({
shareId: 99,
shareUnitReward: 0,
shareRewardAmount: 4_500,
shareQuantity: null,
workOrderRewardAmount: 4_500,
}),
4_500,
)
})
test('金额异常(负数、NaN)一律归零,不产生负数追缴', () => {
assert.equal(
resolveAfterSalesSuggestedRecoveryFen({
shareId: null,
shareUnitReward: 0,
shareRewardAmount: 0,
shareQuantity: null,
workOrderRewardAmount: -100,
}),
0,
)
assert.equal(
resolveAfterSalesSuggestedRecoveryFen({
shareId: 1,
shareUnitReward: Number.NaN,
shareRewardAmount: Number.NaN,
shareQuantity: 2,
workOrderRewardAmount: 0,
}),
0,
)
})
test('售后列表 SQL 必须带出发单金额与责任份额金额,否则处置弹窗拿不到默认值', async () => {
const source = await readFile(
new URL('../../repositories/worker-platform/after-sales-repo.ts', import.meta.url),
'utf8',
)
for (const fragment of [
'wo.reward_amount AS work_order_reward_amount',
'wo.sharing_enabled AS work_order_sharing_enabled',
'wos.unit_reward AS share_unit_reward',
'wos.share_reward AS share_reward_amount',
]) {
assert.ok(source.includes(fragment), `AFTER_SALES_CASE_SELECT 缺少 ${fragment}`)
}
})
test('售后 mapper 必须返回建议追缴金额,供前端默认填充', async () => {
const source = await readFile(new URL('./worker-finance-mappers.ts', import.meta.url), 'utf8')
assert.ok(
source.includes('suggestedRecoveryAmount: resolveAfterSalesSuggestedRecoveryFen({'),
'mapWorkerAfterSalesCase 必须计算 suggestedRecoveryAmount',
)
assert.ok(source.includes('workOrderRewardAmount,'), 'mapper 必须返回 workOrderRewardAmount')
assert.ok(source.includes('shareUnitReward,'), 'mapper 必须返回 shareUnitReward')
assert.ok(source.includes('shareRewardAmount,'), 'mapper 必须返回 shareRewardAmount')
})
@@ -7,6 +7,7 @@ import {
} from '../../repositories/worker-platform/index.js'
import { safeParseJson } from '../admin/admin-query-utils.js'
import { refreshUploadedFileUrls } from '../file-storage/file-storage-service.js'
import { resolveAfterSalesSuggestedRecoveryFen } from './after-sales-recovery-amount.js'
import { createWorkerSessionToken } from './worker-auth-utils.js'
import {
normalizeBoolean,
@@ -227,20 +228,45 @@ export function mapWorkerAfterSalesCase(afterSalesCase: {
worker_username?: string
worker_display_name?: string
share_quantity?: number | null
work_order_reward_amount?: number | string | null
work_order_sharing_enabled?: boolean | null
share_unit_reward?: number | string | null
share_reward_amount?: number | string | null
}) {
const snapshot = safeParseJson(afterSalesCase.acceptance_snapshot_json)
const shareId = afterSalesCase.work_order_share_id
? Number(afterSalesCase.work_order_share_id)
: null
const shareQuantity = afterSalesCase.share_quantity ? Number(afterSalesCase.share_quantity) : null
const workOrderRewardAmount = Math.max(
0,
Math.round(Number(afterSalesCase.work_order_reward_amount || 0)),
)
const shareUnitReward = Math.max(0, Math.round(Number(afterSalesCase.share_unit_reward || 0)))
const shareRewardAmount = Math.max(0, Math.round(Number(afterSalesCase.share_reward_amount || 0)))
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,
shareId,
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,
shareQuantity,
workOrderRewardAmount,
sharingEnabled: Boolean(afterSalesCase.work_order_sharing_enabled),
shareUnitReward,
shareRewardAmount,
suggestedRecoveryAmount: resolveAfterSalesSuggestedRecoveryFen({
shareId,
shareUnitReward,
shareRewardAmount,
shareQuantity,
workOrderRewardAmount,
}),
problemType: String(afterSalesCase.problem_type || ''),
complaintNote: String(afterSalesCase.complaint_note || ''),
complaintFiles: normalizeUploadedFiles(safeParseJson(afterSalesCase.complaint_files_json)).map(
@@ -40,6 +40,12 @@ export default function AdminWorkerPlatformPage() {
const location = useLocation()
const [activeTab, setActiveTab] = useState(() => loadActiveTab(searchParams.get('tab')))
const afterSalesPanelRef = useRef<AfterSalesPanelHandle>(null)
/**
* 从工单列表跳转过来时,售后面板可能尚未挂载(Tab 懒渲染),
* 此时 ref 还是 null,直接调用会被可选链静默吞掉,导致要点两次。
* 先记录待打开的工单,等面板挂载后再真正打开建单弹窗。
*/
const [pendingAfterSalesOrder, setPendingAfterSalesOrder] = useState<WorkOrder | null>(null)
const role = getAdminRole()
const isAdmin = role === 'admin'
const isSupport = role === 'support'
@@ -82,12 +88,20 @@ export default function AdminWorkerPlatformPage() {
const state = location.state as AfterSalesLocationState | null
if (state?.createOrder) {
setActiveTab('after-sales')
afterSalesPanelRef.current?.openCreateModal(state.createOrder)
setPendingAfterSalesOrder(state.createOrder)
// 清掉 state,避免刷新或后退时重复弹窗。
window.history.replaceState({}, '')
}
}, [location.state])
// 售后 Tab 挂载完成(ref 可写)后再打开建单弹窗,避免首点击丢失。
useEffect(() => {
if (visibleActiveTab !== 'after-sales' || !pendingAfterSalesOrder) return
if (!afterSalesPanelRef.current) return
afterSalesPanelRef.current.openCreateModal(pendingAfterSalesOrder)
setPendingAfterSalesOrder(null)
}, [visibleActiveTab, pendingAfterSalesOrder])
function changeTab(nextTab: string) {
if (!isWorkerPlatformTab(nextTab)) return
if (isFinance && nextTab !== 'finance') return
@@ -47,6 +47,8 @@ type AfterSalesCreateFormValues = {
type AfterSalesResolutionFormValues = {
action?: 'not_upheld' | 'remedied' | 'upheld'
resolutionNote?: string
/** 拼单售后追缴的份数;整单售后不展示该字段 */
recoveryQuantity?: number
recoveryAmount?: number
}
@@ -83,6 +85,15 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
const [afterSalesCreateForm] = Form.useForm<AfterSalesCreateFormValues>()
const [afterSalesResolutionForm] = Form.useForm<AfterSalesResolutionFormValues>()
const [afterSalesRepayForm] = Form.useForm<AfterSalesRepayFormValues>()
/** 拼单售后追缴份数(实时回显,用于金额联动展示)。 */
const resolutionRecoveryQuantityWatch = Form.useWatch(
'recoveryQuantity',
afterSalesResolutionForm,
)
const resolutionRecoveryQuantity = Math.max(
1,
Math.round(Number(resolutionRecoveryQuantityWatch || 1)),
)
const afterSalesSharingQuery = useQuery({
queryKey: ['admin-worker-platform-after-sales-sharing', afterSalesOrder?.workOrderId],
@@ -157,10 +168,24 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
function openAfterSalesResolutionModal(item: WorkerAfterSalesCase) {
setAfterSalesCase(item)
const existingRecoveryAmount = Number(item.recoveryAmount || 0)
const shareQuantity = Math.max(1, Number(item.shareQuantity || 1))
const unitRewardFen = Math.max(0, Number(item.shareUnitReward || 0))
// 已追缴过的按原金额反推份数;未处置的默认 1 份。
const recoveryQuantity =
item.shareId && existingRecoveryAmount > 0 && unitRewardFen > 0
? Math.min(shareQuantity, Math.max(1, Math.round(existingRecoveryAmount / unitRewardFen)))
: 1
afterSalesResolutionForm.setFieldsValue({
action: 'upheld',
resolutionNote: '',
recoveryAmount: Number(item.recoveryAmount || 0) / 100,
recoveryQuantity: item.shareId ? recoveryQuantity : undefined,
// 已追缴过的沿用原金额;未处置时按后端建议值:
// 整单 = 发单金额,拼单 = 该责任份额的一份价格。
recoveryAmount:
existingRecoveryAmount > 0
? existingRecoveryAmount / 100
: Number(item.suggestedRecoveryAmount || 0) / 100,
})
}
@@ -272,7 +297,7 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
setAfterSalesPageSize(nextPageSize)
},
})}
scroll={{ x: 1160 }}
scroll={{ x: 1230 }}
columns={[
{
title: '售后单',
@@ -289,13 +314,20 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
},
{
title: '责任打手',
width: 150,
width: 220,
render: (_, item) => (
<div className="cell-stack">
<Typography.Text>{item.workerName || `打手#${item.workerId}`}</Typography.Text>
{item.shareQuantity ? (
<Tag color="purple">拼单 {item.shareQuantity} 份</Tag>
) : null}
{item.shareId ? (
<Tag color="purple">
拼单 {item.shareQuantity || 1} 份 · 一份 {formatMoney(item.shareUnitReward)} ·
合计 {formatMoney(item.shareRewardAmount)}
</Tag>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
整单发单 {formatMoney(item.workOrderRewardAmount)}
</Typography.Text>
)}
</div>
),
},
@@ -447,7 +479,7 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
.filter((share) => share.status === 'accepted')
.map((share) => ({
value: share.shareId,
label: `${share.worker?.displayName || share.worker?.username || `打手#${share.workerId}`} · ${share.quantity} 份`,
label: `${share.worker?.displayName || share.worker?.username || `打手#${share.workerId}`} · ${share.quantity} 份 · 一份 ${formatMoney(share.unitReward)} · 合计 ${formatMoney(share.shareReward)}`,
}))}
/>
</Form.Item>
@@ -497,6 +529,16 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
form={afterSalesResolutionForm}
layout="vertical"
onFinish={submitAfterSalesResolution}
onValuesChange={(changedValues) => {
// 拼单:份数变化时按「一份价格 × 份数」重算追缴金额;金额仍可手动改。
if (!afterSalesCase?.shareId) return
if (changedValues.recoveryQuantity === undefined) return
const quantity = Math.max(1, Math.round(Number(changedValues.recoveryQuantity || 1)))
afterSalesResolutionForm.setFieldsValue({
recoveryAmount:
(Math.max(0, Number(afterSalesCase.shareUnitReward || 0)) * quantity) / 100,
})
}}
>
<Form.Item
label="处置结论"
@@ -517,14 +559,49 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
>
{({ getFieldValue }) =>
getFieldValue('action') === 'upheld' ? (
<Form.Item
label="追缴金额"
name="recoveryAmount"
rules={[{ required: true, message: '请输入追缴金额' }]}
extra="系统依次扣减该责任打手的本单待解冻金额、提现冻结中的金额(会自动驳回其待审核提现)、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。"
>
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
</Form.Item>
<>
{afterSalesCase.shareId ? (
<>
<Alert
showIcon
type="info"
style={{ marginBottom: 16 }}
message={`发单金额:一份 ${formatMoney(afterSalesCase.shareUnitReward)} × ${resolutionRecoveryQuantity} 份 = ${formatMoney(Number(afterSalesCase.shareUnitReward || 0) * resolutionRecoveryQuantity)}`}
description={`该责任份额共 ${afterSalesCase.shareQuantity || 1} 份,合计 ${formatMoney(afterSalesCase.shareRewardAmount)};默认追缴 1 份,调整份数后追缴金额自动重算,也可直接修改金额。`}
/>
<Form.Item
label="追缴份数"
name="recoveryQuantity"
rules={[{ required: true, message: '请输入追缴份数' }]}
extra={`最多可追缴 ${afterSalesCase.shareQuantity || 1} 份`}
>
<InputNumber
min={1}
max={Math.max(1, Number(afterSalesCase.shareQuantity || 1))}
precision={0}
addonAfter="份"
className="full-width"
/>
</Form.Item>
</>
) : (
<Alert
showIcon
type="info"
style={{ marginBottom: 16 }}
message={`发单金额:${formatMoney(afterSalesCase.workOrderRewardAmount)}(整单)`}
description="整单售后默认按工单发单金额追缴,可自行调整。"
/>
)}
<Form.Item
label="追缴金额"
name="recoveryAmount"
rules={[{ required: true, message: '请输入追缴金额' }]}
extra="系统依次扣减该责任打手的本单待解冻金额、提现冻结中的金额(会自动驳回其待审核提现)、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。"
>
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
</Form.Item>
</>
) : (
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
选择补救完成或问题不成立时,系统自动将该工单同一责任打手此前已追缴且未退还的金额退还至其可用余额。
@@ -235,6 +235,16 @@ export type WorkerAfterSalesCase = {
shopName: string
workerName: string
shareQuantity: number | null
/** 工单发单金额(分):整单售后按此金额追缴 */
workOrderRewardAmount: number
/** 工单是否开启拼单 */
sharingEnabled: boolean
/** 责任份额的一份价格(分) */
shareUnitReward: number
/** 责任份额的合计金额(分)= 一份价格 × 份数 */
shareRewardAmount: number
/** 后端建议的默认追缴金额(分):整单 = 发单金额,拼单 = 一份价格 */
suggestedRecoveryAmount: number
problemType: string
complaintNote: string
complaintFiles: UploadedFile[]
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
function readSource(relativePath: string) {
return readFileSync(new URL(relativePath, import.meta.url).pathname, 'utf8')
}
test('工单跳到售后:先记录待打开工单,等面板挂载后再弹窗(避免点两次)', () => {
const source = readSource('../src/pages/admin/AdminWorkerPlatformPage.tsx')
assert.ok(source.includes('pendingAfterSalesOrder'), '必须用 pending state 承接待打开的售后工单')
assert.ok(
source.includes('setPendingAfterSalesOrder(state.createOrder)'),
'收到路由 state 时只记录待打开工单,不能直接调用尚未挂载的 ref',
)
assert.ok(
!source.includes('afterSalesPanelRef.current?.openCreateModal(state.createOrder)'),
'不能在 setActiveTab 的同一次同步执行里调用 ref(此时面板还没挂载,调用会被静默吞掉)',
)
assert.ok(
source.includes('}, [visibleActiveTab, pendingAfterSalesOrder])'),
'第二个 effect 必须在售后 Tab 激活后重跑',
)
assert.ok(
source.includes("if (visibleActiveTab !== 'after-sales' || !pendingAfterSalesOrder) return"),
'只有切到售后 Tab 且存在待打开工单时才处理',
)
assert.ok(
source.includes('afterSalesPanelRef.current.openCreateModal(pendingAfterSalesOrder)'),
'面板挂载后必须真正打开建单弹窗',
)
})
test('售后处置弹窗:默认金额取后端建议值,并展示发单金额与拼单份数', () => {
const source = readSource('../src/pages/admin/panels/AfterSalesPanel.tsx')
assert.ok(
source.includes('suggestedRecoveryAmount'),
'默认追缴金额必须使用 suggestedRecoveryAmount',
)
assert.ok(
source.includes('existingRecoveryAmount > 0'),
'已追缴过的售后必须沿用原有追缴金额,不能被建议值覆盖',
)
assert.ok(source.includes('afterSalesCase.workOrderRewardAmount'), '整单必须展示工单发单金额')
assert.ok(source.includes('afterSalesCase.shareUnitReward'), '拼单必须展示一份价格')
assert.ok(source.includes('afterSalesCase.shareRewardAmount'), '拼单必须展示份额合计金额')
assert.ok(source.includes('afterSalesCase.shareQuantity'), '拼单必须展示已拼份数')
assert.ok(source.includes('(整单)'), '整单场景要有明确标识')
})
test('拼单处置:默认一份,可输入份数并按「一份价格 × 份数」联动追缴金额;整单不出现份数输入', () => {
const source = readSource('../src/pages/admin/panels/AfterSalesPanel.tsx')
assert.ok(source.includes('recoveryQuantity?: number'), '表单值类型必须支持追缴份数')
assert.ok(source.includes('label="追缴份数"'), '拼单必须提供追缴份数输入框')
assert.ok(source.includes('name="recoveryQuantity"'), '份数输入必须绑定 recoveryQuantity')
assert.ok(source.includes('min={1}'), '份数至少 1 份')
assert.ok(
source.includes('Math.max(1, Number(afterSalesCase.shareQuantity || 1))'),
'份数上限必须是该责任份额的份数',
)
assert.ok(source.includes('onValuesChange'), '份数变化必须联动追缴金额')
assert.ok(
source.includes('changedValues.recoveryQuantity === undefined'),
'只有份数变化才重算金额,手动改金额不能被覆盖',
)
assert.ok(
source.includes('Number(afterSalesCase.shareUnitReward || 0)) * quantity') ||
source.includes('Number(afterSalesCase.shareUnitReward || 0)'),
'联动金额必须按一份价格乘以份数计算',
)
assert.ok(source.includes('resolutionRecoveryQuantity'), '弹窗必须回显当前追缴份数')
// 份数输入必须只在 shareId 分支(拼单)渲染,整单只展示发单金额。
const shareBranch = source.indexOf('afterSalesCase.shareId ? (')
const quantityLabel = source.indexOf('label="追缴份数"')
assert.ok(shareBranch > 0 && quantityLabel > shareBranch, '份数输入必须在拼单分支内')
const wholeOrderAlert = source.indexOf('(整单)')
const nextRecoveryField = source.indexOf('label="追缴金额"', wholeOrderAlert)
assert.ok(wholeOrderAlert > quantityLabel, '整单分支应在拼单分支之后')
assert.ok(
!source.slice(wholeOrderAlert, nextRecoveryField).includes('recoveryQuantity'),
'整单分支不能包含份数输入',
)
})
test('建单份额下拉:每个份额要显示份数、一份价格与合计金额', () => {
const source = readSource('../src/pages/admin/panels/AfterSalesPanel.tsx')
assert.ok(source.includes('`${share.worker?.displayName'), '份额下拉仍要显示打手名')
assert.ok(source.includes('一份 ${formatMoney(share.unitReward)}'), '份额下拉必须显示一份价格')
assert.ok(source.includes('合计 ${formatMoney(share.shareReward)}'), '份额下拉必须显示合计金额')
})
test('售后单类型必须包含金额字段,否则前端读取为 undefined', () => {
const source = readSource('../src/types/worker-platform.ts')
for (const field of [
'workOrderRewardAmount: number',
'sharingEnabled: boolean',
'shareUnitReward: number',
'shareRewardAmount: number',
'suggestedRecoveryAmount: number',
]) {
assert.ok(source.includes(field), `WorkerAfterSalesCase 缺少 ${field}`)
}
})