Files
order_site/apps/backend/src/repositories/task-repo.ts
T
2026-05-25 22:03:07 +08:00

461 lines
13 KiB
TypeScript

import { query, withTransaction } from '../db/client.js'
import type { PoolClient } from 'pg'
import type {
TaskCreateInput,
TaskListQueryInput,
TaskRuntimeContextPatch,
TaskUpdatePatch,
} from '../types/repository-inputs.js'
import type {
TaskListQueryResult,
TaskRow,
} from '../types/repository-rows.js'
type TaskQueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }>
type TaskRuntimeContextRow = {
browser_session_id: string
login_type: string
nickname: string
role_id: string
role_name: string
area: string
partition_name: string
screenshot_path: string
artifacts_json: string | Record<string, unknown>
state_json: string | Record<string, unknown>
}
const TASK_FIELDS = `
ft.*,
ct.id AS primary_claim_token_id,
ct.token AS primary_claim_token,
ct.status AS primary_claim_token_status,
ct.expired_at AS primary_claim_expires_at,
ctx.browser_session_id,
ctx.login_type,
ctx.nickname,
ctx.role_id,
ctx.role_name,
ctx.area,
ctx.partition_name,
ctx.screenshot_path,
ctx.artifacts_json,
ctx.state_json,
inv.inventory_item_id AS primary_inventory_item_id,
inv.display_value AS primary_inventory_display_value,
inv.credential_type AS primary_inventory_credential_type,
inv.binding_status AS primary_inventory_binding_status
`
const TASK_JOINS = `
FROM fulfillment_tasks ft
LEFT JOIN claim_tokens ct ON ct.task_id = ft.id
LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id
LEFT JOIN LATERAL (
SELECT
tib.inventory_item_id,
tib.binding_status,
ii.display_value,
ii.credential_type
FROM task_inventory_bindings tib
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
WHERE tib.task_id = ft.id AND tib.binding_status IN ('reserved', 'consumed')
ORDER BY
CASE tib.binding_status
WHEN 'reserved' THEN 0
WHEN 'consumed' THEN 1
ELSE 2
END ASC,
tib.id DESC
LIMIT 1
) inv ON TRUE
`
function buildTaskSelect(extraFields = ''): string {
const normalizedExtra = String(extraFields || '').trim()
const fieldSql = normalizedExtra ? `${TASK_FIELDS}, ${normalizedExtra}` : TASK_FIELDS
return `SELECT ${fieldSql} ${TASK_JOINS}`
}
export async function listTasksByOrderId(orderId: number | string): Promise<TaskRow[]> {
const result = await query<TaskRow>(
`${buildTaskSelect()}
WHERE ft.order_id = $1
ORDER BY ft.id ASC`,
[Number(orderId)],
)
return result.rows
}
export async function createTask(input: TaskCreateInput): Promise<TaskRow | null> {
return withTransaction(async (client) => {
const taskResult = await client.query<{ id: number }>(
`
INSERT INTO fulfillment_tasks (
order_id,
order_item_id,
unit_index,
task_no,
provider,
platform,
shop_id,
shop_name,
platform_order_id,
profile_id,
executor_key,
task_status,
inventory_status,
delivery_status,
result_code,
result_message,
claim_token,
claim_expires_at,
automation_mode,
requires_claim,
user_action_status,
attempt_count,
last_error,
context_json,
created_at,
updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19,
$20, $21, $22, $23, $24::jsonb, $25, $26
)
RETURNING id
`,
[
input.orderId,
input.orderItemId,
input.unitIndex,
input.taskNo,
input.provider,
input.platform,
input.shopId || '',
input.shopName || '',
input.platformOrderId,
input.profileId,
input.executorKey,
input.taskStatus || 'pending_payment',
input.inventoryStatus || 'pending',
input.deliveryStatus || 'pending',
input.resultCode || '',
input.resultMessage || '',
input.claimToken || '',
input.claimExpiresAt || null,
input.automationMode || 'manual',
Boolean(input.requiresClaim),
input.userActionStatus || 'not_required',
input.attemptCount || 0,
input.lastError || '',
input.contextJson || '{}',
input.createdAt,
input.updatedAt,
],
)
const taskId = Number(taskResult.rows[0]?.id || 0)
if (input.runtimeContext) {
await upsertTaskRuntimeContextWithClient(client, taskId, input.runtimeContext, input.createdAt)
}
return getTaskByIdWithExecutor(client.query.bind(client) as TaskQueryExecutor, taskId)
})
}
export async function updateTask(taskId: number | string, patch: TaskUpdatePatch): Promise<TaskRow | null> {
const current = await getTaskById(taskId)
if (!current) {
return null
}
return withTransaction(async (client) => {
const next = { ...current, ...patch }
await client.query(
`
UPDATE fulfillment_tasks
SET
task_status = $1,
inventory_status = $2,
delivery_status = $3,
result_code = $4,
result_message = $5,
claim_token = $6,
claim_expires_at = $7,
automation_mode = $8,
requires_claim = $9,
user_action_status = $10,
attempt_count = $11,
last_error = $12,
context_json = $13::jsonb,
claimed_at = $14,
role_confirmed_at = $15,
redeemed_at = $16,
updated_at = $17
WHERE id = $18
`,
[
next.task_status,
next.inventory_status,
next.delivery_status,
next.result_code,
next.result_message,
next.claim_token || '',
next.claim_expires_at || null,
next.automation_mode || 'manual',
Boolean(next.requires_claim),
next.user_action_status || 'not_required',
next.attempt_count || 0,
next.last_error || '',
next.context_json || '{}',
next.claimed_at || null,
next.role_confirmed_at || null,
next.redeemed_at || null,
next.updated_at,
Number(taskId),
],
)
if (containsRuntimeContextPatch(patch)) {
await upsertTaskRuntimeContextWithClient(client, Number(taskId), {
browserSessionId: patch.browser_session_id,
loginType: patch.login_type,
nickname: patch.nickname,
roleId: patch.role_id,
roleName: patch.role_name,
area: patch.area,
partitionName: patch.partition_name,
screenshotPath: patch.screenshot_path,
artifactsJson: patch.artifacts_json,
stateJson: patch.state_json,
}, patch.updated_at || current.updated_at)
}
return getTaskByIdWithExecutor(client.query.bind(client) as TaskQueryExecutor, taskId)
})
}
export async function getTaskById(taskId: number | string): Promise<TaskRow | null> {
return getTaskByIdWithExecutor(query, taskId)
}
export async function findTaskByClaimTokenId(claimTokenId: number | string): Promise<TaskRow | null> {
const result = await query<TaskRow>(
`${buildTaskSelect()}
JOIN claim_tokens ctf ON ctf.task_id = ft.id
WHERE ctf.id = $1
LIMIT 1`,
[Number(claimTokenId)],
)
return result.rows[0] || null
}
export async function listTasks({
page = 1,
pageSize = 20,
status = '',
platformOrderId = '',
taskNo = '',
skuCode = '',
roleId = '',
dateFrom = '',
dateTo = '',
}: TaskListQueryInput = {}): Promise<TaskListQueryResult> {
const offset = (page - 1) * pageSize
const filters: string[] = []
const params: unknown[] = []
if (status) {
params.push(status)
filters.push(`ft.task_status = $${params.length}`)
}
if (platformOrderId) {
params.push(`%${platformOrderId}%`)
filters.push(`ft.platform_order_id ILIKE $${params.length}`)
}
if (taskNo) {
params.push(`%${taskNo}%`)
filters.push(`ft.task_no ILIKE $${params.length}`)
}
if (skuCode) {
params.push(skuCode)
filters.push(`oi.sku_code = $${params.length}`)
}
if (roleId) {
params.push(`%${roleId}%`)
filters.push(`ctx.role_id ILIKE $${params.length}`)
}
if (dateFrom) {
params.push(dateFrom)
filters.push(`ft.created_at >= $${params.length}`)
}
if (dateTo) {
params.push(dateTo)
filters.push(`ft.created_at <= $${params.length}`)
}
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
const totalResult = await query<{ [column: string]: unknown, total: number }>(
`
SELECT COUNT(*)::int AS total
FROM fulfillment_tasks ft
LEFT JOIN order_items oi ON oi.id = ft.order_item_id
LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id
${whereClause}
`,
params,
)
params.push(pageSize)
params.push(offset)
const itemsResult = await query<TaskRow>(
`${buildTaskSelect('oi.sku_code, oi.sku_name, oi.quantity')}
LEFT JOIN order_items oi ON oi.id = ft.order_item_id
${whereClause}
ORDER BY ft.id DESC
LIMIT $${params.length - 1} OFFSET $${params.length}`,
params,
)
return {
items: itemsResult.rows,
total: Number(totalResult.rows[0]?.total || 0),
}
}
async function upsertTaskRuntimeContextWithClient(
client: PoolClient,
taskId: number | string,
patch: TaskRuntimeContextPatch = {},
timestamp: string | undefined,
): Promise<void> {
const currentResult = await client.query<TaskRuntimeContextRow>(
'SELECT * FROM task_runtime_contexts WHERE task_id = $1 LIMIT 1',
[Number(taskId)],
)
const current = currentResult.rows[0] || null
const next = {
browser_session_id: patch.browserSessionId ?? current?.browser_session_id ?? '',
login_type: patch.loginType ?? current?.login_type ?? '',
nickname: patch.nickname ?? current?.nickname ?? '',
role_id: patch.roleId ?? current?.role_id ?? '',
role_name: patch.roleName ?? current?.role_name ?? '',
area: patch.area ?? current?.area ?? '',
partition_name: patch.partitionName ?? current?.partition_name ?? '',
screenshot_path: patch.screenshotPath ?? current?.screenshot_path ?? '',
artifacts_json: patch.artifactsJson ?? current?.artifacts_json ?? '{}',
state_json: patch.stateJson ?? current?.state_json ?? '{}',
updated_at: timestamp,
}
if (!current) {
await client.query(
`
INSERT INTO task_runtime_contexts (
task_id,
browser_session_id,
login_type,
nickname,
role_id,
role_name,
area,
partition_name,
screenshot_path,
artifacts_json,
state_json,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, $12, $13)
`,
[
Number(taskId),
next.browser_session_id,
next.login_type,
next.nickname,
next.role_id,
next.role_name,
next.area,
next.partition_name,
next.screenshot_path,
typeof next.artifacts_json === 'string' ? next.artifacts_json : JSON.stringify(next.artifacts_json || {}),
typeof next.state_json === 'string' ? next.state_json : JSON.stringify(next.state_json || {}),
timestamp,
timestamp,
],
)
return
}
await client.query(
`
UPDATE task_runtime_contexts
SET
browser_session_id = $1,
login_type = $2,
nickname = $3,
role_id = $4,
role_name = $5,
area = $6,
partition_name = $7,
screenshot_path = $8,
artifacts_json = $9::jsonb,
state_json = $10::jsonb,
updated_at = $11
WHERE task_id = $12
`,
[
next.browser_session_id,
next.login_type,
next.nickname,
next.role_id,
next.role_name,
next.area,
next.partition_name,
next.screenshot_path,
typeof next.artifacts_json === 'string' ? next.artifacts_json : JSON.stringify(next.artifacts_json || {}),
typeof next.state_json === 'string' ? next.state_json : JSON.stringify(next.state_json || {}),
timestamp,
Number(taskId),
],
)
}
async function getTaskByIdWithExecutor(
executor: TaskQueryExecutor,
taskId: number | string,
): Promise<TaskRow | null> {
const result = await executor(
`${buildTaskSelect()}
WHERE ft.id = $1
LIMIT 1`,
[Number(taskId)],
)
return (result.rows[0] as TaskRow | undefined) || null
}
function containsRuntimeContextPatch(patch: TaskUpdatePatch = {}): boolean {
return [
'browser_session_id',
'login_type',
'nickname',
'role_id',
'role_name',
'area',
'partition_name',
'screenshot_path',
'artifacts_json',
'state_json',
].some((key) => Object.prototype.hasOwnProperty.call(patch, key))
}