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)
|
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 {
|
func itemDocumentData(itemId: Int, docId: Int) async throws -> Data {
|
||||||
let request = try makeRequest("/items/\(itemId)/documents/\(docId)")
|
let request = try makeRequest("/items/\(itemId)/documents/\(docId)")
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
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 suggWarranty: String?
|
||||||
@State private var suggPrice: Int?
|
@State private var suggPrice: Int?
|
||||||
@State private var suggPriceCandidates: [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 busy = false
|
||||||
@State private var busyDoc = false
|
@State private var busyDoc = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@@ -161,9 +164,10 @@ struct ItemEditView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Belege (Rechnung/Garantieschein)") {
|
Section("Belege (Rechnung/Garantieschein)") {
|
||||||
if suggWarranty != nil || suggPrice != nil {
|
if hatVorschlag {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary)
|
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 let w = suggWarranty { Text("Garantie bis \(w)") }
|
||||||
if suggPriceCandidates.count > 1 {
|
if suggPriceCandidates.count > 1 {
|
||||||
// Mehrere Beträge gefunden – Auswahl anbieten.
|
// Mehrere Beträge gefunden – Auswahl anbieten.
|
||||||
@@ -179,8 +183,13 @@ struct ItemEditView: View {
|
|||||||
} else if let p = suggPrice {
|
} else if let p = suggPrice {
|
||||||
Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)")
|
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 {
|
HStack {
|
||||||
Button("Übernehmen") { applySuggestion() }
|
Button("Übernehmen") { Task { await applySuggestion() } }
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Verwerfen") { verwerfeVorschlag() }
|
Button("Verwerfen") { verwerfeVorschlag() }
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -301,9 +310,13 @@ struct ItemEditView: View {
|
|||||||
do {
|
do {
|
||||||
let res = try await APIClient.shared.uploadItemDocument(
|
let res = try await APIClient.shared.uploadItemDocument(
|
||||||
itemId: item.id, data: data, filename: filename, contentType: contentType)
|
itemId: item.id, data: data, filename: filename, contentType: contentType)
|
||||||
suggWarranty = res.suggestedWarrantyUntil
|
let s = res.suggestions
|
||||||
suggPrice = res.suggestedPriceCents
|
suggWarranty = s.suggestedWarrantyUntil
|
||||||
suggPriceCandidates = res.suggestedPriceCandidates
|
suggPrice = s.suggestedPriceCents
|
||||||
|
suggPriceCandidates = s.suggestedPriceCandidates
|
||||||
|
suggAcquired = s.suggestedAcquiredOn
|
||||||
|
suggShopId = s.suggestedShopId
|
||||||
|
suggShopName = s.suggestedShopName
|
||||||
await reloadDocuments()
|
await reloadDocuments()
|
||||||
} catch { self.error = error.localizedDescription }
|
} catch { self.error = error.localizedDescription }
|
||||||
}
|
}
|
||||||
@@ -331,9 +344,23 @@ struct ItemEditView: View {
|
|||||||
} catch { self.error = error.localizedDescription }
|
} 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 w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true }
|
||||||
if let p = suggPrice { priceText = ItemEditView.formatCents(p) }
|
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()
|
verwerfeVorschlag()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +368,9 @@ struct ItemEditView: View {
|
|||||||
suggWarranty = nil
|
suggWarranty = nil
|
||||||
suggPrice = nil
|
suggPrice = nil
|
||||||
suggPriceCandidates = []
|
suggPriceCandidates = []
|
||||||
|
suggAcquired = nil
|
||||||
|
suggShopId = nil
|
||||||
|
suggShopName = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eingabe in Hauptwährungseinheit → Rappen/Cent.
|
/// Eingabe in Hauptwährungseinheit → Rappen/Cent.
|
||||||
@@ -383,6 +413,15 @@ struct ItemAddSheet: View {
|
|||||||
@State private var hasWarranty = false
|
@State private var hasWarranty = false
|
||||||
@State private var warranty = Date()
|
@State private var warranty = Date()
|
||||||
@State private var note = ""
|
@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 busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
@@ -406,6 +445,37 @@ struct ItemAddSheet: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("Gemeinsame Startwerte – danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.")
|
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) } }
|
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||||||
Section {
|
Section {
|
||||||
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy)
|
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy)
|
||||||
@@ -414,20 +484,83 @@ struct ItemAddSheet: View {
|
|||||||
.navigationTitle("Einzelstücke anlegen")
|
.navigationTitle("Einzelstücke anlegen")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
.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 {
|
.task {
|
||||||
locations = (try? await APIClient.shared.locations()) ?? []
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
shops = (try? await APIClient.shared.shops()) ?? []
|
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 {
|
private func create() async {
|
||||||
busy = true; defer { busy = false }; error = nil
|
busy = true; defer { busy = false }; error = nil
|
||||||
do {
|
do {
|
||||||
_ = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest(
|
var sid = shopId
|
||||||
count: count, locationId: locationId, shopId: 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,
|
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
|
||||||
warrantyUntil: hasWarranty ? dateToString(warranty) : 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()
|
await onDone()
|
||||||
dismiss()
|
dismiss()
|
||||||
} catch { self.error = error.localizedDescription }
|
} 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.
|
/// Antwort nach dem Beleg-Upload – mit den aus dem PDF geschätzten Werten.
|
||||||
struct ItemDocumentUpload: Codable {
|
/// Aus einem Beleg-PDF geschätzte Werte (Analyse ohne Speichern).
|
||||||
let id: Int
|
struct DocumentSuggestions: Codable {
|
||||||
let suggestedWarrantyUntil: String?
|
var suggestedWarrantyUntil: String? = nil
|
||||||
let suggestedPriceCents: Int?
|
var suggestedPriceCents: Int? = nil
|
||||||
let suggestedPriceCandidates: [Int]
|
var suggestedPriceCandidates: [Int] = []
|
||||||
|
var suggestedAcquiredOn: String? = nil
|
||||||
|
var suggestedShopId: Int? = nil
|
||||||
|
var suggestedShopName: String? = nil
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
|
||||||
case suggestedWarrantyUntil = "suggested_warranty_until"
|
case suggestedWarrantyUntil = "suggested_warranty_until"
|
||||||
case suggestedPriceCents = "suggested_price_cents"
|
case suggestedPriceCents = "suggested_price_cents"
|
||||||
case suggestedPriceCandidates = "suggested_price_candidates"
|
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 {
|
init(from decoder: Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try c.decode(Int.self, forKey: .id)
|
|
||||||
suggestedWarrantyUntil = try c.decodeIfPresent(String.self, forKey: .suggestedWarrantyUntil)
|
suggestedWarrantyUntil = try c.decodeIfPresent(String.self, forKey: .suggestedWarrantyUntil)
|
||||||
suggestedPriceCents = try c.decodeIfPresent(Int.self, forKey: .suggestedPriceCents)
|
suggestedPriceCents = try c.decodeIfPresent(Int.self, forKey: .suggestedPriceCents)
|
||||||
suggestedPriceCandidates = try c.decodeIfPresent([Int].self, forKey: .suggestedPriceCandidates) ?? []
|
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 acquiredOn: String?
|
||||||
let warrantyUntil: String?
|
let warrantyUntil: String?
|
||||||
let note: String?
|
let note: String?
|
||||||
|
var priceCents: Int? = nil
|
||||||
|
var currency: String? = nil
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case count, note
|
case count, note, currency
|
||||||
case locationId = "location_id"
|
case locationId = "location_id"
|
||||||
case shopId = "shop_id"
|
case shopId = "shop_id"
|
||||||
case acquiredOn = "acquired_on"
|
case acquiredOn = "acquired_on"
|
||||||
case warrantyUntil = "warranty_until"
|
case warrantyUntil = "warranty_until"
|
||||||
|
case priceCents = "price_cents"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user