diff --git a/bc-sync-runtime.js b/bc-sync-runtime.js index d4e0d76..cb6fcbb 100644 --- a/bc-sync-runtime.js +++ b/bc-sync-runtime.js @@ -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, }; } diff --git a/src/components/MatrixView.tsx b/src/components/MatrixView.tsx index 93f797d..2c868de 100644 --- a/src/components/MatrixView.tsx +++ b/src/components/MatrixView.tsx @@ -170,37 +170,30 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) { setBcValidationError(null); setBcValidationWarning(null); 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); if (!result.success) { if (isPreviewTokenMismatchError(result.error)) { const refreshedPreview = await previewBusinessCentralSync(headers, bcPreviewRow); - if (refreshedPreview.success) { - setBcValidationResult(refreshedPreview); - const retry = await applyBusinessCentralSync(headers, bcPreviewRow, refreshedPreview.previewToken); - if (retry.success) { - setBcValidationResult(prev => retry.preview ? { ...retry.preview, hasChanges: false } : prev); - if (retry.warning) { - setBcValidationWarning(retry.warning); - } - return; + if (refreshedPreview.success) { + setBcValidationResult(refreshedPreview); + const retry = await applyBusinessCentralSync(headers, bcPreviewRow, refreshedPreview.previewToken); + if (retry.success) { + setBcValidationResult(prev => retry.preview ? { ...retry.preview, hasChanges: false } : prev); + if (retry.warning) { + setBcValidationWarning(retry.warning); } - setBcValidationError(retry.error || 'Apply failed'); return; } + setBcValidationError(retry.error || 'Apply failed'); + return; } - setBcValidationError(result.error || 'Apply failed'); - if (result.warning) { - setBcValidationWarning(result.warning); - } - return; } + setBcValidationError(result.error || 'Apply failed'); + if (result.warning) { + setBcValidationWarning(result.warning); + } + return; + } setBcValidationResult(prev => result.preview ? { ...result.preview, hasChanges: false } : prev); if (result.warning) { setBcValidationWarning(result.warning); @@ -417,8 +410,8 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) { -
- {(['items', 'itemUnitsOfMeasure'] as const).map(sectionKey => { +
+ {(['items', 'itemUnitsOfMeasure', 'itemUnits40HC'] as const).map(sectionKey => { const section = bcValidationResult[sectionKey]; const changed = section.changes.filter((change: any) => change.changed); @@ -472,7 +465,7 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
)} -
+
@@ -514,6 +507,27 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) { {JSON.stringify(bcMappingPreview.itemUnitsOfMeasurePayload, null, 2)}
+ +
+
+
+

API `itemUnitsOfMeasure2` 40HC preview

+

SKU {bcMappingPreview.articleNo}

+
+
+
+ {bcMappingPreview.itemUnits40HCFields.map(field => ( +
+
{field.sourceLabel}
+
{field.targetField}
+
{String(field.value ?? '—')}
+
+ ))} +
+
+                    {JSON.stringify(bcMappingPreview.itemUnits40HCPayload, null, 2)}
+                  
+
)} diff --git a/src/services/businessCentral.ts b/src/services/businessCentral.ts index 1feccee..02fbbf4 100644 --- a/src/services/businessCentral.ts +++ b/src/services/businessCentral.ts @@ -27,7 +27,7 @@ export interface BCFieldChange { } export interface BCSyncPreviewSection { - type: 'items' | 'itemUnitsOfMeasure'; + type: 'items' | 'itemUnitsOfMeasure' | 'itemUnits40HC'; desired: Record; current: Record | null; changes: BCFieldChange[]; @@ -46,6 +46,7 @@ export interface BCSyncPreviewResult { articleNo: string; items: BCSyncPreviewSection; itemUnitsOfMeasure: BCSyncPreviewSection; + itemUnits40HC: BCSyncPreviewSection; hasChanges: boolean; previewToken: string; error?: string; @@ -59,6 +60,7 @@ export interface BCSyncApplyResult { results?: { items: { applied: boolean; reason?: string; url?: string }; itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string }; + itemUnits40HC: { applied: boolean; reason?: string; url?: string }; }; error?: string; warning?: string; diff --git a/src/services/businessCentralMapping.ts b/src/services/businessCentralMapping.ts index 1c42cbe..3205d0f 100644 --- a/src/services/businessCentralMapping.ts +++ b/src/services/businessCentralMapping.ts @@ -11,8 +11,10 @@ export interface BusinessCentralMappingPreview { articleNo: string; itemsPayload: Record; itemUnitsOfMeasurePayload: Record; + itemUnits40HCPayload: Record; itemsFields: MappingFieldPreview[]; itemUnitsFields: MappingFieldPreview[]; + itemUnits40HCFields: MappingFieldPreview[]; } function findHeaderIndex(headers: string[], patterns: string[]): number { @@ -81,6 +83,11 @@ export function buildBusinessCentralMappingPreview(headers: string[], row: Excel 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 outerWIdx = findHeaderIndex(headers, ['outer', 'w']); const outerLIdx = findHeaderIndex(headers, ['outer', 'l']); const outerHIdx = findHeaderIndex(headers, ['outer', 'h']); @@ -108,10 +115,17 @@ export function buildBusinessCentralMappingPreview(headers: string[], row: Excel 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), @@ -130,5 +144,9 @@ export function buildBusinessCentralMappingPreview(headers: string[], row: Excel 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), + ], }; }