Die App konnte fuenf Dinge, die Web-Oberflaeche vierzehn. Alles darueber hinaus
- nachsehen was gestern passiert ist, einen Lagerort anlegen - zwang zurueck an
den Browser.
Mehrere Server: Statt einer Adresse und einem Token kennt die App jetzt
beliebig viele Profile, jedes mit eigenem Token im Keychain. Ein Wechsel ist
damit ein Tipp und meldet an keinem der Server ab. Jedes Profil hat einen frei
waehlbaren Namen ("Zuhause"), der oben in der App steht; ohne Angabe faellt er
auf den Rechnernamen zurueck. Eine bestehende Anmeldung wird beim ersten Start
in ein Profil ueberfuehrt - ohne das waere man nach dem Update abgemeldet.
Statt einer Startseite mit Kacheln gibt es vier Tabs: Start, Scannen, Listen
und - nur fuer Administratoren, wie im Web - Verwaltung. Neu darin sind eine
Uebersicht mit den Kennzahlen des Servers, die per Tipp in die passende Liste
fuehren, der Bewegungsverlauf (auch je Artikel, weil /movements danach filtern
kann) und die Stammdaten: Lagerorte, Einheiten, Gebinde, Kategorien und
Gruppen. Die fuenf teilen sich eine Ansicht und unterscheiden sich nur darin,
wie geladen und geschrieben wird.
Zwei Dinge, die beim Pruefen gegen einen echten Server auffielen: Der Server
schickt Zeitstempel in UTC, haengt hinter SQLite aber keine Zeitzone an. Naiv
gelesen haette die App jede Uhrzeit um die eigene Zeitverschiebung daneben
angezeigt, deshalb wird die nackte Form ausdruecklich als UTC gelesen. Und die
Anzeigeeinstellungen gehoeren dem Server, werden beim Wechsel also neu geladen.
Bewusst nicht dabei: Benutzer, Einstellungen, Branding und Import/Export. Die
bleiben vorerst im Web.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
539 lines
14 KiB
Swift
539 lines
14 KiB
Swift
import Foundation
|
|
|
|
// Entspricht den Schemas des Backends (backend/app/schemas.py).
|
|
|
|
struct TokenResponse: Codable {
|
|
let accessToken: String
|
|
let role: String
|
|
let username: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case accessToken = "access_token"
|
|
case role, username
|
|
}
|
|
}
|
|
|
|
struct MeResponse: Codable {
|
|
let id: Int
|
|
let username: String
|
|
let role: String
|
|
|
|
var isAdmin: Bool { role == "admin" }
|
|
}
|
|
|
|
struct Unit: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let name: String
|
|
let kind: String
|
|
let factor: Double
|
|
/// Eingebaute Einheiten lassen sich umbenennen, aber nicht loeschen.
|
|
let isBuiltin: Bool
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, name, kind, factor
|
|
case isBuiltin = "is_builtin"
|
|
}
|
|
}
|
|
|
|
struct BarcodeEntry: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let code: String
|
|
let note: String?
|
|
}
|
|
|
|
struct Product: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let barcode: String?
|
|
let name: String
|
|
let brand: String?
|
|
let imageUrl: String?
|
|
let baseUnit: String
|
|
let packageSize: Double?
|
|
let packageLabel: String?
|
|
let groupId: Int?
|
|
let minStock: Double?
|
|
let stock: Double
|
|
let expiredCount: Int
|
|
let kind: String
|
|
let unitName: String
|
|
let unitFactor: Double
|
|
let barcodes: [BarcodeEntry]
|
|
/// Welche MHD-Genauigkeit bei diesem Produkt ueblich ist ("day"/"month").
|
|
let datePrecision: String?
|
|
/// Kategorie: reine Ordnungshilfe fuer die Artikelliste (nicht die Gruppe).
|
|
let categoryId: Int?
|
|
let categoryName: String?
|
|
/// Mindestbestand in der erfassten Einheit, fuer die Anzeige.
|
|
let minStockDisplay: Double?
|
|
let minStockUnitLabel: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, barcode, name, brand, stock, kind, barcodes
|
|
case datePrecision = "date_precision"
|
|
case categoryId = "category_id"
|
|
case categoryName = "category_name"
|
|
case minStockDisplay = "min_stock_display"
|
|
case minStockUnitLabel = "min_stock_unit_label"
|
|
case imageUrl = "image_url"
|
|
case baseUnit = "base_unit"
|
|
case packageSize = "package_size"
|
|
case packageLabel = "package_label"
|
|
case groupId = "group_id"
|
|
case minStock = "min_stock"
|
|
case expiredCount = "expired_count"
|
|
case unitName = "unit_name"
|
|
case unitFactor = "unit_factor"
|
|
}
|
|
|
|
/// Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit).
|
|
var articleUnitLabel: String {
|
|
if let size = packageSize, size > 0 { return packageLabel ?? "Packung" }
|
|
return unitName
|
|
}
|
|
|
|
/// Faktor, um Basiseinheiten in Artikeleinheiten umzurechnen.
|
|
var articleUnitFactor: Double {
|
|
if let size = packageSize, size > 0 { return size }
|
|
return unitFactor == 0 ? 1 : unitFactor
|
|
}
|
|
|
|
var stockInArticleUnits: Double { stock / articleUnitFactor }
|
|
|
|
/// Wie es um den Mindestbestand steht. Beides in Basiseinheiten verglichen.
|
|
enum StockLevel { case none, ok, close, below }
|
|
|
|
var stockLevel: StockLevel {
|
|
guard let minimum = minStock, minimum > 0 else { return .none }
|
|
if stock < minimum { return .below }
|
|
// Weniger als ein Viertel Luft: bald nachkaufen.
|
|
return stock < minimum * 1.25 ? .close : .ok
|
|
}
|
|
}
|
|
|
|
/// Auswahleintrag fuer Einheiten.
|
|
///
|
|
/// Anzeige und uebertragener Wert sind bewusst getrennt: Das Backend kennt
|
|
/// fuer das Gebinde nur das Schluesselwort "package" (siehe
|
|
/// services/conversion.py). Die Bezeichnung "Glas" oder "Dose" ist reine
|
|
/// Anzeige - wird sie mitgeschickt, antwortet der Server
|
|
/// "Unbekannte Einheit: Glas".
|
|
struct UnitOption: Hashable, Identifiable {
|
|
let value: String
|
|
let label: String
|
|
var id: String { value }
|
|
|
|
static let packageValue = "package"
|
|
}
|
|
|
|
struct LookupResult: Codable {
|
|
let found: Bool
|
|
let existingProduct: Product?
|
|
let suggestion: Suggestion?
|
|
/// Gruppe: nur gesetzt, wenn der Code dort ausdruecklich hinterlegt ist.
|
|
let groupId: Int?
|
|
let groupName: String?
|
|
/// Kategorie: aus der Open-Food-Facts-Einordnung vorgeschlagen.
|
|
let categoryId: Int?
|
|
let categoryName: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case found, suggestion
|
|
case existingProduct = "existing_product"
|
|
case groupId = "group_id"
|
|
case groupName = "group_name"
|
|
case categoryId = "category_id"
|
|
case categoryName = "category_name"
|
|
}
|
|
|
|
struct Suggestion: Codable {
|
|
let barcode: String?
|
|
let name: String
|
|
let brand: String?
|
|
let imageUrl: String?
|
|
let baseUnit: String?
|
|
let packageSize: Double?
|
|
let quantityText: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case barcode, name, brand
|
|
case imageUrl = "image_url"
|
|
case baseUnit = "base_unit"
|
|
case packageSize = "package_size"
|
|
case quantityText = "quantity_text"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Lot: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let productId: Int
|
|
let quantity: Double
|
|
let bestBefore: String?
|
|
let bestBeforePrecision: String?
|
|
let locationId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, quantity
|
|
case productId = "product_id"
|
|
case bestBefore = "best_before"
|
|
case bestBeforePrecision = "best_before_precision"
|
|
case locationId = "location_id"
|
|
}
|
|
}
|
|
|
|
struct StorageLocation: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let name: String
|
|
let parentId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, name
|
|
case parentId = "parent_id"
|
|
}
|
|
}
|
|
|
|
struct CheckInLine: Codable {
|
|
let quantity: Double
|
|
let bestBefore: String?
|
|
let bestBeforePrecision: String
|
|
let locationId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case quantity
|
|
case bestBefore = "best_before"
|
|
case bestBeforePrecision = "best_before_precision"
|
|
case locationId = "location_id"
|
|
}
|
|
}
|
|
|
|
struct BatchCheckInRequest: Codable {
|
|
let productId: Int
|
|
let unit: String
|
|
let lines: [CheckInLine]
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case unit, lines
|
|
case productId = "product_id"
|
|
}
|
|
}
|
|
|
|
struct CheckOutRequest: Codable {
|
|
let productId: Int
|
|
let quantity: Double
|
|
let unit: String
|
|
let lotId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case quantity, unit
|
|
case productId = "product_id"
|
|
case lotId = "lot_id"
|
|
}
|
|
}
|
|
|
|
struct StockResponse: Codable {
|
|
let productStock: Double
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case productStock = "product_stock"
|
|
}
|
|
}
|
|
|
|
struct NewProductRequest: Codable {
|
|
let barcode: String?
|
|
let name: String
|
|
let brand: String?
|
|
let imageUrl: String?
|
|
let baseUnit: String
|
|
let packageSize: Double?
|
|
let packageLabel: String?
|
|
let datePrecision: String
|
|
let groupId: Int?
|
|
let categoryId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case barcode, name, brand
|
|
case imageUrl = "image_url"
|
|
case baseUnit = "base_unit"
|
|
case packageSize = "package_size"
|
|
case packageLabel = "package_label"
|
|
case datePrecision = "date_precision"
|
|
case groupId = "group_id"
|
|
case categoryId = "category_id"
|
|
}
|
|
}
|
|
|
|
// MARK: - Listen (Einkaufsliste, bald ablaufend)
|
|
|
|
struct ShoppingItem: Codable, Identifiable {
|
|
let productId: Int
|
|
let name: String
|
|
let baseUnit: String
|
|
let packageSize: Double?
|
|
let stock: Double
|
|
let minStock: Double
|
|
let deficit: Double
|
|
|
|
var id: Int { productId }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name, stock, deficit
|
|
case productId = "product_id"
|
|
case baseUnit = "base_unit"
|
|
case packageSize = "package_size"
|
|
case minStock = "min_stock"
|
|
}
|
|
}
|
|
|
|
struct GroupShoppingItem: Codable, Identifiable {
|
|
let groupId: Int
|
|
let name: String
|
|
let stock: Double
|
|
let minStock: Double
|
|
let deficit: Double
|
|
let unitName: String
|
|
let productCount: Int
|
|
|
|
var id: Int { groupId }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name, stock, deficit
|
|
case groupId = "group_id"
|
|
case minStock = "min_stock"
|
|
case unitName = "unit_name"
|
|
case productCount = "product_count"
|
|
}
|
|
}
|
|
|
|
struct ExpiringItem: Codable, Identifiable {
|
|
let lotId: Int
|
|
let productId: Int
|
|
let productName: String
|
|
let quantity: Double
|
|
let baseUnit: String
|
|
let bestBefore: String
|
|
let bestBeforePrecision: String?
|
|
let daysLeft: Int
|
|
let packageSize: Double?
|
|
let packageLabel: String?
|
|
let unitName: String
|
|
let unitFactor: Double
|
|
|
|
var id: Int { lotId }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case quantity
|
|
case lotId = "lot_id"
|
|
case productId = "product_id"
|
|
case productName = "product_name"
|
|
case baseUnit = "base_unit"
|
|
case bestBefore = "best_before"
|
|
case bestBeforePrecision = "best_before_precision"
|
|
case daysLeft = "days_left"
|
|
case packageSize = "package_size"
|
|
case packageLabel = "package_label"
|
|
case unitName = "unit_name"
|
|
case unitFactor = "unit_factor"
|
|
}
|
|
}
|
|
|
|
// MARK: - Aenderungen
|
|
|
|
/// Die Felder, die das Produktformular der App besitzt (PATCH).
|
|
///
|
|
/// Bewusst mit eigener Kodierung: Swift laesst `nil`-Optionals sonst einfach
|
|
/// weg, und das Backend wertet nur mitgeschickte Felder aus. Leeren waere damit
|
|
/// unmoeglich - "keine Kategorie" oder eine geloeschte Marke wuerden stumm
|
|
/// verpuffen. Felder, die die App nicht bearbeitet (etwa der Mindestbestand),
|
|
/// stehen deshalb gar nicht erst hier drin.
|
|
struct ProductUpdateRequest: Encodable {
|
|
var name: String?
|
|
var brand: String?
|
|
var packageSize: Double?
|
|
var packageLabel: String?
|
|
var datePrecision: String?
|
|
var groupId: Int?
|
|
var categoryId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name, brand
|
|
case packageSize = "package_size"
|
|
case packageLabel = "package_label"
|
|
case datePrecision = "date_precision"
|
|
case groupId = "group_id"
|
|
case categoryId = "category_id"
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(name, forKey: .name)
|
|
try container.encode(brand, forKey: .brand)
|
|
try container.encode(packageSize, forKey: .packageSize)
|
|
try container.encode(packageLabel, forKey: .packageLabel)
|
|
try container.encode(datePrecision, forKey: .datePrecision)
|
|
try container.encode(groupId, forKey: .groupId)
|
|
try container.encode(categoryId, forKey: .categoryId)
|
|
}
|
|
}
|
|
|
|
struct LotUpdateRequest: Codable {
|
|
var quantity: Double?
|
|
var bestBefore: String?
|
|
var bestBeforePrecision: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case quantity
|
|
case bestBefore = "best_before"
|
|
case bestBeforePrecision = "best_before_precision"
|
|
}
|
|
}
|
|
|
|
/// Ein Eintrag aus /settings (Schluessel/Wert).
|
|
struct SettingEntry: Codable {
|
|
let key: String
|
|
let value: String
|
|
}
|
|
|
|
/// Gruppe: fasst Bestaende mehrerer Marken zusammen (z.B. "Mehl").
|
|
struct GroupItem: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let name: String
|
|
}
|
|
|
|
/// Kategorie: ordnet nur die Artikelliste, verschachtelbar (Suesswaren -> Schokolade).
|
|
struct CategoryItem: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let name: String
|
|
let parentId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, name
|
|
case parentId = "parent_id"
|
|
}
|
|
}
|
|
|
|
// MARK: - Uebersicht und Verlauf
|
|
|
|
/// Kennzahlen der Startseite (GET /dashboard/stats).
|
|
struct DashboardStats: Codable {
|
|
let productsInStock: Int
|
|
let articleUnits: Double
|
|
let expiringSoon: Int
|
|
let expired: Int
|
|
let shoppingItems: Int
|
|
let productsTotal: Int
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case productsInStock = "products_in_stock"
|
|
case articleUnits = "article_units"
|
|
case expiringSoon = "expiring_soon"
|
|
case expired
|
|
case shoppingItems = "shopping_items"
|
|
case productsTotal = "products_total"
|
|
}
|
|
}
|
|
|
|
/// Ein Eintrag im Bewegungsverlauf (GET /movements).
|
|
struct Movement: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let productId: Int
|
|
let productName: String
|
|
/// "in" oder "out"
|
|
let type: String
|
|
/// in Basiseinheiten
|
|
let quantity: Double
|
|
let baseUnit: String
|
|
let unitUsed: String
|
|
let username: String?
|
|
let note: String?
|
|
let createdAt: String
|
|
let packageSize: Double?
|
|
let packageLabel: String?
|
|
let unitName: String
|
|
let unitFactor: Double
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, type, quantity, username, note
|
|
case productId = "product_id"
|
|
case productName = "product_name"
|
|
case baseUnit = "base_unit"
|
|
case unitUsed = "unit_used"
|
|
case createdAt = "created_at"
|
|
case packageSize = "package_size"
|
|
case packageLabel = "package_label"
|
|
case unitName = "unit_name"
|
|
case unitFactor = "unit_factor"
|
|
}
|
|
|
|
var isIncoming: Bool { type == "in" }
|
|
}
|
|
|
|
// MARK: - Stammdaten
|
|
|
|
/// Gebinde (Packung, Glas, ...) mit Einzahl und Mehrzahl.
|
|
struct PackageType: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let singular: String
|
|
let plural: String
|
|
let isBuiltin: Bool
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, singular, plural
|
|
case isBuiltin = "is_builtin"
|
|
}
|
|
}
|
|
|
|
struct NewLocationRequest: Codable {
|
|
let name: String
|
|
let parentId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name
|
|
case parentId = "parent_id"
|
|
}
|
|
}
|
|
|
|
struct NewUnitRequest: Codable {
|
|
let name: String
|
|
let kind: String
|
|
let factor: Double
|
|
}
|
|
|
|
struct NewPackageTypeRequest: Codable {
|
|
let singular: String
|
|
let plural: String
|
|
}
|
|
|
|
struct NewCategoryRequest: Codable {
|
|
let name: String
|
|
let parentId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name
|
|
case parentId = "parent_id"
|
|
}
|
|
}
|
|
|
|
/// Umbenennen von Stammdaten. Ein einzelnes Feld reicht, weil das Backend nur
|
|
/// mitgeschickte Schluessel auswertet.
|
|
struct RenameRequest: Codable {
|
|
let name: String
|
|
}
|
|
|
|
struct PackageTypeUpdateRequest: Codable {
|
|
let singular: String
|
|
let plural: String
|
|
}
|
|
|
|
/// Neue Gruppe anlegen (nur fuer Administratoren).
|
|
struct NewGroupRequest: Codable {
|
|
let name: String
|
|
let minStock: Double?
|
|
let minStockUnitId: Int?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name
|
|
case minStock = "min_stock"
|
|
case minStockUnitId = "min_stock_unit_id"
|
|
}
|
|
}
|