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,1374 +1,1374 @@
1
- /**
2
- * The only write path in the plugin.
3
- *
4
- * Every operation is one official Dataverse unbound action —
5
- * `InsertOptionValue`, `UpdateOptionValue` — and nothing else. A plan is built
6
- * read-only, shown to a person in the settings page, and executed only by that
7
- * person's confirmation. Results are appended to the change ledger.
8
- */
9
- import { DataverseClient } from './client.js'
10
- import { getPagedWithSelectFallback } from './client.js'
11
- import { D365Error } from './errors.js'
12
- import { busyError, checkEnvironmentIdle } from './preflight.js'
13
- import { allows, readPolicy } from './write-policy.js'
14
- import { createPlan, recordChange, setPlanStatus, writePublishReport } from './write-store.js'
15
- import { COMPONENT_TYPES, buildComponentFilter, resolveComponentId, solutionLabel } from './tool-layers.js'
16
-
17
- const DEFAULT_LANGUAGE = 1033
18
-
19
- /** PublishAllXml gets its own budget: it is a whole-organization operation. */
20
- const PUBLISH_TIMEOUT_MS = 180_000
21
-
22
- /**
23
- * Resolve which option set a request targets: a global one by name, or the
24
- * local option set owned by a single picklist column.
25
- */
26
- function optionTarget(request) {
27
- const optionSetName = String(request?.optionSetName ?? '').trim()
28
- const entity = String(request?.entity ?? '').trim()
29
- const attribute = String(request?.attribute ?? '').trim()
30
- if (entity && attribute) return { kind: 'local', entity, attribute }
31
- if (optionSetName) return { kind: 'global', name: optionSetName }
32
- throw new D365Error('缺少 optionSetName,或 entity + attribute(本地选项集)', {
33
- code: 'missing-option-set',
34
- hint: '全局选项集传 optionSet;字段自带的选项集传 entity 与 attribute。',
35
- })
36
- }
37
-
38
- /** Human-readable name of an option target. */
39
- function targetLabel(target) {
40
- return target.kind === 'local' ? `${target.entity}.${target.attribute}` : target.name
41
- }
42
-
43
- /**
44
- * Read one option set — global by name, or the one attached to a column.
45
- *
46
- * A column's local option set is only reachable through a type cast; which cast
47
- * applies depends on whether the column is single- or multi-select, and some
48
- * deployments reject the multi-select cast for a plain picklist. Trying both
49
- * beats making the caller know the difference.
50
- */
51
- async function readOptionSet(client, target, signal) {
52
- let definition
53
- if (target.kind === 'local') {
54
- const base = `EntityDefinitions(LogicalName='${escapeName(target.entity)}')`
55
- + `/Attributes(LogicalName='${escapeName(target.attribute)}')`
56
- const params = { $expand: 'OptionSet($select=Options,IsGlobal,Name)' }
57
- const casts = [
58
- 'Microsoft.Dynamics.CRM.PicklistAttributeMetadata',
59
- 'Microsoft.Dynamics.CRM.MultiSelectPicklistAttributeMetadata',
60
- '',
61
- ]
62
- let failure
63
- for (const cast of casts) {
64
- try {
65
- definition = await client.requestJson(client.buildUrl(cast ? `${base}/${cast}` : base, params), { signal })
66
- break
67
- } catch (error) {
68
- failure = error
69
- }
70
- }
71
- if (!definition) {
72
- throw new D365Error(`字段 ${target.entity}.${target.attribute} 的选项集读取失败`, {
73
- code: 'option-set-unreadable',
74
- hint: `${failure?.message ?? ''} 该字段可能不是选项集字段。`,
75
- })
76
- }
77
- } else {
78
- definition = await client.requestJson(
79
- client.buildUrl(`GlobalOptionSets(Name='${escapeName(target.name)}')`),
80
- { signal },
81
- ).catch(async () => {
82
- // Some deployments expose the keyed form under the pluralized entity set.
83
- return client.requestJson(
84
- client.buildUrl(`GlobalOptionSetDefinitions(Name='${escapeName(target.name)}')`),
85
- { signal },
86
- )
87
- })
88
- }
89
-
90
- const source = definition?.OptionSet ?? definition
91
- const options = (source?.Options ?? []).map((option) => ({
92
- value: Number(option.Value),
93
- label: option.Label?.UserLocalizedLabel?.Label
94
- ?? option.Label?.LocalizedLabels?.[0]?.Label
95
- ?? '',
96
- }))
97
- return { name: source?.Name ?? targetLabel(target), options }
98
- }
99
-
100
- function codeOf(label) {
101
- return String(label ?? '').split('|')[0].trim()
102
- }
103
-
104
- /**
105
- * Verify the solution a change is supposed to land in, before a plan is saved.
106
- *
107
- * The `MSCRM.SolutionUniqueName` header only records a change inside an
108
- * existing, unmanaged solution; a typo would otherwise surface after a person
109
- * already confirmed the plan. Checking here turns it into a normal planning
110
- * error with a chance to fix the name first.
111
- */
112
- async function assertSolution(client, solutionUniqueName, signal) {
113
- const name = String(solutionUniqueName ?? '').trim()
114
- if (!name) return
115
- const safe = name.replace(/'/g, "''")
116
- const body = await client.requestJson(
117
- client.buildUrl('solutions', { $select: 'uniquename,ismanaged', $filter: `uniquename eq '${safe}'` }),
118
- { signal },
119
- )
120
- const row = body?.value?.[0]
121
- if (!row) {
122
- throw new D365Error(`解决方案不存在:${name}`, {
123
- code: 'unknown-solution',
124
- hint: '检查解决方案唯一名。改动只能落在一个已存在的未受管解决方案里;留空则进默认解决方案。',
125
- })
126
- }
127
- const managed = row.ismanaged ?? row.IsManaged
128
- if (managed) {
129
- throw new D365Error(`解决方案 ${name} 是受管的,不能写入`, {
130
- code: 'managed-solution',
131
- hint: '受管解决方案不能再接受元数据改动;改用承载本项目改动的未受管解决方案。',
132
- })
133
- }
134
- }
135
-
136
- /**
137
- * Build an "add missing options" plan.
138
- * @param request - `{ optionSetName | entity+attribute, labels: string[], solutionUniqueName? }`
139
- * where each label is `CODE|Description`.
140
- */
141
- export async function planOptionAdd(config, envKey, request, signal) {
142
- const env = config.environments[envKey]
143
- if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
144
-
145
- const target = optionTarget(request)
146
- const labels = Array.isArray(request?.labels) ? request.labels.map((item) => String(item).trim()).filter(Boolean) : []
147
- if (!labels.length) throw new D365Error('缺少要新增的选项列表(labels)', { code: 'missing-labels' })
148
-
149
- const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
150
- await assertSolution(client, request?.solutionUniqueName, signal)
151
- const { options } = await readOptionSet(client, target, signal)
152
-
153
- const existing = new Map()
154
- for (const option of options) {
155
- const code = codeOf(option.label)
156
- if (code && !existing.has(code)) existing.set(code, option)
157
- }
158
-
159
- let nextValue = options.length ? Math.max(...options.map((option) => option.value)) + 1 : 1
160
- const operations = []
161
- const skipped = []
162
- const scope = target.kind === 'local'
163
- ? { entity: target.entity, attribute: target.attribute }
164
- : { optionSetName: target.name }
165
-
166
- for (const label of labels) {
167
- const code = codeOf(label)
168
- if (existing.has(code)) {
169
- skipped.push({ code, value: existing.get(code).value, label: existing.get(code).label })
170
- continue
171
- }
172
- operations.push({
173
- type: 'InsertOptionValue',
174
- ...scope,
175
- value: nextValue,
176
- label,
177
- languageCode: optionLanguage(request),
178
- solutionUniqueName: request?.solutionUniqueName ?? undefined,
179
- summary: `${targetLabel(target)} +${nextValue} ${label}`,
180
- })
181
- nextValue += 1
182
- }
183
-
184
- const maxValue = options.length ? Math.max(...options.map((option) => option.value)) : 0
185
- return {
186
- kind: 'optionValue.add',
187
- capability: 'optionValue.add',
188
- title: `${env.key}:向 ${targetLabel(target)} 新增 ${operations.length} 个选项`,
189
- envKey: env.key,
190
- envUrl: env.url,
191
- ...scope,
192
- optionTarget: target.kind,
193
- before: { totalOptions: options.length, maxValue },
194
- operations,
195
- skipped,
196
- requestedBy: request?.requestedBy ?? 'agent',
197
- rationale: request?.rationale ?? '',
198
- }
199
- }
200
-
201
- /**
202
- * Build a "relabel existing options" plan.
203
- * @param request - `{ optionSetName | entity+attribute, updates: [{ value, label }] }`
204
- */
205
- export async function planOptionRelabel(config, envKey, request, signal) {
206
- const env = config.environments[envKey]
207
- if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
208
-
209
- const target = optionTarget(request)
210
- const updates = Array.isArray(request?.updates) ? request.updates : []
211
- if (!updates.length) throw new D365Error('缺少 updates', { code: 'missing-updates' })
212
-
213
- const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
214
- await assertSolution(client, request?.solutionUniqueName, signal)
215
- const { options } = await readOptionSet(client, target, signal)
216
- const byValue = new Map(options.map((option) => [Number(option.value), option]))
217
- const scope = target.kind === 'local'
218
- ? { entity: target.entity, attribute: target.attribute }
219
- : { optionSetName: target.name }
220
-
221
- const operations = []
222
- const skipped = []
223
- for (const update of updates) {
224
- const value = Number(update?.value)
225
- const label = String(update?.label ?? '').trim()
226
- const current = byValue.get(value)
227
- if (!current) {
228
- skipped.push({ value, label, reason: '选项不存在' })
229
- continue
230
- }
231
- if (!label || current.label === label) {
232
- skipped.push({ value, label, reason: '标签相同' })
233
- continue
234
- }
235
- operations.push({
236
- type: 'UpdateOptionValue',
237
- ...scope,
238
- value,
239
- label,
240
- languageCode: optionLanguage(request),
241
- solutionUniqueName: request?.solutionUniqueName ?? undefined,
242
- previousLabel: current.label,
243
- summary: `${targetLabel(target)} ~${value} ${current.label} → ${label}`,
244
- })
245
- }
246
-
247
- return {
248
- kind: 'optionValue.updateLabel',
249
- capability: 'optionValue.updateLabel',
250
- title: `${env.key}:修改 ${targetLabel(target)} 的 ${operations.length} 个选项标签`,
251
- envKey: env.key,
252
- envUrl: env.url,
253
- ...scope,
254
- optionTarget: target.kind,
255
- before: { totalOptions: options.length },
256
- operations,
257
- skipped,
258
- requestedBy: request?.requestedBy ?? 'agent',
259
- rationale: request?.rationale ?? '',
260
- }
261
- }
262
-
263
- /**
264
- * Build a "delete option values" plan. Destructive: the caller must acknowledge.
265
- * @param request - `{ optionSetName | entity+attribute, values: number[], acknowledge: true }`
266
- */
267
- export async function planOptionDelete(config, envKey, request, signal) {
268
- const env = config.environments[envKey]
269
- if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
270
-
271
- if (request?.acknowledge !== true) {
272
- throw new D365Error('删除选项值不可恢复,必须先明确确认(acknowledge: true)', {
273
- code: 'destructive-not-acknowledged',
274
- hint: '已有数据如果引用该值,删除会失败;能删掉的标签也找不回来。确认后再生成计划。',
275
- })
276
- }
277
-
278
- const target = optionTarget(request)
279
- const values = (Array.isArray(request?.values) ? request.values : [])
280
- .map((value) => Number(value))
281
- .filter((value) => Number.isFinite(value))
282
- if (!values.length) throw new D365Error('缺少要删除的选项值(values)', { code: 'missing-values' })
283
-
284
- const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
285
- await assertSolution(client, request?.solutionUniqueName, signal)
286
- const { options } = await readOptionSet(client, target, signal)
287
- const byValue = new Map(options.map((option) => [Number(option.value), option]))
288
- const scope = target.kind === 'local'
289
- ? { entity: target.entity, attribute: target.attribute }
290
- : { optionSetName: target.name }
291
-
292
- const operations = []
293
- const skipped = []
294
- for (const value of values) {
295
- const current = byValue.get(value)
296
- if (!current) {
297
- skipped.push({ value, reason: '选项不存在' })
298
- continue
299
- }
300
- operations.push({
301
- type: 'DeleteOptionValue',
302
- ...scope,
303
- value,
304
- previousLabel: current.label,
305
- solutionUniqueName: request?.solutionUniqueName ?? undefined,
306
- summary: `删除选项 ${value}(${current.label})`,
307
- })
308
- }
309
-
310
- return {
311
- kind: 'optionValue.delete',
312
- capability: 'optionValue.delete',
313
- title: `${env.key}:从 ${targetLabel(target)} 删除 ${operations.length} 个选项`,
314
- envKey: env.key,
315
- envUrl: env.url,
316
- ...scope,
317
- optionTarget: target.kind,
318
- before: { totalOptions: options.length },
319
- operations,
320
- skipped,
321
- requestedBy: request?.requestedBy ?? 'agent',
322
- rationale: request?.rationale ?? '',
323
- }
324
- }
325
-
326
- /** Escape a logical name for use inside an OData key segment. */
327
- function escapeName(value) {
328
- return String(value ?? '').replace(/'/g, "''").trim()
329
- }
330
-
331
- const REQUIRED_LEVELS = ['None', 'Recommended', 'Required']
332
-
333
- /** Column kinds this plugin can create, with their metadata defaults. */
334
- const ATTRIBUTE_TYPES = {
335
- string: {
336
- label: '文本',
337
- odata: 'Microsoft.Dynamics.CRM.StringAttributeMetadata',
338
- extra: (request) => ({
339
- MaxLength: Math.min(4000, Math.max(1, Number(request.maxLength ?? 100) || 100)),
340
- FormatName: { Value: request.format === 'Email' || request.format === 'Url' ? request.format : 'Text' },
341
- }),
342
- },
343
- memo: {
344
- label: '多行文本',
345
- odata: 'Microsoft.Dynamics.CRM.MemoAttributeMetadata',
346
- extra: (request) => ({ MaxLength: Math.min(1048576, Math.max(1, Number(request.maxLength ?? 2000) || 2000)) }),
347
- },
348
- integer: {
349
- label: '整数',
350
- odata: 'Microsoft.Dynamics.CRM.IntegerAttributeMetadata',
351
- extra: () => ({ Format: { Value: 'None' } }),
352
- },
353
- decimal: {
354
- label: '小数',
355
- odata: 'Microsoft.Dynamics.CRM.DecimalAttributeMetadata',
356
- extra: (request) => ({ Precision: Math.min(10, Math.max(0, Number(request.precision ?? 2) || 2)) }),
357
- },
358
- money: {
359
- label: '货币',
360
- odata: 'Microsoft.Dynamics.CRM.MoneyAttributeMetadata',
361
- extra: (request) => ({
362
- Precision: Math.min(4, Math.max(0, Number(request.precision ?? 2) || 2)),
363
- MaxValue: 1000000000000,
364
- MinValue: -1000000000000,
365
- }),
366
- },
367
- boolean: {
368
- label: '是/否',
369
- odata: 'Microsoft.Dynamics.CRM.BooleanAttributeMetadata',
370
- extra: (request) => {
371
- const language = Number(request.language ?? DEFAULT_LANGUAGE)
372
- return {
373
- DefaultValue: false,
374
- OptionSet: {
375
- TrueOption: { Value: 1, Label: labelBody(request.trueLabel ?? '是', language) },
376
- FalseOption: { Value: 0, Label: labelBody(request.falseLabel ?? '否', language) },
377
- },
378
- }
379
- },
380
- },
381
- datetime: {
382
- label: '日期时间',
383
- odata: 'Microsoft.Dynamics.CRM.DateTimeAttributeMetadata',
384
- extra: (request) => ({ Format: { Value: request.dateOnly ? 'DateOnly' : 'DateAndTime' } }),
385
- },
386
- }
387
-
388
- /** Build the JSON definition for a new column. */
389
- function attributeDefinition(request) {
390
- const type = String(request.type ?? 'string').toLowerCase()
391
- const spec = ATTRIBUTE_TYPES[type]
392
- if (!spec) {
393
- throw new D365Error(`不支持的字段类型:${type}`, {
394
- code: 'bad-attribute-type',
395
- hint: `可用类型:${Object.keys(ATTRIBUTE_TYPES).join(', ')}`,
396
- })
397
- }
398
-
399
- const schemaName = String(request.schemaName ?? '').trim()
400
- if (!/^[A-Za-z][A-Za-z0-9_]{1,49}$/.test(schemaName)) {
401
- throw new D365Error(`字段名不合法:${schemaName}`, {
402
- code: 'bad-schema-name',
403
- hint: '必须以字母开头,只含字母、数字、下划线,且带发布者前缀(如 new_)。',
404
- })
405
- }
406
-
407
- const displayName = String(request.displayName ?? schemaName).trim()
408
- const language = Number(request.language ?? DEFAULT_LANGUAGE)
409
- const requiredLevel = REQUIRED_LEVELS.includes(request.requiredLevel) ? request.requiredLevel : 'None'
410
- const description = String(request.description ?? '').trim()
411
-
412
- return {
413
- '@odata.type': spec.odata,
414
- SchemaName: schemaName,
415
- DisplayName: labelBody(displayName, language),
416
- ...(description ? { Description: labelBody(description, language) } : {}),
417
- RequiredLevel: { Value: requiredLevel },
418
- ...spec.extra({ ...request, language }),
419
- }
420
- }
421
-
422
- /** Read one column's metadata; rejects when it does not exist. */
423
- async function readAttribute(client, entity, logicalName, signal) {
424
- const path = `EntityDefinitions(LogicalName='${escapeName(entity)}')/Attributes(LogicalName='${escapeName(logicalName)}')`
425
- return client.requestJson(client.buildUrl(path), { signal })
426
- }
427
-
428
- /** Read one table's metadata. */
429
- async function readEntity(client, entity, signal) {
430
- const path = `EntityDefinitions(LogicalName='${escapeName(entity)}')`
431
- return client.requestJson(client.buildUrl(path, { $select: 'LogicalName,DisplayName,Description' }), { signal })
432
- .catch(() => client.requestJson(client.buildUrl(path), { signal }))
433
- }
434
-
435
- /** A label's display text, whichever shape the server used. */
436
- function labelText(label) {
437
- return label?.UserLocalizedLabel?.Label ?? label?.LocalizedLabels?.[0]?.Label ?? ''
438
- }
439
-
440
- /**
441
- * Build a "create a column" plan.
442
- * @param request - `{ entity, schemaName, displayName?, type?, maxLength?,
443
- * precision?, requiredLevel?, description?, language?, solutionUniqueName? }`
444
- */
445
- export async function planAddAttribute(config, envKey, request, signal) {
446
- const env = config.environments[envKey]
447
- if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
448
-
449
- const entity = String(request?.entity ?? '').trim()
450
- if (!entity) throw new D365Error('缺少 entity(表逻辑名)', { code: 'missing-entity' })
451
-
452
- const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
453
- await assertSolution(client, request?.solutionUniqueName, signal)
454
- await readEntity(client, entity, signal)
455
-
456
- const definition = attributeDefinition(request)
457
- const operations = []
458
- const skipped = []
459
-
460
- const exists = await readAttribute(client, entity, definition.SchemaName, signal).catch(() => null)
461
- if (exists) {
462
- skipped.push({ schemaName: definition.SchemaName, reason: '字段已存在' })
463
- } else {
464
- operations.push({
465
- type: 'CreateAttribute',
466
- entity,
467
- attribute: definition,
468
- schemaName: definition.SchemaName,
469
- solutionUniqueName: request?.solutionUniqueName ?? undefined,
470
- summary: `${entity}.${definition.SchemaName}(${ATTRIBUTE_TYPES[String(request.type ?? 'string').toLowerCase()].label})`,
471
- })
472
- }
473
-
474
- return {
475
- kind: 'attribute.add',
476
- capability: 'attribute.add',
477
- title: `${env.key}:在 ${entity} 上新增 ${operations.length} 个字段`,
478
- envKey: env.key,
479
- envUrl: env.url,
480
- target: `${entity}.${definition.SchemaName}`,
481
- before: { entity, exists: Boolean(exists) },
482
- operations,
483
- skipped,
484
- requestedBy: request?.requestedBy ?? 'agent',
485
- rationale: request?.rationale ?? '',
486
- }
487
- }
488
-
489
- /**
490
- * Build an "update a column's label" plan.
491
- * @param request - `{ entity, attribute, displayName?, description?, requiredLevel? }`
492
- */
493
- export async function planUpdateAttribute(config, envKey, request, signal) {
494
- const env = config.environments[envKey]
495
- if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
496
-
497
- const entity = String(request?.entity ?? '').trim()
498
- const attributeName = String(request?.attribute ?? request?.schemaName ?? '').trim()
499
- if (!entity) throw new D365Error('缺少 entity(表逻辑名)', { code: 'missing-entity' })
500
- if (!attributeName) throw new D365Error('缺少 attribute(字段逻辑名)', { code: 'missing-attribute' })
501
-
502
- const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
503
- await assertSolution(client, request?.solutionUniqueName, signal)
504
- const current = await readAttribute(client, entity, attributeName, signal).catch((error) => {
505
- throw new D365Error(`字段 ${entity}.${attributeName} 不存在或无法读取`, {
506
- code: 'attribute-not-found',
507
- hint: error?.message,
508
- })
509
- })
510
-
511
- const language = Number(request?.language ?? DEFAULT_LANGUAGE)
512
- const changes = {
513
- '@odata.type': current['@odata.type'] ?? 'Microsoft.Dynamics.CRM.AttributeMetadata',
514
- LogicalName: current.LogicalName ?? attributeName,
515
- }
516
- const before = {
517
- displayName: labelText(current.DisplayName),
518
- description: labelText(current.Description),
519
- requiredLevel: current.RequiredLevel?.Value ?? 'None',
520
- }
521
- const parts = []
522
-
523
- const displayName = String(request?.displayName ?? '').trim()
524
- if (displayName && displayName !== before.displayName) {
525
- changes.DisplayName = labelBody(displayName, language)
526
- parts.push(`显示名 ${before.displayName || '∅'}→${displayName}`)
527
- }
528
- const description = String(request?.description ?? '').trim()
529
- if (description && description !== before.description) {
530
- changes.Description = labelBody(description, language)
531
- parts.push('描述')
532
- }
533
- const requiredLevel = String(request?.requiredLevel ?? '').trim()
534
- if (requiredLevel && REQUIRED_LEVELS.includes(requiredLevel) && requiredLevel !== before.requiredLevel) {
535
- changes.RequiredLevel = { Value: requiredLevel }
536
- parts.push(`必填级别 ${before.requiredLevel}→${requiredLevel}`)
537
- }
538
-
539
- const operations = parts.length
540
- ? [{
541
- type: 'UpdateAttribute',
542
- entity,
543
- attributeName: current.LogicalName ?? attributeName,
544
- changes,
545
- solutionUniqueName: request?.solutionUniqueName ?? undefined,
546
- summary: `${entity}.${current.LogicalName ?? attributeName}:${parts.join(';')}`,
547
- }]
548
- : []
549
-
550
- return {
551
- kind: 'attribute.update',
552
- capability: 'attribute.update',
553
- title: `${env.key}:修改 ${entity}.${attributeName} 的显示信息`,
554
- envKey: env.key,
555
- envUrl: env.url,
556
- target: `${entity}.${attributeName}`,
557
- before,
558
- operations,
559
- skipped: operations.length ? [] : [{ attribute: attributeName, reason: '请求的值与现值相同' }],
560
- requestedBy: request?.requestedBy ?? 'agent',
561
- rationale: request?.rationale ?? '',
562
- }
563
- }
564
-
565
- /**
566
- * Build a "delete a column" plan. Destructive: the caller must acknowledge it.
567
- * @param request - `{ entity, attribute, acknowledge: true }`
568
- */
569
- export async function planDeleteAttribute(config, envKey, request, signal) {
570
- const env = config.environments[envKey]
571
- if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
572
-
573
- if (request?.acknowledge !== true) {
574
- throw new D365Error('删除字段不可恢复,必须先明确确认(acknowledge: true)', {
575
- code: 'destructive-not-acknowledged',
576
- hint: '这是不可逆操作:列数据随列一起删除。确认后再生成计划,计划仍会在设置页等待人工确认。',
577
- })
578
- }
579
-
580
- const entity = String(request?.entity ?? '').trim()
581
- const attributeName = String(request?.attribute ?? '').trim()
582
- if (!entity || !attributeName) {
583
- throw new D365Error('缺少 entity 或 attribute', { code: 'missing-target' })
584
- }
585
-
586
- const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
587
- await assertSolution(client, request?.solutionUniqueName, signal)
588
- const current = await readAttribute(client, entity, attributeName, signal).catch(() => null)
589
-
590
- const operations = current
591
- ? [{
592
- type: 'DeleteAttribute',
593
- entity,
594
- attributeName: current.LogicalName ?? attributeName,
595
- // Without this the delete lands in the default solution instead of the
596
- // one the caller named — the other planners all carry it.
597
- solutionUniqueName: request?.solutionUniqueName ?? undefined,
598
- displayName: labelText(current.DisplayName),
599
- summary: `删除字段 ${entity}.${current.LogicalName ?? attributeName}(${labelText(current.DisplayName) || '无显示名'})`,
600
- }]
601
- : []
602
-
603
- return {
604
- kind: 'attribute.delete',
605
- capability: 'attribute.delete',
606
- title: `${env.key}:删除 ${entity}.${attributeName}`,
607
- envKey: env.key,
608
- envUrl: env.url,
609
- target: `${entity}.${attributeName}`,
610
- before: { exists: Boolean(current), displayName: labelText(current?.DisplayName) },
611
- operations,
612
- skipped: operations.length ? [] : [{ attribute: attributeName, reason: '字段不存在' }],
613
- requestedBy: request?.requestedBy ?? 'agent',
614
- rationale: request?.rationale ?? '',
615
- }
616
- }
617
-
618
- /**
619
- * Build an "update a table's label" plan.
620
- * @param request - `{ entity, displayName?, description? }`
621
- */
622
- export async function planUpdateEntity(config, envKey, request, signal) {
623
- const env = config.environments[envKey]
624
- if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
625
-
626
- const entity = String(request?.entity ?? '').trim()
627
- if (!entity) throw new D365Error('缺少 entity(表逻辑名)', { code: 'missing-entity' })
628
-
629
- const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
630
- await assertSolution(client, request?.solutionUniqueName, signal)
631
- const current = await readEntity(client, entity, signal)
632
-
633
- const language = Number(request?.language ?? DEFAULT_LANGUAGE)
634
- const before = {
635
- displayName: labelText(current.DisplayName),
636
- description: labelText(current.Description),
637
- }
638
- const changes = { '@odata.type': 'Microsoft.Dynamics.CRM.EntityMetadata', LogicalName: current.LogicalName ?? entity }
639
- const parts = []
640
-
641
- const displayName = String(request?.displayName ?? '').trim()
642
- if (displayName && displayName !== before.displayName) {
643
- changes.DisplayName = labelBody(displayName, language)
644
- parts.push(`显示名 ${before.displayName || '∅'}→${displayName}`)
645
- }
646
- const description = String(request?.description ?? '').trim()
647
- if (description && description !== before.description) {
648
- changes.Description = labelBody(description, language)
649
- parts.push('描述')
650
- }
651
-
652
- const operations = parts.length
653
- ? [{
654
- type: 'UpdateEntity',
655
- entity: current.LogicalName ?? entity,
656
- changes,
657
- solutionUniqueName: request?.solutionUniqueName ?? undefined,
658
- summary: `${entity}:${parts.join(';')}`,
659
- }]
660
- : []
661
-
662
- return {
663
- kind: 'entity.update',
664
- capability: 'entity.update',
665
- title: `${env.key}:修改表 ${entity} 的显示信息`,
666
- envKey: env.key,
667
- envUrl: env.url,
668
- target: entity,
669
- before,
670
- operations,
671
- skipped: operations.length ? [] : [{ entity, reason: '请求的值与现值相同' }],
672
- requestedBy: request?.requestedBy ?? 'agent',
673
- rationale: request?.rationale ?? '',
674
- }
675
- }
676
-
677
- /**
678
- * Build a plan that adds or removes columns of a system view.
679
- *
680
- * Only the cell list inside `layoutxml` is rewritten: the filter (`fetchxml`),
681
- * sorting and conditional formatting stay exactly as they were. Personal views
682
- * (`userquery`) are deliberately out of reach — their ids are user-specific and
683
- * editing them from a deployment tool is never what anyone means.
684
- *
685
- * @param request - `{ view, entity?, addColumns?: string[], removeColumns?: string[], solutionUniqueName? }`
686
- */
687
- export async function planViewColumns(config, envKey, request, signal) {
688
- const env = config.environments[envKey]
689
- if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
690
-
691
- const viewName = String(request?.view ?? '').trim()
692
- if (!viewName) throw new D365Error('缺少 view(视图名)', { code: 'missing-view' })
693
- const entity = String(request?.entity ?? '').trim()
694
-
695
- const adds = (Array.isArray(request?.addColumns) ? request.addColumns : [])
696
- .map((item) => String(item).trim())
697
- .filter(Boolean)
698
- const removes = (Array.isArray(request?.removeColumns) ? request.removeColumns : [])
699
- .map((item) => String(item).trim())
700
- .filter(Boolean)
701
- if (!adds.length && !removes.length) {
702
- throw new D365Error('缺少要增删的列(addColumns / removeColumns)', { code: 'missing-columns' })
703
- }
704
-
705
- const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
706
- await assertSolution(client, request?.solutionUniqueName, signal)
707
-
708
- const filter = entity
709
- ? `name eq '${escapeName(viewName)}' and returnedtypecode eq '${escapeName(entity)}'`
710
- : `name eq '${escapeName(viewName)}'`
711
- const body = await client.requestJson(client.buildUrl('savedqueries', {
712
- $select: 'savedqueryid,name,returnedtypecode,layoutxml,querytype',
713
- $filter: filter,
714
- }), { signal })
715
- const rows = body?.value ?? []
716
- if (!rows.length) {
717
- throw new D365Error(`没有找到视图:${viewName}`, {
718
- code: 'unknown-view',
719
- hint: entity ? '检查视图名与表逻辑名是否对得上。' : '同名视图可能属于另一张表,补一个 entity 再试。',
720
- })
721
- }
722
- if (rows.length > 1) {
723
- throw new D365Error(`有 ${rows.length} 个视图叫 ${viewName},请同时指定 entity`, {
724
- code: 'ambiguous-view',
725
- hint: rows.map((row) => row.returnedtypecode).join('、'),
726
- })
727
- }
728
-
729
- const view = rows[0]
730
- const layout = String(view.layoutxml ?? '')
731
- const rowMatch = /(<row\b[^>]*>)([\s\S]*?)(<\/row>)/.exec(layout)
732
- if (!rowMatch) {
733
- throw new D365Error(`视图 ${viewName} 的 layoutxml 结构无法识别`, {
734
- code: 'unparsable-layout',
735
- hint: layout.slice(0, 160),
736
- })
737
- }
738
-
739
- const cellPattern = /<cell\b[^>]*\/>/g
740
- let cells = rowMatch[2].match(cellPattern) ?? []
741
- const present = new Set(cells.map((xml) => /\bname="([^"]+)"/.exec(xml)?.[1]).filter(Boolean))
742
- const beforeCount = present.size
743
- const parts = []
744
- const skipped = []
745
-
746
- for (const attribute of removes) {
747
- if (!present.has(attribute)) {
748
- skipped.push({ attribute, reason: '视图里没有这一列' })
749
- continue
750
- }
751
- cells = cells.filter((xml) => /\bname="([^"]+)"/.exec(xml)?.[1] !== attribute)
752
- present.delete(attribute)
753
- parts.push(`- ${attribute}`)
754
- }
755
- for (const attribute of adds) {
756
- if (present.has(attribute)) {
757
- skipped.push({ attribute, reason: '视图里已有这一列' })
758
- continue
759
- }
760
- // A column name is XML text: it goes into an attribute value and must not be
761
- // able to close the tag or open a new one.
762
- if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(attribute)) {
763
- throw new D365Error(`不是合法的字段名:${attribute}`, { code: 'bad-attribute', hint: '列名用字段逻辑名,只含字母、数字与下划线。' })
764
- }
765
- cells.push(`<cell name="${attribute}" width="100" />`)
766
- present.add(attribute)
767
- parts.push(`+ ${attribute}`)
768
- }
769
-
770
- const rebuilt = `${rowMatch[1]}${cells.join('')}${rowMatch[3]}`
771
- const operations = parts.length
772
- ? [{
773
- type: 'UpdateViewLayout',
774
- viewId: view.savedqueryid,
775
- viewName: view.name,
776
- entity: view.returnedtypecode,
777
- // A function replacement: a layout string containing `$&` or `$1` would be
778
- // expanded by String.prototype.replace's special patterns otherwise.
779
- layoutxml: layout.replace(rowMatch[0], () => rebuilt),
780
- solutionUniqueName: request?.solutionUniqueName ?? undefined,
781
- summary: `视图「${view.name}」(${view.returnedtypecode})${parts.join(' ')}`,
782
- }]
783
- : []
784
-
785
- return {
786
- kind: 'view.columns',
787
- capability: 'view.columns',
788
- title: `${env.key}:调整视图 ${viewName} 的列`,
789
- envKey: env.key,
790
- envUrl: env.url,
791
- target: `${view.returnedtypecode}/${viewName}`,
792
- before: { columns: beforeCount },
793
- operations,
794
- skipped,
795
- requestedBy: request?.requestedBy ?? 'agent',
796
- rationale: request?.rationale ?? '',
797
- }
798
- }
799
-
800
- /**
801
- * Component types `RemoveActiveCustomizations` accepts, with the numeric
802
- * `ComponentType` the message expects.
803
- *
804
- * The numbers are the `componenttype` picklist values Dataverse itself publishes
805
- * on `solutioncomponent.componenttype` (read live from the org, not guessed):
806
- * Entity 1, Attribute 2, Option Set 9, Saved Query 26, Workflow 29, System Form
807
- * 60, Web Resource 61, Plugin Type 90, Plugin Assembly 91, SDK Message
808
- * Processing Step 92. `CustomAPI` sits outside that picklist because the table
809
- * carries its own object type code, which is what `solutioncomponents` stores
810
- * for it (10519 here).
811
- */
812
- const REMOVE_LAYER_TYPES = {
813
- Entity: 1,
814
- Attribute: 2,
815
- OptionSet: 9,
816
- SavedQuery: 26,
817
- Workflow: 29,
818
- SystemForm: 60,
819
- WebResource: 61,
820
- PluginType: 90,
821
- PluginAssembly: 91,
822
- SdkMessageProcessingStep: 92,
823
- CustomAPI: 10519,
824
- }
825
-
826
- /** The unmanaged layer is the one the layer view labels "Active". */
827
- const ACTIVE_LAYER = /^active$/i
828
-
829
- const GUID_ONLY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
830
-
831
- /**
832
- * Dataverse answers `objecttypecode` with the literal "none" for a form that is
833
- * not bound to any table (dashboards, some maintenance forms). Publishing that
834
- * as an entity name fails the whole PublishXml, so it has to read as "no scope".
835
- */
836
- const publishEntity = (value) => {
837
- const name = String(value ?? '').trim()
838
- if (!name || name.toLowerCase() === 'none') return undefined
839
- // A caller may address a table by GUID; a GUID is not a table name, and
840
- // putting it in PublishXml fails the whole publish.
841
- if (GUID_ONLY.test(name)) return undefined
842
- return name
843
- }
844
-
845
- /**
846
- * Work out what a publish has to cover for a component whose layer is removed.
847
- *
848
- * Returns `{ entity }`, `{ optionSetName }` or `{}`; an empty answer means the
849
- * caller publishes everything, which is correct but slower — never wrong, so a
850
- * failed lookup is not allowed to block the removal.
851
- */
852
- async function resolveLayerPublishScope(client, { componentType, componentId, component, entity, signal }) {
853
- const read = async (path, select) => client.requestJson(
854
- client.buildUrl(path, { $select: select }),
855
- { signal },
856
- )
857
- try {
858
- if (componentType === 'Entity') return { entity: publishEntity(component) }
859
- if (componentType === 'Attribute') return { entity: publishEntity(entity) }
860
- if (componentType === 'SystemForm') {
861
- const form = await read(`systemforms(${componentId})`, 'objecttypecode')
862
- return { entity: publishEntity(form?.objecttypecode) }
863
- }
864
- if (componentType === 'SavedQuery') {
865
- const view = await read(`savedqueries(${componentId})`, 'returnedtypecode')
866
- return { entity: publishEntity(view?.returnedtypecode) }
867
- }
868
- if (componentType === 'OptionSet') {
869
- // A global option set is published by name; the plan may have been built
870
- // from a GUID, so read the name back when needed.
871
- const name = GUID_ONLY.test(component)
872
- ? (await read(`GlobalOptionSetDefinitions(${componentId})`, 'Name'))?.Name
873
- : component
874
- return { optionSetName: name || undefined }
875
- }
876
- return {}
877
- } catch {
878
- return {}
879
- }
880
- }
881
-
882
- /**
883
- * Build a "remove the unmanaged layer" plan.
884
- *
885
- * Removing a layer deletes the top-most *unmanaged* definition of a component,
886
- * so whatever a managed solution last brought becomes active again. There is no
887
- * undo, hence `acknowledge` plus the usual confirmation card.
888
- * @param request - `{ componentType: 'Entity' | 'Attribute', component, entity?, acknowledge: true }`
889
- */
890
- export async function planRemoveLayer(config, envKey, request, signal) {
891
- const env = config.environments[envKey]
892
- if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
893
-
894
- if (request?.acknowledge !== true) {
895
- throw new D365Error('移除自定义层不可恢复,必须先明确确认(acknowledge: true)', {
896
- code: 'destructive-not-acknowledged',
897
- hint: '删掉的是未受管的那一层,Dataverse 没有还原入口。确认后再生成计划,执行前仍要在确认卡片上批准。',
898
- })
899
- }
900
-
901
- const componentType = String(request?.componentType ?? 'Entity').trim()
902
- const componentCode = REMOVE_LAYER_TYPES[componentType]
903
- if (!componentCode) {
904
- throw new D365Error(`不支持移除层的组件类型:${componentType}`, {
905
- code: 'bad-component-type',
906
- hint: `支持的组件类型:${Object.keys(REMOVE_LAYER_TYPES).join('、')}。`
907
- + '本地选项集不是这里的 OptionSet,它随字段存在,用 componentType=Attribute 加 entity 查字段的层。',
908
- })
909
- }
910
-
911
- const component = String(request?.component ?? '').trim()
912
- const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
913
- const componentId = await resolveComponentId(
914
- client,
915
- { componentType, component, entity: request?.entity },
916
- signal,
917
- )
918
-
919
- const { page } = await getPagedWithSelectFallback(client, 'msdyn_componentlayers', {
920
- $select: 'msdyn_componentlayerid,msdyn_name,msdyn_solutionname,msdyn_publishername,msdyn_order,msdyn_overwritetime',
921
- $filter: buildComponentFilter(componentId, componentType),
922
- }, 50, signal)
923
-
924
- const layers = [...(page?.records ?? [])]
925
- .sort((a, b) => Number(a.msdyn_order ?? 0) - Number(b.msdyn_order ?? 0))
926
- if (!layers.length) {
927
- throw new D365Error(`组件 ${component} 在这个环境里没有任何层记录`, {
928
- code: 'no-layers',
929
- hint: '只有进过解决方案、或被直接自定义过的组件才有层;查不到层说明它没被改过,也就没有可删的自定义层。',
930
- })
931
- }
932
-
933
- const active = layers[layers.length - 1]
934
- if (!ACTIVE_LAYER.test(String(active.msdyn_solutionname ?? ''))) {
935
- throw new D365Error(`最上面一层属于解决方案 ${solutionLabel(active)},不是未受管的自定义层`, {
936
- code: 'not-active-layer',
937
- hint: '这里只删「Active」那一层。要撤掉解决方案带来的定义,得在解决方案里移除组件,或导入一个新 patch 覆盖它。',
938
- })
939
- }
940
-
941
- const below = layers.length > 1 ? layers[layers.length - 2] : null
942
- const shownName = active.msdyn_name ?? component
943
- // A publish is what makes a metadata change visible, and its scope differs per
944
- // component type: an entity or column is published through its table, a form
945
- // or view through the table it belongs to, a global option set by name, and a
946
- // plugin or web resource needs no publish at all. When the scope cannot be
947
- // worked out, the caller falls back to publishing everything.
948
- const publish = await resolveLayerPublishScope(client, {
949
- componentType, componentId, component, entity: request?.entity, signal,
950
- })
951
-
952
- const operations = [{
953
- type: 'RemoveActiveCustomizations',
954
- componentType: componentCode,
955
- componentTypeName: componentType,
956
- componentId,
957
- entity: publish.entity,
958
- optionSetName: publish.optionSetName,
959
- attribute: componentType === 'Attribute' ? component : undefined,
960
- removedLayer: {
961
- solution: active.msdyn_solutionname ?? 'Active',
962
- publisher: active.msdyn_publishername ?? '',
963
- order: Number(active.msdyn_order ?? 0),
964
- },
965
- becomesActive: below
966
- ? {
967
- solution: solutionLabel(below),
968
- publisher: below.msdyn_publishername ?? '',
969
- order: Number(below.msdyn_order ?? 0),
970
- }
971
- : null,
972
- summary: `移除 ${componentType} ${shownName} 的自定义层(Active)`
973
- + `;之后生效:${below ? solutionLabel(below) : '系统定义'}`,
974
- }]
975
-
976
- return {
977
- kind: 'layer.remove',
978
- capability: 'layer.remove',
979
- title: `${env.key}:移除 ${componentType} ${component} 的自定义层`,
980
- envKey: env.key,
981
- envUrl: env.url,
982
- target: `${componentType} ${component}`,
983
- before: {
984
- layers: layers.map((layer) => ({
985
- solution: layer.msdyn_solutionname ?? 'Active',
986
- publisher: layer.msdyn_publishername ?? '',
987
- order: Number(layer.msdyn_order ?? 0),
988
- time: layer.msdyn_overwritetime ?? null,
989
- })),
990
- activeLayer: active.msdyn_solutionname ?? 'Active',
991
- activePublisher: active.msdyn_publishername ?? '',
992
- becomesActive: below ? solutionLabel(below) : '系统定义',
993
- },
994
- operations,
995
- skipped: [],
996
- requestedBy: request?.requestedBy ?? 'agent',
997
- rationale: request?.rationale ?? '',
998
- }
999
- }
1000
-
1001
- /** Persist a plan so the page can show it. */
1002
- export async function savePlan(config, plan) {
1003
- return createPlan(config, plan)
1004
- }
1005
-
1006
- /**
1007
- * Option-set action bodies address either a global option set (by name) or a
1008
- * column's local one (by owning column) — never both.
1009
- */
1010
- function optionScope(operation) {
1011
- return operation.entity
1012
- ? { EntityLogicalName: operation.entity, AttributeLogicalName: operation.attribute }
1013
- : { OptionSetName: operation.optionSetName }
1014
- }
1015
-
1016
- /**
1017
- * Language a new or changed option label is written in. `language` is a real
1018
- * parameter of the plan actions; dropping it silently wrote 1033 (English) into
1019
- * a 2052 organization and left the Chinese label untouched.
1020
- */
1021
- function optionLanguage(request) {
1022
- const value = Number(request?.language)
1023
- return Number.isInteger(value) && value > 0 ? value : DEFAULT_LANGUAGE
1024
- }
1025
-
1026
- function labelBody(label, languageCode) {
1027
- return {
1028
- '@odata.type': 'Microsoft.Dynamics.CRM.Label',
1029
- LocalizedLabels: [
1030
- {
1031
- '@odata.type': 'Microsoft.Dynamics.CRM.LocalizedLabel',
1032
- Label: label,
1033
- LanguageCode: languageCode,
1034
- },
1035
- ],
1036
- }
1037
- }
1038
-
1039
- /**
1040
- * Execute a plan. Callers must have already confirmed this is a human action.
1041
- * @returns `{ results, failures, ledger }`.
1042
- */
1043
- export async function executePlan(config, plan, signal, options = {}) {
1044
- const policy = await readPolicy(config.configFile)
1045
- if (!allows(policy, plan.envKey, plan.capability)) {
1046
- throw new D365Error(`环境 ${plan.envKey} 未授权 ${plan.capability}`, {
1047
- code: 'not-allowed',
1048
- hint: '在设置页的「写入权限」里勾选该环境与能力后再执行。',
1049
- })
1050
- }
1051
-
1052
- const env = config.environments[plan.envKey]
1053
- if (!env) throw new D365Error(`未知环境:${plan.envKey}`, { code: 'unknown-environment' })
1054
- if (!plan.operations?.length) throw new D365Error('计划中没有操作', { code: 'empty-plan' })
1055
- // Defence in depth: callers check this too, but a plan that already ran must
1056
- // never run again through any path — the operations are not idempotent.
1057
- if ((plan.status ?? 'pending') !== 'pending') {
1058
- throw new D365Error(`计划 ${plan.id} 的状态是 ${plan.status},不能再次执行`, {
1059
- code: 'plan-not-pending',
1060
- hint: '先核对 .d365/changes 里的记录;要再做一次请重新生成计划。',
1061
- })
1062
- }
1063
-
1064
- const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
1065
-
1066
- // Dataverse holds the customization lock while a solution imports or a
1067
- // publish runs; with Microsoft's own updates that is a lot of the day. Writing
1068
- // into that window means waiting minutes for a lock, or failing outright, so
1069
- // check first and let the person decide with `force`.
1070
- const preflight = await checkEnvironmentIdle(client, {
1071
- windowMinutes: config.busyWindowMinutes,
1072
- signal,
1073
- })
1074
- if (preflight.busy && options.force !== true) throw busyError(plan.envKey, preflight)
1075
-
1076
- // Claim the plan before the first write. Anything that stops this process
1077
- // mid-plan (crash, killed tab, Host restart) leaves status "running", so the
1078
- // plan cannot be silently executed again and replay the operations that
1079
- // already landed. Callers refuse non-pending plans unless a person forces it.
1080
- await setPlanStatus(config, plan.id, 'running', { startedAt: new Date().toISOString() })
1081
-
1082
- try {
1083
- const results = []
1084
- const failures = []
1085
-
1086
- for (const operation of plan.operations) {
1087
- try {
1088
- if (operation.type === 'InsertOptionValue') {
1089
- const body = {
1090
- ...optionScope(operation),
1091
- Value: operation.value,
1092
- Label: labelBody(operation.label, operation.languageCode ?? DEFAULT_LANGUAGE),
1093
- }
1094
- if (operation.solutionUniqueName) body.SolutionUniqueName = operation.solutionUniqueName
1095
- const response = await client.postJson('InsertOptionValue', body, signal)
1096
- results.push({ ...operation, ok: true, returnedValue: response?.Value })
1097
- } else if (operation.type === 'UpdateOptionValue') {
1098
- const body = {
1099
- ...optionScope(operation),
1100
- Value: operation.value,
1101
- Label: labelBody(operation.label, operation.languageCode ?? DEFAULT_LANGUAGE),
1102
- // `MergeLabels: false` replaced the whole label, so every other
1103
- // language the organization had for this option was dropped. Merging
1104
- // updates only the language in `Label`.
1105
- MergeLabels: true,
1106
- }
1107
- if (operation.solutionUniqueName) body.SolutionUniqueName = operation.solutionUniqueName
1108
- await client.postJson('UpdateOptionValue', body, signal)
1109
- results.push({ ...operation, ok: true })
1110
- } else if (operation.type === 'DeleteOptionValue') {
1111
- const body = {
1112
- ...optionScope(operation),
1113
- Value: operation.value,
1114
- }
1115
- if (operation.solutionUniqueName) body.SolutionUniqueName = operation.solutionUniqueName
1116
- await client.postJson('DeleteOptionValue', body, signal)
1117
- results.push({ ...operation, ok: true })
1118
- } else if (operation.type === 'UpdateViewLayout') {
1119
- // SavedQuery is an update message, not an action: patch the layout back.
1120
- // Conditional formatting and the filter (fetchxml) stay untouched.
1121
- const headers = operation.solutionUniqueName
1122
- ? { 'MSCRM.SolutionUniqueName': operation.solutionUniqueName }
1123
- : undefined
1124
- await client.sendJson(`savedqueries(${operation.viewId})`, {
1125
- method: 'PATCH',
1126
- body: { layoutxml: operation.layoutxml },
1127
- headers,
1128
- }, signal)
1129
- results.push({ ...operation, ok: true })
1130
- } else if (operation.type === 'CreateAttribute') {
1131
- // Creating a column is not an action: it is a POST of the typed
1132
- // metadata to the entity's Attributes collection.
1133
- const path = `EntityDefinitions(LogicalName='${escapeName(operation.entity)}')/Attributes`
1134
- const headers = operation.solutionUniqueName
1135
- ? { 'MSCRM.SolutionUniqueName': operation.solutionUniqueName }
1136
- : undefined
1137
- await client.sendJson(path, { method: 'POST', body: operation.attribute, headers }, signal)
1138
- results.push({ ...operation, ok: true })
1139
- } else if (operation.type === 'UpdateAttribute') {
1140
- const path = `EntityDefinitions(LogicalName='${escapeName(operation.entity)}')`
1141
- + `/Attributes(LogicalName='${escapeName(operation.attributeName)}')`
1142
- // `MSCRM.MergeLabels` keeps the organization's other languages; without
1143
- // it the PUT replaces every localized label with the one sent. The
1144
- // solution header makes the change land in that solution's layer, not
1145
- // the unnamed active layer.
1146
- await client.sendJson(path, {
1147
- method: 'PUT',
1148
- body: operation.changes,
1149
- headers: {
1150
- 'MSCRM.MergeLabels': 'true',
1151
- ...(operation.solutionUniqueName ? { 'MSCRM.SolutionUniqueName': operation.solutionUniqueName } : {}),
1152
- },
1153
- }, signal)
1154
- results.push({ ...operation, ok: true })
1155
- } else if (operation.type === 'DeleteAttribute') {
1156
- const path = `EntityDefinitions(LogicalName='${escapeName(operation.entity)}')`
1157
- + `/Attributes(LogicalName='${escapeName(operation.attributeName)}')`
1158
- const headers = operation.solutionUniqueName
1159
- ? { 'MSCRM.SolutionUniqueName': operation.solutionUniqueName }
1160
- : undefined
1161
- await client.sendJson(path, { method: 'DELETE', headers }, signal)
1162
- results.push({ ...operation, ok: true })
1163
- } else if (operation.type === 'RemoveActiveCustomizations') {
1164
- // Unbound action: the component type is numeric and the object id is the
1165
- // metadata id resolved when the plan was built.
1166
- await client.postJson('RemoveActiveCustomizations', {
1167
- ComponentType: operation.componentType,
1168
- ObjectId: operation.componentId,
1169
- }, signal)
1170
- results.push({ ...operation, ok: true })
1171
- } else if (operation.type === 'UpdateEntity') {
1172
- const path = `EntityDefinitions(LogicalName='${escapeName(operation.entity)}')`
1173
- await client.sendJson(path, {
1174
- method: 'PUT',
1175
- body: operation.changes,
1176
- headers: {
1177
- 'MSCRM.MergeLabels': 'true',
1178
- ...(operation.solutionUniqueName ? { 'MSCRM.SolutionUniqueName': operation.solutionUniqueName } : {}),
1179
- },
1180
- }, signal)
1181
- results.push({ ...operation, ok: true })
1182
- } else {
1183
- failures.push({ operation, error: `不支持的操作类型:${operation.type}` })
1184
- }
1185
- } catch (error) {
1186
- failures.push({
1187
- operation,
1188
- error: error.message,
1189
- hint: error.hint,
1190
- })
1191
- }
1192
- }
1193
-
1194
- // Metadata changes need a publish to become visible, but a failed publish must
1195
- // not report the writes as failed — they already happened.
1196
- //
1197
- // PublishAllXml walks every component of the organization; on a large one it
1198
- // takes minutes (36 option values once sat unpublished behind a 60s timeout).
1199
- // It therefore runs detached: the person gets the write result immediately,
1200
- // and a sidecar file records how the publish ended.
1201
- let publishState = 'skipped'
1202
- const publishScope = publishTargets(results)
1203
- if (results.length) {
1204
- publishState = 'running'
1205
- const publishClient = new DataverseClient(env, {
1206
- timeoutMs: Math.max(config.timeoutMs ?? 60_000, PUBLISH_TIMEOUT_MS),
1207
- counters: client.counters,
1208
- })
1209
- publishInBackground(config, plan, publishClient, publishScope)
1210
- }
1211
-
1212
- const ledger = await recordChange(config, {
1213
- planId: plan.id,
1214
- kind: plan.kind,
1215
- envKey: plan.envKey,
1216
- envUrl: plan.envUrl,
1217
- title: plan.title,
1218
- target: plan.target,
1219
- optionSetName: plan.optionSetName,
1220
- capability: plan.capability,
1221
- applied: results,
1222
- failed: failures,
1223
- publishState,
1224
- publishScope,
1225
- preflight: {
1226
- available: preflight.available,
1227
- busy: preflight.busy,
1228
- active: preflight.active,
1229
- stale: preflight.stale.length,
1230
- forced: options.force === true,
1231
- },
1232
- })
1233
-
1234
- // Terminal state before the caller deletes the file: if that delete fails,
1235
- // the plan still refuses a second run instead of replaying itself.
1236
- await setPlanStatus(config, plan.id, 'done', {
1237
- finishedAt: new Date().toISOString(),
1238
- appliedCount: results.length,
1239
- failedCount: failures.length,
1240
- }).catch(() => {})
1241
-
1242
- return { results, failures, publishState, publishScope, ledger, preflight }
1243
- } catch (error) {
1244
- // Keep the evidence: the plan stays on disk with the reason it stopped, so
1245
- // the page can show "failed" instead of pretending nothing happened.
1246
- await setPlanStatus(config, plan.id, 'failed', {
1247
- finishedAt: new Date().toISOString(),
1248
- error: String(error?.message ?? error),
1249
- }).catch(() => {})
1250
- throw error
1251
- }
1252
- }
1253
-
1254
- /**
1255
- * Fire the publish and forget about the caller's request.
1256
- *
1257
- * Deliberately not awaited: holding an HTTP response open for the duration of a
1258
- * whole-organization publish is what produced "请求超时" while the values were
1259
- * already saved. Failures land in `.d365/changes/publish-<planId>.json`.
1260
- */
1261
- function publishInBackground(config, plan, client, scope) {
1262
- const startedAt = new Date().toISOString()
1263
- void publishOn(client, scope)
1264
- .then(async (outcome) => {
1265
- await writePublishReport(config, plan.id, {
1266
- planId: plan.id,
1267
- envKey: plan.envKey,
1268
- startedAt,
1269
- finishedAt: new Date().toISOString(),
1270
- targets: scope,
1271
- ...outcome,
1272
- })
1273
- })
1274
- .catch(() => {})
1275
- }
1276
-
1277
- /**
1278
- * Run one publish and resolve with its outcome instead of firing and forgetting.
1279
- *
1280
- * The settings page uses this for "publish again": the person is staring at a
1281
- * failed publish, so the result has to come back while they wait.
1282
- */
1283
- export async function publishNow(config, envKey, scope, options = {}) {
1284
- const env = config.environments?.[envKey]
1285
- if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
1286
- const client = new DataverseClient(env, {
1287
- timeoutMs: Math.max(config.timeoutMs ?? 60_000, PUBLISH_TIMEOUT_MS),
1288
- })
1289
- // A publish started while the organization is importing or publishing is the
1290
- // most common way this fails, and the page is where people retry it. Check
1291
- // first so the answer is "the environment is busy", not a raw 429.
1292
- const preflight = await checkEnvironmentIdle(client, {
1293
- windowMinutes: config.busyWindowMinutes,
1294
- signal: options.signal,
1295
- })
1296
- if (preflight.busy && options.force !== true) throw busyError(envKey, preflight)
1297
- const outcome = await publishOn(client, scope, options.signal)
1298
- return { ...outcome, targets: scope, counters: client.counters }
1299
- }
1300
-
1301
- async function publishOn(client, scope, signal) {
1302
- const scoped = (scope?.entities?.length ?? 0) > 0 || (scope?.optionSets?.length ?? 0) > 0
1303
- try {
1304
- if (scoped) await client.postJson('PublishXml', { ParameterXml: buildPublishXml(scope) }, signal)
1305
- else await client.postJson('PublishAllXml', {}, signal)
1306
- return { ok: true, mode: scoped ? 'scoped' : 'all' }
1307
- } catch (error) {
1308
- return { ok: false, mode: scoped ? 'scoped' : 'all', error: error.message, hint: error.hint }
1309
- }
1310
- }
1311
-
1312
-
1313
- /**
1314
- * Which components a publish has to cover.
1315
- *
1316
- * `PublishAllXml` walks every entity and option set in the organization: on a
1317
- * large org that is minutes of CPU, and it is the piece that used to time out.
1318
- * Everything we write is local to an entity or a global option set, so the
1319
- * publish can be narrowed to exactly those.
1320
- */
1321
- export function publishTargets(results) {
1322
- const entities = new Set()
1323
- const optionSets = new Set()
1324
- for (const operation of results) {
1325
- switch (operation.type) {
1326
- case 'InsertOptionValue':
1327
- case 'UpdateOptionValue':
1328
- case 'DeleteOptionValue':
1329
- if (operation.entity) entities.add(operation.entity)
1330
- else if (operation.optionSetName) optionSets.add(operation.optionSetName)
1331
- break
1332
- case 'CreateAttribute':
1333
- case 'UpdateAttribute':
1334
- case 'DeleteAttribute':
1335
- case 'UpdateEntity':
1336
- case 'UpdateViewLayout':
1337
- if (operation.entity) entities.add(operation.entity)
1338
- break
1339
- case 'RemoveActiveCustomizations':
1340
- // The message itself names a numeric component type; what has to be
1341
- // published is whichever table (or global option set) owns it.
1342
- if (operation.entity) entities.add(operation.entity)
1343
- else if (operation.optionSetName) optionSets.add(operation.optionSetName)
1344
- break
1345
- default:
1346
- break
1347
- }
1348
- }
1349
- return { entities: [...entities].sort(), optionSets: [...optionSets].sort() }
1350
- }
1351
-
1352
- function escapeXml(value) {
1353
- return String(value)
1354
- .replace(/&/g, '&amp;')
1355
- .replace(/</g, '&lt;')
1356
- .replace(/>/g, '&gt;')
1357
- .replace(/"/g, '&quot;')
1358
- .replace(/'/g, '&apos;')
1359
- }
1360
-
1361
- /**
1362
- * `PublishXml` takes an `importexportxml` document listing what to publish; the
1363
- * element order mirrors the solution schema (entities before optionsets).
1364
- */
1365
- export function buildPublishXml({ entities = [], optionSets = [] }) {
1366
- const parts = []
1367
- if (entities.length) {
1368
- parts.push(`<entities>${entities.map((name) => `<entity>${escapeXml(name)}</entity>`).join('')}</entities>`)
1369
- }
1370
- if (optionSets.length) {
1371
- parts.push(`<optionsets>${optionSets.map((name) => `<optionset>${escapeXml(name)}</optionset>`).join('')}</optionsets>`)
1372
- }
1373
- return `<importexportxml>${parts.join('')}</importexportxml>`
1374
- }
1
+ /**
2
+ * The only write path in the plugin.
3
+ *
4
+ * Every operation is one official Dataverse unbound action —
5
+ * `InsertOptionValue`, `UpdateOptionValue` — and nothing else. A plan is built
6
+ * read-only, shown to a person in the settings page, and executed only by that
7
+ * person's confirmation. Results are appended to the change ledger.
8
+ */
9
+ import { DataverseClient } from './client.js'
10
+ import { getPagedWithSelectFallback } from './client.js'
11
+ import { D365Error } from './errors.js'
12
+ import { busyError, checkEnvironmentIdle } from './preflight.js'
13
+ import { allows, readPolicy } from './write-policy.js'
14
+ import { createPlan, recordChange, setPlanStatus, writePublishReport } from './write-store.js'
15
+ import { COMPONENT_TYPES, buildComponentFilter, resolveComponentId, solutionLabel } from './tool-layers.js'
16
+
17
+ const DEFAULT_LANGUAGE = 1033
18
+
19
+ /** PublishAllXml gets its own budget: it is a whole-organization operation. */
20
+ const PUBLISH_TIMEOUT_MS = 180_000
21
+
22
+ /**
23
+ * Resolve which option set a request targets: a global one by name, or the
24
+ * local option set owned by a single picklist column.
25
+ */
26
+ function optionTarget(request) {
27
+ const optionSetName = String(request?.optionSetName ?? '').trim()
28
+ const entity = String(request?.entity ?? '').trim()
29
+ const attribute = String(request?.attribute ?? '').trim()
30
+ if (entity && attribute) return { kind: 'local', entity, attribute }
31
+ if (optionSetName) return { kind: 'global', name: optionSetName }
32
+ throw new D365Error('缺少 optionSetName,或 entity + attribute(本地选项集)', {
33
+ code: 'missing-option-set',
34
+ hint: '全局选项集传 optionSet;字段自带的选项集传 entity 与 attribute。',
35
+ })
36
+ }
37
+
38
+ /** Human-readable name of an option target. */
39
+ function targetLabel(target) {
40
+ return target.kind === 'local' ? `${target.entity}.${target.attribute}` : target.name
41
+ }
42
+
43
+ /**
44
+ * Read one option set — global by name, or the one attached to a column.
45
+ *
46
+ * A column's local option set is only reachable through a type cast; which cast
47
+ * applies depends on whether the column is single- or multi-select, and some
48
+ * deployments reject the multi-select cast for a plain picklist. Trying both
49
+ * beats making the caller know the difference.
50
+ */
51
+ async function readOptionSet(client, target, signal) {
52
+ let definition
53
+ if (target.kind === 'local') {
54
+ const base = `EntityDefinitions(LogicalName='${escapeName(target.entity)}')`
55
+ + `/Attributes(LogicalName='${escapeName(target.attribute)}')`
56
+ const params = { $expand: 'OptionSet($select=Options,IsGlobal,Name)' }
57
+ const casts = [
58
+ 'Microsoft.Dynamics.CRM.PicklistAttributeMetadata',
59
+ 'Microsoft.Dynamics.CRM.MultiSelectPicklistAttributeMetadata',
60
+ '',
61
+ ]
62
+ let failure
63
+ for (const cast of casts) {
64
+ try {
65
+ definition = await client.requestJson(client.buildUrl(cast ? `${base}/${cast}` : base, params), { signal })
66
+ break
67
+ } catch (error) {
68
+ failure = error
69
+ }
70
+ }
71
+ if (!definition) {
72
+ throw new D365Error(`字段 ${target.entity}.${target.attribute} 的选项集读取失败`, {
73
+ code: 'option-set-unreadable',
74
+ hint: `${failure?.message ?? ''} 该字段可能不是选项集字段。`,
75
+ })
76
+ }
77
+ } else {
78
+ definition = await client.requestJson(
79
+ client.buildUrl(`GlobalOptionSets(Name='${escapeName(target.name)}')`),
80
+ { signal },
81
+ ).catch(async () => {
82
+ // Some deployments expose the keyed form under the pluralized entity set.
83
+ return client.requestJson(
84
+ client.buildUrl(`GlobalOptionSetDefinitions(Name='${escapeName(target.name)}')`),
85
+ { signal },
86
+ )
87
+ })
88
+ }
89
+
90
+ const source = definition?.OptionSet ?? definition
91
+ const options = (source?.Options ?? []).map((option) => ({
92
+ value: Number(option.Value),
93
+ label: option.Label?.UserLocalizedLabel?.Label
94
+ ?? option.Label?.LocalizedLabels?.[0]?.Label
95
+ ?? '',
96
+ }))
97
+ return { name: source?.Name ?? targetLabel(target), options }
98
+ }
99
+
100
+ function codeOf(label) {
101
+ return String(label ?? '').split('|')[0].trim()
102
+ }
103
+
104
+ /**
105
+ * Verify the solution a change is supposed to land in, before a plan is saved.
106
+ *
107
+ * The `MSCRM.SolutionUniqueName` header only records a change inside an
108
+ * existing, unmanaged solution; a typo would otherwise surface after a person
109
+ * already confirmed the plan. Checking here turns it into a normal planning
110
+ * error with a chance to fix the name first.
111
+ */
112
+ async function assertSolution(client, solutionUniqueName, signal) {
113
+ const name = String(solutionUniqueName ?? '').trim()
114
+ if (!name) return
115
+ const safe = name.replace(/'/g, "''")
116
+ const body = await client.requestJson(
117
+ client.buildUrl('solutions', { $select: 'uniquename,ismanaged', $filter: `uniquename eq '${safe}'` }),
118
+ { signal },
119
+ )
120
+ const row = body?.value?.[0]
121
+ if (!row) {
122
+ throw new D365Error(`解决方案不存在:${name}`, {
123
+ code: 'unknown-solution',
124
+ hint: '检查解决方案唯一名。改动只能落在一个已存在的未受管解决方案里;留空则进默认解决方案。',
125
+ })
126
+ }
127
+ const managed = row.ismanaged ?? row.IsManaged
128
+ if (managed) {
129
+ throw new D365Error(`解决方案 ${name} 是受管的,不能写入`, {
130
+ code: 'managed-solution',
131
+ hint: '受管解决方案不能再接受元数据改动;改用承载本项目改动的未受管解决方案。',
132
+ })
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Build an "add missing options" plan.
138
+ * @param request - `{ optionSetName | entity+attribute, labels: string[], solutionUniqueName? }`
139
+ * where each label is `CODE|Description`.
140
+ */
141
+ export async function planOptionAdd(config, envKey, request, signal) {
142
+ const env = config.environments[envKey]
143
+ if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
144
+
145
+ const target = optionTarget(request)
146
+ const labels = Array.isArray(request?.labels) ? request.labels.map((item) => String(item).trim()).filter(Boolean) : []
147
+ if (!labels.length) throw new D365Error('缺少要新增的选项列表(labels)', { code: 'missing-labels' })
148
+
149
+ const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
150
+ await assertSolution(client, request?.solutionUniqueName, signal)
151
+ const { options } = await readOptionSet(client, target, signal)
152
+
153
+ const existing = new Map()
154
+ for (const option of options) {
155
+ const code = codeOf(option.label)
156
+ if (code && !existing.has(code)) existing.set(code, option)
157
+ }
158
+
159
+ let nextValue = options.length ? Math.max(...options.map((option) => option.value)) + 1 : 1
160
+ const operations = []
161
+ const skipped = []
162
+ const scope = target.kind === 'local'
163
+ ? { entity: target.entity, attribute: target.attribute }
164
+ : { optionSetName: target.name }
165
+
166
+ for (const label of labels) {
167
+ const code = codeOf(label)
168
+ if (existing.has(code)) {
169
+ skipped.push({ code, value: existing.get(code).value, label: existing.get(code).label })
170
+ continue
171
+ }
172
+ operations.push({
173
+ type: 'InsertOptionValue',
174
+ ...scope,
175
+ value: nextValue,
176
+ label,
177
+ languageCode: optionLanguage(request),
178
+ solutionUniqueName: request?.solutionUniqueName ?? undefined,
179
+ summary: `${targetLabel(target)} +${nextValue} ${label}`,
180
+ })
181
+ nextValue += 1
182
+ }
183
+
184
+ const maxValue = options.length ? Math.max(...options.map((option) => option.value)) : 0
185
+ return {
186
+ kind: 'optionValue.add',
187
+ capability: 'optionValue.add',
188
+ title: `${env.key}:向 ${targetLabel(target)} 新增 ${operations.length} 个选项`,
189
+ envKey: env.key,
190
+ envUrl: env.url,
191
+ ...scope,
192
+ optionTarget: target.kind,
193
+ before: { totalOptions: options.length, maxValue },
194
+ operations,
195
+ skipped,
196
+ requestedBy: request?.requestedBy ?? 'agent',
197
+ rationale: request?.rationale ?? '',
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Build a "relabel existing options" plan.
203
+ * @param request - `{ optionSetName | entity+attribute, updates: [{ value, label }] }`
204
+ */
205
+ export async function planOptionRelabel(config, envKey, request, signal) {
206
+ const env = config.environments[envKey]
207
+ if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
208
+
209
+ const target = optionTarget(request)
210
+ const updates = Array.isArray(request?.updates) ? request.updates : []
211
+ if (!updates.length) throw new D365Error('缺少 updates', { code: 'missing-updates' })
212
+
213
+ const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
214
+ await assertSolution(client, request?.solutionUniqueName, signal)
215
+ const { options } = await readOptionSet(client, target, signal)
216
+ const byValue = new Map(options.map((option) => [Number(option.value), option]))
217
+ const scope = target.kind === 'local'
218
+ ? { entity: target.entity, attribute: target.attribute }
219
+ : { optionSetName: target.name }
220
+
221
+ const operations = []
222
+ const skipped = []
223
+ for (const update of updates) {
224
+ const value = Number(update?.value)
225
+ const label = String(update?.label ?? '').trim()
226
+ const current = byValue.get(value)
227
+ if (!current) {
228
+ skipped.push({ value, label, reason: '选项不存在' })
229
+ continue
230
+ }
231
+ if (!label || current.label === label) {
232
+ skipped.push({ value, label, reason: '标签相同' })
233
+ continue
234
+ }
235
+ operations.push({
236
+ type: 'UpdateOptionValue',
237
+ ...scope,
238
+ value,
239
+ label,
240
+ languageCode: optionLanguage(request),
241
+ solutionUniqueName: request?.solutionUniqueName ?? undefined,
242
+ previousLabel: current.label,
243
+ summary: `${targetLabel(target)} ~${value} ${current.label} → ${label}`,
244
+ })
245
+ }
246
+
247
+ return {
248
+ kind: 'optionValue.updateLabel',
249
+ capability: 'optionValue.updateLabel',
250
+ title: `${env.key}:修改 ${targetLabel(target)} 的 ${operations.length} 个选项标签`,
251
+ envKey: env.key,
252
+ envUrl: env.url,
253
+ ...scope,
254
+ optionTarget: target.kind,
255
+ before: { totalOptions: options.length },
256
+ operations,
257
+ skipped,
258
+ requestedBy: request?.requestedBy ?? 'agent',
259
+ rationale: request?.rationale ?? '',
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Build a "delete option values" plan. Destructive: the caller must acknowledge.
265
+ * @param request - `{ optionSetName | entity+attribute, values: number[], acknowledge: true }`
266
+ */
267
+ export async function planOptionDelete(config, envKey, request, signal) {
268
+ const env = config.environments[envKey]
269
+ if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
270
+
271
+ if (request?.acknowledge !== true) {
272
+ throw new D365Error('删除选项值不可恢复,必须先明确确认(acknowledge: true)', {
273
+ code: 'destructive-not-acknowledged',
274
+ hint: '已有数据如果引用该值,删除会失败;能删掉的标签也找不回来。确认后再生成计划。',
275
+ })
276
+ }
277
+
278
+ const target = optionTarget(request)
279
+ const values = (Array.isArray(request?.values) ? request.values : [])
280
+ .map((value) => Number(value))
281
+ .filter((value) => Number.isFinite(value))
282
+ if (!values.length) throw new D365Error('缺少要删除的选项值(values)', { code: 'missing-values' })
283
+
284
+ const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
285
+ await assertSolution(client, request?.solutionUniqueName, signal)
286
+ const { options } = await readOptionSet(client, target, signal)
287
+ const byValue = new Map(options.map((option) => [Number(option.value), option]))
288
+ const scope = target.kind === 'local'
289
+ ? { entity: target.entity, attribute: target.attribute }
290
+ : { optionSetName: target.name }
291
+
292
+ const operations = []
293
+ const skipped = []
294
+ for (const value of values) {
295
+ const current = byValue.get(value)
296
+ if (!current) {
297
+ skipped.push({ value, reason: '选项不存在' })
298
+ continue
299
+ }
300
+ operations.push({
301
+ type: 'DeleteOptionValue',
302
+ ...scope,
303
+ value,
304
+ previousLabel: current.label,
305
+ solutionUniqueName: request?.solutionUniqueName ?? undefined,
306
+ summary: `删除选项 ${value}(${current.label})`,
307
+ })
308
+ }
309
+
310
+ return {
311
+ kind: 'optionValue.delete',
312
+ capability: 'optionValue.delete',
313
+ title: `${env.key}:从 ${targetLabel(target)} 删除 ${operations.length} 个选项`,
314
+ envKey: env.key,
315
+ envUrl: env.url,
316
+ ...scope,
317
+ optionTarget: target.kind,
318
+ before: { totalOptions: options.length },
319
+ operations,
320
+ skipped,
321
+ requestedBy: request?.requestedBy ?? 'agent',
322
+ rationale: request?.rationale ?? '',
323
+ }
324
+ }
325
+
326
+ /** Escape a logical name for use inside an OData key segment. */
327
+ function escapeName(value) {
328
+ return String(value ?? '').replace(/'/g, "''").trim()
329
+ }
330
+
331
+ const REQUIRED_LEVELS = ['None', 'Recommended', 'Required']
332
+
333
+ /** Column kinds this plugin can create, with their metadata defaults. */
334
+ const ATTRIBUTE_TYPES = {
335
+ string: {
336
+ label: '文本',
337
+ odata: 'Microsoft.Dynamics.CRM.StringAttributeMetadata',
338
+ extra: (request) => ({
339
+ MaxLength: Math.min(4000, Math.max(1, Number(request.maxLength ?? 100) || 100)),
340
+ FormatName: { Value: request.format === 'Email' || request.format === 'Url' ? request.format : 'Text' },
341
+ }),
342
+ },
343
+ memo: {
344
+ label: '多行文本',
345
+ odata: 'Microsoft.Dynamics.CRM.MemoAttributeMetadata',
346
+ extra: (request) => ({ MaxLength: Math.min(1048576, Math.max(1, Number(request.maxLength ?? 2000) || 2000)) }),
347
+ },
348
+ integer: {
349
+ label: '整数',
350
+ odata: 'Microsoft.Dynamics.CRM.IntegerAttributeMetadata',
351
+ extra: () => ({ Format: { Value: 'None' } }),
352
+ },
353
+ decimal: {
354
+ label: '小数',
355
+ odata: 'Microsoft.Dynamics.CRM.DecimalAttributeMetadata',
356
+ extra: (request) => ({ Precision: Math.min(10, Math.max(0, Number(request.precision ?? 2) || 2)) }),
357
+ },
358
+ money: {
359
+ label: '货币',
360
+ odata: 'Microsoft.Dynamics.CRM.MoneyAttributeMetadata',
361
+ extra: (request) => ({
362
+ Precision: Math.min(4, Math.max(0, Number(request.precision ?? 2) || 2)),
363
+ MaxValue: 1000000000000,
364
+ MinValue: -1000000000000,
365
+ }),
366
+ },
367
+ boolean: {
368
+ label: '是/否',
369
+ odata: 'Microsoft.Dynamics.CRM.BooleanAttributeMetadata',
370
+ extra: (request) => {
371
+ const language = Number(request.language ?? DEFAULT_LANGUAGE)
372
+ return {
373
+ DefaultValue: false,
374
+ OptionSet: {
375
+ TrueOption: { Value: 1, Label: labelBody(request.trueLabel ?? '是', language) },
376
+ FalseOption: { Value: 0, Label: labelBody(request.falseLabel ?? '否', language) },
377
+ },
378
+ }
379
+ },
380
+ },
381
+ datetime: {
382
+ label: '日期时间',
383
+ odata: 'Microsoft.Dynamics.CRM.DateTimeAttributeMetadata',
384
+ extra: (request) => ({ Format: { Value: request.dateOnly ? 'DateOnly' : 'DateAndTime' } }),
385
+ },
386
+ }
387
+
388
+ /** Build the JSON definition for a new column. */
389
+ function attributeDefinition(request) {
390
+ const type = String(request.type ?? 'string').toLowerCase()
391
+ const spec = ATTRIBUTE_TYPES[type]
392
+ if (!spec) {
393
+ throw new D365Error(`不支持的字段类型:${type}`, {
394
+ code: 'bad-attribute-type',
395
+ hint: `可用类型:${Object.keys(ATTRIBUTE_TYPES).join(', ')}`,
396
+ })
397
+ }
398
+
399
+ const schemaName = String(request.schemaName ?? '').trim()
400
+ if (!/^[A-Za-z][A-Za-z0-9_]{1,49}$/.test(schemaName)) {
401
+ throw new D365Error(`字段名不合法:${schemaName}`, {
402
+ code: 'bad-schema-name',
403
+ hint: '必须以字母开头,只含字母、数字、下划线,且带发布者前缀(如 new_)。',
404
+ })
405
+ }
406
+
407
+ const displayName = String(request.displayName ?? schemaName).trim()
408
+ const language = Number(request.language ?? DEFAULT_LANGUAGE)
409
+ const requiredLevel = REQUIRED_LEVELS.includes(request.requiredLevel) ? request.requiredLevel : 'None'
410
+ const description = String(request.description ?? '').trim()
411
+
412
+ return {
413
+ '@odata.type': spec.odata,
414
+ SchemaName: schemaName,
415
+ DisplayName: labelBody(displayName, language),
416
+ ...(description ? { Description: labelBody(description, language) } : {}),
417
+ RequiredLevel: { Value: requiredLevel },
418
+ ...spec.extra({ ...request, language }),
419
+ }
420
+ }
421
+
422
+ /** Read one column's metadata; rejects when it does not exist. */
423
+ async function readAttribute(client, entity, logicalName, signal) {
424
+ const path = `EntityDefinitions(LogicalName='${escapeName(entity)}')/Attributes(LogicalName='${escapeName(logicalName)}')`
425
+ return client.requestJson(client.buildUrl(path), { signal })
426
+ }
427
+
428
+ /** Read one table's metadata. */
429
+ async function readEntity(client, entity, signal) {
430
+ const path = `EntityDefinitions(LogicalName='${escapeName(entity)}')`
431
+ return client.requestJson(client.buildUrl(path, { $select: 'LogicalName,DisplayName,Description' }), { signal })
432
+ .catch(() => client.requestJson(client.buildUrl(path), { signal }))
433
+ }
434
+
435
+ /** A label's display text, whichever shape the server used. */
436
+ function labelText(label) {
437
+ return label?.UserLocalizedLabel?.Label ?? label?.LocalizedLabels?.[0]?.Label ?? ''
438
+ }
439
+
440
+ /**
441
+ * Build a "create a column" plan.
442
+ * @param request - `{ entity, schemaName, displayName?, type?, maxLength?,
443
+ * precision?, requiredLevel?, description?, language?, solutionUniqueName? }`
444
+ */
445
+ export async function planAddAttribute(config, envKey, request, signal) {
446
+ const env = config.environments[envKey]
447
+ if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
448
+
449
+ const entity = String(request?.entity ?? '').trim()
450
+ if (!entity) throw new D365Error('缺少 entity(表逻辑名)', { code: 'missing-entity' })
451
+
452
+ const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
453
+ await assertSolution(client, request?.solutionUniqueName, signal)
454
+ await readEntity(client, entity, signal)
455
+
456
+ const definition = attributeDefinition(request)
457
+ const operations = []
458
+ const skipped = []
459
+
460
+ const exists = await readAttribute(client, entity, definition.SchemaName, signal).catch(() => null)
461
+ if (exists) {
462
+ skipped.push({ schemaName: definition.SchemaName, reason: '字段已存在' })
463
+ } else {
464
+ operations.push({
465
+ type: 'CreateAttribute',
466
+ entity,
467
+ attribute: definition,
468
+ schemaName: definition.SchemaName,
469
+ solutionUniqueName: request?.solutionUniqueName ?? undefined,
470
+ summary: `${entity}.${definition.SchemaName}(${ATTRIBUTE_TYPES[String(request.type ?? 'string').toLowerCase()].label})`,
471
+ })
472
+ }
473
+
474
+ return {
475
+ kind: 'attribute.add',
476
+ capability: 'attribute.add',
477
+ title: `${env.key}:在 ${entity} 上新增 ${operations.length} 个字段`,
478
+ envKey: env.key,
479
+ envUrl: env.url,
480
+ target: `${entity}.${definition.SchemaName}`,
481
+ before: { entity, exists: Boolean(exists) },
482
+ operations,
483
+ skipped,
484
+ requestedBy: request?.requestedBy ?? 'agent',
485
+ rationale: request?.rationale ?? '',
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Build an "update a column's label" plan.
491
+ * @param request - `{ entity, attribute, displayName?, description?, requiredLevel? }`
492
+ */
493
+ export async function planUpdateAttribute(config, envKey, request, signal) {
494
+ const env = config.environments[envKey]
495
+ if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
496
+
497
+ const entity = String(request?.entity ?? '').trim()
498
+ const attributeName = String(request?.attribute ?? request?.schemaName ?? '').trim()
499
+ if (!entity) throw new D365Error('缺少 entity(表逻辑名)', { code: 'missing-entity' })
500
+ if (!attributeName) throw new D365Error('缺少 attribute(字段逻辑名)', { code: 'missing-attribute' })
501
+
502
+ const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
503
+ await assertSolution(client, request?.solutionUniqueName, signal)
504
+ const current = await readAttribute(client, entity, attributeName, signal).catch((error) => {
505
+ throw new D365Error(`字段 ${entity}.${attributeName} 不存在或无法读取`, {
506
+ code: 'attribute-not-found',
507
+ hint: error?.message,
508
+ })
509
+ })
510
+
511
+ const language = Number(request?.language ?? DEFAULT_LANGUAGE)
512
+ const changes = {
513
+ '@odata.type': current['@odata.type'] ?? 'Microsoft.Dynamics.CRM.AttributeMetadata',
514
+ LogicalName: current.LogicalName ?? attributeName,
515
+ }
516
+ const before = {
517
+ displayName: labelText(current.DisplayName),
518
+ description: labelText(current.Description),
519
+ requiredLevel: current.RequiredLevel?.Value ?? 'None',
520
+ }
521
+ const parts = []
522
+
523
+ const displayName = String(request?.displayName ?? '').trim()
524
+ if (displayName && displayName !== before.displayName) {
525
+ changes.DisplayName = labelBody(displayName, language)
526
+ parts.push(`显示名 ${before.displayName || '∅'}→${displayName}`)
527
+ }
528
+ const description = String(request?.description ?? '').trim()
529
+ if (description && description !== before.description) {
530
+ changes.Description = labelBody(description, language)
531
+ parts.push('描述')
532
+ }
533
+ const requiredLevel = String(request?.requiredLevel ?? '').trim()
534
+ if (requiredLevel && REQUIRED_LEVELS.includes(requiredLevel) && requiredLevel !== before.requiredLevel) {
535
+ changes.RequiredLevel = { Value: requiredLevel }
536
+ parts.push(`必填级别 ${before.requiredLevel}→${requiredLevel}`)
537
+ }
538
+
539
+ const operations = parts.length
540
+ ? [{
541
+ type: 'UpdateAttribute',
542
+ entity,
543
+ attributeName: current.LogicalName ?? attributeName,
544
+ changes,
545
+ solutionUniqueName: request?.solutionUniqueName ?? undefined,
546
+ summary: `${entity}.${current.LogicalName ?? attributeName}:${parts.join(';')}`,
547
+ }]
548
+ : []
549
+
550
+ return {
551
+ kind: 'attribute.update',
552
+ capability: 'attribute.update',
553
+ title: `${env.key}:修改 ${entity}.${attributeName} 的显示信息`,
554
+ envKey: env.key,
555
+ envUrl: env.url,
556
+ target: `${entity}.${attributeName}`,
557
+ before,
558
+ operations,
559
+ skipped: operations.length ? [] : [{ attribute: attributeName, reason: '请求的值与现值相同' }],
560
+ requestedBy: request?.requestedBy ?? 'agent',
561
+ rationale: request?.rationale ?? '',
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Build a "delete a column" plan. Destructive: the caller must acknowledge it.
567
+ * @param request - `{ entity, attribute, acknowledge: true }`
568
+ */
569
+ export async function planDeleteAttribute(config, envKey, request, signal) {
570
+ const env = config.environments[envKey]
571
+ if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
572
+
573
+ if (request?.acknowledge !== true) {
574
+ throw new D365Error('删除字段不可恢复,必须先明确确认(acknowledge: true)', {
575
+ code: 'destructive-not-acknowledged',
576
+ hint: '这是不可逆操作:列数据随列一起删除。确认后再生成计划,计划仍会在设置页等待人工确认。',
577
+ })
578
+ }
579
+
580
+ const entity = String(request?.entity ?? '').trim()
581
+ const attributeName = String(request?.attribute ?? '').trim()
582
+ if (!entity || !attributeName) {
583
+ throw new D365Error('缺少 entity 或 attribute', { code: 'missing-target' })
584
+ }
585
+
586
+ const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
587
+ await assertSolution(client, request?.solutionUniqueName, signal)
588
+ const current = await readAttribute(client, entity, attributeName, signal).catch(() => null)
589
+
590
+ const operations = current
591
+ ? [{
592
+ type: 'DeleteAttribute',
593
+ entity,
594
+ attributeName: current.LogicalName ?? attributeName,
595
+ // Without this the delete lands in the default solution instead of the
596
+ // one the caller named — the other planners all carry it.
597
+ solutionUniqueName: request?.solutionUniqueName ?? undefined,
598
+ displayName: labelText(current.DisplayName),
599
+ summary: `删除字段 ${entity}.${current.LogicalName ?? attributeName}(${labelText(current.DisplayName) || '无显示名'})`,
600
+ }]
601
+ : []
602
+
603
+ return {
604
+ kind: 'attribute.delete',
605
+ capability: 'attribute.delete',
606
+ title: `${env.key}:删除 ${entity}.${attributeName}`,
607
+ envKey: env.key,
608
+ envUrl: env.url,
609
+ target: `${entity}.${attributeName}`,
610
+ before: { exists: Boolean(current), displayName: labelText(current?.DisplayName) },
611
+ operations,
612
+ skipped: operations.length ? [] : [{ attribute: attributeName, reason: '字段不存在' }],
613
+ requestedBy: request?.requestedBy ?? 'agent',
614
+ rationale: request?.rationale ?? '',
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Build an "update a table's label" plan.
620
+ * @param request - `{ entity, displayName?, description? }`
621
+ */
622
+ export async function planUpdateEntity(config, envKey, request, signal) {
623
+ const env = config.environments[envKey]
624
+ if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
625
+
626
+ const entity = String(request?.entity ?? '').trim()
627
+ if (!entity) throw new D365Error('缺少 entity(表逻辑名)', { code: 'missing-entity' })
628
+
629
+ const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
630
+ await assertSolution(client, request?.solutionUniqueName, signal)
631
+ const current = await readEntity(client, entity, signal)
632
+
633
+ const language = Number(request?.language ?? DEFAULT_LANGUAGE)
634
+ const before = {
635
+ displayName: labelText(current.DisplayName),
636
+ description: labelText(current.Description),
637
+ }
638
+ const changes = { '@odata.type': 'Microsoft.Dynamics.CRM.EntityMetadata', LogicalName: current.LogicalName ?? entity }
639
+ const parts = []
640
+
641
+ const displayName = String(request?.displayName ?? '').trim()
642
+ if (displayName && displayName !== before.displayName) {
643
+ changes.DisplayName = labelBody(displayName, language)
644
+ parts.push(`显示名 ${before.displayName || '∅'}→${displayName}`)
645
+ }
646
+ const description = String(request?.description ?? '').trim()
647
+ if (description && description !== before.description) {
648
+ changes.Description = labelBody(description, language)
649
+ parts.push('描述')
650
+ }
651
+
652
+ const operations = parts.length
653
+ ? [{
654
+ type: 'UpdateEntity',
655
+ entity: current.LogicalName ?? entity,
656
+ changes,
657
+ solutionUniqueName: request?.solutionUniqueName ?? undefined,
658
+ summary: `${entity}:${parts.join(';')}`,
659
+ }]
660
+ : []
661
+
662
+ return {
663
+ kind: 'entity.update',
664
+ capability: 'entity.update',
665
+ title: `${env.key}:修改表 ${entity} 的显示信息`,
666
+ envKey: env.key,
667
+ envUrl: env.url,
668
+ target: entity,
669
+ before,
670
+ operations,
671
+ skipped: operations.length ? [] : [{ entity, reason: '请求的值与现值相同' }],
672
+ requestedBy: request?.requestedBy ?? 'agent',
673
+ rationale: request?.rationale ?? '',
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Build a plan that adds or removes columns of a system view.
679
+ *
680
+ * Only the cell list inside `layoutxml` is rewritten: the filter (`fetchxml`),
681
+ * sorting and conditional formatting stay exactly as they were. Personal views
682
+ * (`userquery`) are deliberately out of reach — their ids are user-specific and
683
+ * editing them from a deployment tool is never what anyone means.
684
+ *
685
+ * @param request - `{ view, entity?, addColumns?: string[], removeColumns?: string[], solutionUniqueName? }`
686
+ */
687
+ export async function planViewColumns(config, envKey, request, signal) {
688
+ const env = config.environments[envKey]
689
+ if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
690
+
691
+ const viewName = String(request?.view ?? '').trim()
692
+ if (!viewName) throw new D365Error('缺少 view(视图名)', { code: 'missing-view' })
693
+ const entity = String(request?.entity ?? '').trim()
694
+
695
+ const adds = (Array.isArray(request?.addColumns) ? request.addColumns : [])
696
+ .map((item) => String(item).trim())
697
+ .filter(Boolean)
698
+ const removes = (Array.isArray(request?.removeColumns) ? request.removeColumns : [])
699
+ .map((item) => String(item).trim())
700
+ .filter(Boolean)
701
+ if (!adds.length && !removes.length) {
702
+ throw new D365Error('缺少要增删的列(addColumns / removeColumns)', { code: 'missing-columns' })
703
+ }
704
+
705
+ const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
706
+ await assertSolution(client, request?.solutionUniqueName, signal)
707
+
708
+ const filter = entity
709
+ ? `name eq '${escapeName(viewName)}' and returnedtypecode eq '${escapeName(entity)}'`
710
+ : `name eq '${escapeName(viewName)}'`
711
+ const body = await client.requestJson(client.buildUrl('savedqueries', {
712
+ $select: 'savedqueryid,name,returnedtypecode,layoutxml,querytype',
713
+ $filter: filter,
714
+ }), { signal })
715
+ const rows = body?.value ?? []
716
+ if (!rows.length) {
717
+ throw new D365Error(`没有找到视图:${viewName}`, {
718
+ code: 'unknown-view',
719
+ hint: entity ? '检查视图名与表逻辑名是否对得上。' : '同名视图可能属于另一张表,补一个 entity 再试。',
720
+ })
721
+ }
722
+ if (rows.length > 1) {
723
+ throw new D365Error(`有 ${rows.length} 个视图叫 ${viewName},请同时指定 entity`, {
724
+ code: 'ambiguous-view',
725
+ hint: rows.map((row) => row.returnedtypecode).join('、'),
726
+ })
727
+ }
728
+
729
+ const view = rows[0]
730
+ const layout = String(view.layoutxml ?? '')
731
+ const rowMatch = /(<row\b[^>]*>)([\s\S]*?)(<\/row>)/.exec(layout)
732
+ if (!rowMatch) {
733
+ throw new D365Error(`视图 ${viewName} 的 layoutxml 结构无法识别`, {
734
+ code: 'unparsable-layout',
735
+ hint: layout.slice(0, 160),
736
+ })
737
+ }
738
+
739
+ const cellPattern = /<cell\b[^>]*\/>/g
740
+ let cells = rowMatch[2].match(cellPattern) ?? []
741
+ const present = new Set(cells.map((xml) => /\bname="([^"]+)"/.exec(xml)?.[1]).filter(Boolean))
742
+ const beforeCount = present.size
743
+ const parts = []
744
+ const skipped = []
745
+
746
+ for (const attribute of removes) {
747
+ if (!present.has(attribute)) {
748
+ skipped.push({ attribute, reason: '视图里没有这一列' })
749
+ continue
750
+ }
751
+ cells = cells.filter((xml) => /\bname="([^"]+)"/.exec(xml)?.[1] !== attribute)
752
+ present.delete(attribute)
753
+ parts.push(`- ${attribute}`)
754
+ }
755
+ for (const attribute of adds) {
756
+ if (present.has(attribute)) {
757
+ skipped.push({ attribute, reason: '视图里已有这一列' })
758
+ continue
759
+ }
760
+ // A column name is XML text: it goes into an attribute value and must not be
761
+ // able to close the tag or open a new one.
762
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(attribute)) {
763
+ throw new D365Error(`不是合法的字段名:${attribute}`, { code: 'bad-attribute', hint: '列名用字段逻辑名,只含字母、数字与下划线。' })
764
+ }
765
+ cells.push(`<cell name="${attribute}" width="100" />`)
766
+ present.add(attribute)
767
+ parts.push(`+ ${attribute}`)
768
+ }
769
+
770
+ const rebuilt = `${rowMatch[1]}${cells.join('')}${rowMatch[3]}`
771
+ const operations = parts.length
772
+ ? [{
773
+ type: 'UpdateViewLayout',
774
+ viewId: view.savedqueryid,
775
+ viewName: view.name,
776
+ entity: view.returnedtypecode,
777
+ // A function replacement: a layout string containing `$&` or `$1` would be
778
+ // expanded by String.prototype.replace's special patterns otherwise.
779
+ layoutxml: layout.replace(rowMatch[0], () => rebuilt),
780
+ solutionUniqueName: request?.solutionUniqueName ?? undefined,
781
+ summary: `视图「${view.name}」(${view.returnedtypecode})${parts.join(' ')}`,
782
+ }]
783
+ : []
784
+
785
+ return {
786
+ kind: 'view.columns',
787
+ capability: 'view.columns',
788
+ title: `${env.key}:调整视图 ${viewName} 的列`,
789
+ envKey: env.key,
790
+ envUrl: env.url,
791
+ target: `${view.returnedtypecode}/${viewName}`,
792
+ before: { columns: beforeCount },
793
+ operations,
794
+ skipped,
795
+ requestedBy: request?.requestedBy ?? 'agent',
796
+ rationale: request?.rationale ?? '',
797
+ }
798
+ }
799
+
800
+ /**
801
+ * Component types `RemoveActiveCustomizations` accepts, with the numeric
802
+ * `ComponentType` the message expects.
803
+ *
804
+ * The numbers are the `componenttype` picklist values Dataverse itself publishes
805
+ * on `solutioncomponent.componenttype` (read live from the org, not guessed):
806
+ * Entity 1, Attribute 2, Option Set 9, Saved Query 26, Workflow 29, System Form
807
+ * 60, Web Resource 61, Plugin Type 90, Plugin Assembly 91, SDK Message
808
+ * Processing Step 92. `CustomAPI` sits outside that picklist because the table
809
+ * carries its own object type code, which is what `solutioncomponents` stores
810
+ * for it (10519 here).
811
+ */
812
+ const REMOVE_LAYER_TYPES = {
813
+ Entity: 1,
814
+ Attribute: 2,
815
+ OptionSet: 9,
816
+ SavedQuery: 26,
817
+ Workflow: 29,
818
+ SystemForm: 60,
819
+ WebResource: 61,
820
+ PluginType: 90,
821
+ PluginAssembly: 91,
822
+ SdkMessageProcessingStep: 92,
823
+ CustomAPI: 10519,
824
+ }
825
+
826
+ /** The unmanaged layer is the one the layer view labels "Active". */
827
+ const ACTIVE_LAYER = /^active$/i
828
+
829
+ const GUID_ONLY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
830
+
831
+ /**
832
+ * Dataverse answers `objecttypecode` with the literal "none" for a form that is
833
+ * not bound to any table (dashboards, some maintenance forms). Publishing that
834
+ * as an entity name fails the whole PublishXml, so it has to read as "no scope".
835
+ */
836
+ const publishEntity = (value) => {
837
+ const name = String(value ?? '').trim()
838
+ if (!name || name.toLowerCase() === 'none') return undefined
839
+ // A caller may address a table by GUID; a GUID is not a table name, and
840
+ // putting it in PublishXml fails the whole publish.
841
+ if (GUID_ONLY.test(name)) return undefined
842
+ return name
843
+ }
844
+
845
+ /**
846
+ * Work out what a publish has to cover for a component whose layer is removed.
847
+ *
848
+ * Returns `{ entity }`, `{ optionSetName }` or `{}`; an empty answer means the
849
+ * caller publishes everything, which is correct but slower — never wrong, so a
850
+ * failed lookup is not allowed to block the removal.
851
+ */
852
+ async function resolveLayerPublishScope(client, { componentType, componentId, component, entity, signal }) {
853
+ const read = async (path, select) => client.requestJson(
854
+ client.buildUrl(path, { $select: select }),
855
+ { signal },
856
+ )
857
+ try {
858
+ if (componentType === 'Entity') return { entity: publishEntity(component) }
859
+ if (componentType === 'Attribute') return { entity: publishEntity(entity) }
860
+ if (componentType === 'SystemForm') {
861
+ const form = await read(`systemforms(${componentId})`, 'objecttypecode')
862
+ return { entity: publishEntity(form?.objecttypecode) }
863
+ }
864
+ if (componentType === 'SavedQuery') {
865
+ const view = await read(`savedqueries(${componentId})`, 'returnedtypecode')
866
+ return { entity: publishEntity(view?.returnedtypecode) }
867
+ }
868
+ if (componentType === 'OptionSet') {
869
+ // A global option set is published by name; the plan may have been built
870
+ // from a GUID, so read the name back when needed.
871
+ const name = GUID_ONLY.test(component)
872
+ ? (await read(`GlobalOptionSetDefinitions(${componentId})`, 'Name'))?.Name
873
+ : component
874
+ return { optionSetName: name || undefined }
875
+ }
876
+ return {}
877
+ } catch {
878
+ return {}
879
+ }
880
+ }
881
+
882
+ /**
883
+ * Build a "remove the unmanaged layer" plan.
884
+ *
885
+ * Removing a layer deletes the top-most *unmanaged* definition of a component,
886
+ * so whatever a managed solution last brought becomes active again. There is no
887
+ * undo, hence `acknowledge` plus the usual confirmation card.
888
+ * @param request - `{ componentType: 'Entity' | 'Attribute', component, entity?, acknowledge: true }`
889
+ */
890
+ export async function planRemoveLayer(config, envKey, request, signal) {
891
+ const env = config.environments[envKey]
892
+ if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
893
+
894
+ if (request?.acknowledge !== true) {
895
+ throw new D365Error('移除自定义层不可恢复,必须先明确确认(acknowledge: true)', {
896
+ code: 'destructive-not-acknowledged',
897
+ hint: '删掉的是未受管的那一层,Dataverse 没有还原入口。确认后再生成计划,执行前仍要在确认卡片上批准。',
898
+ })
899
+ }
900
+
901
+ const componentType = String(request?.componentType ?? 'Entity').trim()
902
+ const componentCode = REMOVE_LAYER_TYPES[componentType]
903
+ if (!componentCode) {
904
+ throw new D365Error(`不支持移除层的组件类型:${componentType}`, {
905
+ code: 'bad-component-type',
906
+ hint: `支持的组件类型:${Object.keys(REMOVE_LAYER_TYPES).join('、')}。`
907
+ + '本地选项集不是这里的 OptionSet,它随字段存在,用 componentType=Attribute 加 entity 查字段的层。',
908
+ })
909
+ }
910
+
911
+ const component = String(request?.component ?? '').trim()
912
+ const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
913
+ const componentId = await resolveComponentId(
914
+ client,
915
+ { componentType, component, entity: request?.entity },
916
+ signal,
917
+ )
918
+
919
+ const { page } = await getPagedWithSelectFallback(client, 'msdyn_componentlayers', {
920
+ $select: 'msdyn_componentlayerid,msdyn_name,msdyn_solutionname,msdyn_publishername,msdyn_order,msdyn_overwritetime',
921
+ $filter: buildComponentFilter(componentId, componentType),
922
+ }, 50, signal)
923
+
924
+ const layers = [...(page?.records ?? [])]
925
+ .sort((a, b) => Number(a.msdyn_order ?? 0) - Number(b.msdyn_order ?? 0))
926
+ if (!layers.length) {
927
+ throw new D365Error(`组件 ${component} 在这个环境里没有任何层记录`, {
928
+ code: 'no-layers',
929
+ hint: '只有进过解决方案、或被直接自定义过的组件才有层;查不到层说明它没被改过,也就没有可删的自定义层。',
930
+ })
931
+ }
932
+
933
+ const active = layers[layers.length - 1]
934
+ if (!ACTIVE_LAYER.test(String(active.msdyn_solutionname ?? ''))) {
935
+ throw new D365Error(`最上面一层属于解决方案 ${solutionLabel(active)},不是未受管的自定义层`, {
936
+ code: 'not-active-layer',
937
+ hint: '这里只删「Active」那一层。要撤掉解决方案带来的定义,得在解决方案里移除组件,或导入一个新 patch 覆盖它。',
938
+ })
939
+ }
940
+
941
+ const below = layers.length > 1 ? layers[layers.length - 2] : null
942
+ const shownName = active.msdyn_name ?? component
943
+ // A publish is what makes a metadata change visible, and its scope differs per
944
+ // component type: an entity or column is published through its table, a form
945
+ // or view through the table it belongs to, a global option set by name, and a
946
+ // plugin or web resource needs no publish at all. When the scope cannot be
947
+ // worked out, the caller falls back to publishing everything.
948
+ const publish = await resolveLayerPublishScope(client, {
949
+ componentType, componentId, component, entity: request?.entity, signal,
950
+ })
951
+
952
+ const operations = [{
953
+ type: 'RemoveActiveCustomizations',
954
+ componentType: componentCode,
955
+ componentTypeName: componentType,
956
+ componentId,
957
+ entity: publish.entity,
958
+ optionSetName: publish.optionSetName,
959
+ attribute: componentType === 'Attribute' ? component : undefined,
960
+ removedLayer: {
961
+ solution: active.msdyn_solutionname ?? 'Active',
962
+ publisher: active.msdyn_publishername ?? '',
963
+ order: Number(active.msdyn_order ?? 0),
964
+ },
965
+ becomesActive: below
966
+ ? {
967
+ solution: solutionLabel(below),
968
+ publisher: below.msdyn_publishername ?? '',
969
+ order: Number(below.msdyn_order ?? 0),
970
+ }
971
+ : null,
972
+ summary: `移除 ${componentType} ${shownName} 的自定义层(Active)`
973
+ + `;之后生效:${below ? solutionLabel(below) : '系统定义'}`,
974
+ }]
975
+
976
+ return {
977
+ kind: 'layer.remove',
978
+ capability: 'layer.remove',
979
+ title: `${env.key}:移除 ${componentType} ${component} 的自定义层`,
980
+ envKey: env.key,
981
+ envUrl: env.url,
982
+ target: `${componentType} ${component}`,
983
+ before: {
984
+ layers: layers.map((layer) => ({
985
+ solution: layer.msdyn_solutionname ?? 'Active',
986
+ publisher: layer.msdyn_publishername ?? '',
987
+ order: Number(layer.msdyn_order ?? 0),
988
+ time: layer.msdyn_overwritetime ?? null,
989
+ })),
990
+ activeLayer: active.msdyn_solutionname ?? 'Active',
991
+ activePublisher: active.msdyn_publishername ?? '',
992
+ becomesActive: below ? solutionLabel(below) : '系统定义',
993
+ },
994
+ operations,
995
+ skipped: [],
996
+ requestedBy: request?.requestedBy ?? 'agent',
997
+ rationale: request?.rationale ?? '',
998
+ }
999
+ }
1000
+
1001
+ /** Persist a plan so the page can show it. */
1002
+ export async function savePlan(config, plan) {
1003
+ return createPlan(config, plan)
1004
+ }
1005
+
1006
+ /**
1007
+ * Option-set action bodies address either a global option set (by name) or a
1008
+ * column's local one (by owning column) — never both.
1009
+ */
1010
+ function optionScope(operation) {
1011
+ return operation.entity
1012
+ ? { EntityLogicalName: operation.entity, AttributeLogicalName: operation.attribute }
1013
+ : { OptionSetName: operation.optionSetName }
1014
+ }
1015
+
1016
+ /**
1017
+ * Language a new or changed option label is written in. `language` is a real
1018
+ * parameter of the plan actions; dropping it silently wrote 1033 (English) into
1019
+ * a 2052 organization and left the Chinese label untouched.
1020
+ */
1021
+ function optionLanguage(request) {
1022
+ const value = Number(request?.language)
1023
+ return Number.isInteger(value) && value > 0 ? value : DEFAULT_LANGUAGE
1024
+ }
1025
+
1026
+ function labelBody(label, languageCode) {
1027
+ return {
1028
+ '@odata.type': 'Microsoft.Dynamics.CRM.Label',
1029
+ LocalizedLabels: [
1030
+ {
1031
+ '@odata.type': 'Microsoft.Dynamics.CRM.LocalizedLabel',
1032
+ Label: label,
1033
+ LanguageCode: languageCode,
1034
+ },
1035
+ ],
1036
+ }
1037
+ }
1038
+
1039
+ /**
1040
+ * Execute a plan. Callers must have already confirmed this is a human action.
1041
+ * @returns `{ results, failures, ledger }`.
1042
+ */
1043
+ export async function executePlan(config, plan, signal, options = {}) {
1044
+ const policy = await readPolicy(config.configFile)
1045
+ if (!allows(policy, plan.envKey, plan.capability)) {
1046
+ throw new D365Error(`环境 ${plan.envKey} 未授权 ${plan.capability}`, {
1047
+ code: 'not-allowed',
1048
+ hint: '在设置页的「写入权限」里勾选该环境与能力后再执行。',
1049
+ })
1050
+ }
1051
+
1052
+ const env = config.environments[plan.envKey]
1053
+ if (!env) throw new D365Error(`未知环境:${plan.envKey}`, { code: 'unknown-environment' })
1054
+ if (!plan.operations?.length) throw new D365Error('计划中没有操作', { code: 'empty-plan' })
1055
+ // Defence in depth: callers check this too, but a plan that already ran must
1056
+ // never run again through any path — the operations are not idempotent.
1057
+ if ((plan.status ?? 'pending') !== 'pending') {
1058
+ throw new D365Error(`计划 ${plan.id} 的状态是 ${plan.status},不能再次执行`, {
1059
+ code: 'plan-not-pending',
1060
+ hint: '先核对 .d365/changes 里的记录;要再做一次请重新生成计划。',
1061
+ })
1062
+ }
1063
+
1064
+ const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
1065
+
1066
+ // Dataverse holds the customization lock while a solution imports or a
1067
+ // publish runs; with Microsoft's own updates that is a lot of the day. Writing
1068
+ // into that window means waiting minutes for a lock, or failing outright, so
1069
+ // check first and let the person decide with `force`.
1070
+ const preflight = await checkEnvironmentIdle(client, {
1071
+ windowMinutes: config.busyWindowMinutes,
1072
+ signal,
1073
+ })
1074
+ if (preflight.busy && options.force !== true) throw busyError(plan.envKey, preflight)
1075
+
1076
+ // Claim the plan before the first write. Anything that stops this process
1077
+ // mid-plan (crash, killed tab, Host restart) leaves status "running", so the
1078
+ // plan cannot be silently executed again and replay the operations that
1079
+ // already landed. Callers refuse non-pending plans unless a person forces it.
1080
+ await setPlanStatus(config, plan.id, 'running', { startedAt: new Date().toISOString() })
1081
+
1082
+ try {
1083
+ const results = []
1084
+ const failures = []
1085
+
1086
+ for (const operation of plan.operations) {
1087
+ try {
1088
+ if (operation.type === 'InsertOptionValue') {
1089
+ const body = {
1090
+ ...optionScope(operation),
1091
+ Value: operation.value,
1092
+ Label: labelBody(operation.label, operation.languageCode ?? DEFAULT_LANGUAGE),
1093
+ }
1094
+ if (operation.solutionUniqueName) body.SolutionUniqueName = operation.solutionUniqueName
1095
+ const response = await client.postJson('InsertOptionValue', body, signal)
1096
+ results.push({ ...operation, ok: true, returnedValue: response?.Value })
1097
+ } else if (operation.type === 'UpdateOptionValue') {
1098
+ const body = {
1099
+ ...optionScope(operation),
1100
+ Value: operation.value,
1101
+ Label: labelBody(operation.label, operation.languageCode ?? DEFAULT_LANGUAGE),
1102
+ // `MergeLabels: false` replaced the whole label, so every other
1103
+ // language the organization had for this option was dropped. Merging
1104
+ // updates only the language in `Label`.
1105
+ MergeLabels: true,
1106
+ }
1107
+ if (operation.solutionUniqueName) body.SolutionUniqueName = operation.solutionUniqueName
1108
+ await client.postJson('UpdateOptionValue', body, signal)
1109
+ results.push({ ...operation, ok: true })
1110
+ } else if (operation.type === 'DeleteOptionValue') {
1111
+ const body = {
1112
+ ...optionScope(operation),
1113
+ Value: operation.value,
1114
+ }
1115
+ if (operation.solutionUniqueName) body.SolutionUniqueName = operation.solutionUniqueName
1116
+ await client.postJson('DeleteOptionValue', body, signal)
1117
+ results.push({ ...operation, ok: true })
1118
+ } else if (operation.type === 'UpdateViewLayout') {
1119
+ // SavedQuery is an update message, not an action: patch the layout back.
1120
+ // Conditional formatting and the filter (fetchxml) stay untouched.
1121
+ const headers = operation.solutionUniqueName
1122
+ ? { 'MSCRM.SolutionUniqueName': operation.solutionUniqueName }
1123
+ : undefined
1124
+ await client.sendJson(`savedqueries(${operation.viewId})`, {
1125
+ method: 'PATCH',
1126
+ body: { layoutxml: operation.layoutxml },
1127
+ headers,
1128
+ }, signal)
1129
+ results.push({ ...operation, ok: true })
1130
+ } else if (operation.type === 'CreateAttribute') {
1131
+ // Creating a column is not an action: it is a POST of the typed
1132
+ // metadata to the entity's Attributes collection.
1133
+ const path = `EntityDefinitions(LogicalName='${escapeName(operation.entity)}')/Attributes`
1134
+ const headers = operation.solutionUniqueName
1135
+ ? { 'MSCRM.SolutionUniqueName': operation.solutionUniqueName }
1136
+ : undefined
1137
+ await client.sendJson(path, { method: 'POST', body: operation.attribute, headers }, signal)
1138
+ results.push({ ...operation, ok: true })
1139
+ } else if (operation.type === 'UpdateAttribute') {
1140
+ const path = `EntityDefinitions(LogicalName='${escapeName(operation.entity)}')`
1141
+ + `/Attributes(LogicalName='${escapeName(operation.attributeName)}')`
1142
+ // `MSCRM.MergeLabels` keeps the organization's other languages; without
1143
+ // it the PUT replaces every localized label with the one sent. The
1144
+ // solution header makes the change land in that solution's layer, not
1145
+ // the unnamed active layer.
1146
+ await client.sendJson(path, {
1147
+ method: 'PUT',
1148
+ body: operation.changes,
1149
+ headers: {
1150
+ 'MSCRM.MergeLabels': 'true',
1151
+ ...(operation.solutionUniqueName ? { 'MSCRM.SolutionUniqueName': operation.solutionUniqueName } : {}),
1152
+ },
1153
+ }, signal)
1154
+ results.push({ ...operation, ok: true })
1155
+ } else if (operation.type === 'DeleteAttribute') {
1156
+ const path = `EntityDefinitions(LogicalName='${escapeName(operation.entity)}')`
1157
+ + `/Attributes(LogicalName='${escapeName(operation.attributeName)}')`
1158
+ const headers = operation.solutionUniqueName
1159
+ ? { 'MSCRM.SolutionUniqueName': operation.solutionUniqueName }
1160
+ : undefined
1161
+ await client.sendJson(path, { method: 'DELETE', headers }, signal)
1162
+ results.push({ ...operation, ok: true })
1163
+ } else if (operation.type === 'RemoveActiveCustomizations') {
1164
+ // Unbound action: the component type is numeric and the object id is the
1165
+ // metadata id resolved when the plan was built.
1166
+ await client.postJson('RemoveActiveCustomizations', {
1167
+ ComponentType: operation.componentType,
1168
+ ObjectId: operation.componentId,
1169
+ }, signal)
1170
+ results.push({ ...operation, ok: true })
1171
+ } else if (operation.type === 'UpdateEntity') {
1172
+ const path = `EntityDefinitions(LogicalName='${escapeName(operation.entity)}')`
1173
+ await client.sendJson(path, {
1174
+ method: 'PUT',
1175
+ body: operation.changes,
1176
+ headers: {
1177
+ 'MSCRM.MergeLabels': 'true',
1178
+ ...(operation.solutionUniqueName ? { 'MSCRM.SolutionUniqueName': operation.solutionUniqueName } : {}),
1179
+ },
1180
+ }, signal)
1181
+ results.push({ ...operation, ok: true })
1182
+ } else {
1183
+ failures.push({ operation, error: `不支持的操作类型:${operation.type}` })
1184
+ }
1185
+ } catch (error) {
1186
+ failures.push({
1187
+ operation,
1188
+ error: error.message,
1189
+ hint: error.hint,
1190
+ })
1191
+ }
1192
+ }
1193
+
1194
+ // Metadata changes need a publish to become visible, but a failed publish must
1195
+ // not report the writes as failed — they already happened.
1196
+ //
1197
+ // PublishAllXml walks every component of the organization; on a large one it
1198
+ // takes minutes (36 option values once sat unpublished behind a 60s timeout).
1199
+ // It therefore runs detached: the person gets the write result immediately,
1200
+ // and a sidecar file records how the publish ended.
1201
+ let publishState = 'skipped'
1202
+ const publishScope = publishTargets(results)
1203
+ if (results.length) {
1204
+ publishState = 'running'
1205
+ const publishClient = new DataverseClient(env, {
1206
+ timeoutMs: Math.max(config.timeoutMs ?? 60_000, PUBLISH_TIMEOUT_MS),
1207
+ counters: client.counters,
1208
+ })
1209
+ publishInBackground(config, plan, publishClient, publishScope)
1210
+ }
1211
+
1212
+ const ledger = await recordChange(config, {
1213
+ planId: plan.id,
1214
+ kind: plan.kind,
1215
+ envKey: plan.envKey,
1216
+ envUrl: plan.envUrl,
1217
+ title: plan.title,
1218
+ target: plan.target,
1219
+ optionSetName: plan.optionSetName,
1220
+ capability: plan.capability,
1221
+ applied: results,
1222
+ failed: failures,
1223
+ publishState,
1224
+ publishScope,
1225
+ preflight: {
1226
+ available: preflight.available,
1227
+ busy: preflight.busy,
1228
+ active: preflight.active,
1229
+ stale: preflight.stale.length,
1230
+ forced: options.force === true,
1231
+ },
1232
+ })
1233
+
1234
+ // Terminal state before the caller deletes the file: if that delete fails,
1235
+ // the plan still refuses a second run instead of replaying itself.
1236
+ await setPlanStatus(config, plan.id, 'done', {
1237
+ finishedAt: new Date().toISOString(),
1238
+ appliedCount: results.length,
1239
+ failedCount: failures.length,
1240
+ }).catch(() => {})
1241
+
1242
+ return { results, failures, publishState, publishScope, ledger, preflight }
1243
+ } catch (error) {
1244
+ // Keep the evidence: the plan stays on disk with the reason it stopped, so
1245
+ // the page can show "failed" instead of pretending nothing happened.
1246
+ await setPlanStatus(config, plan.id, 'failed', {
1247
+ finishedAt: new Date().toISOString(),
1248
+ error: String(error?.message ?? error),
1249
+ }).catch(() => {})
1250
+ throw error
1251
+ }
1252
+ }
1253
+
1254
+ /**
1255
+ * Fire the publish and forget about the caller's request.
1256
+ *
1257
+ * Deliberately not awaited: holding an HTTP response open for the duration of a
1258
+ * whole-organization publish is what produced "请求超时" while the values were
1259
+ * already saved. Failures land in `.d365/changes/publish-<planId>.json`.
1260
+ */
1261
+ function publishInBackground(config, plan, client, scope) {
1262
+ const startedAt = new Date().toISOString()
1263
+ void publishOn(client, scope)
1264
+ .then(async (outcome) => {
1265
+ await writePublishReport(config, plan.id, {
1266
+ planId: plan.id,
1267
+ envKey: plan.envKey,
1268
+ startedAt,
1269
+ finishedAt: new Date().toISOString(),
1270
+ targets: scope,
1271
+ ...outcome,
1272
+ })
1273
+ })
1274
+ .catch(() => {})
1275
+ }
1276
+
1277
+ /**
1278
+ * Run one publish and resolve with its outcome instead of firing and forgetting.
1279
+ *
1280
+ * The settings page uses this for "publish again": the person is staring at a
1281
+ * failed publish, so the result has to come back while they wait.
1282
+ */
1283
+ export async function publishNow(config, envKey, scope, options = {}) {
1284
+ const env = config.environments?.[envKey]
1285
+ if (!env) throw new D365Error(`未知环境:${envKey}`, { code: 'unknown-environment' })
1286
+ const client = new DataverseClient(env, {
1287
+ timeoutMs: Math.max(config.timeoutMs ?? 60_000, PUBLISH_TIMEOUT_MS),
1288
+ })
1289
+ // A publish started while the organization is importing or publishing is the
1290
+ // most common way this fails, and the page is where people retry it. Check
1291
+ // first so the answer is "the environment is busy", not a raw 429.
1292
+ const preflight = await checkEnvironmentIdle(client, {
1293
+ windowMinutes: config.busyWindowMinutes,
1294
+ signal: options.signal,
1295
+ })
1296
+ if (preflight.busy && options.force !== true) throw busyError(envKey, preflight)
1297
+ const outcome = await publishOn(client, scope, options.signal)
1298
+ return { ...outcome, targets: scope, counters: client.counters }
1299
+ }
1300
+
1301
+ async function publishOn(client, scope, signal) {
1302
+ const scoped = (scope?.entities?.length ?? 0) > 0 || (scope?.optionSets?.length ?? 0) > 0
1303
+ try {
1304
+ if (scoped) await client.postJson('PublishXml', { ParameterXml: buildPublishXml(scope) }, signal)
1305
+ else await client.postJson('PublishAllXml', {}, signal)
1306
+ return { ok: true, mode: scoped ? 'scoped' : 'all' }
1307
+ } catch (error) {
1308
+ return { ok: false, mode: scoped ? 'scoped' : 'all', error: error.message, hint: error.hint }
1309
+ }
1310
+ }
1311
+
1312
+
1313
+ /**
1314
+ * Which components a publish has to cover.
1315
+ *
1316
+ * `PublishAllXml` walks every entity and option set in the organization: on a
1317
+ * large org that is minutes of CPU, and it is the piece that used to time out.
1318
+ * Everything we write is local to an entity or a global option set, so the
1319
+ * publish can be narrowed to exactly those.
1320
+ */
1321
+ export function publishTargets(results) {
1322
+ const entities = new Set()
1323
+ const optionSets = new Set()
1324
+ for (const operation of results) {
1325
+ switch (operation.type) {
1326
+ case 'InsertOptionValue':
1327
+ case 'UpdateOptionValue':
1328
+ case 'DeleteOptionValue':
1329
+ if (operation.entity) entities.add(operation.entity)
1330
+ else if (operation.optionSetName) optionSets.add(operation.optionSetName)
1331
+ break
1332
+ case 'CreateAttribute':
1333
+ case 'UpdateAttribute':
1334
+ case 'DeleteAttribute':
1335
+ case 'UpdateEntity':
1336
+ case 'UpdateViewLayout':
1337
+ if (operation.entity) entities.add(operation.entity)
1338
+ break
1339
+ case 'RemoveActiveCustomizations':
1340
+ // The message itself names a numeric component type; what has to be
1341
+ // published is whichever table (or global option set) owns it.
1342
+ if (operation.entity) entities.add(operation.entity)
1343
+ else if (operation.optionSetName) optionSets.add(operation.optionSetName)
1344
+ break
1345
+ default:
1346
+ break
1347
+ }
1348
+ }
1349
+ return { entities: [...entities].sort(), optionSets: [...optionSets].sort() }
1350
+ }
1351
+
1352
+ function escapeXml(value) {
1353
+ return String(value)
1354
+ .replace(/&/g, '&amp;')
1355
+ .replace(/</g, '&lt;')
1356
+ .replace(/>/g, '&gt;')
1357
+ .replace(/"/g, '&quot;')
1358
+ .replace(/'/g, '&apos;')
1359
+ }
1360
+
1361
+ /**
1362
+ * `PublishXml` takes an `importexportxml` document listing what to publish; the
1363
+ * element order mirrors the solution schema (entities before optionsets).
1364
+ */
1365
+ export function buildPublishXml({ entities = [], optionSets = [] }) {
1366
+ const parts = []
1367
+ if (entities.length) {
1368
+ parts.push(`<entities>${entities.map((name) => `<entity>${escapeXml(name)}</entity>`).join('')}</entities>`)
1369
+ }
1370
+ if (optionSets.length) {
1371
+ parts.push(`<optionsets>${optionSets.map((name) => `<optionset>${escapeXml(name)}</optionset>`).join('')}</optionsets>`)
1372
+ }
1373
+ return `<importexportxml>${parts.join('')}</importexportxml>`
1374
+ }