Add editable article prices with gross input and staffel tiers.
Wire PUT /prices, de-DE formatting, tax/staffel pills, and keep the category list cache in sync after photo/price updates.
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
/** Helpers for GET/PATCH /api/article/:sku detail shape. */
|
||||
|
||||
import {
|
||||
formatPriceAmount,
|
||||
netFromGross,
|
||||
parsePriceInput,
|
||||
} from './price';
|
||||
|
||||
export function sortedPhotos(article) {
|
||||
if (!Array.isArray(article?.photos)) return [];
|
||||
return [...article.photos].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
||||
@@ -66,6 +72,72 @@ export function priceBrutto(price) {
|
||||
return price.net * (1 + price.taxRate / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tax rates offered in the price editor: company standard, reduced, free (0),
|
||||
* plus the article's current rate if it is not already listed.
|
||||
* (No GET /tax list endpoint — rates come from the company record.)
|
||||
*/
|
||||
export function companyTaxRateOptions(company, currentTaxRate) {
|
||||
const rates = [];
|
||||
if (company?.taxrate != null) rates.push(Number(company.taxrate));
|
||||
if (company?.reducedtaxrate != null) rates.push(Number(company.reducedtaxrate));
|
||||
if (company?.taxfreerate != null) rates.push(Number(company.taxfreerate));
|
||||
if (currentTaxRate != null && !rates.includes(Number(currentTaxRate))) {
|
||||
rates.push(Number(currentTaxRate));
|
||||
}
|
||||
return [...new Set(rates)].sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
* POS price rows for the same customer group as the primary (base) price.
|
||||
*/
|
||||
export function posPriceGroup(article) {
|
||||
const base = primaryPrice(article);
|
||||
if (!base) return [];
|
||||
const groupId = base.customergroupId;
|
||||
const rows =
|
||||
groupId != null
|
||||
? (article.prices ?? []).filter((row) => row.customergroupId === groupId)
|
||||
: (article.prices ?? []);
|
||||
return [...rows].sort(
|
||||
(a, b) => (a.minmultiplier ?? 0) - (b.minmultiplier ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
/** Staffelpreise (minmultiplier ≠ 1) as editor draft rows (gross, de-DE). */
|
||||
export function tierDraftFromArticle(article) {
|
||||
return posPriceGroup(article)
|
||||
.filter((row) => row.minmultiplier !== 1)
|
||||
.map((row) => ({
|
||||
minmultiplier: row.minmultiplier,
|
||||
gross: formatPriceAmount(priceBrutto(row)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/prices body: base + optional tier rows.
|
||||
* `tiers`: [{ minmultiplier, gross }] with gross as de-DE input string.
|
||||
*/
|
||||
export function buildPosPricesPutBody({ net, taxRate, tiers = [] }) {
|
||||
const body = [{ price: net, tax: taxRate }];
|
||||
const seen = new Set([1]);
|
||||
|
||||
for (const tier of tiers) {
|
||||
const mult = Number(tier.minmultiplier);
|
||||
const tierNet = netFromGross(parsePriceInput(tier.gross), taxRate);
|
||||
if (!Number.isFinite(mult) || mult <= 1 || seen.has(mult)) {
|
||||
throw new Error('Ungültiger Staffel-Multiplikator');
|
||||
}
|
||||
if (tierNet == null) {
|
||||
throw new Error('Ungültiger Staffelpreis');
|
||||
}
|
||||
seen.add(mult);
|
||||
body.push({ price: tierNet, tax: taxRate, minmultiplier: mult });
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export function brandName(article) {
|
||||
return article?.brand?.name ?? null;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export function normalizeCompany(raw, overrides = {}) {
|
||||
iso639: overrides.iso639 !== undefined ? overrides.iso639 : raw.iso639,
|
||||
taxrate: overrides.taxrate !== undefined ? overrides.taxrate : raw.taxrate,
|
||||
reducedtaxrate: overrides.reducedtaxrate !== undefined ? overrides.reducedtaxrate : raw.reducedtaxrate,
|
||||
taxfreerate: overrides.taxfreerate !== undefined ? overrides.taxfreerate : raw.taxfreerate,
|
||||
rootCategoryId: overrides.rootCategoryId !== undefined ? overrides.rootCategoryId : raw.rootCategoryId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,54 @@
|
||||
const PRICE_LOCALE = 'de-DE';
|
||||
|
||||
export function getBrutto(netto, kSteuerklasse) {
|
||||
const rate = { 1: 19, 2: 7, 3: 0 }[kSteuerklasse] ?? 19;
|
||||
return netto * (1 + rate / 100);
|
||||
}
|
||||
|
||||
/** Currency display, e.g. `6,60 €`. */
|
||||
export function formatPrice(amount) {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
if (amount == null || !Number.isFinite(Number(amount))) return '';
|
||||
return new Intl.NumberFormat(PRICE_LOCALE, {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Amount without currency symbol (for fields with a separate € adornment).
|
||||
* e.g. `6,60` / `1.234,56`
|
||||
*/
|
||||
export function formatPriceAmount(amount, { minFractionDigits = 2, maxFractionDigits = 2 } = {}) {
|
||||
if (amount == null || !Number.isFinite(Number(amount))) return '';
|
||||
return new Intl.NumberFormat(PRICE_LOCALE, {
|
||||
minimumFractionDigits: minFractionDigits,
|
||||
maximumFractionDigits: maxFractionDigits,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse de-DE (or plain) price input → number.
|
||||
* Accepts `6,60`, `1.234,56`, and plain `6.60`.
|
||||
*/
|
||||
export function parsePriceInput(value) {
|
||||
if (value === '' || value == null) return null;
|
||||
let normalized = String(value).trim().replace(/\s/g, '');
|
||||
if (normalized === '') return null;
|
||||
|
||||
if (normalized.includes(',')) {
|
||||
// de-DE: thousand `.`, decimal `,`
|
||||
normalized = normalized.replace(/\./g, '').replace(',', '.');
|
||||
}
|
||||
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
/** Convert gross (brutto) → net for PUT /prices. Rounded to 4 decimal places. */
|
||||
export function netFromGross(gross, taxRate) {
|
||||
if (gross == null || taxRate == null) return null;
|
||||
if (!Number.isFinite(gross) || !Number.isFinite(taxRate) || taxRate < 0) return null;
|
||||
const factor = 1 + taxRate / 100;
|
||||
if (factor <= 0) return null;
|
||||
return Math.round((gross / factor) * 10000) / 10000;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user