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
@@ -5,6 +5,7 @@
5
5
  import { Type, type Static } from "@sinclair/typebox";
6
6
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
7
7
  import type { OpcDatabase } from "../db/index.js";
8
+ import { BusinessWorkflows } from "../opc/business-workflows.js";
8
9
  import { json, toolError } from "../utils/tool-helper.js";
9
10
 
10
11
  const HrSchema = Type.Union([
@@ -129,7 +130,15 @@ export function registerHrTool(api: OpenClawPluginApi, db: OpcDatabase): void {
129
130
  p.contract_type ?? "full_time",
130
131
  p.notes ?? "", now, now,
131
132
  );
132
- return json(db.queryOne("SELECT * FROM opc_hr_records WHERE id = ?", id));
133
+ // 业务闭环:自动记里程碑
134
+ const workflows = new BusinessWorkflows(db);
135
+ const autoCreated = workflows.afterEmployeeAdded({
136
+ id, company_id: p.company_id, employee_name: p.employee_name,
137
+ position: p.position, contract_type: p.contract_type ?? "full_time",
138
+ salary: p.salary,
139
+ });
140
+ const record = db.queryOne("SELECT * FROM opc_hr_records WHERE id = ?", id);
141
+ return json({ ...record as object, _auto_created: autoCreated });
133
142
  }
134
143
 
135
144
  case "list_employees": {
@@ -0,0 +1,251 @@
1
+ /**
2
+ * 星环OPC中心 — legal-tool 集成测试
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
6
+ import { createTestDb, insertTestCompany, factories } from "../__tests__/test-utils.js";
7
+ import { SqliteAdapter } from "../db/sqlite-adapter.js";
8
+
9
+ describe("legal-tool database integration", () => {
10
+ let db: SqliteAdapter;
11
+ let companyId: string;
12
+
13
+ beforeEach(() => {
14
+ db = createTestDb();
15
+ companyId = insertTestCompany(db);
16
+ });
17
+
18
+ afterEach(() => {
19
+ db.close();
20
+ });
21
+
22
+ describe("create_contract", () => {
23
+ it("should create a contract successfully", () => {
24
+ const contractData = factories.contract(companyId, {
25
+ title: "技术服务合同",
26
+ counterparty: "客户A",
27
+ contract_type: "service",
28
+ amount: 100000,
29
+ });
30
+
31
+ const id = db.genId();
32
+ const now = new Date().toISOString();
33
+
34
+ db.execute(
35
+ `INSERT INTO opc_contracts
36
+ (id, company_id, title, counterparty, contract_type, amount, start_date, end_date, status, key_terms, risk_notes, reminder_date, created_at, updated_at)
37
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
38
+ id, contractData.company_id, contractData.title, contractData.counterparty,
39
+ contractData.contract_type, contractData.amount, contractData.start_date,
40
+ contractData.end_date, contractData.status, contractData.key_terms,
41
+ contractData.risk_notes, contractData.reminder_date, now, now
42
+ );
43
+
44
+ const contract = db.queryOne("SELECT * FROM opc_contracts WHERE id = ?", id) as any;
45
+ expect(contract).not.toBeNull();
46
+ expect(contract.title).toBe("技术服务合同");
47
+ expect(contract.amount).toBe(100000);
48
+ expect(contract.status).toBe("active");
49
+ });
50
+
51
+ it("should handle contract without amount", () => {
52
+ const contractData = factories.contract(companyId, {
53
+ title: "保密协议",
54
+ contract_type: "NDA",
55
+ amount: 0,
56
+ });
57
+
58
+ const id = db.genId();
59
+ const now = new Date().toISOString();
60
+
61
+ db.execute(
62
+ `INSERT INTO opc_contracts
63
+ (id, company_id, title, counterparty, contract_type, amount, start_date, end_date, status, key_terms, risk_notes, reminder_date, created_at, updated_at)
64
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
65
+ id, contractData.company_id, contractData.title, contractData.counterparty,
66
+ contractData.contract_type, contractData.amount, contractData.start_date,
67
+ contractData.end_date, contractData.status, contractData.key_terms,
68
+ contractData.risk_notes, contractData.reminder_date, now, now
69
+ );
70
+
71
+ const contract = db.queryOne("SELECT * FROM opc_contracts WHERE id = ?", id) as any;
72
+ expect(contract.amount).toBe(0);
73
+ expect(contract.contract_type).toBe("NDA");
74
+ });
75
+
76
+ it("should create multiple contracts for same company", () => {
77
+ const contract1 = factories.contract(companyId, { title: "合同1" });
78
+ const contract2 = factories.contract(companyId, { title: "合同2" });
79
+ const now = new Date().toISOString();
80
+
81
+ [contract1, contract2].forEach((c) => {
82
+ const id = db.genId();
83
+ db.execute(
84
+ `INSERT INTO opc_contracts
85
+ (id, company_id, title, counterparty, contract_type, amount, start_date, end_date, status, key_terms, risk_notes, reminder_date, created_at, updated_at)
86
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
87
+ id, c.company_id, c.title, c.counterparty, c.contract_type, c.amount,
88
+ c.start_date, c.end_date, c.status, c.key_terms, c.risk_notes, c.reminder_date, now, now
89
+ );
90
+ });
91
+
92
+ const contracts = db.query("SELECT * FROM opc_contracts WHERE company_id = ?", companyId) as any[];
93
+ expect(contracts.length).toBe(2);
94
+ });
95
+ });
96
+
97
+ describe("list_contracts", () => {
98
+ it("should list all contracts for a company", () => {
99
+ // Create 3 contracts
100
+ for (let i = 0; i < 3; i++) {
101
+ const contract = factories.contract(companyId, { title: `合同${i + 1}` });
102
+ const id = db.genId();
103
+ const now = new Date().toISOString();
104
+ db.execute(
105
+ `INSERT INTO opc_contracts
106
+ (id, company_id, title, counterparty, contract_type, amount, start_date, end_date, status, key_terms, risk_notes, reminder_date, created_at, updated_at)
107
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
108
+ id, contract.company_id, contract.title, contract.counterparty,
109
+ contract.contract_type, contract.amount, contract.start_date,
110
+ contract.end_date, contract.status, contract.key_terms,
111
+ contract.risk_notes, contract.reminder_date, now, now
112
+ );
113
+ }
114
+
115
+ const contracts = db.query("SELECT * FROM opc_contracts WHERE company_id = ?", companyId) as any[];
116
+ expect(contracts.length).toBe(3);
117
+ });
118
+
119
+ it("should filter contracts by status", () => {
120
+ const activeContract = factories.contract(companyId, { status: "active" });
121
+ const expiredContract = factories.contract(companyId, { status: "expired" });
122
+ const now = new Date().toISOString();
123
+
124
+ [activeContract, expiredContract].forEach((c) => {
125
+ const id = db.genId();
126
+ db.execute(
127
+ `INSERT INTO opc_contracts
128
+ (id, company_id, title, counterparty, contract_type, amount, start_date, end_date, status, key_terms, risk_notes, reminder_date, created_at, updated_at)
129
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
130
+ id, c.company_id, c.title, c.counterparty, c.contract_type, c.amount,
131
+ c.start_date, c.end_date, c.status, c.key_terms, c.risk_notes, c.reminder_date, now, now
132
+ );
133
+ });
134
+
135
+ const activeContracts = db.query(
136
+ "SELECT * FROM opc_contracts WHERE company_id = ? AND status = ?",
137
+ companyId, "active"
138
+ ) as any[];
139
+ expect(activeContracts.length).toBe(1);
140
+ expect(activeContracts[0].status).toBe("active");
141
+ });
142
+ });
143
+
144
+ describe("update_contract", () => {
145
+ it("should update contract status", () => {
146
+ const contract = factories.contract(companyId);
147
+ const id = db.genId();
148
+ const now = new Date().toISOString();
149
+
150
+ db.execute(
151
+ `INSERT INTO opc_contracts
152
+ (id, company_id, title, counterparty, contract_type, amount, start_date, end_date, status, key_terms, risk_notes, reminder_date, created_at, updated_at)
153
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
154
+ id, contract.company_id, contract.title, contract.counterparty,
155
+ contract.contract_type, contract.amount, contract.start_date,
156
+ contract.end_date, contract.status, contract.key_terms,
157
+ contract.risk_notes, contract.reminder_date, now, now
158
+ );
159
+
160
+ db.execute(
161
+ "UPDATE opc_contracts SET status = ?, updated_at = ? WHERE id = ?",
162
+ "terminated", now, id
163
+ );
164
+
165
+ const updated = db.queryOne("SELECT * FROM opc_contracts WHERE id = ?", id) as any;
166
+ expect(updated.status).toBe("terminated");
167
+ });
168
+
169
+ it("should update risk notes", () => {
170
+ const contract = factories.contract(companyId);
171
+ const id = db.genId();
172
+ const now = new Date().toISOString();
173
+
174
+ db.execute(
175
+ `INSERT INTO opc_contracts
176
+ (id, company_id, title, counterparty, contract_type, amount, start_date, end_date, status, key_terms, risk_notes, reminder_date, created_at, updated_at)
177
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
178
+ id, contract.company_id, contract.title, contract.counterparty,
179
+ contract.contract_type, contract.amount, contract.start_date,
180
+ contract.end_date, contract.status, contract.key_terms,
181
+ contract.risk_notes, contract.reminder_date, now, now
182
+ );
183
+
184
+ const newRiskNotes = "需注意付款条款";
185
+ db.execute(
186
+ "UPDATE opc_contracts SET risk_notes = ?, updated_at = ? WHERE id = ?",
187
+ newRiskNotes, now, id
188
+ );
189
+
190
+ const updated = db.queryOne("SELECT * FROM opc_contracts WHERE id = ?", id) as any;
191
+ expect(updated.risk_notes).toBe(newRiskNotes);
192
+ });
193
+ });
194
+
195
+ describe("delete_contract", () => {
196
+ it("should delete a contract", () => {
197
+ const contract = factories.contract(companyId);
198
+ const id = db.genId();
199
+ const now = new Date().toISOString();
200
+
201
+ db.execute(
202
+ `INSERT INTO opc_contracts
203
+ (id, company_id, title, counterparty, contract_type, amount, start_date, end_date, status, key_terms, risk_notes, reminder_date, created_at, updated_at)
204
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
205
+ id, contract.company_id, contract.title, contract.counterparty,
206
+ contract.contract_type, contract.amount, contract.start_date,
207
+ contract.end_date, contract.status, contract.key_terms,
208
+ contract.risk_notes, contract.reminder_date, now, now
209
+ );
210
+
211
+ db.execute("DELETE FROM opc_contracts WHERE id = ?", id);
212
+
213
+ const deleted = db.queryOne("SELECT * FROM opc_contracts WHERE id = ?", id);
214
+ expect(deleted).toBeNull();
215
+ });
216
+ });
217
+
218
+ describe("contract expiration detection", () => {
219
+ it("should detect expiring contracts", () => {
220
+ const futureDate = new Date();
221
+ futureDate.setDate(futureDate.getDate() + 10);
222
+ const reminderDate = futureDate.toISOString().split("T")[0];
223
+
224
+ const contract = factories.contract(companyId, {
225
+ reminder_date: reminderDate,
226
+ status: "active",
227
+ });
228
+ const id = db.genId();
229
+ const now = new Date().toISOString();
230
+
231
+ db.execute(
232
+ `INSERT INTO opc_contracts
233
+ (id, company_id, title, counterparty, contract_type, amount, start_date, end_date, status, key_terms, risk_notes, reminder_date, created_at, updated_at)
234
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
235
+ id, contract.company_id, contract.title, contract.counterparty,
236
+ contract.contract_type, contract.amount, contract.start_date,
237
+ contract.end_date, contract.status, contract.key_terms,
238
+ contract.risk_notes, contract.reminder_date, now, now
239
+ );
240
+
241
+ const expiringSoon = db.query(
242
+ `SELECT * FROM opc_contracts
243
+ WHERE company_id = ? AND status = 'active'
244
+ AND reminder_date <= date('now', '+30 days')`,
245
+ companyId
246
+ ) as any[];
247
+
248
+ expect(expiringSoon.length).toBe(1);
249
+ });
250
+ });
251
+ });
@@ -5,6 +5,7 @@
5
5
  import { Type, type Static } from "@sinclair/typebox";
6
6
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
7
7
  import type { OpcDatabase } from "../db/index.js";
8
+ import { BusinessWorkflows, VALID_DIRECTIONS } from "../opc/business-workflows.js";
8
9
  import { json, toolError } from "../utils/tool-helper.js";
9
10
 
10
11
  const LegalSchema = Type.Union([
@@ -14,6 +15,9 @@ const LegalSchema = Type.Union([
14
15
  title: Type.String({ description: "合同标题" }),
15
16
  counterparty: Type.String({ description: "签约对方" }),
16
17
  contract_type: Type.String({ description: "合同类型: 服务合同/采购合同/劳动合同/租赁合同/合作协议/NDA/其他" }),
18
+ direction: Type.Optional(Type.String({
19
+ description: "合同方向: sales(我方提供服务/产品,收钱) | procurement(我方采购,付钱) | outsourcing(外包/劳务) | partnership(合作协议)"
20
+ })),
17
21
  amount: Type.Optional(Type.Number({ description: "合同金额(元)" })),
18
22
  start_date: Type.Optional(Type.String({ description: "起始日期 (YYYY-MM-DD)" })),
19
23
  end_date: Type.Optional(Type.String({ description: "结束日期 (YYYY-MM-DD)" })),
@@ -102,14 +106,32 @@ export function registerLegalTool(api: OpenClawPluginApi, db: OpcDatabase): void
102
106
  case "create_contract": {
103
107
  const id = db.genId();
104
108
  const now = new Date().toISOString();
109
+ const direction = p.direction || "sales";
110
+ // 校验 direction 合法性
111
+ if (!BusinessWorkflows.validateDirection(direction)) {
112
+ return toolError(
113
+ `无效的合同方向「${direction}」,合法值: ${VALID_DIRECTIONS.join(", ")}`,
114
+ "INVALID_DIRECTION",
115
+ );
116
+ }
105
117
  db.execute(
106
- `INSERT INTO opc_contracts (id, company_id, title, counterparty, contract_type, amount, start_date, end_date, status, key_terms, risk_notes, reminder_date, created_at, updated_at)
107
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)`,
108
- id, p.company_id, p.title, p.counterparty, p.contract_type,
118
+ `INSERT INTO opc_contracts (id, company_id, title, counterparty, contract_type, direction, amount, start_date, end_date, status, key_terms, risk_notes, reminder_date, created_at, updated_at)
119
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)`,
120
+ id, p.company_id, p.title, p.counterparty, p.contract_type, direction,
109
121
  p.amount ?? 0, p.start_date ?? "", p.end_date ?? "",
110
122
  p.key_terms ?? "", p.risk_notes ?? "", p.reminder_date ?? "", now, now,
111
123
  );
112
- return json(db.queryOne("SELECT * FROM opc_contracts WHERE id = ?", id));
124
+ // 业务闭环:自动创建关联记录
125
+ const workflows = new BusinessWorkflows(db);
126
+ const autoCreated = workflows.afterContractCreated({
127
+ id, company_id: p.company_id, title: p.title,
128
+ counterparty: p.counterparty, contract_type: p.contract_type,
129
+ direction,
130
+ amount: p.amount ?? 0,
131
+ start_date: p.start_date ?? "", end_date: p.end_date ?? "",
132
+ });
133
+ const contract = db.queryOne("SELECT * FROM opc_contracts WHERE id = ?", id);
134
+ return json({ ...contract as object, _auto_created: autoCreated });
113
135
  }
114
136
 
115
137
  case "list_contracts": {
@@ -0,0 +1,231 @@
1
+ /**
2
+ * 星环OPC中心 — lifecycle-tool 集成测试
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
6
+ import { createTestDb, insertTestCompany } from "../__tests__/test-utils.js";
7
+ import { SqliteAdapter } from "../db/sqlite-adapter.js";
8
+
9
+ describe("lifecycle-tool database integration", () => {
10
+ let db: SqliteAdapter;
11
+ let companyId: string;
12
+
13
+ beforeEach(() => {
14
+ db = createTestDb();
15
+ companyId = insertTestCompany(db);
16
+ });
17
+
18
+ afterEach(() => {
19
+ db.close();
20
+ });
21
+
22
+ describe("company status transitions", () => {
23
+ it("should transition from pending to active", () => {
24
+ const company = db.getCompany(companyId);
25
+ expect(company).not.toBeNull();
26
+
27
+ db.execute(
28
+ "UPDATE opc_companies SET status = ?, updated_at = ? WHERE id = ?",
29
+ "active", new Date().toISOString(), companyId
30
+ );
31
+
32
+ const updated = db.getCompany(companyId);
33
+ expect(updated!.status).toBe("active");
34
+ });
35
+
36
+ it("should transition from active to suspended", () => {
37
+ db.execute(
38
+ "UPDATE opc_companies SET status = ?, updated_at = ? WHERE id = ?",
39
+ "suspended", new Date().toISOString(), companyId
40
+ );
41
+
42
+ const updated = db.getCompany(companyId);
43
+ expect(updated!.status).toBe("suspended");
44
+ });
45
+
46
+ it("should transition from active to acquired", () => {
47
+ db.execute(
48
+ "UPDATE opc_companies SET status = ?, updated_at = ? WHERE id = ?",
49
+ "acquired", new Date().toISOString(), companyId
50
+ );
51
+
52
+ const updated = db.getCompany(companyId);
53
+ expect(updated!.status).toBe("acquired");
54
+ });
55
+
56
+ it("should transition from acquired to packaged", () => {
57
+ db.execute(
58
+ "UPDATE opc_companies SET status = ?, updated_at = ? WHERE id = ?",
59
+ "packaged", new Date().toISOString(), companyId
60
+ );
61
+
62
+ const updated = db.getCompany(companyId);
63
+ expect(updated!.status).toBe("packaged");
64
+ });
65
+
66
+ it("should transition to terminated", () => {
67
+ db.execute(
68
+ "UPDATE opc_companies SET status = ?, updated_at = ? WHERE id = ?",
69
+ "terminated", new Date().toISOString(), companyId
70
+ );
71
+
72
+ const updated = db.getCompany(companyId);
73
+ expect(updated!.status).toBe("terminated");
74
+ });
75
+ });
76
+
77
+ describe("milestone tracking", () => {
78
+ it("should record first transaction milestone", () => {
79
+ const id = db.genId();
80
+ const now = new Date().toISOString();
81
+
82
+ // Schema uses: title, category, target_date, completed_date, status
83
+ db.execute(
84
+ `INSERT INTO opc_milestones (id, company_id, title, category, description, completed_date, status, created_at)
85
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
86
+ id, companyId, "首笔交易", "business", "首笔交易完成", "2026-01-15", "completed", now
87
+ );
88
+
89
+ const milestones = db.query(
90
+ "SELECT * FROM opc_milestones WHERE company_id = ?",
91
+ companyId
92
+ ) as any[];
93
+ expect(milestones.length).toBe(1);
94
+ expect(milestones[0].title).toBe("首笔交易");
95
+ });
96
+
97
+ it("should record profitability milestone", () => {
98
+ const id = db.genId();
99
+ const now = new Date().toISOString();
100
+
101
+ db.execute(
102
+ `INSERT INTO opc_milestones (id, company_id, title, category, description, completed_date, status, created_at)
103
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
104
+ id, companyId, "首次盈利", "financial", "实现首次盈利", "2026-02-01", "completed", now
105
+ );
106
+
107
+ const milestones = db.query(
108
+ "SELECT * FROM opc_milestones WHERE company_id = ? AND title = ?",
109
+ companyId, "首次盈利"
110
+ ) as any[];
111
+ expect(milestones.length).toBe(1);
112
+ });
113
+
114
+ it("should track multiple milestones", () => {
115
+ const milestoneData = [
116
+ { title: "首笔交易", category: "business" },
117
+ { title: "首次盈利", category: "financial" },
118
+ { title: "首位员工", category: "hr" },
119
+ ];
120
+ const now = new Date().toISOString();
121
+
122
+ milestoneData.forEach((data) => {
123
+ const id = db.genId();
124
+ db.execute(
125
+ `INSERT INTO opc_milestones (id, company_id, title, category, description, completed_date, status, created_at)
126
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
127
+ id, companyId, data.title, data.category, `Milestone: ${data.title}`, "2026-01-15", "completed", now
128
+ );
129
+ });
130
+
131
+ const milestones = db.query(
132
+ "SELECT * FROM opc_milestones WHERE company_id = ?",
133
+ companyId
134
+ ) as any[];
135
+ expect(milestones.length).toBe(3);
136
+ });
137
+ });
138
+
139
+ describe("stage detection", () => {
140
+ it("should identify startup stage (new company)", () => {
141
+ const company = db.getCompany(companyId);
142
+ expect(company).not.toBeNull();
143
+
144
+ // No transactions yet - startup stage
145
+ const transactions = db.query(
146
+ "SELECT * FROM opc_transactions WHERE company_id = ?",
147
+ companyId
148
+ ) as any[];
149
+ expect(transactions.length).toBe(0);
150
+ });
151
+
152
+ it("should identify growth stage (has revenue)", () => {
153
+ // Add revenue transactions
154
+ for (let i = 0; i < 3; i++) {
155
+ const id = db.genId();
156
+ db.execute(
157
+ `INSERT INTO opc_transactions (id, company_id, type, category, amount, description, counterparty, transaction_date, created_at)
158
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
159
+ id, companyId, "income", "service_income", 50000, "销售收入", "客户A", "2026-01-15"
160
+ );
161
+ }
162
+
163
+ const transactions = db.query(
164
+ "SELECT * FROM opc_transactions WHERE company_id = ? AND type = 'income'",
165
+ companyId
166
+ ) as any[];
167
+ expect(transactions.length).toBeGreaterThan(0);
168
+ });
169
+
170
+ it("should identify maturity stage (consistent profit)", () => {
171
+ // Simulate 6 months of profitable transactions
172
+ for (let month = 1; month <= 6; month++) {
173
+ const incomeId = db.genId();
174
+ const expenseId = db.genId();
175
+
176
+ db.execute(
177
+ `INSERT INTO opc_transactions (id, company_id, type, category, amount, description, counterparty, transaction_date, created_at)
178
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
179
+ incomeId, companyId, "income", "service_income", 100000, "月收入", "客户", `2026-0${month}-15`
180
+ );
181
+
182
+ db.execute(
183
+ `INSERT INTO opc_transactions (id, company_id, type, category, amount, description, counterparty, transaction_date, created_at)
184
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
185
+ expenseId, companyId, "expense", "salary", 30000, "月支出", "员工", `2026-0${month}-20`
186
+ );
187
+ }
188
+
189
+ const summary = db.queryOne(
190
+ `SELECT
191
+ COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as total_income,
192
+ COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as total_expense
193
+ FROM opc_transactions WHERE company_id = ?`,
194
+ companyId
195
+ ) as any;
196
+
197
+ expect(summary.total_income).toBeGreaterThan(summary.total_expense);
198
+ });
199
+ });
200
+
201
+ describe("health metrics", () => {
202
+ it("should calculate company health score", () => {
203
+ // Add some positive indicators
204
+ const incomeId = db.genId();
205
+ db.execute(
206
+ `INSERT INTO opc_transactions (id, company_id, type, category, amount, description, counterparty, transaction_date, created_at)
207
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
208
+ incomeId, companyId, "income", "service_income", 200000, "销售", "客户", "2026-01-15"
209
+ );
210
+
211
+ const contractId = db.genId();
212
+ const now = new Date().toISOString();
213
+ db.execute(
214
+ `INSERT INTO opc_contracts (id, company_id, title, counterparty, contract_type, amount, start_date, end_date, status, key_terms, risk_notes, reminder_date, created_at, updated_at)
215
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
216
+ contractId, companyId, "服务合同", "客户A", "service", 100000, "2026-01-01", "2026-12-31", "active", "", "", "2026-11-30", now, now
217
+ );
218
+
219
+ const metrics = db.queryOne(
220
+ `SELECT
221
+ (SELECT COUNT(*) FROM opc_transactions WHERE company_id = ? AND type = 'income') as income_count,
222
+ (SELECT COUNT(*) FROM opc_contracts WHERE company_id = ? AND status = 'active') as active_contracts
223
+ FROM opc_companies WHERE id = ?`,
224
+ companyId, companyId, companyId
225
+ ) as any;
226
+
227
+ expect(metrics.income_count).toBeGreaterThan(0);
228
+ expect(metrics.active_contracts).toBeGreaterThan(0);
229
+ });
230
+ });
231
+ });