mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 10:55:25 +02:00
682 lines
23 KiB
JavaScript
682 lines
23 KiB
JavaScript
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 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 unitsOuterIdx = findHeaderIndex(headers, ['units', 'outer']);
|
|
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 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),
|
|
};
|
|
|
|
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)),
|
|
};
|
|
|
|
return {
|
|
articleNo,
|
|
itemsPayload,
|
|
itemUnitsOfMeasurePayload,
|
|
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),
|
|
],
|
|
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),
|
|
],
|
|
};
|
|
}
|
|
|
|
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',
|
|
].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 uomFilter = encodeURIComponent(`itemNo eq '${articleNo}' and code eq '${config.itemUnitsCode || 'OUTER'}'`);
|
|
const uomUrl = `${getItemUnitsOfMeasureUrl(config)}?$filter=${uomFilter}&$select=${getItemUnitsSelect()}`;
|
|
const { json: uomJson, etag: uomEtag } = await fetchJsonOrThrow(uomUrl, token, 'BC GET itemUnitsOfMeasure');
|
|
const itemUnitsOfMeasure = Array.isArray(uomJson.value) && uomJson.value.length > 0 ? uomJson.value[0] : null;
|
|
|
|
return {
|
|
item,
|
|
itemUnitsOfMeasure,
|
|
itemEtag: item?.['@odata.etag'] || itemsEtag || '*',
|
|
itemUnitsEtag: itemUnitsOfMeasure?.['@odata.etag'] || uomEtag || '*',
|
|
};
|
|
}
|
|
|
|
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 previewPayload = {
|
|
articleNo: mapping.articleNo,
|
|
items: itemsSection,
|
|
itemUnitsOfMeasure: itemUnitsSection,
|
|
};
|
|
|
|
return {
|
|
...previewPayload,
|
|
hasChanges: itemsSection.changes.some(change => change.changed) || itemUnitsSection.changes.some(change => change.changed),
|
|
previewToken: buildPreviewHash({
|
|
articleNo: mapping.articleNo,
|
|
items: itemsSection.changes,
|
|
itemUnitsOfMeasure: itemUnitsSection.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 };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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',
|
|
systemId: snapshot.item?.systemId || '',
|
|
cpnpNo: mapping.itemsPayload.cpnpNo,
|
|
itemsPayload: preview.items.desired,
|
|
itemUnitsPayload: preview.itemUnitsOfMeasure.desired,
|
|
...preview.items.desired,
|
|
...preview.itemUnitsOfMeasure.desired,
|
|
};
|
|
|
|
const results = {
|
|
items: await applyItemsSection(config, token, snapshot, preview, context),
|
|
itemUnitsOfMeasure: await applyItemUnitsSection(config, token, snapshot, preview, context),
|
|
};
|
|
|
|
if (results.items.applied || results.itemUnitsOfMeasure.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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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')
|
|
: 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 };
|