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
@@ -46,6 +46,28 @@ const MediaSchema = Type.Union([
46
46
  action: Type.Literal("delete_content"),
47
47
  content_id: Type.String({ description: "内容 ID" }),
48
48
  }),
49
+ Type.Object({
50
+ action: Type.Literal("generate_content_brief"),
51
+ company_id: Type.String({ description: "公司 ID" }),
52
+ platform: Type.String({ description: "目标平台: 微信公众号/小红书/抖音/微博/知乎/B站" }),
53
+ topic: Type.String({ description: "主题关键词" }),
54
+ }),
55
+ Type.Object({
56
+ action: Type.Literal("submit_for_review"),
57
+ content_id: Type.String({ description: "内容 ID" }),
58
+ }),
59
+ Type.Object({
60
+ action: Type.Literal("approve_content"),
61
+ content_id: Type.String({ description: "内容 ID" }),
62
+ approved: Type.Boolean({ description: "是否通过: true=通过, false=需修改" }),
63
+ review_notes: Type.Optional(Type.String({ description: "审批备注/修改意见" })),
64
+ reviewer: Type.Optional(Type.String({ description: "审批人" })),
65
+ }),
66
+ Type.Object({
67
+ action: Type.Literal("content_analytics"),
68
+ company_id: Type.String({ description: "公司 ID" }),
69
+ month: Type.Optional(Type.String({ description: "月份 (YYYY-MM),默认当月" })),
70
+ }),
49
71
  ]);
50
72
 
51
73
  type MediaParams = Static<typeof MediaSchema>;
@@ -90,7 +112,9 @@ export function registerMediaTool(api: OpenClawPluginApi, db: OpcDatabase): void
90
112
  label: "OPC 新媒体运营",
91
113
  description:
92
114
  "新媒体运营工具。操作: create_content(创建内容), list_content(内容列表), " +
93
- "update_content(更新内容), content_calendar(发布日历), platform_guide(平台指南), delete_content(删除内容)",
115
+ "update_content(更新内容), content_calendar(发布日历), platform_guide(平台指南), " +
116
+ "delete_content(删除内容), generate_content_brief(AI内容策划摘要), " +
117
+ "submit_for_review(提交审批), approve_content(审批内容), content_analytics(内容分析)",
94
118
  parameters: MediaSchema,
95
119
  async execute(_toolCallId, params) {
96
120
  const p = params as MediaParams;
@@ -161,6 +185,137 @@ export function registerMediaTool(api: OpenClawPluginApi, db: OpcDatabase): void
161
185
  return json({ ok: true });
162
186
  }
163
187
 
