App: Chargenliste, Anbrechen und Teilverbrauch

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 07:40:23 +02:00
co-authored by Claude Opus 5
parent f04d80a330
commit c860c82912
5 changed files with 449 additions and 1 deletions
+33
View File
@@ -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)
+269
View File
@@ -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
}
}
}
+132
View File
@@ -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?
+9 -1
View File
@@ -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.
+6
View File
@@ -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: {