Files
Vorrania/ios/Sources/APIClient.swift
Scarriffle 5b952524d7 Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln
Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende:
Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen
mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder
je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine
verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich
ueber Open Products Facts.

Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in
Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:33:56 +02:00

346 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
enum APIError: LocalizedError {
case notConfigured
case unauthorized
case server(String)
var errorDescription: String? {
switch self {
case .notConfigured: return "Server-Adresse fehlt. Bitte in den Einstellungen hinterlegen."
case .unauthorized: return "Nicht angemeldet oder Sitzung abgelaufen."
case .server(let message): return message
}
}
}
/// Schlanker Client für die Vorrania-REST-API.
/// Alle Pfade laufen über /api (wie im Web, siehe web/nginx.conf).
actor APIClient {
static let shared = APIClient()
private var baseURL: URL? { Session.shared.baseURL }
private var token: String? { Session.shared.token }
// MARK: - Basis
private func makeRequest(_ path: String, method: String = "GET") throws -> URLRequest {
guard let baseURL else { throw APIError.notConfigured }
guard let url = URL(string: "api" + path, relativeTo: baseURL) else {
throw APIError.notConfigured
}
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = 15
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
return request
}
private func send<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request)
try check(response, data: data)
return try JSONDecoder().decode(T.self, from: data)
}
private func check(_ response: URLResponse, data: Data) throws {
guard let http = response as? HTTPURLResponse else { return }
guard !(200..<300).contains(http.statusCode) else { return }
if http.statusCode == 401 { throw APIError.unauthorized }
// FastAPI liefert Fehler als {"detail": "..."}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let detail = object["detail"] as? String {
throw APIError.server(detail)
}
throw APIError.server("Serverfehler (\(http.statusCode))")
}
private func jsonBody<T: Encodable>(_ request: inout URLRequest, _ value: T) throws {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(value)
}
// MARK: - Auth
func login(username: String, password: String) async throws -> TokenResponse {
var request = try makeRequest("/auth/login", method: "POST")
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var components = URLComponents()
components.queryItems = [
URLQueryItem(name: "username", value: username),
URLQueryItem(name: "password", value: password),
]
request.httpBody = components.percentEncodedQuery?.data(using: .utf8)
return try await send(request, as: TokenResponse.self)
}
func me() async throws -> MeResponse {
try await send(try makeRequest("/auth/me"), as: MeResponse.self)
}
// MARK: - Stammdaten
func units() async throws -> [Unit] {
try await send(try makeRequest("/units"), as: [Unit].self)
}
func locations() async throws -> [StorageLocation] {
try await send(try makeRequest("/locations"), as: [StorageLocation].self)
}
/// `categoryId == 0` liefert Artikel ohne Kategorie; eine echte ID schliesst
/// die Unterkategorien mit ein.
func searchProducts(_ query: String, categoryId: Int? = nil) async throws -> [Product] {
var parts: [String] = []
if !query.isEmpty {
let escaped = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
parts.append("q=\(escaped)")
}
if let categoryId { parts.append("category_id=\(categoryId)") }
let suffix = parts.isEmpty ? "" : "?" + parts.joined(separator: "&")
return try await send(try makeRequest("/products" + suffix), as: [Product].self)
}
func product(id: Int) async throws -> Product {
try await send(try makeRequest("/products/\(id)"), as: Product.self)
}
func lookup(barcode: String) async throws -> LookupResult {
let escaped = barcode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
return try await send(try makeRequest("/products/lookup?barcode=\(escaped)"), as: LookupResult.self)
}
func createProduct(_ payload: NewProductRequest) async throws -> Product {
var request = try makeRequest("/products", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: Product.self)
}
func updateProduct(id: Int, _ payload: ProductUpdateRequest) async throws -> Product {
var request = try makeRequest("/products/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: Product.self)
}
func groups() async throws -> [GroupItem] {
try await send(try makeRequest("/groups"), as: [GroupItem].self)
}
func categories() async throws -> [CategoryItem] {
try await send(try makeRequest("/categories"), as: [CategoryItem].self)
}
func createGroup(_ payload: NewGroupRequest) async throws -> GroupItem {
var request = try makeRequest("/groups", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: GroupItem.self)
}
func settings() async throws -> [SettingEntry] {
try await send(try makeRequest("/settings"), as: [SettingEntry].self)
}
// MARK: - Listen
func shoppingList() async throws -> [ShoppingItem] {
try await send(try makeRequest("/shopping-list"), as: [ShoppingItem].self)
}
func shoppingGroups() async throws -> [GroupShoppingItem] {
try await send(try makeRequest("/shopping-list/groups"), as: [GroupShoppingItem].self)
}
func expiring(days: Int? = nil) async throws -> [ExpiringItem] {
let path = days.map { "/expiring?days=\($0)" } ?? "/expiring"
return try await send(try makeRequest(path), as: [ExpiringItem].self)
}
// MARK: - Bestand
func lots(productId: Int) async throws -> [Lot] {
try await send(try makeRequest("/lots?product_id=\(productId)"), as: [Lot].self)
}
func checkInBatch(_ payload: BatchCheckInRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/checkin/batch", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
func checkOut(_ payload: CheckOutRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/checkout", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
func updateLot(id: Int, _ payload: LotUpdateRequest) async throws -> Lot {
var request = try makeRequest("/lots/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: Lot.self)
}
func deleteLot(id: Int) async throws {
try await sendNoContent(try makeRequest("/lots/\(id)", method: "DELETE"))
}
/// Fuer Antworten ohne Rumpf (204) - dort gibt es nichts zu dekodieren.
private func sendNoContent(_ request: URLRequest) async throws {
let (data, response) = try await URLSession.shared.data(for: request)
try check(response, data: data)
}
// MARK: - Gegenstaende (Non-Food)
func shops() async throws -> [ShopItem] {
try await send(try makeRequest("/shops"), as: [ShopItem].self)
}
/// Effektive (vererbte) Felder einer Kategorie fuer das Artikelformular.
func categoryFields(categoryId: Int) async throws -> [FieldDefinition] {
try await send(try makeRequest("/categories/\(categoryId)/fields"), as: [FieldDefinition].self)
}
/// Menge eines Gegenstands an einem Lagerort erhoehen.
func objectCheckIn(_ payload: ObjectCheckInRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/checkin", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
/// Menge von einem Lagerort zum anderen umbuchen (ohne Grund).
func relocate(_ payload: RelocateRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/relocate", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
/// Menge mit Pflicht-Grund aus dem Bestand entfernen.
func removeStock(_ payload: RemoveRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/remove", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
/// Entnahme-Statistik (Summe je Grund) und die juengsten Entnahmen.
func productRemovals(id: Int) async throws -> RemovalSummary {
try await send(try makeRequest("/products/\(id)/removals"), as: RemovalSummary.self)
}
// MARK: - Uebersicht und Verlauf
func dashboardStats() async throws -> DashboardStats {
try await send(try makeRequest("/dashboard/stats"), as: DashboardStats.self)
}
/// Die Dashboards des Benutzers - dieselben wie in der Web-Oberflaeche.
func dashboards() async throws -> DashboardList {
try await send(try makeRequest("/dashboard/layouts"), as: DashboardList.self)
}
// MARK: - Diagrammdaten
func expirySplit() async throws -> ExpirySplit {
try await send(try makeRequest("/dashboard/expiry-split"), as: ExpirySplit.self)
}
func byCategory() async throws -> [CategoryShare] {
try await send(try makeRequest("/dashboard/by-category"), as: [CategoryShare].self)
}
func timeline(days: Int, productId: Int? = nil) async throws -> [TimelinePoint] {
var path = "/dashboard/timeline?days=\(days)"
if let productId { path += "&product_id=\(productId)" }
return try await send(try makeRequest(path), as: [TimelinePoint].self)
}
func activity(days: Int) async throws -> [ActivityPoint] {
try await send(try makeRequest("/dashboard/activity?days=\(days)"), as: [ActivityPoint].self)
}
/// Neueste zuerst. `productId` filtert auf einen Artikel.
func movements(limit: Int = 100, productId: Int? = nil) async throws -> [Movement] {
var path = "/movements?limit=\(limit)"
if let productId { path += "&product_id=\(productId)" }
return try await send(try makeRequest(path), as: [Movement].self)
}
// MARK: - Stammdaten
func createLocation(_ payload: NewLocationRequest) async throws -> StorageLocation {
var request = try makeRequest("/locations", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StorageLocation.self)
}
func renameLocation(id: Int, name: String) async throws -> StorageLocation {
var request = try makeRequest("/locations/\(id)", method: "PATCH")
try jsonBody(&request, RenameRequest(name: name))
return try await send(request, as: StorageLocation.self)
}
func deleteLocation(id: Int) async throws {
try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE"))
}
func createUnit(_ payload: NewUnitRequest) async throws -> Unit {
var request = try makeRequest("/units", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: Unit.self)
}
func renameUnit(id: Int, name: String) async throws -> Unit {
var request = try makeRequest("/units/\(id)", method: "PATCH")
try jsonBody(&request, RenameRequest(name: name))
return try await send(request, as: Unit.self)
}
func deleteUnit(id: Int) async throws {
try await sendNoContent(try makeRequest("/units/\(id)", method: "DELETE"))
}
func packageTypes() async throws -> [PackageType] {
try await send(try makeRequest("/package-types"), as: [PackageType].self)
}
func createPackageType(_ payload: NewPackageTypeRequest) async throws -> PackageType {
var request = try makeRequest("/package-types", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: PackageType.self)
}
func updatePackageType(id: Int, _ payload: PackageTypeUpdateRequest) async throws -> PackageType {
var request = try makeRequest("/package-types/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: PackageType.self)
}
func deletePackageType(id: Int) async throws {
try await sendNoContent(try makeRequest("/package-types/\(id)", method: "DELETE"))
}
func createCategory(_ payload: NewCategoryRequest) async throws -> CategoryItem {
var request = try makeRequest("/categories", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: CategoryItem.self)
}
func renameCategory(id: Int, name: String) async throws -> CategoryItem {
var request = try makeRequest("/categories/\(id)", method: "PATCH")
try jsonBody(&request, RenameRequest(name: name))
return try await send(request, as: CategoryItem.self)
}
func deleteCategory(id: Int) async throws {
try await sendNoContent(try makeRequest("/categories/\(id)", method: "DELETE"))
}
func renameGroup(id: Int, name: String) async throws -> GroupItem {
var request = try makeRequest("/groups/\(id)", method: "PATCH")
try jsonBody(&request, RenameRequest(name: name))
return try await send(request, as: GroupItem.self)
}
func deleteGroup(id: Int) async throws {
try await sendNoContent(try makeRequest("/groups/\(id)", method: "DELETE"))
}
}