summaries.go
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "time" |
| 9 | |
| 10 | "forge.gpl-nas.de/physiobase/internal/befund" |
| 11 | "forge.gpl-nas.de/physiobase/internal/store" |
| 12 | ) |
| 13 | |
| 14 | // Zeilen, die mit diesem Zeichen beginnen, sind Hinweise für die Therapeutin |
| 15 | // und erscheinen nicht im Ausdruck. |
| 16 | const noteMark = "✎" |
| 17 | |
| 18 | // Printable entfernt Hinweiszeilen und daraus entstandene leere Abschnitte. |
| 19 | func Printable(body string) string { |
| 20 | var out []string |
| 21 | for _, line := range strings.Split(body, "\n") { |
| 22 | if strings.HasPrefix(strings.TrimSpace(line), noteMark) { |
| 23 | continue |
| 24 | } |
| 25 | out = append(out, line) |
| 26 | } |
| 27 | // Überschriften ohne Inhalt bis zur nächsten Überschrift entfernen |
| 28 | var cleaned []string |
| 29 | for i := 0; i < len(out); i++ { |
| 30 | if strings.HasPrefix(out[i], "## ") { |
| 31 | j := i + 1 |
| 32 | for j < len(out) && strings.TrimSpace(out[j]) == "" { |
| 33 | j++ |
| 34 | } |
| 35 | if j == len(out) || strings.HasPrefix(out[j], "## ") { |
| 36 | i = j - 1 |
| 37 | continue |
| 38 | } |
| 39 | } |
| 40 | cleaned = append(cleaned, out[i]) |
| 41 | } |
| 42 | return strings.TrimSpace(strings.Join(cleaned, "\n")) |
| 43 | } |
| 44 | |
| 45 | type draft struct{ b strings.Builder } |
| 46 | |
| 47 | func (d *draft) section(title string) { fmt.Fprintf(&d.b, "\n## %s\n\n", title) } |
| 48 | func (d *draft) line(format string, a ...any) { |
| 49 | fmt.Fprintf(&d.b, format+"\n", a...) |
| 50 | } |
| 51 | func (d *draft) note(text string) { d.line("%s %s", noteMark, text) } |
| 52 | func (d *draft) String() string { return strings.TrimSpace(d.b.String()) + "\n" } |
| 53 | |
| 54 | // latestHandoutItems: Übungen des zuletzt bearbeiteten Heimprogramms des Patienten. |
| 55 | func (s *Server) latestHandoutItems(ctx context.Context, pid int64) (store.Handout, []store.HandoutItem, error) { |
| 56 | hs, err := s.st.HandoutsForPatient(ctx, pid) |
| 57 | if err != nil || len(hs) == 0 { |
| 58 | return store.Handout{}, nil, err |
| 59 | } |
| 60 | items, err := s.st.HandoutItems(ctx, hs[0].ID) |
| 61 | return hs[0], items, err |
| 62 | } |
| 63 | |
| 64 | func (d *draft) exercises(h store.Handout, items []store.HandoutItem) { |
| 65 | if len(items) == 0 { |
| 66 | d.line("- ") |
| 67 | d.note("Noch kein Heimprogramm – auf der Patientenseite unter „Heimprogramme“ anlegen.") |
| 68 | return |
| 69 | } |
| 70 | for _, it := range items { |
| 71 | if it.Dosage != "" { |
| 72 | d.line("- **%s** – %s", it.Entry.Title, it.Dosage) |
| 73 | } else { |
| 74 | d.line("- **%s**", it.Entry.Title) |
| 75 | } |
| 76 | } |
| 77 | d.line("") |
| 78 | d.line("Die genaue Anleitung mit Bildern finden Sie im Übungsblatt „%s“.", h.Title) |
| 79 | } |
| 80 | |
| 81 | func safetyNet(d *draft) { |
| 82 | d.section("Wann Sie sich melden sollten") |
| 83 | d.line("- wenn die Beschwerden deutlich stärker werden oder nach Übungen länger als einen Tag schlechter bleiben") |
| 84 | d.line("- bei neuem Taubheitsgefühl, Kribbeln oder Kraftverlust") |
| 85 | d.line("- bei Schwellung, Rötung, Überwärmung oder Fieber") |
| 86 | d.note("Bei Rückenbeschwerden ggf. ergänzen: Taubheit im Gesäß-/Genitalbereich oder Probleme beim Wasserlassen → sofort ärztlich abklären.") |
| 87 | } |
| 88 | |
| 89 | // generalDraft baut die Gesamtzusammenfassung aus dem neuesten Befund, dem |
| 90 | // Heimprogramm und dem Verlauf seit dem ersten Befund. |
| 91 | func (s *Server) generalDraft(ctx context.Context, p store.Patient) (string, error) { |
| 92 | bs, err := s.st.Befunde(ctx, p.ID) |
| 93 | if err != nil { |
| 94 | return "", err |
| 95 | } |
| 96 | h, items, err := s.latestHandoutItems(ctx, p.ID) |
| 97 | if err != nil { |
| 98 | return "", err |
| 99 | } |
| 100 | d := &draft{} |
| 101 | d.note("Entwurf aus den Befunddaten. In einfache Worte bringen; ✎-Zeilen werden nicht gedruckt.") |
| 102 | var last, first befund.Data |
| 103 | if len(bs) > 0 { |
| 104 | last, _ = befund.Parse(bs[0].Data) |
| 105 | first, _ = befund.Parse(bs[len(bs)-1].Data) |
| 106 | } |
| 107 | |
| 108 | d.section("Ihre Beschwerden") |
| 109 | if diag := last.Get("diag"); diag != "" { |
| 110 | d.line("Ärztliche Diagnose: %s", diag) |
| 111 | d.line("") |
| 112 | } |
| 113 | d.line("") |
| 114 | d.note("In 2–3 Sätzen erklären, was los ist und warum. Fachbegriffe übersetzen.") |
| 115 | if h := last.Get("hypAfterExam"); h != "" { |
| 116 | d.note("Ihre Arbeitshypothese: " + oneLine(h)) |
| 117 | } |
| 118 | |
| 119 | if now, worst := last.Get("pain_now"), last.Get("pain_worst"); now != "" || worst != "" { |
| 120 | d.section("Was wir festgestellt haben") |
| 121 | if now != "" { |
| 122 | d.line("- Schmerz heute: %s von 10", now) |
| 123 | } |
| 124 | if worst != "" { |
| 125 | d.line("- Stärkster Schmerz in den letzten 24 Stunden: %s von 10", worst) |
| 126 | } |
| 127 | d.note("Wichtigste Befunde in Alltagssprache ergänzen (Beweglichkeit, Kraft …).") |
| 128 | } |
| 129 | |
| 130 | d.section("Ihre Ziele") |
| 131 | for _, k := range []string{"goal_patient", "goal_agreement"} { |
| 132 | if v := last.Get(k); v != "" { |
| 133 | for _, l := range strings.Split(v, "\n") { |
| 134 | if l = strings.TrimSpace(strings.TrimLeft(l, "-•* ")); l != "" { |
| 135 | d.line("- %s", l) |
| 136 | } |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | for _, r := range last.Tables["psfs"] { |
| 141 | if r["activity"] != "" { |
| 142 | if r["score"] != "" { |
| 143 | d.line("- %s (heute %s von 10 möglich)", r["activity"], r["score"]) |
| 144 | } else { |
| 145 | d.line("- %s", r["activity"]) |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | d.note("Ziele so formulieren, wie der Patient sie selbst sagen würde.") |
| 150 | |
| 151 | d.section("So gehen wir vor") |
| 152 | if plan := last.Get("plan"); plan != "" { |
| 153 | d.note("Therapieplan aus dem Befund: " + oneLine(plan)) |
| 154 | } |
| 155 | d.line("- ") |
| 156 | d.note("Behandlungsschwerpunkte, Anzahl/Häufigkeit der Termine, wann wir neu messen.") |
| 157 | |
| 158 | d.section("Ihr Übungsprogramm für zu Hause") |
| 159 | d.exercises(h, items) |
| 160 | |
| 161 | if len(bs) > 1 { |
| 162 | d.section("Ihr Fortschritt") |
| 163 | if a, b := first.Get("pain_now"), last.Get("pain_now"); a != "" && b != "" { |
| 164 | d.line("- Schmerz: von %s auf %s (von 10)", a, b) |
| 165 | } |
| 166 | if a, b := befund.PSFSMean(first), befund.PSFSMean(last); a != "" && b != "" { |
| 167 | d.line("- Ihre Alltagsaktivitäten: von durchschnittlich %s auf %s (von 10)", a, b) |
| 168 | } |
| 169 | d.note("Was hat sich im Alltag verbessert? Konkrete Beispiele des Patienten aufgreifen.") |
| 170 | } |
| 171 | |
| 172 | safetyNet(d) |
| 173 | return d.String(), nil |
| 174 | } |
| 175 | |
| 176 | // sessionDraft baut die Zusammenfassung einer Behandlung. |
| 177 | func (s *Server) sessionDraft(ctx context.Context, x store.Session) (string, error) { |
| 178 | h, items, err := s.latestHandoutItems(ctx, x.PatientID) |
| 179 | if err != nil { |
| 180 | return "", err |
| 181 | } |
| 182 | d := &draft{} |
| 183 | d.note("Entwurf aus dem Verlaufseintrag. ✎-Zeilen werden nicht gedruckt.") |
| 184 | d.section("Das haben wir heute gemacht") |
| 185 | if x.Goal != "" { |
| 186 | d.line("- %s", x.Goal) |
| 187 | } else { |
| 188 | d.line("- ") |
| 189 | } |
| 190 | if x.Note != "" { |
| 191 | d.note("Ihre Notiz: " + oneLine(x.Note)) |
| 192 | } |
| 193 | d.section("Ihre Übungen bis zum nächsten Termin") |
| 194 | d.exercises(h, items) |
| 195 | d.section("Darauf sollten Sie achten") |
| 196 | d.line("- ") |
| 197 | d.note("z. B. Belastung dosieren, Schmerz bei Übungen bis 3–4 von 10 ist in Ordnung, am Folgetag nicht schlechter.") |
| 198 | d.section("Nächster Termin") |
| 199 | day, _ := time.ParseInLocation("2006-01-02", x.Date, time.Local) |
| 200 | next, found, err := s.st.NextAppointment(ctx, x.PatientID, day.Add(24*time.Hour-time.Second)) |
| 201 | if err != nil { |
| 202 | return "", err |
| 203 | } |
| 204 | if found { |
| 205 | with := "" |
| 206 | if next.TherapistName != "" { |
| 207 | with = " bei " + next.TherapistName |
| 208 | } |
| 209 | d.line("- %s um %s Uhr%s", dayLabel(next.Start), next.Start.Format("15:04"), with) |
| 210 | } else { |
| 211 | d.line("- ") |
| 212 | d.note("Noch kein Folgetermin eingetragen.") |
| 213 | } |
| 214 | return d.String(), nil |
| 215 | } |
| 216 | |
| 217 | func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") } |
| 218 | |
| 219 | // ---------- Handler ---------- |
| 220 | |
| 221 | func (s *Server) createGeneralSummary(w http.ResponseWriter, r *http.Request) { |
| 222 | ctx := r.Context() |
| 223 | pid, ok := pathID(r, "id") |
| 224 | if !ok { |
| 225 | http.NotFound(w, r) |
| 226 | return |
| 227 | } |
| 228 | p, err := s.st.Patient(ctx, pid) |
| 229 | if err != nil { |
| 230 | s.fail(w, r, err) |
| 231 | return |
| 232 | } |
| 233 | body, err := s.generalDraft(ctx, p) |
| 234 | if err != nil { |
| 235 | s.fail(w, r, err) |
| 236 | return |
| 237 | } |
| 238 | id, err := s.st.CreateSummary(ctx, store.Summary{ |
| 239 | PatientID: pid, Kind: "gesamt", |
| 240 | Date: time.Now().Format("2006-01-02"), Title: "Ihre Physiotherapie – Zusammenfassung", Body: body, |
| 241 | }) |
| 242 | if err != nil { |
| 243 | s.fail(w, r, err) |
| 244 | return |
| 245 | } |
| 246 | redirect(w, r, "/zusammenfassung/%d", id) |
| 247 | } |
| 248 | |
| 249 | func (s *Server) createSessionSummary(w http.ResponseWriter, r *http.Request) { |
| 250 | ctx := r.Context() |
| 251 | sid, ok := pathID(r, "id") |
| 252 | if !ok { |
| 253 | http.NotFound(w, r) |
| 254 | return |
| 255 | } |
| 256 | x, err := s.st.Session(ctx, sid) |
| 257 | if err != nil { |
| 258 | s.fail(w, r, err) |
| 259 | return |
| 260 | } |
| 261 | body, err := s.sessionDraft(ctx, x) |
| 262 | if err != nil { |
| 263 | s.fail(w, r, err) |
| 264 | return |
| 265 | } |
| 266 | id, err := s.st.CreateSummary(ctx, store.Summary{ |
| 267 | PatientID: x.PatientID, SessionID: x.ID, Kind: "sitzung", |
| 268 | Date: x.Date, Title: "Ihre Behandlung am " + deDate(x.Date), Body: body, |
| 269 | }) |
| 270 | if err != nil { |
| 271 | s.fail(w, r, err) |
| 272 | return |
| 273 | } |
| 274 | redirect(w, r, "/zusammenfassung/%d", id) |
| 275 | } |
| 276 | |
| 277 | func (s *Server) loadSummary(w http.ResponseWriter, r *http.Request) (store.Summary, store.Patient, bool) { |
| 278 | id, ok := pathID(r, "id") |
| 279 | if !ok { |
| 280 | http.NotFound(w, r) |
| 281 | return store.Summary{}, store.Patient{}, false |
| 282 | } |
| 283 | x, err := s.st.Summary(r.Context(), id) |
| 284 | if err != nil { |
| 285 | s.fail(w, r, err) |
| 286 | return x, store.Patient{}, false |
| 287 | } |
| 288 | p, err := s.st.Patient(r.Context(), x.PatientID) |
| 289 | if err != nil { |
| 290 | s.fail(w, r, err) |
| 291 | return x, p, false |
| 292 | } |
| 293 | return x, p, true |
| 294 | } |
| 295 | |
| 296 | func (s *Server) showSummary(w http.ResponseWriter, r *http.Request) { |
| 297 | x, p, ok := s.loadSummary(w, r) |
| 298 | if !ok { |
| 299 | return |
| 300 | } |
| 301 | log, err := s.st.MailLogFor(r.Context(), x.ID) |
| 302 | if err != nil { |
| 303 | s.fail(w, r, err) |
| 304 | return |
| 305 | } |
| 306 | q := r.URL.Query() |
| 307 | s.render(w, r, "summary", http.StatusOK, map[string]any{ |
| 308 | "S": x, "P": p, "Preview": Printable(x.Body), "Saved": q.Get("gespeichert") == "1", |
| 309 | "MailBlock": s.mailBlockReason(p), "MailLog": log, "Sent": q.Get("gesendet") == "1", "MailError": q.Get("fehler"), |
| 310 | }) |
| 311 | } |
| 312 | |
| 313 | func (s *Server) saveSummary(w http.ResponseWriter, r *http.Request) { |
| 314 | if !limitForm(w, r) { |
| 315 | return |
| 316 | } |
| 317 | x, _, ok := s.loadSummary(w, r) |
| 318 | if !ok { |
| 319 | return |
| 320 | } |
| 321 | x.Title, x.Date, x.Body = formText(r, "title"), validDate(formText(r, "date")), formText(r, "body") |
| 322 | if x.Title == "" { |
| 323 | x.Title = "Zusammenfassung" |
| 324 | } |
| 325 | if err := s.st.UpdateSummary(r.Context(), x); err != nil { |
| 326 | s.fail(w, r, err) |
| 327 | return |
| 328 | } |
| 329 | if r.FormValue("next") == "druck" { |
| 330 | redirect(w, r, "/zusammenfassung/%d/druck", x.ID) |
| 331 | return |
| 332 | } |
| 333 | redirect(w, r, "/zusammenfassung/%d?gespeichert=1", x.ID) |
| 334 | } |
| 335 | |
| 336 | func (s *Server) deleteSummary(w http.ResponseWriter, r *http.Request) { |
| 337 | id, ok := pathID(r, "id") |
| 338 | if !ok { |
| 339 | http.NotFound(w, r) |
| 340 | return |
| 341 | } |
| 342 | pid, err := s.st.DeleteSummary(r.Context(), id) |
| 343 | if err != nil { |
| 344 | s.fail(w, r, err) |
| 345 | return |
| 346 | } |
| 347 | redirect(w, r, "/patient/%d#zusammenfassungen", pid) |
| 348 | } |
| 349 | |
| 350 | func (s *Server) printSummary(w http.ResponseWriter, r *http.Request) { |
| 351 | x, p, ok := s.loadSummary(w, r) |
| 352 | if !ok { |
| 353 | return |
| 354 | } |
| 355 | s.render(w, r, "summary_print", http.StatusOK, map[string]any{"S": x, "P": p, "Body": Printable(x.Body)}) |
| 356 | } |
| 357 | |
| 358 | func deDate(iso string) string { |
| 359 | if t, err := time.Parse("2006-01-02", iso); err == nil { |
| 360 | return t.Format("02.01.2006") |
| 361 | } |
| 362 | return iso |
| 363 | } |
| 364 |