feat(workflow): Tages-Workflow-UI für Telefon-Akquise (Liste + Maske)

Server: neuer Endpunkt /xhr/workflow/list aggregiert pro Kontaktadresse
die letzte Gesprächsnotiz, das effektiv gültige Wiedervorlage-Datum
(jüngste Notiz mit Wert), den Erledigt-Status und eine priorisierte
Sortierung (überfällig → ohne Notizen → Rest). Filter: kunden_id, status
(offen/erledigt), wiedervorlage (heute/3tage/ueberfaellig/woche).
Sichtbarkeit wie kontaktadressen über projekt_users.

CallLog erfasst den Ersteller serverseitig (user_id, Migration _db_V01_14,
kein FK wegen separater Auth-DB); to_dict ergänzt mitarbeiter_name.
calllog_list unterstützt kontaktadresse_id als Filter.

Client: TagesWorkflowListe (Route /tagesworkflow, Filterleiste) und
TagesWorkflowMaske (Route /tagesworkflowkontakt, lesende Historie,
Notiz-Formular mit "Speichern und weiter"), Routes + Menüpunkt erster
Eintrag. berechneWiedervorlageDatum aus der Call_Log-Maske nach
models/tag.ts ausgelagert (gemeinsame Nutzung).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
gg committed 2026-09-24 18:56:23 +02:00
1 parent 981b28df03
commit c96ed6e97d
13 files changed
+1247 -44

No files matched your search

