diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index ae68d24..65a5d4f 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -114,7 +114,10 @@ def match_receipt( ergebnis.append(MatchLine( text=text, candidates=[ - MatchCandidate(product_id=p.id, name=p.name, brand=p.brand, score=s) + MatchCandidate( + product_id=p.id, name=p.name, brand=p.brand, score=s, + package_size=p.package_size, base_unit=p.base_unit.value, + ) for p, s in treffer[:5] ], )) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index de724c4..90b460b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -499,6 +499,10 @@ class MatchCandidate(BaseModel): name: str brand: str | None = None score: int # 0–100, Ähnlichkeit zur Kassenzeile + # Für die Einheit beim Einlagern (ganze Gebinde bzw. Basiseinheit) – so muss + # die App den Artikel nicht noch einmal einzeln laden. + package_size: float | None = None + base_unit: str class MatchLine(BaseModel): diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 6bbfab4..58b11a8 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -301,6 +301,13 @@ actor APIClient { return try await send(request, as: StockResponse.self) } + /// Kassenzettel-Zeilen (OCR) den aehnlichsten Lebensmitteln zuordnen. + func matchReceipt(_ lines: [String], threshold: Int? = nil) async throws -> [MatchLine] { + var request = try makeRequest("/products/match", method: "POST") + try jsonBody(&request, MatchRequest(lines: lines, threshold: threshold)) + return try await send(request, as: [MatchLine].self) + } + func checkOut(_ payload: CheckOutRequest) async throws -> StockResponse { var request = try makeRequest("/stock/checkout", method: "POST") try jsonBody(&request, payload) diff --git a/ios/Sources/CheckInView.swift b/ios/Sources/CheckInView.swift index 110eca8..d7bccf0 100644 --- a/ios/Sources/CheckInView.swift +++ b/ios/Sources/CheckInView.swift @@ -1,4 +1,7 @@ import SwiftUI +import Vision +import PhotosUI +import UIKit struct CheckInView: View { @Environment(\.dismiss) private var dismiss @@ -337,3 +340,431 @@ struct CheckInView: View { } } } + +// MARK: - Kassenzettel scannen (nur Lebensmittel) + +private extension UIImage { + /// Orientierung auf `.up` normalisieren, damit `cgImage`-Pixel und `size`-Punkte + /// zusammenpassen (Kamerafotos sind sonst gedreht). + func nachObenGedreht() -> UIImage { + guard imageOrientation != .up else { return self } + let format = UIGraphicsImageRendererFormat.default() + format.scale = scale + return UIGraphicsImageRenderer(size: size, format: format).image { _ in + draw(in: CGRect(origin: .zero, size: size)) + } + } + + /// Auf ein Rechteck in Punkt-Koordinaten (size) zuschneiden. + func zugeschnitten(auf rect: CGRect) -> UIImage? { + let px = CGRect(x: rect.minX * scale, y: rect.minY * scale, + width: rect.width * scale, height: rect.height * scale) + guard let cg = cgImage?.cropping(to: px.integral) else { return nil } + return UIImage(cgImage: cg, scale: scale, orientation: .up) + } +} + +/// OCR (on-device) auf einem Ausschnitt – deutsche Texterkennung, zeilenweise von +/// oben nach unten. Das Bild verlässt das Gerät nicht; nur die Textzeilen gehen zum +/// Server für den Abgleich. +private func ocrZeilen(_ cg: CGImage) async -> [String] { + await withCheckedContinuation { fortsetzung in + DispatchQueue.global(qos: .userInitiated).async { + let anfrage = VNRecognizeTextRequest() + anfrage.recognitionLevel = .accurate + anfrage.usesLanguageCorrection = true + anfrage.recognitionLanguages = ["de-DE"] + try? VNImageRequestHandler(cgImage: cg, options: [:]).perform([anfrage]) + let treffer = (anfrage.results as? [VNRecognizedTextObservation]) ?? [] + let vonOben = treffer.sorted { $0.boundingBox.maxY > $1.boundingBox.maxY } + fortsetzung.resume(returning: vonOben.compactMap { $0.topCandidates(1).first?.string }) + } + } +} + +/// Offensichtliche Nicht-Artikel (Preise, Summen, Kopf/Fuß) grob aussortieren. +private func plausibleArtikelZeilen(_ zeilen: [String]) -> [String] { + let muell = ["summe", "gesamt", "zwischensumme", "mwst", "ust", "eur", "bar", + "rueckgeld", "rückgeld", "kartenzahlung", "betrag", "total", + "kassenbon", "beleg", "datum", "uhr", "filiale", "kunde", "steuer"] + return zeilen.compactMap { roh in + let s = roh.trimmingCharacters(in: .whitespacesAndNewlines) + guard s.filter({ $0.isLetter }).count >= 3 else { return nil } + let klein = s.lowercased() + return muell.contains(where: { klein.contains($0) }) ? nil : s + } +} + +/// Menge aus einer Zeile lesen („2x", „2 Stk", führende Zahl), sonst 1. +private func mengeAusZeile(_ zeile: String) -> Double { + for muster in ["(\\d+)\\s*[xX]", "(\\d+)\\s*[sS][tT]", "^\\s*(\\d+)\\b"] { + if let r = zeile.range(of: muster, options: .regularExpression) { + let ziffern = zeile[r].filter { $0.isNumber } + if let n = Int(ziffern), n > 0, n < 100 { return Double(n) } + } + } + return 1 +} + +/// Einstieg: Foto → Bereich markieren → Zeilen prüfen → einlagern. +struct KassenzettelView: View { + @Environment(\.dismiss) private var dismiss + @State private var bild: UIImage? + @State private var zeilen: [MatchLine]? + @State private var analysiere = false + @State private var kameraAn = false + @State private var galerie: PhotosPickerItem? + @State private var locations: [StorageLocation] = [] + + var body: some View { + NavigationStack { + inhalt + .navigationTitle("Kassenzettel") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Schließen") { dismiss() } } } + .task { locations = (try? await APIClient.shared.locations()) ?? [] } + .fullScreenCover(isPresented: $kameraAn) { + CameraPicker(isPresented: $kameraAn) { img in bild = img.nachObenGedreht(); zeilen = nil } + .ignoresSafeArea() + } + .onChange(of: galerie) { item in + guard let item else { return } + Task { + if let data = try? await item.loadTransferable(type: Data.self), + let ui = UIImage(data: data) { bild = ui.nachObenGedreht(); zeilen = nil } + galerie = nil + } + } + } + } + + @ViewBuilder private var inhalt: some View { + if analysiere { + VStack(spacing: 12) { ProgressView(); Text("Lese Artikel…").foregroundStyle(.secondary) } + } else if let zeilen { + KassenzettelPruefenView(match: zeilen, locations: locations) { dismiss() } + } else if let bild { + KassenzettelMarkierenView(bild: bild, onWeiter: starteErkennung) { self.bild = nil } + } else { + start + } + } + + private var start: some View { + VStack(spacing: 16) { + Spacer() + Image(systemName: "doc.text.viewfinder").font(.system(size: 52)).foregroundStyle(.secondary) + Text("Kassenzettel fotografieren, den Artikelbereich markieren – die App liest die Zeilen und schlägt Lebensmittel vor.") + .multilineTextAlignment(.center).foregroundStyle(.secondary).padding(.horizontal) + Button { kameraAn = true } label: { + Label("Foto aufnehmen", systemImage: "camera").frame(maxWidth: .infinity) + }.buttonStyle(.borderedProminent) + PhotosPicker(selection: $galerie, matching: .images) { + Label("Aus Galerie wählen", systemImage: "photo").frame(maxWidth: .infinity) + }.buttonStyle(.bordered) + Spacer() + }.padding() + } + + private func starteErkennung(_ ausschnitt: UIImage) { + analysiere = true + Task { + let erg = await erkenne(ausschnitt) + await MainActor.run { zeilen = erg; analysiere = false } + } + } + + private func erkenne(_ img: UIImage) async -> [MatchLine] { + guard let cg = img.cgImage else { return [] } + let roh = await ocrZeilen(cg) + let zeilen = plausibleArtikelZeilen(roh) + guard !zeilen.isEmpty else { return [] } + return (try? await APIClient.shared.matchReceipt(zeilen)) + ?? zeilen.map { MatchLine(text: $0, candidates: []) } + } +} + +/// Rechteck über den Artikelblock ziehen; liefert den zugeschnittenen Bildausschnitt. +struct KassenzettelMarkierenView: View { + let bild: UIImage + var onWeiter: (UIImage) -> Void + var onNeu: () -> Void + + @State private var rect: CGRect = .zero + @State private var startRect: CGRect? + @State private var fitRect: CGRect = .zero + + var body: some View { + VStack(spacing: 12) { + Text("Ziehe das Rechteck über die Artikel. Am Griff unten rechts vergrößern.") + .font(.callout).foregroundStyle(.secondary) + .multilineTextAlignment(.center).padding(.horizontal) + GeometryReader { geo in + ZStack(alignment: .topLeading) { + Image(uiImage: bild).resizable().scaledToFit() + .frame(width: geo.size.width, height: geo.size.height) + Rectangle().stroke(Color.accentColor, lineWidth: 2) + .background(Color.accentColor.opacity(0.15)) + .frame(width: rect.width, height: rect.height) + .offset(x: rect.minX, y: rect.minY) + .gesture(zieh(resize: false)) + Rectangle().fill(Color.accentColor) + .frame(width: 26, height: 26) + .offset(x: rect.maxX - 13, y: rect.maxY - 13) + .gesture(zieh(resize: true)) + } + .onAppear { setzeFit(geo.size) } + .onChange(of: geo.size) { setzeFit($0) } + } + HStack { + Button("Neues Foto", action: onNeu) + Spacer() + Button("Weiter") { weiter() }.buttonStyle(.borderedProminent) + }.padding(.horizontal) + }.padding(.vertical) + } + + private func setzeFit(_ container: CGSize) { + let iw = bild.size.width, ih = bild.size.height + guard iw > 0, ih > 0, container.width > 0 else { return } + let s = min(container.width / iw, container.height / ih) + let w = iw * s, h = ih * s + fitRect = CGRect(x: (container.width - w) / 2, y: (container.height - h) / 2, width: w, height: h) + if rect == .zero { rect = fitRect.insetBy(dx: fitRect.width * 0.08, dy: fitRect.height * 0.12) } + } + + private func zieh(resize: Bool) -> some Gesture { + DragGesture() + .onChanged { v in + if startRect == nil { startRect = rect } + guard let s = startRect else { return } + var r = s + if resize { + r.size.width = max(48, s.width + v.translation.width) + r.size.height = max(48, s.height + v.translation.height) + } else { + r.origin.x = s.origin.x + v.translation.width + r.origin.y = s.origin.y + v.translation.height + } + rect = klemme(r) + } + .onEnded { _ in startRect = nil } + } + + private func klemme(_ r: CGRect) -> CGRect { + guard fitRect.width > 0 else { return r } + var o = r + o.size.width = min(o.size.width, fitRect.width) + o.size.height = min(o.size.height, fitRect.height) + o.origin.x = min(max(o.origin.x, fitRect.minX), fitRect.maxX - o.width) + o.origin.y = min(max(o.origin.y, fitRect.minY), fitRect.maxY - o.height) + return o + } + + private func weiter() { + guard fitRect.width > 0 else { onWeiter(bild); return } + let s = bild.size.width / fitRect.width // Bildschirm-Einheiten → Bildpunkte + let crop = CGRect(x: (rect.minX - fitRect.minX) * s, y: (rect.minY - fitRect.minY) * s, + width: rect.width * s, height: rect.height * s) + onWeiter(bild.zugeschnitten(auf: crop) ?? bild) + } +} + +private struct KassenzettelArtikel: Hashable { let id: Int; let name: String; let unit: String } + +private struct KassenzettelZeile: Identifiable { + let id = UUID() + let text: String + let kandidaten: [MatchCandidate] + var artikel: KassenzettelArtikel? + var menge: Double + var ortId: String? +} + +private struct ZeilenRef: Identifiable { let id: UUID } + +/// Prüfen-Liste: je Zeile Artikel (Auto-Treffer + eigene Suche), Menge, Lagerort. +struct KassenzettelPruefenView: View { + let match: [MatchLine] + let locations: [StorageLocation] + var onFertig: () -> Void + + @State private var zeilen: [KassenzettelZeile] = [] + @State private var sucheZiel: ZeilenRef? + @State private var scanZiel: ZeilenRef? + @State private var busy = false + @State private var meldung: String? + + private var zugeordnet: Int { zeilen.filter { $0.artikel != nil }.count } + + var body: some View { + Group { + if zeilen.isEmpty { + VStack(spacing: 10) { + Image(systemName: "doc.text.magnifyingglass").font(.system(size: 40)).foregroundStyle(.secondary) + Text("Keine Artikel erkannt.").font(.headline) + Text("Versuch es mit einem engeren Rechteck oder besserem Licht.") + .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) + }.padding() + } else { + List { + ForEach($zeilen) { $z in zeile($z) } + Section { + Button { Task { await einlagern() } } label: { + HStack { + if busy { ProgressView().padding(.trailing, 4) } + Text("\(zugeordnet) Artikel einlagern") + } + }.disabled(busy || zugeordnet == 0) + } footer: { + Text("Nicht zugeordnete Zeilen werden übersprungen. Neue Artikel werden nicht angelegt.") + } + } + } + } + .onAppear(perform: aufbauen) + .sheet(item: $sucheZiel) { ref in + NavigationStack { + ArtikelSucheSheet { produkt in setzeArtikel(ref.id, produkt: produkt); sucheZiel = nil } + } + } + .sheet(item: $scanZiel) { ref in + LocationScannerView(locations: locations) { loc in setzeOrt(ref.id, loc.id); scanZiel = nil } + } + .alert("Fertig", isPresented: Binding(get: { meldung != nil }, + set: { if !$0 { meldung = nil; onFertig() } })) { + Button("OK") { } + } message: { Text(meldung ?? "") } + } + + @ViewBuilder private func zeile(_ z: Binding) -> some View { + let zeile = z.wrappedValue + VStack(alignment: .leading, spacing: 8) { + Text(zeile.text).font(.caption).foregroundStyle(.secondary).lineLimit(1) + Menu { + ForEach(zeile.kandidaten) { k in + Button("\(k.name) · \(k.score) %") { + z.wrappedValue.artikel = KassenzettelArtikel(id: k.productId, name: k.name, unit: k.checkInUnit) + } + } + Divider() + Button { sucheZiel = ZeilenRef(id: zeile.id) } label: { Label("Suchen…", systemImage: "magnifyingglass") } + if zeile.artikel != nil { + Button(role: .destructive) { z.wrappedValue.artikel = nil } label: { + Label("Nicht zuordnen", systemImage: "xmark") + } + } + } label: { + HStack { + Image(systemName: zeile.artikel == nil ? "questionmark.circle" : "checkmark.circle.fill") + .foregroundStyle(zeile.artikel == nil ? Color.secondary : Color.green) + Text(zeile.artikel?.name ?? "– Artikel wählen –") + .foregroundStyle(zeile.artikel == nil ? Color.secondary : Color.primary) + Spacer() + Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.secondary) + } + } + HStack(spacing: 12) { + Stepper("Menge \(Int(zeile.menge))", value: z.menge, in: 1...99).fixedSize() + Spacer() + Menu { + Button("– ohne –") { z.wrappedValue.ortId = nil } + ForEach(locations) { loc in Button(loc.name) { z.wrappedValue.ortId = loc.id } } + } label: { + Label(ortName(zeile.ortId), systemImage: "mappin.and.ellipse").font(.callout).lineLimit(1) + } + Button { scanZiel = ZeilenRef(id: zeile.id) } label: { + Image(systemName: "qrcode.viewfinder") + }.buttonStyle(.borderless) + } + }.padding(.vertical, 4) + } + + private func ortName(_ id: String?) -> String { + guard let id else { return "ohne Ort" } + return locations.first { $0.id == id }?.name ?? "Ort" + } + + private func aufbauen() { + guard zeilen.isEmpty else { return } + zeilen = match.map { m in + KassenzettelZeile( + text: m.text, kandidaten: m.candidates, + artikel: m.candidates.first.map { + KassenzettelArtikel(id: $0.productId, name: $0.name, unit: $0.checkInUnit) + }, + menge: mengeAusZeile(m.text), ortId: nil, + ) + } + } + + private func setzeArtikel(_ id: UUID, produkt: Product) { + guard let i = zeilen.firstIndex(where: { $0.id == id }) else { return } + let unit = (produkt.packageSize ?? 0) > 0 ? "package" : produkt.baseUnit + zeilen[i].artikel = KassenzettelArtikel(id: produkt.id, name: produkt.name, unit: unit) + } + + private func setzeOrt(_ id: UUID, _ ortId: String) { + guard let i = zeilen.firstIndex(where: { $0.id == id }) else { return } + zeilen[i].ortId = ortId + } + + private func einlagern() async { + busy = true + var ein = 0, weg = 0 + var fehler: String? + for z in zeilen { + guard let a = z.artikel else { weg += 1; continue } + let payload = BatchCheckInRequest( + productId: a.id, unit: a.unit, + lines: [CheckInLine(quantity: z.menge, bestBefore: nil, + bestBeforePrecision: "day", locationId: z.ortId)], + ) + do { _ = try await APIClient.shared.checkInBatch(payload); ein += 1 } + catch { fehler = error.localizedDescription } + } + busy = false + meldung = "\(ein) eingelagert, \(weg) übersprungen." + + (fehler.map { " Fehler: \($0)" } ?? "") + } +} + +/// Freie Artikelsuche (nur Lebensmittel) als Rückfall, wenn kein Auto-Treffer passt. +struct ArtikelSucheSheet: View { + var onPick: (Product) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var text = "" + @State private var treffer: [Product] = [] + @State private var suchlauf = UUID() + + var body: some View { + List(treffer) { p in + Button { + onPick(p); dismiss() + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(p.name).foregroundStyle(.primary) + if let b = p.brand, !b.isEmpty { + Text(b).font(.caption).foregroundStyle(.secondary) + } + } + } + } + .listStyle(.plain) + .searchable(text: $text, prompt: "Lebensmittel suchen") + .navigationTitle("Artikel suchen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } + .onChange(of: text) { q in + let lauf = UUID(); suchlauf = lauf + Task { + try? await Task.sleep(nanoseconds: 250_000_000) + if suchlauf != lauf { return } + let liste = (try? await APIClient.shared.searchProducts(q)) ?? [] + let food = liste.filter { $0.tracking != "object" } + await MainActor.run { if suchlauf == lauf { treffer = Array(food.prefix(25)) } } + } + } + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index d2da64f..4c2e307 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -277,6 +277,38 @@ struct BatchCheckInRequest: Codable { } } +// MARK: - Kassenzettel-Abgleich + +struct MatchRequest: Codable { + let lines: [String] + let threshold: Int? +} + +struct MatchCandidate: Codable, Identifiable, Hashable { + let productId: Int + let name: String + let brand: String? + let score: Int + let packageSize: Double? + let baseUnit: String + + var id: Int { productId } + /// Einheit fuers Einlagern: ganze Gebinde, sonst die Basiseinheit. + var checkInUnit: String { (packageSize ?? 0) > 0 ? "package" : baseUnit } + + enum CodingKeys: String, CodingKey { + case name, brand, score + case productId = "product_id" + case packageSize = "package_size" + case baseUnit = "base_unit" + } +} + +struct MatchLine: Codable { + let text: String + let candidates: [MatchCandidate] +} + struct CheckOutRequest: Codable { let productId: Int let quantity: Double diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift index 9c93fa1..cd4f1f4 100644 --- a/ios/Sources/RootView.swift +++ b/ios/Sources/RootView.swift @@ -166,6 +166,8 @@ struct ScanTabView: View { } struct ListsTabView: View { + @State private var kassenzettelAn = false + var body: some View { NavigationStack { VStack(spacing: 16) { @@ -213,10 +215,18 @@ struct ListsTabView: View { ActionTile(title: "Verlauf", subtitle: "Wer hat wann was ein- und ausgelagert", systemImage: "clock.arrow.circlepath") } + Button { + kassenzettelAn = true + } label: { + ActionTile(title: "Kassenzettel", subtitle: "Bon fotografieren und Lebensmittel einlagern", + systemImage: "doc.text.viewfinder") + } + .buttonStyle(.plain) Spacer() } .padding() .navigationTitle("Listen") + .fullScreenCover(isPresented: $kassenzettelAn) { KassenzettelView() } } } }