From a38c0651c5e07645aa8850f540c18e67d6829238 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 27 Jul 2026 09:43:02 +0200 Subject: [PATCH] iOS: Kaufpreis + Beleg beim Anlegen; Kaufdatum/Shop-Vorschlag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ItemAddSheet: Kaufpreis (+Währung) und Beleg (Bild/PDF). PDF wird beim Waehlen analysiert und Kaufdatum/Garantie/Preis/Shop vorbefuellt; erkannter neuer Shop wird beim Anlegen erstellt. Beleg haengt an das erste angelegte Stueck. - ItemEditView-Vorschlag zeigt/uebernimmt jetzt auch Kaufdatum und Shop (bekannten zuweisen, sonst anlegen). - Models/Client: DocumentSuggestions, analyzeItemDocument, ItemCreateRequest um price_cents/currency erweitert. Co-Authored-By: Claude Opus 4.8 --- ios/Sources/APIClient.swift | 19 +++++ ios/Sources/ItemViews.swift | 151 +++++++++++++++++++++++++++++++++--- ios/Sources/Models.swift | 40 ++++++++-- 3 files changed, 193 insertions(+), 17 deletions(-) diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 4171bc5..172f554 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -207,6 +207,25 @@ actor APIClient { 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) diff --git a/ios/Sources/ItemViews.swift b/ios/Sources/ItemViews.swift index 26d27ba..2e8c642 100644 --- a/ios/Sources/ItemViews.swift +++ b/ios/Sources/ItemViews.swift @@ -105,6 +105,9 @@ struct ItemEditView: View { @State private var suggWarranty: String? @State private var suggPrice: Int? @State private var suggPriceCandidates: [Int] = [] + @State private var suggAcquired: String? + @State private var suggShopId: Int? + @State private var suggShopName: String? @State private var busy = false @State private var busyDoc = false @State private var error: String? @@ -161,9 +164,10 @@ struct ItemEditView: View { } Section("Belege (Rechnung/Garantieschein)") { - if suggWarranty != nil || suggPrice != nil { + if hatVorschlag { VStack(alignment: .leading, spacing: 6) { Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary) + if let a = suggAcquired { Text("Gekauft am \(a)") } if let w = suggWarranty { Text("Garantie bis \(w)") } if suggPriceCandidates.count > 1 { // Mehrere Beträge gefunden – Auswahl anbieten. @@ -179,8 +183,13 @@ struct ItemEditView: View { } else if let p = suggPrice { Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)") } + if let sid = suggShopId { + Text("Shop: \(shops.first(where: { $0.id == sid })?.name ?? "bekannt")") + } else if let name = suggShopName { + Text("Shop anlegen: \(name)") + } HStack { - Button("Übernehmen") { applySuggestion() } + Button("Übernehmen") { Task { await applySuggestion() } } Spacer() Button("Verwerfen") { verwerfeVorschlag() } .foregroundStyle(.secondary) @@ -301,9 +310,13 @@ struct ItemEditView: View { do { let res = try await APIClient.shared.uploadItemDocument( itemId: item.id, data: data, filename: filename, contentType: contentType) - suggWarranty = res.suggestedWarrantyUntil - suggPrice = res.suggestedPriceCents - suggPriceCandidates = res.suggestedPriceCandidates + let s = res.suggestions + suggWarranty = s.suggestedWarrantyUntil + suggPrice = s.suggestedPriceCents + suggPriceCandidates = s.suggestedPriceCandidates + suggAcquired = s.suggestedAcquiredOn + suggShopId = s.suggestedShopId + suggShopName = s.suggestedShopName await reloadDocuments() } catch { self.error = error.localizedDescription } } @@ -331,9 +344,23 @@ struct ItemEditView: View { } catch { self.error = error.localizedDescription } } - private func applySuggestion() { + private var hatVorschlag: Bool { + suggWarranty != nil || suggPrice != nil || suggAcquired != nil + || suggShopId != nil || suggShopName != nil + } + + private func applySuggestion() async { + if let a = suggAcquired, let d = stringToDate(a) { acquired = d; hasAcquired = true } if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true } if let p = suggPrice { priceText = ItemEditView.formatCents(p) } + if let sid = suggShopId { + shopId = sid + } else if let name = suggShopName, + let shop = try? await APIClient.shared.createShop( + NewShopRequest(name: name, website: nil)) { + if !shops.contains(where: { $0.id == shop.id }) { shops.append(shop) } + shopId = shop.id + } verwerfeVorschlag() } @@ -341,6 +368,9 @@ struct ItemEditView: View { suggWarranty = nil suggPrice = nil suggPriceCandidates = [] + suggAcquired = nil + suggShopId = nil + suggShopName = nil } /// Eingabe in Hauptwährungseinheit → Rappen/Cent. @@ -383,6 +413,15 @@ struct ItemAddSheet: View { @State private var hasWarranty = false @State private var warranty = Date() @State private var note = "" + @State private var priceText = "" + @State private var currency = "CHF" + @State private var docData: Data? + @State private var docName = "" + @State private var docType = "" + @State private var newShopName: String? + @State private var addInfo: String? + @State private var showFileImporter = false + @State private var pickerItem: PhotosPickerItem? @State private var busy = false @State private var error: String? @@ -406,6 +445,37 @@ struct ItemAddSheet: View { } footer: { Text("Gemeinsame Startwerte – danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.") } + + Section("Kaufpreis") { + HStack { + TextField("0.00", text: $priceText).keyboardType(.decimalPad) + Picker("", selection: $currency) { + Text("CHF").tag("CHF") + Text("EUR").tag("EUR") + } + .pickerStyle(.segmented).frame(width: 130) + } + } + + Section { + if docData != nil { Text(docName).font(.callout) } + PhotosPicker(selection: $pickerItem, matching: .images) { + Label("Bild wählen", systemImage: "photo") + } + Button { showFileImporter = true } label: { + Label("PDF wählen", systemImage: "doc.badge.plus") + } + if let addInfo { Text(addInfo).font(.caption).foregroundStyle(.secondary) } + if let newShopName { + Text("Neuer Shop „\(newShopName)“ wird beim Anlegen erstellt.") + .font(.caption).foregroundStyle(.secondary) + } + } header: { + Text("Beleg (Rechnung/Garantieschein)") + } footer: { + Text("Der Beleg wird an das erste angelegte Stück gehängt; aus einem PDF werden Kaufdatum, Garantie, Preis und Shop vorgeschlagen.") + } + if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } Section { Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy) @@ -414,20 +484,83 @@ struct ItemAddSheet: View { .navigationTitle("Einzelstücke anlegen") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } + .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in + if case .success(let url) = result { + Task { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + if let data = try? Data(contentsOf: url) { + await analyze(data, name: url.lastPathComponent, type: "application/pdf") + } + } + } + } + .onChange(of: pickerItem) { neu in + guard let neu else { return } + Task { + if let data = try? await neu.loadTransferable(type: Data.self) { + await analyze(data, name: "foto.jpg", type: "image/jpeg") + } + pickerItem = nil + } + } .task { locations = (try? await APIClient.shared.locations()) ?? [] shops = (try? await APIClient.shared.shops()) ?? [] } } + /// Beleg beim Anlegen analysieren und Felder vorbefüllen (nur bei PDF liefert + /// der Server Treffer). + private func analyze(_ data: Data, name: String, type: String) async { + docData = data + docName = name + docType = type + addInfo = nil + newShopName = nil + guard let s = try? await APIClient.shared.analyzeItemDocument( + data: data, filename: name, contentType: type) else { return } + var teile: [String] = [] + if let a = s.suggestedAcquiredOn, let d = stringToDate(a) { + acquired = d; hasAcquired = true; teile.append("Kaufdatum") + } + if let w = s.suggestedWarrantyUntil, let d = stringToDate(w) { + warranty = d; hasWarranty = true; teile.append("Garantie") + } + if let p = s.suggestedPriceCents { + priceText = ItemEditView.formatCents(p); teile.append("Preis") + } + if let sid = s.suggestedShopId { + shopId = sid; teile.append("Shop") + } else if let sn = s.suggestedShopName { + newShopName = sn + } + addInfo = teile.isEmpty ? "Beleg erkannt – keine Automatik-Treffer." + : "Übernommen: \(teile.joined(separator: ", "))." + } + private func create() async { busy = true; defer { busy = false }; error = nil do { - _ = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest( - count: count, locationId: locationId, shopId: shopId, + var sid = shopId + if sid == nil, let name = newShopName, + let shop = try? await APIClient.shared.createShop( + NewShopRequest(name: name, website: nil)) { + sid = shop.id + } + let cents = ItemEditView.parseCents(priceText) + let created = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest( + count: count, locationId: locationId, shopId: sid, acquiredOn: hasAcquired ? dateToString(acquired) : nil, warrantyUntil: hasWarranty ? dateToString(warranty) : nil, - note: note.isEmpty ? nil : note)) + note: note.isEmpty ? nil : note, + priceCents: cents, + currency: cents == nil ? nil : currency)) + if let data = docData, let first = created.first { + _ = try? await APIClient.shared.uploadItemDocument( + itemId: first.id, data: data, + filename: docName.isEmpty ? "beleg" : docName, contentType: docType) + } await onDone() dismiss() } catch { self.error = error.localizedDescription } diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index e45a813..562a660 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -712,25 +712,46 @@ struct Item: Codable, Identifiable, Hashable { } /// Antwort nach dem Beleg-Upload – mit den aus dem PDF geschätzten Werten. -struct ItemDocumentUpload: Codable { - let id: Int - let suggestedWarrantyUntil: String? - let suggestedPriceCents: Int? - let suggestedPriceCandidates: [Int] +/// Aus einem Beleg-PDF geschätzte Werte (Analyse ohne Speichern). +struct DocumentSuggestions: Codable { + var suggestedWarrantyUntil: String? = nil + var suggestedPriceCents: Int? = nil + var suggestedPriceCandidates: [Int] = [] + var suggestedAcquiredOn: String? = nil + var suggestedShopId: Int? = nil + var suggestedShopName: String? = nil enum CodingKeys: String, CodingKey { - case id case suggestedWarrantyUntil = "suggested_warranty_until" case suggestedPriceCents = "suggested_price_cents" case suggestedPriceCandidates = "suggested_price_candidates" + case suggestedAcquiredOn = "suggested_acquired_on" + case suggestedShopId = "suggested_shop_id" + case suggestedShopName = "suggested_shop_name" } + init() {} + init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) suggestedWarrantyUntil = try c.decodeIfPresent(String.self, forKey: .suggestedWarrantyUntil) suggestedPriceCents = try c.decodeIfPresent(Int.self, forKey: .suggestedPriceCents) suggestedPriceCandidates = try c.decodeIfPresent([Int].self, forKey: .suggestedPriceCandidates) ?? [] + suggestedAcquiredOn = try c.decodeIfPresent(String.self, forKey: .suggestedAcquiredOn) + suggestedShopId = try c.decodeIfPresent(Int.self, forKey: .suggestedShopId) + suggestedShopName = try c.decodeIfPresent(String.self, forKey: .suggestedShopName) + } +} + +struct ItemDocumentUpload: Codable { + let id: Int + let suggestions: DocumentSuggestions + + enum IdKey: String, CodingKey { case id } + + init(from decoder: Decoder) throws { + id = try decoder.container(keyedBy: IdKey.self).decode(Int.self, forKey: .id) + suggestions = try DocumentSuggestions(from: decoder) } } @@ -741,13 +762,16 @@ struct ItemCreateRequest: Codable { let acquiredOn: String? let warrantyUntil: String? let note: String? + var priceCents: Int? = nil + var currency: String? = nil enum CodingKeys: String, CodingKey { - case count, note + case count, note, currency case locationId = "location_id" case shopId = "shop_id" case acquiredOn = "acquired_on" case warrantyUntil = "warranty_until" + case priceCents = "price_cents" } }