yindie-erp-mcp 2.0.5 → 2.0.7

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/dist/client.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
+ import { parseJsonSafe } from './tool-helpers.js';
4
5
  /** ERP API 客户端 */
5
6
  export class YindieClient {
6
7
  /** 后端根地址 */
@@ -86,7 +87,7 @@ export class YindieClient {
86
87
  const response = await fetch(url, init);
87
88
  const contentType = response.headers.get('content-type') ?? '';
88
89
  if (contentType.includes('application/json')) {
89
- const result = (await response.json());
90
+ const result = (await parseJsonSafe(await response.text()));
90
91
  if (result.code === 401 && !retried) {
91
92
  this.token = await this.relogin();
92
93
  return this.download(method, path, fileName, options, true);
@@ -125,7 +126,7 @@ export class YindieClient {
125
126
  init.body = JSON.stringify(body);
126
127
  }
127
128
  const response = await fetch(url, init);
128
- const result = (await response.json());
129
+ const result = (await parseJsonSafe(await response.text()));
129
130
  if (result.code === 401 && !retried) {
130
131
  this.token = await this.relogin();
131
132
  return this.request(method, path, body, params, true);
@@ -151,7 +152,7 @@ export class YindieClient {
151
152
  };
152
153
  const form = await formFactory();
153
154
  const response = await fetch(url, { method: 'POST', headers, body: form });
154
- const result = (await response.json());
155
+ const result = (await parseJsonSafe(await response.text()));
155
156
  if (result.code === 401 && !retried) {
156
157
  this.token = await this.relogin();
157
158
  return this.requestForm(path, formFactory, true);
@@ -77,7 +77,10 @@ function buildHowTo(item) {
77
77
  }
78
78
  text += `可多选部位:${(item.options || []).join('/')};勾选后每项再选尺寸 ${(item.sizeOptions || []).join('/')}`;
79
79
  if (item.allowCustomSize) {
80
- text += '或手填';
80
+ text += '或手填尺寸';
81
+ }
82
+ if (item.allowAdd) {
83
+ text += ';自定义部位写入 customOptions[] 并同步进 checkbox/combineMap';
81
84
  }
82
85
  if (item.syncSizeOptions?.length) {
83
86
  text += `;${item.syncSizeOptions.join('/')} 尺寸联动`;
@@ -193,13 +196,13 @@ export const CRAFT_SELECTION_GUIDE = {
193
196
  radio: '单选 → {"radio":"选项"};howToSelect 提示可填备注时再带 note',
194
197
  checkbox: '多选 → {"checkbox":["a","b"]}',
195
198
  'checkbox+perItemSize': '多选+尺寸 → {"checkbox":["领"],"combineMap":{"领":"0.15"}}',
196
- 'checkbox+gateNone': '先有无再多选 → 无:{"radio":"无","checkbox":["无"]};有:{"radio":"有","checkbox":["领"],"combineMap":{"领":"0.15"}}',
199
+ 'checkbox+gateNone': '先有无再多选 → 无:{"radio":"无","checkbox":["无"]};有:{"radio":"有","checkbox":["领"],"combineMap":{"领":"0.15"}};allowAdd:true 自定义部位须同时写 customOptions[]+checkbox(+combineMap 尺寸),缺 checkbox 拒写(导出也不显示)',
197
200
  combine: '结合单选 → {"combineKey":"净脚口三折光压线","combineValue":"3"};needValue=false 可不带 combineValue',
198
201
  'combine+multiFill': '多填 → {"combineMap":{"28L*":"2","24L*":"1"}}',
199
202
  input: '输入 → {"input":"文案"};各模块末尾 xxNote 备注、人工填写项同此'
200
203
  },
201
204
  colorCardFields: COLOR_CARD_FIELDS,
202
- colorCardNote: '色卡选料写在 style_save colors[].colorCardJson(JSON 字符串,键=上面 fieldKey,值={materialCode,materialName,titleCn};pantsPocketCloth 列仅裤子前后袋工艺有值时可填)',
205
+ colorCardNote: '色卡选料写在 style_save colors[].colorCardJson(JSON 字符串,键=上面 fieldKey,值={materialCode,materialName,titleCn};pantsPocketCloth 列仅裤子前后袋工艺有值时可填)。color_card_export 只读 colorCardJson,无选料拒写;工艺 onCard 只影响前端可选列,不是导出数据源;备注走 color_card_save',
203
206
  jacket: flattenGroups('jacket', JACKET_STYLE_GROUPS),
204
207
  pants: flattenGroups('pants', PANTS_STYLE_GROUPS)
205
208
  };
@@ -19,6 +19,101 @@ function main() {
19
19
  if (normalized.garmentPart !== undefined || normalized.craft !== undefined) {
20
20
  throw new Error('兼容键应删除');
21
21
  }
22
+ let rejected = false;
23
+ try {
24
+ normalizeCraftSavePayload({
25
+ styleId: '1',
26
+ crafts: [{ garmentPart: 'jacket', craftModule: 'style', fieldKey: 'description', fieldValue: '{"radio":"单挂9扣瞎填"}' }]
27
+ });
28
+ }
29
+ catch {
30
+ rejected = true;
31
+ }
32
+ if (!rejected) {
33
+ throw new Error('非法选项应拒');
34
+ }
35
+ normalizeCraftSavePayload({
36
+ styleId: '1',
37
+ crafts: [
38
+ {
39
+ garmentPart: 'jacket',
40
+ craftModule: 'edge',
41
+ fieldKey: 'outerNeedle',
42
+ fieldValue: '{"radio":"有","checkbox":["领"],"combineMap":{"领":"0.15"}}'
43
+ }
44
+ ]
45
+ });
46
+ let badPress = false;
47
+ try {
48
+ normalizeCraftSavePayload({
49
+ styleId: '1',
50
+ crafts: [
51
+ {
52
+ garmentPart: 'jacket',
53
+ craftModule: 'edge',
54
+ fieldKey: 'outerPress',
55
+ fieldValue: '{"radio":"瞎填","checkbox":["瞎填部位"]}'
56
+ }
57
+ ]
58
+ });
59
+ }
60
+ catch {
61
+ badPress = true;
62
+ }
63
+ if (!badPress) {
64
+ throw new Error('outerPress 非法 gate 应拒');
65
+ }
66
+ let badPressBox = false;
67
+ try {
68
+ normalizeCraftSavePayload({
69
+ styleId: '1',
70
+ crafts: [
71
+ {
72
+ garmentPart: 'jacket',
73
+ craftModule: 'edge',
74
+ fieldKey: 'outerPress',
75
+ fieldValue: '{"radio":"有","checkbox":["瞎填部位A"]}'
76
+ }
77
+ ]
78
+ });
79
+ }
80
+ catch {
81
+ badPressBox = true;
82
+ }
83
+ if (!badPressBox) {
84
+ throw new Error('outerPress 非法 checkbox 应拒');
85
+ }
86
+ normalizeCraftSavePayload({
87
+ styleId: '1',
88
+ crafts: [
89
+ {
90
+ garmentPart: 'jacket',
91
+ craftModule: 'edge',
92
+ fieldKey: 'outerPress',
93
+ fieldValue: '{"radio":"有","checkbox":["自定义部位"],"customOptions":["自定义部位"],"combineMap":{"自定义部位":"0.15"}}'
94
+ }
95
+ ]
96
+ });
97
+ let customOnly = false;
98
+ try {
99
+ normalizeCraftSavePayload({
100
+ styleId: '1',
101
+ crafts: [
102
+ {
103
+ garmentPart: 'jacket',
104
+ craftModule: 'edge',
105
+ fieldKey: 'outerPress',
106
+ fieldValue: '{"radio":"有","checkbox":["领"],"customOptions":["前门襟"],"combineMap":{"领":"0.15"}}'
107
+ }
108
+ ]
109
+ });
110
+ }
111
+ catch {
112
+ customOnly = true;
113
+ }
114
+ if (!customOnly) {
115
+ throw new Error('customOptions 未进 checkbox 应拒');
116
+ }
22
117
  console.log('normalizeCraftSavePayload self-check ok');
23
118
  }
24
119
  main();
@@ -0,0 +1,118 @@
1
+ import { JACKET_STYLE_GROUPS, PANTS_STYLE_GROUPS } from './template.js';
2
+ /** 字段索引 key=garmentPart.fieldKey */
3
+ const FIELD_INDEX = buildIndex();
4
+ /** 构建字段索引 */
5
+ function buildIndex() {
6
+ const map = new Map();
7
+ indexPart(map, 'jacket', JACKET_STYLE_GROUPS);
8
+ indexPart(map, 'pants', PANTS_STYLE_GROUPS);
9
+ return map;
10
+ }
11
+ /** 索引一个部位 */
12
+ function indexPart(map, part, groups) {
13
+ for (const group of groups) {
14
+ for (const item of group.items) {
15
+ map.set(`${part}.${item.key}`, item);
16
+ }
17
+ }
18
+ }
19
+ /** 校验 crafts 选项值(按 control / gateNone) */
20
+ export function assertCraftOptionValues(crafts) {
21
+ if (!Array.isArray(crafts)) {
22
+ return;
23
+ }
24
+ crafts.forEach((row, index) => {
25
+ if (!row || typeof row !== 'object' || Array.isArray(row)) {
26
+ return;
27
+ }
28
+ const craft = row;
29
+ const part = craft.garmentPart;
30
+ const fieldKey = craft.fieldKey;
31
+ const fieldValue = craft.fieldValue;
32
+ if (typeof part !== 'string' || typeof fieldKey !== 'string' || typeof fieldValue !== 'string') {
33
+ return;
34
+ }
35
+ const def = FIELD_INDEX.get(`${part}.${fieldKey}`);
36
+ if (!def || def.control === 'input' || !fieldValue.startsWith('{')) {
37
+ return;
38
+ }
39
+ const json = JSON.parse(fieldValue);
40
+ const prefix = `crafts[${index}] ${part}.${fieldKey}`;
41
+ const custom = new Set(json.customOptions || []);
42
+ const optionSet = new Set([...(def.options || []), ...custom]);
43
+ const combineSet = new Set((def.combineOptions || []).map((o) => o.label));
44
+ for (const label of custom) {
45
+ combineSet.add(label);
46
+ }
47
+ const noneLabel = def.noneLabel || '无';
48
+ if (def.control === 'radio') {
49
+ if (json.radio != null && json.radio !== '') {
50
+ assertIn(prefix, 'radio', json.radio, optionSet);
51
+ }
52
+ return;
53
+ }
54
+ if (def.control === 'combine') {
55
+ if (json.combineKey != null && json.combineKey !== '') {
56
+ assertIn(prefix, 'combineKey', json.combineKey, combineSet);
57
+ }
58
+ if (json.combineMap) {
59
+ for (const key of Object.keys(json.combineMap)) {
60
+ assertIn(prefix, 'combineMap', key, combineSet);
61
+ }
62
+ }
63
+ return;
64
+ }
65
+ if (def.control === 'checkbox') {
66
+ if (def.gateNone) {
67
+ const gateSet = new Set(['有', noneLabel]);
68
+ if (json.radio != null && json.radio !== '') {
69
+ assertIn(prefix, 'radio', json.radio, gateSet);
70
+ }
71
+ const bodyAllowed = new Set(optionSet);
72
+ bodyAllowed.add(noneLabel);
73
+ if (Array.isArray(json.checkbox)) {
74
+ for (const value of json.checkbox) {
75
+ assertIn(prefix, 'checkbox', value, bodyAllowed);
76
+ }
77
+ }
78
+ if (json.combineMap) {
79
+ for (const key of Object.keys(json.combineMap)) {
80
+ assertIn(prefix, 'combineMap', key, bodyAllowed);
81
+ }
82
+ }
83
+ assertCustomInCheckbox(prefix, json.customOptions, json.checkbox);
84
+ return;
85
+ }
86
+ if (Array.isArray(json.checkbox)) {
87
+ for (const value of json.checkbox) {
88
+ assertIn(prefix, 'checkbox', value, optionSet);
89
+ }
90
+ }
91
+ if (json.combineMap) {
92
+ for (const key of Object.keys(json.combineMap)) {
93
+ assertIn(prefix, 'combineMap', key, optionSet);
94
+ }
95
+ }
96
+ assertCustomInCheckbox(prefix, json.customOptions, json.checkbox);
97
+ }
98
+ });
99
+ }
100
+ /** customOptions 须同步进 checkbox,否则导出不显示 */
101
+ function assertCustomInCheckbox(prefix, customOptions, checkbox) {
102
+ if (!Array.isArray(customOptions) || customOptions.length === 0) {
103
+ return;
104
+ }
105
+ const boxes = new Set(Array.isArray(checkbox) ? checkbox : []);
106
+ for (const opt of customOptions) {
107
+ if (!boxes.has(opt)) {
108
+ throw new Error(`${prefix} customOptions 中的「${opt}」须同时写入 checkbox,否则导出不显示`);
109
+ }
110
+ }
111
+ }
112
+ /** 断言取值在白名单(allowAdd 自定义项须写入 customOptions 后才进白名单) */
113
+ function assertIn(prefix, kind, value, allowed) {
114
+ if (allowed.has(value)) {
115
+ return;
116
+ }
117
+ throw new Error(`${prefix} 取值非法:${value},可选:${[...allowed].join('/')}`);
118
+ }
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ async function main() {
21
21
  const moduleActions = legacy ? undefined : buildModuleActions(permissions);
22
22
  const server = new McpServer({
23
23
  name: 'yindie-erp',
24
- version: '2.0.1'
24
+ version: '2.0.17'
25
25
  }, {
26
26
  instructions: ERP_MCP_INSTRUCTIONS
27
27
  });
@@ -1,21 +1,143 @@
1
1
  import { z } from 'zod';
2
+ import { assertCraftOptionValues } from './craft/validate-options.js';
2
3
  /** JSON 请求体字段 */
3
4
  export const payloadField = z.string().describe('JSON 请求体,字段与后端 BO 一致');
4
5
  /** 主键字段 */
5
6
  export const idsField = z.string().describe('主键 ID,多个用英文逗号分隔');
6
7
  /** 单个主键字段,字符串避免雪花 ID 精度丢失 */
7
8
  export const idField = z.string().describe('主键 ID');
9
+ /** 样衣类型白名单 */
10
+ export const SAMPLE_TYPES = new Set(['proto', 'pre_prod', 'bulk', 'mass', 'sales', 'photo']);
11
+ /** ≥16 位数字字面量改成字符串,避免雪花 ID 精度丢失 */
12
+ export function preserveSnowflakeIds(text) {
13
+ return text.replace(/([:\[,]\s*)(\d{16,})(\s*[,\]}])/g, '$1"$2"$3');
14
+ }
15
+ /** 解析保真后的 JSON */
16
+ export function parseJsonSafe(text) {
17
+ return JSON.parse(preserveSnowflakeIds(text));
18
+ }
19
+ /** 校验对象内 id 类字段不为 number */
20
+ function assertNoNumberIds(value, path = '') {
21
+ if (value == null || typeof value !== 'object') {
22
+ return;
23
+ }
24
+ if (Array.isArray(value)) {
25
+ value.forEach((item, i) => assertNoNumberIds(item, `${path}[${i}]`));
26
+ return;
27
+ }
28
+ for (const [key, child] of Object.entries(value)) {
29
+ const here = path ? `${path}.${key}` : key;
30
+ if (typeof child === 'number' && (key === 'id' || key === 'ids' || /Id$/.test(key))) {
31
+ throw new Error(`雪花 ID 必须字符串:${here}=${child}(勿用 JSON number)`);
32
+ }
33
+ assertNoNumberIds(child, here);
34
+ }
35
+ }
8
36
  /** 解析 JSON 请求体 */
9
37
  export function parsePayload(args) {
10
38
  const raw = args.payload;
11
39
  if (typeof raw === 'string') {
12
- return JSON.parse(raw);
40
+ const parsed = parseJsonSafe(raw);
41
+ assertNoNumberIds(parsed);
42
+ return parsed;
13
43
  }
14
44
  if (raw && typeof raw === 'object') {
45
+ assertNoNumberIds(raw);
15
46
  return raw;
16
47
  }
17
48
  throw new Error('payload 必须是 JSON 字符串');
18
49
  }
50
+ /** 解析 get 主键:顶层 id|orderId → payload.id|orderId */
51
+ export function resolveGetId(args) {
52
+ const top = args.id ?? args.orderId;
53
+ if (top != null && top !== '') {
54
+ return String(top);
55
+ }
56
+ if (args.payload == null) {
57
+ return undefined;
58
+ }
59
+ const payload = parsePayload(args);
60
+ const id = payload.id ?? payload.orderId;
61
+ if (id == null || id === '') {
62
+ return undefined;
63
+ }
64
+ return String(id);
65
+ }
66
+ /** 解析逗号分隔或数组 ID 列表 */
67
+ export function asIdList(value) {
68
+ if (value == null || value === '') {
69
+ return undefined;
70
+ }
71
+ const raw = Array.isArray(value) ? value : String(value).split(',');
72
+ const ids = raw.map((item) => String(item).trim()).filter((item) => item !== '');
73
+ return ids.length > 0 ? ids : undefined;
74
+ }
75
+ /** 按逗号字段白名单裁剪对象/数组 */
76
+ export function pickFields(data, fields) {
77
+ if (fields == null || fields === '') {
78
+ return data;
79
+ }
80
+ const keys = fields.split(',').map((s) => s.trim()).filter((s) => s !== '');
81
+ if (keys.length === 0) {
82
+ return data;
83
+ }
84
+ if (Array.isArray(data)) {
85
+ return data.map((item) => pickFields(item, fields));
86
+ }
87
+ if (data == null || typeof data !== 'object') {
88
+ return data;
89
+ }
90
+ const src = data;
91
+ const out = {};
92
+ for (const key of keys) {
93
+ if (key in src) {
94
+ out[key] = src[key];
95
+ }
96
+ }
97
+ return out;
98
+ }
99
+ /** 解析 include 列表 */
100
+ export function parseInclude(value) {
101
+ if (value == null || value === '') {
102
+ return new Set();
103
+ }
104
+ const raw = Array.isArray(value) ? value : String(value).split(',');
105
+ return new Set(raw.map((item) => String(item).trim()).filter((item) => item !== ''));
106
+ }
107
+ /** 规整 style_save:自动包 styles,禁止空列表 */
108
+ export function normalizeStyleSavePayload(raw) {
109
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
110
+ throw new Error('style_save payload 须为对象');
111
+ }
112
+ const input = raw;
113
+ let styles = input.styles;
114
+ if (!Array.isArray(styles)) {
115
+ if (input.styleNo != null || input.colors != null || input.styleName != null) {
116
+ const { orderId, ...rest } = input;
117
+ return { orderId, styles: [rest] };
118
+ }
119
+ throw new Error('style_save 须 payload.styles:[{...}],勿把款式字段放顶层');
120
+ }
121
+ if (styles.length === 0) {
122
+ throw new Error('style_save 的 styles 不能为空数组');
123
+ }
124
+ return { ...input, styles };
125
+ }
126
+ /** 规整样衣写入:类型白名单 + sampleNo→roundName */
127
+ export function normalizeSamplePayload(raw) {
128
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
129
+ throw new Error('样衣 payload 须为对象');
130
+ }
131
+ const payload = { ...raw };
132
+ if (payload.sampleType != null && !SAMPLE_TYPES.has(String(payload.sampleType))) {
133
+ throw new Error(`sampleType 仅允许 proto/pre_prod/bulk/mass/sales/photo,收到:${payload.sampleType}`);
134
+ }
135
+ if (payload.roundName == null && payload.sampleNo != null) {
136
+ payload.roundName = payload.sampleNo;
137
+ delete payload.sampleNo;
138
+ }
139
+ return payload;
140
+ }
19
141
  /** 规整工艺保存入参 */
20
142
  export function normalizeCraftSavePayload(raw) {
21
143
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
@@ -46,6 +168,7 @@ export function normalizeCraftSavePayload(raw) {
46
168
  if (topPart !== undefined) {
47
169
  delete payload.garmentPart;
48
170
  }
171
+ assertCraftOptionValues(payload.crafts);
49
172
  return payload;
50
173
  }
51
174
  /** 解析删除主键 */