Compare commits

..

2 Commits

Author SHA1 Message Date
knedlik
20cd0b8547 Änderung: Filterfunktionen costobjects.py implementiert 2026-02-26 07:58:25 +01:00
knedlik
3f7e405824 Neues Dashboard: Kostenobjekte 2026-02-23 08:11:38 +01:00
10 changed files with 607 additions and 15 deletions

Binary file not shown.

3
app/data/datasets.py Normal file
View File

@@ -0,0 +1,3 @@
from scriptloader import get_sql
from db import get_conn

View File

@@ -1,4 +1,7 @@
import streamlit as st
from sqlalchemy import create_engine, Text from sqlalchemy import create_engine, Text
from data.scriptloader import get_sql
import oracledb
import pandas as pd import pandas as pd
from dotenv import load_dotenv from dotenv import load_dotenv
from pathlib import Path from pathlib import Path
@@ -26,6 +29,25 @@ def get_conn(db):
logging.info(f"Datenbank {db} konnte nicht gefunden werden") logging.info(f"Datenbank {db} konnte nicht gefunden werden")
return engine return engine
def load_data(sql_file, db):
sql = get_sql(sql_file)
engine = get_conn(db)
with engine.connect():
df = pd.read_sql(sql, engine)
return df
# def get_data(db): # def get_data(db):
# engine = get_conn(db) # engine = get_conn(db)
# with engine.connect() as conn: # with engine.connect() as conn:

View File

@@ -7,4 +7,4 @@ def get_sql(filename):
if __name__ == "__main__": if __name__ == "__main__":
print(get_sql("sales_umsatz")) print(get_sql("ergebnis_kpi"))

View File

@@ -0,0 +1,92 @@
/*******************************************************************************************
* Basis-Daten aus der Kostenrechnung
*******************************************************************************************/
with basis as(
Select
p.geschaeftsjahr as jahr,
p.geschaeftsperiode as monat,
p.periode_key,
b.zgrp1,
b.zgrp2,
b.zgrp3,
b.bezeichnung as Bereich,
ko.kostenobjekt,
ko.bezeichnung as obj_bezeichnung,
ko.obj_text,
ko.objektgruppe,
ko.objekttyp,
ko.verantwortlicher,
ks.co_koa_grp,
ks.co_grp_bez,
ks.co_grp_text,
ks.kostenart,
ks.koa_text,
coalesce(ws.uml_kz,'') as uml_kz,
coalesce(ws.zgrp_1_entlastung, '') as zgrp1_entlastung,
coalesce(ws.wert * -1, 0) as wert,
coalesce(ws.wert_plan * -1, 0) as wert_plan,
0 as wert_forecast
from
co_dw.bi.fact_wertsummen ws
left join co_dw.bi.dim_periode p
on ws.periode_key = p.periode_key
left join co_dw.bi.dim_kostenobjekt ko
on ws.objekt_key = ko.objekt_key
left join co_dw.bi.dim_koastruktur ks
on ws.kostenart = ks.kostenart
left join co_dw.bi.dim_bereich b
on ko.bereich_key = b.bereich_key
where
ks.co_koa_grp_01 = 'GMN001'
and ko.objektgruppe in ('HKST', 'VKST', 'KTRG', 'KtrBereich', 'KtrMNr')
and p.geschaeftsperiode < 14
and (p.geschaeftsjahr = :jahr or p.geschaeftsjahr = :jahr -1)
),
/*******************************************************************************************
* Gesamtergebnis
*******************************************************************************************/
ergebnis_gesamt as (
select
zgrp1,
zgrp2,
zgrp3,
kostenobjekt,
obj_bezeichnung,
obj_text,
co_koa_grp,
co_grp_bez,
co_grp_text,
'Ergebnis' as main_kpi,
'Gesamtergebnis' as kpi,
round(sum(case when jahr = :jahr-1 then wert else 0 end),0) as vor_jahr,
round(sum(case when jahr = :jahr-1 and monat <= :monat then wert else 0 end),0) as vor_jahr_bis_monat,
round(sum(case when jahr = :jahr-1 and monat = :monat then wert else 0 end),0) as vor_jahr_monat,
round(sum(case when jahr = :jahr then wert else 0 end),0) as akt_jahr,
round(sum(case when jahr = :jahr and monat <= :monat then wert else 0 end),0) as akt_jahr_bis_monat,
round(sum(case when jahr = :jahr and monat = :monat then wert else 0 end),0) as akt_jahr_monat,
round(sum(case when jahr = :jahr then wert_plan else 0 end),0) as plan_akt_jahr,
round(sum(case when jahr = :jahr and monat <= :monat then wert_plan else 0 end),0) as plan_akt_jahr_bis_monat,
round(sum(case when jahr = :jahr and monat = :monat then wert_plan else 0 end),0) as plan_akt_jahr_monat,
round(sum(case when jahr = :jahr then wert_forecast else 0 end),0) as forecast_akt_jahr,
round(sum(case when jahr = :jahr and monat <= :monat then wert_forecast else 0 end),0) as forecast_akt_jahr_bis_monat,
round(sum(case when jahr = :jahr and monat = :monat then wert_forecast else 0 end),0) as forecast_akt_jahr_monat
from
basis
group by
zgrp1,
zgrp2,
zgrp3,
kostenobjekt,
obj_bezeichnung,
obj_text,
co_koa_grp,
co_grp_bez,
co_grp_text
)
select * from ergebnis_gesamt
select * from co_dw.bi.Fact_Wertsummen

