押金解冻天数可配置与待解冻押金扣减,超时订单保留记录
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs.json')
|
||||
const CLOUDTENTACLES_HEALTH_JOB_ID = 'cloudtentacles-health'
|
||||
const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout'
|
||||
const DEPOSIT_UNFREEZE_JOB_ID = 'deposit-unfreeze'
|
||||
|
||||
export function getScheduledJobsFilePath() {
|
||||
return SCHEDULED_JOBS_FILE_PATH
|
||||
@@ -46,6 +47,12 @@ export function getWorkOrderTimeoutJob(config: JsonObject = getScheduledJobsConf
|
||||
|| createDefaultWorkOrderTimeoutJob()
|
||||
}
|
||||
|
||||
export function getDepositUnfreezeJob(config: JsonObject = getScheduledJobsConfig()) {
|
||||
return (Array.isArray(config.jobs) ? config.jobs : [])
|
||||
.find((item) => String(item.id || '').trim() === DEPOSIT_UNFREEZE_JOB_ID)
|
||||
|| createDefaultDepositUnfreezeJob()
|
||||
}
|
||||
|
||||
export function getScheduledJobById(jobId: unknown, config: JsonObject = getScheduledJobsConfig()) {
|
||||
return (Array.isArray(config.jobs) ? config.jobs : [])
|
||||
.find((item) => String(item.id || '').trim() === String(jobId || '').trim()) || null
|
||||
@@ -59,6 +66,7 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
|
||||
.filter((item): item is NonNullable<ReturnType<typeof normalizeScheduledJob>> => Boolean(item))
|
||||
const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID)
|
||||
const hasWorkOrderTimeout = jobs.some((item) => item.id === WORK_ORDER_TIMEOUT_JOB_ID)
|
||||
const hasDepositUnfreeze = jobs.some((item) => item.id === DEPOSIT_UNFREEZE_JOB_ID)
|
||||
|
||||
if (!hasCloudtentaclesHealth) {
|
||||
jobs.push(createDefaultCloudtentaclesHealthJob())
|
||||
@@ -66,6 +74,9 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) {
|
||||
if (!hasWorkOrderTimeout) {
|
||||
jobs.push(createDefaultWorkOrderTimeoutJob())
|
||||
}
|
||||
if (!hasDepositUnfreeze) {
|
||||
jobs.push(createDefaultDepositUnfreezeJob())
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
|
||||
@@ -85,10 +96,27 @@ function normalizeScheduledJob(rawValue: unknown) {
|
||||
if (type === 'work_order_timeout') {
|
||||
return normalizeWorkOrderTimeoutJob(rawValue)
|
||||
}
|
||||
if (type === 'deposit_unfreeze') {
|
||||
return normalizeDepositUnfreezeJob(rawValue)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeDepositUnfreezeJob(rawValue: JsonObject) {
|
||||
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
||||
|
||||
return {
|
||||
id: DEPOSIT_UNFREEZE_JOB_ID,
|
||||
type: 'deposit_unfreeze',
|
||||
enabled: rawValue.enabled !== false,
|
||||
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600),
|
||||
config: {
|
||||
scanLimit: normalizeRangeInteger(config.scanLimit, 100, 1, 2000),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWorkOrderTimeoutJob(rawValue: JsonObject) {
|
||||
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
|
||||
|
||||
@@ -171,10 +199,23 @@ function createDefaultScheduledJobsConfig() {
|
||||
jobs: [
|
||||
createDefaultCloudtentaclesHealthJob(),
|
||||
createDefaultWorkOrderTimeoutJob(),
|
||||
createDefaultDepositUnfreezeJob(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultDepositUnfreezeJob() {
|
||||
return {
|
||||
id: DEPOSIT_UNFREEZE_JOB_ID,
|
||||
type: 'deposit_unfreeze',
|
||||
enabled: true,
|
||||
intervalSeconds: 60,
|
||||
config: {
|
||||
scanLimit: 100,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultWorkOrderTimeoutJob() {
|
||||
return {
|
||||
id: WORK_ORDER_TIMEOUT_JOB_ID,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { JsonObject } from '../../types/json.js'
|
||||
import { logInfo } from '../../utils/logger.js'
|
||||
import { settleDueDepositUnfreezes } from '../worker-platform/worker-service.js'
|
||||
|
||||
export async function runDepositUnfreezeJob(job: JsonObject) {
|
||||
const config =
|
||||
job.config && typeof job.config === 'object' && !Array.isArray(job.config)
|
||||
? (job.config as JsonObject)
|
||||
: {}
|
||||
const scanLimit = Math.max(1, Number(config.scanLimit || 100))
|
||||
|
||||
const result = await settleDueDepositUnfreezes({ limit: scanLimit })
|
||||
|
||||
logInfo('[deposit-unfreeze]', '押金解冻扫描完成', result)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 'ok',
|
||||
message: `扫描 ${result.checkedCount} 笔,解冻 ${result.processedCount} 笔`,
|
||||
checkedCount: result.checkedCount,
|
||||
failedCount: 0,
|
||||
asset: result.processedCount,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getScheduledJobsConfig,
|
||||
} from './config-service.js'
|
||||
import { runCloudtentaclesHealthJob } from './cloudtentacles-health-job.js'
|
||||
import { runDepositUnfreezeJob } from './deposit-unfreeze-job.js'
|
||||
import { runWorkOrderTimeoutJob } from './work-order-timeout-job.js'
|
||||
|
||||
const timers = new Map<string, NodeJS.Timeout>()
|
||||
@@ -182,6 +183,9 @@ function dispatchJob(job: JsonObject) {
|
||||
if (job.type === 'work_order_timeout') {
|
||||
return runWorkOrderTimeoutJob(job)
|
||||
}
|
||||
if (job.type === 'deposit_unfreeze') {
|
||||
return runDepositUnfreezeJob(job)
|
||||
}
|
||||
|
||||
throw createHttpError(`不支持的定时任务类型:${job.type}`, {
|
||||
statusCode: 400,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
createWorkOrderEvent,
|
||||
createWorkerFinanceRequest,
|
||||
createWorkerUser,
|
||||
deductPendingDepositUnfreeze,
|
||||
deleteWorkCategory,
|
||||
deleteWorkOrder,
|
||||
deleteWorkerLevel,
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
listWorkerUsers,
|
||||
listWorkOrderShares,
|
||||
listWorkOrderSharesByOrderIds,
|
||||
sumPendingUnfreezeByOrderIds,
|
||||
resolveProblemWorkOrder,
|
||||
reviewWorkerFinanceRequest,
|
||||
submitWorkOrderShareAcceptance,
|
||||
@@ -456,6 +458,9 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
bucket.push(share)
|
||||
sharesByOrderId.set(orderId, bucket)
|
||||
}
|
||||
const pendingUnfreezeByOrderId = await sumPendingUnfreezeByOrderIds(
|
||||
items.map((item) => Number(item.id)),
|
||||
)
|
||||
return {
|
||||
items: items.map((item) => {
|
||||
const orderShares = sharesByOrderId.get(Number(item.id)) || []
|
||||
@@ -467,6 +472,7 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
).length
|
||||
return {
|
||||
...mapWorkOrderAdmin(item),
|
||||
pendingUnfreezeAmount: pendingUnfreezeByOrderId.get(Number(item.id)) || 0,
|
||||
sharingProgress: {
|
||||
joinedQuantity,
|
||||
pendingSubmissionCount,
|
||||
@@ -477,6 +483,62 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deductAdminWorkOrderPendingDeposit(
|
||||
workOrderId: number | string,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (workOrder.status !== WORK_ORDER_STATUS.ACCEPTED) {
|
||||
throw createHttpError('只有已验收的工单可以扣减待解冻押金', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_deduct_pending_status_invalid',
|
||||
})
|
||||
}
|
||||
const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0)
|
||||
const note = String(payload.note || payload.reason || '').trim()
|
||||
if (amount <= 0) {
|
||||
throw createHttpError('扣减金额必须大于 0', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_deduct_pending_amount_invalid',
|
||||
})
|
||||
}
|
||||
if (!note) {
|
||||
throw createHttpError('请填写扣减原因', {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_deduct_pending_note_required',
|
||||
})
|
||||
}
|
||||
const result = await deductPendingDepositUnfreeze({
|
||||
workOrderId: workOrder.id,
|
||||
amount,
|
||||
note,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (result.failureReason === 'no_pending_deposit') {
|
||||
throw createHttpError('该工单没有待解冻的押金', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_deduct_pending_none',
|
||||
})
|
||||
}
|
||||
if (result.failureReason === 'amount_exceeds_pending') {
|
||||
throw createHttpError('扣减金额超过该工单待解冻押金总额', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_deduct_pending_amount_exceeds',
|
||||
})
|
||||
}
|
||||
await createWorkOrderEvent({
|
||||
workOrderId: workOrder.id,
|
||||
actorType: 'admin',
|
||||
actorId: '',
|
||||
eventType: 'pending_deposit_deducted',
|
||||
fromStatus: workOrder.status,
|
||||
toStatus: workOrder.status,
|
||||
payloadJson: JSON.stringify({ amount, note }),
|
||||
now: nowIso(),
|
||||
})
|
||||
return { deductedAmount: result.deductedAmount }
|
||||
}
|
||||
|
||||
export async function getAdminWorkOrderSharing(workOrderId: number | string) {
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
const shares = await listWorkOrderShares(workOrder.id)
|
||||
@@ -937,6 +999,7 @@ export async function acceptAdminWorkOrder(workOrderId: number | string) {
|
||||
const now = nowIso()
|
||||
const { order: updated, failureReason } = await acceptWorkOrderAndSettle({
|
||||
workOrderId: workOrder.id,
|
||||
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
|
||||
now,
|
||||
})
|
||||
if (failureReason === 'sharing_pending_submissions') {
|
||||
|
||||
@@ -285,6 +285,7 @@ export function mapWorkerUser(worker: WorkerUserRow) {
|
||||
wallet: {
|
||||
availableAmount: Number(worker.available_amount || 0),
|
||||
frozenDepositAmount: Number(worker.frozen_deposit_amount || 0),
|
||||
pendingUnfreezeAmount: Number(worker.pending_unfreeze_amount || 0),
|
||||
totalCreditedAmount: Number(worker.total_credited_amount || 0),
|
||||
totalSettledAmount: Number(worker.total_settled_amount || 0),
|
||||
},
|
||||
@@ -452,6 +453,7 @@ export function mapWallet(wallet: Awaited<ReturnType<typeof addWorkerWalletCredi
|
||||
workerId: Number(wallet?.worker_id || 0),
|
||||
availableAmount: Number(wallet?.available_amount || 0),
|
||||
frozenDepositAmount: Number(wallet?.frozen_deposit_amount || 0),
|
||||
pendingUnfreezeAmount: Number(wallet?.pending_unfreeze_amount || 0),
|
||||
totalCreditedAmount: Number(wallet?.total_credited_amount || 0),
|
||||
totalSettledAmount: Number(wallet?.total_settled_amount || 0),
|
||||
}
|
||||
@@ -759,6 +761,8 @@ export function normalizeWalletLedgerType(value: unknown) {
|
||||
'withdraw_paid',
|
||||
'deposit_freeze',
|
||||
'deposit_release',
|
||||
'deposit_pending_unfreeze',
|
||||
'deposit_unfreeze',
|
||||
'deposit_deduction',
|
||||
'reward_settlement',
|
||||
].includes(ledgerType)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../file-storage/file-storage-service.js'
|
||||
|
||||
export type WorkerFinanceConfig = {
|
||||
depositUnfreezeDays: number
|
||||
recharge: {
|
||||
enabled: boolean
|
||||
channelName: string
|
||||
@@ -46,6 +47,7 @@ export function normalizeWorkerFinanceConfig(rawValue: unknown): WorkerFinanceCo
|
||||
const withdraw = isPlainObject(source.withdraw) ? source.withdraw : {}
|
||||
|
||||
return {
|
||||
depositUnfreezeDays: normalizeRangeInteger(source.depositUnfreezeDays, 3, 0, 30),
|
||||
recharge: {
|
||||
enabled: typeof recharge.enabled === 'boolean' ? recharge.enabled : true,
|
||||
channelName: String(recharge.channelName || recharge.channel_name || '').trim(),
|
||||
@@ -65,6 +67,7 @@ export function normalizeWorkerFinanceConfig(rawValue: unknown): WorkerFinanceCo
|
||||
|
||||
export function createDefaultWorkerFinanceConfig(): WorkerFinanceConfig {
|
||||
return {
|
||||
depositUnfreezeDays: 3,
|
||||
recharge: {
|
||||
enabled: true,
|
||||
channelName: '',
|
||||
@@ -82,6 +85,7 @@ export function createDefaultWorkerFinanceConfig(): WorkerFinanceConfig {
|
||||
|
||||
function refreshWorkerFinanceConfig(config: WorkerFinanceConfig): WorkerFinanceConfig {
|
||||
return {
|
||||
depositUnfreezeDays: config.depositUnfreezeDays,
|
||||
recharge: {
|
||||
...config.recharge,
|
||||
qrCodeImage: config.recharge.qrCodeImage
|
||||
@@ -94,6 +98,14 @@ function refreshWorkerFinanceConfig(config: WorkerFinanceConfig): WorkerFinanceC
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRangeInteger(value: unknown, fallback: number, min: number, max: number) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed)) {
|
||||
return fallback
|
||||
}
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
|
||||
function normalizeUploadedFileForStorage(value: unknown): UploadedFileDto | null {
|
||||
if (!isPlainObject(value)) {
|
||||
return null
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
countWorkerAcceptedOrders,
|
||||
countWorkerActiveOrders,
|
||||
countWorkerTimeoutEvents,
|
||||
countWorkerWithdrawRequestsOnDay,
|
||||
countWorkCategoryUsages,
|
||||
countWorkerLevelUsages,
|
||||
countWorkOrderPendingSharingSubmissions,
|
||||
@@ -48,7 +49,9 @@ import {
|
||||
listWorkerUsers,
|
||||
listWorkOrderShares,
|
||||
listWorkOrderSharesByOrderIds,
|
||||
listDueDepositUnfreezes,
|
||||
listOverdueWorkOrders,
|
||||
releaseDepositUnfreeze,
|
||||
resolveProblemWorkOrder,
|
||||
reviewWorkerFinanceRequest,
|
||||
settleOverdueWorkOrder,
|
||||
@@ -521,6 +524,20 @@ export async function createWorkerWithdrawRequest(
|
||||
errorCode: 'worker_withdraw_disabled',
|
||||
})
|
||||
}
|
||||
|
||||
await settleDueDepositUnfreezes({ workerId: worker.id, limit: 50 })
|
||||
const withdrawCountToday = await countWorkerWithdrawRequestsOnDay(
|
||||
worker.id,
|
||||
resolveChinaDayRange().start,
|
||||
resolveChinaDayRange().end,
|
||||
)
|
||||
if (withdrawCountToday > 0) {
|
||||
throw createHttpError('每天限提现 1 次,今天已提交过提现申请', {
|
||||
statusCode: 409,
|
||||
errorCode: 'worker_withdraw_daily_limit_reached',
|
||||
})
|
||||
}
|
||||
|
||||
const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0)
|
||||
if (amount <= 0) {
|
||||
throw createHttpError('提现金额必须大于 0', {
|
||||
@@ -743,6 +760,38 @@ export function normalizeWorkOrderTimeoutPolicy(value: unknown): WorkOrderTimeou
|
||||
return isWorkOrderTimeoutPolicy(value) ? value : 'reopen'
|
||||
}
|
||||
|
||||
export async function settleDueDepositUnfreezes(
|
||||
options: { limit?: number; workerId?: number } = {},
|
||||
) {
|
||||
const due = await listDueDepositUnfreezes({
|
||||
limit: Math.max(1, Number(options.limit || 100)),
|
||||
workerId: Number(options.workerId || 0),
|
||||
})
|
||||
let processedCount = 0
|
||||
for (const unfreeze of due) {
|
||||
const released = await releaseDepositUnfreeze({
|
||||
unfreezeId: unfreeze.id,
|
||||
now: nowIso(),
|
||||
})
|
||||
if (released) processedCount += 1
|
||||
}
|
||||
return {
|
||||
checkedCount: due.length,
|
||||
processedCount,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveChinaDayRange(): { start: string; end: string } {
|
||||
const utcNow = new Date()
|
||||
const utcDayStartMs =
|
||||
Date.UTC(utcNow.getUTCFullYear(), utcNow.getUTCMonth(), utcNow.getUTCDate()) -
|
||||
8 * 3_600_000
|
||||
return {
|
||||
start: new Date(utcDayStartMs).toISOString(),
|
||||
end: new Date(utcDayStartMs + 86_400_000).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function settleOverdueWorkOrders(
|
||||
options: { limit?: number; workerId?: number } = {},
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user