From 1c989072a8fff7659753362961571d8bea55862a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 21 Aug 2026 16:28:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E6=8E=A5=E5=8D=95=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker-platform/after-sales-recovery.ts | 28 ++ .../worker-platform/after-sales-repo.ts | 30 +- .../src/services/worker-platform/mappers.ts | 273 ++---------------- .../worker-platform/work-order-pricing.ts | 96 ++++++ .../work-order-requirement-fields.ts | 155 ++++++++++ .../worker-platform-service.test.ts | 46 +++ 6 files changed, 360 insertions(+), 268 deletions(-) create mode 100644 apps/backend/src/repositories/worker-platform/after-sales-recovery.ts create mode 100644 apps/backend/src/services/worker-platform/work-order-pricing.ts create mode 100644 apps/backend/src/services/worker-platform/work-order-requirement-fields.ts diff --git a/apps/backend/src/repositories/worker-platform/after-sales-recovery.ts b/apps/backend/src/repositories/worker-platform/after-sales-recovery.ts new file mode 100644 index 00000000..607d2d78 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/after-sales-recovery.ts @@ -0,0 +1,28 @@ +export type AfterSalesRecoveryAllocation = { + pendingDepositDeductionAmount: number + availableDeductionAmount: number + debtAmount: number +} + +export function resolveAfterSalesRecoveryAllocation(input: { + recoveryAmount: number + pendingDepositAmount: number + availableAmount: number +}): AfterSalesRecoveryAllocation { + let remaining = Math.max(0, Number(input.recoveryAmount) || 0) + const pendingDepositDeductionAmount = Math.min( + remaining, + Math.max(0, Number(input.pendingDepositAmount) || 0), + ) + remaining -= pendingDepositDeductionAmount + const availableDeductionAmount = Math.min( + remaining, + Math.max(0, Number(input.availableAmount) || 0), + ) + remaining -= availableDeductionAmount + return { + pendingDepositDeductionAmount, + availableDeductionAmount, + debtAmount: remaining, + } +} 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 bf5d2c6c..21353711 100644 --- a/apps/backend/src/repositories/worker-platform/after-sales-repo.ts +++ b/apps/backend/src/repositories/worker-platform/after-sales-repo.ts @@ -1,6 +1,7 @@ import type { PoolClient } from 'pg' import { query, withTransaction } from '../../db/client.js' +import { resolveAfterSalesRecoveryAllocation } from './after-sales-recovery.js' import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js' import type { WorkerAfterSalesCaseEventRow, @@ -426,8 +427,6 @@ async function recoverAfterSalesAmountWithClient( availableDeductionAmount: number debtAmount: number }> { - let remaining = Math.max(0, input.amount) - let pendingDepositDeductionAmount = 0 const pendingResult = await client.query( ` SELECT * FROM worker_deposit_unfreezes @@ -439,10 +438,19 @@ async function recoverAfterSalesAmountWithClient( ) await ensureWorkerWalletWithClient(client, input.workerId, input.now) let wallet: WorkerWalletRow | null = await getWorkerWalletWithClient(client, input.workerId) + const allocation = resolveAfterSalesRecoveryAllocation({ + recoveryAmount: input.amount, + pendingDepositAmount: pendingResult.rows.reduce( + (total, row) => total + Math.max(0, Number(row.amount) || 0), + 0, + ), + availableAmount: Number(wallet?.available_amount || 0), + }) + let pendingRemaining = allocation.pendingDepositDeductionAmount for (const row of pendingResult.rows) { - if (remaining <= 0) break - const rowAmount = Number(row.amount || 0) - const deduction = Math.min(rowAmount, remaining) + if (pendingRemaining <= 0) break + const rowAmount = Math.max(0, Number(row.amount) || 0) + const deduction = Math.min(rowAmount, pendingRemaining) if (deduction <= 0) continue if (deduction >= rowAmount) { await client.query( @@ -478,12 +486,11 @@ async function recoverAfterSalesAmountWithClient( input.now, ], ) - pendingDepositDeductionAmount += deduction - remaining -= deduction + pendingRemaining -= deduction } wallet = await getWorkerWalletWithClient(client, input.workerId) - const availableDeductionAmount = Math.min(remaining, Number(wallet?.available_amount || 0)) + const availableDeductionAmount = allocation.availableDeductionAmount if (availableDeductionAmount > 0) { const nextAvailable = Number(wallet?.available_amount || 0) - availableDeductionAmount await client.query( @@ -507,19 +514,18 @@ async function recoverAfterSalesAmountWithClient( input.now, ], ) - remaining -= availableDeductionAmount } - if (remaining > 0) { + if (allocation.debtAmount > 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], + [input.caseId, input.workerId, allocation.debtAmount, input.now], ) } - return { pendingDepositDeductionAmount, availableDeductionAmount, debtAmount: remaining } + return allocation } async function createAfterSalesCaseEventWithClient( diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 97c2e0a8..a3c6f55f 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -37,6 +37,23 @@ import { resolveVisibleDelaySeconds, resolveWorkerPermissions, } from './worker-permission-utils.js' +import { + normalizeRequirementFields, + resolveRequirementFields, +} from './work-order-requirement-fields.js' +export { + normalizeRequirementFields, + normalizeRequirementFieldsFromPayload, + normalizeRequirementFieldsText, + resolveRequirementFields, + type RequirementField, +} from './work-order-requirement-fields.js' +export { + isWorkOrderSharingFilled, + resolveSkuNameQuantity, + resolveWorkOrderRulePricing, + resolveWorkOrderSharingEnabled, +} from './work-order-pricing.js' export { ensureWorkerAuthConfigured, @@ -696,158 +713,6 @@ export function mapWallet(wallet: Awaited { - const source = - item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {} - const key = String(source.key || '').trim() - if (!key) return null - const type = source.type === 'select' ? 'select' : 'text' - const options = Array.isArray(source.options) - ? source.options.map((option) => String(option || '').trim()).filter(Boolean) - : [] - return { - key, - label: String(source.label || key).trim(), - required: source.required !== false, - type, - options, - mockValue: String(source.mockValue || '').trim(), - } - }) - .filter((item): item is RequirementField => Boolean(item)) - - if (fields.length > 0 && !isDefaultTemplateRequirementFields(fields)) { - return fields - } - return [ - { - key: 'gameId', - label: '游戏编号', - required: true, - type: 'text', - options: [], - mockValue: 'test_uid', - }, - { - key: 'gameNickname', - label: '游戏昵称', - required: true, - type: 'text', - options: [], - mockValue: '测试角色', - }, - { - key: 'system', - label: '系统', - required: false, - type: 'select', - options: ['安卓', '苹果'], - mockValue: '安卓', - }, - { - key: 'serverZone', - label: '区服', - required: false, - type: 'select', - options: ['QQ区', '微信区'], - mockValue: 'QQ区', - }, - ] -} - -function isDefaultTemplateRequirementFields(fields: RequirementField[]): boolean { - if (fields.length === 0) return true - const defaultKeys = new Set(['gameId', 'gameNickname', 'system', 'serverZone']) - const legacyKeys = new Set(['gameAccount', 'serverName', 'roleName']) - return fields.every((field) => defaultKeys.has(field.key) || legacyKeys.has(field.key)) -} - -export function normalizeRequirementFieldsFromPayload(payload: JsonObject) { - const directFields = Array.isArray(payload.fields) ? payload.fields : null - const requirement = - payload.requirement && - typeof payload.requirement === 'object' && - !Array.isArray(payload.requirement) - ? (payload.requirement as JsonObject) - : {} - if (directFields) { - return normalizeRequirementFields(directFields) - } - if (Array.isArray(requirement.fields)) { - return normalizeRequirementFields(requirement.fields) - } - - const textFields = normalizeRequirementFieldsText( - payload.fieldsText || payload.requiredFieldsText, - ) - return textFields.length > 0 ? textFields : normalizeRequirementFields([]) -} - -export function normalizeRequirementFieldsText(value: unknown) { - return String(value || '') - .split(/\r?\n/) - .map((line, index) => { - const text = line.trim() - if (!text) return null - const delimiter = text.includes(':') ? ':' : ':' - if (text.includes(delimiter)) { - const [rawKey, ...labelParts] = text.split(delimiter) - const key = String(rawKey || '').trim() - if (!key) return null - const labelWithOptions = labelParts.join(delimiter).trim() || key - const [label, rawOptions] = splitFieldOptions(labelWithOptions) - return { - key, - label: label || key, - required: true, - type: rawOptions.length > 0 ? 'select' : 'text', - options: rawOptions, - mockValue: '', - } - } - const [plainLabel, plainOptions] = splitFieldOptions(text) - return { - key: `field${index + 1}`, - label: plainLabel, - required: true, - type: plainOptions.length > 0 ? 'select' : 'text', - options: plainOptions, - mockValue: '', - } - }) - .filter((item): item is RequirementField => Boolean(item)) -} - -function splitFieldOptions(value: string): [string, string[]] { - const text = String(value || '').trim() - if (!text.includes('#')) { - return [text, []] - } - const [label, ...optionParts] = text.split('#') - const options = optionParts - .join('#') - .split(/[,,、]/) - .map((option) => String(option || '').trim()) - .filter(Boolean) - return [String(label || '').trim(), options] -} - export function resolveMatchingProductRule( order: OrderRow, item: OrderItemRow, @@ -1208,110 +1073,6 @@ export function normalizeWorkProductRuleMatch(payload: JsonObject): JsonObject { return result } -/** - * 从商品名称文字中解析规格数量(二次匹配数量)。 - * 支持阿拉伯数字与中文数字,如:指挥官秘钥1个 → 1、指挥官秘钥15个 → 15、指挥官秘钥三个 → 3。 - * 解析不到时返回 1。 - */ -export function resolveSkuNameQuantity(productName: unknown): number { - const text = String(productName || '').trim() - if (!text) return 1 - - const arabicMatch = text.match(/(\d+)\s*(?:个|份|张|枚)/) - if (arabicMatch) { - const parsed = Number(arabicMatch[1]) - if (Number.isSafeInteger(parsed) && parsed > 0) return parsed - } - - const chineseMatch = text.match(/([零一二三四五六七八九十百千两]+)\s*(?:个|份|张|枚)/) - if (chineseMatch?.[1]) { - const parsed = parseChineseNumber(chineseMatch[1]) - if (parsed > 0) return parsed - } - - return 1 -} - -export function resolveWorkOrderRulePricing(input: { - skuQuantity: number - unitPriceFen: number - fixedRewardAmount: number - sharingEnabled: boolean - sharingAutoFromOrder: boolean - sharingTotalQuantity: number - sharingUnitReward: number -}) { - const skuQuantity = Math.max(1, Number(input.skuQuantity) || 1) - const unitPriceFen = Math.max(0, Number(input.unitPriceFen) || 0) - const sharingTotalQuantity = input.sharingAutoFromOrder - ? skuQuantity - : Math.max(1, Number(input.sharingTotalQuantity) || 1) - const sharingUnitReward = input.sharingAutoFromOrder - ? unitPriceFen - : Math.max(0, Number(input.sharingUnitReward) || 0) - const rewardAmount = input.sharingEnabled - ? sharingTotalQuantity * sharingUnitReward - : unitPriceFen > 0 - ? unitPriceFen * skuQuantity - : Math.max(0, Number(input.fixedRewardAmount) || 0) - - return { sharingTotalQuantity, sharingUnitReward, rewardAmount } -} - -/** 接单模板仅在实际总份数大于 1 时生成拼单工单。 */ -export function resolveWorkOrderSharingEnabled( - configuredEnabled: boolean, - totalQuantity: number, -): boolean { - return configuredEnabled && Math.max(1, Number(totalQuantity) || 1) > 1 -} - -/** 判断拼单工单是否已经没有剩余可承接份数。 */ -export function isWorkOrderSharingFilled( - sharingEnabled: boolean, - totalQuantity: number, - joinedQuantity: number, -): boolean { - return ( - sharingEnabled && - Math.max(0, Number(joinedQuantity) || 0) >= Math.max(1, Number(totalQuantity) || 1) - ) -} - -const CHINESE_DIGITS: Record = { - 零: 0, - 一: 1, - 二: 2, - 两: 2, - 三: 3, - 四: 4, - 五: 5, - 六: 6, - 七: 7, - 八: 8, - 九: 9, -} - -function parseChineseNumber(text: string): number { - let section = 0 - let digit = 0 - for (const char of text) { - if (char === '十') { - section += (digit || 1) * 10 - digit = 0 - } else if (char === '百') { - section += (digit || 1) * 100 - digit = 0 - } else if (char === '千') { - section += (digit || 1) * 1000 - digit = 0 - } else if (char in CHINESE_DIGITS) { - digit = CHINESE_DIGITS[char] ?? 0 - } - } - return section + digit -} - export function matchesOptionalText(ruleValue: unknown, sourceValue: unknown) { const ruleText = String(ruleValue || '') .trim() diff --git a/apps/backend/src/services/worker-platform/work-order-pricing.ts b/apps/backend/src/services/worker-platform/work-order-pricing.ts new file mode 100644 index 00000000..cac77015 --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-pricing.ts @@ -0,0 +1,96 @@ +export function resolveSkuNameQuantity(productName: unknown): number { + const text = String(productName || '').trim() + if (!text) return 1 + + const arabicMatch = text.match(/(\d+)\s*(?:个|份|张|枚)/) + if (arabicMatch) { + const parsed = Number(arabicMatch[1]) + if (Number.isSafeInteger(parsed) && parsed > 0) return parsed + } + + const chineseMatch = text.match(/([零一二三四五六七八九十百千两]+)\s*(?:个|份|张|枚)/) + if (chineseMatch?.[1]) { + const parsed = parseChineseNumber(chineseMatch[1]) + if (parsed > 0) return parsed + } + + return 1 +} + +export function resolveWorkOrderRulePricing(input: { + skuQuantity: number + unitPriceFen: number + fixedRewardAmount: number + sharingEnabled: boolean + sharingAutoFromOrder: boolean + sharingTotalQuantity: number + sharingUnitReward: number +}) { + const skuQuantity = Math.max(1, Number(input.skuQuantity) || 1) + const unitPriceFen = Math.max(0, Number(input.unitPriceFen) || 0) + const sharingTotalQuantity = input.sharingAutoFromOrder + ? skuQuantity + : Math.max(1, Number(input.sharingTotalQuantity) || 1) + const sharingUnitReward = input.sharingAutoFromOrder + ? unitPriceFen + : Math.max(0, Number(input.sharingUnitReward) || 0) + const rewardAmount = input.sharingEnabled + ? sharingTotalQuantity * sharingUnitReward + : unitPriceFen > 0 + ? unitPriceFen * skuQuantity + : Math.max(0, Number(input.fixedRewardAmount) || 0) + + return { sharingTotalQuantity, sharingUnitReward, rewardAmount } +} + +export function resolveWorkOrderSharingEnabled( + configuredEnabled: boolean, + totalQuantity: number, +): boolean { + return configuredEnabled && Math.max(1, Number(totalQuantity) || 1) > 1 +} + +export function isWorkOrderSharingFilled( + sharingEnabled: boolean, + totalQuantity: number, + joinedQuantity: number, +): boolean { + return ( + sharingEnabled && + Math.max(0, Number(joinedQuantity) || 0) >= Math.max(1, Number(totalQuantity) || 1) + ) +} + +const CHINESE_DIGITS: Record = { + 零: 0, + 一: 1, + 二: 2, + 两: 2, + 三: 3, + 四: 4, + 五: 5, + 六: 6, + 七: 7, + 八: 8, + 九: 9, +} + +function parseChineseNumber(text: string): number { + let section = 0 + let digit = 0 + for (const char of text) { + if (char === '十') { + section += (digit || 1) * 10 + digit = 0 + } else if (char === '百') { + section += (digit || 1) * 100 + digit = 0 + } else if (char === '千') { + section += (digit || 1) * 1000 + digit = 0 + } else if (char in CHINESE_DIGITS) { + digit = CHINESE_DIGITS[char] ?? 0 + } + } + return section + digit +} diff --git a/apps/backend/src/services/worker-platform/work-order-requirement-fields.ts b/apps/backend/src/services/worker-platform/work-order-requirement-fields.ts new file mode 100644 index 00000000..84b4209d --- /dev/null +++ b/apps/backend/src/services/worker-platform/work-order-requirement-fields.ts @@ -0,0 +1,155 @@ +import type { WorkOrderRow } from '../../repositories/worker-platform/index.js' +import type { JsonObject } from '../../types/json.js' +import { safeParseJson } from '../admin/admin-query-utils.js' + +export type RequirementField = { + key: string + label: string + required: boolean + type: 'text' | 'select' + options: string[] + mockValue: string +} + +export function resolveRequirementFields(workOrder: WorkOrderRow) { + return normalizeRequirementFields(safeParseJson(workOrder.requirement_json).fields) +} + +export function normalizeRequirementFields(value: unknown) { + const rawFields = Array.isArray(value) ? value : [] + const fields = rawFields + .map((item) => { + const source = + item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {} + const key = String(source.key || '').trim() + if (!key) return null + const type = source.type === 'select' ? 'select' : 'text' + const options = Array.isArray(source.options) + ? source.options.map((option) => String(option || '').trim()).filter(Boolean) + : [] + return { + key, + label: String(source.label || key).trim(), + required: source.required !== false, + type, + options, + mockValue: String(source.mockValue || '').trim(), + } + }) + .filter((item): item is RequirementField => Boolean(item)) + + if (fields.length > 0 && !isDefaultTemplateRequirementFields(fields)) { + return fields + } + return [ + { + key: 'gameId', + label: '游戏编号', + required: true, + type: 'text', + options: [], + mockValue: 'test_uid', + }, + { + key: 'gameNickname', + label: '游戏昵称', + required: true, + type: 'text', + options: [], + mockValue: '测试角色', + }, + { + key: 'system', + label: '系统', + required: false, + type: 'select', + options: ['安卓', '苹果'], + mockValue: '安卓', + }, + { + key: 'serverZone', + label: '区服', + required: false, + type: 'select', + options: ['QQ区', '微信区'], + mockValue: 'QQ区', + }, + ] +} + +function isDefaultTemplateRequirementFields(fields: RequirementField[]): boolean { + if (fields.length === 0) return true + const defaultKeys = new Set(['gameId', 'gameNickname', 'system', 'serverZone']) + const legacyKeys = new Set(['gameAccount', 'serverName', 'roleName']) + return fields.every((field) => defaultKeys.has(field.key) || legacyKeys.has(field.key)) +} + +export function normalizeRequirementFieldsFromPayload(payload: JsonObject) { + const directFields = Array.isArray(payload.fields) ? payload.fields : null + const requirement = + payload.requirement && + typeof payload.requirement === 'object' && + !Array.isArray(payload.requirement) + ? (payload.requirement as JsonObject) + : {} + if (directFields) { + return normalizeRequirementFields(directFields) + } + if (Array.isArray(requirement.fields)) { + return normalizeRequirementFields(requirement.fields) + } + + const textFields = normalizeRequirementFieldsText( + payload.fieldsText || payload.requiredFieldsText, + ) + return textFields.length > 0 ? textFields : normalizeRequirementFields([]) +} + +export function normalizeRequirementFieldsText(value: unknown) { + return String(value || '') + .split(/\r?\n/) + .map((line, index) => { + const text = line.trim() + if (!text) return null + const delimiter = text.includes(':') ? ':' : ':' + if (text.includes(delimiter)) { + const [rawKey, ...labelParts] = text.split(delimiter) + const key = String(rawKey || '').trim() + if (!key) return null + const labelWithOptions = labelParts.join(delimiter).trim() || key + const [label, rawOptions] = splitFieldOptions(labelWithOptions) + return { + key, + label: label || key, + required: true, + type: rawOptions.length > 0 ? 'select' : 'text', + options: rawOptions, + mockValue: '', + } + } + const [plainLabel, plainOptions] = splitFieldOptions(text) + return { + key: `field${index + 1}`, + label: plainLabel, + required: true, + type: plainOptions.length > 0 ? 'select' : 'text', + options: plainOptions, + mockValue: '', + } + }) + .filter((item): item is RequirementField => Boolean(item)) +} + +function splitFieldOptions(value: string): [string, string[]] { + const text = String(value || '').trim() + if (!text.includes('#')) { + return [text, []] + } + const [label, ...optionParts] = text.split('#') + const options = optionParts + .join('#') + .split(/[,,、]/) + .map((option) => String(option || '').trim()) + .filter(Boolean) + return [String(label || '').trim(), options] +} diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index f84ed977..f15cf913 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -44,6 +44,52 @@ import { resolveWorkOrderSharingEnabled, validateWorkerPassword, } from './index.js' +import { resolveAfterSalesRecoveryAllocation } from '../../repositories/worker-platform/after-sales-recovery.js' + +test('售后追缴优先扣减待解冻押金', () => { + assert.deepEqual( + resolveAfterSalesRecoveryAllocation({ + recoveryAmount: 10_000, + pendingDepositAmount: 12_000, + availableAmount: 8_000, + }), + { + pendingDepositDeductionAmount: 10_000, + availableDeductionAmount: 0, + debtAmount: 0, + }, + ) +}) + +test('售后追缴在押金不足时继续扣减可用余额', () => { + assert.deepEqual( + resolveAfterSalesRecoveryAllocation({ + recoveryAmount: 10_000, + pendingDepositAmount: 3_000, + availableAmount: 8_000, + }), + { + pendingDepositDeductionAmount: 3_000, + availableDeductionAmount: 7_000, + debtAmount: 0, + }, + ) +}) + +test('售后追缴在余额不足时形成欠款', () => { + assert.deepEqual( + resolveAfterSalesRecoveryAllocation({ + recoveryAmount: 10_000, + pendingDepositAmount: 3_000, + availableAmount: 2_000, + }), + { + pendingDepositDeductionAmount: 3_000, + availableDeductionAmount: 2_000, + debtAmount: 5_000, + }, + ) +}) function buildOrderItemRow(): OrderItemRow { return {