diff --git a/AGENTS.md b/AGENTS.md index 2e6b9f5..e42924d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 2f717e9..240c988 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app.js b/app.js index 0b85490..c8a36c0 100644 --- a/app.js +++ b/app.js @@ -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) { `; } +function renderFifo(data) { + const v = viewEls(); + if (!v) return; + const { products, totals, productCount } = data; + + v.kpis.innerHTML = ` +
+
Lagerwert (FIFO)
+
${formatEuro(totals.value)}
+
Summe Stückkosten × Restmenge
+
+
+
Produkte
+
${productCount}
+
verschiedene Artikel
+
+
+
Menge
+
${formatQuantity(totals.qty)}
+
Restmenge gesamt
+
+
+
Positionen
+
${products.length}
+
FIFO-Schichten
+
`; + + if (els.fifoInfo) els.fifoInfo.textContent = `${products.length} Positionen`; + + v.table.innerHTML = ""; + products.forEach((p) => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${p.name} + ${p.code} + ${p.categId ?? ""} + ${formatEuro(p.unitCost)} + ${formatQuantity(p.qty)} + ${formatEuro(p.value)}`; + v.table.appendChild(tr); + }); + + const tr = document.createElement("tr"); + tr.className = "total"; + tr.innerHTML = ` + Gesamt + + + + ${formatQuantity(totals.qty)} + ${formatEuro(totals.value)}`; + 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 = ` + ${p.name} + ${p.categId ?? ""} + ${p.createDate} + ${formatEuro(p.unitCost)} + ${formatQuantity(p.qty)}`; + v.table.appendChild(tr); + }); + + const tr = document.createElement("tr"); + tr.className = "total"; + tr.innerHTML = ` + Gesamt + + + + ${formatQuantity(totals.qty)}`; + 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", diff --git a/index.html b/index.html index 03ec31b..33b1e79 100644 --- a/index.html +++ b/index.html @@ -71,6 +71,12 @@ ODOO Rechnungen pro Monat + + FIFO Lager Bewertung + + + Produkt Materialkosten + @@ -277,6 +283,64 @@
+ +
+
+ +
+
+

FIFO Lager Bewertung

+ +
+
+ + + + + + + + + + + + +
ProduktCodeKategorieStückkostenMengeWert
+
+
+ +
+
+ + +
+
+
+

Produkt Materialkosten

+ +
+

+ Restmenge = verbleibende Menge der jeweiligen Kostenstaffel – nicht der Gesamtbestand des Produkts. Letzte Kostenstaffel, nicht FIFO. +

+
+ + + + + + + + + + + +
ProduktKategorieEinlagerungsdatumStückkostenRestmenge (Staffel)
+
+
+ +
+
+
diff --git a/styles.css b/styles.css index 1cd308e..7fdf022 100644 --- a/styles.css +++ b/styles.css @@ -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); }