View File

@@ -0,0 +1,22 @@
/* Orlacle (PENTA) Kostenobjekte */
select
GESCHAEFTSJAHR as jahr,
OBJEKTTYP as typ,
KOSTENOBJEKT as obj,
BEZEICHNUNG,
VERANTWORTLICHER,
FELD_2_X30 as vorgesetzter,
ZUORDNUNGSGRUPPE_1 as zgrp1,
ZUORDNUNGSGRUPPE_2 as zgrp2,
ZUORDNUNGSGRUPPE_3 as zgrp3,
ZUORDNUNGSGRUPPE_4 as zgrp4,
ZUORDNUNGSGRUPPE_5 as zgrp5,
ZUORDNUNGSGRUPPE_6 as zgrp6,
FELD_1_X30 as fertigung,
OBJEKTGRUPPE as objgrp,
sysdate
from
pkos
where
objekttyp in ('01', '02')

View File

View File

@@ -1,34 +1,153 @@
import streamlit as st import streamlit as st
import pandas as pd import pandas as pd
from data.scriptloader import get_sql from data.scriptloader import get_sql
from data.db import get_conn from data.db import load_data
from auth_runtime import require_login from auth_runtime import require_login
from ui.sidebar import build_sidebar, hide_sidebar_if_logged_out from ui.sidebar import build_sidebar, hide_sidebar_if_logged_out
from auth import get_fullname_for_user from auth import get_fullname_for_user
import numpy as np
# hide_sidebar_if_logged_out()
st.set_page_config(page_title="Co-App Home", page_icon="🏠") st.set_page_config(page_title="Co-App Home", page_icon="🏠", layout="wide")
authenticator = require_login() authenticator = require_login()
st.session_state["authenticator"] = authenticator st.session_state["authenticator"] = authenticator
def load_data(): @st.cache_data
sql = get_sql("co_kostenobjekte") def cache_data() -> pd.DataFrame:
print(sql) """
engine = get_conn("co_dw") Load and cache the base dataset for this page.
with engine.connect() as conn:
df = pd.read_sql(sql, engine) Streamlit reruns the script on every interaction; caching avoids
print(df) repeated I/O and makes filtering feel instant.
return df """
try:
df = load_data("ora_kostenobjekte", "oracle")
return df
except:
st.warning("Fehler beim Laden der Daten", icon="⚠️")
def sidebar_filters(df) -> dict:
"""
Render the sidebar UI and return the current filter selections.
This function should contain UI concerns only (widgets, layout),
and not data filtering logic, to keep the code maintainable.
"""
st.sidebar.header("Filter")
if st.sidebar.button("Refresh (Global)"):
cache_data.clear()
st.rerun()
year = st.sidebar.selectbox(label="Jahr", options=(2025, 2026), index=1)
filter_text = st.sidebar.text_input(label="Textsuche", placeholder="Suche Objekt, Text, Verantwortlicher")
col_s1, col_s2 = st.sidebar.columns(2)
with col_s1:
typ = st.sidebar.selectbox(label="Typ", options=sorted(df["typ"].dropna().unique()), index=1)
with col_s2:
obj = st.sidebar.multiselect("obj", sorted(df["obj"].dropna().unique()))
zgrp1 = st.sidebar.multiselect("ZGrp1", sorted(df["zgrp1"].dropna().unique()))
zgrp2 = st.sidebar.multiselect("ZGrp2", sorted(df["zgrp2"].dropna().unique()))
zgrp3 = st.sidebar.multiselect("ZGrp3", sorted(df["zgrp3"].dropna().unique()))
return {"year": year, "filter_text": filter_text, "typ": typ, "obj": obj, "zgrp1": zgrp1, "zgrp2": zgrp2, "zgrp3": zgrp3}
def build_mask(df: pd.DataFrame, sidebar_filter: dict) -> np.ndarray:
"""
Build a boolean mask based on filter selections.
The mask approach keeps the logic readable and makes it easy
to add more conditions later.
"""
mask = np.ones(len(df), dtype=bool)
filter_text = (sidebar_filter.get("filter_text") or "").strip()
if filter_text:
m1 = df["bezeichnung"].astype("string").str.contains(filter_text, case=False, na=False, regex=False)
m2 = df["verantwortlicher"].astype("string").str.contains(filter_text, case=False, na=False, regex=False)
m3 = df["vorgesetzter"].astype("string").str.contains(filter_text, case=False, na=False, regex=False)
mask &= (m1 | m2 | m3)
if sidebar_filter["year"]:
mask &= df["jahr"].eq(sidebar_filter["year"])
if sidebar_filter["typ"]:
mask &= df["typ"].eq(sidebar_filter["typ"])
if sidebar_filter["obj"]:
mask &= df["obj"].isin(sidebar_filter["obj"])
if sidebar_filter["zgrp1"]:
mask &= df["zgrp1"].isin(sidebar_filter["zgrp1"])
if sidebar_filter["zgrp2"]:
mask &= df["zgrp2"].isin(sidebar_filter["zgrp2"])
if sidebar_filter["zgrp3"]:
mask &= df["zgrp3"].isin(sidebar_filter["zgrp3"])
return mask
def render_table(df):
"""
Render the result table.
Keep this function presentation-only: it should not modify data.
"""
st.dataframe(df, hide_index=True, width="stretch", height="stretch")
def page():
"""
Page entry point: orchestrates data loading, UI, filtering, and rendering.
"""
# -----------------------------
# Data loading (cached)
# -----------------------------
df = cache_data()
# -----------------------------
# UI (Sidebar)
# -----------------------------
sidebar_filter = sidebar_filters(df)
# -----------------------------
# Business logic (filtering)
# -----------------------------
mask = build_mask(df, sidebar_filter)
# -----------------------------
# Presentation
# -----------------------------
df = df.sort_values(by="obj", key=lambda s: pd.to_numeric(s, errors="coerce"))
df_clean = df[df.columns[:-1]] # letzte Spalten entfernen (sysdate)
df_display = df_clean.loc[mask]
render_table(df_display)
col1, col2 = st.columns(2)
data_as_of = df["sysdate"].max()
row_count = len(df_display)
with col1:
st.text(f"Datenstand: {data_as_of}")
st.text("Datenquelle: Oracle (PENTA)")
with col2:
st.markdown(f"<div style='text-align: right;'>Anzahl Zeilen: {row_count}</div>", unsafe_allow_html=True)
st.dataframe(load_data())
if __name__ == "__main__": if __name__ == "__main__":
df = load_data() df = page()
print(df)

