@us_momo/dsh-erp-analysis 1.5.2 → 1.6.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/README.md +4 -0
- package/package.json +2 -1
- package/plugin/client.js +3 -0
- package/plugin/runtime.mjs +5 -1
- package/release.json +1 -1
- package/release.mjs +15 -6
- package/selection_archive.mjs +151 -0
package/release.mjs
CHANGED
|
@@ -97,6 +97,7 @@ export function validateRelease(manifest) {
|
|
|
97
97
|
"app/release.mjs",
|
|
98
98
|
"app/control.mjs",
|
|
99
99
|
"app/launcher.mjs",
|
|
100
|
+
"app/selection_archive.mjs",
|
|
100
101
|
"app/control.html",
|
|
101
102
|
"workspace/AGENTS.md",
|
|
102
103
|
];
|
|
@@ -212,7 +213,13 @@ async function installReleaseUnlocked(root, manifest, { defer = true } = {}) {
|
|
|
212
213
|
await atomicJson(join(root, "pending.json"), marker);
|
|
213
214
|
return { ...marker, status: "pending-reload" };
|
|
214
215
|
}
|
|
215
|
-
|
|
216
|
+
try {
|
|
217
|
+
await publishWorkspace(root, target, marker);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (!current || !error.workspaceRestored || !["EPERM", "EBUSY", "EACCES"].includes(error.code)) throw error;
|
|
220
|
+
await atomicJson(join(root, "pending.json"), marker);
|
|
221
|
+
return { ...marker, status: "pending-reload", reason: "workspace_busy" };
|
|
222
|
+
}
|
|
216
223
|
await rm(join(root, "pending.json"), { force: true });
|
|
217
224
|
return { ...marker, status: "installed" };
|
|
218
225
|
}
|
|
@@ -248,17 +255,19 @@ async function publishWorkspace(root, target, marker) {
|
|
|
248
255
|
await rename(join(workspace, name), join(stage, name));
|
|
249
256
|
for (const name of old.reverse())
|
|
250
257
|
await rename(join(previous, name), join(workspace, name));
|
|
258
|
+
error.workspaceRestored = true;
|
|
251
259
|
throw error;
|
|
252
260
|
}
|
|
253
261
|
}
|
|
254
262
|
async function activatePendingUnlocked(root) {
|
|
255
263
|
const pending = await readJson(join(root, "pending.json"), null);
|
|
256
264
|
if (!pending || (await liveLeases(root)).length) return false;
|
|
257
|
-
|
|
258
|
-
root,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
265
|
+
try {
|
|
266
|
+
await publishWorkspace(root, join(root, "versions", pending.version), pending);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (!error.workspaceRestored || !["EPERM", "EBUSY", "EACCES"].includes(error.code)) throw error;
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
262
271
|
await rm(join(root, "pending.json"), { force: true });
|
|
263
272
|
return true;
|
|
264
273
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { atomicJson, readJson } from './release.mjs';
|
|
5
|
+
|
|
6
|
+
export const selectionRequirements = ['erp_selection_list', 'erp_selection_read', 'erp_selection_save',
|
|
7
|
+
'erp_selection_operation', 'erp_selection_file_save', 'erp_selection_analyze'];
|
|
8
|
+
|
|
9
|
+
export function selectionCapability(tools) {
|
|
10
|
+
const names = new Set(tools.map(tool => tool.name));
|
|
11
|
+
const missing = selectionRequirements.filter(name => !names.has(name));
|
|
12
|
+
return { available: missing.length === 0, missing };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const researchSaveTool = {
|
|
16
|
+
name: 'erp_selection_research_save',
|
|
17
|
+
description: '用户明确保存后,将本机研究草稿分步写入ERP并返回可读链接。request含固定operationId(UUID)和path(JSON草稿)。草稿为{direction:{id?,expectedVersion?,name?,market?,summary?,relatedId?},report:{id?,expectedVersion?,title?,sections,references?},materials:[{key,path,sourceNote?,site?,collectedOn?}]}。新主题给name;接续先read,给direction.id,已有主报告须给report.id/expectedVersion,仅写本轮章节。Markdown图片用,材料路径相对草稿文件;保存后自动上传并换为ERP图片地址。每步固定操作号,结果未知再次调用原operationId和原草稿,恢复成功步骤;不会重复建整套档案。普通研究讨论不调用本工具。',
|
|
18
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['request'], properties: {
|
|
19
|
+
request: { type: 'object', additionalProperties: false, required: ['operationId', 'path'], properties: {
|
|
20
|
+
operationId: { type: 'string', format: 'uuid' }, path: { type: 'string' },
|
|
21
|
+
} },
|
|
22
|
+
} },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function stepId(operationId, name) {
|
|
26
|
+
const bytes = createHash('sha256').update(operationId + ':' + name).digest().subarray(0, 16);
|
|
27
|
+
bytes[6] = (bytes[6] & 15) | 80; bytes[8] = (bytes[8] & 63) | 128;
|
|
28
|
+
const hex = bytes.toString('hex');
|
|
29
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
30
|
+
}
|
|
31
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
32
|
+
function canonical(value) {
|
|
33
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
34
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value);
|
|
38
|
+
|
|
39
|
+
function fields(value, allowed, name) {
|
|
40
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !allowed.includes(key)))
|
|
41
|
+
throw new Error(`${name}草稿字段不符,请按 erp_selection_research_save 的结构准备。`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function saveResearch(client, root, { request }) {
|
|
45
|
+
if (!uuid(request?.operationId)) throw new Error('保存操作编号须为固定 UUID。');
|
|
46
|
+
const capabilities = selectionCapability((await client.tools()).tools || []);
|
|
47
|
+
if (!capabilities.available)
|
|
48
|
+
throw new Error(`当前会话连接未提供选品建档工具:${capabilities.missing.join(', ')}。请在公司 ERP 分析入口接续,不把本会话目录缺失解释为ERP未实现。`);
|
|
49
|
+
const device = await readJson(join(root, 'private', 'device.json'), null);
|
|
50
|
+
if (!uuid(device?.deviceId)) throw new Error('请先在公司 ERP 分析入口登录本人 ERP。');
|
|
51
|
+
client.checkIdentity?.(device);
|
|
52
|
+
const call = (name, args) => client.call(name, args, { expectedDeviceId: device.deviceId });
|
|
53
|
+
const path = await realpath(resolve(root, 'workspace', request.path));
|
|
54
|
+
if ((await stat(path)).size > 2 * 1024 * 1024) throw new Error('研究草稿超过 2 MiB。');
|
|
55
|
+
const draft = JSON.parse(await readFile(path, 'utf8'));
|
|
56
|
+
fields(draft, ['direction', 'report', 'materials'], '研究');
|
|
57
|
+
fields(draft.direction, ['id', 'expectedVersion', 'name', 'market', 'summary', 'relatedId'], '主题/方向');
|
|
58
|
+
fields(draft.report, ['id', 'expectedVersion', 'title', 'sections', 'references'], '报告');
|
|
59
|
+
fields(draft.report.sections, ['scope', 'types', 'prices', 'needs', 'trends', 'conclusion', 'sourcing'], '章节');
|
|
60
|
+
if (!Object.keys(draft.report.sections).length || Object.values(draft.report.sections).some(value => typeof value !== 'string' || value.length > 60000))
|
|
61
|
+
throw new Error('请提供本轮研究章节文字,每章最多 60000 字符。');
|
|
62
|
+
if (draft.report.references !== undefined && !Array.isArray(draft.report.references)) throw new Error('报告引用须为数组。');
|
|
63
|
+
if (draft.direction.id && !uuid(draft.direction.id) || !draft.direction.id && (typeof draft.direction.name !== 'string' || !draft.direction.name.trim()))
|
|
64
|
+
throw new Error('新研究须有名称;接续须有正确主题/方向编号。');
|
|
65
|
+
if (!Array.isArray(draft.materials ?? []) || (draft.materials?.length ?? 0) > 40) throw new Error('materials须为最多40项的原件与代表图片数组。');
|
|
66
|
+
const keys = new Set(), materials = [];
|
|
67
|
+
for (const item of draft.materials ?? []) {
|
|
68
|
+
fields(item, ['key', 'path', 'sourceNote', 'site', 'collectedOn'], '原件');
|
|
69
|
+
if (typeof item.key !== 'string' || !/^[a-zA-Z0-9_-]{1,60}$/.test(item.key) || keys.has(item.key)) throw new Error('材料key须唯一,使用字母、数字、下划线或短横线。');
|
|
70
|
+
keys.add(item.key);
|
|
71
|
+
const source = await realpath(resolve(dirname(path), item.path));
|
|
72
|
+
if (!(await stat(source)).isFile() || (await stat(source)).size > 8 * 1024 * 1024) throw new Error('原件须为8MiB以内的文件。');
|
|
73
|
+
const bytes = await readFile(source);
|
|
74
|
+
materials.push({ ...item, filename: basename(source), bytes, sha256: hash(bytes) });
|
|
75
|
+
}
|
|
76
|
+
const markdown = Object.values(draft.report.sections).join('\n');
|
|
77
|
+
for (const match of markdown.matchAll(/\(material:([a-zA-Z0-9_-]+)\)/g))
|
|
78
|
+
if (!keys.has(match[1])) throw new Error('章节引用了未提供的材料key。');
|
|
79
|
+
const illustrations = materials.filter(item => /\.(png|jpe?g|webp)$/i.test(item.filename));
|
|
80
|
+
const unplaced = illustrations.filter(item => !markdown.includes(`](material:${item.key})`));
|
|
81
|
+
if (unplaced.length && draft.report.sections.types === undefined) throw new Error('请提供types章节,用于展示本轮代表图片。');
|
|
82
|
+
for (const match of markdown.matchAll(/!\[[^\]]*\]\(([^\s)]+)(?:\s+[^)]*)?\)/g)) {
|
|
83
|
+
const url = match[1];
|
|
84
|
+
if (url.startsWith('material:')) {
|
|
85
|
+
const material = materials.find(item => item.key === url.slice(9));
|
|
86
|
+
if (!material || !/\.(png|jpe?g|webp)$/i.test(material.filename)) throw new Error('报告图片须引用已提供的图片材料key。');
|
|
87
|
+
} else if (!/^https?:\/\//i.test(url) && !/^\/api\/v1\/selection\/files\/[0-9a-f-]+\?inline=1$/i.test(url)) {
|
|
88
|
+
throw new Error('报告图片仍指向本机或临时地址,请以 material:key 引用原图材料。');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const digest = hash(JSON.stringify({ draft, files: materials.map(item => [item.key, item.sha256]) }));
|
|
92
|
+
const journalPath = join(root, 'private', 'selection-saves', device.deviceId, request.operationId + '.json');
|
|
93
|
+
let journal = await readJson(journalPath, null);
|
|
94
|
+
if (journal && journal.digest !== digest) throw new Error('同一保存操作编号的研究内容或原件已变化,请先核对原结果。');
|
|
95
|
+
journal ||= { digest, deviceId: device.deviceId, steps: {} };
|
|
96
|
+
await atomicJson(journalPath, journal);
|
|
97
|
+
const progress = () => ({ directionId: journal.steps.direction?.item.id ?? draft.direction.id,
|
|
98
|
+
reportId: journal.steps.report?.item.id, fileIds: Object.fromEntries(materials.filter(item => journal.steps['file-' + item.key]).map(item => [item.key, journal.steps['file-' + item.key].item.id])) });
|
|
99
|
+
async function step(key, action, data, target) {
|
|
100
|
+
const operationId = stepId(request.operationId, key);
|
|
101
|
+
const command = { operationId, action, data, ...(target?.id ? { targetId: target.id, expectedVersion: target.expectedVersion } : {}) };
|
|
102
|
+
const digest = hash(JSON.stringify(canonical(command)));
|
|
103
|
+
const stored = journal.steps[key];
|
|
104
|
+
if (stored) {
|
|
105
|
+
if (stored.digest !== digest) throw new Error('保存步骤内容已变化,请核对原研究。');
|
|
106
|
+
return stored.item;
|
|
107
|
+
}
|
|
108
|
+
const original = await call('erp_selection_operation', { request: { operationId } });
|
|
109
|
+
if (!['saved', 'not_found'].includes(original.status)) throw new Error('原操作状态未知,本次未重投保存。');
|
|
110
|
+
if (original.status === 'saved' && original.requestDigest !== digest) throw new Error('原操作回执与本轮内容不一致,不能以原编号认领或覆盖。请核对已保存资料。');
|
|
111
|
+
const receipt = original.status === 'saved' ? original : await call(action === 'material' ? 'erp_selection_file_save' : 'erp_selection_save', {
|
|
112
|
+
request: action === 'material' ? { operationId, ...data } : command,
|
|
113
|
+
});
|
|
114
|
+
if (receipt.operationId !== operationId || !uuid(receipt.item?.id)) throw new Error('保存回执不完整,请用原操作编号恢复。');
|
|
115
|
+
journal.steps[key] = { digest, item: receipt.item };
|
|
116
|
+
await atomicJson(journalPath, journal);
|
|
117
|
+
return receipt.item;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
let latest;
|
|
121
|
+
if (draft.direction.id) latest = await call('erp_selection_read', { request: { directionId: draft.direction.id } });
|
|
122
|
+
const existingMain = latest?.reports.find(item => item.kind === 'main');
|
|
123
|
+
if (existingMain && (draft.report.id !== existingMain.id || !Number.isInteger(draft.report.expectedVersion)))
|
|
124
|
+
throw new Error('已有主报告:请先读取最新报告,合并本轮章节并提供report.id和expectedVersion。');
|
|
125
|
+
const { id, expectedVersion, ...directionData } = draft.direction;
|
|
126
|
+
const direction = id && !Object.keys(directionData).length ? latest.direction : await step('direction', 'direction', directionData, id ? { id, expectedVersion } : undefined);
|
|
127
|
+
const fileIds = {};
|
|
128
|
+
for (const item of materials) {
|
|
129
|
+
const { key, filename, bytes, sourceNote, site, collectedOn } = item;
|
|
130
|
+
const data = { directionId: direction.id, filename, contentBase64: bytes.toString('base64'),
|
|
131
|
+
...(sourceNote !== undefined ? { sourceNote } : {}), ...(site !== undefined ? { site } : {}), ...(collectedOn !== undefined ? { collectedOn } : {}) };
|
|
132
|
+
fileIds[key] = (await step('file-' + key, 'material', data)).id;
|
|
133
|
+
}
|
|
134
|
+
const sections = Object.fromEntries(Object.entries(draft.report.sections).map(([key, value]) => [key,
|
|
135
|
+
value.replace(/\(material:([a-zA-Z0-9_-]+)\)/g, (_, key) => `(/api/v1/selection/files/${fileIds[key]}?inline=1)`)]));
|
|
136
|
+
if (unplaced.length) sections.types += '\n\n### 代表图片\n' + unplaced.map(item =>
|
|
137
|
+
`\n![${(item.sourceNote || item.filename).replace(/[\[\]\r\n]/g, ' ')}](/api/v1/selection/files/${fileIds[item.key]}?inline=1)\n\n${item.sourceNote || item.filename}\n`).join('\n');
|
|
138
|
+
const allReferences = [...(existingMain?.references ?? []), ...(draft.report.references ?? []), ...materials.map(item => ({ title: item.filename, fileId: fileIds[item.key] }))];
|
|
139
|
+
const references = allReferences.filter((item, index) => allReferences.findIndex(other => JSON.stringify(other) === JSON.stringify(item)) === index);
|
|
140
|
+
const report = journal.steps.report?.item ?? await step('report', 'report', { ...(draft.report.id ? {} : { directionId: direction.id, kind: 'main' }),
|
|
141
|
+
...(draft.report.title !== undefined ? { title: draft.report.title } : {}), sections, references }, draft.report.id ? draft.report : undefined);
|
|
142
|
+
const saved = await call('erp_selection_read', { request: { directionId: direction.id } });
|
|
143
|
+
if (!saved.reports.some(item => item.id === report.id) || materials.some(item => !saved.files.some(file => file.id === fileIds[item.key])))
|
|
144
|
+
throw new Error('保存已受理,但回读未核对完成,请用原请求恢复。');
|
|
145
|
+
return { status: 'saved', operationId: request.operationId, directionId: direction.id, reportId: report.id, reportVersion: report.version,
|
|
146
|
+
fileIds, url: new URL('/workspace/selection?directionId=' + direction.id, client.base).href };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
error.partial = progress();
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|