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>
284 lines
11 KiB
Swift
284 lines
11 KiB
Swift
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: - Uebersicht und Verlauf
|
|
|
|
func dashboardStats() async throws -> DashboardStats {
|
|
try await send(try makeRequest("/dashboard/stats"), as: DashboardStats.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"))
|
|
}
|
|
}
|