import crypto from 'crypto'; import { getBcConfig, getBCToken, getItemsUrl, getItemUnitsOfMeasureUrl } from './bc-runtime.js'; function findHeaderIndex(headers, patterns) { const normalized = headers.map(h => String(h || '').toLowerCase()); return normalized.findIndex(header => patterns.every(pattern => header.includes(pattern.toLowerCase())) ); } function findCategorizationCodeIndex(headers) { const normalized = headers.map(h => String(h || '').toLowerCase().trim()); const categorizationIdx = normalized.findIndex(header => header.replace(/[\s_-]+/g, '') === 'categorizationcode'); if (categorizationIdx >= 0) return categorizationIdx; const typeIdx = normalized.findIndex(header => header === 'type'); if (typeIdx >= 0) return typeIdx; return normalized.findIndex(header => header.includes('product') && header.includes('type')); } function formatDateForBc(value) { if (value === null || value === undefined || value === '') return null; if (typeof value === 'number' && value >= 25569 && value <= 60000) { const excelEpoch = new Date(1899, 11, 30); const date = new Date(excelEpoch.getTime() + value * 86400000); return date.toISOString().split('T')[0]; } const str = String(value).trim(); if (!str) return null; if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str; const parsed = new Date(str); if (!Number.isNaN(parsed.getTime())) { return parsed.toISOString().split('T')[0]; } return str; } function getValue(row, index) { if (index === null || index < 0) return null; const value = row[index]; return value === undefined || value === '' ? null : value; } function toBcDecimal(value) { if (value === null || value === undefined || value === '') return null; if (typeof value === 'number' && Number.isFinite(value)) return value; const normalized = String(value).trim().replace(/\s+/g, '').replace(',', '.'); if (!normalized) return null; const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : null; } function makeFieldPreview(sourceLabel, sourceIndex, targetField, value) { return { sourceLabel, sourceIndex, targetField, value }; } export function buildBusinessCentralMappingPreview(headers, row) { const articleNoIdx = findHeaderIndex(headers, ['article', 'no']); const articleDetailsEnIdx = findHeaderIndex(headers, ['article', 'details', 'english']); const articleDetailsDeIdx = findHeaderIndex(headers, ['article', 'details', 'german']); const launchDateIdx = findHeaderIndex(headers, ['launch']); const readyToOrderDateIdx = findHeaderIndex(headers, ['ready']); const moqIdx = findHeaderIndex(headers, ['moq']); const shortDeIdx = findHeaderIndex(headers, ['short', 'description', 'german']); const shortEnIdx = findHeaderIndex(headers, ['short', 'description', 'english']); const cpnpIdx = findHeaderIndex(headers, ['cpnp']); const categorizationCodeIdx = findCategorizationCodeIndex(headers); const unitsOuterIdx = findHeaderIndex(headers, ['units', 'outer']); const units40HqIdx = (() => { const hqIdx = findHeaderIndex(headers, ['40', 'hq']); if (hqIdx >= 0) return hqIdx; return findHeaderIndex(headers, ['40', 'hc']); })(); const innerWIdx = findHeaderIndex(headers, ['inner', 'w']); const innerLIdx = findHeaderIndex(headers, ['inner', 'l']); const innerHIdx = findHeaderIndex(headers, ['inner', 'h']); const outerWIdx = findHeaderIndex(headers, ['outer', 'w']); const outerLIdx = findHeaderIndex(headers, ['outer', 'l']); const outerHIdx = findHeaderIndex(headers, ['outer', 'h']); const articleNo = String(getValue(row, articleNoIdx) ?? ''); const categorizationCode = getValue(row, categorizationCodeIdx); const hasCategorizationCode = String(categorizationCode ?? '').trim() !== ''; const itemsPayload = { no: getValue(row, articleNoIdx), articleDetailsEnglish: getValue(row, articleDetailsEnIdx), articleDetailsGerman: getValue(row, articleDetailsDeIdx), launchDate: formatDateForBc(getValue(row, launchDateIdx)), readyToOrderDate: formatDateForBc(getValue(row, readyToOrderDateIdx)), minimumOrderQuantity: toBcDecimal(getValue(row, moqIdx)), shortDescriptionInGerman: getValue(row, shortDeIdx), shortDescriptionInEnglish: getValue(row, shortEnIdx), cpnpNo: getValue(row, cpnpIdx), }; if (hasCategorizationCode) { itemsPayload.categorizationCode = String(categorizationCode).trim(); } const itemUnitsOfMeasurePayload = { itemNo: getValue(row, articleNoIdx), code: 'OUTER', qtyPerUnitOfMeasure: toBcDecimal(getValue(row, unitsOuterIdx)), width: toBcDecimal(getValue(row, outerWIdx)), length: toBcDecimal(getValue(row, outerLIdx)), height: toBcDecimal(getValue(row, outerHIdx)), }; const itemUnits40HCPayload = { itemNo: getValue(row, articleNoIdx), code: '40HC', qtyPerUnitOfMeasure: toBcDecimal(getValue(row, units40HqIdx)), }; return { articleNo, itemsPayload, itemUnitsOfMeasurePayload, itemUnits40HCPayload, itemsFields: [ makeFieldPreview('Article No.', articleNoIdx, 'no', itemsPayload.no), makeFieldPreview('Article Details - English', articleDetailsEnIdx, 'articleDetailsEnglish', itemsPayload.articleDetailsEnglish), makeFieldPreview('Article Details - German', articleDetailsDeIdx, 'articleDetailsGerman', itemsPayload.articleDetailsGerman), makeFieldPreview('Launch Date', launchDateIdx, 'launchDate', itemsPayload.launchDate), makeFieldPreview('Ready to Order Date', readyToOrderDateIdx, 'readyToOrderDate', itemsPayload.readyToOrderDate), makeFieldPreview('MOQ', moqIdx, 'minimumOrderQuantity', itemsPayload.minimumOrderQuantity), makeFieldPreview('Short Description - German', shortDeIdx, 'shortDescriptionInGerman', itemsPayload.shortDescriptionInGerman), makeFieldPreview('Short Description - English', shortEnIdx, 'shortDescriptionInEnglish', itemsPayload.shortDescriptionInEnglish), makeFieldPreview('CPNP', cpnpIdx, 'cpnpNo', itemsPayload.cpnpNo), ...(hasCategorizationCode ? [makeFieldPreview('CategorizationCode', categorizationCodeIdx, 'categorizationCode', itemsPayload.categorizationCode)] : []), ], itemUnitsFields: [ makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnitsOfMeasurePayload.itemNo), makeFieldPreview('Units Outer', unitsOuterIdx, 'qtyPerUnitOfMeasure', itemUnitsOfMeasurePayload.qtyPerUnitOfMeasure), makeFieldPreview('Outer W (cm)', outerWIdx, 'width', itemUnitsOfMeasurePayload.width), makeFieldPreview('Outer L (cm)', outerLIdx, 'length', itemUnitsOfMeasurePayload.length), makeFieldPreview('Outer H (cm)', outerHIdx, 'height', itemUnitsOfMeasurePayload.height), ], itemUnits40HCFields: [ makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnits40HCPayload.itemNo), makeFieldPreview('Units 40FT HQ', units40HqIdx, 'qtyPerUnitOfMeasure', itemUnits40HCPayload.qtyPerUnitOfMeasure), ], }; } function normalizeForComparison(field, value) { const strField = String(field || '').toLowerCase(); if (DECIMAL_FIELDS.has(field) && (value === null || value === undefined || value === '')) { return '0'; } if (strField.includes('date')) { if (value === null || value === undefined || value === '') { return '0001-01-01'; } const normalizedDate = formatDateForBc(value); return normalizedDate === '0001-01-01' ? '0001-01-01' : normalizedDate; } if (value === null || value === undefined || value === '') return null; if (typeof value === 'string') { return value.replace(/\r\n/g, '\n').trimEnd(); } if (typeof value === 'number' && Number.isFinite(value)) { return String(value); } if (typeof value === 'boolean') { return value ? 'true' : 'false'; } return String(value).trim(); } function buildFieldChanges(fields, currentRecord, desiredPayload) { return fields .filter(field => field.targetField !== 'itemNo' && field.targetField !== 'no') .map(field => { const before = currentRecord ? currentRecord[field.targetField] : undefined; const after = desiredPayload[field.targetField]; const normalizedBefore = normalizeForComparison(field.targetField, before); const normalizedAfter = normalizeForComparison(field.targetField, after); return { sourceLabel: field.sourceLabel, sourceIndex: field.sourceIndex, targetField: field.targetField, before, after, changed: normalizedBefore !== normalizedAfter, }; }); } function describeUnappliedChanges(sectionLabel, changes) { return changes .filter(change => change.changed) .map(change => `${change.sourceLabel} (${change.targetField}): expected ${JSON.stringify(change.after)} but BC has ${JSON.stringify(change.before)}`) .join('; '); } function buildChangedPayload(changes) { const payload = {}; changes .filter(change => change.changed) .forEach(change => { const field = change.targetField; const value = change.after; if (DECIMAL_FIELDS.has(field)) { payload[field] = toBcDecimal(value); return; } if (EMPTY_STRING_FIELDS.has(field)) { payload[field] = value === null || value === undefined ? '' : String(value); return; } if (String(field || '').toLowerCase().includes('date')) { payload[field] = formatDateForBc(value); return; } payload[field] = value; }); return payload; } function buildPreviewHash(payload) { return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); } function getItemsSelect() { return [ 'systemId', 'no', 'articleDetailsEnglish', 'articleDetailsGerman', 'launchDate', 'readyToOrderDate', 'minimumOrderQuantity', 'shortDescriptionInGerman', 'shortDescriptionInEnglish', 'cpnpNo', 'categorizationCode', ].join(','); } function getItemUnitsSelect() { return [ 'itemNo', 'code', 'qtyPerUnitOfMeasure', 'qtyRoundingPrecision', 'length', 'width', 'height', 'cubage', 'weight', 'layerPerPalet2CRZ', 'outerPerLayer2CRZ', 'barCodeCRZ', 'layerPerPaletCRZ', 'outerPerLayerCRZ', 'netWeightCRZ', 'innerTypeBCT', ].join(','); } async function fetchJsonOrThrow(url, token, label) { const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, }); if (!res.ok) { const txt = await res.text(); throw new Error(`${label} failed (${res.status}): ${txt}`); } return { json: await res.json(), etag: res.headers.get('etag') || null, }; } export async function fetchBusinessCentralSnapshot(config, token, articleNo) { const itemsFilter = encodeURIComponent(`no eq '${articleNo}'`); const itemsUrl = `${getItemsUrl(config)}?$filter=${itemsFilter}&$select=${getItemsSelect()}`; const { json: itemsJson, etag: itemsEtag } = await fetchJsonOrThrow(itemsUrl, token, 'BC GET items'); const item = Array.isArray(itemsJson.value) && itemsJson.value.length > 0 ? itemsJson.value[0] : null; const outerCode = config.itemUnitsCode || 'OUTER'; const uomUrl = getItemUnitsOfMeasureUrl(config); const outerFilter = encodeURIComponent(`itemNo eq '${articleNo}' and code eq '${outerCode}'`); const outerUrl = `${uomUrl}?$filter=${outerFilter}&$select=${getItemUnitsSelect()}`; const { json: outerJson, etag: outerEtag } = await fetchJsonOrThrow(outerUrl, token, 'BC GET itemUnitsOfMeasure OUTER'); const itemUnitsOfMeasure = Array.isArray(outerJson.value) && outerJson.value.length > 0 ? outerJson.value[0] : null; const hcFilter = encodeURIComponent(`itemNo eq '${articleNo}' and code eq '40HC'`); const hcUrl = `${uomUrl}?$filter=${hcFilter}&$select=${getItemUnitsSelect()}`; const { json: hcJson, etag: hcEtag } = await fetchJsonOrThrow(hcUrl, token, 'BC GET itemUnitsOfMeasure 40HC'); const itemUnits40HC = Array.isArray(hcJson.value) && hcJson.value.length > 0 ? hcJson.value[0] : null; return { item, itemUnitsOfMeasure, itemUnits40HC, itemEtag: item?.['@odata.etag'] || itemsEtag || '*', itemUnitsEtag: itemUnitsOfMeasure?.['@odata.etag'] || outerEtag || '*', itemUnits40HCEtag: itemUnits40HC?.['@odata.etag'] || hcEtag || '*', }; } function makePreviewSection({ type, desiredPayload, currentRecord, fieldPreviews, writeMethod, writeUrlTemplate, writeBodyTemplate, supported = true, supportReason = null, }) { const changes = buildFieldChanges(fieldPreviews, currentRecord, desiredPayload); return { type, desired: desiredPayload, current: currentRecord || null, changes, changedFields: changes.filter(change => change.changed).map(change => change.targetField), writeConfigured: Boolean(writeUrlTemplate), writeMethod: writeMethod || null, writeUrlTemplate: writeUrlTemplate || null, writeBodyTemplate: writeBodyTemplate || null, canApply: Boolean(writeUrlTemplate) && supported, supported, supportReason, }; } export function buildBusinessCentralSyncPreview(config, mapping, snapshot) { const itemsSection = makePreviewSection({ type: 'items', desiredPayload: mapping.itemsPayload, currentRecord: snapshot.item, fieldPreviews: mapping.itemsFields, writeMethod: config.itemsWriteMethod || config.writeMethod, writeUrlTemplate: config.itemsWriteUrlTemplate || config.writeUrlTemplate, writeBodyTemplate: config.itemsWriteBodyTemplate || null, }); const itemUnitsSection = makePreviewSection({ type: 'itemUnitsOfMeasure', desiredPayload: mapping.itemUnitsOfMeasurePayload, currentRecord: snapshot.itemUnitsOfMeasure, fieldPreviews: mapping.itemUnitsFields, writeMethod: config.itemUnitsWriteMethod || null, writeUrlTemplate: config.itemUnitsWriteUrlTemplate || null, writeBodyTemplate: config.itemUnitsWriteBodyTemplate || null, }); const itemUnits40HCSection = makePreviewSection({ type: 'itemUnits40HC', desiredPayload: mapping.itemUnits40HCPayload, currentRecord: snapshot.itemUnits40HC, fieldPreviews: mapping.itemUnits40HCFields, writeMethod: config.itemUnits40HCWriteMethod || config.itemUnitsWriteMethod || null, writeUrlTemplate: config.itemUnits40HCWriteUrlTemplate || config.itemUnitsWriteUrlTemplate || null, writeBodyTemplate: config.itemUnits40HCWriteBodyTemplate || config.itemUnitsWriteBodyTemplate || null, }); const previewPayload = { articleNo: mapping.articleNo, items: itemsSection, itemUnitsOfMeasure: itemUnitsSection, itemUnits40HC: itemUnits40HCSection, }; return { ...previewPayload, hasChanges: itemsSection.changes.some(change => change.changed) || itemUnitsSection.changes.some(change => change.changed) || itemUnits40HCSection.changes.some(change => change.changed), previewToken: buildPreviewHash({ articleNo: mapping.articleNo, items: itemsSection.changes, itemUnitsOfMeasure: itemUnitsSection.changes, itemUnits40HC: itemUnits40HCSection.changes, }), }; } export async function previewBusinessCentralSync(config, token, headers, row) { const mapping = buildBusinessCentralMappingPreview(headers, row); const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo); return buildBusinessCentralSyncPreview(config, mapping, snapshot); } async function renderAndPatchRecord({ token, url, method, body, etag }) { const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }; if (String(method || 'PATCH').toUpperCase() === 'PATCH') { headers['If-Match'] = etag || '*'; } const res = await fetch(url, { method: method || 'PATCH', headers, body, }); if (!res.ok) { const txt = await res.text(); throw new Error(`BC write failed (${res.status}): ${txt}`); } } function renderTemplate(template, context) { return String(template).replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, key) => { const value = context[key]; if (value === null || value === undefined) return ''; if (typeof value === 'object') return JSON.stringify(value); return String(value); }); } const DECIMAL_FIELDS = new Set([ 'minimumOrderQuantity', 'qtyPerUnitOfMeasure', 'length', 'width', 'height', 'cubage', 'weight', 'layerPerPalet2CRZ', 'outerPerLayer2CRZ', 'netWeightCRZ', ]); const EMPTY_STRING_FIELDS = new Set([ 'articleDetailsEnglish', 'articleDetailsGerman', 'shortDescriptionInGerman', 'shortDescriptionInEnglish', 'cpnpNo', ]); function normalizeDecimalPayload(value) { if (Array.isArray(value)) { return value.map(normalizeDecimalPayload); } if (!value || typeof value !== 'object') { return value; } const next = {}; for (const [key, nested] of Object.entries(value)) { if (EMPTY_STRING_FIELDS.has(key) && (nested === null || nested === undefined)) { next[key] = ''; continue; } if (DECIMAL_FIELDS.has(key)) { next[key] = toBcDecimal(nested); continue; } next[key] = normalizeDecimalPayload(nested); } return next; } function renderJsonBody(template, context, fallbackPayload) { const rendered = template ? renderTemplate(template, context) : JSON.stringify(fallbackPayload); if (typeof rendered !== 'string') { return JSON.stringify(normalizeDecimalPayload(rendered)); } try { const parsed = JSON.parse(rendered); return JSON.stringify(normalizeDecimalPayload(parsed)); } catch { return rendered; } } async function applyItemsSection(config, token, snapshot, preview, context) { const changedFields = preview.items.changes.filter(change => change.changed); if (changedFields.length === 0) { return { applied: false, reason: 'No item changes' }; } if (!config.itemsWriteUrlTemplate) { return { applied: false, reason: 'Items write template not configured' }; } const url = renderTemplate(config.itemsWriteUrlTemplate, context); const payload = renderJsonBody(preview.items.writeBodyTemplate, context, buildChangedPayload(preview.items.changes)); await renderAndPatchRecord({ token, url, method: preview.items.writeMethod || 'PATCH', body: payload, etag: snapshot.itemEtag || snapshot.item?.['@odata.etag'] || '*', }); return { applied: true, url }; } async function applyItemUnitsSection(config, token, snapshot, preview, context) { const changedFields = preview.itemUnitsOfMeasure.changes.filter(change => change.changed); if (changedFields.length === 0) { return { applied: false, reason: 'No itemUnitsOfMeasure changes' }; } if (!preview.itemUnitsOfMeasure.supported) { return { applied: false, reason: preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only' }; } if (!config.itemUnitsWriteUrlTemplate) { return { applied: false, reason: 'itemUnitsOfMeasure write template not configured' }; } const url = renderTemplate(config.itemUnitsWriteUrlTemplate, context); const payload = renderJsonBody(preview.itemUnitsOfMeasure.writeBodyTemplate, context, buildChangedPayload(preview.itemUnitsOfMeasure.changes)); await renderAndPatchRecord({ token, url, method: preview.itemUnitsOfMeasure.writeMethod || 'PATCH', body: payload, etag: snapshot.itemUnitsEtag || snapshot.itemUnitsOfMeasure?.['@odata.etag'] || '*', }); return { applied: true, url }; } async function applyItemUnits40HCSection(config, token, snapshot, preview, context) { const changedFields = preview.itemUnits40HC.changes.filter(change => change.changed); if (changedFields.length === 0) { return { applied: false, reason: 'No itemUnits40HC changes' }; } if (!config.itemUnits40HCWriteUrlTemplate && !config.itemUnitsWriteUrlTemplate) { return { applied: false, reason: 'itemUnits40HC write template not configured' }; } const urlTemplate = config.itemUnits40HCWriteUrlTemplate || config.itemUnitsWriteUrlTemplate; const writeMethod = preview.itemUnits40HC.writeMethod || 'PATCH'; const hcContext = { ...context, itemUnitsCode: '40HC', itemUnits40HCCode: '40HC', itemUnitsPayload: preview.itemUnits40HC.desired, }; const url = renderTemplate(urlTemplate, hcContext); const payload = renderJsonBody(preview.itemUnits40HC.writeBodyTemplate, hcContext, buildChangedPayload(preview.itemUnits40HC.changes)); await renderAndPatchRecord({ token, url, method: writeMethod, body: payload, etag: snapshot.itemUnits40HCEtag || snapshot.itemUnits40HC?.['@odata.etag'] || '*', }); return { applied: true, url }; } export async function applyBusinessCentralSync(config, token, headers, row, previewToken) { const mapping = buildBusinessCentralMappingPreview(headers, row); const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo); const preview = buildBusinessCentralSyncPreview(config, mapping, snapshot); const hasItemUnitsChanges = preview.itemUnitsOfMeasure.changes.some(change => change.changed); if (hasItemUnitsChanges && !snapshot.itemUnitsOfMeasure) { const error = new Error(`BC itemUnitsOfMeasure row missing for ${mapping.articleNo}. This BC API cannot update these fields until the row exists or BC exposes an upsert action.`); error.statusCode = 409; throw error; } const hasItemUnits40HCChanges = preview.itemUnits40HC.changes.some(change => change.changed); if (hasItemUnits40HCChanges && !snapshot.itemUnits40HC) { const error = new Error(`BC itemUnits40HC row missing for ${mapping.articleNo}. This BC API cannot update these fields until the row exists or BC exposes an upsert action.`); error.statusCode = 409; throw error; } if (previewToken && previewToken !== preview.previewToken) { const error = new Error('Preview token mismatch. BC data changed or preview is stale.'); error.statusCode = 409; throw error; } const context = { itemsUrl: getItemsUrl(config), itemUnitsOfMeasureUrl: getItemUnitsOfMeasureUrl(config), articleNo: mapping.articleNo, itemNo: mapping.articleNo, itemUnitsCode: config.itemUnitsCode || 'OUTER', itemUnits40HCCode: '40HC', systemId: snapshot.item?.systemId || '', cpnpNo: mapping.itemsPayload.cpnpNo, itemsPayload: preview.items.desired, itemUnitsPayload: preview.itemUnitsOfMeasure.desired, itemUnits40HCPayload: preview.itemUnits40HC.desired, ...preview.items.desired, ...preview.itemUnitsOfMeasure.desired, ...preview.itemUnits40HC.desired, }; const results = { items: await applyItemsSection(config, token, snapshot, preview, context), itemUnitsOfMeasure: await applyItemUnitsSection(config, token, snapshot, preview, context), itemUnits40HC: await applyItemUnits40HCSection(config, token, snapshot, preview, context), }; if (results.items.applied || results.itemUnitsOfMeasure.applied || results.itemUnits40HC.applied) { const verificationSnapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo); if (results.items.applied) { const itemMismatches = preview.items.changes .filter(change => change.changed) .map(change => ({ ...change, before: verificationSnapshot.item ? verificationSnapshot.item[change.targetField] : undefined, })) .filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after)); if (itemMismatches.length > 0) { const error = new Error(`BC verification failed for items: ${describeUnappliedChanges('items', itemMismatches)}`); error.statusCode = 409; throw error; } } if (results.itemUnitsOfMeasure.applied) { const uomMismatches = preview.itemUnitsOfMeasure.changes .filter(change => change.changed) .map(change => ({ ...change, before: verificationSnapshot.itemUnitsOfMeasure ? verificationSnapshot.itemUnitsOfMeasure[change.targetField] : undefined, })) .filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after)); if (uomMismatches.length > 0) { const error = new Error(`BC verification failed for itemUnitsOfMeasure: ${describeUnappliedChanges('itemUnitsOfMeasure', uomMismatches)}`); error.statusCode = 409; throw error; } } if (results.itemUnits40HC.applied) { const hcMismatches = preview.itemUnits40HC.changes .filter(change => change.changed) .map(change => ({ ...change, before: verificationSnapshot.itemUnits40HC ? verificationSnapshot.itemUnits40HC[change.targetField] : undefined, })) .filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after)); if (hcMismatches.length > 0) { const error = new Error(`BC verification failed for itemUnits40HC: ${describeUnappliedChanges('itemUnits40HC', hcMismatches)}`); error.statusCode = 409; throw error; } } } return { success: true, articleNo: mapping.articleNo, previewToken: preview.previewToken, results, preview, warning: (preview.itemUnitsOfMeasure.changes.some(change => change.changed) && !preview.itemUnitsOfMeasure.supported) ? (preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only') : (preview.itemUnits40HC.changes.some(change => change.changed) && !preview.itemUnits40HC.supported) ? (preview.itemUnits40HC.supportReason || 'itemUnits40HC sync is not supported by this BC API yet; preview only') : undefined, }; } export async function previewBusinessCentralCpnp(config, token, articleNo, cpnpNo) { const { json } = await fetchJsonOrThrow( `${getItemsUrl(config)}?$filter=${encodeURIComponent(`no eq '${articleNo}'`)}&$select=${getItemsSelect()}`, token, 'BC GET items' ); const item = Array.isArray(json.value) && json.value.length > 0 ? json.value[0] : null; if (!item) { throw new Error(`Item not found in BC: ${articleNo}`); } const desired = { cpnpNo: String(cpnpNo) }; const current = { cpnpNo: item.cpnpNo }; const changes = buildFieldChanges( [{ sourceLabel: 'CPNP', sourceIndex: null, targetField: 'cpnpNo' }], current, desired ); return { articleNo, items: { type: 'items', desired, current, changes, changedFields: changes.filter(change => change.changed).map(change => change.targetField), writeConfigured: Boolean(config.writeUrlTemplate), writeMethod: config.writeMethod || 'PATCH', writeUrlTemplate: config.writeUrlTemplate || null, writeBodyTemplate: config.writeBodyTemplate || null, canApply: Boolean(config.writeUrlTemplate), }, itemUnitsOfMeasure: { type: 'itemUnitsOfMeasure', desired: {}, current: null, changes: [], changedFields: [], writeConfigured: Boolean(config.itemUnitsWriteUrlTemplate), writeMethod: config.itemUnitsWriteMethod || null, writeUrlTemplate: config.itemUnitsWriteUrlTemplate || null, writeBodyTemplate: config.itemUnitsWriteBodyTemplate || null, canApply: false, }, hasChanges: changes.some(change => change.changed), previewToken: buildPreviewHash({ articleNo, cpnpNo: String(cpnpNo), current: item.cpnpNo, }), }; } export async function applyBusinessCentralCpnp(config, token, articleNo, cpnpNo, previewToken) { const preview = await previewBusinessCentralCpnp(config, token, articleNo, cpnpNo); if (previewToken && previewToken !== preview.previewToken) { const error = new Error('Preview token mismatch. BC data changed or preview is stale.'); error.statusCode = 409; throw error; } const item = preview.items.current; const url = renderTemplate(config.writeUrlTemplate || `{{itemsUrl}}('{{itemNo}}')`, { itemsUrl: getItemsUrl(config), articleNo, itemNo: articleNo, cpnpNo: String(cpnpNo), systemId: item?.systemId || '', itemsPayload: { cpnpNo: String(cpnpNo) }, }); const body = config.writeBodyTemplate ? renderTemplate(config.writeBodyTemplate, { itemsUrl: getItemsUrl(config), articleNo, itemNo: articleNo, cpnpNo: String(cpnpNo), systemId: item?.systemId || '', itemsPayload: { cpnpNo: String(cpnpNo) }, }) : JSON.stringify({ cpnpNo: String(cpnpNo) }); await renderAndPatchRecord({ token, url, method: config.writeMethod || 'PATCH', body, etag: item?.['@odata.etag'] || '*', }); return { success: true, articleNo, cpnpNo: String(cpnpNo), previewToken: preview.previewToken, }; } export { getBcConfig, getBCToken, getItemsUrl, getItemUnitsOfMeasureUrl };