2026-05-15 11:00:21 +02:00
|
|
|
const BC_SYNC_PREVIEW_URL = '/api/bc-sync-preview';
|
|
|
|
|
const BC_SYNC_APPLY_URL = '/api/bc-sync-apply';
|
2026-05-14 17:17:10 +02:00
|
|
|
const BC_EXPORT_URL = '/api/bc-export';
|
|
|
|
|
|
|
|
|
|
export interface BCUpdateResult {
|
|
|
|
|
success: boolean;
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 11:00:21 +02:00
|
|
|
export function isPreviewTokenMismatchError(error?: string): boolean {
|
|
|
|
|
return Boolean(error && error.toLowerCase().includes('preview token mismatch'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 17:17:10 +02:00
|
|
|
export interface BCDownloadResult {
|
|
|
|
|
success: boolean;
|
|
|
|
|
error?: string;
|
|
|
|
|
filename?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 11:00:21 +02:00
|
|
|
export interface BCFieldChange {
|
|
|
|
|
sourceLabel: string;
|
|
|
|
|
sourceIndex: number | null;
|
|
|
|
|
targetField: string;
|
|
|
|
|
before: any;
|
|
|
|
|
after: any;
|
|
|
|
|
changed: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface BCSyncPreviewSection {
|
|
|
|
|
type: 'items' | 'itemUnitsOfMeasure';
|
|
|
|
|
desired: Record<string, any>;
|
|
|
|
|
current: Record<string, any> | null;
|
|
|
|
|
changes: BCFieldChange[];
|
|
|
|
|
changedFields: string[];
|
|
|
|
|
writeConfigured: boolean;
|
|
|
|
|
writeMethod: string | null;
|
|
|
|
|
writeUrlTemplate: string | null;
|
|
|
|
|
writeBodyTemplate: string | null;
|
|
|
|
|
canApply: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface BCSyncPreviewResult {
|
|
|
|
|
success: boolean;
|
|
|
|
|
articleNo: string;
|
|
|
|
|
items: BCSyncPreviewSection;
|
|
|
|
|
itemUnitsOfMeasure: BCSyncPreviewSection;
|
|
|
|
|
hasChanges: boolean;
|
|
|
|
|
previewToken: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface BCSyncApplyResult {
|
|
|
|
|
success: boolean;
|
|
|
|
|
articleNo: string;
|
|
|
|
|
previewToken?: string;
|
|
|
|
|
preview?: BCSyncPreviewResult;
|
|
|
|
|
results?: {
|
|
|
|
|
items: { applied: boolean; reason?: string; url?: string };
|
|
|
|
|
itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string };
|
|
|
|
|
};
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 17:17:10 +02:00
|
|
|
async function readResponsePayload(res: Response): Promise<{ data: any; rawText: string }> {
|
|
|
|
|
const rawText = await res.text();
|
|
|
|
|
if (!rawText.trim()) {
|
|
|
|
|
return { data: null, rawText };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return { data: JSON.parse(rawText), rawText };
|
|
|
|
|
} catch {
|
|
|
|
|
return { data: rawText, rawText };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function updateCpnpNoInBC(articleNo: string, cpnpNo: string): Promise<BCUpdateResult> {
|
|
|
|
|
try {
|
2026-05-15 11:00:21 +02:00
|
|
|
const res = await fetch(BC_SYNC_APPLY_URL, {
|
2026-05-14 17:17:10 +02:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ articleNo, cpnpNo }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { data, rawText } = await readResponsePayload(res);
|
|
|
|
|
if (!res.ok || !data?.success) {
|
|
|
|
|
const error =
|
|
|
|
|
data?.error ||
|
|
|
|
|
(typeof data === 'string' && data.trim()) ||
|
|
|
|
|
rawText ||
|
|
|
|
|
`HTTP ${res.status}`;
|
|
|
|
|
return { success: false, error };
|
|
|
|
|
}
|
|
|
|
|
return { success: true };
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
return { success: false, error: err.message };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 11:00:21 +02:00
|
|
|
export async function previewBusinessCentralSync(headers: string[], row: any[]): Promise<BCSyncPreviewResult> {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(BC_SYNC_PREVIEW_URL, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ headers, row }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { data, rawText } = await readResponsePayload(res);
|
|
|
|
|
if (!res.ok || !data?.success) {
|
|
|
|
|
const error =
|
|
|
|
|
data?.error ||
|
|
|
|
|
(typeof data === 'string' && data.trim()) ||
|
|
|
|
|
rawText ||
|
|
|
|
|
`HTTP ${res.status}`;
|
|
|
|
|
return { success: false, error } as BCSyncPreviewResult;
|
|
|
|
|
}
|
|
|
|
|
return data as BCSyncPreviewResult;
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
return { success: false, error: err.message } as BCSyncPreviewResult;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function applyBusinessCentralSync(headers: string[], row: any[], previewToken?: string): Promise<BCSyncApplyResult> {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(BC_SYNC_APPLY_URL, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ headers, row, previewToken }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { data, rawText } = await readResponsePayload(res);
|
|
|
|
|
if (!res.ok || !data?.success) {
|
|
|
|
|
const error =
|
|
|
|
|
data?.error ||
|
|
|
|
|
(typeof data === 'string' && data.trim()) ||
|
|
|
|
|
rawText ||
|
|
|
|
|
`HTTP ${res.status}`;
|
|
|
|
|
return { success: false, error } as BCSyncApplyResult;
|
|
|
|
|
}
|
|
|
|
|
return data as BCSyncApplyResult;
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
return { success: false, error: err.message } as BCSyncApplyResult;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 17:17:10 +02:00
|
|
|
function getFilenameFromDisposition(contentDisposition: string | null): string | null {
|
|
|
|
|
if (!contentDisposition) return null;
|
|
|
|
|
|
|
|
|
|
const utf8Match = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
|
|
|
|
if (utf8Match?.[1]) {
|
|
|
|
|
try {
|
|
|
|
|
return decodeURIComponent(utf8Match[1].trim().replace(/^"|"$/g, ''));
|
|
|
|
|
} catch {
|
|
|
|
|
return utf8Match[1].trim().replace(/^"|"$/g, '');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filenameMatch = contentDisposition.match(/filename\s*=\s*([^;]+)/i);
|
|
|
|
|
if (filenameMatch?.[1]) {
|
|
|
|
|
return filenameMatch[1].trim().replace(/^"|"$/g, '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function downloadBusinessCentralItemsExcel(): Promise<BCDownloadResult> {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(BC_EXPORT_URL, {
|
|
|
|
|
method: 'GET',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
let error = `HTTP ${res.status}`;
|
|
|
|
|
try {
|
|
|
|
|
const { data, rawText } = await readResponsePayload(res);
|
|
|
|
|
error = data?.error || (typeof data === 'string' && data.trim()) || rawText || error;
|
|
|
|
|
} catch {
|
|
|
|
|
error = `HTTP ${res.status}`;
|
|
|
|
|
}
|
|
|
|
|
return { success: false, error };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const blob = await res.blob();
|
|
|
|
|
const filename =
|
|
|
|
|
getFilenameFromDisposition(res.headers.get('content-disposition')) ||
|
|
|
|
|
`BusinessCentral_Items_${new Date().toISOString().split('T')[0]}.xlsx`;
|
|
|
|
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
link.href = url;
|
|
|
|
|
link.download = filename;
|
|
|
|
|
link.rel = 'noopener';
|
|
|
|
|
document.body.appendChild(link);
|
|
|
|
|
link.click();
|
|
|
|
|
link.remove();
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
|
|
|
|
return { success: true, filename };
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
return { success: false, error: err.message };
|
|
|
|
|
}
|
|
|
|
|
}
|