Stapel merken sich gelesene Barcode-Inhalte, entscheiden die Zuordnung

Jeder Stapel fuehrt jetzt die Menge der Barcode-Zeichenketten, die bei
seinen Modulen gelesen wurden (stack.codes). Traegt ein gescanntes Modul
einen Code, den ein Stapel bereits kennt, hat das Vorrang vor dem Vergleich
der technischen Angaben - ein Barcode ist exakt gelesen, abgeleitete Angaben
koennen fehlerbehaftet sein. Genau ein Stapel ueber einen bekannten Code
-> Zuweisung, auch bei unvollstaendigen Angaben; mehrere -> weiterhin
mehrdeutig, der Nutzer entscheidet; kein passender Code -> unveraendert die
bisherige Regel. Eine Seriennummer, die bei jedem Modul anders ist, laeuft
dabei einfach ins Leere, ohne die Zuordnung ueber die Teilenummer zu
verhindern.

proposeAssignment und commitAssignment bekommen dafuer einen neuen, optional
en codes-Parameter (Vorgabe []); bestehende Aufrufe ohne diesen Parameter
verhalten sich unveraendert.

storage.js sichert und prueft stack.codes jetzt mit: fehlt das Feld (Stand
aus einer aelteren Fassung), gilt der Stapel als ohne bekannte Codes statt
den ganzen Stand zu verwerfen; ist es vorhanden, muss es eine Liste von
Zeichenketten sein wie jedes andere Feld auch.
This commit is contained in:
vchuser
2026-07-29 09:05:23 +02:00
parent 661bd07a26
commit 61dde3eb08
4 changed files with 221 additions and 10 deletions
+69 -10
View File
@@ -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';
@@ -41,12 +41,60 @@ 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;
}
/**
* Ergaenzt die Menge der bei einem Stapel bekannten Barcode-Inhalte um die
* neu gelesenen Codes (z.B. Teile- und Seriennummer desselben Etiketts).
*/
function addCodes(stack, codes) {
const set = new Set(stack.codes);
for (const code of normalizedCodeSet(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).
* Genau ein Stapel mit einem der gescannten Codes -> Zuweisung, auch bei
* unvollstaendigen technischen Angaben. Mehrere -> mehrdeutig, der Nutzer
* entscheidet. Kennt kein Stapel einen der Codes, 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)))
.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);
@@ -60,14 +108,21 @@ 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 der Code-Menge des Stapels
* hinzugefuegt, 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 {
if (!Array.isArray(stack.codes)) stack.codes = [];
// Ein spaeterer, vollstaendigerer Scan ergaenzt fehlende Felder des Stapels. // Ein spaeterer, vollstaendigerer Scan ergaenzt fehlende Felder des Stapels.
for (const field of ['capacityGb', 'formFactor', 'rank', 'speed', 'partNumber']) { for (const field of ['capacityGb', 'formFactor', 'rank', 'speed', 'partNumber']) {
if (stack.spec[field] === null && spec[field] !== null) { if (stack.spec[field] === null && spec[field] !== null) {
@@ -76,6 +131,8 @@ export function commitAssignment(session, spec, source, stackId) {
} }
} }
addCodes(stack, codes);
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.
@@ -114,7 +171,9 @@ export function moveEntry(session, entryId, stackId) {
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 }; // entry selbst fuehrt keine Codes (nur Stapel tun das) - ein durch
// Umsortieren neu entstandener Stapel startet deshalb ohne bekannte Codes.
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));
} }
+14
View File
@@ -28,12 +28,23 @@ 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))
); );
} }
@@ -86,6 +97,9 @@ export function loadSession(store) {
for (const stack of parsed.stacks) { for (const stack of parsed.stacks) {
stack.count = parsed.entries.filter((entry) => entry.stackId === stack.id).length; stack.count = parsed.entries.filter((entry) => entry.stackId === stack.id).length;
// Ein Stand aus einer aelteren Fassung ohne stack.codes gilt als
// Stapel ohne bekannte Codes, nicht als unstimmiger Zustand.
if (!Array.isArray(stack.codes)) stack.codes = [];
} }
return parsed; return parsed;
+83
View File
@@ -158,6 +158,89 @@ 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']);
});
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());
+55
View File
@@ -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({