MHD per Texterkennung einlesen
In der Chargenzeile beim Einlagern gibt es einen Knopf "MHD scannen". Er oeffnet die Texterkennung von VisionKit; erkannte Daten erscheinen als Vorschlag und werden erst uebernommen, wenn man sie antippt. Bewusst kein automatisches Uebernehmen: Eine Fehllesung beim MHD faellt sonst erst auf, wenn die Ware bereits falsch einsortiert ist. Der Parser deckt die Schreibweisen ab, die auf Verpackungen vorkommen - Tagesdaten (12.09.2026, 2026-09-12, 12/09/2026, 12.09.26) und Monatsangaben (09/2026, 09.2026, 2026-09, 09/26). Erkennt der Aufdruck nur Monat und Jahr, stellt sich die Eingabe sichtbar auf Monat/Jahr um. Damit aus Chargennummern und EAN-Codes keine Phantasiedaten werden, greifen mehrere Einschraenkungen: Muster mit Tag werden zuerst geprueft, ein bereits erkannter Textabschnitt wird nicht erneut ausgewertet, Monate ausserhalb 1-12 entfallen und das Jahr muss im Fenster von fuenf Jahren rueckwaerts bis dreissig vorwaerts liegen. Der Knopf erscheint nur, wenn das Geraet Texterkennung unterstuetzt (Neural Engine); sonst bleibt die Eingabe von Hand. Getestet: Der Parser wurde eigenstaendig uebersetzt und gegen 14 Faelle geprueft, alle korrekt - darunter, dass "12.09.2026" nicht als Monatsangabe gelesen wird, dass eine EAN keine Treffer erzeugt und dass unplausible Jahre und Monate verworfen werden. Der iOS-Geraetebuild laeuft fehlerfrei durch. Die Erkennung an einer echten Verpackung habe ich nicht selbst ausprobiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
94
ios/Sources/BestBeforeText.swift
Normal file
94
ios/Sources/BestBeforeText.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
|
||||
/// Erkennt Mindesthaltbarkeitsdaten in Text, wie er auf Verpackungen steht.
|
||||
///
|
||||
/// Aufdrucke sind uneinheitlich: mal "12.09.2026", mal "09/26", mal
|
||||
/// "mind. haltbar bis: 2026-09-12". Gefundene Treffer werden dem Benutzer zur
|
||||
/// Auswahl vorgelegt und nie ungefragt uebernommen – eine Fehllesung beim MHD
|
||||
/// faellt sonst erst auf, wenn die Ware schon falsch einsortiert ist.
|
||||
enum BestBeforeText {
|
||||
|
||||
struct Match: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let date: Date
|
||||
/// "day" oder "month" – ein Aufdruck "09/2026" nennt keinen Tag.
|
||||
let precision: String
|
||||
|
||||
var label: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "de_DE")
|
||||
formatter.dateFormat = precision == "month" ? "MM/yyyy" : "dd.MM.yyyy"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
// Reihenfolge ist wichtig: zuerst die Muster mit Tag, damit "12.09.2026"
|
||||
// nicht als Monatsangabe "12.09" missverstanden wird.
|
||||
private static let patterns: [(regex: String, order: [Character], precision: String)] = [
|
||||
(#"(\d{1,2})[./-](\d{1,2})[./-](\d{4})"#, ["d", "m", "y"], "day"),
|
||||
(#"(\d{4})[./-](\d{1,2})[./-](\d{1,2})"#, ["y", "m", "d"], "day"),
|
||||
(#"(\d{1,2})[./-](\d{1,2})[./-](\d{2})(?!\d)"#, ["d", "m", "Y"], "day"),
|
||||
(#"(\d{1,2})[./-](\d{4})"#, ["m", "y"], "month"),
|
||||
(#"(\d{4})[./-](\d{1,2})(?!\d)"#, ["y", "m"], "month"),
|
||||
(#"(\d{1,2})[./-](\d{2})(?!\d)"#, ["m", "Y"], "month"),
|
||||
]
|
||||
|
||||
/// Alle plausiblen Daten aus einem Textstueck, in Fundreihenfolge und ohne Dubletten.
|
||||
static func matches(in text: String) -> [Match] {
|
||||
var found: [Match] = []
|
||||
var belegt: [Range<String.Index>] = []
|
||||
|
||||
for pattern in patterns {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern.regex) else { continue }
|
||||
let range = NSRange(text.startIndex..., in: text)
|
||||
|
||||
for result in regex.matches(in: text, range: range) {
|
||||
guard let treffer = Range(result.range, in: text) else { continue }
|
||||
// Ein Textabschnitt zaehlt nur einmal: sonst liest das Monatsmuster
|
||||
// aus einem bereits erkannten Tagesdatum noch einen zweiten Treffer.
|
||||
if belegt.contains(where: { $0.overlaps(treffer) }) { continue }
|
||||
|
||||
var teile: [Character: Int] = [:]
|
||||
for (index, key) in pattern.order.enumerated() {
|
||||
guard let teil = Range(result.range(at: index + 1), in: text),
|
||||
let zahl = Int(text[teil]) else { continue }
|
||||
teile[key] = zahl
|
||||
}
|
||||
|
||||
guard let datum = build(teile, precision: pattern.precision) else { continue }
|
||||
belegt.append(treffer)
|
||||
found.append(Match(date: datum, precision: pattern.precision))
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private static func build(_ teile: [Character: Int], precision: String) -> Date? {
|
||||
let monat = teile["m"] ?? 0
|
||||
guard (1...12).contains(monat) else { return nil }
|
||||
|
||||
// "Y" ist ein zweistelliges Jahr: 26 -> 2026.
|
||||
let jahr: Int
|
||||
if let vier = teile["y"] {
|
||||
jahr = vier
|
||||
} else if let zwei = teile["Y"] {
|
||||
jahr = 2000 + zwei
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
// Aufdrucke liegen realistisch in diesem Fenster; alles andere ist
|
||||
// vermutlich eine Chargennummer und kein MHD.
|
||||
let aktuell = Calendar.current.component(.year, from: Date())
|
||||
guard (aktuell - 5)...(aktuell + 30) ~= jahr else { return nil }
|
||||
|
||||
var komponenten = DateComponents()
|
||||
komponenten.year = jahr
|
||||
komponenten.month = monat
|
||||
komponenten.day = precision == "month" ? 1 : (teile["d"] ?? 1)
|
||||
|
||||
if precision == "day" {
|
||||
guard let tag = teile["d"], (1...31).contains(tag) else { return nil }
|
||||
}
|
||||
return Calendar.current.date(from: komponenten)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user