接单工单支持任务时限,超时自动判定失败并处置

This commit is contained in:
yml2213
2026-08-01 19:02:57 +08:00
parent 66193c5d59
commit 23ad60a1ef
18 changed files with 681 additions and 61 deletions
@@ -0,0 +1,18 @@
ALTER TABLE work_orders
ADD COLUMN deadline_at TIMESTAMPTZ,
ADD COLUMN timeout_minutes INTEGER NOT NULL DEFAULT 0,
ADD COLUMN timeout_policy TEXT NOT NULL DEFAULT 'reopen';
CREATE INDEX idx_work_orders_deadline
ON work_orders(status, deadline_at)
WHERE deadline_at IS NOT NULL;
ALTER TABLE work_product_rules
ADD COLUMN timeout_minutes INTEGER NOT NULL DEFAULT 0,
ADD COLUMN timeout_policy TEXT NOT NULL DEFAULT 'reopen';
COMMENT ON COLUMN work_orders.deadline_at IS '代练超时截止时间,打手抢单后按时限计算';
COMMENT ON COLUMN work_orders.timeout_minutes IS '任务时限(分钟),0 表示不限时';
COMMENT ON COLUMN work_orders.timeout_policy IS '超时处置策略:reopen 退押金回大厅 / cancel_release 取消退押金 / cancel_deduct 取消扣押金';
COMMENT ON COLUMN work_product_rules.timeout_minutes IS '规则默认任务时限(分钟),0 表示不限时';
COMMENT ON COLUMN work_product_rules.timeout_policy IS '规则默认超时处置策略';
@@ -109,6 +109,8 @@ export type WorkProductRuleRow = {
sharing_enabled: boolean sharing_enabled: boolean
sharing_total_quantity: number sharing_total_quantity: number
sharing_unit_reward: number sharing_unit_reward: number
timeout_minutes: number
timeout_policy: string
requirement_json: string | Record<string, unknown> requirement_json: string | Record<string, unknown>
sort_order: number sort_order: number
created_at: string created_at: string
@@ -159,6 +161,9 @@ export type WorkOrderRow = {
assigned_at: string | null assigned_at: string | null
submitted_at: string | null submitted_at: string | null
accepted_at: string | null accepted_at: string | null
deadline_at: string | null
timeout_minutes: number
timeout_policy: string
created_at: string created_at: string
updated_at: string updated_at: string
category_name?: string category_name?: string
@@ -241,6 +246,8 @@ export type CreateWorkOrderInput = {
sharingEnabled?: boolean sharingEnabled?: boolean
sharingTotalQuantity?: number sharingTotalQuantity?: number
sharingUnitReward?: number sharingUnitReward?: number
timeoutMinutes?: number
timeoutPolicy?: string
materialJson: string materialJson: string
requirementJson: string requirementJson: string
now: string now: string
@@ -408,6 +408,8 @@ export async function upsertWorkProductRule(input: {
sharingEnabled?: boolean sharingEnabled?: boolean
sharingTotalQuantity?: number sharingTotalQuantity?: number
sharingUnitReward?: number sharingUnitReward?: number
timeoutMinutes?: number
timeoutPolicy?: string
requirementJson: string requirementJson: string
sortOrder: number sortOrder: number
now: string now: string
@@ -419,6 +421,7 @@ export async function upsertWorkProductRule(input: {
match_type, category_id, enabled, auto_create, reward_amount, match_type, category_id, enabled, auto_create, reward_amount,
required_deposit_amount, deposit_threshold_amount, required_deposit_amount, deposit_threshold_amount,
sharing_enabled, sharing_total_quantity, sharing_unit_reward, sharing_enabled, sharing_total_quantity, sharing_unit_reward,
timeout_minutes, timeout_policy,
requirement_json, requirement_json,
sort_order, created_at, updated_at sort_order, created_at, updated_at
) VALUES ( ) VALUES (
@@ -426,8 +429,9 @@ export async function upsertWorkProductRule(input: {
$7, $8, $9, $10, $11, $7, $8, $9, $10, $11,
$12, $13, $12, $13,
$14, $15, $16, $14, $15, $16,
$17::jsonb, $17, $18,
$18, $19, $20 $19::jsonb,
$20, $21, $22
) )
ON CONFLICT (rule_key) DO UPDATE ON CONFLICT (rule_key) DO UPDATE
SET SET
@@ -446,6 +450,8 @@ export async function upsertWorkProductRule(input: {
sharing_enabled = EXCLUDED.sharing_enabled, sharing_enabled = EXCLUDED.sharing_enabled,
sharing_total_quantity = EXCLUDED.sharing_total_quantity, sharing_total_quantity = EXCLUDED.sharing_total_quantity,
sharing_unit_reward = EXCLUDED.sharing_unit_reward, sharing_unit_reward = EXCLUDED.sharing_unit_reward,
timeout_minutes = EXCLUDED.timeout_minutes,
timeout_policy = EXCLUDED.timeout_policy,
requirement_json = EXCLUDED.requirement_json, requirement_json = EXCLUDED.requirement_json,
sort_order = EXCLUDED.sort_order, sort_order = EXCLUDED.sort_order,
updated_at = EXCLUDED.updated_at updated_at = EXCLUDED.updated_at
@@ -468,6 +474,8 @@ export async function upsertWorkProductRule(input: {
input.sharingEnabled === true, input.sharingEnabled === true,
toPositiveInteger(input.sharingTotalQuantity, 1), toPositiveInteger(input.sharingTotalQuantity, 1),
toPositiveInteger(input.sharingUnitReward, 0), toPositiveInteger(input.sharingUnitReward, 0),
toPositiveInteger(input.timeoutMinutes, 0),
String(input.timeoutPolicy || 'reopen').trim(),
input.requirementJson, input.requirementJson,
input.sortOrder, input.sortOrder,
input.now, input.now,
@@ -484,10 +492,11 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
work_order_no, order_id, order_item_id, task_id, platform_order_id, work_order_no, order_id, order_item_id, task_id, platform_order_id,
product_name, category_id, status, reward_amount, required_deposit_amount, product_name, category_id, status, reward_amount, required_deposit_amount,
deposit_threshold_amount, sharing_enabled, sharing_total_quantity, deposit_threshold_amount, sharing_enabled, sharing_total_quantity,
sharing_unit_reward, material_json, requirement_json, created_at, updated_at sharing_unit_reward, timeout_minutes, timeout_policy,
material_json, requirement_json, created_at, updated_at
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15::jsonb, $16::jsonb, $17, $18 $11, $12, $13, $14, $15, $16, $17::jsonb, $18::jsonb, $19, $20
) )
RETURNING id RETURNING id
`, `,
@@ -506,6 +515,8 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise<Work
input.sharingEnabled === true, input.sharingEnabled === true,
toPositiveInteger(input.sharingTotalQuantity, 1), toPositiveInteger(input.sharingTotalQuantity, 1),
toPositiveInteger(input.sharingUnitReward, 0), toPositiveInteger(input.sharingUnitReward, 0),
toPositiveInteger(input.timeoutMinutes, 0),
String(input.timeoutPolicy || 'reopen').trim() || 'reopen',
input.materialJson, input.materialJson,
input.requirementJson, input.requirementJson,
input.now, input.now,
@@ -671,6 +682,8 @@ export async function updateWorkOrderBasic(
requiredDepositAmount?: number requiredDepositAmount?: number
depositThresholdAmount?: number depositThresholdAmount?: number
requirementJson?: string requirementJson?: string
timeoutMinutes?: number
timeoutPolicy?: string
updatedAt: string updatedAt: string
}, },
): Promise<WorkOrderRow | null> { ): Promise<WorkOrderRow | null> {
@@ -687,8 +700,10 @@ export async function updateWorkOrderBasic(
required_deposit_amount = $5, required_deposit_amount = $5,
deposit_threshold_amount = $6, deposit_threshold_amount = $6,
requirement_json = $7::jsonb, requirement_json = $7::jsonb,
updated_at = $8 timeout_minutes = $8,
WHERE id = $9 timeout_policy = $9,
updated_at = $10
WHERE id = $11
`, `,
[ [
String((patch.platformOrderId ?? current.platform_order_id) || '').trim(), String((patch.platformOrderId ?? current.platform_order_id) || '').trim(),
@@ -704,6 +719,8 @@ export async function updateWorkOrderBasic(
0, 0,
), ),
toJsonString(patch.requirementJson ?? current.requirement_json), toJsonString(patch.requirementJson ?? current.requirement_json),
toPositiveInteger(patch.timeoutMinutes ?? current.timeout_minutes, 0),
String((patch.timeoutPolicy ?? current.timeout_policy) || 'reopen').trim(),
patch.updatedAt, patch.updatedAt,
Number(workOrderId), Number(workOrderId),
], ],
@@ -727,6 +744,7 @@ export async function grabWorkOrder(input: {
workerId: number workerId: number
depositAmount: number depositAmount: number
maxActiveOrders: number maxActiveOrders: number
deadlineAt: string | null
now: string now: string
}): Promise<GrabWorkOrderResult> { }): Promise<GrabWorkOrderResult> {
return withTransaction(async (client) => { return withTransaction(async (client) => {
@@ -755,13 +773,14 @@ export async function grabWorkOrder(input: {
status = 'in_progress', status = 'in_progress',
assigned_worker_id = $1, assigned_worker_id = $1,
assigned_at = $2, assigned_at = $2,
deadline_at = $3,
updated_at = $2 updated_at = $2
WHERE id = $3 WHERE id = $4
AND status = 'open' AND status = 'open'
AND assigned_worker_id IS NULL AND assigned_worker_id IS NULL
RETURNING id RETURNING id
`, `,
[input.workerId, input.now, input.workOrderId], [input.workerId, input.now, input.deadlineAt, input.workOrderId],
) )
if (!result.rows[0]) { if (!result.rows[0]) {
return { return {
@@ -857,6 +876,7 @@ export async function cancelWorkerWorkOrder(input: {
status = 'open', status = 'open',
assigned_worker_id = NULL, assigned_worker_id = NULL,
assigned_at = NULL, assigned_at = NULL,
deadline_at = NULL,
updated_at = $1 updated_at = $1
WHERE id = $2 WHERE id = $2
`, `,
@@ -1228,6 +1248,12 @@ export async function resolveProblemWorkOrder(input: {
acceptance_json = CASE WHEN $3 THEN '{}'::jsonb ELSE acceptance_json END, acceptance_json = CASE WHEN $3 THEN '{}'::jsonb ELSE acceptance_json END,
submitted_at = CASE WHEN $3 THEN NULL ELSE submitted_at END, submitted_at = CASE WHEN $3 THEN NULL ELSE submitted_at END,
problem_note = CASE WHEN $2 THEN '' ELSE problem_note END, problem_note = CASE WHEN $2 THEN '' ELSE problem_note END,
deadline_at = CASE
WHEN $1 = 'in_progress' AND timeout_minutes > 0
THEN NOW() + (timeout_minutes * INTERVAL '1 minute')
WHEN $1 = 'open' THEN NULL
ELSE deadline_at
END,
updated_at = $4 updated_at = $4
WHERE id = $5 WHERE id = $5
`, `,
@@ -1253,6 +1279,163 @@ export async function resolveProblemWorkOrder(input: {
}) })
} }
export async function listOverdueWorkOrders({
limit = 50,
workerId = 0,
}: { limit?: number; workerId?: number } = {}): Promise<WorkOrderRow[]> {
const params: unknown[] = []
let workerClause = ''
if (workerId > 0) {
params.push(workerId)
workerClause = `AND wo.assigned_worker_id = $${params.length}`
}
params.push(limit)
const result = await query<WorkOrderRow>(
`${WORK_ORDER_SELECT}
WHERE wo.status = 'in_progress'
AND wo.deadline_at IS NOT NULL
AND wo.deadline_at < NOW()
${workerClause}
ORDER BY wo.deadline_at ASC
LIMIT $${params.length}`,
params,
)
return result.rows
}
export async function settleOverdueWorkOrder(input: {
workOrderId: number
policy: 'reopen' | 'cancel_release' | 'cancel_deduct'
now: string
}): Promise<{
order: WorkOrderRow | null
failureReason: 'work_order_not_overdue' | 'work_order_has_active_shares' | null
}> {
return withTransaction(async (client) => {
const currentResult = await client.query<WorkOrderRow>(
`
SELECT *
FROM work_orders
WHERE id = $1
AND status = 'in_progress'
AND deadline_at IS NOT NULL
AND deadline_at < NOW()
FOR UPDATE
`,
[input.workOrderId],
)
const workOrder = currentResult.rows[0] || null
if (!workOrder) {
return { order: null, failureReason: 'work_order_not_overdue' }
}
const shareResult = await client.query<{ total: number }>(
`
SELECT COUNT(*)::int AS total
FROM work_order_shares
WHERE work_order_id = $1 AND status != 'cancelled'
`,
[input.workOrderId],
)
if (Number(shareResult.rows[0]?.total || 0) > 0) {
return { order: null, failureReason: 'work_order_has_active_shares' }
}
const shouldReleaseDeposit = ['reopen', 'cancel_release'].includes(input.policy)
const shouldDeductDeposit = input.policy === 'cancel_deduct'
const workerId = Number(workOrder.assigned_worker_id || 0)
let resolvedDepositAmount = 0
if (workerId > 0 && (shouldReleaseDeposit || shouldDeductDeposit)) {
await ensureWorkerWalletWithClient(client, workerId, input.now)
const wallet = await getWorkerWalletWithClient(client, workerId)
resolvedDepositAmount = Math.min(
await getOutstandingDepositAmountWithClient(client, workerId, input.workOrderId),
Number(wallet?.frozen_deposit_amount || 0),
)
if (resolvedDepositAmount > 0) {
const nextAvailable =
Number(wallet?.available_amount || 0) + (shouldReleaseDeposit ? resolvedDepositAmount : 0)
const nextFrozen = Math.max(
0,
Number(wallet?.frozen_deposit_amount || 0) - resolvedDepositAmount,
)
const ledgerType = shouldReleaseDeposit ? 'deposit_release' : 'deposit_deduction'
const ledgerAmount = shouldReleaseDeposit ? resolvedDepositAmount : -resolvedDepositAmount
const ledgerNote = shouldReleaseDeposit ? '超时处置释放押金' : '超时处置扣除押金'
await client.query(
`
UPDATE worker_wallets
SET available_amount = $1, frozen_deposit_amount = $2, updated_at = $3
WHERE worker_id = $4
`,
[nextAvailable, nextFrozen, input.now, workerId],
)
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, $2, $3, $4, $5, $6, $7, $8::jsonb, $9)
`,
[
workerId,
ledgerType,
ledgerAmount,
nextAvailable,
nextFrozen,
input.workOrderId,
ledgerNote,
JSON.stringify({ action: `timeout_${input.policy}` }),
input.now,
],
)
}
}
const targetStatus = input.policy === 'reopen' ? 'open' : 'cancelled'
await client.query(
`
UPDATE work_orders
SET
status = $1,
assigned_worker_id = NULL,
assigned_at = NULL,
deadline_at = NULL,
acceptance_json = CASE WHEN $1 = 'open' THEN '{}'::jsonb ELSE acceptance_json END,
submitted_at = CASE WHEN $1 = 'open' THEN NULL ELSE submitted_at END,
problem_note = CASE WHEN $1 = 'open' THEN '' ELSE problem_note END,
published_at = CASE WHEN $1 = 'open' THEN $2 ELSE published_at END,
updated_at = $2
WHERE id = $3
`,
[targetStatus, input.now, input.workOrderId],
)
await createWorkOrderEventWithClient(client, {
workOrderId: input.workOrderId,
actorType: 'system',
actorId: '',
eventType: `timeout_${input.policy}`,
fromStatus: 'in_progress',
toStatus: targetStatus,
payloadJson: JSON.stringify({
policy: input.policy,
depositAmount: resolvedDepositAmount,
}),
now: input.now,
})
return {
order: await getWorkOrderByIdWithClient(client, input.workOrderId),
failureReason: null,
}
})
}
export async function countWorkerActiveOrders(workerId: number | string): Promise<number> { export async function countWorkerActiveOrders(workerId: number | string): Promise<number> {
const result = await query<{ total: number }>( const result = await query<{ total: number }>(
` `
@@ -10,6 +10,7 @@ import {
const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs.json') const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs.json')
const CLOUDTENTACLES_HEALTH_JOB_ID = 'cloudtentacles-health' const CLOUDTENTACLES_HEALTH_JOB_ID = 'cloudtentacles-health'
const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout'
export function getScheduledJobsFilePath() { export function getScheduledJobsFilePath() {
return SCHEDULED_JOBS_FILE_PATH return SCHEDULED_JOBS_FILE_PATH
@@ -39,17 +40,32 @@ export function getCloudtentaclesHealthJob(config: JsonObject = getScheduledJobs
|| createDefaultCloudtentaclesHealthJob() || createDefaultCloudtentaclesHealthJob()
} }
export function getWorkOrderTimeoutJob(config: JsonObject = getScheduledJobsConfig()) {
return (Array.isArray(config.jobs) ? config.jobs : [])
.find((item) => String(item.id || '').trim() === WORK_ORDER_TIMEOUT_JOB_ID)
|| createDefaultWorkOrderTimeoutJob()
}
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
}
export function normalizeScheduledJobsConfig(rawValue: unknown) { export function normalizeScheduledJobsConfig(rawValue: unknown) {
const source = isPlainObject(rawValue) ? rawValue : {} const source = isPlainObject(rawValue) ? rawValue : {}
const rawJobs = Array.isArray(source.jobs) ? source.jobs : [] const rawJobs = Array.isArray(source.jobs) ? source.jobs : []
const jobs = rawJobs const jobs = rawJobs
.map((item) => normalizeScheduledJob(item)) .map((item) => normalizeScheduledJob(item))
.filter((item): item is ReturnType<typeof normalizeCloudtentaclesHealthJob> => Boolean(item)) .filter((item): item is NonNullable<ReturnType<typeof normalizeScheduledJob>> => Boolean(item))
const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID) const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID)
const hasWorkOrderTimeout = jobs.some((item) => item.id === WORK_ORDER_TIMEOUT_JOB_ID)
if (!hasCloudtentaclesHealth) { if (!hasCloudtentaclesHealth) {
jobs.push(createDefaultCloudtentaclesHealthJob()) jobs.push(createDefaultCloudtentaclesHealthJob())
} }
if (!hasWorkOrderTimeout) {
jobs.push(createDefaultWorkOrderTimeoutJob())
}
return { return {
enabled: typeof source.enabled === 'boolean' ? source.enabled : true, enabled: typeof source.enabled === 'boolean' ? source.enabled : true,
@@ -63,11 +79,28 @@ function normalizeScheduledJob(rawValue: unknown) {
} }
const type = String(rawValue.type || '').trim() const type = String(rawValue.type || '').trim()
if (type !== 'cloudtentacles_health') { if (type === 'cloudtentacles_health') {
return null return normalizeCloudtentaclesHealthJob(rawValue)
}
if (type === 'work_order_timeout') {
return normalizeWorkOrderTimeoutJob(rawValue)
} }
return normalizeCloudtentaclesHealthJob(rawValue) return null
}
function normalizeWorkOrderTimeoutJob(rawValue: JsonObject) {
const config = isPlainObject(rawValue.config) ? rawValue.config : {}
return {
id: WORK_ORDER_TIMEOUT_JOB_ID,
type: 'work_order_timeout',
enabled: rawValue.enabled !== false,
intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 60, 30, 3600),
config: {
scanLimit: normalizeRangeInteger(config.scanLimit, 50, 1, 1000),
},
}
} }
function normalizeCloudtentaclesHealthJob(rawValue: JsonObject) { function normalizeCloudtentaclesHealthJob(rawValue: JsonObject) {
@@ -137,10 +170,23 @@ function createDefaultScheduledJobsConfig() {
enabled: true, enabled: true,
jobs: [ jobs: [
createDefaultCloudtentaclesHealthJob(), createDefaultCloudtentaclesHealthJob(),
createDefaultWorkOrderTimeoutJob(),
], ],
} }
} }
function createDefaultWorkOrderTimeoutJob() {
return {
id: WORK_ORDER_TIMEOUT_JOB_ID,
type: 'work_order_timeout',
enabled: true,
intervalSeconds: 60,
config: {
scanLimit: 50,
},
}
}
function createDefaultCloudtentaclesHealthJob() { function createDefaultCloudtentaclesHealthJob() {
return { return {
id: CLOUDTENTACLES_HEALTH_JOB_ID, id: CLOUDTENTACLES_HEALTH_JOB_ID,
@@ -2,10 +2,11 @@ import { logError, logInfo, logWarn } from '../../utils/logger.js'
import { createHttpError } from '../../utils/http.js' import { createHttpError } from '../../utils/http.js'
import type { JsonObject } from '../../types/json.js' import type { JsonObject } from '../../types/json.js'
import { import {
getCloudtentaclesHealthJob, getScheduledJobById,
getScheduledJobsConfig, getScheduledJobsConfig,
} from './config-service.js' } from './config-service.js'
import { runCloudtentaclesHealthJob } from './cloudtentacles-health-job.js' import { runCloudtentaclesHealthJob } from './cloudtentacles-health-job.js'
import { runWorkOrderTimeoutJob } from './work-order-timeout-job.js'
const timers = new Map<string, NodeJS.Timeout>() const timers = new Map<string, NodeJS.Timeout>()
const jobStates = new Map<string, JsonObject>() const jobStates = new Map<string, JsonObject>()
@@ -67,14 +68,20 @@ export async function runScheduledJobNow(jobId: unknown) {
const result = await runJob(job, { manual: true }) const result = await runJob(job, { manual: true })
if (getScheduledJobsConfig().enabled !== false) { if (getScheduledJobsConfig().enabled !== false) {
const latestJob = getCloudtentaclesHealthJob(getScheduledJobsConfig()) rescheduleJob(job)
if (latestJob.enabled === true) {
scheduleJob(latestJob, Math.max(60, Number(latestJob.intervalSeconds || 300)) * 1000)
}
} }
return result return result
} }
function rescheduleJob(job: JsonObject) {
const jobId = String(job.id || '').trim()
if (!jobId) return
const latestJob = getScheduledJobById(jobId)
if (!latestJob || latestJob.enabled !== true) return
const intervalMs = Math.max(60, Number(latestJob.intervalSeconds || 60)) * 1000
scheduleJob(latestJob, intervalMs)
}
function scheduleJob(job: JsonObject, delayMs: number) { function scheduleJob(job: JsonObject, delayMs: number) {
const jobId = String(job.id || '').trim() const jobId = String(job.id || '').trim()
if (!jobId) { if (!jobId) {
@@ -90,10 +97,7 @@ function scheduleJob(job: JsonObject, delayMs: number) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
timers.delete(jobId) timers.delete(jobId)
void runJob(job).finally(() => { void runJob(job).finally(() => {
const latestJob = getCloudtentaclesHealthJob(getScheduledJobsConfig()) rescheduleJob(job)
if (latestJob.enabled === true) {
scheduleJob(latestJob, Math.max(60, Number(latestJob.intervalSeconds || 300)) * 1000)
}
}) })
}, delayMs) }, delayMs)
@@ -126,25 +130,27 @@ async function runJob(job: JsonObject, { manual = false }: { manual?: boolean }
try { try {
const result = await dispatchJob(job) const result = await dispatchJob(job)
const summary =
result && typeof result === 'object' ? (result as JsonObject) : {}
updateJobState(jobId, { updateJobState(jobId, {
running: false, running: false,
lastFinishedAt: new Date().toISOString(), lastFinishedAt: new Date().toISOString(),
lastStatus: result?.status || 'ok', lastStatus: String(summary.status || 'ok'),
lastMessage: result?.message || '执行完成', lastMessage: String(summary.message || '执行完成'),
lastAsset: typeof result?.asset === 'number' ? result.asset : null, lastAsset: typeof summary.asset === 'number' ? summary.asset : null,
lastThreshold: typeof result?.threshold === 'number' ? result.threshold : null, lastThreshold: typeof summary.threshold === 'number' ? summary.threshold : null,
lastAccounts: Array.isArray(result?.accounts) ? result.accounts : [], lastAccounts: Array.isArray(summary.accounts) ? summary.accounts : [],
lastAccountCount: Number(result?.accountCount || 0), lastAccountCount: Number(summary.accountCount || 0),
lastCheckedCount: Number(result?.checkedCount || 0), lastCheckedCount: Number(summary.checkedCount || 0),
lastOkCount: Number(result?.okCount || 0), lastOkCount: Number(summary.okCount || 0),
lastLowAssetCount: Number(result?.lowAssetCount || 0), lastLowAssetCount: Number(summary.lowAssetCount || 0),
lastFailedCount: Number(result?.failedCount || 0), lastFailedCount: Number(summary.failedCount || 0),
lastManual: manual, lastManual: manual,
}) })
logInfo('[scheduler]', '定时任务执行完成', { logInfo('[scheduler]', '定时任务执行完成', {
jobId, jobId,
type: job.type, type: job.type,
status: result?.status || 'ok', status: String(summary.status || 'ok'),
}) })
return result return result
} catch (error) { } catch (error) {
@@ -173,6 +179,9 @@ function dispatchJob(job: JsonObject) {
if (job.type === 'cloudtentacles_health') { if (job.type === 'cloudtentacles_health') {
return runCloudtentaclesHealthJob(job) return runCloudtentaclesHealthJob(job)
} }
if (job.type === 'work_order_timeout') {
return runWorkOrderTimeoutJob(job)
}
throw createHttpError(`不支持的定时任务类型:${job.type}`, { throw createHttpError(`不支持的定时任务类型:${job.type}`, {
statusCode: 400, statusCode: 400,
@@ -0,0 +1,24 @@
import type { JsonObject } from '../../types/json.js'
import { logInfo } from '../../utils/logger.js'
import { settleOverdueWorkOrders } from '../worker-platform/worker-service.js'
export async function runWorkOrderTimeoutJob(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 || 50))
const result = await settleOverdueWorkOrders({ limit: scanLimit })
logInfo('[work-order-timeout]', '超时工单扫描完成', result)
return {
ok: result.processedCount === result.checkedCount,
status: 'ok',
message: `扫描 ${result.checkedCount} 个,处置 ${result.processedCount} 个,跳过 ${result.skippedCount}`,
checkedCount: result.checkedCount,
failedCount: result.skippedCount,
asset: result.processedCount,
}
}
@@ -86,7 +86,7 @@ import {
} from './worker-finance-config-service.js' } from './worker-finance-config-service.js'
import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeSubmittedFields, normalizeUploadedFiles, resolveMatchingProductRule, resolveRequirementFields } from './mappers.js' import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeSubmittedFields, normalizeUploadedFiles, resolveMatchingProductRule, resolveRequirementFields } from './mappers.js'
import { ensureWorkerPlatformDefaults, getRequiredWorkOrder, getRequiredWorker } from './worker-service.js' import { ensureWorkerPlatformDefaults, getRequiredWorkOrder, getRequiredWorker, normalizeWorkOrderTimeoutPolicy } from './worker-service.js'
export async function listAdminWorkerLevels() { export async function listAdminWorkerLevels() {
await ensureWorkerPlatformDefaults() await ensureWorkerPlatformDefaults()
@@ -281,6 +281,8 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
sharingEnabled, sharingEnabled,
sharingTotalQuantity, sharingTotalQuantity,
sharingUnitReward, sharingUnitReward,
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
requirementJson: JSON.stringify({ fields }), requirementJson: JSON.stringify({ fields }),
sortOrder: normalizeInteger(payload.sortOrder, 100), sortOrder: normalizeInteger(payload.sortOrder, 100),
now: nowIso(), now: nowIso(),
@@ -646,6 +648,8 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
rewardAmount, rewardAmount,
requiredDepositAmount, requiredDepositAmount,
depositThresholdAmount, depositThresholdAmount,
timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)),
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy),
materialJson: JSON.stringify(material), materialJson: JSON.stringify(material),
requirementJson: JSON.stringify({ fields }), requirementJson: JSON.stringify({ fields }),
now, now,
@@ -721,6 +725,13 @@ export async function updateAdminWorkOrder(
rewardAmount, rewardAmount,
requiredDepositAmount, requiredDepositAmount,
depositThresholdAmount, depositThresholdAmount,
timeoutMinutes:
payload.timeoutMinutes === undefined
? workOrder.timeout_minutes
: Math.max(0, normalizeInteger(payload.timeoutMinutes, workOrder.timeout_minutes)),
timeoutPolicy: normalizeWorkOrderTimeoutPolicy(
payload.timeoutPolicy ?? workOrder.timeout_policy,
),
requirementJson: JSON.stringify({ fields }), requirementJson: JSON.stringify({ fields }),
updatedAt: now, updatedAt: now,
}) })
@@ -735,6 +746,8 @@ export async function updateAdminWorkOrder(
productName: updated?.product_name, productName: updated?.product_name,
rewardAmount, rewardAmount,
requiredDepositAmount, requiredDepositAmount,
timeoutMinutes: updated?.timeout_minutes,
timeoutPolicy: updated?.timeout_policy,
fields, fields,
}), }),
now, now,
@@ -995,6 +1008,8 @@ export async function syncWorkerOrdersForSourceOrder(
sharingEnabled: rule.sharing_enabled === true, sharingEnabled: rule.sharing_enabled === true,
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1), sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
sharingUnitReward: Number(rule.sharing_unit_reward || 0), sharingUnitReward: Number(rule.sharing_unit_reward || 0),
timeoutMinutes: Number(rule.timeout_minutes || 0),
timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(),
materialJson: JSON.stringify({ materialJson: JSON.stringify({
source: { source: {
orderId: Number(order.id), orderId: Number(order.id),
@@ -247,6 +247,8 @@ export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined)
totalQuantity: Number(rule.sharing_total_quantity || 1), totalQuantity: Number(rule.sharing_total_quantity || 1),
unitReward: Number(rule.sharing_unit_reward || 0), unitReward: Number(rule.sharing_unit_reward || 0),
}, },
timeoutMinutes: Number(rule.timeout_minutes || 0),
timeoutPolicy: String(rule.timeout_policy || 'reopen').trim(),
requirement: { requirement: {
fields: normalizeRequirementFields(requirement.fields), fields: normalizeRequirementFields(requirement.fields),
}, },
@@ -355,6 +357,9 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
totalQuantity: Number(workOrder.sharing_total_quantity || 1), totalQuantity: Number(workOrder.sharing_total_quantity || 1),
unitReward: Number(workOrder.sharing_unit_reward || 0), unitReward: Number(workOrder.sharing_unit_reward || 0),
}, },
timeoutMinutes: Number(workOrder.timeout_minutes || 0),
timeoutPolicy: String(workOrder.timeout_policy || 'reopen').trim(),
deadlineAt: workOrder.deadline_at,
depositThresholdAmount: Number( depositThresholdAmount: Number(
workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
), ),
@@ -47,8 +47,10 @@ import {
listWorkerUsers, listWorkerUsers,
listWorkOrderShares, listWorkOrderShares,
listWorkOrderSharesByOrderIds, listWorkOrderSharesByOrderIds,
listOverdueWorkOrders,
resolveProblemWorkOrder, resolveProblemWorkOrder,
reviewWorkerFinanceRequest, reviewWorkerFinanceRequest,
settleOverdueWorkOrder,
submitWorkOrderShareAcceptance, submitWorkOrderShareAcceptance,
updateWorkOrder, updateWorkOrder,
updateWorkerPassword, updateWorkerPassword,
@@ -642,6 +644,7 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
const pageSize = normalizePageSize(query.pageSize) const pageSize = normalizePageSize(query.pageSize)
const categoryId = normalizeOptionalId(query.categoryId) const categoryId = normalizeOptionalId(query.categoryId)
const worker = await getRequiredWorker(session.workerId) const worker = await getRequiredWorker(session.workerId)
await settleOverdueWorkOrders({ workerId: worker.id, limit: 50 })
const permissions = resolveWorkerPermissions(worker) const permissions = resolveWorkerPermissions(worker)
const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0) const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0)
const visibleAfterIso = const visibleAfterIso =
@@ -706,6 +709,7 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
workerId: Number(worker.id), workerId: Number(worker.id),
depositAmount: freezeAmount, depositAmount: freezeAmount,
maxActiveOrders: permissions.maxActiveOrders, maxActiveOrders: permissions.maxActiveOrders,
deadlineAt: resolveWorkOrderDeadlineAt(workOrder, nowIso()),
now: nowIso(), now: nowIso(),
}) })
if (!grabbed.order) { if (!grabbed.order) {
@@ -715,11 +719,61 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
return { order: mapWorkOrderForWorker(grabbed.order, permissions) } return { order: mapWorkOrderForWorker(grabbed.order, permissions) }
} }
function resolveWorkOrderDeadlineAt(
workOrder: WorkOrderRow,
now: string,
): string | null {
const timeoutMinutes = Number(workOrder.timeout_minutes || 0)
if (timeoutMinutes <= 0) return null
return new Date(new Date(now).getTime() + timeoutMinutes * 60 * 1000).toISOString()
}
export const WORK_ORDER_TIMEOUT_POLICIES = ['reopen', 'cancel_release', 'cancel_deduct'] as const
export type WorkOrderTimeoutPolicy = (typeof WORK_ORDER_TIMEOUT_POLICIES)[number]
export function isWorkOrderTimeoutPolicy(value: unknown): value is WorkOrderTimeoutPolicy {
return WORK_ORDER_TIMEOUT_POLICIES.includes(value as WorkOrderTimeoutPolicy)
}
export function normalizeWorkOrderTimeoutPolicy(value: unknown): WorkOrderTimeoutPolicy {
return isWorkOrderTimeoutPolicy(value) ? value : 'reopen'
}
export async function settleOverdueWorkOrders(
options: { limit?: number; workerId?: number } = {},
) {
const overdue = await listOverdueWorkOrders({
limit: Math.max(1, Number(options.limit || 50)),
workerId: Number(options.workerId || 0),
})
let processedCount = 0
let skippedCount = 0
for (const workOrder of overdue) {
const result = await settleOverdueWorkOrder({
workOrderId: workOrder.id,
policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy),
now: nowIso(),
})
if (result.order) {
processedCount += 1
} else {
skippedCount += 1
}
}
return {
checkedCount: overdue.length,
processedCount,
skippedCount,
}
}
export async function listWorkerMyOrders(query: JsonObject = {}, session: WorkerSession) { export async function listWorkerMyOrders(query: JsonObject = {}, session: WorkerSession) {
requireActiveWorkerSession(session) requireActiveWorkerSession(session)
const worker = await getRequiredWorker(session.workerId)
await settleOverdueWorkOrders({ workerId: worker.id, limit: 50 })
const page = normalizePage(query.page) const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize) const pageSize = normalizePageSize(query.pageSize)
const worker = await getRequiredWorker(session.workerId)
const { items, total } = await listWorkOrders({ const { items, total } = await listWorkOrders({
page, page,
pageSize, pageSize,
@@ -835,6 +889,22 @@ export async function submitWorkerOrderAcceptance(
if (sharingShare) { if (sharingShare) {
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session) return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
} }
const now = nowIso()
if (
workOrder.status === WORK_ORDER_STATUS.IN_PROGRESS &&
workOrder.deadline_at &&
new Date(workOrder.deadline_at).getTime() <= Date.now()
) {
await settleOverdueWorkOrder({
workOrderId: workOrder.id,
policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy),
now,
})
throw createHttpError('任务已超时,系统已按超时策略处置', {
statusCode: 409,
errorCode: 'work_order_timeout_expired',
})
}
if (Number(workOrder.assigned_worker_id || 0) !== workerId) { if (Number(workOrder.assigned_worker_id || 0) !== workerId) {
throw createHttpError('只能提交自己的订单', { throw createHttpError('只能提交自己的订单', {
statusCode: 403, statusCode: 403,
@@ -850,7 +920,6 @@ export async function submitWorkerOrderAcceptance(
}) })
} }
const now = nowIso()
const files = normalizeUploadedFiles(payload.files) const files = normalizeUploadedFiles(payload.files)
const imageUrls = [ const imageUrls = [
...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean), ...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean),
@@ -0,0 +1,34 @@
import { Tag } from 'antd'
import { useEffect, useState } from 'react'
export function DeadlineCountdown({
deadlineAt,
showExpired = false,
}: {
deadlineAt: string
showExpired?: boolean
}) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [])
const remainingMs = new Date(deadlineAt).getTime() - now
if (remainingMs <= 0) {
return <Tag color="red"></Tag>
}
const totalSeconds = Math.floor(remainingMs / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
const label = minutes > 0 ? `${minutes}${seconds}` : `${seconds}`
const color = remainingMs < 10 * 60 * 1000 ? 'orange' : 'blue'
return <Tag color={color}> {label}</Tag>
}
export function formatTimeoutPolicyLabel(policy?: string) {
if (policy === 'cancel_release') return '超时取消退押金'
if (policy === 'cancel_deduct') return '超时取消扣押金'
return '超时退回大厅'
}
@@ -113,6 +113,8 @@ export default function ProductRulesPanel() {
sharingTotalQuantity?: number sharingTotalQuantity?: number
sharingUnitReward?: number sharingUnitReward?: number
sharingTotalAmount?: number sharingTotalAmount?: number
timeoutMinutes?: number
timeoutPolicy?: string
fieldsText?: string fieldsText?: string
enabled?: boolean enabled?: boolean
autoCreate?: boolean autoCreate?: boolean
@@ -389,6 +391,27 @@ export default function ProductRulesPanel() {
> >
<Input.TextArea rows={4} /> <Input.TextArea rows={4} />
</Form.Item> </Form.Item>
<Form.Item
label="默认任务时限"
name="timeoutMinutes"
tooltip="自动创建的工单继承该时限,打手抢单后开始计时;0 表示不限时"
>
<InputNumber min={0} step={5} addonAfter="分钟" />
</Form.Item>
<Form.Item
label="默认超时策略"
name="timeoutPolicy"
tooltip="reopen:释放押金退回大厅;cancel_release:取消退押金;cancel_deduct:取消扣押金"
>
<Select
style={{ width: 260 }}
options={[
{ value: 'reopen', label: '释放押金退回大厅' },
{ value: 'cancel_release', label: '取消订单并退还押金' },
{ value: 'cancel_deduct', label: '取消订单并扣除押金' },
]}
/>
</Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
</Button> </Button>
@@ -87,6 +87,8 @@ import {
} from '@/utils/admin-pagination' } from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time' import { formatAdminDateTime } from '@/utils/admin-time'
import { import {
DeadlineCountdown,
formatTimeoutPolicyLabel,
getAcceptanceFiles, getAcceptanceFiles,
getAcceptanceImageUrls, getAcceptanceImageUrls,
getAcceptanceSubmittedAt, getAcceptanceSubmittedAt,
@@ -129,6 +131,8 @@ export default function WorkOrdersPanel() {
rewardAmount?: number rewardAmount?: number
requiredDepositAmount?: number requiredDepositAmount?: number
fieldsText?: string fieldsText?: string
timeoutMinutes?: number
timeoutPolicy?: string
}>() }>()
const [sharingForm] = Form.useForm<{ const [sharingForm] = Form.useForm<{
enabled?: boolean enabled?: boolean
@@ -320,6 +324,8 @@ export default function WorkOrdersPanel() {
requiredDepositAmount: requiredDepositAmount:
Math.round(Number(row.requiredDepositAmount || 0)) / 100, Math.round(Number(row.requiredDepositAmount || 0)) / 100,
fieldsText: formatRequirementFieldsText(getRequirementFields(row)), fieldsText: formatRequirementFieldsText(getRequirementFields(row)),
timeoutMinutes: Number(row.timeoutMinutes || 0),
timeoutPolicy: row.timeoutPolicy || 'reopen',
}) })
} }
@@ -330,6 +336,8 @@ export default function WorkOrdersPanel() {
rewardAmount?: number rewardAmount?: number
requiredDepositAmount?: number requiredDepositAmount?: number
fieldsText?: string fieldsText?: string
timeoutMinutes?: number
timeoutPolicy?: string
}) { }) {
if (!editOrder) return if (!editOrder) return
const succeeded = await runAction( const succeeded = await runAction(
@@ -341,6 +349,8 @@ export default function WorkOrdersPanel() {
rewardAmount: Number(values.rewardAmount || 0), rewardAmount: Number(values.rewardAmount || 0),
requiredDepositAmount: Number(values.requiredDepositAmount || 0), requiredDepositAmount: Number(values.requiredDepositAmount || 0),
fieldsText: String(values.fieldsText || ''), fieldsText: String(values.fieldsText || ''),
timeoutMinutes: Math.max(0, Number(values.timeoutMinutes || 0)),
timeoutPolicy: values.timeoutPolicy || 'reopen',
}), }),
'工单信息已保存', '工单信息已保存',
) )
@@ -398,6 +408,34 @@ export default function WorkOrdersPanel() {
width: 110, width: 110,
render: (_, row) => formatMoney(row.requiredDepositAmount), render: (_, row) => formatMoney(row.requiredDepositAmount),
}, },
{
title: '时限',
width: 190,
render: (_, row) => {
const minutes = Number(row.timeoutMinutes || 0)
if (minutes <= 0) {
return <Typography.Text type="secondary"></Typography.Text>
}
return (
<div className="cell-stack">
<Space wrap size={6}>
<Tag color="blue">{minutes} </Tag>
<Typography.Text type="secondary">
{formatTimeoutPolicyLabel(row.timeoutPolicy)}
</Typography.Text>
</Space>
{row.status === 'in_progress' && row.deadlineAt ? (
<DeadlineCountdown deadlineAt={row.deadlineAt} showExpired />
) : null}
{row.deadlineAt ? (
<Typography.Text type="secondary">
{formatAdminDateTime(row.deadlineAt)}
</Typography.Text>
) : null}
</div>
)
},
},
{ {
title: '拼单', title: '拼单',
width: 190, width: 190,
@@ -699,6 +737,29 @@ export default function WorkOrdersPanel() {
<Descriptions.Item label="押金"> <Descriptions.Item label="押金">
{formatMoney(detailOrder.requiredDepositAmount)} {formatMoney(detailOrder.requiredDepositAmount)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="任务时限">
{Number(detailOrder.timeoutMinutes || 0) > 0
? `${detailOrder.timeoutMinutes} 分钟`
: '不限时'}
</Descriptions.Item>
<Descriptions.Item label="超时策略">
{formatTimeoutPolicyLabel(detailOrder.timeoutPolicy)}
</Descriptions.Item>
<Descriptions.Item label="超时截止">
{detailOrder.deadlineAt ? (
<Space wrap size={8}>
<DeadlineCountdown
deadlineAt={detailOrder.deadlineAt}
showExpired
/>
<Typography.Text type="secondary">
{formatAdminDateTime(detailOrder.deadlineAt)}
</Typography.Text>
</Space>
) : (
'-'
)}
</Descriptions.Item>
<Descriptions.Item label="发布时间"> <Descriptions.Item label="发布时间">
{formatAdminDateTime(detailOrder.publishedAt)} {formatAdminDateTime(detailOrder.publishedAt)}
</Descriptions.Item> </Descriptions.Item>
@@ -857,6 +918,32 @@ export default function WorkOrdersPanel() {
> >
<Input.TextArea rows={5} /> <Input.TextArea rows={5} />
</Form.Item> </Form.Item>
<Form.Item
label="任务时限"
name="timeoutMinutes"
tooltip="打手抢单后开始计时,超时由系统自动按策略处置;0 表示不限时"
>
<InputNumber
min={0}
step={5}
addonAfter="分钟"
style={{ width: 160 }}
/>
</Form.Item>
<Form.Item
label="超时策略"
name="timeoutPolicy"
tooltip="reopen:释放押金退回大厅继续可抢;cancel_release:取消订单并退还押金;cancel_deduct:取消订单并扣除押金"
>
<Select
style={{ width: 320 }}
options={[
{ value: 'reopen', label: '释放押金退回大厅' },
{ value: 'cancel_release', label: '取消订单并退还押金' },
{ value: 'cancel_deduct', label: '取消订单并扣除押金' },
]}
/>
</Form.Item>
</Form> </Form>
) : null} ) : null}
</Modal> </Modal>
@@ -1,8 +1,14 @@
import { Descriptions, Space, Tag, Typography } from 'antd' import { Descriptions, Space, Tag, Typography } from 'antd'
import ImagePreviewList from '@/components/files/ImagePreviewList' import ImagePreviewList from '@/components/files/ImagePreviewList'
import {
DeadlineCountdown,
formatTimeoutPolicyLabel,
} from '@/components/DeadlineCountdown'
import type { CollectField, UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform' import type { CollectField, UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
import { formatAdminDateTime } from '@/utils/admin-time' import { formatAdminDateTime } from '@/utils/admin-time'
export { DeadlineCountdown, formatTimeoutPolicyLabel }
export function getRequirementFields(order: WorkOrder | null): CollectField[] { export function getRequirementFields(order: WorkOrder | null): CollectField[] {
if (!order) return [] if (!order) return []
const rawFields = Array.isArray(order.requirement?.fields) const rawFields = Array.isArray(order.requirement?.fields)
@@ -267,17 +267,22 @@ function NotificationPanel({
const [testResult, setTestResult] = useState<AdminNotificationTestResult | null>(null) const [testResult, setTestResult] = useState<AdminNotificationTestResult | null>(null)
const source = notificationConfig.source const source = notificationConfig.source
const monitorAccounts = scheduledJobs.cloudtentaclesAccounts || [] const monitorAccounts = scheduledJobs.cloudtentaclesAccounts || []
const jobsWithMergedAccounts = scheduledJobs.source.jobs.map((job) => ({ const jobsWithMergedAccounts = scheduledJobs.source.jobs.map((job) => {
...job, if (job.type !== 'cloudtentacles_health') {
config: { return job
...job.config, }
accounts: mergeScheduledJobAccounts( return {
job.config?.accounts || [], ...job,
monitorAccounts, config: {
Number(job.config?.assetThreshold ?? 500), ...job.config,
), accounts: mergeScheduledJobAccounts(
}, job.config?.accounts || [],
})) monitorAccounts,
Number(job.config?.assetThreshold ?? 500),
),
},
}
})
async function saveNotification() { async function saveNotification() {
setSavingNotification(true) setSavingNotification(true)
@@ -313,18 +318,23 @@ function NotificationPanel({
try { try {
const payload: AdminScheduledJobsConfig = { const payload: AdminScheduledJobsConfig = {
...scheduledJobs.source, ...scheduledJobs.source,
jobs: jobsWithMergedAccounts.map((job) => ({ jobs: jobsWithMergedAccounts.map((job) => {
...job, if (job.type !== 'cloudtentacles_health') {
config: { return { ...job }
assetThreshold: Number(job.config?.assetThreshold ?? 500), }
accounts: (job.config?.accounts || []).map((account) => ({ return {
sourceKey: account.sourceKey, ...job,
label: account.label, config: {
enabled: account.enabled !== false, assetThreshold: Number(job.config?.assetThreshold ?? 500),
assetThreshold: Number(account.assetThreshold ?? job.config?.assetThreshold ?? 500), accounts: (job.config?.accounts || []).map((account) => ({
})), sourceKey: account.sourceKey,
}, label: account.label,
})), enabled: account.enabled !== false,
assetThreshold: Number(account.assetThreshold ?? job.config?.assetThreshold ?? 500),
})),
},
}
}),
} }
const response = await saveAdminScheduledJobsConfig(payload) const response = await saveAdminScheduledJobsConfig(payload)
onScheduledJobsChange(response.data) onScheduledJobsChange(response.data)
@@ -587,6 +597,72 @@ function ScheduledJobCard({
onRun: () => void onRun: () => void
onChange: (job: AdminScheduledJobItem) => void onChange: (job: AdminScheduledJobItem) => void
}) { }) {
if (job.type === 'work_order_timeout' || job.id === 'work-order-timeout') {
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 }}>
退 / 退 /
</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 ?? 50)}
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}`,
`跳过 ${runtime.lastFailedCount ?? 0}`,
`上次:${formatAdminDateTime(runtime.lastFinishedAt || runtime.lastRunAt)}`,
`下次:${formatAdminDateTime(runtime.nextRunAt)}`,
].join(' · ')}
/>
) : null}
</Card>
)
}
const accounts = job.config?.accounts || [] const accounts = job.config?.accounts || []
const defaultThreshold = Number(job.config?.assetThreshold ?? 500) const defaultThreshold = Number(job.config?.assetThreshold ?? 500)
@@ -812,6 +888,9 @@ function formatScheduledJobTitle(job: AdminScheduledJobItem) {
if (job.type === 'cloudtentacles_health' || job.id === 'cloudtentacles-health') { if (job.type === 'cloudtentacles_health' || job.id === 'cloudtentacles-health') {
return 'kuaishou-lewan 健康检查' return 'kuaishou-lewan 健康检查'
} }
if (job.type === 'work_order_timeout' || job.id === 'work-order-timeout') {
return '接单工单超时扫描'
}
return job.id || job.type || '定时任务' return job.id || job.type || '定时任务'
} }
@@ -25,8 +25,9 @@ import {
Typography, Typography,
} from 'antd' } from 'antd'
import type { TableColumnsType } from 'antd' import type { TableColumnsType } from 'antd'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { DeadlineCountdown } from '@/components/DeadlineCountdown'
import ImagePreviewList from '@/components/files/ImagePreviewList' import ImagePreviewList from '@/components/files/ImagePreviewList'
import ImageUpload from '@/components/files/ImageUpload' import ImageUpload from '@/components/files/ImageUpload'
import { import {
@@ -227,6 +228,9 @@ export default function WorkerOrdersPage() {
render: (_, row) => ( render: (_, row) => (
<div className="cell-stack"> <div className="cell-stack">
<Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag> <Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>
{row.status === 'in_progress' && row.deadlineAt ? (
<DeadlineCountdown deadlineAt={row.deadlineAt} />
) : null}
<Typography.Text type="secondary"> <Typography.Text type="secondary">
{getStatusHint(row.status)} {getStatusHint(row.status)}
</Typography.Text> </Typography.Text>
@@ -104,6 +104,8 @@ export function saveAdminWorkProductRule(payload: {
sharingTotalQuantity?: number sharingTotalQuantity?: number
sharingUnitReward?: number sharingUnitReward?: number
sharingTotalAmount?: number sharingTotalAmount?: number
timeoutMinutes?: number
timeoutPolicy?: string
fieldsText?: string fieldsText?: string
sortOrder?: number sortOrder?: number
}) { }) {
@@ -228,6 +230,8 @@ export function updateAdminWorkOrder(
rewardAmount?: number rewardAmount?: number
requiredDepositAmount?: number requiredDepositAmount?: number
fieldsText?: string fieldsText?: string
timeoutMinutes?: number
timeoutPolicy?: string
}, },
) { ) {
return apiPut<{ order: WorkOrder }>( return apiPut<{ order: WorkOrder }>(
@@ -12,8 +12,10 @@ export interface AdminScheduledJobItem {
intervalSeconds: number intervalSeconds: number
cooldownSeconds: number cooldownSeconds: number
config: { config: {
assetThreshold: number assetThreshold?: number
accounts: AdminScheduledJobCloudtentaclesAccount[] accounts?: AdminScheduledJobCloudtentaclesAccount[]
scanLimit?: number
[key: string]: unknown
} }
} }
@@ -148,6 +148,8 @@ export type WorkProductRule = {
requirement: { requirement: {
fields: CollectField[] fields: CollectField[]
} }
timeoutMinutes?: number
timeoutPolicy?: 'reopen' | 'cancel_release' | 'cancel_deduct' | string
sortOrder: number sortOrder: number
createdAt: string createdAt: string
updatedAt: string updatedAt: string
@@ -221,6 +223,9 @@ export type WorkOrder = {
joinedQuantity: number joinedQuantity: number
pendingSubmissionCount: number pendingSubmissionCount: number
} }
timeoutMinutes?: number
timeoutPolicy?: 'reopen' | 'cancel_release' | 'cancel_deduct' | string
deadlineAt?: string | null
material: Record<string, unknown> material: Record<string, unknown>
requirement: Record<string, unknown> requirement: Record<string, unknown>
acceptance: { acceptance: {