@qinsilk/scm-agent-tools 0.0.0-stage → 0.2.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 +48 -2
- package/cli/api.js +114 -0
- package/cli/commands/data.js +112 -0
- package/cli/commands/docs.js +121 -0
- package/cli/commands/log.js +152 -0
- package/cli/commands/query.js +285 -0
- package/cli/commands/report.js +213 -0
- package/cli/commands/staff.js +505 -0
- package/cli/config.js +46 -0
- package/cli/datasets.js +541 -0
- package/cli/scm-agent.js +110 -0
- package/cli/skills.js +89 -0
- package/cli/table.js +43 -0
- package/docs/api.md +2067 -0
- package/docs/cli.md +252 -0
- package/package.json +27 -4
- package/reports/excel.js +107 -0
- package/reports/factoryEvaluation.js +128 -0
- package/reports/goodsBom.js +648 -0
- package/reports/goodsFirstOrder.js +409 -0
- package/reports/goodsInventory.js +220 -0
- package/reports/goodsSize.js +440 -0
- package/reports/image.js +216 -0
- package/reports/leakCheck.js +694 -0
- package/reports/materialPurchaseExport.js +393 -0
- package/reports/materialTransit.js +593 -0
- package/reports/processFollowup.js +312 -0
- package/reports/produceBoard.js +353 -0
- package/reports/produceCost.js +366 -0
- package/reports/produceCostDetail.js +622 -0
- package/reports/produceDaily.js +485 -0
- package/reports/storeInGoods.js +276 -0
- package/reports/supplierBalance.js +671 -0
- package/reports/ticketStatistics.js +289 -0
- package/reports/util.js +83 -0
- package/skills/bom/345/225/206/345/223/201/344/277/241/346/201/257/SKILL.md +40 -0
- package/skills/qinsilk-scm/SKILL.md +126 -0
- package/skills//344/276/233/345/272/224/345/225/206/345/257/271/350/264/246/345/257/274/345/207/272/SKILL.md +38 -0
- package/skills//345/225/206/345/223/201/345/260/272/345/257/270/344/277/241/346/201/257/SKILL.md +40 -0
- package/skills//345/225/206/345/223/201/351/246/226/345/215/225/351/235/242/346/226/231/345/267/245/345/272/217/345/215/225/344/273/267/SKILL.md +39 -0
- package/skills//345/267/245/345/216/202/350/257/204/344/274/260/346/212/245/350/241/250/SKILL.md +39 -0
- package/skills//346/210/220/345/223/201/345/205/245/345/272/223/346/212/245/350/241/250/SKILL.md +40 -0
- package/skills//346/234/215/350/243/205/346/210/220/345/223/201/347/233/230/345/255/230/350/241/250/SKILL.md +39 -0
- package/skills//346/274/217/346/226/231/346/243/200/346/265/213/346/227/245/346/212/245/SKILL.md +40 -0
- package/skills//347/211/251/346/226/231/345/234/250/351/200/224/347/212/266/346/200/201/346/212/245/350/241/250/SKILL.md +40 -0
- package/skills//347/211/251/346/226/231/351/207/207/350/264/255/345/257/274/345/207/272/SKILL.md +39 -0
- package/skills//347/224/237/344/272/247/345/215/225/345/267/245/345/272/217/350/267/237/350/277/233/350/241/250/SKILL.md +39 -0
- package/skills//347/224/237/344/272/247/346/210/220/346/234/254/346/212/245/350/241/250/SKILL.md +41 -0
- package/skills//347/224/237/344/272/247/346/210/220/346/234/254/346/212/245/350/241/250/346/230/216/347/273/206/SKILL.md +40 -0
- package/skills//347/224/237/344/272/247/346/227/245/346/212/245/SKILL.md +42 -0
- package/skills//347/224/237/344/272/247/347/212/266/345/206/265/347/273/237/350/256/241/350/241/250/SKILL.md +39 -0
- package/skills//347/224/237/344/272/247/350/212/202/347/202/271/347/234/213/346/235/277/SKILL.md +39 -0
package/README.md
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
|
-
#
|
|
1
|
+
# scm-agent-tools —— 让 AI 帮你查秦丝生产ERP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
配合秦丝生产ERP「AI 助手」使用的命令行工具与 AI 技能。装好之后,你对 AI 助手说一句话就能查数据:
|
|
4
|
+
|
|
5
|
+
- 「查一下款 A123 的生产单」
|
|
6
|
+
- 「A123 完成裁床了吗」
|
|
7
|
+
- 「帮我看下最近的物料采购单」
|
|
8
|
+
- 「找一下叫华达呢的物料」
|
|
9
|
+
|
|
10
|
+
也能一句话生成官方报表文件:
|
|
11
|
+
|
|
12
|
+
- 「生成今天的生产日报」
|
|
13
|
+
- 「导出近一个月的工厂评估报表」
|
|
14
|
+
- 「跟明达厂出一份对账单」
|
|
15
|
+
- 「导出服装成品盘存表」
|
|
16
|
+
|
|
17
|
+
官方技能库共 16 个报表技能(生产日报、生产节点看板、生产单工序跟进表、服装成品盘存表、生产成本报表、供应商对账导出等),全部只读、开箱即用;你还可以让 AI 按自己公司的业务定制专属报表。
|
|
18
|
+
|
|
19
|
+
## 你需要先准备
|
|
20
|
+
|
|
21
|
+
一个生产ERP账号,并在网页右上角「AI 助手」页面生成一个 AI Key(页面有一键复制的话术,把它粘贴给你的 AI 助手即可全自动安装;下文是手动安装步骤)。
|
|
22
|
+
|
|
23
|
+
## 手动安装
|
|
24
|
+
|
|
25
|
+
电脑需要 Node.js 18+。终端执行:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g @qinsilk/scm-agent-tools
|
|
29
|
+
scm-agent skills install
|
|
30
|
+
scm-agent login --key qsk-你的Key
|
|
31
|
+
scm-agent status
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
看到「生产ERP CLI 已配置完成」就成功了。国内安装慢时加 `--registry=https://registry.npmmirror.com`。
|
|
35
|
+
|
|
36
|
+
## 常见问题
|
|
37
|
+
|
|
38
|
+
| 你看到的 | 怎么办 |
|
|
39
|
+
|---|---|
|
|
40
|
+
| AI Key 无效 / 已过期 / 已吊销 | 回生产ERP网页「AI 助手」页面重新生成,再执行一次 login |
|
|
41
|
+
| 权限不足 | 这是账号本身没有该数据的权限,找管理员开通 |
|
|
42
|
+
| 请求过于频繁 | 稍等一分钟再用 |
|
|
43
|
+
| 想切断工具的全部权限 | 网页「AI 助手」页面对应 Key 点「吊销」,立即生效 |
|
|
44
|
+
|
|
45
|
+
## 隐私
|
|
46
|
+
|
|
47
|
+
AI Key 只保存在你自己的电脑(`~/.scm-agent/config.json`,仅本用户可读);工具只能查询你本人在网页上有权限看的数据;所有查询会被系统日志记录。
|
|
48
|
+
|
|
49
|
+
全部命令的详细说明(给你或你的 AI 看):[docs/cli.md](docs/cli.md)。接口返回的每个字段是什么意思:[docs/api.md](docs/api.md)。
|
package/cli/api.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// HTTP 访问层:AI Key 换令牌、令牌缓存与自动续期、错误翻译。
|
|
2
|
+
// 使用 Node 18 内置 fetch,零第三方依赖。
|
|
3
|
+
const { readConfig, writeConfig } = require('./config');
|
|
4
|
+
|
|
5
|
+
/** 服务端业务错误码到用户可读文案的映射 */
|
|
6
|
+
const ERROR_TEXT = {
|
|
7
|
+
481: 'AI Key 无效或已过期,请在生产ERP网页「AI 助手」页面重新生成',
|
|
8
|
+
482: '权限不足:当前用户没有访问该数据的权限',
|
|
9
|
+
483: 'AI Key 已吊销,请在生产ERP网页「AI 助手」页面重新生成',
|
|
10
|
+
484: '请求过于频繁,请稍后再试',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** 令牌提前 5 分钟续期,避免请求途中过期 */
|
|
14
|
+
const TOKEN_REFRESH_AHEAD_MS = 5 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 用 AI Key 换取访问令牌并保存到配置。
|
|
18
|
+
* 失败时抛出带用户可读 message 的 Error,不重试(避免放大爆破限制)。
|
|
19
|
+
*/
|
|
20
|
+
async function login(key, server) {
|
|
21
|
+
const response = await request(server, 'POST', '/api/agent/v1/token', { key }, { auth: false });
|
|
22
|
+
const config = { server, key, accessToken: response.accessToken, tokenExpiresAt: Date.now() + response.expiresIn * 1000, userInfo: { userName: response.userName, companyName: response.companyName, scopeText: response.scopeText } };
|
|
23
|
+
writeConfig(config);
|
|
24
|
+
return response;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 带认证调用业务接口。令牌临近过期时自动重新交换。
|
|
29
|
+
*/
|
|
30
|
+
async function call(method, apiPath, queryParams = {}) {
|
|
31
|
+
const config = requireConfig();
|
|
32
|
+
let current = ensureFreshToken(config);
|
|
33
|
+
let result;
|
|
34
|
+
try {
|
|
35
|
+
result = await request(config.server, method, apiPath, buildQuery(queryParams), { token: (await current).accessToken });
|
|
36
|
+
} catch (e) {
|
|
37
|
+
// 令牌失效时强制刷新一次再重试(服务器重启、Redis 清空等场景)
|
|
38
|
+
if (e.code === 481) {
|
|
39
|
+
const refreshed = await login(config.key, config.server);
|
|
40
|
+
result = await request(config.server, method, apiPath, buildQuery(queryParams), { token: refreshed.accessToken });
|
|
41
|
+
} else {
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 确保令牌可用:临近或已过期时重新交换。返回带有效令牌的配置。
|
|
50
|
+
*/
|
|
51
|
+
async function ensureFreshToken(config) {
|
|
52
|
+
if (!config.accessToken || !config.tokenExpiresAt || config.tokenExpiresAt - Date.now() < TOKEN_REFRESH_AHEAD_MS) {
|
|
53
|
+
const response = await login(config.key, config.server);
|
|
54
|
+
return { ...config, accessToken: response.accessToken };
|
|
55
|
+
}
|
|
56
|
+
return config;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requireConfig() {
|
|
60
|
+
const config = readConfig();
|
|
61
|
+
if (!config || !config.key || !config.server) {
|
|
62
|
+
throw new Error('尚未配置。请先执行:scm-agent login --key qsk-xxxx [--server https://scm.qinsilk.com]');
|
|
63
|
+
}
|
|
64
|
+
return config;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildQuery(params) {
|
|
68
|
+
const clean = {};
|
|
69
|
+
for (const [k, v] of Object.entries(params)) {
|
|
70
|
+
if (v !== undefined && v !== null && v !== '') {
|
|
71
|
+
clean[k] = v;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return clean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 底层请求。服务端统一返回 { status, message, data }:
|
|
79
|
+
* status === 200 视为成功返回 data;否则抛出带 code 的 Error。
|
|
80
|
+
*/
|
|
81
|
+
async function request(server, method, apiPath, body, { token, auth = true } = {}) {
|
|
82
|
+
const url = new URL(apiPath, server);
|
|
83
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
84
|
+
if (token) {
|
|
85
|
+
headers.Authorization = `Bearer ${token}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let response;
|
|
89
|
+
if (method === 'GET') {
|
|
90
|
+
if (body && Object.keys(body).length) {
|
|
91
|
+
for (const [k, v] of Object.entries(body)) {
|
|
92
|
+
url.searchParams.set(k, String(v));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
response = await fetch(url, { headers });
|
|
96
|
+
} else {
|
|
97
|
+
response = await fetch(url, { method, headers, body: JSON.stringify(body) });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let payload;
|
|
101
|
+
try {
|
|
102
|
+
payload = await response.json();
|
|
103
|
+
} catch (e) {
|
|
104
|
+
throw new Error(`服务器响应异常(HTTP ${response.status}):${server}`);
|
|
105
|
+
}
|
|
106
|
+
if (payload.status === 200) {
|
|
107
|
+
return payload.data;
|
|
108
|
+
}
|
|
109
|
+
const error = new Error(ERROR_TEXT[payload.status] || payload.message || `请求失败(${payload.status})`);
|
|
110
|
+
error.code = payload.status;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { login, call, request, requireConfig, ERROR_TEXT };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// data 命令族:给 AI 一条受控取数入口,对应报表内部接口(数据集定义见 cli/datasets.js)。
|
|
2
|
+
// 三条固定限制在这里实施:强制条件不满足不发请求;日期跨度上限在本地校验;
|
|
3
|
+
// 分页数据集一次只取一页,末尾提示还有多少条(不自动翻页,翻页由调用方显式 --page)。
|
|
4
|
+
// 官方报表不受影响:reports/*.js 仍然直接调这些接口拉全量。
|
|
5
|
+
const { call } = require('../api');
|
|
6
|
+
const { printTable } = require('../table');
|
|
7
|
+
const {
|
|
8
|
+
DATASETS, findDataset, listDatasets, requireText, validateArgs, buildQuery, unknownOptions, ALL_OPTIONS, kebab,
|
|
9
|
+
} = require('../datasets');
|
|
10
|
+
|
|
11
|
+
const data = {
|
|
12
|
+
command: 'data [name]',
|
|
13
|
+
describe: '取报表数据(16 个数据集;scm-agent data --list 查看全部与必填条件)',
|
|
14
|
+
builder: (y) => {
|
|
15
|
+
y.positional('name', {
|
|
16
|
+
describe: '数据集名(见 scm-agent data --list)',
|
|
17
|
+
type: 'string',
|
|
18
|
+
});
|
|
19
|
+
y.option('list', {
|
|
20
|
+
describe: '列出全部数据集、必填条件与用途',
|
|
21
|
+
type: 'boolean',
|
|
22
|
+
});
|
|
23
|
+
// 参数并集来自数据集定义;单个数据集不接受的参数在 handler 里提示,
|
|
24
|
+
// 这样 --help 一处就能看到全部参数,而错误信息仍然具体。
|
|
25
|
+
for (const option of ALL_OPTIONS) {
|
|
26
|
+
y.option(option.name, { describe: option.describe, type: option.type });
|
|
27
|
+
}
|
|
28
|
+
return y;
|
|
29
|
+
},
|
|
30
|
+
handler: async (argv) => {
|
|
31
|
+
try {
|
|
32
|
+
await runData(argv);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error(`错误:${e.message}`);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async function runData(argv) {
|
|
41
|
+
if (argv.list || !argv.name) {
|
|
42
|
+
printDatasetList();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const dataset = findDataset(argv.name);
|
|
47
|
+
if (!dataset) {
|
|
48
|
+
throw new Error(`没有数据集「${argv.name}」。可用:${DATASETS.map((d) => d.name).join('、')}(scm-agent data --list 查看必填条件)`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const extra = unknownOptions(dataset, argv);
|
|
52
|
+
if (extra.length) {
|
|
53
|
+
throw new Error(`数据集 ${dataset.name} 不接受参数 ${extra.join('、')};它支持:${dataset.options.map((o) => '--' + kebab(o.name)).join('、') || '(无参数)'}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const invalid = validateArgs(dataset, argv);
|
|
57
|
+
if (invalid) {
|
|
58
|
+
throw new Error(invalid);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const query = buildQuery(dataset, argv);
|
|
62
|
+
const payload = await call('GET', dataset.path, query);
|
|
63
|
+
|
|
64
|
+
if (argv.json) {
|
|
65
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 分页数据集:{ page, limit, total, list }
|
|
70
|
+
if (dataset.paged) {
|
|
71
|
+
const rows = payload.list || [];
|
|
72
|
+
console.log(`共 ${payload.total} 条,当前显示 ${rows.length} 条`);
|
|
73
|
+
if (!rows.length) {
|
|
74
|
+
console.log('(无数据)');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
printTable(rows, dataset.columns);
|
|
78
|
+
const page = Number(argv.page) || 1;
|
|
79
|
+
const taken = (page - 1) * (Number(argv.limit) || 20) + rows.length;
|
|
80
|
+
if (payload.total > taken) {
|
|
81
|
+
console.log(`\n还有 ${payload.total - taken} 条未取;用 --page ${page + 1} 继续,或加 --limit 100 减少请求次数`);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 数组型单条接口(工序节点、物料占用):一次返回全部行,没有分页
|
|
87
|
+
if (Array.isArray(payload)) {
|
|
88
|
+
console.log(`共 ${payload.length} 条`);
|
|
89
|
+
if (!payload.length) {
|
|
90
|
+
console.log('(无数据)');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
printTable(payload, dataset.columns);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 单对象(BOM、对账 11 表、工序明细、公司配置):字段太多,直接输出 JSON
|
|
98
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** data --list:数据集名、必填条件、用途 */
|
|
102
|
+
function printDatasetList() {
|
|
103
|
+
console.log('数据集清单(字段说明:scm-agent docs api --find <字段名>):');
|
|
104
|
+
printTable(listDatasets(), [
|
|
105
|
+
{ field: 'name', label: '数据集' },
|
|
106
|
+
{ field: 'require', label: '必填条件' },
|
|
107
|
+
{ field: 'describe', label: '用途' },
|
|
108
|
+
]);
|
|
109
|
+
console.log('\n日期类数据集跨度上限 92 天;分页数据集一次只取一页,用 --page 翻页。');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { data, runData, printDatasetList, requireText };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// 文档命令族:把包内附带的说明文档路径与内容交给 AI。
|
|
2
|
+
// 文档随 npm 包发布,AI 的上下文里看不到文件系统,因此需要一条命令拿到绝对路径,
|
|
3
|
+
// 或者按关键词只取需要的那几行,避免把整份文档读进上下文。
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
/** 包内文档清单:命令里用的短名 → 文件名与用途 */
|
|
8
|
+
const DOCS = {
|
|
9
|
+
cli: { file: 'cli.md', describe: '全部命令、参数、错误码与写命令的确认方式' },
|
|
10
|
+
api: { file: 'api.md', describe: '接口返回字段字典:字段含义、取值枚举、单位、为空的原因' },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function docsRoot() {
|
|
14
|
+
return path.join(__dirname, '..', '..', 'docs');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function docPath(name) {
|
|
18
|
+
const entry = DOCS[name];
|
|
19
|
+
if (!entry) {
|
|
20
|
+
throw new Error(`没有文档「${name}」。可用:${Object.keys(DOCS).join('、')}`);
|
|
21
|
+
}
|
|
22
|
+
const filePath = path.join(docsRoot(), entry.file);
|
|
23
|
+
if (!fs.existsSync(filePath)) {
|
|
24
|
+
throw new Error(`文档文件不存在:${filePath}(安装可能不完整,执行 scm-agent update 重新安装)`);
|
|
25
|
+
}
|
|
26
|
+
return filePath;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 按关键词筛出文档里相关的行:匹配行本身,加上它上方最近的一级或二级标题,
|
|
31
|
+
* 让 AI 即使只拿到几行也能判断属于哪个接口、哪个层级。
|
|
32
|
+
*/
|
|
33
|
+
function findLines(lines, keyword) {
|
|
34
|
+
const lower = keyword.toLowerCase();
|
|
35
|
+
const hits = [];
|
|
36
|
+
let lastHeading = '';
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
if (line.startsWith('#')) {
|
|
39
|
+
lastHeading = line;
|
|
40
|
+
}
|
|
41
|
+
if (line.toLowerCase().includes(lower)) {
|
|
42
|
+
hits.push({ heading: lastHeading, line });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return hits;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const docs = {
|
|
49
|
+
command: 'docs [name]',
|
|
50
|
+
describe: '查看包内文档的位置与内容(命令说明、接口字段字典)',
|
|
51
|
+
builder: (yargs) => yargs
|
|
52
|
+
.positional('name', {
|
|
53
|
+
describe: `文档名:${Object.keys(DOCS).join(' 或 ')}(不填则列出全部文档)`,
|
|
54
|
+
type: 'string',
|
|
55
|
+
})
|
|
56
|
+
.option('find', {
|
|
57
|
+
describe: '只输出包含该关键词的行(用于查某个字段或命令,避免读入整份文档)',
|
|
58
|
+
type: 'string',
|
|
59
|
+
})
|
|
60
|
+
.option('print', {
|
|
61
|
+
describe: '输出整份文档内容',
|
|
62
|
+
type: 'boolean',
|
|
63
|
+
default: false,
|
|
64
|
+
})
|
|
65
|
+
.example('scm-agent docs', '列出全部文档与文件位置')
|
|
66
|
+
.example('scm-agent docs api --find cutBedState', '查单个字段的含义')
|
|
67
|
+
.example('scm-agent docs api --print', '输出完整字段字典'),
|
|
68
|
+
handler: (argv) => {
|
|
69
|
+
try {
|
|
70
|
+
if (!argv.name) {
|
|
71
|
+
console.log('包内文档(文件随 npm 包安装,路径为本机绝对路径):');
|
|
72
|
+
for (const [name, entry] of Object.entries(DOCS)) {
|
|
73
|
+
const filePath = path.join(docsRoot(), entry.file);
|
|
74
|
+
const exists = fs.existsSync(filePath);
|
|
75
|
+
console.log(` ${name.padEnd(4)} ${entry.describe}`);
|
|
76
|
+
console.log(` ${exists ? filePath : '(缺失,执行 scm-agent update 重新安装)'}`);
|
|
77
|
+
}
|
|
78
|
+
console.log('\n查某个字段或命令:scm-agent docs api --find <关键词>');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const filePath = docPath(argv.name);
|
|
83
|
+
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
|
84
|
+
|
|
85
|
+
if (argv.find) {
|
|
86
|
+
const hits = findLines(lines, argv.find);
|
|
87
|
+
if (!hits.length) {
|
|
88
|
+
console.log(`文档 ${argv.name} 里没有包含「${argv.find}」的内容。`);
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let lastHeading = null;
|
|
93
|
+
for (const hit of hits) {
|
|
94
|
+
// 命中的行本身就是标题时不再重复输出一次标题
|
|
95
|
+
const isHeading = hit.line.startsWith('#');
|
|
96
|
+
if (!isHeading && hit.heading && hit.heading !== lastHeading) {
|
|
97
|
+
console.log(`\n${hit.heading}`);
|
|
98
|
+
lastHeading = hit.heading;
|
|
99
|
+
}
|
|
100
|
+
console.log(hit.line);
|
|
101
|
+
}
|
|
102
|
+
console.log(`\n(以上为 ${path.basename(filePath)} 中匹配「${argv.find}」的内容,共 ${hits.length} 行)`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (argv.print) {
|
|
107
|
+
console.log(lines.join('\n'));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 默认只给路径:AI 的上下文有限,整份文档通常不需要一次读完
|
|
112
|
+
console.log(`${path.basename(filePath)}(${lines.length} 行):${filePath}`);
|
|
113
|
+
console.log('查单个字段:加 --find <关键词>;需要全文:加 --print');
|
|
114
|
+
} catch (e) {
|
|
115
|
+
console.error(`错误:${e.message}`);
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
module.exports = { docs, DOCS, findLines, docPath };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// 日志查询命令族:db-change 按关键词查数据库变动日志,api 按请求标识查接口日志。
|
|
2
|
+
// 典型用法是两步:先用 db-change 查到「谁在什么时候改了什么」,再用返回的
|
|
3
|
+
// requestId 查 api 得到「通过什么接口操作」。默认输出人类可读表格;--json 输出原始 JSON。
|
|
4
|
+
const { call } = require('../api');
|
|
5
|
+
const { printTable } = require('../table');
|
|
6
|
+
|
|
7
|
+
/** 毫秒时间戳转本地时间文本,取不到时显示 - */
|
|
8
|
+
function formatTime(ms) {
|
|
9
|
+
if (ms === null || ms === undefined) {
|
|
10
|
+
return '-';
|
|
11
|
+
}
|
|
12
|
+
const date = new Date(ms);
|
|
13
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
14
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
|
|
15
|
+
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** data 是对象时列出变动字段名,帮助不展开 JSON 就看出改了什么 */
|
|
19
|
+
function fieldSummary(data) {
|
|
20
|
+
if (data === null || data === undefined) {
|
|
21
|
+
return '-';
|
|
22
|
+
}
|
|
23
|
+
if (typeof data === 'object' && !Array.isArray(data)) {
|
|
24
|
+
return Object.keys(data).join(',');
|
|
25
|
+
}
|
|
26
|
+
return String(data);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const log = {
|
|
30
|
+
command: 'log',
|
|
31
|
+
describe: '查询数据变动日志与接口日志',
|
|
32
|
+
builder(yargs) {
|
|
33
|
+
return yargs
|
|
34
|
+
.command({
|
|
35
|
+
command: 'db-change',
|
|
36
|
+
describe: '数据库变动日志(按关键词/表名/操作人查,至少给一项条件,时间倒序)',
|
|
37
|
+
builder: (y) => y
|
|
38
|
+
.option('keywords', { describe: '关键词,逗号分隔(如物料货号 WL1002),只匹配变动数据,多个词都要命中', type: 'string' })
|
|
39
|
+
.option('table', { describe: '数据库表名(如 scm_material)', type: 'string' })
|
|
40
|
+
.option('uid', { describe: '操作人用户id(可先用 user-list 或上一条日志的 uid)', type: 'number' })
|
|
41
|
+
.option('request-id', { describe: '请求标识:查同一请求的全部变动', type: 'string' })
|
|
42
|
+
.option('from', { describe: '开始日期(yyyy-MM-dd),默认近 30 天', type: 'string' })
|
|
43
|
+
.option('to', { describe: '结束日期(yyyy-MM-dd)', type: 'string' })
|
|
44
|
+
.option('limit', { describe: '本批条数(默认 20,最大 100)', type: 'number' })
|
|
45
|
+
.option('line-num', { describe: '续查行号(来自上一次返回)', type: 'string' })
|
|
46
|
+
.option('scroll-id', { describe: '续查游标(来自上一次返回)', type: 'string' }),
|
|
47
|
+
handler: (argv) => runLog(argv, async () => {
|
|
48
|
+
if (!argv.keywords && !argv.table && !argv.uid && !argv.requestId) {
|
|
49
|
+
throw new Error('请至少给一项条件:--keywords、--table、--uid 或 --request-id');
|
|
50
|
+
}
|
|
51
|
+
const params = {
|
|
52
|
+
keywords: argv.keywords,
|
|
53
|
+
table: argv.table,
|
|
54
|
+
uid: argv.uid,
|
|
55
|
+
requestId: argv.requestId,
|
|
56
|
+
from: argv.from,
|
|
57
|
+
to: argv.to,
|
|
58
|
+
limit: argv.limit,
|
|
59
|
+
lineNum: argv.lineNum,
|
|
60
|
+
scrollId: argv.scrollId,
|
|
61
|
+
};
|
|
62
|
+
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
|
|
63
|
+
const data = await call('GET', '/api/agent/v1/log/db-change', params);
|
|
64
|
+
const rows = data.rows.map((row) => ({
|
|
65
|
+
time: formatTime(row.operationTime),
|
|
66
|
+
operator: row.userName || row.uid || '-',
|
|
67
|
+
operation: row.operationType,
|
|
68
|
+
table: row.table,
|
|
69
|
+
fields: fieldSummary(row.data),
|
|
70
|
+
requestId: row.requestId || '-',
|
|
71
|
+
_row: row,
|
|
72
|
+
}));
|
|
73
|
+
return {
|
|
74
|
+
rows,
|
|
75
|
+
data,
|
|
76
|
+
columns: [
|
|
77
|
+
{ field: 'time', label: '时间' },
|
|
78
|
+
{ field: 'operator', label: '操作人' },
|
|
79
|
+
{ field: 'operation', label: '操作' },
|
|
80
|
+
{ field: 'table', label: '表名' },
|
|
81
|
+
{ field: 'fields', label: '变动字段' },
|
|
82
|
+
{ field: 'requestId', label: '请求标识' },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
}),
|
|
86
|
+
})
|
|
87
|
+
.command({
|
|
88
|
+
command: 'api',
|
|
89
|
+
describe: '接口日志(按请求标识查同一请求的接口操作,标识来自 db-change 的返回)',
|
|
90
|
+
builder: (y) => y
|
|
91
|
+
.option('request-id', { describe: '请求标识(db-change 返回的 requestId 原值)', demandOption: true, type: 'string' }),
|
|
92
|
+
handler: (argv) => runLog(argv, async () => {
|
|
93
|
+
const data = await call('GET', '/api/agent/v1/log/api', { requestId: argv.requestId });
|
|
94
|
+
const rows = data.map((row) => ({
|
|
95
|
+
time: formatTime(row.time),
|
|
96
|
+
operator: row.operatorName || '-',
|
|
97
|
+
description: row.description || '-',
|
|
98
|
+
methodPath: row.methodPath || '-',
|
|
99
|
+
device: row.device || '-',
|
|
100
|
+
_row: row,
|
|
101
|
+
}));
|
|
102
|
+
return {
|
|
103
|
+
rows,
|
|
104
|
+
data,
|
|
105
|
+
columns: [
|
|
106
|
+
{ field: 'time', label: '时间' },
|
|
107
|
+
{ field: 'operator', label: '操作人' },
|
|
108
|
+
{ field: 'description', label: '操作内容' },
|
|
109
|
+
{ field: 'methodPath', label: '接口路径' },
|
|
110
|
+
{ field: 'device', label: '设备' },
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
}),
|
|
114
|
+
})
|
|
115
|
+
.demandCommand(1, '请指定子命令:db-change 或 api');
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** 输出表格与续查提示;--json 输出原始数据;异常输出可读文案并以非 0 退出 */
|
|
120
|
+
async function runLog(argv, task) {
|
|
121
|
+
try {
|
|
122
|
+
const { rows, data, columns } = await task();
|
|
123
|
+
if (argv.json) {
|
|
124
|
+
console.log(JSON.stringify(data, null, 2));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (rows.length === 0) {
|
|
128
|
+
console.log('(无数据)');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (data.hasMore) {
|
|
132
|
+
console.log(`本批 ${rows.length} 条,还有更多:加 --line-num ${JSON.stringify(data.lineNum)} --scroll-id ${JSON.stringify(data.scrollId)} 续查`);
|
|
133
|
+
} else {
|
|
134
|
+
console.log(`共 ${rows.length} 条`);
|
|
135
|
+
}
|
|
136
|
+
printTable(rows, columns);
|
|
137
|
+
rows.forEach((row) => {
|
|
138
|
+
if (row._row && row._row.methodArgs !== undefined && row._row.methodArgs !== null) {
|
|
139
|
+
const args = String(row._row.methodArgs);
|
|
140
|
+
console.log(`\n${row.time} ${row.description} 参数:${args.slice(0, 200)}${args.length > 200 ? '...(更多用 --json 查看)' : ''}`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
if (rows.some((row) => row._row && row._row.requestId)) {
|
|
144
|
+
console.log('\n变动数据的完整字段值用 --json 查看;要知道具体通过什么操作改的,用请求标识执行:scm-agent log api --request-id <请求标识>');
|
|
145
|
+
}
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.error(`错误:${e.message}`);
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { log, formatTime, fieldSummary };
|