diff --git a/src/components/client/PickupCodeBarcode.jsx b/src/components/client/PickupCodeBarcode.jsx
index ceeda18..2fe43a2 100644
--- a/src/components/client/PickupCodeBarcode.jsx
+++ b/src/components/client/PickupCodeBarcode.jsx
@@ -1,27 +1,46 @@
/**
* @file PickupCodeBarcode.jsx
- * @description Штрих-код для кода выдачи (самовывоз) в карточке доставки клиента.
+ * @description Штрих-код под кодом выдачи (самовывоз) в карточке доставки клиента.
+ *
+ * Основной формат — EAN-13: складской сканер настроен на товарный формат.
+ * Код выдачи стоит в начале 13-значного значения, дальше нули и контрольная цифра,
+ * то есть сканер отдаёт строку, начинающуюся с кода (7470 → 7470000000004).
+ * Если код для EAN-13 не годится (не цифры или длиннее 12 знаков) — рисуем Code 128
+ * с самим кодом, чтобы блок не остался без штрих-кода.
*
* Рисуется SVG-ом на клиенте, без картинок и внешних библиотек: PWA работает офлайн,
- * а на печати и на экране полосы остаются резкими при любом размере.
- * Значение — тот же код выдачи, который клиент называет вслух.
+ * а на экране и на печати полосы остаются резкими.
*/
import React from "react";
import { code128Bars } from "./barcodeCode128";
+import { ean13Bars, ean13FullValue, isEan13Applicable } from "./barcodeEan13";
-/** Тихая зона по стандарту — не меньше 10 модулей с каждой стороны. */
+/** Тихая зона по стандарту: не меньше 10 модулей с каждой стороны. */
const QUIET_ZONE_MODULES = 10;
-/** Высота полос и подписи в «единицах» viewBox — масштабируется вместе с SVG. */
+/** Размеры в «единицах» viewBox — масштабируются вместе с SVG. */
const BAR_HEIGHT = 56;
const TEXT_HEIGHT = 18;
-export const PickupCodeBarcode = ({ value, showText = true, className = "" }) => {
+export const PickupCodeBarcode = ({ value, format = "ean13", showText = true, className = "" }) => {
const code = String(value ?? "").trim();
if (!code) return null;
- const bars = code128Bars(code);
+ const useEan13 = format === "ean13" && isEan13Applicable(code);
+ // Код может оказаться несовместимым с форматом (например, буквы в Code 128):
+ // карточка клиента не должна падать из-за штрих-кода — просто не рисуем его.
+ let bars = [];
+ try {
+ bars = useEan13 ? ean13Bars(code) : code128Bars(code);
+ } catch {
+ return null;
+ }
+ if (!bars.length) return null;
+
+ // Под полосами печатаем то, что вернёт сканер: 13 знаков у EAN-13, сам код у Code 128.
+ const caption = useEan13 ? ean13FullValue(code) : code;
+
const modules = bars.reduce((sum, bar) => sum + bar.width, 0);
const width = modules + QUIET_ZONE_MODULES * 2;
const height = BAR_HEIGHT + (showText ? TEXT_HEIGHT : 0);
@@ -38,7 +57,11 @@ export const PickupCodeBarcode = ({ value, showText = true, className = "" }) =>
return (
diff --git a/src/components/client/barcodeBars.js b/src/components/client/barcodeBars.js
new file mode 100644
index 0000000..748b848
--- /dev/null
+++ b/src/components/client/barcodeBars.js
@@ -0,0 +1,21 @@
+/**
+ * @file barcodeBars.js
+ * @description Общее для всех форматов: битовая строка → полосы для отрисовки.
+ * Соседние одинаковые биты склеиваются, иначе SVG получит сотни прямоугольников
+ * и на телефоне будет тормозить.
+ */
+
+/** @param {string} bits 1 = штрих, 0 = пробел */
+export const bitsToBars = (bits) => {
+ const bars = [];
+ for (const bit of String(bits)) {
+ const black = bit === "1";
+ const last = bars[bars.length - 1];
+ if (last && last.black === black) last.width += 1;
+ else bars.push({ black, width: 1 });
+ }
+ return bars;
+};
+
+/** Ширина кода в модулях. */
+export const barsWidth = (bars) => bars.reduce((sum, bar) => sum + bar.width, 0);
diff --git a/src/components/client/barcodeCode128.js b/src/components/client/barcodeCode128.js
index ab568ee..90c4271 100644
--- a/src/components/client/barcodeCode128.js
+++ b/src/components/client/barcodeCode128.js
@@ -11,6 +11,7 @@
* с независимой реализации (python-barcode) — см. barcodeCode128.test.js.
*/
+import { bitsToBars } from "./barcodeBars";
import { CODE128_PATTERNS, CODE128_STOP } from "./code128Patterns";
/** Стартовые символы подмножеств. */
@@ -58,16 +59,7 @@ export const code128Bits = (value) => {
* Биты → полосы для отрисовки: [{ black, width }], ширины в модулях.
* Соседние одинаковые биты склеиваются — иначе SVG получит тысячи прямоугольников.
*/
-export const bitsToBars = (bits) => {
- const bars = [];
- for (const bit of String(bits)) {
- const black = bit === "1";
- const last = bars[bars.length - 1];
- if (last && last.black === black) last.width += 1;
- else bars.push({ black, width: 1 });
- }
- return bars;
-};
+export { bitsToBars } from "./barcodeBars";
/** Готовые полосы для значения. */
export const code128Bars = (value) => bitsToBars(code128Bits(value));
diff --git a/src/components/client/barcodeCode128.test.js b/src/components/client/barcodeCode128.test.js
index 2fe093c..ecdae5b 100644
--- a/src/components/client/barcodeCode128.test.js
+++ b/src/components/client/barcodeCode128.test.js
@@ -8,7 +8,8 @@ import React from "react";
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
-import { bitsToBars, code128Bits, code128Codes, code128Modules } from "./barcodeCode128";
+import { bitsToBars } from "./barcodeBars";
+import { code128Bits, code128Codes, code128Modules } from "./barcodeCode128";
import { PickupCodeBarcode } from "./PickupCodeBarcode";
const REFERENCE_BITS = {
@@ -53,14 +54,14 @@ describe("Code 128 для кода выдачи", () => {
describe("PickupCodeBarcode", () => {
it("рисует штрих-код и подпись со значением", () => {
- const html = renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: "7470" }));
+ const html = renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: "7470", format: "code128" }));
expect(html).toContain("Штрих-код кода выдачи 7470");
expect(html).toContain(" {
- expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: "" }))).toBe("");
- expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: null }))).toBe("");
+ expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: "", format: "code128" }))).toBe("");
+ expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: null, format: "code128" }))).toBe("");
});
});
diff --git a/src/components/client/barcodeEan13.js b/src/components/client/barcodeEan13.js
new file mode 100644
index 0000000..6a0c205
--- /dev/null
+++ b/src/components/client/barcodeEan13.js
@@ -0,0 +1,71 @@
+/**
+ * @file barcodeEan13.js
+ * @description EAN-13 для кода выдачи: код стоит В НАЧАЛЕ, дальше нули до 12 знаков,
+ * последняя цифра — контрольная.
+ *
+ * Почему так: сканер на складе настроен на EAN-13 (товарный формат, ровно 13 цифр).
+ * Код выдачи у нас 4 знака, поэтому он дополняется нулями справа, и сканер отдаёт
+ * строку, которая НАЧИНАЕТСЯ с кода — например «7470000000004» для кода 7470.
+ *
+ * Алгоритм и таблицы проверены на битовых строках эталонной реализации
+ * (python-barcode) — см. barcodeEan13.test.js.
+ */
+
+import { bitsToBars } from "./barcodeBars";
+import { EAN13_CODES, EAN13_EDGE, EAN13_LEFT_PATTERN, EAN13_MIDDLE } from "./ean13Patterns";
+
+/** Сколько знаков данных до контрольной цифры. */
+export const EAN13_PAYLOAD_LENGTH = 12;
+
+/** Подходит ли код для EAN-13: только цифры и не длиннее 12 знаков. */
+export const isEan13Applicable = (value) => {
+ const digits = String(value ?? "").trim();
+ return /^\d+$/.test(digits) && digits.length <= EAN13_PAYLOAD_LENGTH;
+};
+
+/** 12 знаков данных: код, затем нули. */
+export const ean13Payload = (value) => {
+ const digits = String(value ?? "").trim();
+ if (!isEan13Applicable(digits)) return "";
+ return digits.padEnd(EAN13_PAYLOAD_LENGTH, "0");
+};
+
+/** Контрольная цифра EAN-13 (веса 1/3 справа налево). */
+export const ean13CheckDigit = (payload) => {
+ const digits = String(payload).padStart(EAN13_PAYLOAD_LENGTH, "0").slice(0, EAN13_PAYLOAD_LENGTH);
+ let evensum = 0;
+ let oddsum = 0;
+ for (let i = 0; i < digits.length; i += 1) {
+ // Индексы от конца: чётные позиции (считая с предпоследней) — вес 1, остальные — 3.
+ const fromEnd = digits.length - 1 - i;
+ if (fromEnd % 2 === 1) evensum += Number(digits[i]);
+ else oddsum += Number(digits[i]);
+ }
+ return (10 - ((evensum + oddsum * 3) % 10)) % 10;
+};
+
+/** Полное 13-значное значение — то, что вернёт сканер. */
+export const ean13FullValue = (value) => {
+ const payload = ean13Payload(value);
+ if (!payload) return "";
+ return `${payload}${ean13CheckDigit(payload)}`;
+};
+
+/** Битовая строка из 95 модулей. */
+export const ean13Bits = (value) => {
+ const full = ean13FullValue(value);
+ if (!full) return "";
+ const pattern = EAN13_LEFT_PATTERN[Number(full[0])];
+ let bits = EAN13_EDGE;
+ for (let i = 1; i < 7; i += 1) {
+ bits += EAN13_CODES[pattern[i - 1]][Number(full[i])];
+ }
+ bits += EAN13_MIDDLE;
+ for (let i = 7; i < 13; i += 1) {
+ bits += EAN13_CODES.C[Number(full[i])];
+ }
+ return `${bits}${EAN13_EDGE}`;
+};
+
+/** Полосы для отрисовки. */
+export const ean13Bars = (value) => bitsToBars(ean13Bits(value));
diff --git a/src/components/client/barcodeEan13.test.js b/src/components/client/barcodeEan13.test.js
new file mode 100644
index 0000000..4762bc3
--- /dev/null
+++ b/src/components/client/barcodeEan13.test.js
@@ -0,0 +1,80 @@
+/**
+ * @file barcodeEan13.test.js
+ * @description EAN-13 для кода выдачи. Эталонные значения и битовые строки сняты
+ * с независимой реализации (python-barcode, EuropeanArticleNumber13.build()) —
+ * ошибку в таблицах или контрольной цифре поймает тест, а не склад со сканером.
+ */
+import React from "react";
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { ean13Bars, ean13Bits, ean13CheckDigit, ean13FullValue, ean13Payload, isEan13Applicable } from "./barcodeEan13";
+import { PickupCodeBarcode } from "./PickupCodeBarcode";
+
+const REFERENCE = {
+ "7470": { full: "7470000000004", bits: "10101000110010001000110101001110001101010011101010111001011100101110010111001011100101011100101" },
+ "1234": { full: "1234000000008", bits: "10100100110111101001110100011010100111010011101010111001011100101110010111001011100101001000101" },
+ "0000": { full: "0000000000000", bits: "10100011010001101000110100011010001101000110101010111001011100101110010111001011100101110010101" },
+ "9521": { full: "9521000000001", bits: "10101100010011011011001100011010100111000110101010111001011100101110010111001011100101100110101" },
+};
+
+describe("EAN-13 для кода выдачи", () => {
+ it("код стоит в начале значения, дальше нули до 12 знаков", () => {
+ expect(ean13Payload("7470")).toBe("747000000000");
+ expect(ean13Payload("7470").startsWith("7470")).toBe(true);
+ expect(ean13Payload("7470")).toHaveLength(12);
+ });
+
+ it("контрольная цифра и полное значение совпадают с эталоном", () => {
+ Object.entries(REFERENCE).forEach(([code, expected]) => {
+ const payload = ean13Payload(code);
+ expect(String(ean13CheckDigit(payload))).toBe(expected.full.slice(-1));
+ expect(ean13FullValue(code)).toBe(expected.full);
+ });
+ });
+
+ it("биты совпадают с эталонной реализацией (95 модулей)", () => {
+ Object.entries(REFERENCE).forEach(([code, expected]) => {
+ expect(ean13Bits(code)).toBe(expected.bits);
+ expect(ean13Bits(code)).toHaveLength(95);
+ });
+ });
+
+ it("полосы начинаются и заканчиваются штрихом (иначе сканер не считает)", () => {
+ const bars = ean13Bars("7470");
+ expect(bars[0].black).toBe(true);
+ expect(bars[bars.length - 1].black).toBe(true);
+ expect(bars.reduce((sum, bar) => sum + bar.width, 0)).toBe(95);
+ });
+
+ it("нецифровой или слишком длинный код в EAN-13 не лезет", () => {
+ expect(isEan13Applicable("7470")).toBe(true);
+ expect(isEan13Applicable("74701")).toBe(true);
+ expect(isEan13Applicable("123456789012")).toBe(true);
+ expect(isEan13Applicable("1234567890123")).toBe(false);
+ expect(isEan13Applicable("ТП-7470")).toBe(false);
+ expect(ean13FullValue("ТП-7470")).toBe("");
+ });
+});
+
+describe("PickupCodeBarcode в формате EAN-13", () => {
+ it("по умолчанию рисует EAN-13 и печатает значение, которое вернёт сканер", () => {
+ const html = renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: "7470" }));
+ expect(html).toContain("EAN-13");
+ expect(html).toContain("7470000000004");
+ });
+
+ it("слишком длинный для EAN-13 код откатывается на Code 128 с самим кодом", () => {
+ const html = renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: "1234567890123" }));
+ expect(html).toContain("Штрих-код кода выдачи 1234567890123");
+ expect(html).not.toContain("EAN-13");
+ });
+
+ it("код, который вообще нельзя закодировать, не роняет карточку", () => {
+ expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: "ТП-7470" }))).toBe("");
+ });
+
+ it("без кода ничего не рисует", () => {
+ expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: "" }))).toBe("");
+ });
+});
diff --git a/src/components/client/ean13Patterns.js b/src/components/client/ean13Patterns.js
new file mode 100644
index 0000000..cdbf878
--- /dev/null
+++ b/src/components/client/ean13Patterns.js
@@ -0,0 +1,61 @@
+/**
+ * @file ean13Patterns.js
+ * @description Таблицы EAN-13 (штрихи/пробелы, по 7 модулей на цифру).
+ * Перенесены машинно из эталонной реализации (python-barcode, charsets/ean.py),
+ * а не набраны руками: одна неверная цифра делает штрих-код нечитаемым.
+ * LEFT_PATTERN — схема чётности левой половины, выбирается по первой цифре кода.
+ */
+export const EAN13_EDGE = "101";
+export const EAN13_MIDDLE = "01010";
+
+export const EAN13_LEFT_PATTERN = [
+ "AAAAAA",
+ "AABABB",
+ "AABBAB",
+ "AABBBA",
+ "ABAABB",
+ "ABBAAB",
+ "ABBBAA",
+ "ABABAB",
+ "ABABBA",
+ "ABBABA"
+];
+
+export const EAN13_CODES = {
+ A: [
+ "0001101",
+ "0011001",
+ "0010011",
+ "0111101",
+ "0100011",
+ "0110001",
+ "0101111",
+ "0111011",
+ "0110111",
+ "0001011"
+ ],
+ B: [
+ "0100111",
+ "0110011",
+ "0011011",
+ "0100001",
+ "0011101",
+ "0111001",
+ "0000101",
+ "0010001",
+ "0001001",
+ "0010111"
+ ],
+ C: [
+ "1110010",
+ "1100110",
+ "1101100",
+ "1000010",
+ "1011100",
+ "1001110",
+ "1010000",
+ "1000100",
+ "1001000",
+ "1110100"
+ ]
+};