Die Einkaufsliste besteht aus zwei Haelften: gerechnete Bedarfe (Artikel
und Gruppen unter ihrem Mindestbestand, Bedarfe je Lagerort) und das,
was jemand selbst draufgesetzt hat - eigene Eintraege und die aus einem
Rezept uebernommenen Zutaten.
Die App kannte nur die erste Haelfte. Sie fragte /shopping-list,
/shopping-list/groups und /shopping-list/by-location ab - drei Routen,
die ausschliesslich rechnen. Die gespeicherten Eintraege liegen in
/shopping-list/entries und kommen gebuendelt ueber /shopping-list/all;
danach hat die App nie gefragt. Ergebnis: Man setzt im Web etwas auf die
Liste, nimmt das Telefon mit in den Laden, und es ist nicht da.
Jetzt holt die App die gebuendelte Route - ein Aufruf statt drei - und
zeigt die eigenen Posten in einem eigenen Abschnitt "Selbst
draufgesetzt", mit Herkunft ("fuer Pfannkuchen") und Menge. Antippen
hakt ab: Der Posten verschwindet sofort, geht das Speichern schief,
kommt er zurueck - lieber ein Ruecksprung als ein falsches Haekchen.
Dieselbe Blindstelle hatte die Dashboard-Karte der App; sie meldete
"alle Mindestbestaende sind gedeckt", waehrend Posten auf der Liste
standen. Auch sie nimmt jetzt die gebuendelte Route.
Dazu ein Hinweis im Docstring von GET /shopping-list: Diese Route ist
ein Drittel der Liste, wer "die Liste" will, nimmt /all. Die Aufteilung
hat in zwei Tagen zweimal jemanden glauben lassen, es sei nichts da -
beim zweiten Mal war es die App selbst.
Gegen den laufenden Server geprueft: Mit einem Eintrag "Backpapier"
liefert /shopping-list weiterhin [] und /shopping-list/all den Eintrag
samt art, name, menge_text, source, recipe_name und done - genau die
Felder, die das neue Swift-Modell liest.
Nicht kompiliert: kein Xcode auf dieser Maschine. Geprueft sind
Klammer- und Anfuehrungsbalance, dass die drei alten Routen niemand mehr
aufruft, und dass die benutzten Hilfsfunktionen (makeRequest, jsonBody,
send) wirklich so heissen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
716 lines
31 KiB
Swift
716 lines
31 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 daten = try await sendeMitErneuerung(request)
|
||
return try JSONDecoder().decode(T.self, from: daten)
|
||
}
|
||
|
||
/// Fuehrt den Aufruf aus und wiederholt ihn EINMAL, wenn das Vorrania-Token
|
||
/// abgelaufen ist und sich die Anmeldung still erneuern laesst.
|
||
///
|
||
/// Betrifft nur Anmeldungen ueber einen Anmeldedienst: dort liegt ein
|
||
/// Refresh-Token auf dem Geraet. Nach einer Passwort-Anmeldung gibt es
|
||
/// nichts zu erneuern, dann bleibt es beim 401 wie bisher.
|
||
private func sendeMitErneuerung(_ request: URLRequest) async throws -> Data {
|
||
let (daten, antwort) = try await URLSession.shared.data(for: request)
|
||
do {
|
||
try check(antwort, data: daten)
|
||
return daten
|
||
} catch APIError.unauthorized {
|
||
guard await erneuereAnmeldung() else { throw APIError.unauthorized }
|
||
var wiederholung = request
|
||
if let token {
|
||
wiederholung.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||
}
|
||
let (daten2, antwort2) = try await URLSession.shared.data(for: wiederholung)
|
||
try check(antwort2, data: daten2)
|
||
return daten2
|
||
}
|
||
}
|
||
|
||
/// Laufende Erneuerung, damit zehn gleichzeitig abgewiesene Aufrufe nicht
|
||
/// zehn Erneuerungen ausloesen. Der Actor serialisiert bis zum ersten await,
|
||
/// wer hier ankommt, haengt sich also an eine bereits laufende an.
|
||
private var laufendeErneuerung: Task<Bool, Never>?
|
||
|
||
private func erneuereAnmeldung() async -> Bool {
|
||
if let laufend = laufendeErneuerung { return await laufend.value }
|
||
let aufgabe = Task<Bool, Never> { await Self.erneuereJetzt() }
|
||
laufendeErneuerung = aufgabe
|
||
defer { laufendeErneuerung = nil }
|
||
return await aufgabe.value
|
||
}
|
||
|
||
private static func erneuereJetzt() async -> Bool {
|
||
let sitzung = Session.shared
|
||
guard let profil = sitzung.activeProfile,
|
||
let slug = profil.oidcSlug, !slug.isEmpty else { return false }
|
||
do {
|
||
let idToken = try await OIDCLogin.shared.frischesIdToken(fuer: profil.id)
|
||
let token = try await APIClient.shared.oidcNativeLogin(slug: slug, idToken: idToken)
|
||
await MainActor.run {
|
||
sitzung.store(token: token.accessToken, username: token.username,
|
||
isAdmin: token.role == "admin", persist: sitzung.stayLoggedIn,
|
||
oidcSlug: slug)
|
||
}
|
||
return true
|
||
} catch {
|
||
// Refresh-Token abgelaufen oder zurueckgezogen: normale Neuanmeldung.
|
||
return false
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
/// Schiebt die Frist der laufenden Sitzung nach vorn.
|
||
///
|
||
/// Ohne das zaehlt die am Server eingestellte Dauer ab dem *Anmelden*: Wer
|
||
/// die App taeglich benutzt, fliegt trotzdem punktgenau nach Ablauf hinaus.
|
||
///
|
||
/// Bewusst ohne die Erneuerungsschicht: Ein 401 heisst hier, dass die
|
||
/// Sitzung wirklich vorbei ist – dann gehoert sie beendet und nicht ueber
|
||
/// einen Umweg gerettet.
|
||
func sitzungVerlaengern() async throws -> TokenResponse {
|
||
let request = try makeRequest("/auth/refresh", method: "POST")
|
||
let (daten, antwort) = try await URLSession.shared.data(for: request)
|
||
try check(antwort, data: daten)
|
||
return try JSONDecoder().decode(TokenResponse.self, from: daten)
|
||
}
|
||
|
||
/// Am Server eingerichtete Anmeldedienste. Leere Liste = nur Passwort-Login.
|
||
func oidcProviders() async throws -> [OidcProviderResponse] {
|
||
let (daten, antwort) = try await URLSession.shared.data(
|
||
for: try makeRequest("/auth/oidc/providers")
|
||
)
|
||
try check(antwort, data: daten)
|
||
return try JSONDecoder().decode([OidcProviderResponse].self, from: daten)
|
||
}
|
||
|
||
/// Tauscht das ID-Token des Anmeldedienstes gegen ein Vorrania-Token.
|
||
///
|
||
/// Bewusst ohne die Erneuerungsschicht: dieser Aufruf IST die Erneuerung,
|
||
/// ein 401 hier muss zur normalen Anmeldung fuehren.
|
||
func oidcNativeLogin(slug: String, idToken: String, nonce: String? = nil) async throws -> TokenResponse {
|
||
var request = try makeRequest("/auth/oidc/\(slug)/native", method: "POST")
|
||
try jsonBody(&request, OidcNativeLoginBody(idToken: idToken, nonce: nonce))
|
||
let (daten, antwort) = try await URLSession.shared.data(for: request)
|
||
try check(antwort, data: daten)
|
||
return try JSONDecoder().decode(TokenResponse.self, from: daten)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
/// Alles, was an einem Lagerort (inkl. Unterorte) liegt – Ziel des /l/-QR beim Nachschlagen.
|
||
func locationContents(code: String) async throws -> LocationContents {
|
||
try await send(try makeRequest("/location/\(code)"), as: LocationContents.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)
|
||
}
|
||
|
||
/// Mindestbestände je Lagerort ersetzen (Menge in Artikeleinheiten).
|
||
func setProductLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> Product {
|
||
var request = try makeRequest("/products/\(id)/location-min-stock", method: "PUT")
|
||
try jsonBody(&request, list)
|
||
return try await send(request, as: Product.self)
|
||
}
|
||
|
||
/// Aktuelles Artikelfoto laden (mit Anmeldung). Gibt nil bei 404 zurück.
|
||
///
|
||
/// `thumb: true` holt die Briefmarken-Fassung (wenige KB statt mehrerer MB).
|
||
/// Für Listen ist das Pflicht, nicht Kür: Ein Vorschaubild ist 34x34 Punkte
|
||
/// groß, und `preload` läuft über die ganze Artikelliste.
|
||
func productImage(id: Int, thumb: Bool = false) async throws -> Data? {
|
||
let request = try makeRequest("/products/\(id)/image" + (thumb ? "?thumb=1" : ""))
|
||
let (data, response) = try await URLSession.shared.data(for: request)
|
||
if let http = response as? HTTPURLResponse, http.statusCode == 404 { return nil }
|
||
do {
|
||
try check(response, data: data)
|
||
return data
|
||
} catch APIError.unauthorized {
|
||
return try await sendeMitErneuerung(request)
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Alle Einzelstücke über alle Produkte – für die Einzelstück-Liste.
|
||
func allItems() async throws -> [Item] {
|
||
try await send(try makeRequest("/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)
|
||
}
|
||
|
||
/// Ein Einzelstueck frisch holen - nach dem Zuordnen von Zubehoer ist das
|
||
/// urspruenglich uebergebene Objekt veraltet, die Ansicht aber noch offen.
|
||
func item(id: Int) async throws -> Item {
|
||
try await send(try makeRequest("/items/\(id)"), 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)
|
||
}
|
||
|
||
// MARK: - Verleih von Einzelstuecken
|
||
|
||
/// Zubehoer zuordnen. Eigener Endpunkt und nicht ueber updateItem: Dessen
|
||
/// Anfrage sendet bewusst alle Felder mit, auch leere - eine Beziehung
|
||
/// darueber zu pflegen hiesse, sie bei einem vergessenen Feld zu kappen.
|
||
func zubehoerZuordnen(itemId: Int, childItemId: Int) async throws -> Item {
|
||
var request = try makeRequest("/items/\(itemId)/zubehoer", method: "POST")
|
||
try jsonBody(&request, ZubehoerZuordnenRequest(childItemId: childItemId))
|
||
return try await send(request, as: Item.self)
|
||
}
|
||
|
||
func zubehoerLoesen(itemId: Int, childItemId: Int) async throws {
|
||
try await sendNoContent(
|
||
try makeRequest("/items/\(itemId)/zubehoer/\(childItemId)", method: "DELETE")
|
||
)
|
||
}
|
||
|
||
/// Herausgeben. Scheitert mit 409, wenn das Stueck schon verliehen ist.
|
||
func lendItem(id: Int, _ payload: LoanCreateRequest) async throws -> Loan {
|
||
var request = try makeRequest("/items/\(id)/loan", method: "POST")
|
||
try jsonBody(&request, payload)
|
||
return try await send(request, as: Loan.self)
|
||
}
|
||
|
||
/// Rueckgabe buchen. Leeres Datum = heute.
|
||
func returnItem(id: Int, _ payload: LoanReturnRequest = LoanReturnRequest()) async throws -> Loan {
|
||
var request = try makeRequest("/items/\(id)/return", method: "POST")
|
||
try jsonBody(&request, payload)
|
||
return try await send(request, as: Loan.self)
|
||
}
|
||
|
||
/// Verleih-Historie eines Stuecks, neueste zuerst.
|
||
func itemLoans(id: Int) async throws -> [Loan] {
|
||
try await send(try makeRequest("/items/\(id)/loans"), as: [Loan].self)
|
||
}
|
||
|
||
/// Offene Verleihe, faelligste zuerst. `dueIn` grenzt auf die ein, deren
|
||
/// Rueckgabe in so vielen Tagen ansteht (ueberfaellige sind immer dabei).
|
||
func loans(dueIn: Int? = nil) async throws -> [Loan] {
|
||
let pfad = dueIn.map { "/loans?due_in=\($0)" } ?? "/loans"
|
||
return try await send(try makeRequest(pfad), as: [Loan].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 uploadItemDocument(itemId: Int, data: Data, filename: String,
|
||
contentType: String) async throws -> ItemDocumentUpload {
|
||
var request = try makeRequest("/items/\(itemId)/documents", method: "POST")
|
||
let boundary = "Boundary-\(UUID().uuidString)"
|
||
request.setValue("multipart/form-data; boundary=\(boundary)",
|
||
forHTTPHeaderField: "Content-Type")
|
||
let safeName = filename.replacingOccurrences(of: "\"", with: "")
|
||
var body = Data()
|
||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(safeName)\"\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: ItemDocumentUpload.self)
|
||
}
|
||
|
||
/// Beleg nur analysieren (nichts speichern) – zum Vorbefüllen beim Anlegen.
|
||
func analyzeItemDocument(data: Data, filename: String,
|
||
contentType: String) async throws -> DocumentSuggestions {
|
||
var request = try makeRequest("/items/analyze-document", method: "POST")
|
||
let boundary = "Boundary-\(UUID().uuidString)"
|
||
request.setValue("multipart/form-data; boundary=\(boundary)",
|
||
forHTTPHeaderField: "Content-Type")
|
||
let safeName = filename.replacingOccurrences(of: "\"", with: "")
|
||
var body = Data()
|
||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(safeName)\"\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: DocumentSuggestions.self)
|
||
}
|
||
|
||
func itemDocumentData(itemId: Int, docId: Int) async throws -> Data {
|
||
let request = try makeRequest("/items/\(itemId)/documents/\(docId)")
|
||
let (data, response) = try await URLSession.shared.data(for: request)
|
||
try check(response, data: data)
|
||
return data
|
||
}
|
||
|
||
func deleteItemDocument(itemId: Int, docId: Int) async throws {
|
||
try await sendNoContent(
|
||
try makeRequest("/items/\(itemId)/documents/\(docId)", method: "DELETE"))
|
||
}
|
||
|
||
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
|
||
|
||
/// Die ganze Einkaufsliste - gerechnete Bedarfe UND eigene Eintraege.
|
||
///
|
||
/// Ein Aufruf statt drei, und vor allem der einzige, der die selbst
|
||
/// draufgesetzten Posten mitbringt.
|
||
func shoppingAll() async throws -> ShoppingListAll {
|
||
try await send(try makeRequest("/shopping-list/all"), as: ShoppingListAll.self)
|
||
}
|
||
|
||
/// Einen eigenen Eintrag abhaken oder wieder aufmachen.
|
||
@discardableResult
|
||
func setShoppingEntryDone(_ id: Int, done: Bool) async throws -> ShoppingEntry {
|
||
var request = try makeRequest("/shopping-list/entries/\(id)", method: "PATCH")
|
||
try jsonBody(&request, ["done": done])
|
||
return try await send(request, as: ShoppingEntry.self)
|
||
}
|
||
|
||
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 shoppingByLocation() async throws -> [LocationNeeds] {
|
||
try await send(try makeRequest("/shopping-list/by-location"), as: [LocationNeeds].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)
|
||
}
|
||
|
||
/// Kassenzettel-Zeilen (OCR) den aehnlichsten Lebensmitteln zuordnen.
|
||
func matchReceipt(_ lines: [String], threshold: Int? = nil) async throws -> [MatchLine] {
|
||
var request = try makeRequest("/products/match", method: "POST")
|
||
try jsonBody(&request, MatchRequest(lines: lines, threshold: threshold))
|
||
return try await send(request, as: [MatchLine].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 {
|
||
_ = try await sendeMitErneuerung(request)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
/// Nur die *eigenen* Felder einer Kategorie (ohne vererbte) – fuer die
|
||
/// Feldverwaltung, wo man sie anlegt, bearbeitet und loescht.
|
||
func ownFieldDefinitions(categoryId: Int) async throws -> [FieldDefinition] {
|
||
try await send(try makeRequest("/field-definitions?category_id=\(categoryId)"),
|
||
as: [FieldDefinition].self)
|
||
}
|
||
|
||
func createFieldDefinition(_ payload: FieldDefinitionCreate) async throws -> FieldDefinition {
|
||
var request = try makeRequest("/field-definitions", method: "POST")
|
||
try jsonBody(&request, payload)
|
||
return try await send(request, as: FieldDefinition.self)
|
||
}
|
||
|
||
func updateFieldDefinition(id: Int, _ payload: FieldDefinitionUpdate) async throws -> FieldDefinition {
|
||
var request = try makeRequest("/field-definitions/\(id)", method: "PATCH")
|
||
try jsonBody(&request, payload)
|
||
return try await send(request, as: FieldDefinition.self)
|
||
}
|
||
|
||
func deleteFieldDefinition(id: Int) async throws {
|
||
try await sendNoContent(try makeRequest("/field-definitions/\(id)", method: "DELETE"))
|
||
}
|
||
|
||
/// 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(depth: Int? = nil) async throws -> [CategoryShare] {
|
||
let suffix = depth.map { "?depth=\($0)" } ?? ""
|
||
return try await send(try makeRequest("/dashboard/by-category" + suffix), 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: String, 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 updateLocation(id: String, _ payload: LocationUpdateRequest) async throws -> StorageLocation {
|
||
var request = try makeRequest("/locations/\(id)", method: "PATCH")
|
||
try jsonBody(&request, payload)
|
||
return try await send(request, as: StorageLocation.self)
|
||
}
|
||
|
||
func deleteLocation(id: String) 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 updateCategory(id: Int, _ payload: CategoryUpdateRequest) async throws -> CategoryItem {
|
||
var request = try makeRequest("/categories/\(id)", method: "PATCH")
|
||
try jsonBody(&request, payload)
|
||
return try await send(request, as: CategoryItem.self)
|
||
}
|
||
|
||
func deleteCategory(id: Int) async throws {
|
||
try await sendNoContent(try makeRequest("/categories/\(id)", method: "DELETE"))
|
||
}
|
||
|
||
/// Gruppe aendern (Name, Mindestbestand, Einheit, Obergruppen).
|
||
func updateGroup(id: Int, _ payload: GroupUpdateRequest) async throws -> GroupItem {
|
||
var request = try makeRequest("/groups/\(id)", method: "PATCH")
|
||
try jsonBody(&request, payload)
|
||
return try await send(request, as: GroupItem.self)
|
||
}
|
||
|
||
/// Mindestbestaende einer Gruppe je Lagerort ersetzen (Basiseinheiten).
|
||
func setGroupLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> GroupItem {
|
||
var request = try makeRequest("/groups/\(id)/location-min-stock", method: "PUT")
|
||
try jsonBody(&request, list)
|
||
return try await send(request, as: GroupItem.self)
|
||
}
|
||
|
||
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"))
|
||
}
|
||
}
|