Stückkosten

This commit is contained in:
oliver committed 2026-10-26 11:46:41 -03:00
1 parent c816ea3ac9
commit d7c55d444b
5 files changed
+321 -6

No files matched your search

+8 -3
View File
@@ -66,9 +66,14 @@ No bundler — keep everything in global scope as the file already does.
`query` selects the dataset. The datasets are large, so **exactly one dataset is
requested per call**, for the dashboard currently shown (no prefetching).
- Query keys: `"Summe je Abgangsland"` (Umsatz je Abgangsland), `"VAT Summary"`,
`"ODOO Rechnungen pro Monat"`, `"Alle Rechnungen pro Monat"` and `"Quartal Schweiz"`.
Each view's `query` lives in the `VIEWS` map, which maps the active dashboard to its
`normalize` function; `quarter: true` marks the dashboards that use a quarter period.
`"ODOO Rechnungen pro Monat"`, `"Alle Rechnungen pro Monat"`, `"Quartal Schweiz"`,
`"FIFO Lager Bewertung"` and `"Produkt Materialkosten"`. Each view's `query` lives in
the `VIEWS` map, which maps the active dashboard to its `normalize` function;
`quarter: true` marks the dashboards that use a quarter period. The FIFO and
Produkt-Materialkosten responses are `[{ id, query, row_count, data: [...] }]`
(unwrapped in `normalizeFifo` / `normalizeMaterialkosten`). In *Produkt Materialkosten*,
`remaining_qty` is the remaining quantity of that cost bracket (the last cost bracket, not FIFO),
not the product total.
- `extractArray` accepts the dataset either as a bare array or wrapped under the
query key (case-insensitive). If the shape changes upstream, adjust the normalizers.
- The webhook returns **German-formatted strings**, e.g. `"90.717,93 €"` and
+7
View File
@@ -31,6 +31,8 @@ The left sidebar groups reports by topic:
- **Amazon** → *Umsatz je Abgangsland* (Brutto inkl. USt je Abgangsland)
- **Amazon** → *VAT Summary* (USt-Übersicht je Land)
- **ODOO** → *Rechnungen pro Monat* (Pivot über Odoo-Rechnungen)
- **ODOO** → *FIFO Lager Bewertung* (Lagerwert FIFO)
- **ODOO** → *Produkt Materialkosten* (Materialkosten je Produkt)
- **Gesamt** → *Alle Rechnungen pro Monat* (PivotTable.js-Experiment)
- **Gesamt** → *Quartal Schweiz* (AMAZON + ODOO, Lieferung in die Schweiz)
@@ -60,6 +62,8 @@ Gesamt row:
- *Rechnungen pro Monat* → `odoo-rechnungen_YYYY-MMM.csv`
- *Alle Rechnungen pro Monat* → `gesamt-rechnungen-pivot_YYYY-MMM.csv`
- *Quartal Schweiz* → `schweiz-quartal_YYYY-QN.csv`
- *FIFO Lager Bewertung* → `fifo-lager_YYYY-MMM.csv`
- *Produkt Materialkosten* → `materialkosten_YYYY-MMM.csv`
The button is disabled while no dataset is loaded for the current dashboard.
@@ -81,6 +85,7 @@ Rechnungen pro Monat* filters by **Tag**, **Bestellnummer**, **Lieferland** and
For *Quartal Schweiz* the shared **Zeitraum** control shows a **quarter**
selector (Q1–Q4) instead of the month selector; the year selector is shared.
## Pivot (Experiment)
The three invoice dashboards (*ODOO Rechnungen pro Monat*, *Alle Rechnungen pro
@@ -118,6 +123,8 @@ selected with the `query` field:
- *Rechnungen pro Monat* → `query: "ODOO Rechnungen pro Monat"`
- *Alle Rechnungen pro Monat* → `query: "Alle Rechnungen pro Monat"`
- *Quartal Schweiz* → `query: "Quartal Schweiz"` (sends `quarter` instead of `month`)
- *FIFO Lager Bewertung* → `query: "FIFO Lager Bewertung"` (response is `[{ id, query, row_count, data: [...] }]`)
- *Produkt Materialkosten* → `query: "Produkt Materialkosten"` (one row per product; `remaining_qty` = remaining of that cost bracket, not the product total)
## Files
+241 -3
View File
@@ -55,6 +55,15 @@ const els = {
chFilterLieferland: document.getElementById("ch-filter-lieferland"),
chFilterNummer: document.getElementById("ch-filter-rechnungsnummer"),
chFilterQuelle: document.getElementById("ch-filter-quelle"),
// ODOO · FIFO Lager Bewertung
fifoKpis: document.getElementById("fifo-kpis"),
fifoTable: document.querySelector("#fifo-table tbody"),
fifoInfo: document.getElementById("fifo-info"),
fifoState: document.getElementById("fifo-state"),
// ODOO · Produkt Materialkosten
mkTable: document.querySelector("#mk-table tbody"),
mkInfo: document.getElementById("mk-info"),
mkState: document.getElementById("mk-state"),
// Auth
authOverlay: document.getElementById("auth-overlay"),
authForm: document.getElementById("auth-form"),
@@ -195,6 +204,10 @@ function formatQuantity(value) {
return value.toLocaleString("de-DE");
}
function formatDate(value) {
return value ? String(value).slice(0, 10) : "";
}
/* ---------- Data ---------- */
function extractArray(payload, key) {
@@ -313,6 +326,89 @@ function normalizeRechnungen(rows) {
return { invoices, totals: computeRechnungenTotals(invoices) };
}
function pickLocalized(value) {
if (value == null) return "";
if (typeof value !== "object") return String(value);
return value.de_DE ?? value.de_CH ?? value.en_US ?? Object.values(value)[0] ?? "";
}
function normalizeFifo(rows) {
// The webhook returns [{ id, query, row_count, data: [...] }].
let items = Array.isArray(rows) ? rows : [];
if (items.length && items[0] && Array.isArray(items[0].data)) {
items = items.flatMap((r) => (Array.isArray(r.data) ? r.data : []));
}
const products = items.map((row) => {
const unitCost = parseAmount(row.unit_cost);
const qty = parseAmount(row.remaining_qty);
return {
productId: row.product_id,
code: row.default_code ?? "",
name: pickLocalized(row.product_name),
categId: row.categ_id,
unitCost,
qty,
value: unitCost * qty,
};
});
const totals = products.reduce(
(acc, p) => {
acc.value += p.value;
acc.qty += p.qty;
return acc;
},
{ value: 0, qty: 0 }
);
return { products, totals, productCount: new Set(products.map((p) => p.productId)).size };
}
function normalizeMaterialkosten(rows) {
// Response: [{ id, query, row_count, data: [...] }], one row per product.
// remaining_qty = remaining quantity of that cost bracket, NOT the total.
let items = Array.isArray(rows) ? rows : [];
if (items.length && items[0] && Array.isArray(items[0].data)) {
items = items.flatMap((r) => (Array.isArray(r.data) ? r.data : []));
}
const products = items.map((row) => {
const unitCost = parseAmount(row.unit_cost);
const qty = parseAmount(row.remaining_qty);
return {
productId: row.product_id,
code: row.default_code ?? "",
name: pickLocalized(row.product_name),
categId: row.categ_id,
unitCost,
qty,
value: unitCost * qty,
createDate: formatDate(row.create_date),
};
});
// Sort by category, then product name.
products.sort((a, b) => {
const ca = Number(a.categId) || 0;
const cb = Number(b.categId) || 0;
if (ca !== cb) return ca - cb;
return String(a.name).localeCompare(String(b.name), "de");
});
const totals = products.reduce(
(acc, p) => {
acc.value += p.value;
acc.qty += p.qty;
return acc;
},
{ value: 0, qty: 0 }
);
return { products, totals, productCount: new Set(products.map((p) => p.productId)).size };
}
/* ---------- ODOO Rechnungen pro Monat: filters ---------- */
let rechnungenFilter = { tag: "", bestellnummer: "", lieferland: "", nummer: "", quelle: "" };
@@ -646,6 +742,90 @@ function renderRechnungenKpis(data) {
</div>`;
}
function renderFifo(data) {
const v = viewEls();
if (!v) return;
const { products, totals, productCount } = data;
v.kpis.innerHTML = `
<div class="kpi primary">
<div class="kpi-label">Lagerwert (FIFO)</div>
<div class="kpi-value">${formatEuro(totals.value)}</div>
<div class="kpi-sub">Summe Stückkosten × Restmenge</div>
</div>
<div class="kpi">
<div class="kpi-label">Produkte</div>
<div class="kpi-value">${productCount}</div>
<div class="kpi-sub">verschiedene Artikel</div>
</div>
<div class="kpi">
<div class="kpi-label">Menge</div>
<div class="kpi-value">${formatQuantity(totals.qty)}</div>
<div class="kpi-sub">Restmenge gesamt</div>
</div>
<div class="kpi">
<div class="kpi-label">Positionen</div>
<div class="kpi-value">${products.length}</div>
<div class="kpi-sub">FIFO-Schichten</div>
</div>`;
if (els.fifoInfo) els.fifoInfo.textContent = `${products.length} Positionen`;
v.table.innerHTML = "";
products.forEach((p) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${p.name}</td>
<td>${p.code}</td>
<td class="num">${p.categId ?? ""}</td>
<td class="num">${formatEuro(p.unitCost)}</td>
<td class="num">${formatQuantity(p.qty)}</td>
<td class="num">${formatEuro(p.value)}</td>`;
v.table.appendChild(tr);
});
const tr = document.createElement("tr");
tr.className = "total";
tr.innerHTML = `
<td>Gesamt</td>
<td></td>
<td class="num"></td>
<td class="num"></td>
<td class="num">${formatQuantity(totals.qty)}</td>
<td class="num">${formatEuro(totals.value)}</td>`;
v.table.appendChild(tr);
}
function renderMaterialkosten(data) {
const v = viewEls();
if (!v) return;
const { products, totals } = data;
if (els.mkInfo) els.mkInfo.textContent = `${products.length} Positionen`;
v.table.innerHTML = "";
products.forEach((p) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${p.name}</td>
<td class="num">${p.categId ?? ""}</td>
<td>${p.createDate}</td>
<td class="num">${formatEuro(p.unitCost)}</td>
<td class="num">${formatQuantity(p.qty)}</td>`;
v.table.appendChild(tr);
});
const tr = document.createElement("tr");
tr.className = "total";
tr.innerHTML = `
<td>Gesamt</td>
<td class="num"></td>
<td></td>
<td class="num"></td>
<td class="num">${formatQuantity(totals.qty)}</td>`;
v.table.appendChild(tr);
}
function renderRechnungenChart(data) {
const v = viewEls();
if (!v || !v.chart) return;
@@ -792,6 +972,12 @@ function viewEls() {
if (currentView === "odoo-rechnungen") {
return { kpis: els.odooKpis, chart: els.odooChart, legend: els.odooLegend, state: els.odooState, pivot: document.getElementById("odoo-pivot") };
}
if (currentView === "fifo-lager") {
return { kpis: els.fifoKpis, table: els.fifoTable, state: els.fifoState };
}
if (currentView === "materialkosten") {
return { table: els.mkTable, state: els.mkState };
}
if (currentView === "gesamt-rechnungen-pivot") {
return { kpis: els.pivotKpis, state: els.pivotState, pivot: document.getElementById("pivot") };
}
@@ -809,6 +995,10 @@ function renderActiveView() {
renderVat(lastData.data);
} else if (currentView === "odoo-rechnungen" || currentView === "gesamt-rechnungen-pivot" || currentView === "schweiz-quartal") {
renderRechnungenPivot(applyRechnungenFilters(lastData.data));
} else if (currentView === "fifo-lager") {
renderFifo(lastData.data);
} else if (currentView === "materialkosten") {
renderMaterialkosten(lastData.data);
}
}
@@ -828,7 +1018,7 @@ function showSkeleton() {
const v = viewEls();
if (!v) return;
v.state.classList.add("hidden");
v.kpis.innerHTML = skeletonKpis();
if (v.kpis) v.kpis.innerHTML = skeletonKpis();
if (v.chart) v.chart.innerHTML = skeletonBars();
if (v.legend) v.legend.innerHTML = "";
if (v.table) v.table.innerHTML = "";
@@ -845,7 +1035,7 @@ function showError(message) {
function clearViews() {
const v = viewEls();
if (!v) return;
v.kpis.innerHTML = "";
if (v.kpis) v.kpis.innerHTML = "";
if (v.chart) v.chart.innerHTML = "";
if (v.legend) v.legend.innerHTML = "";
if (v.table) v.table.innerHTML = "";
@@ -1009,6 +1199,34 @@ function buildRechnungenCsv(data) {
return lines.join("\r\n");
}
function buildFifoCsv(data) {
const header = ["Produkt", "Code", "Kategorie", "Stückkosten", "Menge", "Wert"];
const lines = [header.map(csvEscape).join(";")];
data.products.forEach((p) => {
lines.push([
p.name, p.code, p.categId ?? "", formatEuro(p.unitCost), formatQuantity(p.qty), formatEuro(p.value),
].map(csvEscape).join(";"));
});
lines.push([
"Gesamt", "", "", "", formatQuantity(data.totals.qty), formatEuro(data.totals.value),
].map(csvEscape).join(";"));
return lines.join("\r\n");
}
function buildMaterialkostenCsv(data) {
const header = ["Produkt", "Kategorie", "Einlagerungsdatum", "Stückkosten", "Restmenge (Staffel)"];
const lines = [header.map(csvEscape).join(";")];
data.products.forEach((p) => {
lines.push([
p.name, p.categId ?? "", p.createDate, formatEuro(p.unitCost), formatQuantity(p.qty),
].map(csvEscape).join(";"));
});
lines.push([
"Gesamt", "", "", "", formatQuantity(data.totals.qty),
].map(csvEscape).join(";"));
return lines.join("\r\n");
}
function downloadCsv() {
if (!lastData || lastData.view !== currentView) return;
@@ -1022,6 +1240,12 @@ function downloadCsv() {
} else if (currentView === "amazon-vat-summary") {
csv = buildVatCsv(lastData.data);
filename = `vat-summary_${period}.csv`;
} else if (currentView === "materialkosten") {
csv = buildMaterialkostenCsv(lastData.data);
filename = `materialkosten_${period}.csv`;
} else if (currentView === "fifo-lager") {
csv = buildFifoCsv(lastData.data);
filename = `fifo-lager_${period}.csv`;
} else if (currentView === "odoo-rechnungen" || currentView === "gesamt-rechnungen-pivot" || currentView === "schweiz-quartal") {
csv = buildRechnungenCsv(applyRechnungenFilters(lastData.data));
filename = `${currentView}_${period}.csv`;
@@ -1048,7 +1272,9 @@ function updateDownloadState() {
currentView === "amazon-vat-summary" ||
currentView === "odoo-rechnungen" ||
currentView === "gesamt-rechnungen-pivot" ||
currentView === "schweiz-quartal";
currentView === "schweiz-quartal" ||
currentView === "fifo-lager" ||
currentView === "materialkosten";
els.download.disabled = !(lastData && lastData.view === currentView && supported);
}
@@ -1074,6 +1300,18 @@ const VIEWS = {
query: "ODOO Rechnungen pro Monat",
normalize: normalizeRechnungen,
},
"fifo-lager": {
title: "FIFO Lager Bewertung",
subtitle: "ODOO · Lagerwert (FIFO)",
query: "FIFO Lager Bewertung",
normalize: normalizeFifo,
},
"materialkosten": {
title: "Produkt Materialkosten",
subtitle: "ODOO · Materialkosten je Produkt (Kostenstaffel)",
query: "Produkt Materialkosten",
normalize: normalizeMaterialkosten,
},
"gesamt-rechnungen-pivot": {
title: "Alle Rechnungen pro Monat",
subtitle: "Gesamt · Pivot",
+64
View File
@@ -71,6 +71,12 @@
<a class="submenu-item" href="#" data-view="odoo-rechnungen">
ODOO Rechnungen pro Monat
</a>
<a class="submenu-item" href="#" data-view="fifo-lager">
FIFO Lager Bewertung
</a>
<a class="submenu-item" href="#" data-view="materialkosten">
Produkt Materialkosten
</a>
</div>
</div>
@@ -277,6 +283,64 @@
<div class="state" id="odoo-state"></div>
</main>
<!-- View: ODOO · FIFO Lager Bewertung -->
<main id="view-fifo-lager" class="view hidden">
<section class="kpis" id="fifo-kpis"></section>
<section class="panel">
<div class="panel-head">
<h2>FIFO Lager Bewertung</h2>
<span class="muted" id="fifo-info"></span>
</div>
<div class="table-wrap">
<table id="fifo-table">
<thead>
<tr>
<th>Produkt</th>
<th>Code</th>
<th class="num">Kategorie</th>
<th class="num">Stückkosten</th>
<th class="num">Menge</th>
<th class="num">Wert</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<div class="state" id="fifo-state"></div>
</main>
<!-- View: ODOO · Produkt Materialkosten -->
<main id="view-materialkosten" class="view hidden">
<section class="panel">
<div class="panel-head">
<h2>Produkt Materialkosten</h2>
<span class="muted" id="mk-info"></span>
</div>
<p class="muted panel-note" id="mk-note">
Restmenge = verbleibende Menge der jeweiligen Kostenstaffel – nicht der Gesamtbestand des Produkts. Letzte Kostenstaffel, nicht FIFO.
</p>
<div class="table-wrap">
<table id="mk-table">
<thead>
<tr>
<th>Produkt</th>
<th class="num">Kategorie</th>
<th>Einlagerungsdatum</th>
<th class="num">Stückkosten</th>
<th class="num" title="Verbleibende Menge der jeweiligen Kostenstaffel – nicht der Gesamtbestand des Produkts. Letzte Kostenstaffel, nicht FIFO.">Restmenge (Staffel)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<div class="state" id="mk-state"></div>
</main>
<!-- View: Gesamt · Alle Rechnungen pro Monat -->
<main id="view-gesamt-rechnungen-pivot" class="view hidden">
<section class="kpis" id="pivot-kpis"></section>
+1
View File
@@ -285,6 +285,7 @@ select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px
}
.panel-head h2 { margin: 0; font-size: 16px; letter-spacing: -.01em; }
.muted { color: var(--muted); font-size: 13px; }
.panel-note { margin: 0 0 14px; }
.legend { display: flex; gap: 14px; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--muted); }