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 state_json: string | Record } 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 { const result = await query( `${buildTaskSelect()} WHERE ft.order_id = $1 ORDER BY ft.id ASC`, [Number(orderId)], ) return result.rows } export async function createTask(input: TaskCreateInput): Promise { 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 { 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 { return getTaskByIdWithExecutor(query, taskId) } export async function findTaskByClaimTokenId(claimTokenId: number | string): Promise { const result = await query( `${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 { 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( `${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 { const currentResult = await client.query( '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 { 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)) }