[E14][@ant] feat: QR-передача партии поставщик→оператор (Story 14.3)

Основной сценарий ПВЗ: поставщик показывает QR сформированной партии,
оператор сканирует — идентификатор подставляется и акт приёмки открывается
без ручного ввода shipment_id.

Два переиспользуемых канон-виджета (DRY, под Story 14.4 заказчик→выдача):
- HandoffQr — генерация QR из идентификатора (qrcode, toDataURL) + копируемый
  код как запасной путь;
- QrScanner — нативный BarcodeDetector (Chromium) + камера, с ручным вводом
  кода если камера/API недоступны; зависимостей не добавляем.

Поставщик (OffererSupplyPreparation): колонка «Передача» с кнопкой QR на
партии в статусе SUPPLY_PREPARED/RECEPTION_IN_PROGRESS → диалог с QR.
Оператор (OperatorReception): кнопка «Сканировать QR» → диалог сканера →
onQrScanned подставляет код и создаёт акт приёмки.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-30 10:41:48 +00:00
parent b1a3e1721c
commit 9fcb0d27ae
7 changed files with 488 additions and 4 deletions
@@ -1,10 +1,11 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { FailAlert } from 'src/shared/api';
import { BaseBadge, BaseButton, EmptyState, TableSkeleton } from 'src/shared/ui/base';
import { BaseBadge, BaseButton, BaseDialog, EmptyState, TableSkeleton } from 'src/shared/ui/base';
import type { BaseBadgeVariant, TableSkeletonColumn } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton';
import { HandoffQr } from 'src/widgets/Marketplace/HandoffQr';
import { listShipments, type MarketplaceShipmentView } from '../api';
import { fetchSupplierOrders } from '../../OffererIncomingOrders/api';
import type { MarketplaceOrderView } from '../../MyOrders/types';
@@ -51,6 +52,21 @@ function openFormation(cycle: ShipmentFormationCycle): void {
dialogOpen.value = true;
}
// QR-код передачи: поставщик показывает его оператору на ПВЗ — тот
// сканирует и сразу открывает приёмку именно этой партии.
const qrDialogOpen = ref(false);
const qrShipment = ref<MarketplaceShipmentView | null>(null);
function openQr(row: MarketplaceShipmentView): void {
qrShipment.value = row;
qrDialogOpen.value = true;
}
// QR имеет смысл, пока партия не принята/не отменена.
function canShowQr(row: MarketplaceShipmentView): boolean {
return row.status === 'SUPPLY_PREPARED' || row.status === 'RECEPTION_IN_PROGRESS';
}
// Статус партии → метка + canon-вариант бейджа.
const SHIPMENT_STATUS: Record<string, { label: string; variant: BaseBadgeVariant }> = {
DRAFT: { label: 'Черновик', variant: 'neutral' },
@@ -106,6 +122,7 @@ const skeletonColumns: TableSkeletonColumn[] = [
{ label: 'Статус', cell: 'badge' },
{ label: 'Сумма', class: 'col-num', cell: 'text', cellWidth: '80px' },
{ label: 'ТТН', cell: 'text', cellWidth: '90px' },
{ label: 'Передача', class: 'col-qr', cell: 'text', cellWidth: '96px' },
];
async function load(): Promise<void> {
@@ -148,7 +165,7 @@ q-page.offerer-supply
v-if='loading && !shipments.length && !formationCycles.length',
:columns='skeletonColumns',
:rows='6',
min-width='960px'
min-width="1040px"
)
//- Раздел 1: заявки, ожидающие явного формирования партии.
@@ -185,6 +202,7 @@ q-page.offerer-supply
th.col-status Статус
th.col-num Сумма
th.col-ttn ТТН
th.col-qr Передача
tbody
tr(v-for='row in shipments', :key='row.id')
td.col-id {{ row.cycle_id }}
@@ -195,6 +213,12 @@ q-page.offerer-supply
.offerer-supply__next(v-if='nextStep(row)') {{ nextStep(row) }}
td.col-num {{ row.total_amount }}
td.col-ttn {{ row.ttn_number || '—' }}
td.col-qr
BaseButton(v-if='canShowQr(row)', variant='ghost', size='sm', @click='openQr(row)')
template(#icon-left)
q-icon(name='qr_code_2', size='16px')
| QR
span(v-else)
EmptyState(
v-if='isEmpty',
@@ -209,6 +233,15 @@ q-page.offerer-supply
:cycle='selectedCycle',
@created='onCreated'
)
BaseDialog(v-model='qrDialogOpen', title='QR-код передачи партии', size='sm')
.offerer-supply__qr(v-if='qrShipment')
HandoffQr(
:value='qrShipment.id',
caption='Покажите этот код оператору пункта выдачи — он отсканирует и откроет приёмку партии.'
)
.offerer-supply__qr-meta
| {{ qrShipment.braname }} · {{ deliveryVariantLabel(qrShipment.delivery_variant) }} · {{ qrShipment.total_amount }}
</template>
<style scoped lang="scss">
@@ -311,6 +344,21 @@ q-page.offerer-supply
line-height: 1.3;
color: var(--p-ink-3);
}
&__qr {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--p-3, 12px);
padding: var(--p-2, 8px) 0;
}
&__qr-meta {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-2);
text-align: center;
font-variant-numeric: tabular-nums;
}
}
.table-scroll {
@@ -318,7 +366,7 @@ q-page.offerer-supply
}
.table {
table-layout: fixed;
min-width: 960px;
min-width: 1040px;
}
.col-id {
width: 150px;
@@ -338,6 +386,10 @@ q-page.offerer-supply
.col-ttn {
width: 120px;
}
.col-qr {
width: 96px;
text-align: center;
}
@media (max-width: 768px) {
.offerer-supply {
@@ -5,9 +5,10 @@ import { useRoute } from 'vue-router';
import { Loading } from 'quasar';
import { SuccessAlert, FailAlert } from 'src/shared/api';
import { OperatorBranchBar, useOperatorBranchStore } from 'src/entities/OperatorBranch';
import { BaseBadge, BaseButton, BaseInput, EmptyState } from 'src/shared/ui/base';
import { BaseBadge, BaseButton, BaseDialog, BaseInput, EmptyState } from 'src/shared/ui/base';
import type { BaseBadgeVariant } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import { QrScanner } from 'src/widgets/Marketplace/QrScanner';
import {
createAplReception,
listAplReceptionsByBraname,
@@ -113,6 +114,16 @@ async function createReceptionForShipment(): Promise<void> {
}
}
// QR-код передачи: поставщик показывает QR партии, оператор сканирует —
// идентификатор подставляется и акт приёмки открывается без ручного ввода.
const scanDialogOpen = ref(false);
async function onQrScanned(code: string): Promise<void> {
scanDialogOpen.value = false;
shipmentIdInput.value = code;
await createReceptionForShipment();
}
const signDialogOpen = ref(false);
const signTarget = ref<MarketplaceAplReceptionView | null>(null);
@@ -160,6 +171,10 @@ q-page.reception(role='region', aria-label='Приёмка партии')
template(#icon-left)
q-icon(name='add', size='16px')
| Создать акт приёмки
BaseButton(variant='secondary', @click='scanDialogOpen = true')
template(#icon-left)
q-icon(name='qr_code_scanner', size='16px')
| Сканировать QR
q-table.reception__table(
:rows='items',
@@ -198,6 +213,9 @@ q-page.reception(role='region', aria-label='Приёмка партии')
:reception='signTarget',
@signed='onChairmanSigned'
)
BaseDialog(v-model='scanDialogOpen', title='Сканирование QR партии', size='sm')
QrScanner(@scanned='onQrScanned')
</template>
<style scoped lang="scss">
+36
View File
@@ -0,0 +1,36 @@
/* eslint-disable */
// QR-передача (Эпик 14, Story 14.3/14.4). Минимальные декларации для
// генерации QR (`qrcode` — без собственных типов) и нативного декодера
// `BarcodeDetector` (поддерживается Chromium; зависимостей не добавляем).
declare module 'qrcode' {
interface QRCodeToDataURLOptions {
margin?: number;
width?: number;
errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H';
color?: { dark?: string; light?: string };
}
export function toDataURL(text: string, options?: QRCodeToDataURLOptions): Promise<string>;
const _default: { toDataURL: typeof toDataURL };
export default _default;
}
interface DetectedBarcode {
rawValue: string;
format: string;
}
interface BarcodeDetectorOptions {
formats?: string[];
}
declare class BarcodeDetector {
constructor(options?: BarcodeDetectorOptions);
detect(source: CanvasImageSource | Blob | ImageData): Promise<DetectedBarcode[]>;
static getSupportedFormats(): Promise<string[]>;
}
interface Window {
BarcodeDetector?: typeof BarcodeDetector;
}
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import QRCode from 'qrcode';
import { copyToClipboard } from 'quasar';
import { SuccessAlert, FailAlert } from 'src/shared/api';
import { BaseButton } from 'src/shared/ui/base';
/**
* Эпик 14 / Story 14.3, 14.4: QR-код передачи на ПВЗ.
*
* Один переиспользуемый компонент для обоих сценариев:
* - поставщик показывает QR партии оператору приёмки (value = shipment_id);
* - заказчик показывает QR заказа оператору выдачи (value = order id).
*
* QR кодирует идентификатор — оператор сканирует и сразу видит, что
* принять / выдать. Под QR — тот же код текстом (копируемый) как запасной
* путь, если камера недоступна.
*/
const props = withDefaults(
defineProps<{
value: string;
caption?: string;
size?: number;
}>(),
{ caption: '', size: 240 },
);
const dataUrl = ref('');
const failed = ref(false);
async function render(): Promise<void> {
failed.value = false;
if (!props.value) {
dataUrl.value = '';
return;
}
try {
dataUrl.value = await QRCode.toDataURL(props.value, {
margin: 1,
width: props.size,
errorCorrectionLevel: 'M',
});
} catch {
failed.value = true;
dataUrl.value = '';
}
}
watch(() => [props.value, props.size], render, { immediate: true });
async function copyCode(): Promise<void> {
try {
await copyToClipboard(props.value);
SuccessAlert('Код скопирован');
} catch (e) {
FailAlert(e, 'Не удалось скопировать код');
}
}
</script>
<template lang="pug">
.handoff-qr
.handoff-qr__frame(:style='{ width: size + "px", height: size + "px" }')
img.handoff-qr__img(v-if='dataUrl', :src='dataUrl', alt='QR-код передачи')
.handoff-qr__fallback(v-else-if='failed')
q-icon(name='error_outline', size='32px')
span Не удалось построить QR
.handoff-qr__caption(v-if='caption') {{ caption }}
.handoff-qr__code
code.handoff-qr__code-text {{ value }}
BaseButton(variant='ghost', size='sm', icon-only, aria-label='Скопировать код', @click='copyCode')
template(#icon-left)
q-icon(name='content_copy', size='16px')
</template>
<style scoped lang="scss">
.handoff-qr {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--p-3, 12px);
&__frame {
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
padding: var(--p-2, 8px);
}
&__img {
width: 100%;
height: 100%;
image-rendering: pixelated;
}
&__fallback {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--p-2, 8px);
color: var(--p-ink-3);
font-size: var(--p-fs-body-sm, 13px);
text-align: center;
}
&__caption {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
text-align: center;
max-width: 320px;
line-height: 1.4;
}
&__code {
display: flex;
align-items: center;
gap: var(--p-2, 8px);
max-width: 100%;
}
&__code-text {
font-family: var(--font-mono);
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-2);
overflow-wrap: anywhere;
background: var(--p-surface-2, var(--p-surface));
padding: 2px 8px;
border-radius: var(--p-r-sm, 8px);
}
}
</style>
@@ -0,0 +1 @@
export { default as HandoffQr } from './HandoffQr.vue';
@@ -0,0 +1,241 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, useTemplateRef } from 'vue';
import { BaseButton, BaseInput } from 'src/shared/ui/base';
/**
* Эпик 14 / Story 14.3, 14.4: сканер QR-кода передачи на ПВЗ.
*
* Переиспользуется оператором приёмки (партия) и оператором выдачи (заказ).
* Декодирование — нативный `BarcodeDetector` (Chromium), без внешних
* зависимостей. Если API/камера недоступны — ручной ввод кода как запасной
* путь (тот же код напечатан под QR у показывающего).
*/
const emit = defineEmits<{
(e: 'scanned', code: string): void;
}>();
type ScanState = 'idle' | 'requesting' | 'scanning' | 'error';
const state = ref<ScanState>('idle');
const errorMessage = ref('');
const manualCode = ref('');
const video = useTemplateRef<HTMLVideoElement>('video');
let stream: MediaStream | null = null;
let detector: BarcodeDetector | null = null;
let rafId: number | null = null;
let stopped = false;
const supported = typeof window !== 'undefined' && 'BarcodeDetector' in window;
function teardown(): void {
stopped = true;
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (stream) {
for (const track of stream.getTracks()) track.stop();
stream = null;
}
}
async function tick(): Promise<void> {
if (stopped || !detector || !video.value) return;
try {
const codes = await detector.detect(video.value);
const hit = codes.find((c) => c.rawValue);
if (hit) {
teardown();
state.value = 'idle';
emit('scanned', hit.rawValue.trim());
return;
}
} catch {
// Кадр мог быть не готов — продолжаем цикл, не падая.
}
rafId = requestAnimationFrame(() => void tick());
}
async function start(): Promise<void> {
errorMessage.value = '';
if (!supported) {
state.value = 'error';
errorMessage.value = 'Камера-сканер недоступна в этом браузере — введите код вручную.';
return;
}
state.value = 'requesting';
stopped = false;
try {
detector = new BarcodeDetector({ formats: ['qr_code'] });
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
});
if (stopped) {
teardown();
return;
}
if (video.value) {
video.value.srcObject = stream;
await video.value.play();
}
state.value = 'scanning';
rafId = requestAnimationFrame(() => void tick());
} catch {
teardown();
state.value = 'error';
errorMessage.value = 'Не удалось получить доступ к камере — введите код вручную.';
}
}
function stop(): void {
teardown();
state.value = 'idle';
}
function submitManual(): void {
const code = manualCode.value.trim();
if (!code) return;
teardown();
state.value = 'idle';
emit('scanned', code);
manualCode.value = '';
}
onBeforeUnmount(teardown);
defineExpose({ start, stop });
</script>
<template lang="pug">
.qr-scanner
.qr-scanner__viewport
video.qr-scanner__video(
ref='video',
v-show='state === "scanning"',
muted,
playsinline
)
.qr-scanner__overlay(v-if='state === "idle"')
q-icon.qr-scanner__muted(name='qr_code_scanner', size='56px')
.qr-scanner__caption Наведите камеру на QR-код передачи
BaseButton(variant='primary', size='sm', @click='start') Включить камеру
.qr-scanner__overlay(v-else-if='state === "requesting"')
q-spinner(color='primary', size='40px')
.qr-scanner__caption Запрос доступа к камере
.qr-scanner__frame(v-else-if='state === "scanning"')
.qr-scanner__corners
.qr-scanner__hint Поместите QR-код в рамку
.qr-scanner__overlay(v-else-if='state === "error"')
q-icon(name='videocam_off', size='48px')
.qr-scanner__caption {{ errorMessage }}
q-btn.qr-scanner__stop(
v-if='state === "scanning"',
flat,
no-caps,
dense,
color='primary',
label='Остановить',
@click='stop'
)
//- Запасной путь: ручной ввод кода (тот же, что напечатан под QR).
.qr-scanner__manual
BaseInput.qr-scanner__manual-input(
v-model='manualCode',
label='Или введите код вручную',
placeholder='идентификатор',
mono,
@keyup.enter='submitManual'
)
BaseButton(variant='secondary', size='sm', :disabled='!manualCode.trim()', @click='submitManual') Применить
</template>
<style scoped lang="scss">
.qr-scanner {
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
&__viewport {
position: relative;
width: 100%;
max-width: 420px;
aspect-ratio: 4 / 3;
margin: 0 auto;
background: var(--p-ink);
border-radius: var(--p-r-md, 12px);
overflow: hidden;
}
&__video {
width: 100%;
height: 100%;
object-fit: cover;
}
&__overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--p-3, 12px);
padding: var(--p-4, 16px);
text-align: center;
color: #fff;
}
&__muted {
color: rgba(255, 255, 255, 0.6);
}
&__caption {
font-size: var(--p-fs-body-sm, 13px);
color: rgba(255, 255, 255, 0.85);
line-height: 1.4;
}
&__frame {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
&__corners {
width: 60%;
height: 60%;
border: 2px solid rgba(255, 255, 255, 0.85);
border-radius: var(--p-r-sm, 8px);
}
&__hint {
position: absolute;
bottom: var(--p-3, 12px);
width: 100%;
text-align: center;
font-size: var(--p-fs-body-sm, 13px);
color: rgba(255, 255, 255, 0.85);
}
&__stop {
align-self: center;
}
&__manual {
display: flex;
align-items: flex-end;
gap: var(--p-2, 8px);
}
&__manual-input {
flex: 1 1 auto;
}
}
</style>
@@ -0,0 +1 @@
export { default as QrScanner } from './QrScanner.vue';