feat(#190): метрики качества первички (AURORA-1759) #229
@ -6,18 +6,20 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit, get_request_audit
|
||||
from src.api.dependencies.auth import require
|
||||
from src.api.dependencies.quality_metrics import QualityMetricsServiceDep
|
||||
from src.common.filenames import content_disposition
|
||||
from src.models.enums import QualityCause
|
||||
from src.schemas.common import Page, PageParams
|
||||
from src.schemas.common import Page
|
||||
from src.schemas.quality_metrics import (
|
||||
ExportFormat,
|
||||
QualityCell,
|
||||
QualityCellDocumentOut,
|
||||
QualityCellParams,
|
||||
QualityExportParams,
|
||||
QualityFilters,
|
||||
QualityMatrixOut,
|
||||
QualityMonthlySeriesOut,
|
||||
QualitySeriesParams,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
@ -36,30 +38,27 @@ async def get_matrix(
|
||||
|
||||
@router.get("/series", summary="Ряд по месяцам даты приёма для причины")
|
||||
async def get_series(
|
||||
service: QualityMetricsServiceDep,
|
||||
filters: Annotated[QualityFilters, Query()],
|
||||
cause: Annotated[QualityCause, Query()],
|
||||
service: QualityMetricsServiceDep, params: Annotated[QualitySeriesParams, Query()]
|
||||
) -> QualityMonthlySeriesOut:
|
||||
return await service.series(filters, cause)
|
||||
return await service.series(params, params.cause)
|
||||
|
||||
|
||||
@router.get("/cells/{cell}/documents", summary="Документы ячейки матрицы")
|
||||
async def get_cell_documents(
|
||||
cell: QualityCell,
|
||||
service: QualityMetricsServiceDep,
|
||||
filters: Annotated[QualityFilters, Query()],
|
||||
page: Annotated[PageParams, Query()],
|
||||
params: Annotated[QualityCellParams, Query()],
|
||||
) -> Page[QualityCellDocumentOut]:
|
||||
"""Ячейка — причина (`QualityCause`), `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM` или `IN_PROGRESS`."""
|
||||
return await service.cell_documents(filters, cell, limit=page.limit, offset=page.offset)
|
||||
return await service.cell_documents(params, cell, limit=params.limit, offset=params.offset)
|
||||
|
||||
|
||||
@router.get("/export", response_class=Response, summary="Выгрузка фактов за период (CSV / JSON)")
|
||||
async def export_facts(
|
||||
service: QualityMetricsServiceDep,
|
||||
filters: Annotated[QualityFilters, Query()],
|
||||
fmt: Annotated[ExportFormat, Query(alias="format")] = "csv",
|
||||
params: Annotated[QualityExportParams, Query()],
|
||||
audit: Annotated[RequestAudit, Depends(get_request_audit)],
|
||||
) -> Response:
|
||||
content, media_type, ext = await service.export(filters, fmt)
|
||||
content, media_type, ext = await service.export(params, params.fmt, audit)
|
||||
header = content_disposition(f"quality-facts.{ext}", ascii_fallback=f"quality-facts.{ext}")
|
||||
return Response(content=content, media_type=media_type, headers={"Content-Disposition": header})
|
||||
|
||||
@ -6,19 +6,23 @@ import enum
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from src.models.enums import DocumentType, QualityCause, QualityOutcome, RecognitionPipeline
|
||||
from src.schemas.common import DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT
|
||||
|
||||
__all__ = [
|
||||
"ExportFormat",
|
||||
"MonthPointOut",
|
||||
"QualityCell",
|
||||
"QualityCellDocumentOut",
|
||||
"QualityCellParams",
|
||||
"QualityExportParams",
|
||||
"QualityFilters",
|
||||
"QualityFixesOut",
|
||||
"QualityMatrixOut",
|
||||
"QualityMonthlySeriesOut",
|
||||
"QualitySeriesParams",
|
||||
"TechnicalFailuresOut",
|
||||
]
|
||||
|
||||
@ -58,6 +62,28 @@ class QualityFilters(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class QualitySeriesParams(QualityFilters):
|
||||
"""Query `/series`: фильтры + причина. FastAPI разворачивает `Annotated[Model, Query()]` в параметры, только если
|
||||
это единственный query-параметр маршрута, поэтому дополнительные поля — в наследнике, а не рядом."""
|
||||
|
||||
cause: QualityCause
|
||||
|
||||
|
||||
class QualityCellParams(QualityFilters):
|
||||
"""Query `/cells/{cell}/documents`: фильтры + пагинация (ограничения как у `PageParams`)."""
|
||||
|
||||
limit: int = Field(DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT)
|
||||
offset: int = Field(0, ge=0)
|
||||
|
||||
|
||||
class QualityExportParams(QualityFilters):
|
||||
"""Query `/export`: фильтры + формат (`?format=csv|json`)."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
fmt: ExportFormat = Field("csv", alias="format")
|
||||
|
||||
|
||||
class TechnicalFailuresOut(BaseModel):
|
||||
"""Технические отказы считаются по файлам (design §6); фильтр по типу документа на них не действует.
|
||||
`total_files_count` — уникальные файлы с фактом приёма или отказа в выбранных период/ГК/ЮЛ/источнике/скане."""
|
||||
|
||||
@ -22,13 +22,14 @@ from fastapi.encoders import jsonable_encoder
|
||||
from sqlalchemy import Select, case, distinct, func, select, union
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit
|
||||
from src.domain.quality_metrics import (
|
||||
QualityOutcomeFactRow,
|
||||
compute_matrix,
|
||||
compute_monthly_series,
|
||||
)
|
||||
from src.models.access import AppUser
|
||||
from src.models.enums import QualityCause, QualityOutcome
|
||||
from src.models.enums import AuditAction, AuditSection, QualityCause, QualityOutcome
|
||||
from src.models.files import Document
|
||||
from src.models.quality_facts import QualityFailureFact, QualityIntakeFact, QualityOutcomeFact
|
||||
from src.schemas.common import Page
|
||||
@ -50,6 +51,8 @@ _IN_DENOMINATOR = (QualityOutcome.AUTO_CONFIRMED, QualityOutcome.CONFIRMED)
|
||||
_BEFORE_CONFIRM = (QualityOutcome.DELETED, QualityOutcome.SUPERSEDED)
|
||||
_AFTER_CONFIRM = (QualityOutcome.DELETED_AFTER_CONFIRM, QualityOutcome.SUPERSEDED_AFTER_CONFIRM)
|
||||
|
||||
_HEAVY_OUTCOME_COLUMNS = ("payload", "payload_schema_version") # в DISTINCT ON не тянем: метрикам не нужны
|
||||
|
||||
EXPORT_COLUMNS = (
|
||||
"document_id", "file_id", "legal_entity_id", "legal_entity_name", "group_id", "group_name", "document_type",
|
||||
"pipeline", "is_scan", "service_name", "service_version", "intake_at", "intake_status", "report_date_recognized",
|
||||
@ -67,8 +70,9 @@ def _latest_outcomes(): # noqa: ANN202 - Subquery
|
||||
"""Последний факт исхода на `document_id` (см. модульную строку)."""
|
||||
outcome = QualityOutcomeFact.outcome
|
||||
rank = case((outcome.in_(_AFTER_CONFIRM), 2), (outcome.in_(_IN_DENOMINATOR), 1), else_=0)
|
||||
columns = [c for c in QualityOutcomeFact.__table__.c if c.name not in _HEAVY_OUTCOME_COLUMNS]
|
||||
return (
|
||||
select(QualityOutcomeFact)
|
||||
select(*columns)
|
||||
.distinct(QualityOutcomeFact.document_id)
|
||||
.order_by(
|
||||
QualityOutcomeFact.document_id,
|
||||
@ -337,9 +341,22 @@ class QualityMetricsService:
|
||||
).order_by(i.intake_at, i.document_id) # fmt: skip
|
||||
return [dict(r._mapping) for r in (await self._session.execute(stmt)).all()]
|
||||
|
||||
async def export(self, f: QualityFilters, fmt: ExportFormat) -> tuple[bytes, str, str]:
|
||||
"""Файл выгрузки: (содержимое, media type, расширение). CSV — UTF-8 с BOM (для Excel), разделитель — запятая."""
|
||||
async def export(self, f: QualityFilters, fmt: ExportFormat, audit: RequestAudit) -> tuple[bytes, str, str]:
|
||||
"""Файл выгрузки: (содержимое, media type, расширение). CSV — UTF-8 с BOM (для Excel), разделитель — запятая.
|
||||
Массовая выгрузка с названиями ЮЛ оставляет `audit_event(EXPORT)` (R-12): отчёт, фильтры, формат, число
|
||||
строк; запись — в транзакции запроса, как у экспорта сводной ОСВ."""
|
||||
records = await self.export_records(f)
|
||||
await audit.record(
|
||||
action=AuditAction.EXPORT,
|
||||
section=AuditSection.EXPORT,
|
||||
object_type="quality_metrics",
|
||||
after_value={
|
||||
"report": "quality_metrics",
|
||||
"format": fmt,
|
||||
"filters": f.model_dump(mode="json", exclude_none=True, include=set(QualityFilters.model_fields)),
|
||||
"rows": len(records),
|
||||
},
|
||||
)
|
||||
if fmt == "json":
|
||||
return (
|
||||
json.dumps(jsonable_encoder(records), ensure_ascii=False).encode(),
|
||||
|
||||
@ -1119,7 +1119,7 @@ async def _failure_dimensions(session: AsyncSession, file_id: uuid.UUID) -> _Fai
|
||||
|
||||
async def _write_mapping_failure_fact(session: AsyncSession, job: RecognitionJob, *, failed_at: datetime) -> None:
|
||||
"""Факт отказа `MAPPING` (§14.5) — `job` уже дошёл до `done`/`needs_check` (`service_name`/`attempt` сервиса
|
||||
известны, в отличие от отказов обмена задачи 10 sites 1-4), а маппинг результата не удался."""
|
||||
известны, в отличие от отказов обмена задачи 10 sites 1-3), а маппинг результата не удался."""
|
||||
dims = await _failure_dimensions(session, job.file_id)
|
||||
if dims is None:
|
||||
logger.warning(
|
||||
|
||||
@ -14,13 +14,17 @@ from typing import Any
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.dependencies.auth import current_user
|
||||
from src.api.router import router
|
||||
from src.core.exception_handlers import register_exception_handlers
|
||||
from src.db.db import get_async_session
|
||||
from src.models.audit_ex import AuditEvent
|
||||
from src.models.enums import (
|
||||
AuditAction,
|
||||
AuditSection,
|
||||
DocumentStatus,
|
||||
DocumentType,
|
||||
QualityCause,
|
||||
@ -57,7 +61,7 @@ async def _forbidden_client(permissions: frozenset[str]) -> AsyncIterator[httpx.
|
||||
|
||||
async def principal() -> UserWithAccess:
|
||||
return UserWithAccess(
|
||||
user=SimpleNamespace(),
|
||||
user=SimpleNamespace(id=uuid.uuid4()),
|
||||
regional_office=SimpleNamespace(),
|
||||
roles=(),
|
||||
permissions=permissions, # type: ignore[arg-type]
|
||||
@ -216,6 +220,28 @@ async def test_quality_metrics_matrix_dedupes_to_latest_outcome_fact(
|
||||
assert [r["outcome"] for r in export] == ["SUPERSEDED_AFTER_CONFIRM"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_quality_metrics_export_writes_audit_event(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group = uuid.uuid4()
|
||||
await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS, group_id=group, group_name="ГК")
|
||||
await _doc(seeded, QualityOutcome.CONFIRMED, QualityCause.AS_IS)
|
||||
client = await auth_client(KA_RF)
|
||||
response = await client.get(f"{BASE}/export", params={"format": "json", "group_id": str(group)})
|
||||
assert response.status_code == HTTP_OK
|
||||
events = (await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).scalars().all()
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event.section is AuditSection.EXPORT
|
||||
assert event.object_type == "quality_metrics"
|
||||
assert event.user_id is not None
|
||||
assert event.after_value == {
|
||||
"report": "quality_metrics",
|
||||
"format": "json",
|
||||
"filters": {"group_id": str(group)},
|
||||
"rows": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_quality_metrics_equal_outcome_at_prefers_after_confirm(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
|
||||
@ -2415,7 +2415,7 @@ async def test_file_reprocess_keeps_failure_fact_of_first_attempt_and_adds_new_i
|
||||
file_id, job1_id = file.id, job1.id
|
||||
|
||||
# первая попытка: вахтёр переводит задание в timeout -> файл ERROR -> факт отказа TIMEOUT (attempt=1, задача 10
|
||||
# sites 1-4, уже на этой ветке)
|
||||
# sites 1-3, уже на этой ветке)
|
||||
assert await expire_deadlines(maker) == 1
|
||||
assert (await _file(maker, file_id)).status is FileStatus.ERROR
|
||||
|
||||
|
||||
@ -240,7 +240,7 @@ PDF, точный вид выгрузки, разбиение на задачи.
|
||||
| 07.11.2026 | Task 15 (API): последний факт исхода на документ — `DISTINCT ON (document_id) ORDER BY document_id, outcome_at DESC, ранг DESC, id DESC` во всех запросах; ранг (`*_AFTER_CONFIRM` 2, подтверждение 1, остальное 0) совпадает с tie-break домена | SQL и домен должны выбирать один и тот же факт | расхождение выбора даёт разные числа в матрице и в списке ячейки |
|
||||
| 07.11.2026 | Task 15: выборка идёт от фактов приёма (LEFT JOIN последнего исхода); исход без факта приёма в метрики не входит; границы периода включительные по UTC (`date_to` отсекается в 00:00 UTC следующего дня) | у исхода без приёма нет даты приёма (когорта); UTC — единая зона БД | для пользователей в МСК граница периода сдвинута на 3 часа |
|
||||
| 07.11.2026 | Task 15: технические отказы — поле `technical_failures {failed_files_count, total_files_count, share}`, период по `failed_at`, `total_files_count` — уникальные `file_id` из приёма и отказов; «в работе» (`in_progress_count`) и `unique_reports_count` (ЮЛ + отчётная дата + тип) — отдельные поля; в ответе отдельно `superseded_after_confirm_count`, `deleted_after_confirm_count` | design §6, решение 27.10 про отказы по файлам | доля отказов считается от другого знаменателя, чем доли причин |
|
||||
| 07.11.2026 | Task 15: ячейки списка документов — `{cell}`: пять причин, `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM`, `IN_PROGRESS`; в списке `confirmed_by_id/name`, `document_link` (null, если документа нет); экспорт — CSV (UTF-8 с BOM, запятая, значения с формулой экранируются) и JSON, включает документы «в работе», аудит `EXPORT` не пишется (нет в `audit_action`) | design §7/§8; ячейки шире причин | выгрузка не оставляет следа в аудите; Excel может показать BOM в чужих импортёрах |
|
||||
| 07.11.2026 | Task 15: ячейки списка документов — `{cell}`: пять причин, `REMOVED_BEFORE_CONFIRM`, `REMOVED_AFTER_CONFIRM`, `IN_PROGRESS`; в списке `confirmed_by_id/name`, `document_link` (null, если документа нет); экспорт — CSV (UTF-8 с BOM, запятая, значения с формулой экранируются) и JSON, включает документы «в работе», аудит `EXPORT` пишется (см. R-12; исходное «нет в `audit_action`» было неверно) | design §7/§8; ячейки шире причин | выгрузка не оставляет следа в аудите; Excel может показать BOM в чужих импортёрах |
|
||||
| 07.11.2026 | Task 15: матрица читает факты периода в Python (8 лёгких колонок), подзапрос последних исходов не ограничен периодом, выгрузка целиком в памяти без стриминга и лимита (T15.2, T15.3) | первая версия, домен остаётся эталоном; без Docker производительность не измерить | на десятках тысяч документов запросы замедлятся — перенести агрегацию в GROUP BY |
|
||||
| 07.11.2026 | Task 16 (R-10): на экране нет фильтра по ЮЛ (есть по ГК, периоду, типу, источнику, скану); API параметр `legal_entity_id` поддерживает | нужен отдельный пикер ЮЛ (тысячи записей); владелец: «главное наличие данных» | в UI нельзя посмотреть качество по одному ЮЛ — только через API/выгрузку |
|
||||
| 07.11.2026 | Task 16 (R-11): матрица «источник × причина» — три параллельных запроса (`pipeline=image`, `pipeline=tabular`, без `pipeline`), колонки «PDF / Excel / Всего»; при выбранном источнике — одна колонка; «Всего» берётся с бэка, не суммой колонок | design §2/§7 требуют сравнения PDF и Excel; бэкенд отдаёт источник только фильтром | три запроса вместо одного на каждую смену фильтров |
|
||||
@ -248,3 +248,4 @@ PDF, точный вид выгрузки, разбиение на задачи.
|
||||
| 07.11.2026 | Task 16: `Sidebar` снова фильтрует пункты по `permission` (поле было в типе, но не применялось после слияния репозиториев); пункт «Пользователи и роли» скрыт без `USERS_ADMIN`; `web/CLAUDE.md` исправлен | это восстановление прежнего поведения (коммит d3c2f8a); сервер и раньше отдавал 403 на эти страницы | у ролей без `USERS_ADMIN` пункт меню пропадает (раньше вёл на экран 403) |
|
||||
| 07.11.2026 | Task 16: тесты фронта — на чистые функции (`qualityView.ts`), не на рендер: в проекте нет jsdom/testing-library; экран в браузере не проверялся (нет стенда); экспорт — через `apiFetchBlob` (токен), `getExportUrl` из брифа не создан | нет инфраструктуры рендер-тестов; обычная ссылка не передаёт токен | раскладка, Drawer и скачивание проверены только чтением кода — обязательна ручная проверка на стенде до merge |
|
||||
| 07.11.2026 | Выкладка: пункт меню «Качество первички» виден только после `make seed` на стенде (seed добавляет пары только для впервые созданного права) | поведение seed | без seed меню скрыто у всех, API отвечает 403 |
|
||||
| 07.11.2026 | R-12 (final review M-2): `GET /quality-metrics/export` пишет `audit_event` (`action=EXPORT`, `section=EXPORT`, `object_type=quality_metrics`, `object_id=null`, `after_value={report, format, filters, rows}`) в транзакции запроса; `AuditAction.EXPORT` существует (как у экспорта сводной ОСВ, `consolidated_osv.py`); права — прежние `QUALITY_METRICS_VIEW` | выгрузка содержит названия ЮЛ и массовая; обоснование «нет в `audit_action`» в Task 15 было ошибочным | без записи выгрузка не оставляла следа; при записи в аудит каждый экспорт добавляет строку журнала |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Alert, Button, DatePicker, Descriptions, Drawer, Result, Select, Space, Spin, Tabs, message } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { exportFacts, getMatrix } from '../../services/api/qualityMetrics';
|
||||
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
|
||||
@ -11,7 +11,7 @@ import { saveBlob } from '../../utils/download';
|
||||
import { DocumentDrillList } from './DocumentDrillList';
|
||||
import { type MatrixColumnData, MatrixTable } from './MatrixTable';
|
||||
import { MonthlyTrend } from './MonthlyTrend';
|
||||
import { formatShare, sourceColumns } from './qualityView';
|
||||
import { formatShare, requestOnce, sourceColumns } from './qualityView';
|
||||
|
||||
const DOC_TYPES: { value: DocumentType; label: string }[] = [
|
||||
{ value: 'BALANCE', label: 'Бухгалтерский баланс' },
|
||||
@ -48,10 +48,11 @@ export function QualityMetricsPage() {
|
||||
// biome-ignore lint/nursery/useAtIndex: в lib проекта (tsconfig) нет Array.prototype.at
|
||||
const matrix = matrices[matrices.length - 1]?.data ?? null;
|
||||
|
||||
// список ГК для фильтра: загружаем, если ещё не загружен
|
||||
// список ГК для фильтра: запрашиваем один раз за жизнь экрана (пустой ответ и ошибка не должны вызывать повтор)
|
||||
const groupsRequested = useRef(false);
|
||||
useEffect(() => {
|
||||
if (groups.length === 0 && !groupsLoading) dispatch(getCompanyGroupsThunk());
|
||||
}, [groups.length, groupsLoading, dispatch]);
|
||||
requestOnce(groupsRequested, groups.length === 0 && !groupsLoading, () => dispatch(getCompanyGroupsThunk()));
|
||||
}, [groups.length, groupsLoading, dispatch]); // повторные срабатывания гасит groupsRequested
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { QualityMatrixOut } from '../../types/qualityMetrics';
|
||||
import { filterByPermission } from '../Sidebar/visibility';
|
||||
import { buildMatrixRows, buildSourceRows, formatReportDate, formatShare, monthLabel, sourceColumns, sumFixes } from './qualityView';
|
||||
import {
|
||||
buildMatrixRows,
|
||||
buildSourceRows,
|
||||
formatReportDate,
|
||||
formatShare,
|
||||
monthLabel,
|
||||
requestOnce,
|
||||
sourceColumns,
|
||||
sumFixes,
|
||||
} from './qualityView';
|
||||
|
||||
const matrixFixture: QualityMatrixOut = {
|
||||
counts: { SERVICE_ERROR: 3, OUR_ERROR: 2, CLIENT_DOCUMENT: 1, WITHOUT_PARTICIPATION: 12, AS_IS: 5 },
|
||||
@ -103,3 +112,20 @@ describe('formatReportDate', () => {
|
||||
expect(formatReportDate(null)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestOnce', () => {
|
||||
it('вызывает загрузку ровно один раз, сколько бы раз ни перерисовывали экран (пустой ответ, ошибка)', () => {
|
||||
const flag = { current: false };
|
||||
const run = vi.fn();
|
||||
for (let render = 0; render < 5; render++) requestOnce(flag, true, run);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('не вызывает загрузку, если справочник уже есть, и не возвращается к ней позже', () => {
|
||||
const flag = { current: false };
|
||||
const run = vi.fn();
|
||||
requestOnce(flag, false, run);
|
||||
requestOnce(flag, true, run);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -137,3 +137,10 @@ export function buildSourceRows(columns: { key: string; data: QualityMatrixOut }
|
||||
return { key: row.key, label: row.label, cells };
|
||||
});
|
||||
}
|
||||
|
||||
/** Выполняет `run` не более одного раза за жизнь `flag` (и только если `needed`): защита от цикла повторных запросов. */
|
||||
export function requestOnce(flag: { current: boolean }, needed: boolean, run: () => void): void {
|
||||
if (flag.current) return;
|
||||
flag.current = true;
|
||||
if (needed) run();
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user