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.
- package/CHANGELOG.md +318 -308
- package/CHANGELOG.zh-CN.md +317 -307
- package/INSTALL.md +94 -94
- package/INSTALL.zh-CN.md +93 -93
- package/LICENSE +21 -21
- package/README.md +336 -327
- package/README.zh-CN.md +336 -327
- package/client.js +1818 -1818
- package/cordis.patch.yml +44 -44
- package/docs/en/architecture.md +172 -172
- package/docs/en/checks.md +184 -184
- package/docs/en/installation.md +151 -151
- package/docs/en/permissions.md +161 -161
- package/docs/en/scenarios.md +202 -202
- package/docs/en/tools.md +665 -665
- package/docs/en/troubleshooting.md +131 -131
- package/docs/zh/architecture.md +172 -172
- package/docs/zh/checks.md +184 -184
- package/docs/zh/installation.md +151 -151
- package/docs/zh/permissions.md +161 -161
- package/docs/zh/scenarios.md +202 -202
- package/docs/zh/tools.md +665 -665
- package/docs/zh/troubleshooting.md +131 -131
- package/examples/prompts.en.md +206 -206
- package/examples/prompts.zh-CN.md +206 -206
- package/examples/waivers.example.json +20 -20
- package/icon.svg +6 -6
- package/index.js +139 -139
- package/lib/auth.js +181 -181
- package/lib/client.js +496 -496
- package/lib/code-check.js +549 -549
- package/lib/config.js +323 -323
- package/lib/contract-history.js +49 -49
- package/lib/contract.js +583 -583
- package/lib/csharp-check.js +493 -493
- package/lib/data-migrate.js +1318 -1318
- package/lib/data-policy.js +78 -78
- package/lib/error-codes.js +80 -80
- package/lib/gitlab.js +161 -161
- package/lib/health.js +205 -205
- package/lib/migrate.js +732 -732
- package/lib/preflight.js +116 -116
- package/lib/refs.js +191 -191
- package/lib/resources.js +320 -320
- package/lib/retention-defaults.js +17 -17
- package/lib/retention.js +384 -384
- package/lib/runtime.js +232 -232
- package/lib/settings-http.js +512 -512
- package/lib/settings-store.js +371 -371
- package/lib/snapshot.js +149 -149
- package/lib/tool-code-check.js +133 -133
- package/lib/tool-configure.js +169 -169
- package/lib/tool-contract.js +376 -376
- package/lib/tool-data-sync.js +559 -559
- package/lib/tool-data-write.js +203 -203
- package/lib/tool-data.js +282 -282
- package/lib/tool-diff.js +484 -484
- package/lib/tool-environments.js +106 -106
- package/lib/tool-flows.js +540 -540
- package/lib/tool-health.js +127 -127
- package/lib/tool-layers.js +497 -497
- package/lib/tool-metadata.js +417 -417
- package/lib/tool-migrate.js +102 -102
- package/lib/tool-refs.js +206 -206
- package/lib/tool-reports.js +299 -299
- package/lib/tool-sync.js +430 -430
- package/lib/tool-write.js +563 -563
- package/lib/waivers.js +83 -83
- package/lib/write-actions.js +1374 -1374
- package/lib/write-policy.js +199 -199
- package/lib/write-store.js +167 -167
- package/locale/en.json +68 -68
- package/locale/zh.json +68 -68
- package/package.json +2 -2
- package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/migrate.js
CHANGED
|
@@ -1,732 +1,732 @@
|
|
|
1
|
-
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
|
|
4
|
-
import { D365Error } from './errors.js'
|
|
5
|
-
import { dataRoot } from './snapshot.js'
|
|
6
|
-
import { DataverseClient } from './client.js'
|
|
7
|
-
|
|
8
|
-
/** componenttype → the name msdyn_componentlayer uses for it. */
|
|
9
|
-
const LAYER_TYPES = {
|
|
10
|
-
1: 'Entity',
|
|
11
|
-
2: 'Attribute',
|
|
12
|
-
9: 'OptionSet',
|
|
13
|
-
10: 'EntityRelationship',
|
|
14
|
-
29: 'Workflow',
|
|
15
|
-
61: 'WebResource',
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const TYPE_LABELS = {
|
|
19
|
-
1: '表',
|
|
20
|
-
2: '字段',
|
|
21
|
-
9: '选项集',
|
|
22
|
-
10: '关系',
|
|
23
|
-
29: '流程',
|
|
24
|
-
61: 'Web 资源',
|
|
25
|
-
36: '视图/图表类',
|
|
26
|
-
50: '安全/扩展类',
|
|
27
|
-
70: '应用类',
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const DEFAULT_MAX_COMPONENTS = 150
|
|
31
|
-
const DEFAULT_CONCURRENCY = 4
|
|
32
|
-
const MAX_CONCURRENCY = 8
|
|
33
|
-
|
|
34
|
-
/** Run `worker` over `items` with at most `limit` in flight, keeping order. */
|
|
35
|
-
async function mapLimit(items, limit, worker) {
|
|
36
|
-
const results = new Array(items.length)
|
|
37
|
-
let cursor = 0
|
|
38
|
-
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
39
|
-
while (cursor < items.length) {
|
|
40
|
-
const index = cursor++
|
|
41
|
-
results[index] = await worker(items[index], index)
|
|
42
|
-
}
|
|
43
|
-
})
|
|
44
|
-
await Promise.all(runners)
|
|
45
|
-
return results
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function typeLabel(type) {
|
|
49
|
-
return TYPE_LABELS[type] ?? `类型 ${type}`
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function layerType(type) {
|
|
53
|
-
return LAYER_TYPES[type] ?? null
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function envLabel(config, key) {
|
|
57
|
-
return config.environments[key]?.label || key
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Parse msdyn_componentjson into a plain key/value map. */
|
|
61
|
-
function componentFields(json) {
|
|
62
|
-
if (!json) return {}
|
|
63
|
-
let payload
|
|
64
|
-
try {
|
|
65
|
-
payload = JSON.parse(json)
|
|
66
|
-
} catch {
|
|
67
|
-
return {}
|
|
68
|
-
}
|
|
69
|
-
const fields = {}
|
|
70
|
-
for (const attribute of payload.Attributes ?? []) fields[attribute.Key] = attribute.Value
|
|
71
|
-
return fields
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function labelText(label) {
|
|
75
|
-
if (!label) return ''
|
|
76
|
-
const localized = Array.isArray(label.LocalizedLabels) ? label.LocalizedLabels : []
|
|
77
|
-
const preferred = localized.find((item) => item.LanguageCode === 2052) ?? localized[0]
|
|
78
|
-
return preferred?.Label ?? ''
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function normalizeVersion(value) {
|
|
82
|
-
return String(value ?? '')
|
|
83
|
-
.split('.')
|
|
84
|
-
.map((part) => Number.parseInt(part, 10) || 0)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** True when `left` is strictly newer than `right`. */
|
|
88
|
-
function versionNewer(left, right) {
|
|
89
|
-
const a = normalizeVersion(left)
|
|
90
|
-
const b = normalizeVersion(right)
|
|
91
|
-
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
92
|
-
const x = a[index] ?? 0
|
|
93
|
-
const y = b[index] ?? 0
|
|
94
|
-
if (x !== y) return x > y
|
|
95
|
-
}
|
|
96
|
-
return false
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function requestAll(client, path, params, signal) {
|
|
100
|
-
const rows = []
|
|
101
|
-
let url = client.buildUrl(path, params)
|
|
102
|
-
let guard = 0
|
|
103
|
-
while (url) {
|
|
104
|
-
const page = await client.requestJson(url, { signal })
|
|
105
|
-
rows.push(...(page.value ?? []))
|
|
106
|
-
url = page['@odata.nextLink']
|
|
107
|
-
guard += 1
|
|
108
|
-
if (guard > 200) break
|
|
109
|
-
}
|
|
110
|
-
return rows
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Query that degrades instead of failing when an endpoint rejects a parameter. */
|
|
114
|
-
async function tryQuery(client, path, params, signal) {
|
|
115
|
-
const attempts = [params, { ...params, $select: undefined }, { ...params, $select: undefined, $top: undefined }]
|
|
116
|
-
let lastError
|
|
117
|
-
for (const attempt of attempts) {
|
|
118
|
-
const cleaned = Object.fromEntries(Object.entries(attempt).filter(([, value]) => value !== undefined))
|
|
119
|
-
try {
|
|
120
|
-
const page = await client.requestJson(client.buildUrl(path, cleaned), { signal })
|
|
121
|
-
return page.value ?? []
|
|
122
|
-
} catch (error) {
|
|
123
|
-
if (error?.status !== 400) throw error
|
|
124
|
-
lastError = error
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
throw lastError
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async function findSolution(client, uniqueName, signal) {
|
|
131
|
-
const rows = await tryQuery(client, 'solutions', {
|
|
132
|
-
$filter: `uniquename eq '${uniqueName.replace(/'/g, "''")}'`,
|
|
133
|
-
$select: 'solutionid,uniquename,friendlyname,version,ismanaged,installedon,publisherid,description',
|
|
134
|
-
}, signal)
|
|
135
|
-
const row = rows.find((item) => String(item.uniquename).toLowerCase() === uniqueName.toLowerCase())
|
|
136
|
-
if (!row) return null
|
|
137
|
-
let publisher = null
|
|
138
|
-
if (row.publisherid) {
|
|
139
|
-
try {
|
|
140
|
-
const publishers = await tryQuery(client, 'publishers', {
|
|
141
|
-
$filter: `publisherid eq ${row.publisherid}`,
|
|
142
|
-
$select: 'publisherid,uniquename,customizationprefix,friendlyname',
|
|
143
|
-
}, signal)
|
|
144
|
-
publisher = publishers[0] ?? null
|
|
145
|
-
} catch {
|
|
146
|
-
publisher = null
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return { ...row, publisher }
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function listComponents(client, solutionId, signal) {
|
|
153
|
-
return requestAll(client, 'solutioncomponents', {
|
|
154
|
-
$filter: `_solutionid_value eq ${solutionId}`,
|
|
155
|
-
$select: 'solutioncomponentid,componenttype,objectid,rootcomponentbehavior',
|
|
156
|
-
}, signal)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** MetadataId → LogicalName map, used to resolve attribute owners. */
|
|
160
|
-
async function entityNameMap(client, signal) {
|
|
161
|
-
const rows = await tryQuery(client, 'EntityDefinitions', { $select: 'MetadataId,LogicalName' }, signal)
|
|
162
|
-
const map = new Map()
|
|
163
|
-
for (const row of rows) map.set(String(row.MetadataId).toLowerCase(), row.LogicalName)
|
|
164
|
-
return map
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function readLayers(client, componentId, typeName, signal) {
|
|
168
|
-
try {
|
|
169
|
-
const rows = await tryQuery(client, 'msdyn_componentlayers', {
|
|
170
|
-
$filter: `msdyn_componentid eq '${componentId}' and msdyn_solutioncomponentname eq '${typeName}'`,
|
|
171
|
-
$select: 'msdyn_name,msdyn_solutionname,msdyn_order,msdyn_publishername,msdyn_overwritetime,msdyn_componentjson',
|
|
172
|
-
}, signal)
|
|
173
|
-
return rows
|
|
174
|
-
} catch (error) {
|
|
175
|
-
if (error?.status === 400 || error?.status === 404) return []
|
|
176
|
-
throw error
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Work out the cross-environment identity of one component. */
|
|
181
|
-
function componentKey(kind, component, topLayer, entities) {
|
|
182
|
-
const fields = componentFields(topLayer?.msdyn_componentjson)
|
|
183
|
-
switch (kind) {
|
|
184
|
-
case 1:
|
|
185
|
-
return { key: fields.logicalname ?? topLayer?.msdyn_name ?? null, extra: {} }
|
|
186
|
-
case 2: {
|
|
187
|
-
const entityKey = fields.entityid ? entities.get(String(fields.entityid).toLowerCase()) : null
|
|
188
|
-
const attributeName = fields.logicalname ?? topLayer?.msdyn_name ?? null
|
|
189
|
-
return { key: attributeName, extra: { entity: entityKey } }
|
|
190
|
-
}
|
|
191
|
-
case 9:
|
|
192
|
-
return { key: fields.name ?? topLayer?.msdyn_name ?? null, extra: {} }
|
|
193
|
-
case 10:
|
|
194
|
-
return { key: fields.schemaname ?? topLayer?.msdyn_name ?? null, extra: {} }
|
|
195
|
-
case 29:
|
|
196
|
-
return { key: fields.name ?? topLayer?.msdyn_name ?? null, extra: { category: fields.category?.Value ?? null } }
|
|
197
|
-
case 61:
|
|
198
|
-
return { key: fields.name ?? topLayer?.msdyn_name ?? null, extra: {} }
|
|
199
|
-
default:
|
|
200
|
-
return { key: null, extra: {} }
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Does the component exist in this environment? */
|
|
205
|
-
async function probe(client, kind, resolved, entities, signal) {
|
|
206
|
-
const { key, extra } = resolved
|
|
207
|
-
const quote = (value) => String(value).replace(/'/g, "''")
|
|
208
|
-
switch (kind) {
|
|
209
|
-
case 1: {
|
|
210
|
-
const rows = await tryQuery(client, 'EntityDefinitions', {
|
|
211
|
-
$filter: `LogicalName eq '${quote(key)}'`,
|
|
212
|
-
$select: 'MetadataId,LogicalName',
|
|
213
|
-
}, signal)
|
|
214
|
-
return rows[0] ? { exists: true, id: rows[0].MetadataId, display: rows[0].LogicalName } : { exists: false }
|
|
215
|
-
}
|
|
216
|
-
case 2: {
|
|
217
|
-
if (!extra.entity) return { exists: false, unknown: '无法确定所属表' }
|
|
218
|
-
const entityId = entities.get(String(extra.entity).toLowerCase()) ?? extra.entity
|
|
219
|
-
const rows = await tryQuery(client, `EntityDefinitions(LogicalName='${quote(entityId)}')/Attributes`, {
|
|
220
|
-
$filter: `LogicalName eq '${quote(key)}'`,
|
|
221
|
-
$select: 'MetadataId,LogicalName',
|
|
222
|
-
}, signal)
|
|
223
|
-
return rows[0] ? { exists: true, id: rows[0].MetadataId, display: `${entityId}.${rows[0].LogicalName}` } : { exists: false }
|
|
224
|
-
}
|
|
225
|
-
case 9: {
|
|
226
|
-
try {
|
|
227
|
-
const definition = await client.requestJson(
|
|
228
|
-
client.buildUrl(`GlobalOptionSetDefinitions(Name='${quote(key)}')/Microsoft.Dynamics.CRM.OptionSetMetadata`),
|
|
229
|
-
{ signal },
|
|
230
|
-
)
|
|
231
|
-
return { exists: true, id: definition.MetadataId, display: definition.Name, options: definition.Options ?? [] }
|
|
232
|
-
} catch (error) {
|
|
233
|
-
if (error?.status === 404) return { exists: false }
|
|
234
|
-
throw error
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
case 10: {
|
|
238
|
-
const rows = await tryQuery(client, 'RelationshipDefinitions', {
|
|
239
|
-
$filter: `SchemaName eq '${quote(key)}'`,
|
|
240
|
-
$select: 'SchemaName,MetadataId',
|
|
241
|
-
}, signal)
|
|
242
|
-
return rows[0] ? { exists: true, id: rows[0].MetadataId, display: rows[0].SchemaName } : { exists: false }
|
|
243
|
-
}
|
|
244
|
-
case 29: {
|
|
245
|
-
const rows = await tryQuery(client, 'workflows', {
|
|
246
|
-
$filter: `name eq '${quote(key)}'`,
|
|
247
|
-
$select: 'workflowid,name,category,type,statecode,statuscode,modifiedon',
|
|
248
|
-
}, signal)
|
|
249
|
-
const match = extra.category === null ? rows[0] : rows.find((row) => row.category === extra.category) ?? rows[0]
|
|
250
|
-
return match ? { exists: true, id: match.workflowid, display: match.name, statecode: match.statecode } : { exists: false }
|
|
251
|
-
}
|
|
252
|
-
case 61: {
|
|
253
|
-
const rows = await tryQuery(client, 'webresourceset', {
|
|
254
|
-
$filter: `name eq '${quote(key)}'`,
|
|
255
|
-
$select: 'webresourceid,name,webresourcetype,modifiedon,ismanaged',
|
|
256
|
-
}, signal)
|
|
257
|
-
return rows[0]
|
|
258
|
-
? { exists: true, id: rows[0].webresourceid, display: rows[0].name, modifiedon: rows[0].modifiedon }
|
|
259
|
-
: { exists: false }
|
|
260
|
-
}
|
|
261
|
-
default:
|
|
262
|
-
return { exists: null, unknown: '未知组件类型' }
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function solutionNameOf(layer) {
|
|
267
|
-
return layer?.msdyn_solutionname || '(系统/活动层)'
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/** `Active` is Dataverse's pseudo-solution for unmanaged layers, not a solution. */
|
|
271
|
-
function isActiveLayerName(name) {
|
|
272
|
-
return /^active$/i.test(String(name ?? ''))
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** Summarise the option values of one option set. */
|
|
276
|
-
function optionIndex(options) {
|
|
277
|
-
const map = new Map()
|
|
278
|
-
for (const option of options ?? []) {
|
|
279
|
-
map.set(Number(option.Value), labelText(option.Label))
|
|
280
|
-
}
|
|
281
|
-
return map
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function compareOptions(sourceOptions, targetOptions) {
|
|
285
|
-
const source = optionIndex(sourceOptions)
|
|
286
|
-
const target = optionIndex(targetOptions)
|
|
287
|
-
const targetOnly = [...target.keys()].filter((value) => !source.has(value))
|
|
288
|
-
const sourceOnly = [...source.keys()].filter((value) => !target.has(value))
|
|
289
|
-
let labelDrift = 0
|
|
290
|
-
for (const [value, label] of source) {
|
|
291
|
-
if (target.has(value) && target.get(value) !== label) labelDrift += 1
|
|
292
|
-
}
|
|
293
|
-
return { sourceCount: source.size, targetCount: target.size, targetOnly, sourceOnly, labelDrift }
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function formatList(values, limit = 6) {
|
|
297
|
-
if (!values.length) return '—'
|
|
298
|
-
const head = values.slice(0, limit).join('、')
|
|
299
|
-
return values.length > limit ? `${head} 等 ${values.length} 个` : head
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function severityOf(findings, severity) {
|
|
303
|
-
return findings.filter((finding) => finding.severity === severity)
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function renderMarkdown(report) {
|
|
307
|
-
const lines = []
|
|
308
|
-
lines.push(`# 迁移评估:${report.solution.uniqueName} → ${report.target.label}`, '')
|
|
309
|
-
lines.push(`- 源环境:${report.source.label}(${report.source.url})`)
|
|
310
|
-
lines.push(`- 目标环境:${report.target.label}(${report.target.url})`)
|
|
311
|
-
lines.push(`- 生成时间:${report.generatedAt}`, '')
|
|
312
|
-
|
|
313
|
-
lines.push('## 结论', '')
|
|
314
|
-
lines.push(report.verdict.summary, '')
|
|
315
|
-
if (report.verdict.recommendation) {
|
|
316
|
-
lines.push(`建议:${report.verdict.recommendation}`, '')
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
lines.push('## 解决方案', '')
|
|
320
|
-
lines.push('| | 源环境 | 目标环境 |', '| --- | --- | --- |')
|
|
321
|
-
lines.push(`| 是否存在 | ${report.solution.sourceExists ? '是' : '否'} | ${report.solution.targetExists ? '是' : '否'} |`)
|
|
322
|
-
lines.push(`| 版本 | ${report.solution.sourceVersion ?? '—'} | ${report.solution.targetVersion ?? '—'} |`)
|
|
323
|
-
lines.push(`| 受管 | ${report.solution.sourceManaged ? '是' : '否'} | ${report.solution.targetManaged === null ? '—' : report.solution.targetManaged ? '是' : '否'} |`)
|
|
324
|
-
if (report.solution.publisher) {
|
|
325
|
-
lines.push(`| 发布者 | ${report.solution.publisher}(前缀 ${report.solution.prefix ?? '—'}) | |`)
|
|
326
|
-
}
|
|
327
|
-
lines.push('')
|
|
328
|
-
|
|
329
|
-
lines.push('## 组件总览', '')
|
|
330
|
-
lines.push('| 类型 | 组件数 | 目标缺失 | 目标已有层 | 无法定位 |', '| --- | --- | --- | --- | --- |')
|
|
331
|
-
for (const row of report.componentRows) {
|
|
332
|
-
lines.push(`| ${row.label} | ${row.total} | ${row.missing} | ${row.withLayers} | ${row.unresolved} |`)
|
|
333
|
-
}
|
|
334
|
-
lines.push('')
|
|
335
|
-
|
|
336
|
-
if (report.truncated) {
|
|
337
|
-
lines.push(`> 组件超过 maxComponents=${report.maxComponents},本次只评估了 ${report.evaluated} 个;传 full: true 可全量评估。`, '')
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const shadowed = report.shadowedSolutions
|
|
341
|
-
if (shadowed.length) {
|
|
342
|
-
lines.push('## 将被遮蔽的解决方案', '')
|
|
343
|
-
lines.push('导入后本方案层位于最上层,下列解决方案在这些组件上的定义将不再生效:', '')
|
|
344
|
-
for (const item of shadowed.slice(0, 20)) {
|
|
345
|
-
lines.push(`- ${item.solution}:${item.count} 个组件(${formatList(item.examples, 4)})`)
|
|
346
|
-
}
|
|
347
|
-
if (shadowed.length > 20) lines.push(`- … 其余 ${shadowed.length - 20} 个`)
|
|
348
|
-
lines.push('')
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const missing = report.findings.filter((finding) => finding.code === 'component-missing')
|
|
352
|
-
if (missing.length) {
|
|
353
|
-
lines.push('## 目标环境没有的组件', '')
|
|
354
|
-
for (const finding of missing.slice(0, 25)) {
|
|
355
|
-
lines.push(`- ${finding.title}`)
|
|
356
|
-
}
|
|
357
|
-
if (missing.length > 25) lines.push(`- … 其余 ${missing.length - 25} 个`)
|
|
358
|
-
lines.push('')
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (report.optionSets.length) {
|
|
362
|
-
lines.push('## 选项集数据对比', '')
|
|
363
|
-
lines.push('| 选项集 | 源值数 | 目标值数 | 源多出 | 目标多出(会丢) | 标签不一致 |', '| --- | --- | --- | --- | --- | --- |')
|
|
364
|
-
for (const item of report.optionSets) {
|
|
365
|
-
lines.push(`| ${item.name} | ${item.sourceCount} | ${item.targetCount} | ${item.sourceOnly.length} | ${item.targetOnly.length} | ${item.labelDrift} |`)
|
|
366
|
-
}
|
|
367
|
-
lines.push('')
|
|
368
|
-
for (const item of report.optionSets) {
|
|
369
|
-
if (item.targetOnly.length) {
|
|
370
|
-
lines.push(`> ${item.name}:目标环境独有 ${item.targetOnly.length} 个值(${formatList(item.targetOnly.map(String), 8)}),导入后会丢失。`)
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
if (report.optionSets.some((item) => item.targetOnly.length)) lines.push('')
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
for (const severity of ['critical', 'warning', 'info']) {
|
|
377
|
-
const group = severityOf(report.findings, severity)
|
|
378
|
-
if (!group.length) continue
|
|
379
|
-
lines.push(`## ${severity === 'critical' ? '严重' : severity === 'warning' ? '警告' : '提示'}`, '')
|
|
380
|
-
for (const finding of group) {
|
|
381
|
-
lines.push(`- **${finding.title}**${finding.detail ? `:${finding.detail}` : ''}`)
|
|
382
|
-
}
|
|
383
|
-
lines.push('')
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
lines.push('## 说明', '')
|
|
387
|
-
lines.push('- 层信息来自 `msdyn_componentlayer` 虚拟实体,只能按「组件 id + 组件类型」逐个查。')
|
|
388
|
-
lines.push('- 层叠顺序由导入时间决定,与版本号无关:谁后导入谁在上。')
|
|
389
|
-
lines.push('- 组件是否存在于目标环境按名称/逻辑名匹配,跨环境 id 不可复用。')
|
|
390
|
-
lines.push('- 未评估的组件类型只做了存在性判断,层信息不可用。')
|
|
391
|
-
return lines.join('\n')
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Assess the risk of importing one solution into other environments.
|
|
396
|
-
*/
|
|
397
|
-
export async function assessMigration(config, request, signal) {
|
|
398
|
-
const sourceKey = request.source || config.defaultEnvironment
|
|
399
|
-
const sourceEnv = config.environments[sourceKey]
|
|
400
|
-
if (!sourceEnv) {
|
|
401
|
-
throw new D365Error(`源环境不存在:${sourceKey}`, {
|
|
402
|
-
code: 'unknown-environment',
|
|
403
|
-
hint: `可用环境:${Object.keys(config.environments).join('、')}`,
|
|
404
|
-
})
|
|
405
|
-
}
|
|
406
|
-
const solutionName = String(request.solution ?? '').trim()
|
|
407
|
-
if (!solutionName) {
|
|
408
|
-
throw new D365Error('缺少 solution:要评估的解决方案唯一名', { code: 'missing-solution' })
|
|
409
|
-
}
|
|
410
|
-
const targetKeys = (request.targets?.length ? request.targets : Object.keys(config.environments).filter((key) => key !== sourceKey))
|
|
411
|
-
for (const key of targetKeys) {
|
|
412
|
-
if (!config.environments[key]) {
|
|
413
|
-
throw new D365Error(`目标环境不存在:${key}`, {
|
|
414
|
-
code: 'unknown-environment',
|
|
415
|
-
hint: `可用环境:${Object.keys(config.environments).join('、')}`,
|
|
416
|
-
})
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const timeoutMs = config.timeoutMs
|
|
421
|
-
const source = new DataverseClient(sourceEnv, { timeoutMs })
|
|
422
|
-
const maxComponents = Number.isFinite(request.maxComponents) && request.maxComponents > 0
|
|
423
|
-
? Math.trunc(request.maxComponents)
|
|
424
|
-
: DEFAULT_MAX_COMPONENTS
|
|
425
|
-
const concurrency = Math.min(
|
|
426
|
-
MAX_CONCURRENCY,
|
|
427
|
-
Math.max(1, Math.trunc(Number(request.concurrency) || DEFAULT_CONCURRENCY)),
|
|
428
|
-
)
|
|
429
|
-
|
|
430
|
-
const solution = await findSolution(source, solutionName, signal)
|
|
431
|
-
if (!solution) {
|
|
432
|
-
const available = await tryQuery(source, 'solutions', { $select: 'uniquename', $top: 50 }, signal)
|
|
433
|
-
const sample = available.slice(0, 10).map((row) => row.uniquename).join('、')
|
|
434
|
-
throw new D365Error(`源环境 ${sourceKey} 没有解决方案 ${solutionName}`, {
|
|
435
|
-
code: 'unknown-solution',
|
|
436
|
-
hint: sample ? `已安装的解决方案例如:${sample}` : undefined,
|
|
437
|
-
})
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const components = await listComponents(source, solution.solutionid, signal)
|
|
441
|
-
const truncated = components.length > maxComponents && request.full !== true
|
|
442
|
-
const evaluated = truncated ? components.slice(0, maxComponents) : components
|
|
443
|
-
|
|
444
|
-
const needsEntities = evaluated.some((component) => component.componenttype === 2)
|
|
445
|
-
const entities = needsEntities ? await entityNameMap(source, signal) : new Map()
|
|
446
|
-
|
|
447
|
-
const resolved = await mapLimit(evaluated, concurrency, async (component) => {
|
|
448
|
-
const typeName = layerType(component.componenttype)
|
|
449
|
-
const layers = typeName ? await readLayers(source, component.objectid, typeName, signal) : []
|
|
450
|
-
const sorted = layers.slice().sort((a, b) => (Number(a.msdyn_order) || 0) - (Number(b.msdyn_order) || 0))
|
|
451
|
-
const top = sorted[sorted.length - 1] ?? null
|
|
452
|
-
const key = componentKey(component.componenttype, component, top, entities)
|
|
453
|
-
return {
|
|
454
|
-
component,
|
|
455
|
-
layerType: typeName,
|
|
456
|
-
layers: sorted,
|
|
457
|
-
top,
|
|
458
|
-
resolved: key,
|
|
459
|
-
sourceLabel: top?.msdyn_name ?? key.key ?? String(component.objectid).slice(0, 8),
|
|
460
|
-
}
|
|
461
|
-
})
|
|
462
|
-
|
|
463
|
-
const optionCache = new Map()
|
|
464
|
-
const targets = await Promise.all(targetKeys.map(async (targetKey) => {
|
|
465
|
-
const targetEnv = config.environments[targetKey]
|
|
466
|
-
const target = new DataverseClient(targetEnv, { timeoutMs })
|
|
467
|
-
const targetSolution = await findSolution(target, solution.uniquename, signal)
|
|
468
|
-
const targetEntities = needsEntities ? await entityNameMap(target, signal) : new Map()
|
|
469
|
-
const findings = []
|
|
470
|
-
const optionSets = []
|
|
471
|
-
const shadowed = new Map()
|
|
472
|
-
const buckets = []
|
|
473
|
-
const missing = []
|
|
474
|
-
const unresolved = []
|
|
475
|
-
|
|
476
|
-
if (!targetSolution) {
|
|
477
|
-
findings.push({
|
|
478
|
-
severity: 'warning',
|
|
479
|
-
confidence: 'high',
|
|
480
|
-
code: 'solution-absent',
|
|
481
|
-
title: `目标环境没有解决方案 ${solution.uniquename}`,
|
|
482
|
-
detail: '导入后会新建一个 unmanaged 层,无法通过卸载解决方案清除',
|
|
483
|
-
})
|
|
484
|
-
} else {
|
|
485
|
-
if (!versionNewer(solution.version, targetSolution.version)) {
|
|
486
|
-
findings.push({
|
|
487
|
-
severity: 'warning',
|
|
488
|
-
confidence: 'high',
|
|
489
|
-
code: 'version-not-newer',
|
|
490
|
-
title: `目标版本 ${targetSolution.version} 不低于源版本 ${solution.version}`,
|
|
491
|
-
detail: '导入后版本号可能不递增,后续升级判断会失真',
|
|
492
|
-
})
|
|
493
|
-
}
|
|
494
|
-
if (targetSolution.ismanaged !== solution.ismanaged) {
|
|
495
|
-
findings.push({
|
|
496
|
-
severity: 'warning',
|
|
497
|
-
confidence: 'high',
|
|
498
|
-
code: 'managed-mismatch',
|
|
499
|
-
title: `受管状态不一致:源 ${solution.ismanaged ? '受管' : '未受管'},目标 ${targetSolution.ismanaged ? '受管' : '未受管'}`,
|
|
500
|
-
detail: '未受管解决方案导入会形成不可卸载的层',
|
|
501
|
-
})
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
if (solution.ismanaged === false) {
|
|
506
|
-
findings.push({
|
|
507
|
-
severity: 'warning',
|
|
508
|
-
confidence: 'high',
|
|
509
|
-
code: 'source-unmanaged',
|
|
510
|
-
title: '源解决方案未受管',
|
|
511
|
-
detail: '导入后目标环境形成 unmanaged 层,只能逐组件删层,无法用卸载解决方案回退',
|
|
512
|
-
})
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
await mapLimit(resolved, concurrency, async (item) => {
|
|
516
|
-
const { component, layerType: typeName, sourceLabel, resolved: key } = item
|
|
517
|
-
const kind = component.componenttype
|
|
518
|
-
const bucket = { type: kind, label: typeLabel(kind), name: sourceLabel, key: key.key }
|
|
519
|
-
buckets.push(bucket)
|
|
520
|
-
if (!typeName) {
|
|
521
|
-
bucket.status = 'unresolved'
|
|
522
|
-
unresolved.push(bucket)
|
|
523
|
-
return
|
|
524
|
-
}
|
|
525
|
-
if (!key.key) {
|
|
526
|
-
bucket.status = 'unresolved'
|
|
527
|
-
unresolved.push(bucket)
|
|
528
|
-
return
|
|
529
|
-
}
|
|
530
|
-
let result
|
|
531
|
-
try {
|
|
532
|
-
result = await probe(target, kind, key, targetEntities, signal)
|
|
533
|
-
} catch (error) {
|
|
534
|
-
bucket.status = 'error'
|
|
535
|
-
bucket.error = error instanceof Error ? error.message : String(error)
|
|
536
|
-
unresolved.push(bucket)
|
|
537
|
-
return
|
|
538
|
-
}
|
|
539
|
-
bucket.display = result.display ?? sourceLabel
|
|
540
|
-
if (result.unknown) {
|
|
541
|
-
bucket.status = 'unresolved'
|
|
542
|
-
bucket.unknown = result.unknown
|
|
543
|
-
unresolved.push(bucket)
|
|
544
|
-
return
|
|
545
|
-
}
|
|
546
|
-
if (!result.exists) {
|
|
547
|
-
bucket.status = 'missing'
|
|
548
|
-
missing.push(bucket)
|
|
549
|
-
findings.push({
|
|
550
|
-
severity: 'info',
|
|
551
|
-
confidence: 'high',
|
|
552
|
-
code: 'component-missing',
|
|
553
|
-
title: `${typeLabel(kind)}「${bucket.display}」在目标环境不存在`,
|
|
554
|
-
detail: '导入时会作为新组件创建',
|
|
555
|
-
})
|
|
556
|
-
return
|
|
557
|
-
}
|
|
558
|
-
bucket.status = 'present'
|
|
559
|
-
const targetLayers = typeName ? await readLayers(target, result.id, typeName, signal) : []
|
|
560
|
-
const sorted = targetLayers.slice().sort((a, b) => (Number(a.msdyn_order) || 0) - (Number(b.msdyn_order) || 0))
|
|
561
|
-
const top = sorted[sorted.length - 1] ?? null
|
|
562
|
-
bucket.topLayer = solutionNameOf(top)
|
|
563
|
-
bucket.layerCount = sorted.length
|
|
564
|
-
for (const layer of sorted) {
|
|
565
|
-
const name = layer.msdyn_solutionname
|
|
566
|
-
if (!name) continue
|
|
567
|
-
// Two names are not "other solutions that get shadowed": `Active` is
|
|
568
|
-
// Dataverse's pseudo-solution for unmanaged layers, and the solution
|
|
569
|
-
// being imported is the one that will replace these layers, not lose.
|
|
570
|
-
if (isActiveLayerName(name)) continue
|
|
571
|
-
if (name.toLowerCase() === String(solution.uniquename ?? '').toLowerCase()) continue
|
|
572
|
-
if (!shadowed.has(name)) shadowed.set(name, { solution: name, count: 0, examples: [] })
|
|
573
|
-
const entry = shadowed.get(name)
|
|
574
|
-
entry.count += 1
|
|
575
|
-
if (entry.examples.length < 8) entry.examples.push(bucket.display)
|
|
576
|
-
}
|
|
577
|
-
if (kind === 9 && result.options) {
|
|
578
|
-
const comparison = await compareOptionSet(source, target, key.key, result.options, signal, optionCache)
|
|
579
|
-
if (comparison) {
|
|
580
|
-
optionSets.push({ name: bucket.display, ...comparison })
|
|
581
|
-
if (comparison.targetOnly.length) {
|
|
582
|
-
findings.push({
|
|
583
|
-
severity: 'critical',
|
|
584
|
-
confidence: 'high',
|
|
585
|
-
code: 'option-values-lost',
|
|
586
|
-
title: `选项集 ${bucket.display} 在目标环境多出 ${comparison.targetOnly.length} 个值`,
|
|
587
|
-
detail: '导入后这些值会被覆盖掉,引用它们的数据可能失效',
|
|
588
|
-
})
|
|
589
|
-
}
|
|
590
|
-
if (comparison.labelDrift) {
|
|
591
|
-
findings.push({
|
|
592
|
-
severity: 'warning',
|
|
593
|
-
confidence: 'high',
|
|
594
|
-
code: 'option-label-drift',
|
|
595
|
-
title: `选项集 ${bucket.display} 有 ${comparison.labelDrift} 个标签与源环境不一致`,
|
|
596
|
-
detail: '导入后会被源环境的标签覆盖',
|
|
597
|
-
})
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
})
|
|
602
|
-
|
|
603
|
-
const byType = new Map()
|
|
604
|
-
for (const bucket of buckets) {
|
|
605
|
-
const entry = byType.get(bucket.type) ?? {
|
|
606
|
-
type: bucket.type,
|
|
607
|
-
label: bucket.label,
|
|
608
|
-
total: 0,
|
|
609
|
-
missing: 0,
|
|
610
|
-
withLayers: 0,
|
|
611
|
-
unresolved: 0,
|
|
612
|
-
}
|
|
613
|
-
entry.total += 1
|
|
614
|
-
if (bucket.status === 'missing') entry.missing += 1
|
|
615
|
-
else if (bucket.status === 'present') entry.withLayers += 1
|
|
616
|
-
else entry.unresolved += 1
|
|
617
|
-
byType.set(bucket.type, entry)
|
|
618
|
-
}
|
|
619
|
-
const componentRows = [...byType.values()].sort((a, b) => b.total - a.total)
|
|
620
|
-
|
|
621
|
-
const shadowedSolutions = [...shadowed.values()].sort((a, b) => b.count - a.count)
|
|
622
|
-
const missingCount = missing.length
|
|
623
|
-
const critical = findings.filter((finding) => finding.severity === 'critical').length
|
|
624
|
-
const warnings = findings.filter((finding) => finding.severity === 'warning').length
|
|
625
|
-
const verdict = {
|
|
626
|
-
summary: critical
|
|
627
|
-
? `发现 ${critical} 个严重问题:${formatList(findings.filter((f) => f.severity === 'critical').map((f) => f.title), 3)}`
|
|
628
|
-
: `没有发现数据丢失风险;${missingCount} 个组件在目标环境不存在,${shadowedSolutions.length} 个解决方案的层会被遮蔽。`,
|
|
629
|
-
recommendation: critical
|
|
630
|
-
? '先处理严重问题再发布:把变更并回受管解决方案,或先在目标环境补齐缺失值。'
|
|
631
|
-
: shadowedSolutions.length
|
|
632
|
-
? '如果这些遮蔽不可接受,考虑把改动并入目标环境已有的受管解决方案,随其升级导入。'
|
|
633
|
-
: null,
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
return {
|
|
637
|
-
key: targetKey,
|
|
638
|
-
label: envLabel(config, targetKey),
|
|
639
|
-
url: targetEnv.url,
|
|
640
|
-
findings,
|
|
641
|
-
critical,
|
|
642
|
-
warnings,
|
|
643
|
-
missing,
|
|
644
|
-
unresolved,
|
|
645
|
-
optionSets,
|
|
646
|
-
shadowedSolutions,
|
|
647
|
-
componentRows,
|
|
648
|
-
verdict,
|
|
649
|
-
targetSolution: targetSolution
|
|
650
|
-
? { exists: true, version: targetSolution.version, managed: targetSolution.ismanaged }
|
|
651
|
-
: { exists: false, version: null, managed: null },
|
|
652
|
-
}
|
|
653
|
-
}))
|
|
654
|
-
|
|
655
|
-
const reports = []
|
|
656
|
-
const root = join(dataRoot(config), 'reports')
|
|
657
|
-
await mkdir(root, { recursive: true })
|
|
658
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
659
|
-
for (const target of targets) {
|
|
660
|
-
const report = {
|
|
661
|
-
generatedAt: new Date().toISOString(),
|
|
662
|
-
source: { key: sourceKey, label: envLabel(config, sourceKey), url: sourceEnv.url },
|
|
663
|
-
target,
|
|
664
|
-
solution: {
|
|
665
|
-
uniqueName: solution.uniquename,
|
|
666
|
-
friendlyName: solution.friendlyname,
|
|
667
|
-
sourceExists: true,
|
|
668
|
-
targetExists: target.targetSolution.exists,
|
|
669
|
-
sourceVersion: solution.version,
|
|
670
|
-
targetVersion: target.targetSolution.version,
|
|
671
|
-
sourceManaged: solution.ismanaged,
|
|
672
|
-
targetManaged: target.targetSolution.managed,
|
|
673
|
-
publisher: solution.publisher?.friendlyname ?? solution.publisher?.uniquename ?? null,
|
|
674
|
-
prefix: solution.publisher?.customizationprefix ?? null,
|
|
675
|
-
},
|
|
676
|
-
truncated,
|
|
677
|
-
maxComponents,
|
|
678
|
-
evaluated: resolved.length,
|
|
679
|
-
componentRows: target.componentRows,
|
|
680
|
-
shadowedSolutions: target.shadowedSolutions,
|
|
681
|
-
optionSets: target.optionSets,
|
|
682
|
-
findings: target.findings,
|
|
683
|
-
verdict: target.verdict,
|
|
684
|
-
}
|
|
685
|
-
const markdown = renderMarkdown(report)
|
|
686
|
-
const base = `migration-${solution.uniquename}-to-${target.key}-${stamp}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
687
|
-
const mdFile = join(root, `${base}.md`)
|
|
688
|
-
const jsonFile = join(root, `${base}.json`)
|
|
689
|
-
await writeFile(mdFile, `${markdown}\n`, 'utf8')
|
|
690
|
-
await writeFile(jsonFile, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
|
691
|
-
reports.push({
|
|
692
|
-
target: target.key,
|
|
693
|
-
targetLabel: target.label,
|
|
694
|
-
critical: target.critical,
|
|
695
|
-
warnings: target.warnings,
|
|
696
|
-
missing: target.missing.length,
|
|
697
|
-
shadowed: target.shadowedSolutions.length,
|
|
698
|
-
verdict: target.verdict,
|
|
699
|
-
reportPath: mdFile.replace(/\\/g, '/'),
|
|
700
|
-
jsonPath: jsonFile.replace(/\\/g, '/'),
|
|
701
|
-
})
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
return {
|
|
705
|
-
source: { key: sourceKey, label: envLabel(config, sourceKey) },
|
|
706
|
-
solution: solution.uniquename,
|
|
707
|
-
componentCount: components.length,
|
|
708
|
-
evaluated: resolved.length,
|
|
709
|
-
truncated,
|
|
710
|
-
maxComponents,
|
|
711
|
-
reports,
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
/** Compare one global option set between two environments. */
|
|
716
|
-
async function compareOptionSet(source, target, name, targetOptions, signal, cache = null) {
|
|
717
|
-
const quote = String(name).replace(/'/g, "''")
|
|
718
|
-
try {
|
|
719
|
-
let pending = cache?.get(name)
|
|
720
|
-
if (!pending) {
|
|
721
|
-
pending = source.requestJson(
|
|
722
|
-
source.buildUrl(`GlobalOptionSetDefinitions(Name='${quote}')/Microsoft.Dynamics.CRM.OptionSetMetadata`),
|
|
723
|
-
{ signal },
|
|
724
|
-
)
|
|
725
|
-
cache?.set(name, pending)
|
|
726
|
-
}
|
|
727
|
-
const definition = await pending
|
|
728
|
-
return compareOptions(definition.Options ?? [], targetOptions)
|
|
729
|
-
} catch {
|
|
730
|
-
return null
|
|
731
|
-
}
|
|
732
|
-
}
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { D365Error } from './errors.js'
|
|
5
|
+
import { dataRoot } from './snapshot.js'
|
|
6
|
+
import { DataverseClient } from './client.js'
|
|
7
|
+
|
|
8
|
+
/** componenttype → the name msdyn_componentlayer uses for it. */
|
|
9
|
+
const LAYER_TYPES = {
|
|
10
|
+
1: 'Entity',
|
|
11
|
+
2: 'Attribute',
|
|
12
|
+
9: 'OptionSet',
|
|
13
|
+
10: 'EntityRelationship',
|
|
14
|
+
29: 'Workflow',
|
|
15
|
+
61: 'WebResource',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TYPE_LABELS = {
|
|
19
|
+
1: '表',
|
|
20
|
+
2: '字段',
|
|
21
|
+
9: '选项集',
|
|
22
|
+
10: '关系',
|
|
23
|
+
29: '流程',
|
|
24
|
+
61: 'Web 资源',
|
|
25
|
+
36: '视图/图表类',
|
|
26
|
+
50: '安全/扩展类',
|
|
27
|
+
70: '应用类',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_MAX_COMPONENTS = 150
|
|
31
|
+
const DEFAULT_CONCURRENCY = 4
|
|
32
|
+
const MAX_CONCURRENCY = 8
|
|
33
|
+
|
|
34
|
+
/** Run `worker` over `items` with at most `limit` in flight, keeping order. */
|
|
35
|
+
async function mapLimit(items, limit, worker) {
|
|
36
|
+
const results = new Array(items.length)
|
|
37
|
+
let cursor = 0
|
|
38
|
+
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
39
|
+
while (cursor < items.length) {
|
|
40
|
+
const index = cursor++
|
|
41
|
+
results[index] = await worker(items[index], index)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
await Promise.all(runners)
|
|
45
|
+
return results
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function typeLabel(type) {
|
|
49
|
+
return TYPE_LABELS[type] ?? `类型 ${type}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function layerType(type) {
|
|
53
|
+
return LAYER_TYPES[type] ?? null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function envLabel(config, key) {
|
|
57
|
+
return config.environments[key]?.label || key
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Parse msdyn_componentjson into a plain key/value map. */
|
|
61
|
+
function componentFields(json) {
|
|
62
|
+
if (!json) return {}
|
|
63
|
+
let payload
|
|
64
|
+
try {
|
|
65
|
+
payload = JSON.parse(json)
|
|
66
|
+
} catch {
|
|
67
|
+
return {}
|
|
68
|
+
}
|
|
69
|
+
const fields = {}
|
|
70
|
+
for (const attribute of payload.Attributes ?? []) fields[attribute.Key] = attribute.Value
|
|
71
|
+
return fields
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function labelText(label) {
|
|
75
|
+
if (!label) return ''
|
|
76
|
+
const localized = Array.isArray(label.LocalizedLabels) ? label.LocalizedLabels : []
|
|
77
|
+
const preferred = localized.find((item) => item.LanguageCode === 2052) ?? localized[0]
|
|
78
|
+
return preferred?.Label ?? ''
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeVersion(value) {
|
|
82
|
+
return String(value ?? '')
|
|
83
|
+
.split('.')
|
|
84
|
+
.map((part) => Number.parseInt(part, 10) || 0)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** True when `left` is strictly newer than `right`. */
|
|
88
|
+
function versionNewer(left, right) {
|
|
89
|
+
const a = normalizeVersion(left)
|
|
90
|
+
const b = normalizeVersion(right)
|
|
91
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
92
|
+
const x = a[index] ?? 0
|
|
93
|
+
const y = b[index] ?? 0
|
|
94
|
+
if (x !== y) return x > y
|
|
95
|
+
}
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function requestAll(client, path, params, signal) {
|
|
100
|
+
const rows = []
|
|
101
|
+
let url = client.buildUrl(path, params)
|
|
102
|
+
let guard = 0
|
|
103
|
+
while (url) {
|
|
104
|
+
const page = await client.requestJson(url, { signal })
|
|
105
|
+
rows.push(...(page.value ?? []))
|
|
106
|
+
url = page['@odata.nextLink']
|
|
107
|
+
guard += 1
|
|
108
|
+
if (guard > 200) break
|
|
109
|
+
}
|
|
110
|
+
return rows
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Query that degrades instead of failing when an endpoint rejects a parameter. */
|
|
114
|
+
async function tryQuery(client, path, params, signal) {
|
|
115
|
+
const attempts = [params, { ...params, $select: undefined }, { ...params, $select: undefined, $top: undefined }]
|
|
116
|
+
let lastError
|
|
117
|
+
for (const attempt of attempts) {
|
|
118
|
+
const cleaned = Object.fromEntries(Object.entries(attempt).filter(([, value]) => value !== undefined))
|
|
119
|
+
try {
|
|
120
|
+
const page = await client.requestJson(client.buildUrl(path, cleaned), { signal })
|
|
121
|
+
return page.value ?? []
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error?.status !== 400) throw error
|
|
124
|
+
lastError = error
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
throw lastError
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function findSolution(client, uniqueName, signal) {
|
|
131
|
+
const rows = await tryQuery(client, 'solutions', {
|
|
132
|
+
$filter: `uniquename eq '${uniqueName.replace(/'/g, "''")}'`,
|
|
133
|
+
$select: 'solutionid,uniquename,friendlyname,version,ismanaged,installedon,publisherid,description',
|
|
134
|
+
}, signal)
|
|
135
|
+
const row = rows.find((item) => String(item.uniquename).toLowerCase() === uniqueName.toLowerCase())
|
|
136
|
+
if (!row) return null
|
|
137
|
+
let publisher = null
|
|
138
|
+
if (row.publisherid) {
|
|
139
|
+
try {
|
|
140
|
+
const publishers = await tryQuery(client, 'publishers', {
|
|
141
|
+
$filter: `publisherid eq ${row.publisherid}`,
|
|
142
|
+
$select: 'publisherid,uniquename,customizationprefix,friendlyname',
|
|
143
|
+
}, signal)
|
|
144
|
+
publisher = publishers[0] ?? null
|
|
145
|
+
} catch {
|
|
146
|
+
publisher = null
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { ...row, publisher }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function listComponents(client, solutionId, signal) {
|
|
153
|
+
return requestAll(client, 'solutioncomponents', {
|
|
154
|
+
$filter: `_solutionid_value eq ${solutionId}`,
|
|
155
|
+
$select: 'solutioncomponentid,componenttype,objectid,rootcomponentbehavior',
|
|
156
|
+
}, signal)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** MetadataId → LogicalName map, used to resolve attribute owners. */
|
|
160
|
+
async function entityNameMap(client, signal) {
|
|
161
|
+
const rows = await tryQuery(client, 'EntityDefinitions', { $select: 'MetadataId,LogicalName' }, signal)
|
|
162
|
+
const map = new Map()
|
|
163
|
+
for (const row of rows) map.set(String(row.MetadataId).toLowerCase(), row.LogicalName)
|
|
164
|
+
return map
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function readLayers(client, componentId, typeName, signal) {
|
|
168
|
+
try {
|
|
169
|
+
const rows = await tryQuery(client, 'msdyn_componentlayers', {
|
|
170
|
+
$filter: `msdyn_componentid eq '${componentId}' and msdyn_solutioncomponentname eq '${typeName}'`,
|
|
171
|
+
$select: 'msdyn_name,msdyn_solutionname,msdyn_order,msdyn_publishername,msdyn_overwritetime,msdyn_componentjson',
|
|
172
|
+
}, signal)
|
|
173
|
+
return rows
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error?.status === 400 || error?.status === 404) return []
|
|
176
|
+
throw error
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Work out the cross-environment identity of one component. */
|
|
181
|
+
function componentKey(kind, component, topLayer, entities) {
|
|
182
|
+
const fields = componentFields(topLayer?.msdyn_componentjson)
|
|
183
|
+
switch (kind) {
|
|
184
|
+
case 1:
|
|
185
|
+
return { key: fields.logicalname ?? topLayer?.msdyn_name ?? null, extra: {} }
|
|
186
|
+
case 2: {
|
|
187
|
+
const entityKey = fields.entityid ? entities.get(String(fields.entityid).toLowerCase()) : null
|
|
188
|
+
const attributeName = fields.logicalname ?? topLayer?.msdyn_name ?? null
|
|
189
|
+
return { key: attributeName, extra: { entity: entityKey } }
|
|
190
|
+
}
|
|
191
|
+
case 9:
|
|
192
|
+
return { key: fields.name ?? topLayer?.msdyn_name ?? null, extra: {} }
|
|
193
|
+
case 10:
|
|
194
|
+
return { key: fields.schemaname ?? topLayer?.msdyn_name ?? null, extra: {} }
|
|
195
|
+
case 29:
|
|
196
|
+
return { key: fields.name ?? topLayer?.msdyn_name ?? null, extra: { category: fields.category?.Value ?? null } }
|
|
197
|
+
case 61:
|
|
198
|
+
return { key: fields.name ?? topLayer?.msdyn_name ?? null, extra: {} }
|
|
199
|
+
default:
|
|
200
|
+
return { key: null, extra: {} }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Does the component exist in this environment? */
|
|
205
|
+
async function probe(client, kind, resolved, entities, signal) {
|
|
206
|
+
const { key, extra } = resolved
|
|
207
|
+
const quote = (value) => String(value).replace(/'/g, "''")
|
|
208
|
+
switch (kind) {
|
|
209
|
+
case 1: {
|
|
210
|
+
const rows = await tryQuery(client, 'EntityDefinitions', {
|
|
211
|
+
$filter: `LogicalName eq '${quote(key)}'`,
|
|
212
|
+
$select: 'MetadataId,LogicalName',
|
|
213
|
+
}, signal)
|
|
214
|
+
return rows[0] ? { exists: true, id: rows[0].MetadataId, display: rows[0].LogicalName } : { exists: false }
|
|
215
|
+
}
|
|
216
|
+
case 2: {
|
|
217
|
+
if (!extra.entity) return { exists: false, unknown: '无法确定所属表' }
|
|
218
|
+
const entityId = entities.get(String(extra.entity).toLowerCase()) ?? extra.entity
|
|
219
|
+
const rows = await tryQuery(client, `EntityDefinitions(LogicalName='${quote(entityId)}')/Attributes`, {
|
|
220
|
+
$filter: `LogicalName eq '${quote(key)}'`,
|
|
221
|
+
$select: 'MetadataId,LogicalName',
|
|
222
|
+
}, signal)
|
|
223
|
+
return rows[0] ? { exists: true, id: rows[0].MetadataId, display: `${entityId}.${rows[0].LogicalName}` } : { exists: false }
|
|
224
|
+
}
|
|
225
|
+
case 9: {
|
|
226
|
+
try {
|
|
227
|
+
const definition = await client.requestJson(
|
|
228
|
+
client.buildUrl(`GlobalOptionSetDefinitions(Name='${quote(key)}')/Microsoft.Dynamics.CRM.OptionSetMetadata`),
|
|
229
|
+
{ signal },
|
|
230
|
+
)
|
|
231
|
+
return { exists: true, id: definition.MetadataId, display: definition.Name, options: definition.Options ?? [] }
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error?.status === 404) return { exists: false }
|
|
234
|
+
throw error
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
case 10: {
|
|
238
|
+
const rows = await tryQuery(client, 'RelationshipDefinitions', {
|
|
239
|
+
$filter: `SchemaName eq '${quote(key)}'`,
|
|
240
|
+
$select: 'SchemaName,MetadataId',
|
|
241
|
+
}, signal)
|
|
242
|
+
return rows[0] ? { exists: true, id: rows[0].MetadataId, display: rows[0].SchemaName } : { exists: false }
|
|
243
|
+
}
|
|
244
|
+
case 29: {
|
|
245
|
+
const rows = await tryQuery(client, 'workflows', {
|
|
246
|
+
$filter: `name eq '${quote(key)}'`,
|
|
247
|
+
$select: 'workflowid,name,category,type,statecode,statuscode,modifiedon',
|
|
248
|
+
}, signal)
|
|
249
|
+
const match = extra.category === null ? rows[0] : rows.find((row) => row.category === extra.category) ?? rows[0]
|
|
250
|
+
return match ? { exists: true, id: match.workflowid, display: match.name, statecode: match.statecode } : { exists: false }
|
|
251
|
+
}
|
|
252
|
+
case 61: {
|
|
253
|
+
const rows = await tryQuery(client, 'webresourceset', {
|
|
254
|
+
$filter: `name eq '${quote(key)}'`,
|
|
255
|
+
$select: 'webresourceid,name,webresourcetype,modifiedon,ismanaged',
|
|
256
|
+
}, signal)
|
|
257
|
+
return rows[0]
|
|
258
|
+
? { exists: true, id: rows[0].webresourceid, display: rows[0].name, modifiedon: rows[0].modifiedon }
|
|
259
|
+
: { exists: false }
|
|
260
|
+
}
|
|
261
|
+
default:
|
|
262
|
+
return { exists: null, unknown: '未知组件类型' }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function solutionNameOf(layer) {
|
|
267
|
+
return layer?.msdyn_solutionname || '(系统/活动层)'
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** `Active` is Dataverse's pseudo-solution for unmanaged layers, not a solution. */
|
|
271
|
+
function isActiveLayerName(name) {
|
|
272
|
+
return /^active$/i.test(String(name ?? ''))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Summarise the option values of one option set. */
|
|
276
|
+
function optionIndex(options) {
|
|
277
|
+
const map = new Map()
|
|
278
|
+
for (const option of options ?? []) {
|
|
279
|
+
map.set(Number(option.Value), labelText(option.Label))
|
|
280
|
+
}
|
|
281
|
+
return map
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function compareOptions(sourceOptions, targetOptions) {
|
|
285
|
+
const source = optionIndex(sourceOptions)
|
|
286
|
+
const target = optionIndex(targetOptions)
|
|
287
|
+
const targetOnly = [...target.keys()].filter((value) => !source.has(value))
|
|
288
|
+
const sourceOnly = [...source.keys()].filter((value) => !target.has(value))
|
|
289
|
+
let labelDrift = 0
|
|
290
|
+
for (const [value, label] of source) {
|
|
291
|
+
if (target.has(value) && target.get(value) !== label) labelDrift += 1
|
|
292
|
+
}
|
|
293
|
+
return { sourceCount: source.size, targetCount: target.size, targetOnly, sourceOnly, labelDrift }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function formatList(values, limit = 6) {
|
|
297
|
+
if (!values.length) return '—'
|
|
298
|
+
const head = values.slice(0, limit).join('、')
|
|
299
|
+
return values.length > limit ? `${head} 等 ${values.length} 个` : head
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function severityOf(findings, severity) {
|
|
303
|
+
return findings.filter((finding) => finding.severity === severity)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renderMarkdown(report) {
|
|
307
|
+
const lines = []
|
|
308
|
+
lines.push(`# 迁移评估:${report.solution.uniqueName} → ${report.target.label}`, '')
|
|
309
|
+
lines.push(`- 源环境:${report.source.label}(${report.source.url})`)
|
|
310
|
+
lines.push(`- 目标环境:${report.target.label}(${report.target.url})`)
|
|
311
|
+
lines.push(`- 生成时间:${report.generatedAt}`, '')
|
|
312
|
+
|
|
313
|
+
lines.push('## 结论', '')
|
|
314
|
+
lines.push(report.verdict.summary, '')
|
|
315
|
+
if (report.verdict.recommendation) {
|
|
316
|
+
lines.push(`建议:${report.verdict.recommendation}`, '')
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
lines.push('## 解决方案', '')
|
|
320
|
+
lines.push('| | 源环境 | 目标环境 |', '| --- | --- | --- |')
|
|
321
|
+
lines.push(`| 是否存在 | ${report.solution.sourceExists ? '是' : '否'} | ${report.solution.targetExists ? '是' : '否'} |`)
|
|
322
|
+
lines.push(`| 版本 | ${report.solution.sourceVersion ?? '—'} | ${report.solution.targetVersion ?? '—'} |`)
|
|
323
|
+
lines.push(`| 受管 | ${report.solution.sourceManaged ? '是' : '否'} | ${report.solution.targetManaged === null ? '—' : report.solution.targetManaged ? '是' : '否'} |`)
|
|
324
|
+
if (report.solution.publisher) {
|
|
325
|
+
lines.push(`| 发布者 | ${report.solution.publisher}(前缀 ${report.solution.prefix ?? '—'}) | |`)
|
|
326
|
+
}
|
|
327
|
+
lines.push('')
|
|
328
|
+
|
|
329
|
+
lines.push('## 组件总览', '')
|
|
330
|
+
lines.push('| 类型 | 组件数 | 目标缺失 | 目标已有层 | 无法定位 |', '| --- | --- | --- | --- | --- |')
|
|
331
|
+
for (const row of report.componentRows) {
|
|
332
|
+
lines.push(`| ${row.label} | ${row.total} | ${row.missing} | ${row.withLayers} | ${row.unresolved} |`)
|
|
333
|
+
}
|
|
334
|
+
lines.push('')
|
|
335
|
+
|
|
336
|
+
if (report.truncated) {
|
|
337
|
+
lines.push(`> 组件超过 maxComponents=${report.maxComponents},本次只评估了 ${report.evaluated} 个;传 full: true 可全量评估。`, '')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const shadowed = report.shadowedSolutions
|
|
341
|
+
if (shadowed.length) {
|
|
342
|
+
lines.push('## 将被遮蔽的解决方案', '')
|
|
343
|
+
lines.push('导入后本方案层位于最上层,下列解决方案在这些组件上的定义将不再生效:', '')
|
|
344
|
+
for (const item of shadowed.slice(0, 20)) {
|
|
345
|
+
lines.push(`- ${item.solution}:${item.count} 个组件(${formatList(item.examples, 4)})`)
|
|
346
|
+
}
|
|
347
|
+
if (shadowed.length > 20) lines.push(`- … 其余 ${shadowed.length - 20} 个`)
|
|
348
|
+
lines.push('')
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const missing = report.findings.filter((finding) => finding.code === 'component-missing')
|
|
352
|
+
if (missing.length) {
|
|
353
|
+
lines.push('## 目标环境没有的组件', '')
|
|
354
|
+
for (const finding of missing.slice(0, 25)) {
|
|
355
|
+
lines.push(`- ${finding.title}`)
|
|
356
|
+
}
|
|
357
|
+
if (missing.length > 25) lines.push(`- … 其余 ${missing.length - 25} 个`)
|
|
358
|
+
lines.push('')
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (report.optionSets.length) {
|
|
362
|
+
lines.push('## 选项集数据对比', '')
|
|
363
|
+
lines.push('| 选项集 | 源值数 | 目标值数 | 源多出 | 目标多出(会丢) | 标签不一致 |', '| --- | --- | --- | --- | --- | --- |')
|
|
364
|
+
for (const item of report.optionSets) {
|
|
365
|
+
lines.push(`| ${item.name} | ${item.sourceCount} | ${item.targetCount} | ${item.sourceOnly.length} | ${item.targetOnly.length} | ${item.labelDrift} |`)
|
|
366
|
+
}
|
|
367
|
+
lines.push('')
|
|
368
|
+
for (const item of report.optionSets) {
|
|
369
|
+
if (item.targetOnly.length) {
|
|
370
|
+
lines.push(`> ${item.name}:目标环境独有 ${item.targetOnly.length} 个值(${formatList(item.targetOnly.map(String), 8)}),导入后会丢失。`)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (report.optionSets.some((item) => item.targetOnly.length)) lines.push('')
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for (const severity of ['critical', 'warning', 'info']) {
|
|
377
|
+
const group = severityOf(report.findings, severity)
|
|
378
|
+
if (!group.length) continue
|
|
379
|
+
lines.push(`## ${severity === 'critical' ? '严重' : severity === 'warning' ? '警告' : '提示'}`, '')
|
|
380
|
+
for (const finding of group) {
|
|
381
|
+
lines.push(`- **${finding.title}**${finding.detail ? `:${finding.detail}` : ''}`)
|
|
382
|
+
}
|
|
383
|
+
lines.push('')
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
lines.push('## 说明', '')
|
|
387
|
+
lines.push('- 层信息来自 `msdyn_componentlayer` 虚拟实体,只能按「组件 id + 组件类型」逐个查。')
|
|
388
|
+
lines.push('- 层叠顺序由导入时间决定,与版本号无关:谁后导入谁在上。')
|
|
389
|
+
lines.push('- 组件是否存在于目标环境按名称/逻辑名匹配,跨环境 id 不可复用。')
|
|
390
|
+
lines.push('- 未评估的组件类型只做了存在性判断,层信息不可用。')
|
|
391
|
+
return lines.join('\n')
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Assess the risk of importing one solution into other environments.
|
|
396
|
+
*/
|
|
397
|
+
export async function assessMigration(config, request, signal) {
|
|
398
|
+
const sourceKey = request.source || config.defaultEnvironment
|
|
399
|
+
const sourceEnv = config.environments[sourceKey]
|
|
400
|
+
if (!sourceEnv) {
|
|
401
|
+
throw new D365Error(`源环境不存在:${sourceKey}`, {
|
|
402
|
+
code: 'unknown-environment',
|
|
403
|
+
hint: `可用环境:${Object.keys(config.environments).join('、')}`,
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
const solutionName = String(request.solution ?? '').trim()
|
|
407
|
+
if (!solutionName) {
|
|
408
|
+
throw new D365Error('缺少 solution:要评估的解决方案唯一名', { code: 'missing-solution' })
|
|
409
|
+
}
|
|
410
|
+
const targetKeys = (request.targets?.length ? request.targets : Object.keys(config.environments).filter((key) => key !== sourceKey))
|
|
411
|
+
for (const key of targetKeys) {
|
|
412
|
+
if (!config.environments[key]) {
|
|
413
|
+
throw new D365Error(`目标环境不存在:${key}`, {
|
|
414
|
+
code: 'unknown-environment',
|
|
415
|
+
hint: `可用环境:${Object.keys(config.environments).join('、')}`,
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const timeoutMs = config.timeoutMs
|
|
421
|
+
const source = new DataverseClient(sourceEnv, { timeoutMs })
|
|
422
|
+
const maxComponents = Number.isFinite(request.maxComponents) && request.maxComponents > 0
|
|
423
|
+
? Math.trunc(request.maxComponents)
|
|
424
|
+
: DEFAULT_MAX_COMPONENTS
|
|
425
|
+
const concurrency = Math.min(
|
|
426
|
+
MAX_CONCURRENCY,
|
|
427
|
+
Math.max(1, Math.trunc(Number(request.concurrency) || DEFAULT_CONCURRENCY)),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
const solution = await findSolution(source, solutionName, signal)
|
|
431
|
+
if (!solution) {
|
|
432
|
+
const available = await tryQuery(source, 'solutions', { $select: 'uniquename', $top: 50 }, signal)
|
|
433
|
+
const sample = available.slice(0, 10).map((row) => row.uniquename).join('、')
|
|
434
|
+
throw new D365Error(`源环境 ${sourceKey} 没有解决方案 ${solutionName}`, {
|
|
435
|
+
code: 'unknown-solution',
|
|
436
|
+
hint: sample ? `已安装的解决方案例如:${sample}` : undefined,
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const components = await listComponents(source, solution.solutionid, signal)
|
|
441
|
+
const truncated = components.length > maxComponents && request.full !== true
|
|
442
|
+
const evaluated = truncated ? components.slice(0, maxComponents) : components
|
|
443
|
+
|
|
444
|
+
const needsEntities = evaluated.some((component) => component.componenttype === 2)
|
|
445
|
+
const entities = needsEntities ? await entityNameMap(source, signal) : new Map()
|
|
446
|
+
|
|
447
|
+
const resolved = await mapLimit(evaluated, concurrency, async (component) => {
|
|
448
|
+
const typeName = layerType(component.componenttype)
|
|
449
|
+
const layers = typeName ? await readLayers(source, component.objectid, typeName, signal) : []
|
|
450
|
+
const sorted = layers.slice().sort((a, b) => (Number(a.msdyn_order) || 0) - (Number(b.msdyn_order) || 0))
|
|
451
|
+
const top = sorted[sorted.length - 1] ?? null
|
|
452
|
+
const key = componentKey(component.componenttype, component, top, entities)
|
|
453
|
+
return {
|
|
454
|
+
component,
|
|
455
|
+
layerType: typeName,
|
|
456
|
+
layers: sorted,
|
|
457
|
+
top,
|
|
458
|
+
resolved: key,
|
|
459
|
+
sourceLabel: top?.msdyn_name ?? key.key ?? String(component.objectid).slice(0, 8),
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
const optionCache = new Map()
|
|
464
|
+
const targets = await Promise.all(targetKeys.map(async (targetKey) => {
|
|
465
|
+
const targetEnv = config.environments[targetKey]
|
|
466
|
+
const target = new DataverseClient(targetEnv, { timeoutMs })
|
|
467
|
+
const targetSolution = await findSolution(target, solution.uniquename, signal)
|
|
468
|
+
const targetEntities = needsEntities ? await entityNameMap(target, signal) : new Map()
|
|
469
|
+
const findings = []
|
|
470
|
+
const optionSets = []
|
|
471
|
+
const shadowed = new Map()
|
|
472
|
+
const buckets = []
|
|
473
|
+
const missing = []
|
|
474
|
+
const unresolved = []
|
|
475
|
+
|
|
476
|
+
if (!targetSolution) {
|
|
477
|
+
findings.push({
|
|
478
|
+
severity: 'warning',
|
|
479
|
+
confidence: 'high',
|
|
480
|
+
code: 'solution-absent',
|
|
481
|
+
title: `目标环境没有解决方案 ${solution.uniquename}`,
|
|
482
|
+
detail: '导入后会新建一个 unmanaged 层,无法通过卸载解决方案清除',
|
|
483
|
+
})
|
|
484
|
+
} else {
|
|
485
|
+
if (!versionNewer(solution.version, targetSolution.version)) {
|
|
486
|
+
findings.push({
|
|
487
|
+
severity: 'warning',
|
|
488
|
+
confidence: 'high',
|
|
489
|
+
code: 'version-not-newer',
|
|
490
|
+
title: `目标版本 ${targetSolution.version} 不低于源版本 ${solution.version}`,
|
|
491
|
+
detail: '导入后版本号可能不递增,后续升级判断会失真',
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
if (targetSolution.ismanaged !== solution.ismanaged) {
|
|
495
|
+
findings.push({
|
|
496
|
+
severity: 'warning',
|
|
497
|
+
confidence: 'high',
|
|
498
|
+
code: 'managed-mismatch',
|
|
499
|
+
title: `受管状态不一致:源 ${solution.ismanaged ? '受管' : '未受管'},目标 ${targetSolution.ismanaged ? '受管' : '未受管'}`,
|
|
500
|
+
detail: '未受管解决方案导入会形成不可卸载的层',
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (solution.ismanaged === false) {
|
|
506
|
+
findings.push({
|
|
507
|
+
severity: 'warning',
|
|
508
|
+
confidence: 'high',
|
|
509
|
+
code: 'source-unmanaged',
|
|
510
|
+
title: '源解决方案未受管',
|
|
511
|
+
detail: '导入后目标环境形成 unmanaged 层,只能逐组件删层,无法用卸载解决方案回退',
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
await mapLimit(resolved, concurrency, async (item) => {
|
|
516
|
+
const { component, layerType: typeName, sourceLabel, resolved: key } = item
|
|
517
|
+
const kind = component.componenttype
|
|
518
|
+
const bucket = { type: kind, label: typeLabel(kind), name: sourceLabel, key: key.key }
|
|
519
|
+
buckets.push(bucket)
|
|
520
|
+
if (!typeName) {
|
|
521
|
+
bucket.status = 'unresolved'
|
|
522
|
+
unresolved.push(bucket)
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
if (!key.key) {
|
|
526
|
+
bucket.status = 'unresolved'
|
|
527
|
+
unresolved.push(bucket)
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
let result
|
|
531
|
+
try {
|
|
532
|
+
result = await probe(target, kind, key, targetEntities, signal)
|
|
533
|
+
} catch (error) {
|
|
534
|
+
bucket.status = 'error'
|
|
535
|
+
bucket.error = error instanceof Error ? error.message : String(error)
|
|
536
|
+
unresolved.push(bucket)
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
bucket.display = result.display ?? sourceLabel
|
|
540
|
+
if (result.unknown) {
|
|
541
|
+
bucket.status = 'unresolved'
|
|
542
|
+
bucket.unknown = result.unknown
|
|
543
|
+
unresolved.push(bucket)
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
if (!result.exists) {
|
|
547
|
+
bucket.status = 'missing'
|
|
548
|
+
missing.push(bucket)
|
|
549
|
+
findings.push({
|
|
550
|
+
severity: 'info',
|
|
551
|
+
confidence: 'high',
|
|
552
|
+
code: 'component-missing',
|
|
553
|
+
title: `${typeLabel(kind)}「${bucket.display}」在目标环境不存在`,
|
|
554
|
+
detail: '导入时会作为新组件创建',
|
|
555
|
+
})
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
bucket.status = 'present'
|
|
559
|
+
const targetLayers = typeName ? await readLayers(target, result.id, typeName, signal) : []
|
|
560
|
+
const sorted = targetLayers.slice().sort((a, b) => (Number(a.msdyn_order) || 0) - (Number(b.msdyn_order) || 0))
|
|
561
|
+
const top = sorted[sorted.length - 1] ?? null
|
|
562
|
+
bucket.topLayer = solutionNameOf(top)
|
|
563
|
+
bucket.layerCount = sorted.length
|
|
564
|
+
for (const layer of sorted) {
|
|
565
|
+
const name = layer.msdyn_solutionname
|
|
566
|
+
if (!name) continue
|
|
567
|
+
// Two names are not "other solutions that get shadowed": `Active` is
|
|
568
|
+
// Dataverse's pseudo-solution for unmanaged layers, and the solution
|
|
569
|
+
// being imported is the one that will replace these layers, not lose.
|
|
570
|
+
if (isActiveLayerName(name)) continue
|
|
571
|
+
if (name.toLowerCase() === String(solution.uniquename ?? '').toLowerCase()) continue
|
|
572
|
+
if (!shadowed.has(name)) shadowed.set(name, { solution: name, count: 0, examples: [] })
|
|
573
|
+
const entry = shadowed.get(name)
|
|
574
|
+
entry.count += 1
|
|
575
|
+
if (entry.examples.length < 8) entry.examples.push(bucket.display)
|
|
576
|
+
}
|
|
577
|
+
if (kind === 9 && result.options) {
|
|
578
|
+
const comparison = await compareOptionSet(source, target, key.key, result.options, signal, optionCache)
|
|
579
|
+
if (comparison) {
|
|
580
|
+
optionSets.push({ name: bucket.display, ...comparison })
|
|
581
|
+
if (comparison.targetOnly.length) {
|
|
582
|
+
findings.push({
|
|
583
|
+
severity: 'critical',
|
|
584
|
+
confidence: 'high',
|
|
585
|
+
code: 'option-values-lost',
|
|
586
|
+
title: `选项集 ${bucket.display} 在目标环境多出 ${comparison.targetOnly.length} 个值`,
|
|
587
|
+
detail: '导入后这些值会被覆盖掉,引用它们的数据可能失效',
|
|
588
|
+
})
|
|
589
|
+
}
|
|
590
|
+
if (comparison.labelDrift) {
|
|
591
|
+
findings.push({
|
|
592
|
+
severity: 'warning',
|
|
593
|
+
confidence: 'high',
|
|
594
|
+
code: 'option-label-drift',
|
|
595
|
+
title: `选项集 ${bucket.display} 有 ${comparison.labelDrift} 个标签与源环境不一致`,
|
|
596
|
+
detail: '导入后会被源环境的标签覆盖',
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
const byType = new Map()
|
|
604
|
+
for (const bucket of buckets) {
|
|
605
|
+
const entry = byType.get(bucket.type) ?? {
|
|
606
|
+
type: bucket.type,
|
|
607
|
+
label: bucket.label,
|
|
608
|
+
total: 0,
|
|
609
|
+
missing: 0,
|
|
610
|
+
withLayers: 0,
|
|
611
|
+
unresolved: 0,
|
|
612
|
+
}
|
|
613
|
+
entry.total += 1
|
|
614
|
+
if (bucket.status === 'missing') entry.missing += 1
|
|
615
|
+
else if (bucket.status === 'present') entry.withLayers += 1
|
|
616
|
+
else entry.unresolved += 1
|
|
617
|
+
byType.set(bucket.type, entry)
|
|
618
|
+
}
|
|
619
|
+
const componentRows = [...byType.values()].sort((a, b) => b.total - a.total)
|
|
620
|
+
|
|
621
|
+
const shadowedSolutions = [...shadowed.values()].sort((a, b) => b.count - a.count)
|
|
622
|
+
const missingCount = missing.length
|
|
623
|
+
const critical = findings.filter((finding) => finding.severity === 'critical').length
|
|
624
|
+
const warnings = findings.filter((finding) => finding.severity === 'warning').length
|
|
625
|
+
const verdict = {
|
|
626
|
+
summary: critical
|
|
627
|
+
? `发现 ${critical} 个严重问题:${formatList(findings.filter((f) => f.severity === 'critical').map((f) => f.title), 3)}`
|
|
628
|
+
: `没有发现数据丢失风险;${missingCount} 个组件在目标环境不存在,${shadowedSolutions.length} 个解决方案的层会被遮蔽。`,
|
|
629
|
+
recommendation: critical
|
|
630
|
+
? '先处理严重问题再发布:把变更并回受管解决方案,或先在目标环境补齐缺失值。'
|
|
631
|
+
: shadowedSolutions.length
|
|
632
|
+
? '如果这些遮蔽不可接受,考虑把改动并入目标环境已有的受管解决方案,随其升级导入。'
|
|
633
|
+
: null,
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
key: targetKey,
|
|
638
|
+
label: envLabel(config, targetKey),
|
|
639
|
+
url: targetEnv.url,
|
|
640
|
+
findings,
|
|
641
|
+
critical,
|
|
642
|
+
warnings,
|
|
643
|
+
missing,
|
|
644
|
+
unresolved,
|
|
645
|
+
optionSets,
|
|
646
|
+
shadowedSolutions,
|
|
647
|
+
componentRows,
|
|
648
|
+
verdict,
|
|
649
|
+
targetSolution: targetSolution
|
|
650
|
+
? { exists: true, version: targetSolution.version, managed: targetSolution.ismanaged }
|
|
651
|
+
: { exists: false, version: null, managed: null },
|
|
652
|
+
}
|
|
653
|
+
}))
|
|
654
|
+
|
|
655
|
+
const reports = []
|
|
656
|
+
const root = join(dataRoot(config), 'reports')
|
|
657
|
+
await mkdir(root, { recursive: true })
|
|
658
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
659
|
+
for (const target of targets) {
|
|
660
|
+
const report = {
|
|
661
|
+
generatedAt: new Date().toISOString(),
|
|
662
|
+
source: { key: sourceKey, label: envLabel(config, sourceKey), url: sourceEnv.url },
|
|
663
|
+
target,
|
|
664
|
+
solution: {
|
|
665
|
+
uniqueName: solution.uniquename,
|
|
666
|
+
friendlyName: solution.friendlyname,
|
|
667
|
+
sourceExists: true,
|
|
668
|
+
targetExists: target.targetSolution.exists,
|
|
669
|
+
sourceVersion: solution.version,
|
|
670
|
+
targetVersion: target.targetSolution.version,
|
|
671
|
+
sourceManaged: solution.ismanaged,
|
|
672
|
+
targetManaged: target.targetSolution.managed,
|
|
673
|
+
publisher: solution.publisher?.friendlyname ?? solution.publisher?.uniquename ?? null,
|
|
674
|
+
prefix: solution.publisher?.customizationprefix ?? null,
|
|
675
|
+
},
|
|
676
|
+
truncated,
|
|
677
|
+
maxComponents,
|
|
678
|
+
evaluated: resolved.length,
|
|
679
|
+
componentRows: target.componentRows,
|
|
680
|
+
shadowedSolutions: target.shadowedSolutions,
|
|
681
|
+
optionSets: target.optionSets,
|
|
682
|
+
findings: target.findings,
|
|
683
|
+
verdict: target.verdict,
|
|
684
|
+
}
|
|
685
|
+
const markdown = renderMarkdown(report)
|
|
686
|
+
const base = `migration-${solution.uniquename}-to-${target.key}-${stamp}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
687
|
+
const mdFile = join(root, `${base}.md`)
|
|
688
|
+
const jsonFile = join(root, `${base}.json`)
|
|
689
|
+
await writeFile(mdFile, `${markdown}\n`, 'utf8')
|
|
690
|
+
await writeFile(jsonFile, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
|
691
|
+
reports.push({
|
|
692
|
+
target: target.key,
|
|
693
|
+
targetLabel: target.label,
|
|
694
|
+
critical: target.critical,
|
|
695
|
+
warnings: target.warnings,
|
|
696
|
+
missing: target.missing.length,
|
|
697
|
+
shadowed: target.shadowedSolutions.length,
|
|
698
|
+
verdict: target.verdict,
|
|
699
|
+
reportPath: mdFile.replace(/\\/g, '/'),
|
|
700
|
+
jsonPath: jsonFile.replace(/\\/g, '/'),
|
|
701
|
+
})
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
source: { key: sourceKey, label: envLabel(config, sourceKey) },
|
|
706
|
+
solution: solution.uniquename,
|
|
707
|
+
componentCount: components.length,
|
|
708
|
+
evaluated: resolved.length,
|
|
709
|
+
truncated,
|
|
710
|
+
maxComponents,
|
|
711
|
+
reports,
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Compare one global option set between two environments. */
|
|
716
|
+
async function compareOptionSet(source, target, name, targetOptions, signal, cache = null) {
|
|
717
|
+
const quote = String(name).replace(/'/g, "''")
|
|
718
|
+
try {
|
|
719
|
+
let pending = cache?.get(name)
|
|
720
|
+
if (!pending) {
|
|
721
|
+
pending = source.requestJson(
|
|
722
|
+
source.buildUrl(`GlobalOptionSetDefinitions(Name='${quote}')/Microsoft.Dynamics.CRM.OptionSetMetadata`),
|
|
723
|
+
{ signal },
|
|
724
|
+
)
|
|
725
|
+
cache?.set(name, pending)
|
|
726
|
+
}
|
|
727
|
+
const definition = await pending
|
|
728
|
+
return compareOptions(definition.Options ?? [], targetOptions)
|
|
729
|
+
} catch {
|
|
730
|
+
return null
|
|
731
|
+
}
|
|
732
|
+
}
|