galaxy-opc-plugin 0.2.0 → 0.2.2

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 (46) hide show
  1. package/index.ts +244 -8
  2. package/package.json +17 -3
  3. package/skills/acquisition-management/SKILL.md +83 -0
  4. package/skills/ai-staff/SKILL.md +89 -0
  5. package/skills/asset-package/SKILL.md +142 -0
  6. package/skills/opb-canvas/SKILL.md +88 -0
  7. package/src/__tests__/e2e/company-lifecycle.test.ts +399 -0
  8. package/src/__tests__/integration/business-workflows.test.ts +366 -0
  9. package/src/__tests__/test-utils.ts +316 -0
  10. package/src/commands/opc-command.ts +422 -0
  11. package/src/db/index.ts +3 -0
  12. package/src/db/migrations.test.ts +324 -0
  13. package/src/db/migrations.ts +131 -0
  14. package/src/db/schema.ts +211 -0
  15. package/src/db/sqlite-adapter.ts +5 -0
  16. package/src/opc/autonomy-rules.ts +132 -0
  17. package/src/opc/briefing-builder.ts +1331 -0
  18. package/src/opc/business-workflows.test.ts +535 -0
  19. package/src/opc/business-workflows.ts +325 -0
  20. package/src/opc/context-injector.ts +366 -28
  21. package/src/opc/event-triggers.ts +472 -0
  22. package/src/opc/intelligence-engine.ts +702 -0
  23. package/src/opc/milestone-detector.ts +251 -0
  24. package/src/opc/proactive-service.ts +179 -0
  25. package/src/opc/reminder-service.ts +4 -43
  26. package/src/opc/session-task-tracker.ts +60 -0
  27. package/src/opc/stage-detector.ts +168 -0
  28. package/src/opc/task-executor.ts +332 -0
  29. package/src/opc/task-templates.ts +179 -0
  30. package/src/tools/acquisition-tool.ts +8 -5
  31. package/src/tools/document-tool.ts +1176 -0
  32. package/src/tools/finance-tool.test.ts +238 -0
  33. package/src/tools/finance-tool.ts +922 -14
  34. package/src/tools/hr-tool.ts +10 -1
  35. package/src/tools/legal-tool.test.ts +251 -0
  36. package/src/tools/legal-tool.ts +26 -4
  37. package/src/tools/lifecycle-tool.test.ts +231 -0
  38. package/src/tools/media-tool.ts +156 -1
  39. package/src/tools/monitoring-tool.ts +135 -2
  40. package/src/tools/opc-tool.test.ts +250 -0
  41. package/src/tools/opc-tool.ts +251 -28
  42. package/src/tools/project-tool.test.ts +218 -0
  43. package/src/tools/schemas.ts +80 -0
  44. package/src/tools/search-tool.ts +227 -0
  45. package/src/tools/staff-tool.ts +395 -2
  46. package/src/web/config-ui.ts +299 -45
