Gruppen-Codes: Dublette behoben und Altbestand nachgezogen

Zwei Fehler in der EAN-Liste einer Gruppe, beide gemeldet und nachgestellt.

Derselbe Code stand zweimal in der Liste: einmal schreibgeschuetzt mit der
Kennzeichnung "Artikel", einmal darunter mit Notizfeld. Grund war, dass
_group_to_out die Codes der Artikel unabhaengig von den Gruppen-Codes
zusammengestellt hat - seit die Zuordnung automatisch einen Gruppen-Code
anlegt, trifft beides auf denselben Code zu. Die schreibgeschuetzte Zeile stand
oben, deshalb war das Notizfeld darunter leicht zu uebersehen.

Jetzt gibt es eine Zeile je Code. Der Gruppen-Code fuehrt den Artikel mit, ueber
den er dazugehoert (neues Feld product_name in BarcodeOut), zeigt weiterhin die
Kennzeichnung "Artikel" - und hat trotzdem ein Notizfeld. Der Muelleimer
entfaellt bei diesen Codes, denn sie kaemen beim naechsten Speichern des
Artikels sofort zurueck; dafuer muss der Artikel die Gruppe wechseln.

Zweitens fehlte fuer bestehende Daten der Code ganz. Die automatische Pflege
greift nur beim Anlegen und Aendern eines Artikels; Zuordnungen, die es vorher
schon gab, hatten nie einen Gruppen-Code bekommen. In der Verwaltung stand der
Code deshalb ausschliesslich als schreibgeschuetzte Artikel-Zeile - genau die
Stelle, an der sich keine Notiz hinterlegen liess. Neu holt backfill() das beim
Start nach: fuer jeden Artikel mit Gruppe und Barcode wird der Gruppen-Code
angelegt, sofern er fehlt. Gefahrlos wiederholbar.

Getestet: 58 pytest-Tests gruen, einer neu (Backfill legt den fehlenden Code an
und beim zweiten Lauf nichts doppelt). Der gemeldete Fall wurde vorher gegen die
laufende API nachgestellt - Altbestand ohne Gruppen-Code und ein doppelt
gelisteter Code nach einer Neuanlage - und danach als behoben bestaetigt: eine
Zeile je Code, mit Artikelnamen und Notizfeld. Web-Build laeuft durch.
Die Oberflaeche habe ich nicht selbst bedient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 21:41:43 +02:00
parent dfbc62398d
commit 5293810529
8 changed files with 102 additions and 14 deletions

View File

@@ -23,6 +23,7 @@ from .routers import (
views,
)
from .seed import ensure_builtin_categories, ensure_builtin_units, ensure_first_admin
from .services.group_codes import backfill as backfill_group_codes
settings = get_settings()
@@ -69,6 +70,8 @@ async def lifespan(app: FastAPI):
ensure_builtin_units(db)
ensure_builtin_categories(db)
ensure_first_admin(db)
# Codes bestehender Gruppen-Zuordnungen nachziehen.
backfill_group_codes(db)
finally:
db.close()
yield

View File

