[598-23][@ant] feat: own-KU лента поставок ПВЗ, whoAmI.branches и выбор категорий по названию — устранить ручной ввод braname/ID и закрыть дыру оператора ПВЗ в Потоке IV
This commit is contained in:
+4
@@ -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 и отправляет проект в совет.
|
||||
|
||||
+12
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -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 {
|
||||
|
||||
+19
-5
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
+53
-2
@@ -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: 'Получить партию поставки по идентификатору.',
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1538
-501
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;
|
||||
}
|
||||
+34
-9
@@ -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,
|
||||
|
||||
+70
-30
@@ -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';
|
||||
+215
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1588
-526
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user