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:
+35
-2
@@ -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(
|
||||
|
||||
+131
-6
@@ -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) {
|
||||
|
||||
+135
-2
@@ -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) {
|
||||
|
||||
+34
-1
@@ -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.3–14.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';
|
||||
Reference in New Issue
Block a user