|
|
|
|
@@ -1895,6 +1895,9 @@ const OPERATIONS_DASHBOARD_FALLBACK_TYPES = [
|
|
|
|
|
{ key: 'dashboard_order_update', label: '订单修改', action: 'order_update' }
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
const OPERATIONS_DASHBOARD_QUERY_BUDGET_MS = 9_000;
|
|
|
|
|
const OPERATIONS_DASHBOARD_STATEMENT_TIMEOUT_MS = 5_000;
|
|
|
|
|
|
|
|
|
|
function operationsDashboardFallbackType(value: unknown, fallbackText: unknown) {
|
|
|
|
|
const operation = operationsDashboardOperationObject(value, fallbackText);
|
|
|
|
|
const action = text(operation.action).trim();
|
|
|
|
|
@@ -1909,13 +1912,6 @@ function operationsDashboardTypeById(value: unknown) {
|
|
|
|
|
return OPERATIONS_DASHBOARD_FALLBACK_TYPES.find((candidate) => candidate.key === key) || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function operationsDashboardNeedsInstructionForType(routeId: unknown, operation: unknown): boolean {
|
|
|
|
|
if (businessRouteById(routeId)) return false;
|
|
|
|
|
if (operationsDashboardRouteFromOperation(operation, '')) return false;
|
|
|
|
|
const fallback = operationsDashboardFallbackType(operation, '');
|
|
|
|
|
return !fallback || fallback.action === 'arrangement_hotel' || fallback.action === 'passenger_list_import';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function operationsDashboardBusiness(
|
|
|
|
|
routeId: unknown,
|
|
|
|
|
instruction?: unknown,
|
|
|
|
|
@@ -4663,9 +4659,30 @@ export class TaskService {
|
|
|
|
|
search?: string;
|
|
|
|
|
limit?: number;
|
|
|
|
|
offset?: number;
|
|
|
|
|
signal?: AbortSignal;
|
|
|
|
|
} = {}
|
|
|
|
|
): Promise<PublicOperationsDashboardPage> {
|
|
|
|
|
this.requireOperationsDashboardAccess(context);
|
|
|
|
|
const requestStartedAt = Date.now();
|
|
|
|
|
const queryDeadlineAt = requestStartedAt + OPERATIONS_DASHBOARD_QUERY_BUDGET_MS;
|
|
|
|
|
const timeoutError = () => new TaskError(
|
|
|
|
|
'operations_dashboard_query_timeout',
|
|
|
|
|
'看板查询耗时过长,请缩短日期范围或增加筛选条件后重试。',
|
|
|
|
|
503
|
|
|
|
|
);
|
|
|
|
|
const logTimeout = (errorCode: string) => this.logger.warn({
|
|
|
|
|
diagnostic_event: 'operations_dashboard.timeout',
|
|
|
|
|
diagnostic_stage: 'operations_dashboard',
|
|
|
|
|
request_id: context.requestId,
|
|
|
|
|
duration_ms: Date.now() - requestStartedAt,
|
|
|
|
|
error_code: errorCode
|
|
|
|
|
}, 'operations dashboard timed out');
|
|
|
|
|
const assertQueryAvailable = () => {
|
|
|
|
|
if (options.signal?.aborted) {
|
|
|
|
|
throw new TaskError('operations_dashboard_request_aborted', '看板请求已取消。', 499);
|
|
|
|
|
}
|
|
|
|
|
if (Date.now() >= queryDeadlineAt) throw timeoutError();
|
|
|
|
|
};
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const to = options.to ? new Date(options.to) : now;
|
|
|
|
|
const from = options.from
|
|
|
|
|
@@ -4694,25 +4711,23 @@ export class TaskService {
|
|
|
|
|
if (businessRouteId && businessRouteId !== 'unclassified' && !operationsDashboardTypeById(businessRouteId)) {
|
|
|
|
|
throw new TaskError('operations_dashboard_business_invalid', '看板业务类型无效。', 400);
|
|
|
|
|
}
|
|
|
|
|
if (businessRouteId === 'unclassified') {
|
|
|
|
|
where += ' AND t.business_route_id IS NULL';
|
|
|
|
|
} else if (businessRouteId) {
|
|
|
|
|
params.push(businessRouteId);
|
|
|
|
|
where += ` AND (t.business_route_id = $${params.length} OR t.business_route_id IS NULL)`;
|
|
|
|
|
}
|
|
|
|
|
const statusCondition = operationsStatusSql(status);
|
|
|
|
|
if (statusCondition) where += ` AND ${statusCondition}`;
|
|
|
|
|
const search = text(options.search).trim().toLowerCase();
|
|
|
|
|
|
|
|
|
|
const boundedLimit = Math.max(1, Math.min(100, Math.trunc(options.limit || 50)));
|
|
|
|
|
const boundedLimit = Math.max(1, Math.min(100, Math.trunc(options.limit || 20)));
|
|
|
|
|
const boundedOffset = Math.max(0, Math.min(1_000_000, Math.trunc(options.offset || 0)));
|
|
|
|
|
const pool = getPool(this.config);
|
|
|
|
|
const businessOptions = [
|
|
|
|
|
...BUSINESS_ROUTES.map((route) => ({ key: route.routeId, label: route.directive })),
|
|
|
|
|
...OPERATIONS_DASHBOARD_FALLBACK_TYPES.map(({ key, label }) => ({ key, label })),
|
|
|
|
|
{ key: 'unclassified', label: '其他任务' }
|
|
|
|
|
];
|
|
|
|
|
const actorQuery = () => pool.query(
|
|
|
|
|
`SELECT id, username, role, is_active
|
|
|
|
|
FROM users
|
|
|
|
|
WHERE organization_id = $1
|
|
|
|
|
ORDER BY is_active DESC, username ASC`,
|
|
|
|
|
[context.organizationId]
|
|
|
|
|
);
|
|
|
|
|
const taskBaseSelect = `t.id, t.task_id, t.status, t.business_route_id, t.archived_at,
|
|
|
|
|
t.created_at, t.updated_at, t.operation,
|
|
|
|
|
creator.id AS actor_id, creator.username AS actor_username,
|
|
|
|
|
@@ -4727,154 +4742,217 @@ export class TaskService {
|
|
|
|
|
AND input_message.task_id = t.id
|
|
|
|
|
AND input_message.role = 'user'
|
|
|
|
|
AND input_message.input_source IS DISTINCT FROM 'reparse') AS input_turn_count`;
|
|
|
|
|
const loadDetailedRows = async (taskRowIds: string[]) => {
|
|
|
|
|
if (!taskRowIds.length) return [] as Record<string, unknown>[];
|
|
|
|
|
const detailResult = await pool.query(
|
|
|
|
|
`SELECT ${taskDetailSelect}
|
|
|
|
|
FROM tasks t
|
|
|
|
|
LEFT JOIN users creator ON creator.id = t.created_by
|
|
|
|
|
WHERE t.organization_id = $1
|
|
|
|
|
AND t.id = ANY($2::uuid[])`,
|
|
|
|
|
[context.organizationId, taskRowIds]
|
|
|
|
|
);
|
|
|
|
|
return detailResult.rows as Record<string, unknown>[];
|
|
|
|
|
};
|
|
|
|
|
const taskSearchSelect = `${taskBaseSelect},
|
|
|
|
|
t.original_text_ciphertext, t.message, t.success_receipt, t.success_receipt_at,
|
|
|
|
|
t.error_summary, t.error_summary_at,
|
|
|
|
|
CASE
|
|
|
|
|
WHEN t.status = 'completed'
|
|
|
|
|
AND (t.success_receipt IS NULL OR t.success_receipt = '{}'::jsonb)
|
|
|
|
|
THEN t.execution_result
|
|
|
|
|
ELSE NULL
|
|
|
|
|
END AS execution_result,
|
|
|
|
|
CASE WHEN t.status = 'awaiting_user_input' THEN t.parse_response ELSE NULL END AS parse_response`;
|
|
|
|
|
const taskCandidateSelect = `${taskBaseSelect},
|
|
|
|
|
CASE WHEN t.business_route_id IS NULL THEN t.original_text_ciphertext ELSE NULL END AS original_text_ciphertext`;
|
|
|
|
|
|
|
|
|
|
const [candidateResult, actorResult] = await Promise.all([
|
|
|
|
|
pool.query(
|
|
|
|
|
`SELECT ${taskBaseSelect}
|
|
|
|
|
FROM tasks t
|
|
|
|
|
LEFT JOIN users creator ON creator.id = t.created_by
|
|
|
|
|
WHERE ${where}
|
|
|
|
|
ORDER BY t.created_at DESC, t.id DESC`,
|
|
|
|
|
params
|
|
|
|
|
),
|
|
|
|
|
actorQuery()
|
|
|
|
|
]);
|
|
|
|
|
if (search && candidateResult.rows.length > 2_000) {
|
|
|
|
|
throw new TaskError(
|
|
|
|
|
'operations_dashboard_search_scope_too_large',
|
|
|
|
|
'关键词查询范围超过 2000 项任务,请先缩短日期或选择人员、任务类型后再查询。',
|
|
|
|
|
400
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
assertQueryAvailable();
|
|
|
|
|
return await withTransaction(this.config, async (client) => {
|
|
|
|
|
assertQueryAvailable();
|
|
|
|
|
await client.query('SET TRANSACTION READ ONLY');
|
|
|
|
|
await client.query(`SET LOCAL statement_timeout = '${OPERATIONS_DASHBOARD_STATEMENT_TIMEOUT_MS}ms'`);
|
|
|
|
|
|
|
|
|
|
const candidateRows = candidateResult.rows as Record<string, unknown>[];
|
|
|
|
|
const searchValuesByTask = new Map<string, string[]>();
|
|
|
|
|
let projectionRows = candidateRows;
|
|
|
|
|
if (search && candidateRows.length) {
|
|
|
|
|
const taskRowIds = candidateRows.map((row) => text(row.id)).filter(Boolean);
|
|
|
|
|
const [detailedRows, messageResult] = await Promise.all([
|
|
|
|
|
loadDetailedRows(taskRowIds),
|
|
|
|
|
pool.query(
|
|
|
|
|
`SELECT m.task_id, m.content_ciphertext, actor.username AS actor_username
|
|
|
|
|
FROM agent_session_messages m
|
|
|
|
|
LEFT JOIN users actor ON actor.id = m.actor_user_id
|
|
|
|
|
WHERE m.organization_id = $1
|
|
|
|
|
AND m.task_id = ANY($2::uuid[])
|
|
|
|
|
AND m.role = 'user'
|
|
|
|
|
AND m.input_source IS DISTINCT FROM 'reparse'`,
|
|
|
|
|
[context.organizationId, taskRowIds]
|
|
|
|
|
)
|
|
|
|
|
]);
|
|
|
|
|
const detailedById = new Map(detailedRows.map((row) => [text(row.id), row]));
|
|
|
|
|
projectionRows = candidateRows.map((row) => detailedById.get(text(row.id)) || row);
|
|
|
|
|
for (const row of messageResult.rows as Record<string, unknown>[]) {
|
|
|
|
|
const key = text(row.task_id);
|
|
|
|
|
const values = searchValuesByTask.get(key) || [];
|
|
|
|
|
values.push(
|
|
|
|
|
text(row.actor_username),
|
|
|
|
|
operationsDashboardLeadershipInstructionText(
|
|
|
|
|
decryptText(this.config, row.content_ciphertext as string | null | undefined)
|
|
|
|
|
)
|
|
|
|
|
const dashboardQuery = async (
|
|
|
|
|
queryStage: string,
|
|
|
|
|
sql: string,
|
|
|
|
|
queryParams: unknown[] = []
|
|
|
|
|
) => {
|
|
|
|
|
assertQueryAvailable();
|
|
|
|
|
const startedAt = Date.now();
|
|
|
|
|
const result = await client.query(sql, queryParams);
|
|
|
|
|
this.logger.info({
|
|
|
|
|
diagnostic_event: 'operations_dashboard.query.completed',
|
|
|
|
|
diagnostic_stage: 'operations_dashboard_query',
|
|
|
|
|
request_id: context.requestId,
|
|
|
|
|
query_stage: queryStage,
|
|
|
|
|
duration_ms: Date.now() - startedAt,
|
|
|
|
|
row_count: result.rowCount ?? 0
|
|
|
|
|
}, 'operations dashboard query completed');
|
|
|
|
|
assertQueryAvailable();
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
const loadDetailedRows = async (taskRowIds: string[]) => {
|
|
|
|
|
if (!taskRowIds.length) return [] as Record<string, unknown>[];
|
|
|
|
|
const detailResult = await dashboardQuery(
|
|
|
|
|
'page_details',
|
|
|
|
|
`SELECT ${taskDetailSelect}
|
|
|
|
|
FROM tasks t
|
|
|
|
|
LEFT JOIN users creator ON creator.id = t.created_by
|
|
|
|
|
WHERE t.organization_id = $1
|
|
|
|
|
AND t.id = ANY($2::uuid[])`,
|
|
|
|
|
[context.organizationId, taskRowIds]
|
|
|
|
|
);
|
|
|
|
|
return detailResult.rows as Record<string, unknown>[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const candidateResult = await dashboardQuery(
|
|
|
|
|
'candidates',
|
|
|
|
|
`SELECT ${search ? taskSearchSelect : taskCandidateSelect}
|
|
|
|
|
FROM tasks t
|
|
|
|
|
LEFT JOIN users creator ON creator.id = t.created_by
|
|
|
|
|
WHERE ${where}
|
|
|
|
|
ORDER BY t.created_at DESC, t.id DESC`,
|
|
|
|
|
params
|
|
|
|
|
);
|
|
|
|
|
searchValuesByTask.set(key, values);
|
|
|
|
|
}
|
|
|
|
|
} else if (candidateRows.length) {
|
|
|
|
|
const classificationIds = candidateRows
|
|
|
|
|
.filter((row) => operationsDashboardNeedsInstructionForType(row.business_route_id, row.operation))
|
|
|
|
|
.map((row) => text(row.id))
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
if (classificationIds.length) {
|
|
|
|
|
const classificationResult = await pool.query(
|
|
|
|
|
`SELECT id, original_text_ciphertext
|
|
|
|
|
FROM tasks
|
|
|
|
|
if (search && candidateResult.rows.length > 2_000) {
|
|
|
|
|
throw new TaskError(
|
|
|
|
|
'operations_dashboard_search_scope_too_large',
|
|
|
|
|
'关键词查询范围超过 2000 项任务,请先缩短日期或选择人员、任务类型后再查询。',
|
|
|
|
|
400
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const actorResult = await dashboardQuery(
|
|
|
|
|
'actors',
|
|
|
|
|
`SELECT id, username, role, is_active
|
|
|
|
|
FROM users
|
|
|
|
|
WHERE organization_id = $1
|
|
|
|
|
AND id = ANY($2::uuid[])`,
|
|
|
|
|
[context.organizationId, classificationIds]
|
|
|
|
|
ORDER BY is_active DESC, username ASC`,
|
|
|
|
|
[context.organizationId]
|
|
|
|
|
);
|
|
|
|
|
const instructionsById = new Map(
|
|
|
|
|
(classificationResult.rows as Record<string, unknown>[])
|
|
|
|
|
.map((row) => [text(row.id), row.original_text_ciphertext])
|
|
|
|
|
);
|
|
|
|
|
projectionRows = candidateRows.map((row) => ({
|
|
|
|
|
...row,
|
|
|
|
|
original_text_ciphertext: instructionsById.get(text(row.id))
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const candidateRows = candidateResult.rows as Record<string, unknown>[];
|
|
|
|
|
const projectedCandidates = candidateRows.flatMap((row) => {
|
|
|
|
|
const projection = operationsDashboardTaskProjection(this.config, row);
|
|
|
|
|
const routeMatches = !businessRouteId
|
|
|
|
|
|| (businessRouteId === 'unclassified'
|
|
|
|
|
? !projection.task.business_route_id
|
|
|
|
|
: projection.task.business_route_id === businessRouteId);
|
|
|
|
|
if (!routeMatches) return [];
|
|
|
|
|
const directSearchMatch = !search || operationsDashboardSearchMatches(search, [
|
|
|
|
|
projection.task.creator.username,
|
|
|
|
|
projection.task.business_route_label,
|
|
|
|
|
projection.instruction,
|
|
|
|
|
projection.result
|
|
|
|
|
]);
|
|
|
|
|
return [{ row, task: projection.task, directSearchMatch }];
|
|
|
|
|
});
|
|
|
|
|
assertQueryAvailable();
|
|
|
|
|
const searchValuesByTask = new Map<string, string[]>();
|
|
|
|
|
if (search && projectedCandidates.length) {
|
|
|
|
|
const taskRowIds = projectedCandidates
|
|
|
|
|
.filter(({ directSearchMatch }) => !directSearchMatch)
|
|
|
|
|
.map(({ row }) => text(row.id))
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
if (taskRowIds.length) {
|
|
|
|
|
const messageResult = await dashboardQuery(
|
|
|
|
|
'search_messages',
|
|
|
|
|
`SELECT m.task_id, m.content_ciphertext, actor.username AS actor_username
|
|
|
|
|
FROM agent_session_messages m
|
|
|
|
|
LEFT JOIN users actor ON actor.id = m.actor_user_id
|
|
|
|
|
WHERE m.organization_id = $1
|
|
|
|
|
AND m.task_id = ANY($2::uuid[])
|
|
|
|
|
AND m.role = 'user'
|
|
|
|
|
AND m.input_source IS DISTINCT FROM 'reparse'`,
|
|
|
|
|
[context.organizationId, taskRowIds]
|
|
|
|
|
);
|
|
|
|
|
for (const row of messageResult.rows as Record<string, unknown>[]) {
|
|
|
|
|
const key = text(row.task_id);
|
|
|
|
|
const values = searchValuesByTask.get(key) || [];
|
|
|
|
|
values.push(
|
|
|
|
|
text(row.actor_username),
|
|
|
|
|
operationsDashboardLeadershipInstructionText(
|
|
|
|
|
decryptText(this.config, row.content_ciphertext as string | null | undefined)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
searchValuesByTask.set(key, values);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const matched = projectionRows.flatMap((row) => {
|
|
|
|
|
const projection = operationsDashboardTaskProjection(this.config, row);
|
|
|
|
|
const routeMatches = !businessRouteId
|
|
|
|
|
|| (businessRouteId === 'unclassified'
|
|
|
|
|
? !projection.task.business_route_id
|
|
|
|
|
: projection.task.business_route_id === businessRouteId);
|
|
|
|
|
if (!routeMatches) return [];
|
|
|
|
|
if (search && !operationsDashboardSearchMatches(search, [
|
|
|
|
|
projection.task.creator.username,
|
|
|
|
|
projection.task.business_route_label,
|
|
|
|
|
projection.instruction,
|
|
|
|
|
projection.result,
|
|
|
|
|
...(searchValuesByTask.get(text(row.id)) || [])
|
|
|
|
|
])) return [];
|
|
|
|
|
return [{ row, task: projection.task }];
|
|
|
|
|
});
|
|
|
|
|
const matchedTasks = matched.map(({ task }) => task);
|
|
|
|
|
const actors = (actorResult.rows as Record<string, unknown>[]).map((row) => ({
|
|
|
|
|
id: text(row.id),
|
|
|
|
|
username: text(row.username),
|
|
|
|
|
role: taskRole(row.role),
|
|
|
|
|
is_active: databaseBoolean(row.is_active)
|
|
|
|
|
} satisfies PublicOperationsDashboardActor));
|
|
|
|
|
const summary = operationsDashboardSummaryFromTasks(matchedTasks);
|
|
|
|
|
const days = operationsDashboardBreakdownsFromTasks(
|
|
|
|
|
matchedTasks,
|
|
|
|
|
(task) => ({ key: task.business_day, label: task.business_day }),
|
|
|
|
|
(left, right) => right.key.localeCompare(left.key)
|
|
|
|
|
);
|
|
|
|
|
const businesses = operationsDashboardBreakdownsFromTasks(
|
|
|
|
|
matchedTasks,
|
|
|
|
|
(task) => ({
|
|
|
|
|
key: task.business_route_id || 'unclassified',
|
|
|
|
|
label: task.business_route_label
|
|
|
|
|
}),
|
|
|
|
|
(left, right) => right.total - left.total || left.label.localeCompare(right.label, 'zh-CN')
|
|
|
|
|
);
|
|
|
|
|
const pageMatches = matched.slice(boundedOffset, boundedOffset + boundedLimit);
|
|
|
|
|
let pageTasks = pageMatches.map(({ task }) => task);
|
|
|
|
|
if (!search && pageMatches.length) {
|
|
|
|
|
const detailedRows = await loadDetailedRows(pageMatches.map(({ row }) => text(row.id)));
|
|
|
|
|
const detailedById = new Map(detailedRows.map((row) => [text(row.id), row]));
|
|
|
|
|
pageTasks = pageMatches.map(({ row, task }) => {
|
|
|
|
|
const detailed = detailedById.get(text(row.id));
|
|
|
|
|
return detailed ? operationsDashboardTaskProjection(this.config, detailed).task : task;
|
|
|
|
|
assertQueryAvailable();
|
|
|
|
|
const matched = projectedCandidates.filter(({ row, directSearchMatch }) => (
|
|
|
|
|
directSearchMatch
|
|
|
|
|
|| operationsDashboardSearchMatches(search, searchValuesByTask.get(text(row.id)) || [])
|
|
|
|
|
));
|
|
|
|
|
assertQueryAvailable();
|
|
|
|
|
const matchedTasks = matched.map(({ task }) => task);
|
|
|
|
|
const actors = (actorResult.rows as Record<string, unknown>[]).map((row) => ({
|
|
|
|
|
id: text(row.id),
|
|
|
|
|
username: text(row.username),
|
|
|
|
|
role: taskRole(row.role),
|
|
|
|
|
is_active: databaseBoolean(row.is_active)
|
|
|
|
|
} satisfies PublicOperationsDashboardActor));
|
|
|
|
|
const summary = operationsDashboardSummaryFromTasks(matchedTasks);
|
|
|
|
|
const days = operationsDashboardBreakdownsFromTasks(
|
|
|
|
|
matchedTasks,
|
|
|
|
|
(task) => ({ key: task.business_day, label: task.business_day }),
|
|
|
|
|
(left, right) => right.key.localeCompare(left.key)
|
|
|
|
|
);
|
|
|
|
|
const businesses = operationsDashboardBreakdownsFromTasks(
|
|
|
|
|
matchedTasks,
|
|
|
|
|
(task) => ({
|
|
|
|
|
key: task.business_route_id || 'unclassified',
|
|
|
|
|
label: task.business_route_label
|
|
|
|
|
}),
|
|
|
|
|
(left, right) => right.total - left.total || left.label.localeCompare(right.label, 'zh-CN')
|
|
|
|
|
);
|
|
|
|
|
const pageMatches = matched.slice(boundedOffset, boundedOffset + boundedLimit);
|
|
|
|
|
let pageTasks = pageMatches.map(({ task }) => task);
|
|
|
|
|
if (pageMatches.length) {
|
|
|
|
|
const detailedRows = await loadDetailedRows(pageMatches.map(({ row }) => text(row.id)));
|
|
|
|
|
const detailedById = new Map(detailedRows.map((row) => [text(row.id), row]));
|
|
|
|
|
pageTasks = pageMatches.map(({ row, task }) => {
|
|
|
|
|
const detailed = detailedById.get(text(row.id));
|
|
|
|
|
return detailed ? operationsDashboardTaskProjection(this.config, detailed).task : task;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
assertQueryAvailable();
|
|
|
|
|
this.logger.info({
|
|
|
|
|
diagnostic_event: 'operations_dashboard.completed',
|
|
|
|
|
diagnostic_stage: 'operations_dashboard',
|
|
|
|
|
request_id: context.requestId,
|
|
|
|
|
duration_ms: Date.now() - requestStartedAt,
|
|
|
|
|
candidate_count: candidateRows.length,
|
|
|
|
|
matched_count: matchedTasks.length,
|
|
|
|
|
page_count: pageTasks.length,
|
|
|
|
|
keyword_filter: Boolean(search),
|
|
|
|
|
business_filter: Boolean(businessRouteId),
|
|
|
|
|
actor_filter: Boolean(actorUserId),
|
|
|
|
|
status_filter: status !== 'all'
|
|
|
|
|
}, 'operations dashboard completed');
|
|
|
|
|
return {
|
|
|
|
|
summary,
|
|
|
|
|
users: operationsDashboardUsersFromTasks(matchedTasks),
|
|
|
|
|
actors,
|
|
|
|
|
days,
|
|
|
|
|
businesses,
|
|
|
|
|
business_options: businessOptions,
|
|
|
|
|
tasks: pageTasks,
|
|
|
|
|
range: { from: from.toISOString(), to: to.toISOString() },
|
|
|
|
|
total: matchedTasks.length,
|
|
|
|
|
offset: boundedOffset,
|
|
|
|
|
limit: boundedLimit,
|
|
|
|
|
has_more: boundedOffset + pageTasks.length < matchedTasks.length
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof TaskError) {
|
|
|
|
|
if (error.code === 'operations_dashboard_query_timeout') logTimeout(error.code);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
const databaseCode = error && typeof error === 'object' && 'code' in error
|
|
|
|
|
? text((error as { code?: unknown }).code)
|
|
|
|
|
: '';
|
|
|
|
|
const errorMessage = error instanceof Error ? error.message : text(error);
|
|
|
|
|
if (options.signal?.aborted) {
|
|
|
|
|
throw new TaskError('operations_dashboard_request_aborted', '看板请求已取消。', 499);
|
|
|
|
|
}
|
|
|
|
|
if (databaseCode === '57014' || /timeout exceeded when trying to connect|query read timeout/i.test(errorMessage)) {
|
|
|
|
|
logTimeout(databaseCode || 'database_timeout');
|
|
|
|
|
throw timeoutError();
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
summary,
|
|
|
|
|
users: operationsDashboardUsersFromTasks(matchedTasks),
|
|
|
|
|
actors,
|
|
|
|
|
days,
|
|
|
|
|
businesses,
|
|
|
|
|
business_options: businessOptions,
|
|
|
|
|
tasks: pageTasks,
|
|
|
|
|
range: { from: from.toISOString(), to: to.toISOString() },
|
|
|
|
|
total: matchedTasks.length,
|
|
|
|
|
offset: boundedOffset,
|
|
|
|
|
limit: boundedLimit,
|
|
|
|
|
has_more: boundedOffset + pageTasks.length < matchedTasks.length
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getOperationsDashboardTask(
|
|
|
|
|
|