[{"data":1,"prerenderedAt":1257},["ShallowReactive",2],{"post-de-rksv-qr-code-auslesen":3},{"post":4,"translated":242,"related":1244,"translations":1251},{"id":5,"title":6,"body":7,"cover":1220,"date":1221,"description":1222,"extension":1223,"key":1224,"keywords":1225,"meta":1231,"navigation":242,"path":1232,"projects":1233,"seo":1235,"slug":1224,"stem":1236,"tags":1237,"updated":1241,"__hash__":1242,"minutes":397,"words":1243},"blog/blog/de/rksv-qr-code-auslesen.md","RKSV-QR-Code auslesen: Kassabons am Handy mit ML Kit und Parsern",{"type":8,"value":9,"toc":1206},"minimark",[10,14,19,28,31,34,57,66,70,73,83,176,179,732,739,744,751,771,778,781,785,788,791,836,840,843,854,880,1053,1057,1068,1071,1074,1078,1081,1111,1115,1118,1121,1125,1128,1132,1164,1168,1185,1188,1202],[11,12,13],"p",{},"Der Beleg-Scanner von MoneyKoi liest österreichische Kassabons direkt am Handy. Der RKSV-QR-Code liefert Summe und Datum, ohne dass OCR nötig ist. ML Kit erkennt den Text, ein eigener Parser pro Kette liest die Positionen, und ein Abgleich prüft, ob sie die Summe ergeben. Das alles ist regelbasiert, ohne LLM, und das Foto geht an keinen KI-Dienst.",[15,16,18],"h2",{"id":17},"warum-am-gerät-und-ohne-llm","Warum am Gerät und ohne LLM",[11,20,21,22,27],{},"MoneyKoi ist ein Haushaltsbuch ohne Bank-Login und ohne Cloud-KI (",[23,24,26],"a",{"href":25},"/projects/moneykoi","zum Projekt","). Der Scanner muss deshalb am Gerät laufen und auch im Flugmodus funktionieren.",[11,29,30],{},"Eine Zeit lang gab es in der App einen experimentellen, optionalen KI-Scanner mit einem Sprachmodell, das ebenfalls am Gerät lief (Gemma über LiteRT-LM). Ende September 2026 habe ich ihn wieder entfernt. Geblieben ist der regelbasierte Weg. Sein Vorteil: Jeder Fehler lässt sich auf eine Regel zurückführen, und eine Korrektur bekommt einen Unit-Test, der nach dem Kassabon benannt ist, an dem der Fehler aufgefallen ist.",[11,32,33],{},"Der Ablauf hat drei Schritte:",[35,36,37,45,51],"ol",{},[38,39,40,44],"li",{},[41,42,43],"strong",{},"Summe finden."," Die Kamera läuft, OCR sucht Geschäft, Summe und Datum. Ein Feld rastet ein, sobald es sicher genug und in zwei Bildern gleich erkannt wurde. Findet die App einen RKSV-Code, ist dieser Schritt sofort erledigt, ganz ohne OCR.",[38,46,47,50],{},[41,48,49],{},"Positionen erfassen."," Der Nutzer fotografiert den Bon, bei langen Bons in mehreren Teilen. Jedes Foto wird mit dem Parser der jeweiligen Kette gelesen, überlappende Zeilen zwischen zwei Fotos werden zusammengeführt.",[38,52,53,56],{},[41,54,55],{},"Prüfen."," Alles erscheint zur Kontrolle, bevor eine Buchung entsteht. Gespeichert wird eine Buchung pro Position.",[11,58,59],{},[60,61],"img",{"alt":62,"height":63,"src":64,"width":65},"Scanner von MoneyKoi im Demo-Modus: Auf dem Foto eines Beispiel-Kassabons sind die sieben erkannten Positionen orange umrahmt",711,"/images/blog/rksv-qr-code-auslesen/scanner-item-lines.webp",320,[15,67,69],{"id":68},"den-rksv-qr-code-auslesen","Den RKSV-QR-Code auslesen",[11,71,72],{},"Seit 2017 tragen Belege aus österreichischen Registrierkassen nach der Registrierkassensicherheitsverordnung (RKSV) einen signierten, maschinenlesbaren Code. In meiner Sammlung echter Kassabons ist er praktisch auf jedem: Supermarkt, Franchise-Nahversorger, Diskonter. Für einen Scanner ist er Gold wert, weil er auch dann stimmt, wenn die OCR den gedruckten Text falsch liest. Ganz verlassen kann man sich trotzdem nicht darauf: Bei McDonald's stehen im Code nur Nullbeträge, die Summe kommt dort aus dem Text.",[11,74,75,76,82],{},"Der Inhalt ist eine Zeichenkette, deren Teile durch Unterstriche getrennt sind (",[23,77,81],{"href":78,"rel":79},"https://github.com/BMF-RKSV-Technik/at-registrierkassen-mustercode",[80],"nofollow","Mustercode und Spezifikation des BMF","):",[84,85,86,99],"table",{},[87,88,89],"thead",{},[90,91,92,96],"tr",{},[93,94,95],"th",{},"Teil",[93,97,98],{},"Inhalt",[100,101,102,114,122,130,141,152,160,168],"tbody",{},[90,103,104,111],{},[105,106,107],"td",{},[108,109,110],"code",{},"R1-AT0",[105,112,113],{},"Formatkennung",[90,115,116,119],{},[105,117,118],{},"Kassen-ID",[105,120,121],{},"Kennung der Registrierkasse",[90,123,124,127],{},[105,125,126],{},"Belegnummer",[105,128,129],{},"fortlaufend pro Kasse",[90,131,132,135],{},[105,133,134],{},"Datum und Uhrzeit",[105,136,137,138],{},"z. B. ",[108,139,140],{},"2026-10-27T10:42:13",[90,142,143,146],{},[105,144,145],{},"fünf Beträge",[105,147,148,149],{},"Bruttobetrag je Steuersatz, z. B. ",[108,150,151],{},"22,15",[90,153,154,157],{},[105,155,156],{},"Umsatzzähler",[105,158,159],{},"verschlüsselt",[90,161,162,165],{},[105,163,164],{},"Zertifikats-Seriennummer",[105,166,167],{},"oft mit der UID-Nummer des Unternehmens",[90,169,170,173],{},[105,171,172],{},"Signatur des Vorbelegs, Signatur",[105,174,175],{},"für die Prüfung der Kette",[11,177,178],{},"Die Summe des Bons ist die Summe der fünf Beträge. Ein Geschäftsname steht nicht drin. Vereinfacht sieht der Parser so aus:",[180,181,186],"pre",{"className":182,"code":183,"language":184,"meta":185,"style":185},"language-dart shiki shiki-themes github-dark-default","final _rksvDate = RegExp(r'^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}');\nfinal _atu = RegExp(r'ATU\\d{8}');\n\nRksvData? parseRksv(String payload) {\n  final parts = payload.split('_'); // leading '_' gives an empty parts[0]\n  if (parts.length \u003C 12 || !parts[1].startsWith('R1-AT')) return null;\n\n  // Find the date instead of trusting a fixed index.\n  final d = [3, 4, 5, 6].firstWhere(\n    (i) => i \u003C parts.length && _rksvDate.hasMatch(parts[i]),\n    orElse: () => -1,\n  );\n  if (d \u003C 2 || d + 7 >= parts.length) return null;\n\n  final brackets = parts\n      .sublist(d + 1, d + 6)\n      .map((s) => double.tryParse(s.replaceAll(',', '.')) ?? 0.0);\n\n  final certSerial = parts[d + 7]; // d + 6 is the encrypted turnover counter\n  return RksvData(\n    date: DateTime.parse(parts[d]),\n    total: brackets.fold(0.0, (a, b) => a + b),\n    cashRegisterId: parts[d - 2],\n    atu: _atu.firstMatch(certSerial)?.group(0),\n  );\n}\n","dart","",[108,187,188,218,237,244,265,294,344,349,355,395,424,446,452,487,492,505,533,581,586,609,620,639,671,689,721,726],{"__ignoreMap":185},[189,190,193,197,201,204,208,211,215],"span",{"class":191,"line":192},"line",1,[189,194,196],{"class":195},"suJrU","final",[189,198,200],{"class":199},"sZEs4"," _rksvDate ",[189,202,203],{"class":195},"=",[189,205,207],{"class":206},"sFSAA"," RegExp",[189,209,210],{"class":199},"(",[189,212,214],{"class":213},"s9uIt","r'^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}'",[189,216,217],{"class":199},");\n",[189,219,221,223,226,228,230,232,235],{"class":191,"line":220},2,[189,222,196],{"class":195},[189,224,225],{"class":199}," _atu ",[189,227,203],{"class":195},[189,229,207],{"class":206},[189,231,210],{"class":199},[189,233,234],{"class":213},"r'ATU\\d{8}'",[189,236,217],{"class":199},[189,238,240],{"class":191,"line":239},3,[189,241,243],{"emptyLinePlaceholder":242},true,"\n",[189,245,247,250,253,257,259,262],{"class":191,"line":246},4,[189,248,249],{"class":206},"RksvData",[189,251,252],{"class":195},"?",[189,254,256],{"class":255},"sc3cj"," parseRksv",[189,258,210],{"class":199},[189,260,261],{"class":206},"String",[189,263,264],{"class":199}," payload) {\n",[189,266,268,271,274,276,279,282,284,287,290],{"class":191,"line":267},5,[189,269,270],{"class":195},"  final",[189,272,273],{"class":199}," parts ",[189,275,203],{"class":195},[189,277,278],{"class":199}," payload.",[189,280,281],{"class":255},"split",[189,283,210],{"class":199},[189,285,286],{"class":213},"'_'",[189,288,289],{"class":199},"); ",[189,291,293],{"class":292},"sH3jZ","// leading '_' gives an empty parts[0]\n",[189,295,297,300,303,306,309,312,315,318,321,324,327,329,332,335,338,341],{"class":191,"line":296},6,[189,298,299],{"class":195},"  if",[189,301,302],{"class":199}," (parts.length ",[189,304,305],{"class":195},"\u003C",[189,307,308],{"class":206}," 12",[189,310,311],{"class":195}," ||",[189,313,314],{"class":195}," !",[189,316,317],{"class":199},"parts[",[189,319,320],{"class":206},"1",[189,322,323],{"class":199},"].",[189,325,326],{"class":255},"startsWith",[189,328,210],{"class":199},[189,330,331],{"class":213},"'R1-AT'",[189,333,334],{"class":199},")) ",[189,336,337],{"class":195},"return",[189,339,340],{"class":206}," null",[189,342,343],{"class":199},";\n",[189,345,347],{"class":191,"line":346},7,[189,348,243],{"emptyLinePlaceholder":242},[189,350,352],{"class":191,"line":351},8,[189,353,354],{"class":292},"  // Find the date instead of trusting a fixed index.\n",[189,356,358,360,363,365,368,371,374,377,379,382,384,387,389,392],{"class":191,"line":357},9,[189,359,270],{"class":195},[189,361,362],{"class":199}," d ",[189,364,203],{"class":195},[189,366,367],{"class":199}," [",[189,369,370],{"class":206},"3",[189,372,373],{"class":199},", ",[189,375,376],{"class":206},"4",[189,378,373],{"class":199},[189,380,381],{"class":206},"5",[189,383,373],{"class":199},[189,385,386],{"class":206},"6",[189,388,323],{"class":199},[189,390,391],{"class":255},"firstWhere",[189,393,394],{"class":199},"(\n",[189,396,398,401,404,407,409,412,415,418,421],{"class":191,"line":397},10,[189,399,400],{"class":199},"    (i) ",[189,402,403],{"class":195},"=>",[189,405,406],{"class":199}," i ",[189,408,305],{"class":195},[189,410,411],{"class":199}," parts.length ",[189,413,414],{"class":195},"&&",[189,416,417],{"class":199}," _rksvDate.",[189,419,420],{"class":255},"hasMatch",[189,422,423],{"class":199},"(parts[i]),\n",[189,425,427,430,433,436,438,441,443],{"class":191,"line":426},11,[189,428,429],{"class":199},"    orElse",[189,431,432],{"class":195},":",[189,434,435],{"class":199}," () ",[189,437,403],{"class":195},[189,439,440],{"class":195}," -",[189,442,320],{"class":206},[189,444,445],{"class":199},",\n",[189,447,449],{"class":191,"line":448},12,[189,450,451],{"class":199},"  );\n",[189,453,455,457,460,462,465,467,469,472,475,478,481,483,485],{"class":191,"line":454},13,[189,456,299],{"class":195},[189,458,459],{"class":199}," (d ",[189,461,305],{"class":195},[189,463,464],{"class":206}," 2",[189,466,311],{"class":195},[189,468,362],{"class":199},[189,470,471],{"class":195},"+",[189,473,474],{"class":206}," 7",[189,476,477],{"class":195}," >=",[189,479,480],{"class":199}," parts.length) ",[189,482,337],{"class":195},[189,484,340],{"class":206},[189,486,343],{"class":199},[189,488,490],{"class":191,"line":489},14,[189,491,243],{"emptyLinePlaceholder":242},[189,493,495,497,500,502],{"class":191,"line":494},15,[189,496,270],{"class":195},[189,498,499],{"class":199}," brackets ",[189,501,203],{"class":195},[189,503,504],{"class":199}," parts\n",[189,506,508,511,514,517,519,522,525,527,530],{"class":191,"line":507},16,[189,509,510],{"class":199},"      .",[189,512,513],{"class":255},"sublist",[189,515,516],{"class":199},"(d ",[189,518,471],{"class":195},[189,520,521],{"class":206}," 1",[189,523,524],{"class":199},", d ",[189,526,471],{"class":195},[189,528,529],{"class":206}," 6",[189,531,532],{"class":199},")\n",[189,534,536,538,541,544,546,549,552,555,558,561,563,566,568,571,573,576,579],{"class":191,"line":535},17,[189,537,510],{"class":199},[189,539,540],{"class":255},"map",[189,542,543],{"class":199},"((s) ",[189,545,403],{"class":195},[189,547,548],{"class":206}," double",[189,550,551],{"class":199},".",[189,553,554],{"class":255},"tryParse",[189,556,557],{"class":199},"(s.",[189,559,560],{"class":255},"replaceAll",[189,562,210],{"class":199},[189,564,565],{"class":213},"','",[189,567,373],{"class":199},[189,569,570],{"class":213},"'.'",[189,572,334],{"class":199},[189,574,575],{"class":195},"??",[189,577,578],{"class":206}," 0.0",[189,580,217],{"class":199},[189,582,584],{"class":191,"line":583},18,[189,585,243],{"emptyLinePlaceholder":242},[189,587,589,591,594,596,599,601,603,606],{"class":191,"line":588},19,[189,590,270],{"class":195},[189,592,593],{"class":199}," certSerial ",[189,595,203],{"class":195},[189,597,598],{"class":199}," parts[d ",[189,600,471],{"class":195},[189,602,474],{"class":206},[189,604,605],{"class":199},"]; ",[189,607,608],{"class":292},"// d + 6 is the encrypted turnover counter\n",[189,610,612,615,618],{"class":191,"line":611},20,[189,613,614],{"class":195},"  return",[189,616,617],{"class":206}," RksvData",[189,619,394],{"class":199},[189,621,623,626,628,631,633,636],{"class":191,"line":622},21,[189,624,625],{"class":199},"    date",[189,627,432],{"class":195},[189,629,630],{"class":206}," DateTime",[189,632,551],{"class":199},[189,634,635],{"class":255},"parse",[189,637,638],{"class":199},"(parts[d]),\n",[189,640,642,645,647,650,653,655,658,661,663,666,668],{"class":191,"line":641},22,[189,643,644],{"class":199},"    total",[189,646,432],{"class":195},[189,648,649],{"class":199}," brackets.",[189,651,652],{"class":255},"fold",[189,654,210],{"class":199},[189,656,657],{"class":206},"0.0",[189,659,660],{"class":199},", (a, b) ",[189,662,403],{"class":195},[189,664,665],{"class":199}," a ",[189,667,471],{"class":195},[189,669,670],{"class":199}," b),\n",[189,672,674,677,679,681,684,686],{"class":191,"line":673},23,[189,675,676],{"class":199},"    cashRegisterId",[189,678,432],{"class":195},[189,680,598],{"class":199},[189,682,683],{"class":195},"-",[189,685,464],{"class":206},[189,687,688],{"class":199},"],\n",[189,690,692,695,697,700,703,706,708,710,713,715,718],{"class":191,"line":691},24,[189,693,694],{"class":199},"    atu",[189,696,432],{"class":195},[189,698,699],{"class":199}," _atu.",[189,701,702],{"class":255},"firstMatch",[189,704,705],{"class":199},"(certSerial)",[189,707,252],{"class":195},[189,709,551],{"class":199},[189,711,712],{"class":255},"group",[189,714,210],{"class":199},[189,716,717],{"class":206},"0",[189,719,720],{"class":199},"),\n",[189,722,724],{"class":191,"line":723},25,[189,725,451],{"class":199},[189,727,729],{"class":191,"line":728},26,[189,730,731],{"class":199},"}\n",[11,733,734,735,738],{},"Die Suche nach dem Datum statt eines festen Index ist eine Lehre aus zwei Fehlern. Die erste Version las das Datum aus ",[108,736,737],{},"parts[3]"," – das ist die Belegnummer – und summierte die Beträge um eins verschoben. Die Summe stimmte trotzdem, zufällig, weil das falsch geparste Datum als 0 zählte. Die zweite Version las die Zertifikatsnummer an der Position des verschlüsselten Umsatzzählers.",[740,741,743],"h3",{"id":742},"wer-hat-den-bon-ausgestellt","Wer hat den Bon ausgestellt",[11,745,746,747,750],{},"Die Zertifikats-Seriennummer enthält oft die UID-Nummer des Unternehmens (",[108,748,749],{},"ATU"," plus acht Ziffern). Fehlt sie dort, liest der Scanner die gedruckte UID-Nummer am Bon. Sie gilt für eine ganze Kette, ist also ein guter Schlüssel. MoneyKoi löst sie in dieser Reihenfolge auf:",[35,752,753,756,759,768],{},[38,754,755],{},"eine fest eingebaute Tabelle mit UID-Nummern großer Ketten,",[38,757,758],{},"gelernte Zuordnungen am Gerät: Zertifikat, UID-Nummer, Kassen-ID,",[38,760,761,762,767],{},"eine Abfrage beim EU-Dienst ",[23,763,766],{"href":764,"rel":765},"https://ec.europa.eu/taxation_customs/vies/",[80],"VIES",", deren Ergebnis für den nächsten Scan gelernt wird,",[38,769,770],{},"der Geschäftsname aus der OCR.",[11,772,773,774,777],{},"Die erste Version der Tabelle war aus dem Gedächtnis befüllt. Die Nummern für SPAR, HOFER, LIDL, dm, BIPA und OMV darin lehnte VIES ab, keine davon konnte je auf einem echten Bon stehen. Heute kommt eine Nummer nur in die Tabelle, wenn sie von einem echten Bon, aus dem Impressum oder aus dem Firmenbuch stammt und VIES sie bestätigt. Bei der VIES-Abfrage selbst gab es einen ähnlichen Fehler: Sie schickte nur die acht Ziffern statt ",[108,775,776],{},"U"," plus Ziffern, und VIES antwortete darauf immer mit „ungültig“. Die dritte Stufe hat also anfangs nie funktioniert.",[11,779,780],{},"Franchise-Geschäfte wie ADEG haben die UID-Nummer des jeweiligen Betreibers, nicht die der Kette. Dort hilft nur das Lernen pro Geschäft.",[15,782,784],{"id":783},"ocr-mit-ml-kit-aus-textzeilen-werden-bonzeilen","OCR mit ML Kit: aus Textzeilen werden Bonzeilen",[11,786,787],{},"ML Kit liefert Textzeilen mit Begrenzungsrahmen, aber keine Tabelle. Der erste Schritt ist deshalb Geometrie: Zeilen, die sich vertikal zu mindestens 55 % überlappen, werden zu einer Bonzeile zusammengefasst. Ein Preis zählt nur, wenn er im rechten Teil des erkannten Texts steht – gemessen an der Breite des Texts, nicht des Fotos, damit ein Bon, der das Bild nicht füllt, trotzdem funktioniert. Ergibt die Gruppierung nicht die Summe, versucht der Scanner es mit 45 % und 65 % erneut und nimmt das Ergebnis, das am nächsten liegt.",[11,789,790],{},"Danach wird normalisiert, und hier steckt viel Kleinarbeit:",[792,793,794,802,812,830],"ul",{},[38,795,796,799,800,551],{},[41,797,798],{},"Minuszeichen."," Bons und OCR liefern Bindestrich, Gedankenstrich, Minus und weitere Varianten. Alles wird vor dem Parsen zu ",[108,801,683],{},[38,803,804,807,808,811],{},[41,805,806],{},"Dezimaltrennzeichen."," BILLA und ADEG drucken auf dem Thermopapier Punkte (",[108,809,810],{},"25.21","), andere Ketten Kommas. Beides muss gehen.",[38,813,814,817,818,821,822,825,826,829],{},[41,815,816],{},"Zerrissene Preise."," ",[108,819,820],{},"2,9 9"," und ",[108,823,824],{},"2 ,99"," werden wieder zu ",[108,827,828],{},"2,99",", ohne Mengenangaben oder Jahreszahlen zu verkleben.",[38,831,832,835],{},[41,833,834],{},"Verschobene Spalten."," Bei LIDL sitzt die Preisspalte etwa eine halbe Zeile tiefer als die Namen. Ohne Korrektur verrutscht jeder Preis um eine Position.",[15,837,839],{"id":838},"ein-parser-pro-kette","Ein Parser pro Kette",[11,841,842],{},"Eine Registry wählt anhand des Geschäfts einen Parser. SPAR, BILLA, LIDL, ADEG, TEDi, Smyths und OBI haben eigene; HOFER läuft derzeit über den SPAR-Parser, weil ich noch keine HOFER-Bons zum Testen habe. Für alle anderen Geschäfte gibt es einen generischen Parser, der nur mit der Geometrie arbeitet.",[11,844,845,846,849,850,853],{},"Die Ketten unterscheiden sich in Details, die über richtig und falsch entscheiden. Das größte Thema sind Rabatte. Bei SPAR ist ",[108,847,848],{},"Aktionsersparnis 1,00"," nur ein Hinweis, der Preis darüber ist schon reduziert. ",[108,851,852],{},"App-Joker 25% -1,25"," dagegen wird abgezogen. Weil OCR das Minus gelegentlich verliert, ziehen eindeutige Rabatt-Wörter immer den Betrag ab, egal mit welchem Vorzeichen, nie aber unter null. Ein negativer Betrag ohne Position davor wird verworfen und nie als eigene Position angelegt.",[11,855,856,857,860,861,864,865,373,868,871,872,875,876,879],{},"Ein zweites Beispiel ist die Liste der Störwörter, mit der Zeilen der Kartenzahlung herausgefiltert werden. Sie wurde anfangs mit ",[108,858,859],{},"contains"," geprüft. ",[108,862,863],{},"STAN"," (eine Autorisierungsnummer) steckt aber in ",[108,866,867],{},"LAUGENSTAN",[108,869,870],{},"BAR"," in ",[108,873,874],{},"BARILLA",", und ",[108,877,878],{},"%"," in jedem Rabatt. Heute gilt eine Wortgrenze:",[180,881,883],{"className":182,"code":882,"language":184,"meta":185,"style":185},"final _noise = \u003CString, RegExp>{};\n\nbool isShopNoise(String rowText, Iterable\u003CString> keywords) {\n  final upper = rowText.toUpperCase();\n  return keywords.any((k) => _noise\n      .putIfAbsent(k, () => RegExp('\\\\b${RegExp.escape(k)}\\\\b'))\n      .hasMatch(upper));\n}\n\n// isShopNoise('STAN ZAHLUNG', ['STAN']) -> true\n// isShopNoise('LAUGENSTAN', ['STAN'])   -> false\n",[108,884,885,910,914,939,957,975,1026,1035,1039,1043,1048],{"__ignoreMap":185},[189,886,887,889,892,894,897,899,901,904,907],{"class":191,"line":192},[189,888,196],{"class":195},[189,890,891],{"class":199}," _noise ",[189,893,203],{"class":195},[189,895,896],{"class":195}," \u003C",[189,898,261],{"class":206},[189,900,373],{"class":199},[189,902,903],{"class":206},"RegExp",[189,905,906],{"class":195},">",[189,908,909],{"class":199},"{};\n",[189,911,912],{"class":191,"line":220},[189,913,243],{"emptyLinePlaceholder":242},[189,915,916,919,922,924,926,929,932,934,936],{"class":191,"line":239},[189,917,918],{"class":206},"bool",[189,920,921],{"class":255}," isShopNoise",[189,923,210],{"class":199},[189,925,261],{"class":206},[189,927,928],{"class":199}," rowText, ",[189,930,931],{"class":206},"Iterable",[189,933,305],{"class":199},[189,935,261],{"class":206},[189,937,938],{"class":199},"> keywords) {\n",[189,940,941,943,946,948,951,954],{"class":191,"line":246},[189,942,270],{"class":195},[189,944,945],{"class":199}," upper ",[189,947,203],{"class":195},[189,949,950],{"class":199}," rowText.",[189,952,953],{"class":255},"toUpperCase",[189,955,956],{"class":199},"();\n",[189,958,959,961,964,967,970,972],{"class":191,"line":267},[189,960,614],{"class":195},[189,962,963],{"class":199}," keywords.",[189,965,966],{"class":255},"any",[189,968,969],{"class":199},"((k) ",[189,971,403],{"class":195},[189,973,974],{"class":199}," _noise\n",[189,976,977,979,982,985,987,989,991,994,997,1000,1003,1005,1007,1010,1012,1015,1018,1020,1023],{"class":191,"line":296},[189,978,510],{"class":199},[189,980,981],{"class":255},"putIfAbsent",[189,983,984],{"class":199},"(k, () ",[189,986,403],{"class":195},[189,988,207],{"class":206},[189,990,210],{"class":199},[189,992,993],{"class":213},"'",[189,995,996],{"class":195},"\\\\",[189,998,999],{"class":213},"b",[189,1001,1002],{"class":199},"${",[189,1004,903],{"class":206},[189,1006,551],{"class":199},[189,1008,1009],{"class":255},"escape",[189,1011,210],{"class":199},[189,1013,1014],{"class":206},"k",[189,1016,1017],{"class":199},")}",[189,1019,996],{"class":195},[189,1021,1022],{"class":213},"b'",[189,1024,1025],{"class":199},"))\n",[189,1027,1028,1030,1032],{"class":191,"line":346},[189,1029,510],{"class":199},[189,1031,420],{"class":255},[189,1033,1034],{"class":199},"(upper));\n",[189,1036,1037],{"class":191,"line":351},[189,1038,731],{"class":199},[189,1040,1041],{"class":191,"line":357},[189,1042,243],{"emptyLinePlaceholder":242},[189,1044,1045],{"class":191,"line":397},[189,1046,1047],{"class":292},"// isShopNoise('STAN ZAHLUNG', ['STAN']) -> true\n",[189,1049,1050],{"class":191,"line":426},[189,1051,1052],{"class":292},"// isShopNoise('LAUGENSTAN', ['STAN'])   -> false\n",[15,1054,1056],{"id":1055},"der-abgleich-die-summe-muss-stimmen","Der Abgleich: die Summe muss stimmen",[11,1058,1059,1060,1063,1064,1067],{},"Nach jedem Foto vergleicht ein Reconciler die Summe der Positionen mit der eingerasteten Summe. Stimmt sie nicht, sucht er die eine Korrektur eines einzelnen Preises, die die Summe exakt trifft: eine typische OCR-Verwechslung bei einer Ziffer oder ein verrutschtes Komma (",[108,1061,1062],{},"199"," → ",[108,1065,1066],{},"1,99","). Gibt es genau eine solche Korrektur, wird sie angewendet und die Position farbig markiert. Gibt es mehrere oder keine, ändert er nichts, und die Prüfansicht zeigt die Differenz an.",[11,1069,1070],{},"Eine erfundene Position „Fehlende Artikel“, die die Lücke füllt, gibt es bewusst nicht. Nutzer wollten lieber einen sichtbaren Fehler als eine Zeile, die das Problem versteckt.",[11,1072,1073],{},"Noch genauer wird der Abgleich mit der Steuertabelle, die unten auf dem Bon steht. Sie nennt zu jedem Betrag den Steuersatz, und die Positionen tragen bei den meisten Ketten einen Buchstaben für ihren Satz. Damit lässt sich jede Steuergruppe einzeln prüfen: Zwei Fehler in zwei Gruppen heilen beide, und zwei Fehler, die sich in der Gesamtsumme aufheben, fallen trotzdem auf. Die Reihenfolge der fünf Beträge im QR-Code nutzt der Scanner erst, nachdem sie sich auf dem Gerät mindestens dreimal mit einer gedruckten Tabelle gedeckt hat, ohne einen Widerspruch.",[15,1075,1077],{"id":1076},"kategorien-ohne-ki","Kategorien ohne KI",[11,1079,1080],{},"Jede Position braucht eine Kategorie, und „Sonstiges“ als automatischer Ausweg ist ausgeschlossen. Die Kette der Versuche, leicht vereinfacht:",[35,1082,1083,1086,1089,1092,1095,1105,1108],{},[38,1084,1085],{},"Was im Scan selbst gewählt wurde.",[38,1087,1088],{},"Gelernte Korrekturen am Gerät.",[38,1090,1091],{},"Eine Schlagwort-Tabelle.",[38,1093,1094],{},"Die eigenen letzten 300 Buchungen.",[38,1096,1097,1098,1100,1101,1104],{},"Unscharfe Suche mit einer gewichteten Levenshtein-Distanz, in der typische OCR-Verwechslungen (",[108,1099,717],{}," statt ",[108,1102,1103],{},"O",") weniger kosten als andere Fehler.",[38,1106,1107],{},"Ein Lebensmittel-Wörterbuch.",[38,1109,1110],{},"Der Steuersatz: 10 % heißt bei den Supermärkten Lebensmittel. 20 % bleibt bewusst offen, weil sich Alkohol und Haushaltsartikel diesen Satz teilen.",[15,1112,1114],{"id":1113},"gemessen-an-echten-kassabons","Gemessen an echten Kassabons",[11,1116,1117],{},"Ohne Messung ist jede Regel ein Bauchgefühl. Deshalb gibt es eine private Sammlung echter Kassabon-Fotos, jeweils mit einer Datei, die das richtige Ergebnis festhält. Ein Testlauf wertet jeden Bon aus und vergleicht pro Kette. Eine Sperre sorgt dafür, dass keine Änderung einen Bon kaputt macht, der vorher funktioniert hat.",[11,1119,1120],{},"Zum schnellen Iterieren läuft die Auswertung auf dem Mac mit Apple Vision statt ML Kit. Dass ein Ersatz nicht das Original ist, habe ich dabei auf die harte Tour gelernt: Vier Runden Regeln für den Kopf des Bons waren auf die Zeilenformen von Vision abgestimmt, und mit ML Kit scheiterte genau dieser Teil öfter. Seitdem gibt es eine zweite Messung mit dem echten ML Kit im Android-Emulator, mit eigener Basislinie. Eine Änderung muss beide bestehen.",[15,1122,1124],{"id":1123},"datenschutz","Datenschutz",[11,1126,1127],{},"Texterkennung und Barcode-Scan laufen am Gerät, das Foto und sein Text gehen an keinen KI-Dienst. Zwei Ausnahmen gehören erwähnt: Kennt die App die UID-Nummer eines Geschäfts nicht, fragt sie bei VIES nach – übertragen wird nur die Nummer des Unternehmens. Und mit dem Cloud-Plan wird das Foto als Beleg im Buch gespeichert, in der EU.",[15,1129,1131],{"id":1130},"grenzen","Grenzen",[792,1133,1134,1140,1146,1152,1158],{},[38,1135,1136,1139],{},[41,1137,1138],{},"Nur lateinische Schrift."," Die verwendete Texterkennung liest keine japanischen Bons, obwohl die App Japanisch kann.",[38,1141,1142,1145],{},[41,1143,1144],{},"Nur Währungen mit zwei Nachkommastellen."," Für ein Buch in Yen oder Forint ist der Scanner gesperrt.",[38,1147,1148,1151],{},[41,1149,1150],{},"Deutsche Kassen aus der Spezifikation."," Den QR-Code deutscher Kassen (TSE) liest der Scanner, gebaut nach der Spezifikation. An einem echten deutschen Bon getestet ist er noch nicht.",[38,1153,1154,1157],{},[41,1155,1156],{},"Lücken in der Abdeckung."," Für HOFER und PENNY habe ich keine Test-Bons; HOFER läuft über den SPAR-Parser, und das ist eine Annahme.",[38,1159,1160,1163],{},[41,1161,1162],{},"Schlechtes Papier."," Verblasste Thermobons und Ziffern, die die Texterkennung verliert, sind die häufigsten Gründe, warum eine Position fehlt oder falsch ist.",[15,1165,1167],{"id":1166},"zum-mitnehmen","Zum Mitnehmen",[792,1169,1170,1173,1176,1179,1182],{},[38,1171,1172],{},"Wenn ein Beleg einen signierten Code trägt, ist der die Wahrheit. OCR füllt nur, was der Code nicht enthält.",[38,1174,1175],{},"Felder in einem Code werden per Muster gefunden, nicht per festem Index.",[38,1177,1178],{},"Eine Korrektur nur anwenden, wenn sie eindeutig ist. Sonst die Differenz zeigen.",[38,1180,1181],{},"Stammdaten wie UID-Nummern nie aus dem Gedächtnis eintragen, sondern an der Quelle prüfen.",[38,1183,1184],{},"Ein Messwerkzeug ist nur so gut wie seine Nähe zur echten Umgebung. Ein Proxy braucht eine zweite Messung mit dem Original.",[1186,1187],"hr",{},[11,1189,1190,1191,1196,1197,551],{},"MoneyKoi gibt es ",[23,1192,1195],{"href":1193,"rel":1194},"https://play.google.com/store/apps/details?id=com.psyren.moneykoi",[80],"als Android-App bei Google Play",". Mehr Infos auf ",[23,1198,1201],{"href":1199,"rel":1200},"https://www.moneykoi.com",[80],"moneykoi.com",[1203,1204,1205],"style",{},"html pre.shiki code .suJrU, html code.shiki .suJrU{--shiki-default:#FF7B72}html pre.shiki code .sZEs4, html code.shiki .sZEs4{--shiki-default:#E6EDF3}html pre.shiki code .sFSAA, html code.shiki .sFSAA{--shiki-default:#79C0FF}html pre.shiki code .s9uIt, html code.shiki .s9uIt{--shiki-default:#A5D6FF}html pre.shiki code .sc3cj, html code.shiki .sc3cj{--shiki-default:#D2A8FF}html pre.shiki code .sH3jZ, html code.shiki .sH3jZ{--shiki-default:#8B949E}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":185,"searchDepth":220,"depth":220,"links":1207},[1208,1209,1212,1213,1214,1215,1216,1217,1218,1219],{"id":17,"depth":220,"text":18},{"id":68,"depth":220,"text":69,"children":1210},[1211],{"id":742,"depth":239,"text":743},{"id":783,"depth":220,"text":784},{"id":838,"depth":220,"text":839},{"id":1055,"depth":220,"text":1056},{"id":1076,"depth":220,"text":1077},{"id":1113,"depth":220,"text":1114},{"id":1123,"depth":220,"text":1124},{"id":1130,"depth":220,"text":1131},{"id":1166,"depth":220,"text":1167},"/images/blog/rksv-qr-code-auslesen/cover.de.webp","2026-10-31T00:00:00.000Z","RKSV-QR-Code auslesen, Positionen mit ML Kit und eigenen Parsern erkennen, die Summe prüfen: wie der Beleg-Scanner von MoneyKoi am Handy arbeitet, ohne LLM.","md","rksv-qr-code-auslesen",[1226,1227,1228,1229,1230],"RKSV QR Code auslesen","Kassabon scannen App","ML Kit Texterkennung Flutter","Beleg Scanner Flutter","Kassenbon OCR",{},"/blog/de/rksv-qr-code-auslesen",[1234],"moneykoi",{"title":6,"description":1222},"blog/de/rksv-qr-code-auslesen",[1238,1239,1240],"Flutter","ML Kit","OCR",null,"dRDVwRsBuz41qRMvJqadwNw71oDTfOL4IYvpex5YxGw",1953,[1245,1248],{"slug":1246,"title":1247,"minutes":357},"offline-first-flutter-supabase","Offline-First mit Flutter und Supabase: Outbox statt Ladebalken",{"slug":1249,"title":1250,"minutes":351},"supabase-rls-multi-tenant","Supabase RLS für Multi-Tenant-Apps: ein Header, zwei Regeln",[1252,1254],{"code":1253,"slug":1224},"de",{"code":1255,"slug":1256},"en","receipt-scanner-flutter-ml-kit",1793537120193]