From 71d52917e4a7479353ebf50876029bce95be00fd Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 16 Aug 2026 00:42:52 +0200 Subject: [PATCH] App: Zweiteinheit erfassen und anzeigen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eigener Abschnitt im Artikel mit "Menge [Stk] entspricht [Gramm] das sind [250]" und der ausgerechneten Kontrolle in der Fusszeile. Nur Charge-Artikel, und nur die anderen Arten stehen zur Wahl - eine Bruecke auf die eigene Art haette nichts umzurechnen. ProductUpdateRequest bekommt sendSecondary nach dem Vorbild von sendMinStock: ein PATCH aus einer anderen Maske soll die Bruecke nicht unbeabsichtigt loeschen. Die Einheitenauswahl beim Ein- und Auslagern nimmt bei hinterlegter Bruecke die Gegenart mit auf, der Bestand steht in beiden Lesarten ("6 Stueck · 500 g"). Alle neuen Product-Felder sind Optionals, damit die App auch gegen einen aelteren Server laeuft. Co-Authored-By: Claude Opus 5 --- ios/Sources/CheckInFormView.swift | 6 ++- ios/Sources/CheckOutView.swift | 6 ++- ios/Sources/Models.swift | 58 +++++++++++++++++++++++ ios/Sources/ProductDetailView.swift | 72 ++++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 4 deletions(-) diff --git a/ios/Sources/CheckInFormView.swift b/ios/Sources/CheckInFormView.swift index defb758..d84420c 100644 --- a/ios/Sources/CheckInFormView.swift +++ b/ios/Sources/CheckInFormView.swift @@ -171,8 +171,12 @@ struct CheckInFormView: View { /// Einheiten der passenden Art plus das Gebinde des Artikels. private var unitOptions: [UnitOption] { + // Bei hinterlegter Zweiteinheit auch die Einheiten der Gegenart – das + // Backend rechnet sie ueber die Bruecke am Artikel um. + var arten: Set = [product.kind] + if product.zweitFaktor != nil, let k = product.zweitKind { arten.insert(k) } var options = units - .filter { $0.kind == product.kind } + .filter { arten.contains($0.kind) } .map { UnitOption(value: $0.name, label: $0.name) } if let size = product.packageSize, size > 0 { // Angezeigt wird "Glas"/"Dose", geschickt wird "package". diff --git a/ios/Sources/CheckOutView.swift b/ios/Sources/CheckOutView.swift index 2059beb..50253d4 100644 --- a/ios/Sources/CheckOutView.swift +++ b/ios/Sources/CheckOutView.swift @@ -136,8 +136,12 @@ struct CheckOutFormView: View { @State private var error: String? private var unitOptions: [UnitOption] { + // Bei hinterlegter Zweiteinheit auch die Einheiten der Gegenart – das + // Backend rechnet sie ueber die Bruecke am Artikel um. + var arten: Set = [product.kind] + if product.zweitFaktor != nil, let k = product.zweitKind { arten.insert(k) } var options = units - .filter { $0.kind == product.kind } + .filter { arten.contains($0.kind) } .map { UnitOption(value: $0.name, label: $0.name) } if let size = product.packageSize, size > 0 { // Angezeigt wird "Glas"/"Dose", geschickt wird "package". diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 12f6352..bcc2596 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -50,6 +50,14 @@ struct Product: Codable, Identifiable, Hashable { let baseUnit: String let packageSize: Double? let packageLabel: String? + /// Zweiteinheit: „secondaryCount Basiseinheiten ≙ secondaryAmount + /// secondaryBase". Bruecke zwischen den sonst strikt getrennten Arten. + /// Optionals, damit die App auch gegen einen aelteren Server laeuft. + let secondaryBase: String? + let secondaryCount: Double? + let secondaryAmount: Double? + /// Vom Server ausgerechnet: Zweit-Basiseinheiten je EINER Basiseinheit. + let secondaryFactor: Double? let groupId: Int? let minStock: Double? let stock: Double @@ -98,6 +106,10 @@ struct Product: Codable, Identifiable, Hashable { case baseUnit = "base_unit" case packageSize = "package_size" case packageLabel = "package_label" + case secondaryBase = "secondary_base" + case secondaryCount = "secondary_count" + case secondaryAmount = "secondary_amount" + case secondaryFactor = "secondary_factor" case groupId = "group_id" case minStock = "min_stock" case expiredCount = "expired_count" @@ -134,6 +146,38 @@ struct Product: Codable, Identifiable, Hashable { var stockInArticleUnits: Double { stock / articleUnitFactor } + /// Zweit-Basiseinheiten je EINER Basiseinheit – nil ohne Bruecke. + var zweitFaktor: Double? { + guard let basis = secondaryBase, !basis.isEmpty, basis != baseUnit else { return nil } + if let f = secondaryFactor, f > 0 { return f } + // Rueckfall, falls das abgeleitete Feld fehlt (aelterer Server). + guard let n = secondaryCount, let m = secondaryAmount, n > 0, m > 0 else { return nil } + return m / n + } + + /// Art der Zweiteinheit ("count"/"weight"/"volume") – fuer die Einheitenwahl. + var zweitKind: String? { + switch secondaryBase { + case "piece": return "count" + case "gram": return "weight" + case "milliliter": return "volume" + default: return nil + } + } + + /// Kuerzel der Zweit-Basiseinheit fuer die Anzeige. + var zweitKurz: String { + switch secondaryBase { + case "gram": return "g" + case "milliliter": return "ml" + case "piece": return "Stück" + default: return "" + } + } + + /// Bestand in der Zweiteinheit, z.B. 500 g bei 6 Stueck. + var stockInZweit: Double? { zweitFaktor.map { stock * $0 } } + /// Wie es um den Mindestbestand steht. Beides in Basiseinheiten verglichen. enum StockLevel { case none, ok, close, below } @@ -585,6 +629,12 @@ struct ProductUpdateRequest: Encodable { var individual: Bool? = nil var bulk: Bool? = nil var sendMode = false + // Zweiteinheit ebenfalls nur auf Wunsch – ein PATCH aus einer anderen Maske + // soll die Bruecke nicht unbeabsichtigt loeschen. + var secondaryBase: String? = nil + var secondaryCount: Double? = nil + var secondaryAmount: Double? = nil + var sendSecondary = false enum CodingKeys: String, CodingKey { case name, brand, individual, bulk @@ -600,6 +650,9 @@ struct ProductUpdateRequest: Encodable { case minStockInPackages = "min_stock_in_packages" case minStockUnitId = "min_stock_unit_id" case baseUnit = "base_unit" + case secondaryBase = "secondary_base" + case secondaryCount = "secondary_count" + case secondaryAmount = "secondary_amount" } func encode(to encoder: Encoder) throws { @@ -625,6 +678,11 @@ struct ProductUpdateRequest: Encodable { try container.encode(bulk, forKey: .bulk) if let baseUnit { try container.encode(baseUnit, forKey: .baseUnit) } } + if sendSecondary { + try container.encode(secondaryBase, forKey: .secondaryBase) + try container.encode(secondaryCount, forKey: .secondaryCount) + try container.encode(secondaryAmount, forKey: .secondaryAmount) + } } } diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index abbc584..eb3d8cb 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -20,6 +20,10 @@ struct ProductDetailView: View { @State private var packageSize = "" @State private var packageLabel = "" @State private var datePrecision = "day" + // Zweiteinheit („3 Stück ≙ 250 g") – Bruecke zur anderen Art. + @State private var zweitAnzahl = "" + @State private var zweitMenge = "" + @State private var zweitBasis = "" @State private var groupId: Int? @State private var categoryId: Int? // Verwaltungsart eines Gegenstands: count | individual | bulk – umstellbar. @@ -62,6 +66,9 @@ struct ProductDetailView: View { 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 zweitBasis != (current.secondaryBase ?? "") { return true } + if zweitAnzahl != (current.secondaryCount.map { formatAmount($0) } ?? "") { return true } + if zweitMenge != (current.secondaryAmount.map { formatAmount($0) } ?? "") { return true } } if current.isObject { if objMode != currentObjMode { return true } @@ -97,8 +104,12 @@ struct ProductDetailView: View { } else { // Lebensmittel und Verbrauchsgegenstände: Vorrat aus den Chargen. Section("Bestand") { - LabeledContent("Vorrat", - value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)") + // Mit Zweiteinheit beide Lesarten: „6 Stück · 500 g". + LabeledContent("Vorrat", value: { + let haupt = "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)" + guard let zweit = current.stockInZweit else { return haupt } + return "\(haupt) · \(formatAmount(zweit)) \(current.zweitKurz)" + }()) if current.expiredCount > 0 { LabeledContent("Abgelaufen", value: "\(current.expiredCount)") .foregroundStyle(.red) @@ -124,6 +135,30 @@ struct ProductDetailView: View { } } + // Zweiteinheit: dieselbe Ware in der ANDEREN Art lesbar machen. + // Damit zaehlt der Artikel auch in Gruppen der anderen Art und + // laesst sich darin ein- und auslagern. Das Gebinde bleibt frei. + if current.foodLike { + Section { + QuantityField(label: "Menge", text: $zweitAnzahl, + suffix: display.baseUnitLabel(current.baseUnit)) + Picker("entspricht", selection: $zweitBasis) { + Text("– keine –").tag("") + ForEach(zweitBasisOptionen, id: \.self) { basis in + Text(display.baseUnitLabel(basis)).tag(basis) + } + } + if !zweitBasis.isEmpty { + QuantityField(label: "das sind", text: $zweitMenge, + suffix: display.baseUnitLabel(zweitBasis)) + } + } header: { + Text("Zweiteinheit (optional)") + } footer: { + Text(zweitHinweis) + } + } + Section { CategoryPicker(categories: categories, selection: $categoryId, tracking: current.tracking) @@ -344,6 +379,25 @@ struct ProductDetailView: View { return teile.joined(separator: " · ") } + /// Basiseinheiten der ANDEREN Arten – eine Bruecke auf die eigene Art + /// haette nichts umzurechnen. + private var zweitBasisOptionen: [String] { + ["piece", "gram", "milliliter"].filter { $0 != current.baseUnit } + } + + /// Kontrolle unter der Eingabe: „1 Stück ≈ 83,33 g". + private var zweitHinweis: String { + let n = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) ?? 0 + let m = Double(zweitMenge.replacingOccurrences(of: ",", with: ".")) ?? 0 + guard !zweitBasis.isEmpty, n > 0, m > 0 else { + return "Zusätzliche Lesart desselben Artikels – damit zählt er auch in " + + "Gruppen der anderen Art und lässt sich darin ein- und auslagern." + } + return "1 \(display.baseUnitLabel(current.baseUnit)) ≈ \(formatAmount(m / n)) " + + "\(display.baseUnitLabel(zweitBasis)). Bestände bleiben beim Ändern " + + "unverändert – nur ihre Umrechnung verschiebt sich." + } + /// Aktuelle Verwaltungsart des geladenen Artikels. private var currentObjMode: String { current.isIndividual ? "individual" : (current.isBulk ? "bulk" : "count") @@ -355,6 +409,9 @@ struct ProductDetailView: View { packageSize = current.packageSize.map { formatAmount($0) } ?? "" packageLabel = current.packageLabel ?? "" datePrecision = current.datePrecision == "month" ? "month" : "day" + zweitBasis = current.secondaryBase ?? "" + zweitAnzahl = current.secondaryCount.map { formatAmount($0) } ?? "" + zweitMenge = current.secondaryAmount.map { formatAmount($0) } ?? "" groupId = current.groupId categoryId = current.categoryId shopId = current.shopId @@ -435,6 +492,17 @@ struct ProductDetailView: View { // Der Mindestbestand wird in ProductLocationMinView gepflegt und // hier bewusst NICHT mitgeschickt – sonst überschriebe das // Stammdaten-Speichern die dort gesetzte „Überall"-Zeile. + // + // Zweiteinheit nur vollstaendig; leere oder halbe Angabe loescht + // die Bruecke (secondary_base null raeumt im Backend mit auf). + let zn = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) + let zm = Double(zweitMenge.replacingOccurrences(of: ",", with: ".")) + req.sendSecondary = true + if !zweitBasis.isEmpty, let zn, let zm, zn > 0, zm > 0 { + req.secondaryBase = zweitBasis + req.secondaryCount = zn + req.secondaryAmount = zm + } if current.isObject { var fv: [String: String?] = [:] for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }