fix(labels): eindeutige Platzhalter-Namen, echte Beispiele, Hinweis bei doppeltem Inhalt
- Die Werte waren richtig verdrahtet; irreführend waren die Namen: „Chargennummer“ (Honigcharge) und „Los-/Chargennummer der Abfüllung“ lasen sich gleich. Jetzt mit Herkunft im Namen, z. B. „Chargennummer der Honigcharge (nicht Los)“, „Los-Zeile („L“ + Los-Nr.)“, „MHD (nur Datum)“, und einem Beispiel in Druckform unter „Inhalt“. - Beispielwerte entsprechen dem Druck und sind alle verschieden. - Etiketten-Prüfung: neue Warnung DUPLICATE_TEXT, wenn zwei Felder den gleichen Text drucken oder Nummer und Zeile nebeneinander stehen (nicht blockierend, abwärtskompatibel). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
d2581abbd4
commit
b59be8e201
9 files changed
+245
-29
No files matched your search
+1
-1
@@ -828,7 +828,7 @@ Für `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` gilt zusätzlic
|
|||||||
|
|
||||||
### 12.4 Etikett-Pflichtangaben
|
### 12.4 Etikett-Pflichtangaben
|
||||||
|
|
||||||
`checkLabel` (`src/label-templates/label-particulars.ts`) ist rein und ohne Datenbank; Regeln und Grenzen: Annahmen 65 bis 68. **Pflichtangaben** (sperren den Druck: 409 `LABEL_PARTICULARS_MISSING` mit `{ gaps }`, Abschnitt 12.6): `LEGAL_NAME` (ein Wort mit „honig“), `NET_QUANTITY` (Füllmenge der Abfüllung, „250,5 g“ wie „250.5 g“, „1 kg“ nur bei vollen Kilogramm; als ganze Wörter), `BEST_BEFORE` („Mindestens haltbar bis“ mit dem Datum), `LOT`, `ORIGIN` und `FOOD_BUSINESS` (Name und Anschrift). **Warnungen** (sperren nie): `LOT_PREFIX`, `X_HEIGHT` (unter `LabelTemplate.minXHeightMm`), `NET_QUANTITY_HEIGHT` (Ziffernhöhe unter der Regel aus `ComplianceSettings.netQuantityDigitRules`), `TEXT_CLIPPED`. Das Ergebnis trägt `requirements` (`minXHeightMm`, `netQuantityMinCapHeightMm`) und je Befund `elementIds` (bei Lücken die abgeschnittenen Elemente, die die Angabe getragen hätten). **Verfeinerungen (Runden 2 bis 4 der Task 5).**
|
`checkLabel` (`src/label-templates/label-particulars.ts`) ist rein und ohne Datenbank; Regeln und Grenzen: Annahmen 65 bis 68. **Pflichtangaben** (sperren den Druck: 409 `LABEL_PARTICULARS_MISSING` mit `{ gaps }`, Abschnitt 12.6): `LEGAL_NAME` (ein Wort mit „honig“), `NET_QUANTITY` (Füllmenge der Abfüllung, „250,5 g“ wie „250.5 g“, „1 kg“ nur bei vollen Kilogramm; als ganze Wörter), `BEST_BEFORE` („Mindestens haltbar bis“ mit dem Datum), `LOT`, `ORIGIN` und `FOOD_BUSINESS` (Name und Anschrift). **Warnungen** (sperren nie): `LOT_PREFIX`, `X_HEIGHT` (unter `LabelTemplate.minXHeightMm`), `NET_QUANTITY_HEIGHT` (Ziffernhöhe unter der Regel aus `ComplianceSettings.netQuantityDigitRules`), `TEXT_CLIPPED`, `DUPLICATE_TEXT` (zwei Felder drucken denselben Inhalt, sobald ein Platzhalter beteiligt ist, oder `lotNumber` neben `lotLine` bzw. `bestBeforeDate` neben `bestBeforeLine`; gleicher fester Text zählt nicht). Das Ergebnis trägt `requirements` (`minXHeightMm`, `netQuantityMinCapHeightMm`) und je Befund `elementIds` (bei Lücken die abgeschnittenen Elemente, die die Angabe getragen hätten). **Verfeinerungen (Runden 2 bis 4 der Task 5).**
|
||||||
- **Ziffern-Wächter je Element.** „Zahl direkt vor der Menge“ („1,250 g“ ist kein Treffer für 250 g) gilt nur im Text eines Elements, nie im verbundenen Text: Eine Losnummer oder ein Datum in einem Nachbarelement macht „500 g“ nicht ungültig. „250g“ gilt als „250 g“, „L2026“ bleibt.
|
- **Ziffern-Wächter je Element.** „Zahl direkt vor der Menge“ („1,250 g“ ist kein Treffer für 250 g) gilt nur im Text eines Elements, nie im verbundenen Text: Eine Losnummer oder ein Datum in einem Nachbarelement macht „500 g“ nicht ungültig. „250g“ gilt als „250 g“, „L2026“ bleibt.
|
||||||
- **Lesereihenfolge** kommt allein aus den Positionen (nach y, Zeilen mit 1 mm Toleranz gegen die erste Oberkante, je Zeile von links nach rechts), nie aus der Reihenfolge der Elemente in der Vorlage.
|
- **Lesereihenfolge** kommt allein aus den Positionen (nach y, Zeilen mit 1 mm Toleranz gegen die erste Oberkante, je Zeile von links nach rechts), nie aus der Reihenfolge der Elemente in der Vorlage.
|
||||||
- **Verteilte Angaben** (Name und Anschrift): Zeilen, Blöcke in Lesereihenfolge oder Ketten wortgleicher Elemente, jeweils mit **symmetrischem** senkrechtem Abstand von höchstens 10 mm (`max(0, y − Unterkante, Oberkante − (y + h))`, gemessen an der laufenden Ober- und Unterkante der ganzen Gruppe, nicht am Vorgänger, `groupByDistance`). Elemente mit leerem normalisiertem Text werden ignoriert und überbrücken nichts; eine hohe Textbox überbrückt Zeilen um ihre Boxhöhe. Nur sichtbare (nicht abgeschnittene) Teile zählen (Annahme 75).
|
- **Verteilte Angaben** (Name und Anschrift): Zeilen, Blöcke in Lesereihenfolge oder Ketten wortgleicher Elemente, jeweils mit **symmetrischem** senkrechtem Abstand von höchstens 10 mm (`max(0, y − Unterkante, Oberkante − (y + h))`, gemessen an der laufenden Ober- und Unterkante der ganzen Gruppe, nicht am Vorgänger, `groupByDistance`). Elemente mit leerem normalisiertem Text werden ignoriert und überbrücken nichts; eine hohe Textbox überbrückt Zeilen um ihre Boxhöhe. Nur sichtbare (nicht abgeschnittene) Teile zählen (Annahme 75).
|
||||||
|
|||||||
@@ -919,3 +919,82 @@ describe('SAMPLE_BOTTLING', () => {
|
|||||||
expect(values.lotLine).toBe('L 2026-0001');
|
expect(values.lotLine).toBe('L 2026-0001');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('checkLabel: the same content twice', () => {
|
||||||
|
function duplicates(elements: LabelTextElement[], values = input().values) {
|
||||||
|
return checkLabel(input({ elements, values })).warnings.filter(
|
||||||
|
(warning) => warning.code === 'DUPLICATE_TEXT',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('a full label without repeats has no DUPLICATE_TEXT', () => {
|
||||||
|
expect(duplicates(fullLabel())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when one placeholder is placed twice', () => {
|
||||||
|
const [finding] = duplicates([
|
||||||
|
...fullLabel(),
|
||||||
|
textElement('legal-2', 'legalName', 86),
|
||||||
|
]);
|
||||||
|
expect(finding.elementIds).toEqual(['legal', 'legal-2']);
|
||||||
|
expect(finding.detail).toContain('„Blütenhonig“');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when two placeholders print the same value (honey type = article name)', () => {
|
||||||
|
const values = {
|
||||||
|
...input().values,
|
||||||
|
honeyType: 'Blütenhonig',
|
||||||
|
productName: 'Blütenhonig',
|
||||||
|
};
|
||||||
|
const [finding] = duplicates(
|
||||||
|
[
|
||||||
|
...fullLabel(),
|
||||||
|
textElement('type', 'honeyType', 86),
|
||||||
|
textElement('name', 'productName', 98),
|
||||||
|
],
|
||||||
|
values,
|
||||||
|
);
|
||||||
|
expect(finding.elementIds).toEqual(['legal', 'type', 'name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when the lot number and the lot line both print the lot', () => {
|
||||||
|
const [finding] = duplicates([
|
||||||
|
...fullLabel(),
|
||||||
|
textElement('lot-number', 'lotNumber', 86),
|
||||||
|
]);
|
||||||
|
expect(finding.elementIds).toEqual(['lot-number', 'lot']);
|
||||||
|
expect(finding.detail).toContain('„2026-0001“');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns when the best-before date and the best-before line both print the date', () => {
|
||||||
|
const [finding] = duplicates([
|
||||||
|
...fullLabel(),
|
||||||
|
textElement('bb-date', 'bestBeforeDate', 86),
|
||||||
|
]);
|
||||||
|
expect(finding.elementIds).toEqual(['bb-date', 'bb']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not warn about repeated fixed text (the designer shows it as is)', () => {
|
||||||
|
expect(
|
||||||
|
duplicates([
|
||||||
|
...fullLabel(),
|
||||||
|
textElement('brand-1', null, 86, { text: 'Wabenhain' }),
|
||||||
|
textElement('brand-2', null, 98, { text: 'Wabenhain' }),
|
||||||
|
]),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not warn about placeholders without a value', () => {
|
||||||
|
const values = { ...input().values, honeyType: '' };
|
||||||
|
expect(
|
||||||
|
duplicates(
|
||||||
|
[
|
||||||
|
...fullLabel(),
|
||||||
|
textElement('type-1', 'honeyType', 86),
|
||||||
|
textElement('type-2', 'honeyType', 98),
|
||||||
|
],
|
||||||
|
values,
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,8 @@ export type LabelFindingCode =
|
|||||||
| 'LOT_PREFIX'
|
| 'LOT_PREFIX'
|
||||||
| 'X_HEIGHT'
|
| 'X_HEIGHT'
|
||||||
| 'NET_QUANTITY_HEIGHT'
|
| 'NET_QUANTITY_HEIGHT'
|
||||||
| 'TEXT_CLIPPED';
|
| 'TEXT_CLIPPED'
|
||||||
|
| 'DUPLICATE_TEXT';
|
||||||
|
|
||||||
export interface LabelFinding {
|
export interface LabelFinding {
|
||||||
code: LabelFindingCode;
|
code: LabelFindingCode;
|
||||||
@@ -96,6 +97,7 @@ const LEGAL_NAME_WORD = 'honig';
|
|||||||
|
|
||||||
interface Candidate {
|
interface Candidate {
|
||||||
element: LabelTextElement;
|
element: LabelTextElement;
|
||||||
|
text: string;
|
||||||
normalized: string;
|
normalized: string;
|
||||||
clipped: boolean;
|
clipped: boolean;
|
||||||
}
|
}
|
||||||
@@ -452,7 +454,7 @@ function candidatesOf(
|
|||||||
normalized !== '' &&
|
normalized !== '' &&
|
||||||
measurer.heightOfStringPt(text, element) >
|
measurer.heightOfStringPt(text, element) >
|
||||||
element.height * MM_TO_PT + CLIP_TOLERANCE_PT;
|
element.height * MM_TO_PT + CLIP_TOLERANCE_PT;
|
||||||
return { element, normalized, clipped };
|
return { element, text, normalized, clipped };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -476,6 +478,52 @@ function unique(ids: string[]): string[] {
|
|||||||
return [...new Set(ids)];
|
return [...new Set(ids)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The first key prints a value the second key's line already holds.
|
||||||
|
const PRINTED_WITHIN: [PlaceholderKey, PlaceholderKey][] = [
|
||||||
|
['lotNumber', 'lotLine'],
|
||||||
|
['bestBeforeDate', 'bestBeforeLine'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elements that print the same content twice: equal text where a placeholder
|
||||||
|
* is involved (fixed text the designer shows as is), or a value next to the
|
||||||
|
* line that already holds it ("2026-0001" and "L 2026-0001").
|
||||||
|
*/
|
||||||
|
function duplicateTextFinding(candidates: Candidate[]): LabelFinding | null {
|
||||||
|
const filled = candidates.filter((candidate) => candidate.normalized !== '');
|
||||||
|
const byText = new Map<string, Candidate[]>();
|
||||||
|
for (const candidate of filled) {
|
||||||
|
byText.set(candidate.normalized, [
|
||||||
|
...(byText.get(candidate.normalized) ?? []),
|
||||||
|
candidate,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const withKey = (key: PlaceholderKey) =>
|
||||||
|
filled.filter((candidate) => candidate.element.placeholderKey === key);
|
||||||
|
const groups = [
|
||||||
|
...[...byText.values()].filter(
|
||||||
|
(group) =>
|
||||||
|
group.length > 1 &&
|
||||||
|
group.some((candidate) => candidate.element.placeholderKey),
|
||||||
|
),
|
||||||
|
...PRINTED_WITHIN.map(([value, line]) => [withKey(value), withKey(line)])
|
||||||
|
.filter(([values, lines]) => values.length > 0 && lines.length > 0)
|
||||||
|
.map(([values, lines]) => [...values, ...lines]),
|
||||||
|
];
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const texts = unique(
|
||||||
|
groups.map((group) => `„${group[0].text.replace(/\s+/g, ' ').trim()}“`),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
code: 'DUPLICATE_TEXT',
|
||||||
|
title: 'Gleicher Inhalt mehrfach',
|
||||||
|
detail: `Mehrere Felder drucken denselben Inhalt: ${texts.join(', ')}. Prüfen, ob das gewollt ist, sonst ein Feld entfernen.`,
|
||||||
|
elementIds: unique(groups.flat().map(({ element }) => element.id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function xHeightFinding(
|
function xHeightFinding(
|
||||||
carriers: Candidate[],
|
carriers: Candidate[],
|
||||||
minXHeightMm: number,
|
minXHeightMm: number,
|
||||||
@@ -635,6 +683,11 @@ export function checkLabel(input: LabelCheckInput): LabelCheckResult {
|
|||||||
warnings.push(netQuantityHeight);
|
warnings.push(netQuantityHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicate = duplicateTextFinding(candidates);
|
||||||
|
if (duplicate) {
|
||||||
|
warnings.push(duplicate);
|
||||||
|
}
|
||||||
|
|
||||||
if (clippedCarriers.size > 0) {
|
if (clippedCarriers.size > 0) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
code: 'TEXT_CLIPPED',
|
code: 'TEXT_CLIPPED',
|
||||||
|
|||||||
+15
@@ -60,4 +60,19 @@ describe("ElementPropertiesPanel", () => {
|
|||||||
await userEvent.click(screen.getByRole("button", { name: "Kursiv" }));
|
await userEvent.click(screen.getByRole("button", { name: "Kursiv" }));
|
||||||
expect(onChange).toHaveBeenCalledWith({ italic: true });
|
expect(onChange).toHaveBeenCalledWith({ italic: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("explains a placeholder under the content field, with the sample it prints", () => {
|
||||||
|
renderPanel({ ...TEXT, placeholderKey: "lotNumber" });
|
||||||
|
|
||||||
|
const content = screen.getByRole("combobox", { name: "Inhalt" });
|
||||||
|
expect(content).toHaveAccessibleDescription(
|
||||||
|
"Nur die Nummer der Abfüllung, ohne „L“. Mit „L“ davor die Los-Zeile nehmen, nicht beides. Beispiel: „2026-0001“",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no placeholder hint for fixed text", () => {
|
||||||
|
renderPanel(TEXT);
|
||||||
|
|
||||||
|
expect(screen.getByRole("combobox", { name: "Inhalt" })).not.toHaveAccessibleDescription();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+10
-2
@@ -10,6 +10,7 @@ import {
|
|||||||
PLACEHOLDER_KEYS,
|
PLACEHOLDER_KEYS,
|
||||||
PLACEHOLDER_LABELS,
|
PLACEHOLDER_LABELS,
|
||||||
} from "@/lib/label-templates/types";
|
} from "@/lib/label-templates/types";
|
||||||
|
import { PLACEHOLDER_HINTS, PLACEHOLDER_SAMPLES } from "@/lib/label-templates/placeholder-keys";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -135,18 +136,25 @@ function TextFields({
|
|||||||
element: Extract<LabelElement, { type: "text" }>;
|
element: Extract<LabelElement, { type: "text" }>;
|
||||||
onChange: (patch: Partial<LabelElement>) => void;
|
onChange: (patch: Partial<LabelElement>) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const key = element.placeholderKey;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="element-text-source">Inhalt</Label>
|
<Label htmlFor="element-text-source">Inhalt</Label>
|
||||||
<EnumSelect
|
<EnumSelect
|
||||||
id="element-text-source"
|
id="element-text-source"
|
||||||
value={element.placeholderKey ?? ""}
|
value={key ?? ""}
|
||||||
onChange={(key) => onChange({ placeholderKey: key === "" ? null : key })}
|
onChange={(next) => onChange({ placeholderKey: next === "" ? null : next })}
|
||||||
values={PLACEHOLDER_KEYS}
|
values={PLACEHOLDER_KEYS}
|
||||||
labels={PLACEHOLDER_LABELS}
|
labels={PLACEHOLDER_LABELS}
|
||||||
noneLabel="Fester Text"
|
noneLabel="Fester Text"
|
||||||
|
describedBy={key ? "element-text-source-hint" : undefined}
|
||||||
/>
|
/>
|
||||||
|
{key ? (
|
||||||
|
<p id="element-text-source-hint" className="text-xs text-muted-foreground">
|
||||||
|
{PLACEHOLDER_HINTS[key]} Beispiel: „{PLACEHOLDER_SAMPLES[key]}“
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!element.placeholderKey ? (
|
{!element.placeholderKey ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
+2
-2
@@ -78,11 +78,11 @@ describe("LabelDesigner placeholder menu", () => {
|
|||||||
expect(insertMenu).not.toBeNull();
|
expect(insertMenu).not.toBeNull();
|
||||||
|
|
||||||
await userEvent.click(insertMenu as HTMLElement);
|
await userEvent.click(insertMenu as HTMLElement);
|
||||||
await userEvent.click(await screen.findByRole("option", { name: "Chargennummer" }));
|
await userEvent.click(await screen.findByRole("option", { name: "Chargennummer der Honigcharge (nicht Los)" }));
|
||||||
|
|
||||||
// The placeholder element was added and is now edited in the properties panel.
|
// The placeholder element was added and is now edited in the properties panel.
|
||||||
expect(await screen.findByRole("combobox", { name: "Inhalt" })).toHaveTextContent(
|
expect(await screen.findByRole("combobox", { name: "Inhalt" })).toHaveTextContent(
|
||||||
"Chargennummer",
|
"Chargennummer der Honigcharge (nicht Los)",
|
||||||
);
|
);
|
||||||
expect(insertMenu).toHaveTextContent("Platzhalter einfügen");
|
expect(insertMenu).toHaveTextContent("Platzhalter einfügen");
|
||||||
expect(insertMenu).not.toHaveTextContent("batchNumber");
|
expect(insertMenu).not.toHaveTextContent("batchNumber");
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export type LabelFindingCode =
|
|||||||
| "LOT_PREFIX"
|
| "LOT_PREFIX"
|
||||||
| "X_HEIGHT"
|
| "X_HEIGHT"
|
||||||
| "NET_QUANTITY_HEIGHT"
|
| "NET_QUANTITY_HEIGHT"
|
||||||
| "TEXT_CLIPPED";
|
| "TEXT_CLIPPED"
|
||||||
|
| "DUPLICATE_TEXT";
|
||||||
|
|
||||||
/** The six particulars that block printing, in the order the panel lists them. */
|
/** The six particulars that block printing, in the order the panel lists them. */
|
||||||
export const PARTICULAR_CODES = [
|
export const PARTICULAR_CODES = [
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { PLACEHOLDER_KEYS, PLACEHOLDER_LABELS } from "./placeholder-keys";
|
import {
|
||||||
|
PLACEHOLDER_HINTS,
|
||||||
|
PLACEHOLDER_KEYS,
|
||||||
|
PLACEHOLDER_LABELS,
|
||||||
|
PLACEHOLDER_SAMPLES,
|
||||||
|
} from "./placeholder-keys";
|
||||||
|
|
||||||
// The monorepo sits next to the frontend; vitest runs from ApiCore-Frontend.
|
// The monorepo sits next to the frontend; vitest runs from ApiCore-Frontend.
|
||||||
// The frontend image build only sees ApiCore-Frontend, so the comparison runs
|
// The frontend image build only sees ApiCore-Frontend, so the comparison runs
|
||||||
@@ -26,4 +31,36 @@ describe("placeholder keys", () => {
|
|||||||
expect(PLACEHOLDER_LABELS[key]).toBeTruthy();
|
expect(PLACEHOLDER_LABELS[key]).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names every key differently, so no two menu entries read alike", () => {
|
||||||
|
const labels = PLACEHOLDER_KEYS.map((key) => PLACEHOLDER_LABELS[key]);
|
||||||
|
expect(new Set(labels).size).toBe(labels.length);
|
||||||
|
// The honey batch number is not the lot of the bottling.
|
||||||
|
expect(PLACEHOLDER_LABELS.batchNumber).not.toBe(PLACEHOLDER_LABELS.lotNumber);
|
||||||
|
expect(PLACEHOLDER_LABELS.batchNumber).toMatch(/Honigcharge/);
|
||||||
|
expect(PLACEHOLDER_LABELS.lotNumber).toMatch(/Abfüllung/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has a short hint for every key", () => {
|
||||||
|
for (const key of PLACEHOLDER_KEYS) {
|
||||||
|
expect(PLACEHOLDER_HINTS[key]).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows distinct samples, so the preview tells the placeholders apart", () => {
|
||||||
|
const samples = PLACEHOLDER_KEYS.map((key) => PLACEHOLDER_SAMPLES[key]);
|
||||||
|
expect(new Set(samples).size).toBe(samples.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same wording as buildPlaceholderValues in the backend.
|
||||||
|
it("shows samples in the form the label prints them", () => {
|
||||||
|
expect(PLACEHOLDER_SAMPLES.lotNumber).not.toMatch(/^L/);
|
||||||
|
expect(PLACEHOLDER_SAMPLES.lotLine).toBe(`L ${PLACEHOLDER_SAMPLES.lotNumber}`);
|
||||||
|
expect(PLACEHOLDER_SAMPLES.bestBeforeDate).toMatch(/^\d{2}\.\d{2}\.\d{4}$/);
|
||||||
|
expect(PLACEHOLDER_SAMPLES.bottlingDate).toMatch(/^\d{2}\.\d{2}\.\d{4}$/);
|
||||||
|
expect(PLACEHOLDER_SAMPLES.bestBeforeLine).toBe(
|
||||||
|
`Mindestens haltbar bis ${PLACEHOLDER_SAMPLES.bestBeforeDate}`,
|
||||||
|
);
|
||||||
|
expect(PLACEHOLDER_SAMPLES.originCountry).toBe("Deutschland");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -20,38 +20,61 @@ export const PLACEHOLDER_KEYS = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
|
export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
|
||||||
|
|
||||||
|
/** Menu and field names. Each one says where the value comes from. */
|
||||||
export const PLACEHOLDER_LABELS: Record<PlaceholderKey, string> = {
|
export const PLACEHOLDER_LABELS: Record<PlaceholderKey, string> = {
|
||||||
batchNumber: "Chargennummer",
|
batchNumber: "Chargennummer der Honigcharge (nicht Los)",
|
||||||
honeyType: "Honigsorte",
|
honeyType: "Honigsorte der Honigcharge",
|
||||||
lotNumber: "Los-/Chargennummer der Abfüllung",
|
lotNumber: "Los-/Chargennummer der Abfüllung (nur Nummer)",
|
||||||
bottlingDate: "Abfülldatum",
|
bottlingDate: "Abfülldatum",
|
||||||
bestBeforeDate: "Mindesthaltbarkeitsdatum (MHD)",
|
bestBeforeDate: "MHD (nur Datum)",
|
||||||
jarSize: "Glasgröße / Füllmenge",
|
jarSize: "Füllmenge (Gramm)",
|
||||||
productName: "Produktname",
|
productName: "Artikelname",
|
||||||
legalName: "Verkehrsbezeichnung (z. B. Blütenhonig)",
|
legalName: "Verkehrsbezeichnung des Artikels (z. B. Blütenhonig)",
|
||||||
originCountry: "Herkunftsland",
|
originCountry: "Herkunftsland (nur Land)",
|
||||||
foodBusinessName: "Lebensmittelunternehmer: Name",
|
foodBusinessName: "Lebensmittelunternehmer: Name",
|
||||||
foodBusinessAddress: "Lebensmittelunternehmer: Anschrift",
|
foodBusinessAddress: "Lebensmittelunternehmer: Anschrift",
|
||||||
bestBeforeLine: "Zeile „Mindestens haltbar bis …“",
|
bestBeforeLine: "MHD-Zeile („Mindestens haltbar bis …“)",
|
||||||
lotLine: "Zeile „L Losnummer“",
|
lotLine: "Los-Zeile („L“ + Los-/Chargennummer)",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One sentence under the content field: what prints, and what not to combine. */
|
||||||
|
export const PLACEHOLDER_HINTS: Record<PlaceholderKey, string> = {
|
||||||
|
batchNumber:
|
||||||
|
"Nummer der Honigcharge (Ernte). Sie ist nicht die Losnummer der Abfüllung und erfüllt die Pflichtangabe „Los“ nicht.",
|
||||||
|
honeyType: "Sorte an der Honigcharge. Kann gleich lauten wie Artikelname oder Verkehrsbezeichnung.",
|
||||||
|
lotNumber:
|
||||||
|
"Nur die Nummer der Abfüllung, ohne „L“. Mit „L“ davor die Los-Zeile nehmen, nicht beides.",
|
||||||
|
bottlingDate: "Tag der Abfüllung, nicht das MHD.",
|
||||||
|
bestBeforeDate:
|
||||||
|
"Nur das Datum. „Mindestens haltbar bis“ davor selbst setzen oder die MHD-Zeile nehmen, nicht beides.",
|
||||||
|
jarSize: "Füllmenge der Abfüllung.",
|
||||||
|
productName: "Name des Artikels im Sortiment.",
|
||||||
|
legalName: "Steht am Artikel. Ist sie dort leer, druckt das Feld nichts.",
|
||||||
|
originCountry: "Land aus den Einstellungen, ohne „Herkunft:“ davor.",
|
||||||
|
foodBusinessName: "Aus den Einstellungen.",
|
||||||
|
foodBusinessAddress: "Aus den Einstellungen, auch mehrzeilig.",
|
||||||
|
bestBeforeLine: "Ganze Zeile mit Datum. Nicht zusätzlich das MHD setzen.",
|
||||||
|
lotLine: "Ganze Zeile mit „L“ und Nummer der Abfüllung. Nicht zusätzlich die Los-/Chargennummer setzen.",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the designer preview shows for a placeholder instead of the raw
|
* What the designer preview shows for a placeholder instead of the raw
|
||||||
* `{{key}}`: a plausible value, so sizes and line breaks look like the print.
|
* `{{key}}`: a plausible value in the form the label prints it (see
|
||||||
|
* `buildPlaceholderValues` in the backend), each one different so the preview
|
||||||
|
* tells the placeholders apart.
|
||||||
*/
|
*/
|
||||||
export const PLACEHOLDER_SAMPLES: Record<PlaceholderKey, string> = {
|
export const PLACEHOLDER_SAMPLES: Record<PlaceholderKey, string> = {
|
||||||
batchNumber: "WH-2026-0001",
|
batchNumber: "HB-2026-001",
|
||||||
honeyType: "Blütenhonig",
|
honeyType: "Sommerhonig",
|
||||||
lotNumber: "L-0001",
|
lotNumber: "2026-0001",
|
||||||
bottlingDate: "15.10.2026",
|
bottlingDate: "01.07.2026",
|
||||||
bestBeforeDate: "10/2028",
|
bestBeforeDate: "01.07.2028",
|
||||||
jarSize: "500 g",
|
jarSize: "500 g",
|
||||||
productName: "Wabenhain Blütenhonig",
|
productName: "Wabenhain Sommerhonig",
|
||||||
legalName: "Blütenhonig",
|
legalName: "Blütenhonig",
|
||||||
originCountry: "Herkunft: Deutschland",
|
originCountry: "Deutschland",
|
||||||
foodBusinessName: "Max Mustermann",
|
foodBusinessName: "Max Mustermann",
|
||||||
foodBusinessAddress: "Musterstr. 1\n86450 Altenmünster",
|
foodBusinessAddress: "Musterstr. 1\n86450 Altenmünster",
|
||||||
bestBeforeLine: "Mindestens haltbar bis Ende 10/2028",
|
bestBeforeLine: "Mindestens haltbar bis 01.07.2028",
|
||||||
lotLine: "L 0001",
|
lotLine: "L 2026-0001",
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user