Compare commits

...

3 Commits

Author SHA1 Message Date
Scarriffle
a38c0651c5 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>
2026-07-27 09:43:02 +02:00
Scarriffle
4e7512b4bc Web: Kaufpreis + Beleg schon beim Anlegen; Kaufdatum/Shop-Vorschlag
- Anlege-Maske: Kaufpreis (+Währung) und Beleg-Upload. Beim Datei-Auswählen wird
  der Beleg analysiert (ohne Speichern) und Kaufdatum/Garantie/Preis/Shop
  vorbefuellt; ein erkannter, noch nicht hinterlegter Shop wird beim Anlegen
  erstellt. Der Beleg haengt an das erste angelegte Stueck.
- Beleg-Vorschlag (bestehende Stuecke) zeigt/uebernimmt jetzt auch Kaufdatum und
  Shop (bekannter Shop zuweisen, sonst anlegen).
- API analyzeItemDocument.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 09:36:25 +02:00
Scarriffle
bbf10b36c6 Backend: Beleg-Analyse um Kaufdatum + Shop; Analyse-Endpoint
- guess_acquired_on (Kaufdatum) und guess_shop (bekannter Shop erkannt -> id;
  sonst Kandidatenname zum Anlegen) ergaenzt.
- Upload liefert zusaetzlich suggested_acquired_on / shop_id / shop_name.
- Neuer POST /items/analyze-document: analysiert einen Beleg OHNE zu speichern
  (zum Vorbefuellen beim Anlegen). Gemeinsamer Helfer _doc_suggestions.
- 6 neue Tests; Suite 150 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 09:30:35 +02:00
9 changed files with 455 additions and 62 deletions

View File

