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:
@@ -114,7 +114,10 @@ def match_receipt(
|
|||||||
ergebnis.append(MatchLine(
|
ergebnis.append(MatchLine(
|
||||||
text=text,
|
text=text,
|
||||||
candidates=[
|
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]
|
for p, s in treffer[:5]
|
||||||
],
|
],
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -499,6 +499,10 @@ class MatchCandidate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
brand: str | None = None
|
brand: str | None = None
|
||||||
score: int # 0–100, Ähnlichkeit zur Kassenzeile
|
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):
|
class MatchLine(BaseModel):
|
||||||
|
|||||||
@@ -301,6 +301,13 @@ actor APIClient {
|
|||||||
return try await send(request, as: StockResponse.self)
|
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 {
|
func checkOut(_ payload: CheckOutRequest) async throws -> StockResponse {
|
||||||
var request = try makeRequest("/stock/checkout", method: "POST")
|
var request = try makeRequest("/stock/checkout", method: "POST")
|
||||||
try jsonBody(&request, payload)
|
try jsonBody(&request, payload)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Vision
|
||||||
|
import PhotosUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct CheckInView: View {
|
struct CheckInView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@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)) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
struct CheckOutRequest: Codable {
|
||||||
let productId: Int
|
let productId: Int
|
||||||
let quantity: Double
|
let quantity: Double
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ struct ScanTabView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ListsTabView: View {
|
struct ListsTabView: View {
|
||||||
|
@State private var kassenzettelAn = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
@@ -213,10 +215,18 @@ struct ListsTabView: View {
|
|||||||
ActionTile(title: "Verlauf", subtitle: "Wer hat wann was ein- und ausgelagert",
|
ActionTile(title: "Verlauf", subtitle: "Wer hat wann was ein- und ausgelagert",
|
||||||
systemImage: "clock.arrow.circlepath")
|
systemImage: "clock.arrow.circlepath")
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
kassenzettelAn = true
|
||||||
|
} label: {
|
||||||
|
ActionTile(title: "Kassenzettel", subtitle: "Bon fotografieren und Lebensmittel einlagern",
|
||||||
|
systemImage: "doc.text.viewfinder")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.navigationTitle("Listen")
|
.navigationTitle("Listen")
|
||||||
|
.fullScreenCover(isPresented: $kassenzettelAn) { KassenzettelView() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user