fix: classify historical dashboard task types

This commit is contained in:
inman
2026-09-01 20:15:55 +08:00
parent 2bb5827867
commit 0d63201b29
3 changed files with 281 additions and 227 deletions

View File

@@ -0,0 +1,59 @@
# Task: Classify historical dashboard task types
## Identity
- Task ID: 20260901-dashboard-historical-types-a7d3e4
- Mode: Feature
- Branch: main
- Worktree: /Users/inmanx/Documents/lwltAPI
- Base commit: 2bb58278674e628c3dbdcaaa8365237a7bb64ae6
- Owner: codex
- Status: Ready for integration
## Scope
- Correct the leadership dashboard's historical task-type dimension without rewriting historical task records.
- Derive a stable, business-readable type from the registered route, original instruction, or stored business action.
- Make every derived type available to the same clickable type filter used by the dashboard.
- Preserve the dashboard's full-range aggregates and improve the default read path after historical classification was added.
## Intent And Constraints
- “Other tasks” must be a genuine residual category, not the default for older records that predate persisted route IDs.
- Dashboard labels must describe business work and must not expose action names, codes, payload structure, or implementation details.
- Historical records, original inputs, outcomes, authorization grants, and ERP state must remain unchanged; classification is read-only at presentation time.
- A type shown in the breakdown must remain touchable: selecting it must narrow the same task/person/input/output view.
- Whole-dashboard aggregates must not be truncated by the 2,000-item keyword-search safety bound. That bound applies only when a keyword requires decrypting and searching all candidate content.
## Outcome
- Added deterministic historical classification in this order: persisted registered type, exact original directive, stored business action, then a small set of business-readable dashboard fallback types.
- Added leadership-safe fallback types for historical order deletion, hotel arrangements, roster imports, and order updates. Their internal stored action names are never returned as visible labels.
- Added fallback types to the dashboard's type selector and API validation, so breakdown rows for those categories drill through to the matching task list.
- Reduced the current 30-day standard-database residual “其他任务” bucket from 306 of 359 tasks to 2 of 359 tasks without updating any task rows.
- Preserved all 359 tasks in the full-range summary. The overall dashboard no longer inherits the 2,000-item keyword-search bound; only keyword searches retain that guard.
- Split the read path into a lightweight full-range aggregate projection and a detailed page projection. Original encrypted input is loaded for the visible page and only for historical rows that require it to disambiguate type.
- Added the organization predicate required by the existing input-history index to both leadership input-count queries.
- Reduced the measured 359-task dashboard service read from about 21.2 seconds to about 5.5 seconds on the current standard database while retaining 50 detailed task rows.
- No task data, database schema, authorization, ERP state, runtime process, or external system was changed.
## Verification
- Real standard-database read-only smoke: 359 total tasks; “其他任务” 2; “删除订单” 43; 50 detailed page rows.
- Real standard-database touch-filter smoke and API option inspection confirmed the four historical fallback types are valid selector options and use the same type filter path.
- TypeScript check: passed.
- Focused account/leadership regression: 8/8 passed.
- `node --run check:repo`: 10/10 passed.
- `node --run test:control-plane`: 153/153 passed.
- `node --run test:legacy`: 256/256 passed.
- `node --run build`: passed.
- `node --check LianSyn-platform/app.js`: passed.
- `git diff --check`: passed.
## Follow-ups
- The running 8786 control-plane process still serves the previously loaded TypeScript module. This projection update requires a separately authorized service restart before the live browser shows the corrected historical type totals.
## Promotion Candidates
- None. The canonical dashboard contract already states that task type is a leadership dimension and that the surface is business-facing; this task corrects the implementation without changing that accepted contract.

View File