View File

@@ -0,0 +1,255 @@
import streamlit as st
import pandas as pd
from data.scriptloader import get_sql
from data.db import get_conn
from auth_runtime import require_login
from ui.sidebar import build_sidebar, hide_sidebar_if_logged_out
from auth import get_fullname_for_user
from sqlalchemy import text
import duckdb
import altair as alt
from tools.helpers import display_value, calc_variance_pct
# hide_sidebar_if_logged_out()
st.set_page_config(page_title="Co-App Home", page_icon="🏠", layout="wide")
authenticator = require_login()
st.session_state["authenticator"] = authenticator
DISPLAY_UNIT = "Mio. €"
def load_data():
sql = get_sql("ergebnis_kpi")
engine = get_conn("co_dw")
with engine.connect() as conn:
df = pd.read_sql(text(sql), con=conn, params={"jahr": 2025, "monat": 12})
return df
# def calc_variance_pct(actual, plan):
# variance = actual - plan
# if plan == 0:
# return None
# if actual * plan < 0:
# return None
# return variance / abs(plan)
def build_dashboard():
df = load_data()
# st.dataframe(df)
be_ist_kum = df["akt_jahr"].sum()
#++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Kalkulation KPI Betriebsergebnis
#++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
operating_result_actual_ytd = df["akt_jahr_bis_monat"].sum()
operating_result_actual_ytd_str = f"{int(operating_result_actual_ytd)/ANZ_EINHEIT:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") + " Mio. €"
operating_result_plan_ytd = df["plan_akt_jahr_bis_monat"].sum()
operating_result_actual_ytd_py = df["vor_jahr_bis_monat"].sum()
operating_result_variance = operating_result_actual_ytd - operating_result_plan_ytd
operating_result_vaiance_pct = calc_variance_pct(operating_result_actual_ytd, operating_result_plan_ytd)
#++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Dashboard - Ebene 1 Betriebsergebnis
#++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
col_1, col_2 = st.columns(2, border=True,vertical_alignment="center")
with col_1:
st.metric(label="Betriebsergebnis", value=operating_result_actual_ytd)
operation_result_monthly = df["akt_jahr_monat"].sum()
be_ist_vorjahr_kum = df["vor_jahr"].sum()
be_ist_monat = df["akt_jahr_monat"].sum()
be_ist_vorjahr_monat = df["vor_jahr_monat"].sum()
be_ist_kum_anz = f"{int(be_ist_kum)/ANZ_EINHEIT:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") + " Mio. €"
be_ist_monat_anz = f"{int(be_ist_monat)/ANZ_EINHEIT:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") + " Mio. €"
be_ist_vorjahr_kum_anz = f"{int(be_ist_vorjahr_kum)/ANZ_EINHEIT:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") + " Mio. €"
be_ist_monat_vorjahr_anz = f"{int(be_ist_vorjahr_monat)/ANZ_EINHEIT:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") + " Mio. €"
#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Ebene 1 - Ergebnis-KPIs
#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# Beispiel-Daten (ersetze das durch deine echten Monatswerte)
months = pd.date_range(end=pd.Timestamp.today().normalize(), periods=12, freq="MS")
values = pd.Series([120, 80, -30, 50, 40, 10, -20, 60, 70, 30, 20, -10], index=months)
df = pd.DataFrame({"month": months, "BE": values}).set_index("month")
current = df["BE"].iloc[-1]
prev = df["BE"].iloc[-2]
delta = current - prev
col1, col2, col3 = st.columns(3)
with col1:
st.metric(
"Betriebsergebnis (intern)",
f"{current:,.0f}",
f"{delta:,.0f}"
)
with st.expander("Verlauf letzte 12 Monate", expanded=False):
# st.bar_chart(df["BE"])
# fig, ax = plt.subplots()
# ax.plot(df.index, df["BE"], marker="o")
# ax.axhline(0, linewidth=1) # Nulllinie für negative Werte
# ax.set_xlabel("")
# ax.set_ylabel("€")
# ax.tick_params(axis="x", rotation=45)
# st.pyplot(fig, clear_figure=True)
df_reset = df.reset_index()
df_reset["Monat"] = df_reset["month"].dt.strftime("%Y-%m")
chart = (
alt.Chart(df_reset)
.mark_bar(size=28) # Balkendicke
.encode(
x=alt.X("Monat:N", sort=None, title=""),
y=alt.Y("BE:Q", title=""),
color=alt.condition(
alt.datum.BE < 0,
alt.value("#d62728"), # rot
alt.value("#2ca02c"), # grün
)
)
)
labels = (
alt.Chart(df_reset)
.mark_text(dy=-8)
.encode(
x="Monat:N",
y="BE:Q",
text=alt.Text("BE:Q", format=",.0f")
)
)
spark = (
alt.Chart(df_reset)
.mark_line(point=True)
.encode(
x=alt.X("Monat:N", axis=None),
y=alt.Y("BE:Q", axis=None)
)
.properties(height=60)
)
st.altair_chart(spark, use_container_width=True)
# st.altair_chart(chart + labels, use_container_width=True)
# col_operating_result, col_contribution_margin = st.columns(2,border=True)
# col_ue_n, col_ae_f, col_ab_f = st.columns(3,border=True,)
# with col_ue_n:
# with st.expander(label="Umsatz",):
# st.metric(label="BE-Ist kumuliert (monat)",value=f"{be_ist_kum_anz} ({be_ist_monat_anz})", delta="-5", border=True)
# st.text("Umsatz")
# with col_ae_f:
# st.text("Auftragseingang fest")
# with col_ab_f:
# st.text("Auftragsbestand fest")
# with col_operating_result:
# internal_operating_result = f"BE = {be_ist_kum_anz} Mio. €"
# st.metric(label="BE-Ist kumuliert (monat)",value=f"{internal_operating_result} ({be_ist_monat_anz})", delta="-5", border=True)
# with st.expander(label=st.markdown(f"# {internal_operating_result}")):
# st.text("Verlauf...")
# # st.text("ERGTAB")
# st.metric(label="BE-Ist kumuliert (monat)",value=f"{be_ist_kum_anz} ({be_ist_monat_anz})", delta="-5", border=True)
# erg = duckdb.sql("""
# select
# 'Umsatz' as Kostenart,
# sum(case when co_koa_grp = 'CO1000' then akt_jahr else 0 end) as Aktuell,
# sum(case when co_koa_grp = 'CO1000' then plan_akt_jahr else 0 end) as Plan,
# sum(case when co_koa_grp = 'CO1000' then vor_jahr else 0 end) as Vorjahr
# from
# df
# """).fetchdf()
# st.metric(label="BE-Ist kumuliert (monat)",value=f"{be_ist_kum_anz} ({be_ist_monat_anz})", delta="-5", border=True)
# st.dataframe(erg, hide_index=True)
# col_erg_ist = st.columns(1)
# with col_erg_ist:
# st.metric(label="BE-Ist (monat)",value=be_ist_monat_anz, delta="-5", border=True)
# col1, col2 = st.columns(2)
# with col1:
# # st.metric(label="BE-Ist (kumuliert)",value=be_ist_kum_anz, delta="-5", border=True)
# with st.success("Ergebnis"):
# st.button(f"{be_ist_kum_anz}")
# # st.success("plus 10% zu Vorjahr")
# # with col2:
# # st.error("-10% zu Plan")
# # with col_erg_plan:
# # st.info("minus 5%")
# # st.warning("minus 10%")
# # st.success("plus 10%")
# # st.error("minus 20%")
# # with col_erg_vorjahr:
# # st.metric(label="BE-Vorjahr (kumuliert)",value=be_ist_vorjahr_kum_anz, delta="-5", border=True)
# # st.metric(label="BE-Vorjahr (monat)",value=be_ist_monat_anz, delta="-5", border=True)
# # st.dataframe(load_data())
if __name__ == "__main__":
df = build_dashboard()

