From c4acc8bec7e47398831a4733decde77110f632dd Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 22 Jul 2026 18:10:24 +0200 Subject: [PATCH] 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 --- ios/Sources/APIClient.swift | 33 ++++++++ ios/Sources/BestBeforeText.swift | 94 +++++++++++++++++++++ ios/Sources/CheckInFormView.swift | 29 +++++++ ios/Sources/DateScanView.swift | 136 ++++++++++++++++++++++++++++++ ios/Sources/Models.swift | 106 +++++++++++++++++++++++ 5 files changed, 398 insertions(+) create mode 100644 ios/Sources/BestBeforeText.swift create mode 100644 ios/Sources/DateScanView.swift diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 2ff9b65..2f53332 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -107,6 +107,27 @@ actor APIClient { return try await send(request, as: Product.self) } + func updateProduct(id: Int, _ payload: ProductUpdateRequest) async throws -> Product { + var request = try makeRequest("/products/\(id)", method: "PATCH") + try jsonBody(&request, payload) + return try await send(request, as: Product.self) + } + + // MARK: - Listen + + func shoppingList() async throws -> [ShoppingItem] { + try await send(try makeRequest("/shopping-list"), as: [ShoppingItem].self) + } + + func shoppingGroups() async throws -> [GroupShoppingItem] { + try await send(try makeRequest("/shopping-list/groups"), as: [GroupShoppingItem].self) + } + + func expiring(days: Int? = nil) async throws -> [ExpiringItem] { + let path = days.map { "/expiring?days=\($0)" } ?? "/expiring" + return try await send(try makeRequest(path), as: [ExpiringItem].self) + } + // MARK: - Bestand func lots(productId: Int) async throws -> [Lot] { @@ -124,4 +145,16 @@ actor APIClient { try jsonBody(&request, payload) return try await send(request, as: StockResponse.self) } + + func updateLot(id: Int, _ payload: LotUpdateRequest) async throws -> Lot { + var request = try makeRequest("/lots/\(id)", method: "PATCH") + try jsonBody(&request, payload) + return try await send(request, as: Lot.self) + } + + func deleteLot(id: Int) async throws { + let request = try makeRequest("/lots/\(id)", method: "DELETE") + let (data, response) = try await URLSession.shared.data(for: request) + try check(response, data: data) // 204: kein Rumpf zum Auswerten + } } diff --git a/ios/Sources/BestBeforeText.swift b/ios/Sources/BestBeforeText.swift new file mode 100644 index 0000000..25ea176 --- /dev/null +++ b/ios/Sources/BestBeforeText.swift @@ -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] = [] + + 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) + } +} diff --git a/ios/Sources/CheckInFormView.swift b/ios/Sources/CheckInFormView.swift index 70da6fd..b54d48c 100644 --- a/ios/Sources/CheckInFormView.swift +++ b/ios/Sources/CheckInFormView.swift @@ -1,5 +1,10 @@ import SwiftUI +/// Damit sich eine UUID direkt als sheet(item:) verwenden laesst. +extension UUID: Identifiable { + public var id: UUID { self } +} + extension Date { /// Erster Tag des Monats - so wird eine Monatsangabe an den Server geschickt. var startOfMonth: Date { @@ -77,6 +82,8 @@ struct CheckInFormView: View { /// Gilt fuer den ganzen Vorgang: Auf einer Packung steht entweder ein /// Tagesdatum oder nur Monat/Jahr - nicht beides gemischt. @State private var precision: String = "day" + /// Charge, fuer die gerade das MHD abgescannt wird. + @State private var scanLineId: UUID? @State private var unit: String = "" @State private var locationId: Int? @State private var busy = false @@ -156,6 +163,15 @@ struct CheckInFormView: View { } else { DatePicker("MHD", selection: $line.bestBefore, displayedComponents: .date) } + if DateScanView.isSupported { + Button { + scanLineId = line.id + } label: { + Label("MHD scannen", systemImage: "text.viewfinder") + .font(.callout) + } + .buttonStyle(.borderless) + } } } .padding(.vertical, 2) @@ -181,6 +197,19 @@ struct CheckInFormView: View { .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } + .sheet(item: $scanLineId) { id in + NavigationStack { + DateScanView { datum, erkanntePraezision in + // Nennt der Aufdruck keinen Tag, stellt sich die Eingabe + // sichtbar auf Monat/Jahr um. + if erkanntePraezision == "month" { precision = "month" } + if let index = lines.firstIndex(where: { $0.id == id }) { + lines[index].bestBefore = datum + lines[index].hasDate = true + } + } + } + } .onAppear { // Gibt es ein Gebinde, ist das die naheliegende Eingabe. unit = packageOptionName ?? product.unitName diff --git a/ios/Sources/DateScanView.swift b/ios/Sources/DateScanView.swift new file mode 100644 index 0000000..90f7e1a --- /dev/null +++ b/ios/Sources/DateScanView.swift @@ -0,0 +1,136 @@ +import SwiftUI +import VisionKit + +/// Liest das MHD per Texterkennung von der Verpackung ab. +/// +/// Gefundene Daten werden als Vorschlag angezeigt; uebernommen wird erst, +/// was der Benutzer antippt. +struct DateScanView: View { + var onPick: (Date, String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var matches: [BestBeforeText.Match] = [] + + /// Texterkennung braucht Geraete mit Neural Engine; auf aelteren fehlt sie. + static var isSupported: Bool { + DataScannerViewController.isSupported && DataScannerViewController.isAvailable + } + + var body: some View { + ZStack(alignment: .bottom) { + if Self.isSupported { + TextScannerRepresentable { text in + let neu = BestBeforeText.matches(in: text) + guard !neu.isEmpty else { return } + // Neueste Funde vorn, hoechstens eine Handvoll anzeigen. + for treffer in neu where !matches.contains(where: { + $0.date == treffer.date && $0.precision == treffer.precision + }) { + matches.insert(treffer, at: 0) + } + if matches.count > 5 { matches = Array(matches.prefix(5)) } + } + .ignoresSafeArea() + } else { + Color.black.ignoresSafeArea() + Text("Dieses Gerät unterstützt keine Texterkennung.") + .foregroundStyle(.white) + } + + VStack(spacing: 10) { + if matches.isEmpty { + hinweis("Das Mindesthaltbarkeitsdatum in den Sucher halten.") + } else { + ForEach(matches) { treffer in + Button { + onPick(treffer.date, treffer.precision) + dismiss() + } label: { + HStack { + Image(systemName: "calendar") + Text(treffer.label).font(.headline) + Spacer() + Text("Übernehmen").font(.subheadline) + } + .padding() + .frame(maxWidth: .infinity) + .background(.thinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + } + hinweis("Bitte gegen den Aufdruck prüfen.") + } + } + .padding() + } + .navigationTitle("MHD scannen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } + } + } + + private func hinweis(_ text: String) -> some View { + Text(text) + .font(.caption) + .padding(10) + .frame(maxWidth: .infinity) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + +/// Duenne Huelle um den Text-Scanner von VisionKit. +private struct TextScannerRepresentable: UIViewControllerRepresentable { + var onText: (String) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(onText: onText) } + + func makeUIViewController(context: Context) -> DataScannerViewController { + let controller = DataScannerViewController( + recognizedDataTypes: [.text()], + qualityLevel: .accurate, + recognizesMultipleItems: true, + isHighFrameRateTrackingEnabled: false, + isHighlightingEnabled: true + ) + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ controller: DataScannerViewController, context: Context) { + guard !controller.isScanning else { return } + try? controller.startScanning() + } + + static func dismantleUIViewController(_ controller: DataScannerViewController, coordinator: Coordinator) { + controller.stopScanning() + } + + final class Coordinator: NSObject, DataScannerViewControllerDelegate { + let onText: (String) -> Void + init(onText: @escaping (String) -> Void) { self.onText = onText } + + func dataScanner(_ scanner: DataScannerViewController, + didAdd addedItems: [RecognizedItem], + allItems: [RecognizedItem]) { + handle(allItems) + } + + func dataScanner(_ scanner: DataScannerViewController, + didUpdate updatedItems: [RecognizedItem], + allItems: [RecognizedItem]) { + handle(allItems) + } + + private func handle(_ items: [RecognizedItem]) { + let text = items.compactMap { item -> String? in + if case .text(let erkannt) = item { return erkannt.transcript } + return nil + }.joined(separator: " ") + guard !text.isEmpty else { return } + onText(text) + } + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 1aac6b4..82b2f67 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -211,3 +211,109 @@ struct NewProductRequest: Codable { case groupId = "group_id" } } + +// MARK: - Listen (Einkaufsliste, bald ablaufend) + +struct ShoppingItem: Codable, Identifiable { + let productId: Int + let name: String + let baseUnit: String + let packageSize: Double? + let stock: Double + let minStock: Double + let deficit: Double + + var id: Int { productId } + + enum CodingKeys: String, CodingKey { + case name, stock, deficit + case productId = "product_id" + case baseUnit = "base_unit" + case packageSize = "package_size" + case minStock = "min_stock" + } +} + +struct GroupShoppingItem: Codable, Identifiable { + let groupId: Int + let name: String + let stock: Double + let minStock: Double + let deficit: Double + let unitName: String + let productCount: Int + + var id: Int { groupId } + + enum CodingKeys: String, CodingKey { + case name, stock, deficit + case groupId = "group_id" + case minStock = "min_stock" + case unitName = "unit_name" + case productCount = "product_count" + } +} + +struct ExpiringItem: Codable, Identifiable { + let lotId: Int + let productId: Int + let productName: String + let quantity: Double + let baseUnit: String + let bestBefore: String + let bestBeforePrecision: String? + let daysLeft: Int + let packageSize: Double? + let packageLabel: String? + let unitName: String + let unitFactor: Double + + var id: Int { lotId } + + enum CodingKeys: String, CodingKey { + case quantity + case lotId = "lot_id" + case productId = "product_id" + case productName = "product_name" + case baseUnit = "base_unit" + case bestBefore = "best_before" + case bestBeforePrecision = "best_before_precision" + case daysLeft = "days_left" + case packageSize = "package_size" + case packageLabel = "package_label" + case unitName = "unit_name" + case unitFactor = "unit_factor" + } +} + +// MARK: - Aenderungen + +/// Nur gesetzte Felder werden geschickt (PATCH). +struct ProductUpdateRequest: Codable { + var name: String? + var brand: String? + var packageSize: Double? + var packageLabel: String? + var datePrecision: String? + var minStock: Double? + + enum CodingKeys: String, CodingKey { + case name, brand + case packageSize = "package_size" + case packageLabel = "package_label" + case datePrecision = "date_precision" + case minStock = "min_stock" + } +} + +struct LotUpdateRequest: Codable { + var quantity: Double? + var bestBefore: String? + var bestBeforePrecision: String? + + enum CodingKeys: String, CodingKey { + case quantity + case bestBefore = "best_before" + case bestBeforePrecision = "best_before_precision" + } +}