@@ -1842,10 +1842,93 @@ function operationsDashboardOutcomeLabel(kind: OperationsDashboardOutcomeKind):
return '进行中';
}
function operationsDashboardBusiness(routeId: unknown): { id: string | null; label: string } {
const route = businessRouteById(routeId);
return route
? { id: route.routeId, label: route.directive }
function operationsDashboardRouteFromInstruction(value: unknown) {
const instruction = text(value).normalize('NFKC').trim();
if (!instruction) return null;
const resolved = resolveBusinessRoute(instruction).route;
if (resolved) return resolved;
const matches = BUSINESS_ROUTES.filter((route) => (
[route.directive, ...route.aliases].some((directive) => {
if (!instruction.startsWith(directive)) return false;
const remainder = instruction.slice(directive.length);
return !remainder || /^[\s::;;]/u.test(remainder);
})
));
return matches.length === 1 ? matches[0] : null;
}
function operationsDashboardOperationObject(value: unknown, fallbackText: unknown): Record<string, unknown> {
const direct = jsonObject(value);
if (Object.keys(direct).length) return direct;
const candidate = text(fallbackText).trim();
if (!candidate.startsWith('{')) return {};
try {
return jsonObject(JSON.parse(candidate));
} catch {
return {};
}
}
function operationsDashboardRouteFromOperation(value: unknown, fallbackText: unknown) {
const operation = operationsDashboardOperationObject(value, fallbackText);
const action = text(operation.action).trim();
if (!action) return null;
const candidates = BUSINESS_ROUTES.filter((route) => route.action === action);
if (candidates.length === 1) return candidates[0];
const data = jsonObject(operation.data);
if (action === 'passenger_list_import') {
const referenceKind = text(jsonObject(data.existing_refs).kind || data.target_kind).trim();
if (referenceKind === 'independent_order') return businessRouteById('passenger_list_import_independent');
if (referenceKind === 'shared_child_order') return businessRouteById('passenger_list_import_shared_child');
}
if (action === 'arrangement_hotel') {
const mode = text(jsonObject(data.arrangement).mode || data.mode).trim();
if (mode === 'create') return businessRouteById('arrangement_hotel_create');
if (mode === 'update') return businessRouteById('arrangement_hotel_update');
}
return null;
}
const OPERATIONS_DASHBOARD_FALLBACK_TYPES = [
{ key: 'dashboard_order_delete', label: '删除订单', action: 'order_delete' },
{ key: 'dashboard_arrangement_hotel', label: '酒店安排', action: 'arrangement_hotel' },
{ key: 'dashboard_passenger_list_import', label: '名单导入', action: 'passenger_list_import' },
{ key: 'dashboard_order_update', label: '订单修改', action: 'order_update' }
] as const;
function operationsDashboardFallbackType(value: unknown, fallbackText: unknown) {
const operation = operationsDashboardOperationObject(value, fallbackText);
const action = text(operation.action).trim();
return OPERATIONS_DASHBOARD_FALLBACK_TYPES.find((candidate) => candidate.action === action) || null;
}
function operationsDashboardTypeById(value: unknown) {
const key = text(value).trim();
if (!key) return null;
const route = businessRouteById(key);
if (route) return { key: route.routeId, label: route.directive };
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,
operation?: unknown
): { id: string | null; label: string } {
const route = businessRouteById(routeId)
|| operationsDashboardRouteFromInstruction(instruction)
|| operationsDashboardRouteFromOperation(operation, instruction);
if (route) return { id: route.routeId, label: route.directive };
const fallback = operationsDashboardFallbackType(operation, instruction);
return fallback
? { id: fallback.key, label: fallback.label }
: { id: null, label: '其他任务' };
}
@@ -1955,12 +2038,13 @@ function operationsDashboardTaskProjection(
config: AppConfig,
row: Record<string, unknown>
): OperationsDashboardTaskProjection {
const instruction = operationsDashboardLeadershipInstructionText(decryptText(
const rawInstruction = decryptText(
config,
row.original_text_ciphertext as string | null | undefined
));
).trim();
const instruction = operationsDashboardLeadershipInstructionText(rawInstruction);
const outcome = operationsDashboardOutcome(row);
const business = operationsDashboardBusiness(row.business_route_id);
const business = operationsDashboardBusiness(row.business_route_id, rawInstruction, row.operation);
const instructionPreview = instruction.replace(/\s+/g, ' ').trim();
const resultPreview = outcome.text.replace(/\s+/g, ' ').trim();
return {
@@ -4608,14 +4692,8 @@ export class TaskService {
where += ` AND t.created_by = $${params.length}`;
}
const businessRouteId = text(options.businessRouteId).trim();
if (businessRouteId === 'unclassified') {
where += ' AND t.business_route_id IS NULL';
} else if (businessRouteId) {
if (!businessRouteById(businessRouteId)) {
throw new TaskError('operations_dashboard_business_invalid', '看板业务类型无效。', 400);
}
params.push(businessRouteId);
where += ` AND t.business_route_id = $${params.length}`;
if (businessRouteId && businessRouteId !== 'unclassified' && !operationsDashboardTypeById(businessRouteId)) {
throw new TaskError('operations_dashboard_business_invalid', '看板业务类型无效。', 400);
}
const statusCondition = operationsStatusSql(status);
if (statusCondition) where += ` AND ${statusCondition}`;
@@ -4626,6 +4704,7 @@ export class TaskService {
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(
@@ -4635,44 +4714,60 @@ export class TaskService {
ORDER BY is_active DESC, username ASC`,
[context.organizationId]
);
const taskSelect = `t.id, t.task_id, t.original_text_ciphertext,
t.status, t.message, t.business_route_id, t.archived_at,
t.created_at, t.updated_at, t.success_receipt, t.success_receipt_at,
t.error_summary, t.error_summary_at, t.execution_result,
t.operation, t.parse_response,
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,
creator.role AS actor_role, creator.is_active AS actor_is_active,
to_char(timezone('Asia/Shanghai', t.created_at), 'YYYY-MM-DD') AS business_day,
to_char(timezone('Asia/Shanghai', t.created_at), 'YYYY-MM-DD') AS business_day`;
const taskDetailSelect = `${taskBaseSelect},
t.original_text_ciphertext, t.message, t.success_receipt, t.success_receipt_at,
t.error_summary, t.error_summary_at, t.execution_result, t.parse_response,
(SELECT COUNT(*)::int
FROM agent_session_messages input_message
WHERE input_message.task_id = t.id
WHERE input_message.organization_id = t.organization_id
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>[];
};
if (search) {
const [candidateResult, actorResult] = await Promise.all([
pool.query(
`SELECT ${taskSelect}
FROM tasks t
LEFT JOIN users creator ON creator.id = t.created_by
WHERE ${where}
ORDER BY t.created_at DESC, t.id DESC
LIMIT 2001`,
params
),
actorQuery()
]);
if (candidateResult.rows.length > 2_000) {
throw new TaskError(
'operations_dashboard_search_scope_too_large',
'关键词查询范围超过 2000 条,请先缩短日期或选择人员、业务类型后再查询。',
400
);
}
const candidateRows = candidateResult.rows as Record<string, unknown>[];
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
);
}
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 messageResult = taskRowIds.length
? await pool.query(
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
@@ -4682,8 +4777,9 @@ export class TaskService {
AND m.input_source IS DISTINCT FROM 'reparse'`,
[context.organizationId, taskRowIds]
)
: { rows: [] as Record<string, unknown>[] };
const searchValuesByTask = new Map<string, string[]>();
]);
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) || [];
@@ -4695,201 +4791,90 @@ export class TaskService {
);
searchValuesByTask.set(key, values);
}
const matched = candidateRows.flatMap((row) => {
const projection = operationsDashboardTaskProjection(this.config, row);
const matches = operationsDashboardSearchMatches(search, [
projection.task.task_id,
projection.task.creator.username,
projection.task.business_route_label,
projection.instruction,
projection.result,
...(searchValuesByTask.get(text(row.id)) || [])
]);
return matches ? [projection.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(matched);
const days = operationsDashboardBreakdownsFromTasks(
matched,
(task) => ({ key: task.business_day, label: task.business_day }),
(left, right) => right.key.localeCompare(left.key)
);
const businesses = operationsDashboardBreakdownsFromTasks(
matched,
(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 pageTasks = matched.slice(boundedOffset, boundedOffset + boundedLimit);
return {
summary,
users: operationsDashboardUsersFromTasks(matched),
actors,
days,
businesses,
business_options: businessOptions,
tasks: pageTasks,
range: { from: from.toISOString(), to: to.toISOString() },
total: matched.length,
offset: boundedOffset,
limit: boundedLimit,
has_more: boundedOffset + pageTasks.length < matched.length
};
} 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
WHERE organization_id = $1
AND id = ANY($2::uuid[])`,
[context.organizationId, classificationIds]
);
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 listParams = [...params, boundedLimit, boundedOffset];
const limitParam = params.length + 1;
const offsetParam = params.length + 2;
const [summaryResult, userResult, actorResult, dayResult, businessResult, listResult] = await Promise.all([
pool.query(
`SELECT COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE ${OPERATIONS_ACTIVE_SQL})::int AS active,
COUNT(*) FILTER (WHERE ${OPERATIONS_COMPLETED_SQL})::int AS completed,
COUNT(*) FILTER (WHERE ${OPERATIONS_ATTENTION_SQL})::int AS attention,
COUNT(*) FILTER (WHERE ${OPERATIONS_FAILED_SQL})::int AS failed,
COUNT(*) FILTER (WHERE t.status = 'cancelled')::int AS cancelled,
COUNT(*) FILTER (WHERE t.archived_at IS NOT NULL)::int AS archived,
COUNT(DISTINCT t.created_by)::int AS user_count
FROM tasks t
LEFT JOIN users creator ON creator.id = t.created_by
WHERE ${where}`,
params
),
pool.query(
`SELECT creator.id AS actor_id, creator.username AS actor_username,
creator.role AS actor_role, creator.is_active AS actor_is_active,
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE ${OPERATIONS_ACTIVE_SQL})::int AS active,
COUNT(*) FILTER (WHERE ${OPERATIONS_COMPLETED_SQL})::int AS completed,
COUNT(*) FILTER (WHERE ${OPERATIONS_ATTENTION_SQL})::int AS attention,
COUNT(*) FILTER (WHERE ${OPERATIONS_FAILED_SQL})::int AS failed,
COUNT(*) FILTER (WHERE t.status = 'cancelled')::int AS cancelled,
COUNT(*) FILTER (WHERE t.archived_at IS NOT NULL)::int AS archived,
MAX(t.updated_at) AS last_activity_at
FROM tasks t
JOIN users creator ON creator.id = t.created_by
WHERE ${where}
GROUP BY creator.id, creator.username, creator.role, creator.is_active
ORDER BY total DESC, last_activity_at DESC, creator.username ASC`,
params
),
actorQuery(),
pool.query(
`SELECT to_char(timezone('Asia/Shanghai', t.created_at), 'YYYY-MM-DD') AS dimension_key,
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE ${OPERATIONS_ACTIVE_SQL})::int AS active,
COUNT(*) FILTER (WHERE ${OPERATIONS_COMPLETED_SQL})::int AS completed,
COUNT(*) FILTER (WHERE ${OPERATIONS_ATTENTION_SQL})::int AS attention,
COUNT(*) FILTER (WHERE ${OPERATIONS_FAILED_SQL})::int AS failed,
COUNT(*) FILTER (WHERE t.status = 'cancelled')::int AS cancelled
FROM tasks t
LEFT JOIN users creator ON creator.id = t.created_by
WHERE ${where}
GROUP BY dimension_key
ORDER BY dimension_key DESC`,
params
),
pool.query(
`SELECT coalesce(t.business_route_id, 'unclassified') AS dimension_key,
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE ${OPERATIONS_ACTIVE_SQL})::int AS active,
COUNT(*) FILTER (WHERE ${OPERATIONS_COMPLETED_SQL})::int AS completed,
COUNT(*) FILTER (WHERE ${OPERATIONS_ATTENTION_SQL})::int AS attention,
COUNT(*) FILTER (WHERE ${OPERATIONS_FAILED_SQL})::int AS failed,
COUNT(*) FILTER (WHERE t.status = 'cancelled')::int AS cancelled
FROM tasks t
LEFT JOIN users creator ON creator.id = t.created_by
WHERE ${where}
GROUP BY dimension_key
ORDER BY total DESC, dimension_key ASC`,
params
),
pool.query(
`SELECT ${taskSelect}
FROM tasks t
LEFT JOIN users creator ON creator.id = t.created_by
WHERE ${where}
ORDER BY t.created_at DESC, t.id DESC
LIMIT $${limitParam} OFFSET $${offsetParam}`,
listParams
)
]);
const summaryRow = summaryResult.rows[0] as Record<string, unknown> | undefined;
const summary: PublicOperationsDashboardSummary = {
total: Number(summaryRow?.total || 0),
active: Number(summaryRow?.active || 0),
completed: Number(summaryRow?.completed || 0),
attention: Number(summaryRow?.attention || 0),
failed: Number(summaryRow?.failed || 0),
cancelled: Number(summaryRow?.cancelled || 0),
archived: Number(summaryRow?.archived || 0),
user_count: Number(summaryRow?.user_count || 0)
};
const users = (userResult.rows as Record<string, unknown>[]).map((row) => ({
actor: {
id: text(row.actor_id),
username: text(row.actor_username),
role: taskRole(row.actor_role),
is_active: databaseBoolean(row.actor_is_active)
},
total: Number(row.total || 0),
active: Number(row.active || 0),
completed: Number(row.completed || 0),
attention: Number(row.attention || 0),
failed: Number(row.failed || 0),
cancelled: Number(row.cancelled || 0),
archived: Number(row.archived || 0),
last_activity_at: row.last_activity_at
? new Date(String(row.last_activity_at)).toISOString()
: null
} satisfies PublicOperationsDashboardUser));
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 breakdown = (row: Record<string, unknown>, label: string): PublicOperationsDashboardBreakdown => ({
key: text(row.dimension_key),
label,
total: Number(row.total || 0),
active: Number(row.active || 0),
completed: Number(row.completed || 0),
attention: Number(row.attention || 0),
failed: Number(row.failed || 0),
cancelled: Number(row.cancelled || 0)
});
const days = (dayResult.rows as Record<string, unknown>[]).map((row) => (
breakdown(row, text(row.dimension_key))
));
const businesses = (businessResult.rows as Record<string, unknown>[]).map((row) => {
const key = text(row.dimension_key);
return breakdown(row, key === 'unclassified' ? '其他任务' : operationsDashboardBusiness(key).label);
});
const tasks = (listResult.rows as Record<string, unknown>[]).map((row) => (
operationsDashboardTaskProjection(this.config, row).task
));
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;
});
}
return {
summary,
users,
users: operationsDashboardUsersFromTasks(matchedTasks),
actors,
days,
businesses,
business_options: businessOptions,
tasks,
tasks: pageTasks,
range: { from: from.toISOString(), to: to.toISOString() },
total: summary.total,
total: matchedTasks.length,
offset: boundedOffset,
limit: boundedLimit,
has_more: boundedOffset + tasks.length < summary.total
has_more: boundedOffset + pageTasks.length < matchedTasks.length
};
}
@@ -4909,7 +4894,8 @@ export class TaskService {
to_char(timezone('Asia/Shanghai', t.created_at), 'YYYY-MM-DD') AS business_day,
(SELECT COUNT(*)::int
FROM agent_session_messages input_message
WHERE input_message.task_id = t.id
WHERE input_message.organization_id = t.organization_id
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
FROM tasks t

View File

@@ -130,8 +130,17 @@ test('operations dashboard is leadership-gated, business-facing, searchable, and
assert.match(tasks, /operations_dashboard_range_too_large/);
assert.match(tasks, /businessRouteId\?: string/);
assert.match(tasks, /operations_dashboard_business_invalid/);
assert.match(tasks, /LIMIT 2001/);
assert.match(tasks, /operations_dashboard_search_scope_too_large/);
assert.match(tasks, /function operationsDashboardRouteFromInstruction/);
assert.match(tasks, /function operationsDashboardRouteFromOperation/);
assert.match(tasks, /function operationsDashboardNeedsInstructionForType/);
assert.match(tasks, /if \(search && candidateResult\.rows\.length > 2_000\)/);
assert.match(tasks, /OPERATIONS_DASHBOARD_FALLBACK_TYPES/);
assert.match(tasks, /dashboard_order_delete/);
assert.match(tasks, /dashboard_arrangement_hotel/);
assert.match(tasks, /dashboard_passenger_list_import/);
assert.match(tasks, /dashboard_order_update/);
assert.match(tasks, /const routeMatches = !businessRouteId/);
assert.match(tasks, /content_ciphertext[\s\S]+decryptText\(this\.config, row\.content_ciphertext/);
assert.match(tasks, /operationsDashboardSearchMatches[\s\S]+projection\.instruction[\s\S]+projection\.result/);
assert.match(tasks, /function operationsDashboardLeadershipResultText/);