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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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("") == []
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
Reference in New Issue
Block a user