2026-05-15 11:00:21 +02:00
import crypto from 'crypto' ;
2026-05-26 10:25:37 +02:00
import { getBcConfig , getBCToken , getItemsUrl , getItemUnitsOfMeasureUrl } from './bc-runtime.js' ;
2026-05-15 11:00:21 +02:00
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' ]);
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' ]);
})();
2026-05-15 11:00:21 +02:00
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 ),
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-15 11:00:21 +02:00
};
2026-05-26 10:34:11 +02:00
const itemUnits40HCPayload = {
itemNo : getValue ( row , articleNoIdx ),
code : '40HC' ,
qtyPerUnitOfMeasure : toBcDecimal ( getValue ( row , units40HqIdx )),
};
2026-05-15 11:00:21 +02:00
return {
articleNo ,
itemsPayload ,
itemUnitsOfMeasurePayload ,
2026-05-26 10:34:11 +02:00
itemUnits40HCPayload ,
2026-05-15 11:00:21 +02:00
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 ),
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-15 11:00:21 +02:00
],
2026-05-26 10:34:11 +02:00
itemUnits40HCFields : [
makeFieldPreview ( 'Article No.' , articleNoIdx , 'itemNo' , itemUnits40HCPayload . itemNo ),
makeFieldPreview ( 'Units 40FT HQ' , units40HqIdx , 'qtyPerUnitOfMeasure' , itemUnits40HCPayload . qtyPerUnitOfMeasure ),
],
2026-05-15 11:00:21 +02:00
};
}
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' )) {
2026-05-20 13:33:37 +02:00
if ( value === null || value === undefined || value === '' ) {
return '0001-01-01' ;
}
const normalizedDate = formatDateForBc ( value );
return normalizedDate === '0001-01-01' ? '0001-01-01' : normalizedDate ;
2026-05-15 11:00:21 +02:00
}
2026-05-20 13:33:37 +02:00
if ( value === null || value === undefined || value === '' ) return null ;
2026-05-15 11:00:21 +02:00
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' ,
2026-05-26 10:25:37 +02:00
'code' ,
'qtyPerUnitOfMeasure' ,
'qtyRoundingPrecision' ,
'length' ,
'width' ,
'height' ,
'cubage' ,
'weight' ,
'layerPerPalet2CRZ' ,
'outerPerLayer2CRZ' ,
'barCodeCRZ' ,
'layerPerPaletCRZ' ,
'outerPerLayerCRZ' ,
'netWeightCRZ' ,
'innerTypeBCT' ,
2026-05-15 11:00:21 +02:00
]. 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 ;
2026-05-15 11:00:21 +02:00
return {
item ,
itemUnitsOfMeasure ,
2026-05-26 10:34:11 +02:00
itemUnits40HC ,
2026-05-15 11:00:21 +02:00
itemEtag : item ? .[ '@odata.etag' ] || itemsEtag || '*' ,
2026-05-26 10:34:11 +02:00
itemUnitsEtag : itemUnitsOfMeasure ? .[ '@odata.etag' ] || outerEtag || '*' ,
itemUnits40HCEtag : itemUnits40HC ? .[ '@odata.etag' ] || hcEtag || '*' ,
2026-05-15 11:00:21 +02:00
};
}
function makePreviewSection ({
type ,
desiredPayload ,
currentRecord ,
fieldPreviews ,
writeMethod ,
writeUrlTemplate ,
writeBodyTemplate ,
2026-05-20 13:33:37 +02:00
supported = true ,
supportReason = null ,
2026-05-15 11:00:21 +02:00
}) {
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 ,
2026-05-20 13:33:37 +02:00
canApply : Boolean ( writeUrlTemplate ) && supported ,
supported ,
supportReason ,
2026-05-15 11:00:21 +02:00
};
}
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 ,
2026-05-20 13:33:37 +02:00
writeBodyTemplate : config . itemsWriteBodyTemplate || null ,
2026-05-15 11:00:21 +02:00
});
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 ,
});
2026-05-15 11:00:21 +02:00
const previewPayload = {
articleNo : mapping . articleNo ,
items : itemsSection ,
itemUnitsOfMeasure : itemUnitsSection ,
2026-05-26 10:34:11 +02:00
itemUnits40HC : itemUnits40HCSection ,
2026-05-15 11:00:21 +02:00
};
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 ),
2026-05-15 11:00:21 +02:00
previewToken : buildPreviewHash ({
articleNo : mapping . articleNo ,
items : itemsSection . changes ,
itemUnitsOfMeasure : itemUnitsSection . changes ,
2026-05-26 10:34:11 +02:00
itemUnits40HC : itemUnits40HCSection . changes ,
2026-05-15 11:00:21 +02:00
}),
};
}
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' ,
2026-05-15 11:00:21 +02:00
]);
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' };
}
2026-05-20 13:33:37 +02:00
if ( ! preview . itemUnitsOfMeasure . supported ) {
return { applied : false , reason : preview . itemUnitsOfMeasure . supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only' };
}
2026-05-15 11:00:21 +02:00
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 };
}
2026-05-15 11:00:21 +02:00
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 );
2026-05-20 13:33:37 +02:00
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 ;
}
2026-05-15 11:00:21 +02:00
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' ,
2026-05-15 11:00:21 +02:00
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 ,
2026-05-15 11:00:21 +02:00
... preview . items . desired ,
... preview . itemUnitsOfMeasure . desired ,
2026-05-26 10:34:11 +02:00
... preview . itemUnits40HC . desired ,
2026-05-15 11:00:21 +02:00
};
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-15 11:00:21 +02:00
};
2026-05-26 10:34:11 +02:00
if ( results . items . applied || results . itemUnitsOfMeasure . applied || results . itemUnits40HC . applied ) {
2026-05-15 11:00:21 +02:00
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 ;
}
}
2026-05-15 11:00:21 +02:00
}
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 )
2026-05-20 13:33:37 +02:00
? ( 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' )
2026-05-20 13:33:37 +02:00
: undefined ,
2026-05-15 11:00:21 +02:00
};
}
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 };