@guanwenai/high-tech-project-cli 0.1.0
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/package.json +19 -0
- package/scripts/cli/arguments.mjs +54 -0
- package/scripts/cli/commandRegistry.mjs +80 -0
- package/scripts/cli/output.mjs +23 -0
- package/scripts/core/context.mjs +35 -0
- package/scripts/core/errors.mjs +37 -0
- package/scripts/core/files.mjs +143 -0
- package/scripts/core/gatewayClient.mjs +275 -0
- package/scripts/core/userConfig.mjs +100 -0
- package/scripts/high-tech-project-cli.mjs +272 -0
- package/scripts/install/configure-gateway.mjs +39 -0
- package/scripts/methodMappings.mjs +29 -0
- package/scripts/services/achievement.mjs +147 -0
- package/scripts/services/finance.mjs +145 -0
- package/scripts/services/ipr.mjs +121 -0
- package/scripts/services/knowledge.mjs +205 -0
- package/scripts/services/material.mjs +252 -0
- package/scripts/services/people.mjs +160 -0
- package/scripts/services/policy.mjs +209 -0
- package/scripts/services/product.mjs +220 -0
- package/scripts/services/project.mjs +99 -0
- package/scripts/services/rd.mjs +225 -0
- package/scripts/services/report.mjs +117 -0
- package/scripts/services/shared.mjs +234 -0
- package/scripts/services/sop.mjs +592 -0
- package/scripts/services/workflow.mjs +99 -0
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, extname } from 'node:path';
|
|
5
|
+
import { Readable, Transform } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
|
|
8
|
+
import { CliError } from '../core/errors.mjs';
|
|
9
|
+
import { resolveWithinBase } from '../core/files.mjs';
|
|
10
|
+
|
|
11
|
+
const HIGH_TECH_ITEM_TYPE = 'HIGH_TECH';
|
|
12
|
+
const BASE_CONTEXT = ['gatewayUrl'];
|
|
13
|
+
const PROJECT_CONTEXT = [...BASE_CONTEXT, 'uuid'];
|
|
14
|
+
const AGENT_WIKI_BASE = '/api/internal/v1/project-writing/sop/agent-wiki';
|
|
15
|
+
const COMMON_REMINDER = '必须以远端 SOP Wiki 返回内容作为唯一事实来源,不得自行编造流程、模板、撰写策略或评审规则。';
|
|
16
|
+
|
|
17
|
+
function objectInput(input) {
|
|
18
|
+
if (input === null || Array.isArray(input) || typeof input !== 'object') {
|
|
19
|
+
throw new CliError('ARGUMENT_INVALID', '命令输入必须是对象');
|
|
20
|
+
}
|
|
21
|
+
return input;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function valueOf(input, snakeName, camelName = snakeName) {
|
|
25
|
+
return input[snakeName] ?? input[camelName];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requiredString(value, field) {
|
|
29
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
30
|
+
throw new CliError('ARGUMENT_INVALID', `${field} 不能为空`);
|
|
31
|
+
}
|
|
32
|
+
return value.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function configTypeInput(input) {
|
|
36
|
+
const value = objectInput(input);
|
|
37
|
+
const nodeCode = requiredString(valueOf(value, 'node_code', 'nodeCode'), 'node_code');
|
|
38
|
+
const configType = requiredString(valueOf(value, 'config_type', 'configType'), 'config_type').toUpperCase();
|
|
39
|
+
if (!['MIDDLEWARE', 'FINAL_FILE'].includes(configType)) {
|
|
40
|
+
throw new CliError('ARGUMENT_INVALID', 'config_type 必须是 MIDDLEWARE 或 FINAL_FILE');
|
|
41
|
+
}
|
|
42
|
+
return { ...value, nodeCode, configType };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nodeCodeInput(input) {
|
|
46
|
+
const value = objectInput(input);
|
|
47
|
+
return {
|
|
48
|
+
...value,
|
|
49
|
+
nodeCode: requiredString(valueOf(value, 'node_code', 'nodeCode'), 'node_code'),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function optionalPositiveId(value, field) {
|
|
54
|
+
if (value === undefined || value === null) return undefined;
|
|
55
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
56
|
+
throw new CliError('ARGUMENT_INVALID', `${field} 必须是正整数`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function outputStrategyInput(input) {
|
|
62
|
+
const value = configTypeInput(input);
|
|
63
|
+
const contextData = valueOf(value, 'context_data', 'contextData');
|
|
64
|
+
if (contextData !== undefined
|
|
65
|
+
&& (contextData === null || Array.isArray(contextData) || typeof contextData !== 'object')) {
|
|
66
|
+
throw new CliError('ARGUMENT_INVALID', 'context_data 必须是对象');
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
nodeCode: value.nodeCode,
|
|
70
|
+
configType: value.configType,
|
|
71
|
+
selectedOutputTemplateId: optionalPositiveId(
|
|
72
|
+
valueOf(value, 'selected_output_template_id', 'selectedOutputTemplateId'),
|
|
73
|
+
'selected_output_template_id',
|
|
74
|
+
),
|
|
75
|
+
selectedScenarioId: optionalPositiveId(
|
|
76
|
+
valueOf(value, 'selected_scenario_id', 'selectedScenarioId'),
|
|
77
|
+
'selected_scenario_id',
|
|
78
|
+
),
|
|
79
|
+
contextData,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function templateFileInput(input) {
|
|
84
|
+
const value = objectInput(input);
|
|
85
|
+
const outputTemplateId = optionalPositiveId(
|
|
86
|
+
valueOf(value, 'output_template_id', 'outputTemplateId'),
|
|
87
|
+
'output_template_id',
|
|
88
|
+
);
|
|
89
|
+
if (outputTemplateId === undefined) {
|
|
90
|
+
throw new CliError('ARGUMENT_INVALID', 'output_template_id 不能为空');
|
|
91
|
+
}
|
|
92
|
+
const targetPath = relativePathValue(
|
|
93
|
+
valueOf(value, 'target_path', 'targetPath'),
|
|
94
|
+
'target_path',
|
|
95
|
+
);
|
|
96
|
+
if (!targetPath.startsWith('.guanwen/sop-templates/')) {
|
|
97
|
+
throw new CliError('ARGUMENT_INVALID', 'target_path 必须位于 .guanwen/sop-templates/ 目录');
|
|
98
|
+
}
|
|
99
|
+
const overwriteValue = valueOf(value, 'confirm_overwrite', 'confirmOverwrite');
|
|
100
|
+
if (overwriteValue !== undefined && typeof overwriteValue !== 'boolean') {
|
|
101
|
+
throw new CliError('ARGUMENT_INVALID', 'confirm_overwrite 必须是布尔值');
|
|
102
|
+
}
|
|
103
|
+
return { outputTemplateId, targetPath, confirmOverwrite: overwriteValue === true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizedFileFormat(template, targetPath) {
|
|
107
|
+
const raw = template?.fileFormat || extname(template?.fileName || targetPath);
|
|
108
|
+
const normalized = String(raw ?? '').trim().replace(/^\./u, '').toUpperCase();
|
|
109
|
+
const format = normalized === 'WORD' ? 'DOCX' : normalized === 'EXCEL' ? 'XLSX' : normalized;
|
|
110
|
+
if (!['DOCX', 'XLSX'].includes(format)) {
|
|
111
|
+
throw new CliError('BUSINESS_ERROR', `模板文件格式不支持,仅支持 DOCX 和 XLSX: ${format}`);
|
|
112
|
+
}
|
|
113
|
+
const targetFormat = extname(targetPath).slice(1).toUpperCase();
|
|
114
|
+
if (targetFormat !== format) {
|
|
115
|
+
throw new CliError('ARGUMENT_INVALID', `target_path 扩展名必须是 .${format.toLowerCase()}`);
|
|
116
|
+
}
|
|
117
|
+
return format;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function assertTargetWritable(relativePath, baseDir, resolvedPath, confirmOverwrite) {
|
|
121
|
+
try {
|
|
122
|
+
await stat(resolvedPath);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error?.code === 'ENOENT') return false;
|
|
125
|
+
throw new CliError('FILE_WRITE_FAILED', '无法检查模板目标文件', {
|
|
126
|
+
relativePath, baseDir, resolvedPath,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (confirmOverwrite) return true;
|
|
130
|
+
throw new CliError('FILE_ALREADY_EXISTS', '模板目标文件已存在,禁止自动覆盖', {
|
|
131
|
+
relativePath, baseDir, resolvedPath,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function downloadTemplate({ client, template, targetPath, baseDir, confirmOverwrite }) {
|
|
136
|
+
if (typeof baseDir !== 'string' || !baseDir) {
|
|
137
|
+
throw new CliError('CONFIG_MISSING', '模板下载缺少 baseDir');
|
|
138
|
+
}
|
|
139
|
+
const resolvedPath = resolveWithinBase(targetPath, baseDir);
|
|
140
|
+
const targetExists = await assertTargetWritable(
|
|
141
|
+
targetPath,
|
|
142
|
+
baseDir,
|
|
143
|
+
resolvedPath,
|
|
144
|
+
confirmOverwrite,
|
|
145
|
+
);
|
|
146
|
+
await mkdir(dirname(resolvedPath), { recursive: true });
|
|
147
|
+
const temporaryPath = `${resolvedPath}.${randomUUID()}.tmp`;
|
|
148
|
+
const response = await client.download({
|
|
149
|
+
method: 'POST',
|
|
150
|
+
path: '/api/project-writing/file/download',
|
|
151
|
+
body: { fileUrl: template.fileUrl, fileName: template.fileName },
|
|
152
|
+
});
|
|
153
|
+
if (!response?.body || typeof response.body.getReader !== 'function') {
|
|
154
|
+
throw new CliError('FILE_WRITE_FAILED', '模板下载响应缺少文件流', {
|
|
155
|
+
relativePath: targetPath, baseDir, resolvedPath,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
let bytesWritten = 0;
|
|
159
|
+
const counter = new Transform({
|
|
160
|
+
transform(chunk, _encoding, callback) {
|
|
161
|
+
bytesWritten += chunk.length;
|
|
162
|
+
callback(null, chunk);
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
try {
|
|
166
|
+
await pipeline(
|
|
167
|
+
Readable.fromWeb(response.body),
|
|
168
|
+
counter,
|
|
169
|
+
createWriteStream(temporaryPath, { flags: 'wx' }),
|
|
170
|
+
);
|
|
171
|
+
if (bytesWritten <= 0) {
|
|
172
|
+
throw new Error('模板文件内容为空');
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
await rename(temporaryPath, resolvedPath);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (!targetExists || !confirmOverwrite) throw error;
|
|
178
|
+
await rm(resolvedPath, { force: true });
|
|
179
|
+
await rename(temporaryPath, resolvedPath);
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
183
|
+
throw new CliError('FILE_WRITE_FAILED', '模板下载或写入失败', {
|
|
184
|
+
relativePath: targetPath,
|
|
185
|
+
baseDir,
|
|
186
|
+
resolvedPath,
|
|
187
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return { resolvedPath, bytesWritten };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function relativePathValue(value, field) {
|
|
194
|
+
const normalized = requiredString(value, field).replaceAll('\\', '/');
|
|
195
|
+
if (normalized.startsWith('/')
|
|
196
|
+
|| /^[a-zA-Z]:/u.test(normalized)
|
|
197
|
+
|| normalized.split('/').some((part) => part === '..')) {
|
|
198
|
+
throw new CliError('ARGUMENT_INVALID', `${field} 必须是工作目录内的相对路径`);
|
|
199
|
+
}
|
|
200
|
+
return normalized;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function auditInput(input) {
|
|
204
|
+
const value = objectInput(input);
|
|
205
|
+
const nodeName = requiredString(valueOf(value, 'node_name', 'nodeName'), 'node_name');
|
|
206
|
+
if (nodeName === '.' || nodeName === '..' || /[\\/]/u.test(nodeName)) {
|
|
207
|
+
throw new CliError('ARGUMENT_INVALID', 'node_name 必须是单个工作空间目录名');
|
|
208
|
+
}
|
|
209
|
+
const outputTemplateId = optionalPositiveId(
|
|
210
|
+
valueOf(value, 'output_template_id', 'outputTemplateId'),
|
|
211
|
+
'output_template_id',
|
|
212
|
+
);
|
|
213
|
+
if (outputTemplateId === undefined) {
|
|
214
|
+
throw new CliError('ARGUMENT_INVALID', 'output_template_id 不能为空');
|
|
215
|
+
}
|
|
216
|
+
const templatePath = relativePathValue(
|
|
217
|
+
valueOf(value, 'template_path', 'templatePath'),
|
|
218
|
+
'template_path',
|
|
219
|
+
);
|
|
220
|
+
if (!templatePath.startsWith('.guanwen/sop-templates/')) {
|
|
221
|
+
throw new CliError('ARGUMENT_INVALID', 'template_path 必须位于 .guanwen/sop-templates/ 目录');
|
|
222
|
+
}
|
|
223
|
+
const artifactPath = relativePathValue(
|
|
224
|
+
valueOf(value, 'artifact_path', 'artifactPath'),
|
|
225
|
+
'artifact_path',
|
|
226
|
+
);
|
|
227
|
+
const artifactParts = artifactPath.split('/');
|
|
228
|
+
if (artifactParts.length !== 2 || artifactParts[0] !== nodeName || !artifactParts[1]) {
|
|
229
|
+
throw new CliError('ARGUMENT_INVALID', 'artifact_path 必须符合 <node_name>/<业务文件名>');
|
|
230
|
+
}
|
|
231
|
+
const fileFormat = requiredString(
|
|
232
|
+
valueOf(value, 'file_format', 'fileFormat'),
|
|
233
|
+
'file_format',
|
|
234
|
+
).replace(/^\./u, '').toUpperCase();
|
|
235
|
+
if (!['DOCX', 'XLSX'].includes(fileFormat)) {
|
|
236
|
+
throw new CliError('ARGUMENT_INVALID', 'file_format 必须是 DOCX 或 XLSX');
|
|
237
|
+
}
|
|
238
|
+
if (extname(templatePath).slice(1).toUpperCase() !== fileFormat
|
|
239
|
+
|| extname(artifactPath).slice(1).toUpperCase() !== fileFormat) {
|
|
240
|
+
throw new CliError('ARGUMENT_INVALID', '模板和交付物扩展名必须与 file_format 一致');
|
|
241
|
+
}
|
|
242
|
+
const fillResults = valueOf(value, 'fill_results', 'fillResults');
|
|
243
|
+
const remainingPlaceholders = valueOf(value, 'remaining_placeholders', 'remainingPlaceholders');
|
|
244
|
+
const formatRisks = valueOf(value, 'format_risks', 'formatRisks');
|
|
245
|
+
if (!Array.isArray(fillResults)
|
|
246
|
+
|| !Array.isArray(remainingPlaceholders)
|
|
247
|
+
|| !Array.isArray(formatRisks)) {
|
|
248
|
+
throw new CliError(
|
|
249
|
+
'ARGUMENT_INVALID',
|
|
250
|
+
'fill_results、remaining_placeholders 和 format_risks 必须是数组',
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const officecliValidation = valueOf(value, 'officecli_validation', 'officecliValidation');
|
|
254
|
+
if (officecliValidation === null
|
|
255
|
+
|| Array.isArray(officecliValidation)
|
|
256
|
+
|| typeof officecliValidation !== 'object'
|
|
257
|
+
|| officecliValidation.valid !== true) {
|
|
258
|
+
throw new CliError('ARGUMENT_INVALID', 'officecli_validation.valid 必须为 true');
|
|
259
|
+
}
|
|
260
|
+
const overwriteValue = valueOf(value, 'confirm_overwrite', 'confirmOverwrite');
|
|
261
|
+
if (overwriteValue !== undefined && typeof overwriteValue !== 'boolean') {
|
|
262
|
+
throw new CliError('ARGUMENT_INVALID', 'confirm_overwrite 必须是布尔值');
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
nodeName,
|
|
266
|
+
outputTemplateId,
|
|
267
|
+
templatePath,
|
|
268
|
+
artifactPath,
|
|
269
|
+
fileFormat,
|
|
270
|
+
fillResults,
|
|
271
|
+
remainingPlaceholders,
|
|
272
|
+
formatRisks,
|
|
273
|
+
officecliValidation,
|
|
274
|
+
confirmOverwrite: overwriteValue === true,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function requireRegularFile(relativePath, baseDir) {
|
|
279
|
+
const resolvedPath = resolveWithinBase(relativePath, baseDir);
|
|
280
|
+
try {
|
|
281
|
+
const value = await stat(resolvedPath);
|
|
282
|
+
if (!value.isFile()) throw new Error('目标不是文件');
|
|
283
|
+
} catch (error) {
|
|
284
|
+
throw new CliError('FILE_NOT_FOUND', 'SOP 审计所需文件不存在', {
|
|
285
|
+
relativePath,
|
|
286
|
+
baseDir,
|
|
287
|
+
resolvedPath,
|
|
288
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return resolvedPath;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function writeAudit(input, baseDir) {
|
|
295
|
+
if (typeof baseDir !== 'string' || !baseDir) {
|
|
296
|
+
throw new CliError('CONFIG_MISSING', 'SOP 审计缺少 baseDir');
|
|
297
|
+
}
|
|
298
|
+
await requireRegularFile(input.templatePath, baseDir);
|
|
299
|
+
await requireRegularFile(input.artifactPath, baseDir);
|
|
300
|
+
const auditPath = `${input.nodeName}/audit.json`;
|
|
301
|
+
const resolvedPath = resolveWithinBase(auditPath, baseDir);
|
|
302
|
+
try {
|
|
303
|
+
await stat(resolvedPath);
|
|
304
|
+
if (!input.confirmOverwrite) {
|
|
305
|
+
throw new CliError('FILE_ALREADY_EXISTS', '审计文件已存在,覆盖前必须取得用户明确确认', {
|
|
306
|
+
relativePath: auditPath, baseDir, resolvedPath,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (error instanceof CliError) throw error;
|
|
311
|
+
if (error?.code !== 'ENOENT') {
|
|
312
|
+
throw new CliError('FILE_WRITE_FAILED', '无法检查审计目标文件', {
|
|
313
|
+
relativePath: auditPath, baseDir, resolvedPath,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const audit = {
|
|
318
|
+
nodeName: input.nodeName,
|
|
319
|
+
outputTemplateId: input.outputTemplateId,
|
|
320
|
+
templatePath: input.templatePath,
|
|
321
|
+
finalArtifactPath: input.artifactPath,
|
|
322
|
+
fileFormat: input.fileFormat,
|
|
323
|
+
fillResults: input.fillResults,
|
|
324
|
+
remainingPlaceholders: input.remainingPlaceholders,
|
|
325
|
+
formatRisks: input.formatRisks,
|
|
326
|
+
officecliValidation: input.officecliValidation,
|
|
327
|
+
generatedAt: new Date().toISOString(),
|
|
328
|
+
};
|
|
329
|
+
const temporaryPath = `${resolvedPath}.${randomUUID()}.tmp`;
|
|
330
|
+
try {
|
|
331
|
+
await writeFile(temporaryPath, `${JSON.stringify(audit, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
332
|
+
await rename(temporaryPath, resolvedPath);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
335
|
+
throw new CliError('FILE_WRITE_FAILED', 'SOP 审计文件写入失败', {
|
|
336
|
+
relativePath: auditPath,
|
|
337
|
+
baseDir,
|
|
338
|
+
resolvedPath,
|
|
339
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
auditPath,
|
|
344
|
+
templatePath: input.templatePath,
|
|
345
|
+
finalArtifactPath: input.artifactPath,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function splitRegion(value) {
|
|
350
|
+
const region = typeof value === 'string' ? value.trim() : '';
|
|
351
|
+
if (!region) return { province: '', city: '' };
|
|
352
|
+
const parts = region.split(/[\s-_/|,,、-]+/u).map((part) => part.trim()).filter(Boolean);
|
|
353
|
+
if (parts.length >= 2) return { province: parts[0], city: parts[1] };
|
|
354
|
+
const provinceIndex = region.indexOf('省');
|
|
355
|
+
if (provinceIndex > 0 && provinceIndex + 1 < region.length) {
|
|
356
|
+
return { province: region.slice(0, provinceIndex + 1), city: region.slice(provinceIndex + 1) };
|
|
357
|
+
}
|
|
358
|
+
const cityIndex = region.indexOf('市');
|
|
359
|
+
if (cityIndex > 0 && cityIndex + 1 < region.length) {
|
|
360
|
+
return { province: region.slice(0, cityIndex + 1), city: region.slice(cityIndex + 1) };
|
|
361
|
+
}
|
|
362
|
+
return { province: region, city: '' };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function filterPrecheck(precheck, configType) {
|
|
366
|
+
if (precheck === null || typeof precheck !== 'object') return precheck;
|
|
367
|
+
const matches = (item) => String(item?.configType ?? '').trim().toUpperCase() === configType;
|
|
368
|
+
const materials = Array.isArray(precheck.materials) ? precheck.materials.filter(matches) : [];
|
|
369
|
+
const groups = Array.isArray(precheck.groups)
|
|
370
|
+
? precheck.groups.filter(matches).map((group) => ({
|
|
371
|
+
configType: group.configType,
|
|
372
|
+
checkStrategy: group.checkStrategy,
|
|
373
|
+
materials: Array.isArray(group.materials) ? group.materials.filter(matches) : [],
|
|
374
|
+
}))
|
|
375
|
+
: [];
|
|
376
|
+
return {
|
|
377
|
+
nodeCode: precheck.nodeCode,
|
|
378
|
+
nodeName: precheck.nodeName,
|
|
379
|
+
configured: materials.length > 0,
|
|
380
|
+
checkStrategies: precheck.checkStrategies,
|
|
381
|
+
materials,
|
|
382
|
+
groups,
|
|
383
|
+
missingReason: materials.length > 0 ? null : `未配置 ${configType} 前置检查材料`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function wrap(data, reminders, nextStepHint) {
|
|
388
|
+
return {
|
|
389
|
+
data,
|
|
390
|
+
system_reminder: [COMMON_REMINDER, ...reminders],
|
|
391
|
+
next_step_hint: nextStepHint,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export const commands = [
|
|
396
|
+
{
|
|
397
|
+
name: 'sop.node-list',
|
|
398
|
+
method: 'POST',
|
|
399
|
+
path: `${AGENT_WIKI_BASE}/node-list`,
|
|
400
|
+
idempotent: true,
|
|
401
|
+
multipart: false,
|
|
402
|
+
requiredContext: BASE_CONTEXT,
|
|
403
|
+
validate: objectInput,
|
|
404
|
+
handler: async ({ client, context }) => {
|
|
405
|
+
const data = await client.request({
|
|
406
|
+
method: 'POST',
|
|
407
|
+
path: `${AGENT_WIKI_BASE}/node-list`,
|
|
408
|
+
idempotent: true,
|
|
409
|
+
body: {
|
|
410
|
+
...(context.tenantCode ? { tenantCode: context.tenantCode } : {}),
|
|
411
|
+
itemType: HIGH_TECH_ITEM_TYPE,
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
return wrap(data, [
|
|
415
|
+
'必须根据返回的 nodeName、nodeRule、entryCondition 和 summary 识别事项节点,不得使用本地硬编码节点。',
|
|
416
|
+
'存在多个候选节点时必须停止并让用户选择,不得自行猜测。',
|
|
417
|
+
], '选定唯一节点后,调用 sop.precheck-get 获取对应配置类型的前置检查。');
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
name: 'sop.precheck-get',
|
|
422
|
+
method: 'POST',
|
|
423
|
+
path: `${AGENT_WIKI_BASE}/node-detail`,
|
|
424
|
+
idempotent: true,
|
|
425
|
+
multipart: false,
|
|
426
|
+
requiredContext: BASE_CONTEXT,
|
|
427
|
+
validate: configTypeInput,
|
|
428
|
+
handler: async ({ client, context, input }) => {
|
|
429
|
+
const detail = await client.request({
|
|
430
|
+
method: 'POST',
|
|
431
|
+
path: `${AGENT_WIKI_BASE}/node-detail`,
|
|
432
|
+
idempotent: true,
|
|
433
|
+
body: {
|
|
434
|
+
...(context.tenantCode ? { tenantCode: context.tenantCode } : {}),
|
|
435
|
+
itemType: HIGH_TECH_ITEM_TYPE,
|
|
436
|
+
nodeCode: input.nodeCode,
|
|
437
|
+
includeSections: ['PRECHECK'],
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
const data = filterPrecheck(detail?.precheck ?? null, input.configType);
|
|
441
|
+
return wrap(data, [
|
|
442
|
+
'必须分别处理 REQUIRED 必备材料和已配置的 PRE_RESULT 前置成果材料,不得自行增加检查项。',
|
|
443
|
+
'必备材料缺失或定稿所需中间件缺失时不得进入下一阶段;非必备材料缺失也必须先向用户确认。',
|
|
444
|
+
], '前置检查通过后,调用 sop.output-strategy-resolve 解析模板与撰写策略。');
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
name: 'sop.output-strategy-resolve',
|
|
449
|
+
method: 'POST',
|
|
450
|
+
path: `${AGENT_WIKI_BASE}/resolve-output-strategy`,
|
|
451
|
+
idempotent: true,
|
|
452
|
+
multipart: false,
|
|
453
|
+
requiredContext: PROJECT_CONTEXT,
|
|
454
|
+
validate: outputStrategyInput,
|
|
455
|
+
handler: async ({ client, context, input }) => {
|
|
456
|
+
const customer = await client.request({
|
|
457
|
+
method: 'POST',
|
|
458
|
+
path: '/api/internal/v1/project-writing/customer/detail',
|
|
459
|
+
idempotent: true,
|
|
460
|
+
body: {
|
|
461
|
+
...(context.tenantCode ? { tenantCode: context.tenantCode } : {}),
|
|
462
|
+
uuid: context.uuid,
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
const { province, city } = splitRegion(customer?.region);
|
|
466
|
+
const industry = typeof customer?.industry === 'string' ? customer.industry.trim() : '';
|
|
467
|
+
if (!province || !city || !industry) {
|
|
468
|
+
throw new CliError('BUSINESS_ERROR', '当前项目缺少完整省市或行业信息,请先补充客户详情', {
|
|
469
|
+
missing: [!province && 'province', !city && 'city', !industry && 'industry'].filter(Boolean),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
const body = {
|
|
473
|
+
...(context.tenantCode ? { tenantCode: context.tenantCode } : {}),
|
|
474
|
+
itemType: HIGH_TECH_ITEM_TYPE,
|
|
475
|
+
nodeCode: input.nodeCode,
|
|
476
|
+
configType: input.configType,
|
|
477
|
+
province,
|
|
478
|
+
city,
|
|
479
|
+
industry,
|
|
480
|
+
...(input.selectedOutputTemplateId === undefined
|
|
481
|
+
? {} : { selectedOutputTemplateId: input.selectedOutputTemplateId }),
|
|
482
|
+
...(input.selectedScenarioId === undefined
|
|
483
|
+
? {} : { selectedScenarioId: input.selectedScenarioId }),
|
|
484
|
+
...(input.contextData === undefined ? {} : { contextData: input.contextData }),
|
|
485
|
+
};
|
|
486
|
+
const data = await client.request({
|
|
487
|
+
method: 'POST',
|
|
488
|
+
path: `${AGENT_WIKI_BASE}/resolve-output-strategy`,
|
|
489
|
+
idempotent: true,
|
|
490
|
+
body,
|
|
491
|
+
});
|
|
492
|
+
return wrap(data, [
|
|
493
|
+
'必须先读取 resolveStatus:RESOLVED 才能继续;NEED_TEMPLATE_SELECTION 或 NEED_STRATEGY_SELECTION 必须让用户选择;NO_TEMPLATE 或 NO_STRATEGY 必须停止。',
|
|
494
|
+
'RESOLVED 后必须逐项检查 resolvedOutputs;可明确对应的已有成果可以复用,缺失成果才下载模板,不得漏做、合并或新增。',
|
|
495
|
+
'最终文件必须写入以当前节点 nodeName 命名的首级目录;覆盖任何已有文件前必须取得用户明确确认。',
|
|
496
|
+
], 'RESOLVED 后先检查已有成果;缺失成果调用 sop.template-file-get,全部成果就绪后调用 sop.review-get。');
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
name: 'sop.template-file-get',
|
|
501
|
+
method: 'POST',
|
|
502
|
+
path: '/api/internal/v1/project-writing/sop/template-file/get',
|
|
503
|
+
idempotent: false,
|
|
504
|
+
multipart: false,
|
|
505
|
+
requiredContext: PROJECT_CONTEXT,
|
|
506
|
+
validate: templateFileInput,
|
|
507
|
+
handler: async ({ client, context, input, baseDir }) => {
|
|
508
|
+
const template = await client.request({
|
|
509
|
+
method: 'POST',
|
|
510
|
+
path: '/api/internal/v1/project-writing/sop/template-file/get',
|
|
511
|
+
idempotent: true,
|
|
512
|
+
body: {
|
|
513
|
+
...(context.tenantCode ? { tenantCode: context.tenantCode } : {}),
|
|
514
|
+
contextId: context.uuid,
|
|
515
|
+
outputTemplateId: input.outputTemplateId,
|
|
516
|
+
targetPath: input.targetPath,
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
if (typeof template?.fileUrl !== 'string' || !template.fileUrl.trim()
|
|
520
|
+
|| typeof template?.fileName !== 'string' || !template.fileName.trim()) {
|
|
521
|
+
throw new CliError('BUSINESS_ERROR', '模板文件缺少下载地址或文件名');
|
|
522
|
+
}
|
|
523
|
+
const fileFormat = normalizedFileFormat(template, input.targetPath);
|
|
524
|
+
const { bytesWritten } = await downloadTemplate({
|
|
525
|
+
client,
|
|
526
|
+
template,
|
|
527
|
+
targetPath: input.targetPath,
|
|
528
|
+
baseDir,
|
|
529
|
+
confirmOverwrite: input.confirmOverwrite,
|
|
530
|
+
});
|
|
531
|
+
const extension = fileFormat.toLowerCase();
|
|
532
|
+
const data = {
|
|
533
|
+
...template,
|
|
534
|
+
workspacePath: input.targetPath,
|
|
535
|
+
fileFormat,
|
|
536
|
+
recommendedSkillCode: 'officecli',
|
|
537
|
+
recommendedSkillPath: 'load_skill(name: "officecli")',
|
|
538
|
+
templateRuntimePath: input.targetPath,
|
|
539
|
+
finalPathRule: `<当前事项名称>/<业务文件名>.${extension}`,
|
|
540
|
+
auditPathRule: '<当前事项名称>/audit.json',
|
|
541
|
+
bytesWritten,
|
|
542
|
+
};
|
|
543
|
+
return wrap(data, [
|
|
544
|
+
'必须先加载 officecli Skill;原始模板仅作为只读底稿,不得原地修改。',
|
|
545
|
+
'最终文件必须写入 <当前事项名称>/,并严格执行 fillGuide、usageNote 和已解析撰写策略。',
|
|
546
|
+
'完成 officecli validate 和结构检查后必须调用 sop.audit-write;验证失败或审计失败不得交付或同步。',
|
|
547
|
+
], '使用 officecli 生成并验证缺失成果,调用 sop.audit-write 写入审计;全部成果就绪后调用 sop.review-get。');
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
name: 'sop.review-get',
|
|
552
|
+
method: 'POST',
|
|
553
|
+
path: '/api/internal/v1/project-writing/sop/node/review-config',
|
|
554
|
+
idempotent: true,
|
|
555
|
+
multipart: false,
|
|
556
|
+
requiredContext: BASE_CONTEXT,
|
|
557
|
+
validate: nodeCodeInput,
|
|
558
|
+
handler: async ({ client, context, input }) => {
|
|
559
|
+
const data = await client.request({
|
|
560
|
+
method: 'POST',
|
|
561
|
+
path: '/api/internal/v1/project-writing/sop/node/review-config',
|
|
562
|
+
idempotent: true,
|
|
563
|
+
body: {
|
|
564
|
+
...(context.tenantCode ? { tenantCode: context.tenantCode } : {}),
|
|
565
|
+
itemType: HIGH_TECH_ITEM_TYPE,
|
|
566
|
+
nodeCode: input.nodeCode,
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
return wrap(data, [
|
|
570
|
+
'reviewSuggestionDocument 是优化建议,必须反馈给用户,但不作为同步阻断依据。',
|
|
571
|
+
'reviewGateDocument 是同步定稿前的硬性校验,任一条件不满足时不得同步。',
|
|
572
|
+
'只有用户明确要求同步定稿且硬性校验通过后,才能调用对应领域写命令;写入后必须使用对应查询命令查询验证。',
|
|
573
|
+
], '评审完成后停留在中间件阶段;只有用户明确要求同步定稿且硬门禁通过时才执行同步并查询验证。');
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
name: 'sop.audit-write',
|
|
578
|
+
method: 'LOCAL',
|
|
579
|
+
path: null,
|
|
580
|
+
idempotent: false,
|
|
581
|
+
multipart: false,
|
|
582
|
+
requiredContext: [],
|
|
583
|
+
validate: auditInput,
|
|
584
|
+
handler: async ({ input, baseDir }) => {
|
|
585
|
+
const data = await writeAudit(input, baseDir);
|
|
586
|
+
return wrap(data, [
|
|
587
|
+
'当前交付物已完成本地审计;审计记录不能替代远端 SOP 评审。',
|
|
588
|
+
'必须继续调用 sop.review-get,评审完成前不得交付定稿或同步正式业务模块。',
|
|
589
|
+
], '调用 sop.review-get 获取优化建议和同步硬门禁。');
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { CliError } from '../core/errors.mjs';
|
|
2
|
+
import { commands as materialCommands } from './material.mjs';
|
|
3
|
+
import { commands as projectCommands } from './project.mjs';
|
|
4
|
+
import { commands as reportCommands } from './report.mjs';
|
|
5
|
+
|
|
6
|
+
const BASE_CONTEXT = ['gatewayUrl'];
|
|
7
|
+
const PROJECT_CONTEXT = [...BASE_CONTEXT, 'uuid'];
|
|
8
|
+
|
|
9
|
+
function findCommand(commands, name) {
|
|
10
|
+
const command = commands.find((item) => item.name === name);
|
|
11
|
+
if (!command) throw new CliError('CLI_INTERNAL_ERROR', `工作流步骤不存在: ${name}`);
|
|
12
|
+
return command;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const PROJECT_BIND = findCommand(projectCommands, 'project.bind');
|
|
16
|
+
const COMPLETENESS = findCommand(reportCommands, 'report.completeness');
|
|
17
|
+
const MATERIAL_LIST = findCommand(materialCommands, 'material.list');
|
|
18
|
+
const REPORT_GENERATE = findCommand(reportCommands, 'report.generate');
|
|
19
|
+
|
|
20
|
+
function objectInput(input) {
|
|
21
|
+
if (input === null || Array.isArray(input) || typeof input !== 'object') {
|
|
22
|
+
throw new CliError('ARGUMENT_INVALID', '工作流输入必须是对象');
|
|
23
|
+
}
|
|
24
|
+
return input;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function initializeInput(input) {
|
|
28
|
+
const value = objectInput(input);
|
|
29
|
+
return { ...value, project: PROJECT_BIND.validate(value.project) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function organizeInput(input) {
|
|
33
|
+
const value = objectInput(input);
|
|
34
|
+
const material = MATERIAL_LIST.validate({ kind: value.kind });
|
|
35
|
+
const report = REPORT_GENERATE.validate({ jobs: value.jobs });
|
|
36
|
+
return { ...value, kind: material.kind, jobs: report.jobs };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function failedStep(command, error) {
|
|
40
|
+
return {
|
|
41
|
+
command,
|
|
42
|
+
code: error?.code ?? 'BUSINESS_ERROR',
|
|
43
|
+
message: error instanceof Error ? error.message : '工作流步骤失败',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function runSequential(steps, args) {
|
|
48
|
+
const results = [];
|
|
49
|
+
for (const { command, input } of steps) {
|
|
50
|
+
try {
|
|
51
|
+
const data = await command.handler({ ...args, command, input });
|
|
52
|
+
results.push({ command: command.name, status: 'completed', data });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const failed = failedStep(command.name, error);
|
|
55
|
+
results.push({ command: command.name, status: 'failed', error: failed });
|
|
56
|
+
return { completed: false, failed, steps: results };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { completed: true, failed: null, steps: results };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const commands = [
|
|
63
|
+
{
|
|
64
|
+
name: 'workflow.initialize', method: 'POST', path: PROJECT_BIND.path,
|
|
65
|
+
idempotent: false, multipart: false, requiredContext: BASE_CONTEXT, validate: initializeInput,
|
|
66
|
+
handler: async (args) => await runSequential([
|
|
67
|
+
{ command: PROJECT_BIND, input: args.input.project },
|
|
68
|
+
], args),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'workflow.completeness', method: 'POST', path: COMPLETENESS.path,
|
|
72
|
+
idempotent: true, multipart: false, requiredContext: PROJECT_CONTEXT, validate: objectInput,
|
|
73
|
+
handler: async (args) => {
|
|
74
|
+
const result = await runSequential([
|
|
75
|
+
{ command: COMPLETENESS, input: COMPLETENESS.validate(args.input) },
|
|
76
|
+
], args);
|
|
77
|
+
const data = result.steps[0]?.data;
|
|
78
|
+
if (result.completed && data?.complete === false) {
|
|
79
|
+
const failed = {
|
|
80
|
+
command: COMPLETENESS.name,
|
|
81
|
+
code: 'WORKFLOW_STEP_FAILED',
|
|
82
|
+
message: '完整性查询存在失败项',
|
|
83
|
+
};
|
|
84
|
+
result.completed = false;
|
|
85
|
+
result.failed = failed;
|
|
86
|
+
result.steps[0] = { command: COMPLETENESS.name, status: 'failed', data, error: failed };
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'workflow.organize-materials', method: 'POST', path: REPORT_GENERATE.path,
|
|
93
|
+
idempotent: false, multipart: false, requiredContext: PROJECT_CONTEXT, validate: organizeInput,
|
|
94
|
+
handler: async (args) => await runSequential([
|
|
95
|
+
{ command: MATERIAL_LIST, input: MATERIAL_LIST.validate({ kind: args.input.kind }) },
|
|
96
|
+
{ command: REPORT_GENERATE, input: REPORT_GENERATE.validate({ jobs: args.input.jobs }) },
|
|
97
|
+
], args),
|
|
98
|
+
},
|
|
99
|
+
];
|