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:
@@ -23,6 +23,7 @@ from .routers import (
|
|||||||
views,
|
views,
|
||||||
)
|
)
|
||||||
from .seed import ensure_builtin_categories, ensure_builtin_units, ensure_first_admin
|
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()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -69,6 +70,8 @@ async def lifespan(app: FastAPI):
|
|||||||
ensure_builtin_units(db)
|
ensure_builtin_units(db)
|
||||||
ensure_builtin_categories(db)
|
ensure_builtin_categories(db)
|
||||||
ensure_first_admin(db)
|
ensure_first_admin(db)
|
||||||
|
# Codes bestehender Gruppen-Zuordnungen nachziehen.
|
||||||
|
backfill_group_codes(db)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -23,21 +23,36 @@ def _group_to_out(db: Session, group: Group) -> GroupOut:
|
|||||||
out = GroupOut.model_validate(group)
|
out = GroupOut.model_validate(group)
|
||||||
products = db.query(Product).filter(Product.group_id == group.id).all()
|
products = db.query(Product).filter(Product.group_id == group.id).all()
|
||||||
out.product_count = len(products)
|
out.product_count = len(products)
|
||||||
out.barcodes = [
|
# Zu welchem Artikel gehoert ein Code? Der Gruppen-Code entsteht beim
|
||||||
BarcodeOut.model_validate(b)
|
# Zuordnen automatisch; die Herkunft soll trotzdem sichtbar bleiben.
|
||||||
for b in db.query(Barcode).filter(Barcode.group_id == group.id).order_by(Barcode.id).all()
|
name_zu_code: dict[str, str] = {}
|
||||||
]
|
|
||||||
# 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:
|
for product in products:
|
||||||
if product.barcode:
|
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(
|
product_codes.append(
|
||||||
ProductBarcodeOut(
|
ProductBarcodeOut(
|
||||||
code=product.barcode, product_id=product.id, product_name=product.name
|
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):
|
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(
|
product_codes.append(
|
||||||
ProductBarcodeOut(
|
ProductBarcodeOut(
|
||||||
code=alias.code, product_id=product.id, product_name=product.name
|
code=alias.code, product_id=product.id, product_name=product.name
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ class BarcodeOut(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
code: str
|
code: str
|
||||||
note: str | None = None
|
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):
|
class ProductBarcodeOut(BaseModel):
|
||||||
|
|||||||
@@ -64,6 +64,28 @@ def sync(db: Session, product: Product) -> None:
|
|||||||
db.add(Barcode(code=code, group_id=product.group_id))
|
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:
|
def detach(db: Session, product: Product) -> None:
|
||||||
"""Vor dem Löschen eines Artikels dessen Gruppen-Code aufräumen."""
|
"""Vor dem Löschen eines Artikels dessen Gruppen-Code aufräumen."""
|
||||||
code = (product.barcode or "").strip()
|
code = (product.barcode or "").strip()
|
||||||
|
|||||||
@@ -171,3 +171,22 @@ def test_fremder_alias_wird_nicht_ueberschrieben(db):
|
|||||||
|
|
||||||
assert _gruppen_codes(db, mehl) == set()
|
assert _gruppen_codes(db, mehl) == set()
|
||||||
assert db.query(Barcode).filter(Barcode.code == "4001").one().product_id == anderer.id
|
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"}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
{
|
{
|
||||||
"fill" : {
|
"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" : [
|
"groups" : [
|
||||||
{
|
{
|
||||||
@@ -12,6 +22,12 @@
|
|||||||
"value" : {
|
"value" : {
|
||||||
"solid" : "extended-gray:1.00000,1.00000"
|
"solid" : "extended-gray:1.00000,1.00000"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance" : "tinted",
|
||||||
|
"value" : {
|
||||||
|
"automatic-gradient" : "extended-gray:0.25000,1.00000"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"glass" : false,
|
"glass" : false,
|
||||||
|
|||||||
@@ -47,8 +47,14 @@ export default function BarcodeList({
|
|||||||
<li key={b.id ?? b.code}>
|
<li key={b.id ?? b.code}>
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0, flex: 1 }}>
|
<span style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0, flex: 1 }}>
|
||||||
<code className="strong">{b.code}</code>
|
<code className="strong">{b.code}</code>
|
||||||
{/* Notiz auch nachtraeglich aenderbar - automatisch angelegte Codes
|
{/* Gehört der Code über einen Artikel zur Gruppe, steht das dabei –
|
||||||
hatten sonst nie eine Gelegenheit, eine zu bekommen. */}
|
eine Notiz ist trotzdem möglich. */}
|
||||||
|
{b.product_name && (
|
||||||
|
<>
|
||||||
|
<span className="badge">Artikel</span>
|
||||||
|
<span className="muted small">{b.product_name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{onEditNote && !disabled ? (
|
{onEditNote && !disabled ? (
|
||||||
<input defaultValue={b.note || ""} placeholder="Notiz…"
|
<input defaultValue={b.note || ""} placeholder="Notiz…"
|
||||||
style={{ marginTop: 0, maxWidth: 220 }}
|
style={{ marginTop: 0, maxWidth: 220 }}
|
||||||
@@ -60,7 +66,10 @@ export default function BarcodeList({
|
|||||||
b.note && <span className="muted small">{b.note}</span>
|
b.note && <span className="muted small">{b.note}</span>
|
||||||
)}
|
)}
|
||||||
</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"
|
<button className="btn-icon danger" title="Code entfernen"
|
||||||
onClick={() => onDelete(b.code)}>
|
onClick={() => onDelete(b.code)}>
|
||||||
<Icon name="trash" size={16} />
|
<Icon name="trash" size={16} />
|
||||||
|
|||||||
@@ -173,9 +173,11 @@ export default function Groups() {
|
|||||||
barcodes={selected.barcodes || []}
|
barcodes={selected.barcodes || []}
|
||||||
productBarcodes={selected.product_barcodes || []}
|
productBarcodes={selected.product_barcodes || []}
|
||||||
disabled={!isAdmin}
|
disabled={!isAdmin}
|
||||||
hint={"Codes der zugeordneten Artikel stehen automatisch hier. Zusätzlich kannst du Codes " +
|
hint={"Codes der zugeordneten Artikel stehen automatisch hier und sind mit „Artikel“ " +
|
||||||
"hinterlegen, die noch zu keinem Artikel gehören: Wird so ein Code beim " +
|
"gekennzeichnet; entfernen lassen sie sich nur, indem der Artikel die Gruppe " +
|
||||||
"Einlagern gescannt, landet der neu angelegte Artikel automatisch in dieser 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) => {
|
onAdd={async (body) => {
|
||||||
try {
|
try {
|
||||||
await api.addGroupBarcode(selected.id, body);
|
await api.addGroupBarcode(selected.id, body);
|
||||||
|
|||||||
Reference in New Issue
Block a user