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(_ 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(_ 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) } /// Aktuelles Artikelfoto laden (mit Anmeldung). Gibt nil bei 404 zurück. func productImage(id: Int) async throws -> Data? { let request = try makeRequest("/products/\(id)/image") let (data, response) = try await URLSession.shared.data(for: request) if let http = response as? HTTPURLResponse, http.statusCode == 404 { return nil } try check(response, data: data) return data } /// Eigenes Foto (Kamera/Galerie) als multipart hochladen. func uploadProductImage(id: Int, data: Data, contentType: String) async throws -> Product { var request = try makeRequest("/products/\(id)/image", method: "PUT") let boundary = "Boundary-\(UUID().uuidString)" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") let ext = contentType == "image/png" ? "png" : "jpg" var body = Data() body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"file\"; filename=\"foto.\(ext)\"\r\n" .data(using: .utf8)!) body.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!) body.append(data) body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) request.httpBody = body return try await send(request, as: Product.self) } func deleteProductImage(id: Int) async throws { try await sendNoContent(try makeRequest("/products/\(id)/image", method: "DELETE")) } // MARK: - Einzelstücke (Items mit UID/QR) func items(productId: Int) async throws -> [Item] { try await send(try makeRequest("/products/\(productId)/items"), as: [Item].self) } func createItems(productId: Int, _ payload: ItemCreateRequest) async throws -> [Item] { var request = try makeRequest("/products/\(productId)/items", method: "POST") try jsonBody(&request, payload) return try await send(request, as: [Item].self) } /// QR-Auflösung: UID → Einzelstück. func itemByUid(uid: String) async throws -> Item { let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid return try await send(try makeRequest("/items/by-uid/\(escaped)"), as: Item.self) } func updateItem(id: Int, _ payload: ItemUpdateRequest) async throws -> Item { var request = try makeRequest("/items/\(id)", method: "PATCH") try jsonBody(&request, payload) return try await send(request, as: Item.self) } func removeItem(id: Int, _ payload: ItemRemoveRequest) async throws { var request = try makeRequest("/items/\(id)/remove", method: "POST") try jsonBody(&request, payload) try await sendNoContent(request) } func deleteItem(id: Int) async throws { try await sendNoContent(try makeRequest("/items/\(id)", method: "DELETE")) } 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) } func createShop(_ payload: NewShopRequest) async throws -> ShopItem { var request = try makeRequest("/shops", method: "POST") try jsonBody(&request, payload) return try await send(request, as: ShopItem.self) } func updateShop(id: Int, _ payload: ShopUpdateRequest) async throws -> ShopItem { var request = try makeRequest("/shops/\(id)", method: "PATCH") try jsonBody(&request, payload) return try await send(request, as: ShopItem.self) } func deleteShop(id: Int) async throws { try await sendNoContent(try makeRequest("/shops/\(id)", method: "DELETE")) } /// 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")) } }