README neu gegliedert, Beispiel für einen produktiven Lauf, Fehlersuche und Umstiegshinweise
This commit is contained in:
@@ -25,6 +25,9 @@ Framework `datapipeline` (Gem). Neueste Version oben.
|
|||||||
vor dem Schreiben, eine Meldung bricht mit `DataPipeline::ReconcileError` ab
|
vor dem Schreiben, eine Meldung bricht mit `DataPipeline::ReconcileError` ab
|
||||||
- Laufhistorie: `history 'datei'` bzw. `etl --history=datei`, eine JSON-Zeile je Lauf; `DataPipeline.history` liest
|
- Laufhistorie: `history 'datei'` bzw. `etl --history=datei`, eine JSON-Zeile je Lauf; `DataPipeline.history` liest
|
||||||
sie; `name` des Laufs (über `etl` der Skriptname), `Stats#to_h`
|
sie; `name` des Laufs (über `etl` der Skriptname), `Stats#to_h`
|
||||||
|
- README neu gegliedert (Überblick, Beispiele, Pipelines schreiben, Plugins, Layouts, Betrieb, Umstieg,
|
||||||
|
Entwicklung) mit Inhaltsverzeichnis, Fehlersuche und Umstiegshinweisen; neues Beispiel
|
||||||
|
`examples/buchungen_export.rb` für einen produktiven Lauf
|
||||||
|
|
||||||
## 2.0.0 – 2026-10-08
|
## 2.0.0 – 2026-10-08
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#
|
||||||
|
# Produktiver Lauf: Buchungen prüfen, Summe und Saldo abgleichen, wiederholbar in die Buchhaltungs-Datenbank
|
||||||
|
# und als CSV in Windows-1252 für das Zielsystem schreiben; mit Sperre, Laufhistorie und Slack bei Problemen
|
||||||
|
#
|
||||||
|
# bin/etl run examples/buchungen_export.rb [--out=tmp] [--dry-run]
|
||||||
|
#
|
||||||
|
require 'fileutils'
|
||||||
|
require 'datapipeline'
|
||||||
|
|
||||||
|
DATA = File.expand_path('data', __dir__)
|
||||||
|
out = DataPipeline.params.fetch(:out, 'tmp')
|
||||||
|
FileUtils.mkdir_p(out)
|
||||||
|
|
||||||
|
# Zugangsdaten stehen nicht im Skript, sondern in der Verbindungsdatei (Default: config/connections.yml)
|
||||||
|
DataPipeline.connections_file = File.expand_path('config/connections.yml', __dir__)
|
||||||
|
|
||||||
|
# Slack nur, wenn ein Token gesetzt ist; so fällt ein fehlender Token schon vor dem Lauf auf
|
||||||
|
slack = DataPipeline::Slack.new(channel: '#etl') if ENV['SLACK_TOKEN']
|
||||||
|
|
||||||
|
DataPipeline.new do
|
||||||
|
name 'buchungen_export'
|
||||||
|
lock "#{out}/.buchungen.lock" # läuft schon ein Export, bricht dieser ab (Exit-Code 3)
|
||||||
|
history "#{out}/runs.ndjson" # eine JSON-Zeile je Lauf, auch nach Abbruch
|
||||||
|
|
||||||
|
read container(:csv, file: "#{DATA}/buchungen.csv", csv: { col_sep: ';' })
|
||||||
|
|
||||||
|
# 1. prüfen: ohne on_error bricht ein Fehler den Lauf ab, es entsteht keine halbe Buchung
|
||||||
|
validate 'belnr' => { required: true, format: /\A\d{10}\z/ },
|
||||||
|
'konto' => { required: true, format: /\A\d+\z/ },
|
||||||
|
'betrag' => { required: true, type: :decimal }
|
||||||
|
|
||||||
|
# 2. umbauen mit Kurzschritten und einem map
|
||||||
|
default 'text' => 'ohne Text'
|
||||||
|
rename 'text' => 'buchungstext'
|
||||||
|
cast 'buzei' => :integer, 'datum' => :date, 'betrag' => :decimal
|
||||||
|
map do |row|
|
||||||
|
row.merge('shkzg' => row['betrag'].negative? ? 'H' : 'S', 'dmbtr' => format('%.2f', row['betrag'].abs))
|
||||||
|
end
|
||||||
|
select %w[belnr buzei datum konto shkzg dmbtr betrag buchungstext]
|
||||||
|
|
||||||
|
# 3. abgleichen, bevor etwas geschrieben wird
|
||||||
|
totals 'betrag'
|
||||||
|
reconcile do |stats|
|
||||||
|
quelle, ergebnis = stats.total('betrag', :read), stats.total('betrag')
|
||||||
|
next "Summe geändert: Quelle #{DataPipeline.decimal(quelle)}, Ergebnis #{DataPipeline.decimal(ergebnis)}" if quelle != ergebnis
|
||||||
|
|
||||||
|
"Soll und Haben ungleich, Saldo #{DataPipeline.decimal(ergebnis)}" unless ergebnis.zero?
|
||||||
|
end
|
||||||
|
|
||||||
|
# 4. schreiben: Upsert macht den Lauf wiederholbar; die CSV-Datei im Zeichensatz des Zielsystems
|
||||||
|
write connection(:buchhaltung)
|
||||||
|
write container(:csv, file: "#{out}/buchungen_sap.csv", encoding: 'Windows-1252', csv: { col_sep: ';' },
|
||||||
|
columns: %w[belnr buzei datum konto shkzg dmbtr buchungstext])
|
||||||
|
|
||||||
|
on_finish do |stats|
|
||||||
|
puts "#{stats.name}: #{stats}"
|
||||||
|
slack&.send_message("*#{stats.name}*: #{stats}") unless stats.ok?
|
||||||
|
end
|
||||||
|
end.run
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Verbindungen der Beispiele. Die Datei wird mit ERB gelesen: Zugangsdaten und Pfade kommen aus der Umgebung,
|
||||||
|
# bei Postgres z. B. password: <%= ENV['PDS_PASSWORD'] %>
|
||||||
|
buchhaltung:
|
||||||
|
type: sqlite
|
||||||
|
database: <%= ENV.fetch('BUCHHALTUNG_DB', 'tmp/buchhaltung.sqlite3') %>
|
||||||
|
table: buchungen
|
||||||
|
create: true
|
||||||
|
upsert: [belnr, buzei]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
belnr;buzei;datum;konto;betrag;text
|
||||||
|
5100000001;1;2026-10-01;1020;1250.00;Zahlung Müller GmbH
|
||||||
|
5100000001;2;2026-10-01;1100;-1250.00;Zahlung Müller GmbH
|
||||||
|
5100000002;1;2026-10-02;6500;380.50;Büromaterial
|
||||||
|
5100000002;2;2026-10-02;1170;29.30;Vorsteuer
|
||||||
|
5100000002;3;2026-10-02;2000;-409.80;Papeterie Zürcher AG
|
||||||
|
5100000003;1;2026-10-03;3200;99.95;
|
||||||
|
5100000003;2;2026-10-03;1020;-99.95;Gutschrift
|
||||||
|
@@ -4,15 +4,15 @@ require 'open3'
|
|||||||
require 'fileutils'
|
require 'fileutils'
|
||||||
|
|
||||||
#
|
#
|
||||||
# the examples of README.md section 1: the code in the documentation is the file,
|
# the examples of README.md section 2: the code in the documentation is the file,
|
||||||
# and every example runs and produces the documented result
|
# and every example runs and produces the documented result
|
||||||
#
|
#
|
||||||
class DocExamplesTest < PipelineTest
|
class DocExamplesTest < PipelineTest
|
||||||
DOC = File.read(File.join(ROOT, 'README.md'))
|
DOC = File.read(File.join(ROOT, 'README.md'))
|
||||||
EXAMPLES = DOC.scan(/<!-- beispiel: (\S+) -->\n```ruby\n(.*?)\n```/m)
|
EXAMPLES = DOC.scan(/<!-- beispiel: (\S+) -->\n```ruby\n(.*?)\n```/m)
|
||||||
|
|
||||||
def run_example dir, name, *args
|
def run_example dir, name, *args, env: {}
|
||||||
env = { 'BUNDLE_GEMFILE' => File.join(ROOT, 'Gemfile') }
|
env = { 'BUNDLE_GEMFILE' => File.join(ROOT, 'Gemfile') }.merge(env)
|
||||||
Open3.capture3(env, RbConfig.ruby, File.join(ROOT, 'bin/etl'), 'run', File.join(ROOT, name), "--out=#{dir}", *args,
|
Open3.capture3(env, RbConfig.ruby, File.join(ROOT, 'bin/etl'), 'run', File.join(ROOT, name), "--out=#{dir}", *args,
|
||||||
chdir: dir)
|
chdir: dir)
|
||||||
end
|
end
|
||||||
@@ -20,7 +20,7 @@ class DocExamplesTest < PipelineTest
|
|||||||
def test_documented_code_equals_files
|
def test_documented_code_equals_files
|
||||||
assert_equal %w[examples/kunden_import.rb examples/rechnungen_satzarten.rb examples/bewegungen_inkrementell.rb
|
assert_equal %w[examples/kunden_import.rb examples/rechnungen_satzarten.rb examples/bewegungen_inkrementell.rb
|
||||||
examples/eigenes_plugin.rb examples/rest_lesen.rb examples/rest_schreiben.rb
|
examples/eigenes_plugin.rb examples/rest_lesen.rb examples/rest_schreiben.rb
|
||||||
examples/rest_zu_rest.rb], EXAMPLES.map(&:first)
|
examples/rest_zu_rest.rb examples/buchungen_export.rb], EXAMPLES.map(&:first)
|
||||||
EXAMPLES.each do |file, code|
|
EXAMPLES.each do |file, code|
|
||||||
assert_equal File.read(File.join(ROOT, file)).rstrip, code.rstrip, "#{file} differs from README.md"
|
assert_equal File.read(File.join(ROOT, file)).rstrip, code.rstrip, "#{file} differs from README.md"
|
||||||
end
|
end
|
||||||
@@ -152,4 +152,33 @@ class DocExamplesTest < PipelineTest
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_buchungen_export
|
||||||
|
with_tmpdir do |dir|
|
||||||
|
env = { 'BUCHHALTUNG_DB' => "#{dir}/buchhaltung.sqlite3", 'SLACK_TOKEN' => nil }
|
||||||
|
out, err, status = run_example(dir, 'examples/buchungen_export.rb', '--dry-run', env:)
|
||||||
|
assert_equal 0, status.exitstatus, err
|
||||||
|
refute File.exist?("#{dir}/buchhaltung.sqlite3")
|
||||||
|
|
||||||
|
2.times do
|
||||||
|
out, err, status = run_example(dir, 'examples/buchungen_export.rb', env:)
|
||||||
|
assert_equal 0, status.exitstatus, err
|
||||||
|
end
|
||||||
|
assert_includes out, 'buchungen_export: read=7 written=7 rejected=0 betrag=0/0'
|
||||||
|
|
||||||
|
db = DataPipeline::SqliteStorage.new(database: "#{dir}/buchhaltung.sqlite3", query: 'select * from buchungen order by belnr, buzei')
|
||||||
|
rows = db.read
|
||||||
|
db.close
|
||||||
|
assert_equal 7, rows.size # upsert: the second run updates instead of inserting
|
||||||
|
assert_equal({ 'belnr' => '5100000002', 'buzei' => 3, 'datum' => '2026-10-02', 'konto' => '2000', 'shkzg' => 'H',
|
||||||
|
'dmbtr' => '409.80', 'betrag' => -409.8, 'buchungstext' => 'Papeterie Zürcher AG' }, rows[4])
|
||||||
|
assert_equal 'ohne Text', rows[5]['buchungstext']
|
||||||
|
|
||||||
|
csv = File.read("#{dir}/buchungen_sap.csv", encoding: 'Windows-1252').encode('UTF-8')
|
||||||
|
assert_includes csv, "5100000001;1;2026-10-01;1020;S;1250.00;Zahlung Müller GmbH\n"
|
||||||
|
history = DataPipeline.history("#{dir}/runs.ndjson", name: 'buchungen_export')
|
||||||
|
assert_equal [true, true, true], history.map { |run| run['ok'] }
|
||||||
|
assert_equal [true, false, false], history.map { |run| run['dry_run'] }
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user