Codetreffer ueberstimmt keinen Widerspruch der technischen Angaben mehr

Ein gelesener Rohcode ist nicht garantiert eine Teilenummer: teilen sich
zwei technisch verschiedene Module z.B. denselben Los-Tag, hat der bisherige
Codeabgleich sie ohne Rueckfrage auf denselben Stapel gezogen. proposeAssignment
verlangt fuer einen Codekandidaten jetzt zusaetzlich specsCompatible; widerspricht
der einzige Treffer, faellt die Zuordnung auf den Vergleich der technischen
Angaben zurueck und im Zweifel auf einen eigenen Stapel.

TDD: Test zuerst rot gesehen (Los-Tag-Fall aus der Beanstandung), dann die
Filterung ergaenzt.
This commit is contained in:
vchuser
2026-07-29 09:21:42 +02:00
parent 7b4a3532bb
commit f948620555
2 changed files with 110 additions and 21 deletions
+56 -20
View File
@@ -57,12 +57,22 @@ function normalizedCodeSet(codes) {
} }
/** /**
* Ergaenzt die Menge der bei einem Stapel bekannten Barcode-Inhalte um die * Die Codemenge eines Stapels ist kein angesammelter, sondern ein
* neu gelesenen Codes (z.B. Teile- und Seriennummer desselben Etiketts). * 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 addCodes(stack, codes) { function recomputeStackCodes(session, stackId) {
const set = new Set(stack.codes); const stack = session.stacks.find((candidate) => candidate.id === stackId);
for (const code of normalizedCodeSet(codes)) set.add(code); 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]; stack.codes = [...set];
} }
@@ -72,17 +82,25 @@ function addCodes(stack, codes) {
* Ein bekannter Barcode-Inhalt hat Vorrang vor dem Vergleich der technischen * Ein bekannter Barcode-Inhalt hat Vorrang vor dem Vergleich der technischen
* Angaben, denn ein Barcode ist exakt gelesen, waehrend abgeleitete Angaben * Angaben, denn ein Barcode ist exakt gelesen, waehrend abgeleitete Angaben
* fehlerbehaftet sein koennen (siehe README, Abschnitt Barcode-Erkennung). * fehlerbehaftet sein koennen (siehe README, Abschnitt Barcode-Erkennung).
* Genau ein Stapel mit einem der gescannten Codes -> Zuweisung, auch bei * Ein Codetreffer allein genuegt aber nicht: nicht jeder gelesene Rohcode ist
* unvollstaendigen technischen Angaben. Mehrere -> mehrdeutig, der Nutzer * eine Teilenummer - steht neben ihr noch eine Los- oder Wiederverkaeufer-
* entscheidet. Kennt kein Stapel einen der Codes, gilt unveraendert die * Kennung auf dem Etikett, kann die zwei technisch verschiedene Module
* bisherige Regel: genau ein vertraeglicher Stapel mit gemeinsamer * verbinden. Ein Stapel ist deshalb ueber einen Code nur dann Kandidat, wenn
* Grundlage -> Zuweisung, keiner -> neuer Stapel, mehrere -> mehrdeutig. * 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 = []) { export function proposeAssignment(session, spec, codes = []) {
const scannedCodes = normalizedCodeSet(codes); const scannedCodes = normalizedCodeSet(codes);
if (scannedCodes.size > 0) { if (scannedCodes.size > 0) {
const codeCandidates = session.stacks const codeCandidates = session.stacks
.filter((stack) => Array.isArray(stack.codes) && stack.codes.some((code) => scannedCodes.has(code))) .filter((stack) => Array.isArray(stack.codes) && stack.codes.some((code) => scannedCodes.has(code)))
.filter((stack) => specsCompatible(stack.spec, spec))
.map((stack) => stack.id); .map((stack) => stack.id);
if (codeCandidates.length === 1) { if (codeCandidates.length === 1) {
@@ -111,9 +129,10 @@ export function proposeAssignment(session, spec, codes = []) {
/** /**
* Bucht das Modul auf den angegebenen Stapel und legt ihn bei Bedarf an. * Bucht das Modul auf den angegebenen Stapel und legt ihn bei Bedarf an.
* `codes` sind die bei diesem Modul gelesenen rohen Barcode-Zeichenketten * `codes` sind die bei diesem Modul gelesenen rohen Barcode-Zeichenketten
* (z.B. Teile- und Seriennummer); sie werden der Code-Menge des Stapels * (z.B. Teile- und Seriennummer); sie werden am Eintrag gespeichert, aus dem
* hinzugefuegt, damit ein spaeteres Modul mit demselben Inhalt darueber * sich die Code-Menge des Stapels ableitet (siehe recomputeStackCodes), damit
* zugeordnet werden kann (siehe proposeAssignment). * ein spaeteres Modul mit demselben Inhalt darueber zugeordnet werden kann
* (siehe proposeAssignment).
*/ */
export function commitAssignment(session, spec, source, stackId, codes = []) { 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);
@@ -122,7 +141,6 @@ export function commitAssignment(session, spec, source, stackId, 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) {
@@ -131,13 +149,20 @@ export function commitAssignment(session, spec, source, stackId, codes = []) {
} }
} }
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.
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;
} }
@@ -157,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;
} }
@@ -166,19 +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) {
// 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: [] }; 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);
} }
@@ -189,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);
} }
+54 -1
View File
@@ -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,
@@ -241,6 +241,59 @@ test('ein Stapel sammelt unterschiedliche Codes ueber mehrere Scans hinweg', ()
assert.deepEqual([...stackA.codes].sort(), ['CODE-1', 'CODE-2']); 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());