@@ -23,21 +23,36 @@ def _group_to_out(db: Session, group: Group) -> GroupOut:
out = GroupOut.model_validate(group)
products = db.query(Product).filter(Product.group_id == group.id).all()
out.product_count = len(products)
out.barcodes = [
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] = []
# Zu welchem Artikel gehoert ein Code? Der Gruppen-Code entsteht beim
# Zuordnen automatisch; die Herkunft soll trotzdem sichtbar bleiben.
name_zu_code: dict[str, str] = {}
for product in products:
if product.barcode:
name_zu_code[product.barcode] = product.name
for alias in db.query(Barcode).filter(Barcode.product_id == product.id):
name_zu_code[alias.code] = product.name
out.barcodes = []
for b in db.query(Barcode).filter(Barcode.group_id == group.id).order_by(Barcode.id).all():
eintrag = BarcodeOut.model_validate(b)
eintrag.product_name = name_zu_code.get(b.code)
out.barcodes.append(eintrag)
# Codes der Artikel in dieser Gruppe mitliefern. Sie waren bisher nirgends
# sichtbar, wodurch eine Gruppe mit Artikeln "0 EANs" anzeigte.
# Was schon als Gruppen-Code gefuehrt wird, hier ueberspringen - sonst
# steht derselbe Code zweimal in der Liste, einmal davon schreibgeschuetzt.
schon_da = {b.code for b in out.barcodes}
product_codes: list[ProductBarcodeOut] = []
for product in products:
if product.barcode and product.barcode not in schon_da:
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):
if alias.code in schon_da:
continue
product_codes.append(
ProductBarcodeOut(
code=alias.code, product_id=product.id, product_name=product.name

View File

@@ -46,6 +46,8 @@ class BarcodeOut(BaseModel):
id: int
code: str
note: str | None = None
# Nur bei Gruppen-Codes gefüllt: der Artikel, über den der Code hier steht.
product_name: str | None = None
class ProductBarcodeOut(BaseModel):

View File

@@ -64,6 +64,28 @@ def sync(db: Session, product: Product) -> None:
db.add(Barcode(code=code, group_id=product.group_id))
def backfill(db: Session) -> int:
"""Holt die Codes bestehender Zuordnungen nach.
``sync`` greift nur beim Anlegen und Ändern eines Artikels. Zuordnungen, die
es vor dieser Funktion schon gab, hätten sonst nie einen Gruppen-Code
bekommen: In der Verwaltung stand der Code dann ausschließlich als
schreibgeschützte Artikel-Zeile, ohne Möglichkeit für eine Notiz.
Läuft beim Start und ist gefahrlos wiederholbar.
"""
betroffen = (
db.query(Product)
.filter(Product.group_id.isnot(None), Product.barcode.isnot(None))
.all()
)
vorher = db.query(Barcode).filter(Barcode.group_id.isnot(None)).count()
for product in betroffen:
sync(db, product)
db.commit()
return db.query(Barcode).filter(Barcode.group_id.isnot(None)).count() - vorher
def detach(db: Session, product: Product) -> None:
"""Vor dem Löschen eines Artikels dessen Gruppen-Code aufräumen."""
code = (product.barcode or "").strip()

View File

@@ -171,3 +171,22 @@ def test_fremder_alias_wird_nicht_ueberschrieben(db):
assert _gruppen_codes(db, mehl) == set()
assert db.query(Barcode).filter(Barcode.code == "4001").one().product_id == anderer.id
def test_backfill_holt_bestehende_zuordnungen_nach(db):
"""Zuordnungen von vor der automatischen Codepflege bekommen ihren Code."""
from app.services.group_codes import backfill
mehl = Group(name="Mehl")
db.add(mehl)
db.commit()
# Bewusst ohne sync() angelegt - so sah der Altbestand aus.
db.add(Product(name="Aldi Mehl", barcode="4001", group_id=mehl.id))
db.commit()
assert _gruppen_codes(db, mehl) == set()
assert backfill(db) == 1
assert _gruppen_codes(db, mehl) == {"4001"}
# Wiederholtes Ausfuehren legt nichts doppelt an.
assert backfill(db) == 0
assert _gruppen_codes(db, mehl) == {"4001"}

View File

@@ -1,6 +1,16 @@
{
"fill" : {
"automatic-gradient" : "srgb:0.55985,0.47253,1.00000,1.00000"
"automatic-gradient" : "display-p3:0.68468,0.62596,1.03953,1.00000",
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 0
},
"stop" : {
"x" : 0.5,
"y" : 0.7
}
}
},
"groups" : [
{
@@ -12,6 +22,12 @@
"value" : {
"solid" : "extended-gray:1.00000,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"automatic-gradient" : "extended-gray:0.25000,1.00000"
}
}
],
"glass" : false,

View File

@@ -47,8 +47,14 @@ export default function BarcodeList({
<li key={b.id ?? b.code}>
<span style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0, flex: 1 }}>
<code className="strong">{b.code}</code>
{/* Notiz auch nachtraeglich aenderbar - automatisch angelegte Codes
hatten sonst nie eine Gelegenheit, eine zu bekommen. */}
{/* Gehört der Code über einen Artikel zur Gruppe, steht das dabei
eine Notiz ist trotzdem möglich. */}
{b.product_name && (
<>
<span className="badge">Artikel</span>
<span className="muted small">{b.product_name}</span>
</>
)}
{onEditNote && !disabled ? (
<input defaultValue={b.note || ""} placeholder="Notiz…"
style={{ marginTop: 0, maxWidth: 220 }}
@@ -60,7 +66,10 @@ export default function BarcodeList({
b.note && <span className="muted small">{b.note}</span>
)}
</span>
{!disabled && (
{/* Codes, die über einen Artikel hier stehen, lassen sich nicht
entfernen sie kämen beim nächsten Speichern sofort zurück.
Dafür muss der Artikel die Gruppe wechseln. */}
{!disabled && !b.product_name && (
<button className="btn-icon danger" title="Code entfernen"
onClick={() => onDelete(b.code)}>
<Icon name="trash" size={16} />

View File

@@ -173,9 +173,11 @@ export default function Groups() {
barcodes={selected.barcodes || []}
productBarcodes={selected.product_barcodes || []}
disabled={!isAdmin}
hint={"Codes der zugeordneten Artikel stehen automatisch hier. Zusätzlich kannst du Codes " +
"hinterlegen, die noch zu keinem Artikel gehören: Wird so ein Code beim " +
"Einlagern gescannt, landet der neu angelegte Artikel automatisch in dieser Gruppe."}
hint={"Codes der zugeordneten Artikel stehen automatisch hier und sind mit „Artikel“ " +
"gekennzeichnet; entfernen lassen sie sich nur, indem der Artikel die Gruppe " +
"wechselt. Eine Notiz kannst du bei jedem Code hinterlegen. Zusätzlich sind " +
"Codes möglich, die noch zu keinem Artikel gehören: Wird so einer beim " +
"Einlagern gescannt, landet der neu angelegte Artikel automatisch hier."}
onAdd={async (body) => {
try {
await api.addGroupBarcode(selected.id, body);