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-data-sync.js
CHANGED
|
@@ -1,559 +1,559 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `d365_data_sync` — business records from one environment to another.
|
|
3
|
-
*
|
|
4
|
-
* The plan is read-only and says everything a person needs before deciding:
|
|
5
|
-
* how many rows, which columns, how every lookup resolved, whether plugins will
|
|
6
|
-
* be bypassed. Execution re-asks on a confirmation card, so a plan file alone
|
|
7
|
-
* can never write.
|
|
8
|
-
*/
|
|
9
|
-
import { loadConfig } from './config.js'
|
|
10
|
-
import { D365Error } from './errors.js'
|
|
11
|
-
import { allows, readPolicy } from './write-policy.js'
|
|
12
|
-
import {
|
|
13
|
-
buildPlan,
|
|
14
|
-
buildUpdatePlan,
|
|
15
|
-
deleteMigrationPlan,
|
|
16
|
-
executeMigration,
|
|
17
|
-
executeUpdate,
|
|
18
|
-
listMigrationPlans,
|
|
19
|
-
readMigrationPlan,
|
|
20
|
-
saveMigrationPlan,
|
|
21
|
-
setMigrationPlanStatus,
|
|
22
|
-
writePlanArtifact,
|
|
23
|
-
} from './data-migrate.js'
|
|
24
|
-
|
|
25
|
-
const ACTIONS = ['plan', 'plan-update', 'show', 'list', 'cancel', 'execute']
|
|
26
|
-
const APPROVE = '批准执行'
|
|
27
|
-
const CANCEL = '取消'
|
|
28
|
-
const CONFIRM_ID = 'd365-data-sync-confirm'
|
|
29
|
-
|
|
30
|
-
export const definition = {
|
|
31
|
-
name: 'd365_data_sync',
|
|
32
|
-
description:
|
|
33
|
-
'把业务记录从一个环境迁到另一个环境:默认镜像(保持 GUID),lookup 优先按 GUID 命中、其次按名称找;'
|
|
34
|
-
+ 'plan 只读预演,报告数据量/字段/lookup/冲突/权限/是否 Bypass 插件,并列出「条件、字段、Bypass」三项请你确认;'
|
|
35
|
-
+ 'execute 需人在确认卡片上批准。不指定条件时默认整表,数据量大时预演会建议加条件。',
|
|
36
|
-
parameters: {
|
|
37
|
-
type: 'object',
|
|
38
|
-
additionalProperties: false,
|
|
39
|
-
properties: {
|
|
40
|
-
action: {
|
|
41
|
-
type: 'string',
|
|
42
|
-
enum: ACTIONS,
|
|
43
|
-
description: 'plan 生成只读计划;plan-update 生成批量更新计划(同一环境、按显式 id 清单改字段);show 重看计划;list 列待执行计划;cancel 取消;execute 确认后执行。默认 plan。',
|
|
44
|
-
},
|
|
45
|
-
source: { type: 'string', description: '源环境 key,例如 dev。' },
|
|
46
|
-
target: { type: 'string', description: '目标环境 key,例如 sit。' },
|
|
47
|
-
environment: { type: 'string', description: 'plan-update:在哪个环境改记录(批量更新只改一个环境)。' },
|
|
48
|
-
values: {
|
|
49
|
-
type: 'object',
|
|
50
|
-
additionalProperties: true,
|
|
51
|
-
description: 'plan-update:要写入的字段与值,键用字段逻辑名,例如 {"statecode": 0, "contoso_verified": true}。只支持标量字段;lookup 请用 d365_data_write。',
|
|
52
|
-
},
|
|
53
|
-
entity: { type: 'string', description: '实体逻辑名或集合名,例如 account / accounts。' },
|
|
54
|
-
since: {
|
|
55
|
-
type: 'string',
|
|
56
|
-
description: '时间窗口起点(ISO,如 2026-11-05T00:00:00+08:00)。默认按 createdon 取;mode=modified 则按 modifiedon。',
|
|
57
|
-
},
|
|
58
|
-
mode: {
|
|
59
|
-
type: 'string',
|
|
60
|
-
enum: ['new', 'modified', 'all'],
|
|
61
|
-
description: 'new=since 之后新增(createdon,默认);modified=since 之后改动过(modifiedon);all=整表。不给 since/filter/ids/fetchXml 时也按整表预演。',
|
|
62
|
-
},
|
|
63
|
-
filter: { type: 'string', description: '额外 OData $filter,例如 "statecode eq 0"。与 since 同时给会 AND 在一起。' },
|
|
64
|
-
ids: { type: 'array', items: { type: 'string' }, description: '只迁这些记录的主键 GUID;plan-update 时是必须显式给出的待改记录清单。' },
|
|
65
|
-
fetchXml: { type: 'string', description: '用 fetchXml 精确指定要迁哪些记录(与 filter/ids 互斥优先)。' },
|
|
66
|
-
fields: {
|
|
67
|
-
type: 'array',
|
|
68
|
-
items: { type: 'string' },
|
|
69
|
-
description: '要迁的字段(逻辑名)。省略则全部可写字段。字段清单里含主键(xxxid)就等于保持 GUID 一致;不含主键则由目标分配新 GUID。',
|
|
70
|
-
},
|
|
71
|
-
keepGuid: { type: 'boolean', description: '不填时跟着 fields 走:字段含主键=保持 GUID(默认);也可以显式指定。false 时由目标分配 GUID(批内引用会重映射)。' },
|
|
72
|
-
includeLookups: { type: 'boolean', description: '默认 true:解析 lookup(先 GUID、再名称)。关掉则只按 GUID 判存在。' },
|
|
73
|
-
ownerMode: {
|
|
74
|
-
type: 'string',
|
|
75
|
-
enum: ['map', 'keep', 'caller'],
|
|
76
|
-
description: 'owner 处理:map=按域账号/邮箱映射到目标用户(默认,找不到落到执行账号);keep=原样保留 GUID;caller=全部归执行账号。',
|
|
77
|
-
},
|
|
78
|
-
conflict: {
|
|
79
|
-
type: 'string',
|
|
80
|
-
enum: ['report', 'skip', 'overwrite'],
|
|
81
|
-
description: '目标已存在同 GUID 时:report=只报告不写(默认);skip=跳过;overwrite=覆盖(execute 时可用 overwrite 显式开启)。',
|
|
82
|
-
},
|
|
83
|
-
onMissingLookup: {
|
|
84
|
-
type: 'string',
|
|
85
|
-
enum: ['report', 'null', 'fail'],
|
|
86
|
-
description: '引用在目标找不到且名称也匹配不上时:report=这些记录不写并报告(默认);null=把该引用置空;fail=整个计划拒绝执行。',
|
|
87
|
-
},
|
|
88
|
-
bypassPlugins: {
|
|
89
|
-
type: 'boolean',
|
|
90
|
-
description: '默认 true(跳过目标环境的同步插件)。false 时插件照常执行,可能触发下游通知/集成。',
|
|
91
|
-
},
|
|
92
|
-
preserveCreatedOn: { type: 'boolean', description: '默认 true:用 overriddencreatedon 保留源创建时间。' },
|
|
93
|
-
suppressDuplicateDetection: { type: 'boolean', description: '默认 true:请求带 MSCRM.SuppressDuplicateDetection,避免目标环境的重复检测规则拒掉创建。' },
|
|
94
|
-
includeState: { type: 'boolean', description: '默认 false:不搬 statecode/statuscode(一律按目标默认状态创建)。' },
|
|
95
|
-
maxRecords: { type: 'integer', description: '单次最多读取多少条,默认 500,上限 5000。' },
|
|
96
|
-
concurrency: { type: 'integer', description: '写请求并发数,默认 4,上限 8。' },
|
|
97
|
-
planId: { type: 'string', description: 'show / cancel / execute:计划 id。' },
|
|
98
|
-
rationale: { type: 'string', description: '为什么要迁,会写进计划与执行记录。' },
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
|
-
output: {
|
|
102
|
-
schema: {
|
|
103
|
-
type: 'object',
|
|
104
|
-
additionalProperties: false,
|
|
105
|
-
required: ['text'],
|
|
106
|
-
properties: { text: { type: 'string' } },
|
|
107
|
-
},
|
|
108
|
-
render(_args, value) {
|
|
109
|
-
return [{ type: 'text', text: value?.text ?? '' }]
|
|
110
|
-
},
|
|
111
|
-
},
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function yesNo(value) {
|
|
115
|
-
return value ? '是' : '否'
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function willWrite(plan, options) {
|
|
119
|
-
const blocked = options.onMissingLookup === 'report' ? plan.counts.blocked : 0
|
|
120
|
-
const overwrite = options.conflict === 'overwrite' ? plan.counts.conflict : 0
|
|
121
|
-
return plan.counts.create - blocked + overwrite
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function renderPlan(plan) {
|
|
125
|
-
const { counts, options, lookupSummary, fields, owners, limits } = plan
|
|
126
|
-
const lines = [`数据迁移计划:${plan.id}(只读预演,尚未写入)`]
|
|
127
|
-
lines.push(`源 → 目标:${plan.source.key}(${plan.source.entity})→ ${plan.target.key}(${plan.target.entity})`)
|
|
128
|
-
if (plan.rationale) lines.push(`理由:${plan.rationale}`)
|
|
129
|
-
lines.push(`范围:${plan.scope.description}`)
|
|
130
|
-
lines.push(`读取:${limits.read} 条${limits.hasMore ? `(已达上限 ${limits.maxRecords},还有更多未读;请缩小范围或调大 maxRecords)` : ''}`)
|
|
131
|
-
lines.push('')
|
|
132
|
-
|
|
133
|
-
lines.push('| 项 | 数量 |')
|
|
134
|
-
lines.push('| --- | --- |')
|
|
135
|
-
lines.push(`| 实际会写入 | ${willWrite(plan, options)} |`)
|
|
136
|
-
lines.push(`| 将新建 | ${counts.create} |`)
|
|
137
|
-
lines.push(`| 其中因引用缺失暂不写 | ${counts.blocked} |`)
|
|
138
|
-
lines.push(`| 目标已存在同 GUID | ${counts.conflict}${options.conflict === 'overwrite' ? '(execute 时覆盖)' : '(报告,不覆盖)'} |`)
|
|
139
|
-
lines.push(`| 引用缺失/歧义合计 | ${counts.missingLookups} |`)
|
|
140
|
-
lines.push('')
|
|
141
|
-
|
|
142
|
-
const plain = fields.filter((field) => !field.lookup).map((field) => field.name)
|
|
143
|
-
const lookups = fields.filter((field) => field.lookup)
|
|
144
|
-
const plainText = plain.length > 40
|
|
145
|
-
? `${plain.slice(0, 40).join(', ')} …(其余 ${plain.length - 40} 个见完整计划)`
|
|
146
|
-
: (plain.join(', ') || '(无普通字段)')
|
|
147
|
-
lines.push(`字段(${fields.length} 个,${lookups.length} 个 lookup):${plainText}`)
|
|
148
|
-
if (lookups.length) {
|
|
149
|
-
lines.push(`lookup(${lookups.length} 个):${lookups.map((field) => `${field.name}(${field.targets.join('|') || '?'})`).join(', ')}`)
|
|
150
|
-
const renamed = lookups.filter((field) => field.navigation && field.navigation !== field.name)
|
|
151
|
-
if (renamed.length) {
|
|
152
|
-
lines.push(`其中导航属性名与逻辑名不同(写入按导航名绑定):${renamed.map((field) => `${field.name} → ${field.navigation}`).join(',')}`)
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
lines.push('')
|
|
156
|
-
|
|
157
|
-
if (lookupSummary.length) {
|
|
158
|
-
lines.push('Lookup 情况:')
|
|
159
|
-
lines.push('| 字段 | 有值 | GUID 命中 | 按名称命中 | 缺失 | 歧义 | 为空 |')
|
|
160
|
-
lines.push('| --- | --- | --- | --- | --- | --- | --- |')
|
|
161
|
-
for (const entry of lookupSummary) {
|
|
162
|
-
lines.push(`| ${entry.attribute} | ${entry.filled} | ${entry.sameGuid} | ${entry.byName} | ${entry.missing} | ${entry.ambiguous} | ${entry.empty} |`)
|
|
163
|
-
}
|
|
164
|
-
const samples = lookupSummary.flatMap((entry) => (entry.missingSamples ?? []).map((sample) => ({ ...sample, attribute: entry.attribute })))
|
|
165
|
-
if (samples.length) {
|
|
166
|
-
lines.push('')
|
|
167
|
-
lines.push('未解析的引用(示例):')
|
|
168
|
-
for (const sample of samples.slice(0, 10)) {
|
|
169
|
-
lines.push(`- ${sample.attribute}:${sample.name ?? sample.sourceId} — ${sample.reason ?? '目标里找不到'}`)
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
lines.push('')
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (owners.distinct) {
|
|
176
|
-
lines.push(`Owner:${owners.distinct} 个所有者,映射到目标 ${owners.mapped} 个,兜底 ${owners.fallback} 个(${options.ownerMode === 'caller' ? '全部归执行账号' : '落到执行账号'})。`)
|
|
177
|
-
lines.push('')
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
lines.push('写入选项:')
|
|
181
|
-
lines.push(`- 保持 GUID:${yesNo(options.keepGuid)}${options.keepGuid ? '' : '(目标重新分配,批内引用会重映射)'}`)
|
|
182
|
-
lines.push(`- 保留创建时间(overriddencreatedon):${yesNo(options.preserveCreatedOn)}`)
|
|
183
|
-
lines.push(`- Bypass 自定义插件:${yesNo(options.bypassPlugins)}${options.bypassPlugins ? '(目标环境不会触发同步插件)' : '(插件照常执行,可能触发下游通知/集成)'}`)
|
|
184
|
-
lines.push(`- 抑制重复检测:${yesNo(options.suppressDuplicateDetection)}`)
|
|
185
|
-
lines.push(`- 冲突处理:${options.conflict}`)
|
|
186
|
-
lines.push(`- 缺失引用:${options.onMissingLookup}`)
|
|
187
|
-
lines.push(`- 搬状态字段:${yesNo(options.includeState)}`)
|
|
188
|
-
lines.push(`- 并发:${options.concurrency}`)
|
|
189
|
-
lines.push('')
|
|
190
|
-
|
|
191
|
-
const warnings = plan.warnings ?? []
|
|
192
|
-
if (warnings.length) {
|
|
193
|
-
lines.push('需要注意:')
|
|
194
|
-
for (const warning of warnings) lines.push(`- ${warning}`)
|
|
195
|
-
lines.push('')
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const permissions = plan.permissions
|
|
199
|
-
if (permissions) {
|
|
200
|
-
const read = (key) => (permissions[key] ? '已开启' : '未开启')
|
|
201
|
-
lines.push('权限(execute 需要):')
|
|
202
|
-
lines.push(`- 读取 ${plan.source.key}(data.read):${read('sourceRead')}`)
|
|
203
|
-
lines.push(`- 读取 ${plan.target.key}(data.read):${read('targetRead')}`)
|
|
204
|
-
lines.push(`- 写入 ${plan.target.key}(data.write):${permissions.targetWrite
|
|
205
|
-
? '已开启'
|
|
206
|
-
: '未开启 —— 执行前要在设置页「权限」里给该环境勾上「新增/修改业务数据」'}`)
|
|
207
|
-
lines.push('')
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
lines.push('请确认这三项(下面是本次预演用的值;要改任何一项,重新 plan):')
|
|
211
|
-
lines.push(`- 迁移条件:${plan.scope.description}${plan.scope.implicit ? ';数据量大建议用 since / filter / ids / fetchXml 加条件' : ''}`)
|
|
212
|
-
lines.push(`- 迁移字段:${fields.length} 个${options.keepGuid
|
|
213
|
-
? `(含主键 ${plan.idField ?? `${plan.source.entity}id`} → 保持 GUID 一致)`
|
|
214
|
-
: '(不含主键 → 目标重新分配 GUID,批内引用会重映射)'}`)
|
|
215
|
-
lines.push(`- Bypass 自定义插件:${yesNo(options.bypassPlugins)}${options.bypassPlugins ? '(默认跳过目标环境的同步插件)' : ''}`)
|
|
216
|
-
lines.push('')
|
|
217
|
-
|
|
218
|
-
if (permissions && !permissions.targetWrite) {
|
|
219
|
-
lines.push(`执行前请先在设置页「权限」里给 ${plan.target.key} 勾上「新增/修改业务数据」,否则 execute 会被拦下。`)
|
|
220
|
-
} else {
|
|
221
|
-
lines.push('确认无误后告诉我执行,我会弹确认卡片(写明写入条数、字段数、lookup 命中、是否 Bypass)。')
|
|
222
|
-
}
|
|
223
|
-
lines.push('这一步没有写任何东西。')
|
|
224
|
-
return lines.join('\n')
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function renderConfirmation(plan, options) {
|
|
228
|
-
const created = plan.counts.create - (options.onMissingLookup === 'report' ? plan.counts.blocked : 0)
|
|
229
|
-
const overwritten = options.conflict === 'overwrite' ? plan.counts.conflict : 0
|
|
230
|
-
const lines = [`${plan.source.key} → ${plan.target.key}(${plan.target.entity})`]
|
|
231
|
-
lines.push(`范围:${plan.scope.description}`)
|
|
232
|
-
lines.push(`将写入 ${created + overwritten} 条:新建 ${created} 条${overwritten ? `,覆盖 ${overwritten} 条` : ''}`)
|
|
233
|
-
if (options.conflict !== 'overwrite' && plan.counts.conflict) {
|
|
234
|
-
lines.push(`另有 ${plan.counts.conflict} 条目标已存在同 GUID,会跳过(要覆盖请改用 conflict="overwrite" 重新执行)。`)
|
|
235
|
-
}
|
|
236
|
-
if (options.onMissingLookup === 'report' && plan.counts.blocked) {
|
|
237
|
-
lines.push(`${plan.counts.blocked} 条因引用缺失会跳过。`)
|
|
238
|
-
}
|
|
239
|
-
lines.push(`字段 ${plan.fields.length} 个;lookup 命中:GUID ${plan.lookupSummary.reduce((sum, item) => sum + item.sameGuid, 0)},按名称 ${plan.lookupSummary.reduce((sum, item) => sum + item.byName, 0)}。`)
|
|
240
|
-
lines.push(`Bypass 自定义插件:${yesNo(options.bypassPlugins)}`)
|
|
241
|
-
lines.push(`保持 GUID:${yesNo(options.keepGuid)};保留创建时间:${yesNo(options.preserveCreatedOn)};抑制重复检测:${yesNo(options.suppressDuplicateDetection)}`)
|
|
242
|
-
lines.push('', '确认后立即写入目标环境;业务数据写入没有待确认计划,也不会自动回滚。')
|
|
243
|
-
return lines.join('\n')
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function renderRun(plan, run) {
|
|
247
|
-
const lines = [`已执行迁移计划 ${plan.id}(${run.runId})`]
|
|
248
|
-
lines.push(`写入 ${run.counts.written} 条(新建 ${run.created.length} / 覆盖 ${run.updated.length}),跳过 ${run.counts.skipped} 条,失败 ${run.counts.failed} 条。`)
|
|
249
|
-
if (run.skippedRecords.length) {
|
|
250
|
-
lines.push('')
|
|
251
|
-
lines.push(`跳过的记录(前 5 条):${run.skippedRecords.slice(0, 5).map((record) => `${record.id} — ${record.reason}`).join(';')}`)
|
|
252
|
-
}
|
|
253
|
-
if (run.failures.length) {
|
|
254
|
-
lines.push('')
|
|
255
|
-
lines.push('失败:')
|
|
256
|
-
for (const failure of run.failures.slice(0, 10)) lines.push(`- ${failure.id}:${failure.error}`)
|
|
257
|
-
if (run.failures.length > 10) lines.push(`- … 其余 ${run.failures.length - 10} 条见执行记录`)
|
|
258
|
-
}
|
|
259
|
-
lines.push('', `执行记录:${run.file}`)
|
|
260
|
-
return lines.join('\n')
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function checkEnvironments(config, args) {
|
|
264
|
-
const sourceKey = String(args.source ?? '').trim()
|
|
265
|
-
const targetKey = String(args.target ?? '').trim()
|
|
266
|
-
if (!sourceKey || !targetKey) {
|
|
267
|
-
throw new D365Error('需要 source 和 target', {
|
|
268
|
-
code: 'missing-environment',
|
|
269
|
-
hint: `例如 source="dev" target="sit";可用:${Object.keys(config.environments).join(', ')}`,
|
|
270
|
-
})
|
|
271
|
-
}
|
|
272
|
-
if (sourceKey === targetKey) throw new D365Error('source 和 target 是同一个环境', { code: 'same-environment' })
|
|
273
|
-
if (!config.environments[sourceKey]) throw new D365Error(`源环境不存在:${sourceKey}`, { hint: `可用:${Object.keys(config.environments).join(', ')}` })
|
|
274
|
-
if (!config.environments[targetKey]) throw new D365Error(`目标环境不存在:${targetKey}`, { hint: `可用:${Object.keys(config.environments).join(', ')}` })
|
|
275
|
-
return { sourceKey, targetKey }
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function renderUpdatePlan(plan) {
|
|
279
|
-
const lines = [`批量更新计划:${plan.id}(只读预演,尚未写入)`]
|
|
280
|
-
lines.push(`环境:${plan.environment}(${plan.entity})`)
|
|
281
|
-
lines.push(`记录:${plan.ids.length} 个 id,找到 ${plan.counts.existing} 条${plan.counts.missing ? `,**${plan.counts.missing} 条在这个环境里不存在**` : ''}`)
|
|
282
|
-
lines.push('')
|
|
283
|
-
lines.push(`要写入的字段(${plan.fields.length} 个):`)
|
|
284
|
-
for (const field of plan.fields) {
|
|
285
|
-
const value = plan.values[field.name]
|
|
286
|
-
lines.push(`- ${field.name}(${field.type}):${JSON.stringify(value)}`)
|
|
287
|
-
}
|
|
288
|
-
lines.push('')
|
|
289
|
-
if (plan.before.length) {
|
|
290
|
-
lines.push('改动预览(前 5 条):')
|
|
291
|
-
for (const row of plan.before.slice(0, 5)) {
|
|
292
|
-
const before = plan.fields.map((field) => `${field.name}=${JSON.stringify(row.current[field.name] ?? null)}`).join(',')
|
|
293
|
-
lines.push(`- ${row.name ?? row.id}:${before}`)
|
|
294
|
-
}
|
|
295
|
-
if (plan.before.length > 5) lines.push(`- … 其余 ${plan.before.length - 5} 条见完整计划`)
|
|
296
|
-
lines.push('')
|
|
297
|
-
}
|
|
298
|
-
if (plan.missing.length) {
|
|
299
|
-
lines.push(`不存在的 id(会被跳过):${plan.missing.slice(0, 5).join(',')}${plan.missing.length > 5 ? ' …' : ''}`)
|
|
300
|
-
lines.push('')
|
|
301
|
-
}
|
|
302
|
-
lines.push('写入选项:')
|
|
303
|
-
lines.push(`- Bypass 自定义插件:${yesNo(plan.options.bypassPlugins)}`)
|
|
304
|
-
lines.push(`- 抑制重复检测:${yesNo(plan.options.suppressDuplicateDetection)}`)
|
|
305
|
-
lines.push(`- 并发:${plan.options.concurrency}`)
|
|
306
|
-
lines.push('')
|
|
307
|
-
lines.push('请确认这三项(下面是本次预演用的值;要改任何一项,重新 plan-update):')
|
|
308
|
-
lines.push(`- 记录范围:${plan.ids.length} 个显式 id(不接受 filter —— 改错一批没有撤销键)`)
|
|
309
|
-
lines.push(`- 更新字段:${plan.fields.map((field) => field.name).join('、')}`)
|
|
310
|
-
lines.push(`- Bypass 自定义插件:${yesNo(plan.options.bypassPlugins)}`)
|
|
311
|
-
lines.push('')
|
|
312
|
-
lines.push('确认无误后告诉我执行,我会弹确认卡片(写明条数、字段、是否 Bypass)。')
|
|
313
|
-
lines.push('这一步没有写任何东西。')
|
|
314
|
-
return lines.join('\n')
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function renderUpdateConfirmation(plan) {
|
|
318
|
-
const lines = [`${plan.environment}(${plan.entity})`]
|
|
319
|
-
lines.push(`将更新 ${plan.before.length} 条记录`)
|
|
320
|
-
lines.push(`字段:${plan.fields.map((field) => `${field.name}=${JSON.stringify(plan.values[field.name])}`).join(',')}`)
|
|
321
|
-
if (plan.missing.length) lines.push(`${plan.missing.length} 个 id 不存在,会被跳过。`)
|
|
322
|
-
lines.push(`Bypass 自定义插件:${yesNo(plan.options.bypassPlugins)};抑制重复检测:${yesNo(plan.options.suppressDuplicateDetection)}`)
|
|
323
|
-
lines.push('', '确认后立即写入;业务数据写入没有撤销键,也不会自动回滚。')
|
|
324
|
-
return lines.join('\n')
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function renderUpdateRun(plan, run) {
|
|
328
|
-
const lines = [`已执行批量更新 ${plan.id}(${run.runId})`]
|
|
329
|
-
lines.push(`更新 ${run.counts.updated} 条,失败 ${run.counts.failed} 条,跳过 ${run.counts.missing} 条(id 不存在)。`)
|
|
330
|
-
if (run.failures.length) {
|
|
331
|
-
lines.push('')
|
|
332
|
-
for (const failure of run.failures.slice(0, 10)) lines.push(`- ${failure.id}:${failure.error}`)
|
|
333
|
-
if (run.failures.length > 10) lines.push(`- … 其余 ${run.failures.length - 10} 条见执行记录`)
|
|
334
|
-
}
|
|
335
|
-
lines.push('', `执行记录:${run.file}`)
|
|
336
|
-
return lines.join('\n')
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
async function executeUpdatePlan(ctx, config, plan, args, exec) {
|
|
340
|
-
const userQuestions = ctx.userQuestions ?? ctx.get('userQuestions')
|
|
341
|
-
const policy = await readPolicy(config.configFile)
|
|
342
|
-
if (!allows(policy, plan.environment, 'data.write')) {
|
|
343
|
-
throw new D365Error(`环境 ${plan.environment} 未授权新增或修改业务数据`, {
|
|
344
|
-
code: 'data-write-forbidden',
|
|
345
|
-
hint: `在设置页「权限」里给 ${plan.environment} 勾上「新增/修改业务数据」后再试。`,
|
|
346
|
-
})
|
|
347
|
-
}
|
|
348
|
-
if (!userQuestions?.ask) {
|
|
349
|
-
throw new D365Error('当前会话没有可用的确认界面', { hint: '批量更新必须在对话里确认。' })
|
|
350
|
-
}
|
|
351
|
-
if (!exec?.agent) throw new D365Error('execute 需要可交互的会话', { hint: '请在主对话里执行。' })
|
|
352
|
-
|
|
353
|
-
const effective = {
|
|
354
|
-
bypassPlugins: typeof args.bypassPlugins === 'boolean' ? args.bypassPlugins : plan.options.bypassPlugins,
|
|
355
|
-
suppressDuplicateDetection: typeof args.suppressDuplicateDetection === 'boolean'
|
|
356
|
-
? args.suppressDuplicateDetection
|
|
357
|
-
: plan.options.suppressDuplicateDetection,
|
|
358
|
-
concurrency: Number.isFinite(args.concurrency)
|
|
359
|
-
? Math.max(1, Math.min(8, Number(args.concurrency)))
|
|
360
|
-
: plan.options.concurrency,
|
|
361
|
-
}
|
|
362
|
-
const answer = await userQuestions.ask({
|
|
363
|
-
questions: [{
|
|
364
|
-
id: CONFIRM_ID,
|
|
365
|
-
header: `确认批量更新 ${plan.environment}`,
|
|
366
|
-
question: `要更新 ${plan.environment} 里这 ${plan.before.length} 条 ${plan.entity} 记录吗?`,
|
|
367
|
-
detail: renderUpdateConfirmation(plan),
|
|
368
|
-
options: [
|
|
369
|
-
{ label: APPROVE, description: '立即写入' },
|
|
370
|
-
{ label: CANCEL, description: '不执行,计划继续保留' },
|
|
371
|
-
],
|
|
372
|
-
}],
|
|
373
|
-
agent: exec.agent,
|
|
374
|
-
...(exec.signal ? { signal: exec.signal } : {}),
|
|
375
|
-
wait: { callId: exec.callId },
|
|
376
|
-
})
|
|
377
|
-
const chosen = answer?.answers?.find((item) => item.id === CONFIRM_ID)?.selected?.[0]
|
|
378
|
-
if (chosen !== APPROVE) return { text: `未执行。计划 ${plan.id} 仍在待执行列表里。` }
|
|
379
|
-
|
|
380
|
-
await setMigrationPlanStatus(config, plan.id, 'running')
|
|
381
|
-
try {
|
|
382
|
-
const run = await executeUpdate(config, plan, effective, exec.signal)
|
|
383
|
-
await setMigrationPlanStatus(config, plan.id, 'done', { runId: run.runId, executedAt: run.at })
|
|
384
|
-
return { text: renderUpdateRun(plan, run) }
|
|
385
|
-
} catch (error) {
|
|
386
|
-
await setMigrationPlanStatus(config, plan.id, 'failed', { error: String(error?.message ?? error) })
|
|
387
|
-
throw error
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
export function register(ctx, state) {
|
|
392
|
-
return ctx.tools.register({
|
|
393
|
-
...definition,
|
|
394
|
-
async execute(args = {}, exec) {
|
|
395
|
-
const config = await loadConfig(state.rawConfig)
|
|
396
|
-
const action = args.action ?? 'plan'
|
|
397
|
-
const policy = await readPolicy(config.configFile)
|
|
398
|
-
|
|
399
|
-
if (action === 'plan-update') {
|
|
400
|
-
const environment = String(args.environment ?? '').trim()
|
|
401
|
-
if (!environment) {
|
|
402
|
-
throw new D365Error('plan-update 需要 environment', {
|
|
403
|
-
code: 'missing-environment',
|
|
404
|
-
hint: '批量更新只改一个环境;跨环境复制记录用 action=plan。',
|
|
405
|
-
})
|
|
406
|
-
}
|
|
407
|
-
if (!allows(policy, environment, 'data.read')) {
|
|
408
|
-
throw new D365Error(`环境 ${environment} 未授权读取业务数据`, {
|
|
409
|
-
code: 'data-read-forbidden',
|
|
410
|
-
hint: `生成更新计划要先读出这些记录现在的值(预览 before → after),请在设置页「权限」里给 ${environment} 勾上「读取业务数据」。`,
|
|
411
|
-
})
|
|
412
|
-
}
|
|
413
|
-
const plan = await buildUpdatePlan(config, args, exec?.signal)
|
|
414
|
-
plan.rationale = args.rationale
|
|
415
|
-
await saveMigrationPlan(config, plan)
|
|
416
|
-
return { text: renderUpdatePlan(plan) }
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
if (action === 'list') {
|
|
420
|
-
const plans = await listMigrationPlans(config)
|
|
421
|
-
if (!plans.length) return { text: '没有待执行的数据迁移计划。' }
|
|
422
|
-
const pending = plans.filter((plan) => (plan.status ?? 'pending') === 'pending')
|
|
423
|
-
const finished = plans.filter((plan) => (plan.status ?? 'pending') !== 'pending')
|
|
424
|
-
const summaryOf = (plan) => (plan.kind === 'data-update'
|
|
425
|
-
? `${plan.environment}(${plan.entity})批量更新 ${plan.counts.existing} 条`
|
|
426
|
-
: `${plan.source.key} → ${plan.target.key}(${plan.target.entity}),${plan.counts.create} 新建 / ${plan.counts.conflict} 冲突,范围 ${plan.scope.description}`)
|
|
427
|
-
const stateOf = (plan) => (plan.status === 'running' ? ' · 执行中断,先核对执行记录' : plan.status === 'failed' ? ' · 上次执行失败' : ' · 已执行')
|
|
428
|
-
const lines = []
|
|
429
|
-
if (pending.length) {
|
|
430
|
-
lines.push(`待执行的数据迁移:${pending.length} 个计划`, '')
|
|
431
|
-
for (const plan of pending) lines.push(`- ${plan.id}:${summaryOf(plan)}`)
|
|
432
|
-
lines.push('', '先 action=show 看细节,确认后 action=execute。')
|
|
433
|
-
} else {
|
|
434
|
-
lines.push('没有待执行的数据迁移计划。')
|
|
435
|
-
}
|
|
436
|
-
if (finished.length) {
|
|
437
|
-
lines.push('', `已结束的:${finished.length} 个(仍可用 action=show 回看)`)
|
|
438
|
-
for (const plan of finished.slice(0, 10)) lines.push(`- ${plan.id}:${summaryOf(plan)}${stateOf(plan)}`)
|
|
439
|
-
if (finished.length > 10) lines.push(`- … 其余 ${finished.length - 10} 个`)
|
|
440
|
-
}
|
|
441
|
-
return { text: lines.join('\n') }
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
if (action === 'show') {
|
|
445
|
-
const id = String(args.planId ?? '').trim()
|
|
446
|
-
if (!id) throw new D365Error('show 需要 planId', { hint: '用 action=list 查看现有计划' })
|
|
447
|
-
const plan = await readMigrationPlan(config, id)
|
|
448
|
-
if (!plan) throw new D365Error(`没有找到计划 ${id}`, { code: 'plan-not-found', hint: '用 action=list 查看现有计划' })
|
|
449
|
-
return { text: plan.kind === 'data-update' ? renderUpdatePlan(plan) : renderPlan(plan) }
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
if (action === 'cancel') {
|
|
453
|
-
const id = String(args.planId ?? '').trim()
|
|
454
|
-
if (!id) throw new D365Error('cancel 需要 planId')
|
|
455
|
-
const removed = await deleteMigrationPlan(config, id)
|
|
456
|
-
return { text: removed ? `已取消计划 ${id}。` : `没有找到计划 ${id}。` }
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
if (action === 'execute') {
|
|
460
|
-
const id = String(args.planId ?? '').trim()
|
|
461
|
-
if (!id) throw new D365Error('execute 需要 planId', { hint: '先 action=plan 生成计划,再 action=execute' })
|
|
462
|
-
const plan = await readMigrationPlan(config, id)
|
|
463
|
-
if (!plan) throw new D365Error(`没有找到计划 ${id}`, { code: 'plan-not-found', hint: '用 action=list 查看现有计划' })
|
|
464
|
-
if ((plan.status ?? 'pending') !== 'pending') {
|
|
465
|
-
throw new D365Error(`计划 ${id} 不是待执行状态(${plan.status})`, {
|
|
466
|
-
code: 'plan-not-pending',
|
|
467
|
-
hint: '上次执行可能中断或已完成;先到 .d365/data-sync/runs 核对结果,需要重跑请重新 plan。',
|
|
468
|
-
})
|
|
469
|
-
}
|
|
470
|
-
if (plan.kind === 'data-update') {
|
|
471
|
-
return executeUpdatePlan(ctx, config, plan, args, exec)
|
|
472
|
-
}
|
|
473
|
-
const targetKey = plan.target.key
|
|
474
|
-
if (!allows(policy, targetKey, 'data.write')) {
|
|
475
|
-
throw new D365Error(`环境 ${targetKey} 未授权新增或修改业务数据`, {
|
|
476
|
-
code: 'data-write-forbidden',
|
|
477
|
-
hint: `在设置页「权限」里给 ${targetKey} 勾上「新增/修改业务数据」后再试。`,
|
|
478
|
-
})
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// Execute-time options can narrow what the plan proposed (overwrite,
|
|
482
|
-
// bypass) but the card always states the final values. It must therefore
|
|
483
|
-
// start from the plan's full option set: the card prints keepGuid,
|
|
484
|
-
// preserveCreatedOn and suppressDuplicateDetection too, and an omitted
|
|
485
|
-
// field used to render as "no" while execution used the plan's "yes".
|
|
486
|
-
const effective = {
|
|
487
|
-
...plan.options,
|
|
488
|
-
conflict: ['report', 'skip', 'overwrite'].includes(args.conflict) ? args.conflict : plan.options.conflict,
|
|
489
|
-
onMissingLookup: ['report', 'null', 'fail'].includes(args.onMissingLookup) ? args.onMissingLookup : plan.options.onMissingLookup,
|
|
490
|
-
bypassPlugins: typeof args.bypassPlugins === 'boolean' ? args.bypassPlugins : plan.options.bypassPlugins,
|
|
491
|
-
concurrency: Number.isFinite(args.concurrency)
|
|
492
|
-
? Math.max(1, Math.min(8, Number(args.concurrency)))
|
|
493
|
-
: plan.options.concurrency,
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const userQuestions = ctx.get('userQuestions')
|
|
497
|
-
if (!userQuestions?.ask) {
|
|
498
|
-
throw new D365Error('当前会话没有可用的确认界面', { hint: '数据迁移必须在对话里确认,没有「存起来稍后执行」的路径。' })
|
|
499
|
-
}
|
|
500
|
-
if (!exec?.agent) throw new D365Error('execute 需要可交互的会话', { hint: '请在主对话里执行。' })
|
|
501
|
-
|
|
502
|
-
const answer = await userQuestions.ask({
|
|
503
|
-
questions: [{
|
|
504
|
-
id: CONFIRM_ID,
|
|
505
|
-
header: `确认迁移 ${plan.source.key} → ${plan.target.key}`,
|
|
506
|
-
question: `要执行这个计划、把记录写入 ${plan.target.key} 吗?`,
|
|
507
|
-
detail: renderConfirmation(plan, effective),
|
|
508
|
-
options: [
|
|
509
|
-
{ label: APPROVE, description: '立即写入目标环境' },
|
|
510
|
-
{ label: CANCEL, description: '不执行,计划继续保留' },
|
|
511
|
-
],
|
|
512
|
-
}],
|
|
513
|
-
agent: exec.agent,
|
|
514
|
-
...(exec.signal ? { signal: exec.signal } : {}),
|
|
515
|
-
wait: { callId: exec.callId },
|
|
516
|
-
})
|
|
517
|
-
const chosen = answer?.answers?.find((item) => item.id === CONFIRM_ID)?.selected?.[0]
|
|
518
|
-
if (chosen !== APPROVE) return { text: `未执行。计划 ${id} 仍在待执行列表里。` }
|
|
519
|
-
|
|
520
|
-
await setMigrationPlanStatus(config, id, 'running')
|
|
521
|
-
try {
|
|
522
|
-
const run = await executeMigration(config, plan, effective, exec.signal)
|
|
523
|
-
await setMigrationPlanStatus(config, id, 'done', { runId: run.runId, executedAt: run.at })
|
|
524
|
-
return { text: renderRun(plan, run) }
|
|
525
|
-
} catch (error) {
|
|
526
|
-
await setMigrationPlanStatus(config, id, 'failed', { error: String(error?.message ?? error) })
|
|
527
|
-
throw error
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// action === 'plan'
|
|
532
|
-
const { sourceKey, targetKey } = checkEnvironments(config, args)
|
|
533
|
-
for (const key of [sourceKey, targetKey]) {
|
|
534
|
-
if (!allows(policy, key, 'data.read')) {
|
|
535
|
-
throw new D365Error(`环境 ${key} 未授权读取业务数据`, {
|
|
536
|
-
code: 'data-read-forbidden',
|
|
537
|
-
hint: `生成计划要读源和目标的记录(含存在性检查),请在设置页「权限」里给 ${key} 勾上「读取业务数据」。`,
|
|
538
|
-
})
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
// A missing `data.write` on the target is not fatal for a plan: the report
|
|
542
|
-
// says so, and `execute` refuses with the same hint.
|
|
543
|
-
|
|
544
|
-
const built = await buildPlan(config, { ...args, source: sourceKey, target: targetKey }, exec?.signal)
|
|
545
|
-
built.permissions = {
|
|
546
|
-
sourceRead: allows(policy, sourceKey, 'data.read'),
|
|
547
|
-
targetRead: allows(policy, targetKey, 'data.read'),
|
|
548
|
-
targetWrite: allows(policy, targetKey, 'data.write'),
|
|
549
|
-
}
|
|
550
|
-
const plan = await saveMigrationPlan(config, { ...built, rationale: args.rationale })
|
|
551
|
-
const artifact = await writePlanArtifact(config, plan)
|
|
552
|
-
const text = `${renderPlan(plan)}\n完整计划(含每条记录):${artifact}`
|
|
553
|
-
return { text }
|
|
554
|
-
},
|
|
555
|
-
presentCall(args = {}) {
|
|
556
|
-
return { card: 'generic', title: `D365 数据迁移:${args.action ?? 'plan'}`, kind: 'other', rawInput: args }
|
|
557
|
-
},
|
|
558
|
-
})
|
|
559
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `d365_data_sync` — business records from one environment to another.
|
|
3
|
+
*
|
|
4
|
+
* The plan is read-only and says everything a person needs before deciding:
|
|
5
|
+
* how many rows, which columns, how every lookup resolved, whether plugins will
|
|
6
|
+
* be bypassed. Execution re-asks on a confirmation card, so a plan file alone
|
|
7
|
+
* can never write.
|
|
8
|
+
*/
|
|
9
|
+
import { loadConfig } from './config.js'
|
|
10
|
+
import { D365Error } from './errors.js'
|
|
11
|
+
import { allows, readPolicy } from './write-policy.js'
|
|
12
|
+
import {
|
|
13
|
+
buildPlan,
|
|
14
|
+
buildUpdatePlan,
|
|
15
|
+
deleteMigrationPlan,
|
|
16
|
+
executeMigration,
|
|
17
|
+
executeUpdate,
|
|
18
|
+
listMigrationPlans,
|
|
19
|
+
readMigrationPlan,
|
|
20
|
+
saveMigrationPlan,
|
|
21
|
+
setMigrationPlanStatus,
|
|
22
|
+
writePlanArtifact,
|
|
23
|
+
} from './data-migrate.js'
|
|
24
|
+
|
|
25
|
+
const ACTIONS = ['plan', 'plan-update', 'show', 'list', 'cancel', 'execute']
|
|
26
|
+
const APPROVE = '批准执行'
|
|
27
|
+
const CANCEL = '取消'
|
|
28
|
+
const CONFIRM_ID = 'd365-data-sync-confirm'
|
|
29
|
+
|
|
30
|
+
export const definition = {
|
|
31
|
+
name: 'd365_data_sync',
|
|
32
|
+
description:
|
|
33
|
+
'把业务记录从一个环境迁到另一个环境:默认镜像(保持 GUID),lookup 优先按 GUID 命中、其次按名称找;'
|
|
34
|
+
+ 'plan 只读预演,报告数据量/字段/lookup/冲突/权限/是否 Bypass 插件,并列出「条件、字段、Bypass」三项请你确认;'
|
|
35
|
+
+ 'execute 需人在确认卡片上批准。不指定条件时默认整表,数据量大时预演会建议加条件。',
|
|
36
|
+
parameters: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
properties: {
|
|
40
|
+
action: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
enum: ACTIONS,
|
|
43
|
+
description: 'plan 生成只读计划;plan-update 生成批量更新计划(同一环境、按显式 id 清单改字段);show 重看计划;list 列待执行计划;cancel 取消;execute 确认后执行。默认 plan。',
|
|
44
|
+
},
|
|
45
|
+
source: { type: 'string', description: '源环境 key,例如 dev。' },
|
|
46
|
+
target: { type: 'string', description: '目标环境 key,例如 sit。' },
|
|
47
|
+
environment: { type: 'string', description: 'plan-update:在哪个环境改记录(批量更新只改一个环境)。' },
|
|
48
|
+
values: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
additionalProperties: true,
|
|
51
|
+
description: 'plan-update:要写入的字段与值,键用字段逻辑名,例如 {"statecode": 0, "contoso_verified": true}。只支持标量字段;lookup 请用 d365_data_write。',
|
|
52
|
+
},
|
|
53
|
+
entity: { type: 'string', description: '实体逻辑名或集合名,例如 account / accounts。' },
|
|
54
|
+
since: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: '时间窗口起点(ISO,如 2026-11-05T00:00:00+08:00)。默认按 createdon 取;mode=modified 则按 modifiedon。',
|
|
57
|
+
},
|
|
58
|
+
mode: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
enum: ['new', 'modified', 'all'],
|
|
61
|
+
description: 'new=since 之后新增(createdon,默认);modified=since 之后改动过(modifiedon);all=整表。不给 since/filter/ids/fetchXml 时也按整表预演。',
|
|
62
|
+
},
|
|
63
|
+
filter: { type: 'string', description: '额外 OData $filter,例如 "statecode eq 0"。与 since 同时给会 AND 在一起。' },
|
|
64
|
+
ids: { type: 'array', items: { type: 'string' }, description: '只迁这些记录的主键 GUID;plan-update 时是必须显式给出的待改记录清单。' },
|
|
65
|
+
fetchXml: { type: 'string', description: '用 fetchXml 精确指定要迁哪些记录(与 filter/ids 互斥优先)。' },
|
|
66
|
+
fields: {
|
|
67
|
+
type: 'array',
|
|
68
|
+
items: { type: 'string' },
|
|
69
|
+
description: '要迁的字段(逻辑名)。省略则全部可写字段。字段清单里含主键(xxxid)就等于保持 GUID 一致;不含主键则由目标分配新 GUID。',
|
|
70
|
+
},
|
|
71
|
+
keepGuid: { type: 'boolean', description: '不填时跟着 fields 走:字段含主键=保持 GUID(默认);也可以显式指定。false 时由目标分配 GUID(批内引用会重映射)。' },
|
|
72
|
+
includeLookups: { type: 'boolean', description: '默认 true:解析 lookup(先 GUID、再名称)。关掉则只按 GUID 判存在。' },
|
|
73
|
+
ownerMode: {
|
|
74
|
+
type: 'string',
|
|
75
|
+
enum: ['map', 'keep', 'caller'],
|
|
76
|
+
description: 'owner 处理:map=按域账号/邮箱映射到目标用户(默认,找不到落到执行账号);keep=原样保留 GUID;caller=全部归执行账号。',
|
|
77
|
+
},
|
|
78
|
+
conflict: {
|
|
79
|
+
type: 'string',
|
|
80
|
+
enum: ['report', 'skip', 'overwrite'],
|
|
81
|
+
description: '目标已存在同 GUID 时:report=只报告不写(默认);skip=跳过;overwrite=覆盖(execute 时可用 overwrite 显式开启)。',
|
|
82
|
+
},
|
|
83
|
+
onMissingLookup: {
|
|
84
|
+
type: 'string',
|
|
85
|
+
enum: ['report', 'null', 'fail'],
|
|
86
|
+
description: '引用在目标找不到且名称也匹配不上时:report=这些记录不写并报告(默认);null=把该引用置空;fail=整个计划拒绝执行。',
|
|
87
|
+
},
|
|
88
|
+
bypassPlugins: {
|
|
89
|
+
type: 'boolean',
|
|
90
|
+
description: '默认 true(跳过目标环境的同步插件)。false 时插件照常执行,可能触发下游通知/集成。',
|
|
91
|
+
},
|
|
92
|
+
preserveCreatedOn: { type: 'boolean', description: '默认 true:用 overriddencreatedon 保留源创建时间。' },
|
|
93
|
+
suppressDuplicateDetection: { type: 'boolean', description: '默认 true:请求带 MSCRM.SuppressDuplicateDetection,避免目标环境的重复检测规则拒掉创建。' },
|
|
94
|
+
includeState: { type: 'boolean', description: '默认 false:不搬 statecode/statuscode(一律按目标默认状态创建)。' },
|
|
95
|
+
maxRecords: { type: 'integer', description: '单次最多读取多少条,默认 500,上限 5000。' },
|
|
96
|
+
concurrency: { type: 'integer', description: '写请求并发数,默认 4,上限 8。' },
|
|
97
|
+
planId: { type: 'string', description: 'show / cancel / execute:计划 id。' },
|
|
98
|
+
rationale: { type: 'string', description: '为什么要迁,会写进计划与执行记录。' },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
output: {
|
|
102
|
+
schema: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
additionalProperties: false,
|
|
105
|
+
required: ['text'],
|
|
106
|
+
properties: { text: { type: 'string' } },
|
|
107
|
+
},
|
|
108
|
+
render(_args, value) {
|
|
109
|
+
return [{ type: 'text', text: value?.text ?? '' }]
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function yesNo(value) {
|
|
115
|
+
return value ? '是' : '否'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function willWrite(plan, options) {
|
|
119
|
+
const blocked = options.onMissingLookup === 'report' ? plan.counts.blocked : 0
|
|
120
|
+
const overwrite = options.conflict === 'overwrite' ? plan.counts.conflict : 0
|
|
121
|
+
return plan.counts.create - blocked + overwrite
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renderPlan(plan) {
|
|
125
|
+
const { counts, options, lookupSummary, fields, owners, limits } = plan
|
|
126
|
+
const lines = [`数据迁移计划:${plan.id}(只读预演,尚未写入)`]
|
|
127
|
+
lines.push(`源 → 目标:${plan.source.key}(${plan.source.entity})→ ${plan.target.key}(${plan.target.entity})`)
|
|
128
|
+
if (plan.rationale) lines.push(`理由:${plan.rationale}`)
|
|
129
|
+
lines.push(`范围:${plan.scope.description}`)
|
|
130
|
+
lines.push(`读取:${limits.read} 条${limits.hasMore ? `(已达上限 ${limits.maxRecords},还有更多未读;请缩小范围或调大 maxRecords)` : ''}`)
|
|
131
|
+
lines.push('')
|
|
132
|
+
|
|
133
|
+
lines.push('| 项 | 数量 |')
|
|
134
|
+
lines.push('| --- | --- |')
|
|
135
|
+
lines.push(`| 实际会写入 | ${willWrite(plan, options)} |`)
|
|
136
|
+
lines.push(`| 将新建 | ${counts.create} |`)
|
|
137
|
+
lines.push(`| 其中因引用缺失暂不写 | ${counts.blocked} |`)
|
|
138
|
+
lines.push(`| 目标已存在同 GUID | ${counts.conflict}${options.conflict === 'overwrite' ? '(execute 时覆盖)' : '(报告,不覆盖)'} |`)
|
|
139
|
+
lines.push(`| 引用缺失/歧义合计 | ${counts.missingLookups} |`)
|
|
140
|
+
lines.push('')
|
|
141
|
+
|
|
142
|
+
const plain = fields.filter((field) => !field.lookup).map((field) => field.name)
|
|
143
|
+
const lookups = fields.filter((field) => field.lookup)
|
|
144
|
+
const plainText = plain.length > 40
|
|
145
|
+
? `${plain.slice(0, 40).join(', ')} …(其余 ${plain.length - 40} 个见完整计划)`
|
|
146
|
+
: (plain.join(', ') || '(无普通字段)')
|
|
147
|
+
lines.push(`字段(${fields.length} 个,${lookups.length} 个 lookup):${plainText}`)
|
|
148
|
+
if (lookups.length) {
|
|
149
|
+
lines.push(`lookup(${lookups.length} 个):${lookups.map((field) => `${field.name}(${field.targets.join('|') || '?'})`).join(', ')}`)
|
|
150
|
+
const renamed = lookups.filter((field) => field.navigation && field.navigation !== field.name)
|
|
151
|
+
if (renamed.length) {
|
|
152
|
+
lines.push(`其中导航属性名与逻辑名不同(写入按导航名绑定):${renamed.map((field) => `${field.name} → ${field.navigation}`).join(',')}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
lines.push('')
|
|
156
|
+
|
|
157
|
+
if (lookupSummary.length) {
|
|
158
|
+
lines.push('Lookup 情况:')
|
|
159
|
+
lines.push('| 字段 | 有值 | GUID 命中 | 按名称命中 | 缺失 | 歧义 | 为空 |')
|
|
160
|
+
lines.push('| --- | --- | --- | --- | --- | --- | --- |')
|
|
161
|
+
for (const entry of lookupSummary) {
|
|
162
|
+
lines.push(`| ${entry.attribute} | ${entry.filled} | ${entry.sameGuid} | ${entry.byName} | ${entry.missing} | ${entry.ambiguous} | ${entry.empty} |`)
|
|
163
|
+
}
|
|
164
|
+
const samples = lookupSummary.flatMap((entry) => (entry.missingSamples ?? []).map((sample) => ({ ...sample, attribute: entry.attribute })))
|
|
165
|
+
if (samples.length) {
|
|
166
|
+
lines.push('')
|
|
167
|
+
lines.push('未解析的引用(示例):')
|
|
168
|
+
for (const sample of samples.slice(0, 10)) {
|
|
169
|
+
lines.push(`- ${sample.attribute}:${sample.name ?? sample.sourceId} — ${sample.reason ?? '目标里找不到'}`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
lines.push('')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (owners.distinct) {
|
|
176
|
+
lines.push(`Owner:${owners.distinct} 个所有者,映射到目标 ${owners.mapped} 个,兜底 ${owners.fallback} 个(${options.ownerMode === 'caller' ? '全部归执行账号' : '落到执行账号'})。`)
|
|
177
|
+
lines.push('')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
lines.push('写入选项:')
|
|
181
|
+
lines.push(`- 保持 GUID:${yesNo(options.keepGuid)}${options.keepGuid ? '' : '(目标重新分配,批内引用会重映射)'}`)
|
|
182
|
+
lines.push(`- 保留创建时间(overriddencreatedon):${yesNo(options.preserveCreatedOn)}`)
|
|
183
|
+
lines.push(`- Bypass 自定义插件:${yesNo(options.bypassPlugins)}${options.bypassPlugins ? '(目标环境不会触发同步插件)' : '(插件照常执行,可能触发下游通知/集成)'}`)
|
|
184
|
+
lines.push(`- 抑制重复检测:${yesNo(options.suppressDuplicateDetection)}`)
|
|
185
|
+
lines.push(`- 冲突处理:${options.conflict}`)
|
|
186
|
+
lines.push(`- 缺失引用:${options.onMissingLookup}`)
|
|
187
|
+
lines.push(`- 搬状态字段:${yesNo(options.includeState)}`)
|
|
188
|
+
lines.push(`- 并发:${options.concurrency}`)
|
|
189
|
+
lines.push('')
|
|
190
|
+
|
|
191
|
+
const warnings = plan.warnings ?? []
|
|
192
|
+
if (warnings.length) {
|
|
193
|
+
lines.push('需要注意:')
|
|
194
|
+
for (const warning of warnings) lines.push(`- ${warning}`)
|
|
195
|
+
lines.push('')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const permissions = plan.permissions
|
|
199
|
+
if (permissions) {
|
|
200
|
+
const read = (key) => (permissions[key] ? '已开启' : '未开启')
|
|
201
|
+
lines.push('权限(execute 需要):')
|
|
202
|
+
lines.push(`- 读取 ${plan.source.key}(data.read):${read('sourceRead')}`)
|
|
203
|
+
lines.push(`- 读取 ${plan.target.key}(data.read):${read('targetRead')}`)
|
|
204
|
+
lines.push(`- 写入 ${plan.target.key}(data.write):${permissions.targetWrite
|
|
205
|
+
? '已开启'
|
|
206
|
+
: '未开启 —— 执行前要在设置页「权限」里给该环境勾上「新增/修改业务数据」'}`)
|
|
207
|
+
lines.push('')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
lines.push('请确认这三项(下面是本次预演用的值;要改任何一项,重新 plan):')
|
|
211
|
+
lines.push(`- 迁移条件:${plan.scope.description}${plan.scope.implicit ? ';数据量大建议用 since / filter / ids / fetchXml 加条件' : ''}`)
|
|
212
|
+
lines.push(`- 迁移字段:${fields.length} 个${options.keepGuid
|
|
213
|
+
? `(含主键 ${plan.idField ?? `${plan.source.entity}id`} → 保持 GUID 一致)`
|
|
214
|
+
: '(不含主键 → 目标重新分配 GUID,批内引用会重映射)'}`)
|
|
215
|
+
lines.push(`- Bypass 自定义插件:${yesNo(options.bypassPlugins)}${options.bypassPlugins ? '(默认跳过目标环境的同步插件)' : ''}`)
|
|
216
|
+
lines.push('')
|
|
217
|
+
|
|
218
|
+
if (permissions && !permissions.targetWrite) {
|
|
219
|
+
lines.push(`执行前请先在设置页「权限」里给 ${plan.target.key} 勾上「新增/修改业务数据」,否则 execute 会被拦下。`)
|
|
220
|
+
} else {
|
|
221
|
+
lines.push('确认无误后告诉我执行,我会弹确认卡片(写明写入条数、字段数、lookup 命中、是否 Bypass)。')
|
|
222
|
+
}
|
|
223
|
+
lines.push('这一步没有写任何东西。')
|
|
224
|
+
return lines.join('\n')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function renderConfirmation(plan, options) {
|
|
228
|
+
const created = plan.counts.create - (options.onMissingLookup === 'report' ? plan.counts.blocked : 0)
|
|
229
|
+
const overwritten = options.conflict === 'overwrite' ? plan.counts.conflict : 0
|
|
230
|
+
const lines = [`${plan.source.key} → ${plan.target.key}(${plan.target.entity})`]
|
|
231
|
+
lines.push(`范围:${plan.scope.description}`)
|
|
232
|
+
lines.push(`将写入 ${created + overwritten} 条:新建 ${created} 条${overwritten ? `,覆盖 ${overwritten} 条` : ''}`)
|
|
233
|
+
if (options.conflict !== 'overwrite' && plan.counts.conflict) {
|
|
234
|
+
lines.push(`另有 ${plan.counts.conflict} 条目标已存在同 GUID,会跳过(要覆盖请改用 conflict="overwrite" 重新执行)。`)
|
|
235
|
+
}
|
|
236
|
+
if (options.onMissingLookup === 'report' && plan.counts.blocked) {
|
|
237
|
+
lines.push(`${plan.counts.blocked} 条因引用缺失会跳过。`)
|
|
238
|
+
}
|
|
239
|
+
lines.push(`字段 ${plan.fields.length} 个;lookup 命中:GUID ${plan.lookupSummary.reduce((sum, item) => sum + item.sameGuid, 0)},按名称 ${plan.lookupSummary.reduce((sum, item) => sum + item.byName, 0)}。`)
|
|
240
|
+
lines.push(`Bypass 自定义插件:${yesNo(options.bypassPlugins)}`)
|
|
241
|
+
lines.push(`保持 GUID:${yesNo(options.keepGuid)};保留创建时间:${yesNo(options.preserveCreatedOn)};抑制重复检测:${yesNo(options.suppressDuplicateDetection)}`)
|
|
242
|
+
lines.push('', '确认后立即写入目标环境;业务数据写入没有待确认计划,也不会自动回滚。')
|
|
243
|
+
return lines.join('\n')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderRun(plan, run) {
|
|
247
|
+
const lines = [`已执行迁移计划 ${plan.id}(${run.runId})`]
|
|
248
|
+
lines.push(`写入 ${run.counts.written} 条(新建 ${run.created.length} / 覆盖 ${run.updated.length}),跳过 ${run.counts.skipped} 条,失败 ${run.counts.failed} 条。`)
|
|
249
|
+
if (run.skippedRecords.length) {
|
|
250
|
+
lines.push('')
|
|
251
|
+
lines.push(`跳过的记录(前 5 条):${run.skippedRecords.slice(0, 5).map((record) => `${record.id} — ${record.reason}`).join(';')}`)
|
|
252
|
+
}
|
|
253
|
+
if (run.failures.length) {
|
|
254
|
+
lines.push('')
|
|
255
|
+
lines.push('失败:')
|
|
256
|
+
for (const failure of run.failures.slice(0, 10)) lines.push(`- ${failure.id}:${failure.error}`)
|
|
257
|
+
if (run.failures.length > 10) lines.push(`- … 其余 ${run.failures.length - 10} 条见执行记录`)
|
|
258
|
+
}
|
|
259
|
+
lines.push('', `执行记录:${run.file}`)
|
|
260
|
+
return lines.join('\n')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function checkEnvironments(config, args) {
|
|
264
|
+
const sourceKey = String(args.source ?? '').trim()
|
|
265
|
+
const targetKey = String(args.target ?? '').trim()
|
|
266
|
+
if (!sourceKey || !targetKey) {
|
|
267
|
+
throw new D365Error('需要 source 和 target', {
|
|
268
|
+
code: 'missing-environment',
|
|
269
|
+
hint: `例如 source="dev" target="sit";可用:${Object.keys(config.environments).join(', ')}`,
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
if (sourceKey === targetKey) throw new D365Error('source 和 target 是同一个环境', { code: 'same-environment' })
|
|
273
|
+
if (!config.environments[sourceKey]) throw new D365Error(`源环境不存在:${sourceKey}`, { hint: `可用:${Object.keys(config.environments).join(', ')}` })
|
|
274
|
+
if (!config.environments[targetKey]) throw new D365Error(`目标环境不存在:${targetKey}`, { hint: `可用:${Object.keys(config.environments).join(', ')}` })
|
|
275
|
+
return { sourceKey, targetKey }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function renderUpdatePlan(plan) {
|
|
279
|
+
const lines = [`批量更新计划:${plan.id}(只读预演,尚未写入)`]
|
|
280
|
+
lines.push(`环境:${plan.environment}(${plan.entity})`)
|
|
281
|
+
lines.push(`记录:${plan.ids.length} 个 id,找到 ${plan.counts.existing} 条${plan.counts.missing ? `,**${plan.counts.missing} 条在这个环境里不存在**` : ''}`)
|
|
282
|
+
lines.push('')
|
|
283
|
+
lines.push(`要写入的字段(${plan.fields.length} 个):`)
|
|
284
|
+
for (const field of plan.fields) {
|
|
285
|
+
const value = plan.values[field.name]
|
|
286
|
+
lines.push(`- ${field.name}(${field.type}):${JSON.stringify(value)}`)
|
|
287
|
+
}
|
|
288
|
+
lines.push('')
|
|
289
|
+
if (plan.before.length) {
|
|
290
|
+
lines.push('改动预览(前 5 条):')
|
|
291
|
+
for (const row of plan.before.slice(0, 5)) {
|
|
292
|
+
const before = plan.fields.map((field) => `${field.name}=${JSON.stringify(row.current[field.name] ?? null)}`).join(',')
|
|
293
|
+
lines.push(`- ${row.name ?? row.id}:${before}`)
|
|
294
|
+
}
|
|
295
|
+
if (plan.before.length > 5) lines.push(`- … 其余 ${plan.before.length - 5} 条见完整计划`)
|
|
296
|
+
lines.push('')
|
|
297
|
+
}
|
|
298
|
+
if (plan.missing.length) {
|
|
299
|
+
lines.push(`不存在的 id(会被跳过):${plan.missing.slice(0, 5).join(',')}${plan.missing.length > 5 ? ' …' : ''}`)
|
|
300
|
+
lines.push('')
|
|
301
|
+
}
|
|
302
|
+
lines.push('写入选项:')
|
|
303
|
+
lines.push(`- Bypass 自定义插件:${yesNo(plan.options.bypassPlugins)}`)
|
|
304
|
+
lines.push(`- 抑制重复检测:${yesNo(plan.options.suppressDuplicateDetection)}`)
|
|
305
|
+
lines.push(`- 并发:${plan.options.concurrency}`)
|
|
306
|
+
lines.push('')
|
|
307
|
+
lines.push('请确认这三项(下面是本次预演用的值;要改任何一项,重新 plan-update):')
|
|
308
|
+
lines.push(`- 记录范围:${plan.ids.length} 个显式 id(不接受 filter —— 改错一批没有撤销键)`)
|
|
309
|
+
lines.push(`- 更新字段:${plan.fields.map((field) => field.name).join('、')}`)
|
|
310
|
+
lines.push(`- Bypass 自定义插件:${yesNo(plan.options.bypassPlugins)}`)
|
|
311
|
+
lines.push('')
|
|
312
|
+
lines.push('确认无误后告诉我执行,我会弹确认卡片(写明条数、字段、是否 Bypass)。')
|
|
313
|
+
lines.push('这一步没有写任何东西。')
|
|
314
|
+
return lines.join('\n')
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function renderUpdateConfirmation(plan) {
|
|
318
|
+
const lines = [`${plan.environment}(${plan.entity})`]
|
|
319
|
+
lines.push(`将更新 ${plan.before.length} 条记录`)
|
|
320
|
+
lines.push(`字段:${plan.fields.map((field) => `${field.name}=${JSON.stringify(plan.values[field.name])}`).join(',')}`)
|
|
321
|
+
if (plan.missing.length) lines.push(`${plan.missing.length} 个 id 不存在,会被跳过。`)
|
|
322
|
+
lines.push(`Bypass 自定义插件:${yesNo(plan.options.bypassPlugins)};抑制重复检测:${yesNo(plan.options.suppressDuplicateDetection)}`)
|
|
323
|
+
lines.push('', '确认后立即写入;业务数据写入没有撤销键,也不会自动回滚。')
|
|
324
|
+
return lines.join('\n')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function renderUpdateRun(plan, run) {
|
|
328
|
+
const lines = [`已执行批量更新 ${plan.id}(${run.runId})`]
|
|
329
|
+
lines.push(`更新 ${run.counts.updated} 条,失败 ${run.counts.failed} 条,跳过 ${run.counts.missing} 条(id 不存在)。`)
|
|
330
|
+
if (run.failures.length) {
|
|
331
|
+
lines.push('')
|
|
332
|
+
for (const failure of run.failures.slice(0, 10)) lines.push(`- ${failure.id}:${failure.error}`)
|
|
333
|
+
if (run.failures.length > 10) lines.push(`- … 其余 ${run.failures.length - 10} 条见执行记录`)
|
|
334
|
+
}
|
|
335
|
+
lines.push('', `执行记录:${run.file}`)
|
|
336
|
+
return lines.join('\n')
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function executeUpdatePlan(ctx, config, plan, args, exec) {
|
|
340
|
+
const userQuestions = ctx.userQuestions ?? ctx.get('userQuestions')
|
|
341
|
+
const policy = await readPolicy(config.configFile)
|
|
342
|
+
if (!allows(policy, plan.environment, 'data.write')) {
|
|
343
|
+
throw new D365Error(`环境 ${plan.environment} 未授权新增或修改业务数据`, {
|
|
344
|
+
code: 'data-write-forbidden',
|
|
345
|
+
hint: `在设置页「权限」里给 ${plan.environment} 勾上「新增/修改业务数据」后再试。`,
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
if (!userQuestions?.ask) {
|
|
349
|
+
throw new D365Error('当前会话没有可用的确认界面', { hint: '批量更新必须在对话里确认。' })
|
|
350
|
+
}
|
|
351
|
+
if (!exec?.agent) throw new D365Error('execute 需要可交互的会话', { hint: '请在主对话里执行。' })
|
|
352
|
+
|
|
353
|
+
const effective = {
|
|
354
|
+
bypassPlugins: typeof args.bypassPlugins === 'boolean' ? args.bypassPlugins : plan.options.bypassPlugins,
|
|
355
|
+
suppressDuplicateDetection: typeof args.suppressDuplicateDetection === 'boolean'
|
|
356
|
+
? args.suppressDuplicateDetection
|
|
357
|
+
: plan.options.suppressDuplicateDetection,
|
|
358
|
+
concurrency: Number.isFinite(args.concurrency)
|
|
359
|
+
? Math.max(1, Math.min(8, Number(args.concurrency)))
|
|
360
|
+
: plan.options.concurrency,
|
|
361
|
+
}
|
|
362
|
+
const answer = await userQuestions.ask({
|
|
363
|
+
questions: [{
|
|
364
|
+
id: CONFIRM_ID,
|
|
365
|
+
header: `确认批量更新 ${plan.environment}`,
|
|
366
|
+
question: `要更新 ${plan.environment} 里这 ${plan.before.length} 条 ${plan.entity} 记录吗?`,
|
|
367
|
+
detail: renderUpdateConfirmation(plan),
|
|
368
|
+
options: [
|
|
369
|
+
{ label: APPROVE, description: '立即写入' },
|
|
370
|
+
{ label: CANCEL, description: '不执行,计划继续保留' },
|
|
371
|
+
],
|
|
372
|
+
}],
|
|
373
|
+
agent: exec.agent,
|
|
374
|
+
...(exec.signal ? { signal: exec.signal } : {}),
|
|
375
|
+
wait: { callId: exec.callId },
|
|
376
|
+
})
|
|
377
|
+
const chosen = answer?.answers?.find((item) => item.id === CONFIRM_ID)?.selected?.[0]
|
|
378
|
+
if (chosen !== APPROVE) return { text: `未执行。计划 ${plan.id} 仍在待执行列表里。` }
|
|
379
|
+
|
|
380
|
+
await setMigrationPlanStatus(config, plan.id, 'running')
|
|
381
|
+
try {
|
|
382
|
+
const run = await executeUpdate(config, plan, effective, exec.signal)
|
|
383
|
+
await setMigrationPlanStatus(config, plan.id, 'done', { runId: run.runId, executedAt: run.at })
|
|
384
|
+
return { text: renderUpdateRun(plan, run) }
|
|
385
|
+
} catch (error) {
|
|
386
|
+
await setMigrationPlanStatus(config, plan.id, 'failed', { error: String(error?.message ?? error) })
|
|
387
|
+
throw error
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function register(ctx, state) {
|
|
392
|
+
return ctx.tools.register({
|
|
393
|
+
...definition,
|
|
394
|
+
async execute(args = {}, exec) {
|
|
395
|
+
const config = await loadConfig(state.rawConfig)
|
|
396
|
+
const action = args.action ?? 'plan'
|
|
397
|
+
const policy = await readPolicy(config.configFile)
|
|
398
|
+
|
|
399
|
+
if (action === 'plan-update') {
|
|
400
|
+
const environment = String(args.environment ?? '').trim()
|
|
401
|
+
if (!environment) {
|
|
402
|
+
throw new D365Error('plan-update 需要 environment', {
|
|
403
|
+
code: 'missing-environment',
|
|
404
|
+
hint: '批量更新只改一个环境;跨环境复制记录用 action=plan。',
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
if (!allows(policy, environment, 'data.read')) {
|
|
408
|
+
throw new D365Error(`环境 ${environment} 未授权读取业务数据`, {
|
|
409
|
+
code: 'data-read-forbidden',
|
|
410
|
+
hint: `生成更新计划要先读出这些记录现在的值(预览 before → after),请在设置页「权限」里给 ${environment} 勾上「读取业务数据」。`,
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
const plan = await buildUpdatePlan(config, args, exec?.signal)
|
|
414
|
+
plan.rationale = args.rationale
|
|
415
|
+
await saveMigrationPlan(config, plan)
|
|
416
|
+
return { text: renderUpdatePlan(plan) }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (action === 'list') {
|
|
420
|
+
const plans = await listMigrationPlans(config)
|
|
421
|
+
if (!plans.length) return { text: '没有待执行的数据迁移计划。' }
|
|
422
|
+
const pending = plans.filter((plan) => (plan.status ?? 'pending') === 'pending')
|
|
423
|
+
const finished = plans.filter((plan) => (plan.status ?? 'pending') !== 'pending')
|
|
424
|
+
const summaryOf = (plan) => (plan.kind === 'data-update'
|
|
425
|
+
? `${plan.environment}(${plan.entity})批量更新 ${plan.counts.existing} 条`
|
|
426
|
+
: `${plan.source.key} → ${plan.target.key}(${plan.target.entity}),${plan.counts.create} 新建 / ${plan.counts.conflict} 冲突,范围 ${plan.scope.description}`)
|
|
427
|
+
const stateOf = (plan) => (plan.status === 'running' ? ' · 执行中断,先核对执行记录' : plan.status === 'failed' ? ' · 上次执行失败' : ' · 已执行')
|
|
428
|
+
const lines = []
|
|
429
|
+
if (pending.length) {
|
|
430
|
+
lines.push(`待执行的数据迁移:${pending.length} 个计划`, '')
|
|
431
|
+
for (const plan of pending) lines.push(`- ${plan.id}:${summaryOf(plan)}`)
|
|
432
|
+
lines.push('', '先 action=show 看细节,确认后 action=execute。')
|
|
433
|
+
} else {
|
|
434
|
+
lines.push('没有待执行的数据迁移计划。')
|
|
435
|
+
}
|
|
436
|
+
if (finished.length) {
|
|
437
|
+
lines.push('', `已结束的:${finished.length} 个(仍可用 action=show 回看)`)
|
|
438
|
+
for (const plan of finished.slice(0, 10)) lines.push(`- ${plan.id}:${summaryOf(plan)}${stateOf(plan)}`)
|
|
439
|
+
if (finished.length > 10) lines.push(`- … 其余 ${finished.length - 10} 个`)
|
|
440
|
+
}
|
|
441
|
+
return { text: lines.join('\n') }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (action === 'show') {
|
|
445
|
+
const id = String(args.planId ?? '').trim()
|
|
446
|
+
if (!id) throw new D365Error('show 需要 planId', { hint: '用 action=list 查看现有计划' })
|
|
447
|
+
const plan = await readMigrationPlan(config, id)
|
|
448
|
+
if (!plan) throw new D365Error(`没有找到计划 ${id}`, { code: 'plan-not-found', hint: '用 action=list 查看现有计划' })
|
|
449
|
+
return { text: plan.kind === 'data-update' ? renderUpdatePlan(plan) : renderPlan(plan) }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (action === 'cancel') {
|
|
453
|
+
const id = String(args.planId ?? '').trim()
|
|
454
|
+
if (!id) throw new D365Error('cancel 需要 planId')
|
|
455
|
+
const removed = await deleteMigrationPlan(config, id)
|
|
456
|
+
return { text: removed ? `已取消计划 ${id}。` : `没有找到计划 ${id}。` }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (action === 'execute') {
|
|
460
|
+
const id = String(args.planId ?? '').trim()
|
|
461
|
+
if (!id) throw new D365Error('execute 需要 planId', { hint: '先 action=plan 生成计划,再 action=execute' })
|
|
462
|
+
const plan = await readMigrationPlan(config, id)
|
|
463
|
+
if (!plan) throw new D365Error(`没有找到计划 ${id}`, { code: 'plan-not-found', hint: '用 action=list 查看现有计划' })
|
|
464
|
+
if ((plan.status ?? 'pending') !== 'pending') {
|
|
465
|
+
throw new D365Error(`计划 ${id} 不是待执行状态(${plan.status})`, {
|
|
466
|
+
code: 'plan-not-pending',
|
|
467
|
+
hint: '上次执行可能中断或已完成;先到 .d365/data-sync/runs 核对结果,需要重跑请重新 plan。',
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
if (plan.kind === 'data-update') {
|
|
471
|
+
return executeUpdatePlan(ctx, config, plan, args, exec)
|
|
472
|
+
}
|
|
473
|
+
const targetKey = plan.target.key
|
|
474
|
+
if (!allows(policy, targetKey, 'data.write')) {
|
|
475
|
+
throw new D365Error(`环境 ${targetKey} 未授权新增或修改业务数据`, {
|
|
476
|
+
code: 'data-write-forbidden',
|
|
477
|
+
hint: `在设置页「权限」里给 ${targetKey} 勾上「新增/修改业务数据」后再试。`,
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Execute-time options can narrow what the plan proposed (overwrite,
|
|
482
|
+
// bypass) but the card always states the final values. It must therefore
|
|
483
|
+
// start from the plan's full option set: the card prints keepGuid,
|
|
484
|
+
// preserveCreatedOn and suppressDuplicateDetection too, and an omitted
|
|
485
|
+
// field used to render as "no" while execution used the plan's "yes".
|
|
486
|
+
const effective = {
|
|
487
|
+
...plan.options,
|
|
488
|
+
conflict: ['report', 'skip', 'overwrite'].includes(args.conflict) ? args.conflict : plan.options.conflict,
|
|
489
|
+
onMissingLookup: ['report', 'null', 'fail'].includes(args.onMissingLookup) ? args.onMissingLookup : plan.options.onMissingLookup,
|
|
490
|
+
bypassPlugins: typeof args.bypassPlugins === 'boolean' ? args.bypassPlugins : plan.options.bypassPlugins,
|
|
491
|
+
concurrency: Number.isFinite(args.concurrency)
|
|
492
|
+
? Math.max(1, Math.min(8, Number(args.concurrency)))
|
|
493
|
+
: plan.options.concurrency,
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const userQuestions = ctx.get('userQuestions')
|
|
497
|
+
if (!userQuestions?.ask) {
|
|
498
|
+
throw new D365Error('当前会话没有可用的确认界面', { hint: '数据迁移必须在对话里确认,没有「存起来稍后执行」的路径。' })
|
|
499
|
+
}
|
|
500
|
+
if (!exec?.agent) throw new D365Error('execute 需要可交互的会话', { hint: '请在主对话里执行。' })
|
|
501
|
+
|
|
502
|
+
const answer = await userQuestions.ask({
|
|
503
|
+
questions: [{
|
|
504
|
+
id: CONFIRM_ID,
|
|
505
|
+
header: `确认迁移 ${plan.source.key} → ${plan.target.key}`,
|
|
506
|
+
question: `要执行这个计划、把记录写入 ${plan.target.key} 吗?`,
|
|
507
|
+
detail: renderConfirmation(plan, effective),
|
|
508
|
+
options: [
|
|
509
|
+
{ label: APPROVE, description: '立即写入目标环境' },
|
|
510
|
+
{ label: CANCEL, description: '不执行,计划继续保留' },
|
|
511
|
+
],
|
|
512
|
+
}],
|
|
513
|
+
agent: exec.agent,
|
|
514
|
+
...(exec.signal ? { signal: exec.signal } : {}),
|
|
515
|
+
wait: { callId: exec.callId },
|
|
516
|
+
})
|
|
517
|
+
const chosen = answer?.answers?.find((item) => item.id === CONFIRM_ID)?.selected?.[0]
|
|
518
|
+
if (chosen !== APPROVE) return { text: `未执行。计划 ${id} 仍在待执行列表里。` }
|
|
519
|
+
|
|
520
|
+
await setMigrationPlanStatus(config, id, 'running')
|
|
521
|
+
try {
|
|
522
|
+
const run = await executeMigration(config, plan, effective, exec.signal)
|
|
523
|
+
await setMigrationPlanStatus(config, id, 'done', { runId: run.runId, executedAt: run.at })
|
|
524
|
+
return { text: renderRun(plan, run) }
|
|
525
|
+
} catch (error) {
|
|
526
|
+
await setMigrationPlanStatus(config, id, 'failed', { error: String(error?.message ?? error) })
|
|
527
|
+
throw error
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// action === 'plan'
|
|
532
|
+
const { sourceKey, targetKey } = checkEnvironments(config, args)
|
|
533
|
+
for (const key of [sourceKey, targetKey]) {
|
|
534
|
+
if (!allows(policy, key, 'data.read')) {
|
|
535
|
+
throw new D365Error(`环境 ${key} 未授权读取业务数据`, {
|
|
536
|
+
code: 'data-read-forbidden',
|
|
537
|
+
hint: `生成计划要读源和目标的记录(含存在性检查),请在设置页「权限」里给 ${key} 勾上「读取业务数据」。`,
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// A missing `data.write` on the target is not fatal for a plan: the report
|
|
542
|
+
// says so, and `execute` refuses with the same hint.
|
|
543
|
+
|
|
544
|
+
const built = await buildPlan(config, { ...args, source: sourceKey, target: targetKey }, exec?.signal)
|
|
545
|
+
built.permissions = {
|
|
546
|
+
sourceRead: allows(policy, sourceKey, 'data.read'),
|
|
547
|
+
targetRead: allows(policy, targetKey, 'data.read'),
|
|
548
|
+
targetWrite: allows(policy, targetKey, 'data.write'),
|
|
549
|
+
}
|
|
550
|
+
const plan = await saveMigrationPlan(config, { ...built, rationale: args.rationale })
|
|
551
|
+
const artifact = await writePlanArtifact(config, plan)
|
|
552
|
+
const text = `${renderPlan(plan)}\n完整计划(含每条记录):${artifact}`
|
|
553
|
+
return { text }
|
|
554
|
+
},
|
|
555
|
+
presentCall(args = {}) {
|
|
556
|
+
return { card: 'generic', title: `D365 数据迁移:${args.action ?? 'plan'}`, kind: 'other', rawInput: args }
|
|
557
|
+
},
|
|
558
|
+
})
|
|
559
|
+
}
|