galaxy-opc-plugin 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,529 @@
1
+ /**
2
+ * 星环OPC中心 — 每日简报生成器
3
+ *
4
+ * 生成每日待办事项、经营数据分析、AI 员工汇报。
5
+ */
6
+
7
+ import type { OpcDatabase } from "../db/index.js";
8
+
9
+ type TodoItem = {
10
+ type: string;
11
+ title: string;
12
+ priority: "urgent" | "high" | "normal";
13
+ dueDate?: string;
14
+ description: string;
15
+ };
16
+
17
+ type MetricData = {
18
+ label: string;
19
+ value: number | string;
20
+ trend?: string;
21
+ unit?: string;
22
+ };
23
+
24
+ type StaffReport = {
25
+ role: string;
26
+ roleName: string;
27
+ observations: string[];
28
+ suggestions: string[];
29
+ tasks: { title: string; status: string }[];
30
+ };
31
+
32
+ /** 生成每日简报 */
33
+ export function generateDailyBrief(db: OpcDatabase, companyId: string): {
34
+ todos: TodoItem[];
35
+ metrics: MetricData[];
36
+ staffReports: StaffReport[];
37
+ summary: string;
38
+ } {
39
+ const todos = collectTodoItems(db, companyId);
40
+ const metrics = analyzeBusinessMetrics(db, companyId);
41
+ const staffReports = generateStaffReports(db, companyId);
42
+
43
+ // 生成摘要
44
+ const urgentCount = todos.filter(t => t.priority === "urgent").length;
45
+ const summary = urgentCount > 0
46
+ ? `今日有 ${urgentCount} 项紧急待办,${todos.length} 项总待办。`
47
+ : todos.length > 0
48
+ ? `今日有 ${todos.length} 项待办事项。`
49
+ : "今日暂无紧急待办事项。";
50
+
51
+ return {
52
+ todos,
53
+ metrics,
54
+ staffReports,
55
+ summary,
56
+ };
57
+ }
58
+
59
+ /** 收集待办事项 */
60
+ function collectTodoItems(db: OpcDatabase, companyId: string): TodoItem[] {
61
+ const todos: TodoItem[] = [];
62
+ const today = new Date().toISOString().slice(0, 10);
63
+
64
+ // 1. 逾期款项催收
65
+ const overduePayments = db.query(
66
+ `SELECT id, direction, amount, due_date, counterparty
67
+ FROM opc_payments
68
+ WHERE company_id = ? AND status IN ('pending', 'partial')
69
+ AND due_date != '' AND due_date < ?
70
+ ORDER BY due_date ASC
71
+ LIMIT 5`,
72
+ companyId, today,
73
+ ) as { id: string; direction: string; amount: number; due_date: string; counterparty: string }[] | undefined;
74
+
75
+ for (const payment of overduePayments ?? []) {
76
+ const daysOverdue = Math.floor(
77
+ (Date.now() - new Date(payment.due_date).getTime()) / 86400000,
78
+ );
79
+ todos.push({
80
+ type: "overdue_payment",
81
+ title: `催收逾期款项:${payment.counterparty}`,
82
+ priority: daysOverdue > 30 ? "urgent" : "high",
83
+ dueDate: payment.due_date,
84
+ description: `${payment.direction === "receivable" ? "应收" : "应付"}款项 ${payment.amount.toLocaleString()} 元,已逾期 ${daysOverdue} 天`,
85
+ });
86
+ }
87
+
88
+ // 2. 合同到期提醒
89
+ const expiringContracts = db.query(
90
+ `SELECT id, title, end_date, counterparty
91
+ FROM opc_contracts
92
+ WHERE company_id = ? AND status = 'active'
93
+ AND end_date != ''
94
+ AND end_date >= ? AND end_date <= date(?, '+30 days')
95
+ ORDER BY end_date ASC
96
+ LIMIT 5`,
97
+ companyId, today, today,
98
+ ) as { id: string; title: string; end_date: string; counterparty: string }[] | undefined;
99
+
100
+ for (const contract of expiringContracts ?? []) {
101
+ const daysUntilExpiry = Math.floor(
102
+ (new Date(contract.end_date).getTime() - Date.now()) / 86400000,
103
+ );
104
+ todos.push({
105
+ type: "expiring_contract",
106
+ title: `合同即将到期:${contract.title}`,
107
+ priority: daysUntilExpiry <= 7 ? "urgent" : daysUntilExpiry <= 14 ? "high" : "normal",
108
+ dueDate: contract.end_date,
109
+ description: `与 ${contract.counterparty} 的合同将在 ${daysUntilExpiry} 天后到期,需要续约或重新协商`,
110
+ });
111
+ }
112
+
113
+ // 2.1. 里程碑到期提醒(订单闭环)
114
+ const dueMilestones = db.query(
115
+ `SELECT m.id, m.title, m.due_date, m.amount, c.title as contract_title, c.counterparty
116
+ FROM opc_contract_milestones m
117
+ JOIN opc_contracts c ON m.contract_id = c.id
118
+ WHERE m.company_id = ? AND m.status IN ('pending', 'in_progress')
119
+ AND m.due_date != ''
120
+ AND m.due_date >= ? AND m.due_date <= date(?, '+7 days')
121
+ ORDER BY m.due_date ASC
122
+ LIMIT 5`,
123
+ companyId, today, today,
124
+ ) as { id: string; title: string; due_date: string; amount: number; contract_title: string; counterparty: string }[] | undefined;
125
+
126
+ for (const milestone of dueMilestones ?? []) {
127
+ const daysUntilDue = Math.floor(
128
+ (new Date(milestone.due_date).getTime() - Date.now()) / 86400000,
129
+ );
130
+ todos.push({
131
+ type: "milestone_due",
132
+ title: `里程碑即将到期:${milestone.title}`,
133
+ priority: daysUntilDue <= 3 ? "urgent" : "high",
134
+ dueDate: milestone.due_date,
135
+ description: `合同「${milestone.contract_title}」的里程碑将在 ${daysUntilDue} 天后到期,应收款 ${milestone.amount.toLocaleString()} 元`,
136
+ });
137
+ }
138
+
139
+ // 3. 税务申报提醒
140
+ const pendingTaxFilings = db.query(
141
+ `SELECT id, period, tax_type, due_date, tax_amount
142
+ FROM opc_tax_filings
143
+ WHERE company_id = ? AND status = 'pending'
144
+ AND due_date != '' AND due_date >= ?
145
+ ORDER BY due_date ASC
146
+ LIMIT 3`,
147
+ companyId, today,
148
+ ) as { id: string; period: string; tax_type: string; due_date: string; tax_amount: number }[] | undefined;
149
+
150
+ for (const filing of pendingTaxFilings ?? []) {
151
+ const daysUntilDue = Math.floor(
152
+ (new Date(filing.due_date).getTime() - Date.now()) / 86400000,
153
+ );
154
+ todos.push({
155
+ type: "tax_filing",
156
+ title: `税务申报:${filing.period} ${filing.tax_type}`,
157
+ priority: daysUntilDue <= 3 ? "urgent" : daysUntilDue <= 7 ? "high" : "normal",
158
+ dueDate: filing.due_date,
159
+ description: `应纳税额 ${filing.tax_amount.toLocaleString()} 元,还有 ${daysUntilDue} 天截止`,
160
+ });
161
+ }
162
+
163
+ // 4. 客户回访提醒
164
+ const staleContacts = db.query(
165
+ `SELECT id, name, last_contact_date, pipeline_stage
166
+ FROM opc_contacts
167
+ WHERE company_id = ?
168
+ AND last_contact_date != ''
169
+ AND last_contact_date < date('now', '-30 days')
170
+ AND pipeline_stage NOT IN ('lost', 'won')
171
+ ORDER BY last_contact_date ASC
172
+ LIMIT 5`,
173
+ companyId,
174
+ ) as { id: string; name: string; last_contact_date: string; pipeline_stage: string }[] | undefined;
175
+
176
+ for (const contact of staleContacts ?? []) {
177
+ const daysSinceContact = Math.floor(
178
+ (Date.now() - new Date(contact.last_contact_date).getTime()) / 86400000,
179
+ );
180
+ todos.push({
181
+ type: "customer_follow_up",
182
+ title: `客户回访:${contact.name}`,
183
+ priority: daysSinceContact > 90 ? "high" : "normal",
184
+ description: `已 ${daysSinceContact} 天未联系,当前阶段:${contact.pipeline_stage}`,
185
+ });
186
+ }
187
+
188
+ // 5. 逾期任务
189
+ const overdueTasks = db.query(
190
+ `SELECT id, title, due_date, priority
191
+ FROM opc_tasks
192
+ WHERE company_id = ? AND status NOT IN ('done', 'completed', 'cancelled')
193
+ AND due_date != '' AND due_date < ?
194
+ ORDER BY due_date ASC
195
+ LIMIT 5`,
196
+ companyId, today,
197
+ ) as { id: string; title: string; due_date: string; priority: string }[] | undefined;
198
+
199
+ for (const task of overdueTasks ?? []) {
200
+ const daysOverdue = Math.floor(
201
+ (Date.now() - new Date(task.due_date).getTime()) / 86400000,
202
+ );
203
+ todos.push({
204
+ type: "overdue_task",
205
+ title: `逾期任务:${task.title}`,
206
+ priority: task.priority === "urgent" || daysOverdue > 7 ? "urgent" : "high",
207
+ dueDate: task.due_date,
208
+ description: `已逾期 ${daysOverdue} 天`,
209
+ });
210
+ }
211
+
212
+ // 按优先级排序
213
+ const priorityOrder = { urgent: 1, high: 2, normal: 3 };
214
+ todos.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
215
+
216
+ return todos;
217
+ }
218
+
219
+ /** 分析经营数据 */
220
+ function analyzeBusinessMetrics(db: OpcDatabase, companyId: string): MetricData[] {
221
+ const metrics: MetricData[] = [];
222
+ const today = new Date();
223
+ const thisMonth = today.toISOString().slice(0, 7);
224
+ const lastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1).toISOString().slice(0, 7);
225
+
226
+ // 本月收入
227
+ const monthIncome = (db.queryOne(
228
+ "SELECT COALESCE(SUM(amount), 0) as total FROM opc_transactions WHERE company_id = ? AND type = 'income' AND strftime('%Y-%m', transaction_date) = ?",
229
+ companyId, thisMonth,
230
+ ) as { total: number } | null)?.total ?? 0;
231
+
232
+ const lastMonthIncome = (db.queryOne(
233
+ "SELECT COALESCE(SUM(amount), 0) as total FROM opc_transactions WHERE company_id = ? AND type = 'income' AND strftime('%Y-%m', transaction_date) = ?",
234
+ companyId, lastMonth,
235
+ ) as { total: number } | null)?.total ?? 0;
236
+
237
+ const incomeTrend = lastMonthIncome > 0
238
+ ? `${((monthIncome - lastMonthIncome) / lastMonthIncome * 100).toFixed(1)}%`
239
+ : undefined;
240
+
241
+ metrics.push({
242
+ label: "本月收入",
243
+ value: monthIncome,
244
+ unit: "元",
245
+ trend: incomeTrend,
246
+ });
247
+
248
+ // 本月支出
249
+ const monthExpense = (db.queryOne(
250
+ "SELECT COALESCE(SUM(amount), 0) as total FROM opc_transactions WHERE company_id = ? AND type = 'expense' AND strftime('%Y-%m', transaction_date) = ?",
251
+ companyId, thisMonth,
252
+ ) as { total: number } | null)?.total ?? 0;
253
+
254
+ metrics.push({
255
+ label: "本月支出",
256
+ value: monthExpense,
257
+ unit: "元",
258
+ });
259
+
260
+ // 本月利润
261
+ const monthProfit = monthIncome - monthExpense;
262
+ metrics.push({
263
+ label: "本月利润",
264
+ value: monthProfit,
265
+ unit: "元",
266
+ });
267
+
268
+ // 现金余额
269
+ const totalIncome = (db.queryOne(
270
+ "SELECT COALESCE(SUM(amount), 0) as total FROM opc_transactions WHERE company_id = ? AND type = 'income'",
271
+ companyId,
272
+ ) as { total: number } | null)?.total ?? 0;
273
+
274
+ const totalExpense = (db.queryOne(
275
+ "SELECT COALESCE(SUM(amount), 0) as total FROM opc_transactions WHERE company_id = ? AND type = 'expense'",
276
+ companyId,
277
+ ) as { total: number } | null)?.total ?? 0;
278
+
279
+ const cashBalance = totalIncome - totalExpense;
280
+
281
+ // 计算可撑月数
282
+ const avgMonthlyBurn = monthExpense > 0 ? monthExpense : totalExpense / 12;
283
+ const runway = avgMonthlyBurn > 0 ? Math.floor(cashBalance / avgMonthlyBurn) : 999;
284
+
285
+ metrics.push({
286
+ label: "现金余额",
287
+ value: cashBalance,
288
+ unit: "元",
289
+ trend: runway < 3 ? `⚠️ 仅够 ${runway} 个月` : runway < 12 ? `可撑 ${runway} 个月` : undefined,
290
+ });
291
+
292
+ // 应收账款
293
+ const receivables = (db.queryOne(
294
+ "SELECT COALESCE(SUM(amount - paid_amount), 0) as total FROM opc_payments WHERE company_id = ? AND direction = 'receivable' AND status IN ('pending', 'partial')",
295
+ companyId,
296
+ ) as { total: number } | null)?.total ?? 0;
297
+
298
+ if (receivables > 0) {
299
+ const overdueReceivables = (db.queryOne(
300
+ "SELECT COALESCE(SUM(amount - paid_amount), 0) as total FROM opc_payments WHERE company_id = ? AND direction = 'receivable' AND status IN ('pending', 'partial') AND due_date < date('now')",
301
+ companyId,
302
+ ) as { total: number } | null)?.total ?? 0;
303
+
304
+ metrics.push({
305
+ label: "应收账款",
306
+ value: receivables,
307
+ unit: "元",
308
+ trend: overdueReceivables > 0 ? `⚠️ ${overdueReceivables.toLocaleString()} 元已逾期` : undefined,
309
+ });
310
+ }
311
+
312
+ return metrics;
313
+ }
314
+
315
+ /** 生成 AI 员工汇报 */
316
+ function generateStaffReports(db: OpcDatabase, companyId: string): StaffReport[] {
317
+ const reports: StaffReport[] = [];
318
+
319
+ // 获取启用的 AI 员工
320
+ const staffConfigs = db.query(
321
+ "SELECT role, role_name FROM opc_staff_config WHERE company_id = ? AND enabled = 1",
322
+ companyId,
323
+ ) as { role: string; role_name: string }[] | undefined;
324
+
325
+ for (const staff of staffConfigs ?? []) {
326
+ const observations: string[] = [];
327
+ const suggestions: string[] = [];
328
+ const tasks: { title: string; status: string }[] = [];
329
+
330
+ // 获取该员工的任务
331
+ const staffTasks = db.query(
332
+ "SELECT title, status FROM opc_staff_tasks WHERE company_id = ? AND staff_role = ? AND status IN ('pending', 'in_progress') ORDER BY assigned_at DESC LIMIT 3",
333
+ companyId, staff.role,
334
+ ) as { title: string; status: string }[] | undefined;
335
+
336
+ tasks.push(...(staffTasks ?? []));
337
+
338
+ // 根据角色生成观察和建议
339
+ switch (staff.role) {
340
+ case "finance": {
341
+ // 财务顾问
342
+ const cashBalance = (db.queryOne(
343
+ "SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE -amount END), 0) as total FROM opc_transactions WHERE company_id = ?",
344
+ companyId,
345
+ ) as { total: number } | null)?.total ?? 0;
346
+
347
+ if (cashBalance < 0) {
348
+ observations.push("现金流为负,需要关注");
349
+ suggestions.push("建议尽快增加收入或控制支出");
350
+ }
351
+
352
+ const overdueReceivables = (db.queryOne(
353
+ "SELECT COUNT(*) as cnt FROM opc_payments WHERE company_id = ? AND direction = 'receivable' AND status IN ('pending', 'partial') AND due_date < date('now')",
354
+ companyId,
355
+ ) as { cnt: number } | null)?.cnt ?? 0;
356
+
357
+ if (overdueReceivables > 0) {
358
+ observations.push(`有 ${overdueReceivables} 笔应收款逾期`);
359
+ suggestions.push("建议及时催收逾期款项");
360
+ }
361
+
362
+ // 应收风险分层统计(订单闭环新增)
363
+ const warningReceivables = (db.queryOne(
364
+ "SELECT COUNT(*) as cnt, COALESCE(SUM(amount - paid_amount), 0) as total FROM opc_payments WHERE company_id = ? AND direction = 'receivable' AND risk_level = 'warning'",
365
+ companyId,
366
+ ) as { cnt: number; total: number } | null);
367
+
368
+ const criticalReceivables = (db.queryOne(
369
+ "SELECT COUNT(*) as cnt, COALESCE(SUM(amount - paid_amount), 0) as total FROM opc_payments WHERE company_id = ? AND direction = 'receivable' AND risk_level = 'critical'",
370
+ companyId,
371
+ ) as { cnt: number; total: number } | null);
372
+
373
+ if (criticalReceivables && criticalReceivables.cnt > 0) {
374
+ observations.push(`有 ${criticalReceivables.cnt} 笔严重逾期应收(>30天),合计 ${criticalReceivables.total.toLocaleString()} 元`);
375
+ suggestions.push("建议升级催收方式或考虑法律途径");
376
+ } else if (warningReceivables && warningReceivables.cnt > 0) {
377
+ observations.push(`有 ${warningReceivables.cnt} 笔预警应收(8-30天),合计 ${warningReceivables.total.toLocaleString()} 元`);
378
+ suggestions.push("建议电话跟进,避免逾期恶化");
379
+ }
380
+
381
+ break;
382
+ }
383
+
384
+ case "legal": {
385
+ // 法务助理
386
+ const expiringContracts = (db.queryOne(
387
+ "SELECT COUNT(*) as cnt FROM opc_contracts WHERE company_id = ? AND status = 'active' AND end_date != '' AND end_date <= date('now', '+30 days')",
388
+ companyId,
389
+ ) as { cnt: number } | null)?.cnt ?? 0;
390
+
391
+ if (expiringContracts > 0) {
392
+ observations.push(`有 ${expiringContracts} 份合同即将到期`);
393
+ suggestions.push("建议提前联系客户商讨续约事宜");
394
+ }
395
+
396
+ break;
397
+ }
398
+
399
+ case "ops": {
400
+ // 运营经理
401
+ const staleContacts = (db.queryOne(
402
+ "SELECT COUNT(*) as cnt FROM opc_contacts WHERE company_id = ? AND last_contact_date < date('now', '-30 days')",
403
+ companyId,
404
+ ) as { cnt: number } | null)?.cnt ?? 0;
405
+
406
+ if (staleContacts > 0) {
407
+ observations.push(`有 ${staleContacts} 个客户超过 30 天未联系`);
408
+ suggestions.push("建议定期回访客户,维护客户关系");
409
+ }
410
+
411
+ break;
412
+ }
413
+ }
414
+
415
+ if (observations.length > 0 || suggestions.length > 0 || tasks.length > 0) {
416
+ reports.push({
417
+ role: staff.role,
418
+ roleName: staff.role_name,
419
+ observations,
420
+ suggestions,
421
+ tasks,
422
+ });
423
+ }
424
+ }
425
+
426
+ return reports;
427
+ }
428
+
429
+ /** 格式化每日简报为 Markdown */
430
+ export function formatDailyBriefMarkdown(brief: ReturnType<typeof generateDailyBrief>): string {
431
+ const lines: string[] = [];
432
+
433
+ lines.push("# 📋 每日简报");
434
+ lines.push("");
435
+ lines.push(`**${new Date().toISOString().slice(0, 10)}** | ${brief.summary}`);
436
+ lines.push("");
437
+
438
+ // 待办事项
439
+ if (brief.todos.length > 0) {
440
+ lines.push("## 📌 今日待办");
441
+ lines.push("");
442
+
443
+ const urgent = brief.todos.filter(t => t.priority === "urgent");
444
+ const high = brief.todos.filter(t => t.priority === "high");
445
+ const normal = brief.todos.filter(t => t.priority === "normal");
446
+
447
+ if (urgent.length > 0) {
448
+ lines.push("### 🔴 紧急");
449
+ for (const todo of urgent) {
450
+ lines.push(`- **${todo.title}**`);
451
+ lines.push(` ${todo.description}`);
452
+ if (todo.dueDate) lines.push(` 截止:${todo.dueDate}`);
453
+ }
454
+ lines.push("");
455
+ }
456
+
457
+ if (high.length > 0) {
458
+ lines.push("### 🟡 重要");
459
+ for (const todo of high) {
460
+ lines.push(`- **${todo.title}**`);
461
+ lines.push(` ${todo.description}`);
462
+ if (todo.dueDate) lines.push(` 截止:${todo.dueDate}`);
463
+ }
464
+ lines.push("");
465
+ }
466
+
467
+ if (normal.length > 0) {
468
+ lines.push("### 🟢 一般");
469
+ for (const todo of normal) {
470
+ lines.push(`- ${todo.title}`);
471
+ }
472
+ lines.push("");
473
+ }
474
+ }
475
+
476
+ // 经营数据
477
+ if (brief.metrics.length > 0) {
478
+ lines.push("## 📊 经营数据");
479
+ lines.push("");
480
+ for (const metric of brief.metrics) {
481
+ const valueStr = typeof metric.value === "number"
482
+ ? metric.value.toLocaleString()
483
+ : metric.value;
484
+ const trendStr = metric.trend ? ` (${metric.trend})` : "";
485
+ lines.push(`- **${metric.label}**: ${valueStr} ${metric.unit ?? ""}${trendStr}`);
486
+ }
487
+ lines.push("");
488
+ }
489
+
490
+ // AI 员工汇报
491
+ if (brief.staffReports.length > 0) {
492
+ lines.push("## 🤖 AI 员工汇报");
493
+ lines.push("");
494
+ for (const report of brief.staffReports) {
495
+ lines.push(`### ${report.roleName}`);
496
+ lines.push("");
497
+
498
+ if (report.observations.length > 0) {
499
+ lines.push("**观察:**");
500
+ for (const obs of report.observations) {
501
+ lines.push(`- ${obs}`);
502
+ }
503
+ lines.push("");
504
+ }
505
+
506
+ if (report.suggestions.length > 0) {
507
+ lines.push("**建议:**");
508
+ for (const sug of report.suggestions) {
509
+ lines.push(`- ${sug}`);
510
+ }
511
+ lines.push("");
512
+ }
513
+
514
+ if (report.tasks.length > 0) {
515
+ lines.push("**进行中的任务:**");
516
+ for (const task of report.tasks) {
517
+ const statusIcon = task.status === "in_progress" ? "🔄" : "⏳";
518
+ lines.push(`- ${statusIcon} ${task.title}`);
519
+ }
520
+ lines.push("");
521
+ }
522
+ }
523
+ }
524
+
525
+ lines.push("---");
526
+ lines.push("*由星环 OPC 智能助手自动生成*");
527
+
528
+ return lines.join("\n");
529
+ }