Files
dashfront/src/components/manifest/manifestConfig.js
2026-10-27 00:28:06 +01:00

883 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
delivery as deliveryApi,
quote as quoteApi,
order as orderApi,
retoure as retoureApi,
} from '../../api';
import supplierAutomat from '../../automats/supplierAutomat';
import entityAutomat from '../../automats/entityAutomat';
import customergroupAutomat from '../../automats/customergroupAutomat';
import deliveryAutomat from '../../automats/deliveryAutomat';
import quoteAutomat from '../../automats/quoteAutomat';
import orderAutomat from '../../automats/orderAutomat';
import retoureAutomat from '../../automats/retoureAutomat';
import { getArticle } from '../../automats/artikelAutomat';
import { selectCompanyTaxrate } from '../../automats/companyAutomat';
import { netFromGross } from '../../utils/price';
import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber';
import {
GREEN_PANEL,
ORANGE_PANEL,
PURPLE_PANEL,
YELLOW_PANEL,
RED_PANEL,
} from '../../colors';
/** Groups that are not sales customers (filter client-side). */
export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']);
export function isSupplierCustomerGroup(group) {
return String(group?.name ?? '').trim().toLowerCase() === 'lieferanten';
}
export function entityDisplayName(entity) {
if (!entity) return '';
const fullName = [entity.firstName, entity.name].filter(Boolean).join(' ');
return fullName || entity.name || entity.firstName || (entity.number ? `Kunde ${entity.number}` : (entity.id ? `Kunde #${entity.id}` : ''));
}
export function sortEntities(entities) {
return [...entities].sort((a, b) => {
const byName = String(a.name).localeCompare(String(b.name), 'de');
if (byName !== 0) return byName;
return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de');
});
}
/**
* Customers = entities whose customergroup is not Lieferanten.
*/
export function filterNonSupplierEntities(entities, groups) {
const supplierIds = new Set(
(groups || [])
.filter(isSupplierCustomerGroup)
.map((g) => g.id),
);
return sortEntities(
(entities || []).filter((e) => !supplierIds.has(e.customergroupId)),
);
}
/**
* Returns the customer group with the lowest sort number that is not Lieferanten.
*/
export function getLowestSortNonSupplierCustomerGroup(groups) {
const nonSuppliers = (groups || []).filter((g) => !isSupplierCustomerGroup(g));
if (nonSuppliers.length === 0) return null;
return [...nonSuppliers].sort(
(a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0),
)[0];
}
/**
* Customers = entities whose customergroup.name is not Lieferanten/POS.
*/
export function filterCustomerEntities(entities, groups) {
const excludedIds = new Set(
groups
.filter((g) => EXCLUDED_CUSTOMER_GROUP_NAMES.has(g.name))
.map((g) => g.id),
);
return sortEntities(
entities.filter((e) => !excludedIds.has(e.customergroupId)),
);
}
export function sortSuppliers(suppliers) {
return [...suppliers].sort((a, b) =>
String(a.name).localeCompare(String(b.name), 'de'),
);
}
export function sortDocs(docs) {
return [...docs].sort((a, b) => String(b.date).localeCompare(String(a.date)));
}
export function formatDate(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString('de-DE');
}
export function formatDateTime(iso) {
if (!iso) return '–';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
/**
* Delivery documents have a server-allocated, read-only `number` plus an
* optional user-entered `externalNumber`. Returns the display value for a
* field, defaulting to a dash when empty.
*/
export function numberDisplay(value) {
if (value == null || String(value).trim() === '') return '–';
return String(value);
}
export function todayInputValue() {
const d = new Date();
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function dateInputValue(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso).slice(0, 10);
return d.toISOString().slice(0, 10);
}
export function emptyLine() {
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
return {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
amount: '1',
sku: '',
name: '',
tax: normTax,
taxrate: normTax,
rebate: '0',
net: '',
gross: '',
pu: '1',
unit: '',
netprice: '',
shippable: true,
};
}
export function lineFromApi(row) {
const netVal = row.netprice != null ? String(row.netprice) : row.net != null ? String(row.net) : '';
const taxVal = row.taxrate != null ? String(row.taxrate) : row.tax != null ? String(row.tax) : '';
const netNum = parseNum(netVal);
const taxNum = parseNum(taxVal);
const rebateNum = parseNum(row.rebate) ?? 0;
let grossVal = '';
if (row.gross != null && row.gross !== '') {
grossVal = String(row.gross);
} else if (netNum != null) {
const rate = taxNum ?? 0;
const g = Math.round((netNum * (1 + rate / 100) * (1 - rebateNum / 100) + Number.EPSILON) * 100) / 100;
grossVal = String(g);
}
return {
id: row.id,
key: `line-${row.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`}`,
amount: row.amount == null ? '' : String(row.amount),
sku: row.sku ?? '',
tax: taxVal,
rebate: row.rebate == null ? '' : String(row.rebate),
net: netVal,
gross: grossVal,
name: row.name ?? '',
pu: row.pu == null ? '' : String(row.pu),
unit: row.unit ?? '',
netprice: netVal,
taxrate: taxVal,
shippable: row.shippable !== undefined ? Boolean(row.shippable) : true,
};
}
export function parseNum(value) {
if (value == null || value === '') return null;
const cleaned = String(value).trim().replace('%', '').trim().replace(',', '.');
if (cleaned === '') return null;
const n = Number(cleaned);
return Number.isFinite(n) ? n : null;
}
export function getFirstVal(...vals) {
for (const v of vals) {
if (v != null && String(v).trim() !== '') return v;
}
return null;
}
export function hasArticleLines(lines) {
if (!Array.isArray(lines) || lines.length === 0) return false;
return lines.some((l) => {
if (!l) return false;
const sku = String(l.sku ?? '').trim();
const name = String(l.name ?? '').trim();
const net = String(l.net ?? l.netprice ?? l.gross ?? '').trim();
return Boolean(sku || name || net);
});
}
/** Build create/update line array. Throws Error on client validation. */
export function buildLines(lines, { requireAtLeastOne, kind } = {}) {
const outLines = [];
const errors = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const sku = String(line.sku ?? '').trim();
const name = String(line.name ?? (sku ? getArticle(sku)?.name : '') ?? '').trim();
const rawAmount = getFirstVal(line.amount, line.count);
const amount = parseNum(rawAmount);
const rawNet = getFirstVal(line.net, line.netprice, line.netPrice, line.eknetto);
let net = parseNum(rawNet);
const rawTax = getFirstVal(line.tax, line.taxrate, line.taxRate, line.mwst);
const tax = parseNum(rawTax);
const rawGross = getFirstVal(line.gross);
const gross = parseNum(rawGross);
const rebate = parseNum(getFirstVal(line.rebate));
if (net == null && gross != null && tax != null) {
net = netFromGross(gross, tax, rebate ?? 0);
}
const rawPu = getFirstVal(line.pu, line.vpe);
const pu = parseNum(rawPu);
const blank = !sku && !name && net == null && gross == null;
if (blank) continue;
const missing = [];
if (!sku && !name) {
missing.push('Artikel (ArtNr oder Name)');
}
if (amount == null) {
missing.push(rawAmount != null ? 'gültige Menge' : 'Menge');
}
if ('pu' in line && pu == null) {
missing.push(rawPu != null ? 'gültige VPE' : 'VPE');
}
if (net == null) {
missing.push(rawNet != null ? 'gültiger Nettopreis' : 'Nettopreis');
}
if (tax == null) {
missing.push(rawTax != null ? 'gültiger Steuersatz' : 'Steuersatz');
}
if (missing.length > 0) {
const identifier = name || sku;
const rowLabel = `Zeile ${i + 1}${identifier ? ` („${identifier}“)` : ''}`;
const fieldsText =
missing.length === 1
? `${missing[0]} fehlt`
: `${missing.slice(0, -1).join(', ')} und ${missing[missing.length - 1]} fehlen`;
errors.push(`${rowLabel}: ${fieldsText}.`);
continue;
}
const row = { amount, net, taxrate: tax };
if (sku) row.sku = sku;
if (name) row.name = name;
if (rebate != null) row.rebate = rebate;
if (pu != null) row.pu = pu;
if (kind !== 'delivery') {
row.shippable = line.shippable !== undefined ? Boolean(line.shippable) : true;
}
outLines.push(row);
}
if (errors.length > 0) {
if (errors.length <= 3) {
throw new Error(errors.join(' '));
}
const visibleErrors = errors.slice(0, 3).join(' ');
const remainingCount = errors.length - 3;
throw new Error(
`${visibleErrors} (und ${remainingCount} weitere fehlerhafte Zeile${remainingCount === 1 ? '' : 'n'})`,
);
}
if (requireAtLeastOne && outLines.length === 0) {
throw new Error('Mindestens eine Artikelzeile nötig.');
}
return outLines;
}
/**
* Convert File objects to base64 for API upload
* @param {Array<{file: File, name: string, type: string, thumbnail?: string, text?: string, parentPdfId?: string|number, isPersisted?: boolean, isDirty?: boolean}>} attachments
* @param {{ onlyNew?: boolean }} [options]
* @returns {Promise<Array<{mimetype: string, name: string, content: string, thumbavif?: string, meta?: object}>>}
*/
export async function buildFilesForUpload(attachments = [], { onlyNew = false } = {}) {
const files = [];
for (const attachment of attachments) {
if (!attachment) continue;
// Skip already persisted attachments in update mode unless modified
if (onlyNew && attachment.isPersisted && !attachment.isDirty) {
continue;
}
if (attachment.isPersisted && !attachment.isDirty && (!attachment.file || attachment.file.size === 0)) {
continue;
}
let fileToUpload = attachment.file;
if (!fileToUpload || fileToUpload.size === 0) {
if (attachment.text) {
fileToUpload = new File([attachment.text], attachment.name || 'file.txt', {
type: 'text/plain;charset=utf-8',
});
} else {
continue;
}
}
try {
const base64 = await fileToBase64(fileToUpload);
const filePayload = {
mimetype: attachment.type || fileToUpload.type,
name: attachment.name || fileToUpload.name,
content: base64,
};
if (attachment.thumbnail) {
const thumbBase64 = attachment.thumbnail.includes(',')
? attachment.thumbnail.split(',')[1]
: attachment.thumbnail;
filePayload.thumbavif = thumbBase64;
}
files.push(filePayload);
} catch (err) {
console.error('Fehler beim Konvertieren von Datei zu Base64:', err);
}
}
return files;
}
/**
* Convert File to base64 string
* @param {File} file
* @returns {Promise<string>}
*/
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === 'string') {
// Remove data URL prefix (e.g., "data:application/pdf;base64,")
const base64 = result.split(',')[1] || result;
resolve(base64);
} else {
reject(new Error('Invalid file reader result'));
}
};
reader.onerror = () => reject(new Error('File reader error'));
reader.readAsDataURL(file);
});
}
/**
* Restore attachments from API response
* @param {Array<{id: number, fileId?: number, manifestId?: number, mimetype: string, name: string, thumbavif?: any, size: number, created?: string, meta?: object}>} apiFiles
* @param {Array<object>} [existingAttachments]
* @returns {Promise<Array<{id: number, fileId: number, manifestId: number, file: File, name: string, type: string, thumbnail: string|null, size: number, text: string|null, parentPdfId: string|null, isPersisted: boolean}>>}
*/
export async function restoreAttachmentsFromApi(apiFiles = [], existingAttachments = []) {
const existingMap = new Map();
for (const existing of existingAttachments) {
if (!existing) continue;
if (existing.id != null) existingMap.set(String(existing.id), existing);
if (existing.fileId != null) existingMap.set(String(existing.fileId), existing);
if (existing.name) existingMap.set(existing.name, existing);
}
const attachments = [];
for (const apiFile of apiFiles) {
try {
let file = null;
let thumbnail = null;
// 1. Try to restore thumbnail from apiFile.thumbavif
if (apiFile.thumbavif) {
try {
if (typeof apiFile.thumbavif === 'string') {
thumbnail = apiFile.thumbavif.startsWith('data:')
? apiFile.thumbavif
: `data:image/avif;base64,${apiFile.thumbavif}`;
} else if (typeof apiFile.thumbavif === 'object') {
const rawBytes = apiFile.thumbavif.data || apiFile.thumbavif;
if (Array.isArray(rawBytes) || rawBytes instanceof Uint8Array) {
const thumbBlob = new Blob([new Uint8Array(rawBytes)], { type: 'image/avif' });
thumbnail = URL.createObjectURL(thumbBlob);
}
}
} catch (thumbErr) {
console.error('Fehler beim Dekodieren von thumbavif:', thumbErr);
}
}
// 2. Fallback to existing matching attachment thumbnail
const matchingExisting =
existingMap.get(String(apiFile.id)) ||
existingMap.get(String(apiFile.fileId)) ||
existingMap.get(apiFile.name);
if (!thumbnail && matchingExisting?.thumbnail) {
thumbnail = matchingExisting.thumbnail;
}
const placeholderName = apiFile.name || 'file';
const placeholderType = apiFile.mimetype || 'application/octet-stream';
file =
matchingExisting?.file instanceof File && matchingExisting.file.size > 0
? matchingExisting.file
: new File([''], placeholderName, { type: placeholderType });
const text = matchingExisting?.text || null;
const parentPdfId = matchingExisting?.parentPdfId || null;
attachments.push({
id: apiFile.id,
fileId: apiFile.fileId ?? apiFile.id,
manifestId: apiFile.manifestId,
isPersisted: true,
file,
name: apiFile.name,
type: apiFile.mimetype,
thumbnail,
size: apiFile.size,
text,
parentPdfId,
uploadedAt: apiFile.created,
meta: apiFile.meta || matchingExisting?.meta || null,
});
} catch (err) {
console.error('Fehler beim Wiederherstellen von Datei:', err);
}
}
return attachments;
}
/**
* Collect new or modified attachments for multipart upload
* @param {Array<{file: File, name: string, type: string, thumbnail?: string, text?: string, parentPdfId?: string|number, isPersisted?: boolean, isDirty?: boolean}>} attachments
* @param {{ onlyNew?: boolean }} [options]
* @returns {Array<{file: File, name: string, mimetype: string, meta?: object}>}
*/
export function collectFilesForUpload(attachments = [], { onlyNew = false } = {}) {
const files = [];
for (const attachment of attachments) {
if (!attachment) continue;
// Skip already persisted attachments in update mode unless modified
if (onlyNew && attachment.isPersisted && !attachment.isDirty) {
continue;
}
if (attachment.isPersisted && !attachment.isDirty && (!attachment.file || attachment.file.size === 0)) {
continue;
}
let fileToUpload = attachment.file;
if (!fileToUpload || fileToUpload.size === 0) {
if (attachment.text) {
fileToUpload = new File([attachment.text], attachment.name || 'file.txt', {
type: 'text/plain;charset=utf-8',
});
} else {
continue;
}
}
let thumbavif = null;
if (attachment.thumbnail) {
thumbavif = attachment.thumbnail.includes(',')
? attachment.thumbnail.split(',')[1]
: attachment.thumbnail;
}
files.push({
file: fileToUpload,
name: attachment.name || fileToUpload.name,
mimetype: attachment.type || fileToUpload.type,
thumbavif,
});
}
return files;
}
/**
* @param {{ number: string, externalNumber?: string, date: string, partyId: number|null, lines: object[], attachments?: object[] }} form
* @param {{ partyField: 'supplierId'|'entityId', requireAtLeastOne: boolean, externalNumber?: boolean }} opts
*/
export async function buildManifestBody(
form,
{ partyField, requireAtLeastOne, externalNumber: useExternalNumber, kind, isUpdate, mode, omitLines },
) {
const date = form.date;
if (!date) throw new Error('Datum fehlt.');
const externalNumber = String(form.externalNumber ?? '').trim();
const isEdit = isUpdate != null ? Boolean(isUpdate) : (mode === 'edit' || !requireAtLeastOne);
const shouldRequireLine = requireAtLeastOne !== false;
const shouldIncludeLines = !omitLines && form.lines !== undefined && form.lines !== null;
let body;
if (kind === 'delivery') {
// Delivery: number is server-allocated/read-only. The caller-supplied
// reference goes in externalNumber (nullable; empty string → null).
body = {
externalNumber: externalNumber || null,
date,
};
if (shouldIncludeLines) {
body.lines = buildLines(form.lines, { requireAtLeastOne: shouldRequireLine, kind: 'delivery' });
}
} else {
let number = String(form.number ?? '').trim();
if (!number) {
number = await ensureDocumentNumber(kind || (partyField === 'entityId' ? 'quote' : 'doc'));
}
if (!number) throw new Error('Nummer konnte nicht ermittelt werden.');
body = {
number,
date,
};
if (shouldIncludeLines) {
body.lines = buildLines(form.lines, { requireAtLeastOne: shouldRequireLine, kind });
}
if (useExternalNumber || form.externalNumber != null) {
body.externalNumber = externalNumber || null;
}
}
if (form.partyId != null && form.partyId !== '') {
const pid = Number(form.partyId);
if (!Number.isNaN(pid)) {
body[partyField] = pid;
}
} else if (kind === 'order') {
throw new Error('Für eine Bestellung ist ein Kunde erforderlich.');
} else if (isEdit) {
body[partyField] = null;
}
// Add files if attachments exist
if (form.attachments && form.attachments.length > 0) {
const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isEdit });
if (filesToUpload.length > 0) {
const formData = new FormData();
body.filesMeta = filesToUpload.map((item) => ({
name: item.name,
thumbavif: item.thumbavif ?? null,
}));
formData.append('data', JSON.stringify(body));
for (const item of filesToUpload) {
formData.append('files', item.file, item.name);
}
return formData;
}
}
return body;
}
export const LINE_COLUMNS = [
{ id: 'pos', label: 'Pos', width: 44, align: 'center' },
{ id: 'amount', label: 'Anzahl', width: 80, align: 'right', type: 'number' },
{ id: 'sku', label: 'ArtNr', width: 130, align: 'left', type: 'text' },
{ id: 'name', label: 'Name', width: 200, align: 'left', type: 'text' },
{ id: 'tax', label: 'MwSt', width: 70, align: 'right', type: 'number' },
{ id: 'rebate', label: 'Rabatt', width: 75, align: 'right', type: 'number' },
{ id: 'net', label: 'Netto', width: 95, align: 'right', type: 'number' },
{ id: 'gross', label: 'Brutto', width: 95, align: 'right', type: 'number' },
];
export const QUOTE_LINE_COLUMNS = [
{ id: 'pos', label: 'Pos', width: 44, align: 'center' },
{ id: 'amount', label: 'Anzahl', width: 80, align: 'right', type: 'number' },
{ id: 'sku', label: 'ArtNr', width: 130, align: 'left', type: 'text' },
{ id: 'name', label: 'Name', width: 200, align: 'left', type: 'text' },
{ id: 'shippable', label: 'Lieferbar', width: 68, align: 'center', type: 'boolean' },
{ id: 'tax', label: 'MwSt', width: 70, align: 'right', type: 'number' },
{ id: 'rebate', label: 'Rabatt', width: 75, align: 'right', type: 'number' },
{ id: 'net', label: 'Netto', width: 95, align: 'right', type: 'number' },
{ id: 'gross', label: 'Brutto', width: 95, align: 'right', type: 'number' },
];
export const ORDER_LINE_COLUMNS = [
{ id: 'pos', label: 'Pos', width: 44, align: 'center' },
{ id: 'sku', label: 'ArtNr', width: 130, align: 'left', type: 'text' },
{ id: 'name', label: 'Name', width: 200, align: 'left', type: 'text' },
{ id: 'amount', label: 'Anzahl', width: 80, align: 'right', type: 'number' },
{ id: 'shippable', label: 'Lieferbar', width: 68, align: 'center', type: 'boolean' },
{ id: 'tax', label: 'MwSt', width: 70, align: 'right', type: 'number' },
{ id: 'rebate', label: 'Rabatt', width: 75, align: 'right', type: 'number' },
{ id: 'net', label: 'Netto', width: 95, align: 'right', type: 'number' },
{ id: 'gross', label: 'Brutto', width: 95, align: 'right', type: 'number' },
];
export const DELIVERY_LINE_COLUMNS = [
{ id: 'pos', label: 'Pos', width: 44, align: 'center' },
{ id: 'sku', label: 'ArtNr', width: 130, align: 'left', type: 'text' },
{ id: 'name', label: 'Name', width: 220, align: 'left', type: 'text' },
{ id: 'amount', label: 'Anzahl', width: 88, align: 'right', type: 'number' },
{ id: 'pu', label: 'VPE', width: 80, align: 'right', type: 'number' },
{ id: 'taxrate', label: 'MwSt', width: 80, align: 'right', type: 'number' },
{ id: 'netprice', label: 'Netto', width: 104, align: 'right', type: 'number' },
];
/** @typedef {'delivery'|'quote'|'order'} ManifestKind */
/**
* Resolves the api namespace for a manifest kind.
*
* Delivery, quote, and order share one editor/list shape but talk to three
* different endpoints.
*
* @param {ManifestKind} kind Manifest kind.
* @returns {{get: Function, create: Function, update: Function, delete: Function}}
*/
export function apiFor(cfgOrKind) {
const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind;
if (kind === 'delivery') return deliveryApi;
if (kind === 'quote') return quoteApi;
if (kind === 'order') return orderApi;
if (kind === 'retoure') return retoureApi;
throw new Error(`Unknown manifest kind: ${kind}`);
}
/**
* Load party options for Autocomplete / list name lookup.
* @param {ManifestKind|object} cfgOrKind
*/
export async function loadParties(cfgOrKind) {
const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind;
if (kind === 'delivery') {
if (supplierAutomat.state.status === 'ready') {
return sortSuppliers(supplierAutomat.state.suppliers);
}
const list = await supplierAutomat.actions.load();
return sortSuppliers(list);
}
const [entities, groups] = await Promise.all([
entityAutomat.actions.ensureLoaded(),
customergroupAutomat.actions.ensureLoaded(),
]);
const entityList = entities ?? entityAutomat.state.items ?? [];
const groupList = groups ?? customergroupAutomat.state.items ?? [];
return filterNonSupplierEntities(entityList, groupList);
}
export function partyDisplayName(cfgOrKind, party) {
if (!party) return '';
if (typeof party === 'string') return party;
const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind;
if (kind === 'delivery') return String(party.name ?? '');
return entityDisplayName(party);
}
/**
* @param {ManifestKind|object} cfgOrKind
*/
export function manifestConfig(cfgOrKind) {
if (cfgOrKind && typeof cfgOrKind === 'object' && cfgOrKind.kind && cfgOrKind.api) {
return cfgOrKind;
}
const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind;
if (kind === 'delivery') {
return {
kind: 'delivery',
api: 'delivery',
listField: 'deliveries',
docField: 'delivery',
partyField: 'supplierId',
listPath: '/lieferung',
createPath: '/lieferung/neu',
editPath: (id) => `/lieferung/${id}`,
dateLabel: 'Lieferdatum',
partyLabel: 'Lieferant',
emptyList: 'Noch keine Lieferungen.',
createButton: 'Neue Lieferung',
createdMsg: 'Lieferung angelegt.',
savedMsg: 'Lieferung gespeichert.',
deletedMsg: 'Lieferung gelöscht.',
confirmDelete: (number) => `Lieferung „${number}“ wirklich löschen?`,
invalidId: 'Ungültige Lieferungs-ID.',
partyGateMessage: null,
getPathLabel: 'GET /api/delivery/:id',
postPathLabel: 'POST /api/delivery',
patchPathLabel: 'PATCH /api/delivery/:id',
listColor: ORANGE_PANEL,
headerColor: ORANGE_PANEL,
linesColor: GREEN_PANEL,
requirePartyBeforeLines: false,
listAutomat: deliveryAutomat,
partiesByIdFromList: (parties) =>
Object.fromEntries(parties.map((row) => [row.id, row])),
// Delivery uses a server-allocated read-only number plus an optional
// caller-supplied external reference.
externalNumber: true,
columns: DELIVERY_LINE_COLUMNS,
};
}
if (kind === 'quote') {
return {
kind: 'quote',
api: 'quote',
listField: 'quotes',
docField: 'quote',
partyField: 'entityId',
listPath: '/auftrag/angebot',
createPath: '/auftrag/neuesangebot',
editPath: (id) => `/auftrag/${id}`,
dateLabel: 'Gültig bis',
partyLabel: 'Kunde',
emptyList: 'Noch keine Angebote.',
createButton: 'Neues Angebot',
createdMsg: 'Angebot angelegt.',
savedMsg: 'Angebot gespeichert.',
deletedMsg: 'Angebot gelöscht.',
confirmDelete: (number) => `Angebot „${number}“ wirklich löschen?`,
invalidId: 'Ungültige Angebots-ID.',
partyGateMessage: null,
getPathLabel: 'GET /api/quote/:id',
postPathLabel: 'POST /api/quote',
patchPathLabel: 'PATCH /api/quote/:id',
listColor: YELLOW_PANEL,
headerColor: YELLOW_PANEL,
linesColor: GREEN_PANEL,
requirePartyBeforeLines: false,
listAutomat: quoteAutomat,
partiesByIdFromList: (parties) =>
Object.fromEntries(parties.map((row) => [row.id, row])),
columns: QUOTE_LINE_COLUMNS,
};
}
if (kind === 'order') {
return {
kind: 'order',
api: 'order',
listField: 'orders',
docField: 'order',
partyField: 'entityId',
listPath: '/auftrag',
createPath: '/auftrag/bestellung/neu',
editPath: (id) => `/auftrag/bestellung/${id}`,
dateLabel: 'Lieferdatum',
partyLabel: 'Kunde',
emptyList: 'Noch keine Bestellungen.',
createButton: 'Neuer Auftrag',
createdMsg: 'Bestellung angelegt.',
savedMsg: 'Bestellung gespeichert.',
deletedMsg: 'Bestellung gelöscht.',
confirmDelete: (number) => `Bestellung „${number}“ wirklich löschen?`,
invalidId: 'Ungültige Bestellungs-ID.',
partyGateMessage: null,
getPathLabel: 'GET /api/order/:id',
postPathLabel: 'POST /api/order',
patchPathLabel: 'PATCH /api/order/:id',
listColor: PURPLE_PANEL,
headerColor: PURPLE_PANEL,
linesColor: GREEN_PANEL,
requirePartyBeforeLines: false,
listAutomat: orderAutomat,
partiesByIdFromList: (parties) =>
Object.fromEntries(parties.map((row) => [row.id, row])),
externalNumber: true,
columns: ORDER_LINE_COLUMNS,
};
}
if (kind === 'retoure') {
return {
kind: 'retoure',
api: 'retoure',
listField: 'retoures',
docField: 'retoure',
partyField: 'entityId',
listPath: '/auftrag/retoure',
createPath: '/auftrag/retoure/neu',
editPath: (id) => `/auftrag/retoure/${id}`,
dateLabel: 'Retourendatum',
partyLabel: 'Kunde',
emptyList: 'Noch keine Retouren.',
createButton: 'Neue Retoure',
createdMsg: 'Retoure angelegt.',
savedMsg: 'Retoure gespeichert.',
deletedMsg: 'Retoure gelöscht.',
confirmDelete: (number) => `Retoure „${number}“ wirklich löschen?`,
invalidId: 'Ungültige Retouren-ID.',
partyGateMessage: null,
getPathLabel: 'GET /api/retoure/:id',
postPathLabel: 'POST /api/retoure',
patchPathLabel: 'PUT /api/retoure/:id',
listColor: RED_PANEL,
headerColor: RED_PANEL,
linesColor: GREEN_PANEL,
requirePartyBeforeLines: false,
listAutomat: retoureAutomat,
partiesByIdFromList: (parties) =>
Object.fromEntries(parties.map((row) => [row.id, row])),
externalNumber: true,
columns: ORDER_LINE_COLUMNS,
};
}
throw new Error(`Unknown manifest kind: ${kind}`);
}
/**
* Resolves supplier SKU and own SKU for a manifest line.
*/
export function resolveLineSkus(line, delivery, supplierArticlesMap = {}) {
const rawSku = line?.sku ? String(line.sku).trim() : '';
const supplierId = delivery?.supplierId ?? delivery?.partyId;
// 1. Direct line properties if available
let supplierSku =
line?.supplierSku ||
line?.supplier_sku ||
line?.supplierArticle?.sku ||
'';
let ownSku =
line?.articleSku ||
line?.article_sku ||
line?.article?.sku ||
line?.supplierArticle?.article?.sku ||
'';
// 2. Check supplierArticlesMap
const sa = rawSku
? (supplierArticlesMap[rawSku] || supplierArticlesMap[rawSku.toLowerCase()])
: null;
if (sa) {
if (!supplierSku && sa.sku) supplierSku = sa.sku;
if (!ownSku && sa.article?.sku) ownSku = sa.article.sku;
}
// 3. Check loaded article details
const cachedArticle =
line?.article ||
(ownSku ? getArticle(ownSku) : null) ||
(rawSku ? getArticle(rawSku) : null);
if (cachedArticle) {
if (!ownSku && cachedArticle.sku) {
ownSku = cachedArticle.sku;
}
const sup =
(supplierId && cachedArticle.suppliers?.find((s) => s.entityId === supplierId)) ||
cachedArticle.suppliers?.[0];
if (!supplierSku && sup?.sku) {
supplierSku = sup.sku;
}
}
// 4. Fallback heuristics if one is still missing
if (!supplierSku && !ownSku) {
ownSku = rawSku;
} else if (!supplierSku && rawSku && rawSku !== ownSku) {
supplierSku = rawSku;
} else if (!ownSku && rawSku && rawSku !== supplierSku) {
ownSku = rawSku;
}
return {
supplierSku: supplierSku || '–',
ownSku: ownSku || '–',
};
}