dsh-d365-kit 0.34.1 → 0.34.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +319 -308
  2. package/CHANGELOG.zh-CN.md +318 -307
  3. package/INSTALL.md +94 -94
  4. package/INSTALL.zh-CN.md +93 -93
  5. package/LICENSE +21 -21
  6. package/README.md +336 -327
  7. package/README.zh-CN.md +336 -327
  8. package/client.js +1818 -1818
  9. package/cordis.patch.yml +44 -44
  10. package/docs/en/architecture.md +172 -172
  11. package/docs/en/checks.md +184 -184
  12. package/docs/en/installation.md +151 -151
  13. package/docs/en/permissions.md +161 -161
  14. package/docs/en/scenarios.md +202 -202
  15. package/docs/en/tools.md +665 -665
  16. package/docs/en/troubleshooting.md +131 -131
  17. package/docs/zh/architecture.md +172 -172
  18. package/docs/zh/checks.md +184 -184
  19. package/docs/zh/installation.md +151 -151
  20. package/docs/zh/permissions.md +161 -161
  21. package/docs/zh/scenarios.md +202 -202
  22. package/docs/zh/tools.md +665 -665
  23. package/docs/zh/troubleshooting.md +131 -131
  24. package/examples/prompts.en.md +206 -206
  25. package/examples/prompts.zh-CN.md +206 -206
  26. package/examples/waivers.example.json +20 -20
  27. package/icon.svg +6 -6
  28. package/index.js +139 -139
  29. package/lib/auth.js +181 -181
  30. package/lib/client.js +496 -496
  31. package/lib/code-check.js +549 -549
  32. package/lib/config.js +323 -323
  33. package/lib/contract-history.js +49 -49
  34. package/lib/contract.js +583 -583
  35. package/lib/csharp-check.js +493 -493
  36. package/lib/data-migrate.js +1318 -1318
  37. package/lib/data-policy.js +78 -78
  38. package/lib/error-codes.js +80 -80
  39. package/lib/gitlab.js +161 -161
  40. package/lib/health.js +205 -205
  41. package/lib/migrate.js +732 -732
  42. package/lib/preflight.js +116 -116
  43. package/lib/refs.js +191 -191
  44. package/lib/resources.js +320 -320
  45. package/lib/retention-defaults.js +17 -17
  46. package/lib/retention.js +384 -384
  47. package/lib/runtime.js +232 -232
  48. package/lib/settings-http.js +512 -512
  49. package/lib/settings-store.js +371 -371
  50. package/lib/snapshot.js +149 -149
  51. package/lib/tool-code-check.js +133 -133
  52. package/lib/tool-configure.js +169 -169
  53. package/lib/tool-contract.js +376 -376
  54. package/lib/tool-data-sync.js +559 -559
  55. package/lib/tool-data-write.js +203 -203
  56. package/lib/tool-data.js +282 -282
  57. package/lib/tool-diff.js +484 -484
  58. package/lib/tool-environments.js +106 -106
  59. package/lib/tool-flows.js +540 -540
  60. package/lib/tool-health.js +127 -127
  61. package/lib/tool-layers.js +497 -497
  62. package/lib/tool-metadata.js +417 -417
  63. package/lib/tool-migrate.js +102 -102
  64. package/lib/tool-refs.js +206 -206
  65. package/lib/tool-reports.js +299 -299
  66. package/lib/tool-sync.js +430 -430
  67. package/lib/tool-write.js +563 -563
  68. package/lib/waivers.js +83 -83
  69. package/lib/write-actions.js +1374 -1374
  70. package/lib/write-policy.js +199 -199
  71. package/lib/write-store.js +167 -167
  72. package/locale/en.json +68 -68
  73. package/locale/zh.json +68 -68
  74. package/package.json +2 -2
  75. package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/tool-data.js CHANGED
