In der App gab es beides nicht. Chargen waren ueberhaupt nur innerhalb
eines Artikels zu sehen, und dort nur zum Korrigieren - eine
Chargenliste wie im Web fehlte, und damit jeder Weg, eine Packung
anzubrechen oder eine Teilmenge zu verbrauchen. Das Backend kann das
seit zwei Wochen.
Neu im Tab "Listen": der Eintrag "Chargen" mit allen Chargen ueber alle
Artikel, durchsuchbar, mit Schalter "Nur angebrochene". Je Zeile stehen
Menge, MHD bzw. Verbrauchsdatum, Lagerort und ein Abzeichen
"angebrochen"; bei einer offenen Packung die Restmenge in Basiseinheiten
("980 g von 1.000 g"), denn "0,98 Packungen" sagt niemandem etwas.
Nach links wischen gibt die zwei Buchungen:
"Anbrechen" mit Rueckfrage - eine Packung wird abgespalten und bekommt
ihr Verbrauchsdatum, der Rest bleibt zu und behaelt sein MHD.
"Verbrauchen" oeffnet ein Fenster fuer die VERBRAUCHTE Menge (nicht den
Rest - das ist die Zahl, die man kennt). Die Einheitenauswahl holt die
Hausmasse der Kategorie dazu, "2 EL" ist damit buchbar und landet bei
Mehl als 20 g. War die Charge noch zu, bricht die Buchung sie an; der
Haken dafuer ist abwaehlbar.
Loeschen fehlt in der Liste mit Absicht: Eine Charge verschwindet durchs
Verbrauchen, und das Korrigieren sitzt beim Artikel.
Dort zeigt die Chargenzeile jetzt auch "angebrochen · verbrauchen bis
..." - zwei Zeilen mit gleicher Menge waren sonst nicht zu
unterscheiden.
Alles in bestehende Dateien gelegt, keine neue Quelldatei: Das
mitgelieferte Xcode-Projekt listet die Dateien einzeln, eine neue waere
ohne "xcodegen generate" stillschweigend nicht mitgebaut worden.
Gegen den laufenden Server geprueft, genau mit den Aufrufen, die die App
macht: /lots/rows liefert alle Felder, die das Swift-Modell liest;
{"quantity":null} an /lots/{id}/open spaltet 1000 von 2000 g ab und
setzt consume_by; 2 Essloeffel an /lots/{id}/consume buchen 20 g ab und
die Antwort traegt opened und lot.
Nicht kompiliert: kein Xcode auf dieser Maschine. Geprueft sind Klammer-
und Anfuehrungsbalance (und dabei EIN echter Fehler gefunden: ein
deutsches Anfuehrungszeichen mit geradem Schlusszeichen haette eine
Zeichenkette vorzeitig beendet), dass formatAmount global erreichbar
ist, und dass die benutzten Hilfsfunktionen so heissen. Der Build in
Xcode bleibt die Probe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
749 lines
33 KiB
Swift
749 lines
33 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)
|
||
}
|
||
|
||
/// Alle Chargen mit Artikelangaben - fuer die uebergreifende Chargenliste.
|
||
func allLotRows() async throws -> [LotRow] {
|
||
try await send(try makeRequest("/lots/rows"), as: [LotRow].self)
|
||
}
|
||
|
||
/// Eine Packung anbrechen: Sie wird als eigene, offene Charge gefuehrt und
|
||
/// bekommt ein "zu verbrauchen bis". Ohne Menge ist es eine Artikeleinheit.
|
||
@discardableResult
|
||
func openLot(id: Int, quantity: Double? = nil) async throws -> Lot {
|
||
var request = try makeRequest("/lots/\(id)/open", method: "POST")
|
||
try jsonBody(&request, ["quantity": quantity])
|
||
return try await send(request, as: Lot.self)
|
||
}
|
||
|
||
/// Teilmenge aus einer Charge verbrauchen.
|
||
///
|
||
/// `autoOpen` bricht eine noch geschlossene Charge dabei an - die Rueckfrage
|
||
/// dazu stellt die Oberflaeche, nicht der Server.
|
||
@discardableResult
|
||
func consumeLot(id: Int, quantity: Double, unit: String,
|
||
note: String?, autoOpen: Bool) async throws -> ConsumeResult {
|
||
var request = try makeRequest("/lots/\(id)/consume", method: "POST")
|
||
try jsonBody(&request, ConsumeRequest(quantity: quantity, unit: unit,
|
||
note: note, autoOpen: autoOpen))
|
||
return try await send(request, as: ConsumeResult.self)
|
||
}
|
||
|
||
/// Die fuer DIESEN Artikel wirksamen Hausmasse samt Herkunft.
|
||
func productMeasures(productId: Int) async throws -> [MeasureAmount] {
|
||
try await send(try makeRequest("/products/\(productId)/measures"),
|
||
as: [MeasureAmount].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"))
|
||
}
|
||
}
|