Stückkosten
This commit is contained in:
1 parent
c816ea3ac9
commit
d7c55d444b
5 files changed
+321
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in new issue
Block a user