@@ -0,0 +1,535 @@
1
+ /**
2
+ * 星环OPC中心 — BusinessWorkflows 单元测试
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
6
+ import { SqliteAdapter } from "../db/sqlite-adapter.js";
7
+ import { BusinessWorkflows, VALID_DIRECTIONS } from "./business-workflows.js";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import os from "node:os";
11
+
12
+ describe("BusinessWorkflows", () => {
13
+ let db: SqliteAdapter;
14
+ let dbPath: string;
15
+ let workflows: BusinessWorkflows;
16
+ let companyId: string;
17
+
18
+ beforeEach(() => {
19
+ dbPath = path.join(os.tmpdir(), `opc-wf-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
20
+ db = new SqliteAdapter(dbPath);
21
+ workflows = new BusinessWorkflows(db);
22
+
23
+ // 创建一个测试公司
24
+ const company = db.createCompany({
25
+ name: "测试科技公司",
26
+ industry: "科技",
27
+ owner_name: "张三",
28
+ owner_contact: "13800138000",
29
+ status: "active",
30
+ registered_capital: 100000,
31
+ description: "测试用公司",
32
+ });
33
+ companyId = company.id;
34
+ });
35
+
36
+ afterEach(() => {
37
+ db.close();
38
+ try { fs.unlinkSync(dbPath); } catch { /* ignore */ }
39
+ try { fs.unlinkSync(dbPath + "-wal"); } catch { /* ignore */ }
40
+ try { fs.unlinkSync(dbPath + "-shm"); } catch { /* ignore */ }
41
+ });
42
+
43
+ // ══════════════════════════════════════════════════════════════
44
+ // direction 校验
45
+ // ══════════════════════════════════════════════════════════════
46
+
47
+ describe("validateDirection", () => {
48
+ it("should accept valid direction values", () => {
49
+ for (const dir of VALID_DIRECTIONS) {
50
+ expect(BusinessWorkflows.validateDirection(dir)).toBe(true);
51
+ }
52
+ });
53
+
54
+ it("should reject invalid direction values", () => {
55
+ expect(BusinessWorkflows.validateDirection("foo")).toBe(false);
56
+ expect(BusinessWorkflows.validateDirection("")).toBe(false);
57
+ expect(BusinessWorkflows.validateDirection("SALES")).toBe(false);
58
+ });
59
+ });
60
+
61
+ describe("afterContractCreated — direction validation", () => {
62
+ it("should throw on invalid direction", () => {
63
+ expect(() => {
64
+ workflows.afterContractCreated({
65
+ id: "c1", company_id: companyId, title: "测试合同",
66
+ counterparty: "客户A", contract_type: "服务合同",
67
+ direction: "invalid", amount: 10000,
68
+ start_date: "2025-01-01", end_date: "2025-06-30",
69
+ });
70
+ }).toThrow("无效的合同方向");
71
+ });
72
+
73
+ it("should not create any records when direction is invalid (rollback)", () => {
74
+ try {
75
+ workflows.afterContractCreated({
76
+ id: "c1", company_id: companyId, title: "测试合同",
77
+ counterparty: "客户A", contract_type: "服务合同",
78
+ direction: "invalid", amount: 10000,
79
+ start_date: "2025-01-01", end_date: "2025-06-30",
80
+ });
81
+ } catch { /* expected */ }
82
+
83
+ // 验证没有残留数据
84
+ const contacts = db.query("SELECT * FROM opc_contacts WHERE company_id = ?", companyId);
85
+ const milestones = db.query("SELECT * FROM opc_milestones WHERE company_id = ?", companyId);
86
+ expect(contacts).toHaveLength(0);
87
+ expect(milestones).toHaveLength(0);
88
+ });
89
+ });
90
+
91
+ // ══════════════════════════════════════════════════════════════
92
+ // 销售合同闭环
93
+ // ══════════════════════════════════════════════════════════════
94
+
95
+ describe("afterContractCreated — sales", () => {
96
+ it("should create contact + project + 4 tasks + milestone", () => {
97
+ const results = workflows.afterContractCreated({
98
+ id: "contract-1", company_id: companyId, title: "AI咨询服务合同",
99
+ counterparty: "阿里巴巴", contract_type: "服务合同",
100
+ direction: "sales", amount: 80000,
101
+ start_date: "2025-03-01", end_date: "2025-05-31",
102
+ });
103
+
104
+ // 验证返回结果
105
+ const modules = results.map(r => r.module);
106
+ expect(modules).toContain("contact");
107
+ expect(modules).toContain("project");
108
+ expect(modules).toContain("milestone");
109
+ expect(results.filter(r => r.module === "task")).toHaveLength(4);
110
+
111
+ // 验证联系人已创建
112
+ const contacts = db.query("SELECT * FROM opc_contacts WHERE company_id = ?", companyId) as { name: string; tags: string }[];
113
+ expect(contacts).toHaveLength(1);
114
+ expect(contacts[0].name).toBe("阿里巴巴");
115
+ expect(contacts[0].tags).toContain("客户");
116
+
117
+ // 验证项目已创建
118
+ const projects = db.query("SELECT * FROM opc_projects WHERE company_id = ?", companyId) as { name: string; budget: number }[];
119
+ expect(projects).toHaveLength(1);
120
+ expect(projects[0].name).toContain("【交付】");
121
+ expect(projects[0].name).toContain("阿里巴巴");
122
+ expect(projects[0].budget).toBe(80000);
123
+
124
+ // 验证任务已创建
125
+ const tasks = db.query("SELECT * FROM opc_tasks WHERE company_id = ?", companyId) as { title: string }[];
126
+ expect(tasks).toHaveLength(4);
127
+ const taskTitles = tasks.map(t => t.title);
128
+ expect(taskTitles).toContain("需求确认与方案设计");
129
+ expect(taskTitles).toContain("核心交付/开发");
130
+ expect(taskTitles).toContain("验收与交付");
131
+ expect(taskTitles).toContain("尾款收取与项目结项");
132
+
133
+ // 验证里程碑
134
+ const milestones = db.query("SELECT * FROM opc_milestones WHERE company_id = ?", companyId) as { title: string; category: string }[];
135
+ expect(milestones).toHaveLength(1);
136
+ expect(milestones[0].title).toContain("签约客户");
137
+ expect(milestones[0].title).toContain("阿里巴巴");
138
+ expect(milestones[0].category).toBe("business");
139
+ });
140
+ });
141
+
142
+ // ══════════════════════════════════════════════════════════════
143
+ // 采购合同闭环
144
+ // ══════════════════════════════════════════════════════════════
145
+
146
+ describe("afterContractCreated — procurement", () => {
147
+ it("should create contact(供应商) + procurement order + milestone", () => {
148
+ const results = workflows.afterContractCreated({
149
+ id: "contract-2", company_id: companyId, title: "Adobe全家桶采购",
150
+ counterparty: "Adobe", contract_type: "采购合同",
151
+ direction: "procurement", amount: 5000,
152
+ start_date: "2025-01-01", end_date: "2025-12-31",
153
+ });
154
+
155
+ const modules = results.map(r => r.module);
156
+ expect(modules).toContain("contact");
157
+ expect(modules).toContain("procurement");
158
+ expect(modules).toContain("milestone");
159
+ expect(modules).not.toContain("project"); // 采购不建项目
160
+
161
+ // 验证联系人标签是供应商
162
+ const contacts = db.query("SELECT * FROM opc_contacts WHERE company_id = ?", companyId) as { tags: string }[];
163
+ expect(contacts[0].tags).toContain("供应商");
164
+
165
+ // 验证采购单
166
+ const orders = db.query("SELECT * FROM opc_procurement_orders WHERE company_id = ?", companyId) as { amount: number; title: string }[];
167
+ expect(orders).toHaveLength(1);
168
+ expect(orders[0].amount).toBe(5000);
169
+ });
170
+ });
171
+
172
+ // ══════════════════════════════════════════════════════════════
173
+ // 外包合同闭环
174
+ // ══════════════════════════════════════════════════════════════
175
+
176
+ describe("afterContractCreated — outsourcing", () => {
177
+ it("should create contact(外包方) + HR record + milestone", () => {
178
+ const results = workflows.afterContractCreated({
179
+ id: "contract-3", company_id: companyId, title: "前端开发外包",
180
+ counterparty: "小李", contract_type: "劳务合同",
181
+ direction: "outsourcing", amount: 30000,
182
+ start_date: "2025-02-01", end_date: "2025-04-30",
183
+ });
184
+
185
+ const modules = results.map(r => r.module);
186
+ expect(modules).toContain("contact");
187
+ expect(modules).toContain("hr");
188
+ expect(modules).toContain("milestone");
189
+ expect(modules).not.toContain("project");
190
+
191
+ // 验证HR记录
192
+ const hrs = db.query("SELECT * FROM opc_hr_records WHERE company_id = ?", companyId) as { employee_name: string; contract_type: string }[];
193
+ expect(hrs).toHaveLength(1);
194
+ expect(hrs[0].employee_name).toBe("小李");
195
+ expect(hrs[0].contract_type).toBe("contractor");
196
+ });
197
+ });
198
+
199
+ // ══════════════════════════════════════════════════════════════
200
+ // 合作协议闭环
201
+ // ══════════════════════════════════════════════════════════════
202
+
203
+ describe("afterContractCreated — partnership", () => {
204
+ it("should create contact(合作伙伴) + milestone only", () => {
205
+ const results = workflows.afterContractCreated({
206
+ id: "contract-4", company_id: companyId, title: "战略合作协议",
207
+ counterparty: "腾讯", contract_type: "合作协议",
208
+ direction: "partnership", amount: 0,
209
+ start_date: "2025-01-01", end_date: "2025-12-31",
210
+ });
211
+
212
+ const modules = results.map(r => r.module);
213
+ expect(modules).toContain("contact");
214
+ expect(modules).toContain("milestone");
215
+ expect(modules).not.toContain("project");
216
+ expect(modules).not.toContain("procurement");
217
+ expect(modules).not.toContain("hr");
218
+ expect(results).toHaveLength(2); // contact + milestone
219
+
220
+ const contacts = db.query("SELECT * FROM opc_contacts WHERE company_id = ?", companyId) as { tags: string }[];
221
+ expect(contacts[0].tags).toContain("合作伙伴");
222
+ });
223
+ });
224
+
225
+ // ══════════════════════════════════════════════════════════════
226
+ // 联系人查重
227
+ // ══════════════════════════════════════════════════════════════
228
+
229
+ describe("contact dedup", () => {
230
+ it("should update existing contact instead of creating duplicate", () => {
231
+ // 第一次创建
232
+ workflows.afterContractCreated({
233
+ id: "c1", company_id: companyId, title: "合同A",
234
+ counterparty: "阿里巴巴", contract_type: "服务合同",
235
+ direction: "sales", amount: 50000,
236
+ start_date: "2025-01-01", end_date: "2025-06-30",
237
+ });
238
+
239
+ // 第二次同一 counterparty
240
+ const results2 = workflows.afterContractCreated({
241
+ id: "c2", company_id: companyId, title: "合同B",
242
+ counterparty: "阿里巴巴", contract_type: "服务合同",
243
+ direction: "sales", amount: 30000,
244
+ start_date: "2025-07-01", end_date: "2025-12-31",
245
+ });
246
+
247
+ // 应该是 updated 而不是 created
248
+ const contactResult = results2.find(r => r.module === "contact");
249
+ expect(contactResult?.action).toBe("updated");
250
+
251
+ // 数据库中只有 1 个联系人
252
+ const contacts = db.query("SELECT * FROM opc_contacts WHERE company_id = ?", companyId);
253
+ expect(contacts).toHaveLength(1);
254
+ });
255
+
256
+ it("should not match contacts from different companies", () => {
257
+ // 创建第二个公司
258
+ const company2 = db.createCompany({
259
+ name: "另一家公司", industry: "金融",
260
+ owner_name: "李四", owner_contact: "",
261
+ status: "active", registered_capital: 0, description: "",
262
+ });
263
+
264
+ // 公司1的合同
265
+ workflows.afterContractCreated({
266
+ id: "c1", company_id: companyId, title: "合同A",
267
+ counterparty: "阿里巴巴", contract_type: "服务合同",
268
+ direction: "sales", amount: 50000,
269
+ start_date: "2025-01-01", end_date: "2025-06-30",
270
+ });
271
+
272
+ // 公司2的合同 — 同名 counterparty 不应冲突
273
+ const results2 = workflows.afterContractCreated({
274
+ id: "c2", company_id: company2.id, title: "合同B",
275
+ counterparty: "阿里巴巴", contract_type: "服务合同",
276
+ direction: "sales", amount: 30000,
277
+ start_date: "2025-01-01", end_date: "2025-06-30",
278
+ });
279
+
280
+ const contactResult = results2.find(r => r.module === "contact");
281
+ expect(contactResult?.action).toBe("created"); // 不同公司,应创建新的
282
+
283
+ const all = db.query("SELECT * FROM opc_contacts") as { company_id: string }[];
284
+ expect(all).toHaveLength(2);
285
+ });
286
+ });
287
+
288
+ // ══════════════════════════════════════════════════════════════
289
+ // 事务回滚
290
+ // ══════════════════════════════════════════════════════════════
291
+
292
+ describe("transaction rollback", () => {
293
+ it("should rollback all changes if any step fails", () => {
294
+ // 先正常创建一个项目让 project name 可能冲突
295
+ // 这里我们通过传入无效的 company_id(外键约束)触发失败
296
+ // 注意: 需要一个会在 workflow 中间步骤失败的场景
297
+ // 使用 sales 方向 + 不存在的 company_id → 联系人插入失败(FK约束)
298
+
299
+ const beforeContacts = db.query("SELECT COUNT(*) as cnt FROM opc_contacts") as { cnt: number }[];
300
+ const beforeMilestones = db.query("SELECT COUNT(*) as cnt FROM opc_milestones") as { cnt: number }[];
301
+
302
+ try {
303
+ workflows.afterContractCreated({
304
+ id: "c-fail", company_id: "nonexistent-company-id", title: "会失败的合同",
305
+ counterparty: "测试", contract_type: "服务合同",
306
+ direction: "sales", amount: 10000,
307
+ start_date: "2025-01-01", end_date: "2025-06-30",
308
+ });
309
+ } catch { /* expected to throw due to FK constraint */ }
310
+
311
+ // 验证没有任何残留数据
312
+ const afterContacts = db.query("SELECT COUNT(*) as cnt FROM opc_contacts") as { cnt: number }[];
313
+ const afterMilestones = db.query("SELECT COUNT(*) as cnt FROM opc_milestones") as { cnt: number }[];
314
+
315
+ expect(afterContacts[0].cnt).toBe(beforeContacts[0].cnt);
316
+ expect(afterMilestones[0].cnt).toBe(beforeMilestones[0].cnt);
317
+ });
318
+ });
319
+
320
+ // ══════════════════════════════════════════════════════════════
321
+ // 交易 workflow
322
+ // ══════════════════════════════════════════════════════════════
323
+
324
+ describe("afterTransactionCreated", () => {
325
+ it("should create invoice for income transactions", () => {
326
+ const results = workflows.afterTransactionCreated({
327
+ id: "tx1", company_id: companyId, type: "income",
328
+ amount: 30000, counterparty: "阿里巴巴",
329
+ description: "首期款项",
330
+ });
331
+
332
+ const invoiceResult = results.find(r => r.module === "invoice");
333
+ expect(invoiceResult).toBeDefined();
334
+ expect(invoiceResult?.action).toBe("created");
335
+
336
+ // 验证发票数据
337
+ const invoices = db.query("SELECT * FROM opc_invoices WHERE company_id = ?", companyId) as {
338
+ amount: number; tax_amount: number; total_amount: number; tax_rate: number;
339
+ }[];
340
+ expect(invoices).toHaveLength(1);
341
+ expect(invoices[0].total_amount).toBe(30000); // 含税金额 = 到账金额
342
+ expect(invoices[0].tax_rate).toBe(0.06);
343
+ // 不含税 = 30000 / 1.06 ≈ 28301.89
344
+ expect(invoices[0].amount).toBeCloseTo(28301.89, 1);
345
+ // 税额 = 30000 - 28301.89 ≈ 1698.11
346
+ expect(invoices[0].tax_amount).toBeCloseTo(1698.11, 1);
347
+ });
348
+
349
+ it("should create milestone for large transactions (>= 5000)", () => {
350
+ const results = workflows.afterTransactionCreated({
351
+ id: "tx2", company_id: companyId, type: "income",
352
+ amount: 10000, counterparty: "客户B",
353
+ description: "服务费",
354
+ });
355
+
356
+ const msResult = results.find(r => r.module === "milestone");
357
+ expect(msResult).toBeDefined();
358
+ expect(msResult?.summary).toContain("收到");
359
+ expect(msResult?.summary).toContain("10000");
360
+ });
361
+
362
+ it("should NOT create milestone for small transactions (< 5000)", () => {
363
+ const results = workflows.afterTransactionCreated({
364
+ id: "tx3", company_id: companyId, type: "income",
365
+ amount: 3000, counterparty: "客户C",
366
+ description: "小额收入",
367
+ });
368
+
369
+ const msResult = results.find(r => r.module === "milestone");
370
+ expect(msResult).toBeUndefined();
371
+ });
372
+
373
+ it("should NOT create invoice for expense transactions", () => {
374
+ const results = workflows.afterTransactionCreated({
375
+ id: "tx4", company_id: companyId, type: "expense",
376
+ amount: 10000, counterparty: "供应商A",
377
+ description: "采购支出",
378
+ });
379
+
380
+ const invoiceResult = results.find(r => r.module === "invoice");
381
+ expect(invoiceResult).toBeUndefined();
382
+
383
+ // 但 >= 5000 仍建里程碑
384
+ const msResult = results.find(r => r.module === "milestone");
385
+ expect(msResult).toBeDefined();
386
+ expect(msResult?.summary).toContain("支出");
387
+ });
388
+ });
389
+
390
+ // ══════════════════════════════════════════════════════════════
391
+ // 员工 workflow
392
+ // ══════════════════════════════════════════════════════════════
393
+
394
+ describe("afterEmployeeAdded", () => {
395
+ it("should create milestone for new employee", () => {
396
+ const results = workflows.afterEmployeeAdded({
397
+ id: "emp1", company_id: companyId, employee_name: "小王",
398
+ position: "前端工程师", contract_type: "full_time", salary: 15000,
399
+ });
400
+
401
+ expect(results).toHaveLength(1);
402
+ expect(results[0].module).toBe("milestone");
403
+ expect(results[0].summary).toContain("小王");
404
+ expect(results[0].summary).toContain("前端工程师");
405
+ expect(results[0].summary).toContain("全职");
406
+
407
+ const milestones = db.query("SELECT * FROM opc_milestones WHERE company_id = ?", companyId) as { category: string }[];
408
+ expect(milestones).toHaveLength(1);
409
+ expect(milestones[0].category).toBe("team");
410
+ });
411
+
412
+ it("should label contractor correctly", () => {
413
+ const results = workflows.afterEmployeeAdded({
414
+ id: "emp2", company_id: companyId, employee_name: "小李",
415
+ position: "设计师", contract_type: "contractor", salary: 8000,
416
+ });
417
+
418
+ expect(results[0].summary).toContain("外包");
419
+ });
420
+
421
+ it("should label intern correctly", () => {
422
+ const results = workflows.afterEmployeeAdded({
423
+ id: "emp3", company_id: companyId, employee_name: "小张",
424
+ position: "实习生", contract_type: "intern", salary: 3000,
425
+ });
426
+
427
+ expect(results[0].summary).toContain("实习");
428
+ });
429
+ });
430
+
431
+ // ══════════════════════════════════════════════════════════════
432
+ // 交易 → 联系人自动创建
433
+ // ══════════════════════════════════════════════════════════════
434
+
435
+ describe("afterTransactionCreated — contact auto-creation", () => {
436
+ it("should create vendor contact for expense with counterparty", () => {
437
+ const results = workflows.afterTransactionCreated({
438
+ id: "tx-exp1", company_id: companyId, type: "expense",
439
+ amount: 5000, counterparty: "Adobe",
440
+ description: "Adobe全家桶",
441
+ });
442
+
443
+ const contactResult = results.find(r => r.module === "contact");
444
+ expect(contactResult).toBeDefined();
445
+ expect(contactResult?.action).toBe("created");
446
+ expect(contactResult?.summary).toContain("供应商");
447
+ expect(contactResult?.summary).toContain("Adobe");
448
+
449
+ // 验证数据库
450
+ const contacts = db.query("SELECT * FROM opc_contacts WHERE company_id = ?", companyId) as { name: string; tags: string; notes: string }[];
451
+ expect(contacts).toHaveLength(1);
452
+ expect(contacts[0].name).toBe("Adobe");
453
+ expect(contacts[0].tags).toContain("供应商");
454
+ expect(contacts[0].notes).toContain("Adobe全家桶");
455
+ });
456
+
457
+ it("should NOT create contact for expense without counterparty", () => {
458
+ const results = workflows.afterTransactionCreated({
459
+ id: "tx-exp2", company_id: companyId, type: "expense",
460
+ amount: 200, counterparty: "",
461
+ description: "打车费",
462
+ });
463
+
464
+ const contactResult = results.find(r => r.module === "contact");
465
+ expect(contactResult).toBeUndefined();
466
+
467
+ const contacts = db.query("SELECT * FROM opc_contacts WHERE company_id = ?", companyId);
468
+ expect(contacts).toHaveLength(0);
469
+ });
470
+
471
+ it("should create client contact for income with counterparty", () => {
472
+ const results = workflows.afterTransactionCreated({
473
+ id: "tx-inc1", company_id: companyId, type: "income",
474
+ amount: 30000, counterparty: "阿里巴巴",
475
+ description: "首期款项",
476
+ });
477
+
478
+ const contactResult = results.find(r => r.module === "contact");
479
+ expect(contactResult).toBeDefined();
480
+ expect(contactResult?.action).toBe("created");
481
+ expect(contactResult?.summary).toContain("客户");
482
+ expect(contactResult?.summary).toContain("阿里巴巴");
483
+
484
+ const contacts = db.query("SELECT * FROM opc_contacts WHERE company_id = ?", companyId) as { name: string; tags: string }[];
485
+ expect(contacts).toHaveLength(1);
486
+ expect(contacts[0].tags).toContain("客户");
487
+ });
488
+
489
+ it("should update existing contact on duplicate counterparty", () => {
490
+ // 第一次交易 — 创建联系人
491
+ workflows.afterTransactionCreated({
492
+ id: "tx-dup1", company_id: companyId, type: "expense",
493
+ amount: 5000, counterparty: "Adobe",
494
+ description: "第一次采购",
495
+ });
496
+
497
+ // 第二次交易 — 应该更新而非创建
498
+ const results2 = workflows.afterTransactionCreated({
499
+ id: "tx-dup2", company_id: companyId, type: "expense",
500
+ amount: 3000, counterparty: "Adobe",
501
+ description: "续费",
502
+ });
503
+
504
+ const contactResult = results2.find(r => r.module === "contact");
505
+ expect(contactResult?.action).toBe("updated");
506
+
507
+ // 数据库中仍只有 1 个联系人
508
+ const contacts = db.query("SELECT * FROM opc_contacts WHERE company_id = ?", companyId) as { notes: string }[];
509
+ expect(contacts).toHaveLength(1);
510
+ expect(contacts[0].notes).toContain("续费");
511
+ });
512
+ });
513
+
514
+ // ══════════════════════════════════════════════════════════════
515
+ // UNIQUE 约束测试
516
+ // ══════════════════════════════════════════════════════════════
517
+
518
+ describe("contacts UNIQUE constraint", () => {
519
+ it("should prevent direct duplicate INSERT on same company + name", () => {
520
+ db.execute(
521
+ `INSERT INTO opc_contacts (id, company_id, name, company_name, tags, notes, last_contact_date, created_at, updated_at)
522
+ VALUES (?, ?, ?, ?, '[]', '', '2025-01-01', datetime('now'), datetime('now'))`,
523
+ "ct-1", companyId, "重复测试", "重复测试",
524
+ );
525
+
526
+ expect(() => {
527
+ db.execute(
528
+ `INSERT INTO opc_contacts (id, company_id, name, company_name, tags, notes, last_contact_date, created_at, updated_at)
529
+ VALUES (?, ?, ?, ?, '[]', '', '2025-01-01', datetime('now'), datetime('now'))`,
530
+ "ct-2", companyId, "重复测试", "重复测试",
531
+ );
532
+ }).toThrow(); // UNIQUE constraint violation
533
+ });
534
+ });
535
+ });