feat(bc): sync 40hc pricing

This commit is contained in:
Christian Vidal Wolf
2026-05-26 10:34:11 +02:00
parent 74d4d37769
commit 4037f7924a
4 changed files with 169 additions and 34 deletions
+109 -8
View File
@@ -62,6 +62,11 @@ export function buildBusinessCentralMappingPreview(headers, row) {
const cpnpIdx = findHeaderIndex(headers, ['cpnp']);
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']);
@@ -92,10 +97,17 @@ export function buildBusinessCentralMappingPreview(headers, row) {
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),
@@ -114,6 +126,10 @@ export function buildBusinessCentralMappingPreview(headers, row) {
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),
],
};
}
@@ -269,16 +285,26 @@ export async function fetchBusinessCentralSnapshot(config, token, articleNo) {
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;
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'] || uomEtag || '*',
itemUnitsEtag: itemUnitsOfMeasure?.['@odata.etag'] || outerEtag || '*',
itemUnits40HCEtag: itemUnits40HC?.['@odata.etag'] || hcEtag || '*',
};
}
@@ -331,19 +357,34 @@ export function buildBusinessCentralSyncPreview(config, mapping, snapshot) {
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),
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,
}),
};
}
@@ -496,6 +537,38 @@ async function applyItemUnitsSection(config, token, snapshot, preview, context)
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);
@@ -508,6 +581,13 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev
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;
@@ -520,20 +600,24 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev
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) {
if (results.items.applied || results.itemUnitsOfMeasure.applied || results.itemUnits40HC.applied) {
const verificationSnapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
if (results.items.applied) {
@@ -565,6 +649,21 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev
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 {
@@ -573,8 +672,10 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev
previewToken: preview.previewToken,
results,
preview,
warning: preview.itemUnitsOfMeasure.changes.some(change => change.changed) && !preview.itemUnitsOfMeasure.supported
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,
};
}