Compare commits

...

4 Commits

Author SHA1 Message Date
Scarriffle
f8c0cec918 iOS: Kaufpreis + Belege am Einzelstück
ItemEditView bekommt einen Kaufpreis (Betrag + CHF/EUR) und eine Beleg-Sektion:
Rechnung/Garantieschein als Bild (Fotoauswahl) oder PDF (Datei-Import)
hochladen, ansehen (QuickLook) und loeschen. Nach einem PDF-Upload schlaegt der
Server "Garantie bis" und Kaufpreis vor – per Knopf uebernehmbar. Item-Model um
priceCents/currency/documents erweitert; Client-Methoden fuer Upload, Download
und Loeschen der Belege.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:02:58 +02:00
Scarriffle
b9709d29bf Web: Kaufpreis + Belege an Einzelstücken
Je Einzelstück eine ausklappbare Detailzeile: Kaufpreis (Betrag + CHF/EUR) und
Belege (Rechnung/Garantieschein) hochladen, ansehen, löschen. Nach dem Upload
eines PDFs schlägt der Server „Garantie bis" und Kaufpreis vor – per Knopf
übernehmbar. Belege werden mit Anmeldung geladen (kein offener Zugriff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 20:57:30 +02:00
Scarriffle
ba65e48d47 Backend: Kaufpreis aus Beleg-PDF schätzen
guess_price_cents zieht den Kaufpreis aus dem Belegtext: bevorzugt den Betrag
nach einem Summen-Stichwort (Gesamtbetrag/Total/…), sonst den groessten Betrag.
Versteht Tausendertrenner (1'299.00) und deutsches Format (1.299,00). Der
Upload liefert den Vorschlag als suggested_price_cents mit. 5 neue Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 20:57:30 +02:00
Scarriffle
61270e6d00 Backend: Kaufpreis + Belege an Einzelstücken, PDF-Garantie-Vorschlag
- Item: price_cents + currency (Kaufpreis rappen-/centgenau). Migration ergaenzt.
- Neue Tabelle item_documents (mehrere Belege je Stueck, PDF oder Bild); Blob
  wird verzoegert geladen, damit Item-Listen leicht bleiben.
- Endpunkte: Upload (POST), Ansehen/Download (GET), Loeschen (DELETE) je Stueck.
  Dateiname beim Download gehaertet (keine Header-Injektion).
- services/warranty.py: schaetzt aus PDF-Text lokal ein Garantieende
  (Zeitraum + Kaufdatum, oder Datum nahe Garantie-Stichwort); pypdf ergaenzt.
  Der Upload liefert den Vorschlag zurueck. 10 neue Tests, Suite 136 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 20:49:08 +02:00
13 changed files with 847 additions and 22 deletions

View File

@@ -85,6 +85,9 @@ def _ensure_schema() -> None:
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt. # Einzelstück-Verwaltung (Items mit UID/QR) je Produkt.
"ALTER TABLE products ADD COLUMN IF NOT EXISTS individual BOOLEAN " "ALTER TABLE products ADD COLUMN IF NOT EXISTS individual BOOLEAN "
"NOT NULL DEFAULT FALSE", "NOT NULL DEFAULT FALSE",
# Kaufpreis (in Rappen/Cent) und Währung am Einzelstück.
"ALTER TABLE items ADD COLUMN IF NOT EXISTS price_cents INTEGER",
"ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)",
] ]
with engine.begin() as conn: with engine.begin() as conn:
for stmt in stmts: for stmt in stmts:

View File

@@ -530,8 +530,35 @@ class Item(Base):
acquired_on: Mapped[date | None] = mapped_column(Date, nullable=True) # gekauft am acquired_on: Mapped[date | None] = mapped_column(Date, nullable=True) # gekauft am
warranty_until: Mapped[date | None] = mapped_column(Date, nullable=True) # Garantie bis warranty_until: Mapped[date | None] = mapped_column(Date, nullable=True) # Garantie bis
note: Mapped[str | None] = mapped_column(String(255), nullable=True) # Notiz/Zustand note: Mapped[str | None] = mapped_column(String(255), nullable=True) # Notiz/Zustand
# Kaufpreis in kleinster Einheit (Rappen/Cent), damit keine Float-Rundung
# entsteht; Währung getrennt (z.B. CHF, EUR).
price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
product: Mapped[Product] = relationship() product: Mapped[Product] = relationship()
location: Mapped[Location | None] = relationship() location: Mapped[Location | None] = relationship()
shop: Mapped[Shop | None] = relationship() shop: Mapped[Shop | None] = relationship()
documents: Mapped[list["ItemDocument"]] = relationship(
cascade="all, delete-orphan", order_by="ItemDocument.id"
)
class ItemDocument(Base):
"""Beleg zu einem Einzelstück: Rechnung oder Garantieschein (PDF oder Bild).
Eigene Tabelle wie :class:`ProductImage`, damit die Binärdaten nicht bei
jeder Item-Abfrage mitgezogen werden. Mehrere Belege je Stück sind erlaubt.
"""
__tablename__ = "item_documents"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
item_id: Mapped[int] = mapped_column(
ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True
)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
content_type: Mapped[str] = mapped_column(String(64), nullable=False)
# Nur bei Bedarf laden (Download): sonst zoege jede Item-Liste die Belege mit.
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, deferred=True)
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)

