galaxy-opc-plugin 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,32 +1,110 @@
1
1
  /**
2
- * 星环OPC中心 — Dashboard 统计 API
2
+ * 星环OPC中心 — Dashboard 监控中心 API
3
3
  *
4
4
  * 路由:
5
- * GET /opc/api/dashboard/stats平台整体统计
5
+ * GET /opc/admin/api/dashboard — 获取监控中心数据
6
+ * POST /opc/admin/api/alerts/:id/dismiss — 忽略预警
6
7
  */
7
8
 
8
9
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
9
10
  import type { OpcDatabase } from "../db/index.js";
10
- import { authenticateRequest, apiRateLimiter } from "./middleware.js";
11
+ import {
12
+ generateDashboardHtml,
13
+ type DashboardData,
14
+ type DashboardMetrics,
15
+ type DashboardAlert,
16
+ type DashboardTodo,
17
+ type DashboardSuggestion,
18
+ } from "../web/dashboard-ui.js";
11
19
 
12
- export function registerDashboardRoutes(api: OpenClawPluginApi, db: OpcDatabase, gatewayToken?: string): void {
20
+ interface DashboardRow {
21
+ total_income: number;
22
+ total_expense: number;
23
+ }
24
+
25
+ interface PaymentRow {
26
+ id: string;
27
+ company_id: string;
28
+ direction: string;
29
+ counterparty: string;
30
+ amount: number;
31
+ paid_amount: number;
32
+ status: string;
33
+ due_date: string;
34
+ category: string;
35
+ }
36
+
37
+ interface AlertRow {
38
+ id: string;
39
+ company_id: string;
40
+ title: string;
41
+ severity: string;
42
+ category: string;
43
+ status: string;
44
+ message: string;
45
+ created_at: string;
46
+ }
47
+
48
+ interface TodoRow {
49
+ id: string;
50
+ company_id: string;
51
+ title: string;
52
+ priority: string;
53
+ category: string;
54
+ status: string;
55
+ due_date: string;
56
+ description: string;
57
+ }
58
+
59
+ /**
60
+ * 注册 Dashboard API 路由
61
+ */
62
+ export function registerDashboardApiRoutes(api: OpenClawPluginApi, db: OpcDatabase): void {
63
+ // GET /opc/admin/api/dashboard
13
64
  api.registerHttpRoute({
14
- path: "/opc/api/dashboard/stats",
65
+ path: "/opc/admin/api/dashboard",
15
66
  handler: (req, res) => {
16
- // 限流检查
17
- if (!apiRateLimiter.check(req, res)) {
18
- return;
19
- }
20
-
21
- // 认证检查
22
- if (!authenticateRequest(req, res, gatewayToken)) {
23
- return;
67
+ try {
68
+ const data = getDashboardData(db);
69
+ const html = generateDashboardHtml(data);
70
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
71
+ res.end(JSON.stringify({ ...data, html }));
72
+ } catch (err) {
73
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
74
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
24
75
  }
76
+ },
77
+ });
25
78
 
79
+ // POST /opc/admin/api/alerts/:id/dismiss
80
+ api.registerHttpRoute({
81
+ path: "/opc/admin/api/alerts/:id/dismiss",
82
+ handler: async (req, res) => {
26
83
  try {
27
- const stats = db.getDashboardStats();
28
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
29
- res.end(JSON.stringify(stats));
84
+ const url = new URL(req.url || "", `http://${req.headers.host}`);
85
+ const pathParts = url.pathname.split("/");
86
+ const alertId = pathParts[pathParts.length - 2]; // .../alerts/{id}/dismiss
87
+
88
+ if (!alertId) {
89
+ res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
90
+ res.end(JSON.stringify({ error: "Missing alert ID" }));
91
+ return;
92
+ }
93
+
94
+ const now = new Date().toISOString();
95
+ const result = db.execute(
96
+ "UPDATE opc_alerts SET status = 'resolved', resolved_at = ? WHERE id = ? AND status = 'active'",
97
+ now,
98
+ alertId,
99
+ );
100
+
101
+ if (result.changes > 0) {
102
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
103
+ res.end(JSON.stringify({ ok: true }));
104
+ } else {
105
+ res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
106
+ res.end(JSON.stringify({ error: "Alert not found or already resolved" }));
107
+ }
30
108
  } catch (err) {
31
109
  res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
32
110
  res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
@@ -34,3 +112,277 @@ export function registerDashboardRoutes(api: OpenClawPluginApi, db: OpcDatabase,
34
112
  },
35
113
  });
36
114
  }
115
+
116
+ /**
117
+ * 获取 Dashboard 数据
118
+ */
119
+ function getDashboardData(db: OpcDatabase): DashboardData {
120
+ const metrics = calculateMetrics(db);
121
+ const alerts = getActiveAlerts(db);
122
+ const todos = getTodayTodos(db);
123
+ const suggestions = generateSuggestions(db, metrics, alerts, todos);
124
+
125
+ return {
126
+ metrics,
127
+ alerts,
128
+ todos,
129
+ suggestions,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * 计算关键指标
135
+ */
136
+ function calculateMetrics(db: OpcDatabase): DashboardMetrics {
137
+ // 本月收入和支出
138
+ const now = new Date();
139
+ const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
140
+ const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10);
141
+
142
+ const currentMonth = db.queryOne(
143
+ `SELECT
144
+ COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END), 0) as total_income,
145
+ COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END), 0) as total_expense
146
+ FROM opc_transactions
147
+ WHERE transaction_date >= ? AND transaction_date <= ?`,
148
+ currentMonthStart,
149
+ currentMonthEnd,
150
+ ) as DashboardRow;
151
+
152
+ // 上月收入和支出(用于计算同比)
153
+ const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().slice(0, 10);
154
+ const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().slice(0, 10);
155
+
156
+ const lastMonth = db.queryOne(
157
+ `SELECT
158
+ COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END), 0) as total_income,
159
+ COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END), 0) as total_expense
160
+ FROM opc_transactions
161
+ WHERE transaction_date >= ? AND transaction_date <= ?`,
162
+ lastMonthStart,
163
+ lastMonthEnd,
164
+ ) as DashboardRow;
165
+
166
+ const monthlyIncome = currentMonth.total_income;
167
+ const monthlyExpense = currentMonth.total_expense;
168
+ const monthlyProfit = monthlyIncome - monthlyExpense;
169
+
170
+ // 计算同比变化(避免除零)
171
+ const monthlyIncomeChange = lastMonth.total_income > 0
172
+ ? ((monthlyIncome - lastMonth.total_income) / lastMonth.total_income) * 100
173
+ : 0;
174
+
175
+ const lastMonthProfit = lastMonth.total_income - lastMonth.total_expense;
176
+ const monthlyProfitChange = lastMonthProfit !== 0
177
+ ? ((monthlyProfit - lastMonthProfit) / Math.abs(lastMonthProfit)) * 100
178
+ : 0;
179
+
180
+ // 现金余额(所有收入 - 所有支出)
181
+ const cashBalance = db.queryOne(
182
+ `SELECT
183
+ COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END), 0) -
184
+ COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END), 0) as balance
185
+ FROM opc_transactions`,
186
+ ) as { balance: number };
187
+
188
+ // 可撑月数(基于最近3个月平均支出)
189
+ const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, 1).toISOString().slice(0, 10);
190
+ const avgExpense = db.queryOne(
191
+ `SELECT COALESCE(AVG(monthly_expense), 0) as avg_expense FROM (
192
+ SELECT SUM(amount) as monthly_expense
193
+ FROM opc_transactions
194
+ WHERE type='expense' AND transaction_date >= ?
195
+ GROUP BY strftime('%Y-%m', transaction_date)
196
+ )`,
197
+ threeMonthsAgo,
198
+ ) as { avg_expense: number };
199
+
200
+ const monthsOfRunway = avgExpense.avg_expense > 0
201
+ ? cashBalance.balance / avgExpense.avg_expense
202
+ : 999;
203
+
204
+ // 应收账款(未收和部分已收的应收款)
205
+ const receivables = db.queryOne(
206
+ `SELECT COALESCE(SUM(amount - paid_amount), 0) as total
207
+ FROM opc_payments
208
+ WHERE direction = 'receivable' AND status IN ('pending', 'partial')`,
209
+ ) as { total: number };
210
+
211
+ // 逾期应收账款
212
+ const today = new Date().toISOString().slice(0, 10);
213
+ const overdueReceivables = db.queryOne(
214
+ `SELECT COALESCE(SUM(amount - paid_amount), 0) as total
215
+ FROM opc_payments
216
+ WHERE direction = 'receivable'
217
+ AND status IN ('pending', 'partial', 'overdue')
218
+ AND due_date < ?`,
219
+ today,
220
+ ) as { total: number };
221
+
222
+ return {
223
+ monthlyIncome,
224
+ monthlyIncomeChange,
225
+ monthlyProfit,
226
+ monthlyProfitChange,
227
+ cashBalance: cashBalance.balance,
228
+ monthsOfRunway,
229
+ receivables: receivables.total,
230
+ overdueReceivables: overdueReceivables.total,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * 获取活跃预警
236
+ */
237
+ function getActiveAlerts(db: OpcDatabase): DashboardAlert[] {
238
+ const alerts = db.query(
239
+ `SELECT id, company_id, title, severity, category, message, created_at
240
+ FROM opc_alerts
241
+ WHERE status = 'active'
242
+ ORDER BY
243
+ CASE severity
244
+ WHEN 'critical' THEN 0
245
+ WHEN 'warning' THEN 1
246
+ ELSE 2
247
+ END,
248
+ created_at DESC
249
+ LIMIT 10`,
250
+ ) as AlertRow[];
251
+
252
+ return alerts.map(alert => ({
253
+ id: alert.id,
254
+ title: alert.title,
255
+ severity: alert.severity as 'critical' | 'warning' | 'info',
256
+ category: alert.category,
257
+ message: alert.message,
258
+ created_at: alert.created_at,
259
+ }));
260
+ }
261
+
262
+ /**
263
+ * 获取今日待办
264
+ */
265
+ function getTodayTodos(db: OpcDatabase): DashboardTodo[] {
266
+ const today = new Date().toISOString().slice(0, 10);
267
+
268
+ const todos = db.query(
269
+ `SELECT id, company_id, title, priority, related_type as category, due_date, description
270
+ FROM opc_todos
271
+ WHERE status = 'pending'
272
+ AND (due_date = ? OR due_date < ?)
273
+ ORDER BY
274
+ CASE priority
275
+ WHEN 'urgent' THEN 0
276
+ WHEN 'high' THEN 1
277
+ ELSE 2
278
+ END,
279
+ due_date ASC
280
+ LIMIT 20`,
281
+ today,
282
+ today,
283
+ ) as TodoRow[];
284
+
285
+ return todos.map(todo => ({
286
+ id: todo.id,
287
+ title: todo.title,
288
+ priority: todo.priority as 'urgent' | 'high' | 'normal',
289
+ category: todo.category || '',
290
+ due_date: todo.due_date || undefined,
291
+ description: todo.description || undefined,
292
+ }));
293
+ }
294
+
295
+ /**
296
+ * 生成 AI 建议
297
+ */
298
+ function generateSuggestions(
299
+ db: OpcDatabase,
300
+ metrics: DashboardMetrics,
301
+ alerts: DashboardAlert[],
302
+ todos: DashboardTodo[],
303
+ ): DashboardSuggestion[] {
304
+ const suggestions: DashboardSuggestion[] = [];
305
+
306
+ // 建议1: 现金流预警
307
+ if (metrics.monthsOfRunway < 2) {
308
+ suggestions.push({
309
+ id: "cash-runway-low",
310
+ title: "现金流预警:可撑月数不足",
311
+ description: `当前现金余额 ¥${metrics.cashBalance.toFixed(0)} 仅能维持 ${metrics.monthsOfRunway.toFixed(1)} 个月运营。建议尽快催收应收账款或拓展收入来源。`,
312
+ action: {
313
+ label: "查看应收账款",
314
+ onclick: "loadView('finance')",
315
+ },
316
+ });
317
+ }
318
+
319
+ // 建议2: 逾期催收
320
+ if (metrics.overdueReceivables > 0) {
321
+ suggestions.push({
322
+ id: "overdue-receivables",
323
+ title: "逾期账款催收提醒",
324
+ description: `有 ¥${metrics.overdueReceivables.toFixed(0)} 的应收账款已逾期,建议立即联系客户催收。`,
325
+ action: {
326
+ label: "查看逾期清单",
327
+ onclick: "loadView('finance')",
328
+ },
329
+ });
330
+ }
331
+
332
+ // 建议3: 利润优化
333
+ if (metrics.monthlyProfit < 0) {
334
+ suggestions.push({
335
+ id: "negative-profit",
336
+ title: "本月利润为负,需优化成本",
337
+ description: `本月支出超过收入 ¥${Math.abs(metrics.monthlyProfit).toFixed(0)},建议检查支出明细,削减非必要成本。`,
338
+ action: {
339
+ label: "查看支出分析",
340
+ onclick: "loadView('finance')",
341
+ },
342
+ });
343
+ }
344
+
345
+ // 建议4: 收入增长策略
346
+ if (metrics.monthlyIncomeChange < -10) {
347
+ suggestions.push({
348
+ id: "income-decline",
349
+ title: "收入下滑,需拓展业务",
350
+ description: `本月收入较上月下降 ${Math.abs(metrics.monthlyIncomeChange).toFixed(1)}%,建议加强客户开发和老客户维护。`,
351
+ action: {
352
+ label: "查看客户管理",
353
+ onclick: "loadView('companies')",
354
+ },
355
+ });
356
+ }
357
+
358
+ // 建议5: 合同到期提醒
359
+ const expiringContracts = db.query(
360
+ `SELECT COUNT(*) as cnt FROM opc_contracts
361
+ WHERE status = 'active'
362
+ AND end_date <= date('now', '+30 days')
363
+ AND end_date >= date('now')`,
364
+ ) as { cnt: number }[];
365
+
366
+ if (expiringContracts.length > 0 && expiringContracts[0].cnt > 0) {
367
+ suggestions.push({
368
+ id: "contracts-expiring",
369
+ title: "合同即将到期",
370
+ description: `有 ${expiringContracts[0].cnt} 份合同将在 30 天内到期,建议提前联系续约。`,
371
+ action: {
372
+ label: "查看合同列表",
373
+ onclick: "loadView('companies')",
374
+ },
375
+ });
376
+ }
377
+
378
+ // 默认建议:一切正常
379
+ if (suggestions.length === 0) {
380
+ suggestions.push({
381
+ id: "all-good",
382
+ title: "经营状况良好",
383
+ description: "当前各项指标正常,继续保持!建议定期回顾财务数据,优化运营效率。",
384
+ });
385
+ }
386
+
387
+ return suggestions.slice(0, 5); // 最多返回5条建议
388
+ }
package/src/api/routes.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
6
6
  import type { OpcDatabase } from "../db/index.js";
7
7
  import { registerCompanyRoutes } from "./companies.js";
8
- import { registerDashboardRoutes } from "./dashboard.js";
8
+ import { registerDashboardApiRoutes } from "./dashboard.js";
9
9
 
10
10
  /**
11
11
  * 从 OpenClaw 配置中读取 gateway auth token。
@@ -26,6 +26,6 @@ export function registerHttpRoutes(api: OpenClawPluginApi, db: OpcDatabase): voi
26
26
  const gatewayToken = getGatewayToken(api);
27
27
 
28
28
  registerCompanyRoutes(api, db, gatewayToken);
29
- registerDashboardRoutes(api, db, gatewayToken);
29
+ registerDashboardApiRoutes(api, db);
30
30
  api.logger.info("opc: 已注册 HTTP API 路由");
31
31
  }
@@ -166,6 +166,152 @@ export const migrations: Migration[] = [
166
166
  // Indexes created via OPC_INDEXES
167
167
  },
168
168
  },
169
+ {
170
+ version: 15,
171
+ description: "Payment management enhancement — add direction, counterparty, paid_amount, category for receivables/payables",
172
+ up(db) {
173
+ const cols = db.pragma("table_info(opc_payments)") as { name: string }[];
174
+ if (!cols.some(c => c.name === "direction")) {
175
+ db.exec("ALTER TABLE opc_payments ADD COLUMN direction TEXT NOT NULL DEFAULT 'receivable'");
176
+ }
177
+ if (!cols.some(c => c.name === "counterparty")) {
178
+ db.exec("ALTER TABLE opc_payments ADD COLUMN counterparty TEXT NOT NULL DEFAULT ''");
179
+ }
180
+ if (!cols.some(c => c.name === "paid_amount")) {
181
+ db.exec("ALTER TABLE opc_payments ADD COLUMN paid_amount REAL NOT NULL DEFAULT 0");
182
+ }
183
+ if (!cols.some(c => c.name === "category")) {
184
+ db.exec("ALTER TABLE opc_payments ADD COLUMN category TEXT NOT NULL DEFAULT ''");
185
+ }
186
+ if (!cols.some(c => c.name === "payment_method")) {
187
+ db.exec("ALTER TABLE opc_payments ADD COLUMN payment_method TEXT NOT NULL DEFAULT ''");
188
+ }
189
+ if (!cols.some(c => c.name === "contract_id")) {
190
+ db.exec("ALTER TABLE opc_payments ADD COLUMN contract_id TEXT NOT NULL DEFAULT ''");
191
+ }
192
+ // 创建新索引
193
+ db.exec("CREATE INDEX IF NOT EXISTS idx_payments_direction ON opc_payments(direction)");
194
+ db.exec("CREATE INDEX IF NOT EXISTS idx_payments_counterparty ON opc_payments(counterparty)");
195
+ db.exec("CREATE INDEX IF NOT EXISTS idx_payments_company_direction ON opc_payments(company_id, direction)");
196
+ db.exec("CREATE INDEX IF NOT EXISTS idx_payments_company_status ON opc_payments(company_id, status)");
197
+ },
198
+ },
199
+ {
200
+ version: 16,
201
+ description: "Order workflow — quotations, quotation_items, contract_milestones tables",
202
+ up(db) {
203
+ // 报价单表
204
+ db.exec(`
205
+ CREATE TABLE IF NOT EXISTS opc_quotations (
206
+ id TEXT PRIMARY KEY,
207
+ company_id TEXT NOT NULL,
208
+ contact_id TEXT NOT NULL DEFAULT '',
209
+ quotation_number TEXT NOT NULL,
210
+ title TEXT NOT NULL,
211
+ total_amount REAL NOT NULL DEFAULT 0,
212
+ valid_until TEXT NOT NULL DEFAULT '',
213
+ status TEXT NOT NULL DEFAULT 'draft',
214
+ notes TEXT NOT NULL DEFAULT '',
215
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
216
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
217
+ FOREIGN KEY (company_id) REFERENCES opc_companies(id),
218
+ FOREIGN KEY (contact_id) REFERENCES opc_contacts(id)
219
+ )
220
+ `);
221
+
222
+ // 报价明细表
223
+ db.exec(`
224
+ CREATE TABLE IF NOT EXISTS opc_quotation_items (
225
+ id TEXT PRIMARY KEY,
226
+ quotation_id TEXT NOT NULL,
227
+ description TEXT NOT NULL,
228
+ quantity REAL NOT NULL DEFAULT 1,
229
+ unit_price REAL NOT NULL DEFAULT 0,
230
+ total_price REAL NOT NULL DEFAULT 0,
231
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
232
+ FOREIGN KEY (quotation_id) REFERENCES opc_quotations(id)
233
+ )
234
+ `);
235
+
236
+ // 合同里程碑表
237
+ db.exec(`
238
+ CREATE TABLE IF NOT EXISTS opc_contract_milestones (
239
+ id TEXT PRIMARY KEY,
240
+ contract_id TEXT NOT NULL,
241
+ company_id TEXT NOT NULL,
242
+ title TEXT NOT NULL,
243
+ description TEXT NOT NULL DEFAULT '',
244
+ due_date TEXT NOT NULL DEFAULT '',
245
+ amount REAL NOT NULL DEFAULT 0,
246
+ status TEXT NOT NULL DEFAULT 'pending',
247
+ completed_date TEXT NOT NULL DEFAULT '',
248
+ notes TEXT NOT NULL DEFAULT '',
249
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
250
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
251
+ FOREIGN KEY (contract_id) REFERENCES opc_contracts(id),
252
+ FOREIGN KEY (company_id) REFERENCES opc_companies(id)
253
+ )
254
+ `);
255
+
256
+ // 创建索引
257
+ db.exec("CREATE INDEX IF NOT EXISTS idx_quotations_company ON opc_quotations(company_id)");
258
+ db.exec("CREATE INDEX IF NOT EXISTS idx_quotations_status ON opc_quotations(status)");
259
+ db.exec("CREATE INDEX IF NOT EXISTS idx_quotations_contact ON opc_quotations(contact_id)");
260
+ db.exec("CREATE INDEX IF NOT EXISTS idx_quotation_items_quotation ON opc_quotation_items(quotation_id)");
261
+ db.exec("CREATE INDEX IF NOT EXISTS idx_contract_milestones_contract ON opc_contract_milestones(contract_id)");
262
+ db.exec("CREATE INDEX IF NOT EXISTS idx_contract_milestones_status ON opc_contract_milestones(status)");
263
+ db.exec("CREATE INDEX IF NOT EXISTS idx_contract_milestones_due_date ON opc_contract_milestones(due_date)");
264
+ },
265
+ },
266
+ {
267
+ version: 17,
268
+ description: "Contract enhancement — add quotation_id, signed_date, payment_terms",
269
+ up(db) {
270
+ const cols = db.pragma("table_info(opc_contracts)") as { name: string }[];
271
+
272
+ if (!cols.some(c => c.name === "quotation_id")) {
273
+ db.exec("ALTER TABLE opc_contracts ADD COLUMN quotation_id TEXT NOT NULL DEFAULT ''");
274
+ }
275
+ if (!cols.some(c => c.name === "signed_date")) {
276
+ db.exec("ALTER TABLE opc_contracts ADD COLUMN signed_date TEXT NOT NULL DEFAULT ''");
277
+ }
278
+ if (!cols.some(c => c.name === "payment_terms")) {
279
+ db.exec("ALTER TABLE opc_contracts ADD COLUMN payment_terms TEXT NOT NULL DEFAULT ''");
280
+ }
281
+
282
+ // 创建索引
283
+ db.exec("CREATE INDEX IF NOT EXISTS idx_contracts_quotation ON opc_contracts(quotation_id)");
284
+ db.exec("CREATE INDEX IF NOT EXISTS idx_contracts_signed_date ON opc_contracts(signed_date)");
285
+ },
286
+ },
287
+ {
288
+ version: 18,
289
+ description: "Payment enhancement — add milestone_id, overdue_days, risk_level for collection management",
290
+ up(db) {
291
+ const cols = db.pragma("table_info(opc_payments)") as { name: string }[];
292
+
293
+ if (!cols.some(c => c.name === "milestone_id")) {
294
+ db.exec("ALTER TABLE opc_payments ADD COLUMN milestone_id TEXT NOT NULL DEFAULT ''");
295
+ }
296
+ if (!cols.some(c => c.name === "overdue_days")) {
297
+ db.exec("ALTER TABLE opc_payments ADD COLUMN overdue_days INTEGER NOT NULL DEFAULT 0");
298
+ }
299
+ if (!cols.some(c => c.name === "risk_level")) {
300
+ db.exec("ALTER TABLE opc_payments ADD COLUMN risk_level TEXT NOT NULL DEFAULT 'normal'");
301
+ }
302
+ if (!cols.some(c => c.name === "last_remind_date")) {
303
+ db.exec("ALTER TABLE opc_payments ADD COLUMN last_remind_date TEXT NOT NULL DEFAULT ''");
304
+ }
305
+ if (!cols.some(c => c.name === "remind_count")) {
306
+ db.exec("ALTER TABLE opc_payments ADD COLUMN remind_count INTEGER NOT NULL DEFAULT 0");
307
+ }
308
+
309
+ // 创建索引
310
+ db.exec("CREATE INDEX IF NOT EXISTS idx_payments_milestone ON opc_payments(milestone_id)");
311
+ db.exec("CREATE INDEX IF NOT EXISTS idx_payments_risk_level ON opc_payments(risk_level)");
312
+ db.exec("CREATE INDEX IF NOT EXISTS idx_payments_overdue ON opc_payments(overdue_days)");
313
+ },
314
+ },
169
315
  ];
170
316
 
171
317
  /**