188
+ case "generate_content_brief": {
189
+ // 获取公司信息
190
+ const company = db.queryOne("SELECT * FROM opc_companies WHERE id = ?", p.company_id) as Record<string, unknown> | null;
191
+ if (!company) return toolError(`公司 ${p.company_id} 不存在`, "COMPANY_NOT_FOUND");
192
+
193
+ // 获取 OPB Canvas
194
+ const canvas = db.queryOne("SELECT * FROM opc_opb_canvas WHERE company_id = ?", p.company_id) as Record<string, unknown> | null;
195
+
196
+ // 获取平台指南
197
+ const guide = PLATFORM_GUIDES[p.platform];
198
+
199
+ const brief = {
200
+ ok: true,
201
+ platform: p.platform,
202
+ topic: p.topic,
203
+ company_info: {
204
+ name: company.name,
205
+ industry: company.industry,
206
+ description: company.description,
207
+ },
208
+ canvas_highlights: canvas ? {
209
+ value_proposition: canvas.solution || canvas.unique_value,
210
+ target_customer: canvas.target_customer,
211
+ pain_point: canvas.pain_point,
212
+ } : null,
213
+ platform_guide: guide ?? null,
214
+ content_strategy: {
215
+ title_suggestions: [
216
+ `${p.topic} — 一人企业的实战经验分享`,
217
+ `关于${p.topic},你需要知道的3件事`,
218
+ `${company.industry}行业 ${p.topic} 深度解析`,
219
+ ],
220
+ outline: [
221
+ "1. 引入:痛点/现象描述(引起共鸣)",
222
+ "2. 分析:为什么会这样(专业视角)",
223
+ "3. 解决方案:具体可操作的方法",
224
+ "4. 案例/数据:增加可信度",
225
+ "5. 总结 + 行动号召(引导互动)",
226
+ ],
227
+ keywords: [p.topic, company.industry as string, "一人企业", "创业"],
228
+ hashtags: guide ? [`#${p.topic}`, `#${company.industry}`, "#一人企业", "#创业干货"] : [`#${p.topic}`],
229
+ platform_tips: guide ? guide.tips : [],
230
+ best_publish_time: guide ? guide.best_time : "建议工作日晚间 20-22 点",
231
+ format_recommendation: guide ? guide.format : "图文内容",
232
+ },
233
+ };
234
+
235
+ return json(brief);
236
+ }
237
+
238
+ case "submit_for_review": {
239
+ const content = db.queryOne("SELECT * FROM opc_media_content WHERE id = ?", p.content_id) as Record<string, unknown> | null;
240
+ if (!content) return toolError("内容不存在", "RECORD_NOT_FOUND");
241
+ db.execute(
242
+ "UPDATE opc_media_content SET status = 'pending_review', updated_at = ? WHERE id = ?",
243
+ new Date().toISOString(), p.content_id,
244
+ );
245
+ return json(db.queryOne("SELECT * FROM opc_media_content WHERE id = ?", p.content_id));
246
+ }
247
+
248
+ case "approve_content": {
249
+ const content = db.queryOne("SELECT * FROM opc_media_content WHERE id = ?", p.content_id) as Record<string, unknown> | null;
250
+ if (!content) return toolError("内容不存在", "RECORD_NOT_FOUND");
251
+
252
+ const now = new Date().toISOString();
253
+ if (p.approved) {
254
+ db.execute(
255
+ "UPDATE opc_media_content SET status = 'approved', reviewer = ?, review_notes = ?, approved_at = ?, updated_at = ? WHERE id = ?",
256
+ p.reviewer ?? "", p.review_notes ?? "", now, now, p.content_id,
257
+ );
258
+ } else {
259
+ db.execute(
260
+ "UPDATE opc_media_content SET status = 'revision_needed', reviewer = ?, review_notes = ?, updated_at = ? WHERE id = ?",
261
+ p.reviewer ?? "", p.review_notes ?? "", now, p.content_id,
262
+ );
263
+ }
264
+
265
+ return json(db.queryOne("SELECT * FROM opc_media_content WHERE id = ?", p.content_id));
266
+ }
267
+
268
+ case "content_analytics": {
269
+ const month = p.month ?? new Date().toISOString().slice(0, 7);
270
+
271
+ const total = (db.queryOne(
272
+ "SELECT COUNT(*) as cnt FROM opc_media_content WHERE company_id = ? AND created_at LIKE ?",
273
+ p.company_id, month + "%",
274
+ ) as { cnt: number }).cnt;
275
+
276
+ const published = (db.queryOne(
277
+ "SELECT COUNT(*) as cnt FROM opc_media_content WHERE company_id = ? AND status = 'published' AND published_date LIKE ?",
278
+ p.company_id, month + "%",
279
+ ) as { cnt: number }).cnt;
280
+
281
+ const byPlatform = db.query(
282
+ "SELECT platform, COUNT(*) as cnt FROM opc_media_content WHERE company_id = ? AND created_at LIKE ? GROUP BY platform",
283
+ p.company_id, month + "%",
284
+ );
285
+
286
+ const byStatus = db.query(
287
+ "SELECT status, COUNT(*) as cnt FROM opc_media_content WHERE company_id = ? AND created_at LIKE ? GROUP BY status",
288
+ p.company_id, month + "%",
289
+ );
290
+
291
+ // 汇总互动指标
292
+ const allMetrics = db.query(
293
+ "SELECT metrics FROM opc_media_content WHERE company_id = ? AND status = 'published' AND published_date LIKE ?",
294
+ p.company_id, month + "%",
295
+ ) as { metrics: string }[];
296
+
297
+ let totalViews = 0, totalLikes = 0, totalComments = 0, totalShares = 0;
298
+ for (const row of allMetrics) {
299
+ try {
300
+ const m = JSON.parse(row.metrics);
301
+ totalViews += m.views ?? 0;
302
+ totalLikes += m.likes ?? 0;
303
+ totalComments += m.comments ?? 0;
304
+ totalShares += m.shares ?? 0;
305
+ } catch { /* skip */ }
306
+ }
307
+
308
+ return json({
309
+ ok: true,
310
+ month,
311
+ total_content: total,
312
+ published_count: published,
313
+ by_platform: byPlatform,
314
+ by_status: byStatus,
315
+ engagement: { views: totalViews, likes: totalLikes, comments: totalComments, shares: totalShares },
316
+ });
317
+ }
318
+
164
319
  default:
165
320
  return toolError(`未知操作: ${(p as { action: string }).action}`, "UNKNOWN_ACTION");
166
321
  }
@@ -6,6 +6,9 @@ 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
8
  import { json, toolError } from "../utils/tool-helper.js";
9
+ import { computeHealthScore, computeGrowthScorecard, getLastBriefing } from "../opc/briefing-builder.js";
10
+ import { detectCompanyStage } from "../opc/stage-detector.js";
11
+ import { getActiveInsights } from "../opc/intelligence-engine.js";
9
12
 