+25 -5
View File
@@ -263,12 +263,14 @@ class Kontaktadresse(dbapp.Model):
def to_dict(self):
"""
Liefert die Kontaktdaten als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; projekt_name wird zusätzlich aus der
Projekt-Relationship eingefügt. is_erledigt wird dynamisch aus den
Gesprächsnotizen des Kontakts berechnet.
über columns2dict serialisiert; projekt_name und kunden_name werden
zusätzlich aus der Projekt- bzw. Kunde-Relationship eingefügt.
is_erledigt wird dynamisch aus den Gesprächsnotizen des Kontakts
berechnet.
"""
result = columns2dict(self)
result['projekt_name'] = self.projekt.projektname if self.projekt else None
result['kunden_name'] = self.projekt.kunde.name if self.projekt and self.projekt.kunde else None
result['is_erledigt'] = self.is_erledigt()
return result
@@ -296,6 +298,14 @@ class CallLog(dbapp.Model):
# NOT NULL) — die Pflicht wird über die Server-Validierung in
# crm.model.call_log durchgesetzt.
kontaktadresse_id = Column(Integer, ForeignKey('kontaktadressen.id'), nullable=False)
# Ersteller der Gesprächsnotiz (Mitarbeiter, der den Anruf getätigt
# hat). Referenziert logisch die 'id' in der Auth-Datenbanktabelle
# 'users'; ein physischer Fremdschlüssel ist aufgrund der zwei
# getrennten SQLite-Datenbanken nicht möglich (Muster wie
# projekt_users.user_id). nullable=True, da Alt-Notizen (angelegt
# vor der Spalte) keinen Ersteller erfassen konnten; die Auflösung
# auf den username erfolgt zur Laufzeit über eine separate Query.
user_id = Column(Integer, nullable=True)
datum = Column(Date, nullable=True)
uhrzeit_beginn = Column(String(10), nullable=True)
dauer = Column(String(10), nullable=True)
@@ -319,8 +329,10 @@ class CallLog(dbapp.Model):
"""
Liefert die Gesprächsnotiz als Dictionary. Die Tabellenspalten werden
über columns2dict serialisiert; projekt_name und kunden_name werden
zusätzlich aus der Projekt-Relationship eingefügt. Die zugeordneten
Tags werden als Liste von Tag-Dictionaries unter 'tags' ergänzt.
zusätzlich aus der Projekt-Relationship eingefügt. mitarbeiter_name
löst die user_id gegen die Auth-Datenbank auf (None bei Alt-Notizen
ohne erfassten Ersteller). Die zugeordneten Tags werden als Liste
von Tag-Dictionaries unter 'tags' ergänzt.
"""
result = columns2dict(self)
if self.projekt:
@@ -329,6 +341,14 @@ class CallLog(dbapp.Model):
else:
result['projekt_name'] = None
result['kunden_name'] = None
# Ersteller gegen die Auth-Datenbank auflösen (Muster wie
# crm.model.projekt_user). Alt-Notizen ohne user_id erhalten None.
result['mitarbeiter_name'] = None
if self.user_id is not None:
from auth.model import User
user = User.query.get(self.user_id)
if user is not None:
result['mitarbeiter_name'] = user.username
# Zugeordnete Tags serialisieren (nur is_parent=False sind zuordenbar).
result['tags'] = [clt.tag.to_dict() for clt in self.tags_rel]
return result
+21 -4
View File
@@ -94,13 +94,17 @@ def _get_validierte_kontaktadresse_id(data: dict, projekt_id) -> int:
return kontaktadresse.id
def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
def get_call_logs(projekt_id: int | None = None,
user_id: int | None = None,
kontaktadresse_id: int | None = None):
"""
Liefert Gesprächsnotizen, sortiert nach Datum absteigend.
Ist projekt_id gesetzt, werden nur die Notizen dieses Projekts
zurückgegeben (serverseitige Filterung). Ohne projekt_id werden
alle Notizen geliefert.
zurückgegeben (serverseitige Filterung). Ist kontaktadresse_id
gesetzt, werden nur die Notizen dieses Kontakts zurückgegeben —
beide Filter können kombiniert werden. Ohne Filter werden alle
Notizen geliefert.
Ist user_id gesetzt, werden nur Notizen von Projekten geliefert, denen
der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-Filterung).
@@ -109,6 +113,8 @@ def get_call_logs(projekt_id: int | None = None, user_id: int | None = None):
query = CallLog.query
if projekt_id is not None:
query = query.filter(CallLog.projekt_id == projekt_id)
if kontaktadresse_id is not None:
query = query.filter(CallLog.kontaktadresse_id == kontaktadresse_id)
if user_id is not None:
# Nur Notizen von Projekten, denen der Nutzer zugeordnet ist.
query = query.filter(
@@ -148,7 +154,7 @@ def get_call_log(call_log_id: int, user_id: int | None = None) -> CallLog:
return call_log
def create_call_log(data: dict) -> CallLog:
def create_call_log(data: dict, user_id: int | None = None) -> CallLog:
"""
Legt eine neue Gesprächsnotiz an. Die vom Client übergebene ID wird
ignoriert; die ID wird serverseitig per Auto-Increment vergeben.
@@ -156,6 +162,11 @@ def create_call_log(data: dict) -> CallLog:
- data: dict mit den Feldern projekt_id, kontaktadresse_id, datum,
uhrzeit_beginn, dauer, betreff, name_ansprechpartner, telefon,
notiz, wiedervorlage_datum, erledigt, tag_ids
- user_id: ID des angemeldeten Mitarbeiters, der die Notiz erfasst
(Ersteller). Wird von der Route übergeben (current_user.id),
damit diese Funktion frei vom Flask-Request-Kontext bleibt.
Der Ersteller wird beim Anlegen gesetzt und beim späteren
Update nicht mehr verändert.
"""
projekt_id = data.get("projekt_id")
if projekt_id is None:
@@ -173,6 +184,12 @@ def create_call_log(data: dict) -> CallLog:
call_log = CallLog()
call_log.projekt_id = int(projekt_id)
call_log.kontaktadresse_id = kontaktadresse_id
# Ersteller erfassen: welcher Mitarbeiter den Anruf getätigt hat.
# Alt-Notizen (vor der user_id-Spalte angelegt) haben NULL; neue
# Notizen ohne Angabe erhalten ebenfalls NULL, bleiben aber die
# Ausnahme, da die Route immer current_user.id übergibt.
if user_id is not None:
call_log.user_id = int(user_id)
call_log.datum = _parse_date(data.get("datum"))
call_log.uhrzeit_beginn = (data.get("uhrzeit_beginn") or "").strip() or None
call_log.dauer = (data.get("dauer") or "").strip() or None
+41
View File
@@ -325,6 +325,47 @@ def _db_V01_13():
session.commit()
def _db_V01_14():
"""
Fügt der Tabelle 'call_logs' die Spalte 'user_id' hinzu: Jede
Gesprächsnotiz erfasst, welcher Mitarbeiter den Anruf getätigt hat.
Die Spalte referenziert logisch die 'id' in der Auth-Datenbanktabelle
'users'; ein physischer Fremdschlüssel ist aufgrund der zwei
getrennten SQLite-Datenbanken nicht möglich (Muster wie
projekt_users.user_id).
Kein Backfill: Für bestehende CallLogs ist der Ersteller nicht mehr
ermittelbar — die Spalte bleibt dort NULL (Anzeige 'unbekannt').
Neue Notizen erhalten die user_id serverseitig über current_user.id
(siehe crm.routes.calllog_create).
Idempotent: Bei Neuinstallation hat create_all die Spalte bereits
angelegt — in diesem Fall wird das ALTER TABLE übersprungen. Der
Index ist über IF NOT EXISTS ebenfalls idempotent.
"""
from sqlalchemy import text
result = session.execute(text("PRAGMA table_info(call_logs)"))
spalten = {row[1] for row in result}
if 'user_id' not in spalten:
session.execute(text(
"ALTER TABLE call_logs ADD COLUMN user_id INTEGER"
))
session.execute(text(
"CREATE INDEX IF NOT EXISTS ix_call_logs_user_id"
" ON call_logs (user_id)"
))
# Roh-SQL-Änderungen committen, BEVOR die Systeminfo aktualisiert wird
# (dasselbe Muster wie _db_V01_08 und _db_V01_13): Die DDL-Anweisung
# läuft über die Default-Bind-Verbindung — SQLALCHEMY_DATABASE_URI
# zeigt auf dieselbe Datei wie der appdb-Bind. Das ORM-Update der
# Systeminfo läuft über die appdb-Bind-Verbindung; ohne dieses Commit
# blockiert die offene Transaktion das UPDATE ('database is locked').
session.commit()
_updatesysteminfo('01.14')
session.commit()
def checkupdates():
"""
Ermittelt die aktuelle DB-Version aus der Systeminfo-Tabelle und führt alle
+203
View File
@@ -0,0 +1,203 @@
"""
Geschäftslogik für den Tages-Workflow (Telefon-Akquise).
Aggregiert pro Kontaktadresse die für den Tages-Workflow relevanten
Informationen: letzte Gesprächsnotiz, aktuell gültiges Wiedervorlage-Datum
und dynamisch berechneter Erledigt-Status. Filter (Kunde, Status,
Wiedervorlage-Zeitraum) und die priorisierte Sortierung sind Teil der
Aggregation — der Client erhält eine fertige, gefilterte Liste.
Aufgerufen werden die Funktionen aus crm/routes.py; die Autorisierungs-
prüfung erfolgt dort über den Decorator authorisation_required.
:author: Mistral Vibe <vibe@mistral.ai>
:date: 2026-09-23
:version: 1.0
"""
from datetime import date, timedelta
from db import dbapp
from crm.model import Kontaktadresse, Projekt, ProjektUser
session = dbapp.session
def _naechste_woche_start_und_ende(heute: date) -> tuple[date, date]:
"""
Liefert Montag und Sonntag der ISO-Kalenderwoche, in die der
übergebene Tag fällt.
"""
jahr, woche, _ = heute.isocalendar()
montag = date.fromisocalendar(jahr, woche, 1)
sonntag = montag + timedelta(days=6)
return montag, sonntag
def _ist_im_wiedervorlage_zeitraum(wiedervorlage_datum: date | None,
zeitraum: str | None,
heute: date) -> bool:
"""
Prüft, ob das Wiedervorlage-Datum in den angeforderten Zeitraum
fällt. Ohne Zeitraum (None) ist die Prüfung immer wahr.
Kontakte ohne wiedervorlage_datum fallen bei gesetztem Zeitraum
heraus — auch Kontakte ohne Gesprächsnotizen, da für sie kein
Wiedervorlage-Datum existiert.
"""
if not zeitraum:
return True
if wiedervorlage_datum is None:
return False
if zeitraum == "heute":
return wiedervorlage_datum == heute
if zeitraum == "3tage":
return heute <= wiedervorlage_datum <= heute + timedelta(days=3)
if zeitraum == "ueberfaellig":
return wiedervorlage_datum < heute
if zeitraum == "woche":
montag, sonntag = _naechste_woche_start_und_ende(heute)
return montag <= wiedervorlage_datum <= sonntag
# Unbekannte Zeitraum-Angaben wirken wie kein Filter.
return True
def _sortierschlüssel(eintrag: dict, heute: date) -> tuple:
"""
Priorisierte Sortierung der Tages-Workflow-Liste (Konzept 2.2):
- Prio 0: überfällige Wiedervorlagen (wiedervorlage < heute),
aufsteigend nach Datum
- Prio 1: Kontakte ohne Gesprächsnotizen
- Prio 2: Rest (heute/zukünftig), aufsteigend nach Wiedervorlage;
Notizen ohne Wiedervorlage ans Ende von Prio 2
Innerhalb gleicher Priorität/Datum entscheidet der Kontaktname.
"""
wiedervorlage = eintrag["wiedervorlage_datum"]
hat_notizen = eintrag["letzte_notiz"] is not None
# wiedervorlage ist ein ISO-String (yyyy-MM-dd) und sortiert lexikalisch
# genauso wie das zugehörige date — daher String-Vergleich mit dem
# isoformat() des heutigen Tages.
heute_str = heute.isoformat()
if wiedervorlage is not None and wiedervorlage < heute_str:
prio = 0
elif not hat_notizen:
prio = 1
else:
prio = 2
# Ohne Wiedervorlage ans Ende der Prioritätsgruppe sortieren.
datum_key = wiedervorlage if wiedervorlage is not None else "9999-12-31"
return (prio, datum_key, eintrag["name"])
def get_tages_workflow_list(user_id: int | None = None,
kunden_id: int | None = None,
status: str | None = None,
wiedervorlage: str | None = None) -> list[dict]:
"""
Liefert die Tages-Workflow-Liste: pro Kontaktadresse ein Dictionary
mit den Kontakt- und Projektdaten, der letzten Gesprächsnotiz, dem
aktuell gültigen Wiedervorlage-Datum und dem Erledigt-Status.
Das aktuell gültige Wiedervorlage-Datum ist das wiedervorlage_datum
der jüngsten Gesprächsnotiz, die eines gesetzt hat (Reihenfolge nach
datum/uhrzeit_beginn absteigend). Spätere Notizen ohne Wiedervorlage
heben eine früher gesetzte nicht auf.
Ist user_id gesetzt, werden nur Kontaktadressen von Projekten geliefert,
denen der Nutzer über projekt_users zugeordnet ist (Sichtbarkeits-
Filterung). SysAdmin und AppAdmin übergeben user_id=None und sehen alle.
Filter (alle optional):
- kunden_id: nur Kontaktadressen von Projekten dieses Kunden
- status: 'offen' (nicht erledigte) oder 'erledigt'; alles andere
wirkt wie kein Filter
- wiedervorlage: 'heute', '3tage' (bis heute+3), 'ueberfaellig'
(< heute) oder 'woche' (ISO-Kalenderwoche des heutigen Tages)
"""
heute = date.today()
query = Kontaktadresse.query
if kunden_id is not None:
# Nur Kontaktadressen von Projekten des ausgewählten Kunden.
query = query.filter(
Kontaktadresse.projekt_id.in_(
dbapp.session.query(Projekt.id).filter(
Projekt.kunden_id == kunden_id
)
)
)
if user_id is not None:
# Nur Kontaktadressen von Projekten, denen der Nutzer zugeordnet ist.
query = query.filter(
Kontaktadresse.projekt_id.in_(
dbapp.session.query(ProjektUser.projekt_id).filter(
ProjektUser.user_id == user_id
)
)
)
kontaktadressen = query.all()
eintraege = []
for kontaktadresse in kontaktadressen:
# Gesprächsnotizen des Kontakts, jüngste zuerst. uhrzeit_beginn
# ist ein 'HH:MM'-String und sortiert lexikalisch korrekt.
logs = sorted(
kontaktadresse.call_logs,
key=lambda log: (log.datum or date.min, log.uhrzeit_beginn or ""),
reverse=True,
)
letzte_notiz = logs[0] if logs else None
# Aktuell gültiges Wiedervorlage-Datum: jüngste Notiz, die
# eines gesetzt hat.
wiedervorlage_datum = next(
(log.wiedervorlage_datum for log in logs
if log.wiedervorlage_datum is not None),
None,
)
ist_erledigt = kontaktadresse.is_erledigt()
# Status-Filter: 'offen' bzw. 'erledigt'; alles andere wirkt
# wie kein Filter.
if status == "offen" and ist_erledigt:
continue
if status == "erledigt" and not ist_erledigt:
continue
# Wiedervorlage-Zeitraum-Filter.
if not _ist_im_wiedervorlage_zeitraum(wiedervorlage_datum,
wiedervorlage, heute):
continue
projekt = kontaktadresse.projekt
eintrag = {
'kontaktadresse_id': kontaktadresse.id,
'projekt_id': kontaktadresse.projekt_id,
'projekt_name': projekt.projektname if projekt else None,
'kunden_id': projekt.kunden_id if projekt else None,
'kunden_name': projekt.kunde.name if projekt and projekt.kunde else None,
'anrede': kontaktadresse.anrede,
'name': kontaktadresse.name,
'firma': kontaktadresse.firma,
'position': kontaktadresse.position,
'telefon': kontaktadresse.telefon,
'email': kontaktadresse.email,
'is_erledigt': ist_erledigt,
# Text der letzten Gesprächsnotiz (leerer String, falls der
# jüngste Log keinen Notiztext enthält) bzw. None, wenn der
# Kontakt noch keine Gesprächsnotiz hat.
'letzte_notiz': (letzte_notiz.notiz or "") if letzte_notiz else None,
'letzte_notiz_datum': letzte_notiz.datum.isoformat() if letzte_notiz and letzte_notiz.datum else None,
'letzte_notiz_uhrzeit': letzte_notiz.uhrzeit_beginn if letzte_notiz else None,
# Tags der letzten Gesprächsnotiz (für die Listen-Anzeige);
# leere Liste, wenn der Kontakt noch keine Notiz hat.
'letzte_notiz_tags': (
[clt.tag.to_dict() for clt in letzte_notiz.tags_rel]
if letzte_notiz else []
),
'wiedervorlage_datum': wiedervorlage_datum.isoformat() if wiedervorlage_datum else None,
}
eintraege.append(eintrag)
# Priorisierte Sortierung: überfällige Wiedervorlagen zuerst, dann
# Kontakte ohne Gesprächsnotizen, dann der Rest nach Wiedervorlage.
eintraege.sort(key=lambda eintrag: _sortierschlüssel(eintrag, heute))
return eintraege