МЕРА, область: коридор сделок узнаёт города развёрток, ГАР не раздаёт домам чужой регион, история цен не врёт про «только этот дом» #3575

Merged
bot-backend merged 4 commits from fix/region-audit-tail into main 2026-09-17 11:22:18 +00:00
Collaborator

Остаток аудита области #2583. Три независимые правки, по коммиту на каждую. #2583 этим PR не закрывается (что осталось — в конце).

Перед работой сверил статус пунктов по коду main и по проду:

  • C3: закрыт PR #2836 (db4fb0a9 в main, Guard-5 на месте, тест test_estimator_quarter_index.py).
  • H3 (#2588) и H4 (#2627): закрыты раньше.
  • H2: закрыт частично (#2617), остаток сходится сам по мере заполнения listings.city.
  • H1, H5, M2: были не сделаны. Это они.

H1: город развёртки попадает в городской фильтр коридора сделок

Что было. _resolve_target_city знал 24 имени из REGIONS[66].cities, а развёртки идут ещё по 23 городам: Реж, Карпинск, Кушва, Лесной, Тавда, Ивдель, Туринск, Артёмовский, Камышлов и другие. Если город не распознан, _fetch_dkp_corridor собирает сделки по одной улице во всей области. «Ленина» там в основном екатеринбургская, поэтому Реж получал коридор по ценам Екатеринбурга. От того же резолвера зависят /street-deals, /sales-vs-listings и повторное открытие оценки: они тоже терялись.

Улики (прод, 2026-09-17, только чтение).

  • По всем 23 городам в deals есть сделки Росреестра, city записан в форме с «е»: Реж 745 (из них 189 за 12 мес.), Карпинск 521/161, Лесной 1215/402, Артемовский 562/185.
  • Ложных срабатываний нет: среди сделок с city='Екатеринбург' ни в одном адресе эти имена не встречаются отдельным словом.
  • Среди 1275 оценок в trade_in_estimates ни одна пока не называет эти города, то есть вред ещё не случился.

Что сделано. Резолвер теперь знает и города развёрток: имена из CITY_DISPLAY_NAMES, те же, что пишутся в listings.city. Города других регионов (Москву) исключаю. Общий REGIONS[66].cities сознательно не трогал. На нём стоят гейты геокодера, которые в #2582 намеренно ушли от перечисления городов. Ещё на нём держатся токены ключа дома: _OBLAST_CITY_TOKENS вырезал бы «новая»/«лесной» из названий улиц.

Не сделано. Населённый пункт вне обоих словарей (сёла) по-прежнему даёт коридор без города. За всю историю таких оценок 1 из 1275: вне bbox ЕКБ, внутри региона 66, город не распознан. Если понадобится, можно отключать коридор, когда город неизвестен и точка вне ЕКБ.

H5: дом получает ГАР-guid только своего региона

Что было. Фильтры матча (регион и город) стояли только на стороне ГАР. UPDATE houses шёл по канону «улица+дом» по всем домам всех регионов. После #3523 (матч для 77 и 50) это стало порчей данных.

Улики (прод, 2026-09-17). Соединил houses.gar_house_guid с gar_house_flats.house_guid:

регион дома регион ГАР-строки домов
66 50 578
66 77 340
50 77 1371
77 50 497

По чужому guid загрузчики ФРТ и капремонта матчат COALESCE(gar_house_guid, house_fias_id, …): чужой идентификатор важнее своего ФИАС.

Что сделано.

  • Матч сверяет houses.region_code с регионом ГАР-строки. Канон ранжируется внутри региона: PARTITION BY region_code, canon.
  • Перед матчем снимаются уже проставленные canon_addr-guid чужого региона. Без этого дом, у которого нет пары в своём регионе, так и остался бы с чужим guid. Дом без region_code не трогаю: не доказано, что guid чужой.
  • В лог добавлено «снято guid чужого региона=N», в runbook добавлен абзац.

Городская часть H5 внутри региона 66 не сделана, и это осознанно. Текстовый фильтр «адрес дома называет другой город» ложно исключил бы 17 правильных екатеринбургских домов с адресом «мкр. Заречный». Домов, которые называют другой город и при этом получили екатеринбургский guid, на проде 0. Около 13 домов с голым адресом вне bbox ЕКБ (Верхняя Пышма, Среднеуральск) текстом не отличить.

Уже записанные данные ФРТ и капремонта, пришедшие по чужому guid, эта правка не исправляет.

M2: подпись истории цен

Что было. /house-analytics при менее чем 8 архивных лотах добирает до 30 соседних домов в радиусе 300 м и возвращает radius_m. Карточка всё равно писала «История цен в этом доме · только этот дом». Рядом, в шапке того же экрана, стояло «в радиусе 300 м».

Что сделано. Заголовок и подпись зависят от radius_m: при расширении выводится «История цен: дом и соседние · дом и соседние в радиусе N м». Поправлены v2 (mappers.ts и AnalyticsView.tsx, включая aria-label и <title>) и старый PriceHistoryChart.

Тесты

Код возврата снят у самих pytest/vitest. Все прогоны на ветке после rebase на origin/main a130303c.

  • Backend, весь сьют: DATABASE_URL=...localhost:5432/test uv run python -m pytest tests/ -q -p no:cacheprovider4 failed, 6422 passed, 45 skipped, rc=1.
    • Все 4 падения не из этого PR, они воспроизводятся на чистом main a130303c: 4 failed, 6 passed, rc=1. Это test_3466_corridor_tier_a.py (2 теста) и test_estimator_radius_floor.py (2 теста), ошибка AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_slack'.
    • Причина: #2380 перевёл эти поля в константы, а тесты #3466/#3554 смержены параллельно и читают их из Settings.
  • Тесты на живой Postgres (локальный postgis/postgis:16-3.4): tests/test_gar_flats_loader.py + tests/test_estimator_target_city_sweep_2583.py81 passed, rc=0. Три живых ГАР-теста реально исполнились, а не пропустились. Новый живой тест добавлен в skip_allowlist.txt.
  • ruff: ruff check app tests → rc=0. ruff format --check по изменённым файлам → rc=0.
  • Frontend: npx tsc --noEmit → rc=0; npx vitest run36 files, 314 passed, rc=0; npm run lint → rc=0, ошибок 0.
  • scraper-kit: uv run python -m pytest -qno tests ran, rc=5. В пакете нет тестов, и пакет в PR не менялся: из него только читается CITY_DISPLAY_NAMES.

Фальсификация

Для каждого пункта ломал фикс вручную на копии исходника (копия лежит в scratchpad), прогонял тест, восстанавливал файл и проверял diff -q.

H1. В _TARGET_CITIES заменил | на or, то есть вернул прежний словарь из 24 имён. 28 failed, 24 passed, rc=1:

E       AssertionError: assert None == 'реж'
E       AssertionError: assert None == 'карпинск'
...
>       assert raw["count"] == 4
E       assert 14 == 4

14 сделок вместо 4: это коридор по «Ленина» всей области, 10 из них екатеринбургские.

H5, а. Убрал AND CAST(h.region_code AS text) = gp.region_code. Тест на живой Postgres, 1 failed, 28 passed, rc=1:

E           AssertionError: assert 2 == 1
E            +  where 2 = <function match_houses_to_gar ...>(<Session>, region_code='66')

Прогон региона 66 проставил екатеринбургский guid подмосковному дому.

H5, б. Отключил снятие чужих guid. 1 failed, 28 passed, rc=1:

E             Extra items in the left set:
E             ('ул. Мира,7', 66, 'g50-mira', 250)
E             Extra items in the right set:
E             ('ул. Мира,7', 66, None, None)

M2, а. В маппере radiusM = 0, в PriceHistoryChart условие заменил на false ?. 3 failed | 1 passed, rc=1:

AssertionError: expected 'История цен в этом доме' to be 'История цен: дом и соседние'
TestingLibraryElementError: Unable to find an element with the text: История цен: дом и соседние.

M2, б. В AnalyticsView вернул константный заголовок. 1 failed | 3 passed, rc=1, падает тест «экран v2 печатает подпись из данных, а не константу».

Приёмка на проде (после деплоя)

Критерии записаны до деплоя. Срок: 2026-09-20.

  1. H1: код в контейнере. Команда docker exec tradein-backend python -c "from app.services.estimator import _resolve_target_city as r; print(r('Реж, Ленина, 12'), r('Карпинск, Ленина, 5'))" должна напечатать реж карпинск. Старый код печатает None None, так что маркер отличает версии.
  2. H1: коридор. В контейнере вызвать _fetch_dkp_corridor(SessionLocal(), address='Реж, Ленина, 12', rooms=2, area=45.0, city='реж'), там только SELECT. count должен совпасть со сделками Режа (deals.city='Реж', улица Ленина, площадь ±15%, 12 мес.), а не со сделками «Ленина» по всей области.
  3. H5. Сама правка ничего на проде не пишет. Эффект наступит только после ops-прогона python -m app.tasks.gar_flats_load --match-only --region 66, затем --region 77 и --region 50. Запускает владелец, это запись в прод.
    • После прогона запрос SELECT h.region_code, g.region_code, count(*) FROM houses h JOIN gar_house_flats g ON g.house_guid = h.gar_house_guid WHERE h.region_code::text <> g.region_code GROUP BY 1,2 должен вернуть 0 строк. База на 2026-09-17: 578 / 340 / 1371 / 497.
    • В логе прогона 66 должно быть «снято guid чужого региона=N» с N около 918.
    • Число матчей 66↔66 (сейчас 7104) не должно заметно упасть.
  4. M2. В бандле tradein-frontend должна появиться строка «дом и соседние». Открыть в v2 раздел «Аналитика дома» для оценки, где шапка показывает «в радиусе 300 м»: заголовок карточки должен быть «История цен: дом и соседние». Проверять на скриншоте.

Деплой

Миграций нет. Меняются образы backend (он же в scraper) и frontend: пересоздадутся tradein-backend, tradein-scraper и tradein-frontend. Перед мержем проверить SELECT count(*) FROM scrape_runs WHERE status='running': дренаж деплоя ждёт не больше 5 минут, потом пересоздаёт scraper.

Что остаётся в #2583

  • H2: закрыт частично. Отдельной задачи на коэффициент выкупа по городам нет. Замеры по городам лежат в комментарии к #2583 от 02.08.
  • M1: основное сделано раньше: confidence_explanation в сводке и подсказке, радиус на карте. analog_tier, address_precision и manual_review_recommended v2 не показывает, и нужно ли их показывать, решает владелец.
  • Городская часть H5 и сёла в H1 (см. выше).

Refs #2583

🤖 Generated with Claude Code

Остаток аудита области #2583. Три независимые правки, по коммиту на каждую. #2583 этим PR **не закрывается** (что осталось — в конце). Перед работой сверил статус пунктов по коду `main` и по проду: - **C3**: закрыт PR #2836 (`db4fb0a9` в `main`, Guard-5 на месте, тест `test_estimator_quarter_index.py`). - **H3** (#2588) и **H4** (#2627): закрыты раньше. - **H2**: закрыт частично (#2617), остаток сходится сам по мере заполнения `listings.city`. - **H1, H5, M2**: были не сделаны. Это они. ## H1: город развёртки попадает в городской фильтр коридора сделок **Что было.** `_resolve_target_city` знал 24 имени из `REGIONS[66].cities`, а развёртки идут ещё по 23 городам: Реж, Карпинск, Кушва, Лесной, Тавда, Ивдель, Туринск, Артёмовский, Камышлов и другие. Если город не распознан, `_fetch_dkp_corridor` собирает сделки по одной улице во всей области. «Ленина» там в основном екатеринбургская, поэтому Реж получал коридор по ценам Екатеринбурга. От того же резолвера зависят `/street-deals`, `/sales-vs-listings` и повторное открытие оценки: они тоже терялись. **Улики (прод, 2026-09-17, только чтение).** - По всем 23 городам в `deals` есть сделки Росреестра, `city` записан в форме с «е»: Реж 745 (из них 189 за 12 мес.), Карпинск 521/161, Лесной 1215/402, Артемовский 562/185. - Ложных срабатываний нет: среди сделок с `city='Екатеринбург'` ни в одном адресе эти имена не встречаются отдельным словом. - Среди 1275 оценок в `trade_in_estimates` ни одна пока не называет эти города, то есть вред ещё не случился. **Что сделано.** Резолвер теперь знает и города развёрток: имена из `CITY_DISPLAY_NAMES`, те же, что пишутся в `listings.city`. Города других регионов (Москву) исключаю. Общий `REGIONS[66].cities` сознательно не трогал. На нём стоят гейты геокодера, которые в #2582 намеренно ушли от перечисления городов. Ещё на нём держатся токены ключа дома: `_OBLAST_CITY_TOKENS` вырезал бы «новая»/«лесной» из названий улиц. **Не сделано.** Населённый пункт вне обоих словарей (сёла) по-прежнему даёт коридор без города. За всю историю таких оценок 1 из 1275: вне bbox ЕКБ, внутри региона 66, город не распознан. Если понадобится, можно отключать коридор, когда город неизвестен и точка вне ЕКБ. ## H5: дом получает ГАР-guid только своего региона **Что было.** Фильтры матча (регион и город) стояли только на стороне ГАР. `UPDATE houses` шёл по канону «улица+дом» по всем домам всех регионов. После #3523 (матч для 77 и 50) это стало порчей данных. **Улики (прод, 2026-09-17).** Соединил `houses.gar_house_guid` с `gar_house_flats.house_guid`: | регион дома | регион ГАР-строки | домов | |---|---|---| | 66 | 50 | **578** | | 66 | 77 | **340** | | 50 | 77 | **1371** | | 77 | 50 | **497** | По чужому guid загрузчики ФРТ и капремонта матчат `COALESCE(gar_house_guid, house_fias_id, …)`: чужой идентификатор важнее своего ФИАС. **Что сделано.** - Матч сверяет `houses.region_code` с регионом ГАР-строки. Канон ранжируется внутри региона: `PARTITION BY region_code, canon`. - Перед матчем снимаются уже проставленные `canon_addr`-guid чужого региона. Без этого дом, у которого нет пары в своём регионе, так и остался бы с чужим guid. Дом без `region_code` не трогаю: не доказано, что guid чужой. - В лог добавлено «снято guid чужого региона=N», в runbook добавлен абзац. **Городская часть H5 внутри региона 66 не сделана, и это осознанно.** Текстовый фильтр «адрес дома называет другой город» ложно исключил бы 17 правильных екатеринбургских домов с адресом «мкр. Заречный». Домов, которые называют другой город и при этом получили екатеринбургский guid, на проде 0. Около 13 домов с голым адресом вне bbox ЕКБ (Верхняя Пышма, Среднеуральск) текстом не отличить. **Уже записанные данные ФРТ и капремонта**, пришедшие по чужому guid, эта правка не исправляет. ## M2: подпись истории цен **Что было.** `/house-analytics` при менее чем 8 архивных лотах добирает до 30 соседних домов в радиусе 300 м и возвращает `radius_m`. Карточка всё равно писала «История цен в этом доме · только этот дом». Рядом, в шапке того же экрана, стояло «в радиусе 300 м». **Что сделано.** Заголовок и подпись зависят от `radius_m`: при расширении выводится «История цен: дом и соседние · дом и соседние в радиусе N м». Поправлены v2 (`mappers.ts` и `AnalyticsView.tsx`, включая aria-label и `<title>`) и старый `PriceHistoryChart`. ## Тесты Код возврата снят у самих pytest/vitest. Все прогоны на ветке после rebase на `origin/main` `a130303c`. - **Backend, весь сьют:** `DATABASE_URL=...localhost:5432/test uv run python -m pytest tests/ -q -p no:cacheprovider` → `4 failed, 6422 passed, 45 skipped`, rc=1. - Все 4 падения **не из этого PR**, они воспроизводятся на чистом `main` `a130303c`: `4 failed, 6 passed`, rc=1. Это `test_3466_corridor_tier_a.py` (2 теста) и `test_estimator_radius_floor.py` (2 теста), ошибка `AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_slack'`. - Причина: #2380 перевёл эти поля в константы, а тесты #3466/#3554 смержены параллельно и читают их из `Settings`. - **Тесты на живой Postgres** (локальный `postgis/postgis:16-3.4`): `tests/test_gar_flats_loader.py` + `tests/test_estimator_target_city_sweep_2583.py` → `81 passed`, rc=0. Три живых ГАР-теста реально исполнились, а не пропустились. Новый живой тест добавлен в `skip_allowlist.txt`. - **ruff:** `ruff check app tests` → rc=0. `ruff format --check` по изменённым файлам → rc=0. - **Frontend:** `npx tsc --noEmit` → rc=0; `npx vitest run` → `36 files, 314 passed`, rc=0; `npm run lint` → rc=0, ошибок 0. - **scraper-kit:** `uv run python -m pytest -q` → `no tests ran`, rc=5. В пакете нет тестов, и пакет в PR не менялся: из него только читается `CITY_DISPLAY_NAMES`. ## Фальсификация Для каждого пункта ломал фикс вручную на копии исходника (копия лежит в scratchpad), прогонял тест, восстанавливал файл и проверял `diff -q`. **H1.** В `_TARGET_CITIES` заменил `|` на `or`, то есть вернул прежний словарь из 24 имён. `28 failed, 24 passed`, rc=1: ``` E AssertionError: assert None == 'реж' E AssertionError: assert None == 'карпинск' ... > assert raw["count"] == 4 E assert 14 == 4 ``` 14 сделок вместо 4: это коридор по «Ленина» всей области, 10 из них екатеринбургские. **H5, а.** Убрал `AND CAST(h.region_code AS text) = gp.region_code`. Тест на живой Postgres, `1 failed, 28 passed`, rc=1: ``` E AssertionError: assert 2 == 1 E + where 2 = <function match_houses_to_gar ...>(<Session>, region_code='66') ``` Прогон региона 66 проставил екатеринбургский guid подмосковному дому. **H5, б.** Отключил снятие чужих guid. `1 failed, 28 passed`, rc=1: ``` E Extra items in the left set: E ('ул. Мира,7', 66, 'g50-mira', 250) E Extra items in the right set: E ('ул. Мира,7', 66, None, None) ``` **M2, а.** В маппере `radiusM = 0`, в `PriceHistoryChart` условие заменил на `false ?`. `3 failed | 1 passed`, rc=1: ``` AssertionError: expected 'История цен в этом доме' to be 'История цен: дом и соседние' TestingLibraryElementError: Unable to find an element with the text: История цен: дом и соседние. ``` **M2, б.** В `AnalyticsView` вернул константный заголовок. `1 failed | 3 passed`, rc=1, падает тест «экран v2 печатает подпись из данных, а не константу». ## Приёмка на проде (после деплоя) Критерии записаны до деплоя. Срок: **2026-09-20**. 1. **H1: код в контейнере.** Команда `docker exec tradein-backend python -c "from app.services.estimator import _resolve_target_city as r; print(r('Реж, Ленина, 12'), r('Карпинск, Ленина, 5'))"` должна напечатать `реж карпинск`. Старый код печатает `None None`, так что маркер отличает версии. 2. **H1: коридор.** В контейнере вызвать `_fetch_dkp_corridor(SessionLocal(), address='Реж, Ленина, 12', rooms=2, area=45.0, city='реж')`, там только SELECT. `count` должен совпасть со сделками Режа (`deals.city='Реж'`, улица Ленина, площадь ±15%, 12 мес.), а не со сделками «Ленина» по всей области. 3. **H5.** Сама правка ничего на проде не пишет. Эффект наступит только после ops-прогона `python -m app.tasks.gar_flats_load --match-only --region 66`, затем `--region 77` и `--region 50`. Запускает владелец, это запись в прод. - После прогона запрос `SELECT h.region_code, g.region_code, count(*) FROM houses h JOIN gar_house_flats g ON g.house_guid = h.gar_house_guid WHERE h.region_code::text <> g.region_code GROUP BY 1,2` должен вернуть 0 строк. База на 2026-09-17: 578 / 340 / 1371 / 497. - В логе прогона 66 должно быть «снято guid чужого региона=N» с N около 918. - Число матчей 66↔66 (сейчас 7104) не должно заметно упасть. 4. **M2.** В бандле `tradein-frontend` должна появиться строка «дом и соседние». Открыть в v2 раздел «Аналитика дома» для оценки, где шапка показывает «в радиусе 300 м»: заголовок карточки должен быть «История цен: дом и соседние». Проверять на скриншоте. ## Деплой Миграций нет. Меняются образы backend (он же в scraper) и frontend: пересоздадутся `tradein-backend`, `tradein-scraper` и `tradein-frontend`. Перед мержем проверить `SELECT count(*) FROM scrape_runs WHERE status='running'`: дренаж деплоя ждёт не больше 5 минут, потом пересоздаёт scraper. ## Что остаётся в #2583 - **H2**: закрыт частично. Отдельной задачи на коэффициент выкупа по городам нет. Замеры по городам лежат в комментарии к #2583 от 02.08. - **M1**: основное сделано раньше: `confidence_explanation` в сводке и подсказке, радиус на карте. `analog_tier`, `address_precision` и `manual_review_recommended` v2 не показывает, и нужно ли их показывать, решает владелец. - Городская часть H5 и сёла в H1 (см. выше). Refs #2583 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 3 commits 2026-09-17 09:47:09 +00:00
Резолвер города знал 24 имени региона 66, а развёртки идут ещё по 23 городам
(Реж, Карпинск, Кушва, Лесной, Тавда…). Город не распознан — коридор ДКП
собирался по одной улице во всей области, где «Ленина» в основном
екатеринбургская, и Реж получал цену Екатеринбурга.

Резолвер теперь знает и города развёрток (кроме городов других регионов).
Общий словарь региона не трогали: на нём гейты геокодера и токены ключа дома.
Замер прода: по всем 23 городам сделки лежат под теми же е-формами, в сделках
ЕКБ ни одного адреса с этими именами отдельным словом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Фильтры матча стояли только на стороне ГАР, UPDATE houses шёл по канону
улица+дом по всем домам всех регионов. После #3523 (матч для 77 и 50) это
стало порчей: прод 2026-09-17 — 578 домов региона 66 с guid региона 50,
340 — с guid 77, 1371 дом 50 с guid 77, 497 домов 77 с guid 50. По чужому
guid ЖКХ/ФРТ/капремонт-загрузчики тянут данные другого дома.

Матч сверяет регион дома с регионом ГАР-строки и ранжирует канон внутри
региона. Перед матчем снимаются уже проставленные canon_addr-guid чужого
региона (дом без региона не трогаем) — иначе дом без пары в своём регионе
так и остался бы с чужим. Эффект на проде — после ops-прогона --match-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(tradein/ui): «только этот дом» только когда выборка не расширялась соседями (#2583 M2)
Some checks failed
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 8m11s
CI Trade-In / changes (pull_request) Successful in 20s
CI / changes (pull_request) Successful in 33s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 3m17s
aefbd993bb
/house-analytics при <8 архивных лотах добирает до 30 соседних домов в
радиусе 300 м и возвращает radius_m. Карточка истории цен всё равно писала
«История цен в этом доме · только этот дом», а рядом на том же экране стояло
«в радиусе 300 м».

Заголовок и подпись теперь зависят от radius_m: при расширении — «дом и
соседние в радиусе N м». Поправлены обе версии экрана (v2 и старый
PriceHistoryChart).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 1 commit 2026-09-17 10:49:38 +00:00
Merge remote-tracking branch 'origin/main' into fix/region-audit-tail
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 24s
CI Trade-In / frontend-checks (pull_request) Successful in 3m19s
CI / changes (pull_request) Successful in 28s
CI Trade-In / backend-tests (pull_request) Successful in 8m48s
CI Trade-In / browser-tests (pull_request) Has been skipped
372328b726
bot-backend merged commit 9c71349eed into main 2026-09-17 11:22:18 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3575
No description provided.