10
13
  const MonitoringSchema = Type.Union([
11
14
  Type.Object({
@@ -52,6 +55,31 @@ const MonitoringSchema = Type.Union([
52
55
  action: Type.Literal("delete_metric"),
53
56
  metric_id: Type.String({ description: "指标记录 ID" }),
54
57
  }),
58
+ Type.Object({
59
+ action: Type.Literal("list_insights"),
60
+ company_id: Type.String({ description: "公司 ID" }),
61
+ insight_type: Type.Optional(Type.String({ description: "类型筛选: data_gap/trend/risk/opportunity/next_step/staff_observation" })),
62
+ }),
63
+ Type.Object({
64
+ action: Type.Literal("dismiss_insight"),
65
+ insight_id: Type.String({ description: "洞察 ID" }),
66
+ }),
67
+ Type.Object({
68
+ action: Type.Literal("health_score"),
69
+ company_id: Type.String({ description: "公司 ID" }),
70
+ }),
71
+ Type.Object({
72
+ action: Type.Literal("growth_scorecard"),
73
+ company_id: Type.String({ description: "公司 ID" }),
74
+ }),
75
+ Type.Object({
76
+ action: Type.Literal("compare_briefing"),
77
+ company_id: Type.String({ description: "公司 ID" }),
78
+ }),
79
+ Type.Object({
80
+ action: Type.Literal("strategy_report"),
81
+ company_id: Type.String({ description: "公司 ID" }),
82
+ }),
55
83
  ]);
56
84
 
57
85
  type MonitoringParams = Static<typeof MonitoringSchema>;
@@ -64,7 +92,9 @@ export function registerMonitoringTool(api: OpenClawPluginApi, db: OpcDatabase):
64
92
  description:
65
93
  "运营监控工具。操作: record_metric(记录指标), get_metrics(查询指标), " +
66
94
  "create_alert(创建告警), list_alerts(告警列表), " +
67
- "dismiss_alert(消除告警), kpi_summary(KPI 汇总), delete_metric(删除指标记录)",
95
+ "dismiss_alert(消除告警), kpi_summary(KPI 汇总), delete_metric(删除指标记录), " +
96
+ "list_insights(查看洞察), dismiss_insight(标记洞察已处理), health_score(健康评分), " +
97
+ "growth_scorecard(增长评分卡), compare_briefing(简报历史对比), strategy_report(战略分析报告)",
68
98
  parameters: MonitoringSchema,
69
99
  async execute(_toolCallId, params) {
70
100
  const p = params as MonitoringParams;
@@ -135,7 +165,7 @@ export function registerMonitoringTool(api: OpenClawPluginApi, db: OpcDatabase):
135
165
  ) as { total_income: number; total_expense: number };
136
166
 
137
167
  const employees = db.queryOne(
138
- "SELECT COUNT(*) as count FROM opc_employees WHERE company_id = ? AND status = 'active'",
168
+ "SELECT COUNT(*) as count FROM opc_hr_records WHERE company_id = ? AND status = 'active'",
139
169
  p.company_id,
140
170
  ) as { count: number };
141
171
 
@@ -194,6 +224,109 @@ export function registerMonitoringTool(api: OpenClawPluginApi, db: OpcDatabase):
194
224
  return json({ ok: true });
195
225
  }
196
226
 
227
+ case "list_insights": {
228
+ let sql = "SELECT * FROM opc_insights WHERE company_id = ? AND status = 'active' AND (expires_at = '' OR expires_at > datetime('now'))";
229
+ const params2: unknown[] = [p.company_id];
230
+ if ("insight_type" in p && p.insight_type) { sql += " AND insight_type = ?"; params2.push(p.insight_type); }
231
+ sql += " ORDER BY priority DESC, created_at DESC LIMIT 20";
232
+ return json(db.query(sql, ...params2));
233
+ }
234
+
235
+ case "dismiss_insight": {
236
+ const now = new Date().toISOString();
237
+ db.execute(
238
+ "UPDATE opc_insights SET status = 'dismissed', updated_at = ? WHERE id = ?",
239
+ now, p.insight_id,
240
+ );
241
+ const ins = db.queryOne("SELECT * FROM opc_insights WHERE id = ?", p.insight_id);
242
+ if (!ins) return toolError("洞察不存在", "RECORD_NOT_FOUND");
243
+ return json(ins);
244
+ }
245
+
246
+ case "health_score": {
247
+ const score = computeHealthScore(db, p.company_id);
248
+ return json(score);
249
+ }
250
+
251
+ case "growth_scorecard": {
252
+ const card = computeGrowthScorecard(db, p.company_id);
253
+ return json(card);
254
+ }
255
+
256
+ case "compare_briefing": {
257
+ const last = getLastBriefing(db, p.company_id);
258
+ if (!last) return json({ message: "尚无历史简报快照,系统将在每日扫描时自动保存" });
259
+ const currentHealth = computeHealthScore(db, p.company_id);
260
+ const currentScorecard = computeGrowthScorecard(db, p.company_id);
261
+ return json({
262
+ last_date: last.date,
263
+ health_score: { previous: last.healthScore, current: currentHealth.total, change: currentHealth.total - last.healthScore },
264
+ income: { previous: last.totalIncome, current: (db.queryOne("SELECT COALESCE(SUM(amount),0) as total FROM opc_transactions WHERE company_id = ? AND type='income'", p.company_id) as { total: number }).total },
265
+ scorecard: { previous: last.scorecardGrade, current: currentScorecard.overall },
266
+ dimensions: currentHealth.dimensions,
267
+ });
268
+ }
269
+
270
+ case "strategy_report": {
271
+ const stageResult = detectCompanyStage(db, p.company_id);
272
+ const healthResult = computeHealthScore(db, p.company_id);
273
+ const scorecardResult = computeGrowthScorecard(db, p.company_id);
274
+ const insights = getActiveInsights(db, p.company_id, 20);
275
+
276
+ const totalIncome = (db.queryOne(
277
+ "SELECT COALESCE(SUM(amount), 0) as total FROM opc_transactions WHERE company_id = ? AND type = 'income'", p.company_id,
278
+ ) as { total: number }).total;
279
+ const totalExpense = (db.queryOne(
280
+ "SELECT COALESCE(SUM(amount), 0) as total FROM opc_transactions WHERE company_id = ? AND type = 'expense'", p.company_id,
281
+ ) as { total: number }).total;
282
+ const revenueMonths = (db.queryOne(
283
+ "SELECT COUNT(DISTINCT strftime('%Y-%m', transaction_date)) as cnt FROM opc_transactions WHERE company_id = ? AND type = 'income' AND amount > 0",
284
+ p.company_id,
285
+ ) as { cnt: number }).cnt;
286
+ const contactCount = (db.queryOne(
287
+ "SELECT COUNT(*) as cnt FROM opc_contacts WHERE company_id = ?", p.company_id,
288
+ ) as { cnt: number }).cnt;
289
+ const contractCount = (db.queryOne(
290
+ "SELECT COUNT(*) as cnt FROM opc_contracts WHERE company_id = ?", p.company_id,
291
+ ) as { cnt: number }).cnt;
292
+ const contentCount = (db.queryOne(
293
+ "SELECT COUNT(*) as cnt FROM opc_media_content WHERE company_id = ?", p.company_id,
294
+ ) as { cnt: number }).cnt;
295
+
296
+ // 收入来源分析
297
+ const counterparties = db.query(
298
+ "SELECT counterparty, SUM(amount) as total FROM opc_transactions WHERE company_id = ? AND type = 'income' AND counterparty != '' GROUP BY counterparty ORDER BY total DESC LIMIT 5",
299
+ p.company_id,
300
+ ) as { counterparty: string; total: number }[];
301
+ const topClient = counterparties[0];
302
+ const concentration = topClient && totalIncome > 0
303
+ ? Math.round((topClient.total / totalIncome) * 100) : 0;
304
+
305
+ return json({
306
+ stage: stageResult,
307
+ health: healthResult,
308
+ scorecard: scorecardResult,
309
+ financials: {
310
+ total_income: totalIncome,
311
+ total_expense: totalExpense,
312
+ net_profit: totalIncome - totalExpense,
313
+ profit_rate: totalIncome > 0 ? Math.round(((totalIncome - totalExpense) / totalIncome) * 100) : 0,
314
+ revenue_months: revenueMonths,
315
+ revenue_sources: counterparties,
316
+ top_client_concentration: `${concentration}%`,
317
+ },
318
+ operations: {
319
+ contact_count: contactCount,
320
+ contract_count: contractCount,
321
+ content_count: contentCount,
322
+ },
323
+ risks: insights.filter(i => i.insight_type === "risk").map(i => ({ title: i.title, priority: i.priority })),
324
+ opportunities: insights.filter(i => i.insight_type === "opportunity").map(i => ({ title: i.title })),
325
+ data_gaps: insights.filter(i => i.insight_type === "data_gap").map(i => i.title),
326
+ staff_observations: insights.filter(i => i.insight_type === "staff_observation").map(i => ({ role: i.staff_role, title: i.title })),
327
+ });
328
+ }
329
+
197
330
  default:
198
331
  return toolError(`未知操作: ${(p as { action: string }).action}`, "UNKNOWN_ACTION");
199
332
  }
@@ -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
+ });