Preis-Vorschlag: Auswahl aus mehreren erkannten Beträgen

Findet die Beleg-Analyse mehrere Beträge, liefert der Upload jetzt eine
Kandidatenliste (bester Tipp zuerst). Web und iOS bieten dann ein Dropdown zur
Auswahl des richtigen Kaufpreises, statt nur den automatischen Tipp. 1 neuer
Test; Backend-Suite gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 06:39:03 +02:00
parent 761c131286
commit 2bc871f229
7 changed files with 94 additions and 8 deletions

View File

@@ -33,6 +33,7 @@ from ..schemas import (
from ..services.items import generate_uid, item_to_out from ..services.items import generate_uid, item_to_out
from ..services.warranty import ( from ..services.warranty import (
extract_pdf_text, extract_pdf_text,
guess_price_candidates,
guess_price_cents, guess_price_cents,
guess_warranty_until, guess_warranty_until,
) )
@@ -246,10 +247,12 @@ async def upload_item_document(
# Garantieende und Preis nur aus PDFs schätzen (Bilder haben keine Textebene). # Garantieende und Preis nur aus PDFs schätzen (Bilder haben keine Textebene).
warranty = price = None warranty = price = None
candidates: list[int] = []
if content_type == "application/pdf": if content_type == "application/pdf":
text = extract_pdf_text(data) text = extract_pdf_text(data)
warranty = guess_warranty_until(text, acquired_on=item.acquired_on) warranty = guess_warranty_until(text, acquired_on=item.acquired_on)
price = guess_price_cents(text) price = guess_price_cents(text)
candidates = guess_price_candidates(text)
return ItemDocumentUploadOut( return ItemDocumentUploadOut(
id=doc.id, id=doc.id,
@@ -258,6 +261,7 @@ async def upload_item_document(
uploaded_at=doc.uploaded_at, uploaded_at=doc.uploaded_at,
suggested_warranty_until=warranty, suggested_warranty_until=warranty,
suggested_price_cents=price, suggested_price_cents=price,
suggested_price_candidates=candidates,
) )

View File

@@ -544,6 +544,8 @@ class ItemDocumentUploadOut(ItemDocumentOut):
"""Antwort nach dem Upload mit aus dem PDF geschätztem Garantieende und Preis.""" """Antwort nach dem Upload mit aus dem PDF geschätztem Garantieende und Preis."""
suggested_warranty_until: date | None = None suggested_warranty_until: date | None = None
suggested_price_cents: int | 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): class ItemOut(BaseModel):

View File

@@ -177,3 +177,27 @@ def guess_price_cents(text: str) -> int | None:
if dist <= 40 and dist < best_dist: if dist <= 40 and dist < best_dist:
best, best_dist = c, dist best, best_dist = c, dist
return best if best is not None else max(c for _, c in betraege) 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]

View File

@@ -2,7 +2,11 @@
from datetime import date 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(): 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(): def test_preis_ohne_betrag_ist_none():
assert guess_price_cents("Kein Preis hier.") is None assert guess_price_cents("Kein Preis hier.") is None
assert guess_price_cents("") 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("") == []

View File

@@ -104,6 +104,7 @@ struct ItemEditView: View {
@State private var documents: [ItemDocument] = [] @State private var documents: [ItemDocument] = []
@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 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?
@@ -164,11 +165,24 @@ struct ItemEditView: View {
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 w = suggWarranty { Text("Garantie bis \(w)") } 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 { HStack {
Button("Übernehmen") { applySuggestion() } Button("Übernehmen") { applySuggestion() }
Spacer() Spacer()
Button("Verwerfen") { suggWarranty = nil; suggPrice = nil } Button("Verwerfen") { verwerfeVorschlag() }
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.font(.callout) .font(.callout)
@@ -289,6 +303,7 @@ struct ItemEditView: View {
itemId: item.id, data: data, filename: filename, contentType: contentType) itemId: item.id, data: data, filename: filename, contentType: contentType)
suggWarranty = res.suggestedWarrantyUntil suggWarranty = res.suggestedWarrantyUntil
suggPrice = res.suggestedPriceCents suggPrice = res.suggestedPriceCents
suggPriceCandidates = res.suggestedPriceCandidates
await reloadDocuments() await reloadDocuments()
} catch { self.error = error.localizedDescription } } catch { self.error = error.localizedDescription }
} }
@@ -319,8 +334,13 @@ struct ItemEditView: View {
private func applySuggestion() { private func applySuggestion() {
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) }
verwerfeVorschlag()
}
private func verwerfeVorschlag() {
suggWarranty = nil suggWarranty = nil
suggPrice = nil suggPrice = nil
suggPriceCandidates = []
} }
/// Eingabe in Hauptwährungseinheit Rappen/Cent. /// Eingabe in Hauptwährungseinheit Rappen/Cent.

View File

@@ -635,11 +635,21 @@ struct ItemDocumentUpload: Codable {
let id: Int let id: Int
let suggestedWarrantyUntil: String? let suggestedWarrantyUntil: String?
let suggestedPriceCents: Int? let suggestedPriceCents: Int?
let suggestedPriceCandidates: [Int]
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id 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"
}
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) ?? []
} }
} }

View File

@@ -88,6 +88,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
itemId: item.id, itemId: item.id,
date: res.suggested_warranty_until || null, date: res.suggested_warranty_until || null,
priceCents: res.suggested_price_cents ?? null, priceCents: res.suggested_price_cents ?? null,
priceCandidates: res.suggested_price_candidates || [],
}); });
} }
toast("Beleg hochgeladen."); toast("Beleg hochgeladen.");
@@ -266,14 +267,27 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</label> </label>
{suggestion && suggestion.itemId === it.id {suggestion && suggestion.itemId === it.id
&& (suggestion.date || suggestion.priceCents != null) && ( && (suggestion.date || suggestion.priceCents != null) && (
<div className="alert ok" style={{ marginTop: "var(--sp-2)" }}> <div className="alert ok" style={{ marginTop: "var(--sp-2)", flexWrap: "wrap" }}>
<Icon name="check" size={16} /> <Icon name="check" size={16} />
<span> <span>
Im Beleg erkannt: Im Beleg erkannt
{suggestion.date ? ` Garantie bis ${suggestion.date}` : ""} {suggestion.date ? `: Garantie bis ${suggestion.date}` : ""}
{suggestion.date && suggestion.priceCents != null ? "," : ""}
{suggestion.priceCents != null ? ` Kaufpreis ${(suggestion.priceCents / 100).toFixed(2)}` : ""}.
</span> </span>
{suggestion.priceCents != null && (
(suggestion.priceCandidates || []).length > 1 ? (
<label className="check-inline" style={{ margin: 0, gap: 4 }}>
Kaufpreis
<select value={suggestion.priceCents} style={{ width: "auto", marginTop: 0 }}
onChange={(e) => setSuggestion({ ...suggestion, priceCents: Number(e.target.value) })}>
{suggestion.priceCandidates.map((c) => (
<option key={c} value={c}>{(c / 100).toFixed(2)} {it.currency || "CHF"}</option>
))}
</select>
</label>
) : (
<span>Kaufpreis {(suggestion.priceCents / 100).toFixed(2)} {it.currency || "CHF"}</span>
)
)}
<button type="button" className="btn sm" style={{ marginLeft: "auto" }} <button type="button" className="btn sm" style={{ marginLeft: "auto" }}
onClick={() => { onClick={() => {
const body = {}; const body = {};