@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,117 @@
1
+ import { MEDIA_TYPES } from '../bw-client.js';
2
+ /**
3
+ * bw_move_object — move any BW object to a different InfoArea.
4
+ *
5
+ * Single POST to /sap/bw/modeling/move_requests — no lock needed.
6
+ */
7
+ export async function bwMoveObject(client, args) {
8
+ const typeLower = args.objectType.toLowerCase();
9
+ const nameLower = args.objectName.toLowerCase();
10
+ const targetUpper = args.targetInfoArea.toUpperCase();
11
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
12
+ <atom:feed xmlns:atom="http://www.w3.org/2005/Atom" xmlns:bwModel="http://www.sap.com/bw/modeling">
13
+ <atom:entry>
14
+ <atom:content type="application/xml">
15
+ <bwModel:moveProperties
16
+ targetObjectType="AREA"
17
+ targetObjectName="${targetUpper}"
18
+ movePosition="CHILD"
19
+ version="inactive"
20
+ lockHandle="">
21
+ </bwModel:moveProperties>
22
+ </atom:content>
23
+ <atom:link
24
+ href="/sap/bw/modeling/${typeLower}/${nameLower}/m"
25
+ type="application/*"
26
+ rel="self">
27
+ </atom:link>
28
+ </atom:entry>
29
+ </atom:feed>`;
30
+ await client.postRaw('/sap/bw/modeling/move_requests', xml, 'application/atom+xml;type=entry');
31
+ return JSON.stringify({
32
+ success: true,
33
+ objectType: typeLower,
34
+ objectName: nameLower,
35
+ targetInfoArea: targetUpper,
36
+ message: `Object '${args.objectName.toUpperCase()}' moved to InfoArea '${targetUpper}'.`,
37
+ });
38
+ }
39
+ /**
40
+ * bw_create_infoarea — create a new InfoArea (immediately active, no activation step needed).
41
+ *
42
+ * Flow:
43
+ * 1. Lock (CREA, no parent_name/parent_type) → lockHandle
44
+ * 2. POST with XML body → InfoArea created and active
45
+ * (unlock is automatic after POST)
46
+ */
47
+ export async function bwCreateInfoArea(client, args) {
48
+ const nameUpper = args.name.toUpperCase();
49
+ const nameLower = args.name.toLowerCase();
50
+ const pkg = args.package ?? '$TMP';
51
+ const desc = args.description ?? '';
52
+ const parentUpper = args.parent_info_area?.toUpperCase() ?? '';
53
+ const language = process.env.BW_LANGUAGE?.toUpperCase() ?? 'DE';
54
+ const user = process.env.BW_USER?.toUpperCase() ?? '';
55
+ // Step 1: Lock with CREA (no parent_name / parent_type for InfoArea)
56
+ const lockHandle = await client.lock('area', nameLower, {
57
+ activity_context: 'CREA',
58
+ }, 'stateful_enqueue');
59
+ // Step 2: POST — creates and activates the InfoArea in one step
60
+ const parentAttr = parentUpper ? ` parentInfoArea="${parentUpper}"` : '';
61
+ const parentElement = parentUpper ? parentUpper : '';
62
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
63
+ <InfoArea:infoArea
64
+ xmlns:InfoArea="http://www.sap.com/bw/modeling/BwInfoArea.ecore"
65
+ xmlns:adtcore="http://www.sap.com/adt/core"
66
+ name="${nameUpper}"${parentAttr}>
67
+ <longDescription>${desc}</longDescription>
68
+ <tlogoProperties
69
+ adtcore:language="${language}"
70
+ adtcore:name="${nameUpper}"
71
+ adtcore:type="AREA"
72
+ adtcore:masterLanguage="${language}"
73
+ adtcore:responsible="${user}">
74
+ <infoArea>${parentElement}</infoArea>
75
+ </tlogoProperties>
76
+ </InfoArea:infoArea>`;
77
+ await client.create('area', nameLower, lockHandle, xml, { 'Development-Class': pkg });
78
+ await client.unlock('area', nameLower);
79
+ return JSON.stringify({
80
+ success: true,
81
+ name: nameUpper,
82
+ parentInfoArea: parentUpper || null,
83
+ description: desc,
84
+ package: pkg,
85
+ message: `InfoArea '${nameUpper}' created and active.`,
86
+ });
87
+ }
88
+ // ── bwGetInfoarea ─────────────────────────────────────────────────────────────
89
+ /**
90
+ * bw_get_infoarea — read an InfoArea definition.
91
+ *
92
+ * GET /sap/bw/modeling/area/{name}
93
+ */
94
+ export async function bwGetInfoarea(client, name) {
95
+ const nameLower = name.toLowerCase();
96
+ const result = await client.get(`/sap/bw/modeling/area/${nameLower}`, MEDIA_TYPES['area']);
97
+ const body = result.body;
98
+ try {
99
+ const parsed = JSON.parse(body);
100
+ const infoAreaName = parsed['name'] ?? name.toUpperCase();
101
+ const label = parsed['endUserTexts']?.['label'] ??
102
+ parsed['descriptions']?.['label'] ??
103
+ parsed['label'] ??
104
+ '';
105
+ const parentArea = parsed['tlogoProperties']?.['infoArea'] ??
106
+ parsed['parentInfoArea'] ??
107
+ null;
108
+ const objectStatus = parsed['tlogoProperties']?.['adtcore:version'] ??
109
+ parsed['tlogoProperties']?.['objectStatus'] ??
110
+ parsed['objectStatus'] ??
111
+ '';
112
+ return JSON.stringify({ name: infoAreaName, label, parent_area: parentArea || null, object_status: objectStatus }, null, 2);
113
+ }
114
+ catch {
115
+ return JSON.stringify({ raw: body });
116
+ }
117
+ }
@@ -0,0 +1,447 @@
1
+ import { MEDIA_TYPES, createClientFromEnv } from '../bw-client.js';
2
+ // ── KYF: objectSpecificDataType → keyfigureType / semantics ──────────────────
3
+ const KYF_TYPE_MAP = {
4
+ DEC: { keyfigureType: 'NUM', semantics: 'NUM' },
5
+ CURR: { keyfigureType: 'AMT', semantics: 'AMT' },
6
+ FLTP: { keyfigureType: 'NUM', semantics: 'NUM' },
7
+ QUAN: { keyfigureType: 'QUA', semantics: 'QUA' },
8
+ DATS: { keyfigureType: 'DAT', semantics: 'DAT' },
9
+ INT4: { keyfigureType: 'INT', semantics: 'INT4' },
10
+ INT8: { keyfigureType: 'INT', semantics: 'INT8' },
11
+ TIMS: { keyfigureType: 'NUM', semantics: 'NUM' },
12
+ };
13
+ /**
14
+ * bw_create_infoobject — create a new InfoObject (CHA or KYF, inactive).
15
+ *
16
+ * KYF flow (POST body accepted by API — no PUT needed):
17
+ * 1. Lock (CREA, stateful_enqueue) → lockHandle
18
+ * 2. Create-POST with full KYF body → object created with correct values
19
+ * 3. Unlock
20
+ * 4. Return lockHandle="" (KYF activation requires no lock)
21
+ *
22
+ * CHA flow (POST body ignored — PUT required):
23
+ * 1. Lock (CREA, stateful_enqueue) → lockHandle
24
+ * 2. Create-POST (minimal body) → object created with server defaults
25
+ * 3. GET /iobj/{name}/m → server-enriched XML + timestamp header
26
+ * 4. Lock (normal, no CREA) → same lockHandle returned (CREA lock still active)
27
+ * 5. PUT /iobj/{name}/m → GET response with desired values substituted
28
+ * 6. Return lockHandle for caller to use with bw_activate
29
+ */
30
+ export async function bwCreateInfoObject(client, args) {
31
+ const isobjType = (args.infoobject_type ?? 'CHA').toUpperCase();
32
+ const nameLower = args.name.toLowerCase();
33
+ const nameUpper = args.name.toUpperCase();
34
+ const pkg = args.package ?? '$TMP';
35
+ const desc = args.description;
36
+ const infoArea = args.info_area.toUpperCase();
37
+ // Step 1: Lock with CREA headers (stateful_enqueue session)
38
+ const lockHandle = await client.lock('iobj', nameLower, {
39
+ activity_context: 'CREA',
40
+ parent_name: infoArea,
41
+ parent_type: 'AREA',
42
+ }, 'stateful_enqueue');
43
+ // ── KYF: POST body accepted — fixedUnit/fixedCurrency via GET+PUT ────────────
44
+ if (isobjType === 'KYF') {
45
+ const ost = (args.object_specific_data_type ?? 'DEC').toUpperCase();
46
+ const mapped = KYF_TYPE_MAP[ost];
47
+ const keyfigureType = mapped?.keyfigureType ?? ost;
48
+ const semantics = mapped?.semantics ?? ost;
49
+ const aggregationType = (args.aggregation_type ?? 'SUM').toUpperCase();
50
+ // SAP ignores fixedUnit/fixedCurrency in the POST body — omit them here
51
+ const language = process.env.BW_LANGUAGE ?? 'DE';
52
+ const kyfXml = `<?xml version="1.0" encoding="UTF-8"?>
53
+ <InfoObject:infoObject
54
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
55
+ xmlns:InfoObject="http://www.sap.com/bw/modeling/BwIobj.ecore"
56
+ xmlns:adtcore="http://www.sap.com/adt/core"
57
+ xsi:type="InfoObject:Keyfigure"
58
+ name="${nameUpper}"
59
+ shortDescriptionSet="false"
60
+ keyFigureSemantic="_"
61
+ keyfigureType="${keyfigureType}"
62
+ objectSpecificDataType="${ost}">
63
+ <infoObjectType>KYF</infoObjectType>
64
+ <dataElement/>
65
+ <longDescription>${desc}</longDescription>
66
+ <shortDescription>${desc}</shortDescription>
67
+ <tlogoProperties adtcore:language="${language}" adtcore:name="${nameUpper}" adtcore:type="IOBJ"
68
+ adtcore:masterLanguage="${language}" adtcore:responsible="${process.env.BW_USER}">
69
+ <infoArea>${infoArea}</infoArea>
70
+ </tlogoProperties>
71
+ <referencedInfoObject/>
72
+ <semantics>${semantics}</semantics>
73
+ <aggregationType>${aggregationType}</aggregationType>
74
+ <exceptionAggregation>
75
+ <referencedCharacteristic/>
76
+ </exceptionAggregation>
77
+ <displayProperties/>
78
+ <inventoryContext/>
79
+ <elimination/>
80
+ <stockCoverageProperties calculationType="B">
81
+ <referencedStockKeyfigure/>
82
+ <referencedDemandKeyfigure/>
83
+ <timeGranularityOfStockCoverage/>
84
+ </stockCoverageProperties>
85
+ </InfoObject:infoObject>`;
86
+ await client.create('iobj', nameLower, lockHandle, kyfXml, { 'Development-Class': pkg });
87
+ await client.unlock('iobj', nameLower);
88
+ // fixedUnit / fixedCurrency: SAP only accepts these via PUT after creation
89
+ if (args.fixed_unit || args.fixed_currency) {
90
+ const freshClient = createClientFromEnv();
91
+ const getResult = await freshClient.get(`/sap/bw/modeling/iobj/${nameLower}/m`, MEDIA_TYPES['iobj']);
92
+ const timestamp = getResult.headers['timestamp'] ?? getResult.headers['TIMESTAMP'];
93
+ let xml = getResult.body;
94
+ const putLockHandle = await client.lock('iobj', nameLower, undefined, 'stateful_enqueue');
95
+ if (args.fixed_unit) {
96
+ const unit = args.fixed_unit.toUpperCase();
97
+ xml = xml.replace('<unitCurrencyInfoObjectRef', `<fixedUnit>${unit}</fixedUnit><unitCurrencyInfoObjectRef`);
98
+ }
99
+ if (args.fixed_currency) {
100
+ const cur = args.fixed_currency.toUpperCase();
101
+ xml = xml.replace('<unitCurrencyInfoObjectRef', `<fixedCurrency>${cur}</fixedCurrency><unitCurrencyInfoObjectRef`);
102
+ }
103
+ await freshClient.put('iobj', nameLower, putLockHandle, xml, timestamp, args.transport);
104
+ await client.unlock('iobj', nameLower);
105
+ }
106
+ const kyfResult = {
107
+ success: true,
108
+ infoobjectType: 'KYF',
109
+ name: nameUpper,
110
+ infoArea: args.info_area,
111
+ description: desc,
112
+ package: pkg,
113
+ lockHandle: '',
114
+ objectSpecificDataType: ost,
115
+ keyfigureType,
116
+ semantics,
117
+ aggregationType,
118
+ };
119
+ if (args.fixed_unit)
120
+ kyfResult['fixedUnit'] = args.fixed_unit.toUpperCase();
121
+ if (args.fixed_currency)
122
+ kyfResult['fixedCurrency'] = args.fixed_currency.toUpperCase();
123
+ kyfResult['message'] = `InfoObject '${nameUpper}' created (inactive). Call bw_activate with lock_handle="" to activate.`;
124
+ return JSON.stringify(kyfResult);
125
+ }
126
+ // ── CHA: POST body ignored — GET + PUT required ────────────────────────────
127
+ // Step 2: Create-POST — API ignores body, creates object with defaults
128
+ const minimalXml = `<?xml version="1.0" encoding="UTF-8"?><iobj:infoObject xmlns:iobj="http://www.sap.com/bw/modeling/BwIobj.ecore" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="iobj:Characteristic" name="${nameUpper}"/>`;
129
+ await client.create('iobj', nameLower, lockHandle, minimalXml, { 'Development-Class': pkg });
130
+ // Step 3: GET server-created object (defaults) to obtain enriched XML + timestamp
131
+ const getResult = await client.get(`/sap/bw/modeling/iobj/${nameLower}/m`, MEDIA_TYPES['iobj']);
132
+ const timestamp = getResult.headers['timestamp'] ?? getResult.headers['TIMESTAMP'];
133
+ // Step 4: Lock again (stateful_enqueue) — same session, server returns same lockHandle
134
+ await client.lock('iobj', nameLower, undefined, 'stateful_enqueue');
135
+ // Step 5: Build PUT body — substitute desired values into the GET response
136
+ let putXml = getResult.body;
137
+ // Remove fieldName attribute — it tells SAP the DDIC field already exists with the old
138
+ // type/length, causing SAP to ignore dataType/length changes in the PUT body.
139
+ putXml = putXml.replace(/\s+fieldName="[^"]*"/, '');
140
+ // Normalise masterDataAccess — GET returns type="GEN" but PUT expects explicit empty attrs
141
+ putXml = putXml.replace(/<masterDataAccess[^/]*\/>/, '<masterDataAccess readClass="" sapHanaPackage="" sapHanaView=""/>');
142
+ const chaDataType = (args.data_type ?? 'CHAR').toUpperCase();
143
+ const chaLength = args.length ?? 10;
144
+ const defaultConv = (chaDataType === 'CHAR' || chaDataType === 'NUMC') ? 'ALPHA' : '';
145
+ const chaConv = args.conversion_routine ?? defaultConv;
146
+ const withMasterData = args.with_master_data ?? false;
147
+ const withTexts = args.with_texts ?? false;
148
+ // Root element attributes
149
+ putXml = putXml.replace(/\bobjectSpecificDataType="[^"]*"/, `objectSpecificDataType="${chaDataType}"`);
150
+ putXml = putXml.replace(/\boutputLength="[^"]*"/, `outputLength="${chaLength}"`);
151
+ if (/\bconversionRoutine="/.test(putXml)) {
152
+ putXml = putXml.replace(/\bconversionRoutine="[^"]*"/, `conversionRoutine="${chaConv}"`);
153
+ }
154
+ else if (chaConv) {
155
+ // Add conversionRoutine attribute to root element if not present
156
+ putXml = putXml.replace(/(<iobj:infoObject\b)/, `$1 conversionRoutine="${chaConv}"`);
157
+ }
158
+ // CHA child elements
159
+ putXml = putXml.replace(/<dataType>[^<]*<\/dataType>/, `<dataType>${chaDataType}</dataType>`);
160
+ putXml = putXml.replace(/<length>[^<]*<\/length>/, `<length>${chaLength}</length>`);
161
+ // masterDataProperties — GET response has NO withMasterData attribute; PUT must add it
162
+ putXml = putXml.replace(/<masterDataProperties(\s[^>]*)?>/, `<masterDataProperties withMasterData="${withMasterData}">`);
163
+ // textProperties — set withTexts / shortTextAvailable / languageDependentTextAvailable
164
+ if (withTexts) {
165
+ putXml = putXml.replace(/<textProperties(\s[^>]*)?>/, `<textProperties shortTextAvailable="true" withTexts="true">`);
166
+ }
167
+ else {
168
+ putXml = putXml.replace(/<textProperties(\s[^>]*)?>/, `<textProperties languageDependentTextAvailable="false" shortTextAvailable="false" withTexts="false">`);
169
+ }
170
+ // Common substitutions — descriptions, InfoArea, texts element, hana mapping
171
+ // SAP XML texts use 1-letter language codes (D, E, F…); BW_LANGUAGE may be ISO (DE, EN…)
172
+ const ISO_TO_SAP = {
173
+ DE: 'D', EN: 'E', FR: 'F', IT: 'I', ES: 'S', NL: 'N', PT: 'P',
174
+ RU: 'R', JA: 'J', KO: 'K', ZH: '1', PL: 'L', CS: 'C',
175
+ };
176
+ const bwLangRaw = process.env.BW_LANGUAGE;
177
+ const bwLangSap = bwLangRaw
178
+ ? (ISO_TO_SAP[bwLangRaw.toUpperCase()] ?? bwLangRaw)
179
+ : undefined;
180
+ putXml = putXml.replace(/<longDescription>[^<]*<\/longDescription>/, `<longDescription>${desc}</longDescription>`);
181
+ putXml = putXml.replace(/<shortDescription>[^<]*<\/shortDescription>/, `<shortDescription>${desc}</shortDescription>`);
182
+ putXml = putXml.replace(/(<texts\b[^/]*?)longText="[^"]*"/, `$1longText="${desc}"`);
183
+ putXml = putXml.replace(/(<texts\b[^/]*?)shortText="[^"]*"/, `$1shortText="${desc}"`);
184
+ if (bwLangSap) {
185
+ putXml = putXml.replace(/(<texts\b[^/]*?)language="[^"]*"/, `$1language="${bwLangSap}"`);
186
+ }
187
+ putXml = putXml.replace(/\badtcore:description="[^"]*"/, `adtcore:description="${desc}"`);
188
+ putXml = putXml.replace(/<infoArea>[^<]*<\/infoArea>/, `<infoArea>${infoArea}</infoArea>`);
189
+ putXml = putXml.replace(/(<sourceField\b[^/]*?)\bdescription="[^"]*"/, `$1description="${desc}"`);
190
+ putXml = putXml.replace(/(<sourceField\b[^/]*?)\bshortDescription="[^"]*"/, `$1shortDescription="${desc}"`);
191
+ // Step 6: Insert compoundParent elements if compound_infoobjects is provided (CHA only)
192
+ if (args.compound_infoobjects && args.compound_infoobjects.length > 0) {
193
+ const freshClient = createClientFromEnv();
194
+ const compoundParentElements = [];
195
+ for (const parent of args.compound_infoobjects) {
196
+ const parentLower = parent.toLowerCase();
197
+ const parentUpper = parent.toUpperCase();
198
+ const parentGet = await freshClient.get(`/sap/bw/modeling/iobj/${parentLower}/a`, MEDIA_TYPES['iobj']);
199
+ const parentXml = parentGet.body;
200
+ const parentProps = parseInfoObjectProps(parentXml);
201
+ const parentLongDescMatch = parentXml.match(/<longDescription>([^<]+)<\/longDescription>/);
202
+ const parentDescription = parentLongDescMatch?.[1] ?? parentProps.label;
203
+ compoundParentElements.push(`<compoundParent` +
204
+ ` description="${parentDescription}"` +
205
+ ` infoObjectType="CHA"` +
206
+ ` name="${parentUpper}"` +
207
+ ` ref="../../${parentLower}/a/model.iobj#//"` +
208
+ ` dataType="${parentProps.dataType}"` +
209
+ ` length="${parentProps.length}">` +
210
+ `<referencedCharacteristic xsi:type="iobj:ReferencedInfoObject" description=""/>` +
211
+ `</compoundParent>`);
212
+ }
213
+ const compoundParentXml = compoundParentElements.join('\n') + '\n';
214
+ const insertionPoints = [
215
+ '<externalSAPHANAView',
216
+ '<runtimeProperties',
217
+ '<sidTable',
218
+ '</iobj:infoObject>',
219
+ ];
220
+ let inserted = false;
221
+ for (const point of insertionPoints) {
222
+ if (putXml.includes(point)) {
223
+ putXml = putXml.replace(point, compoundParentXml + point);
224
+ inserted = true;
225
+ break;
226
+ }
227
+ }
228
+ if (!inserted) {
229
+ throw new Error('Could not find insertion point for compoundParent elements in PUT XML');
230
+ }
231
+ }
232
+ // Step 7: PUT — applies the correct values
233
+ await client.put('iobj', nameLower, lockHandle, putXml, timestamp, args.transport);
234
+ const resultExtra = {
235
+ dataType: chaDataType,
236
+ length: chaLength,
237
+ conversionRoutine: chaConv,
238
+ withMasterData,
239
+ withTexts,
240
+ };
241
+ if (args.referenced_infoobject)
242
+ resultExtra['referencedInfoObject'] = args.referenced_infoobject.toUpperCase();
243
+ if (args.compound_infoobjects?.length)
244
+ resultExtra['compoundParents'] = args.compound_infoobjects.map(p => p.toUpperCase());
245
+ return JSON.stringify({
246
+ success: true,
247
+ infoobjectType: 'CHA',
248
+ name: nameUpper,
249
+ infoArea: args.info_area,
250
+ description: desc,
251
+ package: pkg,
252
+ lockHandle,
253
+ ...resultExtra,
254
+ message: `InfoObject '${nameUpper}' created (inactive). Call bw_activate with lock_handle="${lockHandle}" to activate.`,
255
+ });
256
+ }
257
+ /**
258
+ * Parse key properties from an InfoObject XML response.
259
+ * Used by bw_update_adso and bw_update_transformation.
260
+ */
261
+ export function parseInfoObjectProps(xml) {
262
+ const convMatch = xml.match(/conversionRoutine="([^"]+)"/);
263
+ const shortDescMatch = xml.match(/<shortDescription>([^<]+)<\/shortDescription>/);
264
+ const dataTypeMatch = xml.match(/<dataType>([^<]+)<\/dataType>/);
265
+ const lengthMatch = xml.match(/<length>([^<]+)<\/length>/);
266
+ return {
267
+ conversionRoutine: convMatch?.[1] ?? '',
268
+ label: shortDescMatch?.[1] ?? '',
269
+ dataType: dataTypeMatch?.[1] ?? 'CHAR',
270
+ length: lengthMatch?.[1] ?? '20',
271
+ };
272
+ }
273
+ /**
274
+ * bw_update_infoobject — replace the attribute list of a Characteristic InfoObject.
275
+ *
276
+ * Flow:
277
+ * 1. Lock (update — no activity_context)
278
+ * 2. GET /iobj/{name}/m → current XML + timestamp
279
+ * 3. For each attribute: GET /iobj/{attr}/a → description, shortDescription, dataType, length
280
+ * 4. Remove all existing <attributeN .../> elements
281
+ * 5. Remove existing <hanaAttributeMapping type="02"> block
282
+ * 6. Insert new <attributeN> elements before <externalSAPHANAView>
283
+ * 7. If attributes present: insert <hanaAttributeMapping type="02"> before type="03"
284
+ * 8. PUT full XML with timestamp
285
+ * 9. Activate + Unlock (unlock in finally)
286
+ */
287
+ export async function bwUpdateInfoObject(client, args) {
288
+ const nameLower = args.name.toLowerCase();
289
+ const nameUpper = args.name.toUpperCase();
290
+ const attributes = args.attributes ?? [];
291
+ // Step 1: Lock with the original client (holds the lock session)
292
+ const lockHandle = await client.lock('iobj', nameLower, undefined, 'stateful_enqueue');
293
+ try {
294
+ // Step 2: GET current XML — fresh session to avoid SAP session state pollution
295
+ const freshClient = createClientFromEnv();
296
+ const getResult = await freshClient.get(`/sap/bw/modeling/iobj/${nameLower}/m`, MEDIA_TYPES['iobj']);
297
+ const timestamp = getResult.headers['timestamp'] ?? getResult.headers['TIMESTAMP'];
298
+ let xml = getResult.body;
299
+ // ── KYF fast path: only patch fixedUnit / fixedCurrency / description ─────
300
+ const isKyf = /<infoObjectType>KYF<\/infoObjectType>/.test(xml);
301
+ if (isKyf) {
302
+ if (args.description) {
303
+ const desc = args.description;
304
+ xml = xml.replace(/<longDescription>[^<]*<\/longDescription>/, `<longDescription>${desc}</longDescription>`);
305
+ xml = xml.replace(/<shortDescription>[^<]*<\/shortDescription>/, `<shortDescription>${desc}</shortDescription>`);
306
+ xml = xml.replace(/(<texts\b[^/]*?)longText="[^"]*"/, `$1longText="${desc}"`);
307
+ xml = xml.replace(/(<texts\b[^/]*?)shortText="[^"]*"/, `$1shortText="${desc}"`);
308
+ xml = xml.replace(/\badtcore:description="[^"]*"/, `adtcore:description="${desc}"`);
309
+ }
310
+ if (args.fixed_unit) {
311
+ const unit = args.fixed_unit.toUpperCase();
312
+ if (/<fixedUnit>/.test(xml)) {
313
+ xml = xml.replace(/<fixedUnit>[^<]*<\/fixedUnit>/, `<fixedUnit>${unit}</fixedUnit>`);
314
+ }
315
+ else {
316
+ xml = xml.replace('<unitCurrencyInfoObjectRef', `<fixedUnit>${unit}</fixedUnit><unitCurrencyInfoObjectRef`);
317
+ }
318
+ }
319
+ if (args.fixed_currency) {
320
+ const cur = args.fixed_currency.toUpperCase();
321
+ if (/<fixedCurrency>/.test(xml)) {
322
+ xml = xml.replace(/<fixedCurrency>[^<]*<\/fixedCurrency>/, `<fixedCurrency>${cur}</fixedCurrency>`);
323
+ }
324
+ else {
325
+ xml = xml.replace('<unitCurrencyInfoObjectRef', `<fixedCurrency>${cur}</fixedCurrency><unitCurrencyInfoObjectRef`);
326
+ }
327
+ }
328
+ await freshClient.put('iobj', nameLower, lockHandle, xml, timestamp, args.transport);
329
+ const activationClient = createClientFromEnv();
330
+ await activationClient.activate('iobj', nameLower, lockHandle);
331
+ return JSON.stringify({
332
+ success: true,
333
+ name: nameUpper,
334
+ message: `InfoObject '${nameUpper}' (KYF) updated and activated.`,
335
+ });
336
+ }
337
+ // Step 3: Fetch referenced InfoObject properties for each attribute
338
+ const attrXmlParts = [];
339
+ const sourceFieldParts = [];
340
+ for (const attr of attributes) {
341
+ const attrUpper = attr.name.toUpperCase();
342
+ const attrLower = attr.name.toLowerCase();
343
+ const attrGet = await freshClient.get(`/sap/bw/modeling/iobj/${attrLower}/a`, MEDIA_TYPES['iobj']);
344
+ const attrXml = attrGet.body;
345
+ const props = parseInfoObjectProps(attrXml);
346
+ const longDescMatch = attrXml.match(/<longDescription>([^<]+)<\/longDescription>/);
347
+ const description = longDescMatch?.[1] ?? props.label;
348
+ const shortDescription = props.label;
349
+ const displayInQuery = attr.displayInQuery ?? true;
350
+ const useTextOfOrig = attr.useTextOfOriginalCharacteristic ?? true;
351
+ const ref = `../../${attrLower}/a/model.iobj#//`;
352
+ if (attr.type === 'NAV') {
353
+ const timeDependent = attr.timeDependent ?? false;
354
+ attrXmlParts.push(`<attributeN` +
355
+ ` description="${description}"` +
356
+ ` infoObjectType="CHA"` +
357
+ ` name="${attrUpper}"` +
358
+ ` ref="${ref}"` +
359
+ ` shortDescription="${shortDescription}"` +
360
+ ` calculationScenarioNavigationAttribute="true"` +
361
+ ` dataType="${props.dataType}"` +
362
+ ` displayInQuery="${displayInQuery}"` +
363
+ ` f4HelpOrder="0"` +
364
+ ` length="${props.length}"` +
365
+ (timeDependent ? ` timeDependent="true"` : '') +
366
+ ` type="NAV"` +
367
+ ` useTextOfOriginalCharacteristic="${useTextOfOrig}"/>`);
368
+ }
369
+ else {
370
+ attrXmlParts.push(`<attributeN` +
371
+ ` description="${description}"` +
372
+ ` infoObjectType="CHA"` +
373
+ ` name="${attrUpper}"` +
374
+ ` ref="${ref}"` +
375
+ ` shortDescription="${shortDescription}"` +
376
+ ` dataType="${props.dataType}"` +
377
+ ` displayInQuery="${displayInQuery}"` +
378
+ ` f4HelpOrder="0"` +
379
+ ` hasAttributes="false"` +
380
+ ` length="${props.length}"` +
381
+ ` partOfIndex="false"` +
382
+ ` sidKeyFigure="false"` +
383
+ ` type="DIS"` +
384
+ ` useTextOfOriginalCharacteristic="${useTextOfOrig}"/>`);
385
+ }
386
+ sourceFieldParts.push(`<sourceField description="${description}" infoObjectType="CHA"` +
387
+ ` name="${attrUpper}" shortDescription="${shortDescription}"/>`);
388
+ }
389
+ // Step 4: Remove all existing <attributeN .../> self-closing elements
390
+ xml = xml.replace(/\s*<attributeN\b[^>]*\/>/g, '');
391
+ // Step 5: Remove existing hanaAttributeMapping type="02" block — only when rebuilding
392
+ // with new attributes. When removing all attributes (empty list), SAP cleans up
393
+ // hanaAttributeMapping type="02" automatically on activation.
394
+ if (attrXmlParts.length > 0) {
395
+ xml = xml.replace(/\s*<hanaAttributeMapping type="02">[\s\S]*?<\/hanaAttributeMapping>/g, '');
396
+ }
397
+ // Step 6: Insert new attributeN elements before <runtimeProperties
398
+ if (attrXmlParts.length > 0) {
399
+ const attrBlock = attrXmlParts.join('\n') + '\n';
400
+ xml = xml.replace(/(<runtimeProperties)/, attrBlock + '$1');
401
+ }
402
+ // Step 7: Insert hanaAttributeMapping type="02" before type="03" if attributes present
403
+ if (sourceFieldParts.length > 0) {
404
+ const hanaMappingBlock = `<hanaAttributeMapping type="02">\n` +
405
+ sourceFieldParts.join('\n') + '\n' +
406
+ `</hanaAttributeMapping>\n`;
407
+ xml = xml.replace('<hanaAttributeMapping type="03">', hanaMappingBlock + '<hanaAttributeMapping type="03">');
408
+ }
409
+ // Patch description if provided
410
+ if (args.description) {
411
+ const desc = args.description;
412
+ xml = xml.replace(/<longDescription>[^<]*<\/longDescription>/, `<longDescription>${desc}</longDescription>`);
413
+ xml = xml.replace(/<shortDescription>[^<]*<\/shortDescription>/, `<shortDescription>${desc}</shortDescription>`);
414
+ xml = xml.replace(/(<texts\b[^/]*?)longText="[^"]*"/, `$1longText="${desc}"`);
415
+ xml = xml.replace(/(<texts\b[^/]*?)shortText="[^"]*"/, `$1shortText="${desc}"`);
416
+ xml = xml.replace(/\badtcore:description="[^"]*"/, `adtcore:description="${desc}"`);
417
+ }
418
+ // Step 8: PUT full XML — same fresh session as GET
419
+ await freshClient.put('iobj', nameLower, lockHandle, xml, timestamp, args.transport);
420
+ // Step 9: Activate — another fresh session (mirrors TRFN pattern)
421
+ const activationClient = createClientFromEnv();
422
+ await activationClient.activate('iobj', nameLower, lockHandle);
423
+ }
424
+ finally {
425
+ // Unlock with the original lock-session client
426
+ await client.unlock('iobj', nameLower).catch(() => undefined);
427
+ }
428
+ return JSON.stringify({
429
+ success: true,
430
+ name: nameUpper,
431
+ attributeCount: attributes.length,
432
+ attributes: attributes.map((a) => ({ name: a.name.toUpperCase(), type: a.type })),
433
+ message: `InfoObject '${nameUpper}' updated and activated with ${attributes.length} attribute(s).`,
434
+ });
435
+ }
436
+ // ─────────────────────────────────────────────────────────────────────────────
437
+ /**
438
+ * bw_get_infoobject — read an InfoObject definition (inactive version).
439
+ * Returns raw XML + object status from response headers.
440
+ */
441
+ export async function bwGetInfoObject(client, infoObjectName) {
442
+ const accept = MEDIA_TYPES['iobj'];
443
+ const path = `/sap/bw/modeling/iobj/${infoObjectName.toLowerCase()}/m`;
444
+ const result = await client.get(path, accept);
445
+ const status = result.headers['object_status'] ?? result.headers['OBJECT_STATUS'] ?? 'unknown';
446
+ return `InfoObject: ${infoObjectName.toUpperCase()}\nStatus: ${status}\n\n${result.body}`;
447
+ }