galaxy-opc-plugin 0.2.1 → 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.
Files changed (57) hide show
  1. package/README.md +207 -10
  2. package/index.ts +350 -8
  3. package/openclaw.plugin.json +1 -1
  4. package/package.json +17 -3
  5. package/src/__tests__/e2e/company-lifecycle.test.ts +399 -0
  6. package/src/__tests__/integration/business-workflows.test.ts +366 -0
  7. package/src/__tests__/test-utils.ts +316 -0
  8. package/src/api/companies.ts +4 -0
  9. package/src/api/dashboard.ts +368 -16
  10. package/src/api/routes.ts +2 -2
  11. package/src/commands/opc-command.ts +422 -0
  12. package/src/db/index.ts +3 -0
  13. package/src/db/migrations.test.ts +324 -0
  14. package/src/db/migrations.ts +277 -0
  15. package/src/db/schema.ts +312 -0
  16. package/src/db/sqlite-adapter.ts +44 -2
  17. package/src/opc/accounting-parser.ts +178 -0
  18. package/src/opc/autonomy-rules.ts +132 -0
  19. package/src/opc/briefing-builder.ts +1331 -0
  20. package/src/opc/business-workflows.test.ts +535 -0
  21. package/src/opc/business-workflows.ts +325 -0
  22. package/src/opc/context-injector.ts +366 -28
  23. package/src/opc/daily-brief.ts +529 -0
  24. package/src/opc/event-triggers.ts +472 -0
  25. package/src/opc/intelligence-engine.ts +702 -0
  26. package/src/opc/milestone-detector.ts +251 -0
  27. package/src/opc/onboarding-flow.ts +332 -0
  28. package/src/opc/proactive-service.ts +466 -0
  29. package/src/opc/reminder-service.ts +4 -43
  30. package/src/opc/session-task-tracker.ts +60 -0
  31. package/src/opc/stage-detector.ts +168 -0
  32. package/src/opc/task-executor.ts +332 -0
  33. package/src/opc/task-templates.ts +179 -0
  34. package/src/tools/document-tool.ts +1176 -0
  35. package/src/tools/finance-tool.test-payment.ts +326 -0
  36. package/src/tools/finance-tool.test.ts +238 -0
  37. package/src/tools/finance-tool.ts +1574 -14
  38. package/src/tools/hr-tool.ts +10 -1
  39. package/src/tools/legal-tool.test.ts +251 -0
  40. package/src/tools/legal-tool.ts +26 -4
  41. package/src/tools/lifecycle-tool.test.ts +231 -0
  42. package/src/tools/media-tool.ts +156 -1
  43. package/src/tools/monitoring-tool.ts +134 -1
  44. package/src/tools/onboarding-tool.ts +233 -0
  45. package/src/tools/opc-tool.test.ts +250 -0
  46. package/src/tools/opc-tool.ts +251 -28
  47. package/src/tools/order-tool.ts +481 -0
  48. package/src/tools/project-tool.test.ts +218 -0
  49. package/src/tools/schemas.ts +80 -0
  50. package/src/tools/search-tool.ts +227 -0
  51. package/src/tools/smart-accounting-tool.ts +144 -0
  52. package/src/tools/staff-tool.ts +395 -2
  53. package/src/web/DASHBOARD_INTEGRATION_GUIDE.md +478 -0
  54. package/src/web/config-ui-patches.ts +389 -0
  55. package/src/web/config-ui.ts +4162 -3555
  56. package/src/web/dashboard-ui.ts +582 -0
  57. package/src/web/landing-page.ts +56 -6
