接单工单支持任务时限,超时自动判定失败并处置
This commit is contained in:
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user