881 lines
30 KiB
JavaScript
881 lines
30 KiB
JavaScript
import {
|
||
delivery as deliveryApi,
|
||
quote as quoteApi,
|
||
order as orderApi,
|
||
retoure as retoureApi,
|
||
} from '../../api';
|
||
import entityAutomat from '../../automats/entityAutomat';
|
||
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 companyAutomat, { 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 (companyAutomat.state.suppliersStatus === 'ready') {
|
||
return sortSuppliers(companyAutomat.state.suppliers);
|
||
}
|
||
const list = await companyAutomat.actions.loadSuppliers();
|
||
return sortSuppliers(list);
|
||
}
|
||
const [entities, groups] = await Promise.all([
|
||
entityAutomat.actions.ensureLoaded(),
|
||
companyAutomat.actions.ensureCustomergroupsLoaded(),
|
||
]);
|
||
const entityList = entities ?? entityAutomat.state.items ?? [];
|
||
const groupList = groups ?? companyAutomat.state.customergroups ?? [];
|
||
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 || '–',
|
||
};
|
||
}
|
||
|