Compare commits

..
2 Commits
Author SHA1 Message Date
oliver c816ea3ac9 eg 2026-10-12 10:12:46 -03:00
oliver 9db080faea not bad 2026-10-12 10:09:44 -03:00
8 changed files with 2055 additions and 55 deletions

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
# AGENTS.md
Guidance for agents working in this repository (Brandize · BI Dashboard).
## What this project is
A **flat, server-free browser dashboard** for the Brandize BI webhook. It renders
gross revenue (Brutto inkl. USt) per departure country plus a VAT summary, with KPI
cards, bar charts and detail tables. There is **no build step, no framework, no
package manager** — just static files opened directly in a browser.
Supporting tools: a bash dev-server launcher (`start`) and a Python CLI (`send`) that
uploads CSV rows to an n8n webhook. SQL scripts (`sql/`) define the upstream queries
that feed the webhook (Odoo 17 / PostgreSQL).
## Layout
| Path | Purpose |
| --- | --- |
| `index.html` | Dashboard markup: sidebar nav, shared filter bar, one `<main class="view">` per report, auth overlay |
| `styles.css` | All styling (CSS custom properties in `:root`, BEM-ish class names) |
| `app.js` | Everything: data loading, parsing, rendering, CSV export, auth, navigation |
| `README.md` | User-facing docs (German/English mixed). Update it when behavior changes |
| `start` | Bash: installs `live-server` if missing and serves the folder on :8080 |
| `send` | Python CLI: batches CSV rows to the `.../webhook/VAT` n8n webhook |
| `sql/*.sql` | Upstream Odoo/PostgreSQL queries (see below) |
| `data/` | Git-ignored. Empty in the repo; holds local CSV payloads for `send` |
## Running / validating
There is **no test suite and no linter**. Validation is manual:
- Open `index.html` directly (double-click) — the intended primary usage, works over `file://`.
- Or run `./start` and browse `http://localhost:8080` for live reload.
- `./send data/<file>.csv` requires Python 3 + `requests` and network access to the webhook.
Because `fetch` hits a remote webhook protected by HTTP Basic Auth, you generally
**cannot fully exercise the UI without valid credentials and network access**. Don't
claim validation passed unless you actually loaded the page and saw it work; otherwise
state clearly what you could not run.
## Architecture notes (`app.js`)
Single file, loaded as a plain `<script>` (not a module), `"use strict"` at the top.
No bundler — keep everything in global scope as the file already does.
- **Config constants** at the top: `API_URL`, `MONTHS` (German abbreviations), `PALETTE`.
- **`els`** — a single object caching all `getElementById`/`querySelector` lookups.
Add new DOM handles here rather than querying inline.
- **State**: `currentView`, `lastData` (`{ abgangsland, vat }`), `auth`.
- **Sections** are delimited by `/* ---------- Name ---------- */` banners: Auth,
Period selector, Formatting, Data, Rendering (per report), CSV export, Navigation, Init.
- **Views**: the `VIEWS` map defines title/subtitle per view id. `showView(name)`
toggles `.hidden` on the `<main class="view">` elements and re-renders. A new report
needs: markup section in `index.html`, an entry in `VIEWS`, a render function, and a
`viewEls()` branch.
- **Data flow**: `load()` → `POST API_URL` with `{ query, month }` → `extractArray(payload, key)`
→ `normalizeAbgangsland` / `normalizeVat` → `renderActiveView()`. `load()` queries
**only** the active dashboard's dataset; it is triggered from `showView()` (when a
dashboard is displayed) and from the refresh/period controls — never prefetched.
### Webhook / data contract
- The webhook is called with **POST** and a JSON body `{ "query": <key>, "month": "YYYY-MMM" }`
(quarter dashboards send `{ "query": <key>, "quarter": "YYYY-QN" }` instead).
`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.
- `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
`"1.234"`. Parsing (`parseEuro`, `parseQuantity`) strips symbols and swaps
`.`/`,` — do **not** use `parseFloat` directly on the raw values.
- `month` uses `YYYY-MMM` (e.g. `2026-SEP`), matching `%(month)s` in `sql/*.sql`;
`quarter` uses `YYYY-QN` (e.g. `2026-Q3`), matching `%(quarter)s` in `sql/Quartal_Schweiz.sql`.
### Auth
HTTP Basic. The first request goes out without credentials; a `401` triggers the
login overlay. Credentials live in `sessionStorage` (best-effort — unavailable on
`file://`, where they stay in memory only) and are sent as `Authorization: Basic ...`.
## SQL scripts (`sql/`)
Odoo 17 / PostgreSQL against table `brandize_datacenter_amazon_vat_transaction`.
Each file has a header block documenting the model, parameters and invocation.
- Parameter is named `%(month)s` for psycopg2; in `psql` use `:'month'` instead
(documented in the headers — respect this distinction).
- Money is formatted **in SQL** to German notation via
`translate(to_char(...), ',.', '.,') || ' €'` so the frontend receives display-ready
strings. Keep formatting consistent if you add columns.
- `Delete_Activity_Period.sql` is destructive — note the "ACHTUNG" warning and the
transaction-bracket guidance. Be cautious editing it.
## Conventions
- **Language**: UI text is **German**, code identifiers and comments are English
(comments occasionally German). Keep new user-facing strings German.
- **Number/date formatting**: always `de-DE` via `toLocaleString` in JS and German
`to_char` formatting in SQL. Dates as `YYYY-MMM` with the `MONTHS` abbreviations.
- **JS style**: `"use strict"`, double quotes, semicolons, 2-space indent, `const`/`let`
(no `var`), named `function` declarations, `camelCase`. Nullish-safe (`??`, optional
guards). DOM built imperatively with `document.createElement` (charts are hand-rolled
bars/lists, no chart library).
- **CSS**: use the existing `:root` custom properties for colors/spacing/radius; reuse
existing class patterns rather than inventing new naming.
- **CSV export**: semicolon-delimited, UTF-8 **with BOM** (for Excel umlauts), CRLF line
endings, includes a `Gesamt` total row, filename `*_YYYY-MMM.csv`. Follow this pattern
for any new exportable view.
- Keep changes minimal and consistent with the existing file structure — this is a small,
intentionally dependency-free static app. Prefer no new dependencies.
## Gotchas
- `data/` is git-ignored; never commit contents from it. `send` expects CSVs with a
header row (`csv.DictReader`) and posts JSON arrays in batches of 50.
- The `send` script targets a **different** webhook (`/webhook/VAT`) than the dashboard
(`/webhook/bi-dashboard`). Don't conflate them.
- `fetch` from `file://` sends `Origin: null`; the webhook reflects origins to allow CORS.
Changing how requests are made can break the "just open the file" workflow.
- README documents behavior (navigation, auth, CSV, filter). If you change any of these,
update `README.md` in the same change.
- The invoice dashboards (*ODOO Rechnungen pro Monat*, *Alle Rechnungen pro Monat*
and *Quartal Schweiz*) are pivot experiments: they load jQuery, jQuery UI and
PivotTable.js from CDNs (so they need internet), reuse `normalizeRechnungen`, and
render via `renderRechnungenPivot` (no detail table). Layouts come from
`PIVOT_PRESETS` (keyed per view; the Vorlage dropdown) / the pivotUI panel; the
renderer is fixed to *Table*. The pivot container is recreated on each render so
a preset actually applies.
+59 -8
View File
@@ -15,6 +15,12 @@ The page calls the webhook directly:
https://brandize.app.n8n.cloud/webhook/bi-dashboard
```
via a **POST** request with a JSON body:
```json
{ "query": "Summe je Abgangsland", "month": "2026-SEP" }
```
The webhook sends CORS headers (it reflects the request `Origin`, including
`null` for `file://`), so a direct browser call works.
@@ -24,9 +30,11 @@ The left sidebar groups reports by topic:
- **Amazon** → *Umsatz je Abgangsland* (Brutto inkl. USt je Abgangsland)
- **Amazon** → *VAT Summary* (USt-Übersicht je Land)
- **ODOO** → *Dashboard* (placeholder, to follow)
- **ODOO** → *Rechnungen pro Monat* (Pivot über Odoo-Rechnungen)
- **Gesamt** → *Alle Rechnungen pro Monat* (PivotTable.js-Experiment)
- **Gesamt** → *Quartal Schweiz* (AMAZON + ODOO, Lieferung in die Schweiz)
Both groups start collapsed, so no submenu is visible when the page opens.
All groups start collapsed, so no submenu is visible when the page opens.
Clicking a topic expands its submenu; clicking a submenu item switches the
view.
@@ -49,8 +57,11 @@ Gesamt row:
- *Umsatz je Abgangsland* → `umsatz-je-abgangsland_YYYY-MMM.csv`
- *VAT Summary* → `vat-summary_YYYY-MMM.csv`
- *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`
The button is disabled on dashboards without data (e.g. the ODOO placeholder).
The button is disabled while no dataset is loaded for the current dashboard.
## Filter
@@ -58,15 +69,55 @@ A shared filter bar sits above every dashboard (labelled **Filter** with a
funnel icon and the hint *gilt für alle Dashboards*). It holds the **Zeitraum**
month + year selector (e.g. `2026-SEP`) and the reload button, and stays visible
across all views. The previous month is preselected. The selected period is
sent to the webhook as `?month=YYYY-MMM`, matching the `%(month)s` parameter
used in `../sql/Summe_je_Abgangsland.sql`.
sent to the webhook in the POST body as `month` (e.g. `2026-SEP`), matching the
`%(month)s` parameter used in `../sql/*.sql`.
The invoice dashboards (*ODOO Rechnungen pro Monat*, *Alle Rechnungen pro Monat*
and *Quartal Schweiz*) have an additional, dashboard-specific filter row. *ODOO
Rechnungen pro Monat* filters by **Tag**, **Bestellnummer**, **Lieferland** and
**Rechnungsnummer**; *Alle Rechnungen pro Monat* and *Quartal Schweiz* add a
**Quelle** field. Filtering re-renders the KPIs, chart and pivot live.
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
Monat* and *Quartal Schweiz*) render their data as an interactive
[PivotTable.js](https://pivottable.js.org/) pivot instead of a detail table.
The pivot's rows, columns, values and aggregator are freely configurable in the
pivotUI control panel; the renderer is fixed to *Table*. Each dashboard has its
own filter row and **Vorlage** dropdown with presets:
- *ODOO Rechnungen pro Monat*: *Standard* (**Rechnungsland** rows, no column
group, **Sum(Gesamtpreis brutto)**) and *Zahlart*.
- *Alle Rechnungen pro Monat* and *Quartal Schweiz*: *Standard* (**Quelle**
rows, no column group, **Sum(Gesamtpreis brutto)**) and *Rechnungsland
(Spalten)* (adds **Rechnungsland** as the column group).
*Quartal Schweiz* is filtered to invoices with a **delivery into Switzerland**.
Applying a preset also resets the filters. The chosen layout is kept while you
type in the filters.
It loads jQuery, jQuery UI and PivotTable.js from public CDNs, so — unlike the
rest of the app — it needs an **internet connection** to render; offline it shows
a short message instead. To vendor the libraries locally, download them next to
`index.html` and change the `<link>`/`<script>` URLs in the `<head>`.
## Data sources
One request to the webhook returns both datasets, which are read by key:
Because the datasets are large, the dashboard requests **only the dataset of
the currently displayed dashboard** — one POST per dashboard, sent only when
that dashboard is opened or reloaded. Nothing is prefetched. The dataset is
selected with the `query` field:
- `Summe je Abgangsland` → the *Umsatz je Abgangsland* report
- `VAT Summary` → the *VAT Summary* report
- *Umsatz je Abgangsland* → `query: "Summe je Abgangsland"`
- *VAT Summary* → `query: "VAT Summary"`
- *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`)
## Files
+495 -27
View File
@@ -6,6 +6,7 @@ const PALETTE = ["#4f46e5", "#0ea5e9", "#f59e0b", "#10b981", "#ec4899", "#8b5cf6
const els = {
month: document.getElementById("month-select"),
quarter: document.getElementById("quarter-select"),
year: document.getElementById("year-select"),
refresh: document.getElementById("refresh-btn"),
download: document.getElementById("download-btn"),
@@ -23,6 +24,37 @@ const els = {
vatTable: document.querySelector("#vat-table tbody"),
vatPeriod: document.getElementById("vat-period"),
vatState: document.getElementById("vat-state"),
// ODOO · Rechnungen pro Monat
odooKpis: document.getElementById("odoo-kpis"),
odooChart: document.getElementById("odoo-chart"),
odooLegend: document.getElementById("odoo-legend"),
odooPeriod: document.getElementById("odoo-period"),
odooState: document.getElementById("odoo-state"),
odooPivotPreset: document.getElementById("odoo-pivot-preset"),
odooFilterTag: document.getElementById("odoo-filter-tag"),
odooFilterBestellnummer: document.getElementById("odoo-filter-bestellnummer"),
odooFilterLieferland: document.getElementById("odoo-filter-lieferland"),
odooFilterNummer: document.getElementById("odoo-filter-rechnungsnummer"),
// Gesamt · Alle Rechnungen pro Monat (Pivot, Experiment)
pivotKpis: document.getElementById("pivot-kpis"),
pivotPeriod: document.getElementById("pivot-period"),
pivotState: document.getElementById("pivot-state"),
pivotPreset: document.getElementById("pivot-preset"),
pivotFilterTag: document.getElementById("pivot-filter-tag"),
pivotFilterBestellnummer: document.getElementById("pivot-filter-bestellnummer"),
pivotFilterLieferland: document.getElementById("pivot-filter-lieferland"),
pivotFilterNummer: document.getElementById("pivot-filter-rechnungsnummer"),
pivotFilterQuelle: document.getElementById("pivot-filter-quelle"),
// Schweiz · Quartal Schweiz
chKpis: document.getElementById("ch-kpis"),
chPeriod: document.getElementById("ch-period"),
chState: document.getElementById("ch-state"),
chPreset: document.getElementById("ch-pivot-preset"),
chFilterTag: document.getElementById("ch-filter-tag"),
chFilterBestellnummer: document.getElementById("ch-filter-bestellnummer"),
chFilterLieferland: document.getElementById("ch-filter-lieferland"),
chFilterNummer: document.getElementById("ch-filter-rechnungsnummer"),
chFilterQuelle: document.getElementById("ch-filter-quelle"),
// Auth
authOverlay: document.getElementById("auth-overlay"),
authForm: document.getElementById("auth-form"),
@@ -33,8 +65,10 @@ const els = {
};
// State for the currently displayed dashboard and its last loaded data.
// Only the dataset of the active dashboard is held — nothing is prefetched.
let currentView = "amazon-abgangsland";
let lastData = null; // { abgangsland, vat }
let lastData = null; // { view, data } — data is the normalized dataset of `view`
let loadToken = 0; // guards against stale responses from superseded requests
/* ---------- Auth (HTTP Basic) ---------- */
@@ -121,13 +155,17 @@ function buildPeriodSelectors() {
}
els.month.value = String(defMonth);
els.quarter.value = String(Math.floor(defMonth / 3) + 1);
els.year.value = String(defYear);
}
function selectedPeriod() {
const meta = VIEWS[currentView];
if (meta && meta.quarter) {
return `${els.year.value}-Q${els.quarter.value}`;
}
const month = MONTHS[Number(els.month.value)];
const year = els.year.value;
return `${year}-${month}`;
return `${els.year.value}-${month}`;
}
/* ---------- Formatting ---------- */
@@ -160,6 +198,9 @@ function formatQuantity(value) {
/* ---------- Data ---------- */
function extractArray(payload, key) {
// The webhook may return the requested dataset either directly as an array
// or wrapped under its query key.
if (Array.isArray(payload)) return payload;
if (!payload || typeof payload !== "object") return [];
if (Array.isArray(payload[key])) return payload[key];
const match = Object.keys(payload).find((k) => k.toLowerCase() === key.toLowerCase());
@@ -217,6 +258,152 @@ function normalizeVat(rows) {
return { countries, totals };
}
// Amounts may arrive either as plain numbers (SQL numeric) or as German
// formatted strings ("90.717,93 €") — accept both.
function parseAmount(value) {
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
return parseEuro(value);
}
function computeRechnungenTotals(invoices) {
return invoices.reduce(
(acc, i) => {
acc.gesamtBrutto += i.gesamtBrutto;
acc.gesamtNetto += i.gesamtNetto;
acc.brutto += i.brutto;
acc.bruttoErm += i.bruttoErm;
acc.netto += i.netto;
acc.nettoErm += i.nettoErm;
acc.versandBrutto += i.versandBrutto;
acc.versandNetto += i.versandNetto;
return acc;
},
{ gesamtBrutto: 0, gesamtNetto: 0, brutto: 0, bruttoErm: 0, netto: 0, nettoErm: 0, versandBrutto: 0, versandNetto: 0 }
);
}
function normalizeRechnungen(rows) {
const invoices = rows.map((row) => ({
datum: row["Rechnungsdatum"] ?? "",
nummer: row["Rechnungsnummer"] ?? "",
land: row["Rechnungsland"] ?? "",
landCode: row["Rechnungsland Ländercode"] ?? "",
zahlart: row["Zahlart"] ?? "",
zahlungsreferenz: row["Zahlungsreference der Bestellung"] ?? "",
vatId: row["USt. Id / VAT Id des Käufers (in Bestellung)"] ?? "",
gesamtBrutto: parseAmount(row["Gesamtpreis brutto"]),
gesamtNetto: parseAmount(row["Gesamtpreis netto"]),
brutto: parseAmount(row["Brutto"]),
bruttoErm: parseAmount(row["Brutto erm"]),
ustSatz: parseAmount(row["USt. Satz"]),
ustSatzErm: parseAmount(row["Ust. Satz erm"]),
netto: parseAmount(row["Netto Betrag"]),
nettoErm: parseAmount(row["Netto Betrag erm"]),
versandBrutto: parseAmount(row["Versandkosten brutto"]),
versandNetto: parseAmount(row["Versandkosten netto"]),
auftragsdatum: row["Auftragsdatum"] ?? "",
bestellnummer: row["Bestellnummer"] ?? "",
zahldatum: row["Zahldatum"] ?? "",
lieferName: row["Lieferadresse Name"] ?? "",
tag: row["Tag"] ?? "",
lieferland: row["Lieferland"] ?? "",
quelle: row["Quelle"] ?? "",
}));
return { invoices, totals: computeRechnungenTotals(invoices) };
}
/* ---------- ODOO Rechnungen pro Monat: filters ---------- */
let rechnungenFilter = { tag: "", bestellnummer: "", lieferland: "", nummer: "", quelle: "" };
function matchesFilter(value, query) {
if (!query) return true;
return String(value).toLowerCase().includes(query.toLowerCase());
}
function applyRechnungenFilters(data) {
const f = rechnungenFilter;
const invoices = data.invoices.filter((i) =>
matchesFilter(i.tag, f.tag) &&
matchesFilter(i.bestellnummer, f.bestellnummer) &&
matchesFilter(i.lieferland, f.lieferland) &&
matchesFilter(i.nummer, f.nummer) &&
matchesFilter(i.quelle, f.quelle)
);
return { invoices, totals: computeRechnungenTotals(invoices) };
}
function rechnungenFilterInputs() {
if (currentView === "gesamt-rechnungen-pivot") {
return [els.pivotFilterTag, els.pivotFilterBestellnummer, els.pivotFilterLieferland, els.pivotFilterNummer, els.pivotFilterQuelle];
}
if (currentView === "schweiz-quartal") {
return [els.chFilterTag, els.chFilterBestellnummer, els.chFilterLieferland, els.chFilterNummer, els.chFilterQuelle];
}
if (currentView === "odoo-rechnungen") {
return [els.odooFilterTag, els.odooFilterBestellnummer, els.odooFilterLieferland, els.odooFilterNummer];
}
return [];
}
// Every filter input across the invoice dashboards. Attached once at startup,
// because the views are only toggled — the inputs stay in the DOM.
function allRechnungenFilterInputs() {
return [
els.odooFilterTag, els.odooFilterBestellnummer, els.odooFilterLieferland, els.odooFilterNummer,
els.pivotFilterTag, els.pivotFilterBestellnummer, els.pivotFilterLieferland, els.pivotFilterNummer, els.pivotFilterQuelle,
els.chFilterTag, els.chFilterBestellnummer, els.chFilterLieferland, els.chFilterNummer, els.chFilterQuelle,
];
}
function resetRechnungenFilters() {
rechnungenFilter = { tag: "", bestellnummer: "", lieferland: "", nummer: "", quelle: "" };
rechnungenFilterInputs().forEach((input) => { if (input) input.value = ""; });
}
function onRechnungenFilter() {
const [tag, bestellnummer, lieferland, nummer, quelle] = rechnungenFilterInputs();
rechnungenFilter = {
tag: (tag?.value || "").trim(),
bestellnummer: (bestellnummer?.value || "").trim(),
lieferland: (lieferland?.value || "").trim(),
nummer: (nummer?.value || "").trim(),
quelle: (quelle?.value || "").trim(),
};
if (lastData && lastData.view === currentView) {
renderActiveView();
}
}
function setupRechnungenFilters() {
allRechnungenFilterInputs().forEach((input) => { if (input) input.addEventListener("input", onRechnungenFilter); });
}
function setupRechnungenPivot() {
wirePivotPreset(els.odooPivotPreset, "odoo-rechnungen");
wirePivotPreset(els.pivotPreset, "gesamt-rechnungen-pivot");
wirePivotPreset(els.chPreset, "schweiz-quartal");
}
function wirePivotPreset(select, view) {
if (!select) return;
select.addEventListener("change", () => {
const presets = PIVOT_PRESETS[view];
const preset = presets[select.value] || presets.standard;
pivotOptions[view] = {
rows: [...preset.rows],
cols: [...preset.cols],
vals: [...preset.vals],
aggregatorName: preset.aggregatorName,
rendererName: preset.rendererName,
};
// A preset also loads the default (empty) filters.
resetRechnungenFilters();
renderActiveView();
});
}
/* ---------- Rendering: Umsatz je Abgangsland ---------- */
function colorFor(bereich, map) {
@@ -428,6 +615,171 @@ function renderVat(data) {
renderVatTable(data);
}
/* ---------- Rendering: ODOO Rechnungen pro Monat ---------- */
function renderRechnungenKpis(data) {
const v = viewEls();
if (!v) return;
const { invoices, totals } = data;
const ust = totals.gesamtBrutto - totals.gesamtNetto;
v.kpis.innerHTML = `
<div class="kpi primary">
<div class="kpi-label">Gesamt brutto</div>
<div class="kpi-value">${formatEuro(totals.gesamtBrutto)}</div>
<div class="kpi-sub">alle Rechnungen im Zeitraum</div>
</div>
<div class="kpi">
<div class="kpi-label">Rechnungen</div>
<div class="kpi-value">${invoices.length}</div>
<div class="kpi-sub">Datensätze</div>
</div>
<div class="kpi">
<div class="kpi-label">Gesamt netto</div>
<div class="kpi-value">${formatEuro(totals.gesamtNetto)}</div>
<div class="kpi-sub">ohne USt</div>
</div>
<div class="kpi">
<div class="kpi-label">USt (brutto − netto)</div>
<div class="kpi-value">${formatEuro(ust)}</div>
<div class="kpi-sub">enthaltene Umsatzsteuer</div>
</div>`;
}
function renderRechnungenChart(data) {
const v = viewEls();
if (!v || !v.chart) return;
const byCountry = new Map();
data.invoices.forEach((inv) => {
const key = inv.landCode || inv.land || "–";
byCountry.set(key, (byCountry.get(key) || 0) + inv.gesamtBrutto);
});
const entries = [...byCountry.entries()].sort((a, b) => b[1] - a[1]);
const grand = entries.reduce((sum, [, value]) => sum + value, 0);
const max = entries.reduce((m, [, value]) => Math.max(m, value), 0) || 1;
v.chart.innerHTML = "";
v.legend.innerHTML = "";
if (!entries.length) {
v.chart.innerHTML = '<p class="muted">Keine Daten für diesen Zeitraum.</p>';
return;
}
entries.forEach(([code, value]) => {
const pct = (value / max) * 100;
const share = grand ? (value / grand) * 100 : 0;
const row = document.createElement("div");
row.className = "bar-row";
row.innerHTML = `
<div class="bar-country">${code}</div>
<div class="bar-track">
<div class="bar-fill" style="background:var(--accent)"></div>
</div>
<div class="bar-value">${formatEuro(value)} · ${formatPercent(share)}</div>`;
v.chart.appendChild(row);
const fill = row.querySelector(".bar-fill");
fill.getBoundingClientRect();
fill.style.width = Math.max(pct, 6) + "%";
fill.classList.add("ready");
});
}
/* ---------- Rechnungen pivot (experiment) ---------- */
// Preset pivot layouts per view, selectable from each view's "Vorlage" dropdown.
const PIVOT_PRESETS = {
"gesamt-rechnungen-pivot": {
standard: { rows: ["Quelle"], cols: [], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" },
rechnungsland: { rows: ["Quelle"], cols: ["Rechnungsland"], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" },
},
"odoo-rechnungen": {
standard: { rows: ["Rechnungsland"], cols: [], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" },
zahlart: { rows: ["Zahlart"], cols: [], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" },
},
"schweiz-quartal": {
standard: { rows: ["Quelle"], cols: [], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" },
rechnungsland: { rows: ["Quelle"], cols: ["Rechnungsland"], vals: ["Gesamtpreis brutto"], aggregatorName: "Sum", rendererName: "Table" },
},
};
// Current pivot layout per view (kept so a filter re-render doesn't reset it).
const pivotOptions = {
"gesamt-rechnungen-pivot": { ...PIVOT_PRESETS["gesamt-rechnungen-pivot"].standard },
"odoo-rechnungen": { ...PIVOT_PRESETS["odoo-rechnungen"].standard },
"schweiz-quartal": { ...PIVOT_PRESETS["schweiz-quartal"].standard },
};
function renderRechnungenPivot(data) {
const v = viewEls();
if (!v || !v.pivot) return;
renderRechnungenKpis(data);
if (v.chart) renderRechnungenChart(data);
// Recreate the container so pivotUI cannot reuse state from a previous
// render — clearing innerHTML alone is not enough, so a preset change would
// not take effect.
const prev = v.pivot;
const el = prev.cloneNode(false);
prev.parentNode.replaceChild(el, prev);
if (!window.jQuery || !jQuery.fn.pivotUI) {
el.innerHTML = '<p class="muted">PivotTable.js ist nicht geladen (offline?).</p>';
return;
}
if (!data.invoices.length) {
el.innerHTML = '<p class="muted">Keine Daten für diesen Zeitraum.</p>';
return;
}
// Flaches Datenmodell für die Pivot-Tabelle. Alle Attribute sind im
// pivotUI-Bedienfeld frei kombinierbar (Zeilen/Spalten/Werte/Aggregator).
// Hinweis: "Brutto" ist nur der Normalsatz-Anteil; "Gesamtpreis brutto" ist
// Brutto + Brutto erm (= die Summe, die die KPI "Gesamt brutto" anzeigt).
const rows = data.invoices.map((i) => ({
Quelle: i.quelle || "(leer)",
Rechnungsland: i.land || "(leer)",
Zahlart: i.zahlart || "(leer)",
Bestellnummer: i.bestellnummer || "(leer)",
Tag: i.tag || "(leer)",
Lieferland: i.lieferland || "(leer)",
"Gesamtpreis brutto": i.gesamtBrutto,
Brutto: i.brutto,
"Brutto erm": i.bruttoErm,
"Gesamtpreis netto": i.gesamtNetto,
Netto: i.netto,
"Netto erm": i.nettoErm,
"Versandkosten brutto": i.versandBrutto,
"Versandkosten netto": i.versandNetto,
}));
const opts = {
...pivotOptions[currentView],
unusedAttrsVertical: true,
onRefresh: (config) => {
pivotOptions[currentView] = {
rows: config.rows,
cols: config.cols,
vals: config.vals,
aggregatorName: config.aggregatorName,
rendererName: config.rendererName,
};
},
};
// Only offer the plain table renderer (no heatmaps/charts).
const tableRenderer = jQuery.pivotUtilities && jQuery.pivotUtilities.renderers
? jQuery.pivotUtilities.renderers["Table"]
: null;
if (tableRenderer) opts.renderers = { Table: tableRenderer };
jQuery(el).pivotUI(rows, opts);
}
/* ---------- Active view helpers ---------- */
function viewEls() {
@@ -437,15 +789,26 @@ function viewEls() {
if (currentView === "amazon-vat-summary") {
return { kpis: els.vatKpis, chart: els.vatChart, legend: els.vatLegend, table: els.vatTable, state: els.vatState };
}
if (currentView === "odoo-rechnungen") {
return { kpis: els.odooKpis, chart: els.odooChart, legend: els.odooLegend, state: els.odooState, pivot: document.getElementById("odoo-pivot") };
}
if (currentView === "gesamt-rechnungen-pivot") {
return { kpis: els.pivotKpis, state: els.pivotState, pivot: document.getElementById("pivot") };
}
if (currentView === "schweiz-quartal") {
return { kpis: els.chKpis, state: els.chState, pivot: document.getElementById("ch-pivot") };
}
return null;
}
function renderActiveView() {
if (!lastData) return;
if (!lastData || lastData.view !== currentView) return;
if (currentView === "amazon-abgangsland") {
renderAbgangsland(lastData.abgangsland);
renderAbgangsland(lastData.data);
} else if (currentView === "amazon-vat-summary") {
renderVat(lastData.vat);
renderVat(lastData.data);
} else if (currentView === "odoo-rechnungen" || currentView === "gesamt-rechnungen-pivot" || currentView === "schweiz-quartal") {
renderRechnungenPivot(applyRechnungenFilters(lastData.data));
}
}
@@ -466,9 +829,10 @@ function showSkeleton() {
if (!v) return;
v.state.classList.add("hidden");
v.kpis.innerHTML = skeletonKpis();
v.chart.innerHTML = skeletonBars();
v.legend.innerHTML = "";
v.table.innerHTML = "";
if (v.chart) v.chart.innerHTML = skeletonBars();
if (v.legend) v.legend.innerHTML = "";
if (v.table) v.table.innerHTML = "";
if (v.pivot) v.pivot.innerHTML = "";
}
function showError(message) {
@@ -482,24 +846,52 @@ function clearViews() {
const v = viewEls();
if (!v) return;
v.kpis.innerHTML = "";
v.chart.innerHTML = "";
v.legend.innerHTML = "";
v.table.innerHTML = "";
if (v.chart) v.chart.innerHTML = "";
if (v.legend) v.legend.innerHTML = "";
if (v.table) v.table.innerHTML = "";
if (v.pivot) v.pivot.innerHTML = "";
}
async function load() {
const meta = VIEWS[currentView];
const query = meta && meta.query;
// The datasets are huge: only the dataset of the displayed dashboard is
// requested, and only when that dashboard is shown or reloaded.
if (!query) {
lastData = null;
clearViews();
updateDownloadState();
return;
}
resetRechnungenFilters();
const period = selectedPeriod();
els.tablePeriod.textContent = period;
els.vatPeriod.textContent = period;
els.odooPeriod.textContent = period;
els.pivotPeriod.textContent = period;
els.chPeriod.textContent = period;
els.refresh.classList.add("spinning");
showSkeleton();
const token = ++loadToken;
try {
const headers = { Accept: "application/json" };
const headers = { Accept: "application/json", "Content-Type": "application/json" };
const authorization = authHeader();
if (authorization) headers.Authorization = authorization;
const response = await fetch(`${API_URL}?month=${encodeURIComponent(period)}`, { headers });
const body = meta.quarter ? { query, quarter: period } : { query, month: period };
const response = await fetch(API_URL, {
method: "POST",
headers,
body: JSON.stringify(body),
});
// A newer request (view/period switch) superseded this one — ignore it.
if (token !== loadToken) return;
// The webhook is protected with Basic Auth: ask for credentials when needed.
if (response.status === 401) {
@@ -515,10 +907,7 @@ async function load() {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
lastData = {
abgangsland: normalizeAbgangsland(extractArray(payload, "Summe je Abgangsland")),
vat: normalizeVat(extractArray(payload, "VAT Summary")),
};
lastData = { view: currentView, data: meta.normalize(extractArray(payload, query)) };
renderActiveView();
@@ -528,6 +917,7 @@ async function load() {
v.state.textContent = "";
}
} catch (error) {
if (token !== loadToken) return;
lastData = null;
clearViews();
showError(`Daten konnten nicht geladen werden: ${error.message}`);
@@ -578,19 +968,63 @@ function buildVatCsv(data) {
return lines.join("\r\n");
}
function buildRechnungenCsv(data) {
const showQuelle = currentView === "gesamt-rechnungen-pivot" || currentView === "schweiz-quartal";
const header = [
"Rechnungsdatum", "Rechnungsnummer", "Rechnungsland", "Rechnungsland Ländercode",
"Zahlart", "Zahlungsreference der Bestellung",
"USt. Id / VAT Id des Käufers (in Bestellung)", "Gesamtpreis brutto", "Gesamtpreis netto",
"Brutto", "Brutto erm", "USt. Satz", "Ust. Satz erm", "Netto Betrag", "Netto Betrag erm",
"Versandkosten brutto", "Versandkosten netto", "Auftragsdatum", "Bestellnummer", "Zahldatum",
"Lieferadresse Name", "Tag", "Lieferland",
];
if (showQuelle) header.push("Quelle");
const lines = [header.map(csvEscape).join(";")];
data.invoices.forEach((i) => {
const cols = [
i.datum, i.nummer, i.land, i.landCode, i.zahlart, i.zahlungsreferenz, i.vatId,
formatEuro(i.gesamtBrutto), formatEuro(i.gesamtNetto),
formatEuro(i.brutto), formatEuro(i.bruttoErm), formatQuantity(i.ustSatz), formatQuantity(i.ustSatzErm),
formatEuro(i.netto), formatEuro(i.nettoErm),
formatEuro(i.versandBrutto), formatEuro(i.versandNetto),
i.auftragsdatum, i.bestellnummer, i.zahldatum, i.lieferName, i.tag, i.lieferland,
];
if (showQuelle) cols.push(i.quelle);
lines.push(cols.map(csvEscape).join(";"));
});
const t = data.totals;
const totalCols = [
"Gesamt", String(data.invoices.length), "", "", "", "", "",
formatEuro(t.gesamtBrutto), formatEuro(t.gesamtNetto),
formatEuro(t.brutto), formatEuro(t.bruttoErm), "", "",
formatEuro(t.netto), formatEuro(t.nettoErm),
formatEuro(t.versandBrutto), formatEuro(t.versandNetto),
"", "", "", "", "", "",
];
if (showQuelle) totalCols.push("");
lines.push(totalCols.map(csvEscape).join(";"));
return lines.join("\r\n");
}
function downloadCsv() {
if (!lastData) return;
if (!lastData || lastData.view !== currentView) return;
const period = selectedPeriod();
let csv;
let filename;
if (currentView === "amazon-abgangsland") {
csv = buildAbgangslandCsv(lastData.abgangsland);
csv = buildAbgangslandCsv(lastData.data);
filename = `umsatz-je-abgangsland_${period}.csv`;
} else if (currentView === "amazon-vat-summary") {
csv = buildVatCsv(lastData.vat);
csv = buildVatCsv(lastData.data);
filename = `vat-summary_${period}.csv`;
} else if (currentView === "odoo-rechnungen" || currentView === "gesamt-rechnungen-pivot" || currentView === "schweiz-quartal") {
csv = buildRechnungenCsv(applyRechnungenFilters(lastData.data));
filename = `${currentView}_${period}.csv`;
} else {
return;
}
@@ -609,8 +1043,13 @@ function downloadCsv() {
}
function updateDownloadState() {
const supported = currentView === "amazon-abgangsland" || currentView === "amazon-vat-summary";
els.download.disabled = !(lastData && supported);
const supported =
currentView === "amazon-abgangsland" ||
currentView === "amazon-vat-summary" ||
currentView === "odoo-rechnungen" ||
currentView === "gesamt-rechnungen-pivot" ||
currentView === "schweiz-quartal";
els.download.disabled = !(lastData && lastData.view === currentView && supported);
}
/* ---------- Navigation ---------- */
@@ -619,14 +1058,34 @@ const VIEWS = {
"amazon-abgangsland": {
title: "Umsatz je Abgangsland",
subtitle: "Amazon · Brutto (inkl. USt)",
// Webhook query key and row normalizer for this dashboard's dataset.
query: "Summe je Abgangsland",
normalize: normalizeAbgangsland,
},
"amazon-vat-summary": {
title: "VAT Summary",
subtitle: "Amazon · USt-Übersicht je Land",
query: "VAT Summary",
normalize: normalizeVat,
},
"odoo-dashboard": {
title: "ODOO Dashboard",
subtitle: "ODOO",
"odoo-rechnungen": {
title: "ODOO Rechnungen pro Monat",
subtitle: "ODOO · Rechnungen je Monat",
query: "ODOO Rechnungen pro Monat",
normalize: normalizeRechnungen,
},
"gesamt-rechnungen-pivot": {
title: "Alle Rechnungen pro Monat",
subtitle: "Gesamt · Pivot",
query: "Alle Rechnungen pro Monat",
normalize: normalizeRechnungen,
},
"schweiz-quartal": {
title: "Quartal Schweiz",
subtitle: "Schweiz · AMAZON + ODOO",
query: "Quartal Schweiz",
normalize: normalizeRechnungen,
quarter: true,
},
};
@@ -664,7 +1123,12 @@ function showView(name) {
document.getElementById("view-subtitle").textContent = meta.subtitle;
currentView = name;
renderActiveView();
// Quarter views use the quarter selector instead of the month selector.
const isQuarter = !!meta.quarter;
els.month.classList.toggle("hidden", isQuarter);
els.quarter.classList.toggle("hidden", !isQuarter);
// Query only this dashboard's dataset — nothing is prefetched.
load();
updateDownloadState();
}
@@ -692,7 +1156,11 @@ function setupAuth() {
buildPeriodSelectors();
setupMenu();
setupAuth();
setupRechnungenFilters();
setupRechnungenPivot();
load();
els.month.addEventListener("change", load);
els.quarter.addEventListener("change", load);
els.year.addEventListener("change", load);
els.refresh.addEventListener("click", load);
els.download.addEventListener("click", downloadCsv);
+179 -13
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Brandize · BI Dashboard</title>
<link rel="stylesheet" href="styles.css" />
<!-- PivotTable.js experiment (needs jQuery UI styles) -->
<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/base/jquery-ui.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pivottable@2.23.0/dist/pivot.min.css" />
</head>
<body>
<div class="layout">
@@ -65,8 +68,34 @@
</svg>
</button>
<div class="submenu">
<a class="submenu-item" href="#" data-view="odoo-dashboard">
Dashboard
<a class="submenu-item" href="#" data-view="odoo-rechnungen">
ODOO Rechnungen pro Monat
</a>
</div>
</div>
<!-- Gesamt -->
<div class="menu-group" data-group>
<button class="menu-main" type="button" data-toggle aria-expanded="false">
<span class="menu-logo">
<svg viewBox="0 0 48 48" aria-hidden="true">
<rect width="48" height="48" rx="12" fill="#4f46e5" />
<text x="24" y="32" text-anchor="middle" font-size="24" font-weight="700"
fill="#ffffff" font-family="Arial, sans-serif">Σ</text>
</svg>
</span>
<span class="menu-name">Gesamt</span>
<svg class="chevron" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="submenu">
<a class="submenu-item" href="#" data-view="gesamt-rechnungen-pivot">
Alle Rechnungen pro Monat
</a>
<a class="submenu-item" href="#" data-view="schweiz-quartal">
Quartal Schweiz
</a>
</div>
</div>
@@ -108,6 +137,12 @@
<span class="field-label">Zeitraum</span>
<div class="period">
<select id="month-select" aria-label="Monat"></select>
<select id="quarter-select" aria-label="Quartal" class="hidden">
<option value="1">Q1</option>
<option value="2">Q2</option>
<option value="3">Q3</option>
<option value="4">Q4</option>
</select>
<select id="year-select" aria-label="Jahr"></select>
</div>
</label>
@@ -192,19 +227,146 @@
<div class="state" id="vat-state"></div>
</main>
<!-- View: ODOO · Dashboard (placeholder) -->
<main id="view-odoo-dashboard" class="view hidden">
<section class="panel placeholder">
<div class="placeholder-icon">
<svg viewBox="0 0 48 48" aria-hidden="true">
<rect width="48" height="48" rx="12" fill="#714B67" />
<text x="24" y="30" text-anchor="middle" font-size="14" font-weight="700"
fill="#ffffff" font-family="Arial, sans-serif">odoo</text>
</svg>
<!-- View: ODOO · Rechnungen pro Monat -->
<main id="view-odoo-rechnungen" class="view hidden">
<section class="kpis" id="odoo-kpis"></section>
<section class="panel">
<div class="panel-head">
<h2>Gesamtpreis brutto je Rechnungsland</h2>
<div class="legend" id="odoo-legend"></div>
</div>
<h2>ODOO Dashboard</h2>
<p class="muted">Dieses Dashboard folgt in Kürze.</p>
<div class="chart" id="odoo-chart"></div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Pivot — Summe Gesamtpreis brutto</h2>
<div class="panel-head-actions">
<span class="muted" id="odoo-period"></span>
<label class="field">
<span class="field-label">Vorlage</span>
<select id="odoo-pivot-preset" aria-label="Pivot-Vorlage">
<option value="standard">Standard</option>
<option value="zahlart">Zahlart</option>
</select>
</label>
</div>
</div>
<div class="table-filters">
<label class="field">
<span class="field-label">Tag</span>
<input id="odoo-filter-tag" type="text" />
</label>
<label class="field">
<span class="field-label">Bestellnummer</span>
<input id="odoo-filter-bestellnummer" type="text" />
</label>
<label class="field">
<span class="field-label">Lieferland</span>
<input id="odoo-filter-lieferland" type="text" />
</label>
<label class="field">
<span class="field-label">Rechnungsnummer</span>
<input id="odoo-filter-rechnungsnummer" type="text" />
</label>
</div>
<div id="odoo-pivot"></div>
</section>
<div class="state" id="odoo-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>
<section class="panel">
<div class="panel-head">
<h2>Pivot — Summe Gesamtpreis brutto</h2>
<div class="panel-head-actions">
<span class="muted" id="pivot-period"></span>
<label class="field">
<span class="field-label">Vorlage</span>
<select id="pivot-preset" aria-label="Pivot-Vorlage">
<option value="standard">Standard</option>
<option value="rechnungsland">Rechnungsland (Spalten)</option>
</select>
</label>
</div>
</div>
<div class="table-filters">
<label class="field">
<span class="field-label">Quelle</span>
<input id="pivot-filter-quelle" type="text" />
</label>
<label class="field">
<span class="field-label">Tag</span>
<input id="pivot-filter-tag" type="text" />
</label>
<label class="field">
<span class="field-label">Bestellnummer</span>
<input id="pivot-filter-bestellnummer" type="text" />
</label>
<label class="field">
<span class="field-label">Lieferland</span>
<input id="pivot-filter-lieferland" type="text" />
</label>
<label class="field">
<span class="field-label">Rechnungsnummer</span>
<input id="pivot-filter-rechnungsnummer" type="text" />
</label>
</div>
<div id="pivot"></div>
</section>
<div class="state" id="pivot-state"></div>
</main>
<!-- View: Schweiz · Quartal Schweiz (AMAZON + ODOO, Lieferung in die CH) -->
<main id="view-schweiz-quartal" class="view hidden">
<section class="kpis" id="ch-kpis"></section>
<section class="panel">
<div class="panel-head">
<h2>Pivot — Quartal Schweiz (Summe Gesamtpreis brutto)</h2>
<div class="panel-head-actions">
<span class="muted" id="ch-period"></span>
<label class="field">
<span class="field-label">Vorlage</span>
<select id="ch-pivot-preset" aria-label="Pivot-Vorlage">
<option value="standard">Standard</option>
<option value="rechnungsland">Rechnungsland (Spalten)</option>
</select>
</label>
</div>
</div>
<div class="table-filters">
<label class="field">
<span class="field-label">Quelle</span>
<input id="ch-filter-quelle" type="text" />
</label>
<label class="field">
<span class="field-label">Tag</span>
<input id="ch-filter-tag" type="text" />
</label>
<label class="field">
<span class="field-label">Bestellnummer</span>
<input id="ch-filter-bestellnummer" type="text" />
</label>
<label class="field">
<span class="field-label">Lieferland</span>
<input id="ch-filter-lieferland" type="text" />
</label>
<label class="field">
<span class="field-label">Rechnungsnummer</span>
<input id="ch-filter-rechnungsnummer" type="text" />
</label>
</div>
<div id="ch-pivot"></div>
</section>
<div class="state" id="ch-state"></div>
</main>
</div>
</div>
@@ -241,6 +403,10 @@
</form>
</div>
<!-- PivotTable.js experiment — requires jQuery + jQuery UI -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pivottable@2.23.0/dist/pivot.min.js"></script>
<script src="app.js"></script>
</body>
</html>
+415
View File
@@ -0,0 +1,415 @@
-- =============================================================================
-- Alle Rechnungen pro Monat – Gesamt (ODOO + AMAZON)
-- Odoo 17 / PostgreSQL
--
-- Zweck:
-- Liefert dieselben Spalten wie Rechnungsexport.sql (ODOO-Kundenrechnungen),
-- aber zusammengefuehrt (UNION ALL) mit den Datensaetzen aus der Amazon-
-- Tabelle brandize_datacenter_amazon_vat_transaction. Zusaetzlich gibt die
-- Spalte "Quelle" an, aus welchem System die Zeile stammt ('ODOO'/'AMAZON').
--
-- Modelle:
-- ODOO : account.move (Kundenrechnungen / Gutschriften)
-- AMAZON : brandize_datacenter_amazon_vat_transaction
--
-- Parameter:
-- %(month)s Abrechnungszeitraum als Text, z. B. '2026-SEP'
-- ODOO : Rechnungsdatum im Kalendermonat (siehe params-CTE)
-- AMAZON: activity_period = %(month)s
--
-- Aufruf (Odoo / psycopg2):
-- cr.execute(open('Alle_Rechnungen.sql').read(), {'month': '2026-SEP'})
--
-- In psql stattdessen:
-- \set month '2026-SEP'
--
-- ---------------------------------------------------------------------------
-- Spalten-Mapping ODOO -> AMAZON
-- (Felder ohne Amazon-Pendant bleiben leer)
-- ---------------------------------------------------------------------------
-- Rechnungsdatum <- transaction_complete_date (Fallback: depart)
-- Rechnungsnummer <- vat_inv_number
-- Rechnungsland <- Name zu sale_arrival_country (res_country)
-- Rechnungsland Code <- sale_arrival_country
-- Zahlart <- '' (kein Amazon-Feld)
-- Zahlungsreference <- transaction_event_id
-- USt. Id Kaeufer <- buyer_vat_number
-- Gesamtpreis brutto <- total_activity_value_amt_vat_incl
-- Gesamtpreis netto <- total_activity_value_amt_vat_excl
-- Brutto / -erm <- Steuersplit nach effektivem Satz (Schwelle
-- USt. Satz / -erm CH >= 8, sonst >= 16 - wie im ODOO-Export)
-- Netto Betrag / -erm Satz = total_activity_value_vat_amt / netto * 100
-- Versandkosten brutto <- total_ship_charge_amt_vat_incl
-- Versandkosten netto <- total_ship_charge_amt_vat_excl
-- Auftragsdatum <- transaction_depart_date
-- Bestellnummer <- activity_transaction_id
-- Zahldatum <- '' (kein Amazon-Feld)
-- Lieferadresse Name <- buyer_name
-- Tag <- '' (kein Amazon-Feld)
-- Lieferland <- Name zu arrival_country (res_country)
--
-- ACHTUNG: Das Amazon-Mapping ist eine 1:1-Uebertragung auf die ODOO-Spalten.
-- Die Datumszuordnung (Rechnungsdatum/Auftragsdatum) sowie Bestellnummer
-- sollten gegen die tatsaechliche Amazon-Report-Semantik geprueft und hier
-- ggf. angepasst werden.
-- =============================================================================
WITH params AS (
-- Kalendermonat aus %(month)s (z. B. '2026-SEP') ableiten (ODDO-Seite).
SELECT
to_date(
split_part(%(month)s, '-', 1) || '-' ||
CASE upper(split_part(%(month)s, '-', 2))
WHEN 'JAN' THEN '01' WHEN 'FEB' THEN '02'
WHEN 'MÄR' THEN '03' WHEN 'MAR' THEN '03' WHEN 'APR' THEN '04'
WHEN 'MAI' THEN '05' WHEN 'MAY' THEN '05' WHEN 'JUN' THEN '06'
WHEN 'JUL' THEN '07' WHEN 'AUG' THEN '08' WHEN 'SEP' THEN '09'
WHEN 'OKT' THEN '10' WHEN 'OCT' THEN '10' WHEN 'NOV' THEN '11'
WHEN 'DEZ' THEN '12' WHEN 'DEC' THEN '12'
END,
'YYYY-MM'
) AS first_day
),
-- ===========================================================================
-- ODOO-Seite (identisch zu Rechnungsexport.sql)
-- ===========================================================================
invoices AS (
SELECT
am.id,
am.name,
am.ref,
am.move_type,
am.invoice_date,
am.invoice_origin,
am.partner_id,
am.partner_shipping_id,
am.invoice_payment_term_id,
am.payment_reference
FROM account_move am
CROSS JOIN params p
WHERE am.invoice_date >= p.first_day
AND am.invoice_date < p.first_day + INTERVAL '1 month'
AND am.move_type IN ('out_invoice', 'out_refund')
),
line_taxes AS (
SELECT
aml.move_id AS invoice_id,
at.amount AS tax_rate,
SUM(aml.price_subtotal) AS netto,
SUM(aml.price_total) AS brutto
FROM invoices inv
JOIN account_move_line aml
ON aml.move_id = inv.id
JOIN account_move_line_account_tax_rel amlatr
ON amlatr.account_move_line_id = aml.id
JOIN account_tax at
ON at.id = amlatr.account_tax_id
GROUP BY aml.move_id, at.amount
),
tax_split AS (
SELECT
lt.invoice_id,
MAX(CASE
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
THEN ROUND(lt.brutto::numeric, 2)
END) AS brutto_normal,
MAX(CASE
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
THEN ROUND(lt.tax_rate::numeric, 2)
END) AS ust_satz_normal,
MAX(CASE
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
THEN ROUND(lt.netto::numeric, 2)
END) AS netto_normal,
MAX(CASE
WHEN NOT (
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
)
THEN ROUND(lt.brutto::numeric, 2)
END) AS brutto_erm,
MAX(CASE
WHEN NOT (
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
)
THEN ROUND(lt.tax_rate::numeric, 2)
END) AS ust_satz_erm,
MAX(CASE
WHEN NOT (
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
)
THEN ROUND(lt.netto::numeric, 2)
END) AS netto_erm
FROM line_taxes lt
JOIN invoices inv
ON inv.id = lt.invoice_id
JOIN res_partner rp
ON rp.id = inv.partner_id
LEFT JOIN res_country rc
ON rc.id = rp.country_id
GROUP BY lt.invoice_id
),
shipping AS (
SELECT
aml.move_id AS invoice_id,
ROUND(SUM(aml.price_total)::numeric, 2) AS shipping_brutto,
ROUND(SUM(aml.price_subtotal)::numeric, 2) AS shipping_netto
FROM invoices inv
JOIN account_move_line aml
ON aml.move_id = inv.id
JOIN product_product pp
ON pp.id = aml.product_id
JOIN product_template pt
ON pt.id = pp.product_tmpl_id
WHERE pt.categ_id = 9
GROUP BY aml.move_id
),
invoice_tags AS (
SELECT
inv.id AS invoice_id,
STRING_AGG(
DISTINCT COALESCE(ct.name->>'en_US', ct.name->>'de_DE', ''),
', ' ORDER BY COALESCE(ct.name->>'en_US', ct.name->>'de_DE', '')
) AS tags
FROM invoices inv
JOIN sale_order so
ON so.name = inv.invoice_origin
JOIN sale_order_tag_rel sotr
ON sotr.order_id = so.id
JOIN crm_tag ct
ON ct.id = sotr.tag_id
GROUP BY inv.id
),
invoice_transaction_reference AS (
SELECT
rel.invoice_id,
pt.provider_reference,
ROW_NUMBER() OVER (
PARTITION BY rel.invoice_id
ORDER BY pt.id
) AS rn
FROM account_invoice_transaction_rel rel
JOIN payment_transaction pt
ON pt.id = rel.transaction_id
),
sale_info AS (
SELECT
inv.id AS invoice_id,
MIN(so.date_order) AS order_date
FROM invoices inv
JOIN sale_order so
ON so.name = inv.invoice_origin
GROUP BY inv.id
),
invoice_payments AS (
SELECT
aml.move_id AS invoice_id,
MAX(apr.max_date) AS payment_date
FROM invoices inv
JOIN account_move_line aml
ON aml.move_id = inv.id
JOIN account_account aa
ON aa.id = aml.account_id
AND aa.account_type = 'asset_receivable'
JOIN account_partial_reconcile apr
ON apr.debit_move_id = aml.id
OR apr.credit_move_id = aml.id
GROUP BY aml.move_id
),
odoo_rows AS (
SELECT
TO_CHAR(inv.invoice_date, 'YYYY-MM-DD') AS "Rechnungsdatum",
CASE
WHEN inv.move_type = 'out_refund'
THEN COALESCE(inv.name, inv.ref, '') || '-gs'
ELSE COALESCE(inv.name, inv.ref, '')
END AS "Rechnungsnummer",
COALESCE(rc_inv.name->>'en_US', rc_inv.name->>'de_DE', '') AS "Rechnungsland",
COALESCE(rc_inv.code, '') AS "Rechnungsland Ländercode",
COALESCE(apt.name->>'en_US', apt.name->>'de_DE', '') AS "Zahlart",
COALESCE(itr.provider_reference, '') AS "Zahlungsreference der Bestellung",
COALESCE(partner.vat, '') AS "USt. Id / VAT Id des Käufers (in Bestellung)",
ROUND(COALESCE(ts.brutto_normal, 0) + COALESCE(ts.brutto_erm, 0), 2) AS "Gesamtpreis brutto",
ROUND(COALESCE(ts.netto_normal, 0) + COALESCE(ts.netto_erm, 0), 2) AS "Gesamtpreis netto",
COALESCE(ts.brutto_normal, 0) AS "Brutto",
COALESCE(ts.brutto_erm, 0) AS "Brutto erm",
COALESCE(ts.ust_satz_normal, 0) AS "USt. Satz",
COALESCE(ts.ust_satz_erm, 0) AS "Ust. Satz erm",
COALESCE(ts.netto_normal, 0) AS "Netto Betrag",
COALESCE(ts.netto_erm, 0) AS "Netto Betrag erm",
COALESCE(sh.shipping_brutto, 0) AS "Versandkosten brutto",
COALESCE(sh.shipping_netto, 0) AS "Versandkosten netto",
COALESCE(TO_CHAR(si.order_date, 'YYYY-MM-DD'), '') AS "Auftragsdatum",
COALESCE(inv.invoice_origin, '') AS "Bestellnummer",
COALESCE(TO_CHAR(ip.payment_date, 'YYYY-MM-DD'), '') AS "Zahldatum",
COALESCE(ship.name, partner.name, '') AS "Lieferadresse Name",
COALESCE(it.tags, '') AS "Tag",
COALESCE(
rc_ship.name->>'en_US', rc_ship.name->>'de_DE',
rc_inv.name->>'en_US', rc_inv.name->>'de_DE',
''
) AS "Lieferland"
FROM invoices inv
JOIN res_partner partner
ON partner.id = inv.partner_id
LEFT JOIN res_country rc_inv
ON rc_inv.id = partner.country_id
LEFT JOIN res_partner ship
ON ship.id = inv.partner_shipping_id
LEFT JOIN res_country rc_ship
ON rc_ship.id = ship.country_id
LEFT JOIN account_payment_term apt
ON apt.id = inv.invoice_payment_term_id
LEFT JOIN tax_split ts
ON ts.invoice_id = inv.id
LEFT JOIN shipping sh
ON sh.invoice_id = inv.id
LEFT JOIN invoice_tags it
ON it.invoice_id = inv.id
LEFT JOIN invoice_transaction_reference itr
ON itr.invoice_id = inv.id
AND itr.rn = 1
LEFT JOIN sale_info si
ON si.invoice_id = inv.id
LEFT JOIN invoice_payments ip
ON ip.invoice_id = inv.id
),
-- ===========================================================================
-- AMAZON-Seite
-- ===========================================================================
-- Basiszahlen + effektiver USt-Satz je Transaktionszeile.
amazon_base AS (
SELECT
a.transaction_complete_date,
a.transaction_depart_date,
a.vat_inv_number,
a.sale_arrival_country,
a.arrival_country,
a.transaction_event_id,
a.buyer_vat_number,
a.buyer_name,
a.activity_transaction_id,
COALESCE(a.total_activity_value_amt_vat_incl, 0) AS gross,
COALESCE(a.total_activity_value_amt_vat_excl, 0) AS net,
COALESCE(a.total_ship_charge_amt_vat_incl, 0) AS ship_gross,
COALESCE(a.total_ship_charge_amt_vat_excl, 0) AS ship_net,
CASE
WHEN COALESCE(a.total_activity_value_amt_vat_excl, 0) <> 0
THEN ROUND((COALESCE(a.total_activity_value_vat_amt, 0)
/ a.total_activity_value_amt_vat_excl * 100)::numeric, 2)
ELSE 0
END AS rate
FROM brandize_datacenter_amazon_vat_transaction a
WHERE a.activity_period = %(month)s
-- Optional: Gebuehrenzeilen ausschliessen (wie in VAT_Summary.sql):
-- AND a.sales_channel <> 'AMAZON_FEE'
),
amazon_rows AS (
SELECT
TO_CHAR(COALESCE(b.transaction_complete_date, b.transaction_depart_date), 'YYYY-MM-DD')
AS "Rechnungsdatum",
COALESCE(b.vat_inv_number, '') AS "Rechnungsnummer",
COALESCE(rc_inv.name->>'en_US', rc_inv.name->>'de_DE', '') AS "Rechnungsland",
COALESCE(b.sale_arrival_country, '') AS "Rechnungsland Ländercode",
'' AS "Zahlart",
COALESCE(b.transaction_event_id, '') AS "Zahlungsreference der Bestellung",
COALESCE(b.buyer_vat_number, '') AS "USt. Id / VAT Id des Käufers (in Bestellung)",
ROUND(b.gross::numeric, 2) AS "Gesamtpreis brutto",
ROUND(b.net::numeric, 2) AS "Gesamtpreis netto",
CASE
WHEN (b.sale_arrival_country = 'CH' AND b.rate >= 8)
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
THEN ROUND(b.gross::numeric, 2) ELSE 0
END AS "Brutto",
CASE
WHEN NOT (
(b.sale_arrival_country = 'CH' AND b.rate >= 8)
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
)
THEN ROUND(b.gross::numeric, 2) ELSE 0
END AS "Brutto erm",
CASE
WHEN (b.sale_arrival_country = 'CH' AND b.rate >= 8)
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
THEN b.rate ELSE 0
END AS "USt. Satz",
CASE
WHEN NOT (
(b.sale_arrival_country = 'CH' AND b.rate >= 8)
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
)
THEN b.rate ELSE 0
END AS "Ust. Satz erm",
CASE
WHEN (b.sale_arrival_country = 'CH' AND b.rate >= 8)
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
THEN ROUND(b.net::numeric, 2) ELSE 0
END AS "Netto Betrag",
CASE
WHEN NOT (
(b.sale_arrival_country = 'CH' AND b.rate >= 8)
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
)
THEN ROUND(b.net::numeric, 2) ELSE 0
END AS "Netto Betrag erm",
ROUND(b.ship_gross::numeric, 2) AS "Versandkosten brutto",
ROUND(b.ship_net::numeric, 2) AS "Versandkosten netto",
COALESCE(TO_CHAR(b.transaction_depart_date, 'YYYY-MM-DD'), '') AS "Auftragsdatum",
COALESCE(b.activity_transaction_id, '') AS "Bestellnummer",
'' AS "Zahldatum",
COALESCE(b.buyer_name, '') AS "Lieferadresse Name",
'' AS "Tag",
COALESCE(rc_ship.name->>'en_US', rc_ship.name->>'de_DE', '') AS "Lieferland"
FROM amazon_base b
LEFT JOIN res_country rc_inv
ON rc_inv.code = b.sale_arrival_country
LEFT JOIN res_country rc_ship
ON rc_ship.code = b.arrival_country
)
-- ===========================================================================
-- Zusammenfuehrung: ODOO + AMAZON, je Zeile mit Quelle
-- ===========================================================================
SELECT *, 'ODOO' AS "Quelle" FROM odoo_rows
UNION ALL
SELECT *, 'AMAZON' AS "Quelle" FROM amazon_rows
ORDER BY "Rechnungsdatum", "Rechnungsnummer";
+439
View File
@@ -0,0 +1,439 @@
-- =============================================================================
-- Quartal Schweiz – Gesamt (ODOO + AMAZON), Lieferung in die Schweiz
-- Odoo 17 / PostgreSQL
--
-- Zweck:
-- Dieselben Spalten wie Alle_Rechnungen.sql (inkl. "Quelle"), aber:
-- * Zeitraum = QUARTAL statt Monat
-- * nur Rechnungen mit Lieferung in die SCHWEIZ (Lieferland = 'CH')
--
-- Modelle:
-- ODOO : account.move (Kundenrechnungen / Gutschriften)
-- AMAZON : brandize_datacenter_amazon_vat_transaction
--
-- Parameter:
-- %(quarter)s Quartal als Text, z. B. '2026-Q3'
-- ODOO : Rechnungsdatum im Quartal (siehe quarters-CTE)
-- AMAZON: activity_period in den 3 Monaten des Quartals
--
-- Aufruf (Odoo / psycopg2):
-- cr.execute(open('Quartal_Schweiz.sql').read(), {'quarter': '2026-Q3'})
--
-- In psql stattdessen:
-- \set quarter '2026-Q3'
--
-- Diagnose (falls keine AMAZON-Zeilen geliefert werden):
-- SELECT activity_period, count(*)
-- FROM brandize_datacenter_amazon_vat_transaction
-- WHERE arrival_country = 'CH' OR sale_arrival_country = 'CH'
-- GROUP BY 1 ORDER BY 1;
-- -- zeigt das tatsaechliche Format von activity_period
--
-- ---------------------------------------------------------------------------
-- Spalten-Mapping ODOO -> AMAZON
-- (Felder ohne Amazon-Pendant bleiben leer)
-- ---------------------------------------------------------------------------
-- Rechnungsdatum <- transaction_complete_date (Fallback: depart)
-- Rechnungsnummer <- vat_inv_number
-- Rechnungsland <- Name zu sale_arrival_country (res_country)
-- Rechnungsland Code <- sale_arrival_country
-- Zahlart <- '' (kein Amazon-Feld)
-- Zahlungsreference <- transaction_event_id
-- USt. Id Kaeufer <- buyer_vat_number
-- Gesamtpreis brutto <- total_activity_value_amt_vat_incl
-- Gesamtpreis netto <- total_activity_value_amt_vat_excl
-- Brutto / -erm <- Steuersplit nach effektivem Satz (Schwelle
-- USt. Satz / -erm CH >= 8, sonst >= 16 - wie im ODOO-Export)
-- Netto Betrag / -erm Satz = total_activity_value_vat_amt / netto * 100
-- Versandkosten brutto <- total_ship_charge_amt_vat_incl
-- Versandkosten netto <- total_ship_charge_amt_vat_excl
-- Auftragsdatum <- transaction_depart_date
-- Bestellnummer <- activity_transaction_id
-- Zahldatum <- '' (kein Amazon-Feld)
-- Lieferadresse Name <- buyer_name
-- Tag <- '' (kein Amazon-Feld)
-- Lieferland <- Name zu arrival_country (res_country)
--
-- ACHTUNG: Das Amazon-Mapping ist eine 1:1-Uebertragung auf die ODOO-Spalten.
-- Die Datumszuordnung (Rechnungsdatum/Auftragsdatum) sowie Bestellnummer
-- sollten gegen die tatsaechliche Amazon-Report-Semantik geprueft und hier
-- ggf. angepasst werden.
-- =============================================================================
WITH params AS (
-- Quartal aus %(quarter)s (z. B. '2026-Q3') ableiten.
SELECT
split_part(%(quarter)s, '-', 1) AS yr,
right(split_part(%(quarter)s, '-', 2), 1)::int AS qn
),
quarters AS (
SELECT
yr,
qn,
make_date(yr::int, (qn - 1) * 3 + 1, 1) AS first_day,
make_date(yr::int, (qn - 1) * 3 + 1, 1) + INTERVAL '3 months' AS next_day
FROM params
),
qmonths AS (
-- Alle moeglichen Schreibweisen der 3 Quartalsmonate als 'YYYY-MMM'
-- (deutsche UND englische Kuerzel, da die gespeicherte Schreibweise je
-- nach Herkunft abweichen kann).
SELECT DISTINCT
q.yr || '-' || v.abbr AS activity_period
FROM quarters q
CROSS JOIN generate_series(1, 3) AS g(n)
JOIN (VALUES
(1, 'JAN'), (2, 'FEB'), (3, 'MÄR'), (3, 'MAR'), (4, 'APR'),
(5, 'MAI'), (5, 'MAY'), (6, 'JUN'), (7, 'JUL'), (8, 'AUG'),
(9, 'SEP'), (10, 'OKT'), (10, 'OCT'), (11, 'NOV'),
(12, 'DEZ'), (12, 'DEC')
) AS v(mnum, abbr) ON v.mnum = (q.qn - 1) * 3 + g.n
),
-- ===========================================================================
-- ODOO-Seite (identisch zu Rechnungsexport.sql)
-- ===========================================================================
invoices AS (
SELECT
am.id,
am.name,
am.ref,
am.move_type,
am.invoice_date,
am.invoice_origin,
am.partner_id,
am.partner_shipping_id,
am.invoice_payment_term_id,
am.payment_reference
FROM account_move am
CROSS JOIN quarters q
WHERE am.invoice_date >= q.first_day
AND am.invoice_date < q.next_day
AND am.move_type IN ('out_invoice', 'out_refund')
),
line_taxes AS (
SELECT
aml.move_id AS invoice_id,
at.amount AS tax_rate,
SUM(aml.price_subtotal) AS netto,
SUM(aml.price_total) AS brutto
FROM invoices inv
JOIN account_move_line aml
ON aml.move_id = inv.id
JOIN account_move_line_account_tax_rel amlatr
ON amlatr.account_move_line_id = aml.id
JOIN account_tax at
ON at.id = amlatr.account_tax_id
GROUP BY aml.move_id, at.amount
),
tax_split AS (
SELECT
lt.invoice_id,
MAX(CASE
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
THEN ROUND(lt.brutto::numeric, 2)
END) AS brutto_normal,
MAX(CASE
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
THEN ROUND(lt.tax_rate::numeric, 2)
END) AS ust_satz_normal,
MAX(CASE
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
THEN ROUND(lt.netto::numeric, 2)
END) AS netto_normal,
MAX(CASE
WHEN NOT (
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
)
THEN ROUND(lt.brutto::numeric, 2)
END) AS brutto_erm,
MAX(CASE
WHEN NOT (
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
)
THEN ROUND(lt.tax_rate::numeric, 2)
END) AS ust_satz_erm,
MAX(CASE
WHEN NOT (
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
)
THEN ROUND(lt.netto::numeric, 2)
END) AS netto_erm
FROM line_taxes lt
JOIN invoices inv
ON inv.id = lt.invoice_id
JOIN res_partner rp
ON rp.id = inv.partner_id
LEFT JOIN res_country rc
ON rc.id = rp.country_id
GROUP BY lt.invoice_id
),
shipping AS (
SELECT
aml.move_id AS invoice_id,
ROUND(SUM(aml.price_total)::numeric, 2) AS shipping_brutto,
ROUND(SUM(aml.price_subtotal)::numeric, 2) AS shipping_netto
FROM invoices inv
JOIN account_move_line aml
ON aml.move_id = inv.id
JOIN product_product pp
ON pp.id = aml.product_id
JOIN product_template pt
ON pt.id = pp.product_tmpl_id
WHERE pt.categ_id = 9
GROUP BY aml.move_id
),
invoice_tags AS (
SELECT
inv.id AS invoice_id,
STRING_AGG(
DISTINCT COALESCE(ct.name->>'en_US', ct.name->>'de_DE', ''),
', ' ORDER BY COALESCE(ct.name->>'en_US', ct.name->>'de_DE', '')
) AS tags
FROM invoices inv
JOIN sale_order so
ON so.name = inv.invoice_origin
JOIN sale_order_tag_rel sotr
ON sotr.order_id = so.id
JOIN crm_tag ct
ON ct.id = sotr.tag_id
GROUP BY inv.id
),
invoice_transaction_reference AS (
SELECT
rel.invoice_id,
pt.provider_reference,
ROW_NUMBER() OVER (
PARTITION BY rel.invoice_id
ORDER BY pt.id
) AS rn
FROM account_invoice_transaction_rel rel
JOIN payment_transaction pt
ON pt.id = rel.transaction_id
),
sale_info AS (
SELECT
inv.id AS invoice_id,
MIN(so.date_order) AS order_date
FROM invoices inv
JOIN sale_order so
ON so.name = inv.invoice_origin
GROUP BY inv.id
),
invoice_payments AS (
SELECT
aml.move_id AS invoice_id,
MAX(apr.max_date) AS payment_date
FROM invoices inv
JOIN account_move_line aml
ON aml.move_id = inv.id
JOIN account_account aa
ON aa.id = aml.account_id
AND aa.account_type = 'asset_receivable'
JOIN account_partial_reconcile apr
ON apr.debit_move_id = aml.id
OR apr.credit_move_id = aml.id
GROUP BY aml.move_id
),
odoo_rows AS (
SELECT
TO_CHAR(inv.invoice_date, 'YYYY-MM-DD') AS "Rechnungsdatum",
CASE
WHEN inv.move_type = 'out_refund'
THEN COALESCE(inv.name, inv.ref, '') || '-gs'
ELSE COALESCE(inv.name, inv.ref, '')
END AS "Rechnungsnummer",
COALESCE(rc_inv.name->>'en_US', rc_inv.name->>'de_DE', '') AS "Rechnungsland",
COALESCE(rc_inv.code, '') AS "Rechnungsland Ländercode",
COALESCE(apt.name->>'en_US', apt.name->>'de_DE', '') AS "Zahlart",
COALESCE(itr.provider_reference, '') AS "Zahlungsreference der Bestellung",
COALESCE(partner.vat, '') AS "USt. Id / VAT Id des Käufers (in Bestellung)",
ROUND(COALESCE(ts.brutto_normal, 0) + COALESCE(ts.brutto_erm, 0), 2) AS "Gesamtpreis brutto",
ROUND(COALESCE(ts.netto_normal, 0) + COALESCE(ts.netto_erm, 0), 2) AS "Gesamtpreis netto",
COALESCE(ts.brutto_normal, 0) AS "Brutto",
COALESCE(ts.brutto_erm, 0) AS "Brutto erm",
COALESCE(ts.ust_satz_normal, 0) AS "USt. Satz",
COALESCE(ts.ust_satz_erm, 0) AS "Ust. Satz erm",
COALESCE(ts.netto_normal, 0) AS "Netto Betrag",
COALESCE(ts.netto_erm, 0) AS "Netto Betrag erm",
COALESCE(sh.shipping_brutto, 0) AS "Versandkosten brutto",
COALESCE(sh.shipping_netto, 0) AS "Versandkosten netto",
COALESCE(TO_CHAR(si.order_date, 'YYYY-MM-DD'), '') AS "Auftragsdatum",
COALESCE(inv.invoice_origin, '') AS "Bestellnummer",
COALESCE(TO_CHAR(ip.payment_date, 'YYYY-MM-DD'), '') AS "Zahldatum",
COALESCE(ship.name, partner.name, '') AS "Lieferadresse Name",
COALESCE(it.tags, '') AS "Tag",
COALESCE(
rc_ship.name->>'en_US', rc_ship.name->>'de_DE',
rc_inv.name->>'en_US', rc_inv.name->>'de_DE',
''
) AS "Lieferland"
FROM invoices inv
JOIN res_partner partner
ON partner.id = inv.partner_id
LEFT JOIN res_country rc_inv
ON rc_inv.id = partner.country_id
LEFT JOIN res_partner ship
ON ship.id = inv.partner_shipping_id
LEFT JOIN res_country rc_ship
ON rc_ship.id = ship.country_id
LEFT JOIN account_payment_term apt
ON apt.id = inv.invoice_payment_term_id
LEFT JOIN tax_split ts
ON ts.invoice_id = inv.id
LEFT JOIN shipping sh
ON sh.invoice_id = inv.id
LEFT JOIN invoice_tags it
ON it.invoice_id = inv.id
LEFT JOIN invoice_transaction_reference itr
ON itr.invoice_id = inv.id
AND itr.rn = 1
LEFT JOIN sale_info si
ON si.invoice_id = inv.id
LEFT JOIN invoice_payments ip
ON ip.invoice_id = inv.id
-- Nur Lieferungen in die Schweiz (Versandland, Fallback: Rechnungsland).
WHERE COALESCE(rc_ship.code, rc_inv.code) = 'CH'
),
-- ===========================================================================
-- AMAZON-Seite
-- ===========================================================================
-- Basiszahlen + effektiver USt-Satz je Transaktionszeile.
amazon_base AS (
SELECT
a.transaction_complete_date,
a.transaction_depart_date,
a.vat_inv_number,
a.sale_arrival_country,
a.arrival_country,
a.transaction_event_id,
a.buyer_vat_number,
a.buyer_name,
a.activity_transaction_id,
COALESCE(a.total_activity_value_amt_vat_incl, 0) AS gross,
COALESCE(a.total_activity_value_amt_vat_excl, 0) AS net,
COALESCE(a.total_ship_charge_amt_vat_incl, 0) AS ship_gross,
COALESCE(a.total_ship_charge_amt_vat_excl, 0) AS ship_net,
CASE
WHEN COALESCE(a.total_activity_value_amt_vat_excl, 0) <> 0
THEN ROUND((COALESCE(a.total_activity_value_vat_amt, 0)
/ a.total_activity_value_amt_vat_excl * 100)::numeric, 2)
ELSE 0
END AS rate
FROM brandize_datacenter_amazon_vat_transaction a
WHERE upper(a.activity_period) IN (SELECT upper(activity_period) FROM qmonths)
-- Lieferung in die Schweiz: Ziel-/Versandland kann in arrival_country
-- oder sale_arrival_country stehen.
AND (upper(a.arrival_country) = 'CH' OR upper(a.sale_arrival_country) = 'CH')
-- Optional: Gebuehrenzeilen ausschliessen (wie in VAT_Summary.sql):
-- AND a.sales_channel <> 'AMAZON_FEE'
),
amazon_rows AS (
SELECT
TO_CHAR(COALESCE(b.transaction_complete_date, b.transaction_depart_date), 'YYYY-MM-DD')
AS "Rechnungsdatum",
COALESCE(b.vat_inv_number, '') AS "Rechnungsnummer",
COALESCE(rc_inv.name->>'en_US', rc_inv.name->>'de_DE', '') AS "Rechnungsland",
COALESCE(b.sale_arrival_country, '') AS "Rechnungsland Ländercode",
'' AS "Zahlart",
COALESCE(b.transaction_event_id, '') AS "Zahlungsreference der Bestellung",
COALESCE(b.buyer_vat_number, '') AS "USt. Id / VAT Id des Käufers (in Bestellung)",
ROUND(b.gross::numeric, 2) AS "Gesamtpreis brutto",
ROUND(b.net::numeric, 2) AS "Gesamtpreis netto",
CASE
WHEN (b.sale_arrival_country = 'CH' AND b.rate >= 8)
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
THEN ROUND(b.gross::numeric, 2) ELSE 0
END AS "Brutto",
CASE
WHEN NOT (
(b.sale_arrival_country = 'CH' AND b.rate >= 8)
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
)
THEN ROUND(b.gross::numeric, 2) ELSE 0
END AS "Brutto erm",
CASE
WHEN (b.sale_arrival_country = 'CH' AND b.rate >= 8)
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
THEN b.rate ELSE 0
END AS "USt. Satz",
CASE
WHEN NOT (
(b.sale_arrival_country = 'CH' AND b.rate >= 8)
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
)
THEN b.rate ELSE 0
END AS "Ust. Satz erm",
CASE
WHEN (b.sale_arrival_country = 'CH' AND b.rate >= 8)
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
THEN ROUND(b.net::numeric, 2) ELSE 0
END AS "Netto Betrag",
CASE
WHEN NOT (
(b.sale_arrival_country = 'CH' AND b.rate >= 8)
OR (COALESCE(b.sale_arrival_country, '') <> 'CH' AND b.rate >= 16)
)
THEN ROUND(b.net::numeric, 2) ELSE 0
END AS "Netto Betrag erm",
ROUND(b.ship_gross::numeric, 2) AS "Versandkosten brutto",
ROUND(b.ship_net::numeric, 2) AS "Versandkosten netto",
COALESCE(TO_CHAR(b.transaction_depart_date, 'YYYY-MM-DD'), '') AS "Auftragsdatum",
COALESCE(b.activity_transaction_id, '') AS "Bestellnummer",
'' AS "Zahldatum",
COALESCE(b.buyer_name, '') AS "Lieferadresse Name",
'' AS "Tag",
COALESCE(rc_ship.name->>'en_US', rc_ship.name->>'de_DE', '') AS "Lieferland"
FROM amazon_base b
LEFT JOIN res_country rc_inv
ON rc_inv.code = b.sale_arrival_country
LEFT JOIN res_country rc_ship
ON rc_ship.code = b.arrival_country
)
-- ===========================================================================
-- Zusammenfuehrung: ODOO + AMAZON, je Zeile mit Quelle
-- ===========================================================================
SELECT *, 'ODOO' AS "Quelle" FROM odoo_rows
UNION ALL
SELECT *, 'AMAZON' AS "Quelle" FROM amazon_rows
ORDER BY "Rechnungsdatum", "Rechnungsnummer";
+292
View File
@@ -0,0 +1,292 @@
-- =============================================================================
-- Rechnungsexport – Odoo 17 / PostgreSQL
-- Rechnungsdatum · Rechnungsnummer · Länder · Zahlart · USt-Nr. · Steuersplit
--
-- Modell : account.move (Kundenrechnungen und Gutschriften)
--
-- Parameter:
-- %(month)s Abrechnungszeitraum als Text, z. B. '2026-SEP'
--
-- Aufruf (Odoo / psycopg2):
-- cr.execute(open('Rechnungsexport.sql').read(), {'month': '2026-SEP'})
--
-- In psql stattdessen:
-- \set month '2026-SEP'
-- ... AND am.invoice_date >= :'month' (siehe params-CTE)
--
-- Hinweis zum Monatsfilter:
-- Das Rechnungsdatum wird auf einen vollen Kalendermonat gefiltert.
-- Der Monat wird locale-unabhängig in ein Datum umgerechnet; es werden
-- deutsche (MÄR, MAI, OKT, DEZ) und englische (MAR, MAY, OCT, DEC)
-- Dreibuchstaben-Kürzel akzeptiert.
--
-- Änderungen gegenüber der Vorversion (nur die fehlenden Felder ergänzt):
-- * Auftragsdatum : aus sale_order.date_order (über invoice_origin)
-- * Zahldatum : aus der letzten Ausgleichsbuchung der Debitorenzeile
-- * Lieferadresse Name / Lieferland : aus account_move.partner_shipping_id
-- (Fallback: Rechnungspartner)
-- =============================================================================
WITH params AS (
-- Kalendermonat aus %(month)s (z. B. '2026-SEP') ableiten.
SELECT
to_date(
split_part(%(month)s, '-', 1) || '-' ||
CASE upper(split_part(%(month)s, '-', 2))
WHEN 'JAN' THEN '01' WHEN 'FEB' THEN '02'
WHEN 'MÄR' THEN '03' WHEN 'MAR' THEN '03' WHEN 'APR' THEN '04'
WHEN 'MAI' THEN '05' WHEN 'MAY' THEN '05' WHEN 'JUN' THEN '06'
WHEN 'JUL' THEN '07' WHEN 'AUG' THEN '08' WHEN 'SEP' THEN '09'
WHEN 'OKT' THEN '10' WHEN 'OCT' THEN '10' WHEN 'NOV' THEN '11'
WHEN 'DEZ' THEN '12' WHEN 'DEC' THEN '12'
END,
'YYYY-MM'
) AS first_day
),
invoices AS (
SELECT
am.id,
am.name,
am.ref,
am.move_type,
am.invoice_date,
am.invoice_origin,
am.partner_id,
am.partner_shipping_id,
am.invoice_payment_term_id,
am.payment_reference
FROM account_move am
CROSS JOIN params p
WHERE am.invoice_date >= p.first_day
AND am.invoice_date < p.first_day + INTERVAL '1 month'
AND am.move_type IN ('out_invoice', 'out_refund')
),
-- ---------------------------------------------------------------------------
-- tax summary: invoice line -> tax relation -> account_tax
-- ---------------------------------------------------------------------------
line_taxes AS (
SELECT
aml.move_id AS invoice_id,
at.amount AS tax_rate,
SUM(aml.price_subtotal) AS netto,
SUM(aml.price_total) AS brutto
FROM invoices inv
JOIN account_move_line aml
ON aml.move_id = inv.id
JOIN account_move_line_account_tax_rel amlatr
ON amlatr.account_move_line_id = aml.id
JOIN account_tax at
ON at.id = amlatr.account_tax_id
GROUP BY aml.move_id, at.amount
),
-- ---------------------------------------------------------------------------
-- tax split logic from Python:
-- threshold = 8 for CH, else 16
-- ---------------------------------------------------------------------------
tax_split AS (
SELECT
lt.invoice_id,
MAX(CASE
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
THEN ROUND(lt.brutto::numeric, 2)
END) AS brutto_normal,
MAX(CASE
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
THEN ROUND(lt.tax_rate::numeric, 2)
END) AS ust_satz_normal,
MAX(CASE
WHEN (rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
THEN ROUND(lt.netto::numeric, 2)
END) AS netto_normal,
MAX(CASE
WHEN NOT (
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
)
THEN ROUND(lt.brutto::numeric, 2)
END) AS brutto_erm,
MAX(CASE
WHEN NOT (
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
)
THEN ROUND(lt.tax_rate::numeric, 2)
END) AS ust_satz_erm,
MAX(CASE
WHEN NOT (
(rc.code = 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 8)
OR (COALESCE(rc.code, '') <> 'CH' AND ROUND(lt.tax_rate::numeric, 2) >= 16)
)
THEN ROUND(lt.netto::numeric, 2)
END) AS netto_erm
FROM line_taxes lt
JOIN invoices inv
ON inv.id = lt.invoice_id
JOIN res_partner rp
ON rp.id = inv.partner_id
LEFT JOIN res_country rc
ON rc.id = rp.country_id
GROUP BY lt.invoice_id
),
-- ---------------------------------------------------------------------------
-- shipping costs (product category id = 9, as in Python logic)
-- ---------------------------------------------------------------------------
shipping AS (
SELECT
aml.move_id AS invoice_id,
ROUND(SUM(aml.price_total)::numeric, 2) AS shipping_brutto,
ROUND(SUM(aml.price_subtotal)::numeric, 2) AS shipping_netto
FROM invoices inv
JOIN account_move_line aml
ON aml.move_id = inv.id
JOIN product_product pp
ON pp.id = aml.product_id
JOIN product_template pt
ON pt.id = pp.product_tmpl_id
WHERE pt.categ_id = 9
GROUP BY aml.move_id
),
invoice_tags AS (
SELECT
inv.id AS invoice_id,
STRING_AGG(
DISTINCT COALESCE(ct.name->>'en_US', ct.name->>'de_DE', ''),
', ' ORDER BY COALESCE(ct.name->>'en_US', ct.name->>'de_DE', '')
) AS tags
FROM invoices inv
JOIN sale_order so
ON so.name = inv.invoice_origin
JOIN sale_order_tag_rel sotr
ON sotr.order_id = so.id
JOIN crm_tag ct
ON ct.id = sotr.tag_id
GROUP BY inv.id
),
invoice_transaction_reference AS (
SELECT
rel.invoice_id,
pt.provider_reference,
ROW_NUMBER() OVER (
PARTITION BY rel.invoice_id
ORDER BY pt.id
) AS rn
FROM account_invoice_transaction_rel rel
JOIN payment_transaction pt
ON pt.id = rel.transaction_id
),
-- ---------------------------------------------------------------------------
-- Auftragsdatum: Bestellung über invoice_origin (Verkaufsauftragsname) finden
-- ---------------------------------------------------------------------------
sale_info AS (
SELECT
inv.id AS invoice_id,
MIN(so.date_order) AS order_date
FROM invoices inv
JOIN sale_order so
ON so.name = inv.invoice_origin
GROUP BY inv.id
),
-- ---------------------------------------------------------------------------
-- Zahldatum: Datum der letzten Ausgleichsbuchung der Debitorenzeile.
-- account_partial_reconcile.max_date = jüngeres Datum der beiden Buchungen.
-- ---------------------------------------------------------------------------
invoice_payments AS (
SELECT
aml.move_id AS invoice_id,
MAX(apr.max_date) AS payment_date
FROM invoices inv
JOIN account_move_line aml
ON aml.move_id = inv.id
JOIN account_account aa
ON aa.id = aml.account_id
AND aa.account_type = 'asset_receivable'
JOIN account_partial_reconcile apr
ON apr.debit_move_id = aml.id
OR apr.credit_move_id = aml.id
GROUP BY aml.move_id
)
SELECT
TO_CHAR(inv.invoice_date, 'YYYY-MM-DD') AS "Rechnungsdatum",
CASE
WHEN inv.move_type = 'out_refund'
THEN COALESCE(inv.name, inv.ref, '') || '-gs'
ELSE COALESCE(inv.name, inv.ref, '')
END AS "Rechnungsnummer",
COALESCE(rc_inv.name->>'en_US', rc_inv.name->>'de_DE', '') AS "Rechnungsland",
COALESCE(rc_inv.code, '') AS "Rechnungsland Ländercode",
COALESCE(apt.name->>'en_US', apt.name->>'de_DE', '') AS "Zahlart",
COALESCE(itr.provider_reference, '') AS "Zahlungsreference der Bestellung",
COALESCE(partner.vat, '') AS "USt. Id / VAT Id des Käufers (in Bestellung)",
ROUND(COALESCE(ts.brutto_normal, 0) + COALESCE(ts.brutto_erm, 0), 2) AS "Gesamtpreis brutto",
ROUND(COALESCE(ts.netto_normal, 0) + COALESCE(ts.netto_erm, 0), 2) AS "Gesamtpreis netto",
COALESCE(ts.brutto_normal, 0) AS "Brutto",
COALESCE(ts.brutto_erm, 0) AS "Brutto erm",
COALESCE(ts.ust_satz_normal, 0) AS "USt. Satz",
COALESCE(ts.ust_satz_erm, 0) AS "Ust. Satz erm",
COALESCE(ts.netto_normal, 0) AS "Netto Betrag",
COALESCE(ts.netto_erm, 0) AS "Netto Betrag erm",
COALESCE(sh.shipping_brutto, 0) AS "Versandkosten brutto",
COALESCE(sh.shipping_netto, 0) AS "Versandkosten netto",
COALESCE(TO_CHAR(si.order_date, 'YYYY-MM-DD'), '') AS "Auftragsdatum",
COALESCE(inv.invoice_origin, '') AS "Bestellnummer",
COALESCE(TO_CHAR(ip.payment_date, 'YYYY-MM-DD'), '') AS "Zahldatum",
COALESCE(ship.name, partner.name, '') AS "Lieferadresse Name",
COALESCE(it.tags, '') AS "Tag",
COALESCE(
rc_ship.name->>'en_US', rc_ship.name->>'de_DE',
rc_inv.name->>'en_US', rc_inv.name->>'de_DE',
''
) AS "Lieferland"
FROM invoices inv
JOIN res_partner partner
ON partner.id = inv.partner_id
LEFT JOIN res_country rc_inv
ON rc_inv.id = partner.country_id
LEFT JOIN res_partner ship
ON ship.id = inv.partner_shipping_id
LEFT JOIN res_country rc_ship
ON rc_ship.id = ship.country_id
LEFT JOIN account_payment_term apt
ON apt.id = inv.invoice_payment_term_id
LEFT JOIN tax_split ts
ON ts.invoice_id = inv.id
LEFT JOIN shipping sh
ON sh.invoice_id = inv.id
LEFT JOIN invoice_tags it
ON it.invoice_id = inv.id
LEFT JOIN invoice_transaction_reference itr
ON itr.invoice_id = inv.id
AND itr.rn = 1
LEFT JOIN sale_info si
ON si.invoice_id = inv.id
LEFT JOIN invoice_payments ip
ON ip.invoice_id = inv.id
ORDER BY inv.invoice_date, inv.name;
+43 -7
View File
@@ -319,20 +319,56 @@ select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px
}
/* ---------- Table ---------- */
.table-wrap { overflow-x: auto; }
/* Bounded height so the table body scrolls (both axes) while the header row
stays put. */
.table-wrap { overflow: auto; max-height: 70vh; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
thead th {
.table-wrap thead th {
position: sticky; top: 0; z-index: 2;
background: var(--surface);
text-align: left; font-size: 11.5px; letter-spacing: .05em;
text-transform: uppercase; color: var(--muted);
padding: 10px 12px; border-bottom: 1px solid var(--border);
padding: 10px 12px; box-shadow: inset 0 -1px 0 var(--border);
font-weight: 600;
}
tbody td { padding: 11px 12px; border-bottom: 1px solid var(--surface-2); }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: var(--surface-2); }
th.num, td.num { text-align: right; font-variant-numeric: tabular-nums; }
.table-wrap tbody td { padding: 11px 12px; border-bottom: 1px solid var(--surface-2); }
.table-wrap tbody tr:last-child td { border-bottom: none; }
.table-wrap tbody tr:hover td { background: var(--surface-2); }
.table-wrap th.num, .table-wrap td.num { text-align: right; font-variant-numeric: tabular-nums; }
tr.total td { font-weight: 700; background: var(--accent-soft); }
tr.total:hover td { background: var(--accent-soft); }
tr.summary td { font-weight: 700; background: var(--accent-soft); }
tr.summary:hover td { background: var(--accent-soft); }
/* ---------- PivotTable.js experiment ---------- */
#pivot { overflow-x: auto; }
.panel-head-actions { display: flex; align-items: center; gap: 12px; }
/* ---------- Per-dashboard table filters ---------- */
.table-filters {
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.table-filters input {
border: 1px solid var(--border);
background: var(--surface-2);
padding: 9px 12px;
border-radius: 10px;
font-size: 14px;
font-family: inherit;
color: var(--text);
min-width: 150px;
transition: border-color .15s, box-shadow .15s, background .15s;
}
.table-filters input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
background: #fff;
}
.pill {
display: inline-block; padding: 2px 9px; border-radius: 999px;