From c860c8291222d947deed47825d5b048ac9f4dc1f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 3 Oct 2026 07:40:23 +0200 Subject: [PATCH] App: Chargenliste, Anbrechen und Teilverbrauch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In der App gab es beides nicht. Chargen waren ueberhaupt nur innerhalb eines Artikels zu sehen, und dort nur zum Korrigieren - eine Chargenliste wie im Web fehlte, und damit jeder Weg, eine Packung anzubrechen oder eine Teilmenge zu verbrauchen. Das Backend kann das seit zwei Wochen. Neu im Tab "Listen": der Eintrag "Chargen" mit allen Chargen ueber alle Artikel, durchsuchbar, mit Schalter "Nur angebrochene". Je Zeile stehen Menge, MHD bzw. Verbrauchsdatum, Lagerort und ein Abzeichen "angebrochen"; bei einer offenen Packung die Restmenge in Basiseinheiten ("980 g von 1.000 g"), denn "0,98 Packungen" sagt niemandem etwas. Nach links wischen gibt die zwei Buchungen: "Anbrechen" mit Rueckfrage - eine Packung wird abgespalten und bekommt ihr Verbrauchsdatum, der Rest bleibt zu und behaelt sein MHD. "Verbrauchen" oeffnet ein Fenster fuer die VERBRAUCHTE Menge (nicht den Rest - das ist die Zahl, die man kennt). Die Einheitenauswahl holt die Hausmasse der Kategorie dazu, "2 EL" ist damit buchbar und landet bei Mehl als 20 g. War die Charge noch zu, bricht die Buchung sie an; der Haken dafuer ist abwaehlbar. Loeschen fehlt in der Liste mit Absicht: Eine Charge verschwindet durchs Verbrauchen, und das Korrigieren sitzt beim Artikel. Dort zeigt die Chargenzeile jetzt auch "angebrochen · verbrauchen bis ..." - zwei Zeilen mit gleicher Menge waren sonst nicht zu unterscheiden. Alles in bestehende Dateien gelegt, keine neue Quelldatei: Das mitgelieferte Xcode-Projekt listet die Dateien einzeln, eine neue waere ohne "xcodegen generate" stillschweigend nicht mitgebaut worden. Gegen den laufenden Server geprueft, genau mit den Aufrufen, die die App macht: /lots/rows liefert alle Felder, die das Swift-Modell liest; {"quantity":null} an /lots/{id}/open spaltet 1000 von 2000 g ab und setzt consume_by; 2 Essloeffel an /lots/{id}/consume buchen 20 g ab und die Antwort traegt opened und lot. Nicht kompiliert: kein Xcode auf dieser Maschine. Geprueft sind Klammer- und Anfuehrungsbalance (und dabei EIN echter Fehler gefunden: ein deutsches Anfuehrungszeichen mit geradem Schlusszeichen haette eine Zeichenkette vorzeitig beendet), dass formatAmount global erreichbar ist, und dass die benutzten Hilfsfunktionen so heissen. Der Build in Xcode bleibt die Probe. Co-Authored-By: Claude Opus 5 --- ios/Sources/APIClient.swift | 33 ++++ ios/Sources/ListViews.swift | 269 ++++++++++++++++++++++++++++ ios/Sources/Models.swift | 132 ++++++++++++++ ios/Sources/ProductDetailView.swift | 10 +- ios/Sources/RootView.swift | 6 + 5 files changed, 449 insertions(+), 1 deletion(-) diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 7285d03..70aec54 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -477,6 +477,39 @@ actor APIClient { return try await send(request, as: StockResponse.self) } + /// Alle Chargen mit Artikelangaben - fuer die uebergreifende Chargenliste. + func allLotRows() async throws -> [LotRow] { + try await send(try makeRequest("/lots/rows"), as: [LotRow].self) + } + + /// Eine Packung anbrechen: Sie wird als eigene, offene Charge gefuehrt und + /// bekommt ein "zu verbrauchen bis". Ohne Menge ist es eine Artikeleinheit. + @discardableResult + func openLot(id: Int, quantity: Double? = nil) async throws -> Lot { + var request = try makeRequest("/lots/\(id)/open", method: "POST") + try jsonBody(&request, ["quantity": quantity]) + return try await send(request, as: Lot.self) + } + + /// Teilmenge aus einer Charge verbrauchen. + /// + /// `autoOpen` bricht eine noch geschlossene Charge dabei an - die Rueckfrage + /// dazu stellt die Oberflaeche, nicht der Server. + @discardableResult + func consumeLot(id: Int, quantity: Double, unit: String, + note: String?, autoOpen: Bool) async throws -> ConsumeResult { + var request = try makeRequest("/lots/\(id)/consume", method: "POST") + try jsonBody(&request, ConsumeRequest(quantity: quantity, unit: unit, + note: note, autoOpen: autoOpen)) + return try await send(request, as: ConsumeResult.self) + } + + /// Die fuer DIESEN Artikel wirksamen Hausmasse samt Herkunft. + func productMeasures(productId: Int) async throws -> [MeasureAmount] { + try await send(try makeRequest("/products/\(productId)/measures"), + as: [MeasureAmount].self) + } + func updateLot(id: Int, _ payload: LotUpdateRequest) async throws -> Lot { var request = try makeRequest("/lots/\(id)", method: "PATCH") try jsonBody(&request, payload) diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift index d2fa6a8..bbfeb73 100644 --- a/ios/Sources/ListViews.swift +++ b/ios/Sources/ListViews.swift @@ -649,3 +649,272 @@ struct ProductRow: View { product.stockLevel == .below ? .red : .orange } } + +// MARK: - Chargen + +/// Alle Chargen über alle Artikel – das Gegenstück zur Chargenliste im Web. +/// +/// Hier passiert das Anbrechen und der Teilverbrauch. Beides gab es in der App +/// bisher nirgends: Chargen waren nur im Artikel selbst zu sehen, und dort nur +/// zum Korrigieren. +struct ChargenListView: View { + @EnvironmentObject private var display: DisplaySettings + + @State private var zeilen: [LotRow] = [] + @State private var locations: [StorageLocation] = [] + @State private var suche = "" + @State private var nurAngebrochen = false + @State private var verbrauchen: LotRow? + @State private var anbrechen: LotRow? + @State private var busy = true + @State private var error: String? + @State private var hinweis: String? + + private var gefiltert: [LotRow] { + zeilen.filter { zeile in + if nurAngebrochen && !zeile.istAngebrochen { return false } + guard !suche.isEmpty else { return true } + let text = "\(zeile.productName) \(zeile.productBrand ?? "")".lowercased() + return text.contains(suche.lowercased()) + } + } + + var body: some View { + List { + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + if let hinweis { + Section { Text(hinweis).foregroundStyle(.green).font(.callout) } + } + Section { + Toggle("Nur angebrochene", isOn: $nurAngebrochen) + } + Section("\(gefiltert.count) Chargen") { + ForEach(gefiltert) { zeile in + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(zeile.productName) + if zeile.istAngebrochen { + Text("angebrochen") + .font(.caption2) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.accentColor.opacity(0.15)) + .clipShape(Capsule()) + } + } + Text(untertitel(zeile)) + .font(.caption).foregroundStyle(.secondary) + } + // Nach links wischen: die beiden Buchungen. Loeschen gibt es + // hier bewusst nicht - eine Charge verschwindet durchs + // Verbrauchen, und das Korrigieren sitzt beim Artikel. + .swipeActions(edge: .trailing) { + Button { + verbrauchen = zeile + } label: { Label("Verbrauchen", systemImage: "minus.circle") } + .tint(.orange) + if !zeile.istAngebrochen { + Button { + anbrechen = zeile + } label: { Label("Anbrechen", systemImage: "arrow.up.bin") } + .tint(.blue) + } + } + } + if gefiltert.isEmpty && !busy { + Text(nurAngebrochen ? "Keine angebrochene Packung." + : "Keine Chargen im Bestand.") + .foregroundStyle(.secondary) + } + } + } + .searchable(text: $suche, prompt: "Artikel suchen") + .navigationTitle("Chargen") + .navigationBarTitleDisplayMode(.inline) + .refreshable { await load() } + .task { await load() } + .sheet(item: $verbrauchen) { zeile in + NavigationStack { + VerbrauchSheet(zeile: zeile) { meldung in + hinweis = meldung + Task { await load() } + } + } + } + .confirmationDialog("Eine Packung anbrechen?", + isPresented: Binding(get: { anbrechen != nil }, + set: { if !$0 { anbrechen = nil } }), + titleVisibility: .visible) { + Button("Anbrechen") { + if let zeile = anbrechen { Task { await brichAn(zeile) } } + anbrechen = nil + } + Button("Abbrechen", role: .cancel) { anbrechen = nil } + } message: { + Text("Eine Packung wird als eigene, offene Charge geführt und bekommt ein Verbrauchsdatum. Der Rest bleibt ungeöffnet mit seinem MHD.") + } + } + + private func untertitel(_ zeile: LotRow) -> String { + var teile = [zeile.mengeText] + if zeile.istAngebrochen, let start = zeile.openedQuantity { + teile.append("von \(formatAmount(start)) \(zeile.baseKurz)") + } + if let bis = zeile.consumeBy { + teile.append("verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))") + } else if zeile.bestBefore != nil { + teile.append("MHD \(display.formatBestBefore(zeile.bestBefore, precision: zeile.bestBeforePrecision))") + } + if let id = zeile.locationId, let ort = locations.first(where: { $0.id == id }) { + teile.append(ort.path(in: locations)) + } + return teile.joined(separator: " · ") + } + + private func brichAn(_ zeile: LotRow) async { + do { + let offen = try await APIClient.shared.openLot(id: zeile.id) + if let bis = offen.consumeBy { + hinweis = "Angebrochen – zu verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))." + } else { + hinweis = "Angebrochen." + } + error = nil + await load() + } catch { + self.error = error.localizedDescription + } + } + + private func load() async { + busy = true + defer { busy = false } + do { + zeilen = try await APIClient.shared.allLotRows() + locations = (try? await APIClient.shared.locations()) ?? [] + error = nil + } catch { + self.error = error.localizedDescription + } + } +} + +/// Teilmenge aus einer Charge verbrauchen – „100 ml genommen“, „2 EL Mehl“. +/// +/// Eingetippt wird die VERBRAUCHTE Menge, nicht der Rest: Das ist die Zahl, die +/// man kennt. War die Charge noch zu, wird sie dabei angebrochen; der Haken +/// dafür ist abwählbar, falls man nur eine Buchung nachträgt. +struct VerbrauchSheet: View { + let zeile: LotRow + var onDone: (String) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var menge = "" + @State private var einheit = "" + @State private var notiz = "" + @State private var alsAngebrochen = true + @State private var masse: [MeasureAmount] = [] + @State private var busy = false + @State private var error: String? + + /// Alles, was als Einheit gewählt werden kann: Basiseinheit, Gebinde und + /// die Hausmaße der Kategorie. + private var einheiten: [(token: String, label: String)] { + var liste: [(token: String, label: String)] = [(zeile.baseToken, zeile.baseKurz)] + if let p = zeile.packageSize, p > 0 { + let name = zeile.packageLabel ?? "Packung" + liste.append(("package", "\(name) (\(formatAmount(p)) \(zeile.baseKurz))")) + } + for mass in masse { + let kurz = mass.short.map { " (\($0))" } ?? "" + liste.append((mass.token, + "\(mass.name)\(kurz) ≈ \(formatAmount(mass.amount)) \(kurzFuer(mass.base))")) + } + return liste + } + + private func kurzFuer(_ base: String) -> String { + switch base { + case "gram": return "g" + case "milliliter": return "ml" + default: return "Stück" + } + } + + private var zahl: Double? { + Double(menge.replacingOccurrences(of: ",", with: ".")) + } + + var body: some View { + Form { + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + Section { + Text(zeile.productName).font(.headline) + Text("aktuell \(zeile.mengeText)").font(.caption).foregroundStyle(.secondary) + } + Section("Verbrauchte Menge") { + QuantityField(label: "Menge", text: $menge) + Picker("Einheit", selection: $einheit) { + ForEach(einheiten, id: \.token) { eintrag in + Text(eintrag.label).tag(eintrag.token) + } + } + } + if masse.isEmpty { + Section { + // Das schließende Anführungszeichen MUSS das typografische + // sein: Ein gerades " würde die Zeichenkette hier beenden. + Text("Für die Kategorie dieses Artikels ist kein Hausmaß hinterlegt – „2 EL“ lässt sich deshalb noch nicht buchen. Mengen dafür stehen in der Weboberfläche unter Kategorien.") + .font(.caption).foregroundStyle(.secondary) + } + } + if !zeile.istAngebrochen { + Section { + Toggle("Packung als angebrochen führen", isOn: $alsAngebrochen) + } footer: { + Text("Eine Packung wird abgespalten und bekommt ein Verbrauchsdatum; der Rest behält sein MHD. Ohne Haken wird nur die Menge abgebucht.") + } + } + Section("Notiz (freiwillig)") { + TextField("z.B. für Pfannkuchen", text: $notiz) + } + Section { + Button(busy ? "Wird gebucht…" : "Verbrauchen") { Task { await buche() } } + .disabled(busy || zahl == nil || (zahl ?? 0) <= 0 || einheit.isEmpty) + } + } + .navigationTitle("Verbrauchen") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } + } + .task { + einheit = zeile.baseToken + masse = (try? await APIClient.shared.productMeasures(productId: zeile.productId)) ?? [] + } + } + + private func buche() async { + guard let wert = zahl, wert > 0 else { return } + busy = true + defer { busy = false } + do { + let antwort = try await APIClient.shared.consumeLot( + id: zeile.id, quantity: wert, unit: einheit, + note: notiz.isEmpty ? nil : notiz, + autoOpen: zeile.istAngebrochen ? false : alsAngebrochen) + dismiss() + if antwort.lot == nil { + onDone("Verbraucht – die Charge ist damit leer.") + } else { + onDone(antwort.opened ? "Verbraucht und angebrochen." : "Verbraucht.") + } + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index ac60058..c3fdff2 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -344,6 +344,14 @@ struct Lot: Codable, Identifiable, Hashable { let bestBefore: String? let bestBeforePrecision: String? let locationId: String? + /// Oeffnungstag. nil = die Packung ist noch zu. + let openedOn: String? + /// "Zu verbrauchen bis" - beim Anbrechen aus der Frist gerechnet. + let consumeBy: String? + /// Menge beim Oeffnen, damit "noch 400 von 1000 ml" anzeigbar ist. + let openedQuantity: Double? + + var istAngebrochen: Bool { openedOn != nil } enum CodingKeys: String, CodingKey { case id, quantity @@ -351,9 +359,117 @@ struct Lot: Codable, Identifiable, Hashable { case bestBefore = "best_before" case bestBeforePrecision = "best_before_precision" case locationId = "location_id" + case openedOn = "opened_on" + case consumeBy = "consume_by" + case openedQuantity = "opened_quantity" } } +/// Eine Charge mit den Artikelangaben dran - fuer die uebergreifende +/// Chargenliste, in der Chargen verschiedener Artikel nebeneinander stehen. +struct LotRow: Codable, Identifiable, Hashable { + let id: Int + let productId: Int + let productName: String + let productBrand: String? + let quantity: Double + let baseUnit: String + let packageSize: Double? + let packageLabel: String? + let unitName: String + let unitFactor: Double + let bestBefore: String? + let bestBeforePrecision: String? + let locationId: String? + let openedOn: String? + let consumeBy: String? + let openedQuantity: Double? + + var istAngebrochen: Bool { openedOn != nil } + + /// Basiseinheiten je Artikeleinheit: das Gebinde, sonst die Anzeigeeinheit. + var artikelFaktor: Double { + if let p = packageSize, p > 0 { return p } + return unitFactor > 0 ? unitFactor : 1 + } + + /// Wie die Menge dasteht. Bei einer offenen Packung zaehlt die Restmenge in + /// Basiseinheiten - "0,98 Packungen" sagt niemandem etwas, "980 g" schon. + var mengeText: String { + if istAngebrochen { return "\(formatAmount(quantity)) \(baseKurz)" } + let menge = quantity / artikelFaktor + let label = (packageSize ?? 0) > 0 ? (packageLabel ?? "Packung") + : (unitName.isEmpty ? baseKurz : unitName) + return "\(formatAmount(menge)) \(label)" + } + + var baseKurz: String { + switch baseUnit { + case "gram": return "g" + case "milliliter": return "ml" + default: return "Stück" + } + } + + /// Token fuer die Basiseinheit, wie es das Backend beim Buchen erwartet. + var baseToken: String { + switch baseUnit { + case "gram": return "g" + case "milliliter": return "ml" + default: return "piece" + } + } + + enum CodingKeys: String, CodingKey { + case id, quantity + case productId = "product_id" + case productName = "product_name" + case productBrand = "product_brand" + case baseUnit = "base_unit" + case packageSize = "package_size" + case packageLabel = "package_label" + case unitName = "unit_name" + case unitFactor = "unit_factor" + case bestBefore = "best_before" + case bestBeforePrecision = "best_before_precision" + case locationId = "location_id" + case openedOn = "opened_on" + case consumeBy = "consume_by" + case openedQuantity = "opened_quantity" + } +} + +/// Ein Hausmass, das fuer einen Artikel wirklich gilt - "1 EL = 10 g". +/// +/// Die Mengen haengen an der Kategorie (oder am Artikel), nicht am Mass: Ein +/// Essloeffel Mehl wiegt anders als einer Zucker. +struct MeasureAmount: Codable, Identifiable, Hashable { + let measureId: Int + let name: String + let short: String? + let amount: Double + let base: String + let herkunft: String + + var id: Int { measureId } + + /// Was beim Buchen als Einheit mitgeschickt wird. + var token: String { name } + + enum CodingKeys: String, CodingKey { + case name, short, amount, base, herkunft + case measureId = "measure_id" + } +} + +/// Antwort auf einen Teilverbrauch. +struct ConsumeResult: Codable { + /// Wurde die Charge dabei angebrochen? + let opened: Bool + /// Die offene Charge danach - nil, wenn sie dabei leer geworden ist. + let lot: Lot? +} + struct StorageLocation: Codable, Identifiable, Hashable { let id: String let name: String @@ -821,6 +937,22 @@ struct ProductUpdateRequest: Encodable { } } +/// Teilverbrauch aus einer Charge. +/// +/// `unit` nimmt dasselbe wie das Auslagern: die Basiseinheit, eine verwaltete +/// Einheit, "package" - und die Hausmasse der Kategorie ("EL", "Tasse"). +struct ConsumeRequest: Encodable { + var quantity: Double + var unit: String + var note: String? + var autoOpen: Bool + + enum CodingKeys: String, CodingKey { + case quantity, unit, note + case autoOpen = "auto_open" + } +} + struct LotUpdateRequest: Encodable { var quantity: Double? var bestBefore: String? diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index 616269b..2763a72 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -284,7 +284,15 @@ struct ProductDetailView: View { } label: { HStack { VStack(alignment: .leading, spacing: 2) { - Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))") + // Eine offene Packung sah hier wie jede + // andere Charge aus - bei zwei Zeilen mit + // gleicher Menge ist das nicht zu erraten. + if lot.istAngebrochen, let bis = lot.consumeBy { + Text("angebrochen · verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))") + .foregroundStyle(.orange) + } else { + Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))") + } // Ohne den Lagerort sehen zwei Chargen mit // gleichem MHD identisch aus – er ist hier // oft das einzige Unterscheidungsmerkmal. diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift index 2d06962..dab2179 100644 --- a/ios/Sources/RootView.swift +++ b/ios/Sources/RootView.swift @@ -220,6 +220,12 @@ struct ListsTabView: View { ActionTile(title: "Einzelstücke", subtitle: "Alle Exemplare – filterbar nach Lagerort", systemImage: "tag") } + NavigationLink { + ChargenListView() + } label: { + ActionTile(title: "Chargen", subtitle: "Anbrechen und Teilmengen verbrauchen", + systemImage: "shippingbox.and.arrow.backward") + } NavigationLink { ShoppingListView() } label: {