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 cpnpIdx = findHeaderIndex(headers, ['cpnp']);
const unitsOuterIdx = findHeaderIndex(headers, ['units', 'outer']); 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 innerWIdx = findHeaderIndex(headers, ['inner', 'w']);
const innerLIdx = findHeaderIndex(headers, ['inner', 'l']); const innerLIdx = findHeaderIndex(headers, ['inner', 'l']);
const innerHIdx = findHeaderIndex(headers, ['inner', 'h']); const innerHIdx = findHeaderIndex(headers, ['inner', 'h']);
@@ -92,10 +97,17 @@ export function buildBusinessCentralMappingPreview(headers, row) {
height: toBcDecimal(getValue(row, outerHIdx)), height: toBcDecimal(getValue(row, outerHIdx)),
}; };
const itemUnits40HCPayload = {
itemNo: getValue(row, articleNoIdx),
code: '40HC',
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, units40HqIdx)),
};
return { return {
articleNo, articleNo,
itemsPayload, itemsPayload,
itemUnitsOfMeasurePayload, itemUnitsOfMeasurePayload,
itemUnits40HCPayload,
itemsFields: [ itemsFields: [
makeFieldPreview('Article No.', articleNoIdx, 'no', itemsPayload.no), makeFieldPreview('Article No.', articleNoIdx, 'no', itemsPayload.no),
makeFieldPreview('Article Details - English', articleDetailsEnIdx, 'articleDetailsEnglish', itemsPayload.articleDetailsEnglish), 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 L (cm)', outerLIdx, 'length', itemUnitsOfMeasurePayload.length),
makeFieldPreview('Outer H (cm)', outerHIdx, 'height', itemUnitsOfMeasurePayload.height), 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 { 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 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 outerCode = config.itemUnitsCode || 'OUTER';
const uomUrl = `${getItemUnitsOfMeasureUrl(config)}?$filter=${uomFilter}&$select=${getItemUnitsSelect()}`; const uomUrl = getItemUnitsOfMeasureUrl(config);
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 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 { return {
item, item,
itemUnitsOfMeasure, itemUnitsOfMeasure,
itemUnits40HC,
itemEtag: item?.['@odata.etag'] || itemsEtag || '*', 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, 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 = { const previewPayload = {
articleNo: mapping.articleNo, articleNo: mapping.articleNo,
items: itemsSection, items: itemsSection,
itemUnitsOfMeasure: itemUnitsSection, itemUnitsOfMeasure: itemUnitsSection,
itemUnits40HC: itemUnits40HCSection,
}; };
return { return {
...previewPayload, ...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({ previewToken: buildPreviewHash({
articleNo: mapping.articleNo, articleNo: mapping.articleNo,
items: itemsSection.changes, items: itemsSection.changes,
itemUnitsOfMeasure: itemUnitsSection.changes, itemUnitsOfMeasure: itemUnitsSection.changes,
itemUnits40HC: itemUnits40HCSection.changes,
}), }),
}; };
} }
@@ -496,6 +537,38 @@ async function applyItemUnitsSection(config, token, snapshot, preview, context)
return { applied: true, url }; 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) { export async function applyBusinessCentralSync(config, token, headers, row, previewToken) {
const mapping = buildBusinessCentralMappingPreview(headers, row); const mapping = buildBusinessCentralMappingPreview(headers, row);
const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo); const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
@@ -508,6 +581,13 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev
throw error; 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) { if (previewToken && previewToken !== preview.previewToken) {
const error = new Error('Preview token mismatch. BC data changed or preview is stale.'); const error = new Error('Preview token mismatch. BC data changed or preview is stale.');
error.statusCode = 409; error.statusCode = 409;
@@ -520,20 +600,24 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev
articleNo: mapping.articleNo, articleNo: mapping.articleNo,
itemNo: mapping.articleNo, itemNo: mapping.articleNo,
itemUnitsCode: config.itemUnitsCode || 'OUTER', itemUnitsCode: config.itemUnitsCode || 'OUTER',
itemUnits40HCCode: '40HC',
systemId: snapshot.item?.systemId || '', systemId: snapshot.item?.systemId || '',
cpnpNo: mapping.itemsPayload.cpnpNo, cpnpNo: mapping.itemsPayload.cpnpNo,
itemsPayload: preview.items.desired, itemsPayload: preview.items.desired,
itemUnitsPayload: preview.itemUnitsOfMeasure.desired, itemUnitsPayload: preview.itemUnitsOfMeasure.desired,
itemUnits40HCPayload: preview.itemUnits40HC.desired,
...preview.items.desired, ...preview.items.desired,
...preview.itemUnitsOfMeasure.desired, ...preview.itemUnitsOfMeasure.desired,
...preview.itemUnits40HC.desired,
}; };
const results = { const results = {
items: await applyItemsSection(config, token, snapshot, preview, context), items: await applyItemsSection(config, token, snapshot, preview, context),
itemUnitsOfMeasure: await applyItemUnitsSection(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); const verificationSnapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
if (results.items.applied) { if (results.items.applied) {
@@ -565,6 +649,21 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev
throw error; 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 { return {
@@ -573,8 +672,10 @@ export async function applyBusinessCentralSync(config, token, headers, row, prev
previewToken: preview.previewToken, previewToken: preview.previewToken,
results, results,
preview, 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.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, : undefined,
}; };
} }
+24 -10
View File
@@ -170,13 +170,6 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
setBcValidationError(null); setBcValidationError(null);
setBcValidationWarning(null); setBcValidationWarning(null);
try { try {
const hasWritableItems = bcValidationResult.items.changes.some((change: any) => change.changed);
const hasOnlyUomChanges = !hasWritableItems && bcValidationResult.itemUnitsOfMeasure.changes.some((change: any) => change.changed);
if (hasOnlyUomChanges) {
setBcValidationWarning(bcValidationResult.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is preview only');
return;
}
const result = await applyBusinessCentralSync(headers, bcPreviewRow, bcValidationResult.previewToken); const result = await applyBusinessCentralSync(headers, bcPreviewRow, bcValidationResult.previewToken);
if (!result.success) { if (!result.success) {
if (isPreviewTokenMismatchError(result.error)) { if (isPreviewTokenMismatchError(result.error)) {
@@ -417,8 +410,8 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
{(['items', 'itemUnitsOfMeasure'] as const).map(sectionKey => { {(['items', 'itemUnitsOfMeasure', 'itemUnits40HC'] as const).map(sectionKey => {
const section = bcValidationResult[sectionKey]; const section = bcValidationResult[sectionKey];
const changed = section.changes.filter((change: any) => change.changed); const changed = section.changes.filter((change: any) => change.changed);
@@ -472,7 +465,7 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
</div> </div>
)} )}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div className="rounded-lg border border-slate-700 bg-slate-950/60 p-4"> <div className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div> <div>
@@ -514,6 +507,27 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
{JSON.stringify(bcMappingPreview.itemUnitsOfMeasurePayload, null, 2)} {JSON.stringify(bcMappingPreview.itemUnitsOfMeasurePayload, null, 2)}
</pre> </pre>
</div> </div>
<div className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-white">API `itemUnitsOfMeasure2` 40HC preview</h3>
<p className="text-xs text-slate-400">SKU {bcMappingPreview.articleNo}</p>
</div>
</div>
<div className="space-y-2">
{bcMappingPreview.itemUnits40HCFields.map(field => (
<div key={field.targetField} className="grid grid-cols-3 gap-3 text-xs">
<div className="text-slate-400">{field.sourceLabel}</div>
<div className="text-slate-500 truncate">{field.targetField}</div>
<div className="text-white truncate">{String(field.value ?? '—')}</div>
</div>
))}
</div>
<pre className="mt-4 text-[11px] leading-relaxed bg-slate-950 border border-slate-800 rounded p-3 overflow-auto text-slate-200">
{JSON.stringify(bcMappingPreview.itemUnits40HCPayload, null, 2)}
</pre>
</div>
</div> </div>
</div> </div>
)} )}
+3 -1
View File
@@ -27,7 +27,7 @@ export interface BCFieldChange {
} }
export interface BCSyncPreviewSection { export interface BCSyncPreviewSection {
type: 'items' | 'itemUnitsOfMeasure'; type: 'items' | 'itemUnitsOfMeasure' | 'itemUnits40HC';
desired: Record<string, any>; desired: Record<string, any>;
current: Record<string, any> | null; current: Record<string, any> | null;
changes: BCFieldChange[]; changes: BCFieldChange[];
@@ -46,6 +46,7 @@ export interface BCSyncPreviewResult {
articleNo: string; articleNo: string;
items: BCSyncPreviewSection; items: BCSyncPreviewSection;
itemUnitsOfMeasure: BCSyncPreviewSection; itemUnitsOfMeasure: BCSyncPreviewSection;
itemUnits40HC: BCSyncPreviewSection;
hasChanges: boolean; hasChanges: boolean;
previewToken: string; previewToken: string;
error?: string; error?: string;
@@ -59,6 +60,7 @@ export interface BCSyncApplyResult {
results?: { results?: {
items: { applied: boolean; reason?: string; url?: string }; items: { applied: boolean; reason?: string; url?: string };
itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string }; itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string };
itemUnits40HC: { applied: boolean; reason?: string; url?: string };
}; };
error?: string; error?: string;
warning?: string; warning?: string;
+18
View File
@@ -11,8 +11,10 @@ export interface BusinessCentralMappingPreview {
articleNo: string; articleNo: string;
itemsPayload: Record<string, any>; itemsPayload: Record<string, any>;
itemUnitsOfMeasurePayload: Record<string, any>; itemUnitsOfMeasurePayload: Record<string, any>;
itemUnits40HCPayload: Record<string, any>;
itemsFields: MappingFieldPreview[]; itemsFields: MappingFieldPreview[];
itemUnitsFields: MappingFieldPreview[]; itemUnitsFields: MappingFieldPreview[];
itemUnits40HCFields: MappingFieldPreview[];
} }
function findHeaderIndex(headers: string[], patterns: string[]): number { function findHeaderIndex(headers: string[], patterns: string[]): number {
@@ -81,6 +83,11 @@ export function buildBusinessCentralMappingPreview(headers: string[], row: Excel
const cpnpIdx = findHeaderIndex(headers, ['cpnp']); const cpnpIdx = findHeaderIndex(headers, ['cpnp']);
const unitsOuterIdx = findHeaderIndex(headers, ['units', 'outer']); 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 outerWIdx = findHeaderIndex(headers, ['outer', 'w']); const outerWIdx = findHeaderIndex(headers, ['outer', 'w']);
const outerLIdx = findHeaderIndex(headers, ['outer', 'l']); const outerLIdx = findHeaderIndex(headers, ['outer', 'l']);
const outerHIdx = findHeaderIndex(headers, ['outer', 'h']); const outerHIdx = findHeaderIndex(headers, ['outer', 'h']);
@@ -108,10 +115,17 @@ export function buildBusinessCentralMappingPreview(headers: string[], row: Excel
height: toBcDecimal(getValue(row, outerHIdx)), height: toBcDecimal(getValue(row, outerHIdx)),
}; };
const itemUnits40HCPayload = {
itemNo: getValue(row, articleNoIdx),
code: '40HC',
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, units40HqIdx)),
};
return { return {
articleNo, articleNo,
itemsPayload, itemsPayload,
itemUnitsOfMeasurePayload, itemUnitsOfMeasurePayload,
itemUnits40HCPayload,
itemsFields: [ itemsFields: [
makeFieldPreview('Article No.', articleNoIdx, 'no', itemsPayload.no), makeFieldPreview('Article No.', articleNoIdx, 'no', itemsPayload.no),
makeFieldPreview('Article Details - English', articleDetailsEnIdx, 'articleDetailsEnglish', itemsPayload.articleDetailsEnglish), makeFieldPreview('Article Details - English', articleDetailsEnIdx, 'articleDetailsEnglish', itemsPayload.articleDetailsEnglish),
@@ -130,5 +144,9 @@ export function buildBusinessCentralMappingPreview(headers: string[], row: Excel
makeFieldPreview('Outer L (cm)', outerLIdx, 'length', itemUnitsOfMeasurePayload.length), makeFieldPreview('Outer L (cm)', outerLIdx, 'length', itemUnitsOfMeasurePayload.length),
makeFieldPreview('Outer H (cm)', outerHIdx, 'height', itemUnitsOfMeasurePayload.height), makeFieldPreview('Outer H (cm)', outerHIdx, 'height', itemUnitsOfMeasurePayload.height),
], ],
itemUnits40HCFields: [
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnits40HCPayload.itemNo),
makeFieldPreview('Units 40FT HQ', units40HqIdx, 'qtyPerUnitOfMeasure', itemUnits40HCPayload.qtyPerUnitOfMeasure),
],
}; };
} }