Compare commits

...

87 Commits

Author SHA1 Message Date
gorbenko.pavel 8f1385c301 Реестр пакетов и ссылки на git.tnsu.kz — по https
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:02:03 +05:00
gorbenko.pavel 1797168047 Реестр пакетов: организация переименована Tansu → tansu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:35:14 +05:00
gorbenko.pavel 842b9ad678 Обновлён @tnsu/ui-kit до 0.14.0, реестр @tnsu — организация Tansu
Все версии ui-kit скопированы в публичный реестр организации Tansu (контрольные суммы совпадают),
токен для установки больше не нужен. Сборка проекта проверена.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:47:30 +05:00
Тимур Городецкий 2591d94414 docs: CHANGELOG — заметное добавление поставщика (#12)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 08:59:42 +05:00
Тимур Городецкий 2eaa75e876 feat(ct): заметное добавление потенциального поставщика на Шаге 3 (#12)
Блок добавления не замечали: поле без подписи и серая заблокированная кнопка
читались как «не работает», а сам блок стоял в зоне настроек. Теперь у блока
заголовок и подсказка, кнопка «Добавить» не блокируется (с пустым названием
подсказывает, что ввести), «Из базы поставщиков» рядом. И вторая точка входа
там, где ищут, — колонка «+ Добавить поставщика» в шапке таблицы с окном
«вручную или из базы». Добавление вручную — одна функция на обе кнопки.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 08:59:10 +05:00
Тимур Городецкий eaef011b73 docs: CHANGELOG — своя единица у доп. расходов (#11)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 17:36:01 +05:00
Тимур Городецкий 99c720f26c feat(ct): своя единица у логистики и доп. расходов на Шаге 3 (#11)
У логистики единица была зашита — «рейс», у доп. расходов её задавали только
при добавлении. Теперь единица строки — поле ввода; поставщики, у которых её
не меняли, получают новую, своя единица поставщика остаётся. Подсказка в
количестве логистики — единица строки. Строка уже в SP — единица обновляется
в «Позиции СТ» при сохранении.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 17:35:53 +05:00
Тимур Городецкий 107110b2b8 docs: CHANGELOG — заглушки КП и двойная колонка (#8, #10)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 17:03:06 +05:00
Тимур Городецкий da2c86b4a9 fix(ct): файлы-заглушки КП не видны в списках и не идут в пакет INCORE (#10)
Поставщику, добавленному на Шаге 3 вручную, система создаёт в библиотеке КП
файл-заглушку .txt — оффер в SP должен ссылаться на файл КП. Он показывался
в «Коммерческих предложениях», в «Загруженных КП» Шага 2, в подписи колонки
и скачивался в пакет INCORE. Теперь при показе и выгрузке заглушки скрыты
(узнаём по .txt — на Шаге 2 загружают только pdf/docx/doc), в подписи колонки —
«Добавлен вручную». Данные формы не меняются: по ним сохранение находит файл.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 17:02:58 +05:00
Тимур Городецкий 09a0889741 fix(ct): поставщик Шага 3 после черновика не задваивается при открытии СТ (#8)
«Сохранить черновик» оставляет поставщика, добавленного на Шаге 3, в списке
шага, а в SP он уже лежит колонкой «поставщик_заглушка». Если других
поставщиков в СТ нет, при открытии из реестра колонки собирались из SP — и
поставщик приходил дважды: из SP без цен и из сессии с ценами (подписи
колонок именем файла-заглушки .txt и «КП 2»). Теперь колонки из SP, которые
уже есть поставщиком Шага 3, не добавляются, а их офферы идут к нему.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 16:52:37 +05:00
Тимур Городецкий 5c21718914 docs: CHANGELOG — подсветка незаполненного (#9)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 16:43:39 +05:00
Тимур Городецкий 78e5eec9b6 feat(ct): подсветка незаполненного на Шаге 3, условия оплаты обязательны (#9)
Красная рамка — то, что не пускает дальше Шага 3: «Выбрать», пока нет
победителя, пустой километраж логистики, пустые условия оплаты. Условия
оплаты были помечены обязательными, но сохранять не мешали — теперь
проверяются. Пустая цена — бледной заливкой: заметно, но сохранению не мешает,
поставщик мог не предложить позицию. Подсветка видна сразу, пока заполняют.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 16:43:31 +05:00
Тимур Городецкий ad76e59ca0 feat(ct): «Сохранить черновик» на Шагах 2 и 3 (#8)
Запись шага в SharePoint без проверок и без перехода — правки не пропадают при
закрытии вкладки. Проверено в воркбенче, дублей поставщиков в SP нет.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 16:24:03 +05:00
Тимур Городецкий 053e4d4c3e docs: CHANGELOG — черновик на Шагах 2 и 3 (#8)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 16:24:03 +05:00
Тимур Городецкий 09b1df87c3 feat(ct): «Сохранить черновик» на Шагах 2 и 3 (#8)
Несохранённые правки жили в памяти вкладки и пропадали при её закрытии, а
«Сохранить и продолжить» на Шаге 3 не пускает без победителя и километража.
Черновик пишет шаг в SharePoint так же, без проверок, и оставляет на шаге;
рядом — время последнего сохранения.

На Шаге 3 черновик не переносит поставщиков, добавленных на шаге, в общий
список: таблица держит их в своём, перенос задвоил бы колонку и обнулил бы
суммы. Повторные черновики дублей в SP не создают — SP Id уже запомнены.
На Шаге 2 черновик доступен, когда заданы курсы валют: они пишутся в общий
список «Курс Валют».

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 16:16:23 +05:00
Тимур Городецкий 7341774e50 fix(ct): снятый выбор победителя не возвращается после F5 (#7)
Выбор победителя жил только в состоянии таблицы и попадал в данные формы
лишь по «Сохранить». После F5 сессия не знала, что выбор сняли, и форма
подставляла победителя из шапки СТ. Теперь выбор и снятие сразу пишутся в
данные формы, как статус и исключение поставщика, а победитель из шапки СТ
берётся только когда сессии нет (СТ открыта в другом браузере).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 15:59:25 +05:00
Тимур Городецкий 9da03f931c docs: CHANGELOG — выбор победителя (#7)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 15:15:52 +05:00
Тимур Городецкий 952a7ed42d fix(ct): выбор победителя снимается, без победителя СТ не сохраняется (#7)
Кнопку «Выбран» нельзя было снять. А если победитель не выбран, «Сохранить»
не трогала winSupplier — в шапке СТ оставался прежний (так же при исключении
выбранного поставщика, #1). Теперь «Выбрать» — переключатель, победитель
обязателен: «Сохранить и продолжить» без него не пускает, проверка — до
диалога комментария ИИ. При открытии СТ без сессии выбор восстанавливается из
winSupplier шапки, иначе в другом браузере пришлось бы выбирать заново.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 15:15:44 +05:00
Тимур Городецкий 90b009dfc9 docs: CHANGELOG — цены с пробелами (#6)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 15:00:11 +05:00
Тимур Городецкий 6ad40bc523 fix(ct): цена с пробелами между разрядами считается целиком (#6)
Ввод чисел разбирался parseFloat, который читает до первого пробела: цену
«100 000», скопированную из КП, таблица считала как 100. Все поля ввода СТ
(цена, цена в ₸, количество, километраж, курс валюты) разбирает одна функция:
обычные и неразрывные пробелы, «275 293,50», «1,234,567.89», хвост «₸».

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 15:00:05 +05:00
Тимур Городецкий 9b0135b379 docs: CHANGELOG — серверный поиск на шаге 1 (#5)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 14:48:08 +05:00
Тимур Городецкий 3a6aaa4722 feat(ct): поиск по номеру потребности на шаге 1 — на сервере (#5)
Раньше поиск фильтровал в браузере всё, что загружено за период: чтобы найти
старую потребность, приходилось поднимать период и ждать загрузки сотен
потребностей ради одной. Теперь номер уходит в API (?requestNumber=, часть
номера, в пределах периода) после паузы в наборе — приходят только совпадения.

Попутно: позиции, выбранные до смены списка (поиск, другой период), больше не
пропадают при «Сохранить» — карта позиций копится, а не пересобирается из
текущего списка.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 14:48:01 +05:00
Тимур Городецкий efbe732995 fix: манифесты — колонки, которые пишет код, но не было в схеме (#2)
Без них списки, пересозданные по манифестам, ломали бы шаг 1 СТ
(requestPositionId), сохранение офферов (offerFileId), добавление поставщика
из базы (контакты в «Поставщики») и позиции ОИД (RequestCode, RequestUrl).
Названия и типы — как в живых списках /sites/sed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 14:33:05 +05:00
Тимур Городецкий 40839cc8d8 feat: Compare-Manifests.ps1 — сверка манифестов с живыми списками (#2)
Манифесты отстают от живых списков, когда колонки добавляют руками или кодом,
и пересоздание по ним ломает функции. Скрипт только читает и показывает, чего
нет в манифесте, чего нет на сайте и где отличаются тип, варианты или лукап.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 14:29:24 +05:00
Тимур Городецкий 0ff17d3293 feat(ct): исключение поставщика из сравнения вместо удаления (#1)
Шаг 3 СТ ОМТС: крестик исключает поставщика с обязательной причиной, цены
хранятся в «Исключённые предложения», «Вернуть» возвращает колонку целиком.
Решение и варианты — docs/adr/0001. Проверено в воркбенче на СТ 372;
открытие в другом браузере и PDF-отчёт — после выкатки.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 14:11:43 +05:00
Тимур Городецкий 606c7c0fa9 docs: исключение поставщика — только СТ ОМТС, ОИД и КПС вынесены в #3 и #4 (#1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 12:52:09 +05:00
Тимур Городецкий 40fcc775f7 docs: ADR 0001 — известные ограничения исключения поставщика (#1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 11:52:16 +05:00
Тимур Городецкий 74f6d42e4f feat(ct): поставщика исключают из сравнения с причиной и могут вернуть (#1)
Раньше крестик на Шаге 3 удалял поставщика вместе с ценами, а «Сохранить»
стирал его офферы в SP — случайно удалённого было не вернуть.

Теперь крестик спрашивает обязательную причину и скрывает колонку: таблица,
итоги, рекомендация, ИИ и Adata считаются без него, выбор с исключённого
снимается. Цены остаются в правках и сессии, «Сохранить» пишет их в
«Исключённые предложения», при открытии СТ они подтягиваются. Под таблицей —
список исключённых с кнопкой «Вернуть»; в режиме просмотра скрыт константой.
Повторная загрузка КП на Шаге 2 исключение не снимает.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 11:51:45 +05:00
Тимур Городецкий 373e34b2cf feat(ct): чтение и запись исключённых поставщиков в SP (#1)
CtService: loadExcludedOffers / replaceExcludedOffers — сначала новые строки,
потом прежние в корзину, чтобы сбой записи не терял исключённых; дубли отсеивает
чтение. removeCTPositions убирает и исключённые строки снятых позиций.
Преобразования строк SP ↔ колонки и офферы формы — отдельно, с тестами.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 11:43:32 +05:00
Тимур Городецкий 3907e5a60b feat: общий модуль строк «Исключённых предложений» для трёх форм (#1)
Чистые функции без SharePoint: тело записи, разбор элемента, сборка колонок
(поставщик + файл КП) и строка-маркер для поставщика без цен. Один модуль
на СТ, ОИД и КПС, чтобы правила хранения не разошлись между формами.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 11:35:32 +05:00
Тимур Городецкий b5d7efc749 feat: Provision-Lists.ps1 — вход по файлу сертификата .pfx (#1)
Рабочий app-only сертификат для /sites/sed лежит файлом с паролем, а не в
хранилище CurrentUser\My — скрипт умел только по отпечатку.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 11:26:24 +05:00
Тимур Городецкий f9bed709ea feat: Provision-Lists.ps1 — фильтр -Lists для точечного добавления списков (#1)
Полный прогон на рабочем сайте перезаписывает варианты Choice живых списков
значениями из манифеста, а манифесты отстали от живых списков (#2). Новые
списки «Исключённые предложения» создаём точечно, не трогая остальные.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 11:23:58 +05:00
Тимур Городецкий 26b937afba chore: манифесты списков «Исключённые предложения» для СТ, ОИД и КПС (#1)
Исключённые поставщики хранятся отдельно от «Оценки поставщиков», чтобы
отчёт СТ, INCORE и потоки их не видели. Колонки — копия офферов своей формы
плюс СТ и причина; файл КП — числом, лукап на библиотеку не читается через REST.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 11:23:04 +05:00
Тимур Городецкий 58a6e13f36 docs: ветки — только для долгих, зависимых и рискованных задач
Один разработчик без ревью: ветка нужна не всегда, а когда защищает main от
полуготового кода. Мелкие фиксы — прямо в main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 10:47:00 +05:00
Тимур Городецкий 3ede7f2af4 docs: процесс работы, журнал решений и CHANGELOG (#1)
Требования приходят из чата и меняются — фиксируем, что просили, что решили
и что выехало: регламент в docs/PROCESS.md, ADR 0001 по исключению
поставщиков вместо удаления, шаблон задачи для Gitea, пустой CHANGELOG.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 10:44:09 +05:00
Тимур Городецкий 0f9afdba7d fix: комментарий ИИ разбирается при любой форме структурированного ответа
Тот же промпт вернул другую структуру: массив из одного объекта, список
поставщиков suppliers_analysis вместо suppliers_evaluation, плюсы/минусы
строкой вместо массива — и снова показывался сырой JSON. Разбор стал
терпимым: объект или массив объектов, список поставщиков — любое поле-
массив объектов с supplier, плюсы/минусы — строка или массив, плюс
recommendation.
2026-10-23 10:44:45 +05:00
Тимур Городецкий cfbdfd17c9 fix: комментарий ИИ из структурированного ответа потока показывается текстом
Поток «Комментарий ИИ» стал отвечать JSON {summary, suppliers_evaluation,
conclusion} вместо текста — в шаге 3 и в SP (а значит и в PDF) лежал
сырой JSON. aiCommentToText разворачивает структуру в текст (вывод,
плюсы/минусы по поставщикам, итог) без Markdown-разметки; прежние
форматы (текст, {comment}) — как раньше. Подключено в СТ, ОИД и КПС —
при ответе потока и при чтении из SP, чтобы старые записи с JSON тоже
показывались текстом.
2026-10-23 10:35:38 +05:00
Тимур Городецкий a0d9179d9d fix(ct): null из потока КП не превращается в строку «null»
Когда в КП нет поля (например, сертификатов), поток отдаёт null, а
String(null) давал «null» — он показывался в «Сертификатах» шага 3 и
сохранялся в «Критерии». Разбор ответа вынесен в mapFlowResult: null —
«не распознано», как уже сделано в КПС и ОИД.
2026-10-22 10:15:37 +05:00
Тимур Городецкий 48c7999fa0 fix(ct): у закреплённой потребности вид работ не подменяется видом СМР позиции
Шапку потребности старше периода собираем из позиций СТ, а вида работ
потребности там нет — подставлялся smrType позиции («ГКЛ перегородки»
вместо «СТЕНЫ И ПЕРЕГОРОДКИ»). Теперь пусто, в таблице «—».
2026-10-22 10:06:25 +05:00
Тимур Городецкий 1669c1b0e8 fix(ct): степпер черновика открыт до шага, до которого дошла СТ
В новой вкладке (из реестра) сессии нет, и у черновика без маршрута
степпер давал кликнуть только шаг 1 — до таблицы приходилось идти через
«Сохранить и продолжить» на каждом шаге. Теперь доступные шаги — по
данным СТ из SP: позиции → 2, КП → 3, маршрут → 4.
2026-10-22 09:29:51 +05:00
Тимур Городецкий f99732962b fix(ct): СТ по ссылке открывается после загрузки из SP
Шаги рендерились сразу, а СТ подгружалась позже. Шаг 1 запоминал выбор
при появлении — пустой, и при открытии из реестра показывал «Выберите
позиции», пока не обновить страницу. Теперь до загрузки — спиннер,
ошибка загрузки показывается в форме, а не только в консоли.
2026-10-22 09:23:19 +05:00
Тимур Городецкий 62ebcb2f32 fix(ct): id позиции потребности — по её содержимому, а не по месту в ответе API
API /Requests/{oid}/positions отдаёт позиции без id и в разном порядке
(lineNumber — номер после сортировки по наименованию). id вида
GUID__номер указывал на разные позиции: на шаге 1 снимали одну — из СТ
удалялась другая.

Ключ — GUID потребности + хеш наименования, R66, АГСК, ед. изм.,
количества и вида СМР; одинаковые строки получают номер совпадения.
Метка h- отличает ключ от старых номерных — те ни с чем не сопоставятся.
2026-10-22 09:20:58 +05:00
Тимур Городецкий 5382459414 fix(ct): файлы КП сохраняются под латинским именем
Бэкенд extractPDFText теряет расширение у PDF с кириллицей в имени
(OCR 415 «unsupported file type ''») и отвечает 500 — КП падало при
каждой первой загрузке. Повторная загрузка «проходила» лишь потому, что
текст бэкенд успевал записать, и фронт пропускал извлечение.

Имя в библиотеке КП транслитерируется, остальные символы — в «_».
Результат детерминированный: поиск уже загруженного файла и проверка
дублей работают как раньше.
2026-10-22 09:20:56 +05:00
Тимур Городецкий 61913ee79b fix(ct): шаг 1 видит позиции СТ, открытой из реестра
Позиции СТ из SP не были связаны с потребностями (id = SP Id), поэтому
на шаге 1 при «Выбрано: N» ни одна строка не была отмечена, «Только
выбранные» показывал пустой список, а снять выбор было негде.

- в «Позиции СТ» пишется requestPositionId (GUID потребности__номер),
  шаг 1 сопоставляет выбор по нему; id позиции остаётся SP Id, к нему
  привязаны предложения;
- потребности, чьи позиции уже в СТ, показываются и вне выбранного
  периода (позиции догружаются сразу);
- проект восстанавливается из шапки СТ — «Сохранить и продолжить»
  молча ничего не делала и снимался запрет на чужой проект.

У СТ, созданных до колонки, связи нет — их позиции в таблице не видны.
2026-10-21 12:51:38 +05:00
Тимур Городецкий e40fc402d9 fix(ct): поле км в строке логистики не схлопывается при 3+ поставщиках
Минимальные ширины половинок «рейсы | км», инпут занимает всю половинку, у км убраны стрелки
type=number — они обрезали число.
2026-10-20 14:28:46 +05:00
Тимур Городецкий c1e8792174 feat(ct): поставщик-победитель записывается в шапку СТ на шаге 3
По «Сохранить и продолжить» выбранный поставщик пишется в лукап winSupplier: на шаге 4 меняется
только статус, а поставщик мог быть как обычной колонкой, так и добавленным на шаге 3.
2026-10-20 14:28:45 +05:00
Тимур Городецкий 5192311fff fix: заголовок меню портала «Снабжение» вместо заглушки «Договора»
Название вынесено в SIDEBAR_BRAND — реестр и база контрагентов берут его из одного места.
2026-10-20 14:26:21 +05:00
Тимур Городецкий 4079bddc71 fix(approval): повторная отправка на согласование не падает на удалении старого маршрута
recycle() слал POST без Content-Type с пустым odata-version — SPFx (jsonRequest) сам бросал
«requires the OData-Version header to be 3.0 or 4.0» до запроса. writeRoute общий: повторная
отправка после доработки/перезапуска ломалась во всех трёх формах; в КПС тот же recycle.
2026-10-02 12:29:23 +05:00
Тимур Городецкий 045a4f655b refactor(ct): режим формы — по автору и статусу СТ, а не по регистру ctid/ctId
Раньше ?ctid= (ссылки потока, реестр не-черновиков) включал режим согласующего любому, кто открыл,
и режим залипал в сессии: автор СТ «На доработке» по ссылке из письма не мог править.
Теперь getCtAccess: автор правит в draft…formed и «На доработке»; согласующий и автор на
согласовании/после — просмотр с ценами на шаге 3; посторонний — просмотр без цен.
ctid/ctId равнозначны, approvalSent/isNotificationLink убраны.
2026-10-02 12:07:59 +05:00
Тимур Городецкий 2f00a2dcc9 fix(ct): «Сохранить и продолжить» на шаге 3 пишет «Не получены КП» и доп. информацию в SP
Вызовы syncNoOfferSuppliers/replaceCriteria из п.3 и п.4 претензий не попали в коммиты
f5853b1 и 2b102b8 (вошли только импорты): без них согласующий и PDF не видели поставщиков
без КП и доп. поля, если шаг 2 прошли крошками.
2026-10-02 12:07:44 +05:00
Тимур Городецкий 34da9ae165 fix(ct): поток уведомлений получает строку того, кто вернул на доработку или отклонил
Без активного согласующего форма отправляла потоку последнюю строку маршрута — при возврате на
доработку это мог быть следующий согласующий в статусе «Ждёт очереди», и в письме инициатору
нельзя было указать, кто вернул и с каким комментарием. Теперь (pickRowToNotify): активный
согласующий → его строка; иначе строка «На доработке» или «Отклонено»; иначе последняя.
2026-10-02 11:13:07 +05:00
Тимур Городецкий 90759fc4bc fix(ct): подсветка минимальной суммы в каждой позиции, а не только в общем итоге
Минимум по позиции считался, но подсветку получали только строка «Итого по позиции» у комплекта
и строки доп. расходов — обычная ячейка «Сумма, ₸» не подсвечивалась никогда, и зелёный был виден
только в общем итоге. Итог комплекта к тому же пересчитывался отдельно (без override цены в ₸ и
кол-ва позиции по умолчанию) и сравнивался строгим ===, поэтому минимум терялся на копейках.
Теперь суммы «поставщик × позиция» — один источник (sumPositionTotals), сравнение с допуском
(isMinPositionTotal), минимальная сумма зелёная в каждой позиции.
2026-10-01 17:44:07 +05:00
Тимур Городецкий 2593021a70 feat(ct): кнопка «На доработку» у согласующего; после доработки маршрут идёт с начала
Вернуть СТ инициатору на исправление было нельзя — только «Отклонить». Теперь у согласующего
(шаг 4 и шаг 3 по ссылке из Teams) есть «На доработку» с обязательным комментарием. Строка и СТ
получают статус «На доработке», СТ сразу открыта инициатору для правки, на шаге 4 — плашка, кто
вернул и что исправить. Повторная отправка переписывает маршрут целиком — согласование с начала.
«Отклонить» с перезапуском работает как раньше.

Решение rework добавлено в общий ApproverService/DecisionPanel, но кнопка включена только в СТ ОМТС
(allowRework); в ОИД/КПС статус недостижим. В SP в Choice status «Сравнительная таблица» и
«Согласование СТ» добавлено «На доработке» (и «Отклонено» в статус СТ, которое форма уже писала).
Уведомление инициатору при доработке — в потоке Power Automate (вне репо).
2026-10-01 17:26:29 +05:00
Тимур Городецкий 04cd6fe690 fix(ct): файл КП сохраняется с префиксом СТ — одноимённый КП другой СТ не перезаписывает его
КП загружались в корень библиотеки под исходным именем с перезаписью: «КП.pdf» одной СТ затирал
«КП.pdf» другой, и согласующий первой СТ открывал чужое КП. Теперь файл хранится как
«<id СТ>_<имя>» (buildKpStorageName); это имя идёт в поиск уже загруженного файла, поток, список
«Загруженные КП» и ссылку на файл. Повторная загрузка того же файла в эту же СТ по-прежнему
распознаётся.
2026-10-01 17:03:14 +05:00
Тимур Городецкий 56247fc98f feat(ct): ссылки на КП на шаге 3 для согласующих; участие в цепочке — по почте
Согласующий по ссылке из Teams попадает сразу на шаг 3, а ссылки на КП были только на шаге 2 —
открыть КП при согласовании было негде. Теперь под таблицей сравнения блок «Коммерческие
предложения» (поставщик → файл). Показывается тем же, кому видны цены: в КП тоже цены.
Участие в цепочке согласования сверялось по ФИО — при другом написании согласующему скрывались
цены (и теперь КП); сверяем по почте без учёта регистра, ФИО — запасной вариант.
2026-10-01 16:56:40 +05:00
Тимур Городецкий fa6d88f2ab feat(ct): подсветка несоответствия ед. изм. поставщика и заявки на шаге 3
Несоответствие единиц было видно только по незаметной «*» и только при включённом тумблере.
Теперь «Ед. изм.» поставщика подсвечивается жёлтым, если отличается от заявки (подсказка «В
заявке: кг, у поставщика: т»). С включённым тумблером единица, кол-во и цены подсвечиваются
голубым, если пересчитаны в ед. заявки, и красным, если пересчитать нельзя (шт ↔ кг). Статус —
чистая функция getUnitStatus; у тумблера — легенда цветов вместо «*».
2026-10-01 16:45:10 +05:00
Тимур Городецкий fa92cb4e2e fix(ct): «Перевести в ед. изм. заявки» понимает синонимы и штучные единицы
Тумблер знал только массу, длину, площадь и объём в одном написании. «Штука» в заявке и «шт.»
в КП считались разными незнакомыми единицами — тумблер помечал их «пересчёт невозможен», и
казалось, что он ничего не делает. Теперь единица приводится к канонической через словарь
синонимов (шт/Штука/ед, кг/Килограмм, л/Литр, кв.м./м², пог.м/м.п. и т.д.), добавлены штучные
единицы (компл, упак, пара, рулон, лист, мешок — между собой не пересчитываются). Заодно
«кв.м.» с точкой на конце снова распознаётся: точку срезали после поиска синонимов.
2026-10-01 16:38:51 +05:00
Тимур Городецкий 64a377276c fix(ct): своя строка «Дополнительной информации» по ссылке — без HTML-обёртки SharePoint
Значение своей строки хранится в comment, а это в «Критерии» ОМТС многострочное поле с
форматированием: SharePoint отдаёт его как <div class="ExternalClass…">да</div>, и по ссылке
показывалась разметка. При загрузке теперь остаётся чистый текст (plainTextFromSp).
2026-10-01 16:29:13 +05:00
Тимур Городецкий 2b102b8772 fix(ct): «Дополнительная информация» сохраняется в SP; списки для оплаты, поставки, сертификатов
Ручные правки условий и свои строки («Добавить условие») жили только в браузере: saveCriteria
не вызывался, и согласующий, PDF и автор в новой вкладке видели лишь то, что распознал ИИ.
Теперь по «Сохранить» шага 3 «Критерии» этой СТ заменяются текущими (replaceCriteria), а при
открытии грузятся по СТ, а не по поставщику из всех СТ (loadCriteriaForCt). Свои строки
хранятся в новой колонке criteriaLabel, значение — в comment, как в ОИД.

По просьбе заказчика: «Условия оплаты» — готовые варианты (частые формулировки из SP) или свой
вариант; «Сертификаты» — Да/Нет; «Условия поставки» можно сменить и после заполнения ИИ
(раньше заполненное показывалось текстом без выбора).
2026-10-01 16:18:02 +05:00
Тимур Городецкий f5853b1ad5 feat(ct): поставщики без КП — списком под таблицей сравнения, в т.ч. у согласующего
Поставщиков без КП убрали из колонок (64973a2), но взамен нигде не показывали. Теперь на
шаге 3 под «Дополнительной информацией» — список «Поставщики, не предоставившие КП» с
комментарием. «Не получены КП» сохраняются с привязкой к СТ (колонка CT была в списке, но не
заполнялась, а дедуп шёл по названию на все СТ) и синхронизируются по «Сохранить» на шагах
2 и 3 — удалённые убираются; по ссылке согласующий видит список из SP. Комментарий больше
не теряется при возврате на шаг 2.
2026-10-01 15:34:03 +05:00
Тимур Городецкий efedfc7658 fix(ct): повторное сохранение шага 1 обновляет позиции той же СТ, а не создаёт новую
Вернулись на шаг 1 и нажали «Сохранить и продолжить» — создавалась ещё одна СТ (№10 → №11) с
теми же позициями. Предложения оставались в прежней СТ, а форма переключалась на новую,
которая по ссылке открывалась без поставщиков. Теперь, если СТ уже создана, позиции
обновляются: новые дописываются, снятые уходят в корзину вместе с их предложениями, проект
в шапке меняется при смене проекта (planStep1Save).

Заодно при возврате на шаг 1 прежний выбор виден и сохраняется: позиции нераскрытых
потребностей берутся из формы, выбранные считаются по id — раньше галочки не стояли,
«Только выбранные» был пуст, «Сохранить» неактивна.
2026-10-01 15:11:39 +05:00
Тимур Городецкий 6ef39f8fc9 fix(ct): по ссылке и без сессии «Наименование ТМЦ» берётся из КП, а не из заявки
При открытии СТ по ссылке (согласующий) или в новой вкладке предложения собирались из SP без
поля supplierPosition, и шаг 3 подставлял вместо названия из КП название позиции заявки.
Согласующий не видел, что поставщик предложил другую модель или аналог, а автор, сохранив
шаг 3 без сессии, записывал в SP название заявки поверх названия поставщика. Теперь название
читается из SP и переносится (сборка офферов вынесена в buildOffersFromSpRecords).
2026-10-01 14:28:32 +05:00
Тимур Городецкий 85145e0d6e feat(ct): лишний КП поставщика убирается корзиной вместо окна «заменить / добавить»
Окно на каждый второй КП поставщика при пакетной загрузке (6 файлов — 6 окон) было непонятно.
Теперь вопросов при загрузке нет: каждый КП — своя колонка. В «Загруженных КП» файлы одного
поставщика сгруппированы с пометкой «N КП — сравниваются отдельными колонками», а корзина
(и крестик у обработанного файла) убирает КП из сравнения — колонку, цены и записи этого
файла в SP, с подтверждением. Раньше корзина только прятала строку, колонка оставалась.
Так заменяют исправленный КП: загружают новый, старый убирают.

Заменяет окно из 744b603 (п.2).
2026-10-01 12:58:25 +05:00
Тимур Городецкий dc88dfe5ff feat(ct): колонки одного поставщика с несколькими КП различаются в шапке, Adata и ИИ
У поставщика с несколькими КП колонки выглядели одинаково: в шапке подписывается файл КП,
в «Дополнительной информации», ИИ-комментарии и выборе рекомендованного используется имя
«Название (файл)» — ИИ сопоставляет поставщиков по имени и путал одинаковые. Adata проверяет
компанию: кэш и результат проверки получают все её колонки, «Проверить всех» делает один
платный запрос на компанию, а не на колонку.

Этап 3 из 4 (п.2).
2026-10-01 12:39:02 +05:00
Тимур Городецкий 744b603972 feat(ct): выбор «заменить / добавить отдельно» при втором КП того же поставщика
Система не отличает исправленный КП от отдельного предложения поставщика: поставщик
определяется только по названию. Теперь, когда поток назвал поставщика, у которого в СТ уже
есть КП, обработка ждёт выбора в окне. «Заменить» — прежние колонки поставщика уходят из
сравнения, их записи в SP — в корзину (после записи нового КП, чтобы не потерять данные при
сбое). «Добавить отдельным предложением» — отдельная колонка. Закрытие окна — «добавить»:
ничего не удаляется. Несколько КП подряд — вопросы по очереди.

Этап 2 из 4 (п.2).
2026-10-01 12:22:09 +05:00
Тимур Городецкий 3e95f1470f feat(ct): несколько КП одного поставщика — отдельные колонки в таблице сравнения
Колонка таблицы сравнения теперь «поставщик + файл КП» (id `${spId}_${kpFileSpId}`), а не только
поставщик. Два КП одного поставщика (напр. в KZT и USD) раньше схлопывались в одну колонку —
оставался последний файл, второе предложение пропадало. Теперь каждый КП — своя колонка со своей
валютой; записи в SP различаются по offerFileId: дедуп и чистка повторной обработки не трогают
записи другого КП того же поставщика, восстановление по ссылке раскладывает их по колонкам.
Старые колонки без файла (данные до изменения) работают как раньше.

Этап 1 из 4: выбора «заменить / добавить отдельно» пока нет — второй КП поставщика всегда
идёт отдельной колонкой.
2026-10-01 11:52:35 +05:00
Тимур Городецкий 9490d5b83f fix(kps): повторно загруженный КП поставщика не даёт вторую колонку в таблице сравнения
Сборщики КПС строили поставщика, предложения и критерии по каждому файлу КП, поэтому тот же
КП под другим именем (или исправленный) давал в таблице вторую колонку того же поставщика
и задвоенные строки позиций. Теперь, как в СТ (5f5f0e7), из нескольких КП одного поставщика
берётся последний. Отбор вынесен в shared/offers/kpFiles и общий для СТ и КПС; дедуп офферов
при загрузке КПС переведён на общий keepLatestOfferRecords (поведение то же).

Заодно в репозиторий попадают тесты step2Persist.test.ts.
2026-10-01 11:02:45 +05:00
Тимур Городецкий 2bf50884c0 fix(oid): повторная обработка КП поставщика заменяет его прежние предложения, а не дублирует
Шаг 2 ОИД писал предложения нового КП рядом с прежними записями поставщика. Шаг 3 всегда
грузит предложения из SP, поэтому позиция выглядела «комплектом» из одинаковых строк, а
автосохранение Шага 3 закрепляло это в SP. Теперь по повторному КП прежние записи поставщика
по тем же позициям уходят в корзину — после записи новых, чтобы при сбое не потерять данные.

Дедуп при загрузке, как в СТ, в ОИД не делаем: номер строки комплекта ОИД в Title не хранит,
и такой дедуп удалил бы настоящие строки комплекта.
2026-10-01 10:59:11 +05:00
Тимур Городецкий 1899a5ab38 fix(ct): повторное «Сохранить» на шаге 3 не создаёт доп. строки и поставщиков второй раз
Доп. строки («Логистика», «Доп. расходы») и новые поставщики шага 3 создаются в SP в начале
сохранения, а их SP-id попадали в стейт только в самом конце. Если дальше падало сохранение
офферов, id терялись, и повторное «Сохранить» создавало те же строки ещё раз. Теперь id
запоминаются сразу после создания — и при успехе, и при сбое (applyCreatedIds).

Заодно в репозиторий попадают тесты расчёта сумм matrixHelpers.test.ts.
2026-10-01 10:56:36 +05:00
Тимур Городецкий d6d92ece3f fix(ct): предложения из КП пишутся в SP сразу при обработке, а не только по «Сохранить»
Предложения писались внутри цикла по позициям и только если поставщика уже определил Syntex.
Syntex отключён, поставщика теперь называет поток — узнаём его после разбора всех позиций,
поэтому при обработке КП в «Оценка поставщиков» не попадало ничего: закрыл вкладку без
«Сохранить» — и разобранные цены пропали. Теперь записи строятся после определения поставщика
(buildKpOffersToSave), а сбои потока и записи идут в консоль вместо пустого catch.
2026-10-01 10:37:54 +05:00
Тимур Городецкий 2316f8bd6d fix(ct): повторная обработка КП не оставляет в SP дубли предложений
Каждая обработка КП создаёт записи в «Оценка поставщиков», а прежние удалялись только
по «Сохранить». Исправленный КП поставщика или файл, загруженный заново, оставлял рядом
старые записи — отсюда дубли в SP (СТ №89, №4). Теперь после создания записи прежние
записи того же поставщика по той же позиции уходят в корзину; порядок «создать, потом
удалить» — чтобы при сбое не потерять предложение.
2026-09-30 16:12:46 +05:00
Тимур Городецкий 76ad9c6242 fix(ct): одна позиция не дублируется в таблице сравнения из-за повторных записей КП
Повторная обработка КП добавляет в «Оценка поставщиков» новые записи, а прежние удаляются
только по «Сохранить» — в SP копились дубли (напр. СТ №89), и шаг 3 рисовал позицию
несколькими одинаковыми под-строками. Загрузчики офферов теперь оставляют одну запись
на (поставщик, позиция, itemIndex) — последнюю по Id. Загрузка по ссылке/без сессии
заодно восстанавливает itemIndex из Title: раньше все строки комплекта шли как 0.

Дедуп вынесен в shared/offers, чтобы переиспользовать в ОИД и КПС.
2026-09-30 15:05:08 +05:00
Тимур Городецкий 5f5f0e7f81 fix(ct):пофиксил дублирование позиций в таблице а также убрал добавление поставщика безКП в таблицу 2026-09-23 16:06:26 +05:00
Тимур Городецкий 64973a2232 fix(ct): поставщики без КП с шага 2 не попадают в таблицу сравнения
Они выводились на шаге 3 колонкой с нулевыми ценами, хотя сравнивать у них
нечего. Остаются в списке шага 2 и в «Не получены КП»; ручной ввод цен
сохранён за поставщиками, добавленными на самом шаге 3.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:56:14 +05:00
Тимур Городецкий e4b6582e3c feat(ct): выбор валюты у уже загруженных КП на шаге 2
После возврата с шага 3 очередь загрузки пуста, и КП, по умолчанию
сохранённое в тенге, нельзя было перевести в валюту без перезагрузки файла.
Теперь валюта меняется в «Загруженные КП»: обновляются supplier.currency
и пересчёт офферов в тенге; в режиме просмотра показывается код валюты.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:32:43 +05:00
Тимур Городецкий a45807f669 fix(ct): список загруженных КП пропадал при открытии СТ без сессии
Лукап offer в «Оценке поставщиков» смотрит на FileLeafRef библиотеки КП,
а SP не отдаёт такой лукап через REST (500), поэтому loadKpFilesForOffers
всегда возвращал пусто и «Загруженные КП» видел только автор в своей сессии.
Id файла теперь дублируется в числовое поле offerFileId и читается оттуда.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:30:36 +05:00
Тимур Городецкий adf3efeb64 feat(ct): выбор поставщика из базы данных на шаге СТ 2026-09-17 16:23:56 +05:00
Тимур Городецкий 7e403a8623 feat: извлекаю текст PDF через новый API 2026-09-16 15:01:04 +05:00
Тимур Городецкий b0c08bcc83 fix: финальные оповещения (oid/kps) + ленивая загрузка потребностей 2026-09-14 16:10:39 +05:00
Тимур Городецкий 0914c76e15 fix(ct): оповещения на финале+ починил загрузку по ссылке, сузил блок решения 2026-09-14 10:52:51 +05:00
Тимур Городецкий 37bc153b98 fix: перенес матрицу согласования для всех таблиц со списков на бэк 2026-09-11 16:55:21 +05:00
Тимур Городецкий a762c755fd style(kps): зеленый стиль выбранного поставщика как в СТ 2026-09-08 11:51:48 +05:00
Тимур Городецкий 5f45b7da20 fix(oid): оповещение об отклонении/согласовании (Step3 Teams-ссылка и Step4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-04 11:13:50 +05:00
Тимур Городецкий 04faca37ae Revert "fix(oid): передаём статус СТ в поток оповещения (не читаем из SP)"
This reverts commit a3ffaf696a.
2026-09-04 09:55:39 +05:00
Тимур Городецкий 7b4382370d chore: версия 1.0.0.2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-04 09:49:53 +05:00
Тимур Городецкий a3ffaf696a fix(oid): передаём статус СТ в поток оповещения (не читаем из SP)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-04 09:49:26 +05:00
Тимур Городецкий 6217135629 chore: поднять версию решения до 1.0.0.1 для деплоя правок
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-03 17:59:43 +05:00
166 changed files with 10753 additions and 5766 deletions
+20
View File
@@ -0,0 +1,20 @@
---
name: Запрос
about: Пункт из чата, устная просьба или найденная проблема
---
## Источник
- Кто:
- Когда:
- Где: Teams / устно / сам нашёл
> Цитата как есть
## Суть
Что не так или что нужно, своими словами.
## Как проверить
Что сделать руками, чтобы убедиться, что готово.
+1 -1
View File
@@ -1 +1 @@
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
+45
View File
@@ -0,0 +1,45 @@
# Журнал изменений
Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/). Версия = версия решения
в `config/package-solution.json`, на каждый деплой — тег `vX.Y.Z`.
Разделы: **Добавлено**, **Изменено**, **Исправлено**, **Удалено**.
## [Не выпущено]
### Добавлено
- СТ ОМТС, Шаги 2 и 3: кнопка «Сохранить черновик» — записывает шаг в SharePoint без проверок
и оставляет на шаге, чтобы правки не пропали при закрытии вкладки. (#8)
- СТ ОМТС, Шаг 3: незаполненное подсвечивается сразу. Красная рамка — то, без чего дальше не
пустит (победитель, километраж, условия оплаты); бледная заливка — пустая цена. (#9)
- СТ ОМТС, Шаг 3: у логистики и доп. расходов можно вписать свою единицу измерения — у логистики
вместо «рейс» может быть «тонна», «машина» и т.п. (#11)
- СТ ОМТС, Шаг 3: добавить потенциального поставщика проще найти — блок над таблицей с заголовком
и активной кнопкой, и кнопка «+ Добавить поставщика» прямо в шапке таблицы. (#12)
### Изменено
- СТ ОМТС, Шаг 3: крестик у поставщика не удаляет его, а исключает из сравнения с обязательной
причиной. Цены сохраняются, под таблицей — список исключённых с кнопкой «Вернуть». Согласующим
список пока не показывается. (#1)
- СТ ОМТС, Шаг 1: поиск по номеру потребности идёт на сервере — по части номера, в пределах
периода. Старую потребность можно найти, не загружая весь период. (#5)
- СТ ОМТС, Шаг 3: без выбранного поставщика-победителя «Сохранить и продолжить» не пускает
дальше. (#7)
- СТ ОМТС, Шаг 3: условия оплаты обязательны для «Сохранить и продолжить» — раньше были помечены
обязательными, но сохранять не мешали. (#9)
### Исправлено
- СТ ОМТС: у поставщика, добавленного на Шаге 3 вручную, не видно служебного файла `.txt` —
ни в списках КП на Шагах 2 и 3, ни в подписи колонки, ни в пакете для INCORE. (#10)
- СТ ОМТС, Шаг 3: поставщик, добавленный на шаге и сохранённый черновиком, не задваивается при
открытии СТ из реестра. (#8)
- СТ ОМТС, Шаг 1: позиции, выбранные до поиска или смены периода, больше не пропадают при
«Сохранить и продолжить». (#5)
- СТ ОМТС: цена, скопированная с пробелами между разрядами («100 000»), считается целиком, а не
до первого пробела. То же для цены в ₸, количества, километража и курса валюты. (#6)
- СТ ОМТС, Шаг 3: выбор победителя снимается повторным нажатием на «Выбран». При открытии СТ
в другом браузере выбранный победитель сохраняется. (#7)
+2 -2
View File
@@ -3,7 +3,7 @@
"solution": {
"name": "ct-forms-portal-client-side-solution",
"id": "a1c3b00d-3ac8-449a-ad9d-6d0d522df14f",
"version": "1.0.0.0",
"version": "1.0.0.2",
"includeClientSideAssets": true,
"skipFeatureDeployment": true,
"isDomainIsolated": false,
@@ -30,7 +30,7 @@
"title": "ct-forms-portal Feature",
"description": "The feature that activates elements of the ct-forms-portal solution.",
"id": "5edbf144-12c3-4535-bb61-6417cdcc5f7d",
"version": "1.0.0.0"
"version": "1.0.0.2"
}
]
},
+2 -2
View File
@@ -269,7 +269,7 @@ Info "Подключаем @tnsu/ui-kit..."
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
$npmrc = @'
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
'@
$npmrc | Set-Content -Encoding UTF8 ".npmrc"
Success ".npmrc создан (адрес registry @tnsu)."
@@ -282,7 +282,7 @@ try {
} catch {
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
Warn " //git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ТВОЙ_ТОКЕН"
Warn " //git.tnsu.kz/api/packages/tansu/npm/:_authToken=ТВОЙ_ТОКЕН"
Warn "Потом выполни: npm install @tnsu/ui-kit"
}
+37
View File
@@ -0,0 +1,37 @@
# Как ведётся работа над порталом
Проект ведёт один разработчик. Требования приходят из Teams-чата и устно, поэтому вся
история — что просили, что решили, что выехало — хранится здесь и в Gitea, а не в памяти.
## Где что лежит
| Что | Где | Отвечает на вопрос |
| --- | --- | --- |
| Задачи | Gitea Issues `ct-forms-portal` | Что надо сделать, кто и когда попросил |
| Решения | `docs/adr/` | Почему сделано именно так, какие были варианты |
| Изменения | `CHANGELOG.md` | Что и в какой версии выехало на прод |
| Код | коммиты с `(#номер)` | Каким кодом закрыта задача |
## Путь запроса
1. **Пришло сообщение** (чат, устно) — каждый пункт становится issue. В issue — цитата или
скрин как есть, дата, кто попросил.
2. **Разбор.** Метка по типу: `bug`, `ux`, `feature`, `perf`, `справочники`,
`вопрос-бизнесу`, `смена-модели`.
- Если пункт меняет устройство данных (`смена-модели`) — сначала ADR, потом код.
- Если нужен ответ бизнеса (`вопрос-бизнесу`) — вопрос в чат, issue ждёт ответа.
3. **Работа.** Баг — сначала падающий Jest-тест, потом фикс. Коммиты —
`тип: описание (#номер)`.
- Мелкий фикс (час-два, один-два коммита) — прямо в `main`.
- Ветка `feat/<номер>-кратко` или `fix/<номер>-кратко` — если задача на несколько дней,
зависит от внешнего (новые списки SP, потоки, бэкенд) или рискованная. Цель одна:
`main` в любой момент можно выкатить на прод.
4. **Деплой.** Запись в `CHANGELOG.md`, тег версии `vX.Y.Z` (= версия в
`config/package-solution.json`), issue закрывается, в чат — короткий список изменений.
## Вопрос, который задаём каждому новому пункту
> Это меняет то, как устроены данные, или только как они показываются?
Только показ или поведение — делаем. Меняет данные — сначала проходим весь путь:
где создаётся, кто видит, что уходит в отчёт, INCORE и согласование. Потом ADR.
@@ -0,0 +1,115 @@
# 0001. Исключение поставщика из сравнения вместо удаления
- Статус: Принято
- Дата: 2026-10-27
- Задача: #1 (СТ ОМТС); ОИД — #3, КПС — #4
- Источник: Асиля Сарниязова, Teams, 2026-10-23, п.4: «Я случайно удалила из таблицы
поставщика — и не смогла вернуть его назад, хоть и есть КП»
## Контекст
На Шаге 3 (таблица сравнения) крестик на колонке поставщика удаляет его безвозвратно во всех
трёх формах — СТ, ОИД, КПС:
- колонка убирается из состояния формы, введённые цены стираются;
- при сохранении все офферы по позициям СТ уходят в корзину и заново пишутся только
оставшиеся поставщики (обычная правка записи на списках офферов заблокирована ресивером);
- вернуть поставщика можно только повторной загрузкой КП на Шаге 2, цены, введённые руками,
теряются.
Офферы читает не только портал:
- СТ — бэкенд `api.tnsu.kz/Report/CT` (PDF/Excel-отчёт, уходит в пакет INCORE);
- ОИД — поток ИИ-комментария читает «Оценку поставщиков ОИД» сам;
- потоки уведомлений — внешние, читают ли они офферы, по коду не определить.
В закупочных системах (SAP Ariba, Coupa, Procore и др.) участника не удаляют, а отклоняют
с причиной, и это остаётся в истории.
## Варианты
**А. Флаг `isExcluded` на каждом оффере** в списках «Оценка поставщиков», «… ОИД», «… КПС».
- Плюс: одна колонка, исключённые лежат рядом с обычными.
- Минус: все сторонние читатели начнут видеть исключённых, пока каждый не добавит фильтр —
бэкенд отчёта СТ (иначе исключённый попадёт в PDF и INCORE), поток ИИ ОИД, возможно потоки
уведомлений. Выкатка строго по порядку: колонка → бэкенд и потоки → портал.
**Б. JSON-снимок в шапке СТ** — колонка `excludedSuppliers` (Note) в «Сравнительной таблице»
каждой формы.
- Плюс: «Оценка поставщиков» не меняется, сторонние читатели ничего не замечают.
- Минус: размер. Строка оффера — 200–300 символов; 3 поставщика × 100 позиций = 60–90 тыс.
символов, с комплектами — вдвое больше. Предел многострочной колонки SP — около 64 тыс.
Сохранение упадёт на обычных объёмах.
**В. Отдельный список «Исключённые предложения»** под каждую форму («…», «… ОИД», «… КПС»):
те же колонки, что в «Оценке поставщиков», плюс лукап на СТ.
- Плюс: в «Оценке поставщиков» только активные поставщики — бэкенд, отчёт, INCORE и потоки
не дорабатываются, порядок выкатки не важен.
- Плюс: одна строка на оффер, объём не ограничен.
- Минус: три новых списка, перенос строк между списками при исключении и возврате.
## Решение
Вариант **В**.
- Крестик исключает поставщика: колонка скрывается, итоги, минимумы, рекомендация и ИИ его
не учитывают. Если исключили выбранного — выбор сбрасывается.
- При исключении **причина обязательна**. Она хранится вместе с исключёнными строками.
- Под таблицей у автора — плашка «Исключены из сравнения: поставщик — причина [Вернуть]».
- **Согласующий исключённых пока не видит.** Данные для него грузятся так же, но показ
выключен константой — включить можно без доработки хранения.
- **Повторная загрузка КП** исключённого поставщика на Шаге 2 его не возвращает: он остаётся
исключённым до «Вернуть».
- При сохранении цены исключённых пишутся в «Исключённые предложения», в «Оценку
поставщиков» не попадают. При открытии СТ исключённые читаются по id СТ.
- «Вернуть» → при сохранении строки переезжают обратно в «Оценку поставщиков».
- Поставщик без цен — одна строка-маркер без позиции, чтобы колонка не терялась.
- Позицию сняли на Шаге 1 — её строки в «Исключённых» тоже уходят в корзину.
- Запись офферов и исключённых — в общем модуле для трёх форм, чтобы позже перенести
сохранение на бэкенд без переделки компонентов.
## Устройство списков
Манифесты — `provisioning/manifests/{ct,oid,kps}.provisioning.json`.
- Колонки — копия «Оценки поставщиков» своей формы (чтобы «Вернуть» восстановило оффер
целиком) плюс `CT` (найти исключённых при открытии СТ) и `reason` (причина).
- Ссылки на обычные списки — Lookup, файл КП — сразу `offerFileId: Number`: лукап на
библиотеку документов не читается через REST.
- Номер строки комплекта — в `Title`, как в «Оценке поставщиков» (`поставщик-позиция-N`):
один формат во всех списках, разбор переиспользуется (`parseOfferItemIndex`).
- Причина повторяется в каждой строке поставщика — осознанная денормализация. Отдельный
список «Исключённые поставщики» дал бы 6 списков вместо 3 ради короткого текста.
- Поставщик без цен — строка без `position`, чтобы колонка не терялась.
## Последствия
- Три новых списка на `/sites/sed` — описать в `provisioning/manifests/{ct,oid,kps}`,
создать через `Provision-Lists.ps1` (сначала тест, потом бой).
- Доработка Шага 3 и загрузки СТ во всех трёх формах, Jest-тесты на исключение и возврат.
- Попутно закрываются баги удаления: в ОИД выбор остаётся на удалённом поставщике, в КПС
удалённый поставщик без КП возвращается после перезагрузки.
## Известные ограничения
- **«Дополнительная информация» (условия оплаты, поставки, сроки) исключённого поставщика в SP
не пишется** — «Критерии» читает отчёт СТ, и исключённый попал бы в него. В сессии автора
условия сохраняются; если вернуть поставщика после открытия СТ в другом браузере, их придётся
заполнить заново. Цены возвращаются всегда.
- Если сессия автора уже есть, СТ берёт поставщиков из неё, а не из SP (как и раньше для
обычных поставщиков).
## Объём
Сейчас — только СТ ОМТС (#1). ОИД (#3) и КПС (#4) — позже: списки для них уже созданы, общий
модуль `shared/offers/excludedOffers.ts` готов к переиспользованию.
## Ответы на открытые вопросы (2026-10-27, Тимур)
1. Согласующий исключённых — заложить, но пока не показывать.
2. Повторная загрузка КП не возвращает исключённого поставщика.
3. Причина исключения — обязательная.
4. Списки создаются через `Provision-Lists.ps1` по манифестам (сначала тест, потом бой).
+37
View File
@@ -0,0 +1,37 @@
# Журнал архитектурных решений (ADR)
Одно решение — один файл `NNNN-kratkoe-nazvanie.md`. Файлы не переписываются задним числом:
если решение пересмотрели, пишется новый ADR со ссылкой «Заменяет NNNN», а у старого статус
меняется на «Заменено NNNN».
Статусы: **Предложено** → **Принято** → (**Заменено** / **Отменено**).
| № | Решение | Статус | Дата |
| --- | --- | --- | --- |
| [0001](0001-isklyuchenie-postavshchikov.md) | Исключение поставщика из сравнения вместо удаления | Принято | 2026-10-27 |
## Шаблон
```markdown
# NNNN. Название
- Статус: Предложено
- Дата: ГГГГ-ММ-ДД
- Задача: #номер
- Источник: кто и где попросил
## Контекст
Что происходит сейчас и почему это проблема.
## Варианты
Что рассматривали, плюсы и минусы каждого.
## Решение
Что выбрали.
## Последствия
Что меняется, что придётся сделать, какие риски.
## Открытые вопросы
Что ещё не решено.
```
+4 -4
View File
@@ -15,7 +15,7 @@
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
"@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1",
"@tnsu/ui-kit": "^0.11.0",
"@tnsu/ui-kit": "^0.14.0",
"ncalayer-js-client": "^1.6.0",
"react": "17.0.1",
"react-dom": "17.0.1",
@@ -6269,9 +6269,9 @@
"license": "0BSD"
},
"node_modules/@tnsu/ui-kit": {
"version": "0.11.0",
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.11.0/ui-kit-0.11.0.tgz",
"integrity": "sha512-Yv4hUmCsDNvGMj32ygNhBZiurhe5lU0IHvWSZMkG15h+xmCBmsTzgrItmKenKkKiOM2R4cjOo4D2W8SsTNHleQ==",
"version": "0.14.0",
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz",
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
"license": "ISC",
"peerDependencies": {
"@fluentui/react": "^8.106.4",
+1 -1
View File
@@ -19,7 +19,7 @@
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
"@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1",
"@tnsu/ui-kit": "^0.11.0",
"@tnsu/ui-kit": "^0.14.0",
"ncalayer-js-client": "^1.6.0",
"react": "17.0.1",
"react-dom": "17.0.1",
+162
View File
@@ -0,0 +1,162 @@
<#
.SYNOPSIS
Сверяет манифесты provisioning.json с живыми списками сайта. Только чтение.
.DESCRIPTION
Манифесты снимались с живых списков (Export-ListSchema.ps1), но колонки потом добавляют
руками и кодом — манифест отстаёт, и пересоздание списков по нему ломает функции.
Запускать перед Provision-Lists.ps1 на рабочем сайте и после ручных правок списков.
Показывает по каждому списку:
- в манифесте, НЕТ в живом — колонку создаст Provision-Lists.ps1;
+ в живом, НЕТ в манифесте — манифест отстал: при пересоздании колонки не будет;
~ тип / варианты / лукап — колонка есть, но отличается.
.PARAMETER SiteUrl
Сайт со списками, например https://tnsukz.sharepoint.com/sites/sed
.PARAMETER ClientId
Client Id приложения с доступом к сайту (app-only).
.PARAMETER Tenant
Тенант, например tnsukz.onmicrosoft.com
.PARAMETER Thumbprint
Отпечаток сертификата в хранилище CurrentUser\My. Либо он, либо CertificatePath.
.PARAMETER CertificatePath
Путь к файлу сертификата .pfx — если сертификат не установлен в хранилище.
.PARAMETER CertificatePassword
Пароль к .pfx из CertificatePath.
.PARAMETER ManifestsDir
Папка с манифестами. По умолчанию — provisioning/manifests рядом со скриптом.
.PARAMETER Only
Сверять только манифесты указанных форм (по полю form). По умолчанию — все.
.EXAMPLE
./Compare-Manifests.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/sed" `
-ClientId "<id>" -Tenant "tnsukz.onmicrosoft.com" -CertificatePath "<pfx>" -CertificatePassword "<пароль>"
#>
param(
[Parameter(Mandatory = $true)][string]$SiteUrl,
[Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant,
[string]$Thumbprint,
[string]$CertificatePath,
[string]$CertificatePassword,
[string]$ManifestsDir = "$PSScriptRoot/manifests",
[string[]]$Only
)
$ErrorActionPreference = 'Stop'
# Служебные поля SharePoint и Syntex: в манифестах их нет и быть не должно — в «+ в живом» не показываем.
$SYSTEM_FIELDS = @(
'Title', 'ContentType', 'Attachments', 'Edit', 'LinkTitle', 'LinkTitleNoMenu', 'DocIcon',
'ItemChildCount', 'FolderChildCount', 'AppAuthor', 'AppEditor', 'ComplianceAssetId',
'_ColorTag', '_ComplianceFlags', '_ComplianceTag', '_ComplianceTagWrittenTime', '_ComplianceTagUserId',
'_IsRecord', 'FileLeafRef', '_DisplayName', 'SelectTitle', 'FileRef', '_UIVersionString',
'ParentVersionString', 'ParentLeafName', 'CheckoutUser', 'MediaServiceImageTags',
'_ExtendedDescription', '_ModelId', '_dlc_DocId', '_dlc_DocIdUrl'
)
$SYNTEX_FIELD_PREFIX = 'Prime'
# Тип живой колонки → тип манифеста (Currency/MultiChoice/UserMulti/LookupMulti сводятся к базовому).
$TYPE_MAP = @{
'Text' = 'Text'; 'Note' = 'Note'; 'Number' = 'Number'; 'Currency' = 'Number';
'Choice' = 'Choice'; 'MultiChoice' = 'Choice'; 'DateTime' = 'DateTime'; 'Boolean' = 'Boolean';
'User' = 'User'; 'UserMulti' = 'User'; 'Lookup' = 'Lookup'; 'LookupMulti' = 'Lookup'
}
$listTitleById = @{}
function Get-ListTitleById {
param([string]$Id)
if (-not $listTitleById.ContainsKey($Id)) {
$listTitleById[$Id] = (Get-PnPList -Identity $Id -ErrorAction SilentlyContinue).Title
}
return $listTitleById[$Id]
}
function Compare-Column {
param($Col, $Field)
$diffs = @()
$liveType = $TYPE_MAP[$Field.TypeAsString]
if (-not $liveType) { $liveType = $Field.TypeAsString }
if ($liveType -ne $Col.type) {
$diffs += " ~ тип $($Col.internalName): манифест $($Col.type), живой $($Field.TypeAsString)"
}
if ($Col.type -eq 'Choice') {
$liveChoices = @($Field.Choices)
$manifestChoices = @($Col.choices)
$onlyLive = @($liveChoices | Where-Object { $manifestChoices -notcontains $_ })
$onlyManifest = @($manifestChoices | Where-Object { $liveChoices -notcontains $_ })
if ($onlyLive.Count -gt 0) { $diffs += " ~ варианты $($Col.internalName): только в живом — $($onlyLive -join ', ')" }
if ($onlyManifest.Count -gt 0) { $diffs += " ~ варианты $($Col.internalName): только в манифесте — $($onlyManifest -join ', ')" }
}
if ($Col.type -eq 'Lookup') {
$schema = [xml]$Field.SchemaXml
$target = Get-ListTitleById ($schema.Field.List -replace '[{}]', '')
if ($target -ne $Col.lookupList) {
$diffs += " ~ лукап $($Col.internalName): манифест → «$($Col.lookupList)», живой → «$target»"
}
if ($schema.Field.ShowField -and $schema.Field.ShowField -ne $Col.lookupShowField) {
$diffs += " ~ лукап $($Col.internalName): показ манифест $($Col.lookupShowField), живой $($schema.Field.ShowField)"
}
}
return $diffs
}
if ($Thumbprint) {
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
}
elseif ($CertificatePath) {
$securePassword = ConvertTo-SecureString $CertificatePassword -AsPlainText -Force
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -CertificatePath $CertificatePath -CertificatePassword $securePassword
}
else { throw "Укажите -Thumbprint или -CertificatePath" }
$totalDiffs = 0
foreach ($file in Get-ChildItem -Path $ManifestsDir -Filter '*.provisioning.json') {
$manifest = Get-Content -Path $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
if ($Only -and ($Only -notcontains $manifest.form)) { continue }
Write-Host "`n######## $($manifest.form)" -ForegroundColor Cyan
foreach ($ls in $manifest.lists) {
if (-not (Get-PnPList -Identity $ls.title -ErrorAction SilentlyContinue)) {
Write-Host "== «$($ls.title)»: списка НЕТ на сайте" -ForegroundColor Yellow
$totalDiffs++
continue
}
$allFields = Get-PnPField -List $ls.title
$diffs = @()
foreach ($col in $ls.columns) {
$field = $allFields | Where-Object { $_.InternalName -eq $col.internalName } | Select-Object -First 1
if (-not $field) { $diffs += " - в манифесте, НЕТ в живом: $($col.internalName) [$($col.type)]"; continue }
$diffs += Compare-Column -Col $col -Field $field
}
$customFields = $allFields | Where-Object {
-not $_.Hidden -and -not $_.ReadOnlyField -and -not $_.FromBaseType -and
$SYSTEM_FIELDS -notcontains $_.InternalName -and -not $_.InternalName.StartsWith($SYNTEX_FIELD_PREFIX)
}
foreach ($field in $customFields) {
if (-not ($ls.columns | Where-Object { $_.internalName -eq $field.InternalName })) {
$diffs += " + в живом, НЕТ в манифесте: $($field.InternalName) «$($field.Title)» [$($field.TypeAsString)]"
}
}
if ($diffs.Count -eq 0) {
Write-Host "== «$($ls.title)»: совпадает" -ForegroundColor DarkGray
}
else {
Write-Host "== «$($ls.title)»:" -ForegroundColor White
$diffs | ForEach-Object { Write-Host $_ }
$totalDiffs += $diffs.Count
}
}
}
Write-Host "`nРасхождений: $totalDiffs" -ForegroundColor Cyan
Disconnect-PnPOnline
+40 -5
View File
@@ -34,7 +34,13 @@
Тенант, например tnsukz.onmicrosoft.com
.PARAMETER Thumbprint
Отпечаток сертификата в хранилище CurrentUser\My.
Отпечаток сертификата в хранилище CurrentUser\My. Либо он, либо CertificatePath.
.PARAMETER CertificatePath
Путь к файлу сертификата .pfx — если сертификат не установлен в хранилище.
.PARAMETER CertificatePassword
Пароль к .pfx из CertificatePath.
.PARAMETER ManifestsDir
Папка с манифестами. По умолчанию — provisioning/manifests рядом со скриптом.
@@ -42,17 +48,30 @@
.PARAMETER Only
Провижионить только манифесты указанных форм (по полю form). По умолчанию — все.
.PARAMETER Lists
Провижионить только списки с указанными названиями. Нужен для добавления нового списка
на рабочий сайт: полный прогон перезаписывает варианты Choice у живых списков
значениями из манифеста, а манифест мог отстать от живого списка.
.EXAMPLE
./Provision-Lists.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/sed" `
-ClientId "<id>" -Tenant "tnsukz.onmicrosoft.com" -Thumbprint "<tp>"
.EXAMPLE
./Provision-Lists.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/sed" `
-ClientId "<id>" -Tenant "tnsukz.onmicrosoft.com" -Thumbprint "<tp>" `
-Lists "Исключённые предложения", "Исключённые предложения ОИД"
#>
param(
[Parameter(Mandatory = $true)][string]$SiteUrl,
[Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant,
[Parameter(Mandatory = $true)][string]$Thumbprint,
[string]$Thumbprint,
[string]$CertificatePath,
[string]$CertificatePassword,
[string]$ManifestsDir = "$PSScriptRoot/manifests",
[string[]]$Only
[string[]]$Only,
[string[]]$Lists
)
$ErrorActionPreference = 'Stop'
@@ -143,7 +162,14 @@ function Add-ListColumn {
}
Write-Host "Подключение к $SiteUrl (app-only) ..." -ForegroundColor Cyan
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
if ($Thumbprint) {
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
}
elseif ($CertificatePath) {
$securePassword = ConvertTo-SecureString $CertificatePassword -AsPlainText -Force
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -CertificatePath $CertificatePath -CertificatePassword $securePassword
}
else { throw "Укажите -Thumbprint или -CertificatePath" }
$files = Get-ChildItem -Path $ManifestsDir -Filter '*.provisioning.json' -ErrorAction SilentlyContinue
if (-not $files) { throw "Манифесты *.provisioning.json не найдены в $ManifestsDir" }
@@ -152,9 +178,18 @@ $manifests = @()
foreach ($file in $files) {
$def = Get-Content -Path $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
if ($Only -and ($Only -notcontains $def.form)) { continue }
if ($Lists) {
$def.lists = @($def.lists | Where-Object { $Lists -contains $_.title })
if ($def.lists.Count -eq 0) { continue }
}
$manifests += $def
}
if (-not $manifests) { throw "Нет манифестов после фильтра -Only" }
if (-not $manifests) { throw "Нет манифестов после фильтров -Only / -Lists" }
if ($Lists) {
$found = @($manifests | ForEach-Object { $_.lists } | ForEach-Object { $_.title })
$missing = @($Lists | Where-Object { $found -notcontains $_ })
if ($missing.Count -gt 0) { throw "Списков нет в манифестах: $($missing -join ', ')" }
}
# --- Проход A: создать все списки/библиотеки ---
Write-Host "`n=== Проход A: списки ===" -ForegroundColor Cyan
+21
View File
@@ -103,6 +103,27 @@ cd provisioning
Лог: `[+]` создано, `[=]` уже было, `[~]` обновлено, `[!]` предупреждение.
## Сверка манифестов с живыми списками
Манифест отстаёт, когда колонки добавляют руками или кодом. Перед провижинингом на рабочем
сайте и после ручных правок списков — сверка (только чтение):
```powershell
./Compare-Manifests.ps1 `
-SiteUrl "https://tnsukz.sharepoint.com/sites/sed" `
-ClientId "<client-id>" `
-Tenant "tnsukz.onmicrosoft.com" `
-CertificatePath "<путь к .pfx>" -CertificatePassword "<пароль>"
```
`-` есть в манифесте, нет в живом (создаст Provision-Lists.ps1), `+` есть в живом, нет в
манифесте (при пересоздании пропадёт — дописать в манифест), `~` отличаются тип, варианты или лукап.
## Добавить новый список на рабочий сайт
Полный прогон Provision-Lists.ps1 синхронизирует варианты Choice у ВСЕХ живых списков с
манифестами. Новый список добавляй точечно — `-Lists "Название"`: остальные списки не трогаются.
## Формат манифеста
```jsonc
+141 -2
View File
@@ -27,7 +27,7 @@
"internalName": "status",
"title": "Статус",
"type": "Choice",
"choices": ["Черновик", "Согласовано", "На согласовании"]
"choices": ["Черновик", "Согласовано", "На согласовании", "Отклонено", "На доработке"]
},
{
"internalName": "winSupplier",
@@ -120,6 +120,11 @@
"title": "Количество",
"type": "Number",
"decimals": 3
},
{
"internalName": "requestPositionId",
"title": "requestPositionId",
"type": "Text"
}
]
},
@@ -200,6 +205,11 @@
"internalName": "deliveryDistanceKm",
"title": "deliveryDistanceKm",
"type": "Number"
},
{
"internalName": "offerFileId",
"title": "offerFileId",
"type": "Number"
}
]
},
@@ -255,6 +265,11 @@
"type": "Lookup",
"lookupList": "Поставщики",
"lookupShowField": "name"
},
{
"internalName": "criteriaLabel",
"title": "Название доп.условия",
"type": "Text"
}
]
},
@@ -286,7 +301,9 @@
"Выполнено",
"Рекомендую",
"Черновик",
"Отклонено"
"Отклонено",
"Ждёт очереди",
"На доработке"
]
},
{
@@ -310,6 +327,12 @@
"internalName": "canAddApprover",
"title": "Добавить делеганта",
"type": "Boolean"
},
{
"internalName": "delegatedBy",
"title": "Кем переназначено",
"type": "User",
"userSelectionMode": "PeopleOnly"
}
]
},
@@ -332,6 +355,97 @@
}
]
},
{
"title": "Исключённые предложения",
"template": "GenericList",
"shared": false,
"columns": [
{
"internalName": "CT",
"title": "Номер СТ",
"type": "Lookup",
"lookupList": "Сравнительная таблица",
"lookupShowField": "number"
},
{
"internalName": "supplier",
"title": "Поставщик",
"type": "Lookup",
"lookupList": "Поставщики",
"lookupShowField": "name"
},
{
"internalName": "reason",
"title": "Причина исключения",
"type": "Note"
},
{
"internalName": "position",
"title": "Позиция",
"type": "Lookup",
"lookupList": "Позиции СТ",
"lookupShowField": "position"
},
{
"internalName": "supplierPosition",
"title": "Наименование позиции",
"type": "Text"
},
{
"internalName": "unit",
"title": "Ед. изм",
"type": "Text"
},
{
"internalName": "quantity",
"title": "Количество",
"type": "Number",
"decimals": 3
},
{
"internalName": "price",
"title": "Цена",
"type": "Number",
"decimals": 2
},
{
"internalName": "summa",
"title": "Сумма",
"type": "Number",
"decimals": 2
},
{
"internalName": "valuta",
"title": "Валюта",
"type": "Lookup",
"lookupList": "Курс Валют",
"lookupShowField": "code"
},
{
"internalName": "priceTenge",
"title": "Цена в тенге",
"type": "Number",
"decimals": 2
},
{
"internalName": "summaTenge",
"title": "Сумма в тенге",
"type": "Number",
"decimals": 2
},
{
"internalName": "offerFileId",
"title": "Id файла КП",
"type": "Number",
"decimals": 0
},
{
"internalName": "deliveryDistanceKm",
"title": "Расстояние доставки, км",
"type": "Number"
}
]
},
{
"title": "Поставщики",
"template": "GenericList",
@@ -361,6 +475,31 @@
"internalName": "adataCheckedAt",
"title": "Дата проверки Adata",
"type": "DateTime"
},
{
"internalName": "address",
"title": "Адрес",
"type": "Note"
},
{
"internalName": "contactPerson",
"title": "Контактное лицо",
"type": "Note"
},
{
"internalName": "contactInfo",
"title": "Контактная информация",
"type": "Note"
},
{
"internalName": "email",
"title": "Email",
"type": "Text"
},
{
"internalName": "website",
"title": "Сайт",
"type": "Text"
}
]
},
@@ -267,8 +267,11 @@
"title": "Статус",
"type": "Choice",
"choices": [
"Черновик",
"Ждёт очереди",
"На согласовании",
"Согласовано",
"Отклонено",
"На исполнении",
"Выполнено"
]
@@ -282,6 +285,17 @@
"internalName": "comment",
"title": "Комментарий",
"type": "Text"
},
{
"internalName": "allowReassign",
"title": "Можно перепоручать",
"type": "Boolean"
},
{
"internalName": "delegatedBy",
"title": "Кем переназначено",
"type": "User",
"userSelectionMode": "PeopleOnly"
}
]
},
@@ -372,6 +386,97 @@
"type": "Note"
}
]
},
{
"title": "Исключённые предложения КПС",
"template": "GenericList",
"shared": false,
"columns": [
{
"internalName": "CT",
"title": "Номер СТ",
"type": "Lookup",
"lookupList": "Сравнительная таблица КПС",
"lookupShowField": "number"
},
{
"internalName": "supplier",
"title": "Поставщик",
"type": "Lookup",
"lookupList": "Поставщики",
"lookupShowField": "name"
},
{
"internalName": "reason",
"title": "Причина исключения",
"type": "Note"
},
{
"internalName": "position",
"title": "Позиция",
"type": "Lookup",
"lookupList": "Позиции СТ КПС",
"lookupShowField": "position"
},
{
"internalName": "supplierPosition",
"title": "Наименование позиции",
"type": "Text"
},
{
"internalName": "unit",
"title": "Ед. изм",
"type": "Text"
},
{
"internalName": "quantity",
"title": "Количество",
"type": "Number",
"decimals": 3
},
{
"internalName": "price",
"title": "Цена",
"type": "Number",
"decimals": 2
},
{
"internalName": "summa",
"title": "Сумма",
"type": "Number",
"decimals": 2
},
{
"internalName": "valuta",
"title": "Валюта",
"type": "Lookup",
"lookupList": "Курс Валют",
"lookupShowField": "code"
},
{
"internalName": "priceTenge",
"title": "Цена в тенге",
"type": "Number",
"decimals": 2
},
{
"internalName": "summaTenge",
"title": "Сумма в тенге",
"type": "Number",
"decimals": 2
},
{
"internalName": "offerFileId",
"title": "Id файла КП",
"type": "Number",
"decimals": 0
},
{
"internalName": "deliveryDistanceKm",
"title": "Расстояние доставки, км",
"type": "Number"
}
]
}
]
}
+87 -2
View File
@@ -35,7 +35,8 @@
"choices": [
"Черновик",
"Согласовано",
"На согласовании"
"На согласовании",
"Отклонено"
]
},
{
@@ -100,6 +101,16 @@
"title": "Сумма из сметы",
"type": "Number",
"decimals": 2
},
{
"internalName": "RequestCode",
"title": "Номер из заявки",
"type": "Text"
},
{
"internalName": "RequestUrl",
"title": "Ссылка на заявку",
"type": "Text"
}
]
},
@@ -255,7 +266,8 @@
"Выполнено",
"Рекомендую",
"Черновик",
"Отклонено"
"Отклонено",
"Ждёт очереди"
]
},
{
@@ -272,6 +284,12 @@
"internalName": "canAddAproover",
"title": "Добавить делеганта",
"type": "Boolean"
},
{
"internalName": "delegatedBy",
"title": "Кем переназначено",
"type": "User",
"userSelectionMode": "PeopleOnly"
}
]
},
@@ -373,6 +391,73 @@
"type": "Boolean"
}
]
},
{
"title": "Исключённые предложения ОИД",
"template": "GenericList",
"shared": false,
"columns": [
{
"internalName": "CT",
"title": "Номер СТ",
"type": "Lookup",
"lookupList": "Сравнительная таблица ОИД",
"lookupShowField": "number"
},
{
"internalName": "supplier",
"title": "Поставщик",
"type": "Lookup",
"lookupList": "Поставщики",
"lookupShowField": "name"
},
{
"internalName": "reason",
"title": "Причина исключения",
"type": "Note"
},
{
"internalName": "position",
"title": "Позиция",
"type": "Lookup",
"lookupList": "Позиции СТ ОИД",
"lookupShowField": "position"
},
{
"internalName": "supplierPosition",
"title": "Наименование позиции",
"type": "Text"
},
{
"internalName": "unit",
"title": "Ед. изм",
"type": "Text"
},
{
"internalName": "quantity",
"title": "Количество",
"type": "Number",
"decimals": 3
},
{
"internalName": "price",
"title": "Цена",
"type": "Number",
"decimals": 2
},
{
"internalName": "summa",
"title": "Сумма",
"type": "Number",
"decimals": 2
},
{
"internalName": "offerFileId",
"title": "Id файла КП",
"type": "Number",
"decimals": 0
}
]
}
]
}
+2
View File
@@ -33,6 +33,7 @@
{ "title": "Критерии", "template": "GenericList" },
{ "title": "Согласование СТ", "template": "GenericList" },
{ "title": "Не получены КП", "template": "GenericList" },
{ "title": "Исключённые предложения", "template": "GenericList" },
{ "title": "Поставщики", "template": "GenericList" },
{ "title": "Курс Валют", "template": "GenericList" },
{ "title": "Матрица согласования СТ", "template": "GenericList" },
@@ -53,6 +54,7 @@
{ "title": "Критерии ОИД", "template": "GenericList" },
{ "title": "Согласование СТ ОИД", "template": "GenericList" },
{ "title": "Не получены КП ОИД", "template": "GenericList" },
{ "title": "Исключённые предложения ОИД", "template": "GenericList" },
{ "title": "Коммерческие предложения ОИД", "template": "DocumentLibrary" }
]
}
@@ -20,7 +20,7 @@ import {
Alert
} from '@tnsu/ui-kit';
import { DOCUMENT_TYPES } from './config';
import { buildSidebarItems, CONTRAGENTS_ID } from './sidebarNav';
import { buildSidebarItems, CONTRAGENTS_ID, SIDEBAR_BRAND } from './sidebarNav';
import { useDocumentDashboard } from './registry/useDocumentDashboard';
import { DashboardRow } from './registry/documentRegistry';
@@ -133,7 +133,7 @@ export const Dashboard: React.FC<IDashboardProps> = ({ context, onNavigateForm }
items={sidebarItems}
activeId={activeType}
onChange={onSidebarChange}
brand="Договора"
brand={SIDEBAR_BRAND}
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((c) => !c)}
mobileOpen={mobileOpen}
@@ -4,14 +4,17 @@ const STATUS_LABEL: Record<string, string> = {
'Черновик': 'Сформирована',
'На согласовании': 'На согласовании',
'Согласовано': 'Согласована',
'Отклонено': 'Отклонена'
'Отклонено': 'Отклонена',
'На доработке': 'На доработке'
};
const STATUS_VARIANT: Record<string, DashboardRow['statusVariant']> = {
'Черновик': 'neutral',
'На согласовании': 'info',
'Согласовано': 'success',
'Отклонено': 'error'
'Отклонено': 'error',
// В реестре нет жёлтого варианта — как «На согласовании»: процесс идёт, ждём действий инициатора.
'На доработке': 'info'
};
export function createCtDocumentConfig(): DocumentTypeConfig {
@@ -49,8 +52,8 @@ export function createCtDocumentConfig(): DocumentTypeConfig {
})
},
createNewLabel: 'Создать новую форму СТ',
// Форма CT встроена в портал (маршрут). Открытие существующей: ctId (черновик) / ctid (иначе)
// — как читает сама форма (см. forms/ct/.../hooks/useCtForm.ts).
// Форма CT встроена в портал (маршрут). ctId / ctid форма читает одинаково — режим (правка/просмотр)
// она считает сама по автору и статусу (forms/ct/.../utils/ctAccess.ts); регистр — для старых ссылок.
inPortal: true,
buildInPortalParams: (row) => ({ [row.status === 'Черновик' ? 'ctId' : 'ctid']: row.id }),
mapItem: (raw): DashboardRow => {
@@ -1,6 +1,9 @@
import { ISidebarItem, IconName } from '@tnsu/ui-kit';
import { DOCUMENT_TYPES } from './config';
// Заголовок сайдбара — одинаковый на всех экранах портала.
export const SIDEBAR_BRAND = 'Снабжение';
// Общий раздел «База контрагентов» (отдельный маршрут ?form=contragents).
export const CONTRAGENTS_ID = 'contragents';
export const CONTRAGENTS_TITLE = 'База контрагентов';
@@ -14,6 +14,7 @@ import {
import {
buildSidebarItems,
CONTRAGENTS_ID,
SIDEBAR_BRAND,
} from "../../../components/sidebarNav";
import { ContragentsService } from "../services/ContragentsService";
import { SuppliersService } from "../services/SuppliersService";
@@ -164,7 +165,7 @@ export const ContragentsRegistry: React.FC<IContragentsRegistryProps> = ({
items={buildSidebarItems()}
activeId={CONTRAGENTS_ID}
onChange={onSidebarChange}
brand="Договора"
brand={SIDEBAR_BRAND}
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((c) => !c)}
mobileOpen={mobileOpen}
@@ -21,9 +21,10 @@ const STATUS_LABELS: Record<CTStatus, string> = {
on_approval: 'На согласовании',
approved: 'Согласована',
rejected: 'Отклонена',
rework: 'На доработке',
};
const STATUS_VARIANT: Record<CTStatus, 'neutral' | 'info' | 'success' | 'danger'> = {
const STATUS_VARIANT: Record<CTStatus, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
draft: 'neutral',
kp_loading: 'neutral',
kp_loaded: 'neutral',
@@ -31,6 +32,7 @@ const STATUS_VARIANT: Record<CTStatus, 'neutral' | 'info' | 'success' | 'danger'
on_approval: 'info',
approved: 'success',
rejected: 'danger',
rework: 'warning',
};
// Адаптер над Stepper/FormHeader из @tnsu/ui-kit (по образцу KPS Breadcrumbs): контракт props формы
@@ -26,6 +26,13 @@
}
}
.loader {
display: flex;
align-items: center;
justify-content: center;
padding: 64px 0;
}
.content {
flex: 1;
background: $surface;
@@ -2,26 +2,24 @@ import * as React from 'react';
import { useMemo, useEffect, useState } from 'react';
import type { ICtCreateFormProps } from './ICtCreateFormProps';
import { CtService } from '../services/CtService';
import { useCtForm } from './hooks/useCtForm';
import { CTFormData, useCtForm } from './hooks/useCtForm';
import { IMatrixRow } from '../../../shared/approval';
import { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSelection';
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
import { Step4Approval } from './Step4Approval/Step4Approval';
import { ApprovalMember, CTStatus } from './types/domain';
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findWinnerColumnId, withoutSessionExtras } from './utils/supplierColumns';
import { buildOffersFromSpRecords } from './utils/spOffers';
import { excludedItemsToFormData } from './utils/excludedColumns';
import { isUserInApprovalChain } from './utils/approvalAccess';
import { CtAccess, getCtAccess, isCtAuthor } from './utils/ctAccess';
import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers';
import { reachableStep } from './utils/reachableStep';
import { Alert, Spinner } from '@tnsu/ui-kit';
import styles from './CtCreateForm.module.scss';
import '@tnsu/ui-kit/styles.css';
function deriveCtStatus(chain: ApprovalMember[]): CTStatus {
if (chain.length === 0) return 'formed';
if (chain.some(m => m.status === 'rejected')) return 'rejected';
if (chain.every(m => m.status === 'approved' || m.status === 'recommends' || m.status === 'skipped')) return 'approved';
// Chain is activated only when the initiator has 'recommends' (Рекомендую).
// All-pending means chain was saved as draft but not yet activated via Step 4.
if (chain.some(m => m.status === 'recommends')) return 'on_approval';
return 'formed';
}
const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName, isDarkTheme, siteUrl }) => {
const service = useMemo(() => new CtService(context, siteUrl), [context, siteUrl]);
const [isRestrictedView, setIsRestrictedView] = useState(false);
@@ -30,7 +28,6 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
currentStep,
formData,
initialCtId,
isNotificationLink,
goToStep,
goNext,
goPrev,
@@ -41,38 +38,68 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
ensureHighestStep,
} = useCtForm();
// Цены показываем только участникам согласования: рекомендующему (Инициатор) и согласующим.
// Автор/инициатор при создании и редактировании СТ (до отправки на согласование) видит цены всегда.
// astel-k (isRestrictedView) — независимое ограничение, складывается с этим: если его уберут,
// правило по цепочке согласования останется. Сопоставление участника — по отображаемому имени
// (пока тестируем; при необходимости переключим на ID).
const userInApprovalChain = (formData.approvalChain ?? []).some(m => m.userName === userDisplayName);
const hidePrices = isRestrictedView || (formData.approvalSent === true && !userInApprovalChain);
// Открыта существующая СТ — шаги не показываем, пока она не загрузится из SP. Иначе шаг 1 успевал
// появиться с пустой сессией и запоминал пустой выбор: «Выберите позиции», хотя позиции в СТ есть.
const [isCtLoading, setIsCtLoading] = useState(!!initialCtId);
const [ctLoadError, setCtLoadError] = useState<string | undefined>(undefined);
// Режим формы — по тому, кто открыл и в каком статусе СТ (getCtAccess), а не по ctid/ctId в ссылке.
// Считается на каждом рендере из данных СТ — ничего не «прилипает» в сессии: после «Перезапустить
// согласование» или возврата на доработку режим меняется сам. astel-k — цены не видит никогда.
const accessFor = (ct: CTFormData['ct'], approvalMatrix: IMatrixRow[]): CtAccess => getCtAccess({
hasCt: !!ct,
status: ct?.status,
isAuthor: isCtAuthor(ct?.authorEmail, service.currentUserEmail),
isInChain: isUserInApprovalChain(approvalMatrix, { email: service.currentUserEmail, displayName: userDisplayName }),
isRestrictedCompany: isRestrictedView,
});
const access = accessFor(formData.ct, formData.approvalMatrix ?? []);
const hidePrices = !access.canSeePrices;
useEffect(() => {
service.getCurrentUserProfile()
.then(profile => updateFormData({ initiatorDepartment: profile.department }))
.catch(() => undefined);
service.getCurrentUserCompany()
.then(company => setIsRestrictedView(company.toLowerCase() === 'astel-k'))
.then(company => {
setIsRestrictedView(company.toLowerCase() === 'astel-k');
// Компания инициатора — условие маршрута согласования (см. CtService.getPlannedApprovalMatrix).
updateFormData({ initiatorCompany: company });
})
.catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load existing CT from SP when opened via notification link (?ctid=N)
// Always reloads from SP to get fresh approval statuses
// Открыта существующая СТ (?ctId=N или ?ctid=N — равнозначно): статус и маршрут — всегда свежие из SP.
// Остальные данные — из SP в режиме просмотра (у проверяющего нет своих правок) или когда сессии нет.
useEffect(() => {
if (!initialCtId) return;
Promise.all([
service.loadCTById(initialCtId),
service.loadApprovalChain(initialCtId),
service.getApprovalMatrixForRequest(initialCtId),
service.loadPositionsForCT(initialCtId),
]).then(async ([ct, approvalChain, positions]) => {
]).then(async ([ct, approvalMatrix, positions]) => {
const loadedAccess = accessFor(ct, approvalMatrix);
const preferSp = loadedAccess.isReview;
const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined);
const rawOffers = await service.loadFullOffersForPositions(positionSpIds);
// Исключённые из сравнения (docs/adr/0001). Не грузятся — СТ открываем без них, а не падаем:
// таблица сравнения важнее, но молча не глотаем.
const excludedItems = await service.loadExcludedOffers(initialCtId).catch(err => {
console.warn('[CtCreateForm] не удалось загрузить исключённых поставщиков:', err);
return [];
});
// Поставщики без КП этой СТ — для списка под таблицей сравнения (согласующий по ссылке).
const noOfferFromSp = noOfferRecordsToSuppliers(
await service.loadNoOfferSuppliers(initialCtId).catch(err => {
console.warn('[CtCreateForm] не удалось загрузить поставщиков без КП:', err);
return [];
}),
);
// Build supplierSpId → offerId map from session uploadedKpFiles.
// offerId cannot be read back via SP REST (500), so we restore it from session.
// Лукап offerId через REST не читается (500), поэтому сначала берём из сессии, а без сессии —
// из числового дубля offerFileId (см. CtService.loadKpFilesForOffers).
// formData here is the initial mount value (session state before this update).
const offerIdBySupplierSpId = new Map<number, number>();
for (const kpFile of (formData.uploadedKpFiles ?? [])) {
@@ -83,7 +110,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
// Load currency rates for the valutaIds referenced by offers (separate query avoids
// SP REST 400 that occurs when selecting a Choice field via $expand on a lookup).
const valutaIds = rawOffers
const valutaIds = [...rawOffers, ...excludedItems]
.map(o => o.valutaSpId)
.filter((id): id is number => id !== undefined && id > 0)
.filter((id, idx, arr) => arr.indexOf(id) === idx);
@@ -91,17 +118,29 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
const rateById = new Map(loadedRates.map(r => [Number(r.id), r]));
const spCurrencyRates = loadedRates;
// Build unique suppliers from offers (used only as fallback when session has none)
const supplierCurrencyMap = new Map<number, string>();
rawOffers.forEach(o => {
supplierCurrencyMap.set(o.supplierSpId, o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT');
});
// Колонки из офферов SP — по одной на (поставщик, файл КП): у поставщика может быть несколько
// КП в разных валютах. Используются, только когда в сессии колонок нет.
const currencyCodeOf = (valutaSpId?: number): string =>
valutaSpId ? (rateById.get(valutaSpId)?.code ?? 'KZT') : 'KZT';
// Поставщики Шага 3 из сессии (после «Сохранить черновик») уже лежат в SP — их колонки из SP
// не дублируем.
const sessionExtras = formData.step3ExtraSuppliers ?? [];
const spSuppliers = withoutSessionExtras(buildColumnsFromSpOffers(rawOffers.map(o => ({
supplierSpId: o.supplierSpId,
supplierName: o.supplierName,
offerFileSpId: o.offerFileSpId,
currencyCode: currencyCodeOf(o.valutaSpId),
}))), sessionExtras);
const supplierMap = new Map<number, string>();
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
const spSuppliers = Array.from(supplierMap.entries()).map(([spId, name]) => ({
id: String(spId), spId, name, hasOffer: true as const,
currency: supplierCurrencyMap.get(spId) ?? 'KZT',
}));
// Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из SP.
// Поставщики Шага 3 — тоже колонки: их офферы из SP должны попасть к ним, а не в убранную колонку.
const visibleColumns = formData.suppliers.length > 0
? formData.suppliers
: [...spSuppliers, ...sessionExtras.map(es => ({ id: es.id, spId: es.spId, name: es.name, hasOffer: true }))];
const columnIdOf = (supplierSpId: number, offerFileSpId?: number): string =>
findColumnForRecord(visibleColumns, supplierSpId, offerFileSpId)?.id
?? buildSupplierColumnId(supplierSpId, offerFileSpId);
// Build step3DeliveryKm from SP offers. Step 3 reads km by React-id key
// (`${pos.id}::${s.id}`), so map SP ids → React ids; for delivery the position is an
@@ -112,41 +151,35 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
[...formData.selectedPositions, ...(formData.step3ExtraPositions ?? [])].forEach(p => {
if (p.spId !== undefined) posReactIdBySpId.set(p.spId, p.id);
});
const supReactIdBySpId = new Map<number, string>();
formData.suppliers.forEach(s => {
if (s.spId !== undefined) supReactIdBySpId.set(s.spId, s.id);
});
const step3DeliveryKm: Record<string, number> = {};
rawOffers.forEach(o => {
if (o.deliveryDistanceKm !== undefined) {
const posKey = posReactIdBySpId.get(o.positionSpId) ?? String(o.positionSpId);
const supKey = supReactIdBySpId.get(o.supplierSpId) ?? String(o.supplierSpId);
const supKey = columnIdOf(o.supplierSpId, o.offerFileSpId);
step3DeliveryKm[`${posKey}::${supKey}`] = o.deliveryDistanceKm;
}
});
// Build offers in domain format, restoring offerId from session
const offers = rawOffers.map(o => {
const offerIdNum = offerIdBySupplierSpId.get(o.supplierSpId);
const currencyCode = o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT';
return {
id: '',
supplierId: String(o.supplierSpId),
positionId: String(o.positionSpId),
unit: o.unit,
pricePerUnit: o.price,
quantity: o.quantity,
currencyCode,
pricePerUnitKzt: o.priceTenge || o.price,
totalPriceKzt: o.summaTenge,
totalWithExtraKzt: o.summaTenge,
offerId: offerIdNum !== undefined ? String(offerIdNum) : undefined,
};
// Build offers in domain format. Файл КП берём из самой записи (offerFileId) — карта из сессии
// знает один файл на поставщика и при нескольких КП отдала бы обеим колонкам один и тот же.
const offers = buildOffersFromSpRecords(rawOffers, {
columnIdOf,
currencyCodeOf,
fallbackOfferFileId: supplierSpId => offerIdBySupplierSpId.get(supplierSpId),
});
// Исключённые — отдельными колонками с exclusion: Шаг 3 их не сравнивает, но может вернуть.
// Колонка есть и среди обычных — её вернули, а уборка прежних исключённых строк не прошла:
// обычная важнее, иначе колонка задвоится.
const allExcluded = excludedItemsToFormData(excludedItems, currencyCodeOf);
const activeColumnIds = new Set(spSuppliers.map(s => s.id));
const excluded = {
suppliers: allExcluded.suppliers.filter(s => !activeColumnIds.has(s.id)),
offers: allExcluded.offers.filter(o => !activeColumnIds.has(o.supplierId)),
};
// Reconstruct uploadedKpFiles for users without session (other users opening CT via link)
let uploadedKpFiles = formData.uploadedKpFiles;
if (isNotificationLink || uploadedKpFiles.length === 0) {
if (preferSp || uploadedKpFiles.length === 0) {
const kpFilesRaw = await service.loadKpFilesForOffers(positionSpIds);
if (kpFilesRaw.length > 0) {
uploadedKpFiles = kpFilesRaw.map(f => ({
@@ -157,36 +190,62 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
}
}
const derivedStatus = deriveCtStatus(approvalChain);
// Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits.
// Only use SP-derived data when session has none, or in review mode (always fresh).
const suppliers = withNoOfferSuppliers(
formData.suppliers.length === 0 ? [...spSuppliers, ...excluded.suppliers] : formData.suppliers,
noOfferFromSp,
);
// Победитель обязателен для «Сохранить» на Шаге 3: без сессии берём его из шапки СТ, иначе
// в другом браузере пришлось бы выбирать заново. Только когда сессии нет (колонки из SP):
// в сессии «не выбран» может быть осознанно снятым выбором — его SP не перебивает.
const winnerColumnId = formData.suppliers.length === 0
? findWinnerColumnId(suppliers, ct.winSupplierSpId)
: undefined;
updateFormData({
ct: { ...ct, status: derivedStatus },
approvalChain,
ct,
approvalMatrix,
// Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID),
// which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3.
...(isNotificationLink || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
// Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits.
// Only use SP-derived data when session has none, or when opened via notification link (always fresh).
...(formData.suppliers.length === 0 ? { suppliers: spSuppliers } : {}),
...(isNotificationLink || formData.offers.length === 0 ? { offers } : {}),
...(preferSp || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
suppliers,
...(winnerColumnId !== undefined ? { recommendedSupplierId: winnerColumnId } : {}),
...(preferSp || formData.offers.length === 0 ? { offers: [...offers, ...excluded.offers] } : {}),
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
...(isNotificationLink ? { approvalSent: true } : {}),
...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}),
...(Object.keys(step3DeliveryKm).length > 0 ? { step3DeliveryKm } : {}),
});
if (isNotificationLink) {
// Teams notification link — always show Step 3 (comparison table + approval chain)
if (loadedAccess.isReview) {
// Просмотр (согласующий, автор СТ на согласовании, посторонний) — сразу шаг 3: таблица + маршрут.
goToStep(3);
ensureHighestStep(4);
markStepComplete(3);
markStepComplete(4);
} else if (approvalChain.length > 0) {
// Regular reload — make Step 4 accessible; only mark complete if CT is fully resolved
} else if (ct.status === 'rework') {
// Автор, СТ возвращена на доработку — шаг 4: там видно, кто вернул и что исправить.
ensureHighestStep(4);
if (derivedStatus === 'approved' || derivedStatus === 'rejected') {
goToStep(4);
} else {
// Автор открыл свою СТ — открываем шаги, до которых она уже дошла (позиции → 2, КП → 3, маршрут → 4).
ensureHighestStep(reachableStep({
hasPositions: positions.length > 0,
hasKp: rawOffers.length > 0 || excludedItems.length > 0 || uploadedKpFiles.length > 0,
hasRoute: approvalMatrix.length > 0,
}));
if (ct.status === 'approved' || ct.status === 'rejected') {
markStepComplete(4);
}
}
}).catch(() => undefined);
setIsCtLoading(false);
}).catch((err) => {
// Раньше ошибка глоталась молча → при сбое любого запроса форма оставалась
// на устаревшем статусе из session storage (напр. «Сформирована» вместо
// «На согласовании»). Показываем ошибку, а шаги — по данным сессии, если они есть.
console.error("[CtCreateForm] загрузка СТ по ссылке упала:", err);
setCtLoadError(`Не удалось загрузить СТ: ${(err as Error).message ?? 'неизвестная ошибка'}`);
setIsCtLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -208,8 +267,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
<Step2CommercialOffers
service={service}
formData={formData}
userDisplayName={userDisplayName}
isNotificationLink={isNotificationLink}
canEdit={access.canEdit}
isReadOnly={formData.ct?.status === 'on_approval' || formData.ct?.status === 'approved' || formData.ct?.status === 'rejected'}
isRestrictedView={hidePrices}
onSave={updateFormData}
@@ -223,8 +281,8 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
<Step3ComparisonTable
service={service}
formData={formData}
userDisplayName={userDisplayName}
isNotificationLink={isNotificationLink}
canEdit={access.canEdit}
isReview={access.isReview}
isRestrictedView={hidePrices}
onSave={updateFormData}
onNext={goNext}
@@ -246,8 +304,19 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
}
};
if (isCtLoading) {
return (
<div className={[styles.root, isDarkTheme ? styles.dark : ''].filter(Boolean).join(' ')}>
<div className={styles.loader}>
<Spinner size="large" label="Загрузка СТ…" />
</div>
</div>
);
}
return (
<div className={[styles.root, isDarkTheme ? styles.dark : ''].filter(Boolean).join(' ')}>
{ctLoadError && <Alert variant="err">{ctLoadError}</Alert>}
<Breadcrumbs
currentStep={currentStep}
ctNumber={formData.ct?.number}
@@ -0,0 +1,13 @@
@import '../common/theme';
.root {
display: flex;
align-items: center;
gap: 12px;
}
.savedAt {
font-size: 12px;
color: $muted;
white-space: nowrap;
}
@@ -0,0 +1,35 @@
import * as React from 'react';
import { Button, Spinner } from '@tnsu/ui-kit';
import styles from './SaveDraftButton.module.scss';
interface Props {
onSave: () => void;
isSaving: boolean;
disabled?: boolean;
// Подсказка, почему кнопка недоступна.
disabledReason?: string;
// Время последнего сохранения черновика в этой вкладке.
savedAt?: Date;
}
const TIME_FORMAT: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
// «Сохранить черновик» на шагах СТ: записать недоделанный шаг в SharePoint без проверок и остаться
// на шаге — чтобы правки не пропали при закрытии вкладки и были видны на другом компьютере.
export const SaveDraftButton: React.FC<Props> = ({ onSave, isSaving, disabled = false, disabledReason, savedAt }) => (
<div className={styles.root}>
{savedAt && (
<span className={styles.savedAt}>
Черновик сохранён в {savedAt.toLocaleTimeString('ru-RU', TIME_FORMAT)}
</span>
)}
<Button
variant="secondary"
onClick={onSave}
disabled={disabled || isSaving}
title={disabled ? disabledReason : 'Сохранить в SharePoint и остаться на этом шаге'}
>
{isSaving ? <Spinner size="small" /> : 'Сохранить черновик'}
</Button>
</div>
);
@@ -2,6 +2,7 @@ import * as React from 'react';
import { useState, useCallback } from 'react';
import { Checkbox } from '@tnsu/ui-kit';
import { Request, Position } from '../types/domain';
import { countSelectedInRequest } from './step1Plan';
import styles from './RequestsTable.module.scss';
interface Props {
@@ -10,6 +11,10 @@ interface Props {
lockedProjectId: string | undefined;
onSelectionChange: (positionId: string, position: Position, request: Request, checked: boolean) => void;
onRequestSelectionChange: (request: Request, checked: boolean) => void;
/** Лениво подгрузить позиции заявки (при раскрытии). В read-only не передаётся. */
onExpand?: (requestId: string) => void;
/** Заявки, чьи позиции сейчас грузятся — показываем спиннер-строку. */
loadingPositionIds?: Set<string>;
isReadOnly?: boolean;
}
@@ -24,20 +29,26 @@ export const RequestsTable: React.FC<Props> = ({
lockedProjectId,
onSelectionChange,
onRequestSelectionChange,
onExpand,
loadingPositionIds,
isReadOnly = false,
}) => {
const [expandedRequestIds, setExpandedRequestIds] = useState<Set<string>>(new Set());
const toggleExpand = useCallback((id: string) => {
const willExpand = !expandedRequestIds.has(id);
setExpandedRequestIds(prev => {
const next = new Set(prev);
if (next.has(id)) { next.delete(id); } else { next.add(id); }
return next;
});
}, []);
// При раскрытии — лениво подгружаем позиции этой заявки (идемпотентно в useRequests).
if (willExpand && onExpand) onExpand(id);
}, [expandedRequestIds, onExpand]);
// По id позиций, а не по загруженным: позиции нераскрытой потребности ещё не загружены.
const getRequestSelectionCount = (request: Request): number =>
request.positions.filter(p => selectedPositionIds.has(p.id)).length;
countSelectedInRequest(selectedPositionIds, request.id);
const isProjectLocked = (request: Request): boolean =>
lockedProjectId !== undefined && request.project.id !== lockedProjectId;
@@ -70,8 +81,9 @@ export const RequestsTable: React.FC<Props> = ({
{requests.map(request => {
const isExpanded = expandedRequestIds.has(request.id);
const selCount = getRequestSelectionCount(request);
const totalCount = request.positions.length;
const totalCount = request.positionsCount;
const locked = isProjectLocked(request);
const isLoadingPositions = loadingPositionIds?.has(request.id) ?? false;
return (
<React.Fragment key={request.id}>
@@ -95,7 +107,7 @@ export const RequestsTable: React.FC<Props> = ({
/>
</td>
<td className={styles.expandCell}>
{request.positions.length > 0 && (
{request.positionsCount > 0 && (
<button
className={styles.expandBtn}
onClick={() => toggleExpand(request.id)}
@@ -120,10 +132,18 @@ export const RequestsTable: React.FC<Props> = ({
<td>{request.organization || '—'}</td>
<td className={styles.dateCell}>{formatDate(request.createdAt)}</td>
<td className={styles.countCell}>
<span className={styles.positionCount}>{request.positions.length}</span>
<span className={styles.positionCount}>{request.positionsCount}</span>
</td>
</tr>
{isExpanded && isLoadingPositions && request.positions.length === 0 && (
<tr className={styles.positionRow}>
<td />
<td />
<td colSpan={7} className={styles.materialName}>Загрузка позиций…</td>
</tr>
)}
{isExpanded &&
request.positions.map(position => {
const isSelected = selectedPositionIds.has(position.id);
@@ -5,7 +5,18 @@ import { Request, Position, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { useRequests } from '../hooks/useRequests';
import { useDebouncedValue } from '../hooks/useDebouncedValue';
import { RequestsTable } from './RequestsTable';
import {
KnownPosition,
buildPinnedRequests,
countSelectedInRequest,
mergeKnownPositions,
planStep1Save,
resolveProject,
resolveSelectedPositions,
selectionKey,
} from './step1Plan';
import styles from './Step1PositionSelection.module.scss';
interface Props {
@@ -19,6 +30,8 @@ interface Props {
const DEFAULT_DAYS = 5;
const PAGE_SIZE = 10;
// Пауза в наборе номера, после которой поиск уходит на сервер.
const SEARCH_DEBOUNCE_MS = 500;
export const Step1PositionSelection: React.FC<Props> = ({
service,
@@ -32,14 +45,26 @@ export const Step1PositionSelection: React.FC<Props> = ({
const [daysInput, setDaysInput] = useState(String(DEFAULT_DAYS));
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [showOnlySelected, setShowOnlySelected] = useState(false);
const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>(
new Set(formData.selectedPositions.map(p => p.id))
new Set(formData.selectedPositions.map(selectionKey))
);
const [positionMap, setPositionMap] = useState<Map<string, { position: Position; request: Request }>>(new Map());
const [positionMap, setPositionMap] = useState<Map<string, KnownPosition>>(new Map());
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
const { requests, isLoading, error: loadError, reload } = useRequests(service, days, isReadOnly);
// Сохранённые позиции есть только у созданной СТ, поэтому проект для закреплённых потребностей — из её шапки.
const ctProjectName = formData.ct?.projectName;
const pinnedRequests = useMemo(
() => buildPinnedRequests(formData.selectedPositions, resolveProject(undefined, ctProjectName, true)),
[formData.selectedPositions, ctProjectName],
);
// Поиск по номеру — на сервере (часть номера, в пределах периода): иначе ради одной старой
// потребности приходилось грузить весь период.
const serverSearch = useDebouncedValue(searchQuery.trim(), SEARCH_DEBOUNCE_MS);
const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } =
useRequests(service, days, serverSearch, isReadOnly, pinnedRequests);
const readOnlyRequests = useMemo(() => {
if (!isReadOnly) return [];
@@ -57,36 +82,39 @@ export const Step1PositionSelection: React.FC<Props> = ({
workType: positions[0].smrType,
organization: positions[0].organization ?? '',
createdAt: positions[0].requestCreatedAt ?? '',
positionsCount: positions.length,
positions,
}));
}, [isReadOnly, formData.selectedPositions, formData.selectedProject]);
const filteredRequests = useMemo(() => {
// Фильтр в браузере — пока набирают номер (до ответа сервера) и для закреплённых потребностей СТ,
// которые добавляются к выдаче без учёта поиска.
const q = searchQuery.trim().toLowerCase();
const filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests;
return [...filtered].sort((a, b) => {
const aSelected = a.positions.some(p => selectedPositionIds.has(p.id)) ? 0 : 1;
const bSelected = b.positions.some(p => selectedPositionIds.has(p.id)) ? 0 : 1;
return aSelected - bSelected;
});
}, [requests, searchQuery, selectedPositionIds]);
let filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests;
// Режим «Только выбранные» — оставляем заявки, где есть хотя бы одна выбранная позиция,
// чтобы можно было свериться со своим выбором с любой страницы, не листая назад.
// Выбор считаем по id позиций, а не по загруженным позициям: они грузятся только при раскрытии,
// и после возврата на шаг 1 «Только выбранные» иначе показывал пустой список.
const hasSelected = (r: Request): boolean => countSelectedInRequest(selectedPositionIds, r.id) > 0;
if (showOnlySelected) {
filtered = filtered.filter(hasSelected);
}
return [...filtered].sort((a, b) => (hasSelected(a) ? 0 : 1) - (hasSelected(b) ? 0 : 1));
}, [requests, searchQuery, selectedPositionIds, showOnlySelected]);
const totalPages = Math.max(1, Math.ceil(filteredRequests.length / PAGE_SIZE));
const safePage = Math.min(currentPage, totalPages);
const pagedRequests = isReadOnly ? readOnlyRequests : filteredRequests.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
// Build position→request map for resolving selection metadata
// Позиция → потребность для выбора и «Сохранить». Копим, а не пересобираем: поиск и смена периода
// меняют список, а выбранные раньше позиции должны дойти до сохранения.
useEffect(() => {
const map = new Map<string, { position: Position; request: Request }>();
requests.forEach(req => {
req.positions.forEach(pos => {
map.set(pos.id, { position: pos, request: req });
});
});
setPositionMap(map);
setPositionMap(prev => mergeKnownPositions(prev, requests));
}, [requests]);
const lockedProjectId = formData.selectedProject?.id;
const project = resolveProject(formData.selectedProject, ctProjectName, selectedPositionIds.size > 0);
const lockedProjectId = project?.id;
const handleSelectionChange = useCallback(
(positionId: string, _position: Position, request: Request, checked: boolean) => {
@@ -111,12 +139,16 @@ export const Step1PositionSelection: React.FC<Props> = ({
);
const handleRequestSelectionChange = useCallback(
(request: Request, checked: boolean) => {
if (request.positions.length === 0) return;
async (request: Request, checked: boolean) => {
if (request.positionsCount === 0) return;
if (checked && lockedProjectId !== undefined && request.project.id !== lockedProjectId) return;
// «Выбрать все» может быть нажато на свёрнутой заявке — позиции ещё не загружены.
// Подтягиваем их (ленивая загрузка возвращает список), затем выделяем.
const positions = request.positions.length > 0 ? request.positions : await loadPositions(request.id);
if (positions.length === 0) return;
setSelectedPositionIds(prev => {
const next = new Set(prev);
request.positions.forEach(p => checked ? next.add(p.id) : next.delete(p.id));
positions.forEach(p => checked ? next.add(p.id) : next.delete(p.id));
return next;
});
if (checked && lockedProjectId === undefined) {
@@ -124,12 +156,12 @@ export const Step1PositionSelection: React.FC<Props> = ({
}
if (!checked) {
const remainingAfter = Array.from(selectedPositionIds).filter(
id => !request.positions.some(p => p.id === id)
id => !positions.some(p => p.id === id)
);
if (remainingAfter.length === 0) onSave({ selectedProject: undefined });
}
},
[lockedProjectId, onSave, selectedPositionIds]
[lockedProjectId, onSave, selectedPositionIds, loadPositions]
);
const handleDaysChange = (val: string): void => {
@@ -146,12 +178,16 @@ export const Step1PositionSelection: React.FC<Props> = ({
setCurrentPage(1);
};
const selectedPositions: Position[] = Array.from(selectedPositionIds)
.map(id => positionMap.get(id)?.position)
.filter((p): p is Position => p !== undefined);
// Позиции нераскрытых потребностей берём из сохранённых в форме — иначе после возврата на шаг 1
// выбор есть, а «Сохранить» неактивна.
const selectedPositions: Position[] = resolveSelectedPositions(
Array.from(selectedPositionIds),
id => positionMap.get(id)?.position,
formData.selectedPositions,
);
const selectedProject = selectedPositions.length > 0
? positionMap.get(selectedPositions[0].id)?.request.project ?? null
? positionMap.get(selectedPositions[0].id)?.request.project ?? project ?? null
: null;
const canSave = selectedPositions.length > 0;
@@ -163,16 +199,40 @@ export const Step1PositionSelection: React.FC<Props> = ({
setSaveError(undefined);
try {
const ctNumber = await service.generateCtNumber(selectedProject.name);
const ct = await service.createCT(selectedProject.name, ctNumber);
const enrichedPositions = selectedPositions.map(p => ({
...p,
disciplineCode: p.disciplineCode || (positionMap.get(p.id)?.request.discipline ?? ''),
}));
const savedPositions = await service.createCTPositions(ct.id, enrichedPositions);
const initiatorCompany = positionMap.get(selectedPositions[0].id)?.request.organization
?? formData.initiatorCompany ?? '';
const initiatorCompany = positionMap.get(selectedPositions[0].id)?.request.organization ?? '';
onSave({ ct, selectedProject, selectedPositions: savedPositions, initiatorCompany });
if (formData.ct) {
// СТ уже создана (вернулись на шаг 1) — обновляем её позиции, а не создаём новую СТ:
// иначе предложения остаются в прежней СТ, а новая по ссылке открывается пустой.
const ct = formData.ct;
const plan = planStep1Save(formData.selectedPositions, enrichedPositions);
await service.removeCTPositions(
plan.toRemove.map(p => p.spId).filter((id): id is number => id !== undefined),
);
const created = plan.toCreate.length > 0 ? await service.createCTPositions(ct.id, plan.toCreate) : [];
if (ct.projectName !== selectedProject.name) {
await service.updateCTProject(ct.id, selectedProject.name);
}
// Предложения привязаны к позиции либо по id из сессии, либо по SP Id (загружены из SP).
const removedIds = new Set(plan.toRemove.flatMap(p => [p.id, String(p.spId)]));
onSave({
ct: { ...ct, projectName: selectedProject.name },
selectedProject,
selectedPositions: [...plan.toKeep, ...created],
initiatorCompany,
offers: formData.offers.filter(o => !removedIds.has(o.positionId)),
});
} else {
const ctNumber = await service.generateCtNumber(selectedProject.name);
const ct = await service.createCT(selectedProject.name, ctNumber);
const savedPositions = await service.createCTPositions(ct.id, enrichedPositions);
onSave({ ct, selectedProject, selectedPositions: savedPositions, initiatorCompany });
}
onStepComplete(1);
onNext();
} catch (err) {
@@ -200,14 +260,21 @@ export const Step1PositionSelection: React.FC<Props> = ({
<Button variant="secondary" onClick={reload} disabled={isLoading}>
Обновить
</Button>
<Button
variant={showOnlySelected ? 'accent' : 'secondary'}
onClick={() => { setShowOnlySelected(v => !v); setCurrentPage(1); }}
disabled={!showOnlySelected && selectedPositionIds.size === 0}
>
{showOnlySelected ? 'Показать все' : `Только выбранные (${selectedPositionIds.size})`}
</Button>
<Button variant="secondary" onClick={() => window.open('https://tnsukz.sharepoint.com/sites/sed', '_blank')}>
Мои СТ
</Button>
</div>
{formData.selectedProject && (
{project && (
<Alert variant="info">
Выбран проект: <strong>{formData.selectedProject.name}</strong> — позиции из других проектов недоступны
Выбран проект: <strong>{project.name}</strong> — позиции из других проектов недоступны
</Alert>
)}
</div>
@@ -227,6 +294,8 @@ export const Step1PositionSelection: React.FC<Props> = ({
lockedProjectId={lockedProjectId}
onSelectionChange={handleSelectionChange}
onRequestSelectionChange={handleRequestSelectionChange}
onExpand={loadPositions}
loadingPositionIds={loadingPositionIds}
isReadOnly={isReadOnly}
/>
{!isReadOnly && totalPages > 1 && (
@@ -0,0 +1,226 @@
import {
KnownPosition,
buildPinnedRequests,
countSelectedInRequest,
mergeKnownPositions,
planStep1Save,
resolveProject,
resolveSelectedPositions,
selectionKey,
} from './step1Plan';
import { Position, Request } from '../types/domain';
function makePosition(id: string, overrides: Partial<Position> = {}): Position {
return {
id,
requestId: id.split('__')[0],
requestNumber: '051/26-08/0015',
materialName: `Позиция ${id}`,
disciplineCode: '',
smrType: '',
unit: 'шт',
agskCode: '',
uniqueCode: '',
quantity: 1,
positionType: 'ТМЦ',
deadline: '',
...overrides,
};
}
describe('planStep1Save (СТ уже создана — повторное сохранение шага 1)', () => {
// СТ №10 → вернулись на шаг 1 → «Сохранить» создавало СТ №11 без предложений.
// Теперь позиции существующей СТ обновляются: оставленные — как есть, новые — дописать, снятые — убрать.
const saved = [
makePosition('req1__0', { spId: 1598 }),
makePosition('req1__1', { spId: 1599 }),
makePosition('req1__2', { spId: 1600 }),
];
it('тот же выбор — ничего не создаём и не удаляем', () => {
const plan = planStep1Save(saved, saved.map(p => ({ ...p, spId: undefined })));
expect(plan.toCreate).toEqual([]);
expect(plan.toRemove).toEqual([]);
expect(plan.toKeep.map(p => p.spId)).toEqual([1598, 1599, 1600]);
});
it('добавленная позиция — создать, снятая — убрать', () => {
const selected = [makePosition('req1__0'), makePosition('req1__2'), makePosition('req1__3')];
const plan = planStep1Save(saved, selected);
expect(plan.toKeep.map(p => p.spId)).toEqual([1598, 1600]);
expect(plan.toCreate.map(p => p.id)).toEqual(['req1__3']);
expect(plan.toRemove.map(p => p.spId)).toEqual([1599]);
});
it('у оставленной позиции берём сохранённую версию (с SP Id)', () => {
const plan = planStep1Save(saved, [makePosition('req1__1')]);
expect(plan.toKeep).toEqual([saved[1]]);
});
});
describe('countSelectedInRequest', () => {
// Позиции потребности грузятся только при раскрытии — считаем выбранные по префиксу id «потребность__».
it('считает выбранные позиции потребности без загрузки её позиций', () => {
const selected = new Set(['req1__0', 'req1__4', 'req2__0']);
expect(countSelectedInRequest(selected, 'req1')).toBe(2);
expect(countSelectedInRequest(selected, 'req2')).toBe(1);
expect(countSelectedInRequest(selected, 'req3')).toBe(0);
});
it('не путает потребность с похожим id', () => {
expect(countSelectedInRequest(new Set(['req10__0']), 'req1')).toBe(0);
});
});
describe('mergeKnownPositions', () => {
function makeRequest(id: string, positionIds: string[]): Request {
return {
id, requestNumber: `081/26-04/${id}`, project: { id: 'Проект', name: 'Проект', code: '' },
discipline: '', workType: '', organization: '', createdAt: '',
positionsCount: positionIds.length, positions: positionIds.map(p => makePosition(p)),
};
}
// Выбрали позицию → нашли другую потребность поиском на сервере (первой в выдаче уже нет) →
// «Сохранить» должно сохранить обе. Раньше карта позиций пересобиралась из текущего списка,
// и выбор до поиска молча пропадал (так же — при смене периода).
it('позиции прежнего списка остаются после загрузки нового', () => {
const beforeSearch = mergeKnownPositions(new Map(), [makeRequest('req1', ['req1__0'])]);
const afterSearch = mergeKnownPositions(beforeSearch, [makeRequest('req2', ['req2__0'])]);
expect(Array.from(afterSearch.keys()).sort()).toEqual(['req1__0', 'req2__0']);
expect(afterSearch.get('req1__0')?.request.id).toBe('req1');
});
it('повторная загрузка той же потребности обновляет позицию', () => {
const first = mergeKnownPositions(new Map(), [makeRequest('req1', ['req1__0'])]);
const reloaded = makeRequest('req1', ['req1__0']);
reloaded.positions[0].quantity = 5;
const result = mergeKnownPositions(first, [reloaded]);
expect(result.get('req1__0')?.position.quantity).toBe(5);
});
it('не меняет переданную карту', () => {
const known = new Map<string, KnownPosition>();
mergeKnownPositions(known, [makeRequest('req1', ['req1__0'])]);
expect(known.size).toBe(0);
});
});
describe('resolveSelectedPositions', () => {
// Вернулись на шаг 1: потребность не раскрыта — выбранные позиции берём из сохранённых,
// иначе «Сохранить» неактивна, хотя выбор есть.
it('берёт загруженную позицию, а если её нет — сохранённую', () => {
const loaded = new Map([['req1__1', makePosition('req1__1', { quantity: 7 })]]);
const saved = [makePosition('req1__0', { spId: 1598 }), makePosition('req1__1', { spId: 1599 })];
const result = resolveSelectedPositions(['req1__0', 'req1__1', 'req9__0'], id => loaded.get(id), saved);
expect(result.map(p => ({ id: p.id, quantity: p.quantity }))).toEqual([
{ id: 'req1__0', quantity: 1 },
{ id: 'req1__1', quantity: 7 },
]);
});
});
describe('СТ открыта из реестра — позиции из SP связаны с потребностью через requestPositionId', () => {
// СТ №8: позиции пришли из SP (id = SP Id). Таблица шага 1 их не находила — «Выбрано: 3»,
// а увидеть и снять выбор было негде.
const fromSp = [
makePosition('1543', { spId: 1543, requestPositionId: 'req1__0', requestId: 'req1' }),
makePosition('1544', { spId: 1544, requestPositionId: 'req1__2', requestId: 'req1' }),
];
it('ключ выбора — позиция потребности, а не SP Id', () => {
expect(fromSp.map(selectionKey)).toEqual(['req1__0', 'req1__2']);
expect(countSelectedInRequest(new Set(fromSp.map(selectionKey)), 'req1')).toBe(2);
});
it('старая СТ без связи — ключ остаётся SP Id', () => {
expect(selectionKey(makePosition('1600', { spId: 1600, requestId: '' }))).toBe('1600');
});
it('повторное сохранение: та же позиция из таблицы не создаётся заново, снятая — удаляется', () => {
const plan = planStep1Save(fromSp, [makePosition('req1__0'), makePosition('req1__5')]);
expect(plan.toKeep.map(p => p.spId)).toEqual([1543]);
expect(plan.toCreate.map(p => p.id)).toEqual(['req1__5']);
expect(plan.toRemove.map(p => p.spId)).toEqual([1544]);
});
it('нераскрытая потребность — выбранная позиция берётся из сохранённых по ключу', () => {
const result = resolveSelectedPositions(['req1__2'], () => undefined, fromSp);
expect(result.map(p => p.spId)).toEqual([1544]);
});
});
describe('buildPinnedRequests', () => {
// Потребность старше выбранного периода не приходит в списке — закрепляем её, раз её позиции в СТ.
const project = { id: 'Проект X', name: 'Проект X', code: '' };
it('по одной потребности на каждую, из которой есть позиции в СТ', () => {
const saved = [
makePosition('1', { requestId: 'req1', requestNumber: 'A' }),
makePosition('2', { requestId: 'req2', requestNumber: 'B' }),
makePosition('3', { requestId: 'req1', requestNumber: 'A' }),
];
expect(buildPinnedRequests(saved, project).map(r => [r.id, r.requestNumber])).toEqual([
['req1', 'A'],
['req2', 'B'],
]);
});
it('вид работ потребности в позициях не хранится — пусто, а не вид СМР позиции', () => {
// СТ 367: у потребности 081/26-10/0028 вид работ «СТЕНЫ И ПЕРЕГОРОДКИ», а в шапке закреплённой
// потребности показывалось «ГКЛ перегородки» — вид СМР её позиции.
const saved = [makePosition('1', { requestId: 'req1', smrType: 'ГКЛ перегородки' })];
expect(buildPinnedRequests(saved, project)[0].workType).toBe('');
});
it('позиции без связи с потребностью (старые СТ) — пропускаем', () => {
expect(buildPinnedRequests([makePosition('1600', { requestId: '' })], project)).toEqual([]);
});
it('проект неизвестен — не закрепляем', () => {
expect(buildPinnedRequests([makePosition('req1__0')], undefined)).toEqual([]);
});
});
describe('resolveProject', () => {
// Из реестра selectedProject не восстанавливался → «Сохранить и продолжить» молча ничего не делала
// и пропадал запрет выбирать позиции чужого проекта.
const project = { id: 'Проект X', name: 'Проект X', code: '' };
it('берёт выбранный проект, если он есть', () => {
expect(resolveProject(project, 'Другой', true)).toBe(project);
});
it('без выбранного проекта, но с выбором — проект из шапки СТ (id проекта = его название)', () => {
expect(resolveProject(undefined, 'Выпуск геомембран', true)).toEqual({
id: 'Выпуск геомембран',
name: 'Выпуск геомембран',
code: '',
});
});
it('выбор снят полностью — проект не навязываем, можно выбрать другой', () => {
expect(resolveProject(undefined, 'Выпуск геомембран', false)).toBeUndefined();
});
it('СТ ещё не создана — проекта нет', () => {
expect(resolveProject(undefined, undefined, true)).toBeUndefined();
});
});
@@ -0,0 +1,121 @@
import { Position, Project, Request } from '../types/domain';
// id позиции потребности начинается с `${id потребности}__` (см. utils/positionKey).
const POSITION_ID_SEPARATOR = '__';
// Ключ выбора на шаге 1 — id позиции потребности. У позиции, загруженной из SP (СТ открыта из реестра),
// id — это SP Id, а связь с потребностью лежит в requestPositionId. У СТ, созданных до появления
// этого поля, связи нет — тогда ключ SP Id, и в таблице потребностей такая позиция не найдётся.
export function selectionKey(position: Position): string {
return position.requestPositionId || position.id;
}
// Повторное сохранение шага 1, когда СТ уже создана: что оставить (позиции уже в SP), что дописать,
// что убрать. Раньше шаг 1 всегда создавал новую СТ — предложения оставались в прежней, а новая
// открывалась по ссылке пустой.
export interface Step1SavePlan {
toKeep: Position[]; // сохранённые версии (с SP Id)
toCreate: Position[];
toRemove: Position[];
}
export function planStep1Save(saved: Position[], selected: Position[]): Step1SavePlan {
const selectedKeys = new Set(selected.map(selectionKey));
const savedByKey = new Map(saved.map(p => [selectionKey(p), p]));
const toKeep: Position[] = [];
const toCreate: Position[] = [];
selected.forEach(p => {
const savedPosition = savedByKey.get(selectionKey(p));
if (savedPosition?.spId !== undefined) {
toKeep.push(savedPosition);
} else {
toCreate.push(p);
}
});
return {
toKeep,
toCreate,
toRemove: saved.filter(p => p.spId !== undefined && !selectedKeys.has(selectionKey(p))),
};
}
// Сколько позиций потребности выбрано — по id, без загрузки её позиций (они грузятся при раскрытии).
export function countSelectedInRequest(selectedIds: Set<string>, requestId: string): number {
const prefix = `${requestId}${POSITION_ID_SEPARATOR}`;
let count = 0;
selectedIds.forEach(id => { if (id.startsWith(prefix)) count++; });
return count;
}
export interface KnownPosition {
position: Position;
request: Request;
}
// Все позиции, которые показывались на шаге 1, — копятся, а не заменяются при каждой загрузке.
// Список потребностей меняется (поиск по номеру на сервере, другой период), а выбор остаётся:
// позиция, выбранная до поиска, должна дойти до «Сохранить», хоть её потребности и нет в выдаче.
export function mergeKnownPositions(
known: Map<string, KnownPosition>,
requests: Request[],
): Map<string, KnownPosition> {
const next = new Map(known);
requests.forEach(request => {
request.positions.forEach(position => next.set(position.id, { position, request }));
});
return next;
}
// Выбранные позиции: загруженная версия, а если потребность не раскрыта — сохранённая в форме.
// Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна.
export function resolveSelectedPositions(
selectedKeys: string[],
findLoaded: (key: string) => Position | undefined,
saved: Position[],
): Position[] {
const savedByKey = new Map(saved.map(p => [selectionKey(p), p]));
return selectedKeys
.map(key => findLoaded(key) ?? savedByKey.get(key))
.filter((p): p is Position => p !== undefined);
}
// Потребности, позиции которых уже в СТ. Их надо показать в таблице, даже если потребность старше
// выбранного периода, — иначе «Выбрано: 3», а увидеть и снять выбор негде. Шапку собираем из позиций
// (организацию и дату создания в позициях СТ не храним), сами позиции потребности догружает useRequests.
export function buildPinnedRequests(saved: Position[], project: Project | undefined): Request[] {
if (!project) return [];
const byRequestId = new Map<string, Request>();
saved.forEach(p => {
if (!p.requestId || byRequestId.has(p.requestId)) return;
byRequestId.set(p.requestId, {
id: p.requestId,
requestNumber: p.requestNumber,
project,
discipline: p.disciplineCode,
// Вид работ потребности в позициях СТ не хранится (smrType — вид СМР позиции, это другое) —
// пусто, таблица покажет «—».
workType: '',
organization: p.organization ?? '',
createdAt: p.requestCreatedAt ?? '',
positionsCount: 0,
positions: [],
});
});
return Array.from(byRequestId.values());
}
// Проект СТ. Открыли СТ из реестра — selectedProject в форме нет, берём из шапки СТ (у потребностей
// id проекта = его название, см. CtService._mapRequest). Без этого «Сохранить и продолжить» молча
// ничего не делала и снимался запрет на позиции чужого проекта. Если выбор снят целиком — проект не
// навязываем, чтобы можно было собрать СТ по другому проекту.
export function resolveProject(
selectedProject: Project | undefined,
ctProjectName: string | undefined,
hasSelection: boolean,
): Project | undefined {
if (selectedProject) return selectedProject;
if (!hasSelection || !ctProjectName) return undefined;
return { id: ctProjectName, name: ctProjectName, code: '' };
}
@@ -2,6 +2,7 @@ import * as React from 'react';
import { useCallback, useState } from 'react';
import { Button, Alert, TextInput, IconButton, Spinner } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain';
import { parseNumberInput } from '../utils/parseNumberInput';
import styles from './CurrencyRatesForm.module.scss';
interface Props {
@@ -58,9 +59,9 @@ export const CurrencyRatesForm: React.FC<Props> = ({ rates, onChange, readOnly =
const updateRate = useCallback(
(code: string, value: string) => {
const parsed = parseFloat(value.replace(',', '.'));
const parsed = parseNumberInput(value);
onChange(
rates.map(r => (r.code === code ? { ...r, rateToKzt: isNaN(parsed) ? 0 : parsed } : r))
rates.map(r => (r.code === code ? { ...r, rateToKzt: parsed ?? 0 } : r))
);
},
[rates, onChange]
@@ -109,6 +109,30 @@
}
}
// Размеры совпадают с селектом валюты в очереди загрузки (FileUploadArea), чтобы строки выглядели одинаково.
.kpCurrencySelect {
font-size: 11px;
padding: 2px 6px;
border: 1px solid $border;
border-radius: 3px;
background: $surface;
color: $text;
font-family: inherit;
cursor: pointer;
outline: none;
flex-shrink: 0;
height: 26px;
&:focus { border-color: $accent; }
}
.kpCurrencyCode {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: $muted;
}
.noOfferBadge {
background: $danger-bg;
color: $danger;
@@ -182,3 +206,21 @@
align-items: center;
gap: 12px;
}
// «Загруженные КП»: файлы одного поставщика сгруппированы, у нескольких КП — пометка.
.kpGroup {
display: flex;
flex-direction: column;
gap: 4px;
}
.kpGroupHeader {
display: flex;
align-items: center;
gap: 8px;
}
.kpGroupNote {
font-size: 12px;
color: $muted;
}
@@ -1,6 +1,6 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, Alert, Spinner } from '@tnsu/ui-kit';
import { Button, Alert, Spinner, confirmModal } from '@tnsu/ui-kit';
import { CurrencyRate, FormStep, Supplier } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
@@ -11,13 +11,17 @@ import { useKpProcessing } from './useKpProcessing';
import { persistStep2 } from './step2Persist';
import { SuppliersWithoutKp } from './SuppliersWithoutKp';
import { UploadedKpList } from './UploadedKpList';
import { removeKpFileFromComparison } from './kpRemoval';
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
import { withoutPlaceholders } from '../utils/placeholderKpFile';
import styles from './Step2CommercialOffers.module.scss';
interface Props {
service: CtService;
formData: CTFormData;
userDisplayName: string;
isNotificationLink?: boolean;
// Правка СТ (getCtAccess): без неё — только просмотр, загрузка КП и сохранение недоступны.
canEdit?: boolean;
isReadOnly?: boolean;
isRestrictedView?: boolean;
onSave: (partial: Partial<CTFormData>) => void;
@@ -31,8 +35,7 @@ interface Props {
export const Step2CommercialOffers: React.FC<Props> = ({
service,
formData,
userDisplayName,
isNotificationLink,
canEdit = true,
onSave,
onNext,
onPrev,
@@ -45,10 +48,11 @@ export const Step2CommercialOffers: React.FC<Props> = ({
const [noOfferSuppliers, setNoOfferSuppliers] = useState<NoOfferEntry[]>(() =>
(formData.suppliers ?? [])
.filter(s => !s.hasOffer)
.map(s => ({ localId: s.id, name: s.name, comment: '' })),
.map(s => ({ localId: s.id, name: s.name, comment: s.comment ?? '' })),
);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
const [draftSavedAt, setDraftSavedAt] = useState<Date | undefined>(undefined);
const kp = useKpProcessing(service, formData, onSave);
@@ -70,16 +74,83 @@ export const Step2CommercialOffers: React.FC<Props> = ({
id: s.localId,
name: s.name,
hasOffer: false,
comment: s.comment,
}));
onSave({ suppliers: [...withOffer, ...withoutOffer] });
};
const handleSave = async (): Promise<void> => {
// Смена валюты уже загруженного КП (список «Загруженные КП»). Если файл ещё в очереди этой сессии,
// синхронизируем и её: иначе persistStep2 пересоберёт поставщика из kpFiles со старой валютой.
// Валюта — у конкретного файла: у поставщика может быть несколько КП в разных валютах.
const handleUploadedCurrencyChange = (fileSpId: number, currencyCode: string): void => {
const uploaded = formData.uploadedKpFiles.find(u => u.spId === fileSpId);
if (!uploaded?.supplierName) return;
onSave(applySupplierCurrency(
formData.suppliers ?? [],
formData.offers ?? [],
uploaded,
currencyCode,
currencyRates,
));
kp.kpFiles
.filter(f => f.spId === fileSpId)
.forEach(f => kp.handleCurrencyChange(f.localId, currencyCode));
};
// Убрать КП из сравнения (корзина в «Загруженные КП» или крестик у обработанного файла): так
// заменяют исправленный КП — загружают новый, старый убирают. Колонка и цены уходят сразу,
// записи этого файла в SP — в корзину. Сам файл остаётся в библиотеке.
const handleRemoveKpFile = async (fileSpId: number): Promise<void> => {
const file = formData.uploadedKpFiles.find(u => u.spId === fileSpId);
if (!file) {
kp.removeFileBySpId(fileSpId);
return;
}
const confirmed = await confirmModal({
title: 'Убрать КП из сравнения?',
message: `${file.name}${file.supplierName ? ` (${file.supplierName})` : ''} — его колонка и цены уйдут из таблицы сравнения. Файл останется в библиотеке КП.`,
confirmText: 'Убрать',
danger: true,
});
if (!confirmed) return;
kp.removeFileBySpId(fileSpId);
onSave(removeKpFileFromComparison(
{ suppliers: formData.suppliers ?? [], offers: formData.offers ?? [], uploadedKpFiles: formData.uploadedKpFiles },
file,
));
const positionSpIds = [...formData.selectedPositions, ...(formData.step3ExtraPositions ?? [])]
.map(p => p.spId)
.filter((id): id is number => id !== undefined);
service.recycleKpFileOffers(fileSpId, positionSpIds).catch(err => {
// Не критично: «Сохранить» на Шаге 2/3 перезапишет предложения позиций по текущей таблице.
console.warn('[КП] не удалось убрать предложения КП из SP:', err);
});
};
// Крестик в очереди загрузки: обработанный КП уже в сравнении — убираем его так же, как корзиной;
// файл в процессе или с ошибкой — просто убираем из очереди.
const handleQueueFileRemove = (localId: string): void => {
const file = kp.kpFiles.find(f => f.localId === localId);
if (file?.status === 'done' && file.spId !== undefined) {
handleRemoveKpFile(file.spId).catch(() => undefined);
return;
}
kp.handleFileRemove(localId);
};
// 'draft' — записать шаг в SP и остаться: распознанные КП попадают в SP только здесь, и без
// черновика пропадали при закрытии вкладки.
const handleSave = async (mode: 'next' | 'draft' = 'next'): Promise<void> => {
setIsSaving(true);
setSaveError(undefined);
try {
const patch = await persistStep2({ service, formData, kpFiles: kp.kpFiles, currencyRates, noOfferSuppliers, userDisplayName });
const patch = await persistStep2({ service, formData, kpFiles: kp.kpFiles, currencyRates, noOfferSuppliers });
onSave(patch);
if (mode === 'draft') {
setDraftSavedAt(new Date());
return;
}
onStepComplete(2);
onNext();
} catch (err) {
@@ -90,6 +161,8 @@ export const Step2CommercialOffers: React.FC<Props> = ({
};
const canProceed = !isSaving && currencyRates.every(r => r.rateToKzt > 0);
// Заглушки поставщиков, добавленных на Шаге 3 вручную, — не КП: в списке не показываем.
const realKpFiles = withoutPlaceholders(formData.uploadedKpFiles ?? []);
const currencyOptions = ['KZT', ...currencyRates.filter(r => r.code !== 'KZT').map(r => r.code)];
const uploadedFiles: UploadedFile[] = kp.kpFiles.map(f => ({
@@ -112,10 +185,10 @@ export const Step2CommercialOffers: React.FC<Props> = ({
<FileUploadArea
files={uploadedFiles}
onFilesAdded={files => { kp.handleFilesAdded(files).catch(() => undefined); }}
onFileRemove={kp.handleFileRemove}
onFileRemove={handleQueueFileRemove}
currencyOptions={currencyOptions}
onCurrencyChange={kp.handleCurrencyChange}
disabled={isNotificationLink || isReadOnly}
disabled={!canEdit || isReadOnly}
/>
{kp.duplicateWarning && <Alert variant="warn">{kp.duplicateWarning}</Alert>}
{kp.isProcessing && (
@@ -131,12 +204,15 @@ export const Step2CommercialOffers: React.FC<Props> = ({
<SuppliersWithoutKp items={noOfferSuppliers} onChange={handleNoOfferChange} disabled={isReadOnly} />
{!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && (
{!isRestrictedView && realKpFiles.length > 0 && (
<UploadedKpList
files={formData.uploadedKpFiles}
files={realKpFiles}
service={service}
onRemove={(spId) => onSave({ uploadedKpFiles: formData.uploadedKpFiles.filter(u => u.spId !== spId) })}
disabled={isReadOnly}
currencyOptions={currencyOptions}
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
onCurrencyChange={handleUploadedCurrencyChange}
onRemove={spId => { handleRemoveKpFile(spId).catch(() => undefined); }}
disabled={!canEdit || isReadOnly}
/>
)}
@@ -146,10 +222,20 @@ export const Step2CommercialOffers: React.FC<Props> = ({
</Button>
<div className={styles.footerRight}>
{saveError && <Alert variant="err">{saveError}</Alert>}
{!isNotificationLink && !isReadOnly && (
<Button variant="accent" onClick={handleSave} disabled={!canProceed}>
{isSaving ? <Spinner size="small" /> : 'Сохранить и продолжить'}
</Button>
{canEdit && !isReadOnly && (
<>
<SaveDraftButton
onSave={() => { handleSave('draft').catch(() => undefined /* ошибку показывает handleSave */); }}
isSaving={isSaving}
// Курсы пишутся в общий список «Курс Валют» — нулевой курс туда писать нельзя.
disabled={!canProceed}
disabledReason="Укажите курсы всех валют"
savedAt={draftSavedAt}
/>
<Button variant="accent" onClick={() => { handleSave().catch(() => undefined /* ошибку показывает handleSave */); }} disabled={!canProceed}>
{isSaving ? <Spinner size="small" /> : 'Сохранить и продолжить'}
</Button>
</>
)}
</div>
</div>
@@ -1,40 +1,48 @@
import * as React from 'react';
import { IconButton } from '@tnsu/ui-kit';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { KpFileRef } from './kpCurrency';
import { groupUploadedKpFiles } from './kpRemoval';
import { UploadedKpRow } from './UploadedKpRow';
import styles from './Step2CommercialOffers.module.scss';
interface Props {
files: CTFormData['uploadedKpFiles'];
service: CtService;
currencyOptions: string[];
getCurrency: (file: KpFileRef) => string;
onCurrencyChange: (spId: number, currencyCode: string) => void;
onRemove: (spId: number) => void;
disabled?: boolean;
}
// Секция «Загруженные КП» (постоянный список из formData): ссылка на файл + удаление из списка.
export const UploadedKpList: React.FC<Props> = ({ files, service, onRemove, disabled }) => (
// Секция «Загруженные КП» (постоянный список из formData), сгруппированная по поставщику. Каждый КП —
// отдельная колонка сравнения; несколько КП одного поставщика стоят рядом с пометкой, лишний
// (напр. старая версия КП) убирают корзиной. Валюту можно поправить и после возврата с Шага 3.
export const UploadedKpList: React.FC<Props> = ({ files, ...rowProps }) => (
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Загруженные КП</h3>
<ul className={styles.noOfferList}>
{files.map(f => (
<li key={f.spId} className={styles.noOfferItem}>
{f.supplierName && <span className={styles.noOfferName}>{f.supplierName}</span>}
<a
href={service.getKpFileUrl(f.name)}
target="_blank"
rel="noopener noreferrer"
className={styles.kpFileLink}
>
{f.name}
</a>
<IconButton
icon="trash"
title="Удалить КП из списка"
onClick={() => onRemove(f.spId)}
disabled={disabled}
/>
</li>
))}
{groupUploadedKpFiles(files).map(group => {
const isMultiple = group.files.length > 1;
return (
<li key={group.files[0].spId} className={styles.kpGroup}>
{isMultiple && (
<div className={styles.kpGroupHeader}>
<span className={styles.noOfferName}>{group.supplierName}</span>
<span className={styles.kpGroupNote}>
{group.files.length} КП — сравниваются отдельными колонками; лишний уберите корзиной
</span>
</div>
)}
<ul className={styles.noOfferList}>
{group.files.map(file => (
<UploadedKpRow key={file.spId} file={file} showSupplierName={!isMultiple} {...rowProps} />
))}
</ul>
</li>
);
})}
</ul>
</section>
);
@@ -0,0 +1,63 @@
import * as React from 'react';
import { IconButton } from '@tnsu/ui-kit';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { KpFileRef } from './kpCurrency';
import styles from './Step2CommercialOffers.module.scss';
interface Props {
file: CTFormData['uploadedKpFiles'][number];
// Название поставщика в строке — когда у него один КП (в группе из нескольких оно в заголовке группы).
showSupplierName: boolean;
service: CtService;
currencyOptions: string[];
getCurrency: (file: KpFileRef) => string;
onCurrencyChange: (spId: number, currencyCode: string) => void;
onRemove: (spId: number) => void;
disabled?: boolean;
}
// Строка «Загруженные КП»: ссылка на файл, валюта КП и «убрать из сравнения».
export const UploadedKpRow: React.FC<Props> = ({
file,
showSupplierName,
service,
currencyOptions,
getCurrency,
onCurrencyChange,
onRemove,
disabled,
}) => (
<li className={styles.noOfferItem}>
{showSupplierName && file.supplierName && <span className={styles.noOfferName}>{file.supplierName}</span>}
<a
href={service.getKpFileUrl(file.name)}
target="_blank"
rel="noopener noreferrer"
className={styles.kpFileLink}
>
{file.name}
</a>
{disabled ? (
<span className={styles.kpCurrencyCode}>{getCurrency(file)}</span>
) : (
<>
<select
className={styles.kpCurrencySelect}
value={getCurrency(file)}
onChange={e => onCurrencyChange(file.spId, e.target.value)}
// Валюта хранится на колонке поставщика — без него менять нечего.
disabled={!file.supplierName}
aria-label={`Валюта КП ${file.name}`}
>
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<IconButton
icon="trash"
title="Убрать КП из сравнения"
onClick={() => onRemove(file.spId)}
/>
</>
)}
</li>
);
@@ -0,0 +1,47 @@
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
const USD: CurrencyRate = { code: 'USD', name: 'Доллар США', rateToKzt: 440 };
const KZT_COLUMN: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: 'ТОО «Арлан»', hasOffer: true, currency: 'KZT' };
const USD_COLUMN: Supplier = { id: '263_290', spId: 263, kpFileSpId: 290, name: 'ТОО «Арлан»', hasOffer: true, currency: 'KZT' };
const LEGACY: Supplier = { id: '264', spId: 264, name: 'ТОО «Кала»', hasOffer: true, currency: 'KZT' };
function makeOffer(supplierId: string): SupplierOffer {
return {
id: '', supplierId, positionId: 'p1', pricePerUnit: 10, quantity: 2, currencyCode: 'KZT',
pricePerUnitKzt: 10, totalPriceKzt: 20, totalWithExtraKzt: 20,
};
}
describe('applySupplierCurrency', () => {
// Два КП одного поставщика: смена валюты одного файла не должна пересчитать цены другого.
it('меняет валюту только у колонки этого файла КП', () => {
const { suppliers, offers } = applySupplierCurrency(
[KZT_COLUMN, USD_COLUMN],
[makeOffer('263_277'), makeOffer('263_290')],
{ spId: 290, supplierName: 'ТОО «Арлан»' },
'USD',
[USD],
);
expect(suppliers.map(s => s.currency)).toEqual(['KZT', 'USD']);
expect(offers[0].pricePerUnitKzt).toBe(10);
expect(offers[1]).toMatchObject({ currencyCode: 'USD', pricePerUnitKzt: 4400, totalPriceKzt: 8800 });
});
it('старая колонка без файла находится по имени поставщика', () => {
const { suppliers } = applySupplierCurrency([LEGACY], [], { spId: 278, supplierName: 'ТОО «Кала»' }, 'USD', [USD]);
expect(suppliers[0].currency).toBe('USD');
});
});
describe('getSupplierCurrency', () => {
it('валюта колонки этого файла КП', () => {
const columns = [KZT_COLUMN, { ...USD_COLUMN, currency: 'USD' }];
expect(getSupplierCurrency(columns, { spId: 290, supplierName: 'ТОО «Арлан»' })).toBe('USD');
expect(getSupplierCurrency(columns, { spId: 277, supplierName: 'ТОО «Арлан»' })).toBe('KZT');
});
});
@@ -0,0 +1,49 @@
import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
const BASE_CURRENCY = 'KZT';
// Файл из списка «Загруженные КП», чью валюту меняют/читают.
export interface KpFileRef {
spId: number;
supplierName?: string;
}
// Колонки этого файла КП. У поставщика может быть несколько КП (напр. KZT и USD) — валюта у каждой
// колонки своя. Старые колонки без файла (kpFileSpId) ищем по имени поставщика, как раньше.
function findFileColumns(suppliers: Supplier[], file: KpFileRef): Supplier[] {
const byFile = suppliers.filter(s => s.hasOffer && s.kpFileSpId === file.spId);
if (byFile.length > 0) return byFile;
return suppliers.filter(s => s.hasOffer && s.kpFileSpId === undefined && s.name === file.supplierName);
}
// Валюта хранится на колонке поставщика (Шаг 3 читает supplier.currency), а офферы несут цену в валюте КП
// и её пересчёт в тенге. При смене валюты уже загруженного КП обновляем и то и другое, чтобы
// таблица сравнения и сохранение офферов в SP (valutaId) видели одну и ту же валюту.
export function applySupplierCurrency(
suppliers: Supplier[],
offers: SupplierOffer[],
file: KpFileRef,
currencyCode: string,
currencyRates: CurrencyRate[],
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
const columnIds = new Set(findFileColumns(suppliers, file).map(s => s.id));
const rate = currencyCode === BASE_CURRENCY
? 1
: (currencyRates.find(r => r.code === currencyCode)?.rateToKzt ?? 1);
const nextSuppliers = suppliers.map(s =>
columnIds.has(s.id) ? { ...s, currency: currencyCode } : s,
);
const nextOffers = offers.map(o => {
if (!columnIds.has(o.supplierId)) return o;
const pricePerUnitKzt = o.pricePerUnit * rate;
const totalPriceKzt = pricePerUnitKzt * o.quantity;
return { ...o, currencyCode, pricePerUnitKzt, totalPriceKzt, totalWithExtraKzt: totalPriceKzt };
});
return { suppliers: nextSuppliers, offers: nextOffers };
}
export function getSupplierCurrency(suppliers: Supplier[], file: KpFileRef): string {
return findFileColumns(suppliers, file)[0]?.currency ?? BASE_CURRENCY;
}
@@ -0,0 +1,74 @@
import { buildKpOffersToSave } from './kpOfferRecords';
import { FlowPositionResult } from './kpTypes';
import { Position } from '../types/domain';
function makePosition(overrides: Partial<Position>): Position {
return {
id: 'pos_1',
spId: 1543,
requestId: 'req_1',
requestNumber: '051/26-08/0015',
materialName: 'Доска пробковая',
disciplineCode: '',
smrType: '',
unit: 'шт',
agskCode: '',
uniqueCode: '',
quantity: 4,
positionType: 'ТМЦ',
deadline: '',
...overrides,
};
}
const POSITIONS = [
makePosition({}),
makePosition({ id: 'pos_2', spId: 1544, materialName: 'Флипчарт', quantity: 3 }),
];
const RESULTS: FlowPositionResult[] = [
{ positionId: 'pos_1', positionSpId: 1543, flowResult: { position: 'Доска в раме', unit: 'шт.', quantity: 4, price: 28500, cost: 114000 } },
{ positionId: 'pos_2', positionSpId: 1544, flowResult: { price: 98000, cost: 294000 } },
];
describe('buildKpOffersToSave', () => {
// Syntex отключён: поставщика называет поток, и узнаём его только после разбора всех позиций.
// Раньше записи строились внутри цикла и без поставщика от Syntex не строились вовсе.
it('строит записи по поставщику, определённому после разбора', () => {
const offers = buildKpOffersToSave(RESULTS, POSITIONS, 264, 283);
expect(offers).toHaveLength(2);
expect(offers[0]).toEqual({
positionSpId: 1543,
supplierSpId: 264,
offerSpId: 283,
supplierPosition: 'Доска в раме',
unit: 'шт.',
quantity: 4,
price: 28500,
summa: 114000,
});
});
it('пустые поля потока берёт из позиции СТ', () => {
const offers = buildKpOffersToSave(RESULTS, POSITIONS, 264, 283);
expect(offers[1]).toMatchObject({ supplierPosition: 'Флипчарт', unit: 'шт', quantity: 3, price: 98000 });
});
it('без поставщика записей нет', () => {
expect(buildKpOffersToSave(RESULTS, POSITIONS, undefined, 283)).toEqual([]);
});
it('пропускает позицию без SP Id', () => {
const results: FlowPositionResult[] = [{ positionId: 'pos_x', flowResult: { price: 1 } }];
expect(buildKpOffersToSave(results, POSITIONS, 264, 283)).toEqual([]);
});
it('берёт SP Id из позиции, если в результате его нет', () => {
const results: FlowPositionResult[] = [{ positionId: 'pos_2', flowResult: { price: 5 } }];
expect(buildKpOffersToSave(results, POSITIONS, 264, 283)[0].positionSpId).toBe(1544);
});
});
@@ -0,0 +1,45 @@
import { FlowPositionResult } from './kpTypes';
import { Position } from '../types/domain';
// Запись «Оценка поставщиков» по одной позиции из разобранного КП (вход CtService.saveKpOffer).
export interface KpOfferToSave {
positionSpId: number;
supplierSpId: number;
offerSpId: number;
supplierPosition: string;
unit: string;
quantity: number;
price: number;
summa: number;
}
// Строит записи после разбора всего КП. Поставщик может прийти из ответа потока (Syntex отключён),
// а его узнаём только когда разобраны все позиции — поэтому записи не строим внутри цикла по позициям.
// Пустые поля потока добираем из позиции СТ.
export function buildKpOffersToSave(
results: FlowPositionResult[],
positions: Position[],
supplierSpId: number | undefined,
fileSpId: number,
): KpOfferToSave[] {
if (!supplierSpId) return [];
const offers: KpOfferToSave[] = [];
for (const { positionId, positionSpId, flowResult } of results) {
const position = positions.find(p => p.id === positionId);
const spId = positionSpId ?? position?.spId;
if (!spId) continue;
offers.push({
positionSpId: spId,
supplierSpId,
offerSpId: fileSpId,
supplierPosition: flowResult.position ?? position?.materialName ?? '',
unit: flowResult.unit ?? position?.unit ?? '',
quantity: flowResult.quantity ?? position?.quantity ?? 0,
price: flowResult.price ?? 0,
summa: flowResult.cost ?? 0,
});
}
return offers;
}
@@ -0,0 +1,79 @@
import { groupUploadedKpFiles, removeKpFileFromComparison } from './kpRemoval';
import { Supplier, SupplierOffer } from '../types/domain';
const ARLAN = 'ТОО «Арлан Офис Снаб»';
const KALA = 'ТОО «Кала Саплай»';
const ARLAN_KZT: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: ARLAN, hasOffer: true };
const ARLAN_USD: Supplier = { id: '263_285', spId: 263, kpFileSpId: 285, name: ARLAN, hasOffer: true };
const KALA_KZT: Supplier = { id: '264_278', spId: 264, kpFileSpId: 278, name: KALA, hasOffer: true };
const UPLOADED = [
{ spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf', supplierName: ARLAN },
{ spId: 278, name: 'KP_02_Qala_Supply_KZT.pdf', supplierName: KALA },
{ spId: 285, name: 'KP_01_Arlan_Office_USD.pdf', supplierName: ARLAN },
];
function makeOffer(supplierId: string): SupplierOffer {
return {
id: '', supplierId, positionId: 'p1', pricePerUnit: 1, quantity: 1, currencyCode: 'KZT',
pricePerUnitKzt: 1, totalPriceKzt: 1, totalWithExtraKzt: 1,
};
}
describe('removeKpFileFromComparison', () => {
// Загрузили исправленный КП Арлан — старый убирают корзиной: уходит только его колонка.
it('убирает колонку, предложения и строку списка только этого КП', () => {
const result = removeKpFileFromComparison(
{
suppliers: [ARLAN_KZT, KALA_KZT, ARLAN_USD],
offers: [makeOffer('263_277'), makeOffer('264_278'), makeOffer('263_285')],
uploadedKpFiles: UPLOADED,
},
UPLOADED[0],
);
expect(result.suppliers.map(s => s.id)).toEqual(['264_278', '263_285']);
expect(result.offers.map(o => o.supplierId)).toEqual(['264_278', '263_285']);
expect(result.uploadedKpFiles.map(f => f.spId)).toEqual([278, 285]);
});
it('старую колонку без файла убирает, когда это последний КП поставщика', () => {
const legacy: Supplier = { id: '264', spId: 264, name: KALA, hasOffer: true };
const result = removeKpFileFromComparison(
{ suppliers: [legacy], offers: [makeOffer('264')], uploadedKpFiles: [UPLOADED[1]] },
UPLOADED[1],
);
expect(result.suppliers).toEqual([]);
expect(result.offers).toEqual([]);
});
it('старую колонку без файла оставляет, пока у поставщика есть другие КП', () => {
const legacy: Supplier = { id: '263', spId: 263, name: ARLAN, hasOffer: true };
const result = removeKpFileFromComparison(
{ suppliers: [legacy], offers: [], uploadedKpFiles: [UPLOADED[0], UPLOADED[2]] },
UPLOADED[0],
);
expect(result.suppliers).toEqual([legacy]);
});
});
describe('groupUploadedKpFiles', () => {
it('группирует КП по поставщику в порядке появления', () => {
const groups = groupUploadedKpFiles(UPLOADED);
expect(groups.map(g => ({ supplierName: g.supplierName, spIds: g.files.map(f => f.spId) }))).toEqual([
{ supplierName: ARLAN, spIds: [277, 285] },
{ supplierName: KALA, spIds: [278] },
]);
});
it('файл без поставщика — отдельной группой', () => {
const groups = groupUploadedKpFiles([{ spId: 1, name: 'a.pdf' }, { spId: 2, name: 'b.pdf' }]);
expect(groups).toHaveLength(2);
expect(groups[0].supplierName).toBeUndefined();
});
});
@@ -0,0 +1,47 @@
import { CTFormData } from '../hooks/useCtForm';
type UploadedKpFile = CTFormData['uploadedKpFiles'][number];
type ComparisonData = Pick<CTFormData, 'suppliers' | 'offers' | 'uploadedKpFiles'>;
// Убрать КП из сравнения (корзина в «Загруженные КП»): его колонку, предложения и строку списка.
// Так заменяют исправленный КП — загружают новый, старый убирают. Сам файл остаётся в библиотеке.
// Старая колонка без файла (kpFileSpId) общая на все КП поставщика — убираем её, только когда
// у поставщика не остаётся других КП.
export function removeKpFileFromComparison(data: ComparisonData, file: UploadedKpFile): ComparisonData {
const remainingFiles = data.uploadedKpFiles.filter(f => f.spId !== file.spId);
const hasFileColumn = data.suppliers.some(s => s.kpFileSpId === file.spId);
const supplierHasOtherFiles = remainingFiles.some(f => f.supplierName === file.supplierName);
const isRemoved = (s: CTFormData['suppliers'][number]): boolean => {
if (!s.hasOffer) return false;
if (hasFileColumn) return s.kpFileSpId === file.spId;
return s.kpFileSpId === undefined && s.name === file.supplierName && !supplierHasOtherFiles;
};
const removedColumnIds = new Set(data.suppliers.filter(isRemoved).map(s => s.id));
return {
suppliers: data.suppliers.filter(s => !removedColumnIds.has(s.id)),
offers: data.offers.filter(o => !removedColumnIds.has(o.supplierId)),
uploadedKpFiles: remainingFiles,
};
}
export interface UploadedKpGroup {
supplierName?: string;
files: UploadedKpFile[];
}
// Группы «поставщик → его КП» для списка «Загруженные КП», в порядке первого появления. Несколько КП
// одного поставщика стоят рядом — видно, что они сравниваются отдельными колонками.
export function groupUploadedKpFiles(files: UploadedKpFile[]): UploadedKpGroup[] {
const groups: UploadedKpGroup[] = [];
files.forEach(file => {
const group = file.supplierName ? groups.find(g => g.supplierName === file.supplierName) : undefined;
if (group) {
group.files.push(file);
return;
}
groups.push({ supplierName: file.supplierName, files: [file] });
});
return groups;
}
@@ -13,6 +13,7 @@ export interface KpFile {
file: File;
status: 'uploading' | 'extracting' | 'flowing' | 'done' | 'error';
spId?: number;
storedName?: string; // имя в библиотеке КП (с префиксом СТ) — по нему ссылка на файл
supplierName?: string;
supplierSpId?: number;
supplierBIN?: string;
@@ -0,0 +1,114 @@
import { buildKpSuppliersAndOffers } from './step2Persist';
import { KpFile } from './kpTypes';
import { CurrencyRate } from '../types/domain';
const USD_RATE: CurrencyRate = { code: 'USD', name: 'Доллар США', rateToKzt: 500 };
// Минимальный файл КП: обработан потоком, одна позиция. Нужные поля переопределяются в тесте.
function makeKpFile(overrides: Partial<KpFile>): KpFile {
return {
localId: 'local_1',
file: new File([''], 'kp.pdf'),
status: 'done',
spId: 100,
supplierName: 'ТОО Поставщик',
supplierSpId: 10,
flowDone: 1,
flowTotal: 1,
currencyCode: 'KZT',
flowResults: [
{ positionId: 'pos_1', flowResult: { position: 'Кабель', unit: 'м', price: 200, quantity: 5 } },
],
...overrides,
};
}
describe('buildKpSuppliersAndOffers', () => {
it('строит поставщика и оффер с суммой в тенге', () => {
const { suppliers, offers } = buildKpSuppliersAndOffers([makeKpFile({})], []);
expect(suppliers).toEqual([
{ id: '10_100', spId: 10, kpFileSpId: 100, name: 'ТОО Поставщик', hasOffer: true, currency: 'KZT' },
]);
expect(offers).toHaveLength(1);
expect(offers[0]).toMatchObject({
supplierId: '10_100',
positionId: 'pos_1',
supplierPosition: 'Кабель',
pricePerUnit: 200,
quantity: 5,
pricePerUnitKzt: 200,
totalPriceKzt: 1000,
offerId: '100',
});
});
// Два КП одного поставщика (напр. KZT и USD) — два отдельных предложения: по колонке на файл.
// Раньше брался только последний файл (5f5f0e7), и второе предложение пропадало.
it('каждый КП поставщика — отдельная колонка со своими предложениями', () => {
const kzt = makeKpFile({ localId: 'a', spId: 100 });
const usd = makeKpFile({
localId: 'b',
spId: 101,
currencyCode: 'USD',
flowResults: [{ positionId: 'pos_1', flowResult: { price: 3, quantity: 5 } }],
});
const { suppliers, offers } = buildKpSuppliersAndOffers([kzt, usd], [USD_RATE]);
expect(suppliers.map(s => ({ id: s.id, kpFileSpId: s.kpFileSpId, currency: s.currency }))).toEqual([
{ id: '10_100', kpFileSpId: 100, currency: 'KZT' },
{ id: '10_101', kpFileSpId: 101, currency: 'USD' },
]);
expect(offers.map(o => ({ supplierId: o.supplierId, offerId: o.offerId, pricePerUnit: o.pricePerUnit }))).toEqual([
{ supplierId: '10_100', offerId: '100', pricePerUnit: 200 },
{ supplierId: '10_101', offerId: '101', pricePerUnit: 3 },
]);
});
it('разных поставщиков оставляет отдельными колонками', () => {
const first = makeKpFile({ localId: 'a', supplierSpId: 10, supplierName: 'ТОО Альфа' });
const second = makeKpFile({ localId: 'b', supplierSpId: 20, supplierName: 'ТОО Бета' });
const { suppliers } = buildKpSuppliersAndOffers([first, second], []);
expect(suppliers.map(s => s.id)).toEqual(['10_100', '20_100']);
});
it('пропускает файл, у которого поток ещё не определил поставщика', () => {
const { suppliers, offers } = buildKpSuppliersAndOffers([makeKpFile({ supplierName: undefined })], []);
expect(suppliers).toEqual([]);
expect(offers).toEqual([]);
});
it('поставщик без записи в SP получает локальный id и не даёт офферов', () => {
const file = makeKpFile({ localId: 'local_7', supplierSpId: undefined });
const { suppliers, offers } = buildKpSuppliersAndOffers([file], []);
expect(suppliers[0].id).toBe('local_7');
expect(offers).toEqual([]);
});
it('пересчитывает валютный КП в тенге по курсу', () => {
const file = makeKpFile({ currencyCode: 'USD' });
const { offers } = buildKpSuppliersAndOffers([file], [USD_RATE]);
expect(offers[0]).toMatchObject({
currencyCode: 'USD',
pricePerUnit: 200,
pricePerUnitKzt: 100000,
totalPriceKzt: 500000,
});
});
it('без курса валюты берёт курс 1', () => {
const file = makeKpFile({ currencyCode: 'EUR' });
const { offers } = buildKpSuppliersAndOffers([file], [USD_RATE]);
expect(offers[0].pricePerUnitKzt).toBe(200);
});
});
@@ -1,7 +1,10 @@
import { ApprovalMember, ApprovalMemberStatus, CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
import { CurrencyRate, Supplier, SupplierOffer } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { KpFile, NoOfferEntry } from './kpTypes';
import { buildSupplierColumnId } from '../utils/supplierColumns';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
import { keepExclusions } from '../utils/excludedColumns';
export interface PersistStep2Args {
service: CtService;
@@ -9,21 +12,25 @@ export interface PersistStep2Args {
kpFiles: KpFile[];
currencyRates: CurrencyRate[];
noOfferSuppliers: NoOfferEntry[];
userDisplayName: string;
}
// Строит поставщиков с КП и их офферы из результатов потока (Syntex). Вынесено из persistStep2,
// Строит колонки поставщиков и их офферы из результатов потока. Вынесено из persistStep2,
// чтобы ту же логику использовал инкрементальный persist в useKpProcessing (поставщик должен
// доходить до Шага 3 даже без кнопки «Сохранить и продолжить»).
// Колонка — на каждый файл КП: несколько КП одного поставщика (напр. KZT и USD) — отдельные
// предложения. Раньше брался только последний файл поставщика, и второе предложение пропадало.
export function buildKpSuppliersAndOffers(
kpFiles: KpFile[],
allKpFiles: KpFile[],
currencyRates: CurrencyRate[],
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
const kpFiles = allKpFiles.filter(f => f.supplierName);
const columnIdOf = (f: KpFile): string =>
f.supplierSpId ? buildSupplierColumnId(f.supplierSpId, f.spId) : f.localId;
const suppliers: Supplier[] = kpFiles
.filter(f => f.supplierName)
.map(f => ({
id: f.supplierSpId ? String(f.supplierSpId) : f.localId,
id: columnIdOf(f),
spId: f.supplierSpId,
kpFileSpId: f.spId,
name: f.supplierName as string,
hasOffer: true,
currency: f.currencyCode,
@@ -43,7 +50,7 @@ export function buildKpSuppliersAndOffers(
const totalPriceKzt = pricePerUnitKzt * quantity;
offers.push({
id: '',
supplierId: String(f.supplierSpId),
supplierId: columnIdOf(f),
positionId: fr.positionId,
supplierPosition: fr.flowResult.position,
unit: fr.flowResult.unit,
@@ -64,46 +71,50 @@ export function buildKpSuppliersAndOffers(
// Сохранение Шага 2: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока,
// пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave.
export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFormData>> {
const { service, formData, kpFiles, currencyRates, noOfferSuppliers, userDisplayName } = args;
const { service, formData, kpFiles, currencyRates, noOfferSuppliers } = args;
let savedRates = currencyRates;
if (currencyRates.length > 0) {
savedRates = await service.saveCurrencyRates(currencyRates);
}
for (const s of noOfferSuppliers) {
await service.saveNoOfferSupplierItem(s.name, s.comment);
}
const { suppliers: newSuppliersWithOffer, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates);
const { suppliers: builtSuppliers, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates);
const newSuppliersWithOffer = keepExclusions(builtSuppliers, formData.suppliers ?? []);
const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({
id: s.localId,
name: s.name,
hasOffer: false,
comment: s.comment,
}));
// Persist offers with currency data to SP so shared links show correct currency columns.
// saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here
// before proceeding to Step 3.
if (newOffers.length > 0) {
// id колонки («поставщик_файл») в SP не пишем — туда идут SP Id поставщика и позиции.
const offersForSP = newOffers
.map(o => {
const pos = formData.selectedPositions.find(p => p.id === o.positionId);
return pos?.spId !== undefined ? { ...o, positionId: String(pos.spId) } : null;
const column = newSuppliersWithOffer.find(s => s.id === o.supplierId);
const supplierSpId = column?.spId;
// Исключённый поставщик не попадает в «Оценку поставщиков» — его строки пишет Шаг 3
// в «Исключённые предложения».
if (column?.exclusion) return null;
if (pos?.spId === undefined || supplierSpId === undefined) return null;
return { ...o, positionId: String(pos.spId), supplierId: String(supplierSpId) };
})
.filter((o): o is SupplierOffer => o !== null && !isNaN(parseInt(o.supplierId, 10)));
.filter((o): o is SupplierOffer => o !== null);
if (offersForSP.length > 0) {
await service.saveOffers(offersForSP, savedRates);
}
}
// Мержим с поставщиками из предыдущих сессий на шаге 2
// (повторная загрузка КП того же поставщика — заменяем по имени)
const newSupplierNames = new Set(newSuppliersWithOffer.map(s => s.name));
// Мержим с колонками из предыдущих сессий на шаге 2 — по id колонки (поставщик + файл КП):
// другой КП того же поставщика остаётся отдельной колонкой.
const newSupplierIds = new Set(newSuppliersWithOffer.map(s => s.id));
const mergedSuppliersWithOffer: Supplier[] = [
...(formData.suppliers ?? []).filter(s => s.hasOffer && !newSupplierNames.has(s.name)),
...(formData.suppliers ?? []).filter(s => s.hasOffer && !newSupplierIds.has(s.id)),
...newSuppliersWithOffer,
];
const mergedOffers: SupplierOffer[] = [
@@ -117,43 +128,19 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
const preservedNoOfferSuppliers = (formData.suppliers ?? [])
.filter(s => !s.hasOffer && !newNoOfferNames.has(s.name.trim()));
let approvalChain: ApprovalMember[] = [];
if (formData.ct?.id) {
await service.deleteApprovalChainByCt(formData.ct.id);
let matrix = formData.initiatorDepartment
? await service.loadApprovalMatrix(formData.initiatorCompany, formData.initiatorDepartment)
: [];
if (matrix.length === 0) {
matrix = await service.loadApprovalMatrix(formData.initiatorCompany, '');
}
if (matrix.length > 0) {
approvalChain = [
{
id: 'chain_1',
order: 1,
userId: String(service.currentUserSpId),
userName: userDisplayName,
role: 'Инициатор',
status: 'pending',
},
...matrix.map((m, idx) => ({
id: `chain_${idx + 2}`,
order: idx + 2,
userId: String(m.approverSpId),
userName: m.approverName,
role: m.post,
status: 'pending' as ApprovalMemberStatus,
canAddApprover: m.canAddApprover,
})),
];
approvalChain = await service.saveApprovalChain(approvalChain, formData.ct.id);
}
// Цепочка согласования БОЛЬШЕ не сеется черновиком на Шаге 2: в новом механизме (approval-route
// API, см. план миграции согласования) маршрут — это планируемый resolve, который Step4Approval
// резолвит «на лету» при открытии шага (CtService.getPlannedApprovalMatrix), писать в SP заранее не нужно.
// «Не получены КП» — с привязкой к СТ: по ссылке согласующий видит список под таблицей сравнения.
const allNoOfferSuppliers = [...preservedNoOfferSuppliers, ...suppliersWithoutOffer];
if (formData.ct) {
await service.syncNoOfferSuppliers(formData.ct.id, getNoOfferSuppliers(allNoOfferSuppliers));
}
return {
currencyRates: savedRates,
suppliers: [...mergedSuppliersWithOffer, ...preservedNoOfferSuppliers, ...suppliersWithoutOffer],
suppliers: [...mergedSuppliersWithOffer, ...allNoOfferSuppliers],
offers: mergedOffers,
approvalChain,
};
}
@@ -3,6 +3,9 @@ import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes';
import { buildKpSuppliersAndOffers } from './step2Persist';
import { buildKpOffersToSave } from './kpOfferRecords';
import { buildKpStorageName } from '../utils/kpFileName';
import { keepExclusions } from '../utils/excludedColumns';
let localIdCounter = 0;
@@ -13,6 +16,8 @@ export interface UseKpProcessingResult {
handleFilesAdded: (files: File[]) => Promise<void>;
handleFileRemove: (localId: string) => void;
handleCurrencyChange: (localId: string, currency: string) => void;
// Убрать файл из очереди загрузки по SP Id (КП убрали из сравнения): иначе он снова соберётся в колонку.
removeFileBySpId: (fileSpId: number) => void;
isProcessing: boolean;
}
@@ -48,16 +53,17 @@ export function useKpProcessing(
const currentUploaded = fd.uploadedKpFiles ?? [];
const newEntries = doneFiles
.filter(f => !currentUploaded.some(u => u.spId === f.spId))
.map(f => ({ name: f.file.name, spId: f.spId as number, supplierName: f.supplierName }));
.map(f => ({ name: f.storedName ?? f.file.name, spId: f.spId as number, supplierName: f.supplierName }));
if (newEntries.length > 0) {
patch.uploadedKpFiles = [...currentUploaded, ...newEntries];
}
const { suppliers: kpSuppliers, offers: kpOffers } = buildKpSuppliersAndOffers(doneFiles, fd.currencyRates);
if (kpSuppliers.length > 0) {
// Заменяем поставщика с тем же именем (повторная загрузка КП), остальных — сохраняем.
const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.name === s.name));
patch.suppliers = [...others, ...kpSuppliers];
// Заменяем колонки этих же КП (поставщик + файл), остальные — сохраняем: другой КП того же
// поставщика — отдельное предложение, своя колонка.
const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.id === s.id));
patch.suppliers = [...others, ...keepExclusions(kpSuppliers, fd.suppliers ?? [])];
const kpSupplierIds = new Set(kpSuppliers.map(s => s.id));
const otherOffers = (fd.offers ?? []).filter(o => !kpSupplierIds.has(o.supplierId));
patch.offers = [...otherOffers, ...kpOffers];
@@ -91,20 +97,9 @@ export function useKpProcessing(
try {
const result = await service.triggerFlow(fileName, fileSpId, pos.materialName);
collectedResults.push({ positionId: pos.id, positionSpId: pos.spId, flowResult: result });
if (pos.spId && supplierSpId && result) {
await service.saveKpOffer({
positionSpId: pos.spId,
supplierSpId,
offerSpId: fileSpId,
supplierPosition: result.position ?? pos.materialName,
unit: result.unit ?? pos.unit,
quantity: result.quantity ?? pos.quantity,
price: result.price ?? 0,
summa: result.cost ?? 0,
});
}
} catch {
// individual position failure doesn't stop others
} catch (err) {
// Сбой одной позиции не останавливает остальные, но и не теряется молча.
console.warn(`[КП] поток не разобрал позицию «${pos.materialName}»:`, err);
}
if (mountedRef.current) {
setKpFiles(prev => prev.map(f =>
@@ -128,6 +123,16 @@ export function useKpProcessing(
}
}
// Предложения пишем после того, как поставщик определён: Syntex отключён, и поставщика чаще
// называет сам поток — внутри цикла по позициям его ещё нет, и записи раньше не создавались вовсе.
const offersToSave = buildKpOffersToSave(collectedResults, positions, resolvedSupplierSpId, fileSpId);
await Promise.all(offersToSave.map(offer =>
service.saveKpOffer(offer).catch(err => {
// Не критично: «Сохранить» на Шаге 2/3 перезапишет предложения позиции целиком.
console.warn(`[КП] не удалось записать предложение по позиции ${offer.positionSpId}:`, err);
})
));
// БИН поставщика (для Adata) — берём первый результат из позиций, где он вообще пришёл
const supplierBIN = collectedResults.find(r => r.flowResult.supplierBIN)?.flowResult.supplierBIN;
if (supplierBIN && resolvedSupplierSpId) {
@@ -190,13 +195,17 @@ export function useKpProcessing(
};
const handleFilesAdded = useCallback(async (files: File[]): Promise<void> => {
const ctId = formDataRef.current.ct?.id;
// В «Загруженных КП» имена хранения (с префиксом СТ), в очереди — исходные: сверяем с обоими.
const existingNames = new Set([
...kpFilesRef.current.map(f => f.file.name),
...(formDataRef.current.uploadedKpFiles ?? []).map(f => f.name),
]);
const isUploaded = (f: File): boolean =>
existingNames.has(f.name) || existingNames.has(buildKpStorageName(ctId, f.name));
const newFiles = files.filter(f => !existingNames.has(f.name));
const skipped = files.filter(f => existingNames.has(f.name));
const newFiles = files.filter(f => !isUploaded(f));
const skipped = files.filter(isUploaded);
if (skipped.length > 0) {
setDuplicateWarning(`Файл(ы) уже загружен(ы) в рамках этой СТ: ${skipped.map(f => f.name).join(', ')}`);
@@ -219,27 +228,29 @@ export function useKpProcessing(
if (mountedRef.current) setKpFiles(prev => [...prev, ...entries]);
for (const entry of entries) {
const storageName = buildKpStorageName(ctId, entry.file.name);
try {
let cachedSpId: number | undefined;
try {
const existing = await service.findExistingKpFile(entry.file.name, entry.file.size);
const existing = await service.findExistingKpFile(storageName, entry.file.size);
if (existing) cachedSpId = existing.spId;
} catch {
// lookup failed — proceed with normal upload
}
if (cachedSpId !== undefined) {
patchFile(entry.localId, { spId: cachedSpId, status: 'extracting' });
pollSyntex(entry.localId, cachedSpId, entry.file.name, 0).catch(err => {
patchFile(entry.localId, { spId: cachedSpId, storedName: storageName, status: 'extracting' });
pollSyntex(entry.localId, cachedSpId, storageName, 0).catch(err => {
patchFile(entry.localId, { status: 'error', error: (err as Error).message });
});
continue;
}
const { spId, name } = await service.uploadKpFile(entry.file);
patchFile(entry.localId, { spId, status: 'extracting' });
const { spId, name } = await service.uploadKpFile(entry.file, storageName);
patchFile(entry.localId, { spId, storedName: name, status: 'extracting' });
const isWord = /\.(doc|docx|xls|xlsx)$/i.test(entry.file.name);
const isPdf = /\.pdf$/i.test(entry.file.name);
if (isWord) {
try {
await service.extractWordText(spId);
@@ -247,6 +258,13 @@ export function useKpProcessing(
patchFile(entry.localId, { status: 'error', error: `Ошибка извлечения текста Word: ${(err as Error).message}` });
continue;
}
} else if (isPdf) {
try {
await service.extractPDFText(spId);
} catch (err) {
patchFile(entry.localId, { status: 'error', error: `Ошибка извлечения текста PDF: ${(err as Error).message}` });
continue;
}
}
pollSyntex(entry.localId, spId, name, 0).catch(err => {
@@ -262,6 +280,10 @@ export function useKpProcessing(
setKpFiles(prev => prev.filter(f => f.localId !== localId));
};
const removeFileBySpId = (fileSpId: number): void => {
setKpFiles(prev => prev.filter(f => f.spId !== fileSpId));
};
const handleCurrencyChange = (localId: string, currency: string): void => {
setKpFiles(prev => prev.map(f => f.localId === localId ? { ...f, currencyCode: currency } : f));
};
@@ -275,6 +297,7 @@ export function useKpProcessing(
handleFilesAdded,
handleFileRemove,
handleCurrencyChange,
removeFileBySpId,
isProcessing,
};
}
@@ -59,6 +59,8 @@ export function AddExtraRow({ matrix, isRestrictedView, colSpan }: Props): JSX.E
{suppliersWithOffer.map(s => (
<td key={s.id} colSpan={getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView)} />
))}
{/* Под колонкой «+ Добавить поставщика» (видна там же, где эта строка, — не в режиме просмотра). */}
<td className={styles.addSupplierFillerCell} />
</tr>
);
}
@@ -0,0 +1,69 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, Dialog, Icon, TextInput } from '@tnsu/ui-kit';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
currencyOptions: string[];
onAddManual: (name: string, currency: string) => void;
onOpenDatabase: () => void;
onClose: () => void;
}
const DEFAULT_CURRENCY = 'KZT';
// Окно кнопки «+ Добавить поставщика» в шапке таблицы: вручную (название и валюта) или из базы
// поставщиков. Добавление — те же функции, что у блока над таблицей.
export const AddSupplierDialog: React.FC<Props> = ({ currencyOptions, onAddManual, onOpenDatabase, onClose }) => {
const [name, setName] = useState('');
const [currency, setCurrency] = useState(DEFAULT_CURRENCY);
const canAdd = name.trim().length > 0;
const handleAdd = (): void => {
if (!canAdd) return;
onAddManual(name, currency);
onClose();
};
return (
<Dialog
open
title="Добавить потенциального поставщика"
subText="Поставщик, у которого нет загруженного КП: цены вносятся в таблицу вручную."
onDismiss={onClose}
footer={<Button variant="secondary" onClick={onClose}>Отмена</Button>}
>
<div className={styles.addSupplierDialogBody}>
<div className={styles.addSupplierRow}>
<div className={styles.addInput}>
<TextInput
value={name}
onChange={setName}
placeholder="Наименование поставщика"
ariaLabel="Наименование поставщика"
autoFocus
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); }}
/>
</div>
<select
className={styles.currencySelect}
value={currency}
onChange={e => setCurrency(e.target.value)}
aria-label="Валюта поставщика"
>
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<Button variant="accent" onClick={handleAdd} disabled={!canAdd}>
<Icon name="plus" size="sm" />
Добавить
</Button>
</div>
<div className={styles.addSupplierDivider}>или</div>
<Button variant="secondary" onClick={() => { onClose(); onOpenDatabase(); }}>
<Icon name="building" size="sm" />
Выбрать из базы поставщиков
</Button>
</div>
</Dialog>
);
};
@@ -0,0 +1,82 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, Icon, TextInput } from '@tnsu/ui-kit';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
name: string;
currency: string;
currencyOptions: string[];
onNameChange: (value: string) => void;
onCurrencyChange: (value: string) => void;
onAdd: () => void;
onOpenDatabase: () => void;
}
const NAME_INPUT_ID = 'step3-new-supplier-name';
// Блок «Потенциальные поставщики» над таблицей. Раньше это были поле, выпадашка и серая кнопка без
// подписи — их не замечали: заблокированная кнопка читалась как «не работает». Теперь у блока есть
// заголовок, кнопка не блокируется: с пустым названием подсказывает, что ввести.
export const AddSupplierToolbar: React.FC<Props> = ({
name, currency, currencyOptions, onNameChange, onCurrencyChange, onAdd, onOpenDatabase,
}) => {
const [isNameMissing, setIsNameMissing] = useState(false);
const handleAdd = (): void => {
if (!name.trim()) {
setIsNameMissing(true);
// Поле — в ui-kit, ref он не отдаёт; id задаём сами.
document.getElementById(NAME_INPUT_ID)?.focus();
return;
}
setIsNameMissing(false);
onAdd();
};
const handleNameChange = (value: string): void => {
if (value.trim()) setIsNameMissing(false);
onNameChange(value);
};
return (
<section className={styles.addSupplierBlock}>
<div className={styles.addSupplierHeading}>
<h4 className={styles.addSupplierTitle}>Потенциальные поставщики</h4>
<span className={styles.addSupplierHint}>
Добавьте поставщика, у которого нет загруженного КП, — вручную или из базы поставщиков
</span>
</div>
<div className={styles.addSupplierRow}>
<div className={styles.addInput}>
<TextInput
id={NAME_INPUT_ID}
placeholder="Наименование поставщика"
value={name}
onChange={handleNameChange}
ariaLabel="Наименование нового потенциального поставщика"
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); }}
/>
</div>
<select
className={styles.currencySelect}
value={currency}
onChange={e => onCurrencyChange(e.target.value)}
aria-label="Валюта нового потенциального поставщика"
>
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<Button variant="accent" onClick={handleAdd}>
<Icon name="plus" size="sm" />
Добавить
</Button>
<span className={styles.addSupplierOr}>или</span>
<Button variant="secondary" onClick={onOpenDatabase}>
<Icon name="building" size="sm" />
Из базы поставщиков
</Button>
</div>
{isNameMissing && <span className={styles.addSupplierError}>Введите наименование поставщика</span>}
</section>
);
};
@@ -7,6 +7,8 @@ import { AddExtraRow } from './AddExtraRow';
import { SUPPLIER_STATUS_OPTIONS, getSubCols, formatKzt } from './matrixHelpers';
import { UseAdataCheckResult } from './useAdataCheck';
import { AdataIndicator } from './AdataIndicator';
import { ExcludeSupplierDialog } from './ExcludeSupplierDialog';
import { toggleWinner } from './step3Validation';
import styles from './Step3ComparisonTable.module.scss';
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
@@ -17,19 +19,32 @@ interface Props {
isRestrictedView: boolean;
adata: UseAdataCheckResult;
recommendedId?: string; // победитель формулы 80% цена / 20% условия оплаты (useRecommendedPick)
// Кнопка «+ Добавить поставщика» в последней колонке шапки.
onAddSupplierClick: () => void;
}
// Таблица сравнения целиком: шапка (фикс-колонки + заголовки поставщиков) + строки позиций
// (MatrixRowView/ExtraPositionRow) + итоговые строки + строка добавления (AddExtraRow).
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adata, recommendedId }: Props): JSX.Element {
export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adata, recommendedId, onAddSupplierClick }: Props): JSX.Element {
const {
isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId,
supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers,
setSelectedSupplierId, handleDeleteSupplier, handleStatusChange, handleNameColResizeMouseDown,
setSelectedSupplierId, handleExcludeSupplier, handleStatusChange, handleNameColResizeMouseDown,
} = matrix;
const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0);
// Колонка, для которой открыто окно «Исключить из сравнения».
const [excludingSupplierId, setExcludingSupplierId] = React.useState<string | undefined>(undefined);
const excludingSupplier = suppliersWithOffer.find(s => s.id === excludingSupplierId);
// Как в проверке перед сохранением (getStep3SaveError): выбор на исключённом — не победитель.
const hasWinner = suppliersWithOffer.some(s => s.id === selectedSupplierId);
// Последняя колонка «+ Добавить поставщика» — там, где ищут, как добавить колонку. Во всех строках
// под ней пустая ячейка (addSupplierFillerCell), иначе строки таблицы были бы разной длины.
const showAddColumn = !isReadOnly;
const totalColSpan = FIXED_COLS
+ suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0)
+ (showAddColumn ? 1 : 0);
// Высота row1 (шапка поставщика) переменная (теги валют, выбор поставщика, состояние Adata),
// а row2 прибит sticky сразу под ней. Измеряем реальную высоту и отдаём в CSS-переменную,
@@ -78,9 +93,13 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
<span className={styles.supplierName}>{s.name}</span>
{isRec && <span className={styles.recommendedBadge}>Рекомендуется</span>}
</div>
{matrix.columnLabels[s.id] && (
<div className={styles.supplierFileLabel} title={matrix.columnLabels[s.id]}>
{matrix.columnLabels[s.id]}
</div>
)}
<div className={styles.supplierMetaRow}>
<AdataIndicator supplier={s} state={adata.adataMap[s.id]} onCheck={() => void adata.checkSupplier(s.id)} />
{s.isNoOffer && <span className={styles.noOfferTag}>Без КП</span>}
{currency !== 'KZT' && <span className={styles.currencyTag}>{currency}</span>}
</div>
<div className={styles.supplierControlsRow}>
@@ -103,16 +122,22 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
{!isReadOnly ? (
<>
<button
className={[styles.selectSupplierBtn, isSel ? styles.selectSupplierBtnActive : ''].filter(Boolean).join(' ')}
onClick={() => setSelectedSupplierId(s.id)}
className={[
styles.selectSupplierBtn,
isSel ? styles.selectSupplierBtnActive : '',
// Победитель не выбран — красная рамка у всех «Выбрать»: без него дальше Шага 3 не пускает.
!hasWinner ? styles.selectSupplierBtnRequired : '',
].filter(Boolean).join(' ')}
onClick={() => setSelectedSupplierId(toggleWinner(selectedSupplierId, s.id))}
aria-pressed={isSel}
title={isSel ? 'Снять выбор' : 'Выбрать победителем'}
>
{isSel ? 'Выбран' : 'Выбрать'}
</button>
<button
className={styles.deleteSupplierBtn}
onClick={() => handleDeleteSupplier(s.id, s.isExtraSupplier)}
title="Удалить потенциального поставщика"
onClick={() => setExcludingSupplierId(s.id)}
title="Исключить из сравнения"
>×</button>
</>
) : (
@@ -124,6 +149,13 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</th>
);
})}
{showAddColumn && (
<th rowSpan={2} className={styles.addSupplierHeaderCell}>
<button className={styles.addSupplierHeaderBtn} onClick={onAddSupplierClick} title="Добавить потенциального поставщика">
+ Добавить поставщика
</button>
</th>
)}
</tr>
{/* Row 2: sub-headers — 4 cols for KZT, 6 cols for foreign currency */}
@@ -188,6 +220,7 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</React.Fragment>
);
})}
{showAddColumn && <td className={styles.addSupplierFillerCell} />}
</tr>
{/* Доп. расходы positions — single row each (no bundle support needed) */}
@@ -224,6 +257,7 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</React.Fragment>
);
})}
{showAddColumn && <td className={styles.addSupplierFillerCell} />}
</tr>
)}
@@ -247,12 +281,24 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</React.Fragment>
);
})}
{showAddColumn && <td className={styles.addSupplierFillerCell} />}
</tr>
{/* Inline add-position row */}
<AddExtraRow matrix={matrix} isRestrictedView={isRestrictedView} colSpan={totalColSpan} />
</tbody>
</table>
{excludingSupplier && (
<ExcludeSupplierDialog
supplierName={matrix.columnDisplayNames[excludingSupplier.id] ?? excludingSupplier.name}
onConfirm={reason => {
handleExcludeSupplier(excludingSupplier.id, reason);
setExcludingSupplierId(undefined);
}}
onCancel={() => setExcludingSupplierId(undefined)}
/>
)}
</div>
);
}
@@ -0,0 +1,33 @@
import * as React from 'react';
import styles from './Step3ComparisonTable.module.scss';
export interface CriteriaChoiceOption {
value: string;
label: string;
}
interface Props {
value: string;
options: CriteriaChoiceOption[];
onChange: (value: string) => void;
ariaLabel: string;
}
// Ячейка-выбор «Дополнительной информации» (условия поставки, сертификаты). Значение не из списка
// (напр. свободный текст от ИИ) показываем отдельным пунктом — чтобы не потерялось и его можно было сменить.
export const CriteriaChoiceCell: React.FC<Props> = ({ value, options, onChange, ariaLabel }) => {
const current = value.trim();
const isKnown = current === '' || options.some(o => o.value === current);
return (
<select
className={styles.criteriaSelect}
value={current}
onChange={e => onChange(e.target.value)}
aria-label={ariaLabel}
>
<option value="">—</option>
{!isKnown && <option value={current}>{current}</option>}
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
);
};
@@ -2,18 +2,26 @@ import * as React from 'react';
import { Button, IconButton, TextInput, TextArea, Icon } from '@tnsu/ui-kit';
import { Step3Matrix } from './useStep3Matrix';
import { CriteriaEntry } from './matrixTypes';
import { DELIVERY_TYPE_OPTIONS } from './matrixHelpers';
import { CERTIFICATE_OPTIONS, DELIVERY_TYPE_OPTIONS } from './matrixHelpers';
import { CriteriaChoiceCell, CriteriaChoiceOption } from './CriteriaChoiceCell';
import { PaymentTypeCell } from './PaymentTypeCell';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
matrix: Step3Matrix;
}
// Поля-выбор: условия поставки (Incoterms — в списке описание, храним код) и сертификаты Да/Нет.
const CHOICE_OPTIONS: Partial<Record<keyof CriteriaEntry, CriteriaChoiceOption[]>> = {
deliveryType: DELIVERY_TYPE_OPTIONS.map(o => ({ value: o.code, label: o.label })),
sertificates: CERTIFICATE_OPTIONS.map(o => ({ value: o, label: o })),
};
// Блок «Дополнительная информация» (условия оплаты/поставки/сроки/сертификаты + произвольные условия).
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function CriteriaTable({ matrix }: Props): JSX.Element | null {
const {
isReadOnly, suppliersWithOffer, criteriaMap, customCriteria, newCriteriaLabel,
isReadOnly, suppliersWithOffer, columnDisplayNames, criteriaMap, customCriteria, newCriteriaLabel,
setNewCriteriaLabel, updateCriteriaValue, updateCustomCriteriaValue,
handleAddCustomCriteria, handleRemoveCustomCriteria,
} = matrix;
@@ -38,7 +46,7 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
<tr>
<th className={styles.criteriaLabelHeader} />
{suppliersWithOffer.map(s => (
<th key={s.id} className={styles.criteriaSupplierHeader}>{s.name}</th>
<th key={s.id} className={styles.criteriaSupplierHeader}>{columnDisplayNames[s.id] ?? s.name}</th>
))}
</tr>
</thead>
@@ -54,24 +62,22 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
return (
<td key={s.id} className={styles.criteriaValue}>
{!isReadOnly ? (
row.key === 'deliveryType' ? (
(criteriaMap[s.id]?.deliveryType ?? '').trim() ? (
// Уже заполнено (в т.ч. ИИ) — показываем как текст, без селекта.
criteriaMap[s.id]?.deliveryType
) : (
// Пусто — даём выбрать условие поставки из списка.
<select
className={styles.criteriaSelect}
value=""
onChange={e => updateCriteriaValue(s.id, 'deliveryType', e.target.value)}
aria-label={`${row.label} — ${s.name}`}
>
<option value="">—</option>
{DELIVERY_TYPE_OPTIONS.map(o => (
<option key={o.code} value={o.code}>{o.label}</option>
))}
</select>
)
CHOICE_OPTIONS[row.key] ? (
// Выбор из списка — всегда, даже если поле уже заполнено (в т.ч. ИИ): раньше
// заполненное условие поставки показывалось текстом и его нельзя было сменить.
<CriteriaChoiceCell
value={criteriaMap[s.id]?.[row.key] ?? ''}
options={CHOICE_OPTIONS[row.key] ?? []}
onChange={val => updateCriteriaValue(s.id, row.key, val)}
ariaLabel={`${row.label} — ${s.name}`}
/>
) : row.key === 'paymentType' ? (
<PaymentTypeCell
value={criteriaMap[s.id]?.paymentType ?? ''}
onChange={val => updateCriteriaValue(s.id, 'paymentType', val)}
ariaLabel={`${row.label} — ${s.name}`}
isRequiredEmpty={Boolean(row.required) && isEmpty}
/>
) : (
<div className={[styles.criteriaInput, row.required && isEmpty ? styles.criteriaInputRequired : ''].filter(Boolean).join(' ')}>
<TextArea
@@ -0,0 +1,43 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, Dialog, TextArea } from '@tnsu/ui-kit';
interface Props {
supplierName: string;
onConfirm: (reason: string) => void;
onCancel: () => void;
}
const REASON_ROWS = 3;
// Исключение поставщика из сравнения (docs/adr/0001): причина обязательна — по ней потом видно,
// почему поставщик не рассматривался.
export const ExcludeSupplierDialog: React.FC<Props> = ({ supplierName, onConfirm, onCancel }) => {
const [reason, setReason] = useState('');
const isReasonEmpty = reason.trim().length === 0;
return (
<Dialog
open
title="Исключить из сравнения"
subText={`«${supplierName}» пропадёт из таблицы и итогов. Цены сохранятся — поставщика можно будет вернуть.`}
onDismiss={onCancel}
footer={
<>
<Button variant="secondary" onClick={onCancel}>Отмена</Button>
<Button variant="primary" onClick={() => onConfirm(reason)} disabled={isReasonEmpty}>
Исключить
</Button>
</>
}
>
<TextArea
value={reason}
onChange={setReason}
rows={REASON_ROWS}
placeholder="Причина исключения, например: не прошёл проверку Adata"
ariaLabel="Причина исключения поставщика"
/>
</Dialog>
);
};
@@ -0,0 +1,37 @@
import * as React from 'react';
import { Button } from '@tnsu/ui-kit';
import styles from './Step3ComparisonTable.module.scss';
export interface ExcludedSupplierEntry {
id: string;
name: string;
reason: string;
}
interface Props {
entries: ExcludedSupplierEntry[];
// Без колбэка — только чтение (кнопки «Вернуть» нет).
onRestore?: (id: string) => void;
}
// Поставщики, исключённые из сравнения на Шаге 3 (docs/adr/0001), — под таблицей, рядом со
// списком «не предоставившие КП».
export const ExcludedSuppliersList: React.FC<Props> = ({ entries, onRestore }) => {
if (entries.length === 0) return null;
return (
<div className={styles.criteriaSection}>
<h4 className={styles.criteriaSectionTitle}>Исключены из сравнения</h4>
<ul className={styles.noOfferSupplierList}>
{entries.map(entry => (
<li key={entry.id} className={`${styles.noOfferSupplierItem} ${styles.excludedSupplierItem}`}>
<span className={styles.noOfferSupplierName}>{entry.name}</span>
<span className={styles.noOfferSupplierComment}>{entry.reason}</span>
{onRestore && (
<Button variant="secondary" onClick={() => onRestore(entry.id)}>Вернуть</Button>
)}
</li>
))}
</ul>
</div>
);
};
@@ -3,8 +3,10 @@ import { IconButton, TextInput } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain';
import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix';
import { convertQuantity, unitsEquivalent } from '../utils/unitConversion';
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey } from './matrixHelpers';
import { getUnitStatus } from '../utils/unitConversion';
import { unitStatusClass } from './unitStatusStyles';
import { isPriceMissing } from './step3Validation';
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
@@ -23,7 +25,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
isReadOnly, edits, extraPositions, translateUnits, rawValues, deliveryKm, selectedSupplierId, nameColWidth,
suppliersWithOffer, supplierCurrencies, computedOffers, minTotalByPosition,
updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt, handleDeliveryKmChange,
handleDeleteExtraPosition,
handleDeleteExtraPosition, handleExtraPositionUnitChange,
} = matrix;
const ep = extraPositions.find(e => e.id === pos.id);
@@ -45,7 +47,22 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
/>
)}
</td>
<td className={styles.unitCell}>{pos.unit}</td>
{/* Единица строки — своя (у логистики по умолчанию «рейс»: бывает «тонна», «машина», «услуга»). */}
<td className={styles.unitCell}>
{!isReadOnly ? (
<div className={styles.offerInput}>
<TextInput
value={pos.unit}
onChange={val => handleExtraPositionUnitChange(pos.id, val)}
placeholder="ед. изм."
borderless
ariaLabel={`Ед. изм. строки ${pos.materialName}`}
/>
</div>
) : (
pos.unit
)}
</td>
<td className={styles.qtyCell}>—</td>
{suppliersWithOffer.map(s => {
const key = key0(s);
@@ -60,20 +77,24 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
const priceKzt = (price ?? 0) * rate;
const priceKztEffective = item?.priceKztOverride !== undefined ? item.priceKztOverride : priceKzt;
const kztTotal = posOffers.reduce((sum, o) => sum + (o.totalWithExtraKzt ?? 0), 0);
const isMin = minTotal !== undefined && kztTotal === minTotal && kztTotal > 0;
const isMin = isMinPositionTotal(kztTotal, minTotal);
const isSel = s.id === selectedSupplierId;
const selCls = isSel ? styles.colSelected : '';
// Перевод в ед. изм. заявки (тумблер) — как в обычных позициях.
const reqUnit = pos.unit;
const supUnit = item?.unit || pos.unit;
const tRatio = translateUnits && !unitsEquivalent(supUnit, reqUnit) ? convertQuantity(1, supUnit, reqUnit) : null;
const cannotTranslate = translateUnits && !unitsEquivalent(supUnit, reqUnit) && tRatio === null;
const unitStatus = getUnitStatus(supUnit, reqUnit, translateUnits);
const tRatio = unitStatus.ratio ?? null;
const dispUnit = tRatio !== null ? reqUnit : (item?.unit || pos.unit);
const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty;
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
const showInput = !isReadOnly && !translateUnits;
// Подсветка сразу, пока заполняют: пустая цена — заливкой (сохранять не мешает),
// пустой километраж логистики — рамкой (не пускает дальше Шага 3).
const priceMissingCls = !isReadOnly && isPriceMissing(price, item?.priceKztOverride) ? styles.missingPriceCell : '';
const kmMissingCls = !isReadOnly && deliveryKm[`${pos.id}::${s.id}`] === undefined ? styles.requiredMissingBox : '';
return (
<React.Fragment key={s.id}>
@@ -91,7 +112,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
<span className={styles.computedValue}>{item?.supplierProductName || pos.materialName}</span>
)}
</td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
<td className={[styles.dataCell, unitStatusClass(unitStatus.status), selCls].filter(Boolean).join(' ')} title={unitStatus.hint}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
@@ -102,7 +123,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
/>
</div>
) : (
<span className={styles.computedValue}>{(dispUnit || '—') + (cannotTranslate ? ' *' : '')}</span>
<span className={styles.computedValue}>{dispUnit || '—'}</span>
)}
</td>
<td className={[styles.dataCell, ep?.isDelivery ? styles.deliveryQtyCell : '', selCls].filter(Boolean).join(' ')}>
@@ -114,16 +135,16 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
<TextInput
value={rawValues[`${s.id}::${pos.id}::0::quantity`] ?? (qty !== undefined ? String(qty) : '')}
onChange={val => updateEdit(s.id, pos.id, 0, 'quantity', val)}
placeholder="рейсы"
placeholder={pos.unit || 'кол-во'}
borderless
ariaLabel={`Кол-во рейсов ${s.name} ${pos.materialName}`}
ariaLabel={`Кол-во (${pos.unit || 'ед.'}) ${s.name} ${pos.materialName}`}
/>
</div>
) : (
<span className={styles.computedValue}>{dispQty !== undefined ? formatQty(dispQty) : '—'}</span>
)}
</div>
<div className={styles.deliveryKmBox}>
<div className={[styles.deliveryKmBox, kmMissingCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
@@ -159,7 +180,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
</td>
{isForeign && (
<>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
<td className={[styles.dataCell, priceMissingCls, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
@@ -179,7 +200,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
</td>
</>
)}
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
<td className={[styles.dataCell, priceMissingCls, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
@@ -213,6 +234,8 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
</React.Fragment>
);
})}
{/* Под колонкой «+ Добавить поставщика». */}
{!isReadOnly && <td className={styles.addSupplierFillerCell} />}
</tr>
);
}
@@ -1,168 +1,64 @@
import * as React from 'react';
import { Button, Alert, Spinner, Dialog, TextArea, TextInput } from '@tnsu/ui-kit';
import { ApprovalMemberStatus } from '../types/domain';
import { useStep3Approval } from './useStep3Approval';
import styles from './Step3ComparisonTable.module.scss';
const APPROVAL_STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
pending: 'На согласовании',
recommends: 'Рекомендую',
approved: 'Согласовано',
rejected: 'Отклонено',
skipped: 'Пропущено',
};
const APPROVAL_STATUS_STYLE: Record<ApprovalMemberStatus, { bg: string; color: string }> = {
pending: { bg: '#f3f2f1', color: '#605e5c' },
recommends: { bg: '#dff6dd', color: '#107c10' },
approved: { bg: '#dff6dd', color: '#107c10' },
rejected: { bg: '#fde7e9', color: '#a4262c' },
skipped: { bg: '#edebe9', color: '#a19f9d' },
};
function formatApprovalDate(iso: string): string {
return new Date(iso).toLocaleString('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
import { useMemo, useState } from 'react';
import { CTStatus } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { ApprovalMatrix, DecisionPanel, IMatrixRow, useMatrixEditor } from '../../../../shared/approval';
interface Props {
approval: ReturnType<typeof useStep3Approval>;
isNotificationLink: boolean;
isReadOnly: boolean;
service: CtService;
formData: CTFormData;
onSave: (partial: Partial<CTFormData>) => void;
// Режим просмотра (getCtAccess): согласующий, автор СТ на согласовании, посторонний.
isReview: boolean;
}
// Инлайн-цепочка согласования Шага 3 + диалоги (решение/делегат) + баннер уведомлений.
// Показывается только по Teams-ссылке (?ctid) в read-only. Логика — в useStep3Approval.
export const InlineApprovalChain: React.FC<Props> = ({ approval: a, isNotificationLink, isReadOnly }) => {
const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
Отклонено: 'rejected',
Согласовано: 'approved',
'На согласовании': 'on_approval',
'На доработке': 'rework',
};
// Инлайн-цепочка согласования Шага 3 в режиме просмотра: read-only маршрут + DecisionPanel своей строки
// (панель сама проверяет, чья сейчас очередь). Правка состава/отправка — это Шаг 4, здесь их нет.
export const InlineApprovalChain: React.FC<Props> = ({ service, formData, onSave, isReview }) => {
const { ct } = formData;
const ctId = ct?.id;
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadMatrix = useMemo(
() => (): Promise<IMatrixRow[]> => (ctId ? service.getApprovalMatrixForRequest(ctId) : Promise.resolve([])),
[service, ctId]
);
const editor = useMatrixEditor(loadMatrix, `${ctId ?? ''}-${decisionSeq}`, false, !!ctId && isReview, (loginName) =>
service.getJobTitle(loginName)
);
const approverService = useMemo(() => (ctId ? service.getApproverService(ctId) : undefined), [service, ctId]);
if (!isReview || !ctId || editor.loading || editor.rows.length === 0) {
return null; // цепочка ещё грузится из SP или её нет — блок не показываем
}
async function handleDecided(newStatus: string): Promise<void> {
if (!ct) return;
onSave({ ct: { ...ct, status: RU_STATUS_TO_CT_STATUS[newStatus] || ct.status } });
setDecisionSeq((n) => n + 1);
await service.notifyActiveApprover(ctId as string).catch(() => undefined);
}
return (
<>
{a.notifyWarning && <Alert variant="warn">{a.notifyWarning}</Alert>}
{/* Цепочка согласования — только по Teams-ссылке (ctid); обычный reload → согласование на Шаге 4 */}
{isNotificationLink && isReadOnly && a.approvalChain.length > 0 && (
<div className={styles.approvalChainSection}>
<h4 className={styles.approvalChainTitle}>Цепочка согласования</h4>
<ol className={styles.approvalChainList}>
{a.approvalChain.map((member) => {
const st = APPROVAL_STATUS_STYLE[member.status];
return (
<li key={member.id} className={styles.approvalChainRow}>
<span className={styles.approvalChainOrder}>{member.order}</span>
<span className={styles.approvalChainName}>{member.userName}</span>
{member.role && <span className={styles.approvalChainRole}>{member.role}</span>}
<span className={styles.approvalChainStatusBadge} style={{ background: st.bg, color: st.color }}>
{APPROVAL_STATUS_LABELS[member.status]}
</span>
{member.comment && <span className={styles.approvalChainComment}>«{member.comment}»</span>}
{member.actionDate && <span className={styles.approvalChainDate}>{formatApprovalDate(member.actionDate)}</span>}
{a.isOnApproval && a.currentUserMember?.id === member.id && (
<div className={styles.approvalChainActions}>
{member.canAddApprover && (
<Button variant="secondary" onClick={() => a.openDelegate(member)}>
Добавить делегата
</Button>
)}
<Button variant="primary" onClick={() => a.openApprove(member)}>
Согласовать
</Button>
<Button variant="danger" onClick={() => a.openReject(member)}>
Отклонить
</Button>
</div>
)}
</li>
);
})}
</ol>
</div>
)}
{a.delegateDialog && (
<Dialog
open
title="Добавить делегата"
subText="Выбранный пользователь будет вставлен перед вами в цепочку согласования."
onDismiss={a.closeDelegate}
footer={
<>
<Button variant="secondary" onClick={a.closeDelegate} disabled={a.isDelegateSubmitting}>
Отмена
</Button>
<Button
variant="primary"
onClick={a.handleAddDelegate}
disabled={a.isDelegateSubmitting || !a.delegateDialog.selected}
>
{a.isDelegateSubmitting ? <Spinner size="small" /> : 'Добавить'}
</Button>
</>
}
>
<div className={styles.searchBox}>
<TextInput
placeholder="Поиск делегата (имя)…"
ariaLabel="Поиск делегата"
value={a.delegateDialog.query}
onChange={a.setDelegateQuery}
/>
{a.delegateDialog.suggestions.length > 0 && (
<ul className={styles.suggestions}>
{a.delegateDialog.suggestions.map((s) => (
<li key={s.spId} className={styles.suggestion} onClick={() => a.selectDelegate(s)}>
<span className={styles.suggestionName}>{s.displayName}</span>
<span className={styles.suggestionEmail}>{s.email}</span>
</li>
))}
</ul>
)}
</div>
{a.delegateError && (
<div className={styles.approvalDialogError}>
<Alert variant="err">{a.delegateError}</Alert>
</div>
)}
</Dialog>
)}
{a.actionDialog && (
<Dialog
open
title={a.actionDialog.action === 'approve' ? 'Согласование' : 'Отклонение'}
subText={
a.actionDialog.action === 'approve'
? 'Вы подтверждаете согласование СТ? Комментарий необязателен.'
: 'Укажите причину отклонения (обязательно).'
}
onDismiss={a.closeAction}
footer={
<>
<Button variant="secondary" onClick={a.closeAction} disabled={a.isApprovalSubmitting}>
Отмена
</Button>
<Button
variant={a.actionDialog.action === 'approve' ? 'primary' : 'danger'}
onClick={a.handleApprovalConfirm}
disabled={a.isApprovalSubmitting || (a.actionDialog.action === 'reject' && !a.actionDialog.comment.trim())}
>
{a.isApprovalSubmitting ? <Spinner size="small" /> : a.actionDialog.action === 'approve' ? 'Согласовать' : 'Отклонить'}
</Button>
</>
}
>
<TextArea
value={a.actionDialog.comment}
onChange={a.setActionComment}
rows={3}
ariaLabel="Комментарий"
placeholder={a.actionDialog.action === 'reject' ? 'Укажите причину отклонения...' : 'Комментарий (необязательно)...'}
/>
{a.approvalError && (
<div className={styles.approvalDialogError}>
<Alert variant="err">{a.approvalError}</Alert>
</div>
)}
</Dialog>
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
{approverService && (
<DecisionPanel
service={approverService}
requestId={parseInt(ctId, 10)}
onDecided={handleDecided}
search={(q) => service.searchUsers(q)}
onDelegated={() => setDecisionSeq((n) => n + 1)}
allowRework
/>
)}
</>
);
@@ -0,0 +1,30 @@
import * as React from 'react';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
files: CTFormData['uploadedKpFiles'];
service: CtService;
}
// Ссылки на файлы КП под таблицей сравнения. Согласующий по ссылке из Teams попадает сразу на шаг 3,
// а список КП раньше был только на шаге 2 — открыть КП при согласовании было негде.
export const KpFilesList: React.FC<Props> = ({ files, service }) => {
if (files.length === 0) return null;
return (
<div className={styles.criteriaSection}>
<h4 className={styles.criteriaSectionTitle}>Коммерческие предложения</h4>
<ul className={styles.noOfferSupplierList}>
{files.map(file => (
<li key={file.spId} className={styles.noOfferSupplierItem}>
{file.supplierName && <span className={styles.noOfferSupplierName}>{file.supplierName}</span>}
<a href={service.getKpFileUrl(file.name)} target="_blank" rel="noopener noreferrer">
{file.name}
</a>
</li>
))}
</ul>
</div>
);
};
@@ -3,8 +3,11 @@ import { TextInput, Icon } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain';
import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix';
import { convertQuantity, unitsEquivalent } from '../utils/unitConversion';
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey } from './matrixHelpers';
import { getUnitStatus } from '../utils/unitConversion';
import { parseNumberInput } from '../utils/parseNumberInput';
import { unitStatusClass } from './unitStatusStyles';
import { isPriceMissing } from './step3Validation';
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
@@ -20,7 +23,7 @@ interface Props {
export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrictedView }: Props): JSX.Element {
const {
isReadOnly, edits, translateUnits, rawValues, posQtyOverrides, selectedSupplierId, nameColWidth,
suppliersWithOffer, supplierCurrencies, minTotalByPosition,
suppliersWithOffer, supplierCurrencies, minTotalByPosition, positionTotals,
updatePosQty, updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt,
removeBundleItem, addBundleItem,
} = matrix;
@@ -54,7 +57,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
/>
</div>
) : (
posQtyOverrides[pos.id] !== undefined ? parseFloat(posQtyOverrides[pos.id]) : pos.quantity
(posQtyOverrides[pos.id] !== undefined ? parseNumberInput(posQtyOverrides[pos.id]) : undefined) ?? pos.quantity
)}
</td>
</>
@@ -74,7 +77,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
}
const rate = isForeign ? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1) : 1;
const qty = item.quantity ?? ((s.isNoOffer || s.isExtraSupplier) && pos.quantity ? pos.quantity : undefined);
const qty = item.quantity ?? (s.isExtraSupplier && pos.quantity ? pos.quantity : undefined);
const price = item.pricePerUnit;
const totalInCurrency = (price ?? 0) * (qty ?? 0);
const priceKzt = (price ?? 0) * rate;
@@ -85,13 +88,19 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
// null — единицы совпадают или пересчёт невозможен.
const reqUnit = pos.unit;
const supUnit = item.unit || pos.unit;
const tRatio = translateUnits && !unitsEquivalent(supUnit, reqUnit) ? convertQuantity(1, supUnit, reqUnit) : null;
const cannotTranslate = translateUnits && !unitsEquivalent(supUnit, reqUnit) && tRatio === null;
const unitStatus = getUnitStatus(supUnit, reqUnit, translateUnits);
const tRatio = unitStatus.ratio ?? null;
// Подсветка: «Ед. изм.» — при любом несоответствии заявке; кол-во и цены — когда тумблер включён
// (они показаны пересчитанными или их нельзя сравнивать напрямую).
const unitCls = unitStatusClass(unitStatus.status);
const valueCls = translateUnits ? unitCls : '';
const dispUnit = tRatio !== null ? reqUnit : (item.unit || pos.unit);
const dispQty = (tRatio !== null && qty !== undefined) ? qty * tRatio : qty;
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
const showInput = !isReadOnly && !translateUnits;
// Пустая цена — подсветка сразу, пока заполняют; сохранению не мешает (см. isPriceMissing).
const priceMissingCls = !isReadOnly && isPriceMissing(price, item.priceKztOverride) ? styles.missingPriceCell : '';
return (
<React.Fragment key={s.id}>
@@ -123,7 +132,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
)}
</div>
</td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
<td className={[styles.dataCell, unitCls, selCls].filter(Boolean).join(' ')} title={unitStatus.hint}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
@@ -134,10 +143,10 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
/>
</div>
) : (
<span className={styles.computedValue}>{(dispUnit || '—') + (cannotTranslate ? ' *' : '')}</span>
<span className={styles.computedValue}>{dispUnit || '—'}</span>
)}
</td>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
<td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')} title={translateUnits ? unitStatus.hint : undefined}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
@@ -154,7 +163,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
</td>
{!isRestrictedView && isForeign && (
<>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
<td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
@@ -174,7 +183,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
</td>
</>
)}
{!isRestrictedView && <td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{!isRestrictedView && <td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
{showInput ? (
<div className={styles.offerInput}>
<TextInput
@@ -198,12 +207,19 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
</span>
)}
</td>}
{!isRestrictedView && <td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}>
{!isRestrictedView && <td className={[
styles.dataCell,
// Минимум по позиции — зелёным в каждой строке (у комплекта — в строке «Итого по позиции»).
!hasBundle && isMinPositionTotal(positionTotals.get(buildOfferKey(s.id, pos.id)), minTotal) ? styles.minPriceCell : '',
selCls,
].filter(Boolean).join(' ')}>
<span className={styles.totalValue}>{kztTotal > 0 ? formatKzt(kztTotal) : '—'}</span>
</td>}
</React.Fragment>
);
})}
{/* Под колонкой «+ Добавить поставщика»: одна ячейка на позицию, включая строки комплекта. */}
{rowIdx === 0 && !isReadOnly && <td className={styles.addSupplierFillerCell} rowSpan={posRowSpan} />}
</tr>
);
}
@@ -214,15 +230,14 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
<tr key="subtotal" className={styles.bundleSubtotalRow}>
{suppliersWithOffer.map(s => {
const key = buildOfferKey(s.id, pos.id);
const items = edits[key] ?? [];
const currency = supplierCurrencies[s.id] ?? 'KZT';
const isForeign = currency !== 'KZT';
const isSel = s.id === selectedSupplierId;
const selCls = isSel ? styles.colSelected : '';
const subCols = getSubCols(currency, isRestrictedView);
const rate = isForeign ? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1) : 1;
const totalKzt = items.reduce((sum, it) => sum + (it.pricePerUnit ?? 0) * rate * (it.quantity ?? 0), 0);
const isMin = minTotal !== undefined && totalKzt === minTotal && totalKzt > 0;
// Из того же источника, что и минимум: отдельный пересчёт (без override цены в ₸ и без кол-ва
// позиции по умолчанию) расходился на копейки, и строгое сравнение не находило минимум.
const totalKzt = positionTotals.get(key) ?? 0;
const isMin = isMinPositionTotal(totalKzt, minTotal);
return (
<React.Fragment key={s.id}>
<td colSpan={isRestrictedView ? subCols : subCols - 1} className={[styles.bundleSubtotalCell, selCls].filter(Boolean).join(' ')}>
@@ -0,0 +1,26 @@
import * as React from 'react';
import { NoOfferSupplierEntry } from '../utils/noOfferSuppliers';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
entries: NoOfferSupplierEntry[];
}
// Поставщики, не предоставившие КП (Шаг 2), — списком под таблицей сравнения. Колонками их не
// показываем: сравнивать у них нечего, а список нужен согласующим — к кому обращались.
export const NoOfferSuppliersList: React.FC<Props> = ({ entries }) => {
if (entries.length === 0) return null;
return (
<div className={styles.criteriaSection}>
<h4 className={styles.criteriaSectionTitle}>Поставщики, не предоставившие КП</h4>
<ul className={styles.noOfferSupplierList}>
{entries.map(entry => (
<li key={entry.name} className={styles.noOfferSupplierItem}>
<span className={styles.noOfferSupplierName}>{entry.name}</span>
{entry.comment && <span className={styles.noOfferSupplierComment}>{entry.comment}</span>}
</li>
))}
</ul>
</div>
);
};
@@ -0,0 +1,59 @@
import * as React from 'react';
import { useState } from 'react';
import { TextArea } from '@tnsu/ui-kit';
import { PAYMENT_TYPE_OPTIONS } from './matrixHelpers';
import { isPaymentTypeOption } from '../utils/criteria';
import styles from './Step3ComparisonTable.module.scss';
const CUSTOM_OPTION = '__custom';
interface Props {
value: string;
onChange: (value: string) => void;
ariaLabel: string;
isRequiredEmpty: boolean;
}
// Условия оплаты: готовые варианты или «Свой вариант…» с полем ввода. Значение не из списка
// (вписанное вручную или распознанное ИИ) открывается сразу как свой вариант.
export const PaymentTypeCell: React.FC<Props> = ({ value, onChange, ariaLabel, isRequiredEmpty }) => {
// Выбран «Свой вариант…» вручную (поле ещё пустое) — или значение уже не из списка. Второе считаем
// от значения, а не один раз при монтировании: условия из SP приходят уже после первого рендера.
const [isCustomChosen, setIsCustomChosen] = useState(false);
const isCustom = isCustomChosen || (value.trim() !== '' && !isPaymentTypeOption(value));
const handleSelect = (selected: string): void => {
if (selected === CUSTOM_OPTION) {
setIsCustomChosen(true);
if (isPaymentTypeOption(value)) onChange('');
return;
}
setIsCustomChosen(false);
onChange(selected);
};
return (
<div className={[styles.criteriaInput, isRequiredEmpty ? styles.criteriaInputRequired : ''].filter(Boolean).join(' ')}>
<select
className={styles.criteriaSelect}
value={isCustom ? CUSTOM_OPTION : value.trim()}
onChange={e => handleSelect(e.target.value)}
aria-label={ariaLabel}
>
<option value="">—</option>
{PAYMENT_TYPE_OPTIONS.map(o => <option key={o} value={o}>{o}</option>)}
<option value={CUSTOM_OPTION}>Свой вариант…</option>
</select>
{isCustom && (
<TextArea
value={value}
onChange={onChange}
placeholder="Впишите условия оплаты"
borderless
rows={1}
ariaLabel={`${ariaLabel} — свой вариант`}
/>
)}
</div>
);
};
@@ -170,6 +170,17 @@
color: $text;
}
// Файл КП под названием — только у поставщика с несколькими КП (несколько колонок). Длинное имя
// файла обрезаем многоточием, полное — в title.
.supplierFileLabel {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: $muted;
}
.recommendedBadge {
font-size: 10px;
font-weight: 600;
@@ -213,16 +224,6 @@
white-space: nowrap;
}
.noOfferTag {
font-size: 11px;
background: $danger-bg;
color: $danger;
padding: 1px 7px;
border-radius: 10px;
white-space: nowrap;
font-weight: 600;
}
.deleteSupplierBtn {
width: 20px;
height: 20px;
@@ -340,7 +341,14 @@
vertical-align: middle;
}
.deliveryQtyCell {
// Минимальные ширины половинок ячейки «рейсы | км» строки логистики: рейсы — 3 цифры,
// км — 4 цифры плюс подпись «км». Без нижней границы при 3+ поставщиках таблица ужимала
// колонки, и поле км схлопывалось до невидимого — теперь вместо этого таблица шире (есть скролл).
$delivery-trips-min-width: 40px;
$delivery-km-min-width: 76px;
// Двойной класс — иначе min-width: 90px из .dataCell (объявлен ниже, та же специфичность) перебивает.
.dataCell.deliveryQtyCell {
min-width: 150px;
}
@@ -354,17 +362,38 @@
.deliveryQtyBox,
.deliveryKmBox {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
// Обёртка инпута должна занимать всё место половинки: без flex-grow она ужималась
// по содержимому, и инпут получал лишь остаток рядом с подписью «км».
.offerInput {
flex: 1 1 auto;
min-width: 0;
}
}
.deliveryQtyBox {
min-width: $delivery-trips-min-width;
}
.deliveryKmBox {
min-width: $delivery-km-min-width;
border-left: 1px solid $border;
padding-left: 2px;
// Стрелки type="number" в Chrome резервируют место даже невидимыми и обрезают число
// («200» → «20(»). Км вводят с клавиатуры, стрелки не нужны.
input {
color: $muted;
appearance: textfield;
-moz-appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
@@ -492,6 +521,35 @@
color: $muted;
}
// Подсветка ед. изм. поставщика (unitStatusStyles): !important — иначе фон перебивает подсветка строки
// при наведении и выбранной колонки, и несоответствие пропадает из виду.
.dataCell.unitMismatch {
background: $warn-bg !important;
color: $warn-fg;
}
.dataCell.unitConverted {
background: $accent-bg !important;
}
.dataCell.unitImpossible {
background: $danger-bg !important;
color: $danger;
}
.translateLegendConverted,
.translateLegendImpossible {
padding: 0 4px;
border-radius: 2px;
}
.translateLegendConverted { background: $accent-bg; }
.translateLegendImpossible {
background: $danger-bg;
color: $danger;
}
.selectedTotalCell {
background: $accent-bg !important;
@@ -591,6 +649,37 @@
overflow-x: auto;
}
// «Поставщики, не предоставившие КП» под таблицей (NoOfferSuppliersList) — в рамке как «Доп. информация».
.noOfferSupplierList {
list-style: none;
margin: 0;
padding: 8px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.noOfferSupplierItem {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
font-size: 13px;
}
.noOfferSupplierName {
font-weight: 600;
color: $text;
}
.noOfferSupplierComment {
color: $muted;
}
// Кнопка «Вернуть» — по центру строки с именем и причиной.
.excludedSupplierItem {
align-items: center;
}
.criteriaTable {
border-collapse: collapse;
width: 100%;
@@ -777,20 +866,6 @@
color: $text;
}
.supplierToolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
padding: 4px 0 8px;
max-width: 30%;
> :first-child {
flex: 1 1 0;
min-width: 0;
}
}
.addRow {
display: flex;
align-items: flex-end;
@@ -1260,3 +1335,114 @@
align-items: center;
gap: 12px;
}
// ─── Незаполненное (решение от 2026-10-27: подсвечивать сразу, пока заполняют) ─────
// Красная рамка — то, что не пускает дальше Шага 3 (см. step3Validation): победитель, километраж,
// условия оплаты (.criteriaInputRequired). Бледная заливка — пустая цена: сохранять не мешает.
// !important — фон выбранной колонки (.colSelected) тоже с !important, а пустая цена в выбранной
// колонке должна оставаться заметной.
.missingPriceCell {
background: $danger-bg !important;
}
.requiredMissingBox {
box-shadow: inset 0 0 0 1px $danger;
border-radius: 2px;
}
.selectSupplierBtnRequired {
border-color: $danger;
color: $danger;
}
// ─── Добавление потенциального поставщика ───────────────────────────────────
// Блок над таблицей (AddSupplierToolbar): раньше без заголовка и с серой кнопкой — его не замечали.
.addSupplierBlock {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
border: 1px solid $accent-border;
border-radius: 4px;
background: $accent-bg;
}
.addSupplierHeading {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 12px;
}
.addSupplierTitle {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $accent-dark;
}
.addSupplierHint {
font-size: 12px;
color: $muted;
}
.addSupplierRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.addSupplierOr {
font-size: 12px;
color: $muted;
}
.addSupplierError {
font-size: 12px;
color: $danger;
}
.addSupplierDialogBody {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
> .addSupplierRow { align-self: stretch; }
}
.addSupplierDivider {
font-size: 12px;
color: $muted;
}
// Последняя колонка таблицы «+ Добавить поставщика» — там, где ищут, как добавить колонку.
.addSupplierHeaderCell {
min-width: 140px;
vertical-align: middle;
text-align: center;
background: $head-bg;
border-left: 2px dashed $accent-border !important;
}
.addSupplierHeaderBtn {
padding: 8px 12px;
border: 1px dashed $accent;
border-radius: 4px;
background: $surface;
color: $accent;
font-family: inherit;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
&:hover { background: $accent-bg; }
}
.addSupplierFillerCell {
border-left: 2px dashed $accent-border !important;
}
@@ -1,24 +1,39 @@
import * as React from 'react';
import { Button, Alert, TextInput, Toggle, Spinner, Dialog, Icon } from '@tnsu/ui-kit';
import { useMemo, useState } from 'react';
import { Button, Alert, Toggle, Spinner, Dialog, Icon } from '@tnsu/ui-kit';
import { FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { VendorDatabaseService, IVendorRecord } from '../../services/VendorDatabaseService';
import { CTFormData } from '../hooks/useCtForm';
import styles from './Step3ComparisonTable.module.scss';
import { useStep3Approval } from './useStep3Approval';
import { InlineApprovalChain } from './InlineApprovalChain';
import { useAiComment } from './useAiComment';
import { AiCommentBlock } from './AiCommentBlock';
import { useStep3Matrix } from './useStep3Matrix';
import { ComparisonMatrix } from './ComparisonMatrix';
import { CriteriaTable } from './CriteriaTable';
import { NoOfferSuppliersList } from './NoOfferSuppliersList';
import { KpFilesList } from './KpFilesList';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
import { useAdataCheck } from './useAdataCheck';
import { useRecommendedPick } from './useRecommendedPick';
import { VendorPickerDialog } from './VendorPickerDialog';
import { ExcludedSuppliersList } from './ExcludedSuppliersList';
import { AddSupplierToolbar } from './AddSupplierToolbar';
import { AddSupplierDialog } from './AddSupplierDialog';
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
import { withoutPlaceholders } from '../utils/placeholderKpFile';
// Исключённых поставщиков в режиме просмотра (согласующий, отправленная СТ) пока не показываем —
// решение от 2026-10-27 (docs/adr/0001). Данные грузятся и так: включить — поменять на true.
const SHOW_EXCLUDED_SUPPLIERS_READ_ONLY = false;
interface Props {
service: CtService;
formData: CTFormData;
userDisplayName: string;
isNotificationLink: boolean;
// Режим формы (getCtAccess): canEdit — правка таблицы; isReview — просмотр с маршрутом и кнопками решения.
canEdit: boolean;
isReview: boolean;
isRestrictedView?: boolean;
onSave: (partial: Partial<CTFormData>) => void;
onNext: () => void;
@@ -29,8 +44,8 @@ interface Props {
export const Step3ComparisonTable: React.FC<Props> = ({
service,
formData,
userDisplayName,
isNotificationLink,
canEdit,
isReview,
isRestrictedView = false,
onSave,
onNext,
@@ -42,22 +57,33 @@ export const Step3ComparisonTable: React.FC<Props> = ({
// Ядро матрицы (стейт/рефы/эффекты/мемо/хендлеры/handleSave) вынесено в хук; результат передаём
// как единый проп `matrix` в под-компоненты рендера (ComparisonMatrix/CriteriaTable/AddExtraRow),
// здесь дополнительно достаём то, что нужно контейнеру напрямую (тулбары, AI-payload, футер).
const matrix = useStep3Matrix({ service, formData, onSave, onNext, onStepComplete });
const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, onSave, onNext, onStepComplete });
const {
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
handleAddSupplier, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
extraPositions, suppliersWithOffer, allPositions, computedOffers, selectedSupplierId,
addManualSupplier, handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError,
validateBeforeSave, handleSave, draftSavedAt,
extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
excludedColumns, handleRestoreSupplier,
} = matrix;
const showExcludedSuppliers = !isReadOnly || SHOW_EXCLUDED_SUPPLIERS_READ_ONLY;
// Проверка контрагентов в Adata вынесена в хук.
const adata = useAdataCheck(service, suppliersWithOffer);
// Модалка выбора поставщика из «База данных поставщиков» (см. VendorPickerDialog).
const [showVendorPicker, setShowVendorPicker] = useState(false);
// Окно кнопки «+ Добавить поставщика» в шапке таблицы.
const [showAddSupplierDialog, setShowAddSupplierDialog] = useState(false);
const vendorDbService = useMemo(() => new VendorDatabaseService(service.webPartContext), [service]);
const handleAddVendorFromDatabase = (vendor: IVendorRecord): void => {
const id = handleAddSupplierFromDatabase(vendor);
if (vendor.bin) adata.seedBin(id, vendor.bin);
setShowVendorPicker(false);
};
// Выбор рекомендованного поставщика по формуле 80% цена / 20% условия оплаты вынесен в хук.
const recPick = useRecommendedPick(service, matrix);
// Согласование (инлайн-цепочка Teams-ссылки) вынесено в хук.
const approval = useStep3Approval({ service, formData, userDisplayName, onSave, onStepComplete });
// AI-комментарий + печать PDF вынесены в хук.
const ai = useAiComment({
service,
@@ -69,14 +95,15 @@ export const Step3ComparisonTable: React.FC<Props> = ({
],
suppliers: suppliersWithOffer
.map(s => ({
name: s.name,
// Различимое имя: у поставщика с несколькими КП иначе в тексте будут два одинаковых.
name: columnDisplayNames[s.id] ?? s.name,
grandTotal: allPositions.reduce((sum, pos) => {
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
}, 0),
}))
.filter(s => s.grandTotal > 0),
recommendedSupplier: suppliersWithOffer.find(s => s.id === selectedSupplierId)?.name,
recommendedSupplier: selectedSupplierId ? (columnDisplayNames[selectedSupplierId] ?? undefined) : undefined,
}),
onPdfError: setSaveError,
});
@@ -94,31 +121,17 @@ export const Step3ComparisonTable: React.FC<Props> = ({
return (
<div className={styles.root}>
{/* Toolbar: add supplier — always visible above the table */}
{/* Добавление потенциального поставщика: блок над таблицей + кнопка в шапке таблицы. */}
{!isReadOnly && (
<div className={styles.supplierToolbar}>
<div className={styles.addInput}>
<TextInput
placeholder="Наименование потенциального поставщика"
value={newSupplierName}
onChange={setNewSupplierName}
ariaLabel="Наименование нового потенциального поставщика"
onKeyDown={e => { if (e.key === 'Enter') handleAddSupplier(); }}
/>
</div>
<select
className={styles.currencySelect}
value={newSupplierCurrency}
onChange={e => setNewSupplierCurrency(e.target.value)}
aria-label="Валюта нового потенциального поставщика"
>
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<Button variant="secondary" onClick={handleAddSupplier} disabled={!newSupplierName.trim()}>
<Icon name="plus" size="sm" />
Добавить потенциального поставщика
</Button>
</div>
<AddSupplierToolbar
name={newSupplierName}
currency={newSupplierCurrency}
currencyOptions={currencyOptions}
onNameChange={setNewSupplierName}
onCurrencyChange={setNewSupplierCurrency}
onAdd={handleAddSupplier}
onOpenDatabase={() => setShowVendorPicker(true)}
/>
)}
{/* Тумблер пересчёта единиц измерения поставщика в ед. изм. заявки */}
@@ -129,7 +142,9 @@ export const Step3ComparisonTable: React.FC<Props> = ({
</label>
{translateUnits && (
<span className={styles.translateHint}>
кол-во и цена показаны в ед. изм. заявки; «*» — пересчёт невозможен (ред. недоступно)
кол-во и цена — в ед. изм. заявки (ред. недоступно):{' '}
<span className={styles.translateLegendConverted}>пересчитано</span>,{' '}
<span className={styles.translateLegendImpossible}>пересчёт невозможен</span>
</span>
)}
<div className={styles.adataCheckAllGroup}>
@@ -156,10 +171,34 @@ export const Step3ComparisonTable: React.FC<Props> = ({
{recPick.pickError && <Alert variant="err">{recPick.pickError}</Alert>}
<ComparisonMatrix matrix={matrix} currencyRates={currencyRates} isRestrictedView={isRestrictedView} adata={adata} recommendedId={recPick.recommendedId} />
<ComparisonMatrix
matrix={matrix}
currencyRates={currencyRates}
isRestrictedView={isRestrictedView}
adata={adata}
recommendedId={recPick.recommendedId}
onAddSupplierClick={() => setShowAddSupplierDialog(true)}
/>
<CriteriaTable matrix={matrix} />
<NoOfferSuppliersList entries={getNoOfferSuppliers(formData.suppliers)} />
{showExcludedSuppliers && (
<ExcludedSuppliersList
entries={excludedColumns.map(c => ({
id: c.id,
name: columnDisplayNames[c.id] ?? c.name,
reason: c.exclusion.reason,
}))}
onRestore={isReadOnly ? undefined : handleRestoreSupplier}
/>
)}
{/* В КП — цены: показываем тем же, кому видны цены (участникам согласования и автору). */}
{/* Заглушки поставщиков, добавленных вручную, — не КП: не показываем. */}
{!isRestrictedView && <KpFilesList files={withoutPlaceholders(formData.uploadedKpFiles ?? [])} service={service} />}
<AiCommentBlock
comment={ai.aiComment}
decodedComment={ai.decodedAiComment}
@@ -169,7 +208,12 @@ export const Step3ComparisonTable: React.FC<Props> = ({
/>
<InlineApprovalChain approval={approval} isNotificationLink={isNotificationLink} isReadOnly={isReadOnly} />
<InlineApprovalChain
service={service}
formData={formData}
onSave={onSave}
isReview={isReview}
/>
<div className={styles.footer}>
<Button variant="secondary" onClick={onPrev} disabled={isReadOnly}>
@@ -178,6 +222,9 @@ export const Step3ComparisonTable: React.FC<Props> = ({
</Button>
<div className={styles.footerRight}>
{saveError && <Alert variant="err">{saveError}</Alert>}
{!isReadOnly && (
<SaveDraftButton onSave={() => { handleSave('draft').catch(() => undefined /* ошибку показывает handleSave */); }} isSaving={isSaving} savedAt={draftSavedAt} />
)}
<Button
variant="secondary"
onClick={ai.handleGeneratePdfReport}
@@ -195,7 +242,12 @@ export const Step3ComparisonTable: React.FC<Props> = ({
</Button>
<Button
variant="primary"
onClick={() => (!ai.aiComment && !ai.isAiLoading) ? ai.setShowAiPromptDialog(true) : handleSave()}
onClick={() => {
// Сначала проверка (победитель, километраж), потом предложение комментария ИИ.
if (!validateBeforeSave()) return;
if (!ai.aiComment && !ai.isAiLoading) ai.setShowAiPromptDialog(true);
else handleSave().catch(() => undefined /* ошибку показывает handleSave */);
}}
disabled={isSaving || isReadOnly}
>
{isSaving ? (
@@ -210,6 +262,23 @@ export const Step3ComparisonTable: React.FC<Props> = ({
</div>
</div>
{showAddSupplierDialog && (
<AddSupplierDialog
currencyOptions={currencyOptions}
onAddManual={addManualSupplier}
onOpenDatabase={() => setShowVendorPicker(true)}
onClose={() => setShowAddSupplierDialog(false)}
/>
)}
{showVendorPicker && (
<VendorPickerDialog
vendorDbService={vendorDbService}
onAdd={handleAddVendorFromDatabase}
onClose={() => setShowVendorPicker(false)}
/>
)}
{ai.showAiPromptDialog && (
<Dialog
open
@@ -0,0 +1,105 @@
@import '../common/theme';
.state {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
color: $muted;
font-size: 14px;
}
.filterRow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.searchBox {
flex: 1 1 auto;
min-width: 0;
}
.categoryBox {
flex: 0 0 200px;
max-width: 200px;
// Select из ui-kit не принимает className — форсируем ширину нативного <select> под обёртку.
:global(select) {
width: 100%;
}
}
.hint {
margin-bottom: 8px;
font-size: 12px;
color: $muted;
}
.list {
list-style: none;
margin: 0;
padding: 4px;
width: 100%;
max-height: 50vh;
overflow-y: auto;
box-sizing: border-box;
border: 1px solid $border-strong;
border-radius: 4px;
background: $surface;
display: flex;
flex-direction: column;
gap: 4px;
}
.item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: 4px;
cursor: pointer;
&:hover {
background: $hover;
}
}
.itemMain {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.itemName {
font-weight: 600;
color: $text;
}
.itemBin {
color: $muted;
font-size: 12px;
white-space: nowrap;
}
.itemMeta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: $muted;
}
// ─── Карточка поставщика (read-only значения полей) ─────────────────────────
.viewValue {
min-height: 32px;
display: flex;
align-items: center;
padding: 6px 0;
color: $text;
font-size: 14px;
overflow-wrap: anywhere;
}
@@ -0,0 +1,176 @@
import * as React from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Dialog, Button, TextInput, Select, Spinner, Alert, FormGrid, FormField, Icon } from '@tnsu/ui-kit';
import { VendorDatabaseService, IVendorRecord } from '../../services/VendorDatabaseService';
import styles from './VendorPickerDialog.module.scss';
interface Props {
vendorDbService: VendorDatabaseService;
onAdd: (vendor: IVendorRecord) => void;
onClose: () => void;
}
// Список большой (сотни поставщиков) — без поиска и без выбранного раздела показываем только
// первые N по алфавиту (сортировка уже приходит из VendorDatabaseService.listVendors), чтобы окно
// не было пустым при открытии, но и не рендерило все 500+ строк разом.
const INITIAL_LIMIT = 20;
function ViewField({ label, value, full }: { label: string; value: string; full?: boolean }): JSX.Element {
return (
<FormField label={label} full={full}>
<div className={styles.viewValue}>{value || '—'}</div>
</FormField>
);
}
// Диалог выбора поставщика из «База данных поставщиков» (Шаг 3 СТ): поиск + список → карточка
// поставщика с кнопкой добавления в таблицу сравнения (см. handleAddSupplierFromDatabase).
export const VendorPickerDialog: React.FC<Props> = ({ vendorDbService, onAdd, onClose }) => {
const [vendors, setVendors] = useState<IVendorRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [category, setCategory] = useState('');
const [selected, setSelected] = useState<IVendorRecord | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
vendorDbService
.listVendors()
.then((list) => { if (!cancelled) setVendors(list); })
.catch((e) => { if (!cancelled) setError((e as Error).message || 'Ошибка загрузки базы поставщиков'); })
.finally(() => { if (!cancelled) setIsLoading(false); });
return () => { cancelled = true; };
}, [vendorDbService]);
const categoryOptions = useMemo(() => {
const unique = Array.from(new Set(vendors.map((v) => v.category).filter(Boolean)));
unique.sort((a, b) => a.localeCompare(b, 'ru'));
return unique.map((c) => ({ value: c, label: c }));
}, [vendors]);
const isBrowsing = !query.trim() && !category;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const byCategory = category ? vendors.filter((v) => v.category === category) : vendors;
if (!q) return byCategory;
return byCategory.filter(
(v) =>
v.name.toLowerCase().includes(q) ||
v.bin.includes(q) ||
v.category.toLowerCase().includes(q),
);
}, [vendors, query, category]);
// Без поиска и без раздела — режем список, чтобы не рендерить всё сразу; при любом сужении
// (текст и/или раздел) пользователь уже сам ограничил выборку — показываем всё, что нашлось.
const displayed = isBrowsing ? filtered.slice(0, INITIAL_LIMIT) : filtered;
if (selected) {
return (
<Dialog
open
title="Карточка поставщика"
subText={selected.category ? `Раздел: ${selected.category}` : undefined}
onDismiss={onClose}
width="lg"
footer={
<>
<Button variant="ghost" onClick={() => setSelected(null)}>
<Icon name="arrow-left" size="sm" />
Назад к списку
</Button>
<Button variant="primary" onClick={() => onAdd(selected)}>
<Icon name="plus" size="sm" />
Добавить поставщика в СТ
</Button>
</>
}
>
<FormGrid>
<ViewField label="Наименование" value={selected.name} full />
<ViewField label="БИН" value={selected.bin} />
<ViewField label="Статус" value={selected.status} />
<ViewField label="Юридический адрес" value={selected.legalAddress} full />
<ViewField label="Факт. адрес / склад" value={selected.actualAddress} full />
<ViewField label="Контактное лицо" value={selected.contactPerson} />
<ViewField label="Контактная информация" value={selected.contactInfo} />
<ViewField label="Email" value={selected.email} />
<ViewField label="Веб-сайт" value={selected.website} />
{selected.productName && <ViewField label="Наименование ТМЦ" value={selected.productName} full />}
{selected.catalogLink && <ViewField label="Ссылка на каталог/прайс" value={selected.catalogLink} full />}
</FormGrid>
</Dialog>
);
}
return (
<Dialog
open
title="Выбор поставщика из базы"
subText="Найдите поставщика по названию, БИН или разделу — выбор откроет его карточку."
onDismiss={onClose}
width="lg"
>
{isLoading && (
<div className={styles.state}>
<Spinner size="small" /> Загрузка базы поставщиков…
</div>
)}
{!isLoading && error && <Alert variant="err">{error}</Alert>}
{!isLoading && !error && (
<>
<div className={styles.filterRow}>
<div className={styles.searchBox}>
<TextInput
value={query}
onChange={setQuery}
placeholder="Поиск по названию или БИН…"
ariaLabel="Поиск поставщика"
autoFocus
/>
</div>
<div className={styles.categoryBox}>
<Select
value={category}
onChange={setCategory}
options={categoryOptions}
placeholder="Все разделы"
/>
</div>
</div>
{filtered.length === 0 ? (
<div className={styles.state}>Ничего не найдено</div>
) : (
<>
{isBrowsing && vendors.length > INITIAL_LIMIT && (
<div className={styles.hint}>
Показаны первые {INITIAL_LIMIT} из {vendors.length} — используйте поиск или выберите раздел
</div>
)}
<ul className={styles.list}>
{displayed.map((v) => (
<li key={v.id} className={styles.item} onClick={() => setSelected(v)}>
<div className={styles.itemMain}>
<span className={styles.itemName}>{v.name}</span>
{v.bin && <span className={styles.itemBin}>БИН {v.bin}</span>}
</div>
<div className={styles.itemMeta}>
<span>{v.category || '—'}</span>
{v.contactInfo && <span>{v.contactInfo}</span>}
</div>
</li>
))}
</ul>
</>
)}
</>
)}
</Dialog>
);
};
@@ -0,0 +1,161 @@
import { applyCreatedIds, applyPositionUnitChange, buildOfferKey, computeOffer, getSubCols, isMinPositionTotal, round2Str, sumPositionTotals } from './matrixHelpers';
import { EditableOffers, ExtraPosition } from './matrixTypes';
import { SupplierOffer } from '../types/domain';
describe('applyPositionUnitChange (единица доп. позиции стала своей вместо «рейс»)', () => {
const edits: EditableOffers = {
'sup_a::extra_pos_1': [{ supplierId: 'sup_a', positionId: 'extra_pos_1', itemIndex: 0, unit: 'рейс', pricePerUnit: 50000 }],
'sup_b::extra_pos_1': [{ supplierId: 'sup_b', positionId: 'extra_pos_1', itemIndex: 0, unit: 'машина' }],
'sup_c::extra_pos_1': [{ supplierId: 'sup_c', positionId: 'extra_pos_1', itemIndex: 0 }],
'sup_a::pos_1': [{ supplierId: 'sup_a', positionId: 'pos_1', itemIndex: 0, unit: 'рейс' }],
};
const result = applyPositionUnitChange(edits, 'extra_pos_1', 'рейс', 'тонна');
it('у поставщика с прежней единицей позиции — новая, цена не теряется', () => {
expect(result['sup_a::extra_pos_1'][0]).toMatchObject({ unit: 'тонна', pricePerUnit: 50000 });
});
it('у поставщика без своей единицы — новая', () => {
expect(result['sup_c::extra_pos_1'][0].unit).toBe('тонна');
});
it('свою единицу поставщика не трогает', () => {
expect(result['sup_b::extra_pos_1'][0].unit).toBe('машина');
});
it('другие позиции не трогает, даже с той же единицей', () => {
expect(result['sup_a::pos_1'][0].unit).toBe('рейс');
});
});
describe('computeOffer', () => {
it('считает сумму в тенге для КП в тенге', () => {
const offer = computeOffer(
{ supplierId: 's1', positionId: 'p1', itemIndex: 0, pricePerUnit: 150, quantity: 4 },
'KZT',
1,
);
expect(offer).toMatchObject({
supplierId: 's1',
positionId: 'p1',
currencyCode: 'KZT',
pricePerUnit: 150,
quantity: 4,
pricePerUnitKzt: 150,
totalPriceKzt: 600,
totalWithExtraKzt: 600,
});
});
it('пересчитывает валютную цену по курсу', () => {
const offer = computeOffer(
{ supplierId: 's1', positionId: 'p1', itemIndex: 0, pricePerUnit: 10, quantity: 3 },
'USD',
500,
);
expect(offer.pricePerUnitKzt).toBe(5000);
expect(offer.totalPriceKzt).toBe(15000);
});
it('ручная цена в тенге важнее пересчёта по курсу', () => {
const offer = computeOffer(
{ supplierId: 's1', positionId: 'p1', itemIndex: 0, pricePerUnit: 10, priceKztOverride: 4800, quantity: 2 },
'USD',
500,
);
expect(offer.pricePerUnitKzt).toBe(4800);
expect(offer.totalPriceKzt).toBe(9600);
});
it('пустая ячейка даёт нули, а не NaN', () => {
const offer = computeOffer({ supplierId: 's1', positionId: 'p1', itemIndex: 0 }, 'KZT', 1);
expect(offer.pricePerUnit).toBe(0);
expect(offer.quantity).toBe(0);
expect(offer.totalPriceKzt).toBe(0);
});
});
describe('buildOfferKey', () => {
it('склеивает поставщика и позицию через ::', () => {
expect(buildOfferKey('s1', 'p1')).toBe('s1::p1');
});
});
describe('getSubCols', () => {
it('для тенге 5 колонок, для валюты 7, в урезанном режиме 3', () => {
expect(getSubCols('KZT')).toBe(5);
expect(getSubCols('USD')).toBe(7);
expect(getSubCols('USD', true)).toBe(3);
});
});
describe('round2Str', () => {
it('отрезает хвост плавающей точки', () => {
expect(round2Str(275620.94999)).toBe('275620.95');
});
});
describe('applyCreatedIds', () => {
// Доп. строку создали в SP, а следующий шаг сохранения упал: id должен остаться в стейте,
// иначе повторное «Сохранить» создаст строку «Логистика» ещё раз.
it('проставляет созданный SP Id', () => {
const extras: ExtraPosition[] = [{ id: 'extra_pos_1', materialName: 'Логистика', unit: 'рейс' }];
expect(applyCreatedIds(extras, { extra_pos_1: { spId: 1601 } })).toEqual([
{ id: 'extra_pos_1', materialName: 'Логистика', unit: 'рейс', spId: 1601 },
]);
});
it('не трогает строки, которых нет среди созданных', () => {
const extras: Array<{ id: string; spId?: number }> = [{ id: 'extra_pos_1', spId: 1500 }, { id: 'extra_pos_2' }];
expect(applyCreatedIds(extras, { extra_pos_2: { spId: 1601 } })).toEqual([
{ id: 'extra_pos_1', spId: 1500 },
{ id: 'extra_pos_2', spId: 1601 },
]);
});
it('не мутирует исходный массив', () => {
const extras = [{ id: 'extra_sup_1' as string, spId: undefined as number | undefined }];
applyCreatedIds(extras, { extra_sup_1: { spId: 7 } });
expect(extras[0].spId).toBeUndefined();
});
});
describe('подсветка минимальной суммы по позиции (п.13)', () => {
const offer = (supplierId: string, positionId: string, total: number): SupplierOffer => ({
id: '', supplierId, positionId, pricePerUnit: 0, quantity: 0, currencyCode: 'KZT',
pricePerUnitKzt: 0, totalPriceKzt: total, totalWithExtraKzt: total,
});
// Строки комплекта одной позиции у поставщика складываются в одну сумму.
it('sumPositionTotals: сумма по поставщику и позиции, строки комплекта складываются', () => {
const totals = sumPositionTotals([
offer('s1', 'p1', 100), offer('s1', 'p1', 50), offer('s2', 'p1', 120), offer('s1', 'p2', 10),
]);
expect(totals.get(buildOfferKey('s1', 'p1'))).toBe(150);
expect(totals.get(buildOfferKey('s2', 'p1'))).toBe(120);
expect(totals.get(buildOfferKey('s1', 'p2'))).toBe(10);
});
// Раньше сравнивали строгим ===, и итог комплекта, посчитанный отдельно, расходился на копейки — подсветка пропадала.
it('isMinPositionTotal: совпадение с минимумом с допуском до полкопейки', () => {
expect(isMinPositionTotal(114000, 114000)).toBe(true);
expect(isMinPositionTotal(103823.48 + 0.000001, 103823.48)).toBe(true);
expect(isMinPositionTotal(128800, 114000)).toBe(false);
});
it('isMinPositionTotal: нет цены или нет минимума — не подсвечиваем', () => {
expect(isMinPositionTotal(0, 0)).toBe(false);
expect(isMinPositionTotal(undefined, 100)).toBe(false);
expect(isMinPositionTotal(100, undefined)).toBe(false);
});
});
@@ -1,5 +1,5 @@
import { SupplierOffer } from '../types/domain';
import { CellEdit } from './matrixTypes';
import { CurrencyRate, SupplierOffer } from '../types/domain';
import { CellEdit, EditableOffers, PositionRow } from './matrixTypes';
// Статус потенциального поставщика — фиксированный список для <select> в шапке (не обязателен).
export const SUPPLIER_STATUS_OPTIONS = [
@@ -21,6 +21,19 @@ export const DELIVERY_TYPE_OPTIONS: Array<{ code: string; label: string }> = [
{ code: 'DDP', label: 'Поставка до объекта с оплатой всех расходов (DDP) — доставка силами поставщика с включением транспортных и таможенных расходов' },
];
// Условия оплаты — готовые варианты (самые частые формулировки в SP «Критерии»); можно вписать свой.
export const PAYMENT_TYPE_OPTIONS: string[] = [
'100% предоплата',
'50% предоплата, 50% по факту поставки',
'30% предоплата, 70% по факту поставки',
'40% предоплата, 60% перед отгрузкой',
'Оплата по факту поставки',
'100% оплата в течение 5 рабочих дней после поставки',
];
// Сертификаты — как Choice-колонка sertificates в SP «Критерии».
export const CERTIFICATE_OPTIONS: string[] = ['Да', 'Нет'];
// Foreign currency = 6 sub-cols (Ед. изм. | Кол-во | Цена_curr | Сумма_curr | Цена ₸ | Сумма ₸)
// KZT = 4 sub-cols (Ед. изм. | Кол-во | Цена ₸ | Сумма ₸)
// Restricted = 3 sub-cols (Наименование ТМЦ | Ед. изм. | Кол-во)
@@ -73,3 +86,91 @@ export function computeOffer(draft: CellEdit, currencyCode: string, rate: number
export function buildOfferKey(supplierId: string, positionId: string): string {
return `${supplierId}::${positionId}`;
}
// Поменяли единицу доп. позиции (логистика, доп. расходы): у поставщиков, у которых единица та же,
// что была у позиции (её не трогали), — новая. Свою единицу поставщика не меняем. Ячейки без правок
// и так показывают единицу позиции.
export function applyPositionUnitChange(
edits: EditableOffers,
positionId: string,
oldUnit: string,
newUnit: string,
): EditableOffers {
const next: EditableOffers = {};
Object.keys(edits).forEach(key => {
const isThisPosition = key.split('::')[1] === positionId;
next[key] = isThisPosition
? edits[key].map(item => (!item.unit || item.unit === oldUnit ? { ...item, unit: newUnit } : item))
: edits[key];
});
return next;
}
export interface ColumnOffersInput {
columns: Array<{ id: string; isExtraSupplier: boolean }>;
positions: PositionRow[];
edits: EditableOffers;
supplierCurrencies: Record<string, string>;
currencyRates: CurrencyRate[];
}
// Офферы колонок по всем позициям из правок таблицы (пустая ячейка — оффер с нулями). Одна функция
// и для сравниваемых поставщиков, и для исключённых: цены исключённых считаются так же, чтобы
// сохраниться в SP и вернуться без потерь.
export function computeColumnsOffers(input: ColumnOffersInput): SupplierOffer[] {
const { columns, positions, edits, supplierCurrencies, currencyRates } = input;
const result: SupplierOffer[] = [];
columns.forEach(s => {
positions.forEach(pos => {
const items = edits[buildOfferKey(s.id, pos.id)] ?? [{ supplierId: s.id, positionId: pos.id, itemIndex: 0 }];
const currency = supplierCurrencies[s.id] ?? 'KZT';
const rate = currency !== 'KZT'
? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1)
: 1;
// For extra suppliers quantity may be undefined in edits (user only entered price).
// Fall back to the position's own quantity so totalWithExtraKzt stays non-zero and the
// offer is included in filteredComputed → persisted in offersForSession → restored on remount.
const posQtyFallback = s.isExtraSupplier ? (pos.quantity || undefined) : undefined;
items.forEach(item => result.push(computeOffer(
{
...item,
unit: item.unit || pos.unit,
supplierProductName: item.supplierProductName || pos.materialName,
quantity: item.quantity ?? posQtyFallback,
},
currency, rate
)));
});
});
return result;
}
// Проставляет id, полученные при создании в SP (доп. строки / поставщики Шага 3), в элементы стейта.
// Результат сохраняем сразу после создания: если дальше сохранение упадёт, повторное «Сохранить»
// увидит spId и не создаст ту же строку ещё раз.
export function applyCreatedIds<T extends { id: string }>(
items: T[],
createdById: Record<string, Partial<T>>,
): T[] {
return items.map(item => (createdById[item.id] ? { ...item, ...createdById[item.id] } : item));
}
// Допуск сравнения сумм с минимумом — полкопейки: суммы с плавающей точкой расходятся в хвосте.
const MIN_TOTAL_EPSILON = 0.005;
// Сумма в ₸ по каждой паре (поставщик, позиция): строки комплекта складываются. Один источник и для
// минимума, и для подсветки ячеек — иначе отдельно посчитанный итог комплекта расходился на копейки.
export function sumPositionTotals(offers: SupplierOffer[]): Map<string, number> {
const totals = new Map<string, number>();
offers.forEach(o => {
const key = buildOfferKey(o.supplierId, o.positionId);
totals.set(key, (totals.get(key) ?? 0) + (o.totalWithExtraKzt ?? 0));
});
return totals;
}
// Сумма поставщика по позиции — минимальная среди поставщиков (подсветка зелёным по каждой позиции).
export function isMinPositionTotal(total: number | undefined, minTotal: number | undefined): boolean {
if (total === undefined || minTotal === undefined || total <= 0) return false;
return Math.abs(total - minTotal) < MIN_TOTAL_EPSILON;
}
@@ -1,11 +1,22 @@
// Типы матрицы сравнения (Шаг 3). Перенесено 1:1 из Step3ComparisonTable.
import { SupplierExclusion } from '../types/domain';
export interface ExtraSupplier {
id: string;
name: string;
currency: string;
spId?: number;
status?: string;
exclusion?: SupplierExclusion;
// Заполняются только когда поставщик добавлен из VendorPickerDialog («База данных поставщиков»).
offerId?: number;
bin?: string;
address?: string;
contactPerson?: string;
contactInfo?: string;
email?: string;
website?: string;
}
export interface ExtraPosition {
@@ -0,0 +1,55 @@
import { Supplier } from '../types/domain';
import { ExtraSupplier } from './matrixTypes';
import { buildSuppliersPatch } from './step3SavePatch';
const kpColumn: Supplier = { id: '282_320', spId: 282, kpFileSpId: 320, name: 'Каспий Дорс', hasOffer: true };
const extra: ExtraSupplier = { id: 'extra_sup_1', name: 'ООО Новый', currency: 'KZT' };
describe('buildSuppliersPatch', () => {
it('статусы поставщиков — в данные формы в обоих режимах', () => {
for (const mode of ['next', 'draft'] as const) {
const patch = buildSuppliersPatch({
mode, suppliers: [kpColumn], extraSuppliers: [], statuses: { '282_320': 'Производитель' }, extraSupplierSpIds: {},
});
expect(patch.suppliers[0].status).toBe('Производитель');
}
});
it('«Сохранить и продолжить»: поставщик Шага 3 переезжает в suppliers с SP Id, список Шага 3 очищается', () => {
const patch = buildSuppliersPatch({
mode: 'next', suppliers: [kpColumn], extraSuppliers: [extra], statuses: {}, extraSupplierSpIds: { extra_sup_1: 501 },
});
expect(patch.suppliers.map(s => s.id)).toEqual(['282_320', 'extra_sup_1']);
expect(patch.suppliers[1]).toMatchObject({ spId: 501, hasOffer: true });
expect(patch.step3ExtraSuppliers).toEqual([]);
});
// Остаёмся на шаге: таблица держит поставщика в своём списке Шага 3 — перенос задвоил бы колонку.
it('черновик: поставщик Шага 3 остаётся в своём списке, suppliers его не получают', () => {
const patch = buildSuppliersPatch({
mode: 'draft', suppliers: [kpColumn], extraSuppliers: [extra], statuses: {}, extraSupplierSpIds: { extra_sup_1: 501 },
});
expect(patch.suppliers.map(s => s.id)).toEqual(['282_320']);
expect(patch).not.toHaveProperty('step3ExtraSuppliers');
});
it('повторное «Сохранить и продолжить» не дублирует уже перенесённого поставщика', () => {
const alreadyMoved: Supplier = { id: 'extra_sup_1', spId: 501, name: 'ООО Новый', hasOffer: true };
const patch = buildSuppliersPatch({
mode: 'next', suppliers: [kpColumn, alreadyMoved], extraSuppliers: [extra], statuses: {}, extraSupplierSpIds: { extra_sup_1: 501 },
});
expect(patch.suppliers.filter(s => s.id === 'extra_sup_1')).toHaveLength(1);
});
it('исключение поставщика Шага 3 переезжает вместе с ним', () => {
const patch = buildSuppliersPatch({
mode: 'next', suppliers: [], extraSuppliers: [{ ...extra, exclusion: { reason: 'Дорого' } }], statuses: {}, extraSupplierSpIds: {},
});
expect(patch.suppliers[0].exclusion).toEqual({ reason: 'Дорого' });
});
});
@@ -0,0 +1,49 @@
import { Supplier } from '../types/domain';
import { ExtraSupplier } from './matrixTypes';
// «Сохранить и продолжить» — переход на Шаг 4; «Сохранить черновик» — запись в SP, остаёмся на Шаге 3.
export type Step3SaveMode = 'next' | 'draft';
export interface SuppliersPatchInput {
mode: Step3SaveMode;
suppliers: Supplier[];
extraSuppliers: ExtraSupplier[];
statuses: Record<string, string>;
// SP Id поставщиков, добавленных на Шаге 3 (созданы при этом сохранении или раньше).
extraSupplierSpIds: Record<string, number>;
}
export interface SuppliersPatch {
suppliers: Supplier[];
// Есть только при переходе на Шаг 4: поставщики Шага 3 переезжают в suppliers.
step3ExtraSuppliers?: ExtraSupplier[];
}
// Поставщики в данных формы после сохранения Шага 3.
//
// При переходе на Шаг 4 поставщики, добавленные на Шаге 3, переезжают в suppliers (с тем же id —
// по нему к ним привязаны цены в сессии), а список Шага 3 очищается: таблица размонтируется и
// при возврате соберётся заново.
//
// Черновик оставляет нас на шаге, и таблица по-прежнему держит их в своём списке Шага 3. Перенос
// задвоил бы колонку (она в обоих списках), а без списка Шага 3 у такого поставщика пропала бы
// подстановка количества из позиции — суммы обнулились бы. Поэтому черновик их не переносит:
// SP Id уже запомнены (rememberCreatedExtras), повторное сохранение дублей в SP не создаст.
export function buildSuppliersPatch(input: SuppliersPatchInput): SuppliersPatch {
const { mode, suppliers, extraSuppliers, statuses, extraSupplierSpIds } = input;
const withStatuses = suppliers.map(s => ({ ...s, status: statuses[s.id] ?? s.status }));
if (mode === 'draft') return { suppliers: withStatuses };
const movedExtras: Supplier[] = extraSuppliers
// Уже в suppliers после прежнего сохранения — не дублируем колонку.
.filter(es => !suppliers.some(s => s.id === es.id))
.map(es => ({
id: es.id,
spId: extraSupplierSpIds[es.id] ?? es.spId,
name: es.name,
hasOffer: true,
status: statuses[es.id] ?? '',
exclusion: es.exclusion,
}));
return { suppliers: [...withStatuses, ...movedExtras], step3ExtraSuppliers: [] };
}
@@ -0,0 +1,62 @@
import { STEP3_SAVE_ERRORS, getStep3SaveError, isPriceMissing, toggleWinner } from './step3Validation';
describe('toggleWinner', () => {
it('нажали «Выбрать» — поставщик выбран', () => {
expect(toggleWinner(undefined, '259_278')).toBe('259_278');
});
// Раньше повторное нажатие ничего не делало — снять выбор было нельзя.
it('повторное нажатие на «Выбран» снимает выбор', () => {
expect(toggleWinner('259_278', '259_278')).toBeUndefined();
});
it('нажали «Выбрать» у другого — выбор переходит к нему', () => {
expect(toggleWinner('259_278', '282_320')).toBe('282_320');
});
});
describe('getStep3SaveError', () => {
const compared = ['259_278', '282_320'];
it('победитель выбран — сохранять можно', () => {
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
.toBeUndefined();
});
it('победитель не выбран — нельзя', () => {
expect(getStep3SaveError({ selectedSupplierId: undefined, comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
.toBe(STEP3_SAVE_ERRORS.noWinner);
});
// Выбор из сессии мог остаться на поставщике, которого исключили или чей КП убрали.
it('выбран поставщик не из сравниваемых — как будто не выбран', () => {
expect(getStep3SaveError({ selectedSupplierId: '300_400', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
.toBe(STEP3_SAVE_ERRORS.noWinner);
});
it('логистика без километража — нельзя', () => {
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: true }))
.toBe(STEP3_SAVE_ERRORS.deliveryKmMissing);
});
// Решение от 2026-10-27: «Условия оплаты *» были помечены обязательными, но сохранять не мешали.
it('условия оплаты не у всех — нельзя', () => {
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: true, hasDeliveryWithoutKm: false }))
.toBe(STEP3_SAVE_ERRORS.paymentTermsMissing);
});
});
describe('isPriceMissing', () => {
it('цены нет ни в валюте, ни в ₸ — пусто', () => {
expect(isPriceMissing(undefined, undefined)).toBe(true);
expect(isPriceMissing(0, undefined)).toBe(true);
});
it('цена в валюте КП есть — не пусто', () => {
expect(isPriceMissing(635, undefined)).toBe(false);
});
it('цену ввели вручную в ₸ — не пусто', () => {
expect(isPriceMissing(undefined, 286575.5)).toBe(false);
});
});
@@ -0,0 +1,42 @@
// Проверки перед «Сохранить и продолжить» на Шаге 3. Сообщения — для пользователя.
export const STEP3_SAVE_ERRORS = {
noWinner: 'Выберите поставщика-победителя: кнопка «Выбрать» в шапке его колонки.',
paymentTermsMissing: 'Заполните условия оплаты у всех поставщиков в «Дополнительной информации».',
deliveryKmMissing: 'Укажите километраж для каждого потенциального поставщика в строке логистики',
};
export interface Step3SaveCheckInput {
selectedSupplierId: string | undefined;
// Колонки, которые сравниваются (без исключённых).
comparedColumnIds: string[];
hasMissingPaymentTerms: boolean;
hasDeliveryWithoutKm: boolean;
}
// Что не пускает дальше Шага 3 (решения от 2026-10-27). Всё это подсвечено красной рамкой сразу,
// пока заполняют, — подсветка показывает ровно то, что проверяется здесь.
// - Победитель: уходит в шапку СТ (winSupplier) — без него отчёт и согласование не знают, кого
// выбрали. Должен быть среди сравниваемых: исключённый поставщик победителем не считается.
// - Условия оплаты: по ним считается рекомендация (80% цена / 20% условия оплаты).
// - Километраж логистики.
export function getStep3SaveError(input: Step3SaveCheckInput): string | undefined {
const { selectedSupplierId, comparedColumnIds, hasMissingPaymentTerms, hasDeliveryWithoutKm } = input;
if (!selectedSupplierId || !comparedColumnIds.includes(selectedSupplierId)) {
return STEP3_SAVE_ERRORS.noWinner;
}
if (hasMissingPaymentTerms) return STEP3_SAVE_ERRORS.paymentTermsMissing;
if (hasDeliveryWithoutKm) return STEP3_SAVE_ERRORS.deliveryKmMissing;
return undefined;
}
// Цена в ячейке не введена: ни в валюте КП, ни вручную в ₸. Подсвечивается, но сохранению не
// мешает — поставщик мог не предложить позицию, и 0 вместо пустоты исказил бы сравнение.
export function isPriceMissing(price: number | undefined, priceKztOverride: number | undefined): boolean {
return !(price !== undefined && price > 0) && !(priceKztOverride !== undefined && priceKztOverride > 0);
}
// Кнопка «Выбрать» / «Выбран»: повторное нажатие на выбранного снимает выбор.
export function toggleWinner(currentId: string | undefined, clickedId: string): string | undefined {
return currentId === clickedId ? undefined : clickedId;
}
@@ -0,0 +1,15 @@
import { UnitStatusKind } from '../utils/unitConversion';
import styles from './Step3ComparisonTable.module.scss';
// Подсветка ячеек по статусу ед. изм. поставщика (см. getUnitStatus): жёлтым — единица отличается
// от заявки, голубым — пересчитано в ед. заявки, красным — пересчитать нельзя.
const UNIT_STATUS_CLASS: Record<UnitStatusKind, string> = {
same: '',
mismatch: styles.unitMismatch,
converted: styles.unitConverted,
impossible: styles.unitImpossible,
};
export function unitStatusClass(status: UnitStatusKind): string {
return UNIT_STATUS_CLASS[status];
}
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { CtService } from '../../services/CtService';
import { AdataCompanyInfo, Supplier } from '../types/domain';
import { findSiblingColumnIds } from '../utils/supplierColumns';
export interface AdataSupplierState {
bin?: string;
@@ -15,6 +16,7 @@ export interface UseAdataCheckResult {
checkSupplier: (supplierId: string) => Promise<void>;
checkAllSuppliers: () => Promise<void>;
isCheckingAll: boolean;
seedBin: (supplierId: string, bin: string) => void;
}
// Проверка контрагентов в Adata (Шаг 3): подтягивает БИН + кэш прошлой проверки из списка
@@ -37,16 +39,19 @@ export function useAdataCheck(service: CtService, suppliers: Supplier[]): UseAda
.then(rows => {
setAdataMap(prev => {
const next = { ...prev };
// Кэш — на компанию: у поставщика с несколькими КП он нужен каждой его колонке.
rows.forEach(r => {
const sup = suppliersRef.current.find(s => s.spId === r.supplierSpId);
if (!sup) return;
next[sup.id] = {
...next[sup.id],
bin: r.bin,
data: r.adataData,
checkedAt: r.adataCheckedAt,
isLoading: false,
};
suppliersRef.current
.filter(s => s.spId === r.supplierSpId)
.forEach(sup => {
next[sup.id] = {
...next[sup.id],
bin: r.bin,
data: r.adataData,
checkedAt: r.adataCheckedAt,
isLoading: false,
};
});
});
return next;
});
@@ -62,25 +67,50 @@ export function useAdataCheck(service: CtService, suppliers: Supplier[]): UseAda
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], isLoading: false, error: 'БИН поставщика неизвестен — проверка недоступна' } }));
return;
}
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], bin, isLoading: true, error: undefined } }));
// Adata проверяет компанию — результат раздаём всем колонкам этого поставщика (несколько КП).
const columnIds = findSiblingColumnIds(suppliersRef.current, supplierId);
const patchColumns = (state: (prevState: AdataSupplierState | undefined) => AdataSupplierState): void => {
setAdataMap(prev => {
const next = { ...prev };
columnIds.forEach(id => { next[id] = state(prev[id]); });
return next;
});
};
patchColumns(prevState => ({ ...prevState, bin, isLoading: true, error: undefined }));
try {
const data = await service.fetchAdataInfo(bin);
const checkedAt = new Date().toISOString();
setAdataMap(prev => ({ ...prev, [supplierId]: { bin, data, checkedAt, isLoading: false } }));
patchColumns(() => ({ bin, data, checkedAt, isLoading: false }));
if (sup.spId !== undefined) {
service.saveAdataResult(sup.spId, data).catch(() => undefined);
}
} catch (err) {
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], bin, isLoading: false, error: (err as Error).message } }));
patchColumns(prevState => ({ ...prevState, bin, isLoading: false, error: (err as Error).message }));
}
}, [service]);
const checkAllSuppliers = useCallback(async (): Promise<void> => {
setIsCheckingAll(true);
const targets = suppliersRef.current.filter(s => s.hasOffer && (adataMapRef.current[s.id]?.bin ?? s.bin));
// Одна проверка на компанию: колонки того же поставщика получат результат через checkSupplier.
const checkedSpIds = new Set<number>();
const targets = suppliersRef.current
.filter(s => s.hasOffer && (adataMapRef.current[s.id]?.bin ?? s.bin))
.filter(s => {
if (s.spId === undefined) return true;
if (checkedSpIds.has(s.spId)) return false;
checkedSpIds.add(s.spId);
return true;
});
await Promise.all(targets.map(s => checkSupplier(s.id)));
setIsCheckingAll(false);
}, [checkSupplier]);
return { adataMap, checkSupplier, checkAllSuppliers, isCheckingAll };
// Проставляет БИН сразу (без ожидания эффекта монтирования выше, у которого deps=[] и который
// не подхватит поставщика, добавленного уже после первого рендера) — нужен для поставщика,
// выбранного из VendorPickerDialog, чтобы «Проверить всех»/иконка Adata стали доступны сразу.
const seedBin = useCallback((supplierId: string, bin: string): void => {
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], bin, isLoading: false } }));
}, []);
return { adataMap, checkSupplier, checkAllSuppliers, isCheckingAll, seedBin };
}
@@ -30,7 +30,7 @@ export function useRecommendedPick(service: CtService, matrix: Step3Matrix): Use
const [pickError, setPickError] = useState<string | undefined>(undefined);
const [recommendedId, setRecommendedId] = useState<string | undefined>(undefined);
const { suppliersWithOffer, criteriaMap, computedOffers, allPositions, setSelectedSupplierId } = matrix;
const { suppliersWithOffer, columnDisplayNames, criteriaMap, computedOffers, allPositions, setSelectedSupplierId } = matrix;
const allPaymentTermsFilled = suppliersWithOffer.length > 0
&& suppliersWithOffer.every(s => (criteriaMap[s.id]?.paymentType ?? '').trim().length > 0);
@@ -43,7 +43,8 @@ export function useRecommendedPick(service: CtService, matrix: Step3Matrix): Use
const totals = suppliersWithOffer
.map(s => ({
id: s.id,
name: s.name,
// Различимое имя: ответ ИИ сопоставляется по имени, у поставщика с несколькими КП оно повторяется.
name: columnDisplayNames[s.id] ?? s.name,
paymentType: criteriaMap[s.id]?.paymentType ?? '',
total: allPositions.reduce((sum, pos) => {
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
@@ -81,7 +82,7 @@ export function useRecommendedPick(service: CtService, matrix: Step3Matrix): Use
} finally {
setIsPicking(false);
}
}, [allPaymentTermsFilled, suppliersWithOffer, criteriaMap, computedOffers, allPositions, service, setSelectedSupplierId]);
}, [allPaymentTermsFilled, suppliersWithOffer, columnDisplayNames, criteriaMap, computedOffers, allPositions, service, setSelectedSupplierId]);
return { isPicking, pickError, allPaymentTermsFilled, recommendedId, pickRecommendedSupplier };
}
@@ -1,221 +0,0 @@
import { useRef, useState } from 'react';
import { ApprovalMember, ApprovalMemberStatus, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
export type ApprovalAction = 'approve' | 'reject';
export interface UserSuggestion {
spId: number;
displayName: string;
email: string;
}
export interface ActionDialogState {
memberId: string;
memberUserId: string;
memberName: string;
action: ApprovalAction;
comment: string;
}
export interface DelegateDialogState {
memberId: string;
memberOrder: number;
spApprovalId: number;
query: string;
suggestions: UserSuggestion[];
selected?: UserSuggestion;
}
export interface UseStep3ApprovalArgs {
service: CtService;
formData: CTFormData;
userDisplayName: string;
onSave: (partial: Partial<CTFormData>) => void;
onStepComplete: (step: FormStep) => void;
}
// Логика инлайн-цепочки согласования на Шаге 3 (для согласующего, открывшего СТ по Teams-ссылке ?ctid=):
// голосование approve/reject + нотификации, добавление делегата. Работает поверх formData.approvalChain
// через onSave, клубок рефов матрицы не трогает. Перенесено 1:1 из Step3ComparisonTable.
export function useStep3Approval(args: UseStep3ApprovalArgs) {
const { service, formData, userDisplayName, onSave, onStepComplete } = args;
const { ct } = formData;
const [actionDialog, setActionDialog] = useState<ActionDialogState | null>(null);
const [isApprovalSubmitting, setIsApprovalSubmitting] = useState(false);
const [approvalError, setApprovalError] = useState<string | null>(null);
const [notifyWarning, setNotifyWarning] = useState<string | null>(null);
const [delegateDialog, setDelegateDialog] = useState<DelegateDialogState | null>(null);
const [isDelegateSubmitting, setIsDelegateSubmitting] = useState(false);
const [delegateError, setDelegateError] = useState<string | null>(null);
const delegateSearchTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const approvalChain = formData.approvalChain ?? [];
const isOnApproval = ct?.status === 'on_approval' || ct?.status === 'approved';
const currentUserMember = approvalChain.find(
(m) =>
m.userName === userDisplayName &&
m.status === 'pending' &&
m.role !== 'Инициатор' &&
approvalChain.filter((prev) => prev.order < m.order).every((prev) => prev.status !== 'pending'),
);
const handleApprovalConfirm = async (): Promise<void> => {
if (!actionDialog || !ct) return;
const { memberId, action, comment } = actionDialog;
if (action === 'reject' && !comment.trim()) return;
setIsApprovalSubmitting(true);
setApprovalError(null);
const apiStatus = action === 'approve' ? 'approved' : 'rejected';
const currentMember = approvalChain.find((m) => m.id === memberId);
try {
if (!currentMember?.spApprovalId) throw new Error('Не найден ID записи согласования');
await service.submitApprovalStep(currentMember.spApprovalId, apiStatus, comment.trim());
const notify = async (spId: number): Promise<void> => {
try {
await service.triggerApprovalNotification(spId);
} catch (e) {
console.error('[Step3] triggerApprovalNotification failed for spId', spId, e);
setNotifyWarning('Голос записан, но уведомление в Teams не удалось отправить. Проверьте Power Automate.');
}
};
if (action === 'approve') {
const updatedChain = approvalChain.map((m) =>
m.id === memberId
? { ...m, status: 'approved' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
: m,
);
onSave({ approvalChain: updatedChain });
const nextPending = updatedChain.find((m) => m.status === 'pending');
if (nextPending?.spApprovalId) {
await notify(nextPending.spApprovalId);
}
const allApproved = updatedChain.every((m) => m.status === 'approved' || m.status === 'skipped' || m.status === 'recommends');
const initiator = updatedChain.find((m) => m.role === 'Инициатор');
if (allApproved) {
await service.updateCTStatus(ct.id, 'approved');
onSave({ ct: { ...ct, status: 'approved' } });
if (initiator?.spApprovalId) await notify(initiator.spApprovalId);
onStepComplete(4);
} else if (initiator?.spApprovalId) {
await notify(initiator.spApprovalId);
}
} else {
const othersToDelete = approvalChain.filter((m) => m.id !== memberId && m.status === 'pending' && m.spApprovalId);
if (othersToDelete.length > 0) {
await service.deleteApprovalMembers(othersToDelete.map((m) => m.spApprovalId!));
}
const finalChain = approvalChain
.filter((m) => m.status !== 'pending' || m.id === memberId)
.map((m) =>
m.id === memberId
? { ...m, status: 'rejected' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
: m,
);
await service.updateCTStatus(ct.id, 'rejected');
onSave({ approvalChain: finalChain, ct: { ...ct, status: 'rejected' } });
const initiator = finalChain.find((m) => m.role === 'Инициатор');
if (initiator?.spApprovalId) await notify(initiator.spApprovalId);
}
setActionDialog(null);
} catch (err) {
setApprovalError((err as Error).message ?? 'Ошибка выполнения действия');
} finally {
setIsApprovalSubmitting(false);
}
};
const setDelegateQuery = (q: string): void => {
setDelegateDialog((prev) => (prev ? { ...prev, query: q, suggestions: [], selected: undefined } : prev));
if (delegateSearchTimer.current) {
clearTimeout(delegateSearchTimer.current);
}
if (q.trim().length < 2) return;
delegateSearchTimer.current = setTimeout(() => {
service
.searchUsers(q.trim())
.then((res) => setDelegateDialog((prev) => (prev ? { ...prev, suggestions: res } : prev)))
.catch(() => undefined);
}, 300);
};
const selectDelegate = (u: UserSuggestion): void =>
setDelegateDialog((prev) => (prev ? { ...prev, selected: u, query: u.displayName, suggestions: [] } : prev));
const handleAddDelegate = async (): Promise<void> => {
if (!delegateDialog || !ct || !delegateDialog.selected) return;
const delegateUserId = delegateDialog.selected.spId;
const delegateName = delegateDialog.selected.displayName;
setIsDelegateSubmitting(true);
setDelegateError(null);
try {
const newMember = await service.addApprovalDelegate(ct.id, delegateDialog.memberOrder, delegateUserId, delegateName);
const updatedChain = approvalChain
.map((m) => (m.order >= delegateDialog.memberOrder ? { ...m, order: m.order + 1 } : m))
.concat(newMember)
.sort((a, b) => a.order - b.order);
onSave({ approvalChain: updatedChain });
if (newMember.spApprovalId) {
try {
await service.triggerApprovalNotification(newMember.spApprovalId);
} catch {
setNotifyWarning('Делегат добавлен, но уведомление в Teams не удалось отправить. Проверьте Power Automate.');
}
}
setDelegateDialog(null);
} catch (err) {
setDelegateError((err as Error).message ?? 'Ошибка добавления делегата');
} finally {
setIsDelegateSubmitting(false);
}
};
// Удобные открыватели диалогов (view не дёргает сырые сеттеры).
const openApprove = (member: ApprovalMember): void =>
setActionDialog({ memberId: member.id, memberUserId: member.userId, memberName: member.userName, action: 'approve', comment: '' });
const openReject = (member: ApprovalMember): void =>
setActionDialog({ memberId: member.id, memberUserId: member.userId, memberName: member.userName, action: 'reject', comment: '' });
const setActionComment = (val: string): void =>
setActionDialog((prev) => (prev ? { ...prev, comment: val } : null));
const openDelegate = (member: ApprovalMember): void => {
setDelegateDialog({ memberId: member.id, memberOrder: member.order, spApprovalId: member.spApprovalId!, query: '', suggestions: [] });
setDelegateError(null);
};
return {
approvalChain,
isOnApproval,
currentUserMember,
notifyWarning,
setNotifyWarning,
// action dialog
actionDialog,
isApprovalSubmitting,
approvalError,
openApprove,
openReject,
setActionComment,
closeAction: () => setActionDialog(null),
handleApprovalConfirm,
// delegate dialog
delegateDialog,
setDelegateQuery,
selectDelegate,
isDelegateSubmitting,
delegateError,
openDelegate,
closeDelegate: () => setDelegateDialog(null),
handleAddDelegate,
};
}
@@ -1,16 +1,26 @@
import * as React from 'react';
import { useState, useMemo, useCallback, useRef } from 'react';
import { SupplierOffer, CurrencyRate, FormStep } from '../types/domain';
import { SupplierOffer, CurrencyRate, FormStep, SupplierExclusion } from '../types/domain';
import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm';
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
import { buildOfferKey, computeOffer } from './matrixHelpers';
import { applyCreatedIds, applyPositionUnitChange, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
import { parseNumberInput } from '../utils/parseNumberInput';
import { getStep3SaveError } from './step3Validation';
import { Step3SaveMode, buildSuppliersPatch } from './step3SavePatch';
import { IVendorRecord } from '../../services/VendorDatabaseService';
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
import { buildCriteriaRecords, restoreCriteria } from '../utils/criteria';
let extraIdCounter = 0;
export interface UseStep3MatrixArgs {
service: CtService;
formData: CTFormData;
// Таблица только на просмотр — режим формы (getCtAccess), а не признак из сессии.
isReadOnly: boolean;
onSave: (partial: Partial<CTFormData>) => void;
onNext: () => void;
onStepComplete: (step: FormStep) => void;
@@ -20,11 +30,9 @@ export interface UseStep3MatrixArgs {
// ВЕРБАТИМ из Step3ComparisonTable (§стайл-гайд: логика вне вёрстки). Клубок рефов сохраняет
// несохранённые правки при переходах шагов и F5 (unmount/beforeunload-persist).
export function useStep3Matrix(args: UseStep3MatrixArgs) {
const { service, formData, onSave, onNext, onStepComplete } = args;
const { service, formData, isReadOnly, onSave, onNext, onStepComplete } = args;
const { selectedPositions, suppliers, offers, currencyRates, ct } = formData;
const isReadOnly = formData.approvalSent;
const [extraSuppliers, setExtraSuppliers] = useState<ExtraSupplier[]>(() => {
const persisted = formData.step3ExtraSuppliers ?? [];
persisted.forEach(es => {
@@ -88,11 +96,19 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
return map;
});
const [selectedSupplierId, setSelectedSupplierId] = useState<string | undefined>(
const [selectedSupplierId, setSelectedSupplierIdState] = useState<string | undefined>(
formData.recommendedSupplierId
);
// Выбор победителя сразу пишем в formData, как статус и исключение поставщика: иначе снятый
// выбор жил только на экране, а после F5 возвращался из сессии или из шапки СТ.
const setSelectedSupplierId = useCallback((id: string | undefined): void => {
setSelectedSupplierIdState(id);
onSave({ recommendedSupplierId: id });
}, [onSave]);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined);
// Когда последний раз сохранили черновик — показываем рядом с кнопкой.
const [draftSavedAt, setDraftSavedAt] = useState<Date | undefined>(undefined);
// Тумблер «Перевести в ед. изм. заявки»: переводит кол-во и цену поставщика в ед. заявки.
// При включении блоки поставщиков показываются как вычисленные значения (без редактирования).
const [translateUnits, setTranslateUnits] = useState(false);
@@ -128,23 +144,18 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
const criteriaMapRef = useRef(criteriaMap);
const customCriteriaRef = useRef(customCriteria);
// Load criteria from SP "Критерии" list by supplier SP IDs
// «Дополнительная информация» из SP «Критерии» этой СТ: условия поставщиков и свои строки. Правки
// из сессии важнее — SP подставляем только туда, где в сессии пусто.
React.useEffect(() => {
const supplierSpIds = suppliers
.filter(s => s.hasOffer && s.spId !== undefined)
.map(s => s.spId as number);
if (supplierSpIds.length === 0) return;
service.loadCriteria(supplierSpIds)
.then(rawCriteria => {
const spMap: Record<string, CriteriaEntry> = {};
rawCriteria.forEach(c => {
const sup = suppliers.find(s => s.spId === c.supplierSpId);
if (sup) spMap[sup.id] = { paymentType: c.paymentType, deliveryType: c.deliveryType, deadline: c.deadline, sertificates: c.sertificates };
});
// Merge SP data with any user edits from formData — user edits take priority
setCriteriaMap(prev => ({ ...spMap, ...prev }));
if (!ct?.id) return;
const columns = suppliers.filter(s => s.hasOffer !== false);
service.loadCriteriaForCt(ct.id)
.then(records => {
const restored = restoreCriteria(records, columns);
setCriteriaMap(prev => ({ ...restored.criteriaMap, ...prev }));
setCustomCriteria(prev => (prev.length > 0 ? prev : restored.customCriteria));
})
.catch(() => undefined);
.catch(err => console.warn('[Шаг 3] не удалось загрузить доп. информацию:', err));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -164,7 +175,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
const newEdits: EditableOffers = {};
rawOffers.forEach(raw => {
const pos = allLoadPositions.find(p => p.spId === raw.positionSpId);
const sup = suppliers.find(s => s.spId === raw.supplierSpId);
const sup = findColumnForRecord(suppliers, raw.supplierSpId, raw.offerFileSpId);
if (!pos || !sup) return;
const key = buildOfferKey(sup.id, pos.id);
const item: CellEdit = {
@@ -220,7 +231,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
const merged = currentOffers.map((o: SupplierOffer) => {
const sup = suppliersRef.current.find(s => s.id === o.supplierId);
const pos = selectedPositionsRef.current.find(p => p.id === o.positionId);
const supSpId = sup?.spId !== undefined ? String(sup.spId) : undefined;
// Сверка по SP Id поставщика — только для старых колонок без файла КП: у поставщика с
// несколькими КП она подхватила бы оффер чужой колонки.
const supSpId = sup?.spId !== undefined && sup.kpFileSpId === undefined ? String(sup.spId) : undefined;
const posSpId = pos?.spId !== undefined ? String(pos.spId) : undefined;
const prev = prevOffers.find((p: SupplierOffer) =>
(p.supplierId === o.supplierId || (supSpId !== undefined && p.supplierId === supSpId)) &&
@@ -273,8 +286,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
});
const qtyOverrides: Record<string, number> = {};
Object.keys(posQtyOverridesRef.current).forEach(id => {
const v = parseFloat(posQtyOverridesRef.current[id]);
if (!isNaN(v)) qtyOverrides[id] = v;
const v = parseNumberInput(posQtyOverridesRef.current[id]);
if (v !== undefined) qtyOverrides[id] = v;
});
onSaveRef.current({
offers: updatedOffers,
@@ -288,11 +301,42 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const suppliersWithOffer = useMemo(() => [
...suppliers.map(s => ({ ...s, isExtraSupplier: false as const, isNoOffer: !s.hasOffer })),
...extraSuppliers.map(es => ({ id: es.id, name: es.name, hasOffer: true as const, isExtraSupplier: true as const, isNoOffer: false as const })),
// Все колонки поставщиков с КП, включая исключённых из сравнения (docs/adr/0001).
const allColumns = useMemo(() => [
// Поставщики без КП с Шага 2 (hasOffer:false) в таблицу сравнения не попадают — сравнивать
// у них нечего. Ручной ввод цен остаётся за поставщиками, добавленными на самом Шаге 3 (extras).
...suppliers
.filter(s => s.hasOffer !== false)
.map(s => ({ ...s, isExtraSupplier: false as const })),
...extraSuppliers.map(es => ({
id: es.id, name: es.name, hasOffer: true as const, isExtraSupplier: true as const,
// spId/bin известны сразу для поставщиков, добавленных из «База данных поставщиков»
// (см. handleAddSupplierFromDatabase) — без этого useAdataCheck не увидит их БИН.
spId: es.spId, bin: es.bin, exclusion: es.exclusion,
})),
], [suppliers, extraSuppliers]);
// Сравниваются только неисключённые: от этого списка считаются таблица, итоги, минимумы,
// рекомендация, ИИ и Adata. Исключённые живут отдельно — их цены остаются в edits.
const suppliersWithOffer = useMemo(() => allColumns.filter(c => !c.exclusion), [allColumns]);
const excludedColumns = useMemo(
() => allColumns.filter((c): c is typeof c & { exclusion: SupplierExclusion } => c.exclusion !== undefined),
[allColumns],
);
// У поставщика может быть несколько КП (колонок) — подписываем их файлом КП, а в текстах и ИИ
// используем различимые имена «Название (файл)».
// По всем колонкам, включая исключённых: у исключённой колонки поставщика с двумя КП в списке
// «Исключены из сравнения» тоже нужна подпись файла.
const columnLabels = useMemo(
() => buildColumnLabels(allColumns, formData.uploadedKpFiles ?? []),
[allColumns, formData.uploadedKpFiles],
);
const columnDisplayNames = useMemo(
() => buildColumnDisplayNames(allColumns, columnLabels),
[allColumns, columnLabels],
);
const allPositions = useMemo((): PositionRow[] => {
// Extra positions take priority: if a selected position shares a name with an extra, hide it from the regular section
const extraNames = new Set(extraPositions.map(ep => ep.materialName.toLowerCase().trim()));
@@ -327,47 +371,35 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
return m;
}, [suppliers, offers, extraSuppliers]);
const computedOffers = useMemo((): SupplierOffer[] => {
const result: SupplierOffer[] = [];
suppliersWithOffer.forEach(s => {
allPositions.forEach(pos => {
const key = buildOfferKey(s.id, pos.id);
const items = edits[key] ?? [{ supplierId: s.id, positionId: pos.id, itemIndex: 0 }];
const currency = supplierCurrencies[s.id] ?? 'KZT';
const rate = currency !== 'KZT'
? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1)
: 1;
// For noOffer/extra suppliers quantity may be undefined in edits (user only entered price).
// Fall back to the position's own quantity so totalWithExtraKzt stays non-zero and the
// offer is included in filteredComputed → persisted in offersForSession → restored on remount.
const posQtyFallback = (s.isNoOffer || s.isExtraSupplier) ? (pos.quantity || undefined) : undefined;
items.forEach(item => result.push(computeOffer(
{
...item,
unit: item.unit || pos.unit,
supplierProductName: item.supplierProductName || pos.materialName,
quantity: item.quantity ?? posQtyFallback,
},
currency, rate
)));
});
});
return result;
}, [edits, supplierCurrencies, currencyRates, allPositions, suppliersWithOffer]);
const computedOffers = useMemo(
(): SupplierOffer[] => computeColumnsOffers({
columns: suppliersWithOffer, positions: allPositions, edits, supplierCurrencies, currencyRates,
}),
[edits, supplierCurrencies, currencyRates, allPositions, suppliersWithOffer],
);
const excludedOffers = useMemo(
(): SupplierOffer[] => computeColumnsOffers({
columns: excludedColumns, positions: allPositions, edits, supplierCurrencies, currencyRates,
}),
[edits, supplierCurrencies, currencyRates, allPositions, excludedColumns],
);
React.useEffect(() => { computedOffersRef.current = computedOffers; }, [computedOffers]);
// F5 до «Сохранить» не должен терять и цены исключённых — иначе «Вернуть» вернёт пустую колонку.
React.useEffect(() => { computedOffersRef.current = [...computedOffers, ...excludedOffers]; }, [computedOffers, excludedOffers]);
// Суммы по (поставщик, позиция) — один источник и для минимума, и для подсветки ячеек (п.13).
const positionTotals = useMemo(() => sumPositionTotals(computedOffers), [computedOffers]);
const minTotalByPosition = useMemo((): Map<string, number> => {
const map = new Map<string, number>();
allPositions.forEach(pos => {
const values = suppliersWithOffer
.map(s => computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id)
.reduce((sum, o) => sum + (o.totalWithExtraKzt ?? 0), 0))
.map(s => positionTotals.get(buildOfferKey(s.id, pos.id)) ?? 0)
.filter(v => v > 0);
if (values.length > 0) map.set(pos.id, Math.min(...values));
});
return map;
}, [computedOffers, allPositions, suppliersWithOffer]);
}, [positionTotals, allPositions, suppliersWithOffer]);
const updateEdit = useCallback(
(supplierId: string, positionId: string, rowIdx: number, field: 'pricePerUnit' | 'quantity', value: string) => {
@@ -378,8 +410,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
setEdits(prev => {
const pos = allPositionsRef.current.find(p => p.id === positionId);
const items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }];
const parsed = parseFloat(value.replace(',', '.'));
const numVal = isNaN(parsed) ? 0 : Math.round(parsed * 1000) / 1000;
const parsed = parseNumberInput(value);
const numVal = parsed === undefined ? 0 : Math.round(parsed * 1000) / 1000;
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, [field]: numVal } : it) };
});
},
@@ -395,8 +427,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
setEdits(prev => {
const pos = allPositionsRef.current.find(p => p.id === positionId);
const items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }];
const parsed = parseFloat(value.replace(',', '.'));
const override = isNaN(parsed) ? undefined : Math.round(parsed * 1000) / 1000;
const parsed = parseNumberInput(value);
const override = parsed === undefined ? undefined : Math.round(parsed * 1000) / 1000;
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, priceKztOverride: override } : it) };
});
},
@@ -452,26 +484,29 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
[isReadOnly]
);
const handleDeleteSupplier = useCallback((supplierId: string, isExtra: boolean): void => {
// Исключение вместо удаления (docs/adr/0001): колонка скрывается, цены остаются в edits.
// undefined — вернуть поставщика в сравнение. Пишем сразу в formData, как статус поставщика.
const setSupplierExclusion = useCallback((supplierId: string, exclusion: SupplierExclusion | undefined): void => {
if (isReadOnly) return;
if (isExtra) {
const updated = extraSuppliers.filter(es => es.id !== supplierId);
setExtraSuppliers(updated);
setEdits(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => { if (k.startsWith(`${supplierId}::`)) delete next[k]; });
return next;
});
onSave({ step3ExtraSuppliers: updated });
if (suppliers.some(s => s.id === supplierId)) {
onSave({ suppliers: suppliers.map(s => (s.id === supplierId ? { ...s, exclusion } : s)) });
} else {
setEdits(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => { if (k.startsWith(`${supplierId}::`)) delete next[k]; });
return next;
});
onSave({ suppliers: suppliers.filter(s => s.id !== supplierId) });
const updated = extraSuppliers.map(es => (es.id === supplierId ? { ...es, exclusion } : es));
setExtraSuppliers(updated);
onSave({ step3ExtraSuppliers: updated });
}
}, [isReadOnly, extraSuppliers, suppliers, onSave]);
// Исключённый не может остаться выбранным — иначе он уйдёт победителем в шапку СТ.
if (exclusion && selectedSupplierId === supplierId) setSelectedSupplierId(undefined);
}, [isReadOnly, suppliers, extraSuppliers, selectedSupplierId, setSelectedSupplierId, onSave]);
const handleExcludeSupplier = useCallback((supplierId: string, reason: string): void => {
if (!reason.trim()) return;
setSupplierExclusion(supplierId, { reason: reason.trim() });
}, [setSupplierExclusion]);
const handleRestoreSupplier = useCallback((supplierId: string): void => {
setSupplierExclusion(supplierId, undefined);
}, [setSupplierExclusion]);
const updatePosQty = useCallback((positionId: string, value: string): void => {
if (isReadOnly) return;
@@ -552,16 +587,75 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
}
};
// Поставщик, добавленный на Шаге 3 вручную (без КП). Общая точка для обеих кнопок добавления:
// блока над таблицей и колонки «+ Добавить поставщика» в самой таблице.
const addManualSupplier = (name: string, currency: string): void => {
if (isReadOnly || !name.trim()) return;
const id = `extra_sup_${++extraIdCounter}`;
const updated = [...extraSuppliers, { id, name: name.trim(), currency }];
setExtraSuppliers(updated);
setSupplierStatuses(prev => ({ ...prev, [id]: '' }));
onSave({ step3ExtraSuppliers: updated });
};
// Блок над таблицей: добавить из своих полей и очистить их.
const handleAddSupplier = (): void => {
if (!newSupplierName.trim()) return;
addManualSupplier(newSupplierName, newSupplierCurrency);
setNewSupplierName('');
setNewSupplierCurrency('KZT');
};
// Добавление поставщика из VendorPickerDialog («База данных поставщиков»): та же механика
// колонки, что и handleAddSupplier, но с известными БИН/контактами. В отличие от ручного ввода,
// сразу (не дожидаясь «Сохранить и продолжить») пишем запись в SP «Поставщики» — по аналогии
// с потоком «поставщик с КП», который тоже сохраняет eagerly. Это даёт непустой БИН сразу, и
// кнопка «Проверить всех» (Adata) становится доступна без ожидания сохранения формы.
const handleAddSupplierFromDatabase = (vendor: IVendorRecord): string => {
const id = `extra_sup_${++extraIdCounter}`;
const newEntry = { id, name: newSupplierName.trim(), currency: newSupplierCurrency };
const address = vendor.legalAddress || vendor.actualAddress || undefined;
const newEntry: ExtraSupplier = {
id,
name: vendor.name,
currency: 'KZT',
bin: vendor.bin || undefined,
address,
contactPerson: vendor.contactPerson || undefined,
contactInfo: vendor.contactInfo || undefined,
email: vendor.email || undefined,
website: vendor.website || undefined,
};
const updated = [...extraSuppliers, newEntry];
setExtraSuppliers(updated);
setSupplierStatuses(prev => ({ ...prev, [id]: '' }));
setNewSupplierName('');
setNewSupplierCurrency('KZT');
onSave({ step3ExtraSuppliers: updated });
void (async (): Promise<void> => {
try {
const { spId, offerId } = await service.saveManualSupplierWithKp(vendor.name);
await service.saveSupplierContacts(spId, {
bin: vendor.bin || undefined,
address,
contactPerson: vendor.contactPerson || undefined,
contactInfo: vendor.contactInfo || undefined,
email: vendor.email || undefined,
website: vendor.website || undefined,
});
setExtraSuppliers(prev => {
const next = prev.map(es => (es.id === id ? { ...es, spId, offerId } : es));
onSave({ step3ExtraSuppliers: next });
return next;
});
} catch (err) {
// Колонку не блокируем — при неудаче запись в SP создастся позже как обычно при handleSave
// (уже без предзаполненных БИН/контактов, как для вручную введённого поставщика).
setSaveError(
`Не удалось сразу сохранить «${vendor.name}» в SharePoint — данные досохранятся при нажатии «Сохранить и продолжить». (${(err as Error).message})`
);
}
})();
return id;
};
const handleAddPosition = (): void => {
@@ -585,15 +679,26 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
};
const handleDeliveryKmChange = (posId: string, supId: string, val: string): void => {
const km = parseFloat(val);
const km = parseNumberInput(val);
const key = `${posId}::${supId}`;
const updated = isNaN(km)
const updated = km === undefined
? (({ [key]: _, ...rest }) => rest)(deliveryKm)
: { ...deliveryKm, [key]: km };
setDeliveryKm(updated);
onSave({ step3DeliveryKm: updated });
};
// Своя единица доп. позиции вместо заданной при добавлении (у логистики — «рейс»). Поставщики, у
// которых единицу не меняли, получают новую. В SP уходит при «Сохранить» (см. handleSave).
const handleExtraPositionUnitChange = (positionId: string, unit: string): void => {
if (isReadOnly) return;
const oldUnit = extraPositions.find(ep => ep.id === positionId)?.unit ?? '';
const updated = extraPositions.map(ep => (ep.id === positionId ? { ...ep, unit } : ep));
setExtraPositions(updated);
setEdits(prev => applyPositionUnitChange(prev, positionId, oldUnit, unit));
onSave({ step3ExtraPositions: updated });
};
const handleDeleteExtraPosition = (positionId: string): void => {
if (isReadOnly) return;
const updated = extraPositions.filter(ep => ep.id !== positionId);
@@ -608,87 +713,119 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
onSave({ step3ExtraPositions: updated });
};
const handleSave = async (): Promise<void> => {
// Созданные на «Сохранить» поставщиков и доп. строки запоминаем сразу — в стейт и сессию. Раньше их
// spId попадали в стейт только в самом конце handleSave: упади saveOffers — повторное «Сохранить»
// создало бы в SP ещё одну строку «Логистика» / ещё одного поставщика.
const rememberCreatedExtras = (
supSpIds: Record<string, number>,
offerIds: Record<string, number>,
posSpIds: Record<string, number>,
): void => {
const supplierPatches: Record<string, Partial<ExtraSupplier>> = {};
Object.keys(supSpIds).forEach(id => { supplierPatches[id] = { spId: supSpIds[id], offerId: offerIds[id] }; });
const positionPatches: Record<string, Partial<ExtraPosition>> = {};
Object.keys(posSpIds).forEach(id => { positionPatches[id] = { spId: posSpIds[id] }; });
const nextSuppliers = applyCreatedIds(extraSuppliers, supplierPatches);
const nextPositions = applyCreatedIds(extraPositions, positionPatches);
setExtraSuppliers(nextSuppliers);
setExtraPositions(nextPositions);
onSave({ step3ExtraSuppliers: nextSuppliers, step3ExtraPositions: nextPositions });
};
// Проверка перед «Сохранить и продолжить». Показывает ошибку и говорит, можно ли сохранять.
// Вызывается и до диалога «Комментарий от ИИ» — чтобы не гонять пользователя через ИИ к отказу.
const validateBeforeSave = (): boolean => {
const error = getStep3SaveError({
selectedSupplierId,
comparedColumnIds: suppliersWithOffer.map(s => s.id),
hasMissingPaymentTerms: suppliersWithOffer.some(s => !(criteriaMap[s.id]?.paymentType ?? '').trim()),
hasDeliveryWithoutKm: extraPositions.some(ep =>
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
),
});
setSaveError(error);
return error === undefined;
};
// Запись Шага 3 в SP. 'next' — с проверками и переходом на Шаг 4; 'draft' — без проверок,
// остаёмся на шаге (черновик: сохранить недоделанное, чтобы не потерять при закрытии вкладки).
const handleSave = async (mode: Step3SaveMode = 'next'): Promise<void> => {
if (!ct) return;
const deliveryMissingKm = extraPositions.filter(ep =>
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
);
if (deliveryMissingKm.length > 0) {
setSaveError('Укажите километраж для каждого потенциального поставщика в строке логистики');
return;
}
if (mode === 'next' && !validateBeforeSave()) return;
setIsSaving(true);
setSaveError(undefined);
try {
const extraSupSpIds: Record<string, number> = {};
const extraOfferIds: Record<string, number> = {};
for (const es of extraSuppliers) {
if (es.spId) {
// Already created in SP on a previous save — reuse to avoid duplicates.
extraSupSpIds[es.id] = es.spId;
continue;
}
const { spId, offerId } = await service.saveManualSupplierWithKp(es.name);
extraSupSpIds[es.id] = spId;
extraOfferIds[es.id] = offerId;
}
const noOfferSupSpIds: Record<string, number> = {};
const noOfferOfferIds: Record<string, number> = {};
for (const s of suppliers.filter(sup => !sup.hasOffer)) {
const hasData = computedOffers.some(o => o.supplierId === s.id && o.totalWithExtraKzt > 0);
if (!hasData) continue;
if (s.spId) {
noOfferSupSpIds[s.id] = s.spId;
continue;
}
const { spId, offerId } = await service.saveManualSupplierWithKp(s.name);
noOfferSupSpIds[s.id] = spId;
noOfferOfferIds[s.id] = offerId;
}
const extraPosSpIds: Record<string, number> = {};
for (const ep of extraPositions) {
if (ep.spId) {
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
extraPosSpIds[ep.id] = ep.spId;
continue;
try {
for (const es of extraSuppliers) {
if (es.spId) {
// Already created in SP — either on a previous save, or eagerly by
// handleAddSupplierFromDatabase (which also sets es.offerId). Reuse to avoid duplicates.
extraSupSpIds[es.id] = es.spId;
if (es.offerId) extraOfferIds[es.id] = es.offerId;
continue;
}
const { spId, offerId } = await service.saveManualSupplierWithKp(es.name);
extraSupSpIds[es.id] = spId;
extraOfferIds[es.id] = offerId;
}
const saved = await service.createCTPositions(ct.id, [{
id: ep.id,
requestId: '',
requestNumber: '',
materialName: ep.materialName,
disciplineCode: '',
smrType: '',
unit: ep.unit,
agskCode: '',
uniqueCode: '',
quantity: 0,
positionType: 'Дополнительные затраты',
deadline: '',
}]);
if (saved[0]?.spId) extraPosSpIds[ep.id] = saved[0].spId;
for (const ep of extraPositions) {
if (ep.spId) {
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
extraPosSpIds[ep.id] = ep.spId;
// Единицу могли поменять после создания. Пишем всегда — строк доп. расходов единицы,
// а отслеживать «менялась ли» — лишнее состояние.
await service.updateCTPositionUnit(ep.spId, ep.unit);
continue;
}
const saved = await service.createCTPositions(ct.id, [{
id: ep.id,
requestId: '',
requestNumber: '',
materialName: ep.materialName,
disciplineCode: '',
smrType: '',
unit: ep.unit,
agskCode: '',
uniqueCode: '',
quantity: 0,
positionType: 'Дополнительные затраты',
deadline: '',
}]);
if (saved[0]?.spId) extraPosSpIds[ep.id] = saved[0].spId;
}
} finally {
rememberCreatedExtras(extraSupSpIds, extraOfferIds, extraPosSpIds);
}
const offersToSave = computedOffers
.filter(o => o.totalWithExtraKzt > 0)
.map(o => {
const supSpId = extraSupSpIds[o.supplierId] ?? noOfferSupSpIds[o.supplierId];
const supSpId = extraSupSpIds[o.supplierId];
const posSpId = extraPosSpIds[o.positionId];
const existingSup = suppliers.find(s => s.id === o.supplierId);
const existingPos = selectedPositions.find(p => p.id === o.positionId);
const supSpIdStr = existingSup?.spId !== undefined ? String(existingSup.spId) : undefined;
// Сверка по SP Id поставщика — только для старых колонок без файла КП (см. beforeunload выше).
const supSpIdStr = existingSup?.spId !== undefined && existingSup.kpFileSpId === undefined
? String(existingSup.spId)
: undefined;
const posSpIdStr = existingPos?.spId !== undefined ? String(existingPos.spId) : undefined;
const originalOffer = offers.find(orig =>
(orig.supplierId === o.supplierId || (supSpIdStr !== undefined && orig.supplierId === supSpIdStr)) &&
(orig.positionId === o.positionId || (posSpIdStr !== undefined && orig.positionId === posSpIdStr)) &&
(orig.itemIndex ?? 0) === (o.itemIndex ?? 0)
);
const kpFileOfferId = existingSup
? (formData.uploadedKpFiles ?? []).find(f => f.supplierName === existingSup.name)?.spId
: undefined;
// Файл КП колонки известен напрямую; поиск по имени поставщика — только для старых колонок
// (у поставщика с несколькими КП он отдал бы обеим колонкам первый файл).
const kpFileOfferId = existingSup?.kpFileSpId
?? (existingSup
? (formData.uploadedKpFiles ?? []).find(f => f.supplierName === existingSup.name)?.spId
: undefined);
const isDeliveryPos = extraPositions.find(ep => ep.id === o.positionId)?.isDelivery;
const kmVal = isDeliveryPos ? deliveryKm[`${o.positionId}::${o.supplierId}`] : undefined;
return {
@@ -701,47 +838,84 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
: (existingPos?.spId ? String(existingPos.spId) : o.positionId),
offerId: originalOffer?.offerId
?? (extraOfferIds[o.supplierId] !== undefined ? String(extraOfferIds[o.supplierId]) : undefined)
?? (noOfferOfferIds[o.supplierId] !== undefined ? String(noOfferOfferIds[o.supplierId]) : undefined)
?? (kpFileOfferId !== undefined ? String(kpFileOfferId) : undefined),
...(kmVal !== undefined ? { deliveryDistanceKm: kmVal } : {}),
};
});
await service.saveOffers(offersToSave, currencyRates);
// Исключённые — в свой список, не в «Оценку поставщиков»: отчёт СТ и INCORE их не видят.
await service.replaceExcludedOffers(ct.id, buildExcludedRecordsForSave(
excludedColumns.map(column => {
const existingSup = suppliers.find(s => s.id === column.id);
return {
id: column.id,
exclusion: column.exclusion,
supplierSpId: existingSup?.spId ?? extraSupSpIds[column.id],
// Как у обычных колонок: файл КП колонки, у старых — по имени, у добавленных на Шаге 3 —
// созданный для них файл-заглушка.
offerFileSpId: existingSup
? (existingSup.kpFileSpId ?? (formData.uploadedKpFiles ?? []).find(f => f.supplierName === existingSup.name)?.spId)
: extraOfferIds[column.id],
};
}),
excludedOffers,
{
positionSpIdOf: positionId =>
selectedPositions.find(p => p.id === positionId)?.spId ?? extraPosSpIds[positionId],
valutaSpIdOf: code => {
const id = parseInt(currencyRates.find(r => r.code === code)?.id ?? '', 10);
return isNaN(id) ? undefined : id;
},
deliveryKmOf: (positionId, columnId) => deliveryKm[`${positionId}::${columnId}`],
},
));
// «Не получены КП» — к этой СТ, до согласования: шаг 2 могли пройти крошками, минуя его «Сохранить».
await service.syncNoOfferSuppliers(ct.id, getNoOfferSuppliers(suppliers));
// «Дополнительная информация» — в SP: иначе согласующий и PDF видят только то, что распознал ИИ.
await service.replaceCriteria(ct.id, buildCriteriaRecords(
suppliersWithOffer,
criteriaMap,
customCriteria,
column => column.spId ?? extraSupSpIds[column.id],
));
// Поставщик-победитель → winSupplier в шапке СТ (лукап на «Поставщики»). Пишем сразу по
// «Сохранить и продолжить», а не на Шаге 4 — там меняется только статус, а поставщик мог
// прийти как обычной колонкой (spId уже есть), так и добавленным на этом шаге (spId
// появляется только что, в extraSupSpIds).
if (selectedSupplierId) {
const winSupplierSpId = suppliers.find(s => s.id === selectedSupplierId)?.spId
?? extraSupSpIds[selectedSupplierId];
if (winSupplierSpId !== undefined) {
await service.saveWinSupplier(ct.id, winSupplierSpId);
}
}
// Persist offers with React IDs (sup.id / pos.id) so they survive F5 / step navigation —
// render keys use React IDs, so SP IDs in session cause empty cells on remount.
// Keep offerId from offersToSave (order matches: both derived from same filtered computedOffers).
const filteredComputed = computedOffers.filter(o => o.totalWithExtraKzt > 0);
const offersForSession = filteredComputed.map((o, i) => ({
...o,
offerId: offersToSave[i]?.offerId,
}));
const offersForSession = [
...filteredComputed.map((o, i) => ({
...o,
offerId: offersToSave[i]?.offerId,
})),
// Цены исключённых — тоже в сессию: «Вернуть» после перехода по шагам возвращает их колонку.
...excludedOffers.filter(o => o.totalWithExtraKzt > 0),
];
onSave({
offers: offersForSession,
suppliers: [
...suppliers.map(s => ({
...s,
status: supplierStatuses[s.id] ?? s.status,
...(noOfferSupSpIds[s.id] !== undefined ? { spId: noOfferSupSpIds[s.id] } : {}),
})),
// Move extras into suppliers (with React ID kept as `id` — required so formData.offers
// entries with React IDs still match sup.id on remount). Skip extras that are already in
// formData.suppliers from a prior save (avoids duplicate columns).
...extraSuppliers
.filter(es => !suppliers.some(s => s.id === es.id))
.map(es => ({
id: es.id,
spId: extraSupSpIds[es.id],
name: es.name,
hasOffer: true as const,
status: supplierStatuses[es.id] ?? '',
})),
],
// Поставщики Шага 3 переезжают в suppliers только при переходе на Шаг 4 (см. buildSuppliersPatch).
...buildSuppliersPatch({
mode,
suppliers,
extraSuppliers,
statuses: supplierStatuses,
extraSupplierSpIds: extraSupSpIds,
}),
recommendedSupplierId: selectedSupplierId,
// Extras moved into `suppliers` — clear extras state so `suppliersWithOffer` doesn't duplicate columns.
step3ExtraSuppliers: [],
// Positions don't migrate into selectedPositions — keep them in extras state so the
// "доп.расходы" rows continue to render after Step 4 → breadcrumb back. Track spId to
// skip re-creating SP rows on subsequent saves.
@@ -750,14 +924,18 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
spId: extraPosSpIds[ep.id] ?? ep.spId,
})),
step3PositionQtyOverrides: Object.keys(posQtyOverrides).reduce<Record<string, number>>((acc, id) => {
const v = parseFloat(posQtyOverrides[id]);
if (!isNaN(v)) acc[id] = v;
const v = parseNumberInput(posQtyOverrides[id]);
if (v !== undefined) acc[id] = v;
return acc;
}, {}),
step3DeliveryKm: deliveryKm,
step3CriteriaEdits: criteriaMap,
step3CustomCriteria: customCriteria,
});
if (mode === 'draft') {
setDraftSavedAt(new Date());
return;
}
saveCompletedRef.current = true;
onStepComplete(3);
onNext();
@@ -803,11 +981,14 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
setSaveError,
// derived
suppliersWithOffer,
columnLabels,
columnDisplayNames,
allPositions,
regularPositions,
extraPositionsRows,
supplierCurrencies,
computedOffers,
positionTotals,
minTotalByPosition,
// handlers
updateEdit,
@@ -816,7 +997,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
updateSupplierProductName,
addBundleItem,
removeBundleItem,
handleDeleteSupplier,
excludedColumns,
handleExcludeSupplier,
handleRestoreSupplier,
updatePosQty,
updateCriteriaValue,
updateCustomCriteriaValue,
@@ -824,12 +1007,17 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
handleRemoveCustomCriteria,
handleNameColResizeMouseDown,
handleStatusChange,
addManualSupplier,
handleAddSupplier,
handleAddSupplierFromDatabase,
handleAddPosition,
handleAddDelivery,
handleDeliveryKmChange,
handleDeleteExtraPosition,
handleExtraPositionUnitChange,
validateBeforeSave,
handleSave,
draftSavedAt,
};
}
@@ -1,4 +1,5 @@
@import '../common/theme';
.root {
padding: 24px;
display: flex;
@@ -6,253 +7,6 @@
gap: 24px;
}
.section {
display: flex;
flex-direction: column;
gap: 14px;
}
.sectionTitle {
font-size: 15px;
font-weight: 600;
color: $text;
margin: 0;
padding-bottom: 8px;
border-bottom: 1px solid $border;
}
.emptyChain {
font-size: 13px;
color: $muted-2;
font-style: italic;
margin: 0;
padding: 16px 0;
}
.chainList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 860px;
}
.chainItem {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: $surface;
border: 1px solid $border;
border-radius: 4px;
flex-wrap: wrap;
&:hover {
background: $head-bg;
}
}
.orderBadge {
width: 28px;
height: 28px;
border-radius: 50%;
background: $border;
color: $muted;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
.memberInfo {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 160px;
}
.memberName {
font-size: 13px;
font-weight: 600;
color: $text;
}
.memberRole {
font-size: 12px;
color: $muted;
background: $hover;
padding: 1px 6px;
border-radius: 3px;
}
.statusBadge {
font-size: 11px;
font-weight: 600;
padding: 2px 9px;
border-radius: 10px;
white-space: nowrap;
flex-shrink: 0;
}
.statusPending {
background: $hover;
color: $muted;
}
.statusApproved {
background: $ok-bg;
color: $ok;
}
.statusRejected {
background: $danger-bg;
color: $danger;
}
.statusSkipped {
background: $hover;
color: $muted-2;
}
.memberComment {
font-size: 12px;
color: $muted;
font-style: italic;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actionDate {
font-size: 11px;
color: $muted-2;
white-space: nowrap;
}
.memberActions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.approvalActions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.rejectBtn {
color: $danger;
border-color: $danger;
&:hover {
background: $danger-bg;
color: $danger;
}
}
// ─── Add member ─────────────────────────────────────────────────────────────
.addMemberRow {
display: flex;
align-items: flex-start;
gap: 10px;
flex-wrap: nowrap;
max-width: 30%;
}
.memberNameInput {
flex: 2;
min-width: 0;
position: relative;
}
.memberRoleInput {
flex: 1;
min-width: 0;
}
.addRowBtn {
align-self: flex-end;
}
// ─── People search suggestions ───────────────────────────────────────────────
.suggestions {
list-style: none;
margin: 2px 0 0;
padding: 0;
border: 1px solid $border;
border-radius: 4px;
background: $surface;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
position: absolute;
z-index: 100;
width: 100%;
max-height: 200px;
overflow-y: auto;
}
// Модификатор для списка внутри диалога (замена согласующего): absolute-подсказки
// обрезались телом диалога (overflow-y: auto). В потоке список раздвигает диалог,
// а при длинном списке скроллится внутри него.
.suggestionsInline {
position: static;
box-shadow: none;
}
.suggestionItem {
display: flex;
flex-direction: column;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
border-bottom: 1px solid $hover;
&:last-child {
border-bottom: none;
}
&:hover {
background: $hover;
}
}
.suggestionName {
font-weight: 500;
color: $text;
}
.suggestionEmail {
font-size: 11px;
color: $muted-2;
}
// ─── Row action buttons ───────────────────────────────────────────────────────
.replaceBtn {
flex-shrink: 0;
font-size: 12px;
}
.deleteBtn {
color: $danger;
flex-shrink: 0;
&:hover {
background: $danger-bg;
color: $danger;
}
}
// ─── Footer ─────────────────────────────────────────────────────────────────
.footer {
display: flex;
justify-content: space-between;
@@ -270,10 +24,6 @@
flex-wrap: wrap;
}
.dialogError {
margin-top: 12px;
}
.incoreProgress {
display: flex;
align-items: center;
@@ -1,9 +1,19 @@
import * as React from "react";
import { Button, Alert, Spinner, IconButton, Dialog, TextInput, TextArea, Icon, FormField } from "@tnsu/ui-kit";
import { ApprovalMemberStatus, FormStep } from "../types/domain";
import { useEffect, useMemo, useState } from "react";
import { Button, Alert, Spinner, Dialog, Icon } from "@tnsu/ui-kit";
import { CTStatus, FormStep } from "../types/domain";
import { CtService } from "../../services/CtService";
import { CTFormData } from "../hooks/useCtForm";
import { useStep4Approval, IncoreStep } from "./useStep4Approval";
import { useIncore } from "./useIncore";
import { findReworkRow } from "../utils/approvalAccess";
import {
ApprovalMatrix,
DecisionPanel,
IApproverToWrite,
IMatrixRow,
useMatrixEditor,
findUnassignedStep,
} from "../../../../shared/approval";
import styles from "./Step4Approval.module.scss";
interface Props {
@@ -15,15 +25,16 @@ interface Props {
onStepComplete: (step: FormStep) => void;
}
const STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
pending: "На согласовании",
recommends: "Рекомендую",
approved: "Согласовано",
rejected: "Отклонено",
skipped: "Пропущено",
const SUBMITTED_STATUSES: ReadonlySet<CTStatus> = new Set<CTStatus>(["on_approval", "approved", "rejected"]);
const RU_STATUS_TO_CT_STATUS: Record<string, CTStatus> = {
Отклонено: "rejected",
Согласовано: "approved",
"На согласовании": "on_approval",
"На доработке": "rework",
};
const INCORE_STEP_LABELS: Record<IncoreStep, string> = {
const INCORE_STEP_LABELS: Record<string, string> = {
report: "Формирование отчёта СТ…",
connecting: "Подключение к NCALayer…",
signing: "Ожидание подписи в NCALayer…",
@@ -32,229 +43,135 @@ const INCORE_STEP_LABELS: Record<IncoreStep, string> = {
building: "Формирование карточки с ЭЦП…",
};
function getStatusClass(status: ApprovalMemberStatus): string {
const map: Record<ApprovalMemberStatus, string> = {
pending: styles.statusPending,
recommends: styles.statusApproved,
approved: styles.statusApproved,
rejected: styles.statusRejected,
skipped: styles.statusSkipped,
};
return map[status];
}
// Шаг 4 «Согласование» (см. план миграции согласования на approval-route API): маршрут — планируемый
// (до отправки, resolve по email/отделу/компании инициатора) или уже записанный (после отправки).
// Редактирование состава/решения — общие компоненты shared/approval. Постобработка ЭЦП/INCORE — see useIncore.
export const Step4Approval: React.FC<Props> = ({ service, formData, onPrev, onSave, onStepComplete }) => {
const { ct } = formData;
const ctId = ct?.id;
const status: CTStatus = ct?.status ?? "formed";
const isSubmitted = SUBMITTED_STATUSES.has(status);
const isInitiator = !!ct?.authorEmail && ct.authorEmail === service.currentUserEmail;
function formatDate(iso?: string): string {
if (!iso) return "";
return new Date(iso).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const [sending, setSending] = useState<boolean>(false);
const [error, setError] = useState<string | undefined>(undefined);
export const Step4Approval: React.FC<Props> = (props) => {
const { onPrev } = props;
const a = useStep4Approval(props);
const incore = useIncore({ service, formData, isInitiator });
// Возврат на доработку: кто вернул и что исправить — из записанного маршрута. Сам шаг сейчас показывает
// новый планируемый маршрут для повторной отправки (идёт с начала), старых строк в нём не видно.
const [reworkRow, setReworkRow] = useState<IMatrixRow | undefined>(undefined);
useEffect(() => {
if (status !== "rework" || !ctId) {
setReworkRow(undefined);
return;
}
service
.getApprovalMatrixForRequest(ctId)
.then((rows) => setReworkRow(findReworkRow(rows)))
.catch((err) => console.warn("[Шаг 4] не удалось загрузить комментарий возврата на доработку:", err));
}, [service, ctId, status]);
const loadMatrix = useMemo(
() => (): Promise<IMatrixRow[]> =>
isSubmitted && ctId
? service.getApprovalMatrixForRequest(ctId)
: service.getPlannedApprovalMatrix(service.currentUserEmail, formData.initiatorDepartment, formData.initiatorCompany),
[service, ctId, isSubmitted, formData.initiatorDepartment, formData.initiatorCompany]
);
const editor = useMatrixEditor(loadMatrix, `${ctId ?? "draft"}-${decisionSeq}`, !isSubmitted, true, (loginName) =>
service.getJobTitle(loginName)
);
const approverService = useMemo(() => (ctId ? service.getApproverService(ctId) : undefined), [service, ctId]);
async function handleSubmit(): Promise<void> {
if (!ctId || !ct) return;
if (editor.rows.length === 0) {
setError("Маршрут согласования пуст.");
return;
}
const unassigned = findUnassignedStep(editor.rows);
if (unassigned) {
setError(
unassigned.warning ||
`Шаг «${unassigned.position || unassigned.order}» без назначенного согласующего — обратитесь к администратору маршрутов.`
);
return;
}
setSending(true);
setError(undefined);
const approvers: IApproverToWrite[] = editor.rows.map((r) => ({
email: r.email,
position: r.position,
allowReassign: r.allowReassign,
}));
try {
await service.submitApprovalRoute(ctId, approvers);
await service.updateCTStatus(ctId, "on_approval");
onSave({ ct: { ...ct, status: "on_approval" } });
onStepComplete(4);
await service.notifyActiveApprover(ctId).catch(() => undefined);
} catch (e) {
setError((e as Error).message);
} finally {
setSending(false);
}
}
async function handleDecided(newStatus: string): Promise<void> {
if (!ct) return;
onSave({ ct: { ...ct, status: RU_STATUS_TO_CT_STATUS[newStatus] || status } });
setDecisionSeq((n) => n + 1);
if (ctId) {
await service.notifyActiveApprover(ctId).catch(() => undefined);
}
}
async function handleRestart(): Promise<void> {
if (!ctId || !ct) return;
setSending(true);
setError(undefined);
try {
await service.updateCTStatus(ctId, "formed");
onSave({ ct: { ...ct, status: "formed" } });
setDecisionSeq((n) => n + 1);
} catch (e) {
setError((e as Error).message);
} finally {
setSending(false);
}
}
return (
<div className={styles.root}>
{/* Current approval status banner */}
{a.ct?.status === "approved" && <Alert variant="ok">СТ согласована всеми участниками.</Alert>}
{a.ct?.status === "rejected" && (
<Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert>
{status === "approved" && <Alert variant="ok">СТ согласована всеми участниками.</Alert>}
{status === "rejected" && <Alert variant="err">СТ отклонена. Требуется корректировка и повторная отправка.</Alert>}
{status === "on_approval" && <Alert variant="info">СТ находится на согласовании.</Alert>}
{status === "rework" && (
<Alert variant="warn">
СТ возвращена на доработку{reworkRow?.who ? ` (${reworkRow.who})` : ""}.
{reworkRow?.comment ? ` Что исправить: ${reworkRow.comment}.` : ""} Внесите правки и отправьте на согласование
заново — маршрут пойдёт с начала.
</Alert>
)}
{a.ct?.status === "on_approval" && <Alert variant="info">СТ находится на согласовании.</Alert>}
<ApprovalMatrix editor={editor} search={(q) => service.searchUsers(q)} />
{/* Approval chain */}
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Цепочка согласования</h3>
{error && <Alert variant="err">{error}</Alert>}
{a.chain.length === 0 && (
<p className={styles.emptyChain}>
Добавьте участников для согласования
</p>
)}
{a.chain.length > 0 && (
<ol className={styles.chainList}>
{a.chain.map((member, index) => (
<li key={member.id} className={styles.chainItem}>
<span className={styles.orderBadge}>{member.order}</span>
<div className={styles.memberInfo}>
<span className={styles.memberName}>{member.userName}</span>
{member.role && (
<span className={styles.memberRole}>{member.role}</span>
)}
</div>
{a.showStatuses && (
<span
className={[
styles.statusBadge,
getStatusClass(member.status),
].join(" ")}
>
{STATUS_LABELS[member.status]}
</span>
)}
{member.comment && (
<span className={styles.memberComment} title={member.comment}>
«{member.comment}»
</span>
)}
{member.actionDate && (
<span className={styles.actionDate}>
{formatDate(member.actionDate)}
</span>
)}
{!a.isOnApproval &&
member.status === "pending" &&
member.role !== "Инициатор" && (
<div className={styles.memberActions}>
<IconButton
icon="arrow-up"
disabled={index === 0}
onClick={() => a.moveMember(member.id, "up")}
title="Переместить вверх"
/>
<IconButton
icon="arrow-down"
disabled={index === a.chain.length - 1}
onClick={() => a.moveMember(member.id, "down")}
title="Переместить вниз"
/>
</div>
)}
{/* Replace button - non-initiator pending members, both before and during approval */}
{member.role !== "Инициатор" &&
member.status === "pending" &&
!(a.isOnApproval && a.currentUserMember?.id === member.id) && (
<Button variant="secondary" className={styles.replaceBtn} onClick={() => a.openReplace(member)}>
<Icon name="swap" size="sm" />
Заменить
</Button>
)}
{/* Delete button - only before approval, non-initiator */}
{!a.isOnApproval && member.role !== "Инициатор" && (
<IconButton
icon="trash"
danger
className={styles.deleteBtn}
onClick={() => a.handleDeleteMember(member)}
title="Удалить согласующего"
/>
)}
{/* Actions for the current user when it's their turn */}
{a.isOnApproval && a.currentUserMember?.id === member.id && (
<div className={styles.approvalActions}>
<Button variant="primary" onClick={() => a.openActionDialog(member, "approve")}>
<Icon name="check" size="sm" />
Согласовать
</Button>
<Button variant="danger" className={styles.rejectBtn} onClick={() => a.openActionDialog(member, "reject")}>
<Icon name="close" size="sm" />
Отклонить
</Button>
</div>
)}
</li>
))}
</ol>
)}
{/* Add member */}
{!a.isOnApproval && (
<div>
{a.showAddRow ? (
<div className={styles.addMemberRow}>
<div className={styles.memberNameInput}>
<FormField label="Поиск участника">
<TextInput
value={a.addQuery}
onChange={a.handleAddQueryChange}
placeholder="Введите имя для поиска..."
/>
</FormField>
{a.addSuggestions.length > 0 && !a.addSelected && (
<ul className={styles.suggestions}>
{a.addSuggestions.map((s) => (
<li
key={s.spId}
className={styles.suggestionItem}
onClick={() => a.selectAddSuggestion(s)}
>
<span className={styles.suggestionName}>{s.displayName}</span>
<span className={styles.suggestionEmail}>{s.email}</span>
</li>
))}
</ul>
)}
</div>
<div className={styles.memberRoleInput}>
<FormField label="Роль (необязательно)">
<TextInput
value={a.addRole}
onChange={a.setAddRole}
placeholder="Напр.: Руководитель"
/>
</FormField>
</div>
<Button
variant="primary"
className={styles.addRowBtn}
disabled={!a.addSelected || a.isAddingMember}
onClick={a.handleAddMember}
>
{a.isAddingMember ? (
<Spinner size="small" />
) : (
<>
<Icon name="plus" size="sm" />
Добавить
</>
)}
</Button>
<Button variant="secondary" className={styles.addRowBtn} onClick={a.cancelAddRow}>
Отмена
</Button>
</div>
) : (
<Button variant="secondary" onClick={() => a.setShowAddRow(true)}>
<Icon name="plus" size="sm" />
Добавить согласующего
</Button>
)}
</div>
)}
</section>
{/* Footer actions */}
<div className={styles.footer}>
<Button variant="secondary" onClick={onPrev} disabled={a.isOnApproval}>
<Button variant="secondary" onClick={onPrev} disabled={isSubmitted}>
<Icon name="arrow-left" size="sm" />
Назад
</Button>
<div className={styles.footerRight}>
{a.submitError && <Alert variant="err">{a.submitError}</Alert>}
{!a.isOnApproval && (
<Button
variant="primary"
onClick={a.handleSendForApproval}
disabled={a.chain.length === 0 || a.isSubmitting || !a.ct}
>
{a.isSubmitting ? (
{!isSubmitted && (
<Button variant="primary" onClick={handleSubmit} disabled={sending || editor.rows.length === 0}>
{sending ? (
<Spinner size="small" />
) : (
<>
@@ -265,23 +182,21 @@ export const Step4Approval: React.FC<Props> = (props) => {
</Button>
)}
{/* INCORE: согласованная СТ — финал. Есть карточка во вложении → показываем файл;
нет → кнопка формирования. Только для инициатора. */}
{a.isInitiator && a.ct?.status === "approved" && !a.isLoadingCard && (
a.cardAttachment ? (
{isInitiator && status === "approved" && !incore.isLoadingCard && (
incore.cardAttachment ? (
<a
className={styles.cardLink}
href={a.cardAttachment.url}
href={incore.cardAttachment.url}
target="_blank"
rel="noreferrer"
title="Открыть карточку электронного документа"
>
<Icon name="download" size="sm" />
{a.cardAttachment.name}
{incore.cardAttachment.name}
</a>
) : (
<Button variant="secondary" onClick={a.handleSendToIncore} disabled={a.isSendingToIncore}>
{a.isSendingToIncore ? (
<Button variant="secondary" onClick={incore.handleSendToIncore} disabled={incore.isSendingToIncore}>
{incore.isSendingToIncore ? (
<Spinner size="small" />
) : (
<>
@@ -293,9 +208,8 @@ export const Step4Approval: React.FC<Props> = (props) => {
)
)}
{/* Перезапуск — пока СТ на согласовании (ещё не согласована) */}
{a.isOnApproval && a.isInitiator && a.ct?.status !== "approved" && (
<Button variant="secondary" onClick={a.handleRestart} disabled={a.isSubmitting}>
{isSubmitted && isInitiator && status !== "approved" && (
<Button variant="secondary" onClick={handleRestart} disabled={sending}>
<Icon name="refresh" size="sm" />
Перезапустить согласование
</Button>
@@ -303,141 +217,41 @@ export const Step4Approval: React.FC<Props> = (props) => {
</div>
</div>
{/* Replace member dialog */}
{a.replaceDialog && (
<Dialog
open
title="Заменить согласующего"
subText="Найдите нового участника для замены."
onDismiss={a.closeReplace}
footer={
<>
<Button variant="secondary" onClick={a.closeReplace}>
Отмена
</Button>
<Button
variant="primary"
onClick={a.handleReplaceConfirm}
disabled={!a.replaceDialog.selected || a.isReplacing}
>
{a.isReplacing ? <Spinner size="small" /> : "Заменить"}
</Button>
</>
}
>
<div className={styles.memberNameInput}>
<FormField label="Поиск нового участника">
<TextInput
value={a.replaceDialog.query}
onChange={a.handleReplaceQueryChange}
placeholder="Введите имя для поиска..."
/>
</FormField>
{a.replaceDialog.suggestions.length > 0 && !a.replaceDialog.selected && (
<ul className={`${styles.suggestions} ${styles.suggestionsInline}`}>
{a.replaceDialog.suggestions.map((s) => (
<li
key={s.spId}
className={styles.suggestionItem}
onClick={() => a.selectReplaceSuggestion(s)}
>
<span className={styles.suggestionName}>{s.displayName}</span>
<span className={styles.suggestionEmail}>{s.email}</span>
</li>
))}
</ul>
)}
</div>
</Dialog>
{ctId && approverService && (
<DecisionPanel
service={approverService}
requestId={parseInt(ctId, 10)}
onDecided={handleDecided}
search={(q) => service.searchUsers(q)}
onDelegated={() => setDecisionSeq((n) => n + 1)}
allowRework
/>
)}
{/* Approve / Reject dialog */}
{a.actionDialog && (
<Dialog
open
title={a.actionDialog.action === "approve" ? "Согласование" : "Отклонение"}
subText={
a.actionDialog.action === "approve"
? "Вы подтверждаете согласование СТ? Комментарий необязателен."
: "Укажите причину отклонения (обязательно)."
}
onDismiss={a.closeActionDialog}
footer={
<>
<Button variant="secondary" onClick={a.closeActionDialog} disabled={a.isSubmitting}>
Отмена
</Button>
<Button
variant={a.actionDialog.action === "approve" ? "primary" : "danger"}
onClick={a.handleActionConfirm}
disabled={
a.isSubmitting ||
(a.actionDialog.action === "reject" && !a.actionDialog.comment.trim())
}
>
{a.isSubmitting ? (
<Spinner size="small" />
) : a.actionDialog.action === "approve" ? (
"Согласовать"
) : (
"Отклонить"
)}
</Button>
</>
}
>
<TextArea
value={a.actionDialog.comment}
onChange={a.setActionComment}
rows={3}
ariaLabel="Комментарий"
placeholder={
a.actionDialog.action === "reject"
? "Укажите причину отклонения..."
: "Комментарий (необязательно)..."
}
/>
{a.submitError && (
<div className={styles.dialogError}>
<Alert variant="err">{a.submitError}</Alert>
</div>
)}
</Dialog>
)}
{/* INCORE: прогресс подписания и сборки карточки */}
{a.incoreStep && (
<Dialog
open
title="Подписание и выгрузка в INCORE"
onDismiss={() => undefined}
>
{incore.incoreStep && (
<Dialog open title="Подписание и выгрузка в INCORE" onDismiss={() => undefined}>
<div className={styles.incoreProgress}>
<Spinner size="small" />
<span>{INCORE_STEP_LABELS[a.incoreStep]}</span>
<span>{INCORE_STEP_LABELS[incore.incoreStep]}</span>
</div>
{a.incoreStep === "signing" && (
<p className={styles.incoreHint}>
Выберите сертификат ЭЦП и введите PIN-код в открывшемся окне
NCALayer.
</p>
{incore.incoreStep === "signing" && (
<p className={styles.incoreHint}>Выберите сертификат ЭЦП и введите PIN-код в открывшемся окне NCALayer.</p>
)}
</Dialog>
)}
{/* INCORE: ошибка подписания */}
{a.incoreError && (
{incore.incoreError && (
<Dialog
open
title="Ошибка выгрузки в INCORE"
onDismiss={a.dismissIncoreError}
onDismiss={incore.dismissIncoreError}
footer={
<Button variant="secondary" onClick={a.dismissIncoreError}>
<Button variant="secondary" onClick={incore.dismissIncoreError}>
Закрыть
</Button>
}
>
<Alert variant="err">{a.incoreError}</Alert>
<Alert variant="err">{incore.incoreError}</Alert>
</Dialog>
)}
</div>
@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react';
import { CtService } from '../../services/CtService';
import { SigexSignService, SigexSignStep } from '../../services/SigexSignService';
import { CTFormData } from '../hooks/useCtForm';
import { withoutPlaceholders } from '../utils/placeholderKpFile';
// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX.
export type IncoreStep = 'report' | SigexSignStep;
// Подпись через SIGEX без состояния — один экземпляр на модуль.
const sigexSign = new SigexSignService();
export interface UseIncoreArgs {
service: CtService;
formData: CTFormData;
isInitiator: boolean;
}
// Постобработка согласованной СТ — выгрузка пакета документов с ЭЦП в INCORE.
// Вынесено из бывшего useStep4Approval.ts отдельно от согласования (см. план миграции
// согласования на approval-route API — этот блок мехнизм согласования не трогает).
export function useIncore(args: UseIncoreArgs) {
const { service, formData, isInitiator } = args;
const { ct } = formData;
const incoreCardName = ct ? `Карточка_CT_${ct.id}.pdf` : '';
const [isSendingToIncore, setIsSendingToIncore] = useState(false);
const [incoreStep, setIncoreStep] = useState<IncoreStep | null>(null);
const [incoreError, setIncoreError] = useState<string | null>(null);
const [cardAttachment, setCardAttachment] = useState<{ name: string; url: string } | null>(null);
const [isLoadingCard, setIsLoadingCard] = useState(false);
// Согласованная СТ — финал: если карточка уже во вложениях, показываем файл вместо кнопки.
// Грузим только для инициатора и только в статусе «Согласована».
useEffect(() => {
if (ct?.status !== 'approved' || !isInitiator) {
setCardAttachment(null);
return;
}
let cancelled = false;
setIsLoadingCard(true);
service
.findCtCardAttachment(ct.id, `Карточка_CT_${ct.id}.pdf`)
.then((found) => {
if (!cancelled) setCardAttachment(found);
})
.catch(() => {
if (!cancelled) setCardAttachment(null);
})
.finally(() => {
if (!cancelled) setIsLoadingCard(false);
});
return () => {
cancelled = true;
};
}, [ct?.status, ct?.id, isInitiator, service]);
const handleSendToIncore = async (): Promise<void> => {
if (!ct) return;
setIsSendingToIncore(true);
setIncoreError(null);
try {
// 1. Отчёт СТ в PDF — карточка SIGEX визуализирует именно PDF.
setIncoreStep('report');
const reportBlob = await service.getCtReportBlob(ct.id, 'pdf');
const title = `CT_${ct.id}.pdf`;
// 2. Подпись NCALayer → регистрация → фиксация → карточка (DDC).
const cardBlob = await sigexSign.signAndBuildCard(reportBlob, title, setIncoreStep);
// 3. Архивируем карточку во вложения элемента СТ и переключаем UI на показ файла.
const saved = await service.saveCtCardAttachment(ct.id, cardBlob, incoreCardName);
setCardAttachment(saved);
// 4. Пакет для INCORE: карточка с ЭЦП + файлы КП — скачиваем пользователю.
// Заглушки поставщиков, добавленных вручную, — не КП, в пакет не идут.
service.downloadBlob(cardBlob, incoreCardName);
const kpFiles = withoutPlaceholders(formData.uploadedKpFiles ?? []);
for (const f of kpFiles) {
await service.downloadKpFile(f.name);
await new Promise<void>((resolve) => setTimeout(resolve, 300));
}
} catch (err) {
// Пользователь закрыл окно NCALayer, не подписав — это не ошибка.
if ((err as { canceledByUser?: boolean }).canceledByUser) return;
setIncoreError((err as Error).message ?? 'Ошибка подписания и выгрузки пакета');
} finally {
setIncoreStep(null);
setIsSendingToIncore(false);
}
};
return {
isSendingToIncore,
incoreStep,
incoreError,
dismissIncoreError: (): void => setIncoreError(null),
cardAttachment,
isLoadingCard,
handleSendToIncore,
};
}
@@ -1,488 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { ApprovalMember, ApprovalMemberStatus, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { SigexSignService, SigexSignStep } from '../../services/SigexSignService';
import { CTFormData } from '../hooks/useCtForm';
// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX.
export type IncoreStep = 'report' | SigexSignStep;
// Подпись через SIGEX без состояния — один экземпляр на модуль.
const sigexSign = new SigexSignService();
export type ApprovalAction = 'approve' | 'reject';
export interface ActionDialogState {
memberId: string;
memberUserId: string;
memberName: string;
action: ApprovalAction;
comment: string;
}
export type UserSuggestion = { spId: number; displayName: string; email: string };
export interface ReplaceDialogState {
memberId: string;
spApprovalId?: number;
query: string;
selected: { spId: number; displayName: string } | null;
suggestions: UserSuggestion[];
}
export interface UseStep4ApprovalArgs {
service: CtService;
formData: CTFormData;
userDisplayName: string;
onSave: (partial: Partial<CTFormData>) => void;
onStepComplete: (step: FormStep) => void;
}
// Логика Шага 4 «Согласование» (цепочка): манипуляции участниками (порядок/добавление/удаление/замена),
// голосование (approve/reject + нотификации), отправка на согласование, выгрузка в INCORE, перезапуск.
// Перенесено 1:1 из Step4Approval.tsx (§стайл-гайд: логика вне вёрстки).
export function useStep4Approval(args: UseStep4ApprovalArgs) {
const { service, formData, userDisplayName, onSave, onStepComplete } = args;
const { ct } = formData;
const [chain, setChain] = useState<ApprovalMember[]>(formData.approvalChain);
const [actionDialog, setActionDialog] = useState<ActionDialogState | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSendingToIncore, setIsSendingToIncore] = useState(false);
const [incoreStep, setIncoreStep] = useState<IncoreStep | null>(null);
const [incoreError, setIncoreError] = useState<string | null>(null);
// Карточка INCORE во вложениях элемента СТ: если она уже есть — показываем файл, а не кнопку.
const [cardAttachment, setCardAttachment] = useState<{ name: string; url: string } | null>(null);
const [isLoadingCard, setIsLoadingCard] = useState(false);
const incoreCardName = ct ? `Карточка_CT_${ct.id}.pdf` : '';
// ─── Add member ───
const [showAddRow, setShowAddRow] = useState(false);
const [addQuery, setAddQuery] = useState('');
const [addRole, setAddRole] = useState('');
const [addSuggestions, setAddSuggestions] = useState<UserSuggestion[]>([]);
const [addSelected, setAddSelected] = useState<{ spId: number; displayName: string } | null>(null);
const [isAddingMember, setIsAddingMember] = useState(false);
const addTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// ─── Replace member ───
const [replaceDialog, setReplaceDialog] = useState<ReplaceDialogState | null>(null);
const [isReplacing, setIsReplacing] = useState(false);
const replaceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const moveMember = useCallback((id: string, direction: 'up' | 'down'): void => {
const idx = chain.findIndex((m) => m.id === id);
if (idx === -1) return;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= chain.length) return;
const next = [...chain];
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
const reordered = next.map((m, i) => ({ ...m, order: i + 1 }));
setChain(reordered);
// Пишем порядок в formData сразу, иначе переупорядочивание теряется при переходе на другой шаг
// (chain реинициализируется из formData.approvalChain при возврате). В SP порядок фиксируется
// на отправке на согласование (activateApprovalChain).
onSave({ approvalChain: reordered });
}, [chain, onSave]);
const openActionDialog = useCallback((member: ApprovalMember, action: ApprovalAction): void => {
setActionDialog({
memberId: member.id,
memberUserId: member.userId,
memberName: member.userName,
action,
comment: '',
});
}, []);
const closeActionDialog = useCallback((): void => setActionDialog(null), []);
const setActionComment = useCallback((val: string): void => {
setActionDialog((prev) => (prev ? { ...prev, comment: val } : null));
}, []);
const handleAddQueryChange = (val: string): void => {
const q = val;
setAddQuery(q);
setAddSelected(null);
setAddSuggestions([]);
if (addTimerRef.current) clearTimeout(addTimerRef.current);
if (q.length < 2) return;
addTimerRef.current = setTimeout(async () => {
const results = await service.searchUsers(q);
setAddSuggestions(results);
}, 300);
};
const selectAddSuggestion = (s: UserSuggestion): void => {
setAddSelected({ spId: s.spId, displayName: s.displayName });
setAddQuery(s.displayName);
setAddSuggestions([]);
};
const cancelAddRow = (): void => {
setShowAddRow(false);
setAddQuery('');
setAddRole('');
setAddSelected(null);
setAddSuggestions([]);
};
const handleAddMember = async (): Promise<void> => {
if (!addSelected || !ct) return;
setIsAddingMember(true);
setSubmitError(null);
try {
const newOrder = chain.length + 1;
const newMember = await service.addApprovalMemberDraft(
ct.id, newOrder, addSelected.spId, addSelected.displayName, addRole.trim() || undefined,
);
const newChain = [...chain, newMember];
setChain(newChain);
onSave({ approvalChain: newChain });
cancelAddRow();
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка добавления');
} finally {
setIsAddingMember(false);
}
};
const handleDeleteMember = async (member: ApprovalMember): Promise<void> => {
setSubmitError(null);
try {
if (member.spApprovalId) {
await service.deleteApprovalMembers([member.spApprovalId]);
}
const updated = chain
.filter((m) => m.id !== member.id)
.map((m, i) => ({ ...m, order: i + 1 }));
setChain(updated);
onSave({ approvalChain: updated });
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка удаления');
}
};
const openReplace = (member: ApprovalMember): void => {
setReplaceDialog({
memberId: member.id,
spApprovalId: member.spApprovalId,
query: '',
selected: null,
suggestions: [],
});
};
const closeReplace = (): void => setReplaceDialog(null);
const handleReplaceQueryChange = (val: string): void => {
const q = val;
setReplaceDialog((prev) => (prev ? { ...prev, query: q, selected: null, suggestions: [] } : null));
if (replaceTimerRef.current) clearTimeout(replaceTimerRef.current);
if (q.length < 2) return;
replaceTimerRef.current = setTimeout(async () => {
const results = await service.searchUsers(q);
setReplaceDialog((prev) => (prev ? { ...prev, suggestions: results } : null));
}, 300);
};
const selectReplaceSuggestion = (s: UserSuggestion): void => {
setReplaceDialog((prev) =>
prev ? { ...prev, selected: { spId: s.spId, displayName: s.displayName }, query: s.displayName, suggestions: [] } : null,
);
};
const handleReplaceConfirm = async (): Promise<void> => {
if (!replaceDialog?.selected) return;
setIsReplacing(true);
setSubmitError(null);
try {
const { memberId, spApprovalId, selected } = replaceDialog;
if (spApprovalId) {
await service.replaceApprovalMember(spApprovalId, selected.spId);
}
const updated = chain.map((m) =>
m.id === memberId ? { ...m, userId: String(selected.spId), userName: selected.displayName } : m,
);
setChain(updated);
onSave({ approvalChain: updated });
setReplaceDialog(null);
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка замены');
} finally {
setIsReplacing(false);
}
};
const handleActionConfirm = async (): Promise<void> => {
if (!actionDialog) return;
const { memberId, action, comment } = actionDialog;
if (action === 'reject' && !comment.trim()) return;
setIsSubmitting(true);
setSubmitError(null);
const apiStatus: 'approved' | 'rejected' = action === 'approve' ? 'approved' : 'rejected';
const currentMember = chain.find((m) => m.id === memberId);
try {
if (!currentMember?.spApprovalId) throw new Error('Не найден ID записи согласования');
await service.submitApprovalStep(currentMember.spApprovalId, apiStatus, comment.trim());
const notify = async (spId: number): Promise<void> => {
try {
await service.triggerApprovalNotification(spId);
} catch (e) {
// Оповещение — best-effort: поток может ответить с таймаутом (504), хотя сообщение
// уже ушло. Не пугаем пользователя ложной плашкой, только логируем для отладки.
console.error('[Step4] triggerApprovalNotification failed for spId', spId, e);
}
};
if (action === 'approve') {
const updatedChain = chain.map((m) =>
m.id === memberId
? { ...m, status: 'approved' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
: m,
);
setChain(updatedChain);
onSave({ approvalChain: updatedChain });
const allApproved = updatedChain.every(
(m) => m.status === 'approved' || m.status === 'skipped' || m.status === 'recommends',
);
// Финальное согласование: сначала переводим СТ в «Согласовано», и только ПОТОМ
// шлём оповещение. Иначе поток прочитает статус ещё «На согласовании» и отправит
// не то письмо. Порядок как в ветке отклонения (сначала статус, затем notify).
if (allApproved) {
await service.updateCTStatus(ct!.id, 'approved');
onSave({ ct: { ...ct!, status: 'approved' } });
}
if (currentMember?.spApprovalId) {
await notify(currentMember.spApprovalId);
}
const nextPending = updatedChain.find((m) => m.status === 'pending');
if (nextPending?.spApprovalId) {
await notify(nextPending.spApprovalId);
}
if (allApproved) {
onStepComplete(4);
}
} else {
const othersToDelete = chain.filter(
(m) => m.id !== memberId && m.status === 'pending' && m.spApprovalId,
);
if (othersToDelete.length > 0) {
await service.deleteApprovalMembers(othersToDelete.map((m) => m.spApprovalId!));
}
const finalChain = chain
.filter((m) => m.status !== 'pending' || m.id === memberId)
.map((m) =>
m.id === memberId
? { ...m, status: 'rejected' as ApprovalMemberStatus, comment: comment.trim(), actionDate: new Date().toISOString() }
: m,
);
setChain(finalChain);
await service.updateCTStatus(ct!.id, 'rejected');
onSave({ approvalChain: finalChain, ct: { ...ct!, status: 'rejected' } });
const initiator = finalChain.find((m) => m.role === 'Инициатор');
if (initiator?.spApprovalId) await notify(initiator.spApprovalId);
}
closeActionDialog();
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка выполнения действия');
} finally {
setIsSubmitting(false);
}
};
const handleSendForApproval = async (): Promise<void> => {
if (!ct || chain.length === 0) return;
setIsSubmitting(true);
setSubmitError(null);
try {
await service.activateApprovalChain(chain);
const winSupplier = formData.suppliers.find((s) => s.id === formData.recommendedSupplierId);
service.updateCTStatus(ct.id, 'on_approval', winSupplier?.spId).catch(() => undefined);
const activatedChain = chain.map((m) => ({
...m,
status: (m.role === 'Инициатор' ? 'recommends' : 'pending') as ApprovalMemberStatus,
}));
setChain(activatedChain);
onSave({ approvalChain: activatedChain, ct: { ...ct, status: 'on_approval' }, approvalSent: true });
const firstApprover = chain.find((m) => m.role !== 'Инициатор' && m.spApprovalId);
if (firstApprover?.spApprovalId) {
try {
await service.triggerApprovalNotification(firstApprover.spApprovalId);
} catch (e) {
// best-effort (см. notify выше) — ложную плашку не показываем.
console.error('[Step4] triggerApprovalNotification (send) failed for spId', firstApprover.spApprovalId, e);
}
}
onStepComplete(4);
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка отправки на согласование');
} finally {
setIsSubmitting(false);
}
};
const handleSendToIncore = async (): Promise<void> => {
if (!ct) return;
setIsSendingToIncore(true);
setIncoreError(null);
try {
// 1. Отчёт СТ в PDF — карточка SIGEX визуализирует именно PDF.
setIncoreStep('report');
const reportBlob = await service.getCtReportBlob(ct.id, 'pdf');
const title = `CT_${ct.id}.pdf`;
// 2. Подпись NCALayer → регистрация → фиксация → карточка (DDC).
const cardBlob = await sigexSign.signAndBuildCard(
reportBlob,
title,
setIncoreStep,
);
// 3. Архивируем карточку во вложения элемента СТ и переключаем UI на показ файла.
const saved = await service.saveCtCardAttachment(ct.id, cardBlob, incoreCardName);
setCardAttachment(saved);
// 4. Пакет для INCORE: карточка с ЭЦП + файлы КП — скачиваем пользователю.
service.downloadBlob(cardBlob, incoreCardName);
const kpFiles = formData.uploadedKpFiles ?? [];
for (const f of kpFiles) {
await service.downloadKpFile(f.name);
await new Promise<void>((resolve) => setTimeout(resolve, 300));
}
} catch (err) {
// Пользователь закрыл окно NCALayer, не подписав — это не ошибка.
if ((err as { canceledByUser?: boolean }).canceledByUser) return;
setIncoreError(
(err as Error).message ?? 'Ошибка подписания и выгрузки пакета',
);
} finally {
setIncoreStep(null);
setIsSendingToIncore(false);
}
};
const handleRestart = async (): Promise<void> => {
if (!ct) return;
setIsSubmitting(true);
setSubmitError(null);
try {
const resetChain = chain.map((m) => ({
...m,
status: 'pending' as ApprovalMemberStatus,
comment: undefined,
actionDate: undefined,
}));
await service.updateCTStatus(ct.id, 'formed');
setChain(resetChain);
onSave({ approvalChain: resetChain, ct: { ...ct, status: 'formed' }, approvalSent: false });
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка перезапуска согласования');
} finally {
setIsSubmitting(false);
}
};
const isOnApproval = ct?.status === 'on_approval' || ct?.status === 'approved';
const showStatuses =
formData.approvalSent ||
ct?.status === 'on_approval' ||
ct?.status === 'approved' ||
ct?.status === 'rejected';
const isInitiator = chain.some((m) => m.userName === userDisplayName && m.role === 'Инициатор');
const currentUserMember = chain.find(
(m) =>
m.userName === userDisplayName &&
m.status === 'pending' &&
m.role !== 'Инициатор' &&
chain.filter((prev) => prev.order < m.order).every((prev) => prev.status !== 'pending'),
);
// Согласованная СТ — финал: если карточка уже во вложениях, показываем файл вместо кнопки.
// Грузим только для инициатора и только в статусе «Согласована».
useEffect(() => {
if (ct?.status !== 'approved' || !isInitiator) {
setCardAttachment(null);
return;
}
let cancelled = false;
setIsLoadingCard(true);
service
.findCtCardAttachment(ct.id, `Карточка_CT_${ct.id}.pdf`)
.then((found) => { if (!cancelled) setCardAttachment(found); })
.catch(() => { if (!cancelled) setCardAttachment(null); })
.finally(() => { if (!cancelled) setIsLoadingCard(false); });
return () => { cancelled = true; };
}, [ct?.status, ct?.id, isInitiator, service]);
return {
ct,
chain,
submitError,
isSubmitting,
isSendingToIncore,
incoreStep,
incoreError,
dismissIncoreError: () => setIncoreError(null),
cardAttachment,
isLoadingCard,
isOnApproval,
showStatuses,
isInitiator,
currentUserMember,
// chain ops
moveMember,
handleDeleteMember,
openActionDialog,
openReplace,
// add member
showAddRow,
setShowAddRow,
addQuery,
addRole,
setAddRole,
addSuggestions,
addSelected,
isAddingMember,
handleAddQueryChange,
selectAddSuggestion,
cancelAddRow,
handleAddMember,
// action dialog
actionDialog,
setActionComment,
closeActionDialog,
handleActionConfirm,
// replace dialog
replaceDialog,
isReplacing,
handleReplaceQueryChange,
selectReplaceSuggestion,
closeReplace,
handleReplaceConfirm,
// footer
handleSendForApproval,
handleSendToIncore,
handleRestart,
};
}
@@ -1,5 +1,7 @@
import { useState, useCallback, useEffect } from 'react';
import { CT, FormStep, Position, Supplier, CurrencyRate, SupplierOffer, ApprovalMember } from '../types/domain';
import { CT, FormStep, Position, Supplier, CurrencyRate, SupplierOffer } from '../types/domain';
import { IMatrixRow } from '../../../../shared/approval';
import { ExtraSupplier } from '../Step3ComparisonTable/matrixTypes';
export interface CTFormData {
ct: CT | undefined;
@@ -9,14 +11,15 @@ export interface CTFormData {
currencyRates: CurrencyRate[];
offers: SupplierOffer[];
recommendedSupplierId: string | undefined;
approvalChain: ApprovalMember[];
// Маршрут согласования — только для отображения (например, hidePrices в CtCreateForm.tsx);
// решения читают/пишут его напрямую через CtService (shared/approval), не через formData.
approvalMatrix: IMatrixRow[];
initiatorDepartment: string;
initiatorCompany: string;
step3ExtraSuppliers: Array<{ id: string; name: string; currency: string; spId?: number; status?: string }>;
step3ExtraSuppliers: ExtraSupplier[];
step3ExtraPositions: Array<{ id: string; materialName: string; unit: string; spId?: number; isDelivery?: boolean }>;
step3DeliveryKm?: Record<string, number>; // key = `${posId}::${supId}`
uploadedKpFiles: Array<{ name: string; spId: number; supplierName?: string }>;
approvalSent: boolean;
step3PositionQtyOverrides?: Record<string, number>;
step3CriteriaEdits?: Record<string, { paymentType: string; deliveryType: string; deadline: string; sertificates: string }>;
step3CustomCriteria?: Array<{ id: string; label: string; values: Record<string, string> }>;
@@ -30,13 +33,12 @@ const INITIAL_FORM_DATA: CTFormData = {
currencyRates: [],
offers: [],
recommendedSupplierId: undefined,
approvalChain: [],
approvalMatrix: [],
initiatorDepartment: '',
initiatorCompany: '',
step3ExtraSuppliers: [],
step3ExtraPositions: [],
uploadedKpFiles: [],
approvalSent: false,
step3PositionQtyOverrides: {},
step3CriteriaEdits: {},
step3CustomCriteria: [],
@@ -53,7 +55,8 @@ interface PersistedFormState {
function getUrlCtId(): string | undefined {
const params = new URLSearchParams(window.location.search);
// Accept 'ctId' (our format) and 'ctid' (Power Automate notification link)
// ctId (форма, реестр) и ctid (ссылки потока, реестр) равнозначны — это только id СТ. Режим формы
// (правка / просмотр) определяет getCtAccess по автору и статусу, а не регистр параметра.
return params.get('ctId') ?? params.get('ctid') ?? undefined;
}
@@ -82,7 +85,6 @@ export interface UseCtFormResult {
currentStep: FormStep;
formData: CTFormData;
initialCtId: string | undefined;
isNotificationLink: boolean;
goToStep: (step: FormStep) => void;
goNext: () => void;
goPrev: () => void;
@@ -95,7 +97,6 @@ export interface UseCtFormResult {
export function useCtForm(): UseCtFormResult {
const [initialCtId] = useState<string | undefined>(getUrlCtId);
const isNotificationLink = new URLSearchParams(window.location.search).has('ctid');
const [currentStep, setCurrentStep] = useState<FormStep>(() => {
const saved = initialCtId ? readSession(initialCtId) : null;
@@ -163,7 +164,6 @@ export function useCtForm(): UseCtFormResult {
currentStep,
formData,
initialCtId,
isNotificationLink,
goToStep,
goNext,
goPrev,
@@ -0,0 +1,14 @@
import { useEffect, useState } from 'react';
// Значение, которое обновляется только после паузы delayMs в изменениях — чтобы поиск с запросом
// к серверу уходил, когда пользователь перестал печатать, а не на каждую букву.
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Request } from '../types/domain';
import { useState, useEffect, useCallback } from 'react';
import { Request, Position } from '../types/domain';
import { CtService } from '../../services/CtService';
interface UseRequestsResult {
@@ -7,13 +7,39 @@ interface UseRequestsResult {
isLoading: boolean;
error: string | undefined;
reload: () => void;
/** Лениво подгрузить позиции одной заявки (при раскрытии/выборе-всех). Идемпотентно.
* Возвращает позиции заявки (уже загруженные или только что подтянутые). */
loadPositions: (requestId: string) => Promise<Position[]>;
/** Заявки, чьи позиции сейчас грузятся (для спиннера в строке). */
loadingPositionIds: Set<string>;
}
export function useRequests(service: CtService, days: number, skip?: boolean): UseRequestsResult {
// Потребности, позиции которых уже в СТ, но которые не пришли за выбранный период: показываем
// их в таблице и сразу грузим позиции — без них неизвестно реальное число позиций потребности.
async function loadMissingPinned(service: CtService, pinned: Request[], loaded: Request[]): Promise<Request[]> {
const loadedIds = new Set(loaded.map(r => r.id));
const missing = pinned.filter(r => !loadedIds.has(r.id));
return Promise.all(
missing.map(async request => {
const positions = await service.loadRequestPositions(request);
return { ...request, positions, positionsCount: positions.length };
}),
);
}
// requestNumber — поиск по номеру на сервере (часть номера, в пределах периода); пусто — весь период.
export function useRequests(
service: CtService,
days: number,
requestNumber: string,
skip: boolean,
pinnedRequests: Request[],
): UseRequestsResult {
const [requests, setRequests] = useState<Request[]>([]);
const [isLoading, setIsLoading] = useState(!skip);
const [error, setError] = useState<string | undefined>(undefined);
const [reloadToken, setReloadToken] = useState(0);
const [loadingPositionIds, setLoadingPositionIds] = useState<Set<string>>(new Set());
useEffect(() => {
if (skip) {
@@ -27,10 +53,11 @@ export function useRequests(service: CtService, days: number, skip?: boolean): U
setError(undefined);
service
.fetchRequests(days)
.then(data => {
.fetchRequests(days, requestNumber || undefined)
.then(async data => {
const pinned = await loadMissingPinned(service, pinnedRequests, data);
if (!cancelled) {
setRequests(data);
setRequests([...data, ...pinned]);
setIsLoading(false);
}
})
@@ -44,12 +71,40 @@ export function useRequests(service: CtService, days: number, skip?: boolean): U
return () => {
cancelled = true;
};
}, [service, days, reloadToken, skip]);
}, [service, days, requestNumber, reloadToken, skip, pinnedRequests]);
const loadPositions = useCallback(
async (requestId: string): Promise<Position[]> => {
const target = requests.find(r => r.id === requestId);
if (!target) return [];
// Уже загружены / грузятся — не дёргаем API повторно.
if (target.positions.length > 0) return target.positions;
if (loadingPositionIds.has(requestId)) return [];
setLoadingPositionIds(prev => new Set(prev).add(requestId));
try {
const positions = await service.loadRequestPositions(target);
setRequests(prev => prev.map(r => (r.id === requestId ? { ...r, positions } : r)));
return positions;
} catch {
return []; /* оставляем заявку без позиций — счётчик из positionsCount уже показан */
} finally {
setLoadingPositionIds(prev => {
const next = new Set(prev);
next.delete(requestId);
return next;
});
}
},
[service, requests, loadingPositionIds],
);
return {
requests,
isLoading,
error,
reload: () => setReloadToken(t => t + 1),
loadPositions,
loadingPositionIds,
};
}
@@ -7,6 +7,9 @@ export interface Project {
export interface Position {
id: string;
spId?: number; // SP item ID in Позиции СТ (populated after createCTPositions)
// Позиция потребности, из которой взята позиция СТ: её ключ (см. utils/positionKey) — id позиции
// в таблице шага 1. Хранится в SP-поле 'requestPositionId'; у СТ, созданных до его появления, пусто.
requestPositionId?: string;
requestId: string; // GUID потребности
requestNumber: string; // demandNumber — номер потребности (e.g. "081/26-04/0017")
materialName: string; // position — наименование позиции
@@ -30,6 +33,9 @@ export interface Request {
workType: string;
organization: string;
createdAt: string;
// Кол-во позиций приходит в списке заявок (positionsCount) — для счётчика без загрузки самих
// позиций. Сами positions грузятся лениво при раскрытии заявки (см. loadRequestPositions).
positionsCount: number;
positions: Position[];
}
@@ -37,7 +43,13 @@ export interface CT {
id: string;
number: string; // sequential number within project
projectName: string; // 'project' SP field
status?: CTStatus; // not in SP list, tracked in memory only
status?: CTStatus;
// Email автора записи (SP Author/Created By) — определяет инициатора для показа
// блока «Выгрузка в INCORE» и т.п.: в новом механизме согласования (approval-route API)
// инициатор в саму матрицу согласования не входит, в отличие от прежней цепочки.
authorEmail?: string;
// Победитель (лукап winSupplier → «Поставщики»), записывается на Шаге 3.
winSupplierSpId?: number;
}
export type CTStatus =
@@ -47,19 +59,28 @@ export type CTStatus =
| "formed"
| "on_approval"
| "approved"
| "rejected";
| "rejected"
| "rework"; // возвращена согласующим на доработку — инициатор правит и отправляет заново
export interface Supplier {
id: string;
id: string; // ключ колонки Шага 3: `${spId}_${kpFileSpId}` (см. utils/supplierColumns), у старых данных — String(spId)
spId?: number; // SP item ID in Поставщики (populated after saveSupplierWithOffer)
kpFileSpId?: number; // SP Id файла КП этой колонки — у поставщика может быть несколько КП (напр. KZT и USD)
name: string;
inn?: string; // UI only — not stored in SP
bin?: string; // БИН из флоу (Adata) — хранится в SP-поле 'bin'
hasOffer?: boolean; // determines target list: Поставщики vs Не получены КП
comment?: string; // только у поставщика без КП (hasOffer: false) — почему нет КП
status?: string;
currency?: string; // currency code for KP (KZT by default); stored here so Step 3 can show correct columns even if offers are empty
adataData?: AdataCompanyInfo; // кэш последней проверки Adata — SP-поле 'adataData' (JSON)
adataCheckedAt?: string; // дата последней проверки Adata — SP-поле 'adataCheckedAt'
exclusion?: SupplierExclusion; // исключён из сравнения на Шаге 3 — колонка скрыта, цены сохранены
}
// Поставщик исключён из сравнения (docs/adr/0001). Хранится в «Исключённые предложения».
export interface SupplierExclusion {
reason: string;
}
// Ответ Adata (через прокси api.tnsu.kz/Proxy/AData) по БИН/ИИН контрагента.
@@ -149,26 +170,6 @@ export interface SupplierCriteria {
comment?: string;
}
export interface ApprovalMember {
id: string;
order: number;
userId: string;
userName: string;
role?: string;
status: ApprovalMemberStatus;
comment?: string;
actionDate?: string;
spApprovalId?: number;
canAddApprover?: boolean;
}
export type ApprovalMemberStatus =
| "pending"
| "recommends"
| "approved"
| "rejected"
| "skipped";
export type FormStep = 1 | 2 | 3 | 4;
export const FORM_STEPS: Array<{ step: FormStep; label: string }> = [
@@ -0,0 +1,76 @@
import { findReworkRow, isUserInApprovalChain, pickRowToNotify } from './approvalAccess';
import { IMatrixRow } from '../../../../shared/approval';
function makeRow(overrides: Partial<IMatrixRow>): IMatrixRow {
return {
order: 1, who: 'Иванов Иван', email: 'ivanov@tnsu.kz', position: 'Директор', status: 'На согласовании',
date: '', comment: '', allowReassign: false, ...overrides,
};
}
describe('isUserInApprovalChain (кому видны цены и КП при согласовании)', () => {
// Раньше сверяли только по ФИО: «Иванов Иван» в SP и «Иван Иванов» в профиле — согласующему
// скрывались цены и КП, хотя он в цепочке.
it('находит согласующего по почте, даже если ФИО записано иначе', () => {
const matrix = [makeRow({ who: 'Иванов Иван' })];
expect(isUserInApprovalChain(matrix, { email: 'IVANOV@tnsu.kz', displayName: 'Иван Иванов' })).toBe(true);
});
it('без почты в строке — сверяет по ФИО', () => {
const matrix = [makeRow({ email: '' })];
expect(isUserInApprovalChain(matrix, { email: 'ivanov@tnsu.kz', displayName: 'Иванов Иван' })).toBe(true);
});
it('не в цепочке — false', () => {
const matrix = [makeRow({})];
expect(isUserInApprovalChain(matrix, { email: 'petrov@tnsu.kz', displayName: 'Петров Пётр' })).toBe(false);
});
it('пустая цепочка — false', () => {
expect(isUserInApprovalChain([], { email: 'ivanov@tnsu.kz', displayName: 'Иванов Иван' })).toBe(false);
});
});
describe('findReworkRow (кто вернул СТ на доработку и что исправить)', () => {
it('находит строку со статусом «На доработке»', () => {
const matrix = [
makeRow({ order: 1, status: 'Согласовано' }),
makeRow({ order: 2, who: 'Петров Пётр', status: 'На доработке', comment: 'Уточните цены поставщика' }),
];
expect(findReworkRow(matrix)).toMatchObject({ who: 'Петров Пётр', comment: 'Уточните цены поставщика' });
});
it('нет возврата — undefined', () => {
expect(findReworkRow([makeRow({ status: 'Согласовано' })])).toBeUndefined();
});
});
describe('pickRowToNotify (какую строку маршрута передать потоку уведомлений)', () => {
const row = (id: number, status: string): { id: number; status: string } => ({ id, status });
it('есть активный согласующий — его строку («поступила на согласование»)', () => {
expect(pickRowToNotify([row(1, 'Согласовано'), row(2, 'На согласовании'), row(3, 'Ждёт очереди')])?.id).toBe(2);
});
// Раньше при возврате уходила последняя строка (напр. «Ждёт очереди»), и поток не знал, кто вернул и с каким
// комментарием.
it('возврат на доработку — строку того, кто вернул', () => {
expect(pickRowToNotify([row(1, 'Согласовано'), row(2, 'На доработке'), row(3, 'Ждёт очереди')])?.id).toBe(2);
});
it('отклонение — строку того, кто отклонил', () => {
expect(pickRowToNotify([row(1, 'Отклонено'), row(2, 'Ждёт очереди')])?.id).toBe(1);
});
it('все согласовали — последнюю строку', () => {
expect(pickRowToNotify([row(1, 'Согласовано'), row(2, 'Согласовано')])?.id).toBe(2);
});
it('пустой маршрут — нечего отправлять', () => {
expect(pickRowToNotify([])).toBeUndefined();
});
});
@@ -0,0 +1,34 @@
import { IMatrixRow } from '../../../../shared/approval';
import { REQUEST_STATUS } from '../../../../shared/approval/models/statuses';
export interface CurrentUser {
email: string;
displayName: string;
}
// Участник цепочки согласования — ему видны цены и КП. Сверяем по почте (без учёта регистра): ФИО в SP
// и в профиле пишут по-разному («Иванов Иван» / «Иван Иванов»), и по одному ФИО согласующему цены и КП
// скрывались. ФИО — запасной вариант для строк без почты.
export function isUserInApprovalChain(matrix: IMatrixRow[], user: CurrentUser): boolean {
const email = user.email.trim().toLowerCase();
return matrix.some(row => {
const rowEmail = (row.email ?? '').trim().toLowerCase();
if (rowEmail && email) return rowEmail === email;
return row.who === user.displayName;
});
}
// Строка маршрута, которой СТ вернули на доработку: инициатору показываем, кто вернул и что исправить.
export function findReworkRow(matrix: IMatrixRow[]): IMatrixRow | undefined {
return matrix.find(row => row.status === REQUEST_STATUS.rework);
}
// Строка маршрута для потока уведомлений (он по ней выходит на СТ, её автора и комментарий):
// активный согласующий — «поступила на согласование»; иначе строка того, кто вернул на доработку или
// отклонил, — чтобы в письме инициатору были его имя и комментарий; иначе (все согласовали) — последняя.
export function pickRowToNotify<T extends { status: string }>(rows: T[]): T | undefined {
return rows.find(r => r.status === REQUEST_STATUS.onApproval)
?? rows.find(r => r.status === REQUEST_STATUS.rework)
?? rows.find(r => r.status === REQUEST_STATUS.rejected)
?? rows[rows.length - 1];
}
@@ -0,0 +1,114 @@
import { buildCriteriaRecords, isPaymentTypeOption, plainTextFromSp, restoreCriteria } from './criteria';
import { Supplier } from '../types/domain';
const ARLAN_KZT: Supplier = { id: '263_277', spId: 263, kpFileSpId: 277, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true };
const ARLAN_USD: Supplier = { id: '263_285', spId: 263, kpFileSpId: 285, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true };
const KALA: Supplier = { id: '264_278', spId: 264, kpFileSpId: 278, name: 'ТОО «Кала Саплай»', hasOffer: true };
const MANUAL: Supplier = { id: 'extra_sup_1', name: 'Ручной поставщик', hasOffer: true };
const spIdOf = (column: Supplier): number | undefined => column.spId ?? (column.id === 'extra_sup_1' ? 300 : undefined);
describe('buildCriteriaRecords (ручные правки «Дополнительной информации» → SP)', () => {
// Раньше правки и свои строки жили только в браузере: согласующий и PDF видели лишь то, что распознал ИИ.
it('одна запись условий на поставщика', () => {
const records = buildCriteriaRecords(
[KALA],
{ '264_278': { paymentType: '100% предоплата', deliveryType: 'EXW', deadline: '10 дней', sertificates: 'Да' } },
[],
spIdOf,
);
expect(records).toEqual([{
supplierSpId: 264, paymentType: '100% предоплата', deliveryType: 'EXW', deadline: '10 дней', sertificates: 'Да',
}]);
});
// Условия в SP — на поставщика, а не на КП: у колонок одного поставщика берём первую заполненную.
it('несколько КП одного поставщика — одна запись', () => {
const records = buildCriteriaRecords(
[ARLAN_KZT, ARLAN_USD],
{
'263_277': { paymentType: '', deliveryType: '', deadline: '', sertificates: '' },
'263_285': { paymentType: '50% предоплата, 50% по факту поставки', deliveryType: '', deadline: '', sertificates: '' },
},
[],
spIdOf,
);
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({ paymentType: '50% предоплата, 50% по факту поставки' });
});
it('свои строки — запись на (строка, поставщик) с непустым значением', () => {
const records = buildCriteriaRecords(
[KALA, MANUAL],
{},
[{ id: 'c1', label: 'Гарантия', values: { '264_278': '12 мес', extra_sup_1: '' } }],
spIdOf,
);
// Пустые условия поставщика не пишем — только заполненная своя строка.
expect(records).toEqual([{ supplierSpId: 264, criteriaLabel: 'Гарантия', value: '12 мес' }]);
});
it('пропускает колонку без SP Id поставщика', () => {
const noSp: Supplier = { id: 'local_1', name: 'Без SP', hasOffer: true };
expect(buildCriteriaRecords([noSp], { local_1: { paymentType: 'x', deliveryType: '', deadline: '', sertificates: '' } }, [], () => undefined)).toEqual([]);
});
});
describe('restoreCriteria (SP → таблица, в т.ч. по ссылке)', () => {
it('условия поставщика расходятся по всем его колонкам, свои строки группируются по названию', () => {
const restored = restoreCriteria(
[
{ id: 1, supplierSpId: 263, paymentType: 'Отсрочка', deliveryType: 'DAP', deadline: '', sertificates: 'Нет' },
{ id: 2, supplierSpId: 263, criteriaLabel: 'Гарантия', value: '24 мес' },
{ id: 3, supplierSpId: 264, criteriaLabel: 'Гарантия', value: '12 мес' },
],
[ARLAN_KZT, ARLAN_USD, KALA],
);
expect(restored.criteriaMap['263_277'].paymentType).toBe('Отсрочка');
expect(restored.criteriaMap['263_285'].sertificates).toBe('Нет');
expect(restored.customCriteria).toEqual([
{ id: 'sp_Гарантия', label: 'Гарантия', values: { '263_277': '24 мес', '263_285': '24 мес', '264_278': '12 мес' } },
]);
});
// ИИ при каждой обработке КП добавляет запись условий — берём последнюю.
it('из нескольких записей условий поставщика берёт последнюю', () => {
const restored = restoreCriteria(
[
{ id: 5, supplierSpId: 264, paymentType: 'старое', deliveryType: '', deadline: '', sertificates: '' },
{ id: 9, supplierSpId: 264, paymentType: 'новое', deliveryType: '', deadline: '', sertificates: '' },
],
[KALA],
);
expect(restored.criteriaMap['264_278'].paymentType).toBe('новое');
});
});
describe('isPaymentTypeOption', () => {
it('значение из списка — да, своё — нет', () => {
expect(isPaymentTypeOption('100% предоплата')).toBe(true);
expect(isPaymentTypeOption('40% предоплата, 60% по аккредитиву')).toBe(false);
});
});
describe('plainTextFromSp', () => {
// comment в SP «Критерии» — многострочное поле с форматированием: SharePoint заворачивает текст в HTML,
// и по ссылке своя строка показывалась как «<div class="ExternalClass…">да</div>».
it('убирает обёртку ExternalClass и теги', () => {
expect(plainTextFromSp('<div class="ExternalClass11A142948BDD4FEB93E8B98BB14AE465">да</div>')).toBe('да');
});
it('переносы строк сохраняет, HTML-сущности раскодирует', () => {
expect(plainTextFromSp('<div>12&nbsp;мес<br>от &quot;поставки&quot; &amp; монтажа</div>')).toBe('12 мес\nот "поставки" & монтажа');
});
it('обычный текст не трогает', () => {
expect(plainTextFromSp('12 мес')).toBe('12 мес');
});
});
@@ -0,0 +1,137 @@
import { Supplier } from '../types/domain';
import { CriteriaEntry } from '../Step3ComparisonTable/matrixTypes';
import { PAYMENT_TYPE_OPTIONS } from '../Step3ComparisonTable/matrixHelpers';
// Своя строка «Дополнительной информации» (кнопка «Добавить условие»): значения по колонкам.
export interface CustomCriteriaRow {
id: string;
label: string;
values: Record<string, string>;
}
// Запись «Критерии» в SP. Условия поставщика — одна запись на поставщика; своя строка — запись на
// (строка, поставщик): название в criteriaLabel, значение в comment (как в «Критерии ОИД»).
export interface FixedCriteriaRecord {
id?: number;
supplierSpId: number;
paymentType: string;
deliveryType: string;
deadline: string;
sertificates: string;
}
export interface CustomCriteriaRecord {
id?: number;
supplierSpId: number;
criteriaLabel: string;
value: string;
}
export type CriteriaRecord = FixedCriteriaRecord | CustomCriteriaRecord;
const isCustomRecord = (r: CriteriaRecord): r is CustomCriteriaRecord => 'criteriaLabel' in r;
const EMPTY_ENTRY: CriteriaEntry = { paymentType: '', deliveryType: '', deadline: '', sertificates: '' };
const hasAnyValue = (e: CriteriaEntry): boolean =>
[e.paymentType, e.deliveryType, e.deadline, e.sertificates].some(v => (v ?? '').trim() !== '');
const HTML_ENTITIES: Record<string, string> = {
'&nbsp;': ' ',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
};
// Текст из многострочного поля SP с форматированием (comment в «Критерии»): SharePoint хранит его как HTML
// в обёртке <div class="ExternalClass…">. Оставляем чистый текст, переносы строк — как есть.
export function plainTextFromSp(value: string): string {
if (!value.includes('<')) return value;
return value
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&(nbsp|amp|lt|gt|quot|#39);/g, entity => HTML_ENTITIES[entity] ?? entity)
.trim();
}
export function isPaymentTypeOption(value: string): boolean {
return PAYMENT_TYPE_OPTIONS.includes(value.trim());
}
// Таблица «Дополнительная информация» → записи SP. Условия в SP — на поставщика, а не на КП: у колонок
// одного поставщика (несколько КП) берём первую заполненную. Колонки без SP Id поставщика пропускаем.
export function buildCriteriaRecords(
columns: Supplier[],
criteriaMap: Record<string, CriteriaEntry>,
customCriteria: CustomCriteriaRow[],
spIdOf: (column: Supplier) => number | undefined,
): CriteriaRecord[] {
const records: CriteriaRecord[] = [];
const savedSuppliers = new Set<number>();
columns.forEach(column => {
const supplierSpId = spIdOf(column);
const entry = criteriaMap[column.id];
if (supplierSpId === undefined || savedSuppliers.has(supplierSpId) || !entry || !hasAnyValue(entry)) return;
savedSuppliers.add(supplierSpId);
records.push({
supplierSpId,
paymentType: entry.paymentType.trim(),
deliveryType: entry.deliveryType.trim(),
deadline: entry.deadline.trim(),
sertificates: entry.sertificates.trim(),
});
});
customCriteria.forEach(row => {
const savedForRow = new Set<number>();
columns.forEach(column => {
const supplierSpId = spIdOf(column);
const value = (row.values[column.id] ?? '').trim();
if (supplierSpId === undefined || !value || savedForRow.has(supplierSpId)) return;
savedForRow.add(supplierSpId);
records.push({ supplierSpId, criteriaLabel: row.label, value });
});
});
return records;
}
// Записи SP → таблица (в т.ч. при открытии по ссылке). Условия поставщика расходятся по всем его
// колонкам; ИИ при каждой обработке КП добавляет запись — берём последнюю (max Id).
export function restoreCriteria(
records: CriteriaRecord[],
columns: Supplier[],
): { criteriaMap: Record<string, CriteriaEntry>; customCriteria: CustomCriteriaRow[] } {
const latestFixed = new Map<number, FixedCriteriaRecord>();
records.forEach(r => {
if (isCustomRecord(r)) return;
const previous = latestFixed.get(r.supplierSpId);
if (!previous || (r.id ?? 0) > (previous.id ?? 0)) latestFixed.set(r.supplierSpId, r);
});
const criteriaMap: Record<string, CriteriaEntry> = {};
columns.forEach(column => {
const fixed = column.spId !== undefined ? latestFixed.get(column.spId) : undefined;
if (!fixed) return;
criteriaMap[column.id] = {
...EMPTY_ENTRY,
paymentType: fixed.paymentType,
deliveryType: fixed.deliveryType,
deadline: fixed.deadline,
sertificates: fixed.sertificates,
};
});
const customByLabel = new Map<string, CustomCriteriaRow>();
records.forEach(r => {
if (!isCustomRecord(r)) return;
const row = customByLabel.get(r.criteriaLabel) ?? { id: `sp_${r.criteriaLabel}`, label: r.criteriaLabel, values: {} };
columns.filter(c => c.spId === r.supplierSpId).forEach(c => { row.values[c.id] = r.value; });
customByLabel.set(r.criteriaLabel, row);
});
return { criteriaMap, customCriteria: Array.from(customByLabel.values()) };
}
@@ -0,0 +1,52 @@
import { getCtAccess, isCtAuthor } from './ctAccess';
const base = { hasCt: true, isAuthor: false, isInChain: false, isRestrictedCompany: false };
describe('getCtAccess — кто открыл СТ и в каком она статусе (а не регистр ctid/ctId в ссылке)', () => {
it('новая СТ — правка', () => {
expect(getCtAccess({ ...base, hasCt: false })).toEqual({ canEdit: true, canSeePrices: true, isReview: false });
});
it('автор: черновик — правка', () => {
expect(getCtAccess({ ...base, isAuthor: true, status: 'formed' }).canEdit).toBe(true);
});
// СТ №… «На доработке» по ссылке из письма (ctid) открывалась автору только на просмотр — править было нельзя.
it('автор: «На доработке» — правка, даже если открыл по ссылке-уведомлению', () => {
expect(getCtAccess({ ...base, isAuthor: true, status: 'rework' })).toEqual({ canEdit: true, canSeePrices: true, isReview: false });
});
it('автор: на согласовании / согласовано — просмотр с ценами', () => {
expect(getCtAccess({ ...base, isAuthor: true, status: 'on_approval' })).toEqual({ canEdit: false, canSeePrices: true, isReview: true });
expect(getCtAccess({ ...base, isAuthor: true, status: 'approved' }).canEdit).toBe(false);
});
// «Отклонить» оставлено с перезапуском: править — после «Перезапустить согласование» на шаге 4.
it('автор: отклонено — просмотр до перезапуска', () => {
expect(getCtAccess({ ...base, isAuthor: true, status: 'rejected' }).canEdit).toBe(false);
});
it('согласующий из цепочки — просмотр с ценами', () => {
expect(getCtAccess({ ...base, isInChain: true, status: 'on_approval' })).toEqual({ canEdit: false, canSeePrices: true, isReview: true });
});
it('посторонний — просмотр без цен, даже черновик', () => {
expect(getCtAccess({ ...base, status: 'formed' })).toEqual({ canEdit: false, canSeePrices: false, isReview: true });
});
it('компания с ограничением (astel-k) — без цен даже автору', () => {
expect(getCtAccess({ ...base, isAuthor: true, status: 'formed', isRestrictedCompany: true }).canSeePrices).toBe(false);
});
});
describe('isCtAuthor', () => {
it('сверяет почту без учёта регистра', () => {
expect(isCtAuthor('Ivanov@tnsu.kz', 'ivanov@tnsu.kz')).toBe(true);
expect(isCtAuthor('ivanov@tnsu.kz', 'petrov@tnsu.kz')).toBe(false);
});
// У старых данных автор может быть не загружен — не блокируем никого на своих черновиках, как раньше.
it('автор неизвестен — считаем автором', () => {
expect(isCtAuthor(undefined, 'ivanov@tnsu.kz')).toBe(true);
});
});
@@ -0,0 +1,41 @@
import { CTStatus } from '../types/domain';
// Режим формы СТ определяется тем, КТО открыл и В КАКОМ статусе СТ, — а не регистром параметра в ссылке.
// Раньше `?ctid=` (ссылки потока, реестр для не-черновиков) включал «режим согласующего» любому, кто открыл,
// а `?ctId=` — правку; режим ещё и «прилипал» в сессии. Автор СТ «На доработке» по ссылке из письма
// получал только просмотр. Теперь ctid/ctId равнозначны — они лишь говорят, какую СТ открыть.
export interface CtAccessInput {
hasCt: boolean;
status?: CTStatus;
isAuthor: boolean;
isInChain: boolean; // участник цепочки согласования (см. isUserInApprovalChain)
isRestrictedCompany: boolean; // ограниченная компания (astel-k) — цены не видит никогда
}
export interface CtAccess {
canEdit: boolean; // правка шагов; статусные блокировки шагов 1–2 остаются поверх
canSeePrices: boolean; // цены и ссылки на КП
isReview: boolean; // просмотр: на шаге 3, данные свежие из SP, маршрут и кнопки решения
}
// Статусы, в которых автор правит СТ. «Отклонено» — нет: правка после «Перезапустить согласование».
const AUTHOR_EDIT_STATUSES: ReadonlySet<CTStatus> = new Set<CTStatus>([
'draft', 'kp_loading', 'kp_loaded', 'formed', 'rework',
]);
export function getCtAccess(input: CtAccessInput): CtAccess {
const canEdit = !input.hasCt || (input.isAuthor && AUTHOR_EDIT_STATUSES.has(input.status ?? 'formed'));
return {
canEdit,
canSeePrices: !input.isRestrictedCompany && (canEdit || input.isAuthor || input.isInChain),
isReview: input.hasCt && !canEdit,
};
}
// Автор СТ — по почте без учёта регистра. Автор неизвестен (старые данные, сессия до появления
// authorEmail) — считаем автором: раньше любой мог править СТ, не блокируем никого на своих черновиках.
export function isCtAuthor(authorEmail: string | undefined, userEmail: string): boolean {
if (!authorEmail) return true;
return authorEmail.trim().toLowerCase() === userEmail.trim().toLowerCase();
}
@@ -0,0 +1,128 @@
import { SupplierOffer } from '../types/domain';
import { IExcludedOfferItem, fromExcludedOfferItem, toExcludedOfferBody } from '../../../../shared/offers/excludedOffers';
import { ExcludedSaveDeps, buildExcludedRecordsForSave, excludedItemsToFormData, keepExclusions } from './excludedColumns';
const REASON = 'Дорого';
const USD_SP_ID = 3;
const currencyCodeOf = (valutaSpId?: number): string => (valutaSpId === USD_SP_ID ? 'USD' : 'KZT');
function makeItem(overrides: Partial<IExcludedOfferItem>): IExcludedOfferItem {
return {
id: 1, supplierSpId: 259, supplierName: 'ООО Альфа', offerFileSpId: 278, reason: REASON,
positionSpId: 1476, itemIndex: 0, supplierPosition: 'Кабель', unit: 'м', quantity: 100,
price: 10, summa: 4500, valutaSpId: USD_SP_ID, priceTenge: 4500, summaTenge: 450000,
...overrides,
};
}
function makeOffer(overrides: Partial<SupplierOffer>): SupplierOffer {
return {
id: '', supplierId: '259_278', positionId: 'pos-a', itemIndex: 0, supplierPosition: 'Кабель', unit: 'м',
quantity: 100, pricePerUnit: 10, totalPriceKzt: 450000, currencyCode: 'USD',
pricePerUnitKzt: 4500, totalWithExtraKzt: 450000,
...overrides,
};
}
const deps: ExcludedSaveDeps = {
positionSpIdOf: id => ({ 'pos-a': 1476, 'pos-b': 1477 } as Record<string, number>)[id],
valutaSpIdOf: code => (code === 'USD' ? USD_SP_ID : undefined),
deliveryKmOf: () => undefined,
};
describe('excludedItemsToFormData', () => {
it('колонка с причиной и валютой, id колонки — как у обычных (поставщик_файл КП)', () => {
const { suppliers } = excludedItemsToFormData([makeItem({})], currencyCodeOf);
expect(suppliers).toEqual([{
id: '259_278', spId: 259, kpFileSpId: 278, name: 'ООО Альфа', hasOffer: true,
currency: 'USD', exclusion: { reason: REASON },
}]);
});
it('офферы привязаны к колонке и SP Id позиции, цены — как при обычной загрузке из SP', () => {
const { offers } = excludedItemsToFormData([makeItem({ itemIndex: 1 })], currencyCodeOf);
expect(offers).toEqual([expect.objectContaining({
supplierId: '259_278', positionId: '1476', itemIndex: 1, pricePerUnit: 10, quantity: 100,
currencyCode: 'USD', pricePerUnitKzt: 4500, totalWithExtraKzt: 450000, offerId: '278',
})]);
});
it('поставщик без цен — колонка есть, офферов нет', () => {
const { suppliers, offers } = excludedItemsToFormData([makeItem({ positionSpId: undefined })], currencyCodeOf);
expect(suppliers).toHaveLength(1);
expect(suppliers[0].currency).toBe('KZT');
expect(offers).toEqual([]);
});
});
describe('keepExclusions', () => {
// Решение от 2026-10-27: повторная загрузка КП не возвращает поставщика — только «Вернуть».
it('пересобранная колонка того же КП остаётся исключённой', () => {
const previous = [{ id: '259_278', name: 'ООО Альфа', exclusion: { reason: REASON } }];
const rebuilt = [{ id: '259_278', name: 'ООО Альфа', currency: 'USD' }];
expect(keepExclusions(rebuilt, previous)).toEqual([
{ id: '259_278', name: 'ООО Альфа', currency: 'USD', exclusion: { reason: REASON } },
]);
});
it('новый КП того же поставщика — новая колонка, не исключена', () => {
const previous = [{ id: '259_278', name: 'ООО Альфа', exclusion: { reason: REASON } }];
expect(keepExclusions([{ id: '259_300', name: 'ООО Альфа' }], previous)[0].exclusion).toBeUndefined();
});
});
describe('buildExcludedRecordsForSave', () => {
const column = { id: '259_278', exclusion: { reason: REASON }, supplierSpId: 259, offerFileSpId: 278 };
it('пишет офферы только своей колонки и только непустые (как у обычных поставщиков)', () => {
const records = buildExcludedRecordsForSave([column], [
makeOffer({ positionId: 'pos-a' }),
makeOffer({ positionId: 'pos-b', totalWithExtraKzt: 0 }),
makeOffer({ supplierId: 'другая_колонка' }),
], deps);
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({
supplierSpId: 259, offerFileSpId: 278, reason: REASON, positionSpId: 1476,
price: 10, summa: 450000, valutaSpId: USD_SP_ID, priceTenge: 4500, summaTenge: 450000,
});
});
it('позиция ещё не в SP — оффер пропускается, а не пишется без позиции (это был бы маркер)', () => {
const records = buildExcludedRecordsForSave([column], [
makeOffer({ positionId: 'pos-a' }),
makeOffer({ positionId: 'новая-позиция' }),
], deps);
expect(records.map(r => r.positionSpId)).toEqual([1476]);
});
it('без цен — строка-маркер', () => {
const records = buildExcludedRecordsForSave([column], [], deps);
expect(records).toHaveLength(1);
expect(records[0]).toMatchObject({ supplierSpId: 259, reason: REASON });
expect(records[0].positionSpId).toBeUndefined();
});
it('колонку без SP Id поставщика не пишет', () => {
expect(buildExcludedRecordsForSave([{ ...column, supplierSpId: undefined }], [makeOffer({})], deps)).toEqual([]);
});
it('сохранение → SP → открытие возвращает ту же колонку с теми же ценами', () => {
const records = buildExcludedRecordsForSave([column], [makeOffer({ positionId: 'pos-a', itemIndex: 1 })], deps);
const fromSp = records.map((r, i) => fromExcludedOfferItem({
...toExcludedOfferBody(364, r), Id: i + 1, supplier: { Title: 'ООО Альфа' },
}));
const { suppliers, offers } = excludedItemsToFormData(fromSp, currencyCodeOf);
expect(suppliers[0]).toMatchObject({ id: '259_278', exclusion: { reason: REASON }, currency: 'USD' });
expect(offers[0]).toMatchObject({ positionId: '1476', itemIndex: 1, pricePerUnit: 10, totalWithExtraKzt: 450000 });
});
});
@@ -0,0 +1,117 @@
import { Supplier, SupplierExclusion, SupplierOffer } from '../types/domain';
import {
IExcludedOfferItem,
IExcludedOfferRecord,
groupExcludedColumns,
toExcludedRecords,
} from '../../../../shared/offers/excludedOffers';
import { buildSupplierColumnId } from './supplierColumns';
// Исключённые поставщики СТ (docs/adr/0001) ↔ колонки и офферы формы.
export interface ExcludedFormData {
suppliers: Supplier[];
offers: SupplierOffer[];
}
// Открытие СТ из SP: строки «Исключённых предложений» → колонки (с exclusion) и офферы, в том же
// виде, что buildColumnsFromSpOffers / buildOffersFromSpRecords для обычных поставщиков.
export function excludedItemsToFormData(
items: IExcludedOfferItem[],
currencyCodeOf: (valutaSpId?: number) => string,
): ExcludedFormData {
const suppliers: Supplier[] = [];
const offers: SupplierOffer[] = [];
for (const column of groupExcludedColumns(items)) {
const columnId = buildSupplierColumnId(column.supplierSpId, column.offerFileSpId);
suppliers.push({
id: columnId,
spId: column.supplierSpId,
kpFileSpId: column.offerFileSpId,
name: column.supplierName,
hasOffer: true,
currency: currencyCodeOf(column.offers[0]?.valutaSpId),
exclusion: { reason: column.reason },
});
column.offers.forEach(o => offers.push({
id: '',
supplierId: columnId,
positionId: String(o.positionSpId),
itemIndex: o.itemIndex,
supplierPosition: o.supplierPosition || undefined,
unit: o.unit,
pricePerUnit: o.price,
quantity: o.quantity,
currencyCode: currencyCodeOf(o.valutaSpId),
pricePerUnitKzt: o.priceTenge || o.price,
totalPriceKzt: o.summaTenge ?? o.summa,
totalWithExtraKzt: o.summaTenge ?? o.summa,
offerId: column.offerFileSpId !== undefined ? String(column.offerFileSpId) : undefined,
deliveryDistanceKm: o.deliveryDistanceKm,
}));
}
return { suppliers, offers };
}
// Шаг 2 пересобирает колонки КП с нуля (повторная обработка, сохранение шага). Исключение с прежней
// колонки того же КП переносим: повторная загрузка КП не возвращает поставщика в сравнение —
// только «Вернуть» на Шаге 3 (решение от 2026-10-27).
export function keepExclusions(columns: Supplier[], previous: Supplier[]): Supplier[] {
return columns.map(column => {
const exclusion = previous.find(p => p.id === column.id)?.exclusion;
return exclusion ? { ...column, exclusion } : column;
});
}
// Колонка исключённого поставщика на момент сохранения: SP Id поставщика и файла КП уже известны
// (у добавленных на Шаге 3 — после их создания в SP).
export interface ExcludedColumnToSave {
id: string;
exclusion: SupplierExclusion;
supplierSpId?: number;
offerFileSpId?: number;
}
export interface ExcludedSaveDeps {
// SP Id позиции по id строки таблицы; undefined — позиция ещё не в SP, оффер не пишем.
positionSpIdOf: (positionId: string) => number | undefined;
valutaSpIdOf: (currencyCode: string) => number | undefined;
deliveryKmOf: (positionId: string, columnId: string) => number | undefined;
}
// «Сохранить» Шага 3: колонки исключённых + их посчитанные офферы → строки для SP. Правила те же,
// что у обычных поставщиков: пустые ячейки (сумма 0) не пишем.
export function buildExcludedRecordsForSave(
columns: ExcludedColumnToSave[],
offers: SupplierOffer[],
deps: ExcludedSaveDeps,
): IExcludedOfferRecord[] {
const records: IExcludedOfferRecord[] = [];
for (const column of columns) {
// Без SP Id поставщика строку не записать (лукап) — такой колонки быть не должно: handleSave
// создаёт поставщиков Шага 3 в SP до записи офферов.
if (column.supplierSpId === undefined) continue;
const columnOffers = offers
.filter(o => o.supplierId === column.id && o.totalWithExtraKzt > 0)
.map(o => ({ offer: o, positionSpId: deps.positionSpIdOf(o.positionId) }))
.filter((x): x is { offer: SupplierOffer; positionSpId: number } => x.positionSpId !== undefined)
.map(({ offer, positionSpId }) => ({
positionSpId,
itemIndex: offer.itemIndex ?? 0,
supplierPosition: offer.supplierPosition ?? '',
unit: offer.unit ?? '',
quantity: offer.quantity,
price: offer.pricePerUnit,
summa: offer.totalPriceKzt,
valutaSpId: deps.valutaSpIdOf(offer.currencyCode),
priceTenge: offer.pricePerUnitKzt,
summaTenge: offer.totalWithExtraKzt,
deliveryDistanceKm: deps.deliveryKmOf(offer.positionId, column.id),
}));
records.push(...toExcludedRecords(
{ supplierSpId: column.supplierSpId, offerFileSpId: column.offerFileSpId, reason: column.exclusion.reason },
columnOffers,
));
}
return records;
}
@@ -0,0 +1,48 @@
import { mapFlowResult } from './flowResult';
describe('mapFlowResult (ответ потока разбора КП)', () => {
// СТ 367: в КП нет сертификатов, поток вернул sertificates: null → String(null) = «null»,
// и в шаге 3 у всех поставщиков в «Сертификатах» стоял текст «null».
it('null в текстовых полях — пусто, а не строка «null»', () => {
const result = mapFlowResult({
position: null, unit: null, conditions: null, deliveryType: null,
deadline: null, sertificates: null, supplier: null, supplierBIN: null,
});
expect(result).toEqual({});
});
it('null в числах — не распознано (undefined), а не 0', () => {
const result = mapFlowResult({ price: null, cost: null, quantity: null });
expect(result).toEqual({});
});
it('заполненные поля переносятся как есть', () => {
const result = mapFlowResult({
position: 'Дюбель-гвоздь 6 x 40',
unit: 'шт.',
conditions: '40% предоплата, 60% перед отгрузкой',
deadline: '25-30 рабочих дней',
supplier: 'BuildCore Supply Ltd.',
price: '0.10',
cost: 1527.5,
quantity: 15275,
});
expect(result).toEqual({
position: 'Дюбель-гвоздь 6 x 40',
unit: 'шт.',
conditions: '40% предоплата, 60% перед отгрузкой',
deadline: '25-30 рабочих дней',
supplier: 'BuildCore Supply Ltd.',
price: 0.1,
cost: 1527.5,
quantity: 15275,
});
});
it('нечисловая цена — 0, как раньше', () => {
expect(mapFlowResult({ price: 'по запросу' }).price).toBe(0);
});
});
@@ -0,0 +1,29 @@
import { FlowResult } from '../types/domain';
// Поток отдаёт null, когда в КП поля нет (например, сертификатов). String(null) давал строку «null»,
// и она показывалась в шаге 3 и уходила в SP. null и undefined — «не распознано».
function optionalText(value: unknown): string | undefined {
return value !== undefined && value !== null ? String(value) : undefined;
}
// Нечисловое значение (например, «по запросу») — 0, как и раньше.
function optionalNumber(value: unknown): number | undefined {
return value !== undefined && value !== null ? Number(value) || 0 : undefined;
}
// Разбор ответа потока КП (JSON из responsev2.predictionOutput.text) — как в КПС (_mapFlowResult).
export function mapFlowResult(parsed: Record<string, unknown>): FlowResult {
return {
position: optionalText(parsed.position),
unit: optionalText(parsed.unit),
conditions: optionalText(parsed.conditions),
deliveryType: optionalText(parsed.deliveryType),
deadline: optionalText(parsed.deadline),
sertificates: optionalText(parsed.sertificates),
cost: optionalNumber(parsed.cost),
price: optionalNumber(parsed.price),
quantity: optionalNumber(parsed.quantity),
supplier: optionalText(parsed.supplier),
supplierBIN: optionalText(parsed.supplierBIN),
};
}
@@ -0,0 +1,47 @@
import { buildKpStorageName } from './kpFileName';
describe('buildKpStorageName (имя файла КП в библиотеке)', () => {
// КП сохранялись под исходным именем с перезаписью: «КП.pdf» из одной СТ затирал «КП.pdf» другой,
// и согласующий первой СТ открывал чужое КП. Префикс СТ делает имя уникальным.
it('добавляет префикс СТ', () => {
expect(buildKpStorageName('357', 'KP.pdf')).toBe('357_KP.pdf');
});
it('не добавляет префикс второй раз', () => {
expect(buildKpStorageName('357', '357_KP.pdf')).toBe('357_KP.pdf');
});
it('без СТ — имя без префикса', () => {
expect(buildKpStorageName(undefined, 'KP.pdf')).toBe('KP.pdf');
});
});
describe('buildKpStorageName — только латиница', () => {
// СТ 365, файл 306: PDF с кириллицей в имени → extractPDFText 500 (OCR 415 «unsupported file type ''»),
// тот же PDF с латинским именем проходит. Бэкенд не наш — храним файл под латинским именем.
it('транслитерирует кириллицу', () => {
expect(buildKpStorageName('365', 'КП_тест_кириллица.pdf')).toBe('365_KP_test_kirillitsa.pdf');
});
it('транслитерирует заглавные и казахские буквы', () => {
expect(buildKpStorageName('274', 'Қорғаныш_Әлем_Щит.pdf')).toBe('274_Qorganysh_Alem_Shchit.pdf');
});
it('пробелы, №, кавычки и тире — в безопасные символы', () => {
expect(buildKpStorageName('363', 'Счет №1891 ТОО «Орда» — копия (2).pdf'))
.toBe('363_Schet_N1891_TOO_Orda_kopiya_(2).pdf');
});
it('латинское имя не меняется', () => {
expect(buildKpStorageName('364', 'KP_02_Qurylys-Material_KZT.pdf')).toBe('364_KP_02_Qurylys-Material_KZT.pdf');
});
it('уже сохранённое имя при повторном вызове не меняется (по нему ищем загруженный файл)', () => {
const stored = buildKpStorageName('274', 'Северный мост.pdf');
expect(buildKpStorageName('274', stored)).toBe(stored);
});
it('расширение сохраняется', () => {
expect(buildKpStorageName('1', 'КП.PDF')).toBe('1_KP.PDF');
});
});
@@ -0,0 +1,42 @@
// Транслитерация кириллицы (русский + казахский) для имени файла в SP.
const TRANSLIT: Record<string, string> = {
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y',
к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f',
х: 'kh', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'shch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
ә: 'a', ғ: 'g', қ: 'q', ң: 'n', ө: 'o', ұ: 'u', ү: 'u', һ: 'h', і: 'i',
'№': 'N',
};
// Всё, кроме этих символов, заменяем на «_».
const UNSAFE_CHARS = /[^A-Za-z0-9_.()-]/g;
function transliterateChar(char: string): string {
const lower = char.toLowerCase();
const mapped = TRANSLIT[lower];
if (mapped === undefined) return char;
// Заглавная буква: «Щ» → «Shch», а не «SHCH».
if (char !== lower && mapped.length > 0) return mapped[0].toUpperCase() + mapped.slice(1);
return mapped;
}
// Бэкенд extractPDFText теряет расширение у PDF с кириллицей в имени (OCR 415 «unsupported file type ''»)
// и отвечает 500 — КП падало при каждой первой загрузке. Бэкенд не наш, поэтому храним файл под именем
// только из латиницы, цифр и «_ - . ( )». Результат детерминированный: по нему ищем уже загруженный файл.
function toSafeFileName(name: string): string {
return Array.from(name)
.map(transliterateChar)
.join('')
.replace(UNSAFE_CHARS, '_')
.replace(/_+/g, '_')
.replace(/_+\./g, '.');
}
// Имя файла КП в библиотеке «Коммерческие предложения» — с префиксом СТ. Файлы лежат в корне библиотеки
// и загружались под исходным именем с перезаписью: «КП.pdf» одной СТ затирал «КП.pdf» другой, и
// согласующий открывал чужое КП. Ссылки, выгрузка INCORE и восстановление по ссылке работают по этому имени.
export function buildKpStorageName(ctId: string | undefined, originalName: string): string {
const safeName = toSafeFileName(originalName);
if (!ctId) return safeName;
const prefix = `${ctId}_`;
return safeName.startsWith(prefix) ? safeName : `${prefix}${safeName}`;
}
@@ -0,0 +1,77 @@
import { getNoOfferSuppliers, noOfferRecordsToSuppliers, planNoOfferSync, withNoOfferSuppliers } from './noOfferSuppliers';
import { Supplier } from '../types/domain';
describe('planNoOfferSync («Не получены КП» по одной СТ)', () => {
const existing = [
{ id: 1, name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ, ТЕСТ!!!!!' },
{ id: 2, name: 'ТОО Asia Techno', comment: 'не работают с маленьким объемом' },
];
it('новый поставщик — создать', () => {
const plan = planNoOfferSync(existing, [
...existing.map(e => ({ name: e.name, comment: e.comment })),
{ name: 'ТОО «Новый»', comment: '' },
]);
expect(plan.toCreate).toEqual([{ name: 'ТОО «Новый»', comment: '' }]);
expect(plan.toRemove).toEqual([]);
});
// Удалили на шаге 2 — должен уйти и из SP, иначе согласующий по ссылке увидит его снова.
it('удалённый на шаге 2 — убрать', () => {
const plan = planNoOfferSync(existing, [{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ, ТЕСТ!!!!!' }]);
expect(plan.toRemove).toEqual([2]);
expect(plan.toCreate).toEqual([]);
});
it('изменённый комментарий — пересоздать запись', () => {
const plan = planNoOfferSync(existing, [
{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'отказались' },
{ name: 'ТОО Asia Techno', comment: 'не работают с маленьким объемом' },
]);
expect(plan.toRemove).toEqual([1]);
expect(plan.toCreate).toEqual([{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'отказались' }]);
});
it('пробелы по краям названия и комментария не считаются изменением', () => {
const plan = planNoOfferSync(existing, [
{ name: ' ТОО БЕЗ КП ТЕСТ ', comment: 'НЕ ТРОГАТЬ, ТЕСТ!!!!! ' },
{ name: 'ТОО Asia Techno', comment: 'не работают с маленьким объемом' },
]);
expect(plan).toEqual({ toCreate: [], toRemove: [] });
});
});
describe('getNoOfferSuppliers', () => {
it('поставщики без КП для списка под таблицей сравнения', () => {
const suppliers: Supplier[] = [
{ id: '263_277', spId: 263, name: 'ТОО «Арлан Офис Снаб»', hasOffer: true },
{ id: 'noOffer_1', name: 'ТОО БЕЗ КП ТЕСТ', hasOffer: false, comment: 'НЕ ТРОГАТЬ' },
];
expect(getNoOfferSuppliers(suppliers)).toEqual([{ name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ' }]);
});
});
describe('поставщики без КП при открытии по ссылке', () => {
const fromSp = noOfferRecordsToSuppliers([{ id: 28, name: 'ТОО БЕЗ КП ТЕСТ', comment: 'НЕ ТРОГАТЬ' }]);
it('записи SP становятся поставщиками без КП', () => {
expect(fromSp).toEqual([{ id: 'noOffer_28', name: 'ТОО БЕЗ КП ТЕСТ', hasOffer: false, comment: 'НЕ ТРОГАТЬ' }]);
});
it('добавляются к колонкам с КП', () => {
const withOffer: Supplier[] = [{ id: '263_277', spId: 263, name: 'Арлан', hasOffer: true }];
expect(withNoOfferSuppliers(withOffer, fromSp).map(s => s.id)).toEqual(['263_277', 'noOffer_28']);
});
it('свои поставщики без КП в сессии не заменяются', () => {
const session: Supplier[] = [{ id: 'local_1', name: 'Из сессии', hasOffer: false }];
expect(withNoOfferSuppliers(session, fromSp)).toEqual(session);
});
});
@@ -0,0 +1,47 @@
import { Supplier } from '../types/domain';
// Поставщик, не предоставивший КП: название и комментарий (почему нет КП).
export interface NoOfferSupplierEntry {
name: string;
comment: string;
}
// Запись списка «Не получены КП» этой СТ в SP.
export interface NoOfferSupplierRecord extends NoOfferSupplierEntry {
id: number;
}
const sameEntry = (a: NoOfferSupplierEntry, b: NoOfferSupplierEntry): boolean =>
a.name.trim() === b.name.trim() && a.comment.trim() === b.comment.trim();
// Синхронизация «Не получены КП» одной СТ с тем, что сейчас на шаге 2: новых — создать, удалённых и
// изменённых (другой комментарий) — убрать, изменённых — создать заново. Раньше записи только
// добавлялись и без привязки к СТ, поэтому по ссылке список было не восстановить.
export function planNoOfferSync(
existing: NoOfferSupplierRecord[],
desired: NoOfferSupplierEntry[],
): { toCreate: NoOfferSupplierEntry[]; toRemove: number[] } {
return {
toCreate: desired.filter(d => !existing.some(e => sameEntry(e, d))),
toRemove: existing.filter(e => !desired.some(d => sameEntry(e, d))).map(e => e.id),
};
}
// Поставщики без КП из колонок формы — для списка под таблицей сравнения (Шаг 3).
export function getNoOfferSuppliers(suppliers: Supplier[]): NoOfferSupplierEntry[] {
return suppliers
.filter(s => s.hasOffer === false)
.map(s => ({ name: s.name, comment: s.comment ?? '' }));
}
// Поставщики без КП из SP (открытие по ссылке / без сессии) — в колонки формы с hasOffer: false.
// Префикс id — чтобы не пересечься с id колонок поставщиков с КП.
export function noOfferRecordsToSuppliers(records: NoOfferSupplierRecord[]): Supplier[] {
return records.map(r => ({ id: `noOffer_${r.id}`, name: r.name, hasOffer: false, comment: r.comment }));
}
// Колонки формы с поставщиками без КП из SP: сессию не трогаем, если в ней уже есть свои без КП.
export function withNoOfferSuppliers(current: Supplier[], fromSp: Supplier[]): Supplier[] {
if (fromSp.length === 0 || current.some(s => s.hasOffer === false)) return current;
return [...current, ...fromSp];
}
@@ -0,0 +1,52 @@
import { parseNumberInput } from './parseNumberInput';
describe('parseNumberInput', () => {
// Претензия ОМТС от 2026-10-23, п.6: цену скопировали из КП «100 000» — сумма посчиталась от 100.
it('пробелы между разрядами не обрывают число', () => {
expect(parseNumberInput('100 000')).toBe(100000);
expect(parseNumberInput('1 00000')).toBe(100000);
expect(parseNumberInput('10 0 000')).toBe(100000);
});
// Word, Excel и PDF ставят между разрядами неразрывный пробел, а не обычный.
it('неразрывные пробелы — тоже', () => {
expect(parseNumberInput('100 000')).toBe(100000);
expect(parseNumberInput('1 234 567,5')).toBe(1234567.5);
});
it('запятая — десятичный разделитель, как в русских КП', () => {
expect(parseNumberInput('275 293,50')).toBe(275293.5);
expect(parseNumberInput('0,061')).toBe(0.061);
});
it('точка — тоже десятичный разделитель', () => {
expect(parseNumberInput('286575.5')).toBe(286575.5);
});
// КП на английском: «1,234,567.89». Есть и запятая, и точка — десятичный разделитель последний.
it('английский формат с разделителями разрядов', () => {
expect(parseNumberInput('1,234,567.89')).toBe(1234567.89);
expect(parseNumberInput('1.234.567,89')).toBe(1234567.89);
});
it('несколько одинаковых разделителей — это разряды', () => {
expect(parseNumberInput('1,000,000')).toBe(1000000);
expect(parseNumberInput('1.000.000')).toBe(1000000);
});
it('хвост из валюты или единиц не мешает', () => {
expect(parseNumberInput('100 000 ₸')).toBe(100000);
expect(parseNumberInput('1 540 USD')).toBe(1540);
});
it('пусто или не число — undefined, а не 0: «не введено» ≠ «ноль»', () => {
expect(parseNumberInput('')).toBeUndefined();
expect(parseNumberInput(' ')).toBeUndefined();
expect(parseNumberInput('abc')).toBeUndefined();
});
it('обычные числа как раньше', () => {
expect(parseNumberInput('635')).toBe(635);
expect(parseNumberInput('-5')).toBe(-5);
});
});
@@ -0,0 +1,32 @@
// Число из поля ввода. Цены и количества копируют из КП, Excel и PDF: «100 000», «275 293,50»,
// «1,234,567.89», с неразрывными пробелами между разрядами. parseFloat читает до первого пробела —
// «100 000» превращалось в 100, и сумма считалась в тысячу раз меньше.
export function parseNumberInput(value: string): number | undefined {
// \s в JS покрывает и неразрывные пробелы (U+00A0, U+202F).
const compact = value.replace(/\s/g, '');
if (compact === '') return undefined;
const parsed = parseFloat(normalizeSeparators(compact));
return isNaN(parsed) ? undefined : parsed;
}
// Приводит разделители к виду «1234567.89»:
// - есть и «,», и «.» — десятичный тот, что последний, другой — разряды («1,234.5», «1.234,5»);
// - один и тот же знак несколько раз — разряды («1,000,000»);
// - один знак один раз — десятичный (в русских КП это запятая: «0,061»).
function normalizeSeparators(text: string): string {
const lastComma = text.lastIndexOf(',');
const lastDot = text.lastIndexOf('.');
if (lastComma !== -1 && lastDot !== -1) {
const decimal = lastComma > lastDot ? ',' : '.';
const thousands = decimal === ',' ? '.' : ',';
return text.split(thousands).join('').replace(decimal, '.');
}
const separator = lastComma !== -1 ? ',' : lastDot !== -1 ? '.' : undefined;
if (separator === undefined) return text;
const occurrences = text.split(separator).length - 1;
return occurrences > 1 ? text.split(separator).join('') : text.replace(separator, '.');
}
@@ -0,0 +1,27 @@
import { isPlaceholderKpFile, withoutPlaceholders } from './placeholderKpFile';
describe('isPlaceholderKpFile', () => {
// СТ 375: поставщик «fghfg» добавлен на Шаге 3 — заглушка видна в «Коммерческих предложениях».
it('заглушка поставщика, добавленного вручную', () => {
expect(isPlaceholderKpFile('fghfg_1793100061349.txt')).toBe(true);
expect(isPlaceholderKpFile('ООО Альфа_1793100061349.TXT')).toBe(true);
});
it('настоящие КП — не заглушки', () => {
expect(isPlaceholderKpFile('372_KP_SteelGate_Export_USD.pdf')).toBe(false);
expect(isPlaceholderKpFile('372_KP_TOO_Kaspiy_Dors.docx')).toBe(false);
expect(isPlaceholderKpFile('КП.doc')).toBe(false);
});
});
describe('withoutPlaceholders', () => {
it('оставляет только настоящие КП, порядок сохраняется', () => {
const files = [
{ spId: 318, name: '372_KP_SteelGate.pdf' },
{ spId: 322, name: 'fghfg_1793100061349.txt' },
{ spId: 320, name: '372_KP_Kaspiy.docx' },
];
expect(withoutPlaceholders(files).map(f => f.spId)).toEqual([318, 320]);
});
});

Some files were not shown because too many files have changed in this diff Show More