拆分接单平台规则

This commit is contained in:
yml2213
2026-08-21 16:28:16 +08:00
parent 4e8fb433ef
commit 1c989072a8
6 changed files with 360 additions and 268 deletions
@@ -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,
}
}
@@ -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<WorkerDepositUnfreezeRow>(
`
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(
@@ -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<ReturnType<typeof addWorkerWalletCredi
}
}
export function resolveRequirementFields(workOrder: WorkOrderRow) {
return normalizeRequirementFields(safeParseJson(workOrder.requirement_json).fields)
}
export type RequirementField = {
key: string
label: string
required: boolean
type: 'text' | 'select'
options: string[]
mockValue: string
}
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]
}
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<string, number> = {
: 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()
@@ -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<string, number> = {
: 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
}
@@ -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]
}
@@ -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 {