From 7799f4227a6d0002ead8e6839fa6a5bfbf9c5b74 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 30 Sep 2026 15:41:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20=D0=BA=D0=BE=D0=B4=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B4=D0=B0=D1=87=D0=B8=20=E2=80=94=20=D1=88=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D1=85-=D0=BA=D0=BE=D0=B4=20EAN-13,=20=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=20=D0=B2=20=D0=BD=D0=B0=D1=87=D0=B0=D0=BB=D0=B5=20=D0=B7=D0=BD?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Требование владельца: сканер на складе читает EAN-13, и в значении первым должен идти код выдачи, дальше нули. - код дополняется нулями до 12 знаков + контрольная цифра: 7470 → 7470000000004, то есть сканер отдаёт строку, которая начинается с кода выдачи; - кодировщик EAN-13 (таблицы и контрольная цифра) проверен на битовых строках эталонной реализации (python-barcode) и живым декодером: zxing читает EAN-13 и подтверждает значение; - под полосами печатается значение, которое вернёт сканер (13 цифр); - если код не годится для EAN-13 (не цифры или длиннее 12 знаков) — откат на Code 128 с самим кодом; несовместимый код просто не рисуется, карточка не падает; - общая утилита «биты → полосы» вынесена в barcodeBars. --- src/components/client/PickupCodeBarcode.jsx | 41 +++++++--- src/components/client/barcodeBars.js | 21 +++++ src/components/client/barcodeCode128.js | 12 +-- src/components/client/barcodeCode128.test.js | 9 ++- src/components/client/barcodeEan13.js | 71 +++++++++++++++++ src/components/client/barcodeEan13.test.js | 80 ++++++++++++++++++++ src/components/client/ean13Patterns.js | 61 +++++++++++++++ 7 files changed, 272 insertions(+), 23 deletions(-) create mode 100644 src/components/client/barcodeBars.js create mode 100644 src/components/client/barcodeEan13.js create mode 100644 src/components/client/barcodeEan13.test.js create mode 100644 src/components/client/ean13Patterns.js 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 ( fontSize="13" fill="#000000" > - {code} + {caption} ) : null} 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" + ] +};