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 (
+
+ );
+};
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 ? (