吸收数字员工日报产物闭环

This commit is contained in:
baiyanyun
2026-06-09 22:16:03 +08:00
parent ba806f779e
commit 0d2008b35c
11 changed files with 588 additions and 180 deletions

View File

@@ -12,6 +12,7 @@ import type {
DebugStoreResponse,
DebugTask,
DiagResult,
DigitalEmployeeDailyReport,
DigitalEmployeeDecision,
DigitalEmployeeDuty,
DigitalEmployeeManagedService,
@@ -61,6 +62,7 @@ declare global {
listRouteDecisions?: (options?: { limit?: number }) => Promise<RouteDecisionRecord[]>;
recordRouteDecision?: (input: QimingclawRouteDecisionInput) => Promise<RouteDecisionRecord>;
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>;
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
};
};
@@ -209,6 +211,31 @@ interface QimingclawGovernanceCommandRecord {
result?: Record<string, unknown> | null;
}
interface QimingclawDailyReportRecordInput {
reportId?: string | null;
date: string;
title: string;
summary: string;
totals: Record<string, unknown>;
detailLines: string[];
artifactSources?: unknown[];
filename: string;
textContent: string;
generatedAt?: string | null;
reportScheduleTime?: string | null;
source?: string | null;
model?: string | null;
}
interface QimingclawDailyReportRecordResult {
reportId: string;
planId: string;
taskId: string;
runId: string;
artifactId: string;
eventId: string;
}
interface QimingclawRouteDecisionInput {
id?: string | null;
idempotencyKey?: string | null;
@@ -598,6 +625,17 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'POST' && pathname === '/api/digital-employee/workday/actions') {
return await handleWorkdayAction(parseJsonBody<DigitalEmployeeWorkdayActionRequest>(options.body), await readQimingclawSnapshot()) as T;
}
const dailyReportDetailMatch = pathname.match(/^\/api\/digital-employee\/daily-reports\/([^/]+)$/);
if (method === 'GET' && dailyReportDetailMatch) {
const reportId = decodeURIComponent(dailyReportDetailMatch[1] || '');
const item = dailyReportRows(await readQimingclawSnapshot())
.find((row) => stringValue(row.report_id) === reportId || stringValue(row.artifact_id) === reportId) ?? null;
return { item, source: BUSINESS_VIEW_SOURCE } as T;
}
if (method === 'GET' && pathname === '/api/digital-employee/daily-reports') {
const rows = filterDailyReportRows(dailyReportRows(await readQimingclawSnapshot()), url.searchParams);
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
}
const businessPlanDetailMatch = pathname.match(/^\/api\/digital-employee\/plans\/([^/]+)$/);
if (method === 'GET' && businessPlanDetailMatch) {
const planId = decodeURIComponent(businessPlanDetailMatch[1] || '');
@@ -2228,6 +2266,57 @@ function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessView
});
}
function dailyReportRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
return buildArtifacts(snapshot)
.filter(isDailyReportArtifact)
.map((artifact) => {
const payload = asRecord(artifact.payload) ?? {};
const artifactId = stringValue(artifact.artifact_id) || stringValue(payload.artifact_id) || stringValue(artifact.subject_id);
const reportId = stringValue(payload.report_id) || stringValue(artifact.report_id) || artifactId.replace(/:artifact:text$/, '');
const generatedAt = stringValue(payload.generated_at) || stringValue(artifact.created_at) || new Date().toISOString();
const date = stringValue(payload.date) || generatedAt.slice(0, 10);
return {
report_id: reportId,
artifact_id: artifactId,
event_id: stringValue(payload.event_id) || `${reportId}:event:generated`,
date,
title: stringValue(payload.title) || stringValue(artifact.name) || `${date} 数字员工日报`,
status: stringValue(payload.status) || 'ready',
summary: stringValue(payload.summary) || stringValue(artifact.value) || '',
totals: asRecord(payload.totals) ?? {},
detail_lines: arrayValue(payload.detail_lines),
artifact_sources: arrayValue(payload.artifact_sources),
filename: stringValue(payload.filename) || stringValue(artifact.name) || dailyReportFilename(date),
format: stringValue(payload.format) || 'text',
text_content: stringValue(payload.text_content) || stringValue(artifact.value),
generated_at: generatedAt,
sync_status: stringValue(artifact.sync_status) || null,
entity_type: 'artifact',
entity_id: artifactId,
plan_id: stringValue(artifact.plan_id) || null,
task_id: stringValue(artifact.task_id) || null,
run_id: stringValue(artifact.run_id) || null,
created_at: stringValue(artifact.created_at) || generatedAt,
updated_at: generatedAt,
payload,
};
})
.sort((left, right) => stringValue(right.generated_at).localeCompare(stringValue(left.generated_at)));
}
function isDailyReportArtifact(artifact: ArtifactEntry): boolean {
const payload = asRecord(artifact.payload) ?? {};
return stringValue(artifact.kind) === 'daily_report'
|| stringValue(payload.kind) === 'daily_report'
|| Boolean(stringValue(payload.report_id) && stringValue(payload.text_content));
}
function filterDailyReportRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] {
const date = stringValue(searchParams.get('date'));
return filterBusinessRows(rows, searchParams)
.filter((row) => !date || stringValue(row.date) === date);
}
function filterBusinessRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] {
const entityId = stringValue(searchParams.get('entity_id'));
const remoteId = stringValue(searchParams.get('remote_id'));
@@ -2282,6 +2371,7 @@ function businessRowEntityIds(row: BusinessViewRow): string[] {
row.event_id,
row.artifact_id,
row.approval_id,
row.report_id,
]);
}
@@ -3191,6 +3281,7 @@ function buildWorkday(date: string, snapshot: QimingclawSnapshot | null): Digita
const hasLiveRuntime = hasRuntimeRecords(snapshot);
const sync = snapshot?.sync;
const artifactSources = reportArtifactSources(buildArtifacts(snapshot));
const runDetails = buildRunDetails(events);
const managedServices = buildManagedServices(snapshot);
const managedServiceIssues = managedServices.filter((service) => service.requires_human_action || service.status === 'degraded');
const runningManagedServiceCount = managedServices.filter((service) => service.running).length;
@@ -3260,7 +3351,7 @@ function buildWorkday(date: string, snapshot: QimingclawSnapshot | null): Digita
duties,
events,
execution_summaries: executionSummaries,
run_details: buildRunDetails(events),
run_details: runDetails,
decisions: buildWorkdayDecisions(state, date, snapshot),
delivery: {
headline: hasLiveRuntime ? 'qimingclaw 本地记录已接入' : confirmed ? 'qimingclaw 执行链路已接入' : '等待确认任务设置',
@@ -3295,34 +3386,117 @@ function buildWorkday(date: string, snapshot: QimingclawSnapshot | null): Digita
})),
],
},
daily_report: {
date,
title: `${displayDate(date)} 飞天数字员工日报`,
report_schedule_time: state.reportScheduleTime,
status: generatedAt ? 'ready' : 'not_generated',
generated_at: generatedAt,
generated_by: generatedAt ? 'ai' : 'fallback',
model: 'qimingclaw-adapter',
source: 'qimingclaw',
summary: generatedAt
? `今日已完成数字员工页面到 qimingclaw adapter 的接入,并读取了 qimingclaw 服务状态。${syncLine(snapshot)}`
: '日报将在生成后汇总任务选择、运行明细和后续接入建议。',
totals: {
task_count: selectedCount,
execution_count: events.length,
success_count: successCount,
failure_count: failureCount,
running_count: runningCount,
},
task_sections: executionSummaries,
detail_lines: events.map((event) => `${event.time} ${event.task_title}${event.detail}`),
artifact_sources: artifactSources,
pdf_available: Boolean(generatedAt),
pdf_url: null,
},
daily_report: buildDailyReportProjection({ date, state, generatedAt, selectedCount, events, executionSummaries, runDetails, artifactSources, successCount, failureCount, runningCount, snapshot }),
};
}
function buildDailyReportProjection(input: {
date: string;
state: AdapterState;
generatedAt: string | null;
selectedCount: number;
events: DigitalEmployeeWorkEvent[];
executionSummaries: DigitalEmployeeTaskExecutionSummary[];
runDetails: DigitalEmployeeRunDetail[];
artifactSources: ReturnType<typeof reportArtifactSources>;
successCount: number;
failureCount: number;
runningCount: number;
snapshot: QimingclawSnapshot | null;
}): DigitalEmployeeDailyReport {
const title = `${displayDate(input.date)} 飞天数字员工日报`;
const summary = input.generatedAt
? `今日已完成数字员工页面到 qimingclaw adapter 的接入,并读取了 qimingclaw 服务状态。${syncLine(input.snapshot)}`
: '日报将在生成后汇总任务选择、运行明细和后续接入建议。';
const totals = {
task_count: input.selectedCount,
execution_count: input.events.length,
success_count: input.successCount,
failure_count: input.failureCount,
running_count: input.runningCount,
};
const detailLines = input.events.map((event) => `${event.time} ${event.task_title}${event.detail}`);
const reportId = input.generatedAt ? dailyReportId(input.date, input.generatedAt) : null;
const downloadFilename = dailyReportFilename(input.date);
const baseReport: DigitalEmployeeDailyReport = {
report_id: reportId,
artifact_id: reportId ? `${reportId}:artifact:text` : null,
event_id: reportId ? `${reportId}:event:generated` : null,
date: input.date,
title,
report_schedule_time: input.state.reportScheduleTime,
status: input.generatedAt ? 'ready' : 'not_generated',
generated_at: input.generatedAt,
generated_by: input.generatedAt ? 'ai' : 'fallback',
model: 'qimingclaw-adapter',
source: 'qimingclaw',
summary,
totals,
task_sections: input.executionSummaries,
detail_lines: detailLines,
artifact_sources: input.artifactSources,
download_filename: downloadFilename,
text_content: null,
pdf_available: Boolean(input.generatedAt),
pdf_url: null,
};
return {
...baseReport,
text_content: input.generatedAt
? buildDailyReportText(baseReport, input.runDetails, input.artifactSources)
: null,
};
}
function buildDailyReportText(
report: DigitalEmployeeDailyReport,
details: DigitalEmployeeRunDetail[],
artifacts: ReturnType<typeof reportArtifactSources>,
): string {
return [
report.title || `${report.date} 数字员工日报`,
`日期:${report.date}`,
`生成时间:${report.generated_at || '暂未生成'}`,
`生成来源:${report.generated_by === 'ai' ? 'AI' : 'qimingclaw adapter'}`,
'',
'## 摘要',
report.summary || '暂无摘要。',
'',
'## 指标',
`任务总数:${report.totals.task_count}`,
`执行次数:${report.totals.execution_count}`,
`成功次数:${report.totals.success_count}`,
`失败次数:${report.totals.failure_count}`,
`运行中:${report.totals.running_count ?? 0}`,
'',
'## 任务汇总',
...(report.task_sections.length > 0
? report.task_sections.map((section) => `- ${section.title}${section.latest_status_label},执行 ${section.total_count} 次,成功 ${section.success_count},失败 ${section.failure_count}${section.ai_summary || section.business_summary || ''}`)
: ['暂无任务汇总。']),
'',
'## 运行明细',
...(details.length > 0
? details.map((detail) => `- ${detail.time || '--:--'} ${detail.task_name} / ${detail.step_name || detail.operation_name || '任务执行'}${detail.status_label}${detail.business_detail}`)
: ['暂无运行明细。']),
'',
'## 产物来源',
...(artifacts.length > 0
? artifacts.map((artifact) => `- ${artifact.name}${artifact.source_label || artifact.source_type || '未知来源'}${artifact.uri ? `${artifact.uri}` : ''}`)
: ['暂无产物来源。']),
'',
'## 详情',
...((report.detail_lines ?? []).length > 0 ? report.detail_lines : ['暂无详情。']),
].join('\n');
}
function dailyReportFilename(date: string): string {
return `qimingclaw-digital-report-${date}.txt`;
}
function dailyReportId(date: string, generatedAt: string): string {
return `daily-report:${date}:${slugifyIdPart(generatedAt)}`;
}
function reportArtifactSources(artifacts: ArtifactEntry[]) {
return artifacts.slice(0, 20).map((artifact) => ({
id: stringValue(artifact.artifact_id) || `${stringValue(artifact.subject_id)}:${stringValue(artifact.uri ?? artifact.name)}`,
@@ -3395,7 +3569,35 @@ async function handleWorkdayAction(body: DigitalEmployeeWorkdayActionRequest, sn
}
: undefined,
);
return buildWorkday(date, { ...(snapshot ?? { generatedAt: new Date().toISOString(), services: {}, sessions: { total: 0, active: 0, items: [] } }), uiState: savedState });
const nextSnapshot = { ...(snapshot ?? { generatedAt: new Date().toISOString(), services: {}, sessions: { total: 0, active: 0, items: [] } }), uiState: savedState };
const projection = buildWorkday(date, nextSnapshot);
if (body.action === 'generate_daily_report' && projection.daily_report) {
await recordDailyReportArtifact(projection.daily_report);
}
return projection;
}
async function recordDailyReportArtifact(report: DigitalEmployeeDailyReport): Promise<void> {
if (!report.report_id || !report.generated_at || !report.text_content) return;
try {
await window.QimingClawBridge?.digital?.recordDailyReport?.({
reportId: report.report_id,
date: report.date,
title: report.title,
summary: report.summary,
totals: report.totals,
detailLines: report.detail_lines,
artifactSources: report.artifact_sources ?? [],
filename: report.download_filename || dailyReportFilename(report.date),
textContent: report.text_content,
generatedAt: report.generated_at,
reportScheduleTime: report.report_schedule_time,
source: report.source ?? 'qimingclaw',
model: report.model ?? 'qimingclaw-adapter',
});
} catch (error) {
console.warn('[qimingclawAdapter] record daily report failed:', error);
}
}
async function respondAcpPermissionForDecision(

View File

@@ -298,7 +298,10 @@ export default function DailyReport({ focusSection }: { focusSection?: string |
const downloadDailyReport = useCallback(() => {
if (!report || report.status !== 'ready') return;
downloadTextFile(reportFileName(today), buildReportDownloadText(today, report, taskSections, runDetails, artifactSources));
downloadTextFile(
report.download_filename || reportFileName(today),
report.text_content || buildReportDownloadText(today, report, taskSections, runDetails, artifactSources),
);
}, [artifactSources, report, runDetails, taskSections, today]);
return (

View File

@@ -768,6 +768,9 @@ export interface DigitalEmployeeDelivery {
}
export interface DigitalEmployeeDailyReport {
report_id?: string | null;
artifact_id?: string | null;
event_id?: string | null;
date: string;
title: string;
report_schedule_time: string;
@@ -787,6 +790,8 @@ export interface DigitalEmployeeDailyReport {
task_sections: DigitalEmployeeTaskExecutionSummary[];
detail_lines: string[];
artifact_sources?: DigitalEmployeeReportArtifactSource[];
download_filename?: string | null;
text_content?: string | null;
pdf_available: boolean;
pdf_url?: string | null;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-DGRcTTE8.js"></script>
<script type="module" crossorigin src="./assets/index-P9lyYQrr.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BarRiSjT.css">
</head>
<body>

View File

@@ -10,6 +10,7 @@ import { isWindows } from "../services/system/shellEnv";
import {
recordDigitalEmployeeSnapshot,
recordDigitalEmployeeGovernanceCommand,
recordDigitalEmployeeDailyReport,
readDigitalEmployeeRuntimeRecords,
readDigitalEmployeeState,
readDigitalEmployeeUiState,
@@ -25,6 +26,7 @@ import {
listDigitalEmployeeRouteDecisions,
type DigitalEmployeeManagedService,
type DigitalEmployeeGovernanceCommandRecord,
type DigitalEmployeeDailyReportRecordInput,
type DigitalEmployeeMemoryUpsertInput,
type DigitalEmployeeRouteDecisionInput,
type DigitalEmployeeServiceStatus,
@@ -179,6 +181,13 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
return recordDigitalEmployeeGovernanceCommand(command);
},
);
ipcMain.handle(
"digitalEmployee:recordDailyReport",
async (_, input: DigitalEmployeeDailyReportRecordInput) => {
return recordDigitalEmployeeDailyReport(input);
},
);
}
function buildSnapshot(ctx: HandlerContext): DigitalEmployeeSnapshot {

View File

@@ -187,6 +187,83 @@ describe("digital employee state service", () => {
expect(Array.from(mockState.db?.outbox.values() ?? []).filter((row) => row.entity_type === "artifact")).toHaveLength(1);
});
it("records daily reports as syncable artifact and event history", async () => {
const { recordDigitalEmployeeDailyReport } = await import("./stateService");
const first = recordDigitalEmployeeDailyReport({
date: "2026-06-07",
title: "2026-06-07 数字员工日报",
summary: "今日完成任务汇总。",
totals: {
task_count: 2,
execution_count: 3,
success_count: 2,
failure_count: 1,
running_count: 0,
},
detailLines: ["09:00 任务 A完成", "10:00 任务 B异常"],
artifactSources: [{ id: "artifact-1", name: "summary.csv" }],
filename: "qimingclaw-digital-report-2026-06-07.txt",
textContent: "日报正文",
generatedAt: "2026-06-07T08:00:00.000Z",
reportScheduleTime: "18:00",
source: "qimingclaw",
model: "qimingclaw-adapter",
});
const second = recordDigitalEmployeeDailyReport({
date: "2026-06-07",
title: "2026-06-07 数字员工日报",
summary: "第二次生成。",
totals: { task_count: 2, execution_count: 4, success_count: 3, failure_count: 1 },
detailLines: ["18:00 重新生成日报"],
filename: "qimingclaw-digital-report-2026-06-07.txt",
textContent: "第二版日报正文",
generatedAt: "2026-06-07T09:00:00.000Z",
});
expect(first).toMatchObject({
reportId: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z",
planId: "daily-report:2026-06-07",
taskId: "daily-report:2026-06-07:generate",
runId: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z",
artifactId: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z:artifact:text",
eventId: "daily-report:2026-06-07:2026-06-07T08-00-00-000Z:event:generated",
});
expect(second?.reportId).toBe("daily-report:2026-06-07:2026-06-07T09-00-00-000Z");
expect(mockState.db?.plans.get("daily-report:2026-06-07")).toMatchObject({ status: "completed" });
expect(mockState.db?.tasks.get("daily-report:2026-06-07:generate")).toMatchObject({
status: "completed",
assigned_skill_id: "qimingclaw-artifact-report",
});
expect(mockState.db?.runs.get(first!.runId)).toMatchObject({
status: "completed",
finished_at: "2026-06-07T08:00:00.000Z",
});
expect(Array.from(mockState.db?.artifacts.values() ?? []).filter((row) => row.kind === "daily_report")).toHaveLength(2);
expect(mockState.db?.events.get(first!.eventId)).toMatchObject({
kind: "daily_report_generated",
message: "数字员工日报已生成2026-06-07",
run_id: first!.runId,
});
expect(JSON.parse(mockState.db?.artifacts.get(first!.artifactId)?.payload ?? "{}")).toMatchObject({
report_id: first!.reportId,
date: "2026-06-07",
filename: "qimingclaw-digital-report-2026-06-07.txt",
format: "text",
text_content: "日报正文",
totals: { task_count: 2, execution_count: 3 },
artifact_sources: [{ id: "artifact-1", name: "summary.csv" }],
});
expect(mockState.db?.outbox.get(`artifact:upsert:${first!.artifactId}`)).toMatchObject({
entity_type: "artifact",
status: "pending",
});
expect(mockState.db?.outbox.get(`event:insert:${first!.eventId}`)).toMatchObject({
entity_type: "event",
status: "pending",
});
});
it("records and lists route decisions as syncable events", async () => {
const {
listDigitalEmployeeRouteDecisions,

View File

@@ -392,6 +392,31 @@ export interface DigitalEmployeeRuntimeEventRecord {
payload?: unknown;
}
export interface DigitalEmployeeDailyReportRecordInput {
reportId?: string | null;
date: string;
title: string;
summary: string;
totals: Record<string, unknown>;
detailLines: string[];
artifactSources?: unknown[];
filename: string;
textContent: string;
generatedAt?: string | null;
reportScheduleTime?: string | null;
source?: string | null;
model?: string | null;
}
export interface DigitalEmployeeDailyReportRecordResult {
reportId: string;
planId: string;
taskId: string;
runId: string;
artifactId: string;
eventId: string;
}
export interface DigitalEmployeeGovernanceCommandRecord {
commandKind: string;
commandId: string;
@@ -1451,6 +1476,89 @@ export function recordDigitalEmployeeRuntimeEvent(
tx();
}
export function recordDigitalEmployeeDailyReport(
input: DigitalEmployeeDailyReportRecordInput,
): DigitalEmployeeDailyReportRecordResult | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const now = input.generatedAt || new Date().toISOString();
const date = input.date || now.slice(0, 10);
const planId = `daily-report:${date}`;
const taskId = `${planId}:generate`;
const reportId = input.reportId || `${planId}:${safeId(now)}`;
const runId = reportId;
const artifactId = `${reportId}:artifact:text`;
const eventId = `${reportId}:event:generated`;
const payload = {
report_id: reportId,
date,
title: input.title,
summary: input.summary,
totals: input.totals ?? {},
detail_lines: input.detailLines ?? [],
artifact_sources: input.artifactSources ?? [],
filename: input.filename,
format: "text",
text_content: input.textContent,
generated_at: now,
report_schedule_time: input.reportScheduleTime ?? null,
source: input.source ?? "qimingclaw-adapter",
model: input.model ?? "qimingclaw-adapter",
};
const tx = db.transaction(() => {
upsertPlan({
id: planId,
title: `${date} 数字员工日报`,
objective: "沉淀数字员工日报产物与同步事实。",
status: "completed",
payload: { date, latest_report_id: reportId, source: "daily_report" },
now,
});
upsertTask({
id: taskId,
planId,
title: "生成数字员工日报",
status: "completed",
assignedSkillId: "qimingclaw-artifact-report",
payload: { date, latest_report_id: reportId, source: "daily_report" },
now,
});
upsertRun({
id: runId,
planId,
taskId,
status: "completed",
payload,
now,
});
finishRun(runId, "completed", now);
upsertArtifact({
id: artifactId,
planId,
taskId,
runId,
kind: "daily_report",
name: input.filename,
uri: null,
payload,
now,
});
insertEvent({
event_id: eventId,
kind: "daily_report_generated",
occurred_at: now,
message: `数字员工日报已生成:${date}`,
plan_id: planId,
task_id: taskId,
}, runId, payload);
});
tx();
return { reportId, planId, taskId, runId, artifactId, eventId };
}
export function recordDigitalEmployeeGovernanceCommand(
command: DigitalEmployeeGovernanceCommandRecord,
): { planId: string; taskId: string; runId: string } {

View File

@@ -152,6 +152,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async recordGovernanceCommand(command: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordGovernanceCommand", command);
},
async recordDailyReport(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordDailyReport", input);
},
async respondPermission(sessionId: string, permissionId: string, response: "once" | "always" | "reject") {
return ipcRenderer.invoke("agent:respondPermission", sessionId, permissionId, response);
},

View File

@@ -604,9 +604,9 @@ GET /api/digital-employee/approvals
- 建议:先稳定本地投影 API 和管理端消费模型,再决定是否拆物理业务表。
13. **日报实体与导出交付**
- 已吸收embedded 页面可基于当前 workday projection 导出本地文本日报。
- 缺口:没有正式 DailyReport 实体、日报计划、PDF/HTML 产物、发送记录、管理端回传和日报审批闭环。
- 建议:将日报生成记录作为 Artifact + Event 先沉淀,再考虑格式化导出。
- 已吸收embedded 页面可基于当前 workday projection 导出本地文本日报;生成日报时会通过 qimingclaw bridge 写入 `daily_report` Artifact 与 `daily_report_generated` Event并可通过 `/api/digital-employee/daily-reports` 和详情接口读取只读日报投影,日报 Artifact/Event 本地沉淀已开始闭环
- 缺口:没有正式 DailyReport 物理表、PDF/HTML 文件生成、发送记录、管理端回传确认和日报审批闭环。
- 建议:先稳定文本日报 Artifact/Event 与管理端消费模型,再考虑格式化导出和发送审批
## 管理端联动原则