@@ -0,0 +1,250 @@
1
+ /**
2
+ * 星环OPC中心 — opc-tool (核心管理工具) 集成测试
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
6
+ import { createTestDb, factories } from "../__tests__/test-utils.js";
7
+ import { SqliteAdapter } from "../db/sqlite-adapter.js";
8
+
9
+ describe("opc-tool database integration", () => {
10
+ let db: SqliteAdapter;
11
+
12
+ beforeEach(() => {
13
+ db = createTestDb();
14
+ });
15
+
16
+ afterEach(() => {
17
+ db.close();
18
+ });
19
+
20
+ describe("create_company", () => {
21
+ it("should create a company successfully", () => {
22
+ const companyData = factories.company({
23
+ name: "创业公司A",
24
+ industry: "互联网",
25
+ owner_name: "张三",
26
+ });
27
+
28
+ const company = db.createCompany(companyData);
29
+
30
+ expect(company).not.toBeNull();
31
+ expect(company.id).toBeDefined();
32
+ expect(company.name).toBe("创业公司A");
33
+ expect(company.industry).toBe("互联网");
34
+ expect(company.owner_name).toBe("张三");
35
+ expect(company.status).toBe("active");
36
+ });
37
+
38
+ it("should set default values", () => {
39
+ const companyData = factories.company({
40
+ name: "最小配置公司",
41
+ industry: "咨询",
42
+ owner_name: "李四",
43
+ });
44
+
45
+ const company = db.createCompany(companyData);
46
+
47
+ expect(company.registered_capital).toBeDefined();
48
+ expect(company.created_at).toBeDefined();
49
+ expect(company.updated_at).toBeDefined();
50
+ });
51
+
52
+ it("should create multiple companies", () => {
53
+ const company1 = db.createCompany(factories.company({ name: "公司1" }));
54
+ const company2 = db.createCompany(factories.company({ name: "公司2" }));
55
+
56
+ expect(company1.id).not.toBe(company2.id);
57
+ expect(company1.name).toBe("公司1");
58
+ expect(company2.name).toBe("公司2");
59
+ });
60
+
61
+ it("should handle Chinese characters in company name", () => {
62
+ const company = db.createCompany(factories.company({
63
+ name: "星河科技有限公司",
64
+ description: "专注于AI技术研发",
65
+ }));
66
+
67
+ expect(company.name).toBe("星河科技有限公司");
68
+ expect(company.description).toBe("专注于AI技术研发");
69
+ });
70
+ });
71
+
72
+ describe("get_company", () => {
73
+ it("should retrieve a company by id", () => {
74
+ const created = db.createCompany(factories.company({ name: "测试公司" }));
75
+ const retrieved = db.getCompany(created.id);
76
+
77
+ expect(retrieved).not.toBeNull();
78
+ expect(retrieved!.id).toBe(created.id);
79
+ expect(retrieved!.name).toBe("测试公司");
80
+ });
81
+
82
+ it("should return null for non-existent company", () => {
83
+ const result = db.getCompany("non-existent-id");
84
+ expect(result).toBeNull();
85
+ });
86
+ });
87
+
88
+ describe("list_companies", () => {
89
+ it("should list all companies", () => {
90
+ db.createCompany(factories.company({ name: "公司1" }));
91
+ db.createCompany(factories.company({ name: "公司2" }));
92
+ db.createCompany(factories.company({ name: "公司3" }));
93
+
94
+ const companies = db.listCompanies();
95
+ expect(companies.length).toBe(3);
96
+ });
97
+
98
+ it("should filter companies by status", () => {
99
+ db.createCompany(factories.company({ name: "活跃公司", status: "active" }));
100
+ db.createCompany(factories.company({ name: "暂停公司", status: "suspended" }));
101
+ db.createCompany(factories.company({ name: "已收购公司", status: "acquired" }));
102
+
103
+ const activeCompanies = db.listCompanies("active");
104
+ expect(activeCompanies.length).toBe(1);
105
+ expect(activeCompanies[0].name).toBe("活跃公司");
106
+
107
+ const suspendedCompanies = db.listCompanies("suspended");
108
+ expect(suspendedCompanies.length).toBe(1);
109
+ expect(suspendedCompanies[0].name).toBe("暂停公司");
110
+ });
111
+
112
+ it("should return empty array when no companies exist", () => {
113
+ const companies = db.listCompanies();
114
+ expect(companies).toEqual([]);
115
+ });
116
+ });
117
+
118
+ describe("update_company", () => {
119
+ it("should update company status", () => {
120
+ const company = db.createCompany(factories.company({ status: "active" }));
121
+
122
+ db.execute(
123
+ "UPDATE opc_companies SET status = ?, updated_at = ? WHERE id = ?",
124
+ "suspended", new Date().toISOString(), company.id
125
+ );
126
+
127
+ const updated = db.getCompany(company.id);
128
+ expect(updated!.status).toBe("suspended");
129
+ });
130
+
131
+ it("should update company description", () => {
132
+ const company = db.createCompany(factories.company({ description: "原描述" }));
133
+
134
+ const newDescription = "更新后的描述";
135
+ db.execute(
136
+ "UPDATE opc_companies SET description = ?, updated_at = ? WHERE id = ?",
137
+ newDescription, new Date().toISOString(), company.id
138
+ );
139
+
140
+ const updated = db.getCompany(company.id);
141
+ expect(updated!.description).toBe(newDescription);
142
+ });
143
+
144
+ it("should update registered capital", () => {
145
+ const company = db.createCompany(factories.company({ registered_capital: 100000 }));
146
+
147
+ db.execute(
148
+ "UPDATE opc_companies SET registered_capital = ?, updated_at = ? WHERE id = ?",
149
+ 500000, new Date().toISOString(), company.id
150
+ );
151
+
152
+ const updated = db.getCompany(company.id);
153
+ expect(updated!.registered_capital).toBe(500000);
154
+ });
155
+ });
156
+
157
+ describe("delete_company", () => {
158
+ it("should delete a company", () => {
159
+ const company = db.createCompany(factories.company({ name: "待删除公司" }));
160
+
161
+ db.execute("DELETE FROM opc_companies WHERE id = ?", company.id);
162
+
163
+ const deleted = db.getCompany(company.id);
164
+ expect(deleted).toBeNull();
165
+ });
166
+
167
+ it("should prevent deleting company with related data (foreign key constraint)", () => {
168
+ const company = db.createCompany(factories.company());
169
+
170
+ // Add related transaction
171
+ const txId = db.genId();
172
+ db.execute(
173
+ `INSERT INTO opc_transactions (id, company_id, type, category, amount, description, counterparty, transaction_date, created_at)
174
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
175
+ txId, company.id, "income", "service_income", 10000, "测试", "客户", "2026-01-15"
176
+ );
177
+
178
+ // Try to delete company - should fail due to foreign key constraint
179
+ expect(() => {
180
+ db.execute("DELETE FROM opc_companies WHERE id = ?", company.id);
181
+ }).toThrow();
182
+
183
+ // Proper way: delete related data first, then delete company
184
+ db.execute("DELETE FROM opc_transactions WHERE company_id = ?", company.id);
185
+ db.execute("DELETE FROM opc_companies WHERE id = ?", company.id);
186
+
187
+ const deleted = db.getCompany(company.id);
188
+ expect(deleted).toBeNull();
189
+ });
190
+ });
191
+
192
+ describe("company search and filtering", () => {
193
+ beforeEach(() => {
194
+ db.createCompany(factories.company({ name: "科技公司A", industry: "科技" }));
195
+ db.createCompany(factories.company({ name: "咨询公司B", industry: "咨询" }));
196
+ db.createCompany(factories.company({ name: "科技公司C", industry: "科技" }));
197
+ });
198
+
199
+ it("should filter by industry", () => {
200
+ const techCompanies = db.query(
201
+ "SELECT * FROM opc_companies WHERE industry = ?",
202
+ "科技"
203
+ ) as any[];
204
+ expect(techCompanies.length).toBe(2);
205
+ });
206
+
207
+ it("should search by name pattern", () => {
208
+ const companies = db.query(
209
+ "SELECT * FROM opc_companies WHERE name LIKE ?",
210
+ "%科技%"
211
+ ) as any[];
212
+ expect(companies.length).toBe(2);
213
+ });
214
+ });
215
+
216
+ describe("company statistics", () => {
217
+ it("should count companies by status", () => {
218
+ db.createCompany(factories.company({ status: "active" }));
219
+ db.createCompany(factories.company({ status: "active" }));
220
+ db.createCompany(factories.company({ status: "suspended" }));
221
+ db.createCompany(factories.company({ status: "acquired" }));
222
+
223
+ const stats = db.queryOne(
224
+ `SELECT
225
+ COUNT(*) as total,
226
+ COUNT(CASE WHEN status = 'active' THEN 1 END) as active,
227
+ COUNT(CASE WHEN status = 'suspended' THEN 1 END) as suspended,
228
+ COUNT(CASE WHEN status = 'acquired' THEN 1 END) as acquired
229
+ FROM opc_companies`
230
+ ) as any;
231
+
232
+ expect(stats.total).toBe(4);
233
+ expect(stats.active).toBe(2);
234
+ expect(stats.suspended).toBe(1);
235
+ expect(stats.acquired).toBe(1);
236
+ });
237
+
238
+ it("should calculate total registered capital", () => {
239
+ db.createCompany(factories.company({ registered_capital: 100000 }));
240
+ db.createCompany(factories.company({ registered_capital: 200000 }));
241
+ db.createCompany(factories.company({ registered_capital: 300000 }));
242
+
243
+ const result = db.queryOne(
244
+ "SELECT SUM(registered_capital) as total_capital FROM opc_companies"
245
+ ) as any;
246
+
247
+ expect(result.total_capital).toBe(600000);
248
+ });
249
+ });
250
+ });
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
8
8
  import type { OpcDatabase } from "../db/index.js";
9
+ import { BusinessWorkflows } from "../opc/business-workflows.js";
9
10
  import { CompanyManager } from "../opc/company-manager.js";
10
11
  import type { OpcCompanyStatus, OpcTransactionCategory, OpcTransactionType } from "../opc/types.js";
11
12
  import { ensureCompanyWorkspace } from "../opc/workspace-factory.js";
@@ -42,9 +43,15 @@ export function registerOpcTool(api: OpenClawPluginApi, db: OpcDatabase): void {
42
43
  "操作: register_company(注册公司), get_company(查询公司), list_companies(公司列表),",
43
44
  "update_company(更新公司), activate_company(激活公司), change_company_status(变更状态),",
44
45
  "add_transaction(记账), list_transactions(交易列表), finance_summary(财务摘要),",
45
- "add_contact(添加客户), list_contacts(客户列表), update_contact(更新客户),",
46
+ "add_contact(添加客户,支持CRM字段pipeline_stage/follow_up_date/deal_value/source),",
47
+ "list_contacts(客户列表), update_contact(更新客户,支持CRM字段),",
46
48
  "delete_contact(删除客户), dashboard(看板统计),",
47
- "set_company_skills(设置公司Agent skills), get_company_skills(查询公司Agent skills)",
49
+ "set_company_skills(设置公司Agent skills), get_company_skills(查询公司Agent skills),",
50
+ "batch_import_contacts(批量导入联系人),",
51
+ "crm_pipeline(销售漏斗), add_interaction(添加客户交互记录),",
52
+ "list_interactions(交互历史), follow_up_reminders(跟进提醒),",
53
+ "setup_feishu_channel(配置飞书频道), feishu_channel_status(查询飞书状态),",
54
+ "switch_company(切换到指定公司的专属Agent)",
48
55
  ].join(" "),
49
56
  parameters: OpcManageSchema,
50
57
  async execute(_toolCallId, params) {
@@ -113,18 +120,25 @@ export function registerOpcTool(api: OpenClawPluginApi, db: OpcDatabase): void {
113
120
  }
114
121
 
115
122
  // ── 交易记录 ──
116
- case "add_transaction":
117
- return json(
118
- db.createTransaction({
119
- company_id: p.company_id,
120
- type: p.type as OpcTransactionType,
121
- category: (p.category ?? "other") as OpcTransactionCategory,
122
- amount: p.amount,
123
- description: p.description ?? "",
124
- counterparty: p.counterparty ?? "",
125
- transaction_date: p.transaction_date ?? new Date().toISOString().slice(0, 10),
126
- }),
127
- );
123
+ case "add_transaction": {
124
+ const tx = db.createTransaction({
125
+ company_id: p.company_id,
126
+ type: p.type as OpcTransactionType,
127
+ category: (p.category ?? "other") as OpcTransactionCategory,
128
+ amount: p.amount,
129
+ description: p.description ?? "",
130
+ counterparty: p.counterparty ?? "",
131
+ transaction_date: p.transaction_date ?? new Date().toISOString().slice(0, 10),
132
+ });
133
+ // 业务闭环:收入自动开票、大额记里程碑
134
+ const workflows = new BusinessWorkflows(db);
135
+ const autoCreated = workflows.afterTransactionCreated({
136
+ id: tx.id, company_id: p.company_id, type: p.type,
137
+ amount: p.amount, counterparty: p.counterparty ?? "",
138
+ description: p.description ?? "",
139
+ });
140
+ return json({ ...tx as object, _auto_created: autoCreated });
141
+ }
128
142
 
129
143
  case "list_transactions":
130
144
  return json(
@@ -142,19 +156,29 @@ export function registerOpcTool(api: OpenClawPluginApi, db: OpcDatabase): void {
142
156
  );
143
157
 
144
158
  // ── 客户管理 ──
145
- case "add_contact":
146
- return json(
147
- db.createContact({
148
- company_id: p.company_id,
149
- name: p.name,
150
- phone: p.phone ?? "",
151
- email: p.email ?? "",
152
- company_name: p.company_name ?? "",
153
- tags: p.tags ?? "[]",
154
- notes: p.notes ?? "",
155
- last_contact_date: new Date().toISOString().slice(0, 10),
156
- }),
157
- );
159
+ case "add_contact": {
160
+ const contact = db.createContact({
161
+ company_id: p.company_id,
162
+ name: p.name,
163
+ phone: p.phone ?? "",
164
+ email: p.email ?? "",
165
+ company_name: p.company_name ?? "",
166
+ tags: p.tags ?? "[]",
167
+ notes: p.notes ?? "",
168
+ last_contact_date: new Date().toISOString().slice(0, 10),
169
+ });
170
+ // Update CRM fields if provided
171
+ const crmFields: Record<string, unknown> = {};
172
+ if ((p as Record<string, unknown>).pipeline_stage) crmFields.pipeline_stage = (p as Record<string, unknown>).pipeline_stage;
173
+ if ((p as Record<string, unknown>).follow_up_date) crmFields.follow_up_date = (p as Record<string, unknown>).follow_up_date;
174
+ if ((p as Record<string, unknown>).deal_value !== undefined) crmFields.deal_value = (p as Record<string, unknown>).deal_value;
175
+ if ((p as Record<string, unknown>).source) crmFields.source = (p as Record<string, unknown>).source;
176
+ if (Object.keys(crmFields).length > 0) {
177
+ const sets = Object.keys(crmFields).map((k) => `${k} = ?`).join(", ");
178
+ db.execute(`UPDATE opc_contacts SET ${sets} WHERE id = ?`, ...Object.values(crmFields), contact.id);
179
+ }
180
+ return json(db.queryOne("SELECT * FROM opc_contacts WHERE id = ?", contact.id));
181
+ }
158
182
 
159
183
  case "list_contacts":
160
184
  return json(db.listContacts(p.company_id, p.tag));
@@ -170,7 +194,18 @@ export function registerOpcTool(api: OpenClawPluginApi, db: OpcDatabase): void {
170
194
  if (p.last_contact_date) updateData.last_contact_date = p.last_contact_date;
171
195
  const updatedContact = db.updateContact(p.contact_id, updateData);
172
196
  if (!updatedContact) return toolError(`联系人 ${p.contact_id} 不存在`, "CONTACT_NOT_FOUND");
173
- return json(updatedContact);
197
+ // Update CRM fields if provided
198
+ const pp = p as Record<string, unknown>;
199
+ const crmUpd: Record<string, unknown> = {};
200
+ if (pp.pipeline_stage) crmUpd.pipeline_stage = pp.pipeline_stage;
201
+ if (pp.follow_up_date) crmUpd.follow_up_date = pp.follow_up_date;
202
+ if (pp.deal_value !== undefined) crmUpd.deal_value = pp.deal_value;
203
+ if (pp.source) crmUpd.source = pp.source;
204
+ if (Object.keys(crmUpd).length > 0) {
205
+ const sets = Object.keys(crmUpd).map((k) => `${k} = ?`).join(", ");
206
+ db.execute(`UPDATE opc_contacts SET ${sets}, updated_at = ? WHERE id = ?`, ...Object.values(crmUpd), new Date().toISOString(), p.contact_id);
207
+ }
208
+ return json(db.queryOne("SELECT * FROM opc_contacts WHERE id = ?", p.contact_id));
174
209
  }
175
210
 
176
211
  case "delete_contact":
@@ -201,6 +236,194 @@ export function registerOpcTool(api: OpenClawPluginApi, db: OpcDatabase): void {
201
236
  return json({ company_id: resolvedId, skills });
202
237
  }
203
238
 
239
+ // ── 批量导入联系人 ──
240
+ case "batch_import_contacts": {
241
+ const records: unknown[] = [];
242
+ db.transaction(() => {
243
+ const now = new Date().toISOString();
244
+ const today = now.slice(0, 10);
245
+ for (const c of p.contacts) {
246
+ const id = db.genId();
247
+ db.execute(
248
+ `INSERT OR IGNORE INTO opc_contacts (id, company_id, name, phone, email, company_name, tags, notes, last_contact_date, created_at, updated_at)
249
+ VALUES (?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?)`,
250
+ id, p.company_id, c.name, c.phone ?? "", c.email ?? "",
251
+ c.company_name ?? "", c.tags ?? "[]", today, now, now,
252
+ );
253
+ records.push({ id, name: c.name });
254
+ }
255
+ });
256
+ return json({ ok: true, imported_count: records.length, records });
257
+ }
258
+
259
+ // ── CRM 销售漏斗 ──
260
+ case "crm_pipeline": {
261
+ const stages = ["lead", "qualified", "proposal", "negotiation", "won", "lost", "churned"];
262
+ const pipeline: Record<string, { count: number; total_deal_value: number; contacts: unknown[] }> = {};
263
+ for (const stage of stages) {
264
+ const contacts = db.query(
265
+ `SELECT id, name, company_name, deal_value, follow_up_date, source
266
+ FROM opc_contacts WHERE company_id = ? AND pipeline_stage = ?
267
+ ORDER BY deal_value DESC`,
268
+ p.company_id, stage,
269
+ ) as unknown[];
270
+ const totalValue = (contacts as { deal_value: number }[]).reduce((s, c) => s + (c.deal_value || 0), 0);
271
+ pipeline[stage] = { count: contacts.length, total_deal_value: totalValue, contacts };
272
+ }
273
+ return json({ ok: true, pipeline });
274
+ }
275
+
276
+ // ── 添加客户交互记录 ──
277
+ case "add_interaction": {
278
+ const id = db.genId();
279
+ const now = new Date().toISOString();
280
+ db.execute(
281
+ `INSERT INTO opc_contact_interactions (id, contact_id, company_id, interaction_type, content, created_at)
282
+ VALUES (?, ?, ?, ?, ?, ?)`,
283
+ id, p.contact_id, p.company_id, p.interaction_type, p.content, now,
284
+ );
285
+ // 自动更新联系人的 last_contact_date
286
+ db.execute(
287
+ "UPDATE opc_contacts SET last_contact_date = ?, updated_at = ? WHERE id = ?",
288
+ now.slice(0, 10), now, p.contact_id,
289
+ );
290
+ return json(db.queryOne("SELECT * FROM opc_contact_interactions WHERE id = ?", id));
291
+ }
292
+
293
+ // ── 查看交互历史 ──
294
+ case "list_interactions": {
295
+ const limit = (p as Record<string, unknown>).limit ?? 20;
296
+ const interactions = db.query(
297
+ `SELECT i.*, c.name as contact_name FROM opc_contact_interactions i
298
+ LEFT JOIN opc_contacts c ON i.contact_id = c.id
299
+ WHERE i.contact_id = ? ORDER BY i.created_at DESC LIMIT ?`,
300
+ p.contact_id, limit,
301
+ );
302
+ return json(interactions);
303
+ }
304
+
305
+ // ── 跟进提醒 ──
306
+ case "follow_up_reminders": {
307
+ const days = (p as Record<string, unknown>).days ?? 7;
308
+ const futureDate = new Date();
309
+ futureDate.setDate(futureDate.getDate() + (days as number));
310
+ const futureDateStr = futureDate.toISOString().slice(0, 10);
311
+ const today = new Date().toISOString().slice(0, 10);
312
+
313
+ const upcoming = db.query(
314
+ `SELECT id, name, company_name, pipeline_stage, deal_value, follow_up_date, source
315
+ FROM opc_contacts
316
+ WHERE company_id = ? AND follow_up_date != '' AND follow_up_date <= ?
317
+ AND pipeline_stage NOT IN ('won', 'lost', 'churned')
318
+ ORDER BY follow_up_date`,
319
+ p.company_id, futureDateStr,
320
+ ) as { follow_up_date: string }[];
321
+
322
+ const overdue = upcoming.filter((c) => c.follow_up_date < today);
323
+ const dueSoon = upcoming.filter((c) => c.follow_up_date >= today);
324
+
325
+ return json({ ok: true, overdue_count: overdue.length, upcoming_count: dueSoon.length, overdue, upcoming: dueSoon });
326
+ }
327
+
328
+ // ── 飞书频道 ──
329
+ case "setup_feishu_channel": {
330
+ const cfg = api.runtime.config.loadConfig() as Record<string, unknown>;
331
+ const channels = (cfg.channels ?? {}) as Record<string, unknown>;
332
+ channels.feishu = {
333
+ enabled: true,
334
+ dmPolicy: "pairing",
335
+ groupPolicy: "open",
336
+ streaming: true,
337
+ accounts: {
338
+ main: {
339
+ appId: p.app_id,
340
+ appSecret: p.app_secret,
341
+ botName: p.bot_name || "\u661F\u73AFOPC\u52A9\u624B",
342
+ },
343
+ },
344
+ };
345
+ cfg.channels = channels;
346
+ await api.runtime.config.writeConfigFile(cfg);
347
+ return json({ ok: true, message: "\u98DE\u4E66\u9891\u9053\u5DF2\u914D\u7F6E\uFF0C\u7CFB\u7EDF\u5C06\u81EA\u52A8\u91CD\u542F" });
348
+ }
349
+
350
+ case "feishu_channel_status": {
351
+ const cfg = api.runtime.config.loadConfig();
352
+ const feishuCfg = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
353
+ const feishu = feishuCfg?.feishu as Record<string, unknown> | undefined;
354
+ const accounts = feishu?.accounts as Record<string, Record<string, string>> | undefined;
355
+ const main = accounts?.main;
356
+ return json({
357
+ configured: !!(main?.appId && main.appId !== "YOUR_FEISHU_APP_ID"),
358
+ enabled: feishu?.enabled ?? false,
359
+ botName: main?.botName ?? "",
360
+ dmPolicy: feishu?.dmPolicy ?? "pairing",
361
+ });
362
+ }
363
+
364
+ // ── 切换公司 Agent ──
365
+ case "switch_company": {
366
+ const realId = resolveCompanyId(db, p.company_id);
367
+ const company = manager.getCompany(realId);
368
+ if (!company) return toolError(`公司 "${p.company_id}" 不存在`, "COMPANY_NOT_FOUND");
369
+
370
+ const targetAgentId = `opc-${realId}`;
371
+ const channel = (p as Record<string, unknown>)._channel as string | undefined;
372
+ const peerId = (p as Record<string, unknown>)._peer_id as string | undefined;
373
+
374
+ // 检查目标 agent 是否存在
375
+ const switchCfg = api.runtime.config.loadConfig() as Record<string, unknown>;
376
+ const agents = ((switchCfg.agents as Record<string, unknown>)?.list as Array<Record<string, unknown>>) ?? [];
377
+ const agentExists = agents.some(a => a.id === targetAgentId);
378
+ if (!agentExists) {
379
+ return toolError(
380
+ `公司 "${company.name}" 的专属 Agent (${targetAgentId}) 尚未创建。请先注册并激活该公司。`,
381
+ "RECORD_NOT_FOUND",
382
+ );
383
+ }
384
+
385
+ // 如果有 channel + peer 信息,更新 bindings
386
+ if (channel && peerId) {
387
+ const bindings = ((switchCfg.bindings ?? []) as Array<Record<string, unknown>>);
388
+
389
+ // 移除该 peer 在该 channel 上的旧绑定
390
+ const filtered = bindings.filter(b => {
391
+ const match = b.match as Record<string, unknown> | undefined;
392
+ const peer = match?.peer as Record<string, unknown> | undefined;
393
+ return !(match?.channel === channel && peer?.kind === "direct" && peer?.id === peerId);
394
+ });
395
+
396
+ // 添加新绑定
397
+ filtered.push({
398
+ agentId: targetAgentId,
399
+ match: {
400
+ channel,
401
+ peer: { kind: "direct", id: peerId },
402
+ },
403
+ });
404
+
405
+ switchCfg.bindings = filtered;
406
+ await api.runtime.config.writeConfigFile(switchCfg);
407
+
408
+ return json({
409
+ ok: true,
410
+ message: `已切换到「${company.name}」。系统将自动重启,下一条消息将由公司专属 AI 员工接待。`,
411
+ company: { id: realId, name: company.name },
412
+ agent_id: targetAgentId,
413
+ restarting: true,
414
+ });
415
+ }
416
+
417
+ // 没有 channel/peer 信息(webchat 等),返回提示
418
+ return json({
419
+ ok: true,
420
+ message: `已找到公司「${company.name}」(Agent: ${targetAgentId})。当前频道不支持自动绑定,请在管理后台手动配置 bindings。`,
421
+ company: { id: realId, name: company.name },
422
+ agent_id: targetAgentId,
423
+ restarting: false,
424
+ });
425
+ }
426
+
204
427
  default:
205
428
  return toolError(`未知操作: ${(p as { action: string }).action}`, "UNKNOWN_ACTION");
206
429
  }