/** * Step-handlers для full-cycle сценария. Каждая функция получает * { ctx, step, report } * и обновляет step.checks и report.bugs / report.uxNotes / report.gap. * * ctx — общий накопитель ID'ов созданных сущностей и активных сессий * между steps. Структура шире чем строго необходима — для удобства * логирования в отчёте. */ import { login, makeClient, ADMIN_BASE } from '../lib/api.js' import type { CheckResult, Step, Report } from '../lib/report.js' import { Report as _R } from '../lib/report.js' // type-only ниже import { countRows, psql } from '../lib/db.js' type Ctx = { apiOnly: boolean superAdminToken?: string organization?: { id: string; name: string } adminEmail?: string adminTempPassword?: string adminToken?: string storekeeperEmail?: string cashierEmail?: string cashierTempPassword?: string storekeeperTempPassword?: string counterpartyId?: string storeId?: string retailPointId?: string supplyId?: string supplyLines?: { productId: string; productName: string; quantity: number; price: number }[] retailSaleId?: string saleLines?: { productId: string; quantity: number }[] stockBefore?: Record stockAfterSupply?: Record } interface StepCtx { ctx: Ctx; step: Step; report: Report } const TIMESTAMP = Date.now() function check(step: Step, c: CheckResult) { step.checks.push(c) } function asString(x: unknown): string { if (x == null) return '' if (typeof x === 'string') return x return JSON.stringify(x) } async function ensureSuperAdminToken(ctx: Ctx): Promise { if (!ctx.superAdminToken) { const sa = await login('admin@food-market.local', 'Admin12345!') ctx.superAdminToken = sa.accessToken } return ctx.superAdminToken } // --------------------------------------------------------------------------- export async function step01_create_organization({ ctx, step, report }: StepCtx) { const token = await ensureSuperAdminToken(ctx) const api = makeClient(token) const orgName = `Test Shop ${TIMESTAMP}` const orgEmail = `e2e-${TIMESTAMP}@example.kz` const res = await api.post('/api/super-admin/organizations', { org: { name: orgName, countryCode: 'KZ', bin: '123456789012', address: 'Алматы, ул. Тестовая 1', phone: '+77001234567', email: orgEmail, defaultCurrencyId: null, accountOwnerUserId: null, }, adminLastName: 'Тестов', adminFirstName: 'Админ', adminEmail: `admin-${TIMESTAMP}@example.kz`, adminPosition: 'Директор', }) check(step, { kind: 'api', description: `POST /api/super-admin/organizations → 200`, ok: res.status === 200, detail: res.status === 200 ? `org=${res.data?.organization?.name}` : asString(res.data), }) if (res.status !== 200) { report.bug({ step: '01', severity: 'critical', title: 'Не удаётся создать организацию через API SuperAdmin', detail: `POST /api/super-admin/organizations вернул ${res.status} ${asString(res.data)}`, fix: 'Проверь что таблицы tenant-bootstrap (employee_roles системные) сохранены при reset_db.', }) return } ctx.organization = { id: res.data.organization.id, name: res.data.organization.name } ctx.adminEmail = res.data.adminEmail ctx.adminTempPassword = res.data.adminTempPassword // List ↦ должен возвращать новую org const list = await api.get('/api/super-admin/organizations?archived=false&pageSize=200') const found = list.data?.items?.some((o: { id: string }) => o.id === ctx.organization!.id) check(step, { kind: 'api', description: 'GET /api/super-admin/organizations включает созданную org', ok: !!found, detail: found ? '' : `total=${list.data?.total}`, }) // ФЛК телефона: проверим что орг с невалидным KZ-телефоном (например +71234567890) // отвергается сервером. Делаем pure-API smoke без UI — это проверка backend-validation. const bad = await api.post('/api/super-admin/organizations', { org: { name: `Bad Phone ${TIMESTAMP}`, countryCode: 'KZ', bin: null, address: null, phone: 'abc', email: null, defaultCurrencyId: null, accountOwnerUserId: null, }, adminLastName: 'X', adminFirstName: 'Y', adminEmail: `bad-${TIMESTAMP}@example.kz`, adminPosition: null, }) // Здесь backend может НЕ валидировать phone (это поле опциональное и без regex'а // на уровне домена). Поэтому статус 200 — это logic gap, отметим: if (bad.status === 200) { report.gap('SuperAdmin /organizations принимает любой текст в поле phone — серверной валидации ФЛК нет (только в /api/auth/signup для самозаполнения).') } else { check(step, { kind: 'api', description: 'Невалидный phone отвергается', ok: bad.status >= 400 && bad.status < 500, detail: `${bad.status}`, }) } } // --------------------------------------------------------------------------- export async function step02_create_first_admin({ ctx, step, report }: StepCtx) { // SuperAdmin при создании org уже создаёт первого Admin'a (см. step01). // Проверяем что temp password выдан и в БД появился Employee + Identity-Admin. if (!ctx.organization || !ctx.adminEmail) { step.status = 'skip'; step.notes.push('Нет organization из step 01 — пропускаем.') return } check(step, { kind: 'api', description: 'Temp password возвращён CreateOrgResult', ok: !!ctx.adminTempPassword && ctx.adminTempPassword.length >= 8, detail: ctx.adminTempPassword ? `len=${ctx.adminTempPassword.length}` : 'empty', }) const employeesCount = countRows('employees', `"OrganizationId" = '${ctx.organization.id}'`) check(step, { kind: 'db', description: `employees содержит ровно 1 запись для новой org`, ok: employeesCount === 1, detail: `count=${employeesCount}`, }) // Проверим что AspNetUserRoles содержит role=Admin для нового user-id. const userIdRows = psql( `SELECT u."Id" FROM users u WHERE u."Email" = '${ctx.adminEmail}';`).trim() if (!userIdRows) { check(step, { kind: 'db', description: 'AppUser существует', ok: false, detail: 'не найден' }) return } const userId = userIdRows const roleRows = psql( `SELECT r."Name" FROM "AspNetUserRoles" ur JOIN roles r ON r."Id" = ur."RoleId" WHERE ur."UserId" = '${userId}';`).trim().split('\n') const hasAdmin = roleRows.includes('Admin') check(step, { kind: 'db', description: 'AspNetUserRoles содержит role=Admin для нового user', ok: hasAdmin, detail: roleRows.join(','), }) if (!hasAdmin) { report.bug({ step: '02', severity: 'high', title: 'Identity-роль Admin не присвоена при создании организации', detail: 'CreateOrg в SuperAdminOrganizationsController должен вызывать UserManager.AddToRoleAsync(user, "Admin")', }) } } // --------------------------------------------------------------------------- export async function step03_login_as_admin({ ctx, step, report }: StepCtx) { if (!ctx.adminEmail || !ctx.adminTempPassword) { step.status = 'skip'; step.notes.push('Нет creds admin'); return } try { const sess = await login(ctx.adminEmail, ctx.adminTempPassword) ctx.adminToken = sess.accessToken check(step, { kind: 'api', description: '/connect/token password-grant выдал токен', ok: !!sess.accessToken, }) check(step, { kind: 'api', description: '/api/me содержит role=Admin', ok: sess.roles.includes('Admin'), detail: sess.roles.join(','), }) check(step, { kind: 'api', description: '/api/me содержит правильный orgId', ok: sess.orgId === ctx.organization?.id, detail: sess.orgId ?? 'null', }) } catch (e) { check(step, { kind: 'api', description: 'login admin', ok: false, detail: (e as Error).message }) report.bug({ step: '03', severity: 'critical', title: 'Свежесозданный Admin не может залогиниться с temp password', detail: (e as Error).message, }) } } // --------------------------------------------------------------------------- export async function step04_create_storekeeper_and_cashier({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.organization) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) // Получаем list ролей; нужны Storekeeper (системная) и Кассир (системная). const rolesRes = await api.get('/api/organization/employee-roles?pageSize=200') check(step, { kind: 'api', description: 'employee-roles list', ok: rolesRes.status === 200, detail: `${rolesRes.status}, total=${rolesRes.data?.total}`, }) const roles = (rolesRes.data?.items ?? []) as { id: string; name: string; isSystem: boolean }[] const keeperRole = roles.find((r) => r.name === 'Кладовщик') const cashierRole = roles.find((r) => r.name === 'Кассир') check(step, { kind: 'api', description: 'Системная роль «Кладовщик» существует', ok: !!keeperRole, }) check(step, { kind: 'api', description: 'Системная роль «Кассир» существует', ok: !!cashierRole, }) if (!keeperRole || !cashierRole) { report.bug({ step: '04', severity: 'high', title: 'Не сидируются системные роли при создании org', detail: `roles list = ${roles.map((r) => r.name).join(', ')}`, fix: 'DevDataSeeder.SeedEmployeeRolesAsync должен вызываться после CreateOrg.', }) return } ctx.storekeeperEmail = `keeper-${TIMESTAMP}@example.kz` ctx.cashierEmail = `cashier-${TIMESTAMP}@example.kz` for (const [emailField, roleId, lastName, firstName, position] of [ [ctx.storekeeperEmail, keeperRole.id, 'Кладовщиков', 'Иван', 'Кладовщик'], [ctx.cashierEmail, cashierRole.id, 'Кассиров', 'Пётр', 'Кассир'], ] as const) { const res = await api.post('/api/organization/employees', { lastName, firstName, middleName: null, position, email: emailField, phone: '+77002223344', salary: null, taxNumber: null, description: null, imageUrl: null, roleId, isActive: true, retailPointIds: [], createAccount: true, }) check(step, { kind: 'api', description: `POST /api/organization/employees (${position})`, ok: res.status === 200, detail: `${res.status} ${res.status !== 200 ? asString(res.data) : ''}`, }) if (res.status === 200) { const tempPwd = res.data?.generatedPassword as string | undefined if (position === 'Кладовщик') ctx.storekeeperTempPassword = tempPwd else ctx.cashierTempPassword = tempPwd } } const total = countRows('employees', `"OrganizationId" = '${ctx.organization.id}'`) check(step, { kind: 'db', description: 'employees total = 3 (admin + keeper + cashier)', ok: total === 3, detail: `count=${total}`, }) // Валидация: невалидный email должен быть отвергнут. const bad = await api.post('/api/organization/employees', { lastName: 'Bad', firstName: 'Email', middleName: null, position: null, email: 'not-an-email', phone: null, salary: null, taxNumber: null, description: null, imageUrl: null, roleId: cashierRole.id, isActive: true, retailPointIds: [], createAccount: true, }) if (bad.status === 200) { report.gap('POST /api/organization/employees принимает невалидный email при createAccount=true (Identity создаёт юзера с UserName=not-an-email).') } else { check(step, { kind: 'api', description: 'Невалидный email отвергается при createAccount', ok: bad.status >= 400, detail: `${bad.status}`, }) } } // --------------------------------------------------------------------------- export async function step05_login_as_cashier({ ctx, step, report }: StepCtx) { if (!ctx.cashierEmail || !ctx.cashierTempPassword) { step.status = 'skip'; return } let cashier try { cashier = await login(ctx.cashierEmail, ctx.cashierTempPassword) } catch (e) { check(step, { kind: 'api', description: 'login Cashier', ok: false, detail: (e as Error).message }) report.bug({ step: '05', severity: 'critical', title: 'Cashier не может залогиниться с выданным паролем', detail: (e as Error).message, }) return } check(step, { kind: 'api', description: '/api/me содержит роль соответствующую системной Cashier', ok: cashier.roles.length > 0, // Identity-роль может отсутствовать (используются org-роли), detail: cashier.roles.join(',') || 'no Identity roles', }) // Identity-роль для Cashier — "Cashier"? Проверим. Если она НЕ присвоена, // это значит role-guard на сервере основан только на Identity-роли Admin // (см. Authorize(Roles="Admin")). Cashier тогда всегда 403 на admin-ресурсах, // но и на /sales/retail может не пройти если там стоит [Authorize(Roles="Cashier")]. if (!cashier.roles.includes('Cashier') && !cashier.roles.includes('Admin')) { report.gap('Cashier у созданного через POST /employees не получает Identity-роль "Cashier" — серверная авторизация на /api/sales/retail требует роли Admin или Cashier; нужно либо сидить Identity-роль из org-роли, либо переделать [Authorize] на permissions.') } const apiCashier = makeClient(cashier.accessToken) // /settings/employees должен быть 403 для Cashier. const empRes = await apiCashier.get('/api/organization/employees') check(step, { kind: 'api', description: 'Cashier → GET /api/organization/employees → 403', ok: empRes.status === 403, detail: `${empRes.status}`, }) if (empRes.status !== 403) { if (empRes.status === 200) { report.bug({ step: '05', severity: 'critical', title: 'Cashier ВИДИТ список сотрудников через API', detail: 'GET /api/organization/employees вернул 200 для Cashier; ожидается 403 (нет [Authorize(Roles="Admin")] на List? или Cashier имеет Identity-Admin)', fix: 'Поставить [Authorize(Roles="Admin")] на List + проверить что AddToRoleAsync(...,"Cashier") а не "Admin")', }) } } const salesRes = await apiCashier.get('/api/sales/retail-sales?pageSize=10') check(step, { kind: 'api', description: 'Cashier → GET /api/sales/retail-sales — доступен', ok: salesRes.status === 200 || salesRes.status === 404, detail: `${salesRes.status}`, }) } // --------------------------------------------------------------------------- export async function step06_create_counterparty({ ctx, step, report }: StepCtx) { if (!ctx.adminToken) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) // Получим страну KZ const countries = await api.get('/api/catalog/countries?pageSize=200') const kz = (countries.data?.items as { id: string; code: string }[] | undefined) ?.find((c) => c.code === 'KZ') if (!kz) { report.bug({ step: '06', severity: 'high', title: 'Страна KZ отсутствует в справочнике', detail: '' }) } const res = await api.post('/api/catalog/counterparties', { name: 'ТОО Тест Поставщик', legalName: 'Товарищество с ограниченной ответственностью «Тест Поставщик»', type: 0, // LegalEntity bin: '987654321098', iin: null, taxNumber: null, countryId: kz?.id ?? null, address: 'Алматы, ул. Поставщиков 1', phone: '+77003332211', email: 'supplier@example.kz', bankName: null, bankAccount: null, bik: null, contactPerson: 'Иванов И.И.', notes: null, }) check(step, { kind: 'api', description: 'POST /api/catalog/counterparties', ok: res.status === 200 || res.status === 201, detail: `${res.status} ${res.status >= 400 ? asString(res.data) : ''}`, }) if (res.status === 200 || res.status === 201) { ctx.counterpartyId = res.data?.id } else { report.bug({ step: '06', severity: 'critical', title: 'Создание контрагента падает', detail: `${res.status} ${asString(res.data)}`, }) } } // --------------------------------------------------------------------------- export async function step07_ensure_main_store({ ctx, step, report }: StepCtx) { if (!ctx.adminToken) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) const list = await api.get('/api/catalog/stores?pageSize=200') check(step, { kind: 'api', description: 'GET /api/catalog/stores', ok: list.status === 200, detail: `${list.status}`, }) const stores = (list.data?.items ?? []) as { id: string; name: string; isMain: boolean }[] let main = stores.find((s) => s.isMain) ?? stores[0] if (!main) { const create = await api.post('/api/catalog/stores', { name: 'Main', code: 'MAIN', address: null, phone: null, managerName: null, isMain: true, isActive: true, }) check(step, { kind: 'api', description: 'Main store создан вручную', ok: create.status === 200 || create.status === 201, detail: `${create.status}`, }) if (create.status >= 400) { report.bug({ step: '07', severity: 'high', title: 'Main store не сидируется автоматически и не создаётся вручную', detail: asString(create.data), }) return } main = create.data } else { check(step, { kind: 'db', description: 'Main store существует (от bootstrap)', ok: true, detail: main.name, }) } ctx.storeId = main!.id } // --------------------------------------------------------------------------- export async function step08_create_supply({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.counterpartyId || !ctx.storeId) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) // Берём 3 произвольных products. Реестр products в БД tenant-scoped // (ITenantEntity), поэтому после создания новой org `GET /api/catalog/ // products` возвращает 0 — старые products принадлежат другому tenant'у. // Это logic-gap описанный в отчёте; для прогона сценария создаём 3 // products в новой org прямо сейчас. const products = await api.get('/api/catalog/products?pageSize=5') let items = (products.data?.items ?? []) as { id: string; name: string; unitId?: string }[] if (items.length < 3) { report.gap('Реестр products tenant-scoped: новая org стартует с пустым каталогом, хотя в БД лежат products другой org. e2e-сценарий компенсирует созданием 3 products через API.') // Получим первую unit-of-measure (системную или из org). const unitsRes = await api.get('/api/catalog/units-of-measure?pageSize=10') const unit = (unitsRes.data?.items ?? [])[0] as { id: string; name: string } | undefined if (!unit) { report.bug({ step: '08', severity: 'high', title: 'Нет ни одной единицы измерения для нового tenant', detail: 'Bootstrap должен сидить системные units (шт, кг, л) при создании org.', }) return } const created: { id: string; name: string }[] = [] for (let i = 0; i < 3; i++) { const cr = await api.post('/api/catalog/products', { name: `e2e Product ${i + 1} ${TIMESTAMP}`, article: `E2E-${TIMESTAMP}-${i + 1}`, barcode: null, unitId: unit.id, groupId: null, retailPrice: 100 + i * 50, purchasePrice: 70 + i * 30, isActive: true, }) if (cr.status >= 400) { report.bug({ step: '08', severity: 'high', title: `Не удалось создать product №${i + 1}`, detail: `${cr.status} ${asString(cr.data).slice(0, 200)}`, }) return } created.push({ id: cr.data.id ?? cr.data.productId, name: cr.data.name }) } items = created.map((p) => ({ id: p.id, name: p.name })) check(step, { kind: 'api', description: 'Auto-created 3 products для нового tenant', ok: true, detail: items.map((i) => i.name).join(', '), }) } const lines = items.slice(0, 3).map((p, i) => ({ productId: p.id, productName: p.name, quantity: 10 + i * 5, price: 100 + i * 50, })) ctx.supplyLines = lines // Сохраняем stock-snapshot ДО приёмки. const stockBefore: Record = {} for (const ln of lines) stockBefore[ln.productId] = await stockOf(api, ctx.storeId, ln.productId) ctx.stockBefore = stockBefore const draft = await api.post('/api/purchases/supplies', { counterpartyId: ctx.counterpartyId, storeId: ctx.storeId, docDate: new Date().toISOString(), description: 'e2e draft', lines: lines.map((l) => ({ productId: l.productId, quantity: l.quantity, price: l.price, })), }) check(step, { kind: 'api', description: 'POST /api/purchases/supplies (Draft)', ok: draft.status === 200 || draft.status === 201, detail: `${draft.status} ${draft.status >= 400 ? asString(draft.data).slice(0, 200) : ''}`, }) if (draft.status >= 400) { report.bug({ step: '08', severity: 'critical', title: 'Не удаётся создать Draft Supply', detail: asString(draft.data), }) return } ctx.supplyId = draft.data?.id ?? draft.data?.supplyId if (!ctx.supplyId) { report.bug({ step: '08', severity: 'high', title: 'POST /supplies не возвращает id созданного документа', detail: asString(draft.data).slice(0, 200), }) return } const post = await api.post(`/api/purchases/supplies/${ctx.supplyId}/post`, {}) check(step, { kind: 'api', description: 'POST /api/purchases/supplies/{id}/post (Draft → Posted)', ok: post.status === 200 || post.status === 204, detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`, }) } // --------------------------------------------------------------------------- export async function step09_check_stock_after_supply({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.supplyLines || !ctx.storeId) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) const after: Record = {} for (const ln of ctx.supplyLines) { after[ln.productId] = await stockOf(api, ctx.storeId, ln.productId) } ctx.stockAfterSupply = after for (const ln of ctx.supplyLines) { const before = ctx.stockBefore![ln.productId] ?? 0 const now = after[ln.productId] ?? 0 const delta = now - before check(step, { kind: 'api', description: `stock(${ln.productName}) +${ln.quantity} (было ${before}, стало ${now})`, ok: delta === ln.quantity, detail: `delta=${delta}, expected=${ln.quantity}`, }) if (delta !== ln.quantity) { report.bug({ step: '09', severity: 'high', title: 'Остатки не увеличились после Posted Supply', detail: `product=${ln.productName} expected delta=${ln.quantity} actual=${delta}`, fix: 'Проверь что SupplyPostHandler / hostedService применяет stock_movements при /post', }) } } } // --------------------------------------------------------------------------- export async function step10_ensure_retail_point({ ctx, step, report }: StepCtx) { if (!ctx.adminToken) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) const list = await api.get('/api/catalog/retail-points?pageSize=200') const items = (list.data?.items ?? []) as { id: string; name: string }[] if (items.length === 0) { const create = await api.post('/api/catalog/retail-points', { name: 'Касса 1', code: 'C1', storeId: ctx.storeId, isActive: true, }) check(step, { kind: 'api', description: 'Создана касса', ok: create.status === 200 || create.status === 201, detail: `${create.status}`, }) ctx.retailPointId = create.data?.id } else { ctx.retailPointId = items[0].id check(step, { kind: 'api', description: 'RetailPoint существует', ok: true, detail: items[0].name }) } if (!ctx.retailPointId) { report.bug({ step: '10', severity: 'high', title: 'Не получилось гарантировать наличие розничной точки', detail: '', }) } } // --------------------------------------------------------------------------- export async function step11_create_retail_sale({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.retailPointId || !ctx.supplyLines) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) const lines = ctx.supplyLines.slice(0, 2).map((l) => ({ productId: l.productId, quantity: 2, price: l.price * 2, // продаём по 2 шт. })) ctx.saleLines = lines.map((l) => ({ productId: l.productId, quantity: l.quantity })) const draft = await api.post('/api/sales/retail-sales', { retailPointId: ctx.retailPointId, docDate: new Date().toISOString(), description: 'e2e sale', lines, }) check(step, { kind: 'api', description: 'POST /api/sales/retail-sales (Draft)', ok: draft.status === 200 || draft.status === 201, detail: `${draft.status} ${draft.status >= 400 ? asString(draft.data).slice(0, 200) : ''}`, }) if (draft.status >= 400) { report.bug({ step: '11', severity: 'critical', title: 'Не удаётся создать Draft RetailSale', detail: asString(draft.data), }) return } ctx.retailSaleId = draft.data?.id ?? draft.data?.saleId if (!ctx.retailSaleId) { report.bug({ step: '11', severity: 'high', title: 'POST /retail-sales не возвращает id', detail: asString(draft.data).slice(0, 200), }) return } const post = await api.post(`/api/sales/retail-sales/${ctx.retailSaleId}/post`, {}) check(step, { kind: 'api', description: 'POST /retail-sales/{id}/post', ok: post.status === 200 || post.status === 204, detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`, }) } // --------------------------------------------------------------------------- export async function step12_check_stock_after_sale({ ctx, step, report }: StepCtx) { if (!ctx.adminToken || !ctx.saleLines || !ctx.storeId || !ctx.stockAfterSupply) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken) for (const ln of ctx.saleLines) { const before = ctx.stockAfterSupply[ln.productId] ?? 0 const now = await stockOf(api, ctx.storeId, ln.productId) const delta = before - now check(step, { kind: 'api', description: `stock product=${ln.productId.slice(0, 8)}… −${ln.quantity} (было ${before}, стало ${now})`, ok: delta === ln.quantity, detail: `delta=${delta}, expected=${ln.quantity}`, }) if (delta !== ln.quantity) { report.bug({ step: '12', severity: 'high', title: 'Остатки не уменьшились после Posted RetailSale', detail: `expected delta=${ln.quantity} actual=${delta}`, }) } } } // --------------------------------------------------------------------------- async function stockOf(api: ReturnType, storeId: string, productId: string): Promise { const res = await api.get(`/api/inventory/stock?productId=${productId}&pageSize=200`) if (res.status !== 200) return 0 const items = (res.data?.items ?? []) as { storeId?: string; quantity: number; productId: string }[] // Fallback: если нет фильтра по storeId на endpoint'е, фильтруем сами. const row = items.find((i) => i.storeId === storeId && i.productId === productId) return row?.quantity ?? 0 }