feat(marketplace): стол ПВЗ — слить «Ожидаемые поставки» + «Приёмка» в одну страницу

Оператор не прыгает между «жду» и «принимаю»: единый стол показывает что/когда/кому
везут (дата формирования партии + получатель в карточках состава), запуск приёмки —
только скан QR/ввод кода, тут же статусы и подпись актов. Удалён избыточный
KPI-дашборд incoming-shipments и сама страница (api оставлен). defaultRoute → reception.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-06-01 15:03:45 +00:00
parent 12f645b3b4
commit 8fd251a84d
5 changed files with 32 additions and 291 deletions
@@ -19,7 +19,6 @@ import { AdminWriteoffsPage } from 'src/pages/Marketplace/AdminWriteoffs'
import { ChairmanModerationPage } from 'src/pages/Marketplace/ChairmanModeration'
import { AdminIssuancePointsPage } from 'src/pages/Marketplace/AdminIssuancePoints'
import { OperatorOwnWarehousePage } from 'src/pages/Marketplace/OperatorOwnWarehouse'
import { OperatorIncomingShipmentsPage } from 'src/pages/Marketplace/OperatorIncomingShipments'
import { AdminWarehouseSummaryPage } from 'src/pages/Marketplace/AdminWarehouseSummary'
import { EcosystemRegistryPage } from 'src/pages/Marketplace/EcosystemRegistry'
import { OnboardingCoopAcceptCppPage } from 'src/pages/Marketplace/OnboardingCoopAcceptCpp'
@@ -396,7 +395,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
extension_name: 'market',
title: 'Стол ПВЗ',
icon: 'fa-solid fa-map-location-dot',
defaultRoute: 'marketplace-pvz-incoming-shipments',
defaultRoute: 'marketplace-pvz-reception',
routes: [
{
// Стол ПВЗ — рабочее место оператора КОНКРЕТНОГО кооперативного
@@ -415,28 +414,18 @@ export default async function (): Promise<IWorkspaceConfig[]> {
name: 'market-pvz',
children: [
{
// Поток IV шаг 1: operator-стол «Ожидаемые поставки». Лента партий,
// Поток IV: единый operator-стол «Ожидаемые поставки» — лента партий,
// направленных на КУ оператора (own-KU scoping через
// marketplaceListShipmentsByBraname + isMemberOfBranch).
path: 'incoming-shipments',
name: 'marketplace-pvz-incoming-shipments',
component: markRaw(OperatorIncomingShipmentsPage),
meta: {
title: 'Ожидаемые поставки',
icon: 'fa-solid fa-truck-arrow-right',
requires: 'Warehouse:read:own-KU',
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 5 / Story 5.6: operator-стол приёмки партии на ПВЗ.
// marketplaceListShipmentsByBraname + isMemberOfBranch), их состав
// («что/когда/кому везут») И приёмка по QR-коду на одной странице.
// Отдельный стол «Приёмка партии» слит сюда: оператор не прыгает
// между «жду» и «принимаю» (ревью 2026-06-01).
path: 'reception',
name: 'marketplace-pvz-reception',
component: markRaw(OperatorReceptionPage),
meta: {
title: 'Приёмка партии',
icon: 'fa-solid fa-box-open',
title: 'Ожидаемые поставки',
icon: 'fa-solid fa-truck-arrow-right',
requires: 'Warehouse:read:own-KU',
requiresAuth: true,
agreements: agreementsBase,
@@ -1 +0,0 @@
export { OperatorIncomingShipmentsPage } from './ui';
@@ -1,267 +0,0 @@
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { QTableProps } from 'quasar'
import { FailAlert } from 'src/shared/api'
import { Zeus } from '@coopenomics/sdk'
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits'
import { OperatorBranchBar, useOperatorBranchStore } from 'src/entities/OperatorBranch'
import { BaseBadge, BaseButton, EmptyState } from 'src/shared/ui/base'
import type { BaseBadgeVariant } from 'src/shared/ui/base'
import { PageHint } from 'src/shared/ui/domain'
import { listShipmentsByBraname, type MarketplaceShipmentView } from '../api'
type ShipmentStatus = MarketplaceShipmentView['status']
// Активный КУ оператора — из общего контекста стола (без ввода кода вручную).
const route = useRoute()
const store = useOperatorBranchStore()
const coopname = computed(() => String(route.params.coopname ?? ''))
const braname = computed(() => store.activeBraname ?? '')
const statusFilter = ref<ShipmentStatus[]>([])
const items = ref<MarketplaceShipmentView[]>([])
const loading = ref(false)
// «Ожидаемые» — партии, которые ещё не приняты кооперативом: подготовлены
// поставщиком (в пути) или уже на стадии приёмки.
const EXPECTED_STATUSES: ShipmentStatus[] = [
Zeus.MarketplaceShipmentStatus.SUPPLY_PREPARED,
Zeus.MarketplaceShipmentStatus.RECEPTION_IN_PROGRESS,
]
const statusOptions: { label: string; value: ShipmentStatus }[] = [
{ label: 'Готовится поставщиком', value: Zeus.MarketplaceShipmentStatus.DRAFT },
{ label: 'Готова к приёмке', value: Zeus.MarketplaceShipmentStatus.SUPPLY_PREPARED },
{ label: 'Идёт приёмка', value: Zeus.MarketplaceShipmentStatus.RECEPTION_IN_PROGRESS },
{ label: 'Принята кооперативом', value: Zeus.MarketplaceShipmentStatus.ACCEPTED_TO_COOP },
{ label: 'Отменена', value: Zeus.MarketplaceShipmentStatus.CANCELLED },
]
function isStatusActive(value: ShipmentStatus): boolean {
return statusFilter.value.includes(value)
}
function toggleStatus(value: ShipmentStatus): void {
statusFilter.value = isStatusActive(value)
? statusFilter.value.filter((s) => s !== value)
: [...statusFilter.value, value]
}
const DELIVERY_LABEL: Record<string, string> = {
A: 'Поставщик лично',
B: 'Экспедитор по ТТН',
}
const columns: QTableProps['columns'] = [
{ name: 'id', label: 'Партия', field: (r) => String(r.id).slice(0, 8), align: 'left', sortable: true },
{ name: 'offerer', label: 'Поставщик', field: 'offerer_account', align: 'left', sortable: true },
{ name: 'delivery', label: 'Доставка', field: (r) => DELIVERY_LABEL[r.delivery_variant] ?? r.delivery_variant, align: 'left', sortable: true },
{ name: 'amount', label: 'Сумма', field: 'total_amount', align: 'right', sortable: true },
{ name: 'status', label: 'Состояние', field: 'status', align: 'left', sortable: true },
{ name: 'created_at', label: 'Сформирована', field: 'created_at', align: 'left', sortable: true },
]
const filteredRows = computed(() => {
if (!statusFilter.value.length) return items.value
return items.value.filter((row) => statusFilter.value.includes(row.status))
})
const summary = computed(() => {
const byStatus: Record<string, number> = {}
for (const row of items.value) {
byStatus[row.status] = (byStatus[row.status] ?? 0) + 1
}
const expected = EXPECTED_STATUSES.reduce((sum, s) => sum + (byStatus[s] ?? 0), 0)
return { byStatus, expected }
})
async function load(): Promise<void> {
const name = braname.value?.trim()
if (!name) {
items.value = []
return
}
loading.value = true
try {
items.value = await listShipmentsByBraname({ braname: name })
} catch (e) {
FailAlert(e, 'Не удалось загрузить ожидаемые поставки')
} finally {
loading.value = false
}
}
watch(braname, () => void load())
onMounted(async () => {
await store.ensureLoaded(coopname.value)
void load()
})
function humanStatus(status: string): string {
return statusOptions.find((o) => o.value === status)?.label ?? status
}
function statusVariant(status: string): BaseBadgeVariant {
switch (status) {
case Zeus.MarketplaceShipmentStatus.SUPPLY_PREPARED:
return 'info'
case Zeus.MarketplaceShipmentStatus.RECEPTION_IN_PROGRESS:
return 'warn'
case Zeus.MarketplaceShipmentStatus.ACCEPTED_TO_COOP:
return 'pos'
case Zeus.MarketplaceShipmentStatus.CANCELLED:
return 'neg'
default:
return 'neutral'
}
}
function formatAmount(value: unknown): string {
if (value === null || value === undefined) return '—'
return formatAsset2Digits(String(value))
}
function formatDateTime(value: unknown): string {
if (value === null || value === undefined) return '—'
const parsed = new Date(String(value))
if (Number.isNaN(parsed.getTime())) return '—'
return parsed.toLocaleString('ru-RU')
}
</script>
<template lang="pug">
q-page.shipments(role='region', aria-label='Ожидаемые поставки')
OperatorBranchBar
EmptyState(
v-if='!store.loading && !store.isOperator',
title='Вы не оператор кооперативного участка',
body='Ожидаемые поставки доступны председателю участка и его доверенным лицам.'
)
template(#icon)
q-icon(name='storefront', size='48px')
template(v-else)
PageHint(storage-key='mp:operator-shipments:banner-dismissed')
| Партии поставщиков, направленные на ваш пункт выдачи. Дождитесь состояния
| «Готова к приёмке» и открывайте акт на столе «Приёмка партии».
.shipments__filters
.shipments__chips
.chip(
v-for='opt in statusOptions',
:key='opt.value',
:class='isStatusActive(opt.value) ? "chip--accent" : "chip--neutral"',
role='button',
tabindex='0',
@click='toggleStatus(opt.value)',
@keydown.enter='toggleStatus(opt.value)'
) {{ opt.label }}
BaseButton.shipments__refresh(
variant='ghost',
icon-only,
aria-label='Обновить',
:loading='loading',
@click='load'
)
template(#icon-left)
q-icon(name='refresh', size='20px')
.shipments__stats(v-if='items.length')
.kpi.kpi--accent
.kpi__head
span.kpi__eyebrow Ожидают приёмки
.kpi__val {{ summary.expected }}
.kpi(v-for='(count, status) in summary.byStatus', :key='status')
.kpi__head
span.kpi__eyebrow {{ humanStatus(status) }}
.kpi__val {{ count }}
q-table.shipments__table(
:rows='filteredRows',
:columns='columns',
row-key='id',
:loading='loading',
:pagination='{ rowsPerPage: 25, sortBy: "created_at", descending: true }',
:rows-per-page-options='[25, 50, 100, 0]',
flat,
bordered,
binary-state-sort
)
template(#body-cell-status='props')
q-td(:props='props')
BaseBadge(:variant='statusVariant(props.row.status)') {{ humanStatus(props.row.status) }}
template(#body-cell-amount='props')
q-td(:props='props') {{ formatAmount(props.row.total_amount) }}
template(#body-cell-created_at='props')
q-td(:props='props') {{ formatDateTime(props.row.created_at) }}
template(#no-data)
.shipments__nodata
EmptyState(
title='Ожидаемых поставок нет',
body='Партии поставщиков, направленные на ваш участок, появятся здесь. Проверьте фильтры состояния.'
)
template(#icon)
q-icon(name='local_shipping', size='48px')
</template>
<style scoped lang="scss">
.shipments {
padding: var(--p-6, 24px);
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
&__filters {
display: flex;
align-items: center;
gap: var(--p-3, 12px);
flex-wrap: wrap;
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: var(--p-2, 8px);
.chip {
cursor: pointer;
user-select: none;
height: 28px;
padding: 0 12px;
}
}
&__refresh {
margin-left: auto;
}
&__stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--p-3, 12px);
}
// #no-data слот q-table выравнивает контент влево — центрируем EmptyState
// по горизонтали.
&__nodata {
width: 100%;
display: flex;
justify-content: center;
}
}
@media (max-width: 768px) {
.shipments {
padding: var(--p-4, 16px);
&__refresh {
margin-left: 0;
}
}
}
</style>
@@ -1 +0,0 @@
export { default as OperatorIncomingShipmentsPage } from './OperatorIncomingShipmentsPage.vue';
@@ -114,6 +114,20 @@ function statusVariant(v: string): BaseBadgeVariant {
return RECEPTION_STATUS_VARIANT[v] ?? 'neutral';
}
// Когда сформирована партия — оператор видит дату/время, чтобы прикинуть приёмку
// и заранее подготовить место на складе под скоропорт.
function formatDate(value: unknown): string {
if (value === null || value === undefined) return '—';
const parsed = new Date(String(value));
if (Number.isNaN(parsed.getTime())) return '—';
return parsed.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
const columns: QTableProps['columns'] = [
{ name: 'id', label: 'АПП', field: (r: MarketplaceAplReceptionView) => r.id.slice(0, 8), align: 'left' },
{ name: 'variant', label: 'Вариант', field: 'variant', align: 'center', format: (v: string) => RECEPTION_VARIANT_LABEL[v] ?? v },
@@ -434,7 +448,7 @@ onMounted(async () => {
</script>
<template lang="pug">
q-page.reception(role='region', aria-label='Приёмка партии')
q-page.reception(role='region', aria-label='Ожидаемые поставки и приёмка')
OperatorBranchBar
EmptyState(
@@ -455,7 +469,8 @@ q-page.reception(role='region', aria-label='Приёмка партии')
| Сканировать QR
PageHint(storage-key='mp:operator-reception:banner-dismissed')
| Партии, прибывшие на ваш пункт выдачи, и их состав ниже. Приёмка
| Что везут на ваш пункт, когда сформирована партия и кому пойдёт каждая
| позиция всё ниже, чтобы заранее подготовить место на складе. Приёмка
| запускается ТОЛЬКО сканированием QR поставщика (или вводом кода)
| кнопка «Сканировать QR» в шапке. Это идентификация: без кода принять
| нельзя, даже если знаете человека в лицо. Затем сверьте факт по
@@ -473,11 +488,12 @@ q-page.reception(role='region', aria-label='Приёмка партии')
.reception__ship-info
.reception__ship-offerer {{ s.offerer_account }}
.reception__ship-meta
| {{ SHIPMENT_VARIANT_LABEL[s.delivery_variant] ?? s.delivery_variant }} · {{ formatAsset2Digits(s.total_amount) }}
| {{ SHIPMENT_VARIANT_LABEL[s.delivery_variant] ?? s.delivery_variant }} · {{ formatAsset2Digits(s.total_amount) }} · сформирована {{ formatDate(s.created_at) }}
template(v-if='s.ttn_number') · ТТН {{ s.ttn_number }}
ul.reception__contents(v-if='shipmentContents(s).length')
li.reception__content-line(v-for='o in shipmentContents(s)', :key='o.id')
| {{ o.product_name || 'Товар по предложению' }} {{ o.quantity }} {{ marketplaceUnitShort(o.unit_of_measure) }}
span.reception__content-to · кому: {{ o.orderer_name || o.orderer_account }}
//- Story 14.2: самовывоз по факту — поставщик приехал без заранее
//- сформированной партии. Тоже только через скан QR/ввод кода.
@@ -493,6 +509,7 @@ q-page.reception(role='region', aria-label='Приёмка партии')
ul.reception__contents(v-if='expressContents(c).length')
li.reception__content-line(v-for='o in expressContents(c)', :key='o.id')
| {{ o.product_name || 'Товар по предложению' }} {{ o.quantity }} {{ marketplaceUnitShort(o.unit_of_measure) }}
span.reception__content-to · кому: {{ o.orderer_name || o.orderer_account }}
q-table.reception__table(
:rows='items',
@@ -693,6 +710,10 @@ q-page.reception(role='region', aria-label='Приёмка партии')
font-variant-numeric: tabular-nums;
}
&__content-to {
color: var(--p-ink-3);
}
&__pickup {
display: flex;
flex-direction: column;