@mseep/bw-modeling-mcp 0.8.0

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.
@@ -0,0 +1,1392 @@
1
+ import { MEDIA_TYPES, createClientFromEnv } from '../bw-client.js';
2
+ import { parseInfoObjectProps } from './infoobject.js';
3
+ const TRFN_ACCEPT = MEDIA_TYPES['trfn'];
4
+ /**
5
+ * bw_create_transformation — create a new Transformation (inactive).
6
+ *
7
+ * Flow:
8
+ * 1. GET 8TRANSIENT → server generates the Transformation name
9
+ * 2. Lock (CREA) → lockHandle
10
+ * 3. POST minimal XML (manually constructed, per payloads/trfn_create.md)
11
+ *
12
+ * Returns the generated Transformation name for use with bw_activate.
13
+ */
14
+ export async function bwCreateTransformation(client, args) {
15
+ const srcType = args.source_object_type.toUpperCase();
16
+ const srcName = args.source_object_name.toUpperCase();
17
+ const tgtType = args.target_object_type.toUpperCase();
18
+ const tgtName = args.target_object_name.toUpperCase();
19
+ const pkg = args.package ?? '$TMP';
20
+ // For RSDS sources: encode sourceobjectname as datasourceName.padEnd(30) + sourceSystem.padEnd(10)
21
+ // with spaces URL-encoded as '+' for the 8TRANSIENT query parameter.
22
+ const srcNameForUrl = srcType === 'RSDS'
23
+ ? encodeURIComponent(srcName.padEnd(30) + (args.source_system ?? '').toUpperCase().padEnd(10)).replace(/%20/g, '+')
24
+ : srcName;
25
+ // Step 1: GET 8TRANSIENT → generated Transformation name
26
+ const transientPath = `/sap/bw/modeling/trfn/8transient?GetIdOnly=true` +
27
+ `&sourceobjecttype=${srcType}` +
28
+ `&targetobjecttype=${tgtType}` +
29
+ `&sourceobjectname=${srcNameForUrl}` +
30
+ `&targetobjectname=${tgtName}`;
31
+ const { body: transientXml } = await client.get(transientPath, TRFN_ACCEPT);
32
+ const nameMatch = transientXml.match(/\bname="([^"]+)"/);
33
+ if (!nameMatch) {
34
+ throw new Error(`Could not extract Transformation name from 8TRANSIENT response:\n${transientXml}`);
35
+ }
36
+ const trfnName = nameMatch[1].toUpperCase();
37
+ const trfnLower = trfnName.toLowerCase();
38
+ const language = process.env.BW_LANGUAGE ?? 'DE';
39
+ const masterSystem = new URL(process.env.BW_URL ?? 'http://localhost').hostname.split('.')[0].toUpperCase();
40
+ const responsible = (process.env.BW_USER ?? '').toUpperCase();
41
+ // Step 2: Lock with CREA — exact Eclipse header set, no SAP session headers
42
+ const csrfToken = await client.getCsrfToken();
43
+ const lockPath = `/sap/bw/modeling/trfn/${trfnLower}?action=lock`;
44
+ const lockResponse = await client.rawPost(lockPath, '', {
45
+ 'activity_context': 'CREA',
46
+ 'Accept': TRFN_ACCEPT,
47
+ 'x-csrf-token': csrfToken,
48
+ });
49
+ const lockHandleMatch = lockResponse.body.match(/<LOCK_HANDLE>([^<]+)<\/LOCK_HANDLE>/);
50
+ if (!lockHandleMatch) {
51
+ throw new Error(`No <LOCK_HANDLE> in lock response:\n${lockResponse.body}`);
52
+ }
53
+ const lockHandle = lockHandleMatch[1];
54
+ // Step 3: POST minimal XML (manually constructed — see payloads/trfn_create.md)
55
+ const postBody = `<?xml version="1.0" encoding="UTF-8"?>
56
+ <trfn:transformation
57
+ xmlns:adtcore="http://www.sap.com/adt/core"
58
+ xmlns:atom="http://www.w3.org/2005/Atom"
59
+ xmlns:trfn="http://www.sap.com/bw/modeling/Trfn.ecore"
60
+ description=""
61
+ endRoutine=""
62
+ expertRoutine=""
63
+ name="${trfnName}"
64
+ startRoutine="">
65
+ <tlogoProperties
66
+ adtcore:language="${language}"
67
+ adtcore:name="${trfnName}"
68
+ adtcore:type="TRFN"
69
+ adtcore:version="inactive"
70
+ adtcore:masterLanguage="${language}"
71
+ adtcore:masterSystem="${masterSystem}"
72
+ adtcore:responsible="${responsible}">
73
+ <atom:link
74
+ href="/sap/bw/modeling/trfn/${trfnLower}/m"
75
+ rel="self"
76
+ type="application/vnd.sap-bw-modeling.trfn+xml"/>
77
+ <objectVersion>M</objectVersion>
78
+ <objectStatus>inactive</objectStatus>
79
+ <contentState>NEW</contentState>
80
+ </tlogoProperties>
81
+ <source description="" id="0" name="${srcType === 'RSDS' ? srcName.padEnd(30) + (args.source_system ?? '').toUpperCase().padEnd(10) : srcName}" type="${srcType}"/>
82
+ <target description="" id="0" name="${tgtName}" type="${tgtType}"/>
83
+ </trfn:transformation>`;
84
+ const copyParams = args.copy_from_transformation
85
+ ? `&copyFromObjectName=${args.copy_from_transformation.toUpperCase()}&copyFromObjectType=TRFN`
86
+ : '';
87
+ const createPath = `/sap/bw/modeling/trfn/${trfnLower}?lockHandle=${lockHandle}${copyParams}`;
88
+ // Session B: eigene Session + CSRF-Token, POST mit lockHandle aus Session A
89
+ const client2 = createClientFromEnv();
90
+ await client2.getCsrfToken();
91
+ await client2.postWithCsrf(createPath, postBody, TRFN_ACCEPT, { 'Development-Class': pkg }, true);
92
+ // Step 4: Verify persisted
93
+ try {
94
+ await client.get(`/sap/bw/modeling/trfn/${trfnLower}/m`, TRFN_ACCEPT);
95
+ }
96
+ catch {
97
+ throw new Error(`Transformation '${trfnName}' was not persisted after creation ` +
98
+ `(GET /sap/bw/modeling/trfn/${trfnLower}/m returned 404).`);
99
+ }
100
+ // Step 5: Unlock (CREA lock is no longer needed after successful creation)
101
+ try {
102
+ await client.unlock('trfn', trfnLower);
103
+ }
104
+ catch (unlockErr) {
105
+ process.stderr.write(`Warning: failed to unlock trfn/${trfnLower} after creation: ${unlockErr}\n`);
106
+ }
107
+ return JSON.stringify({
108
+ success: true,
109
+ transformation_name: trfnName,
110
+ source: { type: srcType, name: srcName },
111
+ target: { type: tgtType, name: tgtName },
112
+ package: pkg,
113
+ message: `Transformation '${trfnName}' created inactive. Call bw_activate with object_type "trfn" to activate.`,
114
+ });
115
+ }
116
+ /**
117
+ * bw_get_transformation — read a Transformation (inactive version).
118
+ * Returns raw XML + status + timestamp.
119
+ * Note: Transformation name is a UUID-like generated key, not human-readable.
120
+ */
121
+ export async function bwGetTransformation(client, transformationName, format = 'text') {
122
+ const path = `/sap/bw/modeling/trfn/${transformationName.toLowerCase()}/m`;
123
+ const result = await client.get(path, TRFN_ACCEPT);
124
+ const status = result.headers['object_status'] ?? result.headers['OBJECT_STATUS'] ?? 'unknown';
125
+ const ts = result.headers['timestamp'] ?? '';
126
+ const xml = result.body;
127
+ if (format === 'raw')
128
+ return xml;
129
+ return summarizeTransformation(transformationName.toUpperCase(), status, ts, xml);
130
+ }
131
+ /**
132
+ * Parse the transformation XML and return a compact human-readable summary.
133
+ * Extracts: source/target, routine info, and per-field mapping rules.
134
+ */
135
+ function summarizeTransformation(name, status, timestamp, xml) {
136
+ const lines = [];
137
+ lines.push(`Transformation: ${name}`);
138
+ lines.push(`Status: ${status}`);
139
+ lines.push(`Timestamp: ${timestamp}`);
140
+ // ── Header attributes ─────────────────────────────────────────────────────
141
+ const attr = (key) => xml.match(new RegExp(`\\b${key}="([^"]*)"`))?.[1] ?? '';
142
+ const description = attr('description');
143
+ const startRoutine = attr('startRoutine');
144
+ const endRoutine = attr('endRoutine');
145
+ const expertRoutine = attr('expertRoutine');
146
+ const abapProgram = attr('abapProgram');
147
+ const hanaExec = attr('sapHANAExecutionPossible');
148
+ if (description)
149
+ lines.push(`Description: ${description}`);
150
+ if (abapProgram)
151
+ lines.push(`ABAP Program: ${abapProgram}`);
152
+ if (hanaExec)
153
+ lines.push(`HANA Execution: ${hanaExec}`);
154
+ // ── Source / Target ───────────────────────────────────────────────────────
155
+ const srcMatch = xml.match(/<source\b[^>]*id="0"[^>]*name="([^"]+)"[^>]*(?:description="([^"]*)")?[^>]*type="([^"]+)"/);
156
+ const tgtMatch = xml.match(/<target\b[^>]*id="0"[^>]*name="([^"]+)"[^>]*(?:description="([^"]*)")?[^>]*type="([^"]+)"/);
157
+ if (srcMatch)
158
+ lines.push(`Source: ${srcMatch[3]} ${srcMatch[1]}${srcMatch[2] ? ' (' + srcMatch[2] + ')' : ''}`);
159
+ if (tgtMatch)
160
+ lines.push(`Target: ${tgtMatch[3]} ${tgtMatch[1]}${tgtMatch[2] ? ' (' + tgtMatch[2] + ')' : ''}`);
161
+ // ── Routines ──────────────────────────────────────────────────────────────
162
+ // Also scan rule groups for routinetype=START/END/EXPERT (modern BW/4 style)
163
+ const ruleMatches = [...xml.matchAll(/<rule\b([^>]*)>([\s\S]*?)<\/rule>/g)];
164
+ function findGroupRoutine(type) {
165
+ for (const rm of ruleMatches) {
166
+ const rt = rm[1].match(/routinetype="([^"]*)"/)?.[1] ?? '';
167
+ if (rt.toUpperCase() !== type)
168
+ continue;
169
+ const sAttrs = rm[2].match(/<step\b([^>]*)/)?.[1] ?? '';
170
+ const cls = sAttrs.match(/classNameM="([^"]*)"/)?.[1] ?? '';
171
+ const mth = sAttrs.match(/methodNameM="([^"]*)"/)?.[1] ?? '';
172
+ if (cls)
173
+ return `${cls}.${mth}`;
174
+ }
175
+ return '';
176
+ }
177
+ const startRef = startRoutine || findGroupRoutine('START');
178
+ const endRef = endRoutine || findGroupRoutine('END');
179
+ const expertRef = expertRoutine || findGroupRoutine('EXPERT');
180
+ lines.push('');
181
+ lines.push('── Routines ──');
182
+ lines.push(` startRoutine: ${startRef || '(none)'}`);
183
+ lines.push(` endRoutine: ${endRef || '(none)'}`);
184
+ lines.push(` expertRoutine: ${expertRef || '(none)'}`);
185
+ if (startRef || endRef || expertRef) {
186
+ lines.push(` NOTE: to read routine code, parse "ClassName.MethodName" from the path above`);
187
+ lines.push(` and call GetSource(object_type="CLAS", name=ClassName, method=MethodName).`);
188
+ lines.push(` Never read the ABAP Program listed in the header — it contains the full`);
189
+ lines.push(` generated class (~5000 lines) and will exceed context limits.`);
190
+ }
191
+ if (ruleMatches.length > 0) {
192
+ lines.push('');
193
+ lines.push('── Field Mappings ──');
194
+ for (const rm of ruleMatches) {
195
+ const ruleAttrs = rm[1];
196
+ const ruleBody = rm[2];
197
+ const routinetype = ruleAttrs.match(/routinetype="([^"]*)"/)?.[1] ?? '';
198
+ // Step attributes — use [^>]* so slashes in classNameM paths don't break capture
199
+ const stepMatch = ruleBody.match(/<step\b([^>]*)/);
200
+ const stepAttrs = stepMatch?.[1] ?? '';
201
+ const xsiType = stepAttrs.match(/xsi:type="trfn:Step([^"]+)"/)?.[1] ?? '';
202
+ const stepType = (stepAttrs.match(/\btype="([^"]*)"/)?.[1] ?? routinetype) || xsiType;
203
+ const classNameM = stepAttrs.match(/classNameM="([^"]*)"/)?.[1] ?? '';
204
+ const methodNameM = stepAttrs.match(/methodNameM="([^"]*)"/)?.[1] ?? '';
205
+ const constant = stepAttrs.match(/\bconstant="([^"]*)"/)?.[1] ?? '';
206
+ // Extract source fields from <elementRef>#///source/segment1/FIELD
207
+ const srcFields = [...ruleBody.matchAll(/elementRef>#\/\/\/source\/[^/]+\/([^<]+)<\/elementRef>/g)]
208
+ .map(m => m[1]);
209
+ // Extract target fields from <elementRef>#///target/segment1/FIELD
210
+ const tgtFields = [...ruleBody.matchAll(/elementRef>#\/\/\/target\/[^/]+\/([^<]+)<\/elementRef>/g)]
211
+ .map(m => m[1]);
212
+ const src = srcFields.length > 0 ? srcFields.join(', ') : '(none)';
213
+ const tgt = tgtFields.length > 0 ? tgtFields.join(', ') : '(none)';
214
+ let label = stepType || xsiType || '?';
215
+ // Show routinetype when it adds info not already in the label
216
+ if (routinetype && !label.toUpperCase().includes(routinetype.toUpperCase())) {
217
+ label += ` [${routinetype}]`;
218
+ }
219
+ if (classNameM)
220
+ label += ` | ${classNameM}.${methodNameM}`;
221
+ if (constant)
222
+ label += ` = "${constant}"`;
223
+ // Extract filter conditions on this rule
224
+ const filterParts = [];
225
+ for (const fm of ruleBody.matchAll(/<filter\b([^>]*?)(?:\/>|>)/g)) {
226
+ const fa = fm[1];
227
+ const sign = fa.match(/\bsign="([^"]+)"/)?.[1] ?? '';
228
+ const opt = fa.match(/\boption="([^"]+)"/)?.[1] ?? '';
229
+ const low = fa.match(/\blow="([^"]+)"/)?.[1] ?? '';
230
+ const high = fa.match(/\bhigh="([^"]+)"/)?.[1] ?? '';
231
+ const part = high ? `${sign}${opt}[${low},${high}]` : `${sign}${opt}${low}`;
232
+ if (part.trim())
233
+ filterParts.push(part);
234
+ }
235
+ const filterSuffix = filterParts.length > 0 ? ` {FILTER: ${filterParts.join('; ')}}` : '';
236
+ lines.push(` [${label}] ${src} → ${tgt}${filterSuffix}`);
237
+ // Show formula code inline (StepFormula)
238
+ // Formula can be an attribute on <step formula="..."> or a child <formula> element
239
+ const formulaCode = stepAttrs.match(/\bformula="([^"]*)"/)?.[1]?.trim()
240
+ ?? ruleBody.match(/<formula\b[^>]*>([\s\S]*?)<\/formula>/)?.[1]?.trim()
241
+ ?? ruleBody.match(/<code\b[^>]*>([\s\S]*?)<\/code>/)?.[1]?.trim()
242
+ ?? '';
243
+ if (formulaCode) {
244
+ for (const codeLine of formulaCode.split('\n')) {
245
+ lines.push(` ${codeLine}`);
246
+ }
247
+ }
248
+ // For StepRoutine: show class/method prominently if not already in label
249
+ if (!classNameM && (xsiType === 'Routine' || routinetype === 'ROUTINE')) {
250
+ const routineRef = ruleBody.match(/routineName="([^"]+)"/)?.[1]
251
+ ?? ruleBody.match(/className="([^"]+)"/)?.[1]
252
+ ?? '';
253
+ if (routineRef)
254
+ lines.push(` → Routine: ${routineRef}`);
255
+ }
256
+ }
257
+ }
258
+ // ── Source fields ─────────────────────────────────────────────────────────
259
+ // Drill into the first <segment> inside <source> to get all available source fields
260
+ const srcSegContent = xml.match(/<source\b[^>]*>[\s\S]*?<segment\b[^>]*>([\s\S]*?)<\/segment>/)?.[1] ?? '';
261
+ if (srcSegContent) {
262
+ const srcElems = [...srcSegContent.matchAll(/<element\b([^>]*?)(?:\/>|>([\s\S]*?)<\/element>)/g)];
263
+ if (srcElems.length > 0) {
264
+ lines.push('');
265
+ lines.push(`── Source Fields (${srcElems.length}) ──`);
266
+ const keys = [];
267
+ const vals = [];
268
+ for (const m of srcElems) {
269
+ const attrs = m[1];
270
+ const body = m[2] ?? '';
271
+ const name = attrs.match(/\bname="([^"]+)"/)?.[1] ?? '';
272
+ const isKey = attrs.match(/\bkey="([^"]+)"/)?.[1] === 'true';
273
+ const dt = body.match(/<inlineType\b[^>]*name="([^"]+)"/)?.[1] ?? '';
274
+ const entry = dt ? `${name}(${dt})` : name;
275
+ if (isKey)
276
+ keys.push(entry);
277
+ else
278
+ vals.push(entry);
279
+ }
280
+ if (keys.length)
281
+ lines.push(` Key fields (${keys.length}): ${keys.join(', ')}`);
282
+ if (vals.length)
283
+ lines.push(` Value fields (${vals.length}): ${vals.join(', ')}`);
284
+ }
285
+ }
286
+ // ── Target fields ─────────────────────────────────────────────────────────
287
+ const tgtSegContent = xml.match(/<target\b[^>]*>[\s\S]*?<segment\b[^>]*>([\s\S]*?)<\/segment>/)?.[1] ?? '';
288
+ if (tgtSegContent) {
289
+ const tgtElems = [...tgtSegContent.matchAll(/<element\b([^>]*?)(?:\/>|>([\s\S]*?)<\/element>)/g)];
290
+ if (tgtElems.length > 0) {
291
+ lines.push('');
292
+ lines.push(`── Target Fields (${tgtElems.length}) ──`);
293
+ const keys = [];
294
+ const vals = [];
295
+ for (const m of tgtElems) {
296
+ const attrs = m[1];
297
+ const body = m[2] ?? '';
298
+ const name = attrs.match(/\bname="([^"]+)"/)?.[1] ?? '';
299
+ const isKey = attrs.match(/\bkey="([^"]+)"/)?.[1] === 'true';
300
+ const dt = body.match(/<inlineType\b[^>]*name="([^"]+)"/)?.[1] ?? '';
301
+ const conv = attrs.match(/\bconversionRoutine="([^"]+)"/)?.[1] ?? '';
302
+ const entry = [name, dt ? `(${dt})` : '', conv ? `[${conv}]` : ''].filter(Boolean).join('');
303
+ if (isKey)
304
+ keys.push(entry);
305
+ else
306
+ vals.push(entry);
307
+ }
308
+ if (keys.length)
309
+ lines.push(` Key fields (${keys.length}): ${keys.join(', ')}`);
310
+ if (vals.length)
311
+ lines.push(` Value fields (${vals.length}): ${vals.join(', ')}`);
312
+ }
313
+ }
314
+ return lines.join('\n');
315
+ }
316
+ // ── XML helpers ──────────────────────────────────────────────────────────────
317
+ /**
318
+ * Extract source field properties from the transformation's <source><segment> section.
319
+ * Returns the full element XML block (to be reused verbatim in formula input elements).
320
+ */
321
+ function extractSourceFieldProps(xml, fieldName) {
322
+ const srcSegMatch = xml.match(/<source\b[^>]*>[\s\S]*?<segment[^>]*>([\s\S]*?)<\/segment>/);
323
+ if (!srcSegMatch)
324
+ return { dataType: 'CHAR', length: '20', elementXml: '' };
325
+ const segContent = srcSegMatch[1];
326
+ const elemRegex = new RegExp(`<element\\b[^>]*name="${fieldName.toUpperCase()}"[^>]*>([\\s\\S]*?)<\\/element>`);
327
+ const elemMatch = segContent.match(elemRegex);
328
+ if (!elemMatch)
329
+ return { dataType: 'CHAR', length: '20', elementXml: '' };
330
+ const body = elemMatch[1];
331
+ // Try length first, fall back to precision (DEC fields use precision+scale, no length)
332
+ const inlineMatch = body.match(/<inlineType[^>]*name="([^"]+)"[^>]*(?:length="([^"]+)"|precision="([^"]+)")/);
333
+ return {
334
+ dataType: inlineMatch?.[1] ?? 'CHAR',
335
+ length: inlineMatch?.[2] ?? inlineMatch?.[3] ?? '20',
336
+ elementXml: elemMatch[0],
337
+ };
338
+ }
339
+ /**
340
+ * Extract target InfoObject properties from the transformation's <target><segment> section.
341
+ */
342
+ function extractTargetElemProps(xml, iObjName) {
343
+ const tgtSegMatch = xml.match(/<target\b[^>]*>[\s\S]*?<segment[^>]*>([\s\S]*?)<\/segment>/);
344
+ if (!tgtSegMatch)
345
+ return { convRoutine: '', dataType: 'CHAR', length: '20' };
346
+ const segContent = tgtSegMatch[1];
347
+ const elemRegex = new RegExp(`<element\\b([^>]*infoObjectName="${iObjName.toUpperCase()}"[^>]*)>([\\s\\S]*?)<\\/element>`);
348
+ const elemMatch = segContent.match(elemRegex);
349
+ if (!elemMatch)
350
+ return { convRoutine: '', dataType: 'CHAR', length: '20' };
351
+ const attrStr = elemMatch[1];
352
+ const bodyStr = elemMatch[2];
353
+ const convMatch = attrStr.match(/conversionRoutine="([^"]+)"/);
354
+ const inlineMatch = bodyStr.match(/<inlineType[^>]*name="([^"]+)"[^>]*length="([^"]+)"/);
355
+ return {
356
+ convRoutine: convMatch?.[1] ?? '',
357
+ dataType: inlineMatch?.[1] ?? 'CHAR',
358
+ length: inlineMatch?.[2] ?? '20',
359
+ };
360
+ }
361
+ /**
362
+ * Find the rule that targets the given InfoObject with a StepNoUpdate step,
363
+ * and return its id, its group id, and the full original rule XML to replace.
364
+ */
365
+ function findNoUpdateRule(xml, targetInfoObject) {
366
+ // Extract group element and its id
367
+ const groupMatch = xml.match(/<group\s+id="(\d+)"[^>]*>([\s\S]*?)<\/group>/);
368
+ if (!groupMatch)
369
+ return null;
370
+ const groupId = groupMatch[1];
371
+ const groupContent = groupMatch[0]; // full <group>...</group> including tags
372
+ const target = targetInfoObject.toUpperCase();
373
+ const ruleRegex = /<rule(\s[^>]*)>([\s\S]*?)<\/rule>/g;
374
+ let match;
375
+ while ((match = ruleRegex.exec(groupContent)) !== null) {
376
+ const attrStr = match[1];
377
+ const body = match[2];
378
+ const ruleIdMatch = attrStr.match(/id="(\d+)"/);
379
+ const targetsIObj = body.includes(`/target/segment1/${target}</elementRef>`);
380
+ const isNoUpdate = body.includes('StepNoUpdate') || body.includes('NO_UPDATE') ||
381
+ body.includes('StepInitial') || body.includes('type="INITIAL"');
382
+ if (targetsIObj && isNoUpdate) {
383
+ return {
384
+ ruleId: ruleIdMatch?.[1] ?? '',
385
+ groupId,
386
+ oldRuleXml: match[0],
387
+ };
388
+ }
389
+ }
390
+ return null;
391
+ }
392
+ /**
393
+ * Find any rule that targets the given InfoObject (any known step type).
394
+ * Used by the routine/formula/direct conversion paths.
395
+ */
396
+ function findRuleForTarget(xml, targetInfoObject) {
397
+ const target = targetInfoObject.toUpperCase();
398
+ // Search ALL groups, not just the first. A transformation with a start/end routine has the
399
+ // global routine group (type="G") FIRST; its single rule (routinetype="START"/"END") references
400
+ // every target field and would otherwise shadow the field's own rule, which lives in the
401
+ // standard group (type="S"). Skip those global routine rules so the field's own rule is selected.
402
+ const groupRegex = /<group\s+id="(\d+)"[^>]*>([\s\S]*?)<\/group>/g;
403
+ let groupMatch;
404
+ while ((groupMatch = groupRegex.exec(xml)) !== null) {
405
+ const groupId = groupMatch[1];
406
+ const groupContent = groupMatch[0];
407
+ const ruleRegex = /<rule(\s[^>]*)>([\s\S]*?)<\/rule>/g;
408
+ let match;
409
+ while ((match = ruleRegex.exec(groupContent)) !== null) {
410
+ const attrStr = match[1];
411
+ const body = match[2];
412
+ // Skip global start/end routine rules — they reference all target fields.
413
+ if (/\broutinetype="(?:START|END)"/.test(attrStr))
414
+ continue;
415
+ const targetsIObj = body.includes(`/target/segment1/${target}</elementRef>`);
416
+ if (!targetsIObj)
417
+ continue;
418
+ const ruleIdMatch = attrStr.match(/id="(\d+)"/);
419
+ let stepType = null;
420
+ if (body.includes('StepNoUpdate') || body.includes('type="NO_UPDATE"'))
421
+ stepType = 'NO_UPDATE';
422
+ else if (body.includes('StepInitial') || body.includes('type="INITIAL"'))
423
+ stepType = 'INITIAL';
424
+ else if (body.includes('StepDirect') || body.includes('type="DIRECT"'))
425
+ stepType = 'DIRECT';
426
+ else if (body.includes('StepRoutine') || body.includes('type="ROUTINE"'))
427
+ stepType = 'ROUTINE';
428
+ else if (body.includes('StepFormula') || body.includes('type="FORMULA"'))
429
+ stepType = 'FORMULA';
430
+ else if (body.includes('StepConstant') || body.includes('type="CONSTANT"'))
431
+ stepType = 'CONSTANT';
432
+ else if (body.includes('StepRead') || body.includes('type="READ"'))
433
+ stepType = 'READ';
434
+ if (stepType) {
435
+ return {
436
+ ruleId: ruleIdMatch?.[1] ?? '',
437
+ groupId,
438
+ oldRuleXml: match[0],
439
+ stepType,
440
+ };
441
+ }
442
+ }
443
+ }
444
+ return null;
445
+ }
446
+ /** Escape a string for use in an XML attribute value. */
447
+ function escapeXmlAttr(s) {
448
+ return s
449
+ .replace(/&/g, '&amp;')
450
+ .replace(/</g, '&lt;')
451
+ .replace(/>/g, '&gt;')
452
+ .replace(/"/g, '&quot;');
453
+ }
454
+ /**
455
+ * Convert a StepDirect or StepInitial rule to StepRoutine via string replacements.
456
+ * Input/output element content stays identical — only step type, id, and references change.
457
+ */
458
+ function convertDirectOrInitialRuleToRoutine(ruleXml) {
459
+ let r = ruleXml;
460
+ // 1. Update step1 → step2 in all #/// references within the rule
461
+ r = r.replace(/\/step1\//g, '/step2/');
462
+ // 2. Add performConversionExit to <target id="1">
463
+ r = r.replace(/<target(\s+id="1")[^>]*>/, '<target$1 performConversionExit="NOT_SUPPORTED">');
464
+ // 3. Change xsi:type on the step element
465
+ r = r.replace('xsi:type="trfn:StepDirect"', 'xsi:type="trfn:StepRoutine"');
466
+ r = r.replace('xsi:type="trfn:StepInitial"', 'xsi:type="trfn:StepRoutine"');
467
+ // 4. Change id="1" → id="2" on the <step element only (requires leading space before id)
468
+ r = r.replace(/(<step\b[^>]*\s)id="1"/, '$1id="2"');
469
+ // 5. Change type="DIRECT"/"INITIAL" → type="ROUTINE" on the <step element
470
+ // Use \s before type= to avoid matching xsi:type=
471
+ r = r.replace(/(<step\b[^>]*\s)type="(?:DIRECT|INITIAL)"/, '$1type="ROUTINE"');
472
+ return r;
473
+ }
474
+ /**
475
+ * Convert a StepDirect or StepInitial rule to StepFormula via string replacements.
476
+ * Structurally identical to the routine conversion but sets StepFormula + formula attribute.
477
+ */
478
+ function convertDirectOrInitialRuleToFormula(ruleXml, formula) {
479
+ let r = ruleXml;
480
+ // 1. Update step1 → step2 in all #/// references within the rule
481
+ r = r.replace(/\/step1\//g, '/step2/');
482
+ // 2. Add performConversionExit to <target id="1">
483
+ r = r.replace(/<target(\s+id="1")[^>]*>/, '<target$1 performConversionExit="NOT_SUPPORTED">');
484
+ // 3. Change xsi:type on the step element
485
+ r = r.replace('xsi:type="trfn:StepDirect"', 'xsi:type="trfn:StepFormula"');
486
+ r = r.replace('xsi:type="trfn:StepInitial"', 'xsi:type="trfn:StepFormula"');
487
+ // 4. Change id="1" → id="2" on the <step element only
488
+ r = r.replace(/(<step\b[^>]*\s)id="1"/, '$1id="2"');
489
+ // 5. Change type="DIRECT"/"INITIAL" → type="FORMULA" and append formula attribute
490
+ r = r.replace(/(<step\b[^>]*\s)type="(?:DIRECT|INITIAL)"/, `$1type="FORMULA" formula="${escapeXmlAttr(formula)}"`);
491
+ return r;
492
+ }
493
+ /**
494
+ * Build a StepFormula rule from a StepNoUpdate rule.
495
+ * Supports multiple source fields for multi-field formula expressions.
496
+ * Reuses the full element XML from the source segment verbatim (adds xsi:type only).
497
+ */
498
+ function buildNoUpdateToFormulaRule(ruleXml, groupId, ruleId, targetInfoObject, sourceFields, formula) {
499
+ const stepMatch = ruleXml.match(/<step\b[^>]*>([\s\S]*?)<\/step>/);
500
+ if (!stepMatch)
501
+ throw new Error('Cannot parse step from StepNoUpdate rule');
502
+ const stepOutputBlock = stepMatch[1].trim();
503
+ const tgt = targetInfoObject.toUpperCase();
504
+ const g = groupId;
505
+ const rv = ruleId;
506
+ const sourceTags = sourceFields
507
+ .map((sf, i) => `<source id="${i + 1}">\n <input>#///group${g}/rule${rv}/step2/input${i + 1}</input>\n <elementRef>#///source/segment1/${sf.name}</elementRef>\n </source>`)
508
+ .join('\n ');
509
+ const inputTags = sourceFields
510
+ .map((sf, i) => {
511
+ // Clone element XML from source segment, inject xsi:type="trfn:TransformationElement"
512
+ const elemXml = sf.elementXml
513
+ ? sf.elementXml.replace(/^<element\b/, '<element xsi:type="trfn:TransformationElement"')
514
+ : `<element xsi:type="trfn:TransformationElement" name="${sf.name}">
515
+ <endUserTexts label="${sf.name}"/>
516
+ <inlineType name="${sf.dataType}" length="${sf.length}" semanticType="empty"/>
517
+ <localProperties xsi:type="BwCore:LocalCharacteristicProperties"/>
518
+ <associationType>1</associationType>
519
+ <associationValid>false</associationValid>
520
+ </element>`;
521
+ return `<input id="${i + 1}">\n <output>#///group${g}/rule${rv}/source${i + 1}</output>\n ${elemXml}\n </input>`;
522
+ })
523
+ .join('\n ');
524
+ return `<rule id="${rv}" description="">
525
+ ${sourceTags}
526
+ <target id="1" performConversionExit="NOT_SUPPORTED">
527
+ <output>#///group${g}/rule${rv}/step2/output1</output>
528
+ <elementRef>#///target/segment1/${tgt}</elementRef>
529
+ </target>
530
+ <step xsi:type="trfn:StepFormula" id="2" rank="MAIN" type="FORMULA" formula="${escapeXmlAttr(formula)}">
531
+ ${inputTags}
532
+ ${stepOutputBlock}
533
+ </step>
534
+ </rule>`;
535
+ }
536
+ /**
537
+ * Convert any rule (StepDirect, StepInitial, StepNoUpdate) to StepConstant.
538
+ * - Removes the <source> element from the rule (constants have no source)
539
+ * - Removes the <input id="..."> element from within the step
540
+ * - Keeps the step <output> element unchanged
541
+ * - Sets xsi:type="trfn:StepConstant", id="2", type="CONSTANT", constant="<value>"
542
+ * - Adds performConversionExit="NOT_SUPPORTED" to <target>
543
+ * - Updates step1 → step2 references
544
+ */
545
+ function convertRuleToConstant(ruleXml, constantValue) {
546
+ let r = ruleXml;
547
+ // For a DATS (date) target field BW expects the constant in the EXTERNAL date display format,
548
+ // not the internal YYYYMMDD. The target element's <inlineType> sits inside the step's <output>
549
+ // block (the source element, if any, lives in an <input> block), so detect the type there.
550
+ // The external date format is the user/system date display setting; DD.MM.YYYY is assumed for
551
+ // this DE system (BW_LANGUAGE=DE). TIMS (time) is not handled here — only DATS is confirmed.
552
+ const stepOutput = ruleXml.match(/<output\b[^>]*\bid="[^"]*"[\s\S]*?<\/output>/)?.[0] ?? '';
553
+ const targetIsDats = /<inlineType\b[^>]*\bname="DATS"/.test(stepOutput);
554
+ let value = constantValue;
555
+ if (targetIsDats && /^\d{8}$/.test(value)) {
556
+ // Internal YYYYMMDD → external DD.MM.YYYY. Values already containing separators pass through.
557
+ value = `${value.slice(6, 8)}.${value.slice(4, 6)}.${value.slice(0, 4)}`;
558
+ }
559
+ // 1. Remove <source ...>...</source> element from the rule (not needed for constants)
560
+ r = r.replace(/<source\b[^>]*>[\s\S]*?<\/source>/, '');
561
+ // 2. Update step1 → step2 in all #/// references
562
+ r = r.replace(/\/step1\//g, '/step2/');
563
+ // 3. Add performConversionExit to <target id="1">
564
+ r = r.replace(/<target(\s+id="1")[^>]*>/, '<target$1 performConversionExit="NOT_SUPPORTED">');
565
+ // 4. Remove <input id="...">...</input> block from inside the step
566
+ // Only matches step-level inputs (id attribute present); the <input>ref</input> inside
567
+ // <output> elements has no attributes and is NOT affected.
568
+ r = r.replace(/(<step\b[^>]*>)([\s\S]*?)(<\/step>)/, (_match, open, body, close) => {
569
+ const cleanBody = body.replace(/<input\b[^>]*id="[^"]*"[^>]*>[\s\S]*?<\/input>/g, '');
570
+ return open + cleanBody + close;
571
+ });
572
+ // 5. Change xsi:type on the step element
573
+ r = r.replace(/xsi:type="trfn:Step(?:Direct|Initial|NoUpdate)"/, 'xsi:type="trfn:StepConstant"');
574
+ // 6. Change id="1" → id="2" on the <step element
575
+ r = r.replace(/(<step\b[^>]*\s)id="1"/, '$1id="2"');
576
+ // 7. Change type → CONSTANT and append constant attribute
577
+ r = r.replace(/(<step\b[^>]*\s)type="(?:DIRECT|INITIAL|NO_UPDATE)"/, `$1type="CONSTANT" constant="${escapeXmlAttr(value)}"`);
578
+ return r;
579
+ }
580
+ /**
581
+ * Build a StepRoutine rule from a StepNoUpdate rule.
582
+ * Reuses the existing step output element and adds a new source input element.
583
+ */
584
+ function buildNoUpdateToRoutineRule(ruleXml, groupId, ruleId, targetInfoObject, sourceField, srcType, srcLength) {
585
+ // Extract the step <output> block — its content (element + input ref to target1) stays unchanged
586
+ const stepMatch = ruleXml.match(/<step\b[^>]*>([\s\S]*?)<\/step>/);
587
+ if (!stepMatch)
588
+ throw new Error('Cannot parse step from StepNoUpdate rule');
589
+ const stepOutputBlock = stepMatch[1].trim();
590
+ const src = sourceField.toUpperCase();
591
+ const tgt = targetInfoObject.toUpperCase();
592
+ const g = groupId;
593
+ const rv = ruleId;
594
+ return `<rule id="${rv}" description="">
595
+ <source id="1">
596
+ <input>#///group${g}/rule${rv}/step2/input1</input>
597
+ <elementRef>#///source/segment1/${src}</elementRef>
598
+ </source>
599
+ <target id="1" performConversionExit="NOT_SUPPORTED">
600
+ <output>#///group${g}/rule${rv}/step2/output1</output>
601
+ <elementRef>#///target/segment1/${tgt}</elementRef>
602
+ </target>
603
+ <step xsi:type="trfn:StepRoutine" id="2" rank="MAIN" type="ROUTINE">
604
+ <input id="1">
605
+ <output>#///group${g}/rule${rv}/source1</output>
606
+ <element name="${src}">
607
+ <endUserTexts label="${src}"/>
608
+ <inlineType name="${srcType}" length="${srcLength}" semanticType="empty"/>
609
+ <localProperties xsi:type="BwCore:LocalCharacteristicProperties"/>
610
+ <associationType>1</associationType>
611
+ <associationValid>false</associationValid>
612
+ </element>
613
+ </input>
614
+ ${stepOutputBlock}
615
+ </step>
616
+ </rule>`;
617
+ }
618
+ /**
619
+ * Build a StepRead (Lookup) rule to replace any existing rule.
620
+ */
621
+ function buildLookupRule(_ruleXml, groupId, ruleId, targetInfoObject, sourceField, lookupObject, lookupObjectType) {
622
+ const src = sourceField.toUpperCase();
623
+ const tgt = targetInfoObject.toUpperCase();
624
+ const g = groupId;
625
+ const rv = ruleId;
626
+ return `<rule id="${rv}" description="">
627
+ <source id="1">
628
+ <input>#///group${g}/rule${rv}/step2/input1</input>
629
+ <elementRef>#///source/segment1/${src}</elementRef>
630
+ </source>
631
+ <target id="1" performConversionExit="NOT_SUPPORTED">
632
+ <output>#///group${g}/rule${rv}/step2/output1</output>
633
+ <elementRef>#///target/segment1/${tgt}</elementRef>
634
+ </target>
635
+ <step xsi:type="trfn:StepRead" id="2" rank="MAIN" type="READ" objectName="${lookupObject}" objectType="${lookupObjectType}">
636
+ <input id="1">
637
+ <output>#///group${g}/rule${rv}/source1</output>
638
+ <element name="${src}" infoObjectName="${src}">
639
+ <inlineType name="CHAR" length="22" semanticType="date" globalElementName="${src}"/>
640
+ </element>
641
+ </input>
642
+ <output id="1">
643
+ <input>#///group${g}/rule${rv}/target1</input>
644
+ <element name="${tgt}" infoObjectName="${tgt}">
645
+ <inlineType name="CHAR" length="1" semanticType="date" globalElementName="${tgt}"/>
646
+ </element>
647
+ </output>
648
+ </step>
649
+ </rule>`;
650
+ }
651
+ /**
652
+ * Build a StepDirect rule XML to replace an existing StepNoUpdate rule.
653
+ * Based on the exact structure from adso_workflow.md Block 6b.
654
+ */
655
+ function buildStepDirectRule(params) {
656
+ const { groupId, ruleId, sourceField, targetIObj, srcType, srcLength, tgtConvRoutine, tgtType, tgtLength, tgtLabel, } = params;
657
+ const src = sourceField.toUpperCase();
658
+ const tgt = targetIObj.toUpperCase();
659
+ const tgtLower = targetIObj.toLowerCase();
660
+ const convAttr = tgtConvRoutine ? ` conversionRoutine="${tgtConvRoutine}"` : '';
661
+ return `<rule id="${ruleId}" description="">
662
+ <source id="1">
663
+ <input>#///group${groupId}/rule${ruleId}/step2/input1</input>
664
+ <elementRef>#///source/segment1/${src}</elementRef>
665
+ </source>
666
+ <target performConversionExit="NO" id="1">
667
+ <output>#///group${groupId}/rule${ruleId}/step2/output1</output>
668
+ <elementRef>#///target/segment1/${tgt}</elementRef>
669
+ </target>
670
+ <step xsi:type="trfn:StepDirect" id="2" type="DIRECT" rank="MAIN">
671
+ <input id="1">
672
+ <output>#///group${groupId}/rule${ruleId}/source1</output>
673
+ <element name="${src}">
674
+ <endUserTexts label="${src}"/>
675
+ <inlineType name="${srcType}" length="${srcLength}" semanticType="empty"/>
676
+ <localProperties xsi:type="BwCore:LocalCharacteristicProperties"/>
677
+ <associationType>1</associationType>
678
+ <associationValid>false</associationValid>
679
+ </element>
680
+ </input>
681
+ <output id="1">
682
+ <input>#///group${groupId}/rule${ruleId}/target1</input>
683
+ <element xsi:type="trfn:TransformationElement" name="${tgt}"
684
+ infoObjectName="${tgt}"${convAttr}
685
+ dimension="#///target/segment1/ALL§">
686
+ <endUserTexts label="${tgtLabel}"/>
687
+ <inlineType name="${tgtType}" length="${tgtLength}" semanticType="empty"/>
688
+ <localProperties xsi:type="BwCore:LocalCharacteristicProperties"/>
689
+ <atom:link href="/sap/bw/modeling/iobj/${tgtLower}/a" rel="self" xmlns:atom="http://www.w3.org/2005/Atom"/>
690
+ <associationType>1</associationType>
691
+ <associationValid>true</associationValid>
692
+ </element>
693
+ </output>
694
+ </step>
695
+ </rule>`;
696
+ }
697
+ /**
698
+ * Convert any existing rule back to StepNoUpdate (no mapping).
699
+ * Preserves the target reference and step output element from the existing rule.
700
+ */
701
+ function buildNoUpdateRule(ruleXml, ruleId) {
702
+ // Extract <target ...>...</target> block
703
+ const targetMatch = ruleXml.match(/<target\b[^>]*>[\s\S]*?<\/target>/);
704
+ if (!targetMatch)
705
+ throw new Error('Cannot parse target block from rule');
706
+ const targetBlock = targetMatch[0];
707
+ // Extract <output id="1">...</output> from within the step
708
+ const stepMatch = ruleXml.match(/<step\b[^>]*>([\s\S]*?)<\/step>/);
709
+ if (!stepMatch)
710
+ throw new Error('Cannot parse step from rule');
711
+ const outputMatch = stepMatch[1].match(/<output\b[^>]*id="1"[^>]*>[\s\S]*?<\/output>/);
712
+ if (!outputMatch)
713
+ throw new Error('Cannot parse output block from step');
714
+ const outputBlock = outputMatch[0];
715
+ return `<rule id="${ruleId}" description="">${targetBlock}` +
716
+ `<step xsi:type="trfn:StepNoUpdate" id="1" type="NO_UPDATE" rank="MAIN">` +
717
+ `${outputBlock}</step></rule>`;
718
+ }
719
+ /**
720
+ * bw_update_transformation — map a source field to a target InfoObject,
721
+ * or convert an existing rule to a field routine (StepRoutine).
722
+ *
723
+ * rule_type="direct" (default):
724
+ * Finds any existing rule for the target InfoObject (any step type) and
725
+ * replaces it with StepDirect. source_field is required unless it can be
726
+ * inferred from the existing rule.
727
+ *
728
+ * rule_type="routine":
729
+ * Finds the rule for the target InfoObject (StepDirect, StepInitial, or
730
+ * StepNoUpdate) and converts it to StepRoutine. The server generates the
731
+ * ABAP AMDP class automatically. For StepNoUpdate rules, source_field is
732
+ * required; for StepDirect/StepInitial it is ignored.
733
+ *
734
+ * rule_type="formula":
735
+ * Finds the rule for the target InfoObject (StepDirect, StepInitial, or
736
+ * StepNoUpdate) and converts it to StepFormula. The formula parameter is
737
+ * required. For StepNoUpdate rules, source_field is also required.
738
+ * No ABAP class is generated — the BW runtime evaluates the formula natively.
739
+ * Use /BIC/FIELDNAME for custom InfoObject fields in the formula expression.
740
+ *
741
+ * Workflow: read InfoObject → GET Transformation → Lock → replace rule → PUT
742
+ * Returns lockHandle for bw_activate.
743
+ */
744
+ export async function bwUpdateTransformation(client, transformationName, sourceField, targetInfoObject, ruleType = 'direct', formula, constantValue, lookupObject, lookupObjectType, transport, additionalSourceFields) {
745
+ const tgtUpper = targetInfoObject.toUpperCase();
746
+ let srcUpper = sourceField?.toUpperCase() ?? '';
747
+ // Step 1: Read current Transformation (get full XML + timestamp)
748
+ const trfnPath = `/sap/bw/modeling/trfn/${transformationName.toLowerCase()}/m`;
749
+ const trfnResult = await client.get(trfnPath, TRFN_ACCEPT);
750
+ const timestamp = trfnResult.headers['timestamp'] ?? trfnResult.headers['TIMESTAMP'];
751
+ const originalXml = trfnResult.body;
752
+ let updatedXml;
753
+ if (ruleType === 'routine') {
754
+ // ── Routine path ────────────────────────────────────────────────────────
755
+ const ruleInfo = findRuleForTarget(originalXml, tgtUpper);
756
+ if (!ruleInfo) {
757
+ return JSON.stringify({
758
+ success: false,
759
+ message: `No rule found for target InfoObject ${tgtUpper} in ` +
760
+ `transformation ${transformationName.toUpperCase()}. ` +
761
+ `The field may not exist in the target segment.`,
762
+ });
763
+ }
764
+ let newRule;
765
+ if (ruleInfo.stepType === 'NO_UPDATE') {
766
+ if (!srcUpper) {
767
+ return JSON.stringify({
768
+ success: false,
769
+ message: `source_field is required when converting a StepNoUpdate rule to StepRoutine ` +
770
+ `(target InfoObject ${tgtUpper} has no source mapping yet).`,
771
+ });
772
+ }
773
+ const srcProps = extractSourceFieldProps(originalXml, srcUpper);
774
+ newRule = buildNoUpdateToRoutineRule(ruleInfo.oldRuleXml, ruleInfo.groupId, ruleInfo.ruleId, tgtUpper, srcUpper, srcProps.dataType, srcProps.length);
775
+ }
776
+ else {
777
+ // StepDirect or StepInitial — source is already mapped, just convert the step type
778
+ newRule = convertDirectOrInitialRuleToRoutine(ruleInfo.oldRuleXml);
779
+ }
780
+ updatedXml = originalXml.replace(ruleInfo.oldRuleXml, newRule);
781
+ if (updatedXml === originalXml) {
782
+ throw new Error('Routine rule replacement failed — XML unchanged.');
783
+ }
784
+ const lockHandle = await client.lock('trfn', transformationName);
785
+ try {
786
+ await client.put('trfn', transformationName, lockHandle, updatedXml, timestamp, transport);
787
+ }
788
+ catch (err) {
789
+ await client.unlock('trfn', transformationName).catch(() => { });
790
+ throw err;
791
+ }
792
+ return JSON.stringify({
793
+ success: true,
794
+ message: `InfoObject ${tgtUpper} in transformation ${transformationName.toUpperCase()} ` +
795
+ `converted to StepRoutine. The server has generated the ABAP AMDP class. ` +
796
+ `Call bw_activate to activate.`,
797
+ amdp_note: 'AMDP SQLSCRIPT methods only allow ASCII 7-bit characters. ' +
798
+ 'Do NOT use non-ASCII characters (e.g. German umlauts like ä/ö/ü or symbols like <=) ' +
799
+ 'in SQLSCRIPT code or comments — they will cause a syntax error.',
800
+ lock_handle: lockHandle,
801
+ transformation_name: transformationName.toUpperCase(),
802
+ object_type: 'trfn',
803
+ converted_from: ruleInfo.stepType,
804
+ });
805
+ }
806
+ if (ruleType === 'formula') {
807
+ // ── Formula path ────────────────────────────────────────────────────────
808
+ if (!formula) {
809
+ return JSON.stringify({
810
+ success: false,
811
+ message: 'formula is required for rule_type="formula".',
812
+ });
813
+ }
814
+ const ruleInfo = findRuleForTarget(originalXml, tgtUpper);
815
+ if (!ruleInfo) {
816
+ return JSON.stringify({
817
+ success: false,
818
+ message: `No rule found for target InfoObject ${tgtUpper} in ` +
819
+ `transformation ${transformationName.toUpperCase()}. ` +
820
+ `The field may not exist in the target segment.`,
821
+ });
822
+ }
823
+ let newRule;
824
+ if (ruleInfo.stepType === 'NO_UPDATE') {
825
+ if (!srcUpper) {
826
+ return JSON.stringify({
827
+ success: false,
828
+ message: `source_field is required when converting a StepNoUpdate rule to StepFormula ` +
829
+ `(target InfoObject ${tgtUpper} has no source mapping yet).`,
830
+ });
831
+ }
832
+ const allSourceFields = [srcUpper, ...(additionalSourceFields ?? []).map(f => f.toUpperCase())];
833
+ const srcFieldDefs = allSourceFields.map(f => {
834
+ const props = extractSourceFieldProps(originalXml, f);
835
+ return { name: f, dataType: props.dataType, length: props.length, elementXml: props.elementXml };
836
+ });
837
+ newRule = buildNoUpdateToFormulaRule(ruleInfo.oldRuleXml, ruleInfo.groupId, ruleInfo.ruleId, tgtUpper, srcFieldDefs, formula);
838
+ }
839
+ else {
840
+ // StepDirect or StepInitial — source already mapped, just convert the step type
841
+ newRule = convertDirectOrInitialRuleToFormula(ruleInfo.oldRuleXml, formula);
842
+ }
843
+ updatedXml = originalXml.replace(ruleInfo.oldRuleXml, newRule);
844
+ if (updatedXml === originalXml) {
845
+ throw new Error('Formula rule replacement failed — XML unchanged.');
846
+ }
847
+ const lockHandle = await client.lock('trfn', transformationName);
848
+ try {
849
+ await client.put('trfn', transformationName, lockHandle, updatedXml, timestamp, transport);
850
+ }
851
+ catch (err) {
852
+ await client.unlock('trfn', transformationName).catch(() => { });
853
+ throw err;
854
+ }
855
+ return JSON.stringify({
856
+ success: true,
857
+ message: `InfoObject ${tgtUpper} in transformation ${transformationName.toUpperCase()} ` +
858
+ `converted to StepFormula. Call bw_activate to activate.`,
859
+ formula,
860
+ lock_handle: lockHandle,
861
+ transformation_name: transformationName.toUpperCase(),
862
+ object_type: 'trfn',
863
+ converted_from: ruleInfo.stepType,
864
+ });
865
+ }
866
+ if (ruleType === 'constant') {
867
+ // ── Constant path ───────────────────────────────────────────────────────
868
+ if (!constantValue) {
869
+ return JSON.stringify({
870
+ success: false,
871
+ message: 'constant_value is required for rule_type="constant".',
872
+ });
873
+ }
874
+ const ruleInfo = findRuleForTarget(originalXml, tgtUpper);
875
+ if (!ruleInfo) {
876
+ return JSON.stringify({
877
+ success: false,
878
+ message: `No rule found for target InfoObject ${tgtUpper} in ` +
879
+ `transformation ${transformationName.toUpperCase()}. ` +
880
+ `The field may not exist in the target segment.`,
881
+ });
882
+ }
883
+ const newRule = convertRuleToConstant(ruleInfo.oldRuleXml, constantValue);
884
+ updatedXml = originalXml.replace(ruleInfo.oldRuleXml, newRule);
885
+ if (updatedXml === originalXml) {
886
+ throw new Error('Constant rule replacement failed — XML unchanged.');
887
+ }
888
+ // Report the value actually written (after any DATS external-date conversion), not the raw input.
889
+ const writtenValue = newRule.match(/<step\b[^>]*\bconstant="([^"]*)"/)?.[1] ?? constantValue;
890
+ const lockHandle = await client.lock('trfn', transformationName);
891
+ try {
892
+ await client.put('trfn', transformationName, lockHandle, updatedXml, timestamp, transport);
893
+ }
894
+ catch (err) {
895
+ await client.unlock('trfn', transformationName).catch(() => { });
896
+ throw err;
897
+ }
898
+ return JSON.stringify({
899
+ success: true,
900
+ message: `InfoObject ${tgtUpper} in transformation ${transformationName.toUpperCase()} ` +
901
+ `converted to StepConstant with value "${writtenValue}". Call bw_activate to activate.`,
902
+ constant_value: writtenValue,
903
+ lock_handle: lockHandle,
904
+ transformation_name: transformationName.toUpperCase(),
905
+ object_type: 'trfn',
906
+ converted_from: ruleInfo.stepType,
907
+ });
908
+ }
909
+ // ── Lookup path ──────────────────────────────────────────────────────────
910
+ if (ruleType === 'lookup') {
911
+ if (!lookupObject || !lookupObjectType) {
912
+ return JSON.stringify({
913
+ success: false,
914
+ message: 'lookup_object and lookup_object_type are required for rule_type="lookup".',
915
+ });
916
+ }
917
+ if (!srcUpper) {
918
+ return JSON.stringify({
919
+ success: false,
920
+ message: 'source_field is required for rule_type="lookup".',
921
+ });
922
+ }
923
+ const ruleInfo = findRuleForTarget(originalXml, tgtUpper);
924
+ if (!ruleInfo) {
925
+ return JSON.stringify({
926
+ success: false,
927
+ message: `No rule found for target InfoObject ${tgtUpper} in ` +
928
+ `transformation ${transformationName.toUpperCase()}. ` +
929
+ `The field may not exist in the target segment.`,
930
+ });
931
+ }
932
+ const newRule = buildLookupRule(ruleInfo.oldRuleXml, ruleInfo.groupId, ruleInfo.ruleId, tgtUpper, srcUpper, lookupObject.toUpperCase(), lookupObjectType.toUpperCase());
933
+ updatedXml = originalXml.replace(ruleInfo.oldRuleXml, newRule);
934
+ if (updatedXml === originalXml) {
935
+ throw new Error('Lookup rule replacement failed — XML unchanged.');
936
+ }
937
+ const lockHandle = await client.lock('trfn', transformationName);
938
+ try {
939
+ await client.put('trfn', transformationName, lockHandle, updatedXml, timestamp, transport);
940
+ }
941
+ catch (err) {
942
+ await client.unlock('trfn', transformationName).catch(() => { });
943
+ throw err;
944
+ }
945
+ return JSON.stringify({
946
+ success: true,
947
+ message: `InfoObject ${tgtUpper} in transformation ${transformationName.toUpperCase()} ` +
948
+ `converted to StepRead (Lookup) from ${lookupObjectType.toUpperCase()} ${lookupObject.toUpperCase()}. Call bw_activate to activate.`,
949
+ lookup_object: lookupObject.toUpperCase(),
950
+ lookup_object_type: lookupObjectType.toUpperCase(),
951
+ lock_handle: lockHandle,
952
+ transformation_name: transformationName.toUpperCase(),
953
+ object_type: 'trfn',
954
+ converted_from: ruleInfo.stepType,
955
+ });
956
+ }
957
+ if (ruleType === 'no_update') {
958
+ // ── No-update path — remove any mapping, revert to StepNoUpdate ─────────
959
+ const ruleInfo = findRuleForTarget(originalXml, tgtUpper);
960
+ if (!ruleInfo) {
961
+ return JSON.stringify({
962
+ success: false,
963
+ message: `No rule found for target InfoObject ${tgtUpper} in ` +
964
+ `transformation ${transformationName.toUpperCase()}.`,
965
+ });
966
+ }
967
+ if (ruleInfo.stepType === 'NO_UPDATE') {
968
+ return JSON.stringify({
969
+ success: true,
970
+ message: `InfoObject ${tgtUpper} is already StepNoUpdate — nothing to do.`,
971
+ lock_handle: '',
972
+ transformation_name: transformationName.toUpperCase(),
973
+ object_type: 'trfn',
974
+ });
975
+ }
976
+ const newRule = buildNoUpdateRule(ruleInfo.oldRuleXml, ruleInfo.ruleId);
977
+ updatedXml = originalXml.replace(ruleInfo.oldRuleXml, newRule);
978
+ if (updatedXml === originalXml) {
979
+ throw new Error('no_update replacement failed — XML unchanged.');
980
+ }
981
+ const lockHandle = await client.lock('trfn', transformationName);
982
+ try {
983
+ await client.put('trfn', transformationName, lockHandle, updatedXml, timestamp, transport);
984
+ }
985
+ catch (err) {
986
+ await client.unlock('trfn', transformationName).catch(() => { });
987
+ throw err;
988
+ }
989
+ return JSON.stringify({
990
+ success: true,
991
+ message: `InfoObject ${tgtUpper} in transformation ${transformationName.toUpperCase()} ` +
992
+ `reverted to StepNoUpdate (no mapping). Call bw_activate to activate.`,
993
+ lock_handle: lockHandle,
994
+ transformation_name: transformationName.toUpperCase(),
995
+ object_type: 'trfn',
996
+ converted_from: ruleInfo.stepType,
997
+ });
998
+ }
999
+ // ── Direct path (default) ────────────────────────────────────────────────
1000
+ // Read InfoObject to get label and type info
1001
+ const iObjPath = `/sap/bw/modeling/iobj/${targetInfoObject.toLowerCase()}/m`;
1002
+ const iObjResult = await client.get(iObjPath, MEDIA_TYPES['iobj']);
1003
+ const iObjProps = parseInfoObjectProps(iObjResult.body);
1004
+ // Find any existing rule for the target InfoObject
1005
+ const ruleInfo = findRuleForTarget(originalXml, tgtUpper);
1006
+ if (!ruleInfo) {
1007
+ return JSON.stringify({
1008
+ success: false,
1009
+ message: `No rule found for target InfoObject ${tgtUpper} in ` +
1010
+ `transformation ${transformationName.toUpperCase()}.`,
1011
+ });
1012
+ }
1013
+ // Resolve the effective source field: explicit arg takes priority,
1014
+ // otherwise infer from the first <element name="..."> inside the <step> block.
1015
+ if (!srcUpper) {
1016
+ const inferredMatch = ruleInfo.oldRuleXml.match(/<step\b[^>]*>[\s\S]*?<element\s+[^>]*name="([^"]+)"/);
1017
+ if (inferredMatch) {
1018
+ srcUpper = inferredMatch[1].toUpperCase();
1019
+ }
1020
+ else {
1021
+ return JSON.stringify({
1022
+ success: false,
1023
+ message: `source_field is required — no source mapping could be inferred from the existing rule for ${tgtUpper}.`,
1024
+ });
1025
+ }
1026
+ }
1027
+ const srcProps = extractSourceFieldProps(originalXml, srcUpper);
1028
+ const tgtProps = extractTargetElemProps(originalXml, tgtUpper);
1029
+ const newRule = buildStepDirectRule({
1030
+ groupId: ruleInfo.groupId,
1031
+ ruleId: ruleInfo.ruleId,
1032
+ sourceField: srcUpper,
1033
+ targetIObj: tgtUpper,
1034
+ srcType: srcProps.dataType,
1035
+ srcLength: srcProps.length,
1036
+ tgtConvRoutine: tgtProps.convRoutine || iObjProps.conversionRoutine,
1037
+ tgtType: tgtProps.dataType,
1038
+ tgtLength: tgtProps.length,
1039
+ tgtLabel: iObjProps.label,
1040
+ });
1041
+ updatedXml = originalXml.replace(ruleInfo.oldRuleXml, newRule);
1042
+ if (updatedXml === originalXml) {
1043
+ throw new Error('Rule replacement failed — XML unchanged. The rule text may have unexpected formatting.');
1044
+ }
1045
+ const lockHandle = await client.lock('trfn', transformationName);
1046
+ try {
1047
+ await client.put('trfn', transformationName, lockHandle, updatedXml, timestamp);
1048
+ }
1049
+ catch (err) {
1050
+ await client.unlock('trfn', transformationName).catch(() => { });
1051
+ throw err;
1052
+ }
1053
+ return JSON.stringify({
1054
+ success: true,
1055
+ message: `Source field ${srcUpper} mapped to InfoObject ${tgtUpper} in ` +
1056
+ `transformation ${transformationName.toUpperCase()}. Call bw_activate to activate.`,
1057
+ lock_handle: lockHandle,
1058
+ transformation_name: transformationName.toUpperCase(),
1059
+ object_type: 'trfn',
1060
+ });
1061
+ }
1062
+ // ── bwSetTransformationRoutine ───────────────────────────────────────────────
1063
+ /**
1064
+ * bwSetTransformationRoutine — add a Start, End, or Expert routine to a Transformation.
1065
+ *
1066
+ * Flow:
1067
+ * 1. GET XML — derive classNameM from classNameA (_A → _M), error if missing
1068
+ * 2. Guard: group id="0" must not already exist
1069
+ * 3. Extract fields: source fields for START, target fields for END/EXPERT
1070
+ * 4. Build group id="0" block with full step (classNameM + methodNameM included)
1071
+ * - START: <source id="N"> elements, no sourceSegment on group
1072
+ * - END/EXPERT: <target id="N"> elements, sourceSegment="#///source/segment1" on group
1073
+ * 5. Lock → single PUT (session-isolated)
1074
+ * 6. Return lock_handle for bw_activate
1075
+ */
1076
+ /**
1077
+ * Convert a BW InfoObject name to the corresponding HANA SQL column name.
1078
+ * Standard objects (starting with "0"): strip the leading "0", no quoting needed.
1079
+ * Custom BIC objects: prefix "/BIC/", wrap in double quotes for SQL.
1080
+ */
1081
+ function ioBwNameToHanaSqlColumn(name) {
1082
+ if (name.startsWith('0')) {
1083
+ return name.substring(1); // e.g. 0IOBJ_NAME → IOBJ_NAME
1084
+ }
1085
+ return `"/BIC/${name}"`; // e.g. FIELD_NAME → "/BIC/FIELD_NAME"
1086
+ }
1087
+ /**
1088
+ * Extract target fields from the transformation XML in posit order and build
1089
+ * a HANA SQLScript SELECT statement for a GLOBAL_END / GLOBAL_EXPERT skeleton.
1090
+ * Appends RECORD and SQL__PROCEDURE__SOURCE__RECORD at the end.
1091
+ */
1092
+ function buildHanaEndSelect(xml) {
1093
+ const tgtSegMatch = xml.match(/<target\b[^>]*>[\s\S]*?<segment[^>]*>([\s\S]*?)<\/segment>/);
1094
+ if (!tgtSegMatch)
1095
+ return 'outTab = SELECT * FROM :inTab;';
1096
+ // Collect fields with their posit for ordering
1097
+ const elemRegex = /<element\b[^>]*\bposit="(\d+)"[^>]*\bname="([^"]+)"[^>]*/g;
1098
+ const fields = [];
1099
+ let em;
1100
+ while ((em = elemRegex.exec(tgtSegMatch[1])) !== null) {
1101
+ fields.push({ posit: parseInt(em[1], 10), col: ioBwNameToHanaSqlColumn(em[2]) });
1102
+ }
1103
+ fields.sort((a, b) => a.posit - b.posit);
1104
+ const cols = [
1105
+ ...fields.map(f => ` ${f.col}`),
1106
+ ' RECORD',
1107
+ ' SQL__PROCEDURE__SOURCE__RECORD',
1108
+ ];
1109
+ return `outTab = SELECT\n${cols.join(',\n')}\nFROM :inTab;`;
1110
+ }
1111
+ export async function bwSetTransformationRoutine(client, transformationName, routineType, transport) {
1112
+ const trfnUpper = transformationName.toUpperCase();
1113
+ const trfnLower = transformationName.toLowerCase();
1114
+ const routineTypeUpper = routineType.toUpperCase();
1115
+ const methodName = `GLOBAL_${routineTypeUpper}`;
1116
+ // Step 1: GET current XML
1117
+ const { body: xml1, headers: headers1 } = await client.get(`/sap/bw/modeling/trfn/${trfnLower}/m`, TRFN_ACCEPT);
1118
+ const timestamp1 = headers1['timestamp'] ?? '';
1119
+ // Step 2: Derive classNameM — from classNameA on root, from any existing StepRoutine, or
1120
+ // from the transformation name itself (ABAP mode, no routines yet: /BIC/{last20}_M)
1121
+ const classNameAMatch = xml1.match(/\bclassNameA="([^"]+)"/);
1122
+ let classNameM;
1123
+ if (classNameAMatch) {
1124
+ classNameM = classNameAMatch[1].replace(/_A$/, '_M');
1125
+ }
1126
+ else {
1127
+ const classNameMMatch = xml1.match(/\bclassNameM="([^"]+)"/);
1128
+ if (classNameMMatch) {
1129
+ classNameM = classNameMMatch[1];
1130
+ }
1131
+ else {
1132
+ // ABAP runtime, no routines yet — derive from transformation name
1133
+ classNameM = `/BIC/${trfnUpper.slice(-20)}_M`;
1134
+ }
1135
+ }
1136
+ // Step 3: Guard — reject only if this specific routine type already exists
1137
+ const group0Exists = /<group\b[^>]*\bid="0"/.test(xml1);
1138
+ if (group0Exists) {
1139
+ const routineTypeExists = new RegExp(`<rule\\b[^>]*\\broutinetype="${routineTypeUpper}"`).test(xml1);
1140
+ if (routineTypeExists) {
1141
+ return JSON.stringify({
1142
+ success: false,
1143
+ message: `Transformation ${trfnUpper} already has a ${routineTypeUpper} routine. ` +
1144
+ `Cannot add another one.`,
1145
+ });
1146
+ }
1147
+ }
1148
+ // Step 4: Detect runtime from HANARuntime attribute on root element
1149
+ const hanaRuntimeAttr = /\bHANARuntime="([^"]+)"/.exec(xml1);
1150
+ const hanaRuntime = hanaRuntimeAttr ? hanaRuntimeAttr[1] : 'true';
1151
+ // Step 5: Determine next free rule ID (max existing + 1)
1152
+ const ruleIds = [];
1153
+ const ruleIdRegex = /<rule\b[^>]*\bid="(\d+)"/g;
1154
+ let rm;
1155
+ while ((rm = ruleIdRegex.exec(xml1)) !== null) {
1156
+ ruleIds.push(parseInt(rm[1], 10));
1157
+ }
1158
+ const nextRuleId = ruleIds.length > 0 ? Math.max(...ruleIds) + 1 : 1;
1159
+ // Step 6: Build rule content based on routine type
1160
+ const stepAttrs = `xsi:type="trfn:StepRoutine" id="1" rank="MAIN" type="ROUTINE"` +
1161
+ ` classNameM="${classNameM}" hanaRuntime="${hanaRuntime}" methodNameM="${methodName}"`;
1162
+ let ruleContent;
1163
+ if (routineType === 'start') {
1164
+ // START: source fields from <source>/<segment>/<element>
1165
+ const srcSegMatch = xml1.match(/<source\b[^>]*>[\s\S]*?<segment[^>]*>([\s\S]*?)<\/segment>/);
1166
+ if (!srcSegMatch) {
1167
+ throw new Error(`Could not extract source segment from transformation ${trfnUpper}.`);
1168
+ }
1169
+ const sourceFields = [];
1170
+ const elemRegex = /<element\b[^>]*\bname="([^"]+)"[^>]*>/g;
1171
+ let em;
1172
+ while ((em = elemRegex.exec(srcSegMatch[1])) !== null) {
1173
+ sourceFields.push(em[1]);
1174
+ }
1175
+ const sourceRefs = sourceFields
1176
+ .map((f, i) => `<source id="${i + 1}"><elementRef>#///source/segment1/${f}</elementRef></source>`)
1177
+ .join('');
1178
+ ruleContent =
1179
+ `<rule id="${nextRuleId}" routinetype="${routineTypeUpper}">` +
1180
+ sourceRefs +
1181
+ `<step ${stepAttrs}/>` +
1182
+ `</rule>`;
1183
+ }
1184
+ else {
1185
+ // END / EXPERT: target fields from <target>/<segment>/<element>
1186
+ const tgtSegMatch = xml1.match(/<target\b[^>]*>[\s\S]*?<segment[^>]*>([\s\S]*?)<\/segment>/);
1187
+ if (!tgtSegMatch) {
1188
+ throw new Error(`Could not extract target segment from transformation ${trfnUpper}.`);
1189
+ }
1190
+ const targetFields = [];
1191
+ const elemRegex = /<element\b[^>]*\bname="([^"]+)"[^>]*>/g;
1192
+ let em;
1193
+ while ((em = elemRegex.exec(tgtSegMatch[1])) !== null) {
1194
+ targetFields.push(em[1]);
1195
+ }
1196
+ const targetRefs = targetFields
1197
+ .map((f, i) => `<target id="${i + 1}"><elementRef>#///target/segment1/${f}</elementRef></target>`)
1198
+ .join('');
1199
+ ruleContent =
1200
+ `<rule id="${nextRuleId}" routinetype="${routineTypeUpper}">` +
1201
+ targetRefs +
1202
+ `<step ${stepAttrs}/>` +
1203
+ `</rule>`;
1204
+ }
1205
+ // Step 7: Insert rule — append inside existing group id="0", or create new group before group id="1"
1206
+ let xmlWithGroup;
1207
+ if (group0Exists) {
1208
+ xmlWithGroup = xml1.replace(/(<group\b[^>]*\bid="0"[^>]*>)([\s\S]*?)(<\/group>)/, `$1$2${ruleContent}$3`);
1209
+ if (xmlWithGroup === xml1) {
1210
+ throw new Error('Could not append rule to existing group id="0".');
1211
+ }
1212
+ }
1213
+ else {
1214
+ const groupAttrs = routineType === 'start' ? '' : ` sourceSegment="#///source/segment1"`;
1215
+ const group0Block = `<group id="0"${groupAttrs} type="G">${ruleContent}</group>`;
1216
+ xmlWithGroup = xml1.replace(/<group\s+id="1"/, `${group0Block}<group id="1"`);
1217
+ if (xmlWithGroup === xml1) {
1218
+ throw new Error('Could not insert group id="0" — group id="1" not found in Transformation XML.');
1219
+ }
1220
+ }
1221
+ // Lock → single PUT (session-isolated)
1222
+ const lockHandle = await client.lock('trfn', trfnLower);
1223
+ const putClient = createClientFromEnv();
1224
+ try {
1225
+ await putClient.put('trfn', trfnLower, lockHandle, xmlWithGroup, timestamp1, transport);
1226
+ }
1227
+ catch (err) {
1228
+ await client.unlock('trfn', trfnLower).catch(() => { });
1229
+ throw err;
1230
+ }
1231
+ // ADT class write flow — activate the generated _M class and inject a proper skeleton.
1232
+ // For ABAP: BW generates the class only after activation — skip if 404.
1233
+ // For HANA END/EXPERT: BW auto-generates the class; inject the correct SELECT column list
1234
+ // so the user has the right structure when adding custom logic.
1235
+ {
1236
+ const classEncoded = encodeURIComponent(classNameM).toLowerCase();
1237
+ const source = await client.adtGetSource(classEncoded);
1238
+ if (source !== null) {
1239
+ let updatedSource = source;
1240
+ if (hanaRuntime === 'true' && (routineType === 'end' || routineType === 'expert')) {
1241
+ // Replace the commented stub SELECT with a proper explicit column list
1242
+ const selectStmt = buildHanaEndSelect(xmlWithGroup);
1243
+ updatedSource = source.replace(/-- outTab = SELECT \* FROM :inTab;/, selectStmt);
1244
+ }
1245
+ const adtLock = await client.adtLockClass(classEncoded);
1246
+ try {
1247
+ await client.adtPutSource(classEncoded, adtLock, updatedSource);
1248
+ await client.adtActivate(classEncoded, classNameM);
1249
+ }
1250
+ finally {
1251
+ await client.adtUnlockClass(classEncoded, adtLock).catch(() => { });
1252
+ }
1253
+ }
1254
+ }
1255
+ return JSON.stringify({
1256
+ success: true,
1257
+ message: `${routineTypeUpper} routine added to transformation ${trfnUpper}. ` +
1258
+ `ABAP method ${classNameM}->${methodName} generated. Call bw_activate to activate.`,
1259
+ routine_type: routineTypeUpper,
1260
+ class_name: classNameM,
1261
+ method_name: methodName,
1262
+ lock_handle: lockHandle,
1263
+ transformation_name: trfnUpper,
1264
+ object_type: 'trfn',
1265
+ });
1266
+ }
1267
+ // ── bwDeleteTransformationRoutine ────────────────────────────────────────────
1268
+ /**
1269
+ * bw_delete_transformation_routine — remove a Start, End, or Expert routine.
1270
+ *
1271
+ * Removes the <rule routinetype="START|END|EXPERT"> from <group id="0">.
1272
+ * If no rules remain in group id="0" afterwards, removes the entire group.
1273
+ * Single PUT (session-isolated). Returns lock_handle for bw_activate.
1274
+ */
1275
+ export async function bwDeleteTransformationRoutine(client, transformationName, routineType) {
1276
+ const trfnUpper = transformationName.toUpperCase();
1277
+ const trfnLower = transformationName.toLowerCase();
1278
+ const routineTypeUpper = routineType.toUpperCase();
1279
+ // Step 1: GET current XML
1280
+ const { body: xml, headers } = await client.get(`/sap/bw/modeling/trfn/${trfnLower}/m`, TRFN_ACCEPT);
1281
+ const timestamp = headers['timestamp'] ?? '';
1282
+ // Step 2: Guard — group id="0" must exist
1283
+ if (!/<group\s+id="0"/.test(xml)) {
1284
+ return JSON.stringify({
1285
+ success: false,
1286
+ message: `Transformation ${trfnUpper} has no global routine group (group id="0").`,
1287
+ });
1288
+ }
1289
+ // Step 3: Extract the full <group id="0">...</group> block
1290
+ const group0Regex = /(<group\s+id="0"[^>]*>)([\s\S]*?)(<\/group>)/;
1291
+ const group0Match = xml.match(group0Regex);
1292
+ if (!group0Match) {
1293
+ throw new Error(`Could not parse group id="0" from transformation ${trfnUpper}.`);
1294
+ }
1295
+ const [fullGroup0, groupOpen, groupBody, groupClose] = group0Match;
1296
+ // Step 4: Find the rule with matching routinetype and remove it
1297
+ // Rules look like: <rule id="N" routinetype="START">...</rule>
1298
+ const ruleRegex = new RegExp(`<rule\\b[^>]*\\broutinetype="${routineTypeUpper}"[^>]*>[\\s\\S]*?<\\/rule>`, 'i');
1299
+ if (!ruleRegex.test(groupBody)) {
1300
+ return JSON.stringify({
1301
+ success: false,
1302
+ message: `No ${routineTypeUpper} routine found in group id="0" of transformation ${trfnUpper}.`,
1303
+ });
1304
+ }
1305
+ const newGroupBody = groupBody.replace(ruleRegex, '');
1306
+ // Step 5: If group is now empty (no remaining <rule> elements), remove the entire group
1307
+ const hasRemainingRules = /<rule\b/.test(newGroupBody);
1308
+ let updatedXml;
1309
+ if (!hasRemainingRules) {
1310
+ updatedXml = xml.replace(fullGroup0, '');
1311
+ }
1312
+ else {
1313
+ updatedXml = xml.replace(fullGroup0, groupOpen + newGroupBody + groupClose);
1314
+ }
1315
+ if (updatedXml === xml) {
1316
+ throw new Error('XML unchanged after routine removal — replacement failed.');
1317
+ }
1318
+ // Step 6: Lock → PUT (session-isolated)
1319
+ const lockHandle = await client.lock('trfn', trfnLower);
1320
+ const putClient = createClientFromEnv();
1321
+ try {
1322
+ await putClient.put('trfn', trfnLower, lockHandle, updatedXml, timestamp);
1323
+ }
1324
+ catch (err) {
1325
+ await client.unlock('trfn', trfnLower).catch(() => { });
1326
+ throw err;
1327
+ }
1328
+ return JSON.stringify({
1329
+ success: true,
1330
+ message: `${routineTypeUpper} routine removed from transformation ${trfnUpper}.` +
1331
+ (!hasRemainingRules ? ' Group id="0" removed (no remaining routines).' : '') +
1332
+ ' Call bw_activate to activate.',
1333
+ routine_type: routineTypeUpper,
1334
+ group_removed: !hasRemainingRules,
1335
+ lock_handle: lockHandle,
1336
+ transformation_name: trfnUpper,
1337
+ object_type: 'trfn',
1338
+ });
1339
+ }
1340
+ // ── bwSetTransformationRuntime ────────────────────────────────────────────────
1341
+ /**
1342
+ * bw_set_transformation_runtime — toggle HANARuntime on a Transformation.
1343
+ *
1344
+ * Only changes the HANARuntime attribute on the root <trfn:transformation>
1345
+ * element. All rules and segments are passed through unchanged.
1346
+ * Returns lock_handle for bw_activate.
1347
+ */
1348
+ export async function bwSetTransformationRuntime(client, transformationName, runtime, transport) {
1349
+ const trfnUpper = transformationName.toUpperCase();
1350
+ const trfnLower = transformationName.toLowerCase();
1351
+ // Step 1: Lock
1352
+ const lockHandle = await client.lock('trfn', trfnLower);
1353
+ try {
1354
+ // Step 2: GET current XML
1355
+ const { body: xml, headers } = await client.get(`/sap/bw/modeling/trfn/${trfnLower}/m`, TRFN_ACCEPT);
1356
+ const timestamp = headers['timestamp'] ?? '';
1357
+ // Step 3: Check current value — early return if already correct
1358
+ const currentMatch = xml.match(/\bHANARuntime="(true|false)"/);
1359
+ const currentValue = currentMatch?.[1] ?? 'true';
1360
+ const targetValue = runtime.toLowerCase() === 'hana' ? 'true' : 'false';
1361
+ if (currentValue === targetValue) {
1362
+ await client.unlock('trfn', trfnLower).catch(() => { });
1363
+ return JSON.stringify({
1364
+ success: true,
1365
+ already_set: true,
1366
+ message: `Transformation ${trfnUpper} already has HANARuntime="${targetValue}". No change needed.`,
1367
+ runtime,
1368
+ transformation_name: trfnUpper,
1369
+ object_type: 'trfn',
1370
+ });
1371
+ }
1372
+ // Step 4: Replace HANARuntime attribute
1373
+ const updatedXml = xml.replace(/\bHANARuntime="(true|false)"/, `HANARuntime="${targetValue}"`);
1374
+ if (updatedXml === xml) {
1375
+ throw new Error('HANARuntime replacement failed — XML unchanged.');
1376
+ }
1377
+ // Step 5: PUT
1378
+ await client.put('trfn', trfnLower, lockHandle, updatedXml, timestamp, transport);
1379
+ }
1380
+ catch (err) {
1381
+ await client.unlock('trfn', trfnLower).catch(() => { });
1382
+ throw err;
1383
+ }
1384
+ return JSON.stringify({
1385
+ success: true,
1386
+ message: `Transformation ${trfnUpper} runtime switched to "${runtime}" (HANARuntime="${runtime === 'hana' ? 'true' : 'false'}"). Call bw_activate to activate.`,
1387
+ runtime,
1388
+ lock_handle: lockHandle,
1389
+ transformation_name: trfnUpper,
1390
+ object_type: 'trfn',
1391
+ });
1392
+ }