diff --git a/backend/app/routers/items.py b/backend/app/routers/items.py index dc3b59b..2d57e6b 100644 --- a/backend/app/routers/items.py +++ b/backend/app/routers/items.py @@ -33,6 +33,7 @@ from ..schemas import ( from ..services.items import generate_uid, item_to_out from ..services.warranty import ( extract_pdf_text, + guess_price_candidates, guess_price_cents, guess_warranty_until, ) @@ -246,10 +247,12 @@ async def upload_item_document( # Garantieende und Preis nur aus PDFs schätzen (Bilder haben keine Textebene). warranty = price = None + candidates: list[int] = [] if content_type == "application/pdf": text = extract_pdf_text(data) warranty = guess_warranty_until(text, acquired_on=item.acquired_on) price = guess_price_cents(text) + candidates = guess_price_candidates(text) return ItemDocumentUploadOut( id=doc.id, @@ -258,6 +261,7 @@ async def upload_item_document( uploaded_at=doc.uploaded_at, suggested_warranty_until=warranty, suggested_price_cents=price, + suggested_price_candidates=candidates, ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 49f297b..e01fc2c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -544,6 +544,8 @@ class ItemDocumentUploadOut(ItemDocumentOut): """Antwort nach dem Upload – mit aus dem PDF geschätztem Garantieende und Preis.""" suggested_warranty_until: date | None = None suggested_price_cents: int | None = None + # Alle plausiblen Preise (bester zuerst) – zur Auswahl, falls es mehrere gibt. + suggested_price_candidates: list[int] = [] class ItemOut(BaseModel): diff --git a/backend/app/services/warranty.py b/backend/app/services/warranty.py index f8efae8..4f6b3fc 100644 --- a/backend/app/services/warranty.py +++ b/backend/app/services/warranty.py @@ -177,3 +177,27 @@ def guess_price_cents(text: str) -> int | None: if dist <= 40 and dist < best_dist: best, best_dist = c, dist return best if best is not None else max(c for _, c in betraege) + + +def guess_price_candidates(text: str) -> list[int]: + """Alle plausiblen Beträge aus dem Beleg (in Rappen/Cent), ohne Dubletten. + + Der beste Tipp (:func:`guess_price_cents`) steht vorne; danach die übrigen + Beträge absteigend. So kann die Oberfläche eine Auswahl anbieten, falls der + automatische Tipp danebenliegt. + """ + if not text: + return [] + einzigartig = set() + for m in _AMOUNT_RE.finditer(text): + c = _amount_to_cents(m.group()) + if c: + einzigartig.add(c) + if not einzigartig: + return [] + kandidaten = sorted(einzigartig, reverse=True) + best = guess_price_cents(text) + if best is not None and best in kandidaten: + kandidaten.remove(best) + kandidaten.insert(0, best) + return kandidaten[:8] diff --git a/backend/tests/test_warranty.py b/backend/tests/test_warranty.py index 4ac0443..dc98796 100644 --- a/backend/tests/test_warranty.py +++ b/backend/tests/test_warranty.py @@ -2,7 +2,11 @@ from datetime import date -from app.services.warranty import guess_price_cents, guess_warranty_until +from app.services.warranty import ( + guess_price_candidates, + guess_price_cents, + guess_warranty_until, +) def test_zeitraum_plus_kaufdatum_vom_stueck(): @@ -64,3 +68,11 @@ def test_preis_ohne_stichwort_nimmt_groessten_betrag(): def test_preis_ohne_betrag_ist_none(): assert guess_price_cents("Kein Preis hier.") is None assert guess_price_cents("") is None + + +def test_preis_kandidaten_bester_zuerst_ohne_dubletten(): + text = "Artikel 49.00\nVersand 5.90\nGesamtbetrag: 54.90\nnochmal 49.00" + kandidaten = guess_price_candidates(text) + assert kandidaten[0] == 5490 # bester Tipp vorne + assert set(kandidaten) == {5490, 4900, 590} # ohne Dubletten + assert guess_price_candidates("") == [] diff --git a/ios/Sources/ItemViews.swift b/ios/Sources/ItemViews.swift index d1832bf..26d27ba 100644 --- a/ios/Sources/ItemViews.swift +++ b/ios/Sources/ItemViews.swift @@ -104,6 +104,7 @@ struct ItemEditView: View { @State private var documents: [ItemDocument] = [] @State private var suggWarranty: String? @State private var suggPrice: Int? + @State private var suggPriceCandidates: [Int] = [] @State private var busy = false @State private var busyDoc = false @State private var error: String? @@ -164,11 +165,24 @@ struct ItemEditView: View { VStack(alignment: .leading, spacing: 6) { Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary) if let w = suggWarranty { Text("Garantie bis \(w)") } - if let p = suggPrice { Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)") } + if suggPriceCandidates.count > 1 { + // Mehrere Beträge gefunden – Auswahl anbieten. + Picker("Kaufpreis", selection: Binding( + get: { suggPrice ?? suggPriceCandidates.first ?? 0 }, + set: { suggPrice = $0 } + )) { + ForEach(suggPriceCandidates, id: \.self) { c in + Text("\(ItemEditView.formatCents(c)) \(currency)").tag(c) + } + } + .pickerStyle(.menu) + } else if let p = suggPrice { + Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)") + } HStack { Button("Übernehmen") { applySuggestion() } Spacer() - Button("Verwerfen") { suggWarranty = nil; suggPrice = nil } + Button("Verwerfen") { verwerfeVorschlag() } .foregroundStyle(.secondary) } .font(.callout) @@ -289,6 +303,7 @@ struct ItemEditView: View { itemId: item.id, data: data, filename: filename, contentType: contentType) suggWarranty = res.suggestedWarrantyUntil suggPrice = res.suggestedPriceCents + suggPriceCandidates = res.suggestedPriceCandidates await reloadDocuments() } catch { self.error = error.localizedDescription } } @@ -319,8 +334,13 @@ struct ItemEditView: View { private func applySuggestion() { if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true } if let p = suggPrice { priceText = ItemEditView.formatCents(p) } + verwerfeVorschlag() + } + + private func verwerfeVorschlag() { suggWarranty = nil suggPrice = nil + suggPriceCandidates = [] } /// Eingabe in Hauptwährungseinheit → Rappen/Cent. diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index e136055..5469764 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -635,11 +635,21 @@ struct ItemDocumentUpload: Codable { let id: Int let suggestedWarrantyUntil: String? let suggestedPriceCents: Int? + let suggestedPriceCandidates: [Int] enum CodingKeys: String, CodingKey { case id case suggestedWarrantyUntil = "suggested_warranty_until" case suggestedPriceCents = "suggested_price_cents" + case suggestedPriceCandidates = "suggested_price_candidates" + } + + 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) ?? [] } } diff --git a/web/src/components/Einzelstuecke.jsx b/web/src/components/Einzelstuecke.jsx index 5854e69..af013ef 100644 --- a/web/src/components/Einzelstuecke.jsx +++ b/web/src/components/Einzelstuecke.jsx @@ -88,6 +88,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh itemId: item.id, date: res.suggested_warranty_until || null, priceCents: res.suggested_price_cents ?? null, + priceCandidates: res.suggested_price_candidates || [], }); } toast("Beleg hochgeladen."); @@ -266,14 +267,27 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh {suggestion && suggestion.itemId === it.id && (suggestion.date || suggestion.priceCents != null) && ( -