押金解冻天数可配置与待解冻押金扣减,超时订单保留记录
This commit is contained in:
@@ -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 } = {},
|
||||
) {
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { asRecord, formatMoney, formatSharingShareStatus, resolveSharingShareStatusColor } from './shared'
|
||||
type FinanceConfigFormValues = {
|
||||
depositUnfreezeDays?: number
|
||||
recharge?: {
|
||||
enabled?: boolean
|
||||
channelName?: string
|
||||
@@ -168,6 +169,7 @@ export default function FinancePanel() {
|
||||
setSavingConfig(true)
|
||||
try {
|
||||
await saveAdminWorkerFinanceConfig({
|
||||
depositUnfreezeDays: Number(values.depositUnfreezeDays ?? 3),
|
||||
recharge: {
|
||||
enabled: values.recharge?.enabled !== false,
|
||||
channelName: String(values.recharge?.channelName || '').trim(),
|
||||
@@ -370,6 +372,19 @@ export default function FinancePanel() {
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card title="押金与提现规则" size="small" style={{ marginBottom: 16 }}>
|
||||
<Form.Item
|
||||
label="押金解冻天数"
|
||||
name="depositUnfreezeDays"
|
||||
tooltip="有押金的工单验收通过后,押金进入待解冻,N 天后自动转入可用余额;0 表示验收通过立即释放"
|
||||
>
|
||||
<InputNumber min={0} max={30} step={1} addonAfter="天" />
|
||||
</Form.Item>
|
||||
<Typography.Text type="secondary">
|
||||
解冻期间如订单出现问题,可在"接单工单"中对已验收工单扣减待解冻押金(全额或部分)。
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
|
||||
<Card title="提现入口" size="small" style={{ marginBottom: 16 }}>
|
||||
<Form.Item
|
||||
label="开启提现申请"
|
||||
@@ -528,6 +543,7 @@ function mapFinanceConfigToFormValues(
|
||||
config?: WorkerFinanceConfig,
|
||||
): FinanceConfigFormValues {
|
||||
return {
|
||||
depositUnfreezeDays: Number(config?.depositUnfreezeDays ?? 3),
|
||||
recharge: {
|
||||
enabled: config?.recharge.enabled !== false,
|
||||
channelName: config?.recharge.channelName || '',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
App,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
acceptAdminWorkOrder,
|
||||
createAdminMockWorkOrder,
|
||||
creditAdminWorkerWallet,
|
||||
deductAdminWorkOrderPendingDeposit,
|
||||
deleteAdminWorkCategory,
|
||||
deleteAdminWorkOrder,
|
||||
deleteAdminWorkerLevel,
|
||||
@@ -121,9 +123,11 @@ export default function WorkOrdersPanel() {
|
||||
const [materialScreenshots, setMaterialScreenshots] = useState<UploadedFile[]>([])
|
||||
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
|
||||
const [editOrder, setEditOrder] = useState<WorkOrder | null>(null)
|
||||
const [deductOrder, setDeductOrder] = useState<WorkOrder | null>(null)
|
||||
const [problemForm] = Form.useForm()
|
||||
const [resolutionForm] = Form.useForm()
|
||||
const [materialForm] = Form.useForm()
|
||||
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
|
||||
const [editForm] = Form.useForm<{
|
||||
productName?: string
|
||||
platformOrderId?: string
|
||||
@@ -329,6 +333,29 @@ export default function WorkOrdersPanel() {
|
||||
})
|
||||
}
|
||||
|
||||
function openDeductModal(row: WorkOrder) {
|
||||
setDeductOrder(row)
|
||||
deductForm.setFieldsValue({
|
||||
amount: Math.round(Number(row.pendingUnfreezeAmount || 0)) / 100,
|
||||
note: '',
|
||||
})
|
||||
}
|
||||
|
||||
async function submitDeduct(values: { amount?: number; note?: string }) {
|
||||
if (!deductOrder) return
|
||||
const succeeded = await runAction(
|
||||
() =>
|
||||
deductAdminWorkOrderPendingDeposit(deductOrder.workOrderId, {
|
||||
amount: Number(values.amount || 0),
|
||||
note: String(values.note || '').trim(),
|
||||
}),
|
||||
'待解冻押金已扣减',
|
||||
)
|
||||
if (!succeeded) return
|
||||
setDeductOrder(null)
|
||||
deductForm.resetFields()
|
||||
}
|
||||
|
||||
async function submitEdit(values: {
|
||||
productName?: string
|
||||
platformOrderId?: string
|
||||
@@ -573,6 +600,15 @@ export default function WorkOrdersPanel() {
|
||||
验收
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'accepted' && Number(row.pendingUnfreezeAmount || 0) > 0 ? (
|
||||
<Button
|
||||
icon={<WarningOutlined />}
|
||||
danger
|
||||
onClick={() => openDeductModal(row)}
|
||||
>
|
||||
扣押金
|
||||
</Button>
|
||||
) : null}
|
||||
{['in_progress', 'pending_acceptance'].includes(row.status) ? (
|
||||
<Button
|
||||
icon={<WarningOutlined />}
|
||||
@@ -948,6 +984,50 @@ export default function WorkOrdersPanel() {
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`扣减待解冻押金 · ${deductOrder?.workOrderNo || ''}`}
|
||||
open={Boolean(deductOrder)}
|
||||
onCancel={() => {
|
||||
setDeductOrder(null)
|
||||
deductForm.resetFields()
|
||||
}}
|
||||
onOk={() => deductForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
{deductOrder ? (
|
||||
<Form form={deductForm} layout="vertical" onFinish={submitDeduct}>
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message={`该工单当前待解冻押金 ${formatMoney(
|
||||
deductOrder.pendingUnfreezeAmount,
|
||||
)},扣减后剩余部分仍按原计划解冻。`}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form.Item
|
||||
label="扣减金额"
|
||||
name="amount"
|
||||
rules={[{ required: true, message: '请输入扣减金额' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
max={Math.round(Number(deductOrder.pendingUnfreezeAmount || 0)) / 100}
|
||||
step={1}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="扣减原因"
|
||||
name="note"
|
||||
rules={[{ required: true, message: '请填写扣减原因' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="如:订单出现问题,按规则扣除押金" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="标记问题单"
|
||||
open={Boolean(problemOrder)}
|
||||
|
||||
@@ -663,6 +663,72 @@ function ScheduledJobCard({
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (job.type === 'deposit_unfreeze' || job.id === 'deposit-unfreeze') {
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space wrap>
|
||||
<Switch
|
||||
checked={job.enabled !== false}
|
||||
onChange={(enabled) => onChange({ ...job, enabled })}
|
||||
/>
|
||||
<span>{formatScheduledJobTitle(job)}</span>
|
||||
<Tag color={job.enabled ? 'green' : 'default'}>
|
||||
{job.enabled ? '已启用' : '已停用'}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} loading={running} onClick={onRun}>
|
||||
立即解冻
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
将有押金工单验收通过后进入待解冻的押金,在到期后自动转入可用余额(验收后 3
|
||||
天到账)。
|
||||
</Typography.Paragraph>
|
||||
<div className="platform-form-grid">
|
||||
<NumberField
|
||||
label="执行间隔(秒)"
|
||||
value={job.intervalSeconds}
|
||||
min={1}
|
||||
onChange={(intervalSeconds) => onChange({ ...job, intervalSeconds })}
|
||||
/>
|
||||
<NumberField
|
||||
label="每次解冻数量"
|
||||
value={Number(job.config?.scanLimit ?? 100)}
|
||||
min={1}
|
||||
onChange={(scanLimit) =>
|
||||
onChange({ ...job, config: { ...job.config, scanLimit } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{runtime ? (
|
||||
<Alert
|
||||
className="platform-section-gap"
|
||||
type={
|
||||
runtime.lastStatus === 'ok' || runtime.lastStatus === 'success'
|
||||
? 'success'
|
||||
: runtime.lastStatus
|
||||
? 'warning'
|
||||
: 'info'
|
||||
}
|
||||
showIcon
|
||||
message={runtime.lastMessage || '尚未运行'}
|
||||
description={[
|
||||
`扫描 ${runtime.lastCheckedCount ?? 0}`,
|
||||
`解冻 ${runtime.lastAsset ?? 0}`,
|
||||
`上次:${formatAdminDateTime(runtime.lastFinishedAt || runtime.lastRunAt)}`,
|
||||
`下次:${formatAdminDateTime(runtime.nextRunAt)}`,
|
||||
].join(' · ')}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
const accounts = job.config?.accounts || []
|
||||
const defaultThreshold = Number(job.config?.assetThreshold ?? 500)
|
||||
|
||||
@@ -891,6 +957,9 @@ function formatScheduledJobTitle(job: AdminScheduledJobItem) {
|
||||
if (job.type === 'work_order_timeout' || job.id === 'work-order-timeout') {
|
||||
return '接单工单超时扫描'
|
||||
}
|
||||
if (job.type === 'deposit_unfreeze' || job.id === 'deposit-unfreeze') {
|
||||
return '押金到期解冻'
|
||||
}
|
||||
return job.id || job.type || '定时任务'
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import { formatDateTime } from '@/utils/date-time'
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部订单' },
|
||||
{ value: 'open', label: '已超时' },
|
||||
{ value: 'in_progress', label: '代练中' },
|
||||
{ value: 'pending_acceptance', label: '待验收' },
|
||||
{ value: 'problem', label: '问题单' },
|
||||
@@ -52,6 +53,7 @@ const STATUS_SUMMARY_ITEMS = [
|
||||
{ key: 'in_progress', label: '代练中', note: '正在执行中的订单' },
|
||||
{ key: 'pending_acceptance', label: '待验收', note: '已提交,等待审核' },
|
||||
{ key: 'problem', label: '问题单', note: '需要补充或重新处理' },
|
||||
{ key: 'open', label: '已超时', note: '超时被系统退回的订单数' },
|
||||
{ key: 'accepted', label: '已验收', note: '已完成并结算的订单' },
|
||||
{ key: 'cancelled', label: '已取消', note: '已结束且不再继续' },
|
||||
] as const
|
||||
@@ -730,6 +732,7 @@ function formatMoney(value: number | undefined) {
|
||||
|
||||
function formatStatus(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
open: '已超时',
|
||||
in_progress: '代练中',
|
||||
pending_acceptance: '待验收',
|
||||
problem: '问题单',
|
||||
@@ -741,6 +744,7 @@ function formatStatus(status: string) {
|
||||
|
||||
function getStatusHint(status: string) {
|
||||
const hints: Record<string, string> = {
|
||||
open: '任务已超时被系统判定失败,如需继续可前往大厅重新抢单。',
|
||||
in_progress: '订单进行中,完成后请及时提交验收资料。',
|
||||
pending_acceptance: '已提交验收,等待后台审核。',
|
||||
problem: '后台已标记问题,请根据备注调整后重新提交。',
|
||||
@@ -755,6 +759,7 @@ function resolveStatusColor(status: string) {
|
||||
if (status === 'cancelled') return 'default'
|
||||
if (status === 'problem') return 'red'
|
||||
if (status === 'pending_acceptance') return 'gold'
|
||||
if (status === 'open') return 'red'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
|
||||
@@ -653,6 +653,8 @@ export default function WorkerProfilePage() {
|
||||
{ value: 'withdraw_paid', label: '提现打款' },
|
||||
{ value: 'deposit_freeze', label: '冻结押金' },
|
||||
{ value: 'deposit_release', label: '释放押金' },
|
||||
{ value: 'deposit_pending_unfreeze', label: '押金待解冻' },
|
||||
{ value: 'deposit_unfreeze', label: '押金已解冻' },
|
||||
{ value: 'deposit_deduction', label: '扣除押金' },
|
||||
{ value: 'reward_settlement', label: '结算报酬' },
|
||||
]}
|
||||
@@ -904,6 +906,14 @@ export default function WorkerProfilePage() {
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message={`每天限提现 1 次;有押金的工单验收通过后,押金需 ${Number(
|
||||
financeConfig?.depositUnfreezeDays ?? 3,
|
||||
)} 天解冻到账后方可提现。`}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form.Item label="可提现余额">
|
||||
<Typography.Text strong>
|
||||
{formatMoney(resolveAvailableForWithdraw(worker, summary))}
|
||||
@@ -1057,7 +1067,14 @@ function buildMetricCards(worker: WorkerUser, summary?: WorkerProfileSummary): M
|
||||
{
|
||||
label: '冻结押金',
|
||||
value: toYuan(worker.wallet.frozenDepositAmount),
|
||||
note: '抢单后冻结,验收通过会自动释放',
|
||||
note: '抢单后冻结,验收通过后 3 天自动解冻',
|
||||
prefix: '¥',
|
||||
precision: 2,
|
||||
},
|
||||
{
|
||||
label: '待解冻押金',
|
||||
value: toYuan(worker.wallet.pendingUnfreezeAmount),
|
||||
note: '验收通过后待 3 天解冻到账的押金',
|
||||
prefix: '¥',
|
||||
precision: 2,
|
||||
},
|
||||
@@ -1146,6 +1163,8 @@ function formatLedgerType(ledgerType: string) {
|
||||
withdraw_paid: '提现打款',
|
||||
deposit_freeze: '冻结押金',
|
||||
deposit_release: '释放押金',
|
||||
deposit_pending_unfreeze: '押金待解冻',
|
||||
deposit_unfreeze: '押金已解冻',
|
||||
deposit_deduction: '扣除押金',
|
||||
reward_settlement: '结算报酬',
|
||||
}
|
||||
@@ -1157,6 +1176,8 @@ function resolveLedgerTagColor(ledgerType: string) {
|
||||
if (ledgerType === 'withdraw_paid') return 'purple'
|
||||
if (ledgerType === 'deposit_freeze') return 'gold'
|
||||
if (ledgerType === 'deposit_release') return 'green'
|
||||
if (ledgerType === 'deposit_pending_unfreeze') return 'orange'
|
||||
if (ledgerType === 'deposit_unfreeze') return 'green'
|
||||
if (ledgerType === 'deposit_deduction') return 'red'
|
||||
if (ledgerType === 'reward_settlement') return 'cyan'
|
||||
return 'default'
|
||||
|
||||
@@ -140,6 +140,7 @@ export function fetchAdminWorkerFinanceConfig() {
|
||||
}
|
||||
|
||||
export function saveAdminWorkerFinanceConfig(payload: {
|
||||
depositUnfreezeDays?: number
|
||||
recharge?: {
|
||||
enabled?: boolean
|
||||
channelName?: string
|
||||
@@ -298,3 +299,13 @@ export function acceptAdminWorkOrder(workOrderId: number) {
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/accept`,
|
||||
)
|
||||
}
|
||||
|
||||
export function deductAdminWorkOrderPendingDeposit(
|
||||
workOrderId: number,
|
||||
payload: { amount: number; note: string },
|
||||
) {
|
||||
return apiPost<{ deductedAmount: number }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/deduct-pending-deposit`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export type WorkerUser = {
|
||||
wallet: {
|
||||
availableAmount: number
|
||||
frozenDepositAmount: number
|
||||
pendingUnfreezeAmount: number
|
||||
totalCreditedAmount: number
|
||||
totalSettledAmount: number
|
||||
}
|
||||
@@ -84,6 +85,7 @@ export type WorkerFinanceRequest = {
|
||||
}
|
||||
|
||||
export type WorkerFinanceConfig = {
|
||||
depositUnfreezeDays: number
|
||||
recharge: {
|
||||
enabled: boolean
|
||||
channelName: string
|
||||
@@ -225,6 +227,7 @@ export type WorkOrder = {
|
||||
joinedQuantity: number
|
||||
pendingSubmissionCount: number
|
||||
}
|
||||
pendingUnfreezeAmount?: number
|
||||
timeoutMinutes?: number
|
||||
timeoutPolicy?: 'reopen' | 'cancel_release' | 'cancel_deduct' | string
|
||||
deadlineAt?: string | null
|
||||
|
||||
Reference in New Issue
Block a user