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:
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user