Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b679df6fd3 | ||
|
|
266babc8bf | ||
|
|
f948620555 | ||
|
|
7b4a3532bb | ||
|
|
c9084830b8 | ||
|
|
61dde3eb08 | ||
|
|
661bd07a26 | ||
|
|
70f6a4fbce | ||
|
|
5413be105d | ||
|
|
1acfee76d8 |
@@ -2,18 +2,49 @@
|
|||||||
|
|
||||||
Browser-App, die per Handykamera gebrauchte Server-RAM-Module erkennt und
|
Browser-App, die per Handykamera gebrauchte Server-RAM-Module erkennt und
|
||||||
beim physischen Sortieren am Tisch anleitet: Modul vor die Kamera halten, die
|
beim physischen Sortieren am Tisch anleitet: Modul vor die Kamera halten, die
|
||||||
App nennt den Stapel. Verglichen wird über alle Merkmale, die bei Modul und
|
App nennt den Stapel.
|
||||||
Stapel bekannt sind — Kapazität, Bauform, Rank, Geschwindigkeit und, sofern
|
|
||||||
gelesen, Hersteller-Teilenummer (`specsCompatible` in `src/spec.js`); ein auf
|
Zwei Module gelten als zusammengehörig, wenn sie denselben Barcode-Inhalt
|
||||||
einer Seite fehlendes Merkmal wird dabei übersprungen, nicht als Widerspruch
|
tragen — ein Barcode ist exakt gelesen, das ist der zuverlässigste Maßstab.
|
||||||
gewertet. Passt danach genau ein Stapel, wird das Modul zugewiesen; passen
|
Jeder Stapel führt dafür die Menge der Barcode-Zeichenketten, die bei seinen
|
||||||
mehrere, fragt die App nach (`proposeAssignment` in `src/session.js`). Das hat
|
*aktuell* ihm zugeordneten Modulen gelesen wurden (`stack.codes`); diese Menge
|
||||||
eine bewusst in Kauf genommene Folge: Konnte die Teilenummer nicht gelesen
|
wird nicht angesammelt, sondern aus den Codes der zugeordneten Einträge
|
||||||
werden — der häufige Fall bei überklebten oder beschädigten Etiketten — und
|
(`entry.codes`) abgeleitet — genau wie der Stapelzähler (siehe „Sitzungssicherung
|
||||||
passt anhand der übrigen Merkmale nur ein Stapel, landet das Modul dort, auch
|
im Detail" unten). Sortiert der Nutzer ein Modul um oder entfernt es, verliert
|
||||||
wenn seine tatsächliche Teilenummer eine andere wäre. Diese Abwägung ist
|
der alte Stapel dessen Codes deshalb sofort wieder; ein bereinigter Fehler
|
||||||
Absicht: Ohne sie müsste die App bei jedem zweiten Modul nachfragen, sobald
|
zieht kein weiteres Modul mehr fälschlich an. Trägt ein neu gescanntes Modul
|
||||||
die Texterkennung keine Teilenummer liefert.
|
einen Code, den ein Stapel bereits kennt, ist dieser Stapel Kandidat — aber
|
||||||
|
nur, wenn die technischen Angaben beider Seiten sich dabei nicht widersprechen
|
||||||
|
(`specsCompatible` in `src/spec.js`, siehe unten); ein Codetreffer allein
|
||||||
|
genügt nicht, sonst könnte ein Code, der gar keine Teilenummer ist, zwei
|
||||||
|
technisch verschiedene Module zusammenführen (siehe „Grenzen"). Genau ein
|
||||||
|
solcher Kandidat gehört dorthin, auch wenn die technischen Angaben
|
||||||
|
unvollständig sind (`proposeAssignment` in `src/session.js`). Auf Etiketten
|
||||||
|
stehen oft zwei Codes, von denen einer die Seriennummer ist, die bei jedem
|
||||||
|
Modul anders ist: Sie läuft beim Codeabgleich einfach ins Leere, während die
|
||||||
|
danebenstehende Teilenummer trägt — kein Sonderfall, nur eine Konsequenz des
|
||||||
|
Mengenvergleichs.
|
||||||
|
|
||||||
|
Kennt kein Stapel einen der gelesenen Codes ohne Widerspruch (typischerweise
|
||||||
|
beim ersten Modul eines neuen Stapels, aber auch dann, wenn ein Codetreffer an
|
||||||
|
der Widerspruchsprüfung scheitert), vergleicht die App stattdessen über alle
|
||||||
|
Merkmale, die
|
||||||
|
bei Modul und Stapel bekannt sind — Kapazität, Bauform, Rank, Geschwindigkeit
|
||||||
|
und, sofern gelesen, Hersteller-Teilenummer (`specsCompatible` in
|
||||||
|
`src/spec.js`); ein auf einer Seite fehlendes Merkmal wird dabei übersprungen,
|
||||||
|
nicht als Widerspruch gewertet. Ein Stapel gilt dabei nur als Kandidat, wenn
|
||||||
|
er mit dem Modul mindestens ein tatsächlich erkanntes Merkmal teilt
|
||||||
|
(`sharesSetField` in `src/session.js`) — ein Stapel ganz ohne erkannte Angaben
|
||||||
|
(etwa aus einem manuell entschiedenen Rot-Fall) ist deshalb nie automatisch
|
||||||
|
treffend, sonst würde er lautlos jedes folgende Modul an sich ziehen. Passt
|
||||||
|
danach genau ein Stapel, wird das Modul zugewiesen; passen mehrere oder mehrere
|
||||||
|
über einen bekannten Code, fragt die App nach. Das hat eine bewusst in Kauf
|
||||||
|
genommene Folge: Konnte die Teilenummer nicht gelesen werden — der häufige
|
||||||
|
Fall bei überklebten oder beschädigten Etiketten — und passt anhand der
|
||||||
|
übrigen Merkmale nur ein Stapel, landet das Modul dort, auch wenn seine
|
||||||
|
tatsächliche Teilenummer eine andere wäre. Diese Abwägung ist Absicht: Ohne
|
||||||
|
sie müsste die App bei jedem zweiten Modul nachfragen, sobald die
|
||||||
|
Texterkennung keine Teilenummer liefert.
|
||||||
|
|
||||||
Die Erkennung läuft vollständig lokal im Browser — kein Server, keine Cloud,
|
Die Erkennung läuft vollständig lokal im Browser — kein Server, keine Cloud,
|
||||||
keine Daten verlassen das Gerät.
|
keine Daten verlassen das Gerät.
|
||||||
@@ -26,25 +57,50 @@ keine Daten verlassen das Gerät.
|
|||||||
dieselbe Teilenummer), gilt das als mehrdeutig — die App rät nicht,
|
dieselbe Teilenummer), gilt das als mehrdeutig — die App rät nicht,
|
||||||
sondern fragt nach. Das gilt nur für Barcodes mit einem bekannten
|
sondern fragt nach. Das gilt nur für Barcodes mit einem bekannten
|
||||||
Nummernschema; widersprechen sich mehrere Barcodes mit unbekanntem
|
Nummernschema; widersprechen sich mehrere Barcodes mit unbekanntem
|
||||||
Nummernschema, wird keine ihrer Teilenummern übernommen und das Ergebnis
|
Nummernschema, wird keine ihrer Teilenummern übernommen.
|
||||||
stützt sich allein auf die Texterkennung, ohne dass die App nachfragt.
|
|
||||||
2. **Teilenummer-Decoder.** Aus einer Hersteller-PN wie `M386A8K40BM1-CRC4Y`
|
2. **Teilenummer-Decoder.** Aus einer Hersteller-PN wie `M386A8K40BM1-CRC4Y`
|
||||||
werden Kapazität, Bauform und Geschwindigkeit tabellengesteuert
|
werden Kapazität, Bauform und Geschwindigkeit tabellengesteuert
|
||||||
abgeleitet. Gelingt das, entfällt OCR vollständig.
|
abgeleitet. Gelingt das, entfällt OCR vollständig, das Ergebnis ist grün.
|
||||||
3. **OCR als Rückfallebene.** Nur wenn kein Barcode eindeutig lesbar war (z. B.
|
Kennt der Decoder das Nummernschema nicht (siehe „Grenzen“ unten), ist das
|
||||||
überklebtes oder beschädigtes Etikett). Das Kamerabild wird dafür in
|
allein noch kein Grund für Rot: Zum Sortieren muss keine Kapazität bekannt
|
||||||
Graustufen gewandelt und mit einem **Otsu-Schwellwert** in Schwarz/Weiß
|
sein, eine exakt gelesene Teilenummer genügt, um ein Modul wiederzuerkennen
|
||||||
aufbereitet: Der Schwellwert wird aus der Helligkeitsverteilung des
|
(`recognize` in `src/pipeline.js`). Findet die anschließende Texterkennung
|
||||||
gesamten Bildes bestimmt, nicht aus einer festen Kontrastspreizung
|
dazu auch keine Kapazität, ist das Ergebnis trotzdem grün, nur eben ohne
|
||||||
zwischen hellstem und dunkelstem Pixel. Das macht die Aufbereitung robust
|
abgeleitete Angaben in der Rückmeldung. Ist einer der gelesenen Codes
|
||||||
gegen einzelne Lichtreflexe auf glänzenden Metalletiketten, die eine reine
|
bereits einem Stapel dieser Sitzung bekannt, steht die Zuordnung ohnehin
|
||||||
Min/Max-Spreizung leicht kippen würden. Das OCR-Ergebnis wird
|
fest — dann entfällt auch die Texterkennung, weil sie nichts mehr beitragen
|
||||||
anschließend gegen die bekannten Spec-Werte abgeglichen, was die
|
könnte, das über die Zuordnung entscheidet. Bei einem tatsächlich neuen
|
||||||
typischen Verwechslungen (0/O, 1/I, 8/B, …) auflöst.
|
Modul läuft sie dagegen wie gehabt, denn dort liefert sie die lesbare
|
||||||
|
Beschriftung des neuen Stapels.
|
||||||
|
3. **OCR als Rückfallebene.** Nur wenn kein Barcode eindeutig lesbar war oder
|
||||||
|
sein Nummernschema unbekannt ist (z. B. überklebtes/beschädigtes Etikett
|
||||||
|
oder ein noch nicht in `src/pn-tables.js` hinterlegter Hersteller). Das
|
||||||
|
Kamerabild wird dafür in Graustufen gewandelt und mit einem
|
||||||
|
**Otsu-Schwellwert** in Schwarz/Weiß aufbereitet: Der Schwellwert wird aus
|
||||||
|
der Helligkeitsverteilung des gesamten Bildes bestimmt, nicht aus einer
|
||||||
|
festen Kontrastspreizung zwischen hellstem und dunkelstem Pixel. Das macht
|
||||||
|
die Aufbereitung robust gegen einzelne Lichtreflexe auf glänzenden
|
||||||
|
Metalletiketten, die eine reine Min/Max-Spreizung leicht kippen würden.
|
||||||
|
Das OCR-Ergebnis wird anschließend gegen die bekannten Spec-Werte
|
||||||
|
abgeglichen, was die typischen Verwechslungen (0/O, 1/I, 8/B, …) auflöst.
|
||||||
4. **Stapel-Zuweisung.** Grün (Barcode) und Gelb (OCR) laufen ohne Eingabe
|
4. **Stapel-Zuweisung.** Grün (Barcode) und Gelb (OCR) laufen ohne Eingabe
|
||||||
durch; nur bei roter Konfidenz (nichts Eindeutiges erkannt oder
|
durch; nur bei roter Konfidenz (nichts Eindeutiges erkannt, widersprüchliche
|
||||||
widersprüchliche Barcodes) oder mehrdeutiger Stapelzuordnung fragt die
|
Barcodes oder widersprüchliche unbekannte Barcodes ohne jede OCR-Kapazität)
|
||||||
App nach.
|
oder mehrdeutiger Stapelzuordnung fragt die App nach.
|
||||||
|
5. **Sitzungsfortsetzung beim Start.** Findet die App beim Laden eine in
|
||||||
|
`localStorage` gesicherte, nicht leere Sitzung vor, wird sie nicht
|
||||||
|
stillschweigend übernommen, sondern zur Fortsetzung oder zum Verwerfen
|
||||||
|
angeboten (`askResumeSession` in `src/ui/resume-dialog.js`, verdrahtet über
|
||||||
|
`initResumeOffer()` in `src/main.js`). Erst „fortsetzen“ stellt Stapel,
|
||||||
|
Zähler und die „Zuletzt“-Zeile wieder her; „verwerfen“ löscht den
|
||||||
|
gesicherten Stand und die App startet leer.
|
||||||
|
6. **Ersatzweg ohne Kamera.** Lässt sich die Kamera nicht starten (z. B.
|
||||||
|
verweigerte Berechtigung), bleibt die App bedienbar: eine dauerhaft
|
||||||
|
sichtbare Schaltfläche „Bild auswählen“ öffnet stattdessen die native
|
||||||
|
Dateiauswahl (`setFilePickerVisible` in `src/ui/scan-view.js`). Die App
|
||||||
|
versucht danach nicht automatisch erneut, die Kamera zu starten - der
|
||||||
|
Kamera-Hinweistext bleibt für den Rest der Sitzung stehen und wird nicht
|
||||||
|
von der OCR-Verfügbarkeitsanzeige überschrieben.
|
||||||
|
|
||||||
Barcode- und OCR-Adapter sind gegen hängende Aufrufe abgesichert: Die
|
Barcode- und OCR-Adapter sind gegen hängende Aufrufe abgesichert: Die
|
||||||
Erkennungs-Pipeline (`src/pipeline.js`) bricht einen Barcode-Versuch nach 10
|
Erkennungs-Pipeline (`src/pipeline.js`) bricht einen Barcode-Versuch nach 10
|
||||||
@@ -77,7 +133,7 @@ korrekt funktioniert.
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
97 Tests, alle grün (Stand dieses Dokuments). Getestet werden ausschließlich
|
124 Tests, alle grün (Stand dieses Dokuments). Getestet werden ausschließlich
|
||||||
die reinen Module ohne Browser-Zugriff: Teilenummer-Decoder, Spec-Normalisierung
|
die reinen Module ohne Browser-Zugriff: Teilenummer-Decoder, Spec-Normalisierung
|
||||||
und toleranter Vergleich, OCR-Bildaufbereitung (Otsu-Schwellwert) und
|
und toleranter Vergleich, OCR-Bildaufbereitung (Otsu-Schwellwert) und
|
||||||
-Feldextraktion, Stapel-Zuweisung, die Erkennungs-Pipeline samt Zeitgrenzen
|
-Feldextraktion, Stapel-Zuweisung, die Erkennungs-Pipeline samt Zeitgrenzen
|
||||||
@@ -112,13 +168,18 @@ nicht gesetzt ist).
|
|||||||
| `src/pn-tables.js` | Herstellertabellen für den Teilenummer-Decoder |
|
| `src/pn-tables.js` | Herstellertabellen für den Teilenummer-Decoder |
|
||||||
| `src/pn-decoder.js` | Teilenummer → Spec-Felder |
|
| `src/pn-decoder.js` | Teilenummer → Spec-Felder |
|
||||||
| `src/ocr-extract.js` | OCR-Rohtext → Spec-Felder |
|
| `src/ocr-extract.js` | OCR-Rohtext → Spec-Felder |
|
||||||
| `src/session.js` | Stapel halten, vorschlagen, buchen, umsortieren, zurücknehmen |
|
| `src/session.js` | Stapel halten, vorschlagen, buchen, umsortieren, zurücknehmen — inklusive der aus den Einträgen abgeleiteten, je Stapel bekannten Barcode-Inhalte (`stack.codes`), die vor dem Vergleich der technischen Angaben über die Zuordnung entscheiden, sofern diese Angaben nicht widersprechen |
|
||||||
| `src/pipeline.js` | Barcode → Teilenummer → OCR → Ampelfarbe, inkl. Zeitgrenzen und Mehrdeutigkeitsbehandlung |
|
| `src/pipeline.js` | Barcode → Teilenummer → OCR → Ampelfarbe, inkl. Zeitgrenzen, Mehrdeutigkeitsbehandlung und Kurzschluss über einen bereits bekannten Barcode-Inhalt (`deps.isKnownCode`) |
|
||||||
| `src/storage.js` | Absturzschutz der laufenden Sitzung: sichert und lädt aus `localStorage`, verwirft beim Laden jeden in sich unstimmigen Zustand vollständig (siehe unten) |
|
| `src/storage.js` | Absturzschutz der laufenden Sitzung: sichert und lädt aus `localStorage`, verwirft beim Laden jeden in sich unstimmigen Zustand vollständig (siehe unten) |
|
||||||
| `src/camera.js` | Kamerastart, Einzelbildaufnahme, Bilddatei-Ersatzweg |
|
| `src/camera.js` | Kamerastart, Einzelbildaufnahme, Bilddatei-Ersatzweg |
|
||||||
| `src/barcode.js` | Adapter zu `zxing-wasm` |
|
| `src/barcode.js` | Adapter zu `zxing-wasm` |
|
||||||
| `src/ocr.js` | Bildaufbereitung (Otsu) und Adapter zu `tesseract.js` |
|
| `src/ocr.js` | Bildaufbereitung (Otsu) und Adapter zu `tesseract.js` |
|
||||||
| `src/ui/` | Ansichten: Scan-Ansicht, Ergebnis-Einblendung, Rot-Dialog bei Mehrdeutigkeit, Sitzungsliste |
|
| `src/ui/scan-view.js` | Scan-Ansicht: Kamera-Vorschau, Scan-Knopf, Stapel-Leiste, „Zuletzt"-Zeile, Ersatzweg-Schaltfläche bei fehlender Kamera |
|
||||||
|
| `src/ui/result-overlay.js` | Kurze Treffer-Rückmeldung (grün/gelb), blendet sich nach kurzer Zeit selbst wieder aus |
|
||||||
|
| `src/ui/ambiguous-dialog.js` | Rot-Dialog bei roter Konfidenz oder mehrdeutiger Stapelzuordnung |
|
||||||
|
| `src/ui/resume-dialog.js` | Dialog beim Start: gesicherte Sitzung fortsetzen oder verwerfen |
|
||||||
|
| `src/ui/session-list.js` | Sitzungsliste: Stapel-Übersicht, Umsortieren, Entfernen, Sitzung beenden |
|
||||||
|
| `src/ui/describe-spec.js` | Gemeinsame Kurzbeschreibung eines Specs (Kapazität, Rank, Geschwindigkeit, Bauform), wahlweise mit Platzhaltern für fehlende Felder; von Treffer-Rückmeldung, „Zuletzt"-Zeile und Sitzungsliste gemeinsam genutzt |
|
||||||
| `src/main.js` | Verdrahtung aller Module zur lauffähigen App |
|
| `src/main.js` | Verdrahtung aller Module zur lauffähigen App |
|
||||||
| `src/styles.css` | Farbvariablen (Grün/Gelb/Rot der Ampel-Rückmeldung), Layout des Kamera-Vollbilds samt Zielrahmen, Stapel-Leiste und Aktionsknöpfe sowie die Overlays für Treffer-Rückmeldung, Rot-Dialog und Sitzungsliste |
|
| `src/styles.css` | Farbvariablen (Grün/Gelb/Rot der Ampel-Rückmeldung), Layout des Kamera-Vollbilds samt Zielrahmen, Stapel-Leiste und Aktionsknöpfe sowie die Overlays für Treffer-Rückmeldung, Rot-Dialog und Sitzungsliste |
|
||||||
|
|
||||||
@@ -139,7 +200,15 @@ auch nur eine dieser Bedingungen nicht zu, wird die gesamte gespeicherte
|
|||||||
Sitzung verworfen (die App startet leer), statt mit einem halb kaputten
|
Sitzung verworfen (die App startet leer), statt mit einem halb kaputten
|
||||||
Zustand lautlos weiterzuarbeiten. Der gespeicherte Stapelzähler selbst wird
|
Zustand lautlos weiterzuarbeiten. Der gespeicherte Stapelzähler selbst wird
|
||||||
nie übernommen, sondern beim Laden immer neu aus der tatsächlichen Zahl der
|
nie übernommen, sondern beim Laden immer neu aus der tatsächlichen Zahl der
|
||||||
zugeordneten Einträge berechnet.
|
zugeordneten Einträge berechnet — genauso die Codemenge eines Stapels
|
||||||
|
(`stack.codes`): Sie wird nie aus dem gesicherten Wert übernommen, sondern
|
||||||
|
stets aus den Codes der ihm tatsächlich zugeordneten Einträge (`entry.codes`)
|
||||||
|
neu zusammengesetzt, dieselbe Ableitung, die `session.js` nach jedem
|
||||||
|
Umsortieren oder Entfernen eines Eintrags vornimmt. Sind `stack.codes` bzw.
|
||||||
|
`entry.codes` vorhanden, werden sie streng geprüft (Liste von
|
||||||
|
Zeichenketten); ein gesicherter Stand aus einer Fassung vor dieser Erweiterung
|
||||||
|
ohne diese Felder gilt dagegen nicht als unstimmig, sondern wird beim Laden
|
||||||
|
als Stapel bzw. Eintrag ohne bekannte Codes wiederhergestellt.
|
||||||
|
|
||||||
## Grenzen
|
## Grenzen
|
||||||
|
|
||||||
@@ -148,8 +217,22 @@ zugeordneten Einträge berechnet.
|
|||||||
Sitzung, keine dauerhafte Datenhaltung.
|
Sitzung, keine dauerhafte Datenhaltung.
|
||||||
- Das Laden der Seite braucht eine Verbindung; das Sortieren selbst nicht
|
- Das Laden der Seite braucht eine Verbindung; das Sortieren selbst nicht
|
||||||
(nach dem Laden läuft alles lokal im Browser).
|
(nach dem Laden läuft alles lokal im Browser).
|
||||||
|
- Der Codeabgleich (siehe oben) weiß nicht, ob ein gelesener Barcode-Inhalt
|
||||||
|
tatsächlich eine Teilenummer ist. Trägt ein Etikett daneben noch eine
|
||||||
|
Los- oder Wiederverkäufer-Kennung und teilen zwei technisch verschiedene
|
||||||
|
Module dieselbe, würde ein reiner Codetreffer sie fälschlich zusammenführen.
|
||||||
|
Dagegen schützt, dass ein Codetreffer allein nie genügt: Ein Stapel ist über
|
||||||
|
einen Code nur dann Kandidat, wenn die technischen Angaben ihm zusätzlich
|
||||||
|
nicht widersprechen (`specsCompatible`, siehe `proposeAssignment` in
|
||||||
|
`src/session.js`) — widerspricht der Stapel trotz passendem Code, fällt das
|
||||||
|
Modul auf einen eigenen Stapel zurück, statt lautlos falsch einsortiert zu
|
||||||
|
werden.
|
||||||
- Der Teilenummer-Decoder kennt bisher nur das Samsung-DDR4-Schema. Unbekannte
|
- Der Teilenummer-Decoder kennt bisher nur das Samsung-DDR4-Schema. Unbekannte
|
||||||
Schemata sind kein Fehler — sie führen automatisch zum OCR-Weg.
|
Schemata sind kein Fehler — sie führen automatisch zum OCR-Weg, und selbst
|
||||||
|
wenn dieser keine Kapazität findet, genügt die exakt gelesene Teilenummer
|
||||||
|
allein für Grün (siehe „Funktionsweise“ oben). Nur Kapazität, Bauform,
|
||||||
|
Rank und Geschwindigkeit hängen am Tabelleneintrag — die Wiedererkennung
|
||||||
|
eines bereits erfassten Moduls über seinen Barcode-Inhalt nicht.
|
||||||
- **Der Teilenummer-Decoder ist nur teilweise gegen reale Module geprüft.**
|
- **Der Teilenummer-Decoder ist nur teilweise gegen reale Module geprüft.**
|
||||||
Belegt ist ausschließlich der Samsung-Eintrag `A8K40` (64 GB, 4DRx4) mit
|
Belegt ist ausschließlich der Samsung-Eintrag `A8K40` (64 GB, 4DRx4) mit
|
||||||
dem Geschwindigkeitscode `CRC`, anhand des Referenzmoduls
|
dem Geschwindigkeitscode `CRC`, anhand des Referenzmoduls
|
||||||
@@ -198,7 +281,8 @@ werden:
|
|||||||
|
|
||||||
**Kamera und Aufnahme**
|
**Kamera und Aufnahme**
|
||||||
- Preview-URL auf dem Handy öffnen (muss `https://` sein), Rückkamera-Bild
|
- Preview-URL auf dem Handy öffnen (muss `https://` sein), Rückkamera-Bild
|
||||||
erscheint im Vorschaubereich, "Kamera läuft" wird angezeigt.
|
erscheint im Vorschaubereich; die Statuszeile bleibt leer, solange Kamera
|
||||||
|
und Texterkennung verfügbar sind.
|
||||||
- Verweigerte Kamera-Berechtigung: verständliche Fehlermeldung erscheint
|
- Verweigerte Kamera-Berechtigung: verständliche Fehlermeldung erscheint
|
||||||
*und* die Aufnahme-LED des Geräts erlischt tatsächlich wieder.
|
*und* die Aufnahme-LED des Geräts erlischt tatsächlich wieder.
|
||||||
- Aufnahme-Knopf antippen, bevor das Kamerabild bereit ist: verständliche
|
- Aufnahme-Knopf antippen, bevor das Kamerabild bereit ist: verständliche
|
||||||
@@ -232,7 +316,18 @@ werden:
|
|||||||
darauf zurück?
|
darauf zurück?
|
||||||
- Stichprobe, ob die feste Zeichen-Whitelist der Texterkennung auf echten
|
- Stichprobe, ob die feste Zeichen-Whitelist der Texterkennung auf echten
|
||||||
Etiketten keine tatsächlich benötigten Zeichen ausschließt.
|
Etiketten keine tatsächlich benötigten Zeichen ausschließt.
|
||||||
- Bild ohne lesbaren Code: "kein Barcode gefunden" statt Fehler oder Absturz.
|
- Bild ohne lesbaren Barcode und ohne verwertbaren OCR-Text: Rot-Dialog
|
||||||
|
erscheint statt Fehler oder Absturz.
|
||||||
|
- Modul mit unbekanntem Nummernschema (z. B. ein Hynix-Modul) scannen: grüne
|
||||||
|
Rückmeldung statt Rot, auch wenn keine Kapazität abgeleitet werden kann.
|
||||||
|
Dasselbe Modul ein zweites Mal scannen: landet auf demselben Stapel — und
|
||||||
|
die Rückmeldung erscheint spürbar schneller als beim ersten Scan, weil die
|
||||||
|
Texterkennung diesmal übersprungen wird (nur am Gerät beobachtbar, da
|
||||||
|
`node --test` keine echte Ladezeit misst).
|
||||||
|
- Etikett mit zwei Barcodes (Teile- *und* Seriennummer) mehrfach scannen:
|
||||||
|
beide Scans landen auf demselben Stapel, obwohl die Seriennummer bei realen
|
||||||
|
Modulen nie identisch ist — das belegt, dass die Teilenummer und nicht die
|
||||||
|
Seriennummer die Zuordnung trägt.
|
||||||
|
|
||||||
**Bedienung und Anzeige**
|
**Bedienung und Anzeige**
|
||||||
- Zielrahmen liegt sinnvoll über dem Kamerabild, `object-fit: cover` füllt
|
- Zielrahmen liegt sinnvoll über dem Kamerabild, `object-fit: cover` füllt
|
||||||
@@ -254,8 +349,10 @@ werden:
|
|||||||
- Ein Modul zweimal scannen: beide Male derselbe Stapel, Zähler steigt.
|
- Ein Modul zweimal scannen: beide Male derselbe Stapel, Zähler steigt.
|
||||||
- Rücknahme-Fläche nimmt genau den zuletzt erfassten Eintrag zurück, Zähler
|
- Rücknahme-Fläche nimmt genau den zuletzt erfassten Eintrag zurück, Zähler
|
||||||
sinkt entsprechend.
|
sinkt entsprechend.
|
||||||
- Seite neu laden während einer laufenden Sitzung: Stapel und Zähler bleiben
|
- Seite neu laden während einer laufenden Sitzung: Dialog „Gesicherte
|
||||||
erhalten.
|
Sitzung gefunden“ erscheint; erst nach „fortsetzen“ bleiben Stapel und
|
||||||
|
Zähler erhalten und die „Zuletzt“-Zeile zeigt wieder den tatsächlich
|
||||||
|
letzten Eintrag; „verwerfen“ führt zu einem leeren Start.
|
||||||
- Sitzungsliste öffnen, Eintrag umsortieren, Eintrag entfernen: Ansicht baut
|
- Sitzungsliste öffnen, Eintrag umsortieren, Eintrag entfernen: Ansicht baut
|
||||||
sich korrekt neu auf.
|
sich korrekt neu auf.
|
||||||
- "Sitzung beenden": Ansicht wird leer, und ein anschließendes Neuladen
|
- "Sitzung beenden": Ansicht wird leer, und ein anschließendes Neuladen
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
"name": "ram-sortierhilfe",
|
"name": "ram-sortierhilfe",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Erkennt gebrauchte Server-RAM-Module per Handykamera und leitet beim Sortieren nach Stapeln an.",
|
"description": "Erkennt gebrauchte Server-RAM-Module per Handykamera und leitet beim Sortieren nach Stapeln an.",
|
||||||
"main": "index.js",
|
|
||||||
"directories": {
|
|
||||||
"doc": "docs"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test test/",
|
"test": "node --test test/",
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
+16
-3
@@ -4,6 +4,7 @@ import { startCamera, grabFrame, imageDataFromFile } from './camera.js';
|
|||||||
import { decodeBarcodes } from './barcode.js';
|
import { decodeBarcodes } from './barcode.js';
|
||||||
import { runOcr, isOcrAvailable } from './ocr.js';
|
import { runOcr, isOcrAvailable } from './ocr.js';
|
||||||
import { recognize } from './pipeline.js';
|
import { recognize } from './pipeline.js';
|
||||||
|
import { normalizeToken } from './spec.js';
|
||||||
import {
|
import {
|
||||||
createSession, proposeAssignment, commitAssignment,
|
createSession, proposeAssignment, commitAssignment,
|
||||||
undoLast, moveEntry, removeEntry, nextStackId,
|
undoLast, moveEntry, removeEntry, nextStackId,
|
||||||
@@ -88,9 +89,21 @@ async function processCapture(getFrame) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const frame = await getFrame();
|
const frame = await getFrame();
|
||||||
const { spec, source, confidence } = await recognize(frame, { decodeBarcodes, runOcr });
|
// Ist einer der gleich gelesenen Codes bereits einem Stapel dieser
|
||||||
|
// Sitzung bekannt, kann die Pipeline die bis zu 20 Sekunden dauernde
|
||||||
|
// Texterkennung ueberspringen (siehe recognize() in pipeline.js).
|
||||||
|
const isKnownCode = (code) => {
|
||||||
|
const normalized = normalizeToken(code);
|
||||||
|
return targetSession.stacks.some(
|
||||||
|
(stack) => Array.isArray(stack.codes) && stack.codes.includes(normalized),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const { spec, source, confidence, codes } = await recognize(
|
||||||
|
frame,
|
||||||
|
{ decodeBarcodes, runOcr, isKnownCode },
|
||||||
|
);
|
||||||
|
|
||||||
const plan = proposeAssignment(targetSession, spec);
|
const plan = proposeAssignment(targetSession, spec, codes);
|
||||||
let stackId = plan.stackId;
|
let stackId = plan.stackId;
|
||||||
|
|
||||||
if (confidence === 'red' || plan.kind === 'ambiguous') {
|
if (confidence === 'red' || plan.kind === 'ambiguous') {
|
||||||
@@ -118,7 +131,7 @@ async function processCapture(getFrame) {
|
|||||||
// Sitzung bucht das Ergebnis nicht mehr nach.
|
// Sitzung bucht das Ergebnis nicht mehr nach.
|
||||||
if (targetSession !== session) return;
|
if (targetSession !== session) return;
|
||||||
|
|
||||||
const entry = commitAssignment(targetSession, spec, source, stackId);
|
const entry = commitAssignment(targetSession, spec, source, stackId, codes);
|
||||||
view.setLast(describeEntry(spec, entry.stackId));
|
view.setLast(describeEntry(spec, entry.stackId));
|
||||||
syncView();
|
syncView();
|
||||||
|
|
||||||
|
|||||||
+31
-6
@@ -55,7 +55,15 @@ function allCompatible(specs) {
|
|||||||
* deps.runOcr sind erforderlich. deps.barcodeTimeoutMs und deps.ocrTimeoutMs
|
* deps.runOcr sind erforderlich. deps.barcodeTimeoutMs und deps.ocrTimeoutMs
|
||||||
* sind optional und ueberschreiben die eingebauten Obergrenzen - fuer Tests,
|
* sind optional und ueberschreiben die eingebauten Obergrenzen - fuer Tests,
|
||||||
* die einen haengenden Adapter simulieren, ohne tatsaechlich zu warten.
|
* die einen haengenden Adapter simulieren, ohne tatsaechlich zu warten.
|
||||||
* @returns {Promise<{spec: object, source: 'barcode'|'ocr'|'none', confidence: 'green'|'yellow'|'red'}>}
|
* deps.isKnownCode ist optional: eine Funktion (code: string) => boolean,
|
||||||
|
* mit der der Aufrufer prueft, ob ein roher Barcode-Inhalt bereits einem
|
||||||
|
* Stapel bekannt ist. Ist einer der gelesenen Codes bekannt, steht die
|
||||||
|
* Zuordnung fest und die Texterkennung wird uebersprungen. Fehlt die
|
||||||
|
* Funktion, verhaelt sich die Pipeline wie ohne diese Optimierung.
|
||||||
|
* @returns {Promise<{spec: object, source: 'barcode'|'ocr'|'none', confidence: 'green'|'yellow'|'red', codes: string[]}>}
|
||||||
|
* codes sind die im Bild gelesenen rohen Barcode-Zeichenketten (koennen
|
||||||
|
* neben der Teilenummer z.B. auch eine Seriennummer enthalten) - zur
|
||||||
|
* Weitergabe an proposeAssignment/commitAssignment in session.js.
|
||||||
*/
|
*/
|
||||||
export async function recognize(frame, deps) {
|
export async function recognize(frame, deps) {
|
||||||
const barcodeTimeoutMs = deps.barcodeTimeoutMs ?? DEFAULT_BARCODE_TIMEOUT_MS;
|
const barcodeTimeoutMs = deps.barcodeTimeoutMs ?? DEFAULT_BARCODE_TIMEOUT_MS;
|
||||||
@@ -91,7 +99,7 @@ export async function recognize(frame, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (usable.length === 1) {
|
if (usable.length === 1) {
|
||||||
return { spec: usable[0], source: 'barcode', confidence: 'green' };
|
return { spec: usable[0], source: 'barcode', confidence: 'green', codes };
|
||||||
}
|
}
|
||||||
if (usable.length > 1) {
|
if (usable.length > 1) {
|
||||||
if (allCompatible(usable)) {
|
if (allCompatible(usable)) {
|
||||||
@@ -101,10 +109,19 @@ export async function recognize(frame, deps) {
|
|||||||
// da alle uebrigen Felder aus derselben Teilenummer abgeleitet werden,
|
// da alle uebrigen Felder aus derselben Teilenummer abgeleitet werden,
|
||||||
// sind vertraegliche Treffer ohnehin gleich. Es gibt nichts aufzufuellen,
|
// sind vertraegliche Treffer ohnehin gleich. Es gibt nichts aufzufuellen,
|
||||||
// der erste Treffer genuegt.
|
// der erste Treffer genuegt.
|
||||||
return { spec: usable[0], source: 'barcode', confidence: 'green' };
|
return { spec: usable[0], source: 'barcode', confidence: 'green', codes };
|
||||||
}
|
}
|
||||||
// Mehrdeutigkeit zwischen verwertbaren Barcodes: nicht raten, Nutzer entscheidet.
|
// Mehrdeutigkeit zwischen verwertbaren Barcodes: nicht raten, Nutzer entscheidet.
|
||||||
return { spec: emptySpec(), source: 'none', confidence: 'red' };
|
return { spec: emptySpec(), source: 'none', confidence: 'red', codes };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ist einer der gelesenen Codes bereits einem Stapel bekannt, steht die
|
||||||
|
// Zuordnung fest (siehe proposeAssignment in session.js) - die bis zu
|
||||||
|
// zwanzig Sekunden dauernde Texterkennung waere reine Zeitverschwendung.
|
||||||
|
// Bei einem tatsaechlich neuen Modul (kein Code bekannt) laeuft sie wie
|
||||||
|
// bisher, denn dort liefert sie die lesbare Beschriftung des neuen Stapels.
|
||||||
|
if (typeof deps.isKnownCode === 'function' && codes.some((code) => deps.isKnownCode(code))) {
|
||||||
|
return { spec: best, source: 'barcode', confidence: 'green', codes };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ocrResult = await safely(() => deps.runOcr(frame), '', ocrTimeoutMs);
|
const ocrResult = await safely(() => deps.runOcr(frame), '', ocrTimeoutMs);
|
||||||
@@ -119,7 +136,15 @@ export async function recognize(frame, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isUsable(merged)) {
|
if (isUsable(merged)) {
|
||||||
return { spec: merged, source: 'ocr', confidence: 'yellow' };
|
return { spec: merged, source: 'ocr', confidence: 'yellow', codes };
|
||||||
}
|
}
|
||||||
return { spec: merged, source: 'none', confidence: 'red' };
|
// Zum Sortieren muss keine Kapazitaet bekannt sein - eine exakt gelesene
|
||||||
|
// Teilenummer genuegt, um ein Modul wiederzuerkennen. Sie ueberlebt bis
|
||||||
|
// hierher nur, wenn genau ein unverwertbarer Code sie lieferte oder
|
||||||
|
// mehrere sich einig waren (siehe 'best' oben) - bei widersprechenden
|
||||||
|
// Codes bleibt merged.partNumber null und die Vorsicht damit erhalten.
|
||||||
|
if (merged.partNumber !== null) {
|
||||||
|
return { spec: merged, source: 'barcode', confidence: 'green', codes };
|
||||||
|
}
|
||||||
|
return { spec: merged, source: 'none', confidence: 'red', codes };
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-13
@@ -1,6 +1,6 @@
|
|||||||
// Reines Modul: kein window, kein document, kein localStorage.
|
// Reines Modul: kein window, kein document, kein localStorage.
|
||||||
|
|
||||||
import { specsCompatible } from './spec.js';
|
import { specsCompatible, normalizeToken } from './spec.js';
|
||||||
|
|
||||||
const STACK_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
const STACK_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
|
||||||
@@ -9,7 +9,13 @@ export function createSession() {
|
|||||||
return { stacks: [], entries: [], nextEntryId: 1 };
|
return { stacks: [], entries: [], nextEntryId: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextStackId(session) {
|
/**
|
||||||
|
* Naechster noch nicht vergebener Stapel-Buchstabe. Exportiert, weil auch
|
||||||
|
* main.js einen freien Buchstaben braucht, wenn der Nutzer im Rot-Dialog
|
||||||
|
* ausdruecklich einen neuen Stapel waehlt - dieselbe Regel gilt fuer beide
|
||||||
|
* Faelle, deshalb eine gemeinsame Funktion statt zweier Kopien.
|
||||||
|
*/
|
||||||
|
export function nextStackId(session) {
|
||||||
const used = new Set(session.stacks.map((stack) => stack.id));
|
const used = new Set(session.stacks.map((stack) => stack.id));
|
||||||
for (const letter of STACK_LETTERS) {
|
for (const letter of STACK_LETTERS) {
|
||||||
if (!used.has(letter)) return letter;
|
if (!used.has(letter)) return letter;
|
||||||
@@ -35,12 +41,78 @@ function sharesSetField(a, b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schlaegt vor, wohin ein Modul gehoert.
|
* Fasst eine Liste roher Barcode-Zeichenketten zu einer Menge normalisierter
|
||||||
* Genau ein vertraeglicher Stapel mit gemeinsamer Grundlage -> Zuweisung.
|
* Werte zusammen. Ein Barcode ist exakt gelesen - deshalb genuegt hier die
|
||||||
* Keiner -> neuer Stapel.
|
* blosse Gross-/Leerzeichen-Normalisierung (normalizeToken), nicht der
|
||||||
* Mehrere -> mehrdeutig, der Nutzer entscheidet.
|
* verwechslungstolerante Vergleich (canonical), der fuer die fehleranfaellige
|
||||||
|
* Texterkennung gedacht ist.
|
||||||
*/
|
*/
|
||||||
export function proposeAssignment(session, spec) {
|
function normalizedCodeSet(codes) {
|
||||||
|
const set = new Set();
|
||||||
|
for (const code of codes) {
|
||||||
|
const normalized = normalizeToken(code);
|
||||||
|
if (normalized) set.add(normalized);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Codemenge eines Stapels ist kein angesammelter, sondern ein
|
||||||
|
* abgeleiteter Wert: sie ergibt sich aus den Codes der ihm *aktuell*
|
||||||
|
* zugeordneten Eintraege - genau wie stack.count (siehe loadSession in
|
||||||
|
* src/storage.js, das denselben Ansatz beim Laden verfolgt). Ein Code
|
||||||
|
* verschwindet damit automatisch aus einem Stapel, sobald kein Eintrag ihn
|
||||||
|
* mehr mitbringt (nach moveEntry, removeEntry oder undoLast) - anstatt sich
|
||||||
|
* dauerhaft am Stapel festzusetzen.
|
||||||
|
*/
|
||||||
|
function recomputeStackCodes(session, stackId) {
|
||||||
|
const stack = session.stacks.find((candidate) => candidate.id === stackId);
|
||||||
|
if (!stack) return;
|
||||||
|
const set = new Set();
|
||||||
|
for (const entry of session.entries) {
|
||||||
|
if (entry.stackId !== stackId) continue;
|
||||||
|
for (const code of entry.codes ?? []) set.add(code);
|
||||||
|
}
|
||||||
|
stack.codes = [...set];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schlaegt vor, wohin ein Modul gehoert.
|
||||||
|
*
|
||||||
|
* Ein bekannter Barcode-Inhalt hat Vorrang vor dem Vergleich der technischen
|
||||||
|
* Angaben, denn ein Barcode ist exakt gelesen, waehrend abgeleitete Angaben
|
||||||
|
* fehlerbehaftet sein koennen (siehe README, Abschnitt Barcode-Erkennung).
|
||||||
|
* Ein Codetreffer allein genuegt aber nicht: nicht jeder gelesene Rohcode ist
|
||||||
|
* eine Teilenummer - steht neben ihr noch eine Los- oder Wiederverkaeufer-
|
||||||
|
* Kennung auf dem Etikett, kann die zwei technisch verschiedene Module
|
||||||
|
* verbinden. Ein Stapel ist deshalb ueber einen Code nur dann Kandidat, wenn
|
||||||
|
* die technischen Angaben zusaetzlich nicht widersprechen (specsCompatible);
|
||||||
|
* der Code entscheidet dann innerhalb dessen, was ohnehin vertraeglich ist,
|
||||||
|
* er darf einen Widerspruch nicht ueberstimmen. Genau ein solcher Kandidat
|
||||||
|
* -> Zuweisung, auch bei unvollstaendigen technischen Angaben. Mehrere ->
|
||||||
|
* mehrdeutig, der Nutzer entscheidet. Kennt kein Stapel einen der Codes ohne
|
||||||
|
* Widerspruch, gilt unveraendert die bisherige Regel: genau ein vertraeglicher
|
||||||
|
* Stapel mit gemeinsamer Grundlage -> Zuweisung, keiner -> neuer Stapel,
|
||||||
|
* mehrere -> mehrdeutig.
|
||||||
|
*/
|
||||||
|
export function proposeAssignment(session, spec, codes = []) {
|
||||||
|
const scannedCodes = normalizedCodeSet(codes);
|
||||||
|
if (scannedCodes.size > 0) {
|
||||||
|
const codeCandidates = session.stacks
|
||||||
|
.filter((stack) => Array.isArray(stack.codes) && stack.codes.some((code) => scannedCodes.has(code)))
|
||||||
|
.filter((stack) => specsCompatible(stack.spec, spec))
|
||||||
|
.map((stack) => stack.id);
|
||||||
|
|
||||||
|
if (codeCandidates.length === 1) {
|
||||||
|
return { kind: 'match', stackId: codeCandidates[0], candidates: codeCandidates };
|
||||||
|
}
|
||||||
|
if (codeCandidates.length > 1) {
|
||||||
|
return { kind: 'ambiguous', stackId: null, candidates: codeCandidates };
|
||||||
|
}
|
||||||
|
// Kein Stapel kennt einen der Codes (haeufig: eine bei jedem Modul
|
||||||
|
// wechselnde Seriennummer) - weiter mit dem bisherigen Vergleich.
|
||||||
|
}
|
||||||
|
|
||||||
const candidates = session.stacks
|
const candidates = session.stacks
|
||||||
.filter((stack) => specsCompatible(stack.spec, spec) && sharesSetField(stack.spec, spec))
|
.filter((stack) => specsCompatible(stack.spec, spec) && sharesSetField(stack.spec, spec))
|
||||||
.map((stack) => stack.id);
|
.map((stack) => stack.id);
|
||||||
@@ -54,11 +126,18 @@ export function proposeAssignment(session, spec) {
|
|||||||
return { kind: 'ambiguous', stackId: null, candidates };
|
return { kind: 'ambiguous', stackId: null, candidates };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bucht das Modul auf den angegebenen Stapel und legt ihn bei Bedarf an. */
|
/**
|
||||||
export function commitAssignment(session, spec, source, stackId) {
|
* Bucht das Modul auf den angegebenen Stapel und legt ihn bei Bedarf an.
|
||||||
|
* `codes` sind die bei diesem Modul gelesenen rohen Barcode-Zeichenketten
|
||||||
|
* (z.B. Teile- und Seriennummer); sie werden am Eintrag gespeichert, aus dem
|
||||||
|
* sich die Code-Menge des Stapels ableitet (siehe recomputeStackCodes), damit
|
||||||
|
* ein spaeteres Modul mit demselben Inhalt darueber zugeordnet werden kann
|
||||||
|
* (siehe proposeAssignment).
|
||||||
|
*/
|
||||||
|
export function commitAssignment(session, spec, source, stackId, codes = []) {
|
||||||
let stack = session.stacks.find((candidate) => candidate.id === stackId);
|
let stack = session.stacks.find((candidate) => candidate.id === stackId);
|
||||||
if (!stack) {
|
if (!stack) {
|
||||||
stack = { id: stackId, spec: { ...spec }, count: 0 };
|
stack = { id: stackId, spec: { ...spec }, count: 0, codes: [] };
|
||||||
session.stacks.push(stack);
|
session.stacks.push(stack);
|
||||||
session.stacks.sort((a, b) => a.id.localeCompare(b.id));
|
session.stacks.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
} else {
|
} else {
|
||||||
@@ -73,8 +152,17 @@ export function commitAssignment(session, spec, source, stackId) {
|
|||||||
stack.count += 1;
|
stack.count += 1;
|
||||||
// entry.spec ist das Protokoll dessen, was tatsaechlich gescannt wurde, und muss
|
// entry.spec ist das Protokoll dessen, was tatsaechlich gescannt wurde, und muss
|
||||||
// unabhaengig vom Stapel-Spec bleiben - eigene Kopie, kein geteiltes Objekt.
|
// unabhaengig vom Stapel-Spec bleiben - eigene Kopie, kein geteiltes Objekt.
|
||||||
const entry = { entryId: session.nextEntryId++, spec: { ...spec }, stackId, source };
|
// entry.codes haelt fest, welche Rohcodes bei diesem Eintrag gelesen wurden -
|
||||||
|
// die Grundlage, aus der recomputeStackCodes die Codemenge des Stapels ableitet.
|
||||||
|
const entry = {
|
||||||
|
entryId: session.nextEntryId++,
|
||||||
|
spec: { ...spec },
|
||||||
|
stackId,
|
||||||
|
source,
|
||||||
|
codes: [...normalizedCodeSet(codes)],
|
||||||
|
};
|
||||||
session.entries.push(entry);
|
session.entries.push(entry);
|
||||||
|
recomputeStackCodes(session, stackId);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +182,9 @@ export function undoLast(session) {
|
|||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
const stack = session.stacks.find((candidate) => candidate.id === entry.stackId);
|
const stack = session.stacks.find((candidate) => candidate.id === entry.stackId);
|
||||||
if (stack) stack.count -= 1;
|
if (stack) stack.count -= 1;
|
||||||
|
// Der zurueckgenommene Eintrag zaehlt nicht mehr zu diesem Stapel - seine
|
||||||
|
// Codes duerfen deshalb auch keinen kuenftigen Treffer mehr begruenden.
|
||||||
|
recomputeStackCodes(session, entry.stackId);
|
||||||
dropEmptyStacks(session);
|
dropEmptyStacks(session);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
@@ -103,17 +194,24 @@ export function moveEntry(session, entryId, stackId) {
|
|||||||
const entry = session.entries.find((candidate) => candidate.entryId === entryId);
|
const entry = session.entries.find((candidate) => candidate.entryId === entryId);
|
||||||
if (!entry || entry.stackId === stackId) return;
|
if (!entry || entry.stackId === stackId) return;
|
||||||
|
|
||||||
const from = session.stacks.find((candidate) => candidate.id === entry.stackId);
|
const fromId = entry.stackId;
|
||||||
|
const from = session.stacks.find((candidate) => candidate.id === fromId);
|
||||||
if (from) from.count -= 1;
|
if (from) from.count -= 1;
|
||||||
|
|
||||||
let to = session.stacks.find((candidate) => candidate.id === stackId);
|
let to = session.stacks.find((candidate) => candidate.id === stackId);
|
||||||
if (!to) {
|
if (!to) {
|
||||||
to = { id: stackId, spec: { ...entry.spec }, count: 0 };
|
to = { id: stackId, spec: { ...entry.spec }, count: 0, codes: [] };
|
||||||
session.stacks.push(to);
|
session.stacks.push(to);
|
||||||
session.stacks.sort((a, b) => a.id.localeCompare(b.id));
|
session.stacks.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
}
|
}
|
||||||
to.count += 1;
|
to.count += 1;
|
||||||
entry.stackId = stackId;
|
entry.stackId = stackId;
|
||||||
|
// Die Codemenge beider betroffenen Stapel ist abgeleitet (siehe
|
||||||
|
// recomputeStackCodes) - nach dem Umhaengen des Eintrags muss sie fuer den
|
||||||
|
// alten wie den neuen Stapel neu bestimmt werden, sonst bliebe der Code des
|
||||||
|
// umsortierten Eintrags faelschlich am alten Stapel haengen.
|
||||||
|
recomputeStackCodes(session, fromId);
|
||||||
|
recomputeStackCodes(session, stackId);
|
||||||
dropEmptyStacks(session);
|
dropEmptyStacks(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,5 +222,8 @@ export function removeEntry(session, entryId) {
|
|||||||
const [entry] = session.entries.splice(index, 1);
|
const [entry] = session.entries.splice(index, 1);
|
||||||
const stack = session.stacks.find((candidate) => candidate.id === entry.stackId);
|
const stack = session.stacks.find((candidate) => candidate.id === entry.stackId);
|
||||||
if (stack) stack.count -= 1;
|
if (stack) stack.count -= 1;
|
||||||
|
// Siehe moveEntry: der entfernte Eintrag darf seinen Code nicht laenger am
|
||||||
|
// Stapel hinterlassen.
|
||||||
|
recomputeStackCodes(session, entry.stackId);
|
||||||
dropEmptyStacks(session);
|
dropEmptyStacks(session);
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-2
@@ -28,21 +28,37 @@ function isPositiveInteger(value) {
|
|||||||
return typeof value === 'number' && Number.isInteger(value) && value >= 1;
|
return typeof value === 'number' && Number.isInteger(value) && value >= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isStringArray(value) {
|
||||||
|
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
// stack.codes ist optional: ein Stand aus einer aelteren Fassung ohne dieses
|
||||||
|
// Feld ist kein unstimmiger Zustand und wird nicht verworfen (siehe
|
||||||
|
// loadSession), sondern beim Wiederherstellen als leere Codeliste behandelt.
|
||||||
|
// Ist das Feld dagegen vorhanden, muss es zur restlichen strengen Pruefung
|
||||||
|
// passen: eine Liste von Zeichenketten, sonst ist der Stand in sich
|
||||||
|
// unstimmig.
|
||||||
function isUsableStack(stack) {
|
function isUsableStack(stack) {
|
||||||
return (
|
return (
|
||||||
isPlainObject(stack)
|
isPlainObject(stack)
|
||||||
&& typeof stack.id === 'string'
|
&& typeof stack.id === 'string'
|
||||||
&& isFiniteNumber(stack.count)
|
&& isFiniteNumber(stack.count)
|
||||||
&& isPlainObject(stack.spec)
|
&& isPlainObject(stack.spec)
|
||||||
|
&& (stack.codes === undefined || isStringArray(stack.codes))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entry.codes ist wie stack.codes optional: ein Stand aus einer Fassung vor
|
||||||
|
// dieser Erweiterung kennt das Feld nicht und gilt deshalb nicht als
|
||||||
|
// unstimmig (siehe isUsableStack). Ist es vorhanden, muss es eine Liste von
|
||||||
|
// Zeichenketten sein.
|
||||||
function isUsableEntry(entry) {
|
function isUsableEntry(entry) {
|
||||||
return (
|
return (
|
||||||
isPlainObject(entry)
|
isPlainObject(entry)
|
||||||
&& isPositiveInteger(entry.entryId)
|
&& isPositiveInteger(entry.entryId)
|
||||||
&& typeof entry.stackId === 'string'
|
&& typeof entry.stackId === 'string'
|
||||||
&& isPlainObject(entry.spec)
|
&& isPlainObject(entry.spec)
|
||||||
|
&& (entry.codes === undefined || isStringArray(entry.codes))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +73,14 @@ function isUsableEntry(entry) {
|
|||||||
* Der gespeicherte Stapelzaehler (stack.count) wird nie uebernommen, sondern
|
* Der gespeicherte Stapelzaehler (stack.count) wird nie uebernommen, sondern
|
||||||
* beim Laden stets aus der Zahl der ihm tatsaechlich zugeordneten Eintraege
|
* beim Laden stets aus der Zahl der ihm tatsaechlich zugeordneten Eintraege
|
||||||
* neu berechnet - er dient in isUsableStack nur noch dazu, grob kaputten
|
* neu berechnet - er dient in isUsableStack nur noch dazu, grob kaputten
|
||||||
* Inhalt schon an der Typprueung scheitern zu lassen. Zeigt ein Eintrag auf
|
* Inhalt schon an der Typprueung scheitern zu lassen. Genauso wird die
|
||||||
|
* gespeicherte Codemenge eines Stapels (stack.codes) nie uebernommen, sondern
|
||||||
|
* aus den Codes seiner tatsaechlich zugeordneten Eintraege (entry.codes) neu
|
||||||
|
* zusammengesetzt - dieselbe Ableitung, die session.js nach jedem Umsortieren
|
||||||
|
* oder Entfernen eines Eintrags vornimmt (siehe recomputeStackCodes dort);
|
||||||
|
* ein zwischenzeitlich per Hand korrigierter, aber vor dieser Erweiterung
|
||||||
|
* gesicherter Stand wuerde sonst einen laengst nicht mehr zutreffenden Code
|
||||||
|
* am falschen Stapel wiederherstellen. Zeigt ein Eintrag auf
|
||||||
* eine Stapelkennung, die es nicht gibt, kommt eine Eintragsnummer oder eine
|
* eine Stapelkennung, die es nicht gibt, kommt eine Eintragsnummer oder eine
|
||||||
* Stapelkennung mehrfach vor, oder ist nextEntryId nicht groesser als jede
|
* Stapelkennung mehrfach vor, oder ist nextEntryId nicht groesser als jede
|
||||||
* vorhandene Eintragsnummer, ist der Inhalt in sich unstimmig und wird
|
* vorhandene Eintragsnummer, ist der Inhalt in sich unstimmig und wird
|
||||||
@@ -85,7 +108,16 @@ export function loadSession(store) {
|
|||||||
if (entryIds.some((entryId) => entryId >= parsed.nextEntryId)) return null;
|
if (entryIds.some((entryId) => entryId >= parsed.nextEntryId)) return null;
|
||||||
|
|
||||||
for (const stack of parsed.stacks) {
|
for (const stack of parsed.stacks) {
|
||||||
stack.count = parsed.entries.filter((entry) => entry.stackId === stack.id).length;
|
const stackEntries = parsed.entries.filter((entry) => entry.stackId === stack.id);
|
||||||
|
stack.count = stackEntries.length;
|
||||||
|
// Codemenge aus den Eintraegen ableiten statt dem gesicherten Wert zu
|
||||||
|
// vertrauen (siehe Kommentar oben). Ein Eintrag aus einer Fassung ohne
|
||||||
|
// entry.codes traegt keine Codes bei, gilt aber nicht als unstimmig.
|
||||||
|
const codes = new Set();
|
||||||
|
for (const entry of stackEntries) {
|
||||||
|
for (const code of entry.codes ?? []) codes.add(code);
|
||||||
|
}
|
||||||
|
stack.codes = [...codes];
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed;
|
return parsed;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { extractFields } from '../src/ocr-extract.js';
|
import { extractFields } from '../src/ocr-extract.js';
|
||||||
|
|
||||||
const REFERENZ = `SAMSUNG
|
const REFERENCE = `SAMSUNG
|
||||||
Made in Philippines
|
Made in Philippines
|
||||||
K136000908252DF287
|
K136000908252DF287
|
||||||
64GB 4DRx4 PC4-2400T-LD1-11-MC0
|
64GB 4DRx4 PC4-2400T-LD1-11-MC0
|
||||||
@@ -10,7 +10,7 @@ M386A8K40BM1-CRC4Y S
|
|||||||
1908`;
|
1908`;
|
||||||
|
|
||||||
test('liest alle Felder aus dem Referenz-Etikett', () => {
|
test('liest alle Felder aus dem Referenz-Etikett', () => {
|
||||||
const spec = extractFields(REFERENZ);
|
const spec = extractFields(REFERENCE);
|
||||||
assert.equal(spec.capacityGb, 64);
|
assert.equal(spec.capacityGb, 64);
|
||||||
assert.equal(spec.rank, '4DRx4');
|
assert.equal(spec.rank, '4DRx4');
|
||||||
assert.equal(spec.speed, 'PC4-2400');
|
assert.equal(spec.speed, 'PC4-2400');
|
||||||
|
|||||||
@@ -191,3 +191,93 @@ test('zwei unverwertbare Barcodes, die sich nur in einer Verwechslung unterschei
|
|||||||
assert.equal(result.confidence, 'yellow');
|
assert.equal(result.confidence, 'yellow');
|
||||||
assert.equal(result.spec.partNumber, '732O773');
|
assert.equal(result.spec.partNumber, '732O773');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Erweiterung: unbekanntes Nummernschema mit gelesener Teilenummer genuegt fuer Gruen ---
|
||||||
|
|
||||||
|
test('Barcode mit unbekanntem Nummernschema und gelesener Teilenummer ergibt gruen, auch ohne Kapazitaet', async () => {
|
||||||
|
const result = await recognize({}, {
|
||||||
|
// Hynix-Teilenummer: Schema unbekannt (nur Samsung ist hinterlegt), aber
|
||||||
|
// exakt gelesen - und OCR findet auf dem Etikett ebenfalls nichts
|
||||||
|
// Verwertbares. Trotzdem genuegt die gelesene Teilenummer fuer Gruen.
|
||||||
|
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH'],
|
||||||
|
runOcr: async () => '',
|
||||||
|
});
|
||||||
|
assert.equal(result.source, 'barcode');
|
||||||
|
assert.equal(result.confidence, 'green');
|
||||||
|
assert.equal(result.spec.partNumber, 'HMA84GL7AFR4N-UH');
|
||||||
|
assert.equal(result.spec.capacityGb, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findet OCR trotz unbekanntem Nummernschema eine Kapazitaet, wird sie uebernommen (weiterhin gelb, nicht gruen)', async () => {
|
||||||
|
const result = await recognize({}, {
|
||||||
|
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH'],
|
||||||
|
runOcr: async () => OCR_TEXT,
|
||||||
|
});
|
||||||
|
assert.equal(result.source, 'ocr');
|
||||||
|
assert.equal(result.confidence, 'yellow');
|
||||||
|
assert.equal(result.spec.capacityGb, 64);
|
||||||
|
// Die Teilenummer aus dem Barcode ist exakter und bleibt erhalten.
|
||||||
|
assert.equal(result.spec.partNumber, 'HMA84GL7AFR4N-UH');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('widersprechende unverwertbare Barcodes ohne jede OCR-Kapazitaet: die Vorsicht bleibt erhalten, kein Gruen', () => recognize({}, {
|
||||||
|
decodeBarcodes: async () => ['7325773', '9999999'],
|
||||||
|
runOcr: async () => '',
|
||||||
|
}).then((result) => {
|
||||||
|
assert.equal(result.source, 'none');
|
||||||
|
assert.equal(result.confidence, 'red');
|
||||||
|
}));
|
||||||
|
|
||||||
|
test('recognize liefert die gelesenen Rohcodes zur Weitergabe an die Stapelzuordnung', async () => {
|
||||||
|
const result = await recognize({}, {
|
||||||
|
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH', 'SN-000123'],
|
||||||
|
runOcr: async () => '',
|
||||||
|
});
|
||||||
|
assert.deepEqual([...result.codes].sort(), ['HMA84GL7AFR4N-UH', 'SN-000123'].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Erweiterung: ein bekannter Code ueberspringt die Texterkennung ---
|
||||||
|
|
||||||
|
test('bekannter Code ueberspringt die Texterkennung vollstaendig', async () => {
|
||||||
|
let ocrCalled = false;
|
||||||
|
const result = await recognize({}, {
|
||||||
|
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH'],
|
||||||
|
runOcr: async () => { ocrCalled = true; return ''; },
|
||||||
|
isKnownCode: (code) => code === 'HMA84GL7AFR4N-UH',
|
||||||
|
});
|
||||||
|
assert.equal(ocrCalled, false, 'ein bekannter Code macht die Texterkennung ueberfluessig');
|
||||||
|
assert.equal(result.source, 'barcode');
|
||||||
|
assert.equal(result.confidence, 'green');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bekannter Code unter mehreren Codes ueberspringt die Texterkennung trotzdem (Seriennummer laeuft ins Leere)', async () => {
|
||||||
|
let ocrCalled = false;
|
||||||
|
const result = await recognize({}, {
|
||||||
|
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH', 'SN-000123'],
|
||||||
|
runOcr: async () => { ocrCalled = true; return ''; },
|
||||||
|
isKnownCode: (code) => code === 'HMA84GL7AFR4N-UH',
|
||||||
|
});
|
||||||
|
assert.equal(ocrCalled, false);
|
||||||
|
assert.equal(result.confidence, 'green');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unbekannter Code (isKnownCode liefert stets false) ueberspringt die Texterkennung nicht', async () => {
|
||||||
|
let ocrCalled = false;
|
||||||
|
const result = await recognize({}, {
|
||||||
|
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH'],
|
||||||
|
runOcr: async () => { ocrCalled = true; return ''; },
|
||||||
|
isKnownCode: () => false,
|
||||||
|
});
|
||||||
|
assert.equal(ocrCalled, true);
|
||||||
|
assert.equal(result.confidence, 'green');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ohne isKnownCode-Abhaengigkeit bleibt die Pipeline ohne sie lauffaehig (Texterkennung laeuft wie bisher)', async () => {
|
||||||
|
let ocrCalled = false;
|
||||||
|
const result = await recognize({}, {
|
||||||
|
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH'],
|
||||||
|
runOcr: async () => { ocrCalled = true; return ''; },
|
||||||
|
});
|
||||||
|
assert.equal(ocrCalled, true);
|
||||||
|
assert.equal(result.confidence, 'green');
|
||||||
|
});
|
||||||
|
|||||||
+137
-1
@@ -1,6 +1,6 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { emptySpec } from '../src/spec.js';
|
import { emptySpec, specsCompatible } from '../src/spec.js';
|
||||||
import {
|
import {
|
||||||
createSession, proposeAssignment, commitAssignment,
|
createSession, proposeAssignment, commitAssignment,
|
||||||
undoLast, moveEntry, removeEntry,
|
undoLast, moveEntry, removeEntry,
|
||||||
@@ -158,6 +158,142 @@ test('ein voellig unerkanntes Modul erzeugt einen eigenen Stapel und zieht keine
|
|||||||
assert.equal(plan.kind, 'new', 'der leere Stapel darf ein erkanntes Modul nicht an sich ziehen');
|
assert.equal(plan.kind, 'new', 'der leere Stapel darf ein erkanntes Modul nicht an sich ziehen');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Erweiterung: bekannte Barcode-Inhalte entscheiden die Zuordnung ---
|
||||||
|
|
||||||
|
test('bekannter Barcode-Inhalt entscheidet die Zuordnung, auch bei unvollstaendigen Angaben', () => {
|
||||||
|
const session = createSession();
|
||||||
|
const unknownScheme = { ...emptySpec(), partNumber: 'HMA84GL7AFR4N-UH' };
|
||||||
|
commitAssignment(session, unknownScheme, 'barcode', 'A', ['HMA84GL7AFR4N-UH']);
|
||||||
|
|
||||||
|
// Angaben diesmal komplett leer - ohne Codeabgleich waere das mangels
|
||||||
|
// gemeinsamen Merkmals (sharesSetField) niemals ein Treffer.
|
||||||
|
const plan = proposeAssignment(session, emptySpec(), ['HMA84GL7AFR4N-UH']);
|
||||||
|
assert.equal(plan.kind, 'match');
|
||||||
|
assert.equal(plan.stackId, 'A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anderer Barcode-Inhalt erzeugt einen eigenen Stapel', () => {
|
||||||
|
const session = createSession();
|
||||||
|
commitAssignment(session, { ...emptySpec(), partNumber: 'HMA84GL7AFR4N-UH' }, 'barcode', 'A', ['HMA84GL7AFR4N-UH']);
|
||||||
|
const plan = proposeAssignment(session, { ...emptySpec(), partNumber: 'ANDERE-PN' }, ['ANDERE-PN']);
|
||||||
|
assert.equal(plan.kind, 'new');
|
||||||
|
assert.notEqual(plan.stackId, 'A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine bei jedem Modul wechselnde Seriennummer verhindert die Zuordnung ueber die stabile Teilenummer nicht', () => {
|
||||||
|
const session = createSession();
|
||||||
|
commitAssignment(
|
||||||
|
session,
|
||||||
|
{ ...emptySpec(), partNumber: 'HMA84GL7AFR4N-UH' },
|
||||||
|
'barcode',
|
||||||
|
'A',
|
||||||
|
['HMA84GL7AFR4N-UH', 'SN-AAA111'],
|
||||||
|
);
|
||||||
|
// Gleiche Teilenummer, aber eine andere Seriennummer als beim ersten Mal -
|
||||||
|
// die Seriennummer laeuft ins Leere, die Teilenummer traegt trotzdem.
|
||||||
|
const plan = proposeAssignment(session, emptySpec(), ['HMA84GL7AFR4N-UH', 'SN-BBB222']);
|
||||||
|
assert.equal(plan.kind, 'match');
|
||||||
|
assert.equal(plan.stackId, 'A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Codeabgleich hat Vorrang vor dem Vergleich der technischen Angaben', () => {
|
||||||
|
const session = createSession();
|
||||||
|
// B ist technisch identisch zu A (waere ohne Codeabgleich ebenfalls
|
||||||
|
// Kandidat und wuerde damit sogar Mehrdeutigkeit ausloesen), kennt den
|
||||||
|
// gescannten Code aber nicht.
|
||||||
|
commitAssignment(session, s64(), 'barcode', 'A', ['CODE-A']);
|
||||||
|
commitAssignment(session, s64(), 'barcode', 'B');
|
||||||
|
const plan = proposeAssignment(session, s64(), ['CODE-A']);
|
||||||
|
assert.equal(plan.kind, 'match');
|
||||||
|
assert.equal(plan.stackId, 'A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mehrere Stapel mit passendem Code sind mehrdeutig, der Nutzer entscheidet', () => {
|
||||||
|
const session = createSession();
|
||||||
|
commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-X']);
|
||||||
|
commitAssignment(session, emptySpec(), 'barcode', 'B', ['CODE-X']);
|
||||||
|
const plan = proposeAssignment(session, emptySpec(), ['CODE-X']);
|
||||||
|
assert.equal(plan.kind, 'ambiguous');
|
||||||
|
assert.deepEqual(plan.candidates.sort(), ['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passt kein Code, bleibt die bisherige Regel (Vergleich der Angaben) unveraendert', () => {
|
||||||
|
const session = createSession();
|
||||||
|
commitAssignment(session, s64(), 'barcode', 'A', ['CODE-A']);
|
||||||
|
const plan = proposeAssignment(session, s64(), ['VOELLIG-ANDERER-CODE']);
|
||||||
|
assert.equal(plan.kind, 'match');
|
||||||
|
assert.equal(plan.stackId, 'A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('proposeAssignment und commitAssignment funktionieren ohne codes-Parameter weiterhin (Abwaertskompatibilitaet)', () => {
|
||||||
|
const session = createSession();
|
||||||
|
const plan = proposeAssignment(session, s64());
|
||||||
|
const entry = commitAssignment(session, s64(), 'barcode', plan.stackId);
|
||||||
|
assert.equal(entry.stackId, 'A');
|
||||||
|
assert.deepEqual(session.stacks[0].codes, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein Stapel sammelt unterschiedliche Codes ueber mehrere Scans hinweg', () => {
|
||||||
|
const session = createSession();
|
||||||
|
commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-1']);
|
||||||
|
commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-1', 'CODE-2']);
|
||||||
|
const stackA = session.stacks.find((s) => s.id === 'A');
|
||||||
|
assert.deepEqual([...stackA.codes].sort(), ['CODE-1', 'CODE-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Fehler 1: ein Codetreffer darf einen Widerspruch der Angaben nicht ueberstimmen ---
|
||||||
|
|
||||||
|
test('Codetreffer mit widersprechenden technischen Angaben ist kein Kandidat (geteilter Los-Tag statt Teilenummer)', () => {
|
||||||
|
const session = createSession();
|
||||||
|
// Modul 1: Samsung 64GB LRDIMM, Etikett traegt zusaetzlich einen Los-Tag.
|
||||||
|
const modul1 = { ...emptySpec(), capacityGb: 64, formFactor: 'LRDIMM' };
|
||||||
|
commitAssignment(session, modul1, 'barcode', 'A', ['LOS-2024-07']);
|
||||||
|
|
||||||
|
// Modul 2: Samsung 64GB RDIMM (andere Bauform!), aber derselbe Los-Tag,
|
||||||
|
// weil beide Etiketten aus derselben Fertigungscharge stammen.
|
||||||
|
const modul2 = { ...emptySpec(), capacityGb: 64, formFactor: 'RDIMM' };
|
||||||
|
assert.equal(specsCompatible(modul1, modul2), false, 'Vorbedingung: die Bauformen widersprechen sich');
|
||||||
|
|
||||||
|
const plan = proposeAssignment(session, modul2, ['LOS-2024-07']);
|
||||||
|
assert.notEqual(plan.stackId, 'A', 'ein RDIMM darf trotz geteiltem Los-Tag nicht auf den LRDIMM-Stapel A');
|
||||||
|
assert.equal(plan.kind, 'new', 'ohne vertraeglichen Kandidaten faellt das Modul auf einen eigenen Stapel zurueck');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Fehler 2: eine Korrektur (Umsortieren/Entfernen) muss den Code am alten Stapel entfernen ---
|
||||||
|
|
||||||
|
test('nach moveEntry zieht der Code des umsortierten Eintrags den alten Stapel nicht mehr an', () => {
|
||||||
|
const session = createSession();
|
||||||
|
const a = commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-X']);
|
||||||
|
// Ein zweiter Eintrag mit anderem Code haelt Stapel A am Leben, damit der
|
||||||
|
// Test tatsaechlich prueft, dass CODE-X verschwindet - nicht nur, dass ein
|
||||||
|
// leerer Stapel entfernt wurde.
|
||||||
|
commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-Y']);
|
||||||
|
|
||||||
|
moveEntry(session, a.entryId, 'B');
|
||||||
|
|
||||||
|
const stackA = session.stacks.find((s) => s.id === 'A');
|
||||||
|
assert.ok(stackA, 'Stapel A bleibt bestehen (der zweite Eintrag mit CODE-Y haengt noch dort)');
|
||||||
|
assert.deepEqual(stackA.codes, ['CODE-Y'], 'CODE-X darf nach dem Umsortieren nicht mehr zu Stapel A gehoeren');
|
||||||
|
|
||||||
|
const plan = proposeAssignment(session, emptySpec(), ['CODE-X']);
|
||||||
|
assert.notEqual(plan.stackId, 'A', 'ein neues Modul mit CODE-X darf Stapel A nicht mehr an sich ziehen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nach removeEntry zieht der Code des entfernten Eintrags den alten Stapel nicht mehr an', () => {
|
||||||
|
const session = createSession();
|
||||||
|
const a = commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-X']);
|
||||||
|
commitAssignment(session, emptySpec(), 'barcode', 'A', ['CODE-Y']);
|
||||||
|
|
||||||
|
removeEntry(session, a.entryId);
|
||||||
|
|
||||||
|
const stackA = session.stacks.find((s) => s.id === 'A');
|
||||||
|
assert.ok(stackA, 'Stapel A bleibt bestehen (der zweite Eintrag mit CODE-Y haengt noch dort)');
|
||||||
|
assert.deepEqual(stackA.codes, ['CODE-Y'], 'CODE-X darf nach dem Entfernen nicht mehr zu Stapel A gehoeren');
|
||||||
|
|
||||||
|
const plan = proposeAssignment(session, emptySpec(), ['CODE-X']);
|
||||||
|
assert.notEqual(plan.stackId, 'A', 'ein neues Modul mit CODE-X darf Stapel A nicht mehr an sich ziehen');
|
||||||
|
});
|
||||||
|
|
||||||
test('undoLast-Vertrag: nach einem Umsortieren wird weiterhin der zuletzt erfasste Eintrag zurueckgenommen', () => {
|
test('undoLast-Vertrag: nach einem Umsortieren wird weiterhin der zuletzt erfasste Eintrag zurueckgenommen', () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const a = scan(session, s64());
|
const a = scan(session, s64());
|
||||||
|
|||||||
@@ -244,6 +244,61 @@ test('nextEntryId als Null liefert null', () => {
|
|||||||
assert.equal(loadSession(store), null);
|
assert.equal(loadSession(store), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Erweiterung: Barcode-Inhalte je Stapel ueberleben Sichern und Laden ---
|
||||||
|
|
||||||
|
test('Codes eines Stapels ueberleben Sichern und Laden', () => {
|
||||||
|
const store = fakeStore();
|
||||||
|
const session = createSession();
|
||||||
|
commitAssignment(
|
||||||
|
session,
|
||||||
|
{ ...emptySpec(), partNumber: 'HMA84GL7AFR4N-UH' },
|
||||||
|
'barcode',
|
||||||
|
'A',
|
||||||
|
['HMA84GL7AFR4N-UH', 'SN-000'],
|
||||||
|
);
|
||||||
|
saveSession(session, store);
|
||||||
|
|
||||||
|
const reloaded = loadSession(store);
|
||||||
|
assert.notEqual(reloaded, null);
|
||||||
|
assert.deepEqual([...reloaded.stacks[0].codes].sort(), ['HMA84GL7AFR4N-UH', 'SN-000'].sort());
|
||||||
|
|
||||||
|
const plan = proposeAssignment(reloaded, emptySpec(), ['HMA84GL7AFR4N-UH']);
|
||||||
|
assert.equal(plan.kind, 'match');
|
||||||
|
assert.equal(plan.stackId, 'A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein Stand ohne codes-Feld (aeltere Fassung) wird nicht verworfen, der Stapel gilt als ohne bekannte Codes', () => {
|
||||||
|
const store = fakeStore();
|
||||||
|
store.setItem('ram-sortierhilfe:session', JSON.stringify({
|
||||||
|
stacks: [{ id: 'A', count: 1, spec: { ...emptySpec(), capacityGb: 64 } }],
|
||||||
|
entries: [{ entryId: 1, stackId: 'A', spec: { ...emptySpec(), capacityGb: 64 }, source: 'barcode' }],
|
||||||
|
nextEntryId: 2,
|
||||||
|
}));
|
||||||
|
const reloaded = loadSession(store);
|
||||||
|
assert.notEqual(reloaded, null, 'ein Stand ohne codes-Feld darf nicht verworfen werden');
|
||||||
|
assert.deepEqual(reloaded.stacks[0].codes, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Stapel mit codes als Nicht-Array liefert null', () => {
|
||||||
|
const store = fakeStore();
|
||||||
|
store.setItem('ram-sortierhilfe:session', JSON.stringify({
|
||||||
|
stacks: [{ id: 'A', count: 0, spec: emptySpec(), codes: 'nicht-array' }],
|
||||||
|
entries: [],
|
||||||
|
nextEntryId: 1,
|
||||||
|
}));
|
||||||
|
assert.equal(loadSession(store), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Stapel mit codes-Array, das Nicht-Zeichenketten enthaelt, liefert null', () => {
|
||||||
|
const store = fakeStore();
|
||||||
|
store.setItem('ram-sortierhilfe:session', JSON.stringify({
|
||||||
|
stacks: [{ id: 'A', count: 0, spec: emptySpec(), codes: [123] }],
|
||||||
|
entries: [],
|
||||||
|
nextEntryId: 1,
|
||||||
|
}));
|
||||||
|
assert.equal(loadSession(store), null);
|
||||||
|
});
|
||||||
|
|
||||||
test('Eintrag mit gebrochener entryId liefert null', () => {
|
test('Eintrag mit gebrochener entryId liefert null', () => {
|
||||||
const store = fakeStore();
|
const store = fakeStore();
|
||||||
store.setItem('ram-sortierhilfe:session', JSON.stringify({
|
store.setItem('ram-sortierhilfe:session', JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user