feat(marketplace): стол ПВЗ — слить «Ожидаемые поставки» + «Приёмка» в одну страницу
Оператор не прыгает между «жду» и «принимаю»: единый стол показывает что/когда/кому везут (дата формирования партии + получатель в карточках состава), запуск приёмки — только скан QR/ввод кода, тут же статусы и подпись актов. Удалён избыточный KPI-дашборд incoming-shipments и сама страница (api оставлен). defaultRoute → reception. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
-267
@@ -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';
|
||||
+24
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user