feat(marketplace): стол ПВЗ — склад/приёмка/раскладка под приёмочную модель

Десктоп к приёмочной модели склада (Путь A):

- Приёмка партии: убраны кнопки-лаунчеры («Принять партию», «Принять
  самовывоз»). Приёмка запускается ТОЛЬКО сканом QR/вводом кода (идентификация
  обязательна). Ожидающие партии и самовывоз показаны карточками со составом
  («что везут» — товар + кол-во) без проваливания.
- «Маркировка имущества» → «Раскладка и маркировка»: список принятых позиций
  склада; на строке — полка (свободный input), «Разложить» (split одной позиции
  по нескольким полкам), «Этикетка» (опциональный штрих-код). Снесены выбор
  партии и стратегии PER_ORDER/UNIT/PACKAGE. Печать этикеток сохранена.
- Склад моего КУ: показывает всё принятое (статус RECEIVED «Принято»), колонка
  «Полка», штрих-код опционален (—), сортировка/возраст по received_at.

SDK-обёртки inventory перегенерированы под новые мутации.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-06-01 11:55:11 +00:00
parent b95a40d232
commit 12f645b3b4
5 changed files with 396 additions and 308 deletions
@@ -443,12 +443,13 @@ export default async function (): Promise<IWorkspaceConfig[]> {
},
},
{
// Эпик 5 / Story 5.8 + Эпик 6: operator-стол маркировки имущества.
// Стол раскладки/маркировки: принятое имущество (склад) — назначить
// полку, разложить позицию по нескольким полкам, наклеить штрих-код.
path: 'labeling',
name: 'marketplace-pvz-labeling',
component: markRaw(OperatorInventoryLabelingPage),
meta: {
title: 'Маркировка имущества',
title: 'Раскладка и маркировка',
icon: 'fa-solid fa-tag',
requires: 'Warehouse:read:own-KU',
requiresAuth: true,
@@ -1,27 +1,16 @@
import { Mutations, Queries, Zeus } from '@coopenomics/sdk'
import { Mutations, Queries } from '@coopenomics/sdk'
import { client } from 'src/shared/api/client'
export interface MarketplaceOrderForLabeling {
id: string;
quantity: number;
offer_id: string;
orderer_account: string;
delivery_braname: string;
status: string;
}
// Zeus маппит скаляр ID в `unknown`; идентификатор переопределяем на строку
// (используется как :key и для slice в UI).
type _RawInventoryItem =
Queries.Marketplace.ListInventory.IOutput['marketplaceListInventory'][number];
export type MarketplaceInventoryItemView = Omit<_RawInventoryItem, 'id'> & { id: string };
Queries.Marketplace.ListInventory.IOutput['marketplaceListInventory'][number]
export type MarketplaceInventoryItemView = Omit<_RawInventoryItem, 'id'> & { id: string }
type _RawShipment =
Queries.Marketplace.ListShipments.IOutput['marketplaceListShipments'][number];
export type MarketplaceShipmentView = Omit<_RawShipment, 'id'> & { id: string };
export type MarketplaceLabelShipmentInventoryResultView =
Mutations.Marketplace.LabelShipmentInventory.IOutput['marketplaceLabelShipmentInventory'];
export type IListInventoryInput = Queries.Marketplace.ListInventory.IInput['data']
export type IAssignShelfInput = Mutations.Marketplace.AssignInventoryShelf.IInput['data']
export type ISplitInventoryInput = Mutations.Marketplace.SplitInventory.IInput['data']
export type IGenerateLabelInput = Mutations.Marketplace.GenerateInventoryLabel.IInput['data']
export async function fetchInventoryByBraname(braname: string): Promise<MarketplaceInventoryItemView[]> {
const { [Queries.Marketplace.ListInventory.name]: result } = await client.Query(
@@ -32,40 +21,32 @@ export async function fetchInventoryByBraname(braname: string): Promise<Marketpl
return result as MarketplaceInventoryItemView[]
}
const SHIPMENT_STATUSES_FOR_LABELING: Zeus.MarketplaceShipmentStatus[] = [
Zeus.MarketplaceShipmentStatus.SUPPLY_PREPARED,
Zeus.MarketplaceShipmentStatus.RECEPTION_IN_PROGRESS,
]
export async function fetchShipmentsForLabeling(braname?: string): Promise<MarketplaceShipmentView[]> {
const data: Queries.Marketplace.ListShipments.IInput['data'] = {
statuses: SHIPMENT_STATUSES_FOR_LABELING,
}
if (braname) data.braname = braname
const { [Queries.Marketplace.ListShipments.name]: result } = await client.Query(
Queries.Marketplace.ListShipments.query,
export async function assignInventoryShelf(
data: IAssignShelfInput,
): Promise<MarketplaceInventoryItemView[]> {
const { [Mutations.Marketplace.AssignInventoryShelf.name]: result } = await client.Mutation(
Mutations.Marketplace.AssignInventoryShelf.mutation,
{ variables: { data } },
)
// Zeus отдаёт ID как unknown; сужаем идентификатор до строки во view-типе.
return result as MarketplaceShipmentView[]
return result.inventory as MarketplaceInventoryItemView[]
}
export async function labelInventory(
data: Mutations.Marketplace.LabelInventory.IInput['data'],
): Promise<Mutations.Marketplace.LabelInventory.IOutput['marketplaceLabelInventory']> {
const { [Mutations.Marketplace.LabelInventory.name]: result } = await client.Mutation(
Mutations.Marketplace.LabelInventory.mutation,
export async function splitInventory(
data: ISplitInventoryInput,
): Promise<MarketplaceInventoryItemView[]> {
const { [Mutations.Marketplace.SplitInventory.name]: result } = await client.Mutation(
Mutations.Marketplace.SplitInventory.mutation,
{ variables: { data } },
)
return result
return result.inventory as MarketplaceInventoryItemView[]
}
export async function labelShipmentInventory(
data: Mutations.Marketplace.LabelShipmentInventory.IInput['data'],
): Promise<MarketplaceLabelShipmentInventoryResultView> {
const { [Mutations.Marketplace.LabelShipmentInventory.name]: result } = await client.Mutation(
Mutations.Marketplace.LabelShipmentInventory.mutation,
export async function generateInventoryLabel(
data: IGenerateLabelInput,
): Promise<MarketplaceInventoryItemView[]> {
const { [Mutations.Marketplace.GenerateInventoryLabel.name]: result } = await client.Mutation(
Mutations.Marketplace.GenerateInventoryLabel.mutation,
{ variables: { data } },
)
return result
return result.inventory as MarketplaceInventoryItemView[]
}
@@ -1,177 +1,149 @@
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Loading } from 'quasar'
import { Zeus } from '@coopenomics/sdk'
import { SuccessAlert, FailAlert } from 'src/shared/api'
import { OperatorBranchBar, useOperatorBranchStore } from 'src/entities/OperatorBranch'
import { BarcodeDisplay } from 'src/widgets/Marketplace/BarcodeDisplay'
import { BaseButton, BaseCard, BaseInput, BaseSelect, EmptyState } from 'src/shared/ui/base'
import type { BaseSelectOption } from 'src/shared/ui/base'
import { BaseBadge, BaseButton, BaseDialog, BaseInput, EmptyState } from 'src/shared/ui/base'
import { PageHint } from 'src/shared/ui/domain'
import { Zeus } from '@coopenomics/sdk'
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton'
import {
assignInventoryShelf,
fetchInventoryByBraname,
fetchShipmentsForLabeling,
labelInventory,
labelShipmentInventory,
generateInventoryLabel,
splitInventory,
type MarketplaceInventoryItemView,
type MarketplaceShipmentView,
} from '../api'
type LabelingMode = 'PER_ORDER' | 'BATCH'
type BarcodeStrategy = Zeus.MarketplaceBarcodeStrategy
type BarcodeFormat = Zeus.MarketplaceBarcodeFormat
/**
* Стол ПВЗ, «Раскладка и маркировка». Имущество попадает на склад на приёмке
* (статус RECEIVED) — здесь оператор организует его физически: назначает полку
* (свободная строка), при необходимости раскладывает одну принятую позицию по
* нескольким полкам (split), и опционально наклеивает штрих-код для быстрого
* поиска. Штрих-код не обязателен — склад работает и без него (один холодильник).
*/
// Активный КУ оператора — из общего контекста стола (без ввода кода вручную).
const route = useRoute()
const store = useOperatorBranchStore()
const coopname = computed(() => String(route.params.coopname ?? ''))
const braname = computed(() => store.activeBraname ?? '')
const items = ref<MarketplaceInventoryItemView[]>([])
const loading = ref<boolean>(false)
const loading = ref(false)
const mode = ref<LabelingMode>('BATCH')
const modeOptions: { label: string; value: LabelingMode }[] = [
{ label: 'Маркировка партии целиком', value: 'BATCH' },
{ label: 'Поштучно по заказу', value: 'PER_ORDER' },
]
// Сначала непромаркированные/без полки (нужно разложить), затем остальное.
const RECEIVED = Zeus.MarketplaceInventoryStatus.RECEIVED
const LABELED = Zeus.MarketplaceInventoryStatus.LABELED
const orderIdInput = ref<string>('')
const shipments = ref<MarketplaceShipmentView[]>([])
const selectedShipmentId = ref<string | null>(null)
const defaultStrategy = ref<BarcodeStrategy | null>(null)
// EAN-13 — стандарт маркировки маркетплейса (UX-DR11/DR12), не QR/CODE128.
const defaultFormat = ref<BarcodeFormat>(Zeus.MarketplaceBarcodeFormat.EAN13)
const lastBatchSummary = ref<{ labeled: number; skipped: number } | null>(null)
// «Стратегия по умолчанию» = null (берётся из карточки товара). BaseSelect не
// принимает null в value опций, поэтому используем строковый sentinel и
// мапим его обратно в null через computed-прокси.
const STRATEGY_DEFAULT = '__default__'
const strategyOptions: BaseSelectOption[] = [
{ label: 'По умолчанию из карточки товара', value: STRATEGY_DEFAULT },
{ label: 'Одна этикетка на весь заказ', value: Zeus.MarketplaceBarcodeStrategy.PER_ORDER },
{ label: 'Отдельная этикетка на каждую единицу', value: Zeus.MarketplaceBarcodeStrategy.PER_UNIT },
{ label: 'Этикетка на упаковку', value: Zeus.MarketplaceBarcodeStrategy.PER_PACKAGE },
]
const formatOptions: BaseSelectOption[] = [
{ label: 'CODE128', value: Zeus.MarketplaceBarcodeFormat.CODE128 },
{ label: 'EAN13', value: Zeus.MarketplaceBarcodeFormat.EAN13 },
]
const shipmentOptions = computed<BaseSelectOption[]>(() =>
shipments.value.map((s) => ({
label: `${s.id.slice(0, 8)} · ${s.braname} · ${s.delivery_variant} · ${s.status}`,
value: s.id,
})),
const onWarehouse = computed(() =>
[...items.value]
.filter((i) => i.status === RECEIVED || i.status === LABELED)
.sort((a, b) => {
// Сперва то, чем ещё нужно заняться: без полки, затем без штрих-кода.
const aw = (a.shelf ? 0 : 2) + (a.barcode_value ? 0 : 1)
const bw = (b.shelf ? 0 : 2) + (b.barcode_value ? 0 : 1)
return bw - aw
}),
)
const shipmentModel = computed<string>({
get: () => selectedShipmentId.value ?? '',
set: (v) => {
selectedShipmentId.value = v || null
},
})
const labeledItems = computed(() => items.value.filter((i) => !!i.barcode_value))
// Допустимые значения enum'ов — для type-guard без каста при приёме строки из
// BaseSelect (sentinel STRATEGY_DEFAULT в список не входит → маппится в null).
const STRATEGY_VALUES: readonly BarcodeStrategy[] = [
Zeus.MarketplaceBarcodeStrategy.PER_ORDER,
Zeus.MarketplaceBarcodeStrategy.PER_UNIT,
Zeus.MarketplaceBarcodeStrategy.PER_PACKAGE,
]
function isStrategy(v: string): v is BarcodeStrategy {
return STRATEGY_VALUES.some((s) => s === v)
}
// Черновик полки на строку — чтобы сохранять только по явному действию.
const shelfDraft = reactive<Record<string, string>>({})
const FORMAT_VALUES: readonly BarcodeFormat[] = [
Zeus.MarketplaceBarcodeFormat.CODE128,
Zeus.MarketplaceBarcodeFormat.EAN13,
]
function isFormat(v: string): v is BarcodeFormat {
return FORMAT_VALUES.some((f) => f === v)
}
const strategyModel = computed<string>({
get: () => defaultStrategy.value ?? STRATEGY_DEFAULT,
set: (v) => {
defaultStrategy.value = isStrategy(v) ? v : null
},
})
const formatModel = computed<string>({
get: () => defaultFormat.value,
set: (v) => {
if (isFormat(v)) defaultFormat.value = v
},
})
async function loadInventory(): Promise<void> {
if (!braname.value.trim()) return
async function load(): Promise<void> {
if (!braname.value.trim()) {
items.value = []
return
}
loading.value = true
try {
items.value = await fetchInventoryByBraname(braname.value.trim())
for (const i of items.value) shelfDraft[i.id] = i.shelf ?? ''
} catch (e) {
FailAlert(e, 'Не удалось загрузить инвентарь')
FailAlert(e, 'Не удалось загрузить склад участка')
} finally {
loading.value = false
}
}
async function loadShipments(): Promise<void> {
async function saveShelf(item: MarketplaceInventoryItemView): Promise<void> {
const next = (shelfDraft[item.id] ?? '').trim()
if (next === (item.shelf ?? '')) return
try {
shipments.value = await fetchShipmentsForLabeling(braname.value.trim() || undefined)
await assignInventoryShelf({ inventory_id: item.id, shelf: next || null })
SuccessAlert(next ? `Полка «${next}» сохранена` : 'Полка очищена')
await load()
} catch (e) {
FailAlert(e, 'Не удалось загрузить список партий')
FailAlert(e, 'Не удалось сохранить полку')
}
}
async function generateLabelsPerOrder(): Promise<void> {
if (!orderIdInput.value.trim()) {
FailAlert(new Error('Укажите идентификатор заказа.'))
return
}
Loading.show({ message: 'Генерирую этикетки…' })
async function makeLabel(item: MarketplaceInventoryItemView): Promise<void> {
Loading.show({ message: 'Генерирую этикетку…' })
try {
const result = await labelInventory({ order_id: orderIdInput.value.trim() })
SuccessAlert(`Сгенерировано ${result.inventory.length} этикеток — можно печатать`)
orderIdInput.value = ''
await loadInventory()
await generateInventoryLabel({ inventory_id: item.id })
SuccessAlert('Этикетка сгенерирована — можно печатать')
await load()
} catch (e) {
FailAlert(e, 'Не удалось сгенерировать этикетки')
FailAlert(e, 'Не удалось сгенерировать этикетку')
} finally {
Loading.hide()
}
}
async function generateLabelsForShipment(): Promise<void> {
if (!selectedShipmentId.value) {
FailAlert(new Error('Выберите партию для маркировки.'))
return
}
Loading.show({ message: 'Маркирую партию…' })
// ── Раскладка по полкам (split) ──
const splitDialogOpen = ref(false)
const splitTarget = ref<MarketplaceInventoryItemView | null>(null)
const splitRows = ref<{ quantity: number | null; shelf: string }[]>([])
const splitTotal = computed(() =>
splitRows.value.reduce((a, r) => a + (Number(r.quantity) || 0), 0),
)
const splitValid = computed(
() =>
!!splitTarget.value &&
splitRows.value.length >= 1 &&
splitRows.value.every((r) => Number(r.quantity) > 0) &&
splitTotal.value === splitTarget.value.quantity_per_label,
)
function openSplit(item: MarketplaceInventoryItemView): void {
splitTarget.value = item
// Предзаполняем двумя долями: текущая полка + пустая.
splitRows.value = [
{ quantity: item.quantity_per_label, shelf: item.shelf ?? '' },
{ quantity: null, shelf: '' },
]
splitDialogOpen.value = true
}
function addSplitRow(): void {
splitRows.value.push({ quantity: null, shelf: '' })
}
function removeSplitRow(idx: number): void {
splitRows.value.splice(idx, 1)
}
async function applySplit(): Promise<void> {
if (!splitTarget.value || !splitValid.value) return
const target = splitTarget.value
splitDialogOpen.value = false
Loading.show({ message: 'Раскладываю по полкам…' })
try {
const result = await labelShipmentInventory({
shipment_id: selectedShipmentId.value,
default_strategy: defaultStrategy.value ?? undefined,
format: defaultFormat.value,
await splitInventory({
inventory_id: target.id,
splits: splitRows.value.map((r) => ({
quantity: Number(r.quantity),
shelf: r.shelf.trim() || null,
})),
})
lastBatchSummary.value = {
labeled: result.labeled_order_ids.length,
skipped: result.skipped_order_ids.length,
}
const skipNote = result.skipped_order_ids.length
? ` Пропущено уже промаркированных: ${result.skipped_order_ids.length}.`
: ''
SuccessAlert(
`Партия промаркирована. Заказов в работе: ${result.labeled_order_ids.length}.${skipNote} Готовы к печати ${result.inventory.length} этикеток.`,
)
await loadInventory()
SuccessAlert(`Позиция разложена на ${splitRows.value.length} полок(и)`)
await load()
} catch (e) {
FailAlert(e, 'Не удалось промаркировать партию')
FailAlert(e, 'Не удалось разложить позицию')
} finally {
Loading.hide()
}
@@ -181,153 +153,228 @@ function openPrintWindow(): void {
window.print()
}
watch(braname, () => {
void loadInventory()
void loadShipments()
})
watch(braname, () => void load())
onMounted(async () => {
await store.ensureLoaded(coopname.value)
await loadShipments()
await loadInventory()
void load()
})
</script>
<template lang="pug">
q-page.labeling(role='region', aria-label='Маркировка имущества')
q-page.place(role='region', aria-label='Раскладка и маркировка')
OperatorBranchBar
EmptyState(
v-if='!store.loading && !store.isOperator',
title='Вы не оператор кооперативного участка',
body='Маркировка имущества доступна председателю участка и его доверенным лицам.'
body='Раскладка имущества доступна председателю участка и его доверенным лицам.'
)
template(#icon)
q-icon(name='storefront', size='48px')
template(v-else)
Teleport(to="#header-actions-host", defer)
BaseButton(variant='secondary', size='sm', :disabled='!labeledItems.length', @click='openPrintWindow')
template(#icon-left)
q-icon(name='print', size='16px')
| Печать этикеток
RefreshButton(:loading='loading', @refresh='load')
PageHint.no-print(storage-key='mp:operator-labeling:banner-dismissed')
| Сгенерируйте штрих-коды для прибывших заказов и распечатайте этикетки склада участка.
.labeling__toolbar.no-print
q-btn-toggle(
v-model='mode',
no-caps,
unelevated,
toggle-color='primary',
:options='modeOptions'
)
q-space
BaseButton(variant='ghost', @click='loadShipments')
template(#icon-left)
q-icon(name='refresh', size='18px')
| Обновить партии
BaseButton(variant='secondary', :disabled='!items.length', @click='openPrintWindow')
template(#icon-left)
q-icon(name='print', size='18px')
| Печать
BaseCard.labeling__panel.no-print(
v-if='mode === "BATCH"',
title='Маркировка партии целиком'
)
.t-muted.labeling__hint
| Выберите партию поставки и стратегию по умолчанию. Сервер пройдёт по всем
| заказам партии и сгенерирует этикетки; уже промаркированные пропускаются.
.labeling__form-row
BaseSelect.labeling__field(
v-model='shipmentModel',
label='Партия поставки',
:options='shipmentOptions',
:disabled='!shipmentOptions.length'
)
BaseSelect.labeling__field(
v-model='strategyModel',
label='Стратегия маркировки',
:options='strategyOptions'
)
BaseSelect.labeling__field(
v-model='formatModel',
label='Формат штрих-кода',
:options='formatOptions'
)
.labeling__actions
BaseButton(variant='primary', :disabled='!selectedShipmentId', @click='generateLabelsForShipment')
template(#icon-left)
q-icon(name='qr_code_2', size='16px')
| Промаркировать партию
span.t-muted(v-if='lastBatchSummary')
| Последний прогон: промаркировано {{ lastBatchSummary.labeled }}, пропущено {{ lastBatchSummary.skipped }}.
span.t-muted(v-else)
| Стратегия по умолчанию переопределяет настройку из карточки товара только если выбрана явно.
BaseCard.labeling__panel.no-print(v-else, title='Маркировка одного заказа')
.labeling__form-row
BaseInput.labeling__field(
v-model='orderIdInput',
label='Идентификатор заказа',
placeholder='order_id',
mono
)
BaseButton(variant='primary', @click='generateLabelsPerOrder')
template(#icon-left)
q-icon(name='qr_code_2', size='16px')
| Сгенерировать этикетки
.t-muted
| Стратегия маркировки берётся из карточки товара поставщика.
| Принятое имущество лежит на складе сразу. Назначьте каждой позиции полку
| (свободная строка), при необходимости разложите одну позицию по нескольким
| полкам и по желанию наклейте штрих-код для быстрого поиска. Штрих-код
| не обязателен: если у вас один холодильник, можно обойтись без него.
EmptyState.no-print(
v-if='!items.length',
title='Этикеток пока нет',
body='Промаркируйте партию или отдельный заказ — этикетки появятся ниже и будут готовы к печати.'
v-if='!onWarehouse.length',
title='На складе пусто',
body='Здесь появятся принятые позиции — после приёмки партии на столе «Приёмка партии».'
)
template(#icon)
q-icon(name='qr_code_2', size='48px')
q-icon(name='inventory_2', size='48px')
.labeling__grid(v-else)
.labeling__label(v-for='item in items', :key='item.id')
.labeling__label-name.ellipsis {{ item.product_name_snapshot }}
.labeling__label-meta.ellipsis
.place__list.no-print(v-else)
.place__item(v-for='item in onWarehouse', :key='item.id')
.place__item-info
.place__item-name {{ item.product_name_snapshot || 'Товар по предложению' }}
.place__item-meta
| Принято {{ item.quantity_per_label }} ед. · {{ item.orderer_account_snapshot }}
BaseBadge(v-if='item.barcode_value', variant='pos') Промаркировано
BaseBadge(v-else, variant='neutral') Без штрих-кода
.place__item-controls
BaseInput.place__shelf(
v-model='shelfDraft[item.id]',
label='Полка',
placeholder='A-12',
dense,
@blur='saveShelf(item)',
@keydown.enter='saveShelf(item)'
)
BaseButton(
variant='ghost',
size='sm',
:disabled='!!item.barcode_value || item.quantity_per_label < 2',
@click='openSplit(item)'
)
template(#icon-left)
q-icon(name='call_split', size='16px')
| Разложить
BaseButton(
v-if='!item.barcode_value',
variant='primary',
size='sm',
@click='makeLabel(item)'
)
template(#icon-left)
q-icon(name='qr_code_2', size='16px')
| Этикетка
//- Печатная раскладка этикеток — только промаркированные позиции.
.place__grid(v-if='labeledItems.length')
.place__label(v-for='item in labeledItems', :key='item.id')
.place__label-name.ellipsis {{ item.product_name_snapshot }}
.place__label-meta.ellipsis
| Пайщик: {{ item.orderer_account_snapshot }} · Кол-во: {{ item.quantity_per_label }}
BarcodeDisplay(:code='item.barcode_value', size='md')
template(v-if='item.shelf') · Полка: {{ item.shelf }}
BarcodeDisplay(v-if='item.barcode_value', :code='item.barcode_value', size='md')
BaseDialog(v-model='splitDialogOpen', title='Разложить по полкам', size='md')
.place__split(v-if='splitTarget')
.place__split-head
| {{ splitTarget.product_name_snapshot || 'Товар' }} всего {{ splitTarget.quantity_per_label }} ед.
.place__split-row(v-for='(row, idx) in splitRows', :key='idx')
BaseInput.place__split-qty(
v-model.number='row.quantity',
type='number',
label='Кол-во',
dense
)
BaseInput.place__split-shelf(
v-model='row.shelf',
label='Полка',
placeholder='A-12',
dense
)
BaseButton(
variant='ghost',
size='sm',
icon-only,
:disabled='splitRows.length <= 1',
aria-label='Удалить долю',
@click='removeSplitRow(idx)'
)
template(#icon-left)
q-icon(name='close', size='16px')
.place__split-foot
BaseButton(variant='ghost', size='sm', @click='addSplitRow')
template(#icon-left)
q-icon(name='add', size='16px')
| Ещё полка
span.place__split-total(:class='{ "place__split-total--bad": splitTotal !== splitTarget.quantity_per_label }')
| Сумма: {{ splitTotal }} / {{ splitTarget.quantity_per_label }}
template(#footer)
BaseButton(variant='ghost', size='sm', @click='splitDialogOpen = false') Отмена
BaseButton(variant='primary', size='sm', :disabled='!splitValid', @click='applySplit') Разложить
</template>
<style scoped lang="scss">
.labeling {
.place {
padding: var(--p-6, 24px);
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
&__toolbar {
&__list {
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
}
&__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--p-3, 12px);
flex-wrap: wrap;
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
background: var(--p-surface);
padding: var(--p-3, 12px) var(--p-4, 16px);
}
&__hint {
margin-bottom: var(--p-3, 12px);
}
&__form-row {
&__item-info {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--p-1, 4px);
}
&__item-name {
font-size: var(--p-fs-body, 14px);
font-weight: 600;
color: var(--p-ink);
}
&__item-meta {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-2);
font-variant-numeric: tabular-nums;
}
&__item-controls {
display: flex;
gap: var(--p-3, 12px);
flex-wrap: wrap;
align-items: flex-end;
gap: var(--p-2, 8px);
flex-wrap: wrap;
}
&__field {
flex: 1 1 240px;
min-width: 200px;
&__shelf {
width: 120px;
}
&__actions {
&__split {
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
}
&__split-head {
font-size: var(--p-fs-body, 14px);
font-weight: 600;
color: var(--p-ink);
}
&__split-row {
display: flex;
align-items: flex-end;
gap: var(--p-2, 8px);
}
&__split-qty {
width: 110px;
}
&__split-shelf {
flex: 1 1 auto;
}
&__split-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--p-3, 12px);
flex-wrap: wrap;
margin-top: var(--p-3, 12px);
}
&__split-total {
font-variant-numeric: tabular-nums;
color: var(--p-ink-2);
&--bad {
color: var(--p-danger, #b3261e);
font-weight: 600;
}
}
&__grid {
@@ -356,7 +403,7 @@ q-page.labeling(role='region', aria-label='Маркировка имуществ
}
@media (max-width: 768px) {
.labeling {
.place {
padding: var(--p-4, 16px);
}
}
@@ -365,14 +412,14 @@ q-page.labeling(role='region', aria-label='Маркировка имуществ
.no-print {
display: none !important;
}
.labeling {
.place {
padding: 0;
}
.labeling__grid {
.place__grid {
grid-template-columns: repeat(3, 1fr);
gap: 4mm;
}
.labeling__label {
.place__label {
page-break-inside: avoid;
width: 100%;
}
@@ -24,7 +24,8 @@ const items = ref<MarketplaceInventoryItemView[]>([])
const loading = ref(false)
const statusOptions: { label: string; value: InventoryStatus }[] = [
{ label: 'На складе', value: Zeus.MarketplaceInventoryStatus.LABELED },
{ label: 'Принято', value: Zeus.MarketplaceInventoryStatus.RECEIVED },
{ label: 'Промаркировано', value: Zeus.MarketplaceInventoryStatus.LABELED },
{ label: 'Выдано пайщику', value: Zeus.MarketplaceInventoryStatus.ISSUED },
{ label: 'Возврат на склад', value: Zeus.MarketplaceInventoryStatus.RETURNED },
{ label: 'Списано', value: Zeus.MarketplaceInventoryStatus.WRITTEN_OFF },
@@ -41,13 +42,14 @@ function toggleStatus(value: InventoryStatus): void {
}
const columns: QTableProps['columns'] = [
{ name: 'barcode_value', label: 'Штрих-код', field: 'barcode_value', align: 'left', sortable: true },
{ name: 'shelf', label: 'Полка', field: 'shelf', align: 'left', sortable: true },
{ name: 'product', label: 'Товар', field: 'product_name_snapshot', align: 'left', sortable: true },
{ name: 'orderer', label: 'Заказчик', field: 'orderer_account_snapshot', align: 'left', sortable: true },
{ name: 'quantity', label: 'Ед.', field: 'quantity_per_label', align: 'right', sortable: true },
{ name: 'barcode_value', label: 'Штрих-код', field: 'barcode_value', align: 'left', sortable: true },
{ name: 'status', label: 'Состояние', field: 'status', align: 'left', sortable: true },
{ name: 'age', label: 'Возраст', field: 'labeled_at', align: 'right' },
{ name: 'labeled_at', label: 'Промаркировано', field: 'labeled_at', align: 'left', sortable: true },
{ name: 'age', label: 'Возраст', field: 'received_at', align: 'right' },
{ name: 'received_at', label: 'Принято', field: 'received_at', align: 'left', sortable: true },
]
const filteredRows = computed(() => {
@@ -67,6 +69,7 @@ const summary = computed(() => {
return {
byStatus,
totalActive:
(byStatus[Zeus.MarketplaceInventoryStatus.RECEIVED] ?? 0) +
(byStatus[Zeus.MarketplaceInventoryStatus.LABELED] ?? 0) +
(byStatus[Zeus.MarketplaceInventoryStatus.ISSUED] ?? 0) +
(byStatus[Zeus.MarketplaceInventoryStatus.RETURNED] ?? 0),
@@ -97,8 +100,10 @@ onMounted(async () => {
function humanStatus(status: string): string {
switch (status) {
case Zeus.MarketplaceInventoryStatus.RECEIVED:
return 'Принято'
case Zeus.MarketplaceInventoryStatus.LABELED:
return 'На складе'
return 'Промаркировано'
case Zeus.MarketplaceInventoryStatus.ISSUED:
return 'Выдано пайщику'
case Zeus.MarketplaceInventoryStatus.RETURNED:
@@ -112,6 +117,8 @@ function humanStatus(status: string): string {
function statusVariant(status: string): BaseBadgeVariant {
switch (status) {
case Zeus.MarketplaceInventoryStatus.RECEIVED:
return 'neutral'
case Zeus.MarketplaceInventoryStatus.LABELED:
return 'info'
case Zeus.MarketplaceInventoryStatus.ISSUED:
@@ -158,7 +165,8 @@ q-page.warehouse(role='region', aria-label='Склад участка')
template(v-else)
PageHint(storage-key='mp:operator-warehouse:banner-dismissed')
| Промаркированное имущество вашего пункта выдачи наклейки, заказчики и состояние.
| Имущество, принятое на ваш пункт выдачи: что лежит на складе, на какой
| полке, заказчик и состояние. Штрих-код есть не у всех позиций он опционален.
.warehouse__filters
.warehouse__chips
@@ -202,21 +210,31 @@ q-page.warehouse(role='region', aria-label='Склад участка')
:columns='columns',
row-key='id',
:loading='loading',
:pagination='{ rowsPerPage: 25, sortBy: "labeled_at", descending: true }',
:pagination='{ rowsPerPage: 25, sortBy: "received_at", descending: true }',
:rows-per-page-options='[25, 50, 100, 0]',
flat,
bordered,
binary-state-sort
)
template(#body-cell-shelf='props')
q-td(:props='props')
span(v-if='props.row.shelf') {{ props.row.shelf }}
span.t-muted(v-else)
template(#body-cell-barcode_value='props')
q-td(:props='props')
span.q-mono(v-if='props.row.barcode_value') {{ props.row.barcode_value }}
span.t-muted(v-else)
template(#body-cell-status='props')
q-td(:props='props')
BaseBadge(:variant='statusVariant(props.row.status)') {{ humanStatus(props.row.status) }}
template(#body-cell-age='props')
q-td(:props='props') {{ formatAge(props.row.labeled_at) }}
q-td(:props='props') {{ formatAge(props.row.received_at) }}
template(#body-cell-labeled_at='props')
q-td(:props='props') {{ formatDateTime(props.row.labeled_at) }}
template(#body-cell-received_at='props')
q-td(:props='props') {{ formatDateTime(props.row.received_at) }}
template(#no-data)
.warehouse__nodata
@@ -56,6 +56,9 @@ const expectedShipments = ref<MarketplaceShipmentView[]>([]);
// Story 14.2: поставщики с принятыми заказами, ожидающими самовывоза на КУ
// (партию заранее не формировали) — приёмка по факту присутствия.
const expressCandidates = ref<MarketplaceExpressPickupCandidateView[]>([]);
// Состав ожидаемого имущества по поставщику (для карточек «что везут» без
// проваливания): грузим единицы поставщиков, чьи партии/самовывоз ждут приёмки.
const ordersByOfferer = ref<Record<string, MarketplaceSupplierPickupOrderView[]>>({});
const loading = ref(false);
// Партии, прибывшие на КУ и ожидающие создания акта приёмки: статус
@@ -140,6 +143,7 @@ async function load(): Promise<void> {
);
expectedShipments.value = shipments;
expressCandidates.value = express;
await loadOffererContents();
} catch (e) {
FailAlert(e, 'Не удалось загрузить акты приёмки');
} finally {
@@ -147,14 +151,39 @@ async function load(): Promise<void> {
}
}
// Story 14.2: принять самовывоз по факту присутствия. Открываем ту же форму
// коррекции, что и при сканировании QR (R5/B2): оператор правит фактическое
// количество и цену по каждой единице — процесс приёмки единый, без исключений.
// Сам express-акт создаётся из диалога по «Сформировать акты».
async function acceptExpressPickup(
candidate: MarketplaceExpressPickupCandidateView,
): Promise<void> {
await openPickupForSupplier(candidate.offerer_account);
// Состав того, что ждёт приёмки, — для карточек «что везут». Грузим единицы
// имущества по каждому поставщику, чья партия (SUPPLY_PREPARED) или самовывоз
// ждёт на этом КУ. Сетевая нагрузка ограничена числом поставщиков на приёмке.
async function loadOffererContents(): Promise<void> {
const offerers = new Set<string>();
for (const s of expectedShipments.value) {
if (s.status === Zeus.MarketplaceShipmentStatus.SUPPLY_PREPARED) offerers.add(s.offerer_account);
}
for (const c of expressCandidates.value) offerers.add(c.offerer_account);
const map: Record<string, MarketplaceSupplierPickupOrderView[]> = {};
await Promise.all(
[...offerers].map(async (account) => {
try {
map[account] = await listSupplierPickupOrders({
braname: braname.value.trim(),
offerer_account: account,
});
} catch {
map[account] = [];
}
}),
);
ordersByOfferer.value = map;
}
// Состав партии (задекларированные единицы по shipment_id).
function shipmentContents(s: MarketplaceShipmentView): MarketplaceSupplierPickupOrderView[] {
return (ordersByOfferer.value[s.offerer_account] ?? []).filter((o) => o.shipment_id === s.id);
}
// Состав самовывоза по факту (добор по акцепту — без партии).
function expressContents(c: MarketplaceExpressPickupCandidateView): MarketplaceSupplierPickupOrderView[] {
return (ordersByOfferer.value[c.offerer_account] ?? []).filter((o) => o.status === 'ACCEPTED');
}
// QR-код передачи (Эпик 14, агрегирующая приёмка): оператор сканирует
@@ -426,12 +455,14 @@ q-page.reception(role='region', aria-label='Приёмка партии')
| Сканировать QR
PageHint(storage-key='mp:operator-reception:banner-dismissed')
| Партии, прибывшие на ваш пункт выдачи, ждут приёмки ниже. Выберите партию
| (или отсканируйте QR поставщика), сверьте фактическое количество и цену
| по каждой позиции, сформируйте акт и подпишите его председателем участка.
| Партии, прибывшие на ваш пункт выдачи, и их состав ниже. Приёмка
| запускается ТОЛЬКО сканированием QR поставщика (или вводом кода)
| кнопка «Сканировать QR» в шапке. Это идентификация: без кода принять
| нельзя, даже если знаете человека в лицо. Затем сверьте факт по
| позициям, сформируйте акт и подпишите его председателем участка.
//- Ожидающие приёмки партии: выбор из списка вместо ручного ввода id.
//- QR-сканер — для тех, кто принимает с телефона (Story 14.3).
//- Ожидающие приёмки партии — информационные карточки «что везут».
//- Запуск приёмки — только через скан QR/ввод кода (кнопка в шапке).
.reception__pending
.reception__pending-head
.reception__pending-title Ожидают приёмки
@@ -444,13 +475,12 @@ q-page.reception(role='region', aria-label='Приёмка партии')
.reception__ship-meta
| {{ SHIPMENT_VARIANT_LABEL[s.delivery_variant] ?? s.delivery_variant }} · {{ formatAsset2Digits(s.total_amount) }}
template(v-if='s.ttn_number') · ТТН {{ s.ttn_number }}
BaseButton(variant='primary', size='sm', @click='openPickupForSupplier(s.offerer_account)')
template(#icon-left)
q-icon(name='how_to_reg', size='16px')
| Принять партию
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) }}
//- Story 14.2: самовывоз по факту — поставщик приехал без заранее
//- сформированной партии; оператор открывает приёмку по факту присутствия.
//- сформированной партии. Тоже только через скан QR/ввод кода.
.reception__pending(v-if='expressCandidates.length')
.reception__pending-head
.reception__pending-title Самовывоз по факту (без партии)
@@ -460,10 +490,9 @@ q-page.reception(role='region', aria-label='Приёмка партии')
.reception__ship-offerer {{ c.offerer_account }}
.reception__ship-meta
| Самовывоз · {{ c.orders_count }} заказ(ов) · {{ c.total_units }} ед. · {{ formatAsset2Digits(c.total_amount) }}
BaseButton(variant='secondary', size='sm', @click='acceptExpressPickup(c)')
template(#icon-left)
q-icon(name='how_to_reg', size='16px')
| Принять самовывоз
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) }}
q-table.reception__table(
:rows='items',
@@ -652,6 +681,18 @@ q-page.reception(role='region', aria-label='Приёмка партии')
overflow-wrap: anywhere;
}
&__contents {
margin: var(--p-2, 8px) 0 0;
padding-left: var(--p-4, 16px);
list-style: disc;
}
&__content-line {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-2);
font-variant-numeric: tabular-nums;
}
&__pickup {
display: flex;
flex-direction: column;