iOS: Kassenzettel scannen -> Lebensmittel erkennen & einlagern

Neuer Flow (Kachel im Listen-Tab): Foto aufnehmen/waehlen, Artikelbereich per
Rechteck markieren, on-device OCR (Vision, deutsch; Bild bleibt auf dem Geraet),
Zeilen an /products/match schicken. Pruefen-Liste je Zeile: Artikel-Picker
(Auto-Treffer ueber Schwellwert + eigene Lebensmittel-Suche), Menge (aus Zeile
geparst, korrigierbar), Lagerort-Dropdown + QR-Scan. Einlagern per checkInBatch,
unbekannte Zeilen werden uebersprungen; keine Neuanlage.

Backend: MatchCandidate traegt package_size/base_unit mit, damit die App die
Einheit ohne Extra-Abruf kennt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-30 18:40:10 +02:00
parent 9cc3e325bb
commit 648b8294b8
6 changed files with 488 additions and 1 deletions

View File

@@ -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)

View File

@@ -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<KassenzettelZeile>) -> 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)) } }
}
}
}
}

View File

@@ -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

View File

@@ -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() }
}
}
}