@@ -1,282 +1,282 @@
1
- /**
2
- * `d365_data` — the data half of "who uses this".
3
- *
4
- * Metadata references say a field is on a form; they do not say whether 12 or
5
- * 120000 records carry a value in it. Before deleting a field or an option
6
- * value, the count is the part that decides. Every read here goes through the
7
- * normal client, so it is refused unless the environment grants `data.read`.
8
- *
9
- * Aggregation uses OData `$apply`, which Dataverse supports on ordinary entity
10
- * sets; when a server rejects it the tool falls back to paging and counting.
11
- */
12
- import { loadConfig } from './config.js'
13
- import { formatTable, makeClient, pickEnvironments, writeArtifact } from './runtime.js'
14
- import { getPagedWithSelectFallback } from './client.js'
15
- import { D365Error } from './errors.js'
16
- import { odataString } from './resources.js'
17
-
18
- const ACTIONS = ['count', 'group', 'sample', 'query']
19
-
20
- export const definition = {
21
- name: 'd365_data',
22
- description:
23
- 'Read business records: count them, group them by a column, sample a few rows, or run one query with your own columns / sort / expand / id. '
24
- + 'Use it to judge impact before changing metadata, or to fetch the exact rows a later write needs. '
25
- + 'Requires the environment to grant "读取业务数据" (data.read); metadata reads have their own "读取元数据" switch, on by default.',
26
- parameters: {
27
- type: 'object',
28
- additionalProperties: false,
29
- required: ['action', 'entity'],
30
- properties: {
31
- action: {
32
- type: 'string',
33
- enum: ACTIONS,
34
- description: 'count:总行数;group:按某列分组计数;sample:取几行看样例;query:按 select / orderby / expand / id 取具体行。',
35
- },
36
- entity: {
37
- type: 'string',
38
- description: '实体逻辑名(如 account)或集合名(如 accounts)。',
39
- },
40
- filter: {
41
- type: 'string',
42
- description: "OData $filter,不带 '$filter=',例如 \"statecode eq 0\"。",
43
- },
44
- field: {
45
- type: 'string',
46
- description: 'action=group 时必填:用来分组的列逻辑名,例如 contoso_taxcategory。',
47
- },
48
- select: {
49
- type: 'string',
50
- description: 'action=sample/query 时要返回的列,逗号分隔;sample 省略则取前 5 列,query 省略则取返回行实际带的列。',
51
- },
52
- orderby: {
53
- type: 'string',
54
- description: "action=query 的排序,例如 \"modifiedon desc\"。",
55
- },
56
- expand: {
57
- type: 'string',
58
- description: 'action=query 的 $expand,例如 "primarycontactid($select=fullname)"。',
59
- },
60
- id: {
61
- type: 'string',
62
- description: 'action=query:按主键取一条(GUID,或带引号的备用键值如 \'contoso_001\')。',
63
- },
64
- top: {
65
- type: 'integer',
66
- description: 'action=sample 返回行数(默认 5,最多 100);action=group 返回前 N 组(默认 20);action=query 返回行数(默认 25,最多 500)。',
67
- },
68
- environment: {
69
- type: 'string',
70
- description: '环境 key,省略则用默认环境。',
71
- },
72
- environments: {
73
- type: 'array',
74
- items: { type: 'string' },
75
- description: '一次查多个环境。',
76
- },
77
- allEnvironments: {
78
- type: 'boolean',
79
- description: '对全部已配置环境执行同一查询。',
80
- },
81
- },
82
- },
83
- output: {
84
- schema: {
85
- type: 'object',
86
- additionalProperties: false,
87
- required: ['text'],
88
- properties: { text: { type: 'string' } },
89
- },
90
- render(_args, value) {
91
- return [{ type: 'text', text: value?.text ?? '' }]
92
- },
93
- },
94
- }
95
-
96
- /**
97
- * Resolve the entity set name.
98
- *
99
- * EntitySetName is customisable, so guessing `${entity}s` is only a fallback:
100
- * the metadata read is authoritative and cheap.
101
- * @returns the collection path segment.
102
- */
103
- export async function resolveEntitySet(client, entity, signal) {
104
- const candidate = String(entity).trim()
105
- if (!candidate) throw new D365Error('entity 不能为空', { code: 'missing-entity' })
106
- // A logical name cannot contain a quote; anything that does is either a typo
107
- // or an attempt to widen the filter, and either way must not be interpolated.
108
- if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(candidate)) {
109
- throw new D365Error(`不是合法的实体名:${candidate}`, {
110
- code: 'bad-entity',
111
- hint: '实体逻辑名或集合名只包含字母、数字与下划线,例如 contoso_systemconfiguration。',
112
- })
113
- }
114
- // Keyed metadata paths reject `$top`, so ask the collection form instead —
115
- // the same shape `d365_metadata` already uses successfully.
116
- try {
117
- const result = await getPagedWithSelectFallback(client, 'EntityDefinitions', {
118
- $select: 'LogicalName,EntitySetName,PrimaryIdAttribute',
119
- $filter: `LogicalName eq '${odataString(candidate)}'`,
120
- }, 2, signal)
121
- const row = result?.page?.records?.[0]
122
- if (row?.EntitySetName) {
123
- return { set: row.EntitySetName, logical: row.LogicalName ?? candidate, idField: row.PrimaryIdAttribute }
124
- }
125
- } catch (error) {
126
- // Only a rejected query means "no such entity"; auth failures, timeouts
127
- // and the like must surface as themselves, not as a misleading 404.
128
- if (error?.status !== 400 && error?.status !== 404 && error?.code !== 'unknown-entity') throw error
129
- }
130
- if (!/s$/.test(candidate)) {
131
- throw new D365Error(`实体不存在:${candidate}`, {
132
- code: 'unknown-entity',
133
- hint: '用实体逻辑名(如 contoso_systemconfiguration)或集合名(如 contoso_systemconfigurations)。',
134
- })
135
- }
136
- return { set: candidate, logical: candidate }
137
- }
138
-
139
- /** Count rows, using `$apply` first and `$count=true` as a fallback. */
140
- async function countRows(client, set, filter, signal) {
141
- const params = filter ? { $filter: filter } : {}
142
- try {
143
- const page = await client.getPaged(set, { ...params, $apply: 'aggregate($count as count)' }, 5, signal)
144
- const value = page.records?.[0]?.count
145
- if (typeof value === 'number') return value
146
- } catch (error) {
147
- // A permission refusal is not "this set rejects $apply" — surface it.
148
- if (error?.code === 'data-read-forbidden') throw error
149
- }
150
- try {
151
- const page = await client.requestJson(
152
- client.buildUrl(set, { ...params, $count: 'true', $top: '1' }),
153
- { signal },
154
- )
155
- if (typeof page?.['@odata.count'] === 'number') return page['@odata.count']
156
- } catch (error) {
157
- if (error?.code === 'data-read-forbidden') throw error
158
- }
159
- throw new D365Error(`无法统计 ${set} 的行数`, {
160
- code: 'count-unavailable',
161
- hint: '该表可能不支持聚合查询;用 filter 缩小范围后重试,或改用 action=sample。',
162
- })
163
- }
164
-
165
- export function register(ctx, state) {
166
- return ctx.tools.register({
167
- ...definition,
168
- async execute(args, exec) {
169
- const config = await loadConfig(state.rawConfig)
170
- const action = args.action
171
- if (!ACTIONS.includes(action)) {
172
- throw new D365Error(`不认识的操作:${action}`, { code: 'unknown-action', hint: `可选:${ACTIONS.join(' / ')}` })
173
- }
174
- if (action === 'group' && !args.field) {
175
- throw new D365Error('action=group 需要 field', { code: 'missing-field' })
176
- }
177
-
178
- const envs = pickEnvironments(config, args)
179
- const reports = []
180
- const artifact = { action, entity: args.entity, filter: args.filter, environments: {} }
181
-
182
- for (const env of envs) {
183
- const client = makeClient(env, config)
184
- const { set, logical } = await resolveEntitySet(client, args.entity, exec?.signal)
185
- const filter = args.filter
186
-
187
- if (action === 'count') {
188
- const count = await countRows(client, set, filter, exec?.signal)
189
- artifact.environments[env.key] = { entitySet: set, count }
190
- reports.push(`${env.key}:${logical} 共 ${count} 行${filter ? `(筛选:${filter})` : ''}`)
191
- continue
192
- }
193
-
194
- if (action === 'group') {
195
- const apply = `groupby((${args.field}),aggregate($count as count))`
196
- const page = await client.getPaged(set, {
197
- ...(filter ? { $filter: filter } : {}),
198
- $apply: apply,
199
- }, Number.isFinite(args.top) ? Math.max(1, Math.min(500, args.top)) : 20, exec?.signal)
200
- const rows = page.records ?? []
201
- const display = rows.map((row) => ({ ...row, [args.field]: row[args.field] ?? '(空)' }))
202
- const total = rows.reduce((sum, row) => sum + (row.count ?? 0), 0)
203
- artifact.environments[env.key] = { entitySet: set, field: args.field, groups: rows }
204
- const table = formatTable(display, [args.field, 'count'], { width: 46 })
205
- reports.push(`${env.key}:按 ${args.field} 分组,前 ${rows.length} 组合计 ${total} 行\n${table}`)
206
- continue
207
- }
208
-
209
- if (action === 'query') {
210
- const top = Number.isFinite(args.top) ? Math.max(1, Math.min(500, args.top)) : 25
211
- const columns = args.select
212
- ? args.select.split(',').map((item) => item.trim()).filter(Boolean)
213
- : null
214
- if (args.id !== undefined && args.id !== null && String(args.id).trim()) {
215
- const key = String(args.id).trim()
216
- const bare = key.replace(/^'|'$/g, '')
217
- if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(bare) && !/^'.*'$/.test(key)) {
218
- throw new D365Error(`id 既不是 GUID 也不是带引号的备用键:${key}`, {
219
- code: 'bad-id',
220
- hint: "主键传 GUID;备用键要带单引号,例如 \"contoso_001\" 写成 \"'contoso_001'\"。",
221
- })
222
- }
223
- const params = {}
224
- if (args.select) params.$select = args.select
225
- if (args.expand) params.$expand = args.expand
226
- let row
227
- try {
228
- row = await client.requestJson(client.buildUrl(`${set}(${key})`, params), { signal: exec?.signal })
229
- } catch (error) {
230
- if (error?.status === 404) {
231
- throw new D365Error(`${env.key} 里没有 ${logical}(${key}) 这条记录`, {
232
- code: 'record-not-found',
233
- hint: '确认 id 属于这个环境;跨环境复制过来的 GUID 在目标不存在时也会是 404。',
234
- })
235
- }
236
- throw error
237
- }
238
- if (!row || typeof row !== 'object') {
239
- throw new D365Error(`${env.key} 没有返回 ${logical}(${key})`, { code: 'record-not-found' })
240
- }
241
- artifact.environments[env.key] = { entitySet: set, id: key, rows: [row] }
242
- const shown = columns ?? Object.keys(row).filter((name) => !name.startsWith('@')).slice(0, 8)
243
- reports.push(`${env.key}:${logical}(${key})\n${formatTable([row], shown, { width: 40 })}`)
244
- continue
245
- }
246
- // Do not send `$top` here: `getPaged` asks for one row more than the
247
- // cap so a full page is distinguishable from a complete one.
248
- const params = {}
249
- if (filter) params.$filter = filter
250
- if (args.select) params.$select = args.select
251
- if (args.orderby) params.$orderby = args.orderby
252
- if (args.expand) params.$expand = args.expand
253
- const page = await client.getPaged(set, params, top, exec?.signal)
254
- const rows = page.records ?? []
255
- const total = page.hasMore ? await countRows(client, set, filter, exec?.signal).catch(() => undefined) : rows.length
256
- const shown = columns ?? Object.keys(rows[0] ?? {}).filter((name) => !name.startsWith('@')).slice(0, 8)
257
- artifact.environments[env.key] = { entitySet: set, params, total, rows }
258
- const head = total === undefined
259
- ? `${env.key}:${logical} 返回 ${rows.length} 行(可能还有更多)`
260
- : total === rows.length
261
- ? `${env.key}:${logical} 共 ${total} 行`
262
- : `${env.key}:已列 ${rows.length} 条 / 共 ${total} 条(未列全,加 filter 或调 top)`
263
- reports.push(`${head}\n${formatTable(rows, shown, { width: 40 })}`)
264
- continue
265
- }
266
-
267
- const top = Number.isFinite(args.top) ? Math.max(1, Math.min(100, args.top)) : 5
268
- const params = { $top: top }
269
- if (filter) params.$filter = filter
270
- if (args.select) params.$select = args.select
271
- const page = await client.getPaged(set, params, top, exec?.signal)
272
- const rows = page.records ?? []
273
- const columns = args.select ? args.select.split(',').map((item) => item.trim()) : Object.keys(rows[0] ?? {}).slice(0, 5)
274
- artifact.environments[env.key] = { entitySet: set, rows }
275
- reports.push(`${env.key}:${logical} 样例 ${rows.length} 行\n${formatTable(rows, columns, { width: 32 })}`)
276
- }
277
-
278
- const file = await writeArtifact(config, `data-${action}`, artifact)
279
- return { text: `${reports.join('\n\n')}\n\n完整结果:${file}` }
280
- },
281
- })
282
- }
1
+ /**
2
+ * `d365_data` — the data half of "who uses this".
3
+ *
4
+ * Metadata references say a field is on a form; they do not say whether 12 or
5
+ * 120000 records carry a value in it. Before deleting a field or an option
6
+ * value, the count is the part that decides. Every read here goes through the
7
+ * normal client, so it is refused unless the environment grants `data.read`.
8
+ *
9
+ * Aggregation uses OData `$apply`, which Dataverse supports on ordinary entity
10
+ * sets; when a server rejects it the tool falls back to paging and counting.
11
+ */
12
+ import { loadConfig } from './config.js'
13
+ import { formatTable, makeClient, pickEnvironments, writeArtifact } from './runtime.js'
14
+ import { getPagedWithSelectFallback } from './client.js'
15
+ import { D365Error } from './errors.js'
16
+ import { odataString } from './resources.js'
17
+
18
+ const ACTIONS = ['count', 'group', 'sample', 'query']
19
+
20
+ export const definition = {
21
+ name: 'd365_data',
22
+ description:
23
+ 'Read business records: count them, group them by a column, sample a few rows, or run one query with your own columns / sort / expand / id. '
24
+ + 'Use it to judge impact before changing metadata, or to fetch the exact rows a later write needs. '
25
+ + 'Requires the environment to grant "读取业务数据" (data.read); metadata reads have their own "读取元数据" switch, on by default.',
26
+ parameters: {
27
+ type: 'object',
28
+ additionalProperties: false,
29
+ required: ['action', 'entity'],
30
+ properties: {
31
+ action: {
32
+ type: 'string',
33
+ enum: ACTIONS,
34
+ description: 'count:总行数;group:按某列分组计数;sample:取几行看样例;query:按 select / orderby / expand / id 取具体行。',
35
+ },
36
+ entity: {
37
+ type: 'string',
38
+ description: '实体逻辑名(如 account)或集合名(如 accounts)。',
39
+ },
40
+ filter: {
41
+ type: 'string',
42
+ description: "OData $filter,不带 '$filter=',例如 \"statecode eq 0\"。",
43
+ },
44
+ field: {
45
+ type: 'string',
46
+ description: 'action=group 时必填:用来分组的列逻辑名,例如 contoso_taxcategory。',
47
+ },
48
+ select: {
49
+ type: 'string',
50
+ description: 'action=sample/query 时要返回的列,逗号分隔;sample 省略则取前 5 列,query 省略则取返回行实际带的列。',
51
+ },
52
+ orderby: {
53
+ type: 'string',
54
+ description: "action=query 的排序,例如 \"modifiedon desc\"。",
55
+ },
56
+ expand: {
57
+ type: 'string',
58
+ description: 'action=query 的 $expand,例如 "primarycontactid($select=fullname)"。',
59
+ },
60
+ id: {
61
+ type: 'string',
62
+ description: 'action=query:按主键取一条(GUID,或带引号的备用键值如 \'contoso_001\')。',
63
+ },
64
+ top: {
65
+ type: 'integer',
66
+ description: 'action=sample 返回行数(默认 5,最多 100);action=group 返回前 N 组(默认 20);action=query 返回行数(默认 25,最多 500)。',
67
+ },
68
+ environment: {
69
+ type: 'string',
70
+ description: '环境 key,省略则用默认环境。',
71
+ },
72
+ environments: {
73
+ type: 'array',
74
+ items: { type: 'string' },
75
+ description: '一次查多个环境。',
76
+ },
77
+ allEnvironments: {
78
+ type: 'boolean',
79
+ description: '对全部已配置环境执行同一查询。',
80
+ },
81
+ },
82
+ },
83
+ output: {
84
+ schema: {
85
+ type: 'object',
86
+ additionalProperties: false,
87
+ required: ['text'],
88
+ properties: { text: { type: 'string' } },
89
+ },
90
+ render(_args, value) {
91
+ return [{ type: 'text', text: value?.text ?? '' }]
92
+ },
93
+ },
94
+ }
95
+
96
+ /**
97
+ * Resolve the entity set name.
98
+ *
99
+ * EntitySetName is customisable, so guessing `${entity}s` is only a fallback:
100
+ * the metadata read is authoritative and cheap.
101
+ * @returns the collection path segment.
102
+ */
103
+ export async function resolveEntitySet(client, entity, signal) {
104
+ const candidate = String(entity).trim()
105
+ if (!candidate) throw new D365Error('entity 不能为空', { code: 'missing-entity' })
106
+ // A logical name cannot contain a quote; anything that does is either a typo
107
+ // or an attempt to widen the filter, and either way must not be interpolated.
108
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(candidate)) {
109
+ throw new D365Error(`不是合法的实体名:${candidate}`, {
110
+ code: 'bad-entity',
111
+ hint: '实体逻辑名或集合名只包含字母、数字与下划线,例如 contoso_systemconfiguration。',
112
+ })
113
+ }
114
+ // Keyed metadata paths reject `$top`, so ask the collection form instead —
115
+ // the same shape `d365_metadata` already uses successfully.
116
+ try {
117
+ const result = await getPagedWithSelectFallback(client, 'EntityDefinitions', {
118
+ $select: 'LogicalName,EntitySetName,PrimaryIdAttribute',
119
+ $filter: `LogicalName eq '${odataString(candidate)}'`,
120
+ }, 2, signal)
121
+ const row = result?.page?.records?.[0]
122
+ if (row?.EntitySetName) {
123
+ return { set: row.EntitySetName, logical: row.LogicalName ?? candidate, idField: row.PrimaryIdAttribute }
124
+ }
125
+ } catch (error) {
126
+ // Only a rejected query means "no such entity"; auth failures, timeouts
127
+ // and the like must surface as themselves, not as a misleading 404.
128
+ if (error?.status !== 400 && error?.status !== 404 && error?.code !== 'unknown-entity') throw error
129
+ }
130
+ if (!/s$/.test(candidate)) {
131
+ throw new D365Error(`实体不存在:${candidate}`, {
132
+ code: 'unknown-entity',
133
+ hint: '用实体逻辑名(如 contoso_systemconfiguration)或集合名(如 contoso_systemconfigurations)。',
134
+ })
135
+ }
136
+ return { set: candidate, logical: candidate }
137
+ }
138
+
139
+ /** Count rows, using `$apply` first and `$count=true` as a fallback. */
140
+ async function countRows(client, set, filter, signal) {
141
+ const params = filter ? { $filter: filter } : {}
142
+ try {
143
+ const page = await client.getPaged(set, { ...params, $apply: 'aggregate($count as count)' }, 5, signal)
144
+ const value = page.records?.[0]?.count
145
+ if (typeof value === 'number') return value
146
+ } catch (error) {
147
+ // A permission refusal is not "this set rejects $apply" — surface it.
148
+ if (error?.code === 'data-read-forbidden') throw error
149
+ }
150
+ try {
151
+ const page = await client.requestJson(
152
+ client.buildUrl(set, { ...params, $count: 'true', $top: '1' }),
153
+ { signal },
154
+ )
155
+ if (typeof page?.['@odata.count'] === 'number') return page['@odata.count']
156
+ } catch (error) {
157
+ if (error?.code === 'data-read-forbidden') throw error
158
+ }
159
+ throw new D365Error(`无法统计 ${set} 的行数`, {
160
+ code: 'count-unavailable',
161
+ hint: '该表可能不支持聚合查询;用 filter 缩小范围后重试,或改用 action=sample。',
162
+ })
163
+ }
164
+
165
+ export function register(ctx, state) {
166
+ return ctx.tools.register({
167
+ ...definition,
168
+ async execute(args, exec) {
169
+ const config = await loadConfig(state.rawConfig)
170
+ const action = args.action
171
+ if (!ACTIONS.includes(action)) {
172
+ throw new D365Error(`不认识的操作:${action}`, { code: 'unknown-action', hint: `可选:${ACTIONS.join(' / ')}` })
173
+ }
174
+ if (action === 'group' && !args.field) {
175
+ throw new D365Error('action=group 需要 field', { code: 'missing-field' })
176
+ }
177
+
178
+ const envs = pickEnvironments(config, args)
179
+ const reports = []
180
+ const artifact = { action, entity: args.entity, filter: args.filter, environments: {} }
181
+
182
+ for (const env of envs) {
183
+ const client = makeClient(env, config)
184
+ const { set, logical } = await resolveEntitySet(client, args.entity, exec?.signal)
185
+ const filter = args.filter
186
+
187
+ if (action === 'count') {
188
+ const count = await countRows(client, set, filter, exec?.signal)
189
+ artifact.environments[env.key] = { entitySet: set, count }
190
+ reports.push(`${env.key}:${logical} 共 ${count} 行${filter ? `(筛选:${filter})` : ''}`)
191
+ continue
192
+ }
193
+
194
+ if (action === 'group') {
195
+ const apply = `groupby((${args.field}),aggregate($count as count))`
196
+ const page = await client.getPaged(set, {
197
+ ...(filter ? { $filter: filter } : {}),
198
+ $apply: apply,
199
+ }, Number.isFinite(args.top) ? Math.max(1, Math.min(500, args.top)) : 20, exec?.signal)
200
+ const rows = page.records ?? []
201
+ const display = rows.map((row) => ({ ...row, [args.field]: row[args.field] ?? '(空)' }))
202
+ const total = rows.reduce((sum, row) => sum + (row.count ?? 0), 0)
203
+ artifact.environments[env.key] = { entitySet: set, field: args.field, groups: rows }
204
+ const table = formatTable(display, [args.field, 'count'], { width: 46 })
205
+ reports.push(`${env.key}:按 ${args.field} 分组,前 ${rows.length} 组合计 ${total} 行\n${table}`)
206
+ continue
207
+ }
208
+
209
+ if (action === 'query') {
210
+ const top = Number.isFinite(args.top) ? Math.max(1, Math.min(500, args.top)) : 25
211
+ const columns = args.select
212
+ ? args.select.split(',').map((item) => item.trim()).filter(Boolean)
213
+ : null
214
+ if (args.id !== undefined && args.id !== null && String(args.id).trim()) {
215
+ const key = String(args.id).trim()
216
+ const bare = key.replace(/^'|'$/g, '')
217
+ if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(bare) && !/^'.*'$/.test(key)) {
218
+ throw new D365Error(`id 既不是 GUID 也不是带引号的备用键:${key}`, {
219
+ code: 'bad-id',
220
+ hint: "主键传 GUID;备用键要带单引号,例如 \"contoso_001\" 写成 \"'contoso_001'\"。",
221
+ })
222
+ }
223
+ const params = {}
224
+ if (args.select) params.$select = args.select
225
+ if (args.expand) params.$expand = args.expand
226
+ let row
227
+ try {
228
+ row = await client.requestJson(client.buildUrl(`${set}(${key})`, params), { signal: exec?.signal })
229
+ } catch (error) {
230
+ if (error?.status === 404) {
231
+ throw new D365Error(`${env.key} 里没有 ${logical}(${key}) 这条记录`, {
232
+ code: 'record-not-found',
233
+ hint: '确认 id 属于这个环境;跨环境复制过来的 GUID 在目标不存在时也会是 404。',
234
+ })
235
+ }
236
+ throw error
237
+ }
238
+ if (!row || typeof row !== 'object') {
239
+ throw new D365Error(`${env.key} 没有返回 ${logical}(${key})`, { code: 'record-not-found' })
240
+ }
241
+ artifact.environments[env.key] = { entitySet: set, id: key, rows: [row] }
242
+ const shown = columns ?? Object.keys(row).filter((name) => !name.startsWith('@')).slice(0, 8)
243
+ reports.push(`${env.key}:${logical}(${key})\n${formatTable([row], shown, { width: 40 })}`)
244
+ continue
245
+ }
246
+ // Do not send `$top` here: `getPaged` asks for one row more than the
247
+ // cap so a full page is distinguishable from a complete one.
248
+ const params = {}
249
+ if (filter) params.$filter = filter
250
+ if (args.select) params.$select = args.select
251
+ if (args.orderby) params.$orderby = args.orderby
252
+ if (args.expand) params.$expand = args.expand
253
+ const page = await client.getPaged(set, params, top, exec?.signal)
254
+ const rows = page.records ?? []
255
+ const total = page.hasMore ? await countRows(client, set, filter, exec?.signal).catch(() => undefined) : rows.length
256
+ const shown = columns ?? Object.keys(rows[0] ?? {}).filter((name) => !name.startsWith('@')).slice(0, 8)
257
+ artifact.environments[env.key] = { entitySet: set, params, total, rows }
258
+ const head = total === undefined
259
+ ? `${env.key}:${logical} 返回 ${rows.length} 行(可能还有更多)`
260
+ : total === rows.length
261
+ ? `${env.key}:${logical} 共 ${total} 行`
262
+ : `${env.key}:已列 ${rows.length} 条 / 共 ${total} 条(未列全,加 filter 或调 top)`
263
+ reports.push(`${head}\n${formatTable(rows, shown, { width: 40 })}`)
264
+ continue
265
+ }
266
+
267
+ const top = Number.isFinite(args.top) ? Math.max(1, Math.min(100, args.top)) : 5
268
+ const params = { $top: top }
269
+ if (filter) params.$filter = filter
270
+ if (args.select) params.$select = args.select
271
+ const page = await client.getPaged(set, params, top, exec?.signal)
272
+ const rows = page.records ?? []
273
+ const columns = args.select ? args.select.split(',').map((item) => item.trim()) : Object.keys(rows[0] ?? {}).slice(0, 5)
274
+ artifact.environments[env.key] = { entitySet: set, rows }
275
+ reports.push(`${env.key}:${logical} 样例 ${rows.length} 行\n${formatTable(rows, columns, { width: 32 })}`)
276
+ }
277
+
278
+ const file = await writeArtifact(config, `data-${action}`, artifact)
279
+ return { text: `${reports.join('\n\n')}\n\n完整结果:${file}` }
280
+ },
281
+ })
282
+ }