From a86caf5364eb3c239b4eb59f7276e9155bce9524 Mon Sep 17 00:00:00 2001 From: Wild Karl-Heinz Date: Fri, 9 Oct 2026 21:35:58 +0200 Subject: [PATCH] =?UTF-8?q?README:=20Datentypen=20im=20Layout,=20validate-?= =?UTF-8?q?Typen=20und=20Slack-Benachrichtigung=20ausf=C3=BChrlich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 129 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2a6bb07..ce5c6d8 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ Was die einzelnen Teile tun: | `DataPipeline.params.fetch(:out, 'tmp')` | Parameter von der Kommandozeile: `--out=verzeichnis` | | `lookup …, key: 'code', unique: true` | lädt die Länder einmal als Hash nach Ländercode; kein Zugriff je Zeile | | `on_error :collect, to: …` | jede Zeile, die in einem Schritt scheitert, kommt mit `_step` und `_error` in die Fehlerdatei | -| `validate 'kundennr' => { … }` | Regeln je Spalte: `required`, `format`, `max`, `min`, `in`, `type` | +| `validate 'kundennr' => { … }` | Regeln je Spalte: `required`, `format`, `max`, `min`, `in`, `type` (`:integer`, `:decimal`, `:date`; siehe 2.7) | | `validate { \|row\| … }` | eigene Regel: ein Text (oder ein Array) ist ein Fehler, `nil` heisst in Ordnung | | `filter` | inaktive Kunden fallen weg, ohne Fehler | | `map` | baut die Zielzeile; `laender.fetch` kann nicht scheitern, weil `validate` das Land schon geprüft hat | @@ -178,7 +178,9 @@ Alle Beispiele liegen in [examples/](examples/), arbeiten mit den Daten in `exam `test/examples/doc_examples_test.rb` führt sie aus und prüft, dass der Code hier mit den Dateien übereinstimmt. **Datei mit Satzarten** – Rechnungen aus verschachteltem JSON werden zu einem Kopfsatz mit Positionssätzen; Datei- -und Schlusssatz zählen die Datensätze. Das Layout legt Reihenfolge, Typ und Länge der Felder je Satzart fest (2.9). +und Schlusssatz zählen die Datensätze. Das Layout legt Reihenfolge, Typ und Länge der Felder je Satzart fest; die +Datentypen `AN`, `N`, `DAT`, `TIM` und ihre Prüfung stehen in 2.9. Das Layout formatiert nicht, deshalb bringt das +`map` Datum und Beträge selbst ins Zielformat (`delete('-')`, `format('%.2f', …)`). ```ruby @@ -787,7 +789,10 @@ CSV- oder JSON-Pipeline lädt keines dieser Gems. Wer ein Plugin nicht braucht, **Validierung** – `validate` prüft Spalten nach Regeln, nach einem Layout (2.9) und/oder mit einem Block. Eine verletzte Regel löst `DataPipeline::ValidationError` aus und läuft damit über `on_error`. Leere Werte prüft nur -`required`. Mit `warn: true` bleibt die Zeile drin, es gibt eine Warnung und `stats.warnings` zählt mit (ins Log +`required`. Regeln je Spalte: `required: true`, `max:`/`min:` (Länge als Text), `format:` (Regex auf den Text), +`in:` (erlaubte Werte) und `type:` – `:integer` (ganze Zahl, Basis 10), `:decimal` (alles, was Rubys `Float()` +annimmt) oder `:date` (`Date` oder ISO-Text `YYYY-MM-DD`). Die Feldtypen eines Layouts (`AN`, `N`, `DAT`, `TIM`) und +ihr Unterschied zu diesen Regeln stehen in 2.9. Mit `warn: true` bleibt die Zeile drin, es gibt eine Warnung und `stats.warnings` zählt mit (ins Log kommen höchstens 100); `ignore: %w[FELD]` lässt bekannte Lücken aus. ```ruby @@ -820,6 +825,82 @@ Der Logger schreibt `start` und `finish` (Info), jede verworfene Zeile (Warnung) on_finish { |stats| DataPipeline::Slack.new(channel: '#etl').send_message("Buchungen: #{stats}") unless stats.ok? } ``` +**Slack einrichten** – `DataPipeline::Slack` schickt Text über die Slack-API `chat.postMessage`. Voraussetzungen: + +1. In Slack eine App anlegen (api.slack.com/apps), unter *OAuth & Permissions* den Bot-Scope `chat:write` vergeben, + die App im Workspace installieren und den *Bot User OAuth Token* (`xoxb-…`) kopieren. +2. Die App in den Ziel-Channel einladen (`/invite @appname`), sonst antwortet Slack mit `not_in_channel`. +3. Das Gem `http` in das Gemfile der Anwendung aufnehmen (siehe 2.6); die Klasse wird erst beim ersten Zugriff geladen. +4. Den Token als Umgebungsvariable `SLACK_TOKEN` setzen, z. B. in der `.env` der Anwendung (das Gem lädt keine + `.env`), oder direkt mit `token:` übergeben. Den Token nicht ins Pipeline-Skript schreiben. + +```ruby +slack = DataPipeline::Slack.new # Token aus ENV['SLACK_TOKEN'], Channel '#general' +slack = DataPipeline::Slack.new(channel: '#etl') # anderer Channel (Name oder ID wie 'C0123ABCD') +slack = DataPipeline::Slack.new(channel: '#etl', token: ENV.fetch('SLACK_ETL_TOKEN')) # eigener Token je Anwendung + +slack.send_message('Test aus der Pipeline', verbose: true) # verbose: Antwort von Slack als JSON auf $stdout +``` + +| Option | Default | Bedeutung | +| --- | --- | --- | +| `token:` | `ENV['SLACK_TOKEN']` | Bot-Token; fehlt er, bricht schon `Slack.new` mit `ArgumentError` ab | +| `channel:` | `'#general'` | Ziel-Channel als Name oder ID | + +`send_message(text, verbose: false)` liefert die Antwort von Slack als Hash. Meldet Slack einen Fehler (z. B. +`invalid_auth`, `channel_not_found`, `not_in_channel`), wirft die Methode eine Exception mit diesem Code. In +`on_finish` wird sie nur geloggt (`on_finish: RuntimeError: Slack#send_message failed: …`), der Lauf und sein +Exit-Code bleiben unverändert. Es gibt keine Wiederholung bei Netzwerkfehlern. + +Ein ausführlicheres Beispiel mit Status, Zählern, Laufzeit und den ersten verworfenen Zeilen. Der Notifier wird vor +der Pipeline angelegt; ein fehlender Token fällt so schon beim Start auf und nicht erst nach dem Lauf. Slack setzt +`*fett*`, `_kursiv_` und `` `code` `` um. + +```ruby +slack = DataPipeline::Slack.new(channel: '#etl') + +DataPipeline.new do + # … read, Schritte, write … + + on_finish do |stats| + next if stats.ok? && stats.warnings.zero? # nur melden, wenn etwas zu tun ist + + status = if stats.error then ':red_circle: abgebrochen' + elsif stats.rejected.positive? then ':large_orange_circle: mit verworfenen Zeilen' + else ':large_yellow_circle: mit Warnungen' + end + lines = ["*Buchungsexport #{stats.version}* #{status}#{' (Dry-Run)' if stats.dry_run}", + "Zeitraum #{params[:from]} – #{params[:till]}, Start #{stats.started_at.strftime('%d.%m.%Y %H:%M')}, " \ + "Dauer #{stats.duration}s", + "gelesen #{stats.read} · geschrieben #{stats.written} · verworfen #{stats.rejected} · Warnungen #{stats.warnings}"] + lines << "Fehler: `#{stats.error.class}: #{stats.error.message}`" if stats.error + stats.errors.first(5).each do |e| + lines << "• #{e[:step]}: #{e[:error]} – Beleg #{e[:row]['belnr']}" + end + lines << "… und #{stats.rejected - 5} weitere, siehe data/fehler.csv" if stats.rejected > 5 + + slack.send_message(lines.join("\n")) + end +end.run +``` + +So sieht die Meldung in Slack aus: + +```text +Buchungsexport 1.0 🟠 mit verworfenen Zeilen +Zeitraum 2025-11-01 – 2025-11-30, Start 01.12.2025 06:00, Dauer 4.213s +gelesen 1250 · geschrieben 1243 · verworfen 7 · Warnungen 0 +• validate#1: konto: missing – Beleg 5100000123 +• validate#1: dmbtr: not decimal – Beleg 5100000124 +• validate#2: Betrag 0 – Beleg 5100000130 +• validate#1: shkzg: not in ["S", "H"] – Beleg 5100000131 +• validate#1: konto: format /\A\d+\z/ – Beleg 5100000140 +… und 2 weitere, siehe data/fehler.csv +``` + +`stats.errors` enthält höchstens 100 Einträge als Hash mit `step`, `error` und der Eingangszeile `row`. `params` gibt +es im Pipeline-Block; ausserhalb heisst es `DataPipeline.params`. + **Inkrementelle Läufe** – `watermark` merkt sich je Name den höchsten Wert einer Spalte in einer JSON-Datei. Die Pipeline verfolgt jede Quellzeile und speichert den neuen Wert nur nach einem erfolgreichen Lauf (nicht nach Abbruch, nicht im Dry-Run). Zahlen als Text werden numerisch verglichen. Mit `limit` sollte die Quelle nach der Spalte @@ -888,12 +969,52 @@ validate LAYOUT, warn: true # Pflicht, Länge, write container(:csv, file: 'x.ok', layout: LAYOUT, csv: { headers: false, col_sep: '|' }) ``` -| Typ | Prüfung | +**Felddefinition** – jedes Feld ist ein Array `[Name, Typ, Länge, Pflicht]`: + +| Teil | Bedeutung | | --- | --- | -| `AN` | Länge als Text | -| `N` | Zahl; Länge `10` = Stellen, `13.2` = 11 Vor- und 2 Nachkommastellen | -| `DAT` | `YYYYMMDD`, gültiges Datum | -| `TIM` | `HHMMSS` | +| Name | Spaltenname; in der Zeile ohne Rücksicht auf Gross-/Kleinschreibung, als String oder Symbol gesucht | +| Typ | `AN`, `N`, `DAT` oder `TIM` (siehe unten), Gross-/Kleinschreibung egal; fehlt er (`nil`), gilt `AN` | +| Länge | Zahl oder bei Nachkommastellen ein String (`'11.2'`); fehlt sie, wird die Länge nicht geprüft | +| Pflicht | `true` = darf nicht leer sein; fehlt sie, ist das Feld optional | + +**Datentypen** – geprüft wird immer der Wert als Text (`to_s`). Ein leerer Wert (`nil`, `''`, nur Leerzeichen) +wird nur auf Pflicht geprüft, nie auf den Typ. + +| Typ | Inhalt | Gültig | Länge | Meldung | +| --- | --- | --- | --- | --- | +| `AN` | Text (alphanumerisch) | jeder Wert | höchstens `Länge` Zeichen | `NAME: longer than 10` | +| `N` | Zahl | `\A[+-]?\d+(\.\d+)?\z`: Ziffern, optional Vorzeichen und Punkt als Dezimaltrenner | `10` = höchstens 10 Stellen, keine Nachkommastellen; `'11.2'` = höchstens 9 Vor- und 2 Nachkommastellen (Gesamtlänge 11 wie in SAP); das Vorzeichen zählt nicht, führende Nullen schon | `NAME: not a number`, `… more than 9 digits`, `… more than 2 decimals` | +| `DAT` | Datum | genau 8 Ziffern `YYYYMMDD`, gültiges Kalenderdatum | wird nicht geprüft | `NAME: not a date YYYYMMDD` | +| `TIM` | Uhrzeit | genau 6 Ziffern `HHMMSS`, 00:00:00 bis 23:59:59 | wird nicht geprüft | `NAME: not a time HHMMSS` | + +Ein unbekannter Typ wird wie `AN` behandelt. Das Layout **prüft nur, es formatiert nicht**: Werte werden beim +Schreiben unverändert ausgegeben, ohne Auffüllen mit Nullen oder Leerzeichen. Zahlen, Datum und Uhrzeit müssen +deshalb vorher in einem `map` als Text in das Zielformat gebracht werden: + +```ruby +map do |row| + row.merge('datum' => row['datum'].strftime('%Y%m%d'), # Date -> '20261009'; Date#to_s wäre '2026-10-09' + 'zeit' => row['zeit'].strftime('%H%M%S'), + 'betrag' => format('%.2f', row['betrag']), # BigDecimal#to_s wäre '0.1e2', Float evtl. '1.0e-05' + 'lfdnr' => format('%06d', row['lfdnr'])) # führende Nullen, falls das Zielsystem sie verlangt +end +``` + +**Verhältnis zu `validate`-Regeln** – Layout-Typen beschreiben das Textformat einer Datei mit festen Feldern, die +Regel `type:` von `validate` (2.7) prüft dagegen, ob sich ein Wert in einen Ruby-Typ umwandeln lässt. Beides lässt +sich kombinieren, z. B. Rohdaten mit Regeln prüfen und die fertigen Sätze gegen das Layout. + +| Layout-Typ | ähnliche `validate`-Regel | Unterschied | +| --- | --- | --- | +| `AN` + Länge | `max: 10` | gleich | +| `N` | `type: :integer` bzw. `type: :decimal` | `:decimal` nimmt jedes Ruby-`Float` an (auch `1e3`, `.5`); `N` nur Ziffern mit optionalem Punkt, dafür mit Stellenzahl | +| `DAT` | `type: :date` | `:date` erwartet ISO 8601 (`2026-10-09`) oder ein `Date`, `DAT` dagegen `20261009` | +| `TIM` | `format: /\A\d{6}\z/` | `TIM` prüft zusätzlich Stunden, Minuten und Sekunden | + +**Optionen** – `Layout.new(type_field: 'satyp')` nimmt die Satzart aus dieser Spalte statt aus dem ersten Wert der +Zeile; eine Satzart, die nicht im Layout steht, ist ein Fehler (`record type "X" not in layout (…)`). +`strict: false` lässt beim Schreiben Felder zu, die nicht im Layout stehen (sie werden weggelassen). Beim Schreiben stehen die Werte in der Reihenfolge des Layouts, fehlende Felder bleiben leer, ein unbekanntes Feld bricht mit `DataPipeline::LayoutError` ab. Eine falsch benannte SQL-Spalte verschiebt also nicht mehr still alle