View File

@@ -5,17 +5,44 @@ Ein Item ist ein physisches Exemplar mit eigener UID/QR und eigenen Angaben
Gegenstands-Produkte mit ``Product.individual``. Gegenstands-Produkte mit ``Product.individual``.
""" """
from fastapi import APIRouter, Depends, HTTPException, status import re
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, require_admin from ..deps import get_current_user, require_admin
from ..models import Item, Location, Movement, MovementType, Product, Shop, User from ..models import (
from ..schemas import ItemCreate, ItemOut, ItemRemove, ItemUpdate Item,
ItemDocument,
Location,
Movement,
MovementType,
Product,
Shop,
User,
)
from ..schemas import (
ItemCreate,
ItemDocumentUploadOut,
ItemOut,
ItemRemove,
ItemUpdate,
)
from ..services.items import generate_uid, item_to_out from ..services.items import generate_uid, item_to_out
from ..services.warranty import (
extract_pdf_text,
guess_price_cents,
guess_warranty_until,
)
router = APIRouter(tags=["items"]) router = APIRouter(tags=["items"])
# Belege sind meist kleine PDFs; die Grenze schuetzt vor Ausreissern und passt
# zur nginx-Grenze (client_max_body_size 12m).
DOC_MAX_BYTES = 10 * 1024 * 1024
def _product_or_404(db: Session, product_id: int) -> Product: def _product_or_404(db: Session, product_id: int) -> Product:
product = db.get(Product, product_id) product = db.get(Product, product_id)
@@ -72,6 +99,8 @@ def create_items(
acquired_on=payload.acquired_on, acquired_on=payload.acquired_on,
warranty_until=payload.warranty_until, warranty_until=payload.warranty_until,
note=payload.note, note=payload.note,
price_cents=payload.price_cents,
currency=payload.currency,
) )
db.add(item) db.add(item)
db.flush() db.flush()
@@ -156,3 +185,106 @@ def delete_item(
"""Einzelstück ohne Grund löschen (Korrektur).""" """Einzelstück ohne Grund löschen (Korrektur)."""
db.delete(_item_or_404(db, item_id)) db.delete(_item_or_404(db, item_id))
db.commit() db.commit()
# --------------------------------------------------------------------------
# Belege (Rechnung/Garantieschein) je Einzelstück
# --------------------------------------------------------------------------
def _document_or_404(db: Session, item_id: int, doc_id: int) -> ItemDocument:
doc = (
db.query(ItemDocument)
.filter(ItemDocument.id == doc_id, ItemDocument.item_id == item_id)
.first()
)
if doc is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Beleg nicht gefunden")
return doc
def _safe_filename(name: str | None) -> str:
"""Steuerzeichen/Anführungszeichen raus sonst Header-Injektion im Download."""
cleaned = re.sub(r'[\r\n"]', "", (name or "").strip())
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()
if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
if len(data) > DOC_MAX_BYTES:
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
f"Datei zu groß (max. {DOC_MAX_BYTES // (1024 * 1024)} MB).",
)
content_type = file.content_type or "application/octet-stream"
if content_type != "application/pdf" and not content_type.startswith("image/"):
raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur PDF oder Bild erlaubt."
)
doc = ItemDocument(
item_id=item.id,
filename=_safe_filename(file.filename),
content_type=content_type,
data=data,
)
db.add(doc)
db.commit()
db.refresh(doc)
# Garantieende und Preis nur aus PDFs schätzen (Bilder haben keine Textebene).
warranty = price = None
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)
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,
)
@router.get("/items/{item_id}/documents/{doc_id}")
def get_item_document(
item_id: int,
doc_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Response:
"""Beleg anzeigen/herunterladen."""
doc = _document_or_404(db, item_id, doc_id)
return Response(
content=doc.data,
media_type=doc.content_type,
headers={"Content-Disposition": f'inline; filename="{doc.filename}"'},
)
@router.delete(
"/items/{item_id}/documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT
)
def delete_item_document(
item_id: int,
doc_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
db.delete(_document_or_404(db, item_id, doc_id))
db.commit()

View File

@@ -512,6 +512,8 @@ class ItemCreate(BaseModel):
acquired_on: date | None = None acquired_on: date | None = None
warranty_until: date | None = None warranty_until: date | None = None
note: str | None = Field(default=None, max_length=255) note: str | None = Field(default=None, max_length=255)
price_cents: int | None = Field(default=None, ge=0)
currency: str | None = Field(default=None, max_length=3)
class ItemUpdate(BaseModel): class ItemUpdate(BaseModel):
@@ -520,6 +522,8 @@ class ItemUpdate(BaseModel):
acquired_on: date | None = None acquired_on: date | None = None
warranty_until: date | None = None warranty_until: date | None = None
note: str | None = Field(default=None, max_length=255) note: str | None = Field(default=None, max_length=255)
price_cents: int | None = Field(default=None, ge=0)
currency: str | None = Field(default=None, max_length=3)
class ItemRemove(BaseModel): class ItemRemove(BaseModel):
@@ -527,6 +531,21 @@ class ItemRemove(BaseModel):
note: str | None = Field(default=None, max_length=255) note: str | None = Field(default=None, max_length=255)
class ItemDocumentOut(BaseModel):
"""Beleg-Metadaten (ohne Binärdaten)."""
model_config = ConfigDict(from_attributes=True)
id: int
filename: str
content_type: str
uploaded_at: datetime
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
class ItemOut(BaseModel): class ItemOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@@ -539,10 +558,13 @@ class ItemOut(BaseModel):
acquired_on: date | None = None acquired_on: date | None = None
warranty_until: date | None = None warranty_until: date | None = None
note: str | None = None note: str | None = None
price_cents: int | None = None
currency: str | None = None
created_at: datetime created_at: datetime
# Für QR-Auflösung/Anzeige mitgeliefert: # Für QR-Auflösung/Anzeige mitgeliefert:
product_name: str | None = None product_name: str | None = None
product_brand: str | None = None product_brand: str | None = None
documents: list[ItemDocumentOut] = []
# ---- Views ---- # ---- Views ----

View File

@@ -0,0 +1,179 @@
"""Aus einem Beleg-PDF ein mögliches Garantieende schätzen lokal, ohne KI.
Zwei Wege, in dieser Reihenfolge:
1. Garantiezeitraum ("24 Monate", "2 Jahre") + Kaufdatum (aus dem Beleg oder vom
Einzelstück) → Enddatum ausrechnen.
2. Ein Datum in der Nähe eines Garantie-Stichworts.
Bewusst konservativ: lieber nichts vorschlagen als etwas Falsches das Ergebnis
ist nur ein Vorschlag, den der Nutzer bestätigt. Reine Scan-PDFs ohne Textebene
liefern keinen Text und damit keinen Vorschlag (dafür bräuchte es OCR).
"""
from __future__ import annotations
import io
import re
from datetime import date
from dateutil.relativedelta import relativedelta
_KEYWORD_RE = re.compile(r"garantie|gew[äa]hrleistung|warranty", re.IGNORECASE)
_PERIOD_RE = re.compile(r"(\d{1,3})\s*(jahr|monat|year|month)", re.IGNORECASE)
_PURCHASE_KW = re.compile(
r"rechnungsdatum|rechnung|kaufdatum|bestelldatum|bestellt|belegdatum|"
r"datum|invoice|order date|purchase|receipt",
re.IGNORECASE,
)
# 2024-03-31 oder 31.03.2024 / 31/03/24
_DATE_RE = re.compile(
r"(\d{4})-(\d{1,2})-(\d{1,2})|(\d{1,2})[.\/](\d{1,2})[.\/](\d{2,4})"
)
_WINDOW = 60 # Zeichen um ein Stichwort, in denen Zahl/Datum als zugehörig gelten
def extract_pdf_text(data: bytes) -> str:
"""Text aus einem PDF ziehen. Schlägt es fehl (kaputt, reiner Scan), ""."""
try:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(data))
return "\n".join((page.extract_text() or "") for page in reader.pages)
except Exception:
return ""
def _match_to_date(m: re.Match) -> date | None:
g = m.groups()
try:
if g[0] is not None: # ISO yyyy-mm-dd
y, mo, d = int(g[0]), int(g[1]), int(g[2])
else: # dd.mm.yyyy / dd/mm/yy
d, mo, y = int(g[3]), int(g[4]), int(g[5])
if y < 100:
y += 2000
return date(y, mo, d)
except (TypeError, ValueError):
return None
def _all_dates(text: str) -> list[tuple[int, date]]:
out: list[tuple[int, date]] = []
for m in _DATE_RE.finditer(text):
d = _match_to_date(m)
if d is not None:
out.append((m.start(), d))
return out
def _find_period(text: str) -> tuple[int, str] | None:
"""(Anzahl, Einheit) in der Nähe eines Garantie-Stichworts beide Reihenfolgen."""
for kw in _KEYWORD_RE.finditer(text):
fenster = text[max(0, kw.start() - _WINDOW): kw.end() + _WINDOW]
p = _PERIOD_RE.search(fenster)
if p:
return int(p.group(1)), p.group(2).lower()
return None
def _find_purchase_date(text: str) -> date | None:
dates = _all_dates(text)
if not dates:
return None
best: date | None = None
best_dist = 10**9
for kw in _PURCHASE_KW.finditer(text):
for pos, d in dates:
dist = abs(pos - kw.start())
if dist <= _WINDOW and dist < best_dist:
best, best_dist = d, dist
# Kein Kauf-Stichwort getroffen: das früheste Datum liegt am ehesten vor dem
# Garantieende.
return best if best is not None else min(d for _, d in dates)
def _find_date_near_keyword(text: str) -> date | None:
dates = _all_dates(text)
best: date | None = None
best_dist = 10**9
for kw in _KEYWORD_RE.finditer(text):
for pos, d in dates:
dist = abs(pos - kw.start())
if dist <= _WINDOW and dist < best_dist:
best, best_dist = d, dist
return best
def _add_period(base: date, n: int, unit: str) -> date:
if unit.startswith(("jahr", "year")):
return base + relativedelta(years=n)
return base + relativedelta(months=n)
def guess_warranty_until(text: str, acquired_on: date | None = None) -> date | None:
"""Bestes rät fürs Garantieende oder ``None``.
``acquired_on`` ist das am Einzelstück hinterlegte Kaufdatum es hat Vorrang
vor einem im Beleg gefundenen Datum.
"""
if not text:
return None
period = _find_period(text)
base = acquired_on or _find_purchase_date(text)
if period and base:
return _add_period(base, period[0], period[1])
return _find_date_near_keyword(text)
# --------------------------------------------------------------------------
# Kaufpreis aus dem Beleg schätzen
# --------------------------------------------------------------------------
_TOTAL_KW = re.compile(
r"gesamtbetrag|gesamtsumme|rechnungsbetrag|endbetrag|gesamt|summe|total|"
r"zu zahlen|amount due|grand total|total due",
re.IGNORECASE,
)
# Beträge mit 2 Nachkommastellen, mit/ohne Tausendertrenner: 1'299.00 / 1.299,00 / 49,90
_AMOUNT_RE = re.compile(r"\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2}")
def _amount_to_cents(raw: str) -> int | None:
""""1'299.00" / "1.299,00" / "49,90" -> Rappen/Cent."""
s = raw.replace(" ", "").replace("'", "")
m = re.search(r"[.,](\d{2})$", s) # letzter Trenner ist der Dezimalpunkt
if not m:
return None
dezimal = m.group(1)
ganz = re.sub(r"[.,]", "", s[: m.start()]) # Tausendertrenner entfernen
if not ganz.isdigit():
return None
return int(ganz) * 100 + int(dezimal)
def guess_price_cents(text: str) -> int | None:
"""Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``.
Bevorzugt einen Betrag nahe einem Summen-Stichwort; sonst den größten Betrag
(die Gesamtsumme ist auf Rechnungen meist der höchste Wert).
"""
if not text:
return None
betraege = [
(m.start(), _amount_to_cents(m.group())) for m in _AMOUNT_RE.finditer(text)
]
betraege = [(pos, c) for pos, c in betraege if c]
if not betraege:
return None
# Die Summe steht hinter ihrem Label ("Gesamtbetrag: 54.90") deshalb den
# nächsten Betrag *nach* dem Stichwort nehmen, nicht den absolut nächsten
# (sonst gewönne eine davor stehende Zwischenzeile).
best: int | None = None
best_dist = 10**9
for kw in _TOTAL_KW.finditer(text):
for pos, c in betraege:
if pos >= kw.start():
dist = pos - kw.start()
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)

