feat(marketplace): account-bound QR-код передачи на ПВЗ (Story 14.3/14.4)

Код привязан к аккаунту человека, а не к партии/заказу («как в Ozon»):
один показ = оператор резолвит аккаунт против ленты своего КУ и принимает/
выдаёт разом все ожидаемые единицы этого человека.

- shared/lib/marketplace/handoff-token: encode/decode токена
  `mp1:<kind>:<coopname>:<account>` (pickup|receive). Детерминирован от
  личности+намерения → генерируется заранее/оффлайн, backend не минтит.
  Привязка к КУ — на стороне оператора (резолв против своей ленты); coopname
  отсекает чужой кооператив.
- Поставщик: «Мой код для ПВЗ» (pickup-токен) на «Подготовке отгрузки».
- Заказчик: «Мой код получения» (receive-токен) на «Готово к получению».
- Оператор приёмки: скан pickup-кода → диалог «Принять всё» (сформированные
  партии SUPPLY_PREPARED + самовывоз по факту разом).
- Оператор выдачи: скан receive-кода → панель всех заказов заказчика на выдачу;
  пошаговое открытие/завершение (каждый шаг — своя двойная подпись).
- Legacy-QR (сам shipment_id/order_id) продолжает работать — decode вернёт null
  и код падает на прежний путь по id (обратная совместимость).

