dsh-d365-kit 0.34.1 → 0.34.3

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 +318 -308
  2. package/CHANGELOG.zh-CN.md +317 -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
@@ -1,497 +1,497 @@
1
- /**
2
- * `d365_layers` — solution layers of metadata components.
3
- *
4
- * The layer table (`msdyn_componentlayers`) is the only place that reveals the
5
- * *stack* behind a component: which solution last touched it, in what order,
6
- * and which definition is currently winning. For a team that ships solution
7
- * zips by hand across four environments, that stack is usually the real answer
8
- * to "why does this form look different in UAT".
9
- */
10
- import { loadConfig } from './config.js'
11
- import {
12
- INLINE_ROWS,
13
- formatTable,
14
- makeClient,
15
- pickEnvironments,
16
- writeArtifact,
17
- } from './runtime.js'
18
- import { getPagedWithSelectFallback } from './client.js'
19
- import { odataString } from './resources.js'
20
- import { D365Error } from './errors.js'
21
-
22
- /** Base columns; `msdyn_componentjson` is only added on request (it can be huge). */
23
- const BASE_SELECT = [
24
- 'msdyn_componentlayerid',
25
- 'msdyn_name',
26
- 'msdyn_solutioncomponentname',
27
- 'msdyn_solutionname',
28
- 'msdyn_publishername',
29
- 'msdyn_order',
30
- 'msdyn_componentid',
31
- 'msdyn_overwritetime',
32
- ]
33
-
34
- export const definition = {
35
- name: 'd365_layers',
36
- description:
37
- '查询 D365 CE 元数据组件的解决方案层(msdyn_componentlayers):每个组件被哪些解决方案覆盖过、层的先后顺序、当前生效的是哪一层。'
38
- + '用于判断"为什么这个环境里的表单/字段/视图和别的环境不一样"。',
39
- parameters: {
40
- type: 'object',
41
- additionalProperties: false,
42
- properties: {
43
- component: {
44
- type: 'string',
45
- description: '组件名或组件 GUID。Entity 用逻辑名(account);其它类型用该类记录的名称/唯一名。',
46
- },
47
- componentType: {
48
- type: 'string',
49
- enum: [
50
- 'Entity',
51
- 'Attribute',
52
- 'SystemForm',
53
- 'SavedQuery',
54
- 'Workflow',
55
- 'WebResource',
56
- 'PluginAssembly',
57
- 'PluginType',
58
- 'SdkMessageProcessingStep',
59
- 'CustomAPI',
60
- 'OptionSet',
61
- ],
62
- description: '组件类型:Entity(表)、Attribute(字段)、SystemForm、SavedQuery、Workflow、WebResource、'
63
- + 'PluginAssembly、PluginType、SdkMessageProcessingStep、CustomAPI、OptionSet(全局选项集)。'
64
- + '层表是虚拟实体,必须同时给出组件 id 和类型,否则返回空集。',
65
- },
66
- entity: {
67
- type: 'string',
68
- description: 'componentType=Attribute 时必填:字段所属实体的逻辑名。',
69
- },
70
- solution: {
71
- type: 'string',
72
- description: '只看某个解决方案的层(解决方案唯一名),在返回结果里过滤。',
73
- },
74
- includeJson: {
75
- type: 'boolean',
76
- description: '同时取回 msdyn_componentjson(组件在该层的完整定义,最大 1GB,会写入文件而非直接展开)。',
77
- },
78
- top: {
79
- type: 'integer',
80
- description: '最多取多少条层记录,默认 500。',
81
- },
82
- environments: {
83
- type: 'array',
84
- items: { type: 'string' },
85
- description: '环境 key 列表,例如 ["dev","sit"]。省略则用默认环境。',
86
- },
87
- allEnvironments: {
88
- type: 'boolean',
89
- description: '对全部已配置环境执行同一查询并比较。',
90
- },
91
- },
92
- },
93
- output: {
94
- schema: {
95
- type: 'object',
96
- additionalProperties: false,
97
- required: ['text'],
98
- properties: {
99
- text: { type: 'string' },
100
- },
101
- },
102
- render(_args, value) {
103
- return [{ type: 'text', text: value?.text ?? '' }]
104
- },
105
- },
106
- }
107
-
108
- /**
109
- * How each solution-component type resolves a human name to the component id
110
- * the layer table needs, and the `msdyn_solutioncomponentname` value it stores.
111
- *
112
- * The layer table is a virtual entity: an unfiltered query, or a filter with
113
- * only one of (component id, component type), returns an empty set — the
114
- * portal's own request always sends both.
115
- */
116
- export const COMPONENT_TYPES = {
117
- Entity: { stored: 'Entity', lookup: { path: (name) => `EntityDefinitions(LogicalName='${odataString(name)}')`, id: 'MetadataId' } },
118
- Attribute: {
119
- stored: 'Attribute',
120
- lookup: {
121
- path: (name, args) => `EntityDefinitions(LogicalName='${odataString(args.entity ?? '')}')/Attributes(LogicalName='${odataString(name)}')`,
122
- id: 'MetadataId',
123
- requiresEntity: true,
124
- },
125
- },
126
- SystemForm: { stored: 'SystemForm', lookup: { path: () => 'systemforms', field: 'name', id: 'formid' } },
127
- SavedQuery: { stored: 'SavedQuery', lookup: { path: () => 'savedqueries', field: 'name', id: 'savedqueryid' } },
128
- Workflow: { stored: 'Workflow', lookup: { path: () => 'workflows', field: 'name', id: 'workflowid' } },
129
- WebResource: { stored: 'WebResource', lookup: { path: () => 'webresourceset', field: 'name', id: 'webresourceid' } },
130
- PluginAssembly: { stored: 'PluginAssembly', lookup: { path: () => 'pluginassemblies', field: 'name', id: 'pluginassemblyid' } },
131
- PluginType: { stored: 'PluginType', lookup: { path: () => 'plugintypes', field: 'typename', id: 'plugintypeid' } },
132
- SdkMessageProcessingStep: {
133
- stored: 'SdkMessageProcessingStep',
134
- lookup: { path: () => 'sdkmessageprocessingsteps', field: 'name', id: 'sdkmessageprocessingstepid' },
135
- },
136
- CustomAPI: { stored: 'CustomAPI', lookup: { path: () => 'customapis', field: 'uniquename', id: 'customapiid' } },
137
- // Global option sets live on their own metadata endpoint and are keyed by
138
- // name, not by a record id. Local option sets are part of an attribute and
139
- // are reached through `Attribute` instead — `GlobalOptionSetDefinitions`
140
- // knows nothing about them.
141
- OptionSet: {
142
- stored: 'OptionSet',
143
- lookup: { path: (name) => `GlobalOptionSetDefinitions(Name='${odataString(name)}')`, id: 'MetadataId' },
144
- },
145
- }
146
-
147
- const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
148
-
149
- /**
150
- * Read at most one row, whether the path is keyed (`EntityDefinitions(...)`,
151
- * which answers with a bare object and rejects `$top`) or a collection.
152
- */
153
- async function lookupOne(client, path, params, signal) {
154
- // A keyed read (`/EntityDefinitions(LogicalName='x')`) answers 404 when the
155
- // component is simply not in this org — that is a normal "absent here", not
156
- // a transport failure, and must not abort a multi-environment call.
157
- const absentAsUndefined = async (request) => {
158
- try {
159
- return await request()
160
- } catch (error) {
161
- if (error?.status === 404) return undefined
162
- throw error
163
- }
164
- }
165
-
166
- try {
167
- return await absentAsUndefined(() => client.getOne(path, params, signal))
168
- } catch (error) {
169
- if (error?.status !== 400) throw error
170
- // Keyed metadata paths reject `$top`, which `getOne` adds; retry without it.
171
- return absentAsUndefined(async () => {
172
- const payload = await client.requestJson(client.buildUrl(path, params), { signal })
173
- return Array.isArray(payload?.value) ? payload.value[0] : payload
174
- })
175
- }
176
- }
177
-
178
- /** Resolve the argument to a component GUID for one environment. */
179
- export async function resolveComponentId(client, args, signal) {
180
- const type = args.componentType
181
- const spec = COMPONENT_TYPES[type]
182
- if (!spec) {
183
- throw new D365Error(`不支持的组件类型:${type ?? '(未指定)'}`, {
184
- code: 'bad-component-type',
185
- hint: `可用:${Object.keys(COMPONENT_TYPES).join(', ')}`,
186
- })
187
- }
188
-
189
- const raw = String(args.component ?? '').trim()
190
- if (!raw) {
191
- throw new D365Error('缺少 component', {
192
- code: 'missing-component',
193
- hint: type === 'Entity' ? '例如 component: "account"' : '传组件名或 GUID。',
194
- })
195
- }
196
- if (GUID.test(raw)) return raw
197
-
198
- if (spec.lookup.requiresEntity && !args.entity) {
199
- throw new D365Error('查询字段(Attribute)的层需要 entity 参数', {
200
- code: 'missing-entity',
201
- hint: `例如 {"componentType":"Attribute","component":"name","entity":"account"}`,
202
- })
203
- }
204
-
205
- const path = spec.lookup.path(raw, args)
206
- const params = spec.lookup.field
207
- ? { $select: spec.lookup.id, $filter: `${spec.lookup.field} eq '${odataString(raw)}'` }
208
- : { $select: spec.lookup.id }
209
- const row = await lookupOne(client, path, params, signal)
210
- const id = row?.[spec.lookup.id]
211
- if (!id) {
212
- throw new D365Error(`找不到 ${type} "${raw}"`, {
213
- code: 'component-not-found',
214
- hint: type === 'OptionSet'
215
- ? 'OptionSet 只按全局选项集的名字解析(GlobalOptionSetDefinitions)。本地选项集随字段存在,用 componentType=Attribute 加 entity 查字段的层。'
216
- : '名字要精确匹配(Entity 用逻辑名,其它用记录名称/唯一名),也可以直接传组件 GUID。',
217
- })
218
- }
219
- return id
220
- }
221
-
222
- /** Compose the layer filter for one resolved component. */
223
- export function buildComponentFilter(componentId, componentType) {
224
- const stored = COMPONENT_TYPES[componentType]?.stored ?? componentType
225
- return `(msdyn_componentid eq '${odataString(componentId)}' and msdyn_solutioncomponentname eq '${odataString(stored)}')`
226
- }
227
-
228
- /** Group layer rows by component. */
229
- function groupLayers(rows) {
230
- const groups = new Map()
231
- for (const row of rows) {
232
- const type = row.msdyn_solutioncomponentname ?? '?'
233
- const name = row.msdyn_name ?? '(未命名)'
234
- const key = `${type}::${name}`
235
- if (!groups.has(key)) groups.set(key, { type, name, layers: [] })
236
- groups.get(key).layers.push(row)
237
- }
238
- for (const group of groups.values()) {
239
- group.layers.sort((a, b) => Number(a.msdyn_order ?? 0) - Number(b.msdyn_order ?? 0))
240
- group.active = group.layers[group.layers.length - 1]
241
- }
242
- return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type))
243
- }
244
-
245
- /** Short solution label; the system layer has no solution name. */
246
- export const solutionLabel = (layer) => (layer?.msdyn_solutionname ? layer.msdyn_solutionname : '(系统层)')
247
-
248
- /**
249
- * Dataverse parks the unmanaged customization layer under the pseudo-solution
250
- * name "Active" — it is the only layer a developer can remove, and it always
251
- * sits on top of the managed stack when it exists.
252
- */
253
- export const isActiveLayer = (layer) => /^active$/i.test(layer?.msdyn_solutionname ?? '')
254
-
255
- /** Layer label that says out loud which layer is the unmanaged one. */
256
- function layerLabel(layer) {
257
- if (isActiveLayer(layer)) return 'Active(未受管)'
258
- return solutionLabel(layer)
259
- }
260
-
261
- /** Timestamps the provider leaves at DateTime.MinValue carry no information. */
262
- function formatTime(value) {
263
- if (!value) return '—'
264
- const text = String(value).replace('T', ' ').slice(0, 19)
265
- return /^(0001|0000|1899|1900)/.test(text) ? '—' : text
266
- }
267
-
268
- /**
269
- * The Active layer's `msdyn_overwritetime` is always DateTime.MinValue, so a
270
- * bare "—" would read like a formatting accident. Say what it means instead.
271
- */
272
- function formatLayerTime(layer) {
273
- return isActiveLayer(layer) ? '不记录' : formatTime(layer?.msdyn_overwritetime)
274
- }
275
-
276
- /** One readable line for a per-environment failure. */
277
- function describeFailure(error) {
278
- // The D365Error message already carries a " 建议:" hint, which reads badly
279
- // nested inside another sentence — say it plainly for this one case instead.
280
- if (error?.code === 'component-not-found') {
281
- return '该环境没有这个组件;常见于它还没随解决方案导入到这里。其它环境的结果和对比不受影响。'
282
- }
283
- return String(error?.message ?? error ?? '未知错误')
284
- }
285
-
286
- /** Render one environment's layer stack. */
287
- function renderEnvironment(result, includeJson) {
288
- const lines = []
289
- const groups = groupLayers(result.rows)
290
- lines.push(
291
- `### ${result.env.key} — ${result.rows.length} 条层记录,覆盖 ${groups.length} 个组件`
292
- + `${result.hasMore ? '(已截断,可加 filter 缩小范围)' : ''}`,
293
- )
294
- if (!groups.length) {
295
- lines.push('没有匹配的层记录。组件只在被解决方案承载时才有层;系统内置、未进任何解决方案的组件查不到层。')
296
- return { lines, groups }
297
- }
298
-
299
- const summaryRows = groups.map((group) => ({
300
- 组件: group.name,
301
- 类型: group.type,
302
- 层数: group.layers.length,
303
- 活动层: layerLabel(group.active),
304
- 层序: group.active?.msdyn_order ?? '',
305
- 更新时间: formatLayerTime(group.active),
306
- }))
307
- lines.push('')
308
- lines.push('活动层(层序最大者为当前生效层):')
309
- lines.push(formatTable(summaryRows, ['组件', '类型', '层数', '活动层', '层序', '更新时间'], { limit: INLINE_ROWS }))
310
-
311
- // Say which situation the environment is in. "Active" as a bare solution
312
- // name reads like just another solution, and its absence is easy to miss.
313
- const unmanaged = groups.filter((group) => isActiveLayer(group.active))
314
- if (unmanaged.length) {
315
- lines.push('')
316
- lines.push(`其中 ${unmanaged.length} 个组件最上层是未受管的自定义层(Active):它盖住了下面的受管层,`
317
- + '删掉它才会回落到受管层(d365_write plan-remove-layer)。Active 层的更新时间不记录,显示为「不记录」。')
318
- } else {
319
- lines.push('')
320
- lines.push('该环境没有未受管的自定义层:每个组件最上层都是某个解决方案里的受管层,没有可移除的 Active 层。')
321
- }
322
-
323
- const stacked = groups.filter((group) => group.layers.filter((layer) => layer.msdyn_solutionname).length > 1)
324
- if (stacked.length) {
325
- lines.push('')
326
- lines.push(`被多个解决方案覆盖的组件:${stacked.length} 个(这类组件最容易出现环境间不一致)`)
327
- for (const group of stacked.slice(0, 10)) {
328
- const chain = group.layers.map((layer) => `${layer.msdyn_order}:${layerLabel(layer)}`).join(' → ')
329
- lines.push(`- ${group.name}(${group.type}):${chain}`)
330
- }
331
- if (stacked.length > 10) lines.push(`- … 其余 ${stacked.length - 10} 个见文件`)
332
- }
333
-
334
- if (includeJson) lines.push('', '各层的 msdyn_componentjson 已写入结果文件(未在此展开,单条可达数百 MB)。')
335
- return { lines, groups }
336
- }
337
-
338
- export function register(ctx, state) {
339
- return ctx.tools.register({
340
- ...definition,
341
- async execute(args = {}, exec) {
342
- const config = await loadConfig(state.rawConfig)
343
- const environments = pickEnvironments(config, args)
344
- const top = Number.isFinite(args.top) ? Math.max(1, Math.min(5000, Math.trunc(args.top))) : 500
345
- const select = [...BASE_SELECT, args.includeJson ? 'msdyn_componentjson' : null].filter(Boolean).join(',')
346
-
347
- const results = []
348
- for (const env of environments) {
349
- try {
350
- const client = makeClient(env, config)
351
- const componentId = await resolveComponentId(client, args, exec?.signal)
352
- const componentFilter = buildComponentFilter(componentId, args.componentType)
353
- // `solution` has to go into `$filter`, not after paging: a component
354
- // can have more layers than `top`, and filtering the returned page
355
- // would then report "this solution has no layer" for a layer that
356
- // exists but fell outside the page.
357
- const filter = args.solution
358
- ? `${componentFilter} and msdyn_solutionname eq '${odataString(args.solution)}'`
359
- : componentFilter
360
- // No `$orderby`: the virtual provider only guarantees the id+type
361
- // filter, and ordering is applied after the rows arrive.
362
- const { page, selectDropped } = await getPagedWithSelectFallback(
363
- client,
364
- 'msdyn_componentlayers',
365
- { $select: select, $filter: filter },
366
- top,
367
- exec?.signal,
368
- )
369
- const rows = page.records
370
- results.push({
371
- env,
372
- ok: true,
373
- componentId,
374
- rows: rows.sort((a, b) => Number(a.msdyn_order ?? 0) - Number(b.msdyn_order ?? 0)),
375
- hasMore: page.hasMore,
376
- selectDropped,
377
- filter,
378
- })
379
- } catch (error) {
380
- // One environment missing the component must not sink the others:
381
- // this is the common "component exists in UAT but not in SIT" case.
382
- results.push({ env, ok: false, error })
383
- }
384
- }
385
- if (results.length && results.every((result) => !result.ok)) throw results[0].error
386
-
387
- const lines = [`查询解决方案层:${environments.map((env) => env.key).join(' / ')}`]
388
- lines.push(`组件:${args.componentType ?? '?'} / ${args.component ?? '?'}${args.entity ? `(${args.entity})` : ''}`)
389
- if (args.solution) lines.push(`只看解决方案:${args.solution}`)
390
-
391
- const perEnv = []
392
- for (const result of results) {
393
- lines.push('')
394
- if (!result.ok) {
395
- lines.push(`### ${result.env.key} — 查询失败`)
396
- lines.push(`- ${describeFailure(result.error)}`)
397
- continue
398
- }
399
- const rendered = renderEnvironment(result, args.includeJson)
400
- lines.push(...rendered.lines)
401
- if (result.selectDropped) lines.push('(该环境不接受部分 $select 列,已自动降级)')
402
- perEnv.push({ env: result.env, groups: rendered.groups })
403
- }
404
-
405
- if (perEnv.length > 1) {
406
- lines.push('')
407
- lines.push('### 跨环境对比')
408
- const maps = perEnv.map((entry) => ({
409
- env: entry.env.key,
410
- map: new Map(entry.groups.map((group) => [`${group.type}::${group.name}`, group])),
411
- }))
412
- const allKeys = [...new Set(maps.flatMap((entry) => [...entry.map.keys()]))]
413
- const diffs = []
414
- for (const key of allKeys) {
415
- const name = key.split('::')[1]
416
- const present = maps.filter((entry) => entry.map.has(key))
417
- const absent = maps.filter((entry) => !entry.map.has(key))
418
- if (absent.length) {
419
- diffs.push(`${name} 有层记录的环境:${present.map((entry) => entry.env).join('/')};`
420
- + `无:${absent.map((entry) => entry.env).join('/')}`)
421
- continue
422
- }
423
-
424
- // Compare the whole stack, not just the winner: a solution present in
425
- // one environment shifts every layer above it, which is exactly the
426
- // drift this comparison is for.
427
- const stacks = maps.map((entry) => ({
428
- env: entry.env,
429
- layers: new Map(entry.map.get(key).layers.map((layer) => [layerLabel(layer), Number(layer.msdyn_order ?? 0)])),
430
- }))
431
- const allSolutions = [...new Set(stacks.flatMap((entry) => [...entry.layers.keys()]))]
432
- // Group the differing solutions by *which* environments carry them, so
433
- // four environments read as two statements instead of ten.
434
- const buckets = new Map()
435
- for (const solution of allSolutions) {
436
- const carriers = stacks.filter((entry) => entry.layers.has(solution)).map((entry) => entry.env)
437
- if (carriers.length === stacks.length) continue
438
- const bucketKey = carriers.join('/')
439
- if (!buckets.has(bucketKey)) buckets.set(bucketKey, { carriers, solutions: [] })
440
- buckets.get(bucketKey).solutions.push(solution)
441
- }
442
- if (buckets.size) {
443
- const detail = [...buckets.values()].map((bucket) => {
444
- const missing = stacks.filter((entry) => !bucket.carriers.includes(entry.env)).map((entry) => entry.env)
445
- return ` - 仅 ${bucket.carriers.join('/')} 有(${bucket.solutions.length} 个,${missing.join('/')} 没有):${bucket.solutions.join('、')}`
446
- })
447
- diffs.push(`${name} 层结构不同:\n${detail.join('\n')}`)
448
- }
449
-
450
- const actives = maps.map((entry) => `${entry.env}=${layerLabel(entry.map.get(key).active)}`)
451
- if (new Set(maps.map((entry) => layerLabel(entry.map.get(key).active))).size > 1) {
452
- diffs.push(`${name} 活动层不同:${actives.join(' / ')}`)
453
- }
454
- }
455
- if (!diffs.length) lines.push('各环境的层结构与活动层一致。')
456
- else {
457
- for (const line of diffs.slice(0, 15)) lines.push(`- ${line}`)
458
- if (diffs.length > 15) lines.push(`- … 其余 ${diffs.length - 15} 条见文件`)
459
- }
460
- }
461
-
462
- const path = await writeArtifact(config, `layers-${environments.map((env) => env.key).join('-')}`, {
463
- args,
464
- generatedAt: new Date().toISOString(),
465
- environments: results.map((result) => (result.ok
466
- ? {
467
- env: result.env.key,
468
- url: result.env.url,
469
- ok: true,
470
- componentId: result.componentId,
471
- filter: result.filter,
472
- hasMore: result.hasMore,
473
- rows: result.rows,
474
- }
475
- : {
476
- env: result.env.key,
477
- url: result.env.url,
478
- ok: false,
479
- error: String(result.error?.message ?? result.error ?? ''),
480
- })),
481
- })
482
- lines.push('', `完整层数据:${path}`)
483
- lines.push('说明:层序(msdyn_order)越大越靠上,最大者即当前生效层;系统层表示未由任何解决方案承载。')
484
-
485
- return { text: lines.join('\n') }
486
- },
487
- presentCall(args = {}) {
488
- const envs = args.allEnvironments ? '全部环境' : (args.environments ?? []).join('/') || '默认环境'
489
- return {
490
- card: 'generic',
491
- title: `查询解决方案层${args.component ? `:${args.component}` : ''}(${envs})`,
492
- kind: 'other',
493
- rawInput: args,
494
- }
495
- },
496
- })
497
- }
1
+ /**
2
+ * `d365_layers` — solution layers of metadata components.
3
+ *
4
+ * The layer table (`msdyn_componentlayers`) is the only place that reveals the
5
+ * *stack* behind a component: which solution last touched it, in what order,
6
+ * and which definition is currently winning. For a team that ships solution
7
+ * zips by hand across four environments, that stack is usually the real answer
8
+ * to "why does this form look different in UAT".
9
+ */
10
+ import { loadConfig } from './config.js'
11
+ import {
12
+ INLINE_ROWS,
13
+ formatTable,
14
+ makeClient,
15
+ pickEnvironments,
16
+ writeArtifact,
17
+ } from './runtime.js'
18
+ import { getPagedWithSelectFallback } from './client.js'
19
+ import { odataString } from './resources.js'
20
+ import { D365Error } from './errors.js'
21
+
22
+ /** Base columns; `msdyn_componentjson` is only added on request (it can be huge). */
23
+ const BASE_SELECT = [
24
+ 'msdyn_componentlayerid',
25
+ 'msdyn_name',
26
+ 'msdyn_solutioncomponentname',
27
+ 'msdyn_solutionname',
28
+ 'msdyn_publishername',
29
+ 'msdyn_order',
30
+ 'msdyn_componentid',
31
+ 'msdyn_overwritetime',
32
+ ]
33
+
34
+ export const definition = {
35
+ name: 'd365_layers',
36
+ description:
37
+ '查询 D365 CE 元数据组件的解决方案层(msdyn_componentlayers):每个组件被哪些解决方案覆盖过、层的先后顺序、当前生效的是哪一层。'
38
+ + '用于判断"为什么这个环境里的表单/字段/视图和别的环境不一样"。',
39
+ parameters: {
40
+ type: 'object',
41
+ additionalProperties: false,
42
+ properties: {
43
+ component: {
44
+ type: 'string',
45
+ description: '组件名或组件 GUID。Entity 用逻辑名(account);其它类型用该类记录的名称/唯一名。',
46
+ },
47
+ componentType: {
48
+ type: 'string',
49
+ enum: [
50
+ 'Entity',
51
+ 'Attribute',
52
+ 'SystemForm',
53
+ 'SavedQuery',
54
+ 'Workflow',
55
+ 'WebResource',
56
+ 'PluginAssembly',
57
+ 'PluginType',
58
+ 'SdkMessageProcessingStep',
59
+ 'CustomAPI',
60
+ 'OptionSet',
61
+ ],
62
+ description: '组件类型:Entity(表)、Attribute(字段)、SystemForm、SavedQuery、Workflow、WebResource、'
63
+ + 'PluginAssembly、PluginType、SdkMessageProcessingStep、CustomAPI、OptionSet(全局选项集)。'
64
+ + '层表是虚拟实体,必须同时给出组件 id 和类型,否则返回空集。',
65
+ },
66
+ entity: {
67
+ type: 'string',
68
+ description: 'componentType=Attribute 时必填:字段所属实体的逻辑名。',
69
+ },
70
+ solution: {
71
+ type: 'string',
72
+ description: '只看某个解决方案的层(解决方案唯一名),在返回结果里过滤。',
73
+ },
74
+ includeJson: {
75
+ type: 'boolean',
76
+ description: '同时取回 msdyn_componentjson(组件在该层的完整定义,最大 1GB,会写入文件而非直接展开)。',
77
+ },
78
+ top: {
79
+ type: 'integer',
80
+ description: '最多取多少条层记录,默认 500。',
81
+ },
82
+ environments: {
83
+ type: 'array',
84
+ items: { type: 'string' },
85
+ description: '环境 key 列表,例如 ["dev","sit"]。省略则用默认环境。',
86
+ },
87
+ allEnvironments: {
88
+ type: 'boolean',
89
+ description: '对全部已配置环境执行同一查询并比较。',
90
+ },
91
+ },
92
+ },
93
+ output: {
94
+ schema: {
95
+ type: 'object',
96
+ additionalProperties: false,
97
+ required: ['text'],
98
+ properties: {
99
+ text: { type: 'string' },
100
+ },
101
+ },
102
+ render(_args, value) {
103
+ return [{ type: 'text', text: value?.text ?? '' }]
104
+ },
105
+ },
106
+ }
107
+
108
+ /**
109
+ * How each solution-component type resolves a human name to the component id
110
+ * the layer table needs, and the `msdyn_solutioncomponentname` value it stores.
111
+ *
112
+ * The layer table is a virtual entity: an unfiltered query, or a filter with
113
+ * only one of (component id, component type), returns an empty set — the
114
+ * portal's own request always sends both.
115
+ */
116
+ export const COMPONENT_TYPES = {
117
+ Entity: { stored: 'Entity', lookup: { path: (name) => `EntityDefinitions(LogicalName='${odataString(name)}')`, id: 'MetadataId' } },
118
+ Attribute: {
119
+ stored: 'Attribute',
120
+ lookup: {
121
+ path: (name, args) => `EntityDefinitions(LogicalName='${odataString(args.entity ?? '')}')/Attributes(LogicalName='${odataString(name)}')`,
122
+ id: 'MetadataId',
123
+ requiresEntity: true,
124
+ },
125
+ },
126
+ SystemForm: { stored: 'SystemForm', lookup: { path: () => 'systemforms', field: 'name', id: 'formid' } },
127
+ SavedQuery: { stored: 'SavedQuery', lookup: { path: () => 'savedqueries', field: 'name', id: 'savedqueryid' } },
128
+ Workflow: { stored: 'Workflow', lookup: { path: () => 'workflows', field: 'name', id: 'workflowid' } },
129
+ WebResource: { stored: 'WebResource', lookup: { path: () => 'webresourceset', field: 'name', id: 'webresourceid' } },
130
+ PluginAssembly: { stored: 'PluginAssembly', lookup: { path: () => 'pluginassemblies', field: 'name', id: 'pluginassemblyid' } },
131
+ PluginType: { stored: 'PluginType', lookup: { path: () => 'plugintypes', field: 'typename', id: 'plugintypeid' } },
132
+ SdkMessageProcessingStep: {
133
+ stored: 'SdkMessageProcessingStep',
134
+ lookup: { path: () => 'sdkmessageprocessingsteps', field: 'name', id: 'sdkmessageprocessingstepid' },
135
+ },
136
+ CustomAPI: { stored: 'CustomAPI', lookup: { path: () => 'customapis', field: 'uniquename', id: 'customapiid' } },
137
+ // Global option sets live on their own metadata endpoint and are keyed by
138
+ // name, not by a record id. Local option sets are part of an attribute and
139
+ // are reached through `Attribute` instead — `GlobalOptionSetDefinitions`
140
+ // knows nothing about them.
141
+ OptionSet: {
142
+ stored: 'OptionSet',
143
+ lookup: { path: (name) => `GlobalOptionSetDefinitions(Name='${odataString(name)}')`, id: 'MetadataId' },
144
+ },
145
+ }
146
+
147
+ const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
148
+
149
+ /**
150
+ * Read at most one row, whether the path is keyed (`EntityDefinitions(...)`,
151
+ * which answers with a bare object and rejects `$top`) or a collection.
152
+ */
153
+ async function lookupOne(client, path, params, signal) {
154
+ // A keyed read (`/EntityDefinitions(LogicalName='x')`) answers 404 when the
155
+ // component is simply not in this org — that is a normal "absent here", not
156
+ // a transport failure, and must not abort a multi-environment call.
157
+ const absentAsUndefined = async (request) => {
158
+ try {
159
+ return await request()
160
+ } catch (error) {
161
+ if (error?.status === 404) return undefined
162
+ throw error
163
+ }
164
+ }
165
+
166
+ try {
167
+ return await absentAsUndefined(() => client.getOne(path, params, signal))
168
+ } catch (error) {
169
+ if (error?.status !== 400) throw error
170
+ // Keyed metadata paths reject `$top`, which `getOne` adds; retry without it.
171
+ return absentAsUndefined(async () => {
172
+ const payload = await client.requestJson(client.buildUrl(path, params), { signal })
173
+ return Array.isArray(payload?.value) ? payload.value[0] : payload
174
+ })
175
+ }
176
+ }
177
+
178
+ /** Resolve the argument to a component GUID for one environment. */
179
+ export async function resolveComponentId(client, args, signal) {
180
+ const type = args.componentType
181
+ const spec = COMPONENT_TYPES[type]
182
+ if (!spec) {
183
+ throw new D365Error(`不支持的组件类型:${type ?? '(未指定)'}`, {
184
+ code: 'bad-component-type',
185
+ hint: `可用:${Object.keys(COMPONENT_TYPES).join(', ')}`,
186
+ })
187
+ }
188
+
189
+ const raw = String(args.component ?? '').trim()
190
+ if (!raw) {
191
+ throw new D365Error('缺少 component', {
192
+ code: 'missing-component',
193
+ hint: type === 'Entity' ? '例如 component: "account"' : '传组件名或 GUID。',
194
+ })
195
+ }
196
+ if (GUID.test(raw)) return raw
197
+
198
+ if (spec.lookup.requiresEntity && !args.entity) {
199
+ throw new D365Error('查询字段(Attribute)的层需要 entity 参数', {
200
+ code: 'missing-entity',
201
+ hint: `例如 {"componentType":"Attribute","component":"name","entity":"account"}`,
202
+ })
203
+ }
204
+
205
+ const path = spec.lookup.path(raw, args)
206
+ const params = spec.lookup.field
207
+ ? { $select: spec.lookup.id, $filter: `${spec.lookup.field} eq '${odataString(raw)}'` }
208
+ : { $select: spec.lookup.id }
209
+ const row = await lookupOne(client, path, params, signal)
210
+ const id = row?.[spec.lookup.id]
211
+ if (!id) {
212
+ throw new D365Error(`找不到 ${type} "${raw}"`, {
213
+ code: 'component-not-found',
214
+ hint: type === 'OptionSet'
215
+ ? 'OptionSet 只按全局选项集的名字解析(GlobalOptionSetDefinitions)。本地选项集随字段存在,用 componentType=Attribute 加 entity 查字段的层。'
216
+ : '名字要精确匹配(Entity 用逻辑名,其它用记录名称/唯一名),也可以直接传组件 GUID。',
217
+ })
218
+ }
219
+ return id
220
+ }
221
+
222
+ /** Compose the layer filter for one resolved component. */
223
+ export function buildComponentFilter(componentId, componentType) {
224
+ const stored = COMPONENT_TYPES[componentType]?.stored ?? componentType
225
+ return `(msdyn_componentid eq '${odataString(componentId)}' and msdyn_solutioncomponentname eq '${odataString(stored)}')`
226
+ }
227
+
228
+ /** Group layer rows by component. */
229
+ function groupLayers(rows) {
230
+ const groups = new Map()
231
+ for (const row of rows) {
232
+ const type = row.msdyn_solutioncomponentname ?? '?'
233
+ const name = row.msdyn_name ?? '(未命名)'
234
+ const key = `${type}::${name}`
235
+ if (!groups.has(key)) groups.set(key, { type, name, layers: [] })
236
+ groups.get(key).layers.push(row)
237
+ }
238
+ for (const group of groups.values()) {
239
+ group.layers.sort((a, b) => Number(a.msdyn_order ?? 0) - Number(b.msdyn_order ?? 0))
240
+ group.active = group.layers[group.layers.length - 1]
241
+ }
242
+ return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type))
243
+ }
244
+
245
+ /** Short solution label; the system layer has no solution name. */
246
+ export const solutionLabel = (layer) => (layer?.msdyn_solutionname ? layer.msdyn_solutionname : '(系统层)')
247
+
248
+ /**
249
+ * Dataverse parks the unmanaged customization layer under the pseudo-solution
250
+ * name "Active" — it is the only layer a developer can remove, and it always
251
+ * sits on top of the managed stack when it exists.
252
+ */
253
+ export const isActiveLayer = (layer) => /^active$/i.test(layer?.msdyn_solutionname ?? '')
254
+
255
+ /** Layer label that says out loud which layer is the unmanaged one. */
256
+ function layerLabel(layer) {
257
+ if (isActiveLayer(layer)) return 'Active(未受管)'
258
+ return solutionLabel(layer)
259
+ }
260
+
261
+ /** Timestamps the provider leaves at DateTime.MinValue carry no information. */
262
+ function formatTime(value) {
263
+ if (!value) return '—'
264
+ const text = String(value).replace('T', ' ').slice(0, 19)
265
+ return /^(0001|0000|1899|1900)/.test(text) ? '—' : text
266
+ }
267
+
268
+ /**
269
+ * The Active layer's `msdyn_overwritetime` is always DateTime.MinValue, so a
270
+ * bare "—" would read like a formatting accident. Say what it means instead.
271
+ */
272
+ function formatLayerTime(layer) {
273
+ return isActiveLayer(layer) ? '不记录' : formatTime(layer?.msdyn_overwritetime)
274
+ }
275
+
276
+ /** One readable line for a per-environment failure. */
277
+ function describeFailure(error) {
278
+ // The D365Error message already carries a " 建议:" hint, which reads badly
279
+ // nested inside another sentence — say it plainly for this one case instead.
280
+ if (error?.code === 'component-not-found') {
281
+ return '该环境没有这个组件;常见于它还没随解决方案导入到这里。其它环境的结果和对比不受影响。'
282
+ }
283
+ return String(error?.message ?? error ?? '未知错误')
284
+ }
285
+
286
+ /** Render one environment's layer stack. */
287
+ function renderEnvironment(result, includeJson) {
288
+ const lines = []
289
+ const groups = groupLayers(result.rows)
290
+ lines.push(
291
+ `### ${result.env.key} — ${result.rows.length} 条层记录,覆盖 ${groups.length} 个组件`
292
+ + `${result.hasMore ? '(已截断,可加 filter 缩小范围)' : ''}`,
293
+ )
294
+ if (!groups.length) {
295
+ lines.push('没有匹配的层记录。组件只在被解决方案承载时才有层;系统内置、未进任何解决方案的组件查不到层。')
296
+ return { lines, groups }
297
+ }
298
+
299
+ const summaryRows = groups.map((group) => ({
300
+ 组件: group.name,
301
+ 类型: group.type,
302
+ 层数: group.layers.length,
303
+ 活动层: layerLabel(group.active),
304
+ 层序: group.active?.msdyn_order ?? '',
305
+ 更新时间: formatLayerTime(group.active),
306
+ }))
307
+ lines.push('')
308
+ lines.push('活动层(层序最大者为当前生效层):')
309
+ lines.push(formatTable(summaryRows, ['组件', '类型', '层数', '活动层', '层序', '更新时间'], { limit: INLINE_ROWS }))
310
+
311
+ // Say which situation the environment is in. "Active" as a bare solution
312
+ // name reads like just another solution, and its absence is easy to miss.
313
+ const unmanaged = groups.filter((group) => isActiveLayer(group.active))
314
+ if (unmanaged.length) {
315
+ lines.push('')
316
+ lines.push(`其中 ${unmanaged.length} 个组件最上层是未受管的自定义层(Active):它盖住了下面的受管层,`
317
+ + '删掉它才会回落到受管层(d365_write plan-remove-layer)。Active 层的更新时间不记录,显示为「不记录」。')
318
+ } else {
319
+ lines.push('')
320
+ lines.push('该环境没有未受管的自定义层:每个组件最上层都是某个解决方案里的受管层,没有可移除的 Active 层。')
321
+ }
322
+
323
+ const stacked = groups.filter((group) => group.layers.filter((layer) => layer.msdyn_solutionname).length > 1)
324
+ if (stacked.length) {
325
+ lines.push('')
326
+ lines.push(`被多个解决方案覆盖的组件:${stacked.length} 个(这类组件最容易出现环境间不一致)`)
327
+ for (const group of stacked.slice(0, 10)) {
328
+ const chain = group.layers.map((layer) => `${layer.msdyn_order}:${layerLabel(layer)}`).join(' → ')
329
+ lines.push(`- ${group.name}(${group.type}):${chain}`)
330
+ }
331
+ if (stacked.length > 10) lines.push(`- … 其余 ${stacked.length - 10} 个见文件`)
332
+ }
333
+
334
+ if (includeJson) lines.push('', '各层的 msdyn_componentjson 已写入结果文件(未在此展开,单条可达数百 MB)。')
335
+ return { lines, groups }
336
+ }
337
+
338
+ export function register(ctx, state) {
339
+ return ctx.tools.register({
340
+ ...definition,
341
+ async execute(args = {}, exec) {
342
+ const config = await loadConfig(state.rawConfig)
343
+ const environments = pickEnvironments(config, args)
344
+ const top = Number.isFinite(args.top) ? Math.max(1, Math.min(5000, Math.trunc(args.top))) : 500
345
+ const select = [...BASE_SELECT, args.includeJson ? 'msdyn_componentjson' : null].filter(Boolean).join(',')
346
+
347
+ const results = []
348
+ for (const env of environments) {
349
+ try {
350
+ const client = makeClient(env, config)
351
+ const componentId = await resolveComponentId(client, args, exec?.signal)
352
+ const componentFilter = buildComponentFilter(componentId, args.componentType)
353
+ // `solution` has to go into `$filter`, not after paging: a component
354
+ // can have more layers than `top`, and filtering the returned page
355
+ // would then report "this solution has no layer" for a layer that
356
+ // exists but fell outside the page.
357
+ const filter = args.solution
358
+ ? `${componentFilter} and msdyn_solutionname eq '${odataString(args.solution)}'`
359
+ : componentFilter
360
+ // No `$orderby`: the virtual provider only guarantees the id+type
361
+ // filter, and ordering is applied after the rows arrive.
362
+ const { page, selectDropped } = await getPagedWithSelectFallback(
363
+ client,
364
+ 'msdyn_componentlayers',
365
+ { $select: select, $filter: filter },
366
+ top,
367
+ exec?.signal,
368
+ )
369
+ const rows = page.records
370
+ results.push({
371
+ env,
372
+ ok: true,
373
+ componentId,
374
+ rows: rows.sort((a, b) => Number(a.msdyn_order ?? 0) - Number(b.msdyn_order ?? 0)),
375
+ hasMore: page.hasMore,
376
+ selectDropped,
377
+ filter,
378
+ })
379
+ } catch (error) {
380
+ // One environment missing the component must not sink the others:
381
+ // this is the common "component exists in UAT but not in SIT" case.
382
+ results.push({ env, ok: false, error })
383
+ }
384
+ }
385
+ if (results.length && results.every((result) => !result.ok)) throw results[0].error
386
+
387
+ const lines = [`查询解决方案层:${environments.map((env) => env.key).join(' / ')}`]
388
+ lines.push(`组件:${args.componentType ?? '?'} / ${args.component ?? '?'}${args.entity ? `(${args.entity})` : ''}`)
389
+ if (args.solution) lines.push(`只看解决方案:${args.solution}`)
390
+
391
+ const perEnv = []
392
+ for (const result of results) {
393
+ lines.push('')
394
+ if (!result.ok) {
395
+ lines.push(`### ${result.env.key} — 查询失败`)
396
+ lines.push(`- ${describeFailure(result.error)}`)
397
+ continue
398
+ }
399
+ const rendered = renderEnvironment(result, args.includeJson)
400
+ lines.push(...rendered.lines)
401
+ if (result.selectDropped) lines.push('(该环境不接受部分 $select 列,已自动降级)')
402
+ perEnv.push({ env: result.env, groups: rendered.groups })
403
+ }
404
+
405
+ if (perEnv.length > 1) {
406
+ lines.push('')
407
+ lines.push('### 跨环境对比')
408
+ const maps = perEnv.map((entry) => ({
409
+ env: entry.env.key,
410
+ map: new Map(entry.groups.map((group) => [`${group.type}::${group.name}`, group])),
411
+ }))
412
+ const allKeys = [...new Set(maps.flatMap((entry) => [...entry.map.keys()]))]
413
+ const diffs = []
414
+ for (const key of allKeys) {
415
+ const name = key.split('::')[1]
416
+ const present = maps.filter((entry) => entry.map.has(key))
417
+ const absent = maps.filter((entry) => !entry.map.has(key))
418
+ if (absent.length) {
419
+ diffs.push(`${name} 有层记录的环境:${present.map((entry) => entry.env).join('/')};`
420
+ + `无:${absent.map((entry) => entry.env).join('/')}`)
421
+ continue
422
+ }
423
+
424
+ // Compare the whole stack, not just the winner: a solution present in
425
+ // one environment shifts every layer above it, which is exactly the
426
+ // drift this comparison is for.
427
+ const stacks = maps.map((entry) => ({
428
+ env: entry.env,
429
+ layers: new Map(entry.map.get(key).layers.map((layer) => [layerLabel(layer), Number(layer.msdyn_order ?? 0)])),
430
+ }))
431
+ const allSolutions = [...new Set(stacks.flatMap((entry) => [...entry.layers.keys()]))]
432
+ // Group the differing solutions by *which* environments carry them, so
433
+ // four environments read as two statements instead of ten.
434
+ const buckets = new Map()
435
+ for (const solution of allSolutions) {
436
+ const carriers = stacks.filter((entry) => entry.layers.has(solution)).map((entry) => entry.env)
437
+ if (carriers.length === stacks.length) continue
438
+ const bucketKey = carriers.join('/')
439
+ if (!buckets.has(bucketKey)) buckets.set(bucketKey, { carriers, solutions: [] })
440
+ buckets.get(bucketKey).solutions.push(solution)
441
+ }
442
+ if (buckets.size) {
443
+ const detail = [...buckets.values()].map((bucket) => {
444
+ const missing = stacks.filter((entry) => !bucket.carriers.includes(entry.env)).map((entry) => entry.env)
445
+ return ` - 仅 ${bucket.carriers.join('/')} 有(${bucket.solutions.length} 个,${missing.join('/')} 没有):${bucket.solutions.join('、')}`
446
+ })
447
+ diffs.push(`${name} 层结构不同:\n${detail.join('\n')}`)
448
+ }
449
+
450
+ const actives = maps.map((entry) => `${entry.env}=${layerLabel(entry.map.get(key).active)}`)
451
+ if (new Set(maps.map((entry) => layerLabel(entry.map.get(key).active))).size > 1) {
452
+ diffs.push(`${name} 活动层不同:${actives.join(' / ')}`)
453
+ }
454
+ }
455
+ if (!diffs.length) lines.push('各环境的层结构与活动层一致。')
456
+ else {
457
+ for (const line of diffs.slice(0, 15)) lines.push(`- ${line}`)
458
+ if (diffs.length > 15) lines.push(`- … 其余 ${diffs.length - 15} 条见文件`)
459
+ }
460
+ }
461
+
462
+ const path = await writeArtifact(config, `layers-${environments.map((env) => env.key).join('-')}`, {
463
+ args,
464
+ generatedAt: new Date().toISOString(),
465
+ environments: results.map((result) => (result.ok
466
+ ? {
467
+ env: result.env.key,
468
+ url: result.env.url,
469
+ ok: true,
470
+ componentId: result.componentId,
471
+ filter: result.filter,
472
+ hasMore: result.hasMore,
473
+ rows: result.rows,
474
+ }
475
+ : {
476
+ env: result.env.key,
477
+ url: result.env.url,
478
+ ok: false,
479
+ error: String(result.error?.message ?? result.error ?? ''),
480
+ })),
481
+ })
482
+ lines.push('', `完整层数据:${path}`)
483
+ lines.push('说明:层序(msdyn_order)越大越靠上,最大者即当前生效层;系统层表示未由任何解决方案承载。')
484
+
485
+ return { text: lines.join('\n') }
486
+ },
487
+ presentCall(args = {}) {
488
+ const envs = args.allEnvironments ? '全部环境' : (args.environments ?? []).join('/') || '默认环境'
489
+ return {
490
+ card: 'generic',
491
+ title: `查询解决方案层${args.component ? `:${args.component}` : ''}(${envs})`,
492
+ kind: 'other',
493
+ rawInput: args,
494
+ }
495
+ },
496
+ })
497
+ }