iOS: Kaufpreis + Beleg beim Anlegen; Kaufdatum/Shop-Vorschlag
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user