Подписанный/одноразовый токен-сервис с TTL остаётся доработкой (mp2): аккаунт-
биндинг уже даёт Ozon-UX, криптоподпись пользователь явно отложил.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-30 11:49:28 +00:00
parent d1da62d979
commit 4d02d0047e
6 changed files with 401 additions and 11 deletions
@@ -1,11 +1,14 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { FailAlert } from 'src/shared/api';
import { useSessionStore } from 'src/entities/Session';
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 { encodeHandoffToken, HandoffTokenKind } from 'src/shared/lib/marketplace';
import { TTNPrintPreview, type TTNData } from 'src/widgets/Marketplace/TTNPrintPreview';
import { listShipments, type MarketplaceShipmentView } from '../api';
import { fetchSupplierOrders } from '../../OffererIncomingOrders/api';
@@ -30,6 +33,10 @@ import CreateShipmentDialog from './CreateShipmentDialog.vue';
const PAGE_SIZE = 200;
const route = useRoute();
const session = useSessionStore();
const coopname = computed(() => String(route.params.coopname ?? ''));
const shipments = ref<MarketplaceShipmentView[]>([]);
const acceptedOrders = ref<MarketplaceOrderView[]>([]);
// Заказы сформированных партий (статус SUPPLY_PREPARED) — источник состава ТТН.
@@ -53,8 +60,23 @@ function openFormation(cycle: ShipmentFormationCycle): void {
dialogOpen.value = true;
}
// QR-код передачи: поставщик показывает его оператору на ПВЗ — тот
// сканирует и сразу открывает приёмку именно этой партии.
// Story 14.3: один account-bound код на весь стол. Поставщик показывает его
// оператору приёмки — тот резолвит аккаунт против ленты своего КУ и принимает
// разом всё привезённое (и сформированные партии, и самовывоз по факту).
// Код привязан к личности, не к партии: его можно сгенерировать заранее/оффлайн.
const myCodeDialogOpen = ref(false);
const myPickupCode = computed(() =>
session.username
? encodeHandoffToken({
kind: HandoffTokenKind.Pickup,
coopname: coopname.value,
account: session.username,
})
: '',
);
// QR-код передачи конкретной партии: поставщик показывает его оператору на ПВЗ —
// тот сканирует и открывает приёмку именно этой партии (точечный путь).
const qrDialogOpen = ref(false);
const qrShipment = ref<MarketplaceShipmentView | null>(null);
@@ -179,6 +201,10 @@ q-page.offerer-supply
| кооперативу.
.offerer-supply__toolbar
BaseButton(variant='secondary', size='sm', :disabled='!myPickupCode', @click='myCodeDialogOpen = true')
template(#icon-left)
q-icon(name='qr_code_2', size='16px')
| Мой код для ПВЗ
RefreshButton(:loading='loading', @refresh='load')
TableSkeleton(
@@ -261,6 +287,13 @@ q-page.offerer-supply
@created='onCreated'
)
BaseDialog(v-model='myCodeDialogOpen', title='Мой код для пункта выдачи', size='sm')
.offerer-supply__qr(v-if='myPickupCode')
HandoffQr(
:value='myPickupCode',
caption='Покажите этот код оператору на ПВЗ — он примет разом всё, что вы привезли (и сформированные партии, и самовывоз по факту). Код можно показать заранее или с распечатки.'
)
BaseDialog(v-model='qrDialogOpen', title='QR-код передачи партии', size='sm')
.offerer-supply__qr(v-if='qrShipment')
HandoffQr(
@@ -9,6 +9,7 @@ import { PageHint } from 'src/shared/ui/domain';
import { QrScanner } from 'src/widgets/Marketplace/QrScanner';
import { orderStatusDisplay } from 'src/widgets/Marketplace/OrderCard';
import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import { decodeHandoffToken, HandoffTokenKind } from 'src/shared/lib/marketplace';
import {
listIssuancesByBraname,
type MarketplaceOrderIssuanceView,
@@ -87,21 +88,70 @@ function startFinalize(item: MarketplaceOrderIssuanceView): void {
finalizeDialog.value = true;
}
// QR-код получения: заказчик показывает QR заказа, оператор сканирует
// находим заказ в ленте КУ и запускаем нужный шаг выдачи.
// QR-код получения. Оператор сканирует:
// - account-bound код заказчика (Story 14.4) → резолвим аккаунт против ленты
// своего КУ и показываем РАЗОМ все его заказы на выдачу;
// - legacy-QR с самим order id → точечно открываем шаг выдачи одного заказа.
const scanDialogOpen = ref(false);
// Статусы заказа, релевантные выдаче (ожидают открытия или финальной подписи).
const ISSUANCE_STATUSES = ['ACCEPTED_TO_COOP', 'READY_TO_RECEIVE'];
// Резолв account-bound кода: все заказы заказчика на выдачу на этом КУ.
const pickupDialogOpen = ref(false);
const pickupAccount = ref('');
const pickupOrders = computed(() =>
items.value.filter(
(o) => o.orderer_account === pickupAccount.value && ISSUANCE_STATUSES.includes(o.status),
),
);
// Открыть/завершить выдачу одного заказа. Панель агрегированных заказов
// заказчика остаётся открытой под полноэкранным шагом выдачи — по `load()`
// список пересчитывается, выданный заказ уходит, оператор продолжает следующий.
function startIssuanceStep(order: MarketplaceOrderIssuanceView): void {
if (order.status === 'ACCEPTED_TO_COOP') {
startOpen(order);
} else if (order.status === 'READY_TO_RECEIVE') {
startFinalize(order);
} else {
FailAlert(new Error('Заказ не в статусе выдачи.'));
}
}
function onQrScanned(code: string): void {
scanDialogOpen.value = false;
const token = decodeHandoffToken(code);
if (token) {
if (token.kind !== HandoffTokenKind.Receive) {
FailAlert(new Error('Это код поставки, а не код получения. Отсканируйте код заказчика.'));
return;
}
if (token.coopname && token.coopname !== coopname.value) {
FailAlert(new Error('Код выписан для другого кооператива.'));
return;
}
const has = items.value.some(
(o) => o.orderer_account === token.account && ISSUANCE_STATUSES.includes(o.status),
);
if (!has) {
FailAlert(
new Error(`У заказчика ${token.account} нет заказов на выдачу на этом пункте.`),
);
return;
}
pickupAccount.value = token.account;
pickupDialogOpen.value = true;
return;
}
// Legacy: QR несёт сам order id — точечная выдача одного заказа.
const order = items.value.find((o) => o.id === code);
if (!order) {
FailAlert(new Error('Заказ не найден на этом пункте выдачи. Проверьте КУ и статус заказа.'));
return;
}
if (order.status === 'ACCEPTED_TO_COOP') {
startOpen(order);
} else if (order.status === 'READY_TO_RECEIVE') {
startFinalize(order);
if (ISSUANCE_STATUSES.includes(order.status)) {
startIssuanceStep(order);
} else {
FailAlert(new Error('Заказ не в статусе выдачи.'));
}
@@ -199,6 +249,37 @@ q-page.issuance(role='region', aria-label='Выдача заказов')
BaseDialog(v-model='scanDialogOpen', title='Сканирование QR заказа', size='sm')
QrScanner(@scanned='onQrScanned')
//- Story 14.4: все заказы заказчика на выдачу разом (резолв account-bound
//- кода против ленты этого КУ). Оператор открывает/завершает их по одному —
//- каждый шаг выдачи имеет собственную двойную подпись.
BaseDialog(v-model='pickupDialogOpen', title='Выдача заказчику', size='sm')
.issuance__resolve
.issuance__resolve-account {{ pickupAccount }}
.issuance__resolve-hint(v-if='pickupOrders.length') Готовы к выдаче на этом пункте:
.issuance__resolve-empty(v-else) Все заказы этого заказчика уже выданы.
.issuance__resolve-item(v-for='o in pickupOrders', :key='o.id')
.issuance__resolve-item-info
.issuance__resolve-item-title {{ o.product_name || 'Товар по предложению' }}
.issuance__resolve-item-meta {{ o.quantity }} {{ marketplaceUnitShort(o.unit_of_measure) }} · {{ orderStatusDisplay(o.status).label }}
BaseButton(
v-if='o.status === "ACCEPTED_TO_COOP"',
variant='primary',
size='sm',
@click='startIssuanceStep(o)'
)
template(#icon-left)
q-icon(name='draw', size='16px')
| Открыть
BaseButton(
v-else-if='o.status === "READY_TO_RECEIVE"',
variant='primary',
size='sm',
@click='startIssuanceStep(o)'
)
template(#icon-left)
q-icon(name='inventory', size='16px')
| Завершить
</template>
<style scoped lang="scss">
@@ -212,6 +293,50 @@ q-page.issuance(role='region', aria-label='Выдача заказов')
display: flex;
justify-content: flex-end;
}
&__resolve {
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
}
&__resolve-account {
font-size: var(--p-fs-body, 14px);
font-weight: 600;
color: var(--p-ink);
font-family: var(--font-mono);
}
&__resolve-hint,
&__resolve-empty {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
}
&__resolve-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--p-3, 12px);
padding-top: var(--p-2, 8px);
border-top: 1px solid var(--p-line);
}
&__resolve-item-info {
min-width: 0;
}
&__resolve-item-title {
font-size: var(--p-fs-body, 14px);
color: var(--p-ink);
overflow-wrap: anywhere;
}
&__resolve-item-meta {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-2);
font-variant-numeric: tabular-nums;
}
}
@media (max-width: 768px) {
@@ -10,6 +10,7 @@ import { BaseBadge, BaseButton, BaseDialog, EmptyState } from 'src/shared/ui/bas
import type { BaseBadgeVariant } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import { QrScanner } from 'src/widgets/Marketplace/QrScanner';
import { decodeHandoffToken, HandoffTokenKind } from 'src/shared/lib/marketplace';
import {
listShipmentsByBraname,
type MarketplaceShipmentView,
@@ -174,15 +175,94 @@ async function createReceptionForShipment(shipmentId: string): Promise<void> {
}
}
// QR-код передачи: поставщик показывает QR партии, оператор сканирует
// акт приёмки открывается по считанному shipment_id без ручного ввода.
// QR-код передачи. Оператор сканирует:
// - account-bound код поставщика (Story 14.3) → резолвим аккаунт против ленты
// своего КУ и принимаем РАЗОМ всё привезённое этим человеком;
// - legacy-QR с самим shipment_id → точечная приёмка одной партии.
const scanDialogOpen = ref(false);
// Резолв account-bound кода: всё, что ждёт приёмки от поставщика на этом КУ —
// и сформированные партии (SUPPLY_PREPARED), и самовывоз по факту.
interface ResolvedSupplierPickup {
account: string;
shipments: MarketplaceShipmentView[];
express: MarketplaceExpressPickupCandidateView | null;
}
const pickupDialogOpen = ref(false);
const resolvedPickup = ref<ResolvedSupplierPickup | null>(null);
const resolvedPickupCount = computed(() => {
const r = resolvedPickup.value;
if (!r) return 0;
return r.shipments.length + (r.express ? 1 : 0);
});
function resolveSupplierPickup(account: string): ResolvedSupplierPickup {
return {
account,
shipments: pendingShipments.value.filter((s) => s.offerer_account === account),
express: expressCandidates.value.find((c) => c.offerer_account === account) ?? null,
};
}
async function onQrScanned(code: string): Promise<void> {
scanDialogOpen.value = false;
const token = decodeHandoffToken(code);
if (token) {
if (token.kind !== HandoffTokenKind.Pickup) {
FailAlert(new Error('Это код получения заказа, а не код поставки. Отсканируйте код поставщика.'));
return;
}
if (token.coopname && token.coopname !== coopname.value) {
FailAlert(new Error('Код выписан для другого кооператива.'));
return;
}
const resolved = resolveSupplierPickup(token.account);
if (!resolved.shipments.length && !resolved.express) {
FailAlert(
new Error(`У поставщика ${token.account} нет поставок, ожидающих приёмки на этом пункте.`),
);
return;
}
resolvedPickup.value = resolved;
pickupDialogOpen.value = true;
return;
}
// Legacy: QR несёт сам shipment_id — точечная приёмка одной партии.
await createReceptionForShipment(code);
}
// Принять разом всё привезённое поставщиком: акты по сформированным партиям +
// синтез самовывоза по факту. Один Loading, одна перезагрузка ленты в конце.
async function acceptResolvedPickup(): Promise<void> {
const resolved = resolvedPickup.value;
if (!resolved) return;
pickupDialogOpen.value = false;
Loading.show({ message: 'Принимаю всё привезённое…' });
let created = 0;
try {
for (const s of resolved.shipments) {
await createAplReception({ shipment_id: s.id });
created += 1;
}
if (resolved.express) {
const result = await createExpressReception({
offerer_account: resolved.express.offerer_account,
braname: resolved.express.braname,
});
created += result.apl_receptions.length;
}
SuccessAlert(created > 1 ? `Открыто актов приёмки: ${created}` : 'Акт приёмки создан');
} catch (e) {
FailAlert(e, 'Не удалось принять часть поставок — проверьте ленту и повторите');
} finally {
Loading.hide();
resolvedPickup.value = null;
await load();
}
}
const signDialogOpen = ref(false);
const signTarget = ref<MarketplaceAplReceptionView | null>(null);
@@ -300,6 +380,25 @@ q-page.reception(role='region', aria-label='Приёмка партии')
BaseDialog(v-model='scanDialogOpen', title='Сканирование QR партии', size='sm')
QrScanner(@scanned='onQrScanned')
//- Story 14.3: подтверждение приёмки разом всего привезённого поставщиком
//- (резолв account-bound кода против ленты этого КУ).
BaseDialog(v-model='pickupDialogOpen', title='Приём поставщика', size='sm')
.reception__resolve(v-if='resolvedPickup')
.reception__resolve-account {{ resolvedPickup.account }}
.reception__resolve-hint Принять разом всё, что привёз поставщик на этот пункт:
.reception__resolve-item(v-for='s in resolvedPickup.shipments', :key='s.id')
q-icon(name='local_shipping', size='16px')
span Партия · {{ SHIPMENT_VARIANT_LABEL[s.delivery_variant] ?? s.delivery_variant }} · {{ s.total_amount }}
.reception__resolve-item(v-if='resolvedPickup.express')
q-icon(name='how_to_reg', size='16px')
span Самовывоз по факту · {{ resolvedPickup.express.orders_count }} заказ(ов) · {{ resolvedPickup.express.total_units }} ед. · {{ resolvedPickup.express.total_amount }}
.reception__resolve-actions
BaseButton(variant='ghost', size='sm', @click='pickupDialogOpen = false') Отмена
BaseButton(variant='primary', size='sm', @click='acceptResolvedPickup')
template(#icon-left)
q-icon(name='how_to_reg', size='16px')
| Принять всё ({{ resolvedPickupCount }})
</template>
<style scoped lang="scss">
@@ -363,6 +462,40 @@ q-page.reception(role='region', aria-label='Приёмка партии')
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
&__resolve {
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
}
&__resolve-account {
font-size: var(--p-fs-body, 14px);
font-weight: 600;
color: var(--p-ink);
font-family: var(--font-mono);
}
&__resolve-hint {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
}
&__resolve-item {
display: flex;
align-items: center;
gap: var(--p-2, 8px);
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-2);
font-variant-numeric: tabular-nums;
}
&__resolve-actions {
display: flex;
justify-content: flex-end;
gap: var(--p-2, 8px);
margin-top: var(--p-2, 8px);
}
}
@media (max-width: 768px) {
@@ -1,12 +1,15 @@
<script lang="ts" setup>
import type { QTableProps } from 'quasar';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { FailAlert } from 'src/shared/api';
import { useSessionStore } from 'src/entities/Session';
import { BaseButton, BaseDialog, EmptyState } from 'src/shared/ui/base';
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton';
import { HandoffQr } from 'src/widgets/Marketplace/HandoffQr';
import { PageHint } from 'src/shared/ui/domain';
import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import { encodeHandoffToken, HandoffTokenKind } from 'src/shared/lib/marketplace';
import { listMyReadyToReceive, type MarketplaceOrderIssuanceView } from '../api';
/**
@@ -22,9 +25,28 @@ import { listMyReadyToReceive, type MarketplaceOrderIssuanceView } from '../api'
* визуальное продолжение уведомления.
*/
const route = useRoute();
const session = useSessionStore();
const coopname = computed(() => String(route.params.coopname ?? ''));
const items = ref<MarketplaceOrderIssuanceView[]>([]);
const loading = ref(false);
// Story 14.4: один account-bound код получения. Заказчик показывает его
// оператору выдачи — тот резолвит аккаунт против ленты своего КУ и видит разом
// все готовые к выдаче заказы этого заказчика. Код привязан к личности, не к
// заказу: его можно показать заранее или с распечатки.
const myCodeDialogOpen = ref(false);
const myReceiveCode = computed(() =>
session.username
? encodeHandoffToken({
kind: HandoffTokenKind.Receive,
coopname: coopname.value,
account: session.username,
})
: '',
);
function formatDate(value: unknown): string {
if (value === null || value === undefined || value === '') return '—';
const parsed = new Date(String(value));
@@ -92,6 +114,10 @@ onMounted(load);
q-page.ready(role="region", aria-label="Готово к получению")
//- Действие страницы — в шапку, где стоят общие действия (канон Teleport).
Teleport(to="#header-actions-host", defer)
BaseButton(variant="secondary", size="sm", :disabled="!myReceiveCode", @click="myCodeDialogOpen = true")
template(#icon-left)
q-icon(name="qr_code_2", size="16px")
| Мой код получения
RefreshButton(:loading="loading", @refresh="load")
PageHint(storage-key="mp:ready-to-receive:banner-dismissed")
@@ -120,6 +146,13 @@ q-page.ready(role="region", aria-label="Готово к получению")
template(#icon)
q-icon(name="inventory_2", size="48px")
BaseDialog(v-model="myCodeDialogOpen", title="Мой код получения", size="sm")
.ready__qr(v-if="myReceiveCode")
HandoffQr(
:value="myReceiveCode",
caption="Покажите этот код оператору на пункте выдачи — он выдаст разом все ваши готовые заказы. Код можно показать заранее или с распечатки."
)
BaseDialog(v-model="qrDialogOpen", title="QR-код получения заказа", size="sm")
.ready__qr(v-if="qrOrder")
HandoffQr(
@@ -0,0 +1,60 @@
/**
* Эпик 14 / Story 14.314.4: account-bound код передачи на ПВЗ («как в Ozon»).
*
* Код привязан к АККАУНТУ человека, а не к конкретной партии/единице. Один показ
* = оператор видит и принимает/выдаёт разом все ожидаемые единицы этого человека
* на своём КУ (поставщик привёз 10 — показал один код, приняли все 10; заказчик
* пришёл — показал один код, выдали все его готовые заказы). Код детерминирован от
* личности + намерения, поэтому его можно сгенерировать заранее и оффлайн — backend
* токен не минтит.
*
* Привязка к КУ обеспечивается на стороне оператора: он резолвит аккаунт против
* ленты СВОЕГО КУ — у чужого аккаунта на этом КУ просто нет ожидающих единиц.
* coopname в коде отсекает предъявление в чужом кооперативе.
*
* Формат: `mp1:<kind>:<coopname>:<account>`
* - `mp1` — namespace + версия (резерв под mp2 с подписью/одноразовостью);
* - `kind` — `pickup` (поставщик → приёмка) | `receive` (заказчик → выдача);
* - coopname — кооператив, в котором код действителен;
* - account — аккаунт-биндинг (личность, не партия/единица).
*
* Имена аккаунтов/кооперативов в блокчейне состоят из `a–z`, `1–5`, `.` — символа
* `:` в них быть не может, поэтому разделитель однозначен.
*/
export enum HandoffTokenKind {
/** Поставщик → оператор приёмки: «приняли всё, что я привёз». */
Pickup = 'pickup',
/** Заказчик → оператор выдачи: «выдали все мои готовые заказы». */
Receive = 'receive',
}
export interface HandoffToken {
kind: HandoffTokenKind;
coopname: string;
account: string;
}
const PREFIX = 'mp1';
const SEP = ':';
/** Собрать account-bound код для показа в `HandoffQr`. */
export function encodeHandoffToken(token: HandoffToken): string {
return [PREFIX, token.kind, token.coopname, token.account].join(SEP);
}
/**
* Разобрать отсканированный код. Возвращает `null`, если строка — не
* account-bound токен (в т.ч. legacy-QR, несущий сам `shipment_id`/`order_id`):
* вызывающий код тогда падает на прежний путь по id.
*/
export function decodeHandoffToken(raw: string): HandoffToken | null {
const value = raw.trim();
if (!value.startsWith(PREFIX + SEP)) return null;
const parts = value.split(SEP);
if (parts.length !== 4) return null;
const [, kind, coopname, account] = parts;
if (kind !== HandoffTokenKind.Pickup && kind !== HandoffTokenKind.Receive) return null;
if (!coopname || !account) return null;
return { kind: kind as HandoffTokenKind, coopname, account };
}
@@ -0,0 +1,6 @@
export {
HandoffTokenKind,
encodeHandoffToken,
decodeHandoffToken,
type HandoffToken,
} from './handoff-token';