feat(client): штрих-код под кодом выдачи (самовывоз)
Клиент открывает карточку доставки с телефона и показывает код выдачи на складе. Теперь под кодом рисуется штрих-код с тем же значением — код можно отсканировать, а не набирать руками. - Code 128 без внешних зависимостей (PWA работает офлайн), подмножество C для четырёх цифр: пара цифр = один символ; - таблица символов перенесена машинно из эталонной реализации, алгоритм закреплён тестом на её битовых строках; - отрисовка SVG: резко на экране и на печати, тихая зона 10 модулей; - блок кода выдачи вынесен в общий PickupCodeCard (был продублирован в сохранённом выборе и на вкладке «Самовывоз»); - SW-кэш static-v130.
This commit is contained in:
parent
4cf5c0ffab
commit
dbac57ba2a
|
|
@ -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"];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
Loading…
Reference in New Issue