In der Chargenzeile beim Einlagern gibt es einen Knopf "MHD scannen". Er oeffnet die Texterkennung von VisionKit; erkannte Daten erscheinen als Vorschlag und werden erst uebernommen, wenn man sie antippt. Bewusst kein automatisches Uebernehmen: Eine Fehllesung beim MHD faellt sonst erst auf, wenn die Ware bereits falsch einsortiert ist. Der Parser deckt die Schreibweisen ab, die auf Verpackungen vorkommen - Tagesdaten (12.09.2026, 2026-09-12, 12/09/2026, 12.09.26) und Monatsangaben (09/2026, 09.2026, 2026-09, 09/26). Erkennt der Aufdruck nur Monat und Jahr, stellt sich die Eingabe sichtbar auf Monat/Jahr um. Damit aus Chargennummern und EAN-Codes keine Phantasiedaten werden, greifen mehrere Einschraenkungen: Muster mit Tag werden zuerst geprueft, ein bereits erkannter Textabschnitt wird nicht erneut ausgewertet, Monate ausserhalb 1-12 entfallen und das Jahr muss im Fenster von fuenf Jahren rueckwaerts bis dreissig vorwaerts liegen. Der Knopf erscheint nur, wenn das Geraet Texterkennung unterstuetzt (Neural Engine); sonst bleibt die Eingabe von Hand. Getestet: Der Parser wurde eigenstaendig uebersetzt und gegen 14 Faelle geprueft, alle korrekt - darunter, dass "12.09.2026" nicht als Monatsangabe gelesen wird, dass eine EAN keine Treffer erzeugt und dass unplausible Jahre und Monate verworfen werden. Der iOS-Geraetebuild laeuft fehlerfrei durch. Die Erkennung an einer echten Verpackung habe ich nicht selbst ausprobiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
161 lines
6.3 KiB
Swift
161 lines
6.3 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 Project-Good-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)
|
|
}
|
|
|
|
func searchProducts(_ query: String) async throws -> [Product] {
|
|
let escaped = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
|
return try await send(try makeRequest("/products?q=\(escaped)"), 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)
|
|
}
|
|
|
|
// 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 {
|
|
let request = try makeRequest("/lots/\(id)", method: "DELETE")
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
try check(response, data: data) // 204: kein Rumpf zum Auswerten
|
|
}
|
|
}
|