@@ -24,6 +24,7 @@ from ..models import (
User,
)
from ..schemas import (
DocumentSuggestions,
ItemCreate,
ItemDocumentUploadOut,
ItemOut,
@@ -33,8 +34,10 @@ from ..schemas import (
from ..services.items import generate_uid, item_to_out
from ..services.warranty import (
extract_pdf_text,
guess_acquired_on,
guess_price_candidates,
guess_price_cents,
guess_shop,
guess_warranty_until,
)
@@ -208,20 +211,8 @@ def _safe_filename(name: str | None) -> str:
return cleaned[:255] or "beleg"
@router.post(
"/items/{item_id}/documents",
response_model=ItemDocumentUploadOut,
status_code=status.HTTP_201_CREATED,
)
async def upload_item_document(
item_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ItemDocumentUploadOut:
"""Beleg (PDF oder Bild) hochladen. Bei PDF wird ein Garantieende vorgeschlagen."""
item = _item_or_404(db, item_id)
data = await file.read()
def _read_document(file: UploadFile, data: bytes) -> str:
"""Validiert Größe/Typ eines hochgeladenen Belegs und gibt den Content-Type."""
if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
if len(data) > DOC_MAX_BYTES:
@@ -234,6 +225,43 @@ async def upload_item_document(
raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur PDF oder Bild erlaubt."
)
return content_type
def _doc_suggestions(
db: Session, data: bytes, content_type: str, item_acquired_on=None
) -> DocumentSuggestions:
"""Garantie, Preis, Kaufdatum und Shop aus einem PDF schätzen (Bilder: leer)."""
if content_type != "application/pdf":
return DocumentSuggestions()
text = extract_pdf_text(data)
shop_id, shop_name = guess_shop(text, [(s.id, s.name) for s in db.query(Shop).all()])
return DocumentSuggestions(
suggested_warranty_until=guess_warranty_until(text, acquired_on=item_acquired_on),
suggested_price_cents=guess_price_cents(text),
suggested_price_candidates=guess_price_candidates(text),
suggested_acquired_on=guess_acquired_on(text),
suggested_shop_id=shop_id,
suggested_shop_name=shop_name,
)
@router.post(
"/items/{item_id}/documents",
response_model=ItemDocumentUploadOut,
status_code=status.HTTP_201_CREATED,
)
async def upload_item_document(
item_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ItemDocumentUploadOut:
"""Beleg (PDF oder Bild) hochladen. Bei PDF werden Garantie, Preis, Kaufdatum
und Shop vorgeschlagen."""
item = _item_or_404(db, item_id)
data = await file.read()
content_type = _read_document(file, data)
doc = ItemDocument(
item_id=item.id,
@@ -245,26 +273,28 @@ async def upload_item_document(
db.commit()
db.refresh(doc)
# 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)
vorschlag = _doc_suggestions(db, data, content_type, item.acquired_on)
return ItemDocumentUploadOut(
id=doc.id,
filename=doc.filename,
content_type=doc.content_type,
uploaded_at=doc.uploaded_at,
suggested_warranty_until=warranty,
suggested_price_cents=price,
suggested_price_candidates=candidates,
**vorschlag.model_dump(),
)
@router.post("/items/analyze-document", response_model=DocumentSuggestions)
async def analyze_item_document(
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> DocumentSuggestions:
"""Beleg nur analysieren (nichts speichern) zum Vorbefüllen beim Anlegen."""
data = await file.read()
content_type = _read_document(file, data)
return _doc_suggestions(db, data, content_type)
@router.get("/items/{item_id}/documents/{doc_id}")
def get_item_document(
item_id: int,

View File

@@ -557,12 +557,20 @@ class ItemDocumentOut(BaseModel):
uploaded_at: datetime
class ItemDocumentUploadOut(ItemDocumentOut):
"""Antwort nach dem Upload mit aus dem PDF geschätztem Garantieende und Preis."""
class DocumentSuggestions(BaseModel):
"""Aus einem Beleg-PDF geschätzte Werte (ohne den Beleg zu speichern)."""
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] = []
suggested_acquired_on: date | None = None
# Erkannter Shop: id = bereits hinterlegt; sonst name = Vorschlag zum Anlegen.
suggested_shop_id: int | None = None
suggested_shop_name: str | None = None
class ItemDocumentUploadOut(ItemDocumentOut, DocumentSuggestions):
"""Antwort nach dem Upload Beleg-Metadaten plus die geschätzten Werte."""
class ItemOut(BaseModel):

View File

@@ -126,6 +126,57 @@ def guess_warranty_until(text: str, acquired_on: date | None = None) -> date | N
return _find_date_near_keyword(text)
def guess_acquired_on(text: str) -> date | None:
"""Kaufdatum aus dem Beleg (Datum nahe Rechnungs-/Kaufdatum-Stichwort)."""
return _find_purchase_date(text) if text else None
# --------------------------------------------------------------------------
# Shop / Bezugsquelle aus dem Beleg
# --------------------------------------------------------------------------
_LEGAL_RE = re.compile(
r"\b(GmbH|AG|SA|Sàrl|S\.?à r\.?l\.?|Ltd|Inc|SE|KG|OHG|e\.?K\.?|AS|BV|S\.p\.A\.)\b"
)
def _vendor_candidate(text: str) -> str | None:
"""Bester Rate-Name des Händlers meist im Kopf des Belegs. Best effort."""
lines = [z.strip() for z in text.splitlines() if z.strip()]
for z in lines[:20]:
if _LEGAL_RE.search(z):
return z[:60]
for z in lines[:8]:
buchstaben = sum(c.isalpha() for c in z)
if 3 <= len(z) <= 40 and buchstaben >= 3 and not re.search(
r"rechnung|invoice|quittung|beleg|kassenbon|datum|receipt|order|bestell",
z, re.IGNORECASE,
):
return z[:60]
return None
def guess_shop(text: str, shops: list[tuple[int, str]]) -> tuple[int | None, str | None]:
"""(shop_id, name).
Kommt der Name eines bereits hinterlegten Shops im Beleg vor, wird dieser
vorgeschlagen (längster Treffer gewinnt). Sonst ein Kandidatenname zum
Anlegen oder (None, None), wenn nichts Brauchbares gefunden wird.
"""
if not text:
return (None, None)
low = text.lower()
treffer: tuple[int, str] | None = None
best_len = 0
for sid, name in shops:
n = name.strip().lower()
if len(n) >= 3 and n in low and len(n) > best_len:
treffer = (sid, name)
best_len = len(n)
if treffer is not None:
return treffer
return (None, _vendor_candidate(text))
# --------------------------------------------------------------------------
# Kaufpreis aus dem Beleg schätzen
# --------------------------------------------------------------------------

View File

@@ -3,8 +3,10 @@
from datetime import date
from app.services.warranty import (
guess_acquired_on,
guess_price_candidates,
guess_price_cents,
guess_shop,
guess_warranty_until,
)
@@ -86,3 +88,25 @@ def test_preis_kandidaten_bester_zuerst_ohne_dubletten():
assert kandidaten[0] == 5490 # bester Tipp (Summe) vorne
assert set(kandidaten) == {5490, 4900, 590} # ohne Dubletten
assert guess_price_candidates("") == []
# ---- Kaufdatum & Shop ----
def test_kaufdatum_aus_beleg():
assert guess_acquired_on("Rechnungsdatum: 05.06.2024") == date(2024, 6, 5)
assert guess_acquired_on("nichts hier") is None
def test_shop_erkennt_bekannten_namen():
shops = [(1, "Digitec"), (2, "Galaxus")]
assert guess_shop("Rechnung von Digitec AG, Zürich", shops) == (1, "Digitec")
def test_shop_schlaegt_neuen_namen_vor():
sid, name = guess_shop("ACME Electronics GmbH\nRechnung\nDatum 01.01.2024", [(1, "Galaxus")])
assert sid is None
assert name == "ACME Electronics GmbH"
def test_shop_ohne_text_leer():
assert guess_shop("", [(1, "Digitec")]) == (None, None)

View File

@@ -207,6 +207,25 @@ actor APIClient {
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 {
let request = try makeRequest("/items/\(itemId)/documents/\(docId)")
let (data, response) = try await URLSession.shared.data(for: request)

View File

@@ -105,6 +105,9 @@ struct ItemEditView: View {
@State private var suggWarranty: String?
@State private var suggPrice: 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 busyDoc = false
@State private var error: String?
@@ -161,9 +164,10 @@ struct ItemEditView: View {
}
Section("Belege (Rechnung/Garantieschein)") {
if suggWarranty != nil || suggPrice != nil {
if hatVorschlag {
VStack(alignment: .leading, spacing: 6) {
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 suggPriceCandidates.count > 1 {
// Mehrere Beträge gefunden Auswahl anbieten.
@@ -179,8 +183,13 @@ struct ItemEditView: View {
} else if let p = suggPrice {
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 {
Button("Übernehmen") { applySuggestion() }
Button("Übernehmen") { Task { await applySuggestion() } }
Spacer()
Button("Verwerfen") { verwerfeVorschlag() }
.foregroundStyle(.secondary)
@@ -301,9 +310,13 @@ struct ItemEditView: View {
do {
let res = try await APIClient.shared.uploadItemDocument(
itemId: item.id, data: data, filename: filename, contentType: contentType)
suggWarranty = res.suggestedWarrantyUntil
suggPrice = res.suggestedPriceCents
suggPriceCandidates = res.suggestedPriceCandidates
let s = res.suggestions
suggWarranty = s.suggestedWarrantyUntil
suggPrice = s.suggestedPriceCents
suggPriceCandidates = s.suggestedPriceCandidates
suggAcquired = s.suggestedAcquiredOn
suggShopId = s.suggestedShopId
suggShopName = s.suggestedShopName
await reloadDocuments()
} catch { self.error = error.localizedDescription }
}
@@ -331,9 +344,23 @@ struct ItemEditView: View {
} 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 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()
}
@@ -341,6 +368,9 @@ struct ItemEditView: View {
suggWarranty = nil
suggPrice = nil
suggPriceCandidates = []
suggAcquired = nil
suggShopId = nil
suggShopName = nil
}
/// Eingabe in Hauptwährungseinheit Rappen/Cent.
@@ -383,6 +413,15 @@ struct ItemAddSheet: View {
@State private var hasWarranty = false
@State private var warranty = Date()
@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 error: String?
@@ -406,6 +445,37 @@ struct ItemAddSheet: View {
} footer: {
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) } }
Section {
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy)
@@ -414,20 +484,83 @@ struct ItemAddSheet: View {
.navigationTitle("Einzelstücke anlegen")
.navigationBarTitleDisplayMode(.inline)
.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 {
locations = (try? await APIClient.shared.locations()) ?? []
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 {
busy = true; defer { busy = false }; error = nil
do {
_ = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest(
count: count, locationId: locationId, shopId: shopId,
var sid = 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,
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()
dismiss()
} catch { self.error = error.localizedDescription }

View File

@@ -712,25 +712,46 @@ struct Item: Codable, Identifiable, Hashable {
}
/// Antwort nach dem Beleg-Upload mit den aus dem PDF geschätzten Werten.
struct ItemDocumentUpload: Codable {
let id: Int
let suggestedWarrantyUntil: String?
let suggestedPriceCents: Int?
let suggestedPriceCandidates: [Int]
/// Aus einem Beleg-PDF geschätzte Werte (Analyse ohne Speichern).
struct DocumentSuggestions: Codable {
var suggestedWarrantyUntil: String? = nil
var suggestedPriceCents: Int? = nil
var suggestedPriceCandidates: [Int] = []
var suggestedAcquiredOn: String? = nil
var suggestedShopId: Int? = nil
var suggestedShopName: String? = nil
enum CodingKeys: String, CodingKey {
case id
case suggestedWarrantyUntil = "suggested_warranty_until"
case suggestedPriceCents = "suggested_price_cents"
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 {
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) ?? []
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 warrantyUntil: String?
let note: String?
var priceCents: Int? = nil
var currency: String? = nil
enum CodingKeys: String, CodingKey {
case count, note
case count, note, currency
case locationId = "location_id"
case shopId = "shop_id"
case acquiredOn = "acquired_on"
case warrantyUntil = "warranty_until"
case priceCents = "price_cents"
}
}

View File

@@ -164,6 +164,12 @@ export const api = {
deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }),
// Belege je Einzelstück (Rechnung/Garantieschein). Der Upload liefert einen
// vorgeschlagenen "Garantie bis"-Wert zurück (nur aus PDFs).
// Beleg nur analysieren (nichts speichern) zum Vorbefüllen beim Anlegen.
analyzeItemDocument: (file) => {
const fd = new FormData();
fd.append("file", file);
return request("/items/analyze-document", { method: "POST", formData: fd });
},
uploadItemDocument: (itemId, file) => {
const fd = new FormData();
fd.append("file", file);

View File

@@ -25,6 +25,7 @@ function QrImg({ text, size = 60 }) {
const EMPTY_ADD = {
count: 1, location_id: "", shop_id: "", acquired_on: "", warranty_until: "", note: "",
price: "", currency: "CHF",
};
/**
@@ -39,6 +40,9 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
const [remove, setRemove] = useState(null); // { item, reason, note } | null
const [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
const [docFile, setDocFile] = useState(null); // Beleg, der beim Anlegen mitkommt
const [newShopName, setNewShopName] = useState(null); // erkannter, noch nicht hinterlegter Shop
const [addInfo, setAddInfo] = useState(null); // Hinweis nach Beleg-Analyse
const origin = window.location.origin;
async function load() {
@@ -53,23 +57,91 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
if (onChanged) await onChanged();
}
// Kaufpreis-Eingabe (Hauptwährungseinheit) → Rappen/Cent.
function centsFrom(str) {
const roh = String(str).trim().replace(",", ".");
if (roh === "") return null;
const v = Math.round(parseFloat(roh) * 100);
return Number.isNaN(v) ? null : v;
}
// Beleg beim Anlegen: analysieren und Kaufdatum/Garantie/Preis/Shop vorbefüllen.
async function pickDoc(file) {
setDocFile(file);
setAddInfo(null);
setNewShopName(null);
try {
const s = await api.analyzeItemDocument(file);
const patch = {};
if (s.suggested_acquired_on) patch.acquired_on = s.suggested_acquired_on;
if (s.suggested_warranty_until) patch.warranty_until = s.suggested_warranty_until;
if (s.suggested_price_cents != null) { patch.price = (s.suggested_price_cents / 100).toFixed(2); patch.currency = "CHF"; }
if (s.suggested_shop_id != null) patch.shop_id = String(s.suggested_shop_id);
setAddForm((f) => ({ ...f, ...patch }));
if (s.suggested_shop_id == null && s.suggested_shop_name) setNewShopName(s.suggested_shop_name);
const teile = [];
if (s.suggested_acquired_on) teile.push("Kaufdatum");
if (s.suggested_warranty_until) teile.push("Garantie");
if (s.suggested_price_cents != null) teile.push("Preis");
if (s.suggested_shop_id != null) teile.push("Shop");
setAddInfo(teile.length ? `Aus dem Beleg übernommen: ${teile.join(", ")}.` : "Beleg erkannt keine Automatik-Treffer.");
} catch (err) { onError(err.message); }
}
async function add(e) {
e.preventDefault();
try {
await api.createItems(product.id, {
// Erkannter, aber noch nicht hinterlegter Shop: beim Anlegen erstellen.
let shopId = addForm.shop_id === "" ? null : Number(addForm.shop_id);
if (shopId == null && newShopName) {
const shop = await api.createShop({ name: newShopName });
shopId = shop.id;
}
const cents = centsFrom(addForm.price);
const created = await api.createItems(product.id, {
count: Number(addForm.count) || 1,
location_id: addForm.location_id === "" ? null : Number(addForm.location_id),
shop_id: addForm.shop_id === "" ? null : Number(addForm.shop_id),
shop_id: shopId,
acquired_on: addForm.acquired_on || null,
warranty_until: addForm.warranty_until || null,
note: addForm.note || null,
price_cents: cents,
currency: cents == null ? null : addForm.currency,
});
setAddForm({ ...EMPTY_ADD, location_id: addForm.location_id, shop_id: addForm.shop_id });
// Beleg an das erste angelegte Stück hängen.
if (docFile && created && created.length) {
await api.uploadItemDocument(created[0].id, docFile);
}
setAddForm({ ...EMPTY_ADD, location_id: addForm.location_id, shop_id: addForm.shop_id, currency: addForm.currency });
setDocFile(null);
setNewShopName(null);
setAddInfo(null);
await nachAktion();
toast("Einzelstück(e) angelegt.");
} catch (err) { onError(err.message); }
}
const shopName = (id) => shops.find((s) => s.id === id)?.name || "?";
// Beleg-Vorschlag an ein bestehendes Stück übernehmen (Shop ggf. anlegen).
async function applySuggestion(it) {
try {
const body = {};
if (suggestion.date) body.warranty_until = suggestion.date;
if (suggestion.acquiredOn) body.acquired_on = suggestion.acquiredOn;
if (suggestion.priceCents != null) { body.price_cents = suggestion.priceCents; body.currency = it.currency || "CHF"; }
if (suggestion.shopId != null) {
body.shop_id = suggestion.shopId;
} else if (suggestion.shopName) {
const shop = await api.createShop({ name: suggestion.shopName });
body.shop_id = shop.id;
if (onChanged) await onChanged();
}
await patchItem(it, body);
setSuggestion(null);
} catch (err) { onError(err.message); }
}
// Kaufpreis in Hauptwährungseinheit eingeben, als Rappen/Cent speichern.
function savePrice(item, value) {
const roh = value.trim().replace(",", ".");
@@ -83,12 +155,17 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
try {
const res = await api.uploadItemDocument(item.id, file);
await load();
if (res && (res.suggested_warranty_until || res.suggested_price_cents != null)) {
const hatVorschlag = res && (res.suggested_warranty_until || res.suggested_price_cents != null
|| res.suggested_acquired_on || res.suggested_shop_id != null || res.suggested_shop_name);
if (hatVorschlag) {
setSuggestion({
itemId: item.id,
date: res.suggested_warranty_until || null,
priceCents: res.suggested_price_cents ?? null,
priceCandidates: res.suggested_price_candidates || [],
acquiredOn: res.suggested_acquired_on || null,
shopId: res.suggested_shop_id ?? null,
shopName: res.suggested_shop_name || null,
});
}
toast("Beleg hochgeladen.");
@@ -265,13 +342,13 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadDoc(it, f); e.target.value = ""; }} />
)}
</label>
{suggestion && suggestion.itemId === it.id
&& (suggestion.date || suggestion.priceCents != null) && (
{suggestion && suggestion.itemId === it.id && (
<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.acquiredOn ? `: gekauft ${suggestion.acquiredOn}` : ""}
{suggestion.date ? `${suggestion.acquiredOn ? "," : ":"} Garantie bis ${suggestion.date}` : ""}
</span>
{suggestion.priceCents != null && (
(suggestion.priceCandidates || []).length > 1 ? (
@@ -288,17 +365,14 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
<span>Kaufpreis {(suggestion.priceCents / 100).toFixed(2)} {it.currency || "CHF"}</span>
)
)}
{suggestion.shopId != null && (
<span>Shop: {shopName(suggestion.shopId)}</span>
)}
{suggestion.shopId == null && suggestion.shopName && (
<span>Shop anlegen: <strong>{suggestion.shopName}</strong></span>
)}
<button type="button" className="btn sm" style={{ marginLeft: "auto" }}
onClick={() => {
const body = {};
if (suggestion.date) body.warranty_until = suggestion.date;
if (suggestion.priceCents != null) {
body.price_cents = suggestion.priceCents;
body.currency = it.currency || "CHF";
}
patchItem(it, body);
setSuggestion(null);
}}>
onClick={() => applySuggestion(it)}>
Übernehmen
</button>
<button type="button" className="btn sm ghost" onClick={() => setSuggestion(null)}>Verwerfen</button>
@@ -382,9 +456,33 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
onChange={(e) => setAddForm({ ...addForm, note: e.target.value })} />
</label>
</div>
<div className="row">
<label style={{ width: 200 }}>Kaufpreis
<div className="field-inline">
<input type="number" min="0" step="0.01" placeholder="0.00" value={addForm.price}
onChange={(e) => setAddForm({ ...addForm, price: e.target.value })} />
<select value={addForm.currency} style={{ marginTop: 0 }}
onChange={(e) => setAddForm({ ...addForm, currency: e.target.value })}>
<option value="CHF">CHF</option>
<option value="EUR">EUR</option>
</select>
</div>
</label>
<label className="grow">Beleg (Rechnung/Garantieschein)
<input type="file" accept="application/pdf,image/*"
onChange={(e) => { const f = e.target.files?.[0]; if (f) pickDoc(f); }} />
</label>
</div>
{addInfo && <p className="muted small mt-0"><Icon name="check" size={14} /> {addInfo}</p>}
{newShopName && (
<p className="muted small mt-0">
Neuer Shop <strong>{newShopName}</strong> wird beim Anlegen erstellt oder oben einen bestehenden wählen.
</p>
)}
<p className="muted small">
Gemeinsame Startwerte für alle neuen Stücke danach je Stück änderbar
(z.B. dasselbe Modell 2024 und 2025). Jedes bekommt eine eigene UID + QR.
Ein Beleg wird an das erste angelegte Stück gehängt.
</p>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
</form>