79
app/tools/helpers.py Normal file
View File

@@ -0,0 +1,79 @@
def calc_variance_pct(actual, plan):
"""
Calculates the percentage variance between actual and plan values
for reporting purposes.
The percentage variance is only returned if it is economically
meaningful and interpretable:
- The plan value must not be zero
- Actual and plan must have the same sign (no sign change)
In cases where a percentage variance would be misleading
(e.g. sign change from loss to profit), the function returns None
and absolute variance should be used instead.
Parameters
----------
actual : float | int
Actual (realized) value.
plan : float | int
Planned or budgeted value.
Returns
-------
float | None
Percentage variance relative to the absolute plan value,
or None if the percentage variance is not meaningful.
"""
variance = actual - plan
if plan == 0:
return None
if actual * plan < 0:
return None
return variance / abs(plan)
def display_value(value, unit):
"""
Formats a numeric KPI value for reporting output based on the configured
display unit (e.g. €, T€, Mio. €).
- Scales the input value according to DISPLAY_UNIT
- Applies European number formatting (thousands separator, decimal comma)
- Returns 'n/a' if the input value is None
Parameters
----------
value : float | int | None
Raw KPI value in base currency (e.g. EUR).
Returns
-------
str
Formatted value including display unit, ready for dashboard display.
"""
if value is None:
return "n/a"
unit_factors = {
"Mio. €": 1_000_000,
"T€": 1_000,
"": 1,
}
factor = unit_factors.get(unit, 1)
scaled = value / factor
formatted = f"{scaled:,.2f}"
formatted = (
formatted
.replace(",", "X")
.replace(".", ",")
.replace("X", ".")
)
return f"{formatted} {unit}"