View File

@@ -9,4 +9,5 @@ bcrypt==4.2.1
python-multipart==0.0.20 python-multipart==0.0.20
httpx==0.28.1 httpx==0.28.1
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
pypdf==5.1.0
pytest==8.3.4 pytest==8.3.4

View File

@@ -0,0 +1,55 @@
"""Einzelstücke: Kaufpreis und Belege (Metadaten in der Ausgabe, Dateinamen-Härtung)."""
import pytest
from app.models import Category, CategoryTracking, Item, ItemDocument, Product, Role, User
from app.routers.items import _safe_filename, create_items, get_item
from app.schemas import ItemCreate
@pytest.fixture()
def admin(db):
person = User(username="admin", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def _product(db):
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
product = Product(name="Kamera", category_id=cat.id, individual=True)
db.add(product)
db.commit()
db.refresh(product)
return product
def test_item_kaufpreis_round_trip(db, admin):
product = _product(db)
out = create_items(
product.id, ItemCreate(count=1, price_cents=49900, currency="CHF"), db=db, _=admin
)
assert out[0].price_cents == 49900
assert out[0].currency == "CHF"
def test_item_out_listet_belege(db, admin):
product = _product(db)
item_id = create_items(product.id, ItemCreate(count=1), db=db, _=admin)[0].id
db.add(ItemDocument(item_id=item_id, filename="rechnung.pdf",
content_type="application/pdf", data=b"%PDF-1.4 x"))
db.commit()
fresh = get_item(item_id, db=db, _=admin)
assert len(fresh.documents) == 1
assert fresh.documents[0].filename == "rechnung.pdf"
assert fresh.documents[0].content_type == "application/pdf"
def test_safe_filename_entfernt_header_zeichen():
assert _safe_filename('a"b\r\nc.pdf') == "abc.pdf"
assert _safe_filename(" ") == "beleg"
assert _safe_filename(None) == "beleg"

View File

@@ -0,0 +1,66 @@
"""Lokale Garantieende-Schätzung aus Belegtext (ohne echtes PDF)."""
from datetime import date
from app.services.warranty import guess_price_cents, guess_warranty_until
def test_zeitraum_plus_kaufdatum_vom_stueck():
# Beleg nennt nur den Zeitraum; das Kaufdatum kommt vom Einzelstück.
text = "Vielen Dank für Ihren Einkauf. 24 Monate Garantie auf dieses Gerät."
assert guess_warranty_until(text, acquired_on=date(2024, 3, 31)) == date(2026, 3, 31)
def test_jahre_werden_verstanden():
text = "2 Jahre Gewährleistung"
assert guess_warranty_until(text, acquired_on=date(2023, 1, 15)) == date(2025, 1, 15)
def test_zeitraum_plus_rechnungsdatum_aus_dem_beleg():
text = "Rechnungsdatum: 05.06.2024\nGarantie: 12 Monate"
assert guess_warranty_until(text) == date(2025, 6, 5)
def test_warranty_keyword_before_period_english():
text = "Warranty period is 36 months from purchase."
assert guess_warranty_until(text, acquired_on=date(2022, 2, 1)) == date(2025, 2, 1)
def test_explizites_datum_neben_stichwort():
text = "Garantie gültig bis 31.12.2027."
assert guess_warranty_until(text) == date(2027, 12, 31)
def test_ohne_hinweise_kein_vorschlag():
assert guess_warranty_until("Nur ein Kassenbon ohne alles.") is None
def test_leerer_text_ist_none():
assert guess_warranty_until("", acquired_on=date(2024, 1, 1)) is None
# ---- Preis ----
def test_preis_nahe_total_stichwort():
text = "Artikel 49.00\nVersand 5.90\nGesamtbetrag: 54.90 CHF"
assert guess_price_cents(text) == 5490
def test_preis_mit_tausendertrenner_apostroph():
text = "Total CHF 1'299.00"
assert guess_price_cents(text) == 129900
def test_preis_deutsches_format():
text = "Rechnungsbetrag 1.299,00 EUR"
assert guess_price_cents(text) == 129900
def test_preis_ohne_stichwort_nimmt_groessten_betrag():
text = "Position A 12,90\nPosition B 199,00\nDanke."
assert guess_price_cents(text) == 19900
def test_preis_ohne_betrag_ist_none():
assert guess_price_cents("Kein Preis hier.") is None
assert guess_price_cents("") is None

View File

@@ -182,6 +182,36 @@ actor APIClient {
try await sendNoContent(request) try await sendNoContent(request)
} }
func uploadItemDocument(itemId: Int, data: Data, filename: String,
contentType: String) async throws -> ItemDocumentUpload {
var request = try makeRequest("/items/\(itemId)/documents", 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: ItemDocumentUpload.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)
try check(response, data: data)
return data
}
func deleteItemDocument(itemId: Int, docId: Int) async throws {
try await sendNoContent(
try makeRequest("/items/\(itemId)/documents/\(docId)", method: "DELETE"))
}
func deleteItem(id: Int) async throws { func deleteItem(id: Int) async throws {
try await sendNoContent(try makeRequest("/items/\(id)", method: "DELETE")) try await sendNoContent(try makeRequest("/items/\(id)", method: "DELETE"))
} }

