押金解冻天数可配置与待解冻押金扣减,超时订单保留记录

This commit is contained in:
yml2213
2026-08-02 10:14:05 +08:00
parent 043dd3c128
commit 93df3d1c9b
20 changed files with 854 additions and 38 deletions
@@ -0,0 +1,22 @@
ALTER TABLE worker_wallets
ADD COLUMN pending_unfreeze_amount INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS worker_deposit_unfreezes (
id BIGSERIAL PRIMARY KEY,
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE,
work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
amount INTEGER NOT NULL,
unfreeze_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
released_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_worker_deposit_unfreezes_scan
ON worker_deposit_unfreezes(status, unfreeze_at);
CREATE INDEX idx_worker_deposit_unfreezes_worker
ON worker_deposit_unfreezes(worker_id, status);
COMMENT ON COLUMN worker_wallets.pending_unfreeze_amount IS '待解冻押金:验收通过后 3 天到账';
COMMENT ON TABLE worker_deposit_unfreezes IS '押金解冻计划:验收通过后进入待解冻,到期自动转入可用余额';
@@ -0,0 +1,7 @@
ALTER TABLE work_orders
ADD COLUMN last_assigned_worker_id BIGINT REFERENCES worker_users(id) ON DELETE SET NULL;
CREATE INDEX idx_work_orders_last_worker
ON work_orders(last_assigned_worker_id, status, id DESC);
COMMENT ON COLUMN work_orders.last_assigned_worker_id IS '最近一次接单的打手,超时/取消后保留,用于打手查看历史记录';
@@ -33,6 +33,7 @@ export type WorkerUserRow = {
level_permission_json?: string | Record<string, unknown>
available_amount?: number
frozen_deposit_amount?: number
pending_unfreeze_amount?: number
total_credited_amount?: number
total_settled_amount?: number
}
@@ -41,12 +42,25 @@ export type WorkerWalletRow = {
worker_id: number
available_amount: number
frozen_deposit_amount: number
pending_unfreeze_amount: number
total_credited_amount: number
total_settled_amount: number
created_at: string
updated_at: string
}
export type WorkerDepositUnfreezeRow = {
id: number
worker_id: number
work_order_id: number
amount: number
unfreeze_at: string
status: string
released_at: string | null
created_at: string
updated_at: string
}
export type WorkerWalletLedgerRow = {
id: number
worker_id: number
@@ -150,6 +164,7 @@ export type WorkOrderRow = {
required_deposit_amount: number
deposit_threshold_amount: number
assigned_worker_id: number | null
last_assigned_worker_id: number | null
material_json: string | Record<string, unknown>
requirement_json: string | Record<string, unknown>
acceptance_json: string | Record<string, unknown>
@@ -1,6 +1,6 @@
import { query, withTransaction } from '../../db/client.js'
import { ensureWorkerWalletWithClient, getWorkerWalletWithClient, toJsonString, toPositiveInteger } from './shared.js'
import type { CreateWorkOrderInput, GrabWorkOrderResult, ListInput, ProblemWorkOrderResolutionAction, ProductRuleListInput, WorkCategoryRow, WorkOrderRow, WorkOrderShareRow, WorkProductRuleRow } from './types.js'
import type { CreateWorkOrderInput, GrabWorkOrderResult, ListInput, ProblemWorkOrderResolutionAction, ProductRuleListInput, WorkCategoryRow, WorkerDepositUnfreezeRow, WorkOrderRow, WorkOrderShareRow, WorkProductRuleRow } from './types.js'
import type { PoolClient } from 'pg'
import { maybeUpgradeWorkerLevelWithClient } from './worker-repo.js'
@@ -772,6 +772,7 @@ export async function grabWorkOrder(input: {
SET
status = 'in_progress',
assigned_worker_id = $1,
last_assigned_worker_id = $1,
assigned_at = $2,
deadline_at = $3,
updated_at = $2
@@ -946,6 +947,7 @@ export async function countWorkerCancellationsSince(
export async function acceptWorkOrderAndSettle(input: {
workOrderId: number
unfreezeDays: number
now: string
}): Promise<{
order: WorkOrderRow | null
@@ -1009,8 +1011,11 @@ export async function acceptWorkOrderAndSettle(input: {
Number(wallet?.frozen_deposit_amount || 0),
)
const shareReward = Number(share.share_reward || 0)
const shouldDelayUnfreeze = input.unfreezeDays > 0
const nextAvailable =
Number(wallet?.available_amount || 0) + releaseAmount + shareReward
Number(wallet?.available_amount || 0) +
shareReward +
(shouldDelayUnfreeze ? 0 : releaseAmount)
const nextFrozen = Math.max(
0,
Number(wallet?.frozen_deposit_amount || 0) - releaseAmount,
@@ -1022,31 +1027,70 @@ export async function acceptWorkOrderAndSettle(input: {
SET
available_amount = $1,
frozen_deposit_amount = $2,
total_settled_amount = total_settled_amount + $3,
updated_at = $4
WHERE worker_id = $5
pending_unfreeze_amount = pending_unfreeze_amount + $3,
total_settled_amount = total_settled_amount + $4,
updated_at = $5
WHERE worker_id = $6
`,
[nextAvailable, nextFrozen, shareReward, input.now, shareWorkerId],
[
nextAvailable,
nextFrozen,
shouldDelayUnfreeze ? releaseAmount : 0,
shareReward,
input.now,
shareWorkerId,
],
)
if (releaseAmount > 0) {
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
related_work_order_id, note, payload_json, created_at
) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '拼单验收释放押金', $6::jsonb, $7)
`,
[
shareWorkerId,
releaseAmount,
nextAvailable,
nextFrozen,
input.workOrderId,
JSON.stringify({ workOrderId: input.workOrderId, shareId: Number(share.id) }),
input.now,
],
)
if (shouldDelayUnfreeze) {
await enqueueDepositUnfreezeWithClient(client, {
workerId: shareWorkerId,
workOrderId: input.workOrderId,
amount: releaseAmount,
unfreezeDays: input.unfreezeDays,
now: input.now,
})
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
related_work_order_id, note, payload_json, created_at
) VALUES ($1, 'deposit_pending_unfreeze', $2, $3, $4, $5, '拼单验收押金待解冻', $6::jsonb, $7)
`,
[
shareWorkerId,
releaseAmount,
nextAvailable,
nextFrozen,
input.workOrderId,
JSON.stringify({
workOrderId: input.workOrderId,
shareId: Number(share.id),
unfreezeDays: input.unfreezeDays,
}),
input.now,
],
)
} else {
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
related_work_order_id, note, payload_json, created_at
) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '拼单验收释放押金', $6::jsonb, $7)
`,
[
shareWorkerId,
releaseAmount,
nextAvailable,
nextFrozen,
input.workOrderId,
JSON.stringify({ workOrderId: input.workOrderId, shareId: Number(share.id) }),
input.now,
],
)
}
}
if (shareReward > 0) {
@@ -1095,7 +1139,11 @@ export async function acceptWorkOrderAndSettle(input: {
Number(wallet?.frozen_deposit_amount || 0),
)
const rewardAmount = Number(workOrder.reward_amount || 0)
const nextAvailable = Number(wallet?.available_amount || 0) + releaseAmount + rewardAmount
const shouldDelayUnfreeze = input.unfreezeDays > 0
const nextAvailable =
Number(wallet?.available_amount || 0) +
rewardAmount +
(shouldDelayUnfreeze ? 0 : releaseAmount)
const nextFrozen = Math.max(0, Number(wallet?.frozen_deposit_amount || 0) - releaseAmount)
await client.query(
@@ -1104,23 +1152,61 @@ export async function acceptWorkOrderAndSettle(input: {
SET
available_amount = $1,
frozen_deposit_amount = $2,
total_settled_amount = total_settled_amount + $3,
updated_at = $4
WHERE worker_id = $5
pending_unfreeze_amount = pending_unfreeze_amount + $3,
total_settled_amount = total_settled_amount + $4,
updated_at = $5
WHERE worker_id = $6
`,
[nextAvailable, nextFrozen, rewardAmount, input.now, workerId],
[
nextAvailable,
nextFrozen,
shouldDelayUnfreeze ? releaseAmount : 0,
rewardAmount,
input.now,
workerId,
],
)
if (releaseAmount > 0) {
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
related_work_order_id, note, payload_json, created_at
) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '验收通过释放押金', '{}'::jsonb, $6)
`,
[workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now],
)
if (shouldDelayUnfreeze) {
await enqueueDepositUnfreezeWithClient(client, {
workerId,
workOrderId: input.workOrderId,
amount: releaseAmount,
unfreezeDays: input.unfreezeDays,
now: input.now,
})
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
related_work_order_id, note, payload_json, created_at
) VALUES ($1, 'deposit_pending_unfreeze', $2, $3, $4, $5, '验收通过押金待解冻', $6::jsonb, $7)
`,
[
workerId,
releaseAmount,
nextAvailable,
nextFrozen,
input.workOrderId,
JSON.stringify({
workOrderId: input.workOrderId,
unfreezeDays: input.unfreezeDays,
}),
input.now,
],
)
} else {
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
related_work_order_id, note, payload_json, created_at
) VALUES ($1, 'deposit_release', $2, $3, $4, $5, '验收通过释放押金', '{}'::jsonb, $6)
`,
[workerId, releaseAmount, nextAvailable, nextFrozen, input.workOrderId, input.now],
)
}
}
if (rewardAmount > 0) {
@@ -1437,6 +1523,244 @@ export async function settleOverdueWorkOrder(input: {
})
}
export async function sumPendingUnfreezeByOrderIds(
workOrderIds: number[],
): Promise<Map<number, number>> {
const totals = new Map<number, number>()
const uniqueIds = [...new Set(workOrderIds.map(Number).filter((id) => id > 0))]
if (uniqueIds.length === 0) return totals
const result = await query<{ work_order_id: number; total: number }>(
`
SELECT work_order_id, SUM(amount)::int AS total
FROM worker_deposit_unfreezes
WHERE work_order_id = ANY($1::bigint[])
AND status = 'pending'
GROUP BY work_order_id
`,
[uniqueIds],
)
for (const row of result.rows) {
totals.set(Number(row.work_order_id), Number(row.total || 0))
}
return totals
}
export async function deductPendingDepositUnfreeze(input: {
workOrderId: number
amount: number
note: string
now: string
}): Promise<{
deductedAmount: number
failureReason: 'no_pending_deposit' | 'amount_exceeds_pending' | null
}> {
return withTransaction(async (client) => {
const pendingResult = await client.query<WorkerDepositUnfreezeRow>(
`
SELECT *
FROM worker_deposit_unfreezes
WHERE work_order_id = $1 AND status = 'pending'
ORDER BY unfreeze_at ASC
FOR UPDATE
`,
[input.workOrderId],
)
const pending = pendingResult.rows
if (pending.length === 0) {
return { deductedAmount: 0, failureReason: 'no_pending_deposit' }
}
const totalPending = pending.reduce((sum, row) => sum + Number(row.amount || 0), 0)
if (input.amount <= 0 || input.amount > totalPending) {
return { deductedAmount: 0, failureReason: 'amount_exceeds_pending' }
}
let remainingToDeduct = input.amount
let deductedAmount = 0
for (const row of pending) {
if (remainingToDeduct <= 0) break
const rowAmount = Number(row.amount || 0)
if (rowAmount <= 0) continue
const deductFromRow = Math.min(rowAmount, remainingToDeduct)
const isFullyDeducted = deductFromRow >= rowAmount
if (isFullyDeducted) {
await client.query(
`
UPDATE worker_deposit_unfreezes
SET status = 'deducted', updated_at = $1
WHERE id = $2
`,
[input.now, row.id],
)
} else {
await client.query(
`
UPDATE worker_deposit_unfreezes
SET amount = amount - $1, updated_at = $2
WHERE id = $3
`,
[deductFromRow, input.now, row.id],
)
}
const workerWallet = await getWorkerWalletWithClient(client, row.worker_id)
await client.query(
`
UPDATE worker_wallets
SET pending_unfreeze_amount = GREATEST(0, pending_unfreeze_amount - $1), updated_at = $2
WHERE worker_id = $3
`,
[deductFromRow, input.now, row.worker_id],
)
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
related_work_order_id, note, payload_json, created_at
) VALUES ($1, 'deposit_deduction', $2, $3, $4, $5, '问题单扣减待解冻押金', $6::jsonb, $7)
`,
[
row.worker_id,
-deductFromRow,
Number(workerWallet?.available_amount || 0),
Number(workerWallet?.frozen_deposit_amount || 0),
input.workOrderId,
JSON.stringify({
workOrderId: input.workOrderId,
unfreezeId: row.id,
note: input.note,
}),
input.now,
],
)
deductedAmount += deductFromRow
remainingToDeduct -= deductFromRow
}
return { deductedAmount, failureReason: null }
})
}
export async function listDueDepositUnfreezes({
limit = 100,
workerId = 0,
}: { limit?: number; workerId?: number } = {}): Promise<WorkerDepositUnfreezeRow[]> {
const params: unknown[] = []
let workerClause = ''
if (workerId > 0) {
params.push(workerId)
workerClause = `AND worker_id = $${params.length}`
}
params.push(limit)
const result = await query<WorkerDepositUnfreezeRow>(
`
SELECT *
FROM worker_deposit_unfreezes
WHERE status = 'pending'
AND unfreeze_at <= NOW()
${workerClause}
ORDER BY unfreeze_at ASC
LIMIT $${params.length}
`,
params,
)
return result.rows
}
export async function releaseDepositUnfreeze({
unfreezeId,
now,
}: { unfreezeId: number; now: string }): Promise<WorkerDepositUnfreezeRow | null> {
return withTransaction(async (client) => {
const currentResult = await client.query<WorkerDepositUnfreezeRow>(
`
SELECT *
FROM worker_deposit_unfreezes
WHERE id = $1 AND status = 'pending'
FOR UPDATE
`,
[unfreezeId],
)
const unfreeze = currentResult.rows[0] || null
if (!unfreeze) return null
await ensureWorkerWalletWithClient(client, unfreeze.worker_id, now)
const wallet = await getWorkerWalletWithClient(client, unfreeze.worker_id)
const amount = Math.min(
Number(unfreeze.amount || 0),
Number(wallet?.pending_unfreeze_amount || 0),
)
await client.query(
`
UPDATE worker_deposit_unfreezes
SET status = 'released', released_at = $1, updated_at = $1
WHERE id = $2
`,
[now, unfreeze.id],
)
if (amount > 0) {
const nextAvailable = Number(wallet?.available_amount || 0) + amount
const nextPending = Math.max(
0,
Number(wallet?.pending_unfreeze_amount || 0) - amount,
)
await client.query(
`
UPDATE worker_wallets
SET available_amount = $1, pending_unfreeze_amount = $2, updated_at = $3
WHERE worker_id = $4
`,
[nextAvailable, nextPending, now, unfreeze.worker_id],
)
await client.query(
`
INSERT INTO worker_wallet_ledgers (
worker_id, ledger_type, amount, balance_after, frozen_after,
related_work_order_id, note, payload_json, created_at
) VALUES ($1, 'deposit_unfreeze', $2, $3, $4, $5, '押金已解冻到账', $6::jsonb, $7)
`,
[
unfreeze.worker_id,
amount,
nextAvailable,
Number(wallet?.frozen_deposit_amount || 0),
unfreeze.work_order_id,
JSON.stringify({ unfreezeId: unfreeze.id }),
now,
],
)
}
return {
...unfreeze,
status: 'released',
released_at: now,
updated_at: now,
}
})
}
async function enqueueDepositUnfreezeWithClient(
client: PoolClient,
input: { workerId: number; workOrderId: number; amount: number; unfreezeDays: number; now: string },
) {
if (input.amount <= 0) return
const unfreezeAt = new Date(
new Date(input.now).getTime() + input.unfreezeDays * 86_400_000,
).toISOString()
await client.query(
`
INSERT INTO worker_deposit_unfreezes (
worker_id, work_order_id, amount, unfreeze_at, status, created_at, updated_at
) VALUES ($1, $2, $3, $4, 'pending', $5, $5)
`,
[input.workerId, input.workOrderId, input.amount, unfreezeAt, input.now],
)
}
export async function countWorkerActiveOrders(workerId: number | string): Promise<number> {
const result = await query<{ total: number }>(
`
@@ -1629,6 +1953,7 @@ function buildWorkOrderWhere({
params.push(workerSharingId)
filters.push(
`(wo.assigned_worker_id = $${params.length}
OR wo.last_assigned_worker_id = $${params.length}
OR wo.id IN (
SELECT work_order_id FROM work_order_shares
WHERE worker_id = $${params.length} AND status != 'cancelled'
@@ -14,6 +14,7 @@ const WORKER_USER_SELECT = `
inv.display_name AS inviter_display_name,
ww.available_amount,
ww.frozen_deposit_amount,
ww.pending_unfreeze_amount,
ww.total_credited_amount,
ww.total_settled_amount
FROM worker_users wu
@@ -409,6 +410,26 @@ export async function getWorkerFinanceRequestById(
return result.rows[0] || null
}
export async function countWorkerWithdrawRequestsOnDay(
workerId: number | string,
dayStartIso: string,
dayEndIso: string,
): Promise<number> {
const result = await query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM worker_finance_requests
WHERE worker_id = $1
AND request_type = 'withdraw'
AND status != 'cancelled'
AND created_at >= $2
AND created_at < $3
`,
[Number(workerId), dayStartIso, dayEndIso],
)
return Number(result.rows[0]?.total || 0)
}
export async function listWorkerFinanceRequests({
page = 1,
pageSize = 20,
@@ -4,6 +4,7 @@ import {
acceptAdminWorkOrder,
creditAdminWorkerWallet,
createAdminMockWorkOrder,
deductAdminWorkOrderPendingDeposit,
deleteAdminWorkCategory,
deleteAdminWorkOrder,
deleteAdminWorkerLevel,
@@ -471,4 +472,27 @@ router.post(
}),
)
router.post(
'/worker-platform/orders/:workOrderId/deduct-pending-deposit',
requireAdminRoles(['admin', 'operator']),
createJsonHandler(
(req) =>
deductAdminWorkOrderPendingDeposit(
String(req.params.workOrderId || ''),
req.body || {},
),
{
successMessage: '待解冻押金已扣减',
errorMessage: '扣减待解冻押金失败',
scope: '[admin/worker-platform/orders/:workOrderId/deduct-pending-deposit]',
audit: (req, data) => ({
action: 'work_order_pending_deposit_deducted',
targetType: 'work_order',
targetId: String(req.params.workOrderId || ''),
data: data && typeof data === 'object' ? (data as Record<string, unknown>) : {},
}),
},
),
)
export default router
@@ -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 } = {},
) {