From dbac57ba2aa5b6fd8c88b3d2bc0597f4d12f19b1 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 30 Sep 2026 14:18:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20=D1=88=D1=82=D1=80=D0=B8=D1=85-?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=20=D0=BF=D0=BE=D0=B4=20=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B8=20(=D1=81?= =?UTF-8?q?=D0=B0=D0=BC=D0=BE=D0=B2=D1=8B=D0=B2=D0=BE=D0=B7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Клиент открывает карточку доставки с телефона и показывает код выдачи на складе. Теперь под кодом рисуется штрих-код с тем же значением — код можно отсканировать, а не набирать руками. - Code 128 без внешних зависимостей (PWA работает офлайн), подмножество C для четырёх цифр: пара цифр = один символ; - таблица символов перенесена машинно из эталонной реализации, алгоритм закреплён тестом на её битовых строках; - отрисовка SVG: резко на экране и на печати, тихая зона 10 модулей; - блок кода выдачи вынесен в общий PickupCodeCard (был продублирован в сохранённом выборе и на вкладке «Самовывоз»); - SW-кэш static-v130. --- public/service-worker.js | 2 +- src/components/client/PickupCodeBarcode.jsx | 62 ++++++++++ src/components/client/PickupCodeCard.jsx | 35 ++++++ src/components/client/barcodeCode128.js | 76 ++++++++++++ src/components/client/barcodeCode128.test.js | 66 +++++++++++ src/components/client/code128Patterns.js | 118 +++++++++++++++++++ src/pages/ClientDeliveryPage.jsx | 19 +-- 7 files changed, 361 insertions(+), 17 deletions(-) create mode 100644 src/components/client/PickupCodeBarcode.jsx create mode 100644 src/components/client/PickupCodeCard.jsx create mode 100644 src/components/client/barcodeCode128.js create mode 100644 src/components/client/barcodeCode128.test.js create mode 100644 src/components/client/code128Patterns.js diff --git a/public/service-worker.js b/public/service-worker.js index 965fabf..a9223de 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,7 +1,7 @@ const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; if (!isLocalhost) { - const STATIC_CACHE = "construction-delivery-static-v129"; + const STATIC_CACHE = "construction-delivery-static-v130"; const RUNTIME_CACHE = "construction-delivery-runtime-v119"; const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"]; diff --git a/src/components/client/PickupCodeBarcode.jsx b/src/components/client/PickupCodeBarcode.jsx new file mode 100644 index 0000000..ceeda18 --- /dev/null +++ b/src/components/client/PickupCodeBarcode.jsx @@ -0,0 +1,62 @@ +/** + * @file PickupCodeBarcode.jsx + * @description Штрих-код для кода выдачи (самовывоз) в карточке доставки клиента. + * + * Рисуется SVG-ом на клиенте, без картинок и внешних библиотек: PWA работает офлайн, + * а на печати и на экране полосы остаются резкими при любом размере. + * Значение — тот же код выдачи, который клиент называет вслух. + */ +import React from "react"; + +import { code128Bars } from "./barcodeCode128"; + +/** Тихая зона по стандарту — не меньше 10 модулей с каждой стороны. */ +const QUIET_ZONE_MODULES = 10; + +/** Высота полос и подписи в «единицах» viewBox — масштабируется вместе с SVG. */ +const BAR_HEIGHT = 56; +const TEXT_HEIGHT = 18; + +export const PickupCodeBarcode = ({ value, showText = true, className = "" }) => { + const code = String(value ?? "").trim(); + if (!code) return null; + + const bars = code128Bars(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); + + let x = QUIET_ZONE_MODULES; + const rects = []; + bars.forEach((bar, index) => { + if (bar.black) { + rects.push(); + } + x += bar.width; + }); + + return ( + + + {rects} + {showText ? ( + + {code} + + ) : null} + + ); +}; diff --git a/src/components/client/PickupCodeCard.jsx b/src/components/client/PickupCodeCard.jsx new file mode 100644 index 0000000..1d69e0f --- /dev/null +++ b/src/components/client/PickupCodeCard.jsx @@ -0,0 +1,35 @@ +/** + * @file PickupCodeCard.jsx + * @description Блок «Ваш код выдачи» в карточке доставки клиента (самовывоз): + * крупный код, который клиент называет вслух, и под ним штрих-код с тем же кодом — + * чтобы на складе можно было не набирать код руками, а отсканировать с телефона. + * Разметка одна на оба места страницы (сохранённый выбор и вкладка «Самовывоз»), + * иначе блоки разъезжаются. + */ +import React from "react"; + +import { PickupCodeBarcode } from "./PickupCodeBarcode"; + +export const PickupCodeCard = ({ invitation, deliveryNumberDigits, className = "" }) => { + const code = invitation?.pickupCode || invitation?.pickup_code; + if (!code) return null; + + return ( +
+ {deliveryNumberDigits ? ( +

Ваш номер доставки {deliveryNumberDigits}

+ ) : null} +

Ваш код выдачи

+

{code}

+ +

+ Покажите код при получении заказа или дайте отсканировать штрих-код +

+
+ ); +}; diff --git a/src/components/client/barcodeCode128.js b/src/components/client/barcodeCode128.js new file mode 100644 index 0000000..ab568ee --- /dev/null +++ b/src/components/client/barcodeCode128.js @@ -0,0 +1,76 @@ +/** + * @file barcodeCode128.js + * @description Кодирование Code 128 без внешних зависимостей (нужно для PWA: работает офлайн). + * + * Коды выдачи — четыре цифры, поэтому используется подмножество C: одна пара цифр = + * один символ (штрих-код получается компактнее). Для строк с нецифровыми символами + * или нечётной длины — подмножество B (каждый символ = один символ кода). + * + * Контрольная сумма считается по спецификации: (старт + Σ значение_i × позиция_i) mod 103. + * Правильность таблицы и алгоритма закреплена тестом на эталонных значениях, снятых + * с независимой реализации (python-barcode) — см. barcodeCode128.test.js. + */ + +import { CODE128_PATTERNS, CODE128_STOP } from "./code128Patterns"; + +/** Стартовые символы подмножеств. */ +export const CODE128_START_B = 104; +export const CODE128_START_C = 105; + +/** Подмножество C годится только для чётного количества цифр. */ +export const isCode128CApplicable = (value) => /^\d+$/.test(String(value)) && String(value).length % 2 === 0; + +/** Значения символов (включая контрольный) без старта и STOP. */ +export const code128Codes = (value) => { + const chars = String(value ?? ""); + if (!chars) return []; + const useC = isCode128CApplicable(chars); + const codes = [useC ? CODE128_START_C : CODE128_START_B]; + + if (useC) { + for (let i = 0; i < chars.length; i += 2) { + codes.push(Number.parseInt(chars.slice(i, i + 2), 10)); + } + } else { + for (const ch of chars) { + const index = ch.charCodeAt(0) - 32; + if (index < 0 || index > 94) { + throw new Error(`Символ «${ch}» нельзя закодировать в Code 128B`); + } + codes.push(index); + } + } + + let sum = codes[0]; + for (let i = 1; i < codes.length; i += 1) sum += i * codes[i]; + codes.push(sum % 103); + return codes; +}; + +/** Полная битовая строка: 1 = штрих, 0 = пробел (включая финальный штрих). */ +export const code128Bits = (value) => { + const codes = code128Codes(value); + if (!codes.length) return ""; + return codes.map((code) => CODE128_PATTERNS[code]).join("") + CODE128_STOP + "11"; +}; + +/** + * Биты → полосы для отрисовки: [{ 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 const code128Bars = (value) => bitsToBars(code128Bits(value)); + +/** Ширина кода в модулях (без «тихой зоны»). */ +export const code128Modules = (value) => code128Bits(value).length; diff --git a/src/components/client/barcodeCode128.test.js b/src/components/client/barcodeCode128.test.js new file mode 100644 index 0000000..2fe093c --- /dev/null +++ b/src/components/client/barcodeCode128.test.js @@ -0,0 +1,66 @@ +/** + * @file barcodeCode128.test.js + * @description Штрих-код кода выдачи: эталонные битовые строки сняты с независимой + * реализации (python-barcode, Code128.build()) — если таблица символов или + * контрольная сумма поедут, тест это поймает, а не склад со сканером. + */ +import React from "react"; +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { bitsToBars, code128Bits, code128Codes, code128Modules } from "./barcodeCode128"; +import { PickupCodeBarcode } from "./PickupCodeBarcode"; + +const REFERENCE_BITS = { + "7470": "110100111001000011001010110000100110010001001100011101011", + "1234": "110100111001011001110010001011000100100111101100011101011", + "0000": "110100111001101100110011011001100110011001101100011101011", +}; + +describe("Code 128 для кода выдачи", () => { + it("пара цифр — один символ подмножества C, плюс старт и контрольная", () => { + expect(code128Codes("7470")).toEqual([105, 74, 70, 10]); + expect(code128Codes("0000")).toEqual([105, 0, 0, 2]); + }); + + it("биты совпадают с эталонной реализацией", () => { + Object.entries(REFERENCE_BITS).forEach(([value, bits]) => { + expect(code128Bits(value)).toBe(bits); + }); + }); + + it("код начинается и заканчивается штрихом (иначе сканер не считает)", () => { + const bits = code128Bits("7470"); + expect(bits.startsWith("1")).toBe(true); + expect(bits.endsWith("11")).toBe(true); + expect(bits.length).toBe(code128Modules("7470")); + }); + + it("полосы склеены и покрывают всю строку", () => { + const bars = bitsToBars(code128Bits("7470")); + expect(bars.reduce((sum, bar) => sum + bar.width, 0)).toBe(code128Modules("7470")); + bars.forEach((bar, index) => { + if (index > 0) expect(bar.black).toBe(!bars[index - 1].black); + }); + }); + + it("нечисловой или нечётный код кодируется подмножеством B", () => { + expect(code128Codes("123")[0]).toBe(104); + expect(code128Bits("123")).not.toBe(""); + expect(() => code128Bits("ёж")).toThrow(); + }); +}); + +describe("PickupCodeBarcode", () => { + it("рисует штрих-код и подпись со значением", () => { + const html = renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: "7470" })); + expect(html).toContain("Штрих-код кода выдачи 7470"); + expect(html).toContain(" { + expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: "" }))).toBe(""); + expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: null }))).toBe(""); + }); +}); diff --git a/src/components/client/code128Patterns.js b/src/components/client/code128Patterns.js new file mode 100644 index 0000000..aa50e2b --- /dev/null +++ b/src/components/client/code128Patterns.js @@ -0,0 +1,118 @@ +/** + * @file code128Patterns.js + * @description Таблица символов Code 128 (значения 0..105) и STOP-паттерн. + * Значения перенесены из эталонной реализации (python-barcode, charsets/code128.py) + * машинно, а не набраны руками — ошибка в одном модуле делает штрих-код + * нечитаемым для сканера. + * 1 = штрих, 0 = пробел; каждый символ — 11 модулей, STOP — 13 модулей. + */ +export const CODE128_PATTERNS = [ + "11011001100", + "11001101100", + "11001100110", + "10010011000", + "10010001100", + "10001001100", + "10011001000", + "10011000100", + "10001100100", + "11001001000", + "11001000100", + "11000100100", + "10110011100", + "10011011100", + "10011001110", + "10111001100", + "10011101100", + "10011100110", + "11001110010", + "11001011100", + "11001001110", + "11011100100", + "11001110100", + "11101101110", + "11101001100", + "11100101100", + "11100100110", + "11101100100", + "11100110100", + "11100110010", + "11011011000", + "11011000110", + "11000110110", + "10100011000", + "10001011000", + "10001000110", + "10110001000", + "10001101000", + "10001100010", + "11010001000", + "11000101000", + "11000100010", + "10110111000", + "10110001110", + "10001101110", + "10111011000", + "10111000110", + "10001110110", + "11101110110", + "11010001110", + "11000101110", + "11011101000", + "11011100010", + "11011101110", + "11101011000", + "11101000110", + "11100010110", + "11101101000", + "11101100010", + "11100011010", + "11101111010", + "11001000010", + "11110001010", + "10100110000", + "10100001100", + "10010110000", + "10010000110", + "10000101100", + "10000100110", + "10110010000", + "10110000100", + "10011010000", + "10011000010", + "10000110100", + "10000110010", + "11000010010", + "11001010000", + "11110111010", + "11000010100", + "10001111010", + "10100111100", + "10010111100", + "10010011110", + "10111100100", + "10011110100", + "10011110010", + "11110100100", + "11110010100", + "11110010010", + "11011011110", + "11011110110", + "11110110110", + "10101111000", + "10100011110", + "10001011110", + "10111101000", + "10111100010", + "11110101000", + "11110100010", + "10111011110", + "10111101110", + "11101011110", + "11110101110", + "11010000100", + "11010010000", + "11010011100", +]; + +export const CODE128_STOP = "11000111010"; diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx index be72c1a..ea4c6b0 100644 --- a/src/pages/ClientDeliveryPage.jsx +++ b/src/pages/ClientDeliveryPage.jsx @@ -5,6 +5,7 @@ import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker"; import { PickupSlotsPicker } from "../components/client/PickupSlotsPicker"; import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel"; import { getDeliveryNumberDigits, getInvitationReferenceLabel } from "../components/client/invitationReference"; +import { PickupCodeCard } from "../components/client/PickupCodeCard"; import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice"; import { PushSubscriptionBanner } from "../components/client/PushSubscriptionBanner"; import { PwaInstallCard } from "../components/client/PwaInstallCard"; @@ -626,14 +627,7 @@ export const ClientDeliveryPage = () => { Статус: {invitation?.deliveryType === "pickup" ? "самовывоз" : "доставка"} уже согласован. При повторном открытии этой ссылки будет показан тот же выбор.

{(invitation?.pickupCode || invitation?.pickup_code) ? ( -
- {deliveryNumberDigits ? ( -

Ваш номер доставки {deliveryNumberDigits}

- ) : null} -

Ваш код выдачи

-

{invitation.pickupCode || invitation.pickup_code}

-

Покажите этот код при получении заказа

-
+ ) : null} ) : null} @@ -732,14 +726,7 @@ export const ClientDeliveryPage = () => { ) : null} {activeTab === TAB_PICKUP && (invitation?.pickupCode || invitation?.pickup_code) ? ( -
- {deliveryNumberDigits ? ( -

Ваш номер доставки {deliveryNumberDigits}

- ) : null} -

Ваш код выдачи

-

{invitation.pickupCode || invitation.pickup_code}

-

Покажите этот код при получении заказа

-
+ ) : null} {activeTab === TAB_DELIVERY && !slots.length ? (