[598-23][@ant] feat: own-KU лента поставок ПВЗ, whoAmI.branches и выбор категорий по названию — устранить ручной ввод braname/ID и закрыть дыру оператора ПВЗ в Потоке IV

This commit is contained in:
ant
2026-05-25 05:02:53 +00:00
parent ef5467251b
commit c7dadc5529
22 changed files with 3718 additions and 1076 deletions
@@ -59,6 +59,9 @@ export const marketplaceAccessMatrix: Record<MarketplaceRole, Record<string, str
// Story 6.1: оператор/председатель КУ открывает выдачу первой подписью
// АПП-выдачи (`signiss1`) и видит ленту выдач на своём КУ.
Issuance: ['create', 'sign:first', 'read:own-KU'],
// Поток IV шаг 1: оператор/председатель КУ видит ленту ожидаемых партий
// поставки на своём участке, чтобы открыть приёмку по приходу.
Shipment: ['read:own-KU'],
Inventory: ['label'],
Warehouse: ['read:own-KU'],
KU: ['read:own-KU'],
@@ -77,6 +80,7 @@ export const marketplaceAccessMatrix: Record<MarketplaceRole, Record<string, str
Whitelist: ['manage'],
Vitrine: ['manage', 'read'],
Warehouse: ['read:all'],
Shipment: ['read:all'],
Extension: ['configure'],
// Эпик 8: общий администратор формирует и редактирует DRAFT-проект
// списания, подписывает Заявление 1106 и отправляет проект в совет.
@@ -19,10 +19,21 @@ export class MarketplaceCurrentMemberDTO {
@Field(() => [String])
public readonly marketplace_roles!: string[];
constructor(init: { username: string; core_roles: string[]; marketplace_roles: string[] }) {
@Field(() => [String], {
description: 'Кооперативные участки, в которых пайщик — доверенное лицо (для столов оператора ПВЗ)',
})
public readonly branches!: string[];
constructor(init: {
username: string;
core_roles: string[];
marketplace_roles: string[];
branches: string[];
}) {
this.username = init.username;
this.core_roles = init.core_roles;
this.marketplace_roles = init.marketplace_roles;
this.branches = init.branches;
}
}
@@ -216,6 +216,22 @@ export class MarketplaceListShipmentsInputDTO {
statuses?: MarketplaceShipmentStatusEnum[];
}
@InputType('MarketplaceListShipmentsByBranameInput')
export class MarketplaceListShipmentsByBranameInputDTO {
@Field(() => String, { description: 'Кооперативный участок получения партий.' })
@IsString()
@IsNotEmpty()
braname!: string;
@Field(() => [MarketplaceShipmentStatusEnum], {
nullable: true,
description: 'Фильтр по статусам партий — например, ожидаемые к приёмке.',
})
@IsOptional()
@IsArray()
statuses?: MarketplaceShipmentStatusEnum[];
}
export function toMarketplaceShipmentDTO(
e: MarketplaceShipmentDomainEntity
): MarketplaceShipmentDTO {
@@ -1,12 +1,17 @@
import { Injectable, UseGuards } from '@nestjs/common';
import { Inject, Injectable, UseGuards } from '@nestjs/common';
import { Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { CurrentMarketplaceMember } from '../decorators/current-marketplace-member.decorator';
import { MarketplaceCurrentMemberDTO } from '../dto/marketplace-current-member.dto';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import {
MARKETPLACE_KU_CHAIRMEN_SERVICE,
type MarketplaceKuChairmenService,
} from '../services/marketplace-ku-chairmen.service';
/**
* Story 1.3: тестовый whoami-эндпоинт расширения marketplace.
@@ -19,14 +24,23 @@ import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.gua
@Resolver()
@Injectable()
export class MarketplaceMembershipResolver {
constructor(
@Inject(MARKETPLACE_KU_CHAIRMEN_SERVICE)
private readonly kuChairmenService: MarketplaceKuChairmenService
) {}
@Query(() => MarketplaceCurrentMemberDTO, {
name: 'marketplaceWhoAmI',
description: 'Контекст пайщика для Стола заказов: core_roles + marketplace_roles',
description: 'Контекст пайщика для Стола заказов: core_roles + marketplace_roles + участки оператора',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard)
marketplaceWhoAmI(
async marketplaceWhoAmI(
@CurrentMarketplaceMember() currentMember: IMarketplaceCurrentMember
): MarketplaceCurrentMemberDTO {
return new MarketplaceCurrentMemberDTO(currentMember);
): Promise<MarketplaceCurrentMemberDTO> {
const branches = await this.kuChairmenService.listBranamesForMember(
config.coopname,
currentMember.username
);
return new MarketplaceCurrentMemberDTO({ ...currentMember, branches });
}
}
@@ -1,4 +1,4 @@
import { Inject, Injectable, NotFoundException, UseGuards } from '@nestjs/common';
import { ForbiddenException, Inject, Injectable, NotFoundException, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
@@ -6,11 +6,18 @@ import { CurrentMarketplaceMember } from '../decorators/current-marketplace-memb
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
import { canAccess } from '../access/marketplace-access-matrix';
import type { MarketplaceRole } from '../membership/marketplace-roles.mapper';
import {
MARKETPLACE_KU_CHAIRMEN_SERVICE,
type MarketplaceKuChairmenService,
} from '../services/marketplace-ku-chairmen.service';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import {
MarketplaceCreateShipmentInputDTO,
MarketplaceCreateShipmentResultDTO,
MarketplaceGetShipmentInputDTO,
MarketplaceListShipmentsByBranameInputDTO,
MarketplaceListShipmentsInputDTO,
MarketplaceShipmentDTO,
toMarketplaceShipmentDTO,
@@ -37,7 +44,9 @@ export class MarketplaceShipmentResolver {
@Inject(MARKETPLACE_SHIPMENT_CREATE_SERVICE)
private readonly createService: MarketplaceShipmentCreateService,
@Inject(MARKETPLACE_SHIPMENT_REPOSITORY)
private readonly shipmentRepo: MarketplaceShipmentDomainRepository
private readonly shipmentRepo: MarketplaceShipmentDomainRepository,
@Inject(MARKETPLACE_KU_CHAIRMEN_SERVICE)
private readonly kuChairmenService: MarketplaceKuChairmenService
) {}
@Mutation(() => MarketplaceCreateShipmentResultDTO, {
@@ -91,6 +100,48 @@ export class MarketplaceShipmentResolver {
return list.map(toMarketplaceShipmentDTO);
}
@Query(() => [MarketplaceShipmentDTO], {
name: 'marketplaceListShipmentsByBraname',
description:
'Список партий поставки, ожидаемых на кооперативном участке, — для стола приёмки оператора пункта выдачи.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Shipment', 'read:own-KU')
async marketplaceListShipmentsByBraname(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceListShipmentsByBranameInputDTO
): Promise<MarketplaceShipmentDTO[]> {
const coopname = config.coopname;
const roles = member.marketplace_roles as MarketplaceRole[];
// Ownership-фильтрация — ответственность резолвера (matrix даёт только
// capability). Роль с `Shipment:read:all` видит партии любого КУ; роль
// только с `read:own-KU` (оператор/председатель КУ) обязана быть членом
// запрашиваемого участка, иначе утечёт лента поставок чужого КУ.
if (!canAccess(roles, 'Shipment', 'read:all')) {
const isMember = await this.kuChairmenService.isMemberOfBranch(
coopname,
data.braname,
member.username
);
if (!isMember) {
throw new ForbiddenException(
'Лента поставок доступна только по участку, на котором вы являетесь председателем или доверенным лицом.'
);
}
}
const filter: MarketplaceShipmentListFilter = {
coopname,
braname: data.braname,
status: data.statuses?.length
? (data.statuses as MarketplaceShipmentStatus[])
: undefined,
};
const list = await this.shipmentRepo.list(filter);
return list.map(toMarketplaceShipmentDTO);
}
@Query(() => MarketplaceShipmentDTO, {
name: 'marketplaceGetShipment',
description: 'Получить партию поставки по идентификатору.',
+8
View File
@@ -759,6 +759,9 @@ export const AllTypesProps: Record<string,any> = {
},
MarketplaceListReturnClaimsByBranameInput:{
},
MarketplaceListShipmentsByBranameInput:{
statuses:"MarketplaceShipmentStatus"
},
MarketplaceListShipmentsInput:{
statuses:"MarketplaceShipmentStatus"
@@ -1988,6 +1991,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceListShipments:{
data:"MarketplaceListShipmentsInput"
},
marketplaceListShipmentsByBraname:{
data:"MarketplaceListShipmentsByBranameInput"
},
marketplaceListSupplierOrders:{
input:"MarketplaceListOrdersInput",
options:"PaginationInput"
@@ -3993,6 +3999,7 @@ export const ReturnTypes: Record<string,any> = {
shipments:"MarketplaceShipment"
},
MarketplaceCurrentMember:{
branches:"String",
core_roles:"String",
marketplace_roles:"String",
username:"String"
@@ -5347,6 +5354,7 @@ export const ReturnTypes: Record<string,any> = {
marketplaceListPendingOffers:"MarketplaceOfferPaginationResult",
marketplaceListReturnClaimsByBraname:"MarketplaceReturnClaim",
marketplaceListShipments:"MarketplaceShipment",
marketplaceListShipmentsByBraname:"MarketplaceShipment",
marketplaceListSupplierOrders:"MarketplaceOrderPaginationResult",
marketplaceListWhitelist:"MarketplaceWhitelistEntry",
marketplaceListWriteoffProposals:"PaginatedMarketplaceWriteoffProposals",
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,7 @@ import { OffererPaymentHistoryPage } from 'src/pages/Marketplace/OffererPaymentH
import { AdminWriteoffsPage } from 'src/pages/Marketplace/AdminWriteoffs'
import { ChairmanModerationPage } from 'src/pages/Marketplace/ChairmanModeration'
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'
@@ -451,6 +452,24 @@ export default async function (): Promise<IWorkspaceConfig[]> {
agreements: agreementsBase,
},
},
{
// Поток IV шаг 1: operator-стол «Ожидаемые поставки». Лента партий,
// направленных на КУ оператора (own-KU scoping через
// marketplaceListShipmentsByBraname + isMemberOfBranch). Оператор
// видит, что поставщик подготовил к отгрузке, и по приходу открывает
// приёмку на столе /reception. Read-only обзор.
path: 'incoming-shipments',
name: 'marketplace-pvz-incoming-shipments',
component: markRaw(OperatorIncomingShipmentsPage),
beforeEnter: requireMarketplaceOperator,
meta: {
title: 'Ожидаемые поставки',
icon: 'fa-solid fa-truck-arrow-right',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 5 / Story 5.6: operator-стол приёмки партии на ПВЗ.
// Председатель КУ открывает акт приёмки (registry_id=1102) против
@@ -0,0 +1,14 @@
import { Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
export interface MarketplaceCurrentMemberView {
username: string;
core_roles: string[];
marketplace_roles: string[];
branches: string[];
}
export async function fetchWhoAmI(): Promise<MarketplaceCurrentMemberView> {
const result = await client.Query(Queries.Marketplace.WhoAmI.query, { variables: {} });
return result[Queries.Marketplace.WhoAmI.name] as unknown as MarketplaceCurrentMemberView;
}
@@ -4,6 +4,7 @@ import { Notify } from 'quasar';
import { listAplReceptionsByBraname } from '../../OperatorReception/api';
import { listIssuancesByBraname } from '../../OperatorIssuance/api';
import { listReturnClaimsByBraname } from '../../OperatorReturnClaims/api';
import { fetchWhoAmI } from '../api';
import type { MarketplaceAplReceptionView } from '../../OffererPendingAplReceptions/api';
import type { MarketplaceOrderIssuanceView } from '../../OperatorIssuance/api';
import type { MarketplaceReturnClaimView } from '../../OperatorReturnClaims/api';
@@ -20,19 +21,17 @@ import type { MarketplaceReturnClaimView } from '../../OperatorReturnClaims/api'
* на соответствующих специализированных страницах /market-pvz/reception,
* /issuance, /returns — здесь только сводный read-обзор для председателя КУ.
*
* Поле braname — пока ручной ввод; auto-detect требует, чтобы backend отдавал
* привязку «пользователь → КУ»: на сервере она уже есть в
* MarketplaceKuChairmenService.listBranamesForMember (читает on-chain
* branch.trustee/trusted), но не проброшена во фронт. getBranches возвращает
* trustee=null (individual председателя не найден в PG-хранилище репозитория),
* а marketplaceWhoAmI поля braname не содержит. Подключить auto-detect —
* добавить поле `branches` в MarketplaceCurrentMember через listBranamesForMember
* + регенерация SDK (generate-schema/client — только CI).
* Поле braname автозаполняется из `marketplaceWhoAmI.branches` — список КУ,
* где пайщик доверенное лицо (on-chain branch.trustee/trusted через
* MarketplaceKuChairmenService.listBranamesForMember). Один участок —
* подставляется сразу; несколько — берётся первый, поле остаётся редактируемым
* для ручного переключения.
*/
const POLL_INTERVAL_MS = 20_000;
const braname = ref<string>('');
const ownBranches = ref<string[]>([]);
const activeTab = ref<'receptions' | 'issuances' | 'returns'>('receptions');
const receptions = ref<MarketplaceAplReceptionView[]>([]);
@@ -104,7 +103,21 @@ watch(braname, () => {
returns.value = [];
});
async function autoDetectBranch(): Promise<void> {
try {
const me = await fetchWhoAmI();
ownBranches.value = me.branches ?? [];
if (!braname.value.trim() && ownBranches.value.length > 0) {
braname.value = ownBranches.value[0];
await loadAll();
}
} catch {
// whoAmI недоступен (не залогинен / не член кооператива) — оставляем ручной ввод
}
}
onMounted(() => {
void autoDetectBranch();
pollTimer = setInterval(() => {
if (hasBranch.value) void loadAll();
}, POLL_INTERVAL_MS);
@@ -142,11 +155,23 @@ q-page.mp-role-operator.mp-branch-orders(role="region", aria-label="Сводны
dense,
clearable,
label="ID кооперативного участка (braname)",
hint="Введите код вашего КУ — например voskhod-kpvz",
hint="Подставлен автоматически по вашему КУ. Измените вручную при необходимости.",
@keyup.enter="loadAll"
)
template(#prepend)
q-icon(name="fa-solid fa-warehouse")
div.q-mt-sm(v-if="ownBranches.length > 1")
span.text-caption.q-mr-sm Ваши участки:
q-chip(
v-for="b in ownBranches",
:key="b",
clickable,
dense,
:color="b === braname ? 'primary' : 'grey-3'",
:text-color="b === braname ? 'white' : 'dark'",
:label="b",
@click="braname = b; loadAll()"
)
div.col-12.col-md-4
q-btn(
unelevated,
@@ -5,13 +5,16 @@ import { client } from 'src/shared/api/client';
* Эпик 3 / Story 3.x: admin-настройка whitelist'а доступных категорий.
* Backend Resolver: available-category-admin.resolver.ts → @AuthRoles(['chairman']).
*
* Reuse 4 endpoint'а: Get, Stats, Add, Remove. Tree-выбор подключается
* на следующем шаге (нужен dialog с marketplaceGetCategoryTree).
* Reuse endpoint'ы: Get, Stats, Add, Remove + ListCategories для выбора
* категорий по названию (вместо ручного ввода ID).
*/
export type MarketplaceAvailableCategoryView =
Queries.Marketplace.GetAvailableCategories.IOutput['marketplaceGetAvailableCategories'][number];
export type MarketplaceCategoryView =
Queries.Marketplace.ListCategories.IOutput['marketplaceListCategories'][number];
export type MarketplaceAvailabilityStatsView =
Queries.Marketplace.GetAvailabilityStats.IOutput['marketplaceGetAvailabilityStats'];
@@ -23,6 +26,14 @@ export async function fetchAvailableCategories(): Promise<MarketplaceAvailableCa
return result;
}
export async function fetchCategories(): Promise<MarketplaceCategoryView[]> {
const { [Queries.Marketplace.ListCategories.name]: result } = await client.Query(
Queries.Marketplace.ListCategories.query,
{},
);
return result;
}
export async function fetchAvailabilityStats(): Promise<MarketplaceAvailabilityStatsView> {
const { [Queries.Marketplace.GetAvailabilityStats.name]: result } = await client.Query(
Queries.Marketplace.GetAvailabilityStats.query,
@@ -5,9 +5,11 @@ import {
addAvailableCategories,
fetchAvailabilityStats,
fetchAvailableCategories,
fetchCategories,
removeAvailableCategories,
type MarketplaceAvailabilityStatsView,
type MarketplaceAvailableCategoryView,
type MarketplaceCategoryView,
} from '../api';
/**
@@ -15,8 +17,8 @@ import {
* категорий товаров для кооператива (chairman-only).
*
* MVP: список разрешённых категорий (entire / specific type), статистика
* и удаление. Добавление — через диалог с ручным вводом ID (tree-выбор
* через `marketplaceGetCategoryTree` подключится на следующем шаге).
* и удаление. Добавление — через диалог с выбором категорий по названию
* (marketplaceListCategories), уже добавленные исключаются из списка.
*
* Без whitelist'а — кооператив видит весь глобальный каталог.
* С whitelist'ом — только перечисленные категории доступны для
@@ -25,8 +27,20 @@ import {
const items = ref<MarketplaceAvailableCategoryView[]>([]);
const stats = ref<MarketplaceAvailabilityStatsView | null>(null);
const allCategories = ref<MarketplaceCategoryView[]>([]);
const loading = ref(false);
const addDialogOpen = ref(false);
const selectedToAdd = ref<number[]>([]);
const adding = ref(false);
const addableCategoryOptions = computed(() => {
const whitelisted = new Set(items.value.map((i) => i.categoryId));
return allCategories.value
.filter((c) => !whitelisted.has(c.id))
.map((c) => ({ label: c.display_name, value: c.id }));
});
const entireCategories = computed(() =>
items.value.filter((i) => i.isForEntireCategory),
);
@@ -35,12 +49,14 @@ const specificTypes = computed(() => items.value.filter((i) => i.isForSpecificTy
async function load(): Promise<void> {
loading.value = true;
try {
const [list, st] = await Promise.all([
const [list, st, cats] = await Promise.all([
fetchAvailableCategories(),
fetchAvailabilityStats(),
fetchCategories(),
]);
items.value = list;
stats.value = st;
allCategories.value = cats;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
@@ -50,33 +66,25 @@ async function load(): Promise<void> {
}
function onAdd(): void {
Dialog.create({
title: 'Добавить категории в whitelist',
message:
'Введите ID категорий через запятую (например: 1, 7, 15). Tree-выбор подключится на следующем шаге Story 3.x.',
prompt: {
model: '',
type: 'text',
isValid: (v: string) => /^\s*\d+(\s*,\s*\d+)*\s*$/.test(v),
},
cancel: { label: 'Отмена', flat: true },
ok: { label: 'Добавить', color: 'primary', unelevated: true },
persistent: true,
}).onOk(async (raw: string) => {
const ids = raw
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n));
if (ids.length === 0) return;
try {
await addAvailableCategories(ids);
Notify.create({ type: 'positive', message: `Добавлено категорий: ${ids.length}` });
await load();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
}
});
selectedToAdd.value = [];
addDialogOpen.value = true;
}
async function confirmAdd(): Promise<void> {
const ids = selectedToAdd.value;
if (ids.length === 0) return;
adding.value = true;
try {
await addAvailableCategories(ids);
Notify.create({ type: 'positive', message: `Добавлено категорий: ${ids.length}` });
addDialogOpen.value = false;
await load();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
adding.value = false;
}
}
function onRemove(item: MarketplaceAvailableCategoryView): void {
@@ -172,6 +180,38 @@ q-page.mp-role-admin.mp-category-whitelist(role="region", aria-label="Досту
q-item-section
q-item-label Категория № {{ t.categoryId }} → тип № {{ t.typeId ?? '' }}
q-item-label(caption) Добавлено: {{ t.addedBy }}, активна: {{ t.isActive ? 'да' : 'нет' }}
q-dialog(v-model="addDialogOpen")
q-card(style="min-width: 420px; max-width: 90vw")
q-card-section
div.text-h6 Добавить категории в whitelist
div.text-caption.text-grey-7 Выберите категории по названию. Уже добавленные в список не показываются.
q-card-section
q-select(
v-model="selectedToAdd",
:options="addableCategoryOptions",
emit-value,
map-options,
multiple,
use-chips,
outlined,
dense,
label="Категории",
:loading="loading"
)
div.text-caption.text-grey-6.q-mt-sm(v-if="addableCategoryOptions.length === 0")
| Все доступные категории уже в whitelist.
q-card-actions(align="right")
q-btn(flat, no-caps, label="Отмена", v-close-popup)
q-btn(
unelevated,
no-caps,
color="primary",
label="Добавить",
:loading="adding",
:disable="selectedToAdd.length === 0",
@click="confirmAdd"
)
</template>
<style scoped lang="scss">
@@ -0,0 +1,28 @@
import { Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
export type MarketplaceShipmentView =
Queries.Marketplace.ListShipmentsByBraname.IOutput['marketplaceListShipmentsByBraname'][number];
export type IListShipmentsByBranameInput =
Queries.Marketplace.ListShipmentsByBraname.IInput['data'];
export async function listShipmentsByBraname(
data: IListShipmentsByBranameInput,
): Promise<MarketplaceShipmentView[]> {
const { [Queries.Marketplace.ListShipmentsByBraname.name]: result } = await client.Query(
Queries.Marketplace.ListShipmentsByBraname.query,
{ variables: { data } },
);
return result;
}
export interface MarketplaceOperatorBranchesView {
username: string;
branches: string[];
}
export async function fetchOperatorBranches(): Promise<MarketplaceOperatorBranchesView> {
const result = await client.Query(Queries.Marketplace.WhoAmI.query, { variables: {} });
return result[Queries.Marketplace.WhoAmI.name] as unknown as MarketplaceOperatorBranchesView;
}
@@ -0,0 +1 @@
export { OperatorIncomingShipmentsPage } from './ui';
@@ -0,0 +1,215 @@
<template lang="pug">
q-page.mp-role-operator.q-pa-md(role="region", aria-label="Ожидаемые поставки")
.text-h5.q-mb-xs Ожидаемые поставки
.text-caption.text-grey-7.q-mb-md(style="max-width: 720px")
| Партии поставщиков, направленные на ваш кооперативный участок. Дождитесь статуса «Готова к приёмке» и открывайте акт приёмки на столе «Приёмка партии».
q-card.mp-card.q-mb-md
q-card-section
.row.q-col-gutter-sm.items-end
q-input.col-12.col-sm-4(
v-model="braname",
label="Код кооперативного участка",
dense,
outlined,
clearable,
hint="Подставлен автоматически по вашему КУ. Измените вручную при необходимости.",
@keyup.enter="load"
)
q-select.col-12.col-sm-4(
v-model="statusFilter",
:options="statusOptions",
label="Состояние партии",
dense,
outlined,
multiple,
emit-value,
map-options
)
q-btn.col-12.col-sm-3(
color="primary",
label="Обновить",
:loading="loading",
:disable="!braname",
@click="load"
)
.q-mt-sm(v-if="ownBranches.length > 1")
span.text-caption.q-mr-sm Ваши участки:
q-chip(
v-for="b in ownBranches",
:key="b",
clickable,
dense,
:color="b === braname ? 'primary' : 'grey-3'",
:text-color="b === braname ? 'white' : 'dark'",
:label="b",
@click="braname = b; load()"
)
.row.q-col-gutter-md.q-mb-md(v-if="items.length")
q-card.mp-card.col-12.col-sm-3
q-card-section
.text-caption.text-grey-7 Ожидают приёмки
.text-h6 {{ summary.expected }}
q-card.mp-card.col-12.col-sm-3(
v-for="(count, status) in summary.byStatus",
:key="status"
)
q-card-section
.text-caption.text-grey-7 {{ humanStatus(status) }}
.text-h6 {{ count }}
q-table.mp-card(
: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")
span.mp-status-chip(:class="statusChipClass(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)
.full-width.text-center.q-pa-md.text-grey-7
| {{ braname ? 'Партий по этому КУ нет — проверьте код или фильтр по состояниям.' : 'Введите код КУ и нажмите «Обновить».' }}
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
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 {
fetchOperatorBranches,
listShipmentsByBraname,
type MarketplaceShipmentView,
} from '../api'
type ShipmentStatus = MarketplaceShipmentView['status']
const braname = ref<string>('')
const ownBranches = ref<string[]>([])
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 },
]
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
}
}
async function autoDetectBranch(): Promise<void> {
try {
const me = await fetchOperatorBranches()
ownBranches.value = me.branches ?? []
if (!braname.value?.trim() && ownBranches.value.length > 0) {
braname.value = ownBranches.value[0]
}
} catch {
// whoAmI недоступен — оставляем ручной ввод braname
}
}
onMounted(async () => {
await autoDetectBranch()
await load()
})
function humanStatus(status: string): string {
return statusOptions.find((o) => o.value === status)?.label ?? status
}
function statusChipClass(status: string): string {
switch (status) {
case Zeus.MarketplaceShipmentStatus.SUPPLY_PREPARED:
return 'mp-status-chip--info'
case Zeus.MarketplaceShipmentStatus.RECEPTION_IN_PROGRESS:
return 'mp-status-chip--warning'
case Zeus.MarketplaceShipmentStatus.ACCEPTED_TO_COOP:
return 'mp-status-chip--success'
case Zeus.MarketplaceShipmentStatus.CANCELLED:
return 'mp-status-chip--error'
default:
return 'mp-status-chip--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>
@@ -0,0 +1 @@
export { default as OperatorIncomingShipmentsPage } from './OperatorIncomingShipmentsPage.vue';
@@ -0,0 +1,52 @@
// Сценарий: operator-стол «Ожидаемые поставки» (Поток IV шаг 1).
// Лента партий поставщиков, направленных на КУ оператора. Own-KU scoping
// через marketplaceListShipmentsByBraname + isMemberOfBranch — оператор
// видит только партии своего участка. braname автоподставляется из
// marketplaceWhoAmI.branches.
import { cleanViteOverlays, dismissOnboardingDialogs, env, loginAs, signOnboardingAgreements } from '../../../lib/harness.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const loadFixture = (username) =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../state/participants/${username}.json`), 'utf8'),
);
export const meta = {
title: 'Стол ПВЗ — ожидаемые поставки',
docPath: 'new/marketplace/operator/incoming-shipments.md',
assetsDir: 'assets/new/marketplace/operator/incoming-shipments',
role: 'user',
fixture: 'chairkrg',
fixtures: ['chairkrg'],
};
export default async ({ page, shot }) => {
// Поставки scoped `read:own-KU` — логинимся председателем/оператором КУ
// Красногорск (chairkrg), владельцем КУ `krg`, иначе лента пуста.
const fixture = loadFixture('chairkrg');
await page.addInitScript(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await loginAs(page, fixture);
await signOnboardingAgreements(page);
await dismissOnboardingDialogs(page);
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market-pvz/incoming-shipments`, {
waitUntil: 'domcontentloaded',
timeout: 45000,
});
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3500);
await signOnboardingAgreements(page);
await dismissOnboardingDialogs(page);
await cleanViteOverlays(page);
await shot(
page,
'01-incoming-shipments',
`«Ожидаемые поставки» оператора ПВЗ: код КУ \`krg\` подставлен автоматически, лента партий с поставщиком, вариантом доставки, суммой и состоянием. URL: \`${page.url()}\`.`,
);
};
@@ -18,6 +18,8 @@ export * as ListPendingOffers from './listPendingOffers'
export * as ListModerationLog from './listModerationLog'
/** Эпик 5: партии поставки кооператива */
export * as ListShipments from './listShipments'
/** Поток IV: ожидаемые партии поставки на КУ для стола приёмки оператора ПВЗ */
export * as ListShipmentsByBraname from './listShipmentsByBraname'
/** Эпик 5: одна партия поставки по идентификатору */
export * as GetShipment from './getShipment'
/** Эпик 5: лента маркированных единиц имущества */
@@ -0,0 +1,22 @@
import { marketplaceShipmentSelector } from '../../selectors/marketplace/shipmentSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceListShipmentsByBraname'
export const query = Selector('Query')({
[name]: [
{ data: $('data', 'MarketplaceListShipmentsByBranameInput!') },
marketplaceShipmentSelector,
],
})
export interface IInput {
/**
* @private
*/
[key: string]: unknown
data: ModelTypes['MarketplaceListShipmentsByBranameInput']
}
export type IOutput = InputType<GraphQLTypes['Query'], typeof query>
@@ -7,6 +7,7 @@ export const query = Selector('Query')({
username: true,
core_roles: true,
marketplace_roles: true,
branches: true,
},
})
+8
View File
@@ -759,6 +759,9 @@ export const AllTypesProps: Record<string,any> = {
},
MarketplaceListReturnClaimsByBranameInput:{
},
MarketplaceListShipmentsByBranameInput:{
statuses:"MarketplaceShipmentStatus"
},
MarketplaceListShipmentsInput:{
statuses:"MarketplaceShipmentStatus"
@@ -1988,6 +1991,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceListShipments:{
data:"MarketplaceListShipmentsInput"
},
marketplaceListShipmentsByBraname:{
data:"MarketplaceListShipmentsByBranameInput"
},
marketplaceListSupplierOrders:{
input:"MarketplaceListOrdersInput",
options:"PaginationInput"
@@ -3993,6 +3999,7 @@ export const ReturnTypes: Record<string,any> = {
shipments:"MarketplaceShipment"
},
MarketplaceCurrentMember:{
branches:"String",
core_roles:"String",
marketplace_roles:"String",
username:"String"
@@ -5347,6 +5354,7 @@ export const ReturnTypes: Record<string,any> = {
marketplaceListPendingOffers:"MarketplaceOfferPaginationResult",
marketplaceListReturnClaimsByBraname:"MarketplaceReturnClaim",
marketplaceListShipments:"MarketplaceShipment",
marketplaceListShipmentsByBraname:"MarketplaceShipment",
marketplaceListSupplierOrders:"MarketplaceOrderPaginationResult",
marketplaceListWhitelist:"MarketplaceWhitelistEntry",
marketplaceListWriteoffProposals:"PaginatedMarketplaceWriteoffProposals",
File diff suppressed because it is too large Load Diff