diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index d961873..554549a 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -4,7 +4,14 @@ from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, require_admin from ..models import Barcode, Group, Product, User -from ..schemas import BarcodeCreate, BarcodeOut, GroupCreate, GroupOut, GroupUpdate +from ..schemas import ( + BarcodeCreate, + BarcodeOut, + GroupCreate, + GroupOut, + GroupUpdate, + ProductBarcodeOut, +) from ..services.conversion import BASE_OF_KIND from ..services.stock import current_stock @@ -19,6 +26,24 @@ def _group_to_out(db: Session, group: Group) -> GroupOut: BarcodeOut.model_validate(b) for b in db.query(Barcode).filter(Barcode.group_id == group.id).order_by(Barcode.id).all() ] + # Codes der Artikel in dieser Gruppe mitliefern. Sie waren bisher nirgends + # sichtbar, wodurch eine Kategorie mit Artikeln "0 EANs" anzeigte. + product_codes: list[ProductBarcodeOut] = [] + for product in products: + if product.barcode: + product_codes.append( + ProductBarcodeOut( + code=product.barcode, product_id=product.id, product_name=product.name + ) + ) + for alias in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id): + product_codes.append( + ProductBarcodeOut( + code=alias.code, product_id=product.id, product_name=product.name + ) + ) + out.product_barcodes = product_codes + unit = group.min_stock_unit if unit is not None: # Bestand nur über Produkte gleicher Art, in der Gruppen-Einheit ausgedrückt. @@ -86,6 +111,28 @@ def update_group( return _group_to_out(db, group) +def _describe_conflict(db: Session, code: str) -> str | None: + """Sagt, wem ein Code schon gehört – "bereits vergeben" allein half nicht weiter.""" + product = db.query(Product).filter(Product.barcode == code).first() + existing = db.query(Barcode).filter(Barcode.code == code).first() + + if existing is not None and existing.product_id: + product = db.get(Product, existing.product_id) or product + if product is not None: + return ( + f"Dieser Code gehört bereits zum Artikel \"{product.name}\". " + "Artikel-Codes müssen hier nicht eingetragen werden – beim Scannen " + "wird immer zuerst der Artikel gefunden, und dessen Kategorie zählt." + ) + if existing is not None and existing.group_id: + other = db.get(Group, existing.group_id) + name = other.name if other else "einer anderen Kategorie" + return f"Dieser Code ist bereits der Kategorie \"{name}\" zugeordnet." + if existing is not None: + return "Dieser Code ist bereits vergeben." + return None + + @router.post("/{group_id}/barcodes", response_model=GroupOut, status_code=status.HTTP_201_CREATED) def add_group_barcode( group_id: int, @@ -98,10 +145,9 @@ def add_group_barcode( if group is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") code = payload.code.strip() - if db.query(Barcode).filter(Barcode.code == code).first() or ( - db.query(Product).filter(Product.barcode == code).first() - ): - raise HTTPException(status.HTTP_409_CONFLICT, "Dieser Code ist bereits vergeben") + conflict = _describe_conflict(db, code) + if conflict: + raise HTTPException(status.HTTP_409_CONFLICT, conflict) db.add(Barcode(code=code, note=(payload.note or None), group_id=group.id)) db.commit() db.refresh(group) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 9c79fb9..dea2c67 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -48,6 +48,18 @@ class BarcodeOut(BaseModel): note: str | None = None +class ProductBarcodeOut(BaseModel): + """Code, der über einen Artikel dieser Kategorie angehört. + + Rein informativ: Er steht am Artikel, nicht an der Kategorie. Beim Scannen + findet die Suche immer zuerst den Artikel (siehe routers/products.lookup), + weshalb ein gleichlautender Kategorie-Eintrag nie greifen könnte. + """ + code: str + product_id: int + product_name: str + + # ---- Auth / Users ---- class Token(BaseModel): access_token: str @@ -88,6 +100,8 @@ class GroupOut(BaseModel): min_stock_unit_name: str | None = None kind: str | None = None # Art der Mindestbestand-Einheit barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind + # EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar. + product_barcodes: list[ProductBarcodeOut] = [] class GroupCreate(BaseModel): diff --git a/ios/Sources/CheckInView.swift b/ios/Sources/CheckInView.swift index 7afb3dd..79e5c02 100644 --- a/ios/Sources/CheckInView.swift +++ b/ios/Sources/CheckInView.swift @@ -11,6 +11,7 @@ struct CheckInView: View { @State private var product: Product? @State private var suggestion: LookupResult.Suggestion? @State private var suggestedGroupId: Int? + @State private var suggestedGroupName: String? @State private var unknownCode: String? @State private var manualCodeShown = false @State private var manualCode = "" @@ -110,6 +111,25 @@ struct CheckInView: View { .padding(.horizontal) } + /// Sagt vor dem Anlegen, welche Kategorie der Artikel bekommt und warum. + @ViewBuilder + private func categoryNote() -> some View { + if let name = suggestedGroupName { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "tag") + VStack(alignment: .leading, spacing: 1) { + Text("Wird der Kategorie „\(name)“ zugeordnet").font(.caption).bold() + Text("Dieser EAN-Code ist dort hinterlegt.") + .font(.caption2).foregroundStyle(.secondary) + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.accentColor.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + private func suggestionCard(_ item: LookupResult.Suggestion) -> some View { VStack(alignment: .leading, spacing: 8) { Text(item.name).font(.headline) @@ -117,6 +137,7 @@ struct CheckInView: View { .font(.caption).foregroundStyle(.secondary) Text("Bei Open Food Facts gefunden, noch nicht im Katalog.") .font(.caption2).foregroundStyle(.secondary) + categoryNote() NavigationLink { ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId, suggestion: item) { created in @@ -140,6 +161,7 @@ struct CheckInView: View { Text("Unbekannter Code \(code)").font(.headline) Text("Weder im Katalog noch bei Open Food Facts.") .font(.caption).foregroundStyle(.secondary) + categoryNote() NavigationLink { ProductFormView(prefillBarcode: code, groupId: suggestedGroupId) { created in unknownCode = nil @@ -168,6 +190,7 @@ struct CheckInView: View { do { let result = try await APIClient.shared.lookup(barcode: code) suggestedGroupId = result.groupId + suggestedGroupName = result.groupName if let existing = result.existingProduct { product = existing } else if let hint = result.suggestion { diff --git a/web/src/components/BarcodeList.jsx b/web/src/components/BarcodeList.jsx index 214e870..7b46194 100644 --- a/web/src/components/BarcodeList.jsx +++ b/web/src/components/BarcodeList.jsx @@ -2,10 +2,18 @@ import { useState } from "react"; import Icon from "./Icon"; /** - * Liste zusätzlicher EAN-Codes mit Notiz ("Mehl bei Aldi"). + * Liste der EAN-Codes. + * + * Zwei Herkünfte in einer Liste: Codes, die über einen Artikel dazugehören + * (nur Anzeige – sie stehen am Artikel, nicht hier), und selbst hinterlegte + * Codes, die noch keinem Artikel gehören. Vorher waren nur letztere sichtbar, + * weshalb eine Kategorie mit Artikeln „0 EANs“ anzeigte. + * * onAdd({ code, note }) und onDelete(code) werden vom Aufrufer bereitgestellt. */ -export default function BarcodeList({ barcodes = [], onAdd, onDelete, disabled = false, hint }) { +export default function BarcodeList({ + barcodes = [], productBarcodes = [], onAdd, onDelete, disabled = false, hint, +}) { const [code, setCode] = useState(""); const [note, setNote] = useState(""); const [busy, setBusy] = useState(false); @@ -26,6 +34,15 @@ export default function BarcodeList({ barcodes = [], onAdd, onDelete, disabled = return (
{!disabled && ( diff --git a/web/src/pages/CheckIn.jsx b/web/src/pages/CheckIn.jsx index ed3456c..1e54183 100644 --- a/web/src/pages/CheckIn.jsx +++ b/web/src/pages/CheckIn.jsx @@ -85,7 +85,11 @@ export default function CheckIn() { const payload = suggestionToProduct(suggestion, groupId); const created = await api.createProduct(payload); selectProduct(created); - setInfo(`Produkt "${created.name}" angelegt.`); + const kategorie = groups.find((g) => String(g.id) === String(created.group_id)); + setInfo( + `Produkt "${created.name}" angelegt` + + (kategorie ? ` und der Kategorie "${kategorie.name}" zugeordnet.` : " (ohne Kategorie).") + ); } catch (err) { setError(err.message); } finally { @@ -183,10 +187,14 @@ export default function CheckIn() { {suggestion.brand ? `${suggestion.brand} · ` : ""}auf Open Food Facts gefunden {suggestion.quantity_text ? ` · ${suggestion.quantity_text}` : ""}
-
- Noch nicht im Katalog. - {suggestionGroup.name && ` Kategorie: ${suggestionGroup.name}`} -
+
Noch nicht im Katalog.
+ {suggestionGroup.name && ( +
+ + Wird automatisch der Kategorie {suggestionGroup.name} zugeordnet + – weil dieser EAN-Code dort hinterlegt ist. +
+ )} {isAdmin ? ( @@ -161,8 +163,12 @@ export default function Groups() { { try { await api.addGroupBarcode(selected.id, body); diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx index fdb389c..f9ea50d 100644 --- a/web/src/pages/Settings.jsx +++ b/web/src/pages/Settings.jsx @@ -66,7 +66,7 @@ export default function Settings() {

Chargen, deren Mindesthaltbarkeitsdatum innerhalb dieser Frist liegt (oder - bereits überschritten ist), erscheinen auf der Übersicht unter „Bald ablaufend". + bereits überschritten ist), erscheinen auf der Übersicht unter „Bald ablaufend“.

diff --git a/web/src/styles.css b/web/src/styles.css index b2a8dda..391552c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -304,6 +304,15 @@ td select { width: auto; min-width: 132px; max-width: 100%; } /* Umschalter Tagesdatum / Monat im Kopf der Chargenliste */ .precision-pick { display: flex; align-items: center; gap: var(--sp-2); margin: 0; font-weight: 400; } .precision-pick select { margin: 0; width: auto; padding-top: 3px; padding-bottom: 3px; font-size: 0.8rem; } + /* Hinweis beim Einlagern: welche Kategorie automatisch zugeordnet wird */ +.assign-note { + display: flex; align-items: center; gap: 6px; flex-wrap: wrap; + margin-top: 4px; padding: 4px 8px; + font-size: 0.8rem; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--text); +} .input-danger { border-color: var(--danger) !important; } .hint-danger { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); font-size: 0.72rem; margin-top: 3px; }