feat(client): код выдачи — штрих-код EAN-13, код в начале значения
Требование владельца: сканер на складе читает EAN-13, и в значении первым должен идти код выдачи, дальше нули. - код дополняется нулями до 12 знаков + контрольная цифра: 7470 → 7470000000004, то есть сканер отдаёт строку, которая начинается с кода выдачи; - кодировщик EAN-13 (таблицы и контрольная цифра) проверен на битовых строках эталонной реализации (python-barcode) и живым декодером: zxing читает EAN-13 и подтверждает значение; - под полосами печатается значение, которое вернёт сканер (13 цифр); - если код не годится для EAN-13 (не цифры или длиннее 12 знаков) — откат на Code 128 с самим кодом; несовместимый код просто не рисуется, карточка не падает; - общая утилита «биты → полосы» вынесена в barcodeBars.
This commit is contained in:
parent
dbac57ba2a
commit
7799f4227a
|
|
@ -1,27 +1,46 @@
|
||||||
/**
|
/**
|
||||||
* @file PickupCodeBarcode.jsx
|
* @file PickupCodeBarcode.jsx
|
||||||
* @description Штрих-код для кода выдачи (самовывоз) в карточке доставки клиента.
|
* @description Штрих-код под кодом выдачи (самовывоз) в карточке доставки клиента.
|
||||||
|
*
|
||||||
|
* Основной формат — EAN-13: складской сканер настроен на товарный формат.
|
||||||
|
* Код выдачи стоит в начале 13-значного значения, дальше нули и контрольная цифра,
|
||||||
|
* то есть сканер отдаёт строку, начинающуюся с кода (7470 → 7470000000004).
|
||||||
|
* Если код для EAN-13 не годится (не цифры или длиннее 12 знаков) — рисуем Code 128
|
||||||
|
* с самим кодом, чтобы блок не остался без штрих-кода.
|
||||||
*
|
*
|
||||||
* Рисуется SVG-ом на клиенте, без картинок и внешних библиотек: PWA работает офлайн,
|
* Рисуется SVG-ом на клиенте, без картинок и внешних библиотек: PWA работает офлайн,
|
||||||
* а на печати и на экране полосы остаются резкими при любом размере.
|
* а на экране и на печати полосы остаются резкими.
|
||||||
* Значение — тот же код выдачи, который клиент называет вслух.
|
|
||||||
*/
|
*/
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { code128Bars } from "./barcodeCode128";
|
import { code128Bars } from "./barcodeCode128";
|
||||||
|
import { ean13Bars, ean13FullValue, isEan13Applicable } from "./barcodeEan13";
|
||||||
|
|
||||||
/** Тихая зона по стандарту — не меньше 10 модулей с каждой стороны. */
|
/** Тихая зона по стандарту: не меньше 10 модулей с каждой стороны. */
|
||||||
const QUIET_ZONE_MODULES = 10;
|
const QUIET_ZONE_MODULES = 10;
|
||||||
|
|
||||||
/** Высота полос и подписи в «единицах» viewBox — масштабируется вместе с SVG. */
|
/** Размеры в «единицах» viewBox — масштабируются вместе с SVG. */
|
||||||
const BAR_HEIGHT = 56;
|
const BAR_HEIGHT = 56;
|
||||||
const TEXT_HEIGHT = 18;
|
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();
|
const code = String(value ?? "").trim();
|
||||||
if (!code) return null;
|
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 modules = bars.reduce((sum, bar) => sum + bar.width, 0);
|
||||||
const width = modules + QUIET_ZONE_MODULES * 2;
|
const width = modules + QUIET_ZONE_MODULES * 2;
|
||||||
const height = BAR_HEIGHT + (showText ? TEXT_HEIGHT : 0);
|
const height = BAR_HEIGHT + (showText ? TEXT_HEIGHT : 0);
|
||||||
|
|
@ -38,7 +57,11 @@ export const PickupCodeBarcode = ({ value, showText = true, className = "" }) =>
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`Штрих-код кода выдачи ${code}`}
|
aria-label={
|
||||||
|
useEan13
|
||||||
|
? `Штрих-код EAN-13 кода выдачи ${code} (значение ${caption})`
|
||||||
|
: `Штрих-код кода выдачи ${code}`
|
||||||
|
}
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
className={["block h-auto", className].join(" ").trim()}
|
className={["block h-auto", className].join(" ").trim()}
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
|
@ -54,7 +77,7 @@ export const PickupCodeBarcode = ({ value, showText = true, className = "" }) =>
|
||||||
fontSize="13"
|
fontSize="13"
|
||||||
fill="#000000"
|
fill="#000000"
|
||||||
>
|
>
|
||||||
{code}
|
{caption}
|
||||||
</text>
|
</text>
|
||||||
) : null}
|
) : null}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
* с независимой реализации (python-barcode) — см. barcodeCode128.test.js.
|
* с независимой реализации (python-barcode) — см. barcodeCode128.test.js.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { bitsToBars } from "./barcodeBars";
|
||||||
import { CODE128_PATTERNS, CODE128_STOP } from "./code128Patterns";
|
import { CODE128_PATTERNS, CODE128_STOP } from "./code128Patterns";
|
||||||
|
|
||||||
/** Стартовые символы подмножеств. */
|
/** Стартовые символы подмножеств. */
|
||||||
|
|
@ -58,16 +59,7 @@ export const code128Bits = (value) => {
|
||||||
* Биты → полосы для отрисовки: [{ black, width }], ширины в модулях.
|
* Биты → полосы для отрисовки: [{ black, width }], ширины в модулях.
|
||||||
* Соседние одинаковые биты склеиваются — иначе SVG получит тысячи прямоугольников.
|
* Соседние одинаковые биты склеиваются — иначе SVG получит тысячи прямоугольников.
|
||||||
*/
|
*/
|
||||||
export const bitsToBars = (bits) => {
|
export { bitsToBars } from "./barcodeBars";
|
||||||
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 code128Bars = (value) => bitsToBars(code128Bits(value));
|
export const code128Bars = (value) => bitsToBars(code128Bits(value));
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import React from "react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
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";
|
import { PickupCodeBarcode } from "./PickupCodeBarcode";
|
||||||
|
|
||||||
const REFERENCE_BITS = {
|
const REFERENCE_BITS = {
|
||||||
|
|
@ -53,14 +54,14 @@ describe("Code 128 для кода выдачи", () => {
|
||||||
|
|
||||||
describe("PickupCodeBarcode", () => {
|
describe("PickupCodeBarcode", () => {
|
||||||
it("рисует штрих-код и подпись со значением", () => {
|
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("Штрих-код кода выдачи 7470");
|
||||||
expect(html).toContain("<rect");
|
expect(html).toContain("<rect");
|
||||||
expect(html).toContain("7470");
|
expect(html).toContain("7470");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("без кода ничего не рисует (пустой блок вместо битой картинки)", () => {
|
it("без кода ничего не рисует (пустой блок вместо битой картинки)", () => {
|
||||||
expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: "" }))).toBe("");
|
expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: "", format: "code128" }))).toBe("");
|
||||||
expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: null }))).toBe("");
|
expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: null, format: "code128" }))).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue