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.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,
)

View File

@@ -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):

View File

@@ -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]

View File

@@ -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("") == []

View File

@@ -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.

View File

@@ -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) ?? []
}
}

View File

@@ -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
</label>
{suggestion && suggestion.itemId === it.id
&& (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} />
<span>
Im Beleg erkannt:
{suggestion.date ? ` Garantie bis ${suggestion.date}` : ""}
{suggestion.date && suggestion.priceCents != null ? "," : ""}
{suggestion.priceCents != null ? ` Kaufpreis ${(suggestion.priceCents / 100).toFixed(2)}` : ""}.
Im Beleg erkannt
{suggestion.date ? `: Garantie bis ${suggestion.date}` : ""}
</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" }}
onClick={() => {
const body = {};