From 72fa2ce37d1eef578ba7d2e1275f18ba4d813cd0 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 27 Jul 2026 20:51:07 +0200 Subject: [PATCH] iOS: Verbrauchsgegenstand als dritte Gegenstands-Art (wie ein Lebensmittel) Neuer Artikel: 3-Wege-Verwaltung fuer Gegenstaende (Menge je Lagerort / Einzelstuecke / Verbrauchsgegenstand). Verbrauchsgegenstaende laufen wie Lebensmittel als Charge (Einheit, Packung, MHD, Gruppe, Chargen-Ansicht, Mindestbestand mit Einheit) - foodLike = kein Gegenstand ODER bulk. Menge- Gegenstaende koennen jetzt ebenfalls einer Gruppe zugeordnet werden. ProductDetailView entsprechend auf foodLike umgestellt; NewProductRequest und ProductUpdateRequest um bulk/individual/base_unit erweitert. Co-Authored-By: Claude Opus 4.8 --- ios/Sources/Models.swift | 27 +++++++- ios/Sources/ProductDetailView.swift | 103 +++++++++++++++++----------- ios/Sources/ProductViews.swift | 38 +++++++--- 3 files changed, 116 insertions(+), 52 deletions(-) diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 54e0553..1a41298 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -78,11 +78,13 @@ struct Product: Codable, Identifiable, Hashable { let fieldValues: [String: String?]? /// Gegenstand als Einzelstücke (Items mit UID/QR) statt als Menge geführt. let individual: Bool? + /// Gegenstand als Verbrauchsgegenstand wie ein Lebensmittel (Charge) geführt. + let bulk: Bool? /// Mindestbestand je Lagerort (zusaetzlich zum globalen Mindestbestand). let locationMinStocks: [LocationMinStock]? enum CodingKeys: String, CodingKey { - case id, barcode, name, brand, stock, kind, barcodes, tracking, individual + case id, barcode, name, brand, stock, kind, barcodes, tracking, individual, bulk case locationMinStocks = "location_min_stocks" case datePrecision = "date_precision" case categoryId = "category_id" @@ -109,6 +111,11 @@ struct Product: Codable, Identifiable, Hashable { var isObject: Bool { tracking == "object" } /// Gegenstand, der als Einzelstücke (UID/QR) geführt wird? var isIndividual: Bool { individual == true } + /// Verbrauchsgegenstand: Gegenstand, der wie ein Lebensmittel als Charge läuft. + var isBulk: Bool { bulk == true } + /// Nutzt die Lebensmittel-Pfade (Chargen, Einheit, MHD, Mindestbestand): + /// echte Lebensmittel UND Verbrauchsgegenstände. + var foodLike: Bool { !isObject || isBulk } /// Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit). var articleUnitLabel: String { @@ -300,9 +307,11 @@ struct NewProductRequest: Codable { let groupId: Int? let categoryId: Int? var individual: Bool? = nil + /// Verbrauchsgegenstand: Gegenstand wie ein Lebensmittel (Charge) geführt. + var bulk: Bool? = nil enum CodingKeys: String, CodingKey { - case barcode, name, brand, individual + case barcode, name, brand, individual, bulk case imageUrl = "image_url" case baseUnit = "base_unit" case packageSize = "package_size" @@ -500,9 +509,15 @@ struct ProductUpdateRequest: Encodable { var minStockInPackages: Bool? = nil var minStockUnitId: Int? = nil var sendMinStock = false + // Verwaltungsart eines Gegenstands (Einzelstück/Verbrauchsgegenstand) und die + // Basiseinheit nur senden, wenn ausdrücklich gewünscht – sonst nichts ändern. + var baseUnit: String? = nil + var individual: Bool? = nil + var bulk: Bool? = nil + var sendMode = false enum CodingKeys: String, CodingKey { - case name, brand + case name, brand, individual, bulk case packageSize = "package_size" case packageLabel = "package_label" case datePrecision = "date_precision" @@ -514,6 +529,7 @@ struct ProductUpdateRequest: Encodable { case minStock = "min_stock" case minStockInPackages = "min_stock_in_packages" case minStockUnitId = "min_stock_unit_id" + case baseUnit = "base_unit" } func encode(to encoder: Encoder) throws { @@ -534,6 +550,11 @@ struct ProductUpdateRequest: Encodable { try container.encode(minStockInPackages, forKey: .minStockInPackages) try container.encode(minStockUnitId, forKey: .minStockUnitId) } + if sendMode { + try container.encode(individual, forKey: .individual) + try container.encode(bulk, forKey: .bulk) + if let baseUnit { try container.encode(baseUnit, forKey: .baseUnit) } + } } } diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index e0d5691..fcdc552 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -50,16 +50,19 @@ struct ProductDetailView: View { if name != current.name { return true } if brand != (current.brand ?? "") { return true } if categoryId != current.categoryId { return true } - if current.isObject { - if !current.isIndividual, shopId != current.shopId { return true } - if productUrl != (current.productUrl ?? "") { return true } - if minStock != (current.minStockDisplay.map { formatAmount($0) } ?? "") { return true } - if fieldValues != Self.stringValues(current.fieldValues) { return true } - } else { + // Gruppe gibt es für alles außer Einzelstücke. + if !current.isIndividual, groupId != current.groupId { return true } + // Packung/MHD nur bei Lebensmitteln und Verbrauchsgegenständen. + if current.foodLike { if packageSize != (current.packageSize.map { formatAmount($0) } ?? "") { return true } if packageLabel != (current.packageLabel ?? "") { return true } if datePrecision != (current.datePrecision == "month" ? "month" : "day") { return true } - if groupId != current.groupId { return true } + } + if current.isObject { + if !current.isIndividual && !current.isBulk, shopId != current.shopId { return true } + if !current.isBulk, productUrl != (current.productUrl ?? "") { return true } + if minStock != (current.minStockDisplay.map { formatAmount($0) } ?? "") { return true } + if fieldValues != Self.stringValues(current.fieldValues) { return true } } return false } @@ -82,10 +85,12 @@ struct ProductDetailView: View { if current.isObject && current.isIndividual { ProductItemsSection(product: current, onChanged: { await reload() }, onFinish: { dismiss() }) - } else if current.isObject { + } else if current.isObject && !current.isBulk { + // Menge je Lagerort (Verbrauchsgegenstände laufen als Charge, s.u.). ObjectStockSection(product: current, locations: locations, lots: lots, onChanged: { await reload() }) } else { + // Lebensmittel und Verbrauchsgegenstände: Vorrat aus den Chargen. Section("Bestand") { LabeledContent("Vorrat", value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)") @@ -101,7 +106,7 @@ struct ProductDetailView: View { Section("Artikel") { LabeledField(label: "Name", text: $name) LabeledField(label: "Marke", text: $brand) - if !current.isObject { + if current.foodLike { QuantityField(label: "Packungsgröße", text: $packageSize, suffix: display.baseUnitLabel(current.baseUnit)) Picker("Bezeichnung", selection: $packageLabel) { @@ -123,7 +128,9 @@ struct ProductDetailView: View { : "Nur Lebensmittel-Kategorien.") } - if current.isObject { + // Bezugsquelle/Link nur für Menge je Lagerort und Einzelstücke – + // Verbrauchsgegenstände laufen wie Lebensmittel und lassen das weg. + if current.isObject && !current.isBulk { // „Gekauft bei" gehört bei Einzelstücken zum einzelnen Stück // (jedes kann woanders gekauft sein), nicht ans Modell – wie im // Web wird der Shop hier dann ausgeblendet. Der Produktlink @@ -140,14 +147,10 @@ struct ProductDetailView: View { Link("Im Onlineshop öffnen", destination: url) } } - if !fieldDefs.isEmpty { - Section("Eigene Felder") { - ForEach(fieldDefs) { def in - ObjectFieldRow(def: def, value: fieldBinding(def)) - } - } - } - } else { + } + // Gruppe für alles außer Einzelstücke (Lebensmittel, Verbrauchs- + // gegenstand, Menge je Lagerort). + if !current.isIndividual { Section { Picker("Gruppe", selection: $groupId) { Text("– keine –").tag(Int?.none) @@ -156,14 +159,23 @@ struct ProductDetailView: View { } } } footer: { - Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code " + Text("Gruppe: zählt Bestände mehrerer Artikel zusammen. Der EAN-Code " + "wandert bei einem Wechsel mit.") } } + // Eigene Felder für alle Gegenstände mit Kategorie-Feldern. + if current.isObject && !fieldDefs.isEmpty { + Section("Eigene Felder") { + ForEach(fieldDefs) { def in + ObjectFieldRow(def: def, value: fieldBinding(def)) + } + } + } if current.isObject { Section("Mindestbestand") { - QuantityField(label: "Gesamt (Stück)", text: $minStock) + QuantityField(label: "Gesamt (\(current.isBulk ? current.articleUnitLabel : "Stück"))", + text: $minStock) } } @@ -191,7 +203,7 @@ struct ProductDetailView: View { .disabled(busy || name.isEmpty) } - if !current.isObject { + if current.foodLike { Section("Chargen") { ForEach(lots) { lot in Button { @@ -314,8 +326,35 @@ struct ProductDetailView: View { busy = true defer { busy = false } do { - if current.isObject { - // Gegenstände: keine Lebensmittel-Felder, dafür Shop, Link und eigene Felder. + let minBase = Double(minStock.replacingOccurrences(of: ",", with: ".")) + .map { $0 * current.articleUnitFactor } + if current.foodLike { + // Lebensmittel ODER Verbrauchsgegenstand: als Charge mit Einheit/ + // Packung/MHD/Gruppe. Verbrauchsgegenstände (isObject) tragen + // zusätzlich eigene Felder und einen Mindestbestand. + var req = ProductUpdateRequest( + name: name, + brand: brand.isEmpty ? nil : brand, + packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), + packageLabel: packageLabel.isEmpty ? nil : packageLabel, + datePrecision: datePrecision, + groupId: groupId, + categoryId: categoryId + ) + if current.isObject { + var fv: [String: String?] = [:] + for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" } + req.fieldValues = fv + req.minStock = minBase + req.minStockInPackages = (current.packageSize ?? 0) > 0 + req.minStockUnitId = nil + req.sendMinStock = true + } + current = try await APIClient.shared.updateProduct(id: current.id, req) + if current.isObject { fieldValues = Self.stringValues(current.fieldValues) } + } else { + // Gegenstand: Menge je Lagerort oder Einzelstücke. Menge- + // Gegenstände dürfen jetzt ebenfalls in eine Gruppe. var fv: [String: String?] = [:] for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" } current = try await APIClient.shared.updateProduct( @@ -324,32 +363,18 @@ struct ProductDetailView: View { name: name, brand: brand.isEmpty ? nil : brand, packageSize: nil, packageLabel: nil, datePrecision: nil, - groupId: nil, categoryId: categoryId, + groupId: current.isIndividual ? nil : groupId, categoryId: categoryId, // Einzelstücke tragen den Shop je Stück – nichts ans Modell. shopId: current.isIndividual ? nil : shopId, productUrl: productUrl.isEmpty ? nil : productUrl, fieldValues: fv, - minStock: Double(minStock.replacingOccurrences(of: ",", with: ".")) - .map { $0 * current.articleUnitFactor }, + minStock: minBase, minStockInPackages: (current.packageSize ?? 0) > 0, minStockUnitId: nil, sendMinStock: true ) ) fieldValues = Self.stringValues(current.fieldValues) - } else { - current = try await APIClient.shared.updateProduct( - id: current.id, - ProductUpdateRequest( - name: name, - brand: brand.isEmpty ? nil : brand, - packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), - packageLabel: packageLabel.isEmpty ? nil : packageLabel, - datePrecision: datePrecision, - groupId: groupId, - categoryId: categoryId - ) - ) } status = "Gespeichert." } catch { diff --git a/ios/Sources/ProductViews.swift b/ios/Sources/ProductViews.swift index 17f3f4f..4ea3e72 100644 --- a/ios/Sources/ProductViews.swift +++ b/ios/Sources/ProductViews.swift @@ -72,8 +72,9 @@ struct ProductFormView: View { @State private var selectedCategoryId: Int? // Umschalter Lebensmittel/Gegenstand: filtert die Kategorieauswahl. @State private var modus = "object" - // Gegenstand als Einzelstücke (UID/QR je Stück) führen? - @State private var individual = false + // Verwaltungsart eines Gegenstands: "count" (Menge je Lagerort), + // "individual" (Einzelstücke) oder "bulk" (Verbrauchsgegenstand wie Lebensmittel). + @State private var objMode = "count" @State private var groups: [GroupItem] = [] @State private var categories: [CategoryItem] = [] @State private var units: [Unit] = [] @@ -85,6 +86,13 @@ struct ProductFormView: View { // Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel. private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"] + /// Nutzt die Lebensmittel-Pfade (Einheit, MHD, Charge): Lebensmittel ODER + /// Verbrauchsgegenstand. + private var foodLike: Bool { modus == "food" || objMode == "bulk" } + /// Gruppe anbieten: Lebensmittel, Verbrauchsgegenstand oder Menge je Lagerort + /// (nicht bei Einzelstücken). + private var showGroup: Bool { modus == "food" || (modus == "object" && objMode != "individual") } + var body: some View { Form { Section("Artikel") { @@ -100,13 +108,19 @@ struct ProductFormView: View { .pickerStyle(.segmented) CategoryPicker(categories: categories, selection: $selectedCategoryId, tracking: modus) if modus == "object" { - Toggle("Einzelstücke (UID/QR je Stück)", isOn: $individual) + Picker("Verwaltung", selection: $objMode) { + Text("Menge je Lagerort").tag("count") + Text("Einzelstücke").tag("individual") + Text("Verbrauchsgegenstand").tag("bulk") + } } } footer: { Text(modus == "object" - ? (individual + ? (objMode == "individual" ? "Einzelstücke: jedes Stück mit eigener UID/QR, Kaufdatum, Garantie." - : "Gegenstand: Menge je Lagerort und eigene Felder.") + : objMode == "bulk" + ? "Verbrauchsgegenstand: wie ein Lebensmittel als Charge mit Menge und Einheit (z.B. Sonnencreme in ml) – nur ohne eindeutigen Code." + : "Gegenstand: Menge je Lagerort und eigene Felder.") : "Lebensmittel: Chargen mit Mindesthaltbarkeit.") } .onChange(of: modus) { neu in @@ -116,9 +130,9 @@ struct ProductFormView: View { selectedCategoryId = nil } } - // Lebensmittel-Felder (Gruppe, Einheit, MHD) nur im Lebensmittel-Modus. - // Fuer Gegenstaende irrelevant - sie zaehlen in Stueck je Lagerort. - if modus == "food" { + // Gruppe: Lebensmittel, Verbrauchsgegenstand und Menge je Lagerort + // (nur Einzelstücke bleiben außen vor). + if showGroup { Section { Picker("Gruppe", selection: $selectedGroupId) { Text("– keine –").tag(Int?.none) @@ -134,9 +148,12 @@ struct ProductFormView: View { } } } footer: { - Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code " + Text("Gruppe: zählt Bestände mehrerer Artikel zusammen. Der EAN-Code " + "dieses Artikels erscheint danach automatisch bei der Gruppe.") } + } + // Einheit + MHD nur für Lebensmittel und Verbrauchsgegenstände (Charge). + if foodLike { Section("Einheit") { Picker("Basiseinheit", selection: $baseUnit) { ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) } @@ -217,7 +234,8 @@ struct ProductFormView: View { datePrecision: datePrecision, groupId: selectedGroupId, categoryId: selectedCategoryId, - individual: modus == "object" ? individual : nil + individual: modus == "object" ? (objMode == "individual") : nil, + bulk: modus == "object" ? (objMode == "bulk") : nil ) ) onCreated(product)