拆分接单平台规则
This commit is contained in:
@@ -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 type { PoolClient } from 'pg'
|
||||||
|
|
||||||
import { query, withTransaction } from '../../db/client.js'
|
import { query, withTransaction } from '../../db/client.js'
|
||||||
|
import { resolveAfterSalesRecoveryAllocation } from './after-sales-recovery.js'
|
||||||
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
|
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient } from './shared.js'
|
||||||
import type {
|
import type {
|
||||||
WorkerAfterSalesCaseEventRow,
|
WorkerAfterSalesCaseEventRow,
|
||||||
@@ -426,8 +427,6 @@ async function recoverAfterSalesAmountWithClient(
|
|||||||
availableDeductionAmount: number
|
availableDeductionAmount: number
|
||||||
debtAmount: number
|
debtAmount: number
|
||||||
}> {
|
}> {
|
||||||
let remaining = Math.max(0, input.amount)
|
|
||||||
let pendingDepositDeductionAmount = 0
|
|
||||||
const pendingResult = await client.query<WorkerDepositUnfreezeRow>(
|
const pendingResult = await client.query<WorkerDepositUnfreezeRow>(
|
||||||
`
|
`
|
||||||
SELECT * FROM worker_deposit_unfreezes
|
SELECT * FROM worker_deposit_unfreezes
|
||||||
@@ -439,10 +438,19 @@ async function recoverAfterSalesAmountWithClient(
|
|||||||
)
|
)
|
||||||
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
await ensureWorkerWalletWithClient(client, input.workerId, input.now)
|
||||||
let wallet: WorkerWalletRow | null = await getWorkerWalletWithClient(client, input.workerId)
|
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) {
|
for (const row of pendingResult.rows) {
|
||||||
if (remaining <= 0) break
|
if (pendingRemaining <= 0) break
|
||||||
const rowAmount = Number(row.amount || 0)
|
const rowAmount = Math.max(0, Number(row.amount) || 0)
|
||||||
const deduction = Math.min(rowAmount, remaining)
|
const deduction = Math.min(rowAmount, pendingRemaining)
|
||||||
if (deduction <= 0) continue
|
if (deduction <= 0) continue
|
||||||
if (deduction >= rowAmount) {
|
if (deduction >= rowAmount) {
|
||||||
await client.query(
|
await client.query(
|
||||||
@@ -478,12 +486,11 @@ async function recoverAfterSalesAmountWithClient(
|
|||||||
input.now,
|
input.now,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
pendingDepositDeductionAmount += deduction
|
pendingRemaining -= deduction
|
||||||
remaining -= deduction
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wallet = await getWorkerWalletWithClient(client, input.workerId)
|
wallet = await getWorkerWalletWithClient(client, input.workerId)
|
||||||
const availableDeductionAmount = Math.min(remaining, Number(wallet?.available_amount || 0))
|
const availableDeductionAmount = allocation.availableDeductionAmount
|
||||||
if (availableDeductionAmount > 0) {
|
if (availableDeductionAmount > 0) {
|
||||||
const nextAvailable = Number(wallet?.available_amount || 0) - availableDeductionAmount
|
const nextAvailable = Number(wallet?.available_amount || 0) - availableDeductionAmount
|
||||||
await client.query(
|
await client.query(
|
||||||
@@ -507,19 +514,18 @@ async function recoverAfterSalesAmountWithClient(
|
|||||||
input.now,
|
input.now,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
remaining -= availableDeductionAmount
|
|
||||||
}
|
}
|
||||||
if (remaining > 0) {
|
if (allocation.debtAmount > 0) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO worker_after_sales_debts (
|
INSERT INTO worker_after_sales_debts (
|
||||||
case_id, worker_id, original_amount, outstanding_amount, status, created_at, updated_at
|
case_id, worker_id, original_amount, outstanding_amount, status, created_at, updated_at
|
||||||
) VALUES ($1, $2, $3, $3, 'open', $4, $4)
|
) 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(
|
async function createAfterSalesCaseEventWithClient(
|
||||||
|
|||||||
@@ -37,6 +37,23 @@ import {
|
|||||||
resolveVisibleDelaySeconds,
|
resolveVisibleDelaySeconds,
|
||||||
resolveWorkerPermissions,
|
resolveWorkerPermissions,
|
||||||
} from './worker-permission-utils.js'
|
} 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 {
|
export {
|
||||||
ensureWorkerAuthConfigured,
|
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(
|
export function resolveMatchingProductRule(
|
||||||
order: OrderRow,
|
order: OrderRow,
|
||||||
item: OrderItemRow,
|
item: OrderItemRow,
|
||||||
@@ -1208,110 +1073,6 @@ export function normalizeWorkProductRuleMatch(payload: JsonObject): JsonObject {
|
|||||||
return result
|
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) {
|
export function matchesOptionalText(ruleValue: unknown, sourceValue: unknown) {
|
||||||
const ruleText = String(ruleValue || '')
|
const ruleText = String(ruleValue || '')
|
||||||
.trim()
|
.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,
|
resolveWorkOrderSharingEnabled,
|
||||||
validateWorkerPassword,
|
validateWorkerPassword,
|
||||||
} from './index.js'
|
} 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 {
|
function buildOrderItemRow(): OrderItemRow {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user