fix: classify historical dashboard task types
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user