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/tool-sync.js
CHANGED
|
@@ -1,430 +1,430 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `d365_sync` — pull a fixed metadata set into a local snapshot.
|
|
3
|
-
*
|
|
4
|
-
* Snapshots exist so that "what changed" is answerable without keeping the
|
|
5
|
-
* live environment as the only source of truth. Each sync is a full pull (the
|
|
6
|
-
* Dataverse metadata API has no delta for most tables); the value is not fewer
|
|
7
|
-
* requests but a durable, comparable record with an honest completeness report.
|
|
8
|
-
*/
|
|
9
|
-
import { loadConfig } from './config.js'
|
|
10
|
-
import { makeClient, pickEnvironments, readOrganization } from './runtime.js'
|
|
11
|
-
import { RESOURCES, composeFilter, flattenLabels, odataString } from './resources.js'
|
|
12
|
-
import { getPagedWithSelectFallback } from './client.js'
|
|
13
|
-
import { D365Error } from './errors.js'
|
|
14
|
-
import { ensureDataRoot, pruneSnapshots, snapshotId, writeSnapshot } from './snapshot.js'
|
|
15
|
-
import { enforceRetention } from './retention.js'
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* The default pull set. Deliberately excludes `attributes` (one request per
|
|
19
|
-
* entity) and skips the resources that need per-entity iteration; ask for those
|
|
20
|
-
* explicitly with `includeAttributes`.
|
|
21
|
-
*/
|
|
22
|
-
export const CORE_RESOURCES = [
|
|
23
|
-
'organization',
|
|
24
|
-
'solutions',
|
|
25
|
-
'solution_components',
|
|
26
|
-
'entities',
|
|
27
|
-
'forms',
|
|
28
|
-
'views',
|
|
29
|
-
'workflows',
|
|
30
|
-
'plugin_assemblies',
|
|
31
|
-
'plugin_types',
|
|
32
|
-
'plugin_steps',
|
|
33
|
-
'web_resources',
|
|
34
|
-
'custom_apis',
|
|
35
|
-
'sitemaps',
|
|
36
|
-
'option_sets',
|
|
37
|
-
]
|
|
38
|
-
|
|
39
|
-
/** Entities whose attributes are pulled when `includeAttributes` is set. */
|
|
40
|
-
const MAX_ATTRIBUTE_ENTITIES = 500
|
|
41
|
-
|
|
42
|
-
export const definition = {
|
|
43
|
-
name: 'd365_sync',
|
|
44
|
-
description:
|
|
45
|
-
'把一个或多个环境的 D365 CE 元数据拉成本地快照(写到 .d365/snapshots/<env>/<快照ID>/),并记录每个资源是完整、被行数上限截断,还是失败。'
|
|
46
|
-
+ '快照是 d365_diff 的输入;数据源是实时 Web API,元数据表大多没有增量游标,所以每次都是全量拉取。',
|
|
47
|
-
parameters: {
|
|
48
|
-
type: 'object',
|
|
49
|
-
additionalProperties: false,
|
|
50
|
-
properties: {
|
|
51
|
-
environments: {
|
|
52
|
-
type: 'array',
|
|
53
|
-
items: { type: 'string' },
|
|
54
|
-
description: '环境 key 列表,例如 ["dev","sit"]。省略则用默认环境。',
|
|
55
|
-
},
|
|
56
|
-
allEnvironments: {
|
|
57
|
-
type: 'boolean',
|
|
58
|
-
description: '同步全部已配置环境。',
|
|
59
|
-
},
|
|
60
|
-
resources: {
|
|
61
|
-
type: 'array',
|
|
62
|
-
items: { type: 'string', enum: Object.keys(RESOURCES) },
|
|
63
|
-
description: '只同步指定资源,默认是一组核心资源(不含 attributes)。',
|
|
64
|
-
},
|
|
65
|
-
includeAttributes: {
|
|
66
|
-
type: 'boolean',
|
|
67
|
-
description: '额外拉取每个实体的字段定义。请求数约为实体数,耗时明显更长。',
|
|
68
|
-
},
|
|
69
|
-
entities: {
|
|
70
|
-
type: 'array',
|
|
71
|
-
items: { type: 'string' },
|
|
72
|
-
description: '配合 includeAttributes:只拉这些实体的字段(如 ["account","new_order"])。',
|
|
73
|
-
},
|
|
74
|
-
maxRecords: {
|
|
75
|
-
type: 'integer',
|
|
76
|
-
description: '每个资源最多拉多少行。默认 0,表示全量分页拉完;填正数则限制行数(被截断的资源会在 manifest 里标出)。',
|
|
77
|
-
},
|
|
78
|
-
keep: {
|
|
79
|
-
type: 'integer',
|
|
80
|
-
description: '每个环境保留多少份快照,超出后删除最旧的,默认取配置 snapshotKeep(2)。',
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
output: {
|
|
85
|
-
schema: {
|
|
86
|
-
type: 'object',
|
|
87
|
-
additionalProperties: false,
|
|
88
|
-
required: ['text'],
|
|
89
|
-
properties: {
|
|
90
|
-
text: { type: 'string' },
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
render(_args, value) {
|
|
94
|
-
return [{ type: 'text', text: value?.text ?? '' }]
|
|
95
|
-
},
|
|
96
|
-
},
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Run at most `limit` workers over `items`, keeping result order. */
|
|
100
|
-
async function mapLimit(items, limit, worker) {
|
|
101
|
-
const results = new Array(items.length)
|
|
102
|
-
let cursor = 0
|
|
103
|
-
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
104
|
-
for (;;) {
|
|
105
|
-
const index = cursor
|
|
106
|
-
cursor += 1
|
|
107
|
-
if (index >= items.length) return
|
|
108
|
-
results[index] = await worker(items[index], index)
|
|
109
|
-
}
|
|
110
|
-
})
|
|
111
|
-
await Promise.all(runners)
|
|
112
|
-
return results
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Build and execute one resource request. */
|
|
116
|
-
async function fetchResource(client, resource, options) {
|
|
117
|
-
const def = RESOURCES[resource]
|
|
118
|
-
const path = resource === 'attributes'
|
|
119
|
-
? `EntityDefinitions(LogicalName='${odataString(options.entity)}')/Attributes`
|
|
120
|
-
: def.path({ entity: options.entity })
|
|
121
|
-
const params = {
|
|
122
|
-
$select: def.select,
|
|
123
|
-
$filter: composeFilter(def, { entity: options.entity }),
|
|
124
|
-
$orderby: def.orderby,
|
|
125
|
-
}
|
|
126
|
-
const { page } = await getPagedWithSelectFallback(client, path, params, options.top, options.signal)
|
|
127
|
-
return page
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Fetch attributes for many entities, a few at a time. */
|
|
131
|
-
async function fetchAttributes(client, entityNames, top, signal) {
|
|
132
|
-
const rows = []
|
|
133
|
-
const failures = []
|
|
134
|
-
const started = Date.now()
|
|
135
|
-
|
|
136
|
-
await mapLimit(entityNames, 4, async (entity) => {
|
|
137
|
-
try {
|
|
138
|
-
const page = await fetchResource(client, 'attributes', { entity, top, signal })
|
|
139
|
-
for (const row of page.records) rows.push({ entity, ...flattenLabels(row) })
|
|
140
|
-
} catch (error) {
|
|
141
|
-
failures.push({ entity, message: error.message })
|
|
142
|
-
}
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
return { rows, failures, elapsedMs: Date.now() - started }
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Sync one environment and return its summary. */
|
|
149
|
-
async function syncEnvironment(env, config, args, exec) {
|
|
150
|
-
const client = makeClient(env, config)
|
|
151
|
-
const startedAt = new Date()
|
|
152
|
-
const id = snapshotId(startedAt)
|
|
153
|
-
const requestedAll = Array.isArray(args.resources) && args.resources.length
|
|
154
|
-
? [...new Set(args.resources)]
|
|
155
|
-
: [...CORE_RESOURCES]
|
|
156
|
-
const unknown = requestedAll.filter((resource) => !RESOURCES[resource])
|
|
157
|
-
if (unknown.length) {
|
|
158
|
-
throw new D365Error(`未知的资源:${unknown.join(', ')}`, {
|
|
159
|
-
code: 'unknown-resource',
|
|
160
|
-
hint: `可用:${Object.keys(RESOURCES).join(', ')}`,
|
|
161
|
-
})
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Unlimited by default: a snapshot with rows silently missing is worse than a
|
|
165
|
-
// slow sync, and `d365_diff` would report the missing rows as deletions.
|
|
166
|
-
const maxRecords = args.maxRecords === undefined || args.maxRecords === 0
|
|
167
|
-
? Number.POSITIVE_INFINITY
|
|
168
|
-
: Number.isFinite(args.maxRecords) && args.maxRecords > 0
|
|
169
|
-
? Math.min(1_000_000, Math.trunc(args.maxRecords))
|
|
170
|
-
: Math.max(20_000, config.maxRecords)
|
|
171
|
-
|
|
172
|
-
const organizationRow = flattenLabels(await readOrganization(client))
|
|
173
|
-
|
|
174
|
-
const requested = requestedAll.filter((resource) => resource !== 'organization')
|
|
175
|
-
const wantAttributes = args.includeAttributes === true
|
|
176
|
-
const explicitEntities = Array.isArray(args.entities)
|
|
177
|
-
? args.entities.map((item) => String(item).trim()).filter(Boolean)
|
|
178
|
-
: []
|
|
179
|
-
|
|
180
|
-
const details = {}
|
|
181
|
-
const files = []
|
|
182
|
-
const issues = []
|
|
183
|
-
let entitiesRows = null
|
|
184
|
-
let entitiesTruncated = false
|
|
185
|
-
|
|
186
|
-
// `keys` / `*_relationships` only make sense for one entity, and the loop
|
|
187
|
-
// below has no entity argument: without this the path becomes
|
|
188
|
-
// EntityDefinitions(LogicalName='undefined') and the resource fails.
|
|
189
|
-
const withoutEntity = requested.filter((resource) => RESOURCES[resource]?.entityScoped && !explicitEntities.length)
|
|
190
|
-
for (const resource of withoutEntity) {
|
|
191
|
-
issues.push({ resource, message: '跳过:该资源需要实体(传 entities,或改用 d365_metadata 的 entity 参数)' })
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (organizationRow) {
|
|
195
|
-
files.push({
|
|
196
|
-
resource: 'organization',
|
|
197
|
-
payload: {
|
|
198
|
-
resource: 'organization',
|
|
199
|
-
env: env.key,
|
|
200
|
-
snapshotId: id,
|
|
201
|
-
generatedAt: startedAt.toISOString(),
|
|
202
|
-
count: 1,
|
|
203
|
-
truncated: false,
|
|
204
|
-
rows: [organizationRow],
|
|
205
|
-
},
|
|
206
|
-
})
|
|
207
|
-
details.organization = { count: 1, truncated: false, requests: 1, elapsedMs: 0 }
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// `entities` first: it is both a requested resource and the input to the
|
|
211
|
-
// attribute walk, so a truncation here changes what the attribute pull covers.
|
|
212
|
-
if (requested.includes('entities') || wantAttributes) {
|
|
213
|
-
const started = Date.now()
|
|
214
|
-
const before = client.counters.requests
|
|
215
|
-
try {
|
|
216
|
-
const page = await fetchResource(client, 'entities', { top: maxRecords, signal: exec?.signal })
|
|
217
|
-
entitiesRows = page.records.map((row) => flattenLabels(row))
|
|
218
|
-
entitiesTruncated = page.hasMore
|
|
219
|
-
if (requested.includes('entities')) {
|
|
220
|
-
files.push({
|
|
221
|
-
resource: 'entities',
|
|
222
|
-
payload: {
|
|
223
|
-
resource: 'entities',
|
|
224
|
-
env: env.key,
|
|
225
|
-
snapshotId: id,
|
|
226
|
-
generatedAt: new Date().toISOString(),
|
|
227
|
-
count: entitiesRows.length,
|
|
228
|
-
truncated: page.hasMore,
|
|
229
|
-
rows: entitiesRows,
|
|
230
|
-
},
|
|
231
|
-
})
|
|
232
|
-
}
|
|
233
|
-
details.entities = {
|
|
234
|
-
count: entitiesRows.length,
|
|
235
|
-
truncated: page.hasMore,
|
|
236
|
-
requests: client.counters.requests - before,
|
|
237
|
-
elapsedMs: Date.now() - started,
|
|
238
|
-
}
|
|
239
|
-
if (page.hasMore) {
|
|
240
|
-
issues.push({ resource: 'entities', message: `达到行数上限 ${maxRecords},实体列表不完整,依赖它的字段采集也受限` })
|
|
241
|
-
}
|
|
242
|
-
} catch (error) {
|
|
243
|
-
issues.push({ resource: 'entities', message: error.message })
|
|
244
|
-
details.entities = { count: 0, truncated: true, requests: client.counters.requests - before, elapsedMs: Date.now() - started, error: error.message }
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
for (const resource of requested) {
|
|
249
|
-
if (resource === 'entities') continue
|
|
250
|
-
if (withoutEntity.includes(resource)) continue
|
|
251
|
-
const started = Date.now()
|
|
252
|
-
const before = client.counters.requests
|
|
253
|
-
try {
|
|
254
|
-
const page = await fetchResource(client, resource, { top: maxRecords, signal: exec?.signal })
|
|
255
|
-
const rows = page.records.map((row) => flattenLabels(row))
|
|
256
|
-
files.push({
|
|
257
|
-
resource,
|
|
258
|
-
payload: {
|
|
259
|
-
resource,
|
|
260
|
-
env: env.key,
|
|
261
|
-
snapshotId: id,
|
|
262
|
-
generatedAt: new Date().toISOString(),
|
|
263
|
-
count: rows.length,
|
|
264
|
-
truncated: page.hasMore,
|
|
265
|
-
rows,
|
|
266
|
-
},
|
|
267
|
-
})
|
|
268
|
-
details[resource] = {
|
|
269
|
-
count: rows.length,
|
|
270
|
-
truncated: page.hasMore,
|
|
271
|
-
requests: client.counters.requests - before,
|
|
272
|
-
elapsedMs: Date.now() - started,
|
|
273
|
-
}
|
|
274
|
-
if (page.hasMore) {
|
|
275
|
-
issues.push({ resource, message: `达到行数上限 ${maxRecords},该资源不完整` })
|
|
276
|
-
}
|
|
277
|
-
} catch (error) {
|
|
278
|
-
details[resource] = {
|
|
279
|
-
count: 0,
|
|
280
|
-
truncated: true,
|
|
281
|
-
requests: client.counters.requests - before,
|
|
282
|
-
elapsedMs: Date.now() - started,
|
|
283
|
-
error: error.message,
|
|
284
|
-
}
|
|
285
|
-
issues.push({ resource, message: error.message })
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (wantAttributes) {
|
|
290
|
-
const available = entitiesRows ?? []
|
|
291
|
-
const names = (explicitEntities.length ? explicitEntities : available.map((row) => row.LogicalName))
|
|
292
|
-
.filter((name) => typeof name === 'string' && name.trim())
|
|
293
|
-
const capped = names.slice(0, MAX_ATTRIBUTE_ENTITIES)
|
|
294
|
-
if (names.length > capped.length) {
|
|
295
|
-
issues.push({ resource: 'attributes', message: `实体数超过 ${MAX_ATTRIBUTE_ENTITIES},只采集了前 ${MAX_ATTRIBUTE_ENTITIES} 个实体的字段` })
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const started = Date.now()
|
|
299
|
-
const result = await fetchAttributes(client, capped, maxRecords, exec?.signal)
|
|
300
|
-
files.push({
|
|
301
|
-
resource: 'attributes',
|
|
302
|
-
payload: {
|
|
303
|
-
resource: 'attributes',
|
|
304
|
-
env: env.key,
|
|
305
|
-
snapshotId: id,
|
|
306
|
-
generatedAt: new Date().toISOString(),
|
|
307
|
-
count: result.rows.length,
|
|
308
|
-
truncated: entitiesTruncated || names.length > capped.length || result.failures.length > 0,
|
|
309
|
-
entities: capped.length,
|
|
310
|
-
failures: result.failures,
|
|
311
|
-
rows: result.rows,
|
|
312
|
-
},
|
|
313
|
-
})
|
|
314
|
-
details.attributes = {
|
|
315
|
-
count: result.rows.length,
|
|
316
|
-
truncated: entitiesTruncated || names.length > capped.length || result.failures.length > 0,
|
|
317
|
-
entities: capped.length,
|
|
318
|
-
failedEntities: result.failures.length,
|
|
319
|
-
elapsedMs: result.elapsedMs,
|
|
320
|
-
}
|
|
321
|
-
for (const failure of result.failures.slice(0, 10)) {
|
|
322
|
-
issues.push({ resource: `attributes:${failure.entity}`, message: failure.message })
|
|
323
|
-
}
|
|
324
|
-
if (result.failures.length > 10) {
|
|
325
|
-
issues.push({ resource: 'attributes', message: `另有 ${result.failures.length - 10} 个实体的字段采集失败` })
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const finishedAt = new Date()
|
|
330
|
-
const manifest = {
|
|
331
|
-
snapshotId: id,
|
|
332
|
-
env: env.key,
|
|
333
|
-
label: env.label,
|
|
334
|
-
url: env.url,
|
|
335
|
-
organization: organizationRow
|
|
336
|
-
? {
|
|
337
|
-
organizationid: organizationRow.organizationid,
|
|
338
|
-
name: organizationRow.name,
|
|
339
|
-
version: organizationRow.version,
|
|
340
|
-
organizationstructure: organizationRow.organizationstructure,
|
|
341
|
-
}
|
|
342
|
-
: null,
|
|
343
|
-
startedAt: startedAt.toISOString(),
|
|
344
|
-
finishedAt: finishedAt.toISOString(),
|
|
345
|
-
durationMs: finishedAt - startedAt,
|
|
346
|
-
complete: issues.length === 0,
|
|
347
|
-
issues,
|
|
348
|
-
resources: details,
|
|
349
|
-
requested: ['organization', ...requested, ...(wantAttributes ? ['attributes'] : [])],
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
await ensureDataRoot(config)
|
|
353
|
-
const dir = await writeSnapshot(config, env.key, id, manifest, files)
|
|
354
|
-
const keep = Number.isFinite(args.keep) && args.keep > 0 ? Math.trunc(args.keep) : config.snapshotKeep
|
|
355
|
-
const pruned = await pruneSnapshots(config, env.key, keep)
|
|
356
|
-
await enforceRetention(config, { target: 'snapshots' })
|
|
357
|
-
|
|
358
|
-
return { env, id, dir, manifest, pruned, organization: organizationRow }
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** Render one environment's sync summary. */
|
|
362
|
-
function renderSummary(result, config) {
|
|
363
|
-
const { env, manifest } = result
|
|
364
|
-
const lines = []
|
|
365
|
-
const orgName = manifest.organization?.name ?? '(未取到组织信息)'
|
|
366
|
-
lines.push(`### ${env.key} — ${orgName}`)
|
|
367
|
-
lines.push(`- 快照:${manifest.snapshotId}`)
|
|
368
|
-
lines.push(`- 目录:${result.dir.replace(/\\/g, '/')}`)
|
|
369
|
-
lines.push(`- 状态:${manifest.complete ? '完整' : '部分(见下方 issues)'},耗时 ${(manifest.durationMs / 1000).toFixed(1)}s`)
|
|
370
|
-
lines.push('')
|
|
371
|
-
lines.push('| 资源 | 行数 | 请求 | 状态 |')
|
|
372
|
-
lines.push('| --- | --- | --- | --- |')
|
|
373
|
-
for (const [resource, detail] of Object.entries(manifest.resources)) {
|
|
374
|
-
const status = detail.error
|
|
375
|
-
? `失败:${String(detail.error).slice(0, 60)}`
|
|
376
|
-
: detail.truncated ? '被截断' : '完整'
|
|
377
|
-
lines.push(`| ${resource} | ${detail.count ?? 0} | ${detail.requests ?? '-'} | ${status} |`)
|
|
378
|
-
}
|
|
379
|
-
if (manifest.issues.length) {
|
|
380
|
-
lines.push('')
|
|
381
|
-
lines.push('问题:')
|
|
382
|
-
for (const issue of manifest.issues.slice(0, 12)) lines.push(`- ${issue.resource}:${issue.message}`)
|
|
383
|
-
if (manifest.issues.length > 12) lines.push(`- … 另有 ${manifest.issues.length - 12} 条见 manifest.json`)
|
|
384
|
-
}
|
|
385
|
-
if (result.pruned.length) {
|
|
386
|
-
lines.push('', `已清理旧快照:${result.pruned.join(', ')}(保留 ${config.snapshotKeep} 份)`)
|
|
387
|
-
}
|
|
388
|
-
if (!manifest.resources.attributes) {
|
|
389
|
-
lines.push('', '未采集字段定义(attributes)。需要时加 includeAttributes: true,或指定 entities 只拉部分实体。')
|
|
390
|
-
}
|
|
391
|
-
return lines
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
export function register(ctx, state) {
|
|
395
|
-
return ctx.tools.register({
|
|
396
|
-
...definition,
|
|
397
|
-
async execute(args = {}, exec) {
|
|
398
|
-
const config = await loadConfig(state.rawConfig)
|
|
399
|
-
const environments = pickEnvironments(config, args)
|
|
400
|
-
const lines = [`同步快照:${environments.map((env) => env.key).join(' / ')}`]
|
|
401
|
-
const failures = []
|
|
402
|
-
|
|
403
|
-
for (const env of environments) {
|
|
404
|
-
try {
|
|
405
|
-
const result = await syncEnvironment(env, config, args, exec)
|
|
406
|
-
lines.push('', ...renderSummary(result, config))
|
|
407
|
-
} catch (error) {
|
|
408
|
-
failures.push(`${env.key}:${error.message}`)
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (failures.length) {
|
|
413
|
-
lines.push('')
|
|
414
|
-
lines.push('失败的环境:')
|
|
415
|
-
for (const failure of failures) lines.push(`- ${failure}`)
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
return { text: lines.join('\n') }
|
|
419
|
-
},
|
|
420
|
-
presentCall(args = {}) {
|
|
421
|
-
const envs = args.allEnvironments ? '全部环境' : (args.environments ?? []).join('/') || '默认环境'
|
|
422
|
-
return {
|
|
423
|
-
card: 'generic',
|
|
424
|
-
title: `同步 D365 快照(${envs})`,
|
|
425
|
-
kind: 'other',
|
|
426
|
-
rawInput: args,
|
|
427
|
-
}
|
|
428
|
-
},
|
|
429
|
-
})
|
|
430
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `d365_sync` — pull a fixed metadata set into a local snapshot.
|
|
3
|
+
*
|
|
4
|
+
* Snapshots exist so that "what changed" is answerable without keeping the
|
|
5
|
+
* live environment as the only source of truth. Each sync is a full pull (the
|
|
6
|
+
* Dataverse metadata API has no delta for most tables); the value is not fewer
|
|
7
|
+
* requests but a durable, comparable record with an honest completeness report.
|
|
8
|
+
*/
|
|
9
|
+
import { loadConfig } from './config.js'
|
|
10
|
+
import { makeClient, pickEnvironments, readOrganization } from './runtime.js'
|
|
11
|
+
import { RESOURCES, composeFilter, flattenLabels, odataString } from './resources.js'
|
|
12
|
+
import { getPagedWithSelectFallback } from './client.js'
|
|
13
|
+
import { D365Error } from './errors.js'
|
|
14
|
+
import { ensureDataRoot, pruneSnapshots, snapshotId, writeSnapshot } from './snapshot.js'
|
|
15
|
+
import { enforceRetention } from './retention.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The default pull set. Deliberately excludes `attributes` (one request per
|
|
19
|
+
* entity) and skips the resources that need per-entity iteration; ask for those
|
|
20
|
+
* explicitly with `includeAttributes`.
|
|
21
|
+
*/
|
|
22
|
+
export const CORE_RESOURCES = [
|
|
23
|
+
'organization',
|
|
24
|
+
'solutions',
|
|
25
|
+
'solution_components',
|
|
26
|
+
'entities',
|
|
27
|
+
'forms',
|
|
28
|
+
'views',
|
|
29
|
+
'workflows',
|
|
30
|
+
'plugin_assemblies',
|
|
31
|
+
'plugin_types',
|
|
32
|
+
'plugin_steps',
|
|
33
|
+
'web_resources',
|
|
34
|
+
'custom_apis',
|
|
35
|
+
'sitemaps',
|
|
36
|
+
'option_sets',
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/** Entities whose attributes are pulled when `includeAttributes` is set. */
|
|
40
|
+
const MAX_ATTRIBUTE_ENTITIES = 500
|
|
41
|
+
|
|
42
|
+
export const definition = {
|
|
43
|
+
name: 'd365_sync',
|
|
44
|
+
description:
|
|
45
|
+
'把一个或多个环境的 D365 CE 元数据拉成本地快照(写到 .d365/snapshots/<env>/<快照ID>/),并记录每个资源是完整、被行数上限截断,还是失败。'
|
|
46
|
+
+ '快照是 d365_diff 的输入;数据源是实时 Web API,元数据表大多没有增量游标,所以每次都是全量拉取。',
|
|
47
|
+
parameters: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
properties: {
|
|
51
|
+
environments: {
|
|
52
|
+
type: 'array',
|
|
53
|
+
items: { type: 'string' },
|
|
54
|
+
description: '环境 key 列表,例如 ["dev","sit"]。省略则用默认环境。',
|
|
55
|
+
},
|
|
56
|
+
allEnvironments: {
|
|
57
|
+
type: 'boolean',
|
|
58
|
+
description: '同步全部已配置环境。',
|
|
59
|
+
},
|
|
60
|
+
resources: {
|
|
61
|
+
type: 'array',
|
|
62
|
+
items: { type: 'string', enum: Object.keys(RESOURCES) },
|
|
63
|
+
description: '只同步指定资源,默认是一组核心资源(不含 attributes)。',
|
|
64
|
+
},
|
|
65
|
+
includeAttributes: {
|
|
66
|
+
type: 'boolean',
|
|
67
|
+
description: '额外拉取每个实体的字段定义。请求数约为实体数,耗时明显更长。',
|
|
68
|
+
},
|
|
69
|
+
entities: {
|
|
70
|
+
type: 'array',
|
|
71
|
+
items: { type: 'string' },
|
|
72
|
+
description: '配合 includeAttributes:只拉这些实体的字段(如 ["account","new_order"])。',
|
|
73
|
+
},
|
|
74
|
+
maxRecords: {
|
|
75
|
+
type: 'integer',
|
|
76
|
+
description: '每个资源最多拉多少行。默认 0,表示全量分页拉完;填正数则限制行数(被截断的资源会在 manifest 里标出)。',
|
|
77
|
+
},
|
|
78
|
+
keep: {
|
|
79
|
+
type: 'integer',
|
|
80
|
+
description: '每个环境保留多少份快照,超出后删除最旧的,默认取配置 snapshotKeep(2)。',
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
output: {
|
|
85
|
+
schema: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
additionalProperties: false,
|
|
88
|
+
required: ['text'],
|
|
89
|
+
properties: {
|
|
90
|
+
text: { type: 'string' },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
render(_args, value) {
|
|
94
|
+
return [{ type: 'text', text: value?.text ?? '' }]
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Run at most `limit` workers over `items`, keeping result order. */
|
|
100
|
+
async function mapLimit(items, limit, worker) {
|
|
101
|
+
const results = new Array(items.length)
|
|
102
|
+
let cursor = 0
|
|
103
|
+
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
104
|
+
for (;;) {
|
|
105
|
+
const index = cursor
|
|
106
|
+
cursor += 1
|
|
107
|
+
if (index >= items.length) return
|
|
108
|
+
results[index] = await worker(items[index], index)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
await Promise.all(runners)
|
|
112
|
+
return results
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Build and execute one resource request. */
|
|
116
|
+
async function fetchResource(client, resource, options) {
|
|
117
|
+
const def = RESOURCES[resource]
|
|
118
|
+
const path = resource === 'attributes'
|
|
119
|
+
? `EntityDefinitions(LogicalName='${odataString(options.entity)}')/Attributes`
|
|
120
|
+
: def.path({ entity: options.entity })
|
|
121
|
+
const params = {
|
|
122
|
+
$select: def.select,
|
|
123
|
+
$filter: composeFilter(def, { entity: options.entity }),
|
|
124
|
+
$orderby: def.orderby,
|
|
125
|
+
}
|
|
126
|
+
const { page } = await getPagedWithSelectFallback(client, path, params, options.top, options.signal)
|
|
127
|
+
return page
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Fetch attributes for many entities, a few at a time. */
|
|
131
|
+
async function fetchAttributes(client, entityNames, top, signal) {
|
|
132
|
+
const rows = []
|
|
133
|
+
const failures = []
|
|
134
|
+
const started = Date.now()
|
|
135
|
+
|
|
136
|
+
await mapLimit(entityNames, 4, async (entity) => {
|
|
137
|
+
try {
|
|
138
|
+
const page = await fetchResource(client, 'attributes', { entity, top, signal })
|
|
139
|
+
for (const row of page.records) rows.push({ entity, ...flattenLabels(row) })
|
|
140
|
+
} catch (error) {
|
|
141
|
+
failures.push({ entity, message: error.message })
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
return { rows, failures, elapsedMs: Date.now() - started }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Sync one environment and return its summary. */
|
|
149
|
+
async function syncEnvironment(env, config, args, exec) {
|
|
150
|
+
const client = makeClient(env, config)
|
|
151
|
+
const startedAt = new Date()
|
|
152
|
+
const id = snapshotId(startedAt)
|
|
153
|
+
const requestedAll = Array.isArray(args.resources) && args.resources.length
|
|
154
|
+
? [...new Set(args.resources)]
|
|
155
|
+
: [...CORE_RESOURCES]
|
|
156
|
+
const unknown = requestedAll.filter((resource) => !RESOURCES[resource])
|
|
157
|
+
if (unknown.length) {
|
|
158
|
+
throw new D365Error(`未知的资源:${unknown.join(', ')}`, {
|
|
159
|
+
code: 'unknown-resource',
|
|
160
|
+
hint: `可用:${Object.keys(RESOURCES).join(', ')}`,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Unlimited by default: a snapshot with rows silently missing is worse than a
|
|
165
|
+
// slow sync, and `d365_diff` would report the missing rows as deletions.
|
|
166
|
+
const maxRecords = args.maxRecords === undefined || args.maxRecords === 0
|
|
167
|
+
? Number.POSITIVE_INFINITY
|
|
168
|
+
: Number.isFinite(args.maxRecords) && args.maxRecords > 0
|
|
169
|
+
? Math.min(1_000_000, Math.trunc(args.maxRecords))
|
|
170
|
+
: Math.max(20_000, config.maxRecords)
|
|
171
|
+
|
|
172
|
+
const organizationRow = flattenLabels(await readOrganization(client))
|
|
173
|
+
|
|
174
|
+
const requested = requestedAll.filter((resource) => resource !== 'organization')
|
|
175
|
+
const wantAttributes = args.includeAttributes === true
|
|
176
|
+
const explicitEntities = Array.isArray(args.entities)
|
|
177
|
+
? args.entities.map((item) => String(item).trim()).filter(Boolean)
|
|
178
|
+
: []
|
|
179
|
+
|
|
180
|
+
const details = {}
|
|
181
|
+
const files = []
|
|
182
|
+
const issues = []
|
|
183
|
+
let entitiesRows = null
|
|
184
|
+
let entitiesTruncated = false
|
|
185
|
+
|
|
186
|
+
// `keys` / `*_relationships` only make sense for one entity, and the loop
|
|
187
|
+
// below has no entity argument: without this the path becomes
|
|
188
|
+
// EntityDefinitions(LogicalName='undefined') and the resource fails.
|
|
189
|
+
const withoutEntity = requested.filter((resource) => RESOURCES[resource]?.entityScoped && !explicitEntities.length)
|
|
190
|
+
for (const resource of withoutEntity) {
|
|
191
|
+
issues.push({ resource, message: '跳过:该资源需要实体(传 entities,或改用 d365_metadata 的 entity 参数)' })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (organizationRow) {
|
|
195
|
+
files.push({
|
|
196
|
+
resource: 'organization',
|
|
197
|
+
payload: {
|
|
198
|
+
resource: 'organization',
|
|
199
|
+
env: env.key,
|
|
200
|
+
snapshotId: id,
|
|
201
|
+
generatedAt: startedAt.toISOString(),
|
|
202
|
+
count: 1,
|
|
203
|
+
truncated: false,
|
|
204
|
+
rows: [organizationRow],
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
details.organization = { count: 1, truncated: false, requests: 1, elapsedMs: 0 }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// `entities` first: it is both a requested resource and the input to the
|
|
211
|
+
// attribute walk, so a truncation here changes what the attribute pull covers.
|
|
212
|
+
if (requested.includes('entities') || wantAttributes) {
|
|
213
|
+
const started = Date.now()
|
|
214
|
+
const before = client.counters.requests
|
|
215
|
+
try {
|
|
216
|
+
const page = await fetchResource(client, 'entities', { top: maxRecords, signal: exec?.signal })
|
|
217
|
+
entitiesRows = page.records.map((row) => flattenLabels(row))
|
|
218
|
+
entitiesTruncated = page.hasMore
|
|
219
|
+
if (requested.includes('entities')) {
|
|
220
|
+
files.push({
|
|
221
|
+
resource: 'entities',
|
|
222
|
+
payload: {
|
|
223
|
+
resource: 'entities',
|
|
224
|
+
env: env.key,
|
|
225
|
+
snapshotId: id,
|
|
226
|
+
generatedAt: new Date().toISOString(),
|
|
227
|
+
count: entitiesRows.length,
|
|
228
|
+
truncated: page.hasMore,
|
|
229
|
+
rows: entitiesRows,
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
details.entities = {
|
|
234
|
+
count: entitiesRows.length,
|
|
235
|
+
truncated: page.hasMore,
|
|
236
|
+
requests: client.counters.requests - before,
|
|
237
|
+
elapsedMs: Date.now() - started,
|
|
238
|
+
}
|
|
239
|
+
if (page.hasMore) {
|
|
240
|
+
issues.push({ resource: 'entities', message: `达到行数上限 ${maxRecords},实体列表不完整,依赖它的字段采集也受限` })
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
issues.push({ resource: 'entities', message: error.message })
|
|
244
|
+
details.entities = { count: 0, truncated: true, requests: client.counters.requests - before, elapsedMs: Date.now() - started, error: error.message }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const resource of requested) {
|
|
249
|
+
if (resource === 'entities') continue
|
|
250
|
+
if (withoutEntity.includes(resource)) continue
|
|
251
|
+
const started = Date.now()
|
|
252
|
+
const before = client.counters.requests
|
|
253
|
+
try {
|
|
254
|
+
const page = await fetchResource(client, resource, { top: maxRecords, signal: exec?.signal })
|
|
255
|
+
const rows = page.records.map((row) => flattenLabels(row))
|
|
256
|
+
files.push({
|
|
257
|
+
resource,
|
|
258
|
+
payload: {
|
|
259
|
+
resource,
|
|
260
|
+
env: env.key,
|
|
261
|
+
snapshotId: id,
|
|
262
|
+
generatedAt: new Date().toISOString(),
|
|
263
|
+
count: rows.length,
|
|
264
|
+
truncated: page.hasMore,
|
|
265
|
+
rows,
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
details[resource] = {
|
|
269
|
+
count: rows.length,
|
|
270
|
+
truncated: page.hasMore,
|
|
271
|
+
requests: client.counters.requests - before,
|
|
272
|
+
elapsedMs: Date.now() - started,
|
|
273
|
+
}
|
|
274
|
+
if (page.hasMore) {
|
|
275
|
+
issues.push({ resource, message: `达到行数上限 ${maxRecords},该资源不完整` })
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
details[resource] = {
|
|
279
|
+
count: 0,
|
|
280
|
+
truncated: true,
|
|
281
|
+
requests: client.counters.requests - before,
|
|
282
|
+
elapsedMs: Date.now() - started,
|
|
283
|
+
error: error.message,
|
|
284
|
+
}
|
|
285
|
+
issues.push({ resource, message: error.message })
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (wantAttributes) {
|
|
290
|
+
const available = entitiesRows ?? []
|
|
291
|
+
const names = (explicitEntities.length ? explicitEntities : available.map((row) => row.LogicalName))
|
|
292
|
+
.filter((name) => typeof name === 'string' && name.trim())
|
|
293
|
+
const capped = names.slice(0, MAX_ATTRIBUTE_ENTITIES)
|
|
294
|
+
if (names.length > capped.length) {
|
|
295
|
+
issues.push({ resource: 'attributes', message: `实体数超过 ${MAX_ATTRIBUTE_ENTITIES},只采集了前 ${MAX_ATTRIBUTE_ENTITIES} 个实体的字段` })
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const started = Date.now()
|
|
299
|
+
const result = await fetchAttributes(client, capped, maxRecords, exec?.signal)
|
|
300
|
+
files.push({
|
|
301
|
+
resource: 'attributes',
|
|
302
|
+
payload: {
|
|
303
|
+
resource: 'attributes',
|
|
304
|
+
env: env.key,
|
|
305
|
+
snapshotId: id,
|
|
306
|
+
generatedAt: new Date().toISOString(),
|
|
307
|
+
count: result.rows.length,
|
|
308
|
+
truncated: entitiesTruncated || names.length > capped.length || result.failures.length > 0,
|
|
309
|
+
entities: capped.length,
|
|
310
|
+
failures: result.failures,
|
|
311
|
+
rows: result.rows,
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
details.attributes = {
|
|
315
|
+
count: result.rows.length,
|
|
316
|
+
truncated: entitiesTruncated || names.length > capped.length || result.failures.length > 0,
|
|
317
|
+
entities: capped.length,
|
|
318
|
+
failedEntities: result.failures.length,
|
|
319
|
+
elapsedMs: result.elapsedMs,
|
|
320
|
+
}
|
|
321
|
+
for (const failure of result.failures.slice(0, 10)) {
|
|
322
|
+
issues.push({ resource: `attributes:${failure.entity}`, message: failure.message })
|
|
323
|
+
}
|
|
324
|
+
if (result.failures.length > 10) {
|
|
325
|
+
issues.push({ resource: 'attributes', message: `另有 ${result.failures.length - 10} 个实体的字段采集失败` })
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const finishedAt = new Date()
|
|
330
|
+
const manifest = {
|
|
331
|
+
snapshotId: id,
|
|
332
|
+
env: env.key,
|
|
333
|
+
label: env.label,
|
|
334
|
+
url: env.url,
|
|
335
|
+
organization: organizationRow
|
|
336
|
+
? {
|
|
337
|
+
organizationid: organizationRow.organizationid,
|
|
338
|
+
name: organizationRow.name,
|
|
339
|
+
version: organizationRow.version,
|
|
340
|
+
organizationstructure: organizationRow.organizationstructure,
|
|
341
|
+
}
|
|
342
|
+
: null,
|
|
343
|
+
startedAt: startedAt.toISOString(),
|
|
344
|
+
finishedAt: finishedAt.toISOString(),
|
|
345
|
+
durationMs: finishedAt - startedAt,
|
|
346
|
+
complete: issues.length === 0,
|
|
347
|
+
issues,
|
|
348
|
+
resources: details,
|
|
349
|
+
requested: ['organization', ...requested, ...(wantAttributes ? ['attributes'] : [])],
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
await ensureDataRoot(config)
|
|
353
|
+
const dir = await writeSnapshot(config, env.key, id, manifest, files)
|
|
354
|
+
const keep = Number.isFinite(args.keep) && args.keep > 0 ? Math.trunc(args.keep) : config.snapshotKeep
|
|
355
|
+
const pruned = await pruneSnapshots(config, env.key, keep)
|
|
356
|
+
await enforceRetention(config, { target: 'snapshots' })
|
|
357
|
+
|
|
358
|
+
return { env, id, dir, manifest, pruned, organization: organizationRow }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Render one environment's sync summary. */
|
|
362
|
+
function renderSummary(result, config) {
|
|
363
|
+
const { env, manifest } = result
|
|
364
|
+
const lines = []
|
|
365
|
+
const orgName = manifest.organization?.name ?? '(未取到组织信息)'
|
|
366
|
+
lines.push(`### ${env.key} — ${orgName}`)
|
|
367
|
+
lines.push(`- 快照:${manifest.snapshotId}`)
|
|
368
|
+
lines.push(`- 目录:${result.dir.replace(/\\/g, '/')}`)
|
|
369
|
+
lines.push(`- 状态:${manifest.complete ? '完整' : '部分(见下方 issues)'},耗时 ${(manifest.durationMs / 1000).toFixed(1)}s`)
|
|
370
|
+
lines.push('')
|
|
371
|
+
lines.push('| 资源 | 行数 | 请求 | 状态 |')
|
|
372
|
+
lines.push('| --- | --- | --- | --- |')
|
|
373
|
+
for (const [resource, detail] of Object.entries(manifest.resources)) {
|
|
374
|
+
const status = detail.error
|
|
375
|
+
? `失败:${String(detail.error).slice(0, 60)}`
|
|
376
|
+
: detail.truncated ? '被截断' : '完整'
|
|
377
|
+
lines.push(`| ${resource} | ${detail.count ?? 0} | ${detail.requests ?? '-'} | ${status} |`)
|
|
378
|
+
}
|
|
379
|
+
if (manifest.issues.length) {
|
|
380
|
+
lines.push('')
|
|
381
|
+
lines.push('问题:')
|
|
382
|
+
for (const issue of manifest.issues.slice(0, 12)) lines.push(`- ${issue.resource}:${issue.message}`)
|
|
383
|
+
if (manifest.issues.length > 12) lines.push(`- … 另有 ${manifest.issues.length - 12} 条见 manifest.json`)
|
|
384
|
+
}
|
|
385
|
+
if (result.pruned.length) {
|
|
386
|
+
lines.push('', `已清理旧快照:${result.pruned.join(', ')}(保留 ${config.snapshotKeep} 份)`)
|
|
387
|
+
}
|
|
388
|
+
if (!manifest.resources.attributes) {
|
|
389
|
+
lines.push('', '未采集字段定义(attributes)。需要时加 includeAttributes: true,或指定 entities 只拉部分实体。')
|
|
390
|
+
}
|
|
391
|
+
return lines
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function register(ctx, state) {
|
|
395
|
+
return ctx.tools.register({
|
|
396
|
+
...definition,
|
|
397
|
+
async execute(args = {}, exec) {
|
|
398
|
+
const config = await loadConfig(state.rawConfig)
|
|
399
|
+
const environments = pickEnvironments(config, args)
|
|
400
|
+
const lines = [`同步快照:${environments.map((env) => env.key).join(' / ')}`]
|
|
401
|
+
const failures = []
|
|
402
|
+
|
|
403
|
+
for (const env of environments) {
|
|
404
|
+
try {
|
|
405
|
+
const result = await syncEnvironment(env, config, args, exec)
|
|
406
|
+
lines.push('', ...renderSummary(result, config))
|
|
407
|
+
} catch (error) {
|
|
408
|
+
failures.push(`${env.key}:${error.message}`)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (failures.length) {
|
|
413
|
+
lines.push('')
|
|
414
|
+
lines.push('失败的环境:')
|
|
415
|
+
for (const failure of failures) lines.push(`- ${failure}`)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return { text: lines.join('\n') }
|
|
419
|
+
},
|
|
420
|
+
presentCall(args = {}) {
|
|
421
|
+
const envs = args.allEnvironments ? '全部环境' : (args.environments ?? []).join('/') || '默认环境'
|
|
422
|
+
return {
|
|
423
|
+
card: 'generic',
|
|
424
|
+
title: `同步 D365 快照(${envs})`,
|
|
425
|
+
kind: 'other',
|
|
426
|
+
rawInput: args,
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
})
|
|
430
|
+
}
|