feat(tags): QR-Aufkleber, Geraeteseite, Etikettendruck und Scanner
Der Einstieg, fuer den die Anwendung gebaut ist: scannen, sehen was zu tun ist,
loslegen. `/t/<tagCode>` kennt drei Faelle — registriertes Geraet, vorgedruckter
Tag, unbekannter Code —, die Geraeteseite zeigt Zuweisungen, Schnellstart und
Historie, und beides ist am Handy bedienbar.
Im QR steht ausschliesslich `{APP_URL}/t/{tagCode}`: kein Name, keine
Seriennummer, kein Besitzer. Ein Aufkleber haengt jahrelang sichtbar am Geraet
und wird von jedem Telefon gelesen; was darauf steht, ist veroeffentlicht. Die
Masse sind gemessen, nicht behauptet: ein echtes PDF liefert 62 x 29 mm und A4,
und ein bei 300 dpi gerastertes Etikett wird mit demselben Decoder
zurueckgelesen, den auch der Scanner benutzt.
Drei Defekte zeigte erst der gebaute Stand, alle an den Naehten zwischen den
parallel gebauten Teilen: „Starten“ setzte IN_ARBEIT und sprang nicht, „Vorlage
zuweisen“ bot Vorlagen an, die der Dienst danach ablehnte, und die Geraeteliste
verlinkte nichts. Der unabhaengige Review fand drei Blocker — darunter, dass der
Scanner nie die native BarcodeDetector-API benutzte und dass ein Unterpfad in
APP_URL aus dem gedruckten QR herausfiel.
Closes #14
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #28.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { Prisma, type Geraet } from "@/generated/prisma/client";
|
||||
import { Prisma, type Geraet, type Lauf } from "@/generated/prisma/client";
|
||||
import { GeraeteTyp } from "@/generated/prisma/enums";
|
||||
import { CsvFehler, csvZerlegen, hatSpalte, wertAus, type CsvZeile } from "@/lib/csv";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { tagCodeEindeutigErzeugen } from "@/lib/tagcode";
|
||||
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { laufAnlegen } from "@/server/services/laeufe";
|
||||
|
||||
/**
|
||||
* Geraete und ihre Stammdaten (#011).
|
||||
@@ -445,6 +446,61 @@ export async function geraetLoeschen(geraetId: string, benutzer: HandelnderBenut
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Ein Lauf am Geraet ----------------------------------------------------
|
||||
|
||||
export type LaufAmGeraet = {
|
||||
readonly geraetId: string;
|
||||
readonly vorlagenVersionId: string;
|
||||
readonly zugewiesenAnId?: string | null;
|
||||
readonly faelligAm?: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legt einen Lauf fuer dieses Geraet an — „Vorlage zuweisen“ auf der
|
||||
* Geraeteseite (#014).
|
||||
*
|
||||
* Die Regeln des Laufs bleiben in `services/laeufe.ts`: die Version muss
|
||||
* veroeffentlicht sein, das Geraet darf nicht archiviert sein, die Zuweisung
|
||||
* muss jemanden treffen, der den Lauf auch oeffnen kann. Nichts davon steht hier
|
||||
* ein zweites Mal — diese Funktion beantwortet nur die zwei Fragen, die das
|
||||
* **Geraet** beantwortet:
|
||||
*
|
||||
* 1. **Welcher Kunde?** Der des Geraets. Eine von aussen mitgeschickte `kundeId`
|
||||
* gibt es hier deshalb nicht; im Modus INTERN verwirft `kundeErmitteln` sie
|
||||
* ohnehin (#008).
|
||||
* 2. **Welche Person?** Der Besitzer, wenn es einen gibt. Wer an einem Notebook
|
||||
* einen Lauf anlegt, meint die Person, der es gehoert; ein Server und ein
|
||||
* Raum haben keine, und dann bleibt das Feld leer.
|
||||
*
|
||||
* Der Lauf entsteht als `OFFEN` und damit als Zuweisung — gestartet wird er
|
||||
* erst, wenn jemand ihn zur Bearbeitung oeffnet.
|
||||
*/
|
||||
export async function laufFuerGeraetAnlegen(
|
||||
eingabe: LaufAmGeraet,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<Lauf> {
|
||||
const geraet = await prisma.geraet.findUnique({
|
||||
where: { id: eingabe.geraetId },
|
||||
select: { id: true, kundeId: true, mitarbeiterId: true },
|
||||
});
|
||||
if (!geraet) throw new GeraeteFehler("Dieses Gerät gibt es nicht.");
|
||||
// Die `geraetId` kommt von aussen: ohne diese Pruefung liesse sich mit einer
|
||||
// fremden Kennung Arbeit im Mandanten eines anderen Kunden anlegen.
|
||||
await kundeZugriffPruefen(geraet.kundeId, benutzer);
|
||||
|
||||
return laufAnlegen(
|
||||
{
|
||||
vorlagenVersionId: eingabe.vorlagenVersionId,
|
||||
geraetId: geraet.id,
|
||||
mitarbeiterId: geraet.mitarbeiterId,
|
||||
zugewiesenAnId: eingabe.zugewiesenAnId ?? null,
|
||||
faelligAm: eingabe.faelligAm ?? null,
|
||||
kundeId: geraet.kundeId,
|
||||
},
|
||||
benutzer,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- CSV-Import ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { geraetAnlegen } from "@/server/services/geraete";
|
||||
import type { HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
/**
|
||||
* Stapel vorgedruckter Tags (#014).
|
||||
*
|
||||
* Wer Aufkleber drucken lassen will, braucht die Codes, **bevor** die Geraete
|
||||
* existieren: der Bogen kommt aus dem Drucker, wird aufgeklebt und erst am Geraet
|
||||
* gescannt und benannt. Ein solcher Tag ist ein `Geraet` mit
|
||||
* `registriertAm = null` — kein zweites Modell, kein zweiter Codepfad.
|
||||
*
|
||||
* Die Regeln der Vergabe stehen **nicht** hier, sondern in
|
||||
* `services/geraete.ts`: dort wird der `tagCode` gezogen, in derselben
|
||||
* Transaktion ins Vergabebuch geschrieben und protokolliert. Dieses Modul ruft
|
||||
* `geraetAnlegen({ registrieren: false })` in einer Schleife und sonst nichts.
|
||||
* Die Regeln nachzuschreiben waere der sichere Weg, dass eine von ihnen nur in
|
||||
* einem der beiden Wege landet — und ein Tag ohne Eintrag im Vergabebuch ist ein
|
||||
* Aufkleber, dessen Code irgendwann ein zweites Mal vergeben wird.
|
||||
*/
|
||||
|
||||
export class TagsFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "TagsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein A4-Bogen fasst 24 Etiketten; 50 sind gut zwei Bogen und der uebliche Vorrat. */
|
||||
export const TAGS_STAPEL_STANDARD = 50;
|
||||
|
||||
/**
|
||||
* Obergrenze je Stapel.
|
||||
*
|
||||
* Jeder Tag zieht einen `tagCode`, der nie zurueckkommt — ein vertippter Stapel
|
||||
* ist also nicht ruecknehmbar, sondern nur Papier, das man nicht bedruckt. Dazu
|
||||
* kommt die Laufzeit: jeder Tag ist eine eigene Transaktion, weil das die
|
||||
* Vergabe verlangt. 500 sind gut 20 Bogen, mehr druckt niemand in einem Zug, und
|
||||
* die Oberflaeche nennt die Zahl, **bevor** jemand dagegen laeuft.
|
||||
*/
|
||||
export const TAGS_STAPEL_HOECHSTENS = 500;
|
||||
|
||||
export type TagStapel = {
|
||||
/** Der Kunde, dem die Tags gehoeren – im Modus INTERN die eigene Organisation. */
|
||||
readonly kundeId: string;
|
||||
/** In der Reihenfolge der Erzeugung, also in der Reihenfolge des Bogens. */
|
||||
readonly tagCodes: readonly string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Legt `anzahl` Tags ohne Zuordnung an.
|
||||
*
|
||||
* Die Rechtepruefung sitzt nicht hier, sondern in der Server Action und auf der
|
||||
* Seite — wie in jedem Dienst dieses Projekts. Verlangt wird dort `verwaltung`:
|
||||
* ein Stapel verbrennt N Codes unwiderruflich und ist eine Beschaffung, keine
|
||||
* Geraetepflege. Aus demselben Grund verlangt auch das endgueltige Loeschen eines
|
||||
* Geraets `verwaltung` (#011).
|
||||
*
|
||||
* Es gibt **keine** gemeinsame Transaktion ueber den Stapel. Die Vergabe eines
|
||||
* `tagCode` ist je Tag eine eigene, und ein Abbruch in der Mitte laesst 30
|
||||
* brauchbare Tags stehen statt 50 verlorene — Tags ohne Zuordnung stoeren
|
||||
* niemanden, sie warten in der Liste auf ihren Aufkleber.
|
||||
*/
|
||||
export async function tagsErzeugen(
|
||||
anzahl: number,
|
||||
kundeIdAusEingabe: string | null | undefined,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<TagStapel> {
|
||||
if (!Number.isInteger(anzahl) || anzahl < 1) {
|
||||
throw new TagsFehler("Die Anzahl muss eine ganze Zahl ab 1 sein.");
|
||||
}
|
||||
if (anzahl > TAGS_STAPEL_HOECHSTENS) {
|
||||
throw new TagsFehler(
|
||||
`Höchstens ${TAGS_STAPEL_HOECHSTENS} Tags auf einmal. Jeder davon verbraucht einen ` +
|
||||
"Tag-Code für immer — lieber zweimal drucken als einmal zu viel.",
|
||||
);
|
||||
}
|
||||
|
||||
const tagCodes: string[] = [];
|
||||
let kundeId: string | undefined;
|
||||
for (let nummer = 0; nummer < anzahl; nummer += 1) {
|
||||
// Nacheinander und nicht mit `Promise.all`: `geraetAnlegen` prueft vor dem
|
||||
// Schreiben, ob der gezogene Code frei ist. Parallel gezogene Codes koennten
|
||||
// sich gegenseitig ueberholen, und der Stapel schluege mit einem Indexfehler
|
||||
// fehl, statt fertig zu werden.
|
||||
const geraet = await geraetAnlegen({ registrieren: false, kundeId: kundeIdAusEingabe }, benutzer);
|
||||
tagCodes.push(geraet.tagCode);
|
||||
kundeId ??= geraet.kundeId;
|
||||
}
|
||||
|
||||
// `anzahl >= 1` ist oben geprueft, die Schleife laeuft also mindestens einmal.
|
||||
// Der Compiler weiss das nicht, und ein `!` waere eine Behauptung statt einer
|
||||
// Pruefung.
|
||||
if (kundeId === undefined) {
|
||||
throw new TagsFehler("Es wurde kein Tag angelegt.");
|
||||
}
|
||||
return { kundeId, tagCodes };
|
||||
}
|
||||
Reference in New Issue
Block a user