diff --git a/apps/backend/src/repositories/worker-platform/after-sales-repo.ts b/apps/backend/src/repositories/worker-platform/after-sales-repo.ts index 4ca8bfcb..da5f0734 100644 --- a/apps/backend/src/repositories/worker-platform/after-sales-repo.ts +++ b/apps/backend/src/repositories/worker-platform/after-sales-repo.ts @@ -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 diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index d2b90511..163bffe0 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -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 = { diff --git a/apps/backend/src/services/worker-platform/after-sales-recovery-amount.ts b/apps/backend/src/services/worker-platform/after-sales-recovery-amount.ts new file mode 100644 index 00000000..ba0df72d --- /dev/null +++ b/apps/backend/src/services/worker-platform/after-sales-recovery-amount.ts @@ -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)) +} diff --git a/apps/backend/src/services/worker-platform/after-sales-suggested-recovery.test.ts b/apps/backend/src/services/worker-platform/after-sales-suggested-recovery.test.ts new file mode 100644 index 00000000..49950b92 --- /dev/null +++ b/apps/backend/src/services/worker-platform/after-sales-suggested-recovery.test.ts @@ -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') +}) diff --git a/apps/backend/src/services/worker-platform/worker-finance-mappers.ts b/apps/backend/src/services/worker-platform/worker-finance-mappers.ts index 0142e7ec..5497f19d 100644 --- a/apps/backend/src/services/worker-platform/worker-finance-mappers.ts +++ b/apps/backend/src/services/worker-platform/worker-finance-mappers.ts @@ -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( diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index 2454bd86..7371e932 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -40,6 +40,12 @@ export default function AdminWorkerPlatformPage() { const location = useLocation() const [activeTab, setActiveTab] = useState(() => loadActiveTab(searchParams.get('tab'))) const afterSalesPanelRef = useRef(null) + /** + * 从工单列表跳转过来时,售后面板可能尚未挂载(Tab 懒渲染), + * 此时 ref 还是 null,直接调用会被可选链静默吞掉,导致要点两次。 + * 先记录待打开的工单,等面板挂载后再真正打开建单弹窗。 + */ + const [pendingAfterSalesOrder, setPendingAfterSalesOrder] = useState(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 diff --git a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx index 345655d2..6343ef2b 100644 --- a/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/AfterSalesPanel.tsx @@ -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(function AfterSalesPan const [afterSalesCreateForm] = Form.useForm() const [afterSalesResolutionForm] = Form.useForm() const [afterSalesRepayForm] = Form.useForm() + /** 拼单售后追缴份数(实时回显,用于金额联动展示)。 */ + 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(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(function AfterSalesPan setAfterSalesPageSize(nextPageSize) }, })} - scroll={{ x: 1160 }} + scroll={{ x: 1230 }} columns={[ { title: '售后单', @@ -289,13 +314,20 @@ const AfterSalesPanel = forwardRef(function AfterSalesPan }, { title: '责任打手', - width: 150, + width: 220, render: (_, item) => (
{item.workerName || `打手#${item.workerId}`} - {item.shareQuantity ? ( - 拼单 {item.shareQuantity} 份 - ) : null} + {item.shareId ? ( + + 拼单 {item.shareQuantity || 1} 份 · 一份 {formatMoney(item.shareUnitReward)} · + 合计 {formatMoney(item.shareRewardAmount)} + + ) : ( + + 整单发单 {formatMoney(item.workOrderRewardAmount)} + + )}
), }, @@ -447,7 +479,7 @@ const AfterSalesPanel = forwardRef(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)}`, }))} /> @@ -497,6 +529,16 @@ const AfterSalesPanel = forwardRef(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, + }) + }} > (function AfterSalesPan > {({ getFieldValue }) => getFieldValue('action') === 'upheld' ? ( - - - + <> + {afterSalesCase.shareId ? ( + <> + + + + + + ) : ( + + )} + + + + ) : ( 选择补救完成或问题不成立时,系统自动将该工单同一责任打手此前已追缴且未退还的金额退还至其可用余额。 diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 75574963..694dd614 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -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[] diff --git a/apps/frontend/tests/after-sales-amount-and-modal.test.ts b/apps/frontend/tests/after-sales-amount-and-modal.test.ts new file mode 100644 index 00000000..f35c34c2 --- /dev/null +++ b/apps/frontend/tests/after-sales-amount-and-modal.test.ts @@ -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}`) + } +})