View File

@@ -1,5 +1,8 @@
import SwiftUI import SwiftUI
import CoreImage import CoreImage
import PhotosUI
import QuickLook
import UniformTypeIdentifiers
/// QR-Code als Bild (CoreImage). Inhalt ist der Link auf das Einzelstück, damit /// QR-Code als Bild (CoreImage). Inhalt ist der Link auf das Einzelstück, damit
/// ein Scan auch mit der Systemkamera die App/Weboberfläche öffnet. /// ein Scan auch mit der Systemkamera die App/Weboberfläche öffnet.
@@ -96,11 +99,20 @@ struct ItemEditView: View {
@State private var hasWarranty = false @State private var hasWarranty = false
@State private var warranty = Date() @State private var warranty = Date()
@State private var note = "" @State private var note = ""
@State private var priceText = ""
@State private var currency = "CHF"
@State private var documents: [ItemDocument] = []
@State private var suggWarranty: String?
@State private var suggPrice: Int?
@State private var busy = false @State private var busy = false
@State private var busyDoc = false
@State private var error: String? @State private var error: String?
@State private var showRemove = false @State private var showRemove = false
@State private var reason = "broken" @State private var reason = "broken"
@State private var removeNote = "" @State private var removeNote = ""
@State private var showFileImporter = false
@State private var pickerItem: PhotosPickerItem?
@State private var previewURL: URL?
var body: some View { var body: some View {
Form { Form {
@@ -136,6 +148,52 @@ struct ItemEditView: View {
LabeledField(label: "Notiz", text: $note) LabeledField(label: "Notiz", text: $note)
} }
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("Belege (Rechnung/Garantieschein)") {
if suggWarranty != nil || suggPrice != nil {
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)") }
HStack {
Button("Übernehmen") { applySuggestion() }
Spacer()
Button("Verwerfen") { suggWarranty = nil; suggPrice = nil }
.foregroundStyle(.secondary)
}
.font(.callout)
}
}
ForEach(documents) { d in
Button { Task { await openDocument(d) } } label: {
Label(d.filename, systemImage: "doc.text")
}
.swipeActions(edge: .trailing) {
Button("Löschen", role: .destructive) { Task { await deleteDoc(d) } }
}
}
if documents.isEmpty {
Text("Noch keine Belege.").foregroundStyle(.secondary)
}
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Bild hochladen", systemImage: "photo")
}
Button { showFileImporter = true } label: {
Label("PDF hochladen", systemImage: "doc.badge.plus")
}
if busyDoc { ProgressView() }
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section { Section {
@@ -150,6 +208,30 @@ struct ItemEditView: View {
.sheet(isPresented: $showRemove) { .sheet(isPresented: $showRemove) {
NavigationStack { removeSheet } NavigationStack { removeSheet }
} }
.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 upload(data: data, filename: url.lastPathComponent,
contentType: "application/pdf")
}
}
} else if case .failure(let err) = result {
error = err.localizedDescription
}
}
.onChange(of: pickerItem) { neu in
guard let neu else { return }
Task {
if let data = try? await neu.loadTransferable(type: Data.self) {
await upload(data: data, filename: "foto.jpg", contentType: "image/jpeg")
}
pickerItem = nil
}
}
.quickLookPreview($previewURL)
} }
private var removeSheet: some View { private var removeSheet: some View {
@@ -174,23 +256,84 @@ struct ItemEditView: View {
locationId = item.locationId locationId = item.locationId
shopId = item.shopId shopId = item.shopId
note = item.note ?? "" note = item.note ?? ""
documents = item.documents
currency = item.currency ?? "CHF"
if let p = item.priceCents { priceText = ItemEditView.formatCents(p) }
if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true } if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true }
if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true } if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
} }
private func save() async { private func save() async {
busy = true; defer { busy = false }; error = nil busy = true; defer { busy = false }; error = nil
let cents = ItemEditView.parseCents(priceText)
do { do {
_ = try await APIClient.shared.updateItem(id: item.id, ItemUpdateRequest( _ = try await APIClient.shared.updateItem(id: item.id, ItemUpdateRequest(
locationId: locationId, shopId: shopId, locationId: locationId, shopId: shopId,
acquiredOn: hasAcquired ? dateToString(acquired) : nil, acquiredOn: hasAcquired ? dateToString(acquired) : nil,
warrantyUntil: hasWarranty ? dateToString(warranty) : nil, warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
note: note.isEmpty ? nil : note)) note: note.isEmpty ? nil : note,
priceCents: cents,
currency: cents == nil ? nil : currency))
onChanged?() onChanged?()
dismiss() dismiss()
} catch { self.error = error.localizedDescription } } catch { self.error = error.localizedDescription }
} }
// MARK: - Belege
private func upload(data: Data, filename: String, contentType: String) async {
busyDoc = true
defer { busyDoc = false }
do {
let res = try await APIClient.shared.uploadItemDocument(
itemId: item.id, data: data, filename: filename, contentType: contentType)
suggWarranty = res.suggestedWarrantyUntil
suggPrice = res.suggestedPriceCents
await reloadDocuments()
} catch { self.error = error.localizedDescription }
}
private func reloadDocuments() async {
if let fresh = try? await APIClient.shared.itemByUid(uid: item.uid) {
documents = fresh.documents
}
}
private func openDocument(_ d: ItemDocument) async {
do {
let data = try await APIClient.shared.itemDocumentData(itemId: item.id, docId: d.id)
let name = d.filename.isEmpty ? "beleg" : d.filename
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
try data.write(to: url)
previewURL = url
} catch { self.error = error.localizedDescription }
}
private func deleteDoc(_ d: ItemDocument) async {
do {
try await APIClient.shared.deleteItemDocument(itemId: item.id, docId: d.id)
await reloadDocuments()
} catch { self.error = error.localizedDescription }
}
private func applySuggestion() {
if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true }
if let p = suggPrice { priceText = ItemEditView.formatCents(p) }
suggWarranty = nil
suggPrice = nil
}
/// Eingabe in Hauptwährungseinheit Rappen/Cent.
static func parseCents(_ s: String) -> Int? {
let t = s.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".")
guard !t.isEmpty, let v = Double(t) else { return nil }
return Int((v * 100).rounded())
}
static func formatCents(_ c: Int) -> String {
String(format: "%.2f", Double(c) / 100)
}
private func remove() async { private func remove() async {
do { do {
try await APIClient.shared.removeItem(id: item.id, ItemRemoveRequest( try await APIClient.shared.removeItem(id: item.id, ItemRemoveRequest(

View File

@@ -583,6 +583,19 @@ enum RemovalReasons {
// MARK: - Einzelstücke (Items mit UID/QR) // MARK: - Einzelstücke (Items mit UID/QR)
struct ItemDocument: Codable, Identifiable, Hashable {
let id: Int
let filename: String
let contentType: String
let uploadedAt: String
enum CodingKeys: String, CodingKey {
case id, filename
case contentType = "content_type"
case uploadedAt = "uploaded_at"
}
}
struct Item: Codable, Identifiable, Hashable { struct Item: Codable, Identifiable, Hashable {
let id: Int let id: Int
let uid: String let uid: String
@@ -594,12 +607,15 @@ struct Item: Codable, Identifiable, Hashable {
let acquiredOn: String? // "yyyy-MM-dd" let acquiredOn: String? // "yyyy-MM-dd"
let warrantyUntil: String? let warrantyUntil: String?
let note: String? let note: String?
let priceCents: Int?
let currency: String?
let createdAt: String let createdAt: String
let productName: String? let productName: String?
let productBrand: String? let productBrand: String?
let documents: [ItemDocument]
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, uid, note case id, uid, note, currency, documents
case productId = "product_id" case productId = "product_id"
case locationId = "location_id" case locationId = "location_id"
case locationName = "location_name" case locationName = "location_name"
@@ -607,12 +623,26 @@ struct Item: Codable, Identifiable, Hashable {
case shopName = "shop_name" case shopName = "shop_name"
case acquiredOn = "acquired_on" case acquiredOn = "acquired_on"
case warrantyUntil = "warranty_until" case warrantyUntil = "warranty_until"
case priceCents = "price_cents"
case createdAt = "created_at" case createdAt = "created_at"
case productName = "product_name" case productName = "product_name"
case productBrand = "product_brand" case productBrand = "product_brand"
} }
} }
/// 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?
enum CodingKeys: String, CodingKey {
case id
case suggestedWarrantyUntil = "suggested_warranty_until"
case suggestedPriceCents = "suggested_price_cents"
}
}
struct ItemCreateRequest: Codable { struct ItemCreateRequest: Codable {
let count: Int let count: Int
let locationId: Int? let locationId: Int?
@@ -636,13 +666,16 @@ struct ItemUpdateRequest: Encodable {
var acquiredOn: String? var acquiredOn: String?
var warrantyUntil: String? var warrantyUntil: String?
var note: String? var note: String?
var priceCents: Int?
var currency: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case note case note, currency
case locationId = "location_id" case locationId = "location_id"
case shopId = "shop_id" case shopId = "shop_id"
case acquiredOn = "acquired_on" case acquiredOn = "acquired_on"
case warrantyUntil = "warranty_until" case warrantyUntil = "warranty_until"
case priceCents = "price_cents"
} }
// Immer alle Felder senden (auch null), damit sich ein Feld leeren lässt. // Immer alle Felder senden (auch null), damit sich ein Feld leeren lässt.
@@ -653,6 +686,8 @@ struct ItemUpdateRequest: Encodable {
try c.encode(acquiredOn, forKey: .acquiredOn) try c.encode(acquiredOn, forKey: .acquiredOn)
try c.encode(warrantyUntil, forKey: .warrantyUntil) try c.encode(warrantyUntil, forKey: .warrantyUntil)
try c.encode(note, forKey: .note) try c.encode(note, forKey: .note)
try c.encode(priceCents, forKey: .priceCents)
try c.encode(currency, forKey: .currency)
} }
} }

View File

@@ -162,6 +162,15 @@ export const api = {
updateItem: (id, body) => request(`/items/${id}`, { method: "PATCH", body }), updateItem: (id, body) => request(`/items/${id}`, { method: "PATCH", body }),
removeItem: (id, body) => request(`/items/${id}/remove`, { method: "POST", body }), removeItem: (id, body) => request(`/items/${id}/remove`, { method: "POST", body }),
deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }), 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).
uploadItemDocument: (itemId, file) => {
const fd = new FormData();
fd.append("file", file);
return request(`/items/${itemId}/documents`, { method: "POST", formData: fd });
},
deleteItemDocument: (itemId, docId) =>
request(`/items/${itemId}/documents/${docId}`, { method: "DELETE" }),
// Bestand // Bestand
checkIn: (body) => request("/stock/checkin", { method: "POST", body }), checkIn: (body) => request("/stock/checkin", { method: "POST", body }),

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { api } from "../api"; import { api, authorizedObjectUrl } from "../api";
import { useConfirm } from "../confirm"; import { useConfirm } from "../confirm";
import { useToast } from "../toast"; import { useToast } from "../toast";
import Icon from "./Icon"; import Icon from "./Icon";
@@ -37,6 +37,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [addForm, setAddForm] = useState(EMPTY_ADD); const [addForm, setAddForm] = useState(EMPTY_ADD);
const [remove, setRemove] = useState(null); // { item, reason, note } | null const [remove, setRemove] = useState(null); // { item, reason, note } | null
const [expanded, setExpanded] = useState(null); // Item-ID mit offenem Detail (Preis/Belege)
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
const origin = window.location.origin; const origin = window.location.origin;
async function load() { async function load() {
@@ -68,6 +70,44 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
} catch (err) { onError(err.message); } } catch (err) { onError(err.message); }
} }
// Kaufpreis in Hauptwährungseinheit eingeben, als Rappen/Cent speichern.
function savePrice(item, value) {
const roh = value.trim().replace(",", ".");
const cents = roh === "" ? null : Math.round(parseFloat(roh) * 100);
if (cents !== null && Number.isNaN(cents)) return;
if (cents === (item.price_cents ?? null)) return;
patchItem(item, { price_cents: cents, currency: cents == null ? null : (item.currency || "CHF") });
}
async function uploadDoc(item, file) {
try {
const res = await api.uploadItemDocument(item.id, file);
await load();
if (res && (res.suggested_warranty_until || res.suggested_price_cents != null)) {
setSuggestion({
itemId: item.id,
date: res.suggested_warranty_until || null,
priceCents: res.suggested_price_cents ?? null,
});
}
toast("Beleg hochgeladen.");
} catch (err) { onError(err.message); }
}
async function deleteDoc(item, doc) {
try {
await api.deleteItemDocument(item.id, doc.id);
await load();
} catch (err) { onError(err.message); }
}
async function viewDoc(item, doc) {
try {
const url = await authorizedObjectUrl(`/items/${item.id}/documents/${doc.id}`);
if (url) window.open(url, "_blank", "noopener");
} catch (err) { onError(err.message); }
}
async function patchItem(item, body) { async function patchItem(item, body) {
try { try {
await api.updateItem(item.id, body); await api.updateItem(item.id, body);
@@ -144,7 +184,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</thead> </thead>
<tbody> <tbody>
{items.map((it) => ( {items.map((it) => (
<tr key={it.id}> <Fragment key={it.id}>
<tr>
<td><QrImg text={`${origin}/i/${it.uid}`} size={56} /></td> <td><QrImg text={`${origin}/i/${it.uid}`} size={56} /></td>
<td data-label="UID" className="strong nowrap">{it.uid}</td> <td data-label="UID" className="strong nowrap">{it.uid}</td>
<td data-label="Lagerort"> <td data-label="Lagerort">
@@ -164,7 +205,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</td> </td>
<td data-label="Garantie bis"> <td data-label="Garantie bis">
{isAdmin ? ( {isAdmin ? (
<input type="date" defaultValue={it.warranty_until || ""} style={{ marginTop: 0 }} <input key={it.warranty_until || "none"} type="date"
defaultValue={it.warranty_until || ""} style={{ marginTop: 0 }}
onBlur={(e) => { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} /> onBlur={(e) => { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} />
) : <span>{it.warranty_until || ""}</span>} ) : <span>{it.warranty_until || ""}</span>}
</td> </td>
@@ -184,8 +226,13 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
) : <span>{it.note || ""}</span>} ) : <span>{it.note || ""}</span>}
</td> </td>
<td className="num"> <td className="num">
{isAdmin && (
<div className="field-inline" style={{ justifyContent: "flex-end" }}> <div className="field-inline" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" title="Preis & Belege"
onClick={() => setExpanded(expanded === it.id ? null : it.id)}>
<Icon name={expanded === it.id ? "close" : "package"} size={16} />
</button>
{isAdmin && (
<>
<button className="btn-icon" title="Mit Grund entfernen" <button className="btn-icon" title="Mit Grund entfernen"
onClick={() => setRemove({ item: it, reason: "broken", note: "" })}> onClick={() => setRemove({ item: it, reason: "broken", note: "" })}>
<Icon name="checkout" size={16} /> <Icon name="checkout" size={16} />
@@ -194,10 +241,86 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
onClick={() => loeschen(it)}> onClick={() => loeschen(it)}>
<Icon name="trash" size={16} /> <Icon name="trash" size={16} />
</button> </button>
</div> </>
)} )}
</div>
</td> </td>
</tr> </tr>
{expanded === it.id && (
<tr>
<td colSpan={8}>
<div style={{ display: "grid", gap: "var(--sp-4)",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
<label style={{ margin: 0 }}>Kaufpreis
<div className="field-inline">
<input key={it.price_cents ?? "none"} type="number" step="0.01" min="0"
style={{ maxWidth: 140 }} disabled={!isAdmin}
defaultValue={it.price_cents != null ? (it.price_cents / 100) : ""}
placeholder="0.00" onBlur={(e) => savePrice(it, e.target.value)} />
<select defaultValue={it.currency || "CHF"} disabled={!isAdmin}
onChange={(e) => patchItem(it, { currency: e.target.value })}>
<option value="CHF">CHF</option>
<option value="EUR">EUR</option>
</select>
</div>
</label>
<div>
<label style={{ margin: 0 }}>Belege (Rechnung/Garantieschein)
{isAdmin && (
<input type="file" accept="application/pdf,image/*"
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) && (
<div className="alert ok" style={{ marginTop: "var(--sp-2)" }}>
<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)}` : ""}.
</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);
}}>
Übernehmen
</button>
<button type="button" className="btn sm ghost" onClick={() => setSuggestion(null)}>Verwerfen</button>
</div>
)}
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
{(it.documents || []).map((d) => (
<li key={d.id} className="cell-row">
<button type="button" className="btn sm ghost" onClick={() => viewDoc(it, d)}>
<Icon name="download" size={14} />{d.filename}
</button>
{isAdmin && (
<button className="btn-icon danger" style={{ marginLeft: "auto" }}
title="Beleg löschen" onClick={() => deleteDoc(it, d)}>
<Icon name="trash" size={15} />
</button>
)}
</li>
))}
{(it.documents || []).length === 0 && (
<li className="muted small">Noch keine Belege.</li>
)}
</ul>
</div>
</div>
</td>
</tr>
)}
</Fragment>
))} ))}
{items.length === 0 && <tr><td colSpan={8} className="empty">Noch keine Einzelstücke.</td></tr>} {items.length === 0 && <tr><td colSpan={8} className="empty">Noch keine Einzelstücke.</td></tr>}
</tbody> </tbody>