Files
Craze-Data-check/bc-sync-runtime.js
T

806 lines
28 KiB
JavaScript
Raw Normal View History

import crypto from 'crypto';
2026-05-26 10:25:37 +02:00
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()))
);
}
2026-07-02 15:49:52 +02:00
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']);
2026-07-02 15:49:52 +02:00
const categorizationCodeIdx = findCategorizationCodeIndex(headers);
const unitsOuterIdx = findHeaderIndex(headers, ['units', 'outer']);
2026-05-26 10:34:11 +02:00
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) ?? '');
2026-07-02 15:49:52 +02:00
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),
};
2026-07-02 15:49:52 +02:00
if (hasCategorizationCode) {
itemsPayload.categorizationCode = String(categorizationCode).trim();
}
const itemUnitsOfMeasurePayload = {
itemNo: getValue(row, articleNoIdx),
2026-05-26 10:25:37 +02:00
code: 'OUTER',
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, unitsOuterIdx)),
width: toBcDecimal(getValue(row, outerWIdx)),
length: toBcDecimal(getValue(row, outerLIdx)),
height: toBcDecimal(getValue(row, outerHIdx)),
};
2026-05-26 10:34:11 +02:00
const itemUnits40HCPayload = {
itemNo: getValue(row, articleNoIdx),
code: '40HC',
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, units40HqIdx)),
};
return {
articleNo,
itemsPayload,
itemUnitsOfMeasurePayload,
2026-05-26 10:34:11 +02:00
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),
2026-07-02 15:49:52 +02:00
...(hasCategorizationCode
? [makeFieldPreview('CategorizationCode', categorizationCodeIdx, 'categorizationCode', itemsPayload.categorizationCode)]
: []),
],
itemUnitsFields: [
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnitsOfMeasurePayload.itemNo),
2026-05-26 10:25:37 +02:00
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),
],
2026-05-26 10:34:11 +02:00
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',
2026-07-02 15:49:52 +02:00
'categorizationCode',
].join(',');
}
function getItemUnitsSelect() {
return [
'itemNo',
2026-05-26 10:25:37 +02:00
'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;
2026-05-26 10:34:11 +02:00
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,
2026-05-26 10:34:11 +02:00
itemUnits40HC,
itemEtag: item?.['@odata.etag'] || itemsEtag || '*',
2026-05-26 10:34:11 +02:00
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,
});
2026-05-26 10:34:11 +02:00
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,
2026-05-26 10:34:11 +02:00
itemUnits40HC: itemUnits40HCSection,
};
return {
...previewPayload,
2026-05-26 10:34:11 +02:00
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,
2026-05-26 10:34:11 +02:00
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',
2026-05-26 10:25:37 +02:00
'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 };
}
2026-05-26 10:34:11 +02:00
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;
}
2026-05-26 10:34:11 +02:00
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,
2026-05-26 10:25:37 +02:00
itemUnitsCode: config.itemUnitsCode || 'OUTER',
2026-05-26 10:34:11 +02:00
itemUnits40HCCode: '40HC',
systemId: snapshot.item?.systemId || '',
cpnpNo: mapping.itemsPayload.cpnpNo,
itemsPayload: preview.items.desired,
itemUnitsPayload: preview.itemUnitsOfMeasure.desired,
2026-05-26 10:34:11 +02:00
itemUnits40HCPayload: preview.itemUnits40HC.desired,
...preview.items.desired,
...preview.itemUnitsOfMeasure.desired,
2026-05-26 10:34:11 +02:00
...preview.itemUnits40HC.desired,
};
const results = {
items: await applyItemsSection(config, token, snapshot, preview, context),
itemUnitsOfMeasure: await applyItemUnitsSection(config, token, snapshot, preview, context),
2026-05-26 10:34:11 +02:00
itemUnits40HC: await applyItemUnits40HCSection(config, token, snapshot, preview, context),
};
2026-05-26 10:34:11 +02:00
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;
}
}
2026-05-26 10:34:11 +02:00
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,
2026-05-26 10:34:11 +02:00
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')
2026-05-26 10:34:11 +02:00
: (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 };