App: Zweiteinheit erfassen und anzeigen

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-16 00:42:52 +02:00
parent 788ef2caf2
commit 71d52917e4
4 changed files with 138 additions and 4 deletions

View File

@@ -171,8 +171,12 @@ struct CheckInFormView: View {
/// Einheiten der passenden Art plus das Gebinde des Artikels. /// Einheiten der passenden Art plus das Gebinde des Artikels.
private var unitOptions: [UnitOption] { 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<String> = [product.kind]
if product.zweitFaktor != nil, let k = product.zweitKind { arten.insert(k) }
var options = units var options = units
.filter { $0.kind == product.kind } .filter { arten.contains($0.kind) }
.map { UnitOption(value: $0.name, label: $0.name) } .map { UnitOption(value: $0.name, label: $0.name) }
if let size = product.packageSize, size > 0 { if let size = product.packageSize, size > 0 {
// Angezeigt wird "Glas"/"Dose", geschickt wird "package". // Angezeigt wird "Glas"/"Dose", geschickt wird "package".

View File

@@ -136,8 +136,12 @@ struct CheckOutFormView: View {
@State private var error: String? @State private var error: String?
private var unitOptions: [UnitOption] { 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<String> = [product.kind]
if product.zweitFaktor != nil, let k = product.zweitKind { arten.insert(k) }
var options = units var options = units
.filter { $0.kind == product.kind } .filter { arten.contains($0.kind) }
.map { UnitOption(value: $0.name, label: $0.name) } .map { UnitOption(value: $0.name, label: $0.name) }
if let size = product.packageSize, size > 0 { if let size = product.packageSize, size > 0 {
// Angezeigt wird "Glas"/"Dose", geschickt wird "package". // Angezeigt wird "Glas"/"Dose", geschickt wird "package".

View File

@@ -50,6 +50,14 @@ struct Product: Codable, Identifiable, Hashable {
let baseUnit: String let baseUnit: String
let packageSize: Double? let packageSize: Double?
let packageLabel: String? 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 groupId: Int?
let minStock: Double? let minStock: Double?
let stock: Double let stock: Double
@@ -98,6 +106,10 @@ struct Product: Codable, Identifiable, Hashable {
case baseUnit = "base_unit" case baseUnit = "base_unit"
case packageSize = "package_size" case packageSize = "package_size"
case packageLabel = "package_label" 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 groupId = "group_id"
case minStock = "min_stock" case minStock = "min_stock"
case expiredCount = "expired_count" case expiredCount = "expired_count"
@@ -134,6 +146,38 @@ struct Product: Codable, Identifiable, Hashable {
var stockInArticleUnits: Double { stock / articleUnitFactor } 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. /// Wie es um den Mindestbestand steht. Beides in Basiseinheiten verglichen.
enum StockLevel { case none, ok, close, below } enum StockLevel { case none, ok, close, below }
@@ -585,6 +629,12 @@ struct ProductUpdateRequest: Encodable {
var individual: Bool? = nil var individual: Bool? = nil
var bulk: Bool? = nil var bulk: Bool? = nil
var sendMode = false 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 { enum CodingKeys: String, CodingKey {
case name, brand, individual, bulk case name, brand, individual, bulk
@@ -600,6 +650,9 @@ struct ProductUpdateRequest: Encodable {
case minStockInPackages = "min_stock_in_packages" case minStockInPackages = "min_stock_in_packages"
case minStockUnitId = "min_stock_unit_id" case minStockUnitId = "min_stock_unit_id"
case baseUnit = "base_unit" case baseUnit = "base_unit"
case secondaryBase = "secondary_base"
case secondaryCount = "secondary_count"
case secondaryAmount = "secondary_amount"
} }
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
@@ -625,6 +678,11 @@ struct ProductUpdateRequest: Encodable {
try container.encode(bulk, forKey: .bulk) try container.encode(bulk, forKey: .bulk)
if let baseUnit { try container.encode(baseUnit, forKey: .baseUnit) } 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)
}
} }
} }

View File

@@ -20,6 +20,10 @@ struct ProductDetailView: View {
@State private var packageSize = "" @State private var packageSize = ""
@State private var packageLabel = "" @State private var packageLabel = ""
@State private var datePrecision = "day" @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 groupId: Int?
@State private var categoryId: Int? @State private var categoryId: Int?
// Verwaltungsart eines Gegenstands: count | individual | bulk umstellbar. // Verwaltungsart eines Gegenstands: count | individual | bulk umstellbar.
@@ -62,6 +66,9 @@ struct ProductDetailView: View {
if packageSize != (current.packageSize.map { formatAmount($0) } ?? "") { return true } if packageSize != (current.packageSize.map { formatAmount($0) } ?? "") { return true }
if packageLabel != (current.packageLabel ?? "") { return true } if packageLabel != (current.packageLabel ?? "") { return true }
if datePrecision != (current.datePrecision == "month" ? "month" : "day") { 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 current.isObject {
if objMode != currentObjMode { return true } if objMode != currentObjMode { return true }
@@ -97,8 +104,12 @@ struct ProductDetailView: View {
} else { } else {
// Lebensmittel und Verbrauchsgegenstände: Vorrat aus den Chargen. // Lebensmittel und Verbrauchsgegenstände: Vorrat aus den Chargen.
Section("Bestand") { Section("Bestand") {
LabeledContent("Vorrat", // Mit Zweiteinheit beide Lesarten: 6 Stück · 500 g".
value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)") 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 { if current.expiredCount > 0 {
LabeledContent("Abgelaufen", value: "\(current.expiredCount)") LabeledContent("Abgelaufen", value: "\(current.expiredCount)")
.foregroundStyle(.red) .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 { Section {
CategoryPicker(categories: categories, selection: $categoryId, CategoryPicker(categories: categories, selection: $categoryId,
tracking: current.tracking) tracking: current.tracking)
@@ -344,6 +379,25 @@ struct ProductDetailView: View {
return teile.joined(separator: " · ") 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. /// Aktuelle Verwaltungsart des geladenen Artikels.
private var currentObjMode: String { private var currentObjMode: String {
current.isIndividual ? "individual" : (current.isBulk ? "bulk" : "count") current.isIndividual ? "individual" : (current.isBulk ? "bulk" : "count")
@@ -355,6 +409,9 @@ struct ProductDetailView: View {
packageSize = current.packageSize.map { formatAmount($0) } ?? "" packageSize = current.packageSize.map { formatAmount($0) } ?? ""
packageLabel = current.packageLabel ?? "" packageLabel = current.packageLabel ?? ""
datePrecision = current.datePrecision == "month" ? "month" : "day" datePrecision = current.datePrecision == "month" ? "month" : "day"
zweitBasis = current.secondaryBase ?? ""
zweitAnzahl = current.secondaryCount.map { formatAmount($0) } ?? ""
zweitMenge = current.secondaryAmount.map { formatAmount($0) } ?? ""
groupId = current.groupId groupId = current.groupId
categoryId = current.categoryId categoryId = current.categoryId
shopId = current.shopId shopId = current.shopId
@@ -435,6 +492,17 @@ struct ProductDetailView: View {
// Der Mindestbestand wird in ProductLocationMinView gepflegt und // Der Mindestbestand wird in ProductLocationMinView gepflegt und
// hier bewusst NICHT mitgeschickt sonst überschriebe das // hier bewusst NICHT mitgeschickt sonst überschriebe das
// Stammdaten-Speichern die dort gesetzte Überall"-Zeile. // 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 { if current.isObject {
var fv: [String: String?] = [:] var fv: [String: String?] = [:]
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" } for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }