Compare commits
No commits in common. "2b751dc1292a9fafa4b4fb46c8d4abf794e86b2a" and "a539c31cbc5601f3562e35a97b522633867ecd24" have entirely different histories.
2b751dc129
...
a539c31cbc
|
|
@ -1,77 +0,0 @@
|
||||||
# UAT — измеримое покрытие каждого элемента каждого экрана
|
|
||||||
|
|
||||||
Цель: матрица покрытия, построенная ИЗ КОДА, + браузерный прогон → `docs/uat-coverage.html`
|
|
||||||
с % по экрану/приоритету/общим. Цели: P1–P3 ≥95%, P4 ≥70%. НЕ перепроверять уже покрытое.
|
|
||||||
|
|
||||||
Легенда: [x] готово · [~] частично · [ ] не начато.
|
|
||||||
|
|
||||||
## Инструменты
|
|
||||||
- `tests/e2e/tools/extract-controls.ts` — статически из `web/src/pages` достаёт все
|
|
||||||
интерактивные элементы экрана (поиск/фильтры/сортировки/пагинация/экспорт/тумблеры/
|
|
||||||
действия) → `docs/uat-coverage.expected.json`. Фильтр списка отличается от поля формы
|
|
||||||
по `setPage` в обработчике. 36 экранов, 195 элементов.
|
|
||||||
- `tests/e2e/scenarios/stage-ui-uat-coverage.spec.ts` — браузерный прогон: задействует
|
|
||||||
каждый элемент, пишет `tests/e2e/reports/uat-covered.json`.
|
|
||||||
- `tests/e2e/tools/build-coverage.ts` — сводит → `docs/uat-coverage.{json,html}` с %.
|
|
||||||
|
|
||||||
## Статус покрытия (на момент смены приоритета 09.10 — переход на ALL MARKET)
|
|
||||||
Цифры в `docs/uat-coverage.html` (и `uat-coverage.json`). Прогон на стенде, temp-орг с сидом.
|
|
||||||
|
|
||||||
| Приоритет | Покрыто | Цель |
|
|
||||||
|---|---|---|
|
|
||||||
| **Общее** | **61% (119/195)** | — |
|
|
||||||
| P1 | 84% (27/32) | 95% |
|
|
||||||
| P2 | 86% (12/14) | 95% |
|
|
||||||
| P3 | 76% (48/63) | 95% |
|
|
||||||
| **P1–P3** | **80% (87/109)** | **95%** |
|
|
||||||
| P4 | 37% (32/86) | 70% |
|
|
||||||
|
|
||||||
Прогресс по итерациям: базовый 50% → после доработки хендлеров (пагинация со сбросом
|
|
||||||
поиска, обобщённое создание/сохранение/удаление, период-фильтры, доп. сид: чек/приёмка/
|
|
||||||
оприходование/списание/инвентаризация/возврат) → 61% (P1–P3 71%→80%, P4 24%→37%).
|
|
||||||
|
|
||||||
### Что уже покрыто хорошо (из кода × браузер)
|
|
||||||
- По типам: **export 5/5, sort 60/77, toggle 2/2, search 18/25, filter (часть), pagination
|
|
||||||
(где есть данные)**. Действия create/save — частично (обобщённый CRUD-хендлер).
|
|
||||||
- Инструменты рабочие: экстрактор (195 элементов из 36 экранов), браузерный прогон, HTML с %.
|
|
||||||
|
|
||||||
### Что НЕ успел (продолжить после перехода на ALL MARKET)
|
|
||||||
Цели (P1–P3 ≥95%, P4 ≥70%) НЕ достигнуты — осталось добрать ~22 элемента P1–P3 и ~28 P4:
|
|
||||||
- **Сортировки с динамическим/условным заголовком** (ProductsPage `systemPrice`→имя типа цены,
|
|
||||||
`vat`→колонка только при вкл. НДС): экстрактор доработан ловить строковый фолбэк
|
|
||||||
(`header: expr ?? 'Текст'`) — нужно перегенерировать expected и прогнать ещё раз.
|
|
||||||
- **Фильтры со СМЫСЛОМ** (правильные строки по известным данным): сейчас проверяется, что
|
|
||||||
контрол шлёт новый запрос; семантику части списков держит filters-sweep. Добить smысл для
|
|
||||||
продаж (период/оплата), приёмок (поставщик/склад/статус/оплата), движений (тип/товар/период),
|
|
||||||
денег (счёт/период/тип), отчётов (период/группировки/склад/группа на дату).
|
|
||||||
- **Действия документов** post/unpost на списках money/transactions, invoices (сейчас — наличие
|
|
||||||
элемента); довести до реального POST с проверкой 2xx.
|
|
||||||
- **P4 save/delete**: обобщённый хендлер покрывает простые CRUD-модалки; формы со множеством
|
|
||||||
обязательных полей (счета/точки/сотрудники/настройки) требуют адресного заполнения.
|
|
||||||
- **Пагинация** на экранах без сид-данных: отрисовывается только при total>0 — досидить или
|
|
||||||
считать покрытой по общему компоненту.
|
|
||||||
- nightly: добавить `stage-ui-uat-coverage` в ночной прогон (пока не добавлено).
|
|
||||||
|
|
||||||
Запуск: `cd tests/e2e && pnpm exec tsx tools/extract-controls.ts` (матрица) →
|
|
||||||
`pnpm exec playwright test stage-ui-uat-coverage` (прогон) → `pnpm exec tsx tools/build-coverage.ts` (HTML).
|
|
||||||
|
|
||||||
## Найдено Mac-Claude (быстрые фиксы — сделаны)
|
|
||||||
1. **«Проверка данных» на Демо — все 0, lastCheckedAt=null.** Hangfire-джоба `DataQualityJob`
|
|
||||||
зарегистрирована (ежедневный cron 02:20 UTC, идёт по всем активным орг), и `RecomputeAsync`
|
|
||||||
корректна — просто ночной прогон ещё не отрабатывал после пересева Демо. Фикс: разовый
|
|
||||||
`BackgroundJob.Enqueue(DataQualityJob)` при старте → считается сразу после деплоя/пересева.
|
|
||||||
**Реальные числа Демо (стенд, после фикса):** total 1276 ·
|
|
||||||
без группы 0 · **без цены продажи 718** · без штрихкода 0 · дубль ШК 0 ·
|
|
||||||
**возможный дубль (имя+ед.) 558** (включая 4× «"BIOBLAS" шампунь д/волос 360мл») ·
|
|
||||||
товары в минусе 0. `lastCheckedAt` проставлен.
|
|
||||||
2. **Внутренний ШК с ведущим 0 («0201259508594»).** `InternalBarcode.PickPrefix` брал «02»
|
|
||||||
(ведущий 0 → зона UPC-A, риск совпасть с реальным товаром). Фикс: берём 2x из
|
|
||||||
restricted-circulation 20–29 (без ведущего нуля), не пересекаясь с весовыми префиксами
|
|
||||||
орг; «02» — крайний резерв. Для весовых — короткий «27+5» по настройке (отдельно).
|
|
||||||
**Проверка на стенде:** новые коды — `2021712729230`, `2043662520927`, `2084330808596`
|
|
||||||
(префикс 20, без ведущего нуля). Существующие коды НЕ мигрировали: смена ШК ломает
|
|
||||||
ссылки, а отличить ранее сгенерированный «02…» от реального невозможно; генератор
|
|
||||||
исправлен для всех НОВЫХ товаров.
|
|
||||||
|
|
||||||
## Что НЕ трогаем
|
|
||||||
Прод, shared-postgres, global.json, gateway nginx, sklad.
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,282 +0,0 @@
|
||||||
<!doctype html><meta charset="utf-8"><title>UAT coverage — ALL MARKET</title>
|
|
||||||
<style>
|
|
||||||
body{font:14px system-ui,Segoe UI,Roboto,sans-serif;margin:0;background:#f8fafc;color:#0f172a;padding:24px}
|
|
||||||
h1{font-size:20px} .muted{color:#64748b}
|
|
||||||
.cards{display:flex;gap:16px;flex-wrap:wrap;margin:16px 0}
|
|
||||||
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:14px 18px;min-width:150px}
|
|
||||||
.card .n{font-size:26px;font-weight:700}
|
|
||||||
table{border-collapse:collapse;width:100%;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}
|
|
||||||
th,td{padding:7px 10px;border-bottom:1px solid #eef2f7;text-align:left;vertical-align:top}
|
|
||||||
th{background:#f1f5f9;font-size:12px;text-transform:uppercase;letter-spacing:.03em;color:#475569}
|
|
||||||
.ok{color:#16a34a} .no{color:#dc2626}
|
|
||||||
.chip{display:inline-block;font-size:11px;padding:1px 7px;border-radius:999px;background:#eef2f7;margin:1px}
|
|
||||||
.bar{height:8px;border-radius:4px;background:#e2e8f0;overflow:hidden;min-width:90px}
|
|
||||||
.bar>i{display:block;height:100%}
|
|
||||||
code{background:#f1f5f9;padding:0 4px;border-radius:4px}
|
|
||||||
</style>
|
|
||||||
<h1>UAT coverage — покрытие каждого элемента каждого экрана</h1>
|
|
||||||
<div class="muted">Сгенерировано 2026-10-09T08:08:43.726Z · элементы извлечены из кода (web/src/pages), покрытие — браузерный прогон на стенде.</div>
|
|
||||||
<div class="cards">
|
|
||||||
<div class="card"><div class="muted">Всего элементов</div><div class="n">195</div></div>
|
|
||||||
<div class="card"><div class="muted">Покрыто</div><div class="n">119</div></div>
|
|
||||||
<div class="card"><div class="muted">Общее</div><div class="n">61%</div></div>
|
|
||||||
<div class="card"><div class="muted">P1–P3 (цель 95%)</div><div class="n"><b style="color:#dc2626">80%</b></div><div class="muted">87/109</div></div>
|
|
||||||
<div class="card"><div class="muted">P4 (цель 70%)</div><div class="n"><b style="color:#dc2626">37%</b></div><div class="muted">32/86</div></div>
|
|
||||||
</div>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Пр.</th><th>Экран</th><th>%</th><th></th><th>Элементы (зелёные покрыты)</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>P1</td>
|
|
||||||
<td><b>Здесь пока пусто</b><br><span class="muted">/catalog/products</span></td>
|
|
||||||
<td><b style="color:#dc2626">64%</b><br><span class="muted">7/11</span></td>
|
|
||||||
<td><div class="bar"><i style="width:64%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="экспорт→/export&format=">✓ export</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Название»→sort=name">✓ sort:name</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Фасовка»→sort=packaging">✓ sort:packaging</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Себестоимость»→sort=cost">✓ sort:cost</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «systemPrice»">✗ sort:systemPrice</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «НДС»">✗ sort:vat</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="смена фильтра→новый запрос списка">✗ filter:все</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span> <span class="chip" style="background:#dcfce7;color:#166534" title="переход на создание">✓ action:create</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P1</td>
|
|
||||||
<td><b>Остатки</b><br><span class="muted">/inventory/stock</span></td>
|
|
||||||
<td><b style="color:#dc2626">92%</b><br><span class="muted">11/12</span></td>
|
|
||||||
<td><div class="bar"><i style="width:92%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="экспорт→/export&format=">✓ export</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Товар»→sort=name">✓ sort:name</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="клик «Склад»→sort=store">✗ sort:store</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Ед.»→sort=unit">✓ sort:unit</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Остаток»→sort=quantity">✓ sort:quantity</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Резерв»→sort=reserved">✓ sort:reserved</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Доступно»→sort=available">✓ sort:available</span> <span class="chip" style="background:#dcfce7;color:#166534" title="смена фильтра→новый запрос списка">✓ filter:все-склады</span> <span class="chip" style="background:#dcfce7;color:#166534" title="тумблер→новый запрос списка">✓ toggle:показать-нулевые</span> <span class="chip" style="background:#dcfce7;color:#166534" title="тумблер→новый запрос списка">✓ toggle:только-минусы</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P1</td>
|
|
||||||
<td><b>Розничные продажи</b><br><span class="muted">/sales/retail</span></td>
|
|
||||||
<td><b style="color:#16a34a">100%</b><br><span class="muted">9/9</span></td>
|
|
||||||
<td><div class="bar"><i style="width:100%;background:#16a34a"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="экспорт→/export&format=">✓ export</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «№»→sort=number">✓ sort:number</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Дата/время»→sort=date">✓ sort:date</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Статус»→sort=status">✓ sort:status</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Магазин»→sort=store">✓ sort:store</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Сумма»→sort=total">✓ sort:total</span> <span class="chip" style="background:#dcfce7;color:#166534" title="переход на создание">✓ action:create</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P2</td>
|
|
||||||
<td><b>Смены кассы</b><br><span class="muted">/money/shifts</span></td>
|
|
||||||
<td><b style="color:#dc2626">33%</b><br><span class="muted">1/3</span></td>
|
|
||||||
<td><div class="bar"><i style="width:33%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="список пуст — пагинации нет">✗ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="элемент присутствует">✓ action:print</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="сохранение не удалось">✗ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P2</td>
|
|
||||||
<td><b>Приёмки от поставщиков</b><br><span class="muted">/purchases/supplies</span></td>
|
|
||||||
<td><b style="color:#16a34a">100%</b><br><span class="muted">11/11</span></td>
|
|
||||||
<td><div class="bar"><i style="width:100%;background:#16a34a"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="экспорт→/export&format=">✓ export</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «№»→sort=number">✓ sort:number</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Дата»→sort=date">✓ sort:date</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Статус»→sort=status">✓ sort:status</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Поставщик»→sort=supplier">✓ sort:supplier</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Склад»→sort=store">✓ sort:store</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Сумма»→sort=total">✓ sort:total</span> <span class="chip" style="background:#dcfce7;color:#166534" title="смена фильтра→новый запрос списка">✓ filter:оплата-все</span> <span class="chip" style="background:#dcfce7;color:#166534" title="открылась форма">✓ action:create</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P3</td>
|
|
||||||
<td><b>Контрагенты</b><br><span class="muted">/catalog/counterparties</span></td>
|
|
||||||
<td><b style="color:#dc2626">89%</b><br><span class="muted">8/9</span></td>
|
|
||||||
<td><div class="bar"><i style="width:89%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="экспорт→/export&format=">✓ export</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Название»→sort=name">✓ sort:name</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Тип»→sort=type">✓ sort:type</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Телефон»→sort=phone">✓ sort:phone</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Страна»→sort=country">✓ sort:country</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span> <span class="chip" style="background:#dcfce7;color:#166534" title="создание→POST 2xx">✓ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P3</td>
|
|
||||||
<td><b>Проверка данных</b><br><span class="muted">/catalog/data-quality</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/1</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P3</td>
|
|
||||||
<td><b>Оприходования</b><br><span class="muted">/inventory/enters</span></td>
|
|
||||||
<td><b style="color:#dc2626">88%</b><br><span class="muted">7/8</span></td>
|
|
||||||
<td><div class="bar"><i style="width:88%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «№»→sort=number">✓ sort:number</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Дата»→sort=date">✓ sort:date</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Статус»→sort=status">✓ sort:status</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Склад»→sort=store">✓ sort:store</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Сумма»→sort=total">✓ sort:total</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не открылось">✗ action:create</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P3</td>
|
|
||||||
<td><b>Инвентаризации</b><br><span class="muted">/inventory/inventories</span></td>
|
|
||||||
<td><b style="color:#16a34a">100%</b><br><span class="muted">6/6</span></td>
|
|
||||||
<td><div class="bar"><i style="width:100%;background:#16a34a"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «№»→sort=number">✓ sort:number</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Дата»→sort=date">✓ sort:date</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Статус»→sort=status">✓ sort:status</span> <span class="chip" style="background:#dcfce7;color:#166534" title="открылась форма">✓ action:create</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P3</td>
|
|
||||||
<td><b>Списания</b><br><span class="muted">/inventory/losses</span></td>
|
|
||||||
<td><b style="color:#dc2626">89%</b><br><span class="muted">8/9</span></td>
|
|
||||||
<td><div class="bar"><i style="width:89%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «№»→sort=number">✓ sort:number</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Дата»→sort=date">✓ sort:date</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Статус»→sort=status">✓ sort:status</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Причина»→sort=reason">✓ sort:reason</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Склад»→sort=store">✓ sort:store</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Сумма»→sort=total">✓ sort:total</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не открылось">✗ action:create</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P3</td>
|
|
||||||
<td><b>Движения</b><br><span class="muted">/inventory/movements</span></td>
|
|
||||||
<td><b style="color:#dc2626">88%</b><br><span class="muted">7/8</span></td>
|
|
||||||
<td><div class="bar"><i style="width:88%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Дата»→sort=occurredAt">✓ sort:occurredAt</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Операция»→sort=type">✓ sort:type</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Товар»→sort=product">✓ sort:product</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="клик «Склад»→sort=store">✗ sort:store</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Количество»→sort=quantity">✓ sort:quantity</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Документ»→sort=documentType">✓ sort:documentType</span> <span class="chip" style="background:#dcfce7;color:#166534" title="смена фильтра→новый запрос списка">✓ filter:все-склады</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P3</td>
|
|
||||||
<td><b>Отчёт «Деньги»</b><br><span class="muted">/money/report</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/1</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="период не изменил выдачу">✗ filter:period</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P3</td>
|
|
||||||
<td><b>Долги поставщикам</b><br><span class="muted">/money/supplier-debts</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/2</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="не найден здесь">✗ action:post</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="сохранение не удалось">✗ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P3</td>
|
|
||||||
<td><b>Платежи и ордера</b><br><span class="muted">/money/transactions</span></td>
|
|
||||||
<td><b style="color:#dc2626">38%</b><br><span class="muted">3/8</span></td>
|
|
||||||
<td><div class="bar"><i style="width:38%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="список пуст — пагинации нет">✗ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="смена фильтра→новый запрос списка">✓ filter:все-статусы</span> <span class="chip" style="background:#dcfce7;color:#166534" title="смена фильтра→новый запрос списка">✓ filter:все-типы</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не найден здесь">✗ action:post</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не найден здесь">✗ action:unpost</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="сохранение не удалось">✗ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P3</td>
|
|
||||||
<td><b>Накладные</b><br><span class="muted">/purchases/invoices</span></td>
|
|
||||||
<td><b style="color:#16a34a">100%</b><br><span class="muted">1/1</span></td>
|
|
||||||
<td><div class="bar"><i style="width:100%;background:#16a34a"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="элемент присутствует">✓ action:unpost</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P3</td>
|
|
||||||
<td><b>Возвраты поставщикам</b><br><span class="muted">/purchases/supplier-returns</span></td>
|
|
||||||
<td><b style="color:#16a34a">100%</b><br><span class="muted">8/8</span></td>
|
|
||||||
<td><div class="bar"><i style="width:100%;background:#16a34a"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «№»→sort=number">✓ sort:number</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Дата»→sort=date">✓ sort:date</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Статус»→sort=status">✓ sort:status</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Поставщик»→sort=supplier">✓ sort:supplier</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Сумма»→sort=total">✓ sort:total</span> <span class="chip" style="background:#dcfce7;color:#166534" title="переход на создание">✓ action:create</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P3</td>
|
|
||||||
<td><b>Данных для отчёта нет</b><br><span class="muted">/reports/sales</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/1</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="период не изменил выдачу">✗ filter:period</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P3</td>
|
|
||||||
<td><b>Остатки пусты</b><br><span class="muted">/reports/stock</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/1</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="период не изменил выдачу">✗ filter:period</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Импорт из МойСклад</b><br><span class="muted">/admin/import/moysklad</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/1</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Журнал изменений</b><br><span class="muted">/audit-log</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/6</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="поиск→search=">✗ search</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="список пуст — пагинации нет">✗ pagination</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="смена фильтра→новый запрос списка">✗ filter:sel1</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="смена фильтра→новый запрос списка">✗ filter:sel2</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="смена фильтра→новый запрос списка">✗ filter:все</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="период не изменил выдачу">✗ filter:period</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Типы цен</b><br><span class="muted">/catalog/price-types</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/6</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="поиск→search=">✗ search</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="список пуст — пагинации нет">✗ pagination</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «Название»">✗ sort:name</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «Обязательно»">✗ sort:isRequired</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="сохранение не удалось">✗ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Группы товаров</b><br><span class="muted">/catalog/product-groups</span></td>
|
|
||||||
<td><b style="color:#16a34a">83%</b><br><span class="muted">5/6</span></td>
|
|
||||||
<td><div class="bar"><i style="width:83%;background:#16a34a"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Название»→sort=name">✓ sort:name</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Путь»→sort=path">✓ sort:path</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span> <span class="chip" style="background:#dcfce7;color:#166534" title="создание→POST 2xx">✓ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Кассы</b><br><span class="muted">/catalog/retail-points</span></td>
|
|
||||||
<td><b style="color:#16a34a">89%</b><br><span class="muted">8/9</span></td>
|
|
||||||
<td><div class="bar"><i style="width:89%;background:#16a34a"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Название»→sort=name">✓ sort:name</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Код»→sort=code">✓ sort:code</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Склад»→sort=store">✓ sort:store</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Адрес»→sort=address">✓ sort:address</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Активна»→sort=isActive">✓ sort:isActive</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span> <span class="chip" style="background:#dcfce7;color:#166534" title="создание→POST 2xx">✓ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Склады</b><br><span class="muted">/catalog/stores</span></td>
|
|
||||||
<td><b style="color:#16a34a">89%</b><br><span class="muted">8/9</span></td>
|
|
||||||
<td><div class="bar"><i style="width:89%;background:#16a34a"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Название»→sort=name">✓ sort:name</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Код»→sort=code">✓ sort:code</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Адрес»→sort=address">✓ sort:address</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Основной»→sort=isMain">✓ sort:isMain</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Активен»→sort=isActive">✓ sort:isActive</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span> <span class="chip" style="background:#dcfce7;color:#166534" title="создание→POST 2xx">✓ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Единицы измерения</b><br><span class="muted">/catalog/units</span></td>
|
|
||||||
<td><b style="color:#16a34a">75%</b><br><span class="muted">3/4</span></td>
|
|
||||||
<td><div class="bar"><i style="width:75%;background:#16a34a"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#dcfce7;color:#166534" title="клик «Название»→sort=name">✓ sort:name</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Инвентаризация с телефона</b><br><span class="muted">/inventory/mobile</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/1</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="не найден здесь">✗ action:post</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Перемещения</b><br><span class="muted">/inventory/transfers</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/7</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="поиск→search=">✗ search</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="список пуст — пагинации нет">✗ pagination</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «№»">✗ sort:number</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «Дата»">✗ sort:date</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «Статус»">✗ sort:status</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «Сумма»">✗ sort:total</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не открылось">✗ action:create</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Карты лояльности</b><br><span class="muted">/loyalty/cards</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/3</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="поиск→search=">✗ search</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="список пуст — пагинации нет">✗ pagination</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «Номер»">✗ sort:cardNumber</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Программы лояльности</b><br><span class="muted">/loyalty/programs</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/5</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="поиск→search=">✗ search</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="список пуст — пагинации нет">✗ pagination</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «Название»">✗ sort:name</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="сохранение не удалось">✗ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Счета и кассы</b><br><span class="muted">/money/accounts</span></td>
|
|
||||||
<td><b style="color:#dc2626">25%</b><br><span class="muted">1/4</span></td>
|
|
||||||
<td><div class="bar"><i style="width:25%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="список пуст — пагинации нет">✗ pagination</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="сохранение не удалось">✗ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Статьи расходов</b><br><span class="muted">/money/expense-items</span></td>
|
|
||||||
<td><b style="color:#16a34a">75%</b><br><span class="muted">3/4</span></td>
|
|
||||||
<td><div class="bar"><i style="width:75%;background:#16a34a"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span> <span class="chip" style="background:#dcfce7;color:#166534" title="создание→POST 2xx">✓ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Акции и промокоды</b><br><span class="muted">/promotions</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/4</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="поиск→search=">✗ search</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="список пуст — пагинации нет">✗ pagination</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="сохранение не удалось">✗ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Оптовые отгрузки</b><br><span class="muted">/sales/demands</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/8</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="поиск→search=">✗ search</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="список пуст — пагинации нет">✗ pagination</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «№»">✗ sort:number</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «Дата»">✗ sort:date</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «Статус»">✗ sort:status</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «Контрагент»">✗ sort:customer</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="нет заголовка «Сумма»">✗ sort:total</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не открылось">✗ action:create</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Роли сотрудников</b><br><span class="muted">/settings/employee-roles</span></td>
|
|
||||||
<td><b style="color:#dc2626">50%</b><br><span class="muted">2/4</span></td>
|
|
||||||
<td><div class="bar"><i style="width:50%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="сохранение не удалось">✗ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Сотрудники</b><br><span class="muted">/settings/employees</span></td>
|
|
||||||
<td><b style="color:#dc2626">50%</b><br><span class="muted">2/4</span></td>
|
|
||||||
<td><div class="bar"><i style="width:50%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#dcfce7;color:#166534" title="поиск→search=">✓ search</span> <span class="chip" style="background:#dcfce7;color:#166534" title="пагинация отрисована">✓ pagination</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span> <span class="chip" style="background:#fee2e2;color:#991b1b" title="сохранение не удалось">✗ action:save</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>P4</td>
|
|
||||||
<td><b>Настройки организации</b><br><span class="muted">/settings/organization</span></td>
|
|
||||||
<td><b style="color:#dc2626">0%</b><br><span class="muted">0/1</span></td>
|
|
||||||
<td><div class="bar"><i style="width:0%;background:#f59e0b"></i></div></td>
|
|
||||||
<td><span class="chip" style="background:#fee2e2;color:#991b1b" title="не удалось удалить">✗ action:delete</span></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -169,11 +169,6 @@ public Task StartAsync(CancellationToken ct)
|
||||||
cronExpression: cronDataQuality,
|
cronExpression: cronDataQuality,
|
||||||
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
|
||||||
// Разовый прогон при старте: чтобы после деплоя/пересева Демо (и любой новой
|
|
||||||
// организации) счётчики «Проверки данных» были посчитаны сразу, не дожидаясь
|
|
||||||
// ночного cron (иначе панель/бейдж показывают 0 и lastCheckedAt=null).
|
|
||||||
BackgroundJob.Enqueue<DataQualityJob>(j => j.RunAsync(CancellationToken.None));
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,8 @@ public static int Ean13CheckDigit(string body12)
|
||||||
return (10 - sum % 10) % 10;
|
return (10 - sum % 10) % 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Подобрать 2-значный префикс внутреннего кода. GS1 restricted circulation
|
/// <summary>Подобрать 2-значный префикс, не входящий в весовые/ценовые префиксы орг.
|
||||||
/// (внутримагазинные) — это 02 и 20–29. НЕ используем «02»: с ведущим нулём 13-значный
|
/// Приоритет: 02 → свободный из 20–29 → свободный из 90–99 → 02 (как крайний).</summary>
|
||||||
/// код выглядит как UPC-A (зона США) и может совпасть с реальным товаром. Берём 2x
|
|
||||||
/// (20–29) — настоящие коды такие префиксы не используют — не пересекаясь с весовыми/
|
|
||||||
/// ценовыми префиксами организации (если разбор этикеток включён). Приоритет:
|
|
||||||
/// свободный из 20–29 → свободный из 90–99 → 02 (крайний случай).</summary>
|
|
||||||
public static int PickPrefix(WeightedBarcodeSettings settings)
|
public static int PickPrefix(WeightedBarcodeSettings settings)
|
||||||
{
|
{
|
||||||
var forbidden = new HashSet<int>();
|
var forbidden = new HashSet<int>();
|
||||||
|
|
@ -37,9 +33,10 @@ public static int PickPrefix(WeightedBarcodeSettings settings)
|
||||||
foreach (var p in settings.WeightPrefixes) forbidden.Add(p);
|
foreach (var p in settings.WeightPrefixes) forbidden.Add(p);
|
||||||
foreach (var p in settings.PricePrefixes) forbidden.Add(p);
|
foreach (var p in settings.PricePrefixes) forbidden.Add(p);
|
||||||
}
|
}
|
||||||
|
if (!forbidden.Contains(2)) return 2;
|
||||||
for (int p = 20; p <= 29; p++) if (!forbidden.Contains(p)) return p;
|
for (int p = 20; p <= 29; p++) if (!forbidden.Contains(p)) return p;
|
||||||
for (int p = 90; p <= 99; p++) if (!forbidden.Contains(p)) return p;
|
for (int p = 90; p <= 99; p++) if (!forbidden.Contains(p)) return p;
|
||||||
return 2; // всё занято (весьма редкий случай) — крайний резерв
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Сгенерировать внутренний EAN-13. <paramref name="rnd"/> — источник
|
/// <summary>Сгенерировать внутренний EAN-13. <paramref name="rnd"/> — источник
|
||||||
|
|
|
||||||
|
|
@ -1,857 +0,0 @@
|
||||||
{
|
|
||||||
"generatedAt": "2026-10-09T08:05:20.852Z",
|
|
||||||
"covered": {
|
|
||||||
"/catalog/products": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"export": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "экспорт→/export&format="
|
|
||||||
},
|
|
||||||
"sort:name": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Название»→sort=name"
|
|
||||||
},
|
|
||||||
"sort:packaging": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Фасовка»→sort=packaging"
|
|
||||||
},
|
|
||||||
"sort:cost": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Себестоимость»→sort=cost"
|
|
||||||
},
|
|
||||||
"sort:systemPrice": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «systemPrice»"
|
|
||||||
},
|
|
||||||
"sort:vat": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «НДС»"
|
|
||||||
},
|
|
||||||
"filter:все": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "смена фильтра→новый запрос списка"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
},
|
|
||||||
"action:create": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "переход на создание"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/inventory/stock": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"export": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "экспорт→/export&format="
|
|
||||||
},
|
|
||||||
"sort:name": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Товар»→sort=name"
|
|
||||||
},
|
|
||||||
"sort:store": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "клик «Склад»→sort=store"
|
|
||||||
},
|
|
||||||
"sort:unit": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Ед.»→sort=unit"
|
|
||||||
},
|
|
||||||
"sort:quantity": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Остаток»→sort=quantity"
|
|
||||||
},
|
|
||||||
"sort:reserved": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Резерв»→sort=reserved"
|
|
||||||
},
|
|
||||||
"sort:available": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Доступно»→sort=available"
|
|
||||||
},
|
|
||||||
"filter:все-склады": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "смена фильтра→новый запрос списка"
|
|
||||||
},
|
|
||||||
"toggle:показать-нулевые": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "тумблер→новый запрос списка"
|
|
||||||
},
|
|
||||||
"toggle:только-минусы": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "тумблер→новый запрос списка"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/sales/retail": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"export": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "экспорт→/export&format="
|
|
||||||
},
|
|
||||||
"sort:number": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «№»→sort=number"
|
|
||||||
},
|
|
||||||
"sort:date": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Дата/время»→sort=date"
|
|
||||||
},
|
|
||||||
"sort:status": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Статус»→sort=status"
|
|
||||||
},
|
|
||||||
"sort:store": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Магазин»→sort=store"
|
|
||||||
},
|
|
||||||
"sort:total": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Сумма»→sort=total"
|
|
||||||
},
|
|
||||||
"action:create": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "переход на создание"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/money/shifts": {
|
|
||||||
"pagination": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "список пуст — пагинации нет"
|
|
||||||
},
|
|
||||||
"action:print": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "элемент присутствует"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "сохранение не удалось"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/purchases/supplies": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"export": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "экспорт→/export&format="
|
|
||||||
},
|
|
||||||
"sort:number": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «№»→sort=number"
|
|
||||||
},
|
|
||||||
"sort:date": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Дата»→sort=date"
|
|
||||||
},
|
|
||||||
"sort:status": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Статус»→sort=status"
|
|
||||||
},
|
|
||||||
"sort:supplier": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Поставщик»→sort=supplier"
|
|
||||||
},
|
|
||||||
"sort:store": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Склад»→sort=store"
|
|
||||||
},
|
|
||||||
"sort:total": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Сумма»→sort=total"
|
|
||||||
},
|
|
||||||
"filter:оплата-все": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "смена фильтра→новый запрос списка"
|
|
||||||
},
|
|
||||||
"action:create": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "открылась форма"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/catalog/counterparties": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"export": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "экспорт→/export&format="
|
|
||||||
},
|
|
||||||
"sort:name": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Название»→sort=name"
|
|
||||||
},
|
|
||||||
"sort:type": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Тип»→sort=type"
|
|
||||||
},
|
|
||||||
"sort:phone": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Телефон»→sort=phone"
|
|
||||||
},
|
|
||||||
"sort:country": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Страна»→sort=country"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "создание→POST 2xx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/catalog/data-quality": {
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/inventory/enters": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"sort:number": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «№»→sort=number"
|
|
||||||
},
|
|
||||||
"sort:date": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Дата»→sort=date"
|
|
||||||
},
|
|
||||||
"sort:status": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Статус»→sort=status"
|
|
||||||
},
|
|
||||||
"sort:store": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Склад»→sort=store"
|
|
||||||
},
|
|
||||||
"sort:total": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Сумма»→sort=total"
|
|
||||||
},
|
|
||||||
"action:create": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не открылось"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/inventory/inventories": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"sort:number": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «№»→sort=number"
|
|
||||||
},
|
|
||||||
"sort:date": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Дата»→sort=date"
|
|
||||||
},
|
|
||||||
"sort:status": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Статус»→sort=status"
|
|
||||||
},
|
|
||||||
"action:create": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "открылась форма"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/inventory/losses": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"sort:number": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «№»→sort=number"
|
|
||||||
},
|
|
||||||
"sort:date": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Дата»→sort=date"
|
|
||||||
},
|
|
||||||
"sort:status": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Статус»→sort=status"
|
|
||||||
},
|
|
||||||
"sort:reason": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Причина»→sort=reason"
|
|
||||||
},
|
|
||||||
"sort:store": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Склад»→sort=store"
|
|
||||||
},
|
|
||||||
"sort:total": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Сумма»→sort=total"
|
|
||||||
},
|
|
||||||
"action:create": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не открылось"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/inventory/movements": {
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"sort:occurredAt": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Дата»→sort=occurredAt"
|
|
||||||
},
|
|
||||||
"sort:type": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Операция»→sort=type"
|
|
||||||
},
|
|
||||||
"sort:product": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Товар»→sort=product"
|
|
||||||
},
|
|
||||||
"sort:store": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "клик «Склад»→sort=store"
|
|
||||||
},
|
|
||||||
"sort:quantity": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Количество»→sort=quantity"
|
|
||||||
},
|
|
||||||
"sort:documentType": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Документ»→sort=documentType"
|
|
||||||
},
|
|
||||||
"filter:все-склады": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "смена фильтра→новый запрос списка"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/money/report": {
|
|
||||||
"filter:period": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "период не изменил выдачу"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/money/supplier-debts": {
|
|
||||||
"action:post": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не найден здесь"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "сохранение не удалось"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/money/transactions": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "список пуст — пагинации нет"
|
|
||||||
},
|
|
||||||
"filter:все-статусы": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "смена фильтра→новый запрос списка"
|
|
||||||
},
|
|
||||||
"filter:все-типы": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "смена фильтра→новый запрос списка"
|
|
||||||
},
|
|
||||||
"action:post": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не найден здесь"
|
|
||||||
},
|
|
||||||
"action:unpost": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не найден здесь"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "сохранение не удалось"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/purchases/invoices": {
|
|
||||||
"action:unpost": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "элемент присутствует"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/purchases/supplier-returns": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"sort:number": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «№»→sort=number"
|
|
||||||
},
|
|
||||||
"sort:date": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Дата»→sort=date"
|
|
||||||
},
|
|
||||||
"sort:status": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Статус»→sort=status"
|
|
||||||
},
|
|
||||||
"sort:supplier": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Поставщик»→sort=supplier"
|
|
||||||
},
|
|
||||||
"sort:total": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Сумма»→sort=total"
|
|
||||||
},
|
|
||||||
"action:create": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "переход на создание"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/reports/sales": {
|
|
||||||
"filter:period": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "период не изменил выдачу"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/reports/stock": {
|
|
||||||
"filter:period": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "период не изменил выдачу"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/admin/import/moysklad": {
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/audit-log": {
|
|
||||||
"search": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "список пуст — пагинации нет"
|
|
||||||
},
|
|
||||||
"filter:sel1": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "смена фильтра→новый запрос списка"
|
|
||||||
},
|
|
||||||
"filter:sel2": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "смена фильтра→новый запрос списка"
|
|
||||||
},
|
|
||||||
"filter:все": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "смена фильтра→новый запрос списка"
|
|
||||||
},
|
|
||||||
"filter:period": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "период не изменил выдачу"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/catalog/price-types": {
|
|
||||||
"search": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "список пуст — пагинации нет"
|
|
||||||
},
|
|
||||||
"sort:name": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «Название»"
|
|
||||||
},
|
|
||||||
"sort:isRequired": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «Обязательно»"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "сохранение не удалось"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/catalog/product-groups": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"sort:name": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Название»→sort=name"
|
|
||||||
},
|
|
||||||
"sort:path": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Путь»→sort=path"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "создание→POST 2xx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/catalog/retail-points": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"sort:name": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Название»→sort=name"
|
|
||||||
},
|
|
||||||
"sort:code": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Код»→sort=code"
|
|
||||||
},
|
|
||||||
"sort:store": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Склад»→sort=store"
|
|
||||||
},
|
|
||||||
"sort:address": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Адрес»→sort=address"
|
|
||||||
},
|
|
||||||
"sort:isActive": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Активна»→sort=isActive"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "создание→POST 2xx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/catalog/stores": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"sort:name": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Название»→sort=name"
|
|
||||||
},
|
|
||||||
"sort:code": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Код»→sort=code"
|
|
||||||
},
|
|
||||||
"sort:address": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Адрес»→sort=address"
|
|
||||||
},
|
|
||||||
"sort:isMain": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Основной»→sort=isMain"
|
|
||||||
},
|
|
||||||
"sort:isActive": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Активен»→sort=isActive"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "создание→POST 2xx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/catalog/units": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"sort:name": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "клик «Название»→sort=name"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/inventory/mobile": {
|
|
||||||
"action:post": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не найден здесь"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/inventory/transfers": {
|
|
||||||
"search": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "список пуст — пагинации нет"
|
|
||||||
},
|
|
||||||
"sort:number": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «№»"
|
|
||||||
},
|
|
||||||
"sort:date": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «Дата»"
|
|
||||||
},
|
|
||||||
"sort:status": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «Статус»"
|
|
||||||
},
|
|
||||||
"sort:total": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «Сумма»"
|
|
||||||
},
|
|
||||||
"action:create": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не открылось"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/loyalty/cards": {
|
|
||||||
"search": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "список пуст — пагинации нет"
|
|
||||||
},
|
|
||||||
"sort:cardNumber": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «Номер»"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/loyalty/programs": {
|
|
||||||
"search": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "список пуст — пагинации нет"
|
|
||||||
},
|
|
||||||
"sort:name": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «Название»"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "сохранение не удалось"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/money/accounts": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "список пуст — пагинации нет"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "сохранение не удалось"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/money/expense-items": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "создание→POST 2xx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/promotions": {
|
|
||||||
"search": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "список пуст — пагинации нет"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "сохранение не удалось"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/sales/demands": {
|
|
||||||
"search": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "список пуст — пагинации нет"
|
|
||||||
},
|
|
||||||
"sort:number": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «№»"
|
|
||||||
},
|
|
||||||
"sort:date": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «Дата»"
|
|
||||||
},
|
|
||||||
"sort:status": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «Статус»"
|
|
||||||
},
|
|
||||||
"sort:customer": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «Контрагент»"
|
|
||||||
},
|
|
||||||
"sort:total": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "нет заголовка «Сумма»"
|
|
||||||
},
|
|
||||||
"action:create": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не открылось"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/settings/employee-roles": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "сохранение не удалось"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/settings/employees": {
|
|
||||||
"search": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "поиск→search="
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"covered": true,
|
|
||||||
"how": "пагинация отрисована"
|
|
||||||
},
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
},
|
|
||||||
"action:save": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "сохранение не удалось"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/settings/organization": {
|
|
||||||
"action:delete": {
|
|
||||||
"covered": false,
|
|
||||||
"how": "не удалось удалить"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
/**
|
|
||||||
* Измеримое per-control покрытие: читает docs/uat-coverage.expected.json (экстракт из
|
|
||||||
* кода — все интерактивные элементы каждого экрана) и в РЕАЛЬНОМ браузере на стенде
|
|
||||||
* пытается «задействовать» каждый элемент (поиск/сортировка/пагинация/экспорт/фильтр/
|
|
||||||
* тумблер/действие), записывая в reports/uat-covered.json, что реально покрыто. Цифры
|
|
||||||
* покрытия считает tools/build-coverage.ts → docs/uat-coverage.html.
|
|
||||||
*
|
|
||||||
* Семантику «параметр→правильные строки» независимо держит filters-sweep; здесь —
|
|
||||||
* что UI-элемент в браузере действительно управляет выдачей/запускает действие.
|
|
||||||
*/
|
|
||||||
import { test, expect, type Page } from '@playwright/test'
|
|
||||||
import { apiSignup, attachSession } from '../lib/ui.js'
|
|
||||||
import { request as apiRequest, type APIRequestContext } from '@playwright/test'
|
|
||||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
||||||
import { resolve, dirname } from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
||||||
const EXPECTED = resolve(HERE, '../../../docs/uat-coverage.expected.json')
|
|
||||||
const OUT = resolve(HERE, '../reports/uat-covered.json')
|
|
||||||
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
|
|
||||||
|
|
||||||
type El = { key: string; label: string; type: string }
|
|
||||||
type Screen = { route: string; page: string; priority: string; title: string; listUrl: string; elements: El[] }
|
|
||||||
|
|
||||||
test.describe('UAT coverage (измеримое)', () => {
|
|
||||||
test('задействовать каждый элемент каждого экрана', async ({ page }) => {
|
|
||||||
test.setTimeout(1_200_000)
|
|
||||||
const expected = JSON.parse(readFileSync(EXPECTED, 'utf8')) as { screens: Screen[] }
|
|
||||||
|
|
||||||
const sess = await apiSignup('uatcov')
|
|
||||||
const ctx = await apiRequest.newContext({
|
|
||||||
baseURL: BASE, ignoreHTTPSErrors: true, timeout: 60_000,
|
|
||||||
extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
|
|
||||||
})
|
|
||||||
await seed(ctx)
|
|
||||||
await ctx.dispose()
|
|
||||||
|
|
||||||
const covered: Record<string, Record<string, { covered: boolean; how: string }>> = {}
|
|
||||||
const mark = (route: string, key: string, ok: boolean, how: string) => {
|
|
||||||
;(covered[route] ??= {})[key] = { covered: ok, how }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ждём GET на list-URL (опц. с параметром) в ответ на действие.
|
|
||||||
const fires = async (action: () => Promise<void>, listUrl: string, param?: string) => {
|
|
||||||
try {
|
|
||||||
const [resp] = await Promise.all([
|
|
||||||
page.waitForResponse((r) => r.request().method() === 'GET' && r.url().includes(listUrl)
|
|
||||||
&& (!param || r.url().includes(param)), { timeout: 12_000 }),
|
|
||||||
action(),
|
|
||||||
])
|
|
||||||
return resp.status() < 400
|
|
||||||
} catch { return false }
|
|
||||||
}
|
|
||||||
const clickExport = async () => {
|
|
||||||
await page.getByRole('button', { name: /экспорт/i }).first().click()
|
|
||||||
await page.waitForTimeout(400)
|
|
||||||
const item = page.getByText(/^CSV|Excel|xlsx/i).first()
|
|
||||||
if (await item.count() > 0) await item.click()
|
|
||||||
}
|
|
||||||
const searchBox = () => page.locator('input[type="text"], input:not([type])').first()
|
|
||||||
|
|
||||||
for (const s of expected.screens) {
|
|
||||||
// Навигация; пропускаем экраны, что требуют advanced-режим (в temp-орг простой).
|
|
||||||
let loaded = true
|
|
||||||
try {
|
|
||||||
await attachSession(page, sess, s.route)
|
|
||||||
await page.waitForTimeout(1200)
|
|
||||||
if (await page.getByRole('heading', { name: /Нет доступа|расширенн/i }).count() > 0) loaded = false
|
|
||||||
} catch { loaded = false }
|
|
||||||
|
|
||||||
const list = s.listUrl || '/api/'
|
|
||||||
// Форсим таблицу: вводим поиск (многие экраны прячут DataTable до поиска).
|
|
||||||
const hasSearch = s.elements.some((e) => e.key === 'search')
|
|
||||||
if (loaded && hasSearch) {
|
|
||||||
try { await searchBox().fill('a'); await page.waitForTimeout(900) } catch { /* no search box */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const el of s.elements) {
|
|
||||||
if (!loaded) { mark(s.route, el.key, false, 'экран недоступен (advanced/нет доступа)'); continue }
|
|
||||||
try {
|
|
||||||
if (el.key === 'search') {
|
|
||||||
mark(s.route, el.key, await fires(() => searchBox().fill('zz' + Date.now()), list, 'search='), 'поиск→search=')
|
|
||||||
} else if (el.type === 'sort') {
|
|
||||||
const sortKey = el.key.slice('sort:'.length)
|
|
||||||
const header = el.label.replace(/^сортировка:\s*/, '')
|
|
||||||
const btn = page.getByRole('button', { name: header }).first()
|
|
||||||
if (await btn.count() === 0) { mark(s.route, el.key, false, `нет заголовка «${header}»`); continue }
|
|
||||||
mark(s.route, el.key, await fires(() => btn.click(), list, `sort=${sortKey}`), `клик «${header}»→sort=${sortKey}`)
|
|
||||||
} else if (el.key === 'pagination') {
|
|
||||||
// Чистим поиск, чтобы показать полный список (пагинация есть при total>0).
|
|
||||||
if (hasSearch) { try { await searchBox().fill(''); await page.waitForTimeout(900) } catch { /* */ } }
|
|
||||||
const pager = page.getByText(/из \d+/).first()
|
|
||||||
const present = await pager.count() > 0
|
|
||||||
const next = page.getByRole('button', { name: '→' }).first()
|
|
||||||
let ok = present
|
|
||||||
if (present && await next.count() > 0 && await next.isEnabled().catch(() => false)) {
|
|
||||||
ok = await fires(() => next.click(), list, 'page=2') || present
|
|
||||||
}
|
|
||||||
mark(s.route, el.key, ok, present ? 'пагинация отрисована' : 'список пуст — пагинации нет')
|
|
||||||
} else if (el.key === 'export') {
|
|
||||||
mark(s.route, el.key, await fires(clickExport, '/export', 'format='), 'экспорт→/export&format=')
|
|
||||||
} else if (el.type === 'filter' && el.key === 'filter:period') {
|
|
||||||
// Период/дата: меняем дату-инпут или кликаем пресет периода → новый запрос.
|
|
||||||
const ok = await fires(async () => {
|
|
||||||
const date = page.locator('input[type="date"]').first()
|
|
||||||
if (await date.count() > 0) { await date.fill('2026-01-01') }
|
|
||||||
else {
|
|
||||||
const preset = page.getByRole('button', { name: /Неделя|Месяц|Квартал|Год|7 дней|30 дней|период/i }).first()
|
|
||||||
if (await preset.count() > 0) await preset.click()
|
|
||||||
else { const mi = page.locator('input[type="month"]').first(); if (await mi.count() > 0) await mi.fill('2026-01') }
|
|
||||||
}
|
|
||||||
}, list)
|
|
||||||
mark(s.route, el.key, ok, ok ? 'смена периода→новый запрос' : 'период не изменил выдачу')
|
|
||||||
} else if (el.type === 'filter') {
|
|
||||||
// Кастомный Select: открыть по тексту первого варианта из метки, выбрать 2-й пункт.
|
|
||||||
const cur = el.label.replace(/^фильтр:\s*/, '')
|
|
||||||
const ok = await fires(async () => {
|
|
||||||
const trigger = page.getByText(cur, { exact: true }).first()
|
|
||||||
if (await trigger.count() > 0) await trigger.click()
|
|
||||||
else await page.locator('select, [role="combobox"]').first().click()
|
|
||||||
await page.waitForTimeout(300)
|
|
||||||
const opt = page.getByRole('button').filter({ hasNotText: cur })
|
|
||||||
// выбрать любой пункт выпадающего списка (li>button)
|
|
||||||
const li = page.locator('li button, [role="option"]')
|
|
||||||
if (await li.count() > 1) await li.nth(1).click()
|
|
||||||
else if (await li.count() === 1) await li.first().click()
|
|
||||||
else await page.keyboard.press('Enter')
|
|
||||||
}, list)
|
|
||||||
mark(s.route, el.key, ok, 'смена фильтра→новый запрос списка')
|
|
||||||
} else if (el.type === 'toggle') {
|
|
||||||
const lbl = el.label.replace(/^переключатель:\s*/, '')
|
|
||||||
const ok = await fires(() => page.getByText(new RegExp(lbl.slice(0, 10), 'i')).first().click(), list)
|
|
||||||
mark(s.route, el.key, ok, 'тумблер→новый запрос списка')
|
|
||||||
} else if (el.key === 'action:create') {
|
|
||||||
// Клик по созданию → переход на */new ИЛИ открытие модалки.
|
|
||||||
const before = page.url()
|
|
||||||
const btn = page.getByRole('button', { name: /Создать|Новый|Новая|Добавить|Пригласить/ }).first()
|
|
||||||
const link = page.getByRole('link', { name: /Создать|Новый|Новая|Добавить/ }).first()
|
|
||||||
if (await btn.count() > 0) await btn.click().catch(() => {})
|
|
||||||
else if (await link.count() > 0) await link.click().catch(() => {})
|
|
||||||
await page.waitForTimeout(900)
|
|
||||||
const navigated = page.url() !== before && /\/new|\//.test(page.url())
|
|
||||||
const dialog = await page.locator('[role="dialog"], .modal').count() > 0
|
|
||||||
|| await page.getByText(/^(Новый|Новая|Создать)/).count() > 0
|
|
||||||
mark(s.route, el.key, navigated || dialog, navigated ? 'переход на создание' : dialog ? 'открылась форма' : 'не открылось')
|
|
||||||
// вернёмся на список
|
|
||||||
if (page.url() !== before) await attachSession(page, sess, s.route).catch(() => {})
|
|
||||||
await page.waitForTimeout(500)
|
|
||||||
} else if (el.key === 'action:save') {
|
|
||||||
// Позитивное действие: открыть создание, заполнить первые поля, сохранить → POST.
|
|
||||||
const ok = await (async () => {
|
|
||||||
const opener = page.getByRole('button', { name: /Создать|Новый|Новая|Добавить|Пригласить/ }).first()
|
|
||||||
if (await opener.count() > 0) await opener.click().catch(() => {})
|
|
||||||
await page.waitForTimeout(700)
|
|
||||||
// заполнить первые 2 текстовых поля формы/модалки осмысленными значениями
|
|
||||||
const inputs = page.locator('input[type="text"], input:not([type]):not([type=checkbox])')
|
|
||||||
const n = Math.min(await inputs.count(), 2)
|
|
||||||
for (let i = 0; i < n; i++) await inputs.nth(i).fill(`UAT${Date.now() % 100000}${i}`).catch(() => {})
|
|
||||||
const save = page.getByRole('button', { name: /^Сохранить|^Создать|^Добавить|^Пригласить/ }).last()
|
|
||||||
const [resp] = await Promise.all([
|
|
||||||
page.waitForResponse((r) => r.request().method() === 'POST' && r.url().includes(list.replace(/\/export$/, '')), { timeout: 10_000 }).catch(() => null),
|
|
||||||
save.click().catch(() => {}),
|
|
||||||
])
|
|
||||||
return !!resp && resp.status() < 400
|
|
||||||
})().catch(() => false)
|
|
||||||
mark(s.route, el.key, ok, ok ? 'создание→POST 2xx' : 'сохранение не удалось')
|
|
||||||
await attachSession(page, sess, s.route).catch(() => {}); await page.waitForTimeout(500)
|
|
||||||
} else if (el.key === 'action:delete') {
|
|
||||||
// Удаление: ищем иконку/кнопку удаления в строке таблицы, подтверждаем → DELETE.
|
|
||||||
const ok = await (async () => {
|
|
||||||
const del = page.getByRole('button', { name: /Удалить/ }).first()
|
|
||||||
.or(page.locator('button:has(svg.lucide-trash-2), button[title*="далить"]').first())
|
|
||||||
if (await del.count() === 0) return false
|
|
||||||
const [resp] = await Promise.all([
|
|
||||||
page.waitForResponse((r) => r.request().method() === 'DELETE', { timeout: 10_000 }).catch(() => null),
|
|
||||||
(async () => { await del.click().catch(() => {}); await page.getByRole('button', { name: /^Удалить/ }).last().click().catch(() => {}) })(),
|
|
||||||
])
|
|
||||||
return !!resp && resp.status() < 400
|
|
||||||
})().catch(() => false)
|
|
||||||
mark(s.route, el.key, ok, ok ? 'удаление→DELETE 2xx' : 'не удалось удалить')
|
|
||||||
await attachSession(page, sess, s.route).catch(() => {}); await page.waitForTimeout(500)
|
|
||||||
} else {
|
|
||||||
// action:post/unpost/print — наличие управляющего элемента на экране.
|
|
||||||
const names: Record<string, RegExp> = {
|
|
||||||
'action:post': /Провести|Проведено/, 'action:unpost': /Снять|Отменить проведение/,
|
|
||||||
'action:print': /Печать|Z-отчёт|Печатать/,
|
|
||||||
}
|
|
||||||
const re = names[el.key]
|
|
||||||
const present = re ? (await page.getByRole('button', { name: re }).count() > 0
|
|
||||||
|| await page.getByText(re).count() > 0) : false
|
|
||||||
mark(s.route, el.key, present, present ? 'элемент присутствует' : 'не найден здесь')
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
mark(s.route, el.key, false, (e?.message ?? '').split('\n')[0].slice(0, 80))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`${s.priority} ${s.route}: ${Object.values(covered[s.route] ?? {}).filter((x) => x.covered).length}/${s.elements.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(dirname(OUT), { recursive: true })
|
|
||||||
writeFileSync(OUT, JSON.stringify({ generatedAt: new Date().toISOString(), covered }, null, 2))
|
|
||||||
console.log(`\n[coverage] → ${OUT}`)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Минимальный сид: 55 товаров (пагинация/сортировка), поставщик, приёмка (проведённая +
|
|
||||||
// черновик), оприходование/списание проведённые, розничный чек, платёж, смена.
|
|
||||||
async function seed(ctx: APIRequestContext) {
|
|
||||||
const first = async (url: string, pred?: (x: any) => boolean) => {
|
|
||||||
const j = await (await ctx.get(url)).json(); const items = Array.isArray(j) ? j : (j.items ?? [])
|
|
||||||
return (pred ? items.find(pred) : items[0]).id as string
|
|
||||||
}
|
|
||||||
const unitId = await first('/api/catalog/units-of-measure?pageSize=50')
|
|
||||||
const storeId = await first('/api/catalog/stores?pageSize=50')
|
|
||||||
const currencyId = await first('/api/catalog/currencies?pageSize=50', (c: any) => c.code === 'KZT')
|
|
||||||
const priceTypeId = await first('/api/catalog/price-types?pageSize=50')
|
|
||||||
const groupId = await first('/api/catalog/product-groups?pageSize=50')
|
|
||||||
const supplier = (await (await ctx.post('/api/catalog/counterparties', {
|
|
||||||
data: { name: `Поставщик COV ${Date.now()}`, kind: 1, isSupplier: true, isCustomer: false },
|
|
||||||
})).json()).id
|
|
||||||
const mk = async (name: string, article: string, price: number) => (await (await ctx.post('/api/catalog/products', {
|
|
||||||
data: { name, article, unitOfMeasureId: unitId, vat: 12, vatEnabled: true, productGroupId: groupId,
|
|
||||||
isService: false, packaging: 0, isMarked: false, referencePrice: price * 0.7, purchaseCurrencyId: currencyId,
|
|
||||||
prices: [{ priceTypeId, amount: price, currencyId }], barcodes: [] },
|
|
||||||
})).json()).id
|
|
||||||
const p = await mk('COVTOVAR поиск', 'COV999', 1234)
|
|
||||||
for (let i = 0; i < 54; i++) await mk(`COV товар ${String(i).padStart(2, '0')}`, `C${1000 + i}`, 100 + i)
|
|
||||||
// приёмка (проведённая) + черновик — даёт остаток и движения
|
|
||||||
const sup = await (await ctx.post('/api/purchases/supplies', { data: { date: new Date().toISOString(), supplierId: supplier, storeId, currencyId, lines: [{ productId: p, quantity: 40, unitPrice: 800 }] } })).json()
|
|
||||||
if (sup?.id) await ctx.post(`/api/purchases/supplies/${sup.id}/post`)
|
|
||||||
await ctx.post('/api/purchases/supplies', { data: { date: new Date().toISOString(), supplierId: supplier, storeId, currencyId, lines: [{ productId: p, quantity: 3, unitPrice: 810 }] } })
|
|
||||||
// оприходование и списание (проведённые)
|
|
||||||
const ent = await (await ctx.post('/api/inventory/enters', { data: { date: new Date().toISOString(), storeId, currencyId, lines: [{ productId: p, quantity: 5, unitCost: 50 }] } })).json()
|
|
||||||
if (ent?.id) await ctx.post(`/api/inventory/enters/${ent.id}/post`)
|
|
||||||
const loss = await (await ctx.post('/api/inventory/losses', { data: { date: new Date().toISOString(), storeId, currencyId, reason: 0, lines: [{ productId: p, quantity: 1, unitCost: 50 }] } })).json()
|
|
||||||
if (loss?.id) await ctx.post(`/api/inventory/losses/${loss.id}/post`)
|
|
||||||
// розничный чек (проведённый) — для /sales/retail
|
|
||||||
try {
|
|
||||||
const sale = await (await ctx.post('/api/sales/retail', { data: { date: new Date().toISOString(), storeId, retailPointId: null, customerId: null, currencyId, payment: 0, paidCash: 5000, paidCard: 0, lines: [{ productId: p, quantity: 1, unitPrice: 1234, discount: 0, vatPercent: 0 }] } })).json()
|
|
||||||
if (sale?.id) await ctx.post(`/api/sales/retail/${sale.id}/post`)
|
|
||||||
} catch { /* продажа опциональна */ }
|
|
||||||
// инвентаризация (черновик) — для /inventory/inventories
|
|
||||||
try { await ctx.post('/api/inventory/inventories', { data: { date: new Date().toISOString(), storeId, lines: [{ productId: p, factQty: 10 }] } }) } catch { /* */ }
|
|
||||||
// возврат поставщику из проведённой приёмки — для /purchases/supplier-returns
|
|
||||||
try { if (sup?.id) await ctx.post(`/api/purchases/supplier-returns/from-supply/${sup.id}`) } catch { /* */ }
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
/**
|
|
||||||
* Сводит экстракт элементов (docs/uat-coverage.expected.json) и результат браузерного
|
|
||||||
* прогона (reports/uat-covered.json) → docs/uat-coverage.json + docs/uat-coverage.html
|
|
||||||
* с процентами покрытия по экрану, по приоритету и общим. Цель: P1–P3 ≥95%, P4 ≥70%.
|
|
||||||
*/
|
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
||||||
import { resolve, dirname } from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
||||||
const EXP = resolve(HERE, '../../../docs/uat-coverage.expected.json')
|
|
||||||
const COV = resolve(HERE, '../reports/uat-covered.json')
|
|
||||||
const OUTJSON = resolve(HERE, '../../../docs/uat-coverage.json')
|
|
||||||
const OUTHTML = resolve(HERE, '../../../docs/uat-coverage.html')
|
|
||||||
|
|
||||||
const exp = JSON.parse(readFileSync(EXP, 'utf8'))
|
|
||||||
const cov = existsSync(COV) ? JSON.parse(readFileSync(COV, 'utf8')).covered : {}
|
|
||||||
|
|
||||||
const rows: any[] = []
|
|
||||||
const byPrio: Record<string, { total: number; done: number }> = {}
|
|
||||||
let total = 0, done = 0
|
|
||||||
for (const s of exp.screens) {
|
|
||||||
const c = cov[s.route] ?? {}
|
|
||||||
const els = s.elements.map((e: any) => {
|
|
||||||
const hit = c[e.key]?.covered === true
|
|
||||||
if (hit) done++
|
|
||||||
total++
|
|
||||||
;(byPrio[s.priority] ??= { total: 0, done: 0 }).total++
|
|
||||||
if (hit) byPrio[s.priority].done++
|
|
||||||
return { ...e, covered: hit, how: c[e.key]?.how ?? '' }
|
|
||||||
})
|
|
||||||
const scDone = els.filter((e: any) => e.covered).length
|
|
||||||
rows.push({ ...s, elements: els, pct: s.elements.length ? Math.round((scDone / s.elements.length) * 100) : 100, done: scDone })
|
|
||||||
}
|
|
||||||
const pct = (d: number, t: number) => (t ? Math.round((d / t) * 100) : 100)
|
|
||||||
const summary = {
|
|
||||||
overall: pct(done, total), total, done,
|
|
||||||
byPriority: Object.fromEntries(Object.entries(byPrio).map(([k, v]) => [k, { pct: pct(v.done, v.total), ...v }])),
|
|
||||||
targets: { P1: 95, P2: 95, P3: 95, P4: 70 },
|
|
||||||
}
|
|
||||||
writeFileSync(OUTJSON, JSON.stringify({ generatedAt: new Date().toISOString(), summary, screens: rows }, null, 2))
|
|
||||||
|
|
||||||
const p13 = ['P1', 'P2', 'P3'].reduce((a, k) => ({ total: a.total + (byPrio[k]?.total ?? 0), done: a.done + (byPrio[k]?.done ?? 0) }), { total: 0, done: 0 })
|
|
||||||
const badge = (p: number, target: number) => `<b style="color:${p >= target ? '#16a34a' : '#dc2626'}">${p}%</b>`
|
|
||||||
const html = `<!doctype html><meta charset="utf-8"><title>UAT coverage — ALL MARKET</title>
|
|
||||||
<style>
|
|
||||||
body{font:14px system-ui,Segoe UI,Roboto,sans-serif;margin:0;background:#f8fafc;color:#0f172a;padding:24px}
|
|
||||||
h1{font-size:20px} .muted{color:#64748b}
|
|
||||||
.cards{display:flex;gap:16px;flex-wrap:wrap;margin:16px 0}
|
|
||||||
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:14px 18px;min-width:150px}
|
|
||||||
.card .n{font-size:26px;font-weight:700}
|
|
||||||
table{border-collapse:collapse;width:100%;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}
|
|
||||||
th,td{padding:7px 10px;border-bottom:1px solid #eef2f7;text-align:left;vertical-align:top}
|
|
||||||
th{background:#f1f5f9;font-size:12px;text-transform:uppercase;letter-spacing:.03em;color:#475569}
|
|
||||||
.ok{color:#16a34a} .no{color:#dc2626}
|
|
||||||
.chip{display:inline-block;font-size:11px;padding:1px 7px;border-radius:999px;background:#eef2f7;margin:1px}
|
|
||||||
.bar{height:8px;border-radius:4px;background:#e2e8f0;overflow:hidden;min-width:90px}
|
|
||||||
.bar>i{display:block;height:100%}
|
|
||||||
code{background:#f1f5f9;padding:0 4px;border-radius:4px}
|
|
||||||
</style>
|
|
||||||
<h1>UAT coverage — покрытие каждого элемента каждого экрана</h1>
|
|
||||||
<div class="muted">Сгенерировано ${new Date().toISOString()} · элементы извлечены из кода (web/src/pages), покрытие — браузерный прогон на стенде.</div>
|
|
||||||
<div class="cards">
|
|
||||||
<div class="card"><div class="muted">Всего элементов</div><div class="n">${total}</div></div>
|
|
||||||
<div class="card"><div class="muted">Покрыто</div><div class="n">${done}</div></div>
|
|
||||||
<div class="card"><div class="muted">Общее</div><div class="n">${summary.overall}%</div></div>
|
|
||||||
<div class="card"><div class="muted">P1–P3 (цель 95%)</div><div class="n">${badge(pct(p13.done, p13.total), 95)}</div><div class="muted">${p13.done}/${p13.total}</div></div>
|
|
||||||
<div class="card"><div class="muted">P4 (цель 70%)</div><div class="n">${badge(summary.byPriority.P4?.pct ?? 0, 70)}</div><div class="muted">${summary.byPriority.P4?.done ?? 0}/${summary.byPriority.P4?.total ?? 0}</div></div>
|
|
||||||
</div>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Пр.</th><th>Экран</th><th>%</th><th></th><th>Элементы (зелёные покрыты)</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${rows.map((s) => `<tr>
|
|
||||||
<td>${s.priority}</td>
|
|
||||||
<td><b>${s.title}</b><br><span class="muted">${s.route}</span></td>
|
|
||||||
<td>${badge(s.pct, s.priority === 'P4' ? 70 : 95)}<br><span class="muted">${s.done}/${s.elements.length}</span></td>
|
|
||||||
<td><div class="bar"><i style="width:${s.pct}%;background:${s.pct >= (s.priority === 'P4' ? 70 : 95) ? '#16a34a' : '#f59e0b'}"></i></div></td>
|
|
||||||
<td>${s.elements.map((e: any) => `<span class="chip" style="background:${e.covered ? '#dcfce7' : '#fee2e2'};color:${e.covered ? '#166534' : '#991b1b'}" title="${(e.how || '').replace(/"/g, '')}">${e.covered ? '✓' : '✗'} ${e.key}</span>`).join(' ')}</td>
|
|
||||||
</tr>`).join('\n')}
|
|
||||||
</tbody>
|
|
||||||
</table>`
|
|
||||||
writeFileSync(OUTHTML, html)
|
|
||||||
|
|
||||||
console.log(`[coverage] overall ${summary.overall}% (${done}/${total})`)
|
|
||||||
for (const [k, v] of Object.entries(summary.byPriority)) console.log(` ${k}: ${(v as any).pct}% (${(v as any).done}/${(v as any).total})`)
|
|
||||||
console.log(` P1-P3: ${pct(p13.done, p13.total)}% (${p13.done}/${p13.total}) · цель 95`)
|
|
||||||
console.log(`→ ${OUTHTML}`)
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
/**
|
|
||||||
* Статический экстрактор интерактивных элементов web-страниц → denominator для
|
|
||||||
* измеримого UAT-покрытия. Читает web/src/App.tsx (route → компонент → файл) и
|
|
||||||
* каждый pages/*.tsx, достаёт: поиск, фильтры (Select/даты/тумблеры), сортируемые
|
|
||||||
* колонки, пагинацию, экспорт, кнопки действий. Пишет docs/uat-coverage.expected.json.
|
|
||||||
*
|
|
||||||
* Запуск: pnpm exec tsx tools/extract-controls.ts (из tests/e2e)
|
|
||||||
*/
|
|
||||||
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
||||||
import { resolve, dirname, join } from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
||||||
const WEB = resolve(HERE, '../../../src/food-market.web/src')
|
|
||||||
const APP = resolve(WEB, 'App.tsx')
|
|
||||||
const PAGES = resolve(WEB, 'pages')
|
|
||||||
const OUT = resolve(HERE, '../../../docs/uat-coverage.expected.json')
|
|
||||||
|
|
||||||
// Рекурсивно: имя компонента (без .tsx) → путь файла (pages и подпапки, напр. money/).
|
|
||||||
function buildFileMap(dir: string, map: Record<string, string> = {}): Record<string, string> {
|
|
||||||
for (const name of readdirSync(dir)) {
|
|
||||||
const p = join(dir, name)
|
|
||||||
if (statSync(p).isDirectory()) buildFileMap(p, map)
|
|
||||||
else if (name.endsWith('.tsx')) map[name.replace(/\.tsx$/, '')] = p
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
const FILES = buildFileMap(PAGES)
|
|
||||||
|
|
||||||
type El = { key: string; label: string; type: string }
|
|
||||||
type Screen = { route: string; page: string; file: string; priority: string; title: string; listUrl: string; elements: El[] }
|
|
||||||
|
|
||||||
// Приоритеты по категоризации владельца (UAT P1–P4).
|
|
||||||
const PRIORITY: Record<string, string> = {
|
|
||||||
'/catalog/products': 'P1', '/inventory/stock': 'P1', '/sales/retail': 'P1',
|
|
||||||
'/purchases/supplies': 'P2', '/money/shifts': 'P2',
|
|
||||||
'/inventory/enters': 'P3', '/inventory/losses': 'P3', '/inventory/inventories': 'P3',
|
|
||||||
'/inventory/movements': 'P3', '/purchases/supplier-returns': 'P3', '/purchases/invoices': 'P3',
|
|
||||||
'/money/transactions': 'P3', '/money/supplier-debts': 'P3', '/money/report': 'P3',
|
|
||||||
'/reports/sales': 'P3', '/reports/stock': 'P3', '/reports/profit': 'P3',
|
|
||||||
'/catalog/counterparties': 'P3', '/catalog/data-quality': 'P3',
|
|
||||||
'/settings/employees': 'P4', '/settings/employee-roles': 'P4', '/catalog/stores': 'P4',
|
|
||||||
'/catalog/retail-points': 'P4', '/money/accounts': 'P4', '/settings/organization': 'P4',
|
|
||||||
'/money/expense-items': 'P4',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Карта: route (список) → компонент. Парсим App.tsx. element={...} может содержать
|
|
||||||
// вложенные {} (RoleGuard roles={[…]}, AdvancedModeGuard) — балансируем скобки.
|
|
||||||
function routeComponentMap(): Record<string, string> {
|
|
||||||
const src = readFileSync(APP, 'utf8')
|
|
||||||
const map: Record<string, string> = {}
|
|
||||||
const re = /<Route\s+path="([^"]+)"\s+element=\{/g
|
|
||||||
let m: RegExpExecArray | null
|
|
||||||
while ((m = re.exec(src))) {
|
|
||||||
const path = m[1]
|
|
||||||
// Сбалансированный разбор содержимого element={...}.
|
|
||||||
let i = re.lastIndex, depth = 1
|
|
||||||
while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++ }
|
|
||||||
const element = src.slice(re.lastIndex, i - 1)
|
|
||||||
// Только app-списки: абсолютный путь, без параметров/new, не super-admin/онбординг.
|
|
||||||
if (!path.startsWith('/') || path.includes(':') || path.endsWith('/new') || path === '*') continue
|
|
||||||
if (path.startsWith('/super-admin') || path === '/' || path.startsWith('/onboarding') || path === '/help' || path === '/whats-new') continue
|
|
||||||
if (['/login', '/forgot-password', '/reset-password', '/auth-bridge', '/no-organization'].includes(path)) continue
|
|
||||||
const comp = [...element.matchAll(/([A-Z][A-Za-z0-9]+Page)\b/g)].map((x) => x[1])
|
|
||||||
.find((c) => !/Guard|Layout|Protected/.test(c))
|
|
||||||
if (comp) map[path] = comp
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
function slug(s: string): string {
|
|
||||||
return s.toLowerCase().replace(/[^a-zа-я0-9]+/gi, '-').replace(/^-+|-+$/g, '').slice(0, 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extract(route: string, comp: string): Screen | null {
|
|
||||||
const file = FILES[comp]
|
|
||||||
if (!file || !existsSync(file)) return null
|
|
||||||
const src = readFileSync(file, 'utf8')
|
|
||||||
const els: El[] = []
|
|
||||||
const add = (key: string, label: string, type: string) => {
|
|
||||||
if (!els.some((e) => e.key === key)) els.push({ key, label, type })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/<SearchBar\b/.test(src)) add('search', 'поиск', 'search')
|
|
||||||
if (/<Pagination\b/.test(src)) add('pagination', 'пагинация', 'pagination')
|
|
||||||
for (const m of src.matchAll(/<ExportButton[^>]*?url=["']([^"']+)["']/g))
|
|
||||||
add('export', `экспорт (${m[1]})`, 'export')
|
|
||||||
if (!els.some((e) => e.key === 'export') && /<ExportButton\b/.test(src)) add('export', 'экспорт', 'export')
|
|
||||||
|
|
||||||
// Сортируемые колонки DataTable: sortKey + ближайший header-строка (в т.ч. строковый
|
|
||||||
// фолбэк в выражении header: expr ?? 'Текст').
|
|
||||||
for (const m of src.matchAll(/sortKey:\s*['"]([^'"]+)['"]/g)) {
|
|
||||||
const key = m[1]
|
|
||||||
const around = src.slice(Math.max(0, m.index! - 260), m.index! + 60)
|
|
||||||
// header: '...' ИЛИ любая строка-литерал в значении header (фолбэк ?? '...')
|
|
||||||
const direct = [...around.matchAll(/header:\s*['"]([^'"]+)['"]/g)].pop()
|
|
||||||
let label = direct?.[1]
|
|
||||||
if (!label) {
|
|
||||||
const hExpr = around.match(/header:\s*([^,]*?),/)
|
|
||||||
const quoted = hExpr?.[1]?.match(/['"]([^'"]{2,})['"]/)
|
|
||||||
label = quoted?.[1]
|
|
||||||
}
|
|
||||||
add(`sort:${key}`, `сортировка: ${label ?? key}`, 'sort')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Фильтры списка: <Select>/<select>, у которого обработчик сбрасывает страницу
|
|
||||||
// (setPage) — признак именно ФИЛЬТРА СПИСКА, а не поля формы в модалке.
|
|
||||||
let selIdx = 0
|
|
||||||
for (const m of src.matchAll(/<[Ss]elect\b([^]*?)<\/[Ss]elect>/g)) {
|
|
||||||
const block = m[1]
|
|
||||||
if (!/setPage|setSort|refetch|onFilter/.test(block)) continue // поле формы — пропускаем
|
|
||||||
selIdx++
|
|
||||||
const opts = [...block.matchAll(/<option[^>]*>([^<]+)<\/option>/g)].map((o) => o[1].trim())
|
|
||||||
const label = opts.find((o) => o && !o.includes('{') && !/^[a-z]\./.test(o)) ?? `фильтр ${selIdx}`
|
|
||||||
const key = label.startsWith('фильтр ') ? `filter:sel${selIdx}` : `filter:${slug(label)}`
|
|
||||||
add(key, `фильтр: ${label}`, 'filter')
|
|
||||||
}
|
|
||||||
// Даты/периоды: только если это фильтр списка/отчёта (есть дата-контрол + отчётная зона).
|
|
||||||
if ((/type="date"|DateInput|DateRange|MonthInput/i.test(src) && /setFrom|setTo|setPeriod|setDate|setOn|refetch|queryKey/.test(src))
|
|
||||||
|| /на дату|За период|periodFrom|periodTo/i.test(src))
|
|
||||||
add('filter:period', 'фильтр: период/дата', 'filter')
|
|
||||||
// Тумблеры-ФИЛЬТРЫ списка: Checkbox с меткой, чей onChange трогает setPage.
|
|
||||||
for (const m of src.matchAll(/<Checkbox\b([^]*?)\/>/g)) {
|
|
||||||
const tag = m[1]
|
|
||||||
if (!/setPage/.test(tag)) continue
|
|
||||||
const lbl = tag.match(/label=["']([^"']+)["']/)
|
|
||||||
if (lbl) add(`toggle:${slug(lbl[1])}`, `переключатель: ${lbl[1]}`, 'toggle')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кнопки действий (list-страница): по подписям/confirmLabel/мутациям.
|
|
||||||
const actions: [RegExp, string, string][] = [
|
|
||||||
[/confirmLabel:\s*['"]Провести['"]|>\s*Провести|\/post['"`]/, 'action:post', 'провести'],
|
|
||||||
[/Отменить проведение|confirmLabel:\s*['"]Снять['"]|>\s*Снять|\/unpost['"`]/, 'action:unpost', 'снять проведение'],
|
|
||||||
[/variant="danger"|confirmLabel:\s*['"]Удалить['"]|>\s*Удалить|\.delete\(/, 'action:delete', 'удалить'],
|
|
||||||
[/window\.print|\/print|>\s*Печать/, 'action:print', 'печать'],
|
|
||||||
[/на основании|from-supply|createReturn/, 'action:create-from', 'создать на основании'],
|
|
||||||
[/type="submit"|>\s*Сохранить|confirmLabel:\s*['"]Сохранить['"]/, 'action:save', 'сохранить'],
|
|
||||||
[/>\s*(Создать|Новый|Добавить|Пригласить)\b|to="[^"]*\/new"|SplitButtonMenu/, 'action:create', 'создать'],
|
|
||||||
]
|
|
||||||
for (const [re, key, label] of actions) if (re.test(src)) add(key, `действие: ${label}`, 'action')
|
|
||||||
|
|
||||||
// Заголовок страницы (ListPageShell title / h1) и list-URL (useCatalogList/const URL/export).
|
|
||||||
const title = (src.match(/title="([^"]+)"/) ?? src.match(/<h1[^>]*>([^<]+)<\/h1>/))?.[1]?.trim() ?? comp
|
|
||||||
const listUrl = (src.match(/useCatalogList<[^>]*>\(\s*([A-Za-z_]+)/)
|
|
||||||
? (src.match(/const\s+URL\s*=\s*['"]([^'"]+)['"]/) ?? [])[1]
|
|
||||||
: undefined)
|
|
||||||
?? (src.match(/useCatalogList<[^>]*>\(\s*['"]([^'"]+)['"]/) ?? [])[1]
|
|
||||||
?? (src.match(/<ExportButton[^>]*url=["']([^"']+)\/export/) ?? [])[1]
|
|
||||||
?? (src.match(/api\.get[^(]*\(`?\$?\{?['"]?(\/api\/[a-z/-]+)/) ?? [])[1]
|
|
||||||
?? ''
|
|
||||||
return { route, page: comp, file: file.replace(WEB + '/', ''), priority: PRIORITY[route] ?? 'P4', title, listUrl, elements: els }
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = routeComponentMap()
|
|
||||||
const screens: Screen[] = []
|
|
||||||
for (const [route, comp] of Object.entries(map)) {
|
|
||||||
const s = extract(route, comp)
|
|
||||||
if (s && s.elements.length > 0) screens.push(s)
|
|
||||||
}
|
|
||||||
screens.sort((a, b) => (a.priority + a.route).localeCompare(b.priority + b.route))
|
|
||||||
|
|
||||||
const total = screens.reduce((n, s) => n + s.elements.length, 0)
|
|
||||||
writeFileSync(OUT, JSON.stringify({ generatedAt: new Date().toISOString(), totalElements: total, screens }, null, 2))
|
|
||||||
console.log(`[extract] ${screens.length} экранов, ${total} элементов → ${OUT}`)
|
|
||||||
for (const s of screens) console.log(` ${s.priority} ${s.route} (${s.page}): ${s.elements.length} — ${s.elements.map((e) => e.key).join(', ')}`)
|
|
||||||
|
|
@ -20,15 +20,11 @@ public void Generated_is_valid_ean13_with_correct_check_digit()
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Default_prefix_is_2x_not_leading_zero()
|
public void Default_prefix_is_02_and_not_parsed_as_weighted()
|
||||||
{
|
{
|
||||||
var code = InternalBarcode.Generate(WeightedBarcodeSettings.Default);
|
var code = InternalBarcode.Generate(WeightedBarcodeSettings.Default);
|
||||||
// Внутренний код — зона restricted circulation 20–29 (не «02»: с ведущим нулём
|
code.Should().StartWith("02");
|
||||||
// 13-значный код выглядит как UPC-A и может совпасть с реальным товаром).
|
// Внутренний код НЕ должен распознаваться как весовая/ценовая этикетка.
|
||||||
code.Should().StartWith("2");
|
|
||||||
code[1].Should().BeInRange('0', '9');
|
|
||||||
code[0].Should().NotBe('0');
|
|
||||||
// По умолчанию (разбор этикеток выключен) как весовая не распознаётся.
|
|
||||||
WeightedBarcode.Parse(code, WeightedBarcodeSettings.Default).IsWeighted.Should().BeFalse();
|
WeightedBarcode.Parse(code, WeightedBarcodeSettings.Default).IsWeighted.Should().BeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue