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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 20:51:07 +02:00
parent f287f8f768
commit 72fa2ce37d
3 changed files with 116 additions and 52 deletions

View File

@@ -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) }
}
}
}

View File

@@ -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,33 +326,13 @@ struct ProductDetailView: View {
busy = true
defer { busy = false }
do {
if current.isObject {
// Gegenstände: keine Lebensmittel-Felder, dafür Shop, Link und eigene Felder.
var fv: [String: String?] = [:]
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
current = try await APIClient.shared.updateProduct(
id: current.id,
ProductUpdateRequest(
name: name,
brand: brand.isEmpty ? nil : brand,
packageSize: nil, packageLabel: nil, datePrecision: nil,
groupId: nil, 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 },
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(
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: ".")),
@@ -349,7 +341,40 @@ struct ProductDetailView: View {
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(
id: current.id,
ProductUpdateRequest(
name: name,
brand: brand.isEmpty ? nil : brand,
packageSize: nil, packageLabel: nil, datePrecision: nil,
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: minBase,
minStockInPackages: (current.packageSize ?? 0) > 0,
minStockUnitId: nil,
sendMinStock: true
)
)
fieldValues = Self.stringValues(current.fieldValues)
}
status = "Gespeichert."
} catch {

View File

@@ -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,12 +108,18 @@ 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."
: 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.")
}
@@ -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)