dsh-hams 0.1.3 → 0.1.4
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.
- package/CHANGELOG.md +3 -3
- package/README.md +4 -0
- package/dist/index.js +11335 -0
- package/package.json +9 -5
- package/src/domain/check.ts +0 -193
- package/src/domain/errors.ts +0 -59
- package/src/domain/evidence.ts +0 -134
- package/src/domain/ids.ts +0 -31
- package/src/host/chat-runtime.ts +0 -489
- package/src/host/dsh-adapter.ts +0 -867
- package/src/host/session-adapter.ts +0 -276
- package/src/http/handler.ts +0 -1132
- package/src/index.js +0 -53
- package/src/service/audit.ts +0 -92
- package/src/service/chat.ts +0 -1100
- package/src/service/contracts.ts +0 -193
- package/src/service/defaults.ts +0 -182
- package/src/service/deliverables.ts +0 -240
- package/src/service/hams.ts +0 -3103
- package/src/service/knowledge.ts +0 -940
- package/src/service/member-config.ts +0 -39
- package/src/service/members.ts +0 -506
- package/src/service/memory.ts +0 -375
- package/src/service/permissions.ts +0 -118
- package/src/service/project-lifecycle.ts +0 -233
- package/src/service/projects.ts +0 -840
- package/src/service/receipts.ts +0 -63
- package/src/service/templates.ts +0 -140
- package/src/service/tick.ts +0 -751
- package/src/service/tools.ts +0 -711
- package/src/service/types.ts +0 -219
- package/src/service/work-item-create.ts +0 -159
- package/src/service/workspace-profile.ts +0 -443
- package/src/store/backup.ts +0 -239
- package/src/store/bootstrap.ts +0 -225
- package/src/store/db.ts +0 -179
- package/src/store/lock.ts +0 -194
- package/src/store/migrations.ts +0 -211
- package/src/store/schema/001_p1.sql +0 -846
- package/src/store/schema/001_p1.sql.ts +0 -865
- package/src/store/schema/002_knowledge.sql +0 -54
- package/src/store/schema/002_knowledge.sql.ts +0 -68
- package/src/store/schema/003_projects.sql.ts +0 -3
- package/src/store/schema/004_team.sql.ts +0 -50
- package/src/store/schema/005_target_check.sql.ts +0 -22
- package/src/store/schema/006_target_flow.sql.ts +0 -19
- package/src/store/schema/007_project_delete.sql.ts +0 -17
- package/src/store/schema/008_memory.sql.ts +0 -47
- package/src/store/schema/generate-projects.mjs +0 -16
- package/src/store/seed.ts +0 -114
package/src/service/projects.ts
DELETED
|
@@ -1,840 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 项目读模型与写命令的落库实现(M6)。
|
|
3
|
-
*
|
|
4
|
-
* 这一层只碰 `project / project_target_version / project_metric / metric_observation /
|
|
5
|
-
* project_budget` 五张 P1 表,不做事务包裹与权限判断——那是 HamsService 的职责。
|
|
6
|
-
* 拆出来的原因只有一个:项目详情要拼的查询比工作项多,塞进 hams.ts 会让闭环代码更难读。
|
|
7
|
-
*
|
|
8
|
-
* 口径纪律(backend-design §7):
|
|
9
|
-
* - 目标一旦发布就是冻结版本,改目标要发新版本,这里不允许 UPDATE project_target_version;
|
|
10
|
-
* - 指标只存「预置计算器 + 参数」,不存自由表达式;读数 value=NULL 表示缺数,界面必须显示未知;
|
|
11
|
-
* - 推进计数(no_progress_streak)只有 tick 写,本模块不碰。
|
|
12
|
-
*/
|
|
13
|
-
import type { DatabaseSync } from 'node:sqlite'
|
|
14
|
-
import { stateInvalid } from '../domain/errors.ts'
|
|
15
|
-
import type { IdFactory } from '../domain/ids.ts'
|
|
16
|
-
import { defaultCoordinatorId } from './defaults.ts'
|
|
17
|
-
|
|
18
|
-
export const PROJECT_TYPES = ['one_off', 'continuous'] as const
|
|
19
|
-
export const PROJECT_STATUSES = ['draft', 'active', 'paused', 'archived'] as const
|
|
20
|
-
export const METRIC_DIRECTIONS = ['higher', 'lower', 'range'] as const
|
|
21
|
-
export const METRIC_WINDOWS = ['day', 'week', 'month', 'snapshot'] as const
|
|
22
|
-
|
|
23
|
-
export interface ProjectRow {
|
|
24
|
-
id: string
|
|
25
|
-
name: string
|
|
26
|
-
type: string
|
|
27
|
-
status: string
|
|
28
|
-
owner_human_id: string
|
|
29
|
-
coordinator_id: string | null
|
|
30
|
-
goal_text: string
|
|
31
|
-
current_target_version: number
|
|
32
|
-
no_progress_streak: number
|
|
33
|
-
revision: number
|
|
34
|
-
created_at: number
|
|
35
|
-
updated_at: number
|
|
36
|
-
/** 软删标记:归档项目「删除」后置时间;列表与详情都不再出现。 */
|
|
37
|
-
deleted_at?: number | null
|
|
38
|
-
deleted_by?: string | null
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface MetricInput {
|
|
42
|
-
name: string
|
|
43
|
-
direction: string
|
|
44
|
-
targetValue?: number | null
|
|
45
|
-
targetMax?: number | null
|
|
46
|
-
unit?: string | null
|
|
47
|
-
windowKind: string
|
|
48
|
-
definitionText?: string | null
|
|
49
|
-
sourceKind?: string | null
|
|
50
|
-
calculatorKey?: string | null
|
|
51
|
-
calculatorParams?: Record<string, unknown>
|
|
52
|
-
sourceRef?: string | null
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface CreateProjectInput {
|
|
56
|
-
name: string
|
|
57
|
-
type: string
|
|
58
|
-
goalText: string
|
|
59
|
-
ownerHumanId?: string
|
|
60
|
-
coordinatorId?: string | null
|
|
61
|
-
metrics?: MetricInput[]
|
|
62
|
-
/** 默认 true:创建即发布目标(status=active);false 则留草稿,发布动作走 project/status。 */
|
|
63
|
-
publish?: boolean
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface ProjectSummary {
|
|
67
|
-
id: string
|
|
68
|
-
name: string
|
|
69
|
-
type: string
|
|
70
|
-
status: string
|
|
71
|
-
owner_human_id: string
|
|
72
|
-
owner_name: string | null
|
|
73
|
-
coordinator_id: string | null
|
|
74
|
-
coordinator_name: string | null
|
|
75
|
-
goal_text: string
|
|
76
|
-
current_target_version: number
|
|
77
|
-
revision: number
|
|
78
|
-
created_at: number
|
|
79
|
-
updated_at: number
|
|
80
|
-
work_items: { total: number; open: number; byStatus: Record<string, number> }
|
|
81
|
-
metric_count: number
|
|
82
|
-
observation_count: number
|
|
83
|
-
/** 项目「记忆」页签的有效条数(不含已归档)。 */
|
|
84
|
-
memory_count: number
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export interface MetricWithLatest {
|
|
88
|
-
id: string
|
|
89
|
-
name: string
|
|
90
|
-
direction: string
|
|
91
|
-
target_value: number | null
|
|
92
|
-
target_max: number | null
|
|
93
|
-
unit: string | null
|
|
94
|
-
definition_text: string
|
|
95
|
-
calculator_key: string | null
|
|
96
|
-
window_kind: string
|
|
97
|
-
source_kind: string | null
|
|
98
|
-
source_ref: string | null
|
|
99
|
-
calculator_params_json: string
|
|
100
|
-
latest: {
|
|
101
|
-
value: number | null
|
|
102
|
-
observed_at: number
|
|
103
|
-
window_start: number
|
|
104
|
-
window_end: number
|
|
105
|
-
source_ref: string | null
|
|
106
|
-
} | null
|
|
107
|
-
series: { value: number | null; window_end: number }[]
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export interface ProjectDetail extends ProjectSummary {
|
|
111
|
-
/** 草稿的项目:向导保存到一半的内容(步骤、指标、保存时间),发布后清空。 */
|
|
112
|
-
draft: ProjectDraftState | null
|
|
113
|
-
target_versions: {
|
|
114
|
-
id: string
|
|
115
|
-
version: number
|
|
116
|
-
goal_text: string
|
|
117
|
-
published_by: string
|
|
118
|
-
published_by_name: string | null
|
|
119
|
-
published_at: number
|
|
120
|
-
}[]
|
|
121
|
-
current_target: {
|
|
122
|
-
id: string
|
|
123
|
-
version: number
|
|
124
|
-
goal_text: string
|
|
125
|
-
published_at: number
|
|
126
|
-
} | null
|
|
127
|
-
metrics: MetricWithLatest[]
|
|
128
|
-
budgets: { period_kind: string; period_start: string; metric: string; used: number; limit_value: number }[]
|
|
129
|
-
recent_items: {
|
|
130
|
-
id: string
|
|
131
|
-
title: string
|
|
132
|
-
domain: string
|
|
133
|
-
status: string
|
|
134
|
-
owner_human_id: string
|
|
135
|
-
assignee_id: string | null
|
|
136
|
-
due_at: number | null
|
|
137
|
-
updated_at: number
|
|
138
|
-
}[]
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const MAX_NAME = 80
|
|
142
|
-
const MAX_GOAL = 4000
|
|
143
|
-
const MAX_METRICS = 12
|
|
144
|
-
const MAX_METRIC_NAME = 40
|
|
145
|
-
|
|
146
|
-
function text(value: unknown, field: string, max = MAX_NAME, allowEmpty = false): string {
|
|
147
|
-
if (typeof value !== 'string') throw stateInvalid(`${field} 必须是字符串`)
|
|
148
|
-
const trimmed = value.trim()
|
|
149
|
-
if (!allowEmpty && !trimmed) throw stateInvalid(`${field} 不能为空`)
|
|
150
|
-
if (trimmed.length > max) throw stateInvalid(`${field} 超过 ${max} 字`)
|
|
151
|
-
return trimmed
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function optionalText(value: unknown, field: string, max = MAX_NAME): string | null {
|
|
155
|
-
if (value === undefined || value === null) return null
|
|
156
|
-
if (typeof value !== 'string') throw stateInvalid(`${field} 必须是字符串`)
|
|
157
|
-
const trimmed = value.trim()
|
|
158
|
-
if (!trimmed) return null
|
|
159
|
-
if (trimmed.length > max) throw stateInvalid(`${field} 超过 ${max} 字`)
|
|
160
|
-
return trimmed
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function finiteNumber(value: unknown, field: string): number | null {
|
|
164
|
-
if (value === undefined || value === null) return null
|
|
165
|
-
if (typeof value !== 'number' || !Number.isFinite(value)) throw stateInvalid(`${field} 必须是有限数字`)
|
|
166
|
-
return value
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function oneOf<T extends string>(value: unknown, field: string, allowed: readonly T[]): T {
|
|
170
|
-
if (typeof value !== 'string' || !(allowed as readonly string[]).includes(value)) {
|
|
171
|
-
throw stateInvalid(`${field} 只能是 ${allowed.join(' / ')}`)
|
|
172
|
-
}
|
|
173
|
-
return value as T
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function memberName(db: DatabaseSync, id: string | null): string | null {
|
|
177
|
-
if (!id) return null
|
|
178
|
-
const row = db.prepare('SELECT name FROM member WHERE id = ?').get(id) as { name: string } | undefined
|
|
179
|
-
return row?.name ?? null
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function statusMap(db: DatabaseSync, projectIds: string[]): Map<string, Record<string, number>> {
|
|
183
|
-
const result = new Map<string, Record<string, number>>()
|
|
184
|
-
if (!projectIds.length) return result
|
|
185
|
-
const placeholders = projectIds.map(() => '?').join(',')
|
|
186
|
-
const rows = db
|
|
187
|
-
.prepare(
|
|
188
|
-
`SELECT project_id, status, COUNT(*) AS c FROM work_item
|
|
189
|
-
WHERE project_id IN (${placeholders})
|
|
190
|
-
GROUP BY project_id, status`,
|
|
191
|
-
)
|
|
192
|
-
.all(...(projectIds as never[])) as { project_id: string; status: string; c: number }[]
|
|
193
|
-
for (const row of rows) {
|
|
194
|
-
const bucket = result.get(row.project_id) ?? {}
|
|
195
|
-
bucket[row.status] = Number(row.c)
|
|
196
|
-
result.set(row.project_id, bucket)
|
|
197
|
-
}
|
|
198
|
-
return result
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export function listProjects(
|
|
202
|
-
db: DatabaseSync,
|
|
203
|
-
filter: { status?: string; type?: string; limit?: number; projectId?: string } = {},
|
|
204
|
-
): ProjectSummary[] {
|
|
205
|
-
const clauses: string[] = []
|
|
206
|
-
const params: unknown[] = []
|
|
207
|
-
if (filter.projectId) { clauses.push('p.id = ?'); params.push(filter.projectId) }
|
|
208
|
-
if (filter.status) {
|
|
209
|
-
clauses.push('p.status = ?')
|
|
210
|
-
params.push(filter.status)
|
|
211
|
-
}
|
|
212
|
-
if (filter.type) {
|
|
213
|
-
clauses.push('p.type = ?')
|
|
214
|
-
params.push(filter.type)
|
|
215
|
-
}
|
|
216
|
-
const where = `WHERE p.deleted_at IS NULL${clauses.length ? ` AND ${clauses.join(' AND ')}` : ''}`
|
|
217
|
-
const limit = Math.min(Math.max(filter.limit ?? 100, 1), 200)
|
|
218
|
-
const rows = db
|
|
219
|
-
.prepare(
|
|
220
|
-
`SELECT p.*, o.name AS owner_name, c.name AS coordinator_name,
|
|
221
|
-
(SELECT COUNT(*) FROM work_item w WHERE w.project_id = p.id) AS item_total,
|
|
222
|
-
(SELECT COUNT(*) FROM work_item w WHERE w.project_id = p.id
|
|
223
|
-
AND w.status NOT IN ('completed','cancelled','failed')) AS item_open,
|
|
224
|
-
(SELECT COUNT(*) FROM project_metric m JOIN project_target_version t ON t.id=m.version_id WHERE m.project_id = p.id AND t.version=p.current_target_version) AS metric_count,
|
|
225
|
-
(SELECT COUNT(*) FROM project_metric m
|
|
226
|
-
JOIN metric_observation ob ON ob.metric_id = m.id
|
|
227
|
-
WHERE m.project_id = p.id) AS observation_count,
|
|
228
|
-
(SELECT COUNT(*) FROM project_memory pm
|
|
229
|
-
WHERE pm.project_id = p.id AND pm.status = 'active') AS memory_count
|
|
230
|
-
FROM project p
|
|
231
|
-
LEFT JOIN member o ON o.id = p.owner_human_id
|
|
232
|
-
LEFT JOIN member c ON c.id = p.coordinator_id
|
|
233
|
-
${where}
|
|
234
|
-
ORDER BY p.updated_at DESC
|
|
235
|
-
LIMIT ${limit}`,
|
|
236
|
-
)
|
|
237
|
-
.all(...(params as never[])) as (ProjectRow & {
|
|
238
|
-
owner_name: string | null
|
|
239
|
-
coordinator_name: string | null
|
|
240
|
-
item_total: number
|
|
241
|
-
item_open: number
|
|
242
|
-
metric_count: number
|
|
243
|
-
observation_count: number
|
|
244
|
-
memory_count: number
|
|
245
|
-
})[]
|
|
246
|
-
|
|
247
|
-
const byProject = statusMap(db, rows.map((row) => row.id))
|
|
248
|
-
return rows.map((row) => ({
|
|
249
|
-
id: row.id,
|
|
250
|
-
name: row.name,
|
|
251
|
-
type: row.type,
|
|
252
|
-
status: row.status,
|
|
253
|
-
owner_human_id: row.owner_human_id,
|
|
254
|
-
owner_name: row.owner_name,
|
|
255
|
-
coordinator_id: row.coordinator_id,
|
|
256
|
-
coordinator_name: row.coordinator_name,
|
|
257
|
-
goal_text: row.goal_text,
|
|
258
|
-
current_target_version: Number(row.current_target_version),
|
|
259
|
-
revision: Number(row.revision),
|
|
260
|
-
created_at: Number(row.created_at),
|
|
261
|
-
updated_at: Number(row.updated_at),
|
|
262
|
-
work_items: {
|
|
263
|
-
total: Number(row.item_total),
|
|
264
|
-
open: Number(row.item_open),
|
|
265
|
-
byStatus: byProject.get(row.id) ?? {},
|
|
266
|
-
},
|
|
267
|
-
metric_count: Number(row.metric_count),
|
|
268
|
-
observation_count: Number(row.observation_count),
|
|
269
|
-
memory_count: Number(row.memory_count),
|
|
270
|
-
}))
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
export function getProject(db: DatabaseSync, projectId: string): ProjectDetail {
|
|
274
|
-
const row = db.prepare('SELECT * FROM project WHERE id = ?').get(projectId) as ProjectRow | undefined
|
|
275
|
-
if (!row) throw stateInvalid(`项目不存在:${projectId}`)
|
|
276
|
-
if (row.deleted_at) throw stateInvalid('项目已删除,不再出现在工作台里', '刷新项目列表')
|
|
277
|
-
|
|
278
|
-
const summary = listProjects(db, { projectId, limit: 1 }).find((item) => item.id === projectId)
|
|
279
|
-
if (!summary) throw stateInvalid(`项目读模型缺失:${projectId}`)
|
|
280
|
-
|
|
281
|
-
const targetVersions = db
|
|
282
|
-
.prepare(
|
|
283
|
-
`SELECT t.id, t.version, t.goal_text, t.published_by, t.published_at, m.name AS published_by_name
|
|
284
|
-
FROM project_target_version t
|
|
285
|
-
LEFT JOIN member m ON m.id = t.published_by
|
|
286
|
-
WHERE t.project_id = ?
|
|
287
|
-
ORDER BY t.version DESC`,
|
|
288
|
-
)
|
|
289
|
-
.all(projectId) as {
|
|
290
|
-
id: string
|
|
291
|
-
version: number
|
|
292
|
-
goal_text: string
|
|
293
|
-
published_by: string
|
|
294
|
-
published_by_name: string | null
|
|
295
|
-
published_at: number
|
|
296
|
-
}[]
|
|
297
|
-
const currentTarget = targetVersions.find((item) => Number(item.version) === Number(row.current_target_version)) ?? null
|
|
298
|
-
|
|
299
|
-
const metricRows = db
|
|
300
|
-
.prepare(
|
|
301
|
-
`SELECT m.* FROM project_metric m
|
|
302
|
-
WHERE m.project_id = ? AND m.version_id = ?
|
|
303
|
-
ORDER BY m.name`,
|
|
304
|
-
)
|
|
305
|
-
.all(projectId, currentTarget?.id ?? '') as {
|
|
306
|
-
id: string
|
|
307
|
-
name: string
|
|
308
|
-
direction: string
|
|
309
|
-
target_value: number | null
|
|
310
|
-
target_max: number | null
|
|
311
|
-
unit: string | null
|
|
312
|
-
definition_text: string
|
|
313
|
-
calculator_key: string | null
|
|
314
|
-
window_kind: string
|
|
315
|
-
source_kind: string | null
|
|
316
|
-
source_ref: string | null
|
|
317
|
-
calculator_params_json: string
|
|
318
|
-
}[]
|
|
319
|
-
|
|
320
|
-
const observations = metricRows.length
|
|
321
|
-
? (db
|
|
322
|
-
.prepare(
|
|
323
|
-
`SELECT metric_id, value, observed_at, window_start, window_end, source_ref
|
|
324
|
-
FROM metric_observation
|
|
325
|
-
WHERE metric_id IN (${metricRows.map(() => '?').join(',')})
|
|
326
|
-
ORDER BY window_end DESC, observed_at DESC, rowid DESC`,
|
|
327
|
-
)
|
|
328
|
-
.all(...(metricRows.map((metric) => metric.id) as never[])) as {
|
|
329
|
-
metric_id: string
|
|
330
|
-
value: number | null
|
|
331
|
-
observed_at: number
|
|
332
|
-
window_start: number
|
|
333
|
-
window_end: number
|
|
334
|
-
source_ref: string | null
|
|
335
|
-
}[])
|
|
336
|
-
: []
|
|
337
|
-
const byMetric = new Map<string, typeof observations>()
|
|
338
|
-
for (const observation of observations) {
|
|
339
|
-
const list = byMetric.get(observation.metric_id) ?? []
|
|
340
|
-
list.push(observation)
|
|
341
|
-
byMetric.set(observation.metric_id, list)
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const metrics: MetricWithLatest[] = metricRows.map((metric) => {
|
|
345
|
-
const list = byMetric.get(metric.id) ?? []
|
|
346
|
-
const latest = list[0] ?? null
|
|
347
|
-
return {
|
|
348
|
-
id: metric.id,
|
|
349
|
-
name: metric.name,
|
|
350
|
-
direction: metric.direction,
|
|
351
|
-
target_value: metric.target_value === null ? null : Number(metric.target_value),
|
|
352
|
-
target_max: metric.target_max === null ? null : Number(metric.target_max),
|
|
353
|
-
unit: metric.unit,
|
|
354
|
-
definition_text: metric.definition_text,
|
|
355
|
-
calculator_key: metric.calculator_key,
|
|
356
|
-
window_kind: metric.window_kind,
|
|
357
|
-
source_kind: metric.source_kind,
|
|
358
|
-
source_ref: metric.source_ref,
|
|
359
|
-
calculator_params_json: metric.calculator_params_json,
|
|
360
|
-
latest: latest
|
|
361
|
-
? {
|
|
362
|
-
value: latest.value === null ? null : Number(latest.value),
|
|
363
|
-
observed_at: Number(latest.observed_at),
|
|
364
|
-
window_start: Number(latest.window_start),
|
|
365
|
-
window_end: Number(latest.window_end),
|
|
366
|
-
source_ref: latest.source_ref,
|
|
367
|
-
}
|
|
368
|
-
: null,
|
|
369
|
-
series: list
|
|
370
|
-
.slice(0, 12)
|
|
371
|
-
.reverse()
|
|
372
|
-
.map((item) => ({ value: item.value === null ? null : Number(item.value), window_end: Number(item.window_end) })),
|
|
373
|
-
}
|
|
374
|
-
})
|
|
375
|
-
|
|
376
|
-
const budgets = db
|
|
377
|
-
.prepare(
|
|
378
|
-
`SELECT period_kind, period_start, metric, used, limit_value
|
|
379
|
-
FROM project_budget WHERE project_id = ?
|
|
380
|
-
ORDER BY period_start DESC, metric LIMIT 40`,
|
|
381
|
-
)
|
|
382
|
-
.all(projectId) as ProjectDetail['budgets']
|
|
383
|
-
|
|
384
|
-
const recentItems = db
|
|
385
|
-
.prepare(
|
|
386
|
-
`SELECT id, title, domain, status, owner_human_id, assignee_id, due_at, updated_at
|
|
387
|
-
FROM work_item WHERE project_id = ?
|
|
388
|
-
ORDER BY updated_at DESC LIMIT 8`,
|
|
389
|
-
)
|
|
390
|
-
.all(projectId) as ProjectDetail['recent_items']
|
|
391
|
-
|
|
392
|
-
return {
|
|
393
|
-
...summary,
|
|
394
|
-
draft: row.status === 'draft' ? readProjectDraft(db, projectId) : null,
|
|
395
|
-
target_versions: targetVersions.map((item) => ({
|
|
396
|
-
...item,
|
|
397
|
-
version: Number(item.version),
|
|
398
|
-
published_at: Number(item.published_at),
|
|
399
|
-
})),
|
|
400
|
-
current_target: currentTarget
|
|
401
|
-
? {
|
|
402
|
-
id: currentTarget.id,
|
|
403
|
-
version: Number(currentTarget.version),
|
|
404
|
-
goal_text: currentTarget.goal_text,
|
|
405
|
-
published_at: Number(currentTarget.published_at),
|
|
406
|
-
}
|
|
407
|
-
: null,
|
|
408
|
-
metrics,
|
|
409
|
-
budgets: budgets.map((item) => ({
|
|
410
|
-
...item,
|
|
411
|
-
used: Number(item.used),
|
|
412
|
-
limit_value: Number(item.limit_value),
|
|
413
|
-
})),
|
|
414
|
-
recent_items: recentItems.map((item) => ({
|
|
415
|
-
...item,
|
|
416
|
-
due_at: item.due_at === null ? null : Number(item.due_at),
|
|
417
|
-
updated_at: Number(item.updated_at),
|
|
418
|
-
})),
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
export interface InsertedProject {
|
|
423
|
-
id: string
|
|
424
|
-
revision: number
|
|
425
|
-
targetVersionId: string | null
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
export function insertProject(
|
|
429
|
-
db: DatabaseSync,
|
|
430
|
-
ids: IdFactory,
|
|
431
|
-
now: number,
|
|
432
|
-
actorId: string,
|
|
433
|
-
input: CreateProjectInput,
|
|
434
|
-
): InsertedProject {
|
|
435
|
-
const name = text(input.name, '项目名')
|
|
436
|
-
const type = oneOf(input.type, '项目类型', PROJECT_TYPES)
|
|
437
|
-
const publish = input.publish !== false
|
|
438
|
-
// 草稿向导可以在只填了名称时保存,所以草稿允许空目标;发布目标时仍由 publishTarget 校验。
|
|
439
|
-
const goalText = text(input.goalText ?? '', '目标', MAX_GOAL, !publish)
|
|
440
|
-
const ownerHumanId = input.ownerHumanId ?? actorId
|
|
441
|
-
// 没显式选推进 bot 时用默认的「项目管理」;显式 null 表示这个项目不要推进 bot。
|
|
442
|
-
const coordinatorId =
|
|
443
|
-
input.coordinatorId === undefined
|
|
444
|
-
? defaultCoordinatorId(db)
|
|
445
|
-
: input.coordinatorId === null || input.coordinatorId === ''
|
|
446
|
-
? null
|
|
447
|
-
: text(input.coordinatorId, '推进 bot', 64)
|
|
448
|
-
|
|
449
|
-
const duplicate = db
|
|
450
|
-
.prepare('SELECT id FROM project WHERE name = ? AND deleted_at IS NULL')
|
|
451
|
-
.get(name) as { id: string } | undefined
|
|
452
|
-
if (duplicate) {
|
|
453
|
-
throw stateInvalid(`项目名已存在:${name}`, '换一个名字,或打开已有项目')
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
const owner = db.prepare('SELECT id, kind FROM member WHERE id = ?').get(ownerHumanId) as
|
|
457
|
-
| { id: string; kind: string }
|
|
458
|
-
| undefined
|
|
459
|
-
if (!owner || owner.kind !== 'human') throw stateInvalid('项目负责人必须是存在的人')
|
|
460
|
-
if (coordinatorId) {
|
|
461
|
-
const coordinator = db.prepare('SELECT id, kind FROM member WHERE id = ?').get(coordinatorId) as
|
|
462
|
-
| { id: string; kind: string }
|
|
463
|
-
| undefined
|
|
464
|
-
if (!coordinator) throw stateInvalid(`推进成员不存在:${coordinatorId}`)
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
const metrics = Array.isArray(input.metrics) ? input.metrics : []
|
|
468
|
-
if (metrics.length > MAX_METRICS) throw stateInvalid(`指标最多 ${MAX_METRICS} 个`)
|
|
469
|
-
|
|
470
|
-
const id = ids()
|
|
471
|
-
const status = publish ? 'active' : 'draft'
|
|
472
|
-
|
|
473
|
-
db.prepare(
|
|
474
|
-
`INSERT INTO project
|
|
475
|
-
(id, name, type, status, owner_human_id, coordinator_id, goal_text,
|
|
476
|
-
current_target_version, no_progress_streak, revision, created_at, updated_at)
|
|
477
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 1, ?, ?)`,
|
|
478
|
-
).run(id, name, type, status, ownerHumanId, coordinatorId, goalText, now, now)
|
|
479
|
-
|
|
480
|
-
let targetVersionId: string | null = null
|
|
481
|
-
if (publish) {
|
|
482
|
-
targetVersionId = ids()
|
|
483
|
-
db.prepare(
|
|
484
|
-
`INSERT INTO project_target_version (id, project_id, version, goal_text, published_by, published_at)
|
|
485
|
-
VALUES (?, ?, 1, ?, ?, ?)`,
|
|
486
|
-
).run(targetVersionId, id, goalText, actorId, now)
|
|
487
|
-
db.prepare('UPDATE project SET current_target_version = 1 WHERE id = ?').run(id)
|
|
488
|
-
for (const metric of metrics) insertMetric(db, ids, id, targetVersionId, metric)
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
return { id, revision: 1, targetVersionId }
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
export function insertMetric(
|
|
495
|
-
db: DatabaseSync,
|
|
496
|
-
ids: IdFactory,
|
|
497
|
-
projectId: string,
|
|
498
|
-
versionId: string,
|
|
499
|
-
input: MetricInput,
|
|
500
|
-
): string {
|
|
501
|
-
const name = text(input.name, '指标名', MAX_METRIC_NAME)
|
|
502
|
-
const direction = oneOf(input.direction, '指标方向', METRIC_DIRECTIONS)
|
|
503
|
-
const windowKind = oneOf(input.windowKind, '统计窗口', METRIC_WINDOWS)
|
|
504
|
-
const targetValue = finiteNumber(input.targetValue, '目标值')
|
|
505
|
-
const targetMax = finiteNumber(input.targetMax, '上界')
|
|
506
|
-
const unit = optionalText(input.unit, '单位', 20)
|
|
507
|
-
const definitionText = optionalText(input.definitionText, '口径说明', 500) ?? ''
|
|
508
|
-
const sourceKind = input.sourceKind
|
|
509
|
-
? oneOf(input.sourceKind, '数据来源', ['ledger', 'work_item', 'webhook', 'manual'] as const)
|
|
510
|
-
: 'manual'
|
|
511
|
-
if (direction === 'range' && (targetValue === null || targetMax === null)) {
|
|
512
|
-
throw stateInvalid('区间指标要同时给下界与上界')
|
|
513
|
-
}
|
|
514
|
-
if (direction !== 'range' && targetValue === null) {
|
|
515
|
-
throw stateInvalid('非区间指标要给目标值')
|
|
516
|
-
}
|
|
517
|
-
if (direction === 'range' && targetMax! < targetValue!) throw stateInvalid('区间上界不能小于下界')
|
|
518
|
-
const calculatorKey = optionalText(input.calculatorKey, '计算方式', 80)
|
|
519
|
-
if (calculatorKey && !['manual_latest', 'work_item_count', 'work_item_completion_rate'].includes(calculatorKey)) throw stateInvalid('未知的预置计算方式')
|
|
520
|
-
if (calculatorKey === 'manual_latest' && sourceKind !== 'manual') throw stateInvalid('manual_latest 需要 manual 来源')
|
|
521
|
-
if (calculatorKey?.startsWith('work_item_') && sourceKind !== 'work_item') throw stateInvalid('工作项计算器需要 work_item 来源')
|
|
522
|
-
const params = input.calculatorParams ?? {}
|
|
523
|
-
if (!params || typeof params !== 'object' || Array.isArray(params) || Object.keys(params).some(key => !['domain', 'status'].includes(key)) || Object.values(params).some(value => typeof value !== 'string')) throw stateInvalid('计算器参数仅支持 domain / status 字符串筛选')
|
|
524
|
-
const sourceRef = optionalText(input.sourceRef, '数据来源引用', 500)
|
|
525
|
-
const id = ids()
|
|
526
|
-
db.prepare(
|
|
527
|
-
`INSERT INTO project_metric
|
|
528
|
-
(id, project_id, version_id, name, direction, target_value, target_max, unit,
|
|
529
|
-
definition_text, calculator_key, calculator_params_json, window_kind, source_kind, source_ref)
|
|
530
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
531
|
-
).run(id, projectId, versionId, name, direction, targetValue, targetMax, unit, definitionText, calculatorKey, JSON.stringify(params), windowKind, sourceKind, sourceRef)
|
|
532
|
-
return id
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
export interface StatusChange {
|
|
536
|
-
before: string
|
|
537
|
-
after: string
|
|
538
|
-
revision: number
|
|
539
|
-
targetVersionId: string | null
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
/** 状态机:draft → active(发布目标)→ paused ⇄ active;任一状态可归档,归档不删数据。 */
|
|
543
|
-
export function setProjectStatus(
|
|
544
|
-
db: DatabaseSync,
|
|
545
|
-
ids: IdFactory,
|
|
546
|
-
now: number,
|
|
547
|
-
actorId: string,
|
|
548
|
-
projectId: string,
|
|
549
|
-
to: string,
|
|
550
|
-
): StatusChange {
|
|
551
|
-
const project = db.prepare('SELECT * FROM project WHERE id = ?').get(projectId) as ProjectRow | undefined
|
|
552
|
-
if (!project) throw stateInvalid(`项目不存在:${projectId}`)
|
|
553
|
-
if (project.deleted_at) throw stateInvalid('项目已删除,不能再改状态')
|
|
554
|
-
const target = oneOf(to, '项目状态', PROJECT_STATUSES)
|
|
555
|
-
const before = project.status
|
|
556
|
-
if (before === target) return { before, after: target, revision: Number(project.revision), targetVersionId: null }
|
|
557
|
-
const allowed: Record<string, string[]> = {
|
|
558
|
-
draft: ['active', 'archived'],
|
|
559
|
-
active: ['paused', 'archived'],
|
|
560
|
-
paused: ['active', 'archived'],
|
|
561
|
-
archived: [],
|
|
562
|
-
}
|
|
563
|
-
if (!allowed[before]?.includes(target)) {
|
|
564
|
-
throw stateInvalid(`项目状态不能从 ${before} 改为 ${target}`, '归档后只能新建项目;草稿要先发布目标')
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
let targetVersionId: string | null = null
|
|
568
|
-
if (target === 'active' && Number(project.current_target_version) === 0) {
|
|
569
|
-
const goal = String(project.goal_text ?? '').trim()
|
|
570
|
-
if (!goal) throw stateInvalid('没有目标口径的项目不能进入进行中', '先写目标,再发布')
|
|
571
|
-
targetVersionId = ids()
|
|
572
|
-
db.prepare(
|
|
573
|
-
`INSERT INTO project_target_version (id, project_id, version, goal_text, published_by, published_at)
|
|
574
|
-
VALUES (?, ?, 1, ?, ?, ?)`,
|
|
575
|
-
).run(targetVersionId, projectId, goal, actorId, now)
|
|
576
|
-
db.prepare('UPDATE project SET current_target_version = 1 WHERE id = ?').run(projectId)
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
const revision = Number(project.revision) + 1
|
|
580
|
-
db.prepare('UPDATE project SET status = ?, revision = ?, updated_at = ? WHERE id = ?').run(target, revision, now, projectId)
|
|
581
|
-
return { before, after: target, revision, targetVersionId }
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
export interface ObservationInput {
|
|
585
|
-
projectId: string
|
|
586
|
-
metricId: string
|
|
587
|
-
value?: number | null
|
|
588
|
-
observedAt?: number
|
|
589
|
-
windowStart?: number
|
|
590
|
-
windowEnd?: number
|
|
591
|
-
sourceRef?: string | null
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
export interface InsertedObservation {
|
|
595
|
-
id: string
|
|
596
|
-
metricId: string
|
|
597
|
-
windowStart: number
|
|
598
|
-
windowEnd: number
|
|
599
|
-
value: number | null
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
const WINDOW_MS: Record<string, number> = {
|
|
603
|
-
day: 86_400_000,
|
|
604
|
-
week: 7 * 86_400_000,
|
|
605
|
-
month: 30 * 86_400_000,
|
|
606
|
-
snapshot: 0,
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
export function recordObservation(
|
|
610
|
-
db: DatabaseSync,
|
|
611
|
-
ids: IdFactory,
|
|
612
|
-
now: number,
|
|
613
|
-
input: ObservationInput,
|
|
614
|
-
): InsertedObservation {
|
|
615
|
-
const metric = db
|
|
616
|
-
.prepare(
|
|
617
|
-
`SELECT m.id, m.window_kind, m.project_id FROM project_metric m WHERE m.id = ? AND m.project_id = ?`,
|
|
618
|
-
)
|
|
619
|
-
.get(input.metricId, input.projectId) as { id: string; window_kind: string; project_id: string } | undefined
|
|
620
|
-
if (!metric) throw stateInvalid(`指标不属于该项目:${input.metricId}`)
|
|
621
|
-
|
|
622
|
-
const observedAt = input.observedAt === undefined ? now : finiteNumber(input.observedAt, '观测时间')
|
|
623
|
-
if (observedAt === null) throw stateInvalid('观测时间必须给值')
|
|
624
|
-
const value = input.value === undefined ? null : finiteNumber(input.value, '读数')
|
|
625
|
-
const span = WINDOW_MS[metric.window_kind] ?? 0
|
|
626
|
-
const windowEnd = input.windowEnd === undefined ? observedAt : finiteNumber(input.windowEnd, '窗口结束')
|
|
627
|
-
if (windowEnd === null) throw stateInvalid('窗口结束必须给值')
|
|
628
|
-
const windowStart =
|
|
629
|
-
input.windowStart === undefined
|
|
630
|
-
? metric.window_kind === 'snapshot'
|
|
631
|
-
? windowEnd
|
|
632
|
-
: windowEnd - span
|
|
633
|
-
: finiteNumber(input.windowStart, '窗口开始')
|
|
634
|
-
if (windowStart === null) throw stateInvalid('窗口开始必须给值')
|
|
635
|
-
if (windowStart > windowEnd) throw stateInvalid('窗口开始不能晚于结束')
|
|
636
|
-
|
|
637
|
-
const sourceRef = optionalText(input.sourceRef, '数据引用', 500)
|
|
638
|
-
const id = ids()
|
|
639
|
-
db.prepare(
|
|
640
|
-
`INSERT INTO metric_observation (id, metric_id, observed_at, window_start, window_end, value, source_ref, ingested_at)
|
|
641
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
642
|
-
).run(id, metric.id, observedAt, windowStart, windowEnd, value, sourceRef, now)
|
|
643
|
-
db.prepare('UPDATE project SET revision = revision + 1, updated_at = ? WHERE id = ?').run(now, input.projectId)
|
|
644
|
-
return { id, metricId: metric.id, windowStart, windowEnd, value }
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// ───────────────────────── 新建向导草稿 ─────────────────────────
|
|
648
|
-
|
|
649
|
-
/**
|
|
650
|
-
* 向导草稿:项目已经是 draft,但向导还可能只填到第 2 步。
|
|
651
|
-
* 步骤与指标(含未发布的试算结果)存在 `hams_meta` 的 `project:draft:<id>` 里,
|
|
652
|
-
* 不改表结构,也不把未发布的指标塞进 `project_metric`(那张表只属于已发布版本)。
|
|
653
|
-
*/
|
|
654
|
-
export interface ProjectDraftState {
|
|
655
|
-
step: number
|
|
656
|
-
metrics: MetricInput[]
|
|
657
|
-
savedAt: number
|
|
658
|
-
savedBy: string
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
export interface SaveDraftInput {
|
|
662
|
-
projectId?: string | null
|
|
663
|
-
name: string
|
|
664
|
-
type: string
|
|
665
|
-
goalText?: string
|
|
666
|
-
coordinatorId?: string | null
|
|
667
|
-
metrics?: MetricInput[]
|
|
668
|
-
step?: number
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
const DRAFT_KEY_PREFIX = 'project:draft:'
|
|
672
|
-
const MAX_DRAFT_STEP = 4
|
|
673
|
-
|
|
674
|
-
function draftKey(projectId: string): string {
|
|
675
|
-
return `${DRAFT_KEY_PREFIX}${projectId}`
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
function sanitizeDraftMetrics(input: unknown): MetricInput[] {
|
|
679
|
-
if (!Array.isArray(input)) return []
|
|
680
|
-
return input.slice(0, MAX_METRICS).map((raw) => {
|
|
681
|
-
const metric = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
|
682
|
-
const direction = (METRIC_DIRECTIONS as readonly string[]).includes(String(metric.direction))
|
|
683
|
-
? (metric.direction as (typeof METRIC_DIRECTIONS)[number])
|
|
684
|
-
: 'higher'
|
|
685
|
-
const windowKind = (METRIC_WINDOWS as readonly string[]).includes(String(metric.windowKind))
|
|
686
|
-
? (metric.windowKind as (typeof METRIC_WINDOWS)[number])
|
|
687
|
-
: 'week'
|
|
688
|
-
const numberOrNull = (value: unknown): number | null =>
|
|
689
|
-
typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
690
|
-
return {
|
|
691
|
-
name: String(metric.name ?? '').slice(0, MAX_METRIC_NAME),
|
|
692
|
-
direction,
|
|
693
|
-
targetValue: numberOrNull(metric.targetValue),
|
|
694
|
-
targetMax: numberOrNull(metric.targetMax),
|
|
695
|
-
unit: typeof metric.unit === 'string' ? metric.unit.slice(0, 20) : null,
|
|
696
|
-
windowKind,
|
|
697
|
-
definitionText: typeof metric.definitionText === 'string' ? metric.definitionText.slice(0, 500) : null,
|
|
698
|
-
sourceKind:
|
|
699
|
-
typeof metric.sourceKind === 'string' && ['ledger', 'work_item', 'webhook', 'manual'].includes(metric.sourceKind)
|
|
700
|
-
? metric.sourceKind
|
|
701
|
-
: 'manual',
|
|
702
|
-
calculatorKey: typeof metric.calculatorKey === 'string' ? metric.calculatorKey.slice(0, 80) : null,
|
|
703
|
-
calculatorParams:
|
|
704
|
-
metric.calculatorParams && typeof metric.calculatorParams === 'object' && !Array.isArray(metric.calculatorParams)
|
|
705
|
-
? (metric.calculatorParams as Record<string, unknown>)
|
|
706
|
-
: {},
|
|
707
|
-
sourceRef: typeof metric.sourceRef === 'string' ? metric.sourceRef.slice(0, 500) : null,
|
|
708
|
-
}
|
|
709
|
-
})
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
export function readProjectDraft(db: DatabaseSync, projectId: string): ProjectDraftState | null {
|
|
713
|
-
const row = db.prepare('SELECT value FROM hams_meta WHERE key = ?').get(draftKey(projectId)) as
|
|
714
|
-
| { value: string }
|
|
715
|
-
| undefined
|
|
716
|
-
if (!row) return null
|
|
717
|
-
try {
|
|
718
|
-
const parsed = JSON.parse(row.value) as Partial<ProjectDraftState>
|
|
719
|
-
if (!parsed || typeof parsed !== 'object') return null
|
|
720
|
-
return {
|
|
721
|
-
step: Math.min(Math.max(Number(parsed.step) || 0, 0), MAX_DRAFT_STEP),
|
|
722
|
-
metrics: sanitizeDraftMetrics(parsed.metrics),
|
|
723
|
-
savedAt: Number(parsed.savedAt) || 0,
|
|
724
|
-
savedBy: String(parsed.savedBy || ''),
|
|
725
|
-
}
|
|
726
|
-
} catch {
|
|
727
|
-
return null
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
export function clearProjectDraft(db: DatabaseSync, projectId: string): void {
|
|
732
|
-
db.prepare('DELETE FROM hams_meta WHERE key = ?').run(draftKey(projectId))
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
export interface SavedProjectDraft {
|
|
736
|
-
projectId: string
|
|
737
|
-
revision: number
|
|
738
|
-
created: boolean
|
|
739
|
-
savedAt: number
|
|
740
|
-
step: number
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
/**
|
|
744
|
-
* 保存/更新新建向导草稿。新建时项目就是 draft;更新只允许改 draft,
|
|
745
|
-
* 已发布/暂停/归档的项目必须走正式的项目编辑路径。
|
|
746
|
-
*/
|
|
747
|
-
export function saveProjectDraft(
|
|
748
|
-
db: DatabaseSync,
|
|
749
|
-
ids: IdFactory,
|
|
750
|
-
now: number,
|
|
751
|
-
actorId: string,
|
|
752
|
-
input: SaveDraftInput,
|
|
753
|
-
): SavedProjectDraft {
|
|
754
|
-
const step = Math.min(Math.max(Number(input.step) || 0, 0), MAX_DRAFT_STEP)
|
|
755
|
-
const metrics = sanitizeDraftMetrics(input.metrics)
|
|
756
|
-
|
|
757
|
-
let projectId = optionalText(input.projectId, '项目 id', 64)
|
|
758
|
-
let created = false
|
|
759
|
-
let revision: number
|
|
760
|
-
|
|
761
|
-
if (!projectId) {
|
|
762
|
-
const inserted = insertProject(db, ids, now, actorId, {
|
|
763
|
-
name: input.name,
|
|
764
|
-
type: input.type,
|
|
765
|
-
goalText: input.goalText ?? '',
|
|
766
|
-
coordinatorId: input.coordinatorId,
|
|
767
|
-
metrics: [],
|
|
768
|
-
publish: false,
|
|
769
|
-
})
|
|
770
|
-
projectId = inserted.id
|
|
771
|
-
revision = inserted.revision
|
|
772
|
-
created = true
|
|
773
|
-
} else {
|
|
774
|
-
const row = db.prepare('SELECT * FROM project WHERE id = ?').get(projectId) as ProjectRow | undefined
|
|
775
|
-
if (!row) throw stateInvalid(`项目不存在:${projectId}`)
|
|
776
|
-
if (row.deleted_at) throw stateInvalid('项目已删除,不能再保存草稿')
|
|
777
|
-
if (row.status !== 'draft') throw stateInvalid('只有草稿项目能在新建向导里继续修改', '已发布项目请发布新目标版本')
|
|
778
|
-
|
|
779
|
-
const name = text(input.name, '项目名')
|
|
780
|
-
const type = oneOf(input.type, '项目类型', PROJECT_TYPES)
|
|
781
|
-
const goalText = text(input.goalText ?? '', '目标', MAX_GOAL, true)
|
|
782
|
-
const coordinatorId =
|
|
783
|
-
input.coordinatorId === undefined
|
|
784
|
-
? row.coordinator_id
|
|
785
|
-
: input.coordinatorId === null || input.coordinatorId === ''
|
|
786
|
-
? null
|
|
787
|
-
: text(input.coordinatorId, '推进 bot', 64)
|
|
788
|
-
if (coordinatorId) {
|
|
789
|
-
const coordinator = db.prepare('SELECT id FROM member WHERE id = ?').get(coordinatorId) as { id: string } | undefined
|
|
790
|
-
if (!coordinator) throw stateInvalid(`推进成员不存在:${coordinatorId}`)
|
|
791
|
-
}
|
|
792
|
-
const duplicate = db
|
|
793
|
-
.prepare('SELECT id FROM project WHERE name = ? AND id <> ? AND deleted_at IS NULL')
|
|
794
|
-
.get(name, projectId) as { id: string } | undefined
|
|
795
|
-
if (duplicate) throw stateInvalid(`项目名已存在:${name}`, '换一个名字,或打开已有项目')
|
|
796
|
-
|
|
797
|
-
revision = Number(row.revision) + 1
|
|
798
|
-
db.prepare(
|
|
799
|
-
'UPDATE project SET name = ?, type = ?, goal_text = ?, coordinator_id = ?, revision = ?, updated_at = ? WHERE id = ?',
|
|
800
|
-
).run(name, type, goalText, coordinatorId, revision, now, projectId)
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
const state: ProjectDraftState = { step, metrics, savedAt: now, savedBy: actorId }
|
|
804
|
-
db.prepare("INSERT OR REPLACE INTO hams_meta (key, value) VALUES (?, ?)").run(draftKey(projectId), JSON.stringify(state))
|
|
805
|
-
return { projectId, revision, created, savedAt: now, step }
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// ───────────────────────── 归档项目删除 ─────────────────────────
|
|
809
|
-
|
|
810
|
-
export interface DeletedProject {
|
|
811
|
-
id: string
|
|
812
|
-
revision: number
|
|
813
|
-
detachedWorkItems: number
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
/**
|
|
817
|
-
* 「删除」归档项目:只打软删标记,列表/详情/群聊不再出现;
|
|
818
|
-
* 工作项解绑为「无项目」继续留在任务里(证据与审计不删,符合库的追加式约束)。
|
|
819
|
-
*/
|
|
820
|
-
export function softDeleteProject(
|
|
821
|
-
db: DatabaseSync,
|
|
822
|
-
now: number,
|
|
823
|
-
actorId: string,
|
|
824
|
-
projectId: string,
|
|
825
|
-
): DeletedProject {
|
|
826
|
-
const row = db.prepare('SELECT * FROM project WHERE id = ?').get(projectId) as ProjectRow | undefined
|
|
827
|
-
if (!row) throw stateInvalid(`项目不存在:${projectId}`)
|
|
828
|
-
if (row.deleted_at) throw stateInvalid('项目已经删除')
|
|
829
|
-
if (row.status !== 'archived') throw stateInvalid('只有已归档的项目可以删除', '先归档项目,再删除')
|
|
830
|
-
// work_item 的 revision 只能向前:解绑也必须 +1,否则被 trg_wi_counters_monotonic 拒绝。
|
|
831
|
-
const detached = db.prepare('UPDATE work_item SET project_id = NULL, revision = revision + 1 WHERE project_id = ?').run(projectId)
|
|
832
|
-
const revision = Number(row.revision) + 1
|
|
833
|
-
// project.name 有 UNIQUE 约束:软删也把名字换成墓碑,原名保留在 deleted_name,名字可再次使用。
|
|
834
|
-
const tombstone = `${row.name}·已删除·${projectId.slice(-6)}`
|
|
835
|
-
db.prepare(
|
|
836
|
-
'UPDATE project SET deleted_at = ?, deleted_by = ?, deleted_name = ?, name = ?, revision = ?, updated_at = ? WHERE id = ?',
|
|
837
|
-
).run(now, actorId, row.name, tombstone, revision, now, projectId)
|
|
838
|
-
clearProjectDraft(db, projectId)
|
|
839
|
-
return { id: projectId, revision, detachedWorkItems: Number(detached.changes ?? 0) }
|
|
840
|
-
}
|