feat(client): штрих-код под кодом выдачи (самовывоз)

Клиент открывает карточку доставки с телефона и показывает код выдачи на
складе. Теперь под кодом рисуется штрих-код с тем же значением — код можно
отсканировать, а не набирать руками.

- Code 128 без внешних зависимостей (PWA работает офлайн), подмножество C
  для четырёх цифр: пара цифр = один символ;
- таблица символов перенесена машинно из эталонной реализации, алгоритм
  закреплён тестом на её битовых строках;
- отрисовка SVG: резко на экране и на печати, тихая зона 10 модулей;
- блок кода выдачи вынесен в общий PickupCodeCard (был продублирован в
  сохранённом выборе и на вкладке «Самовывоз»);
- SW-кэш static-v130.
This commit is contained in:
Hermes 2026-09-30 14:18:05 +02:00
parent 4cf5c0ffab
commit dbac57ba2a
7 changed files with 361 additions and 17 deletions

View File

@ -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"];

View File

@ -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(<rect key={index} x={x} y="0" width={bar.width} height={BAR_HEIGHT} fill="#000000" />);
}
x += bar.width;
});
return (
<svg
role="img"
aria-label={`Штрих-код кода выдачи ${code}`}
viewBox={`0 0 ${width} ${height}`}
className={["block h-auto", className].join(" ").trim()}
preserveAspectRatio="xMidYMid meet"
>
<rect x="0" y="0" width={width} height={height} fill="#ffffff" />
{rects}
{showText ? (
<text
x={width / 2}
y={BAR_HEIGHT + 14}
textAnchor="middle"
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
fontSize="13"
fill="#000000"
>
{code}
</text>
) : null}
</svg>
);
};

View File

@ -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 (
<div
className={[
"rounded-2xl border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] p-4 text-center",
className,
].join(" ").trim()}
>
{deliveryNumberDigits ? (
<p className="text-sm font-semibold text-[var(--color-text)]">Ваш номер доставки {deliveryNumberDigits}</p>
) : null}
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Ваш код выдачи</p>
<p className="mt-1 text-3xl font-bold tracking-[0.4em] text-[var(--color-accent)]">{code}</p>
<PickupCodeBarcode value={code} className="mx-auto mt-3 w-full max-w-[320px]" />
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
Покажите код при получении заказа или дайте отсканировать штрих-код
</p>
</div>
);
};

View File

@ -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;

View File

@ -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("<rect");
expect(html).toContain("7470");
});
it("без кода ничего не рисует (пустой блок вместо битой картинки)", () => {
expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: "" }))).toBe("");
expect(renderToStaticMarkup(React.createElement(PickupCodeBarcode, { value: null }))).toBe("");
});
});

View File

@ -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";

View File

@ -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" ? "самовывоз" : "доставка"} уже согласован. При повторном открытии этой ссылки будет показан тот же выбор.
</p>
{(invitation?.pickupCode || invitation?.pickup_code) ? (
<div className="mt-3 rounded-2xl border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] p-4 text-center">
{deliveryNumberDigits ? (
<p className="text-sm font-semibold text-[var(--color-text)]">Ваш номер доставки {deliveryNumberDigits}</p>
) : null}
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Ваш код выдачи</p>
<p className="mt-1 text-3xl font-bold tracking-[0.4em] text-[var(--color-accent)]">{invitation.pickupCode || invitation.pickup_code}</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">Покажите этот код при получении заказа</p>
</div>
<PickupCodeCard invitation={invitation} deliveryNumberDigits={deliveryNumberDigits} className="mt-3" />
) : null}
</Panel>
) : null}
@ -732,14 +726,7 @@ export const ClientDeliveryPage = () => {
) : null}
{activeTab === TAB_PICKUP && (invitation?.pickupCode || invitation?.pickup_code) ? (
<div className="rounded-2xl border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] p-4 text-center">
{deliveryNumberDigits ? (
<p className="text-sm font-semibold text-[var(--color-text)]">Ваш номер доставки {deliveryNumberDigits}</p>
) : null}
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Ваш код выдачи</p>
<p className="mt-1 text-3xl font-bold tracking-[0.4em] text-[var(--color-accent)]">{invitation.pickupCode || invitation.pickup_code}</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">Покажите этот код при получении заказа</p>
</div>
<PickupCodeCard invitation={invitation} deliveryNumberDigits={deliveryNumberDigits} />
) : null}
{activeTab === TAB_DELIVERY && !slots.length ? (