Compare commits
43 Commits
e40fc402d9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 06dff47f70 | |||
| 842b9ad678 | |||
| 2591d94414 | |||
| 2eaa75e876 | |||
| eaef011b73 | |||
| 99c720f26c | |||
| 107110b2b8 | |||
| da2c86b4a9 | |||
| 09a0889741 | |||
| 5c21718914 | |||
| 78e5eec9b6 | |||
| ad76e59ca0 | |||
| 053e4d4c3e | |||
| 09b1df87c3 | |||
| 7341774e50 | |||
| 9da03f931c | |||
| 952a7ed42d | |||
| 90b009dfc9 | |||
| 6ad40bc523 | |||
| 9b0135b379 | |||
| 3a6aaa4722 | |||
| efbe732995 | |||
| 40839cc8d8 | |||
| 0ff17d3293 | |||
| 606c7c0fa9 | |||
| 40fcc775f7 | |||
| 74f6d42e4f | |||
| 373e34b2cf | |||
| 3907e5a60b | |||
| b5d7efc749 | |||
| f9bed709ea | |||
| 26b937afba | |||
| 58a6e13f36 | |||
| 3ede7f2af4 | |||
| 0f9afdba7d | |||
| cfbdfd17c9 | |||
| a0d9179d9d | |||
| 48c7999fa0 | |||
| 1669c1b0e8 | |||
| f99732962b | |||
| 62ebcb2f32 | |||
| 5382459414 | |||
| 61913ee79b |
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Запрос
|
||||
about: Пункт из чата, устная просьба или найденная проблема
|
||||
---
|
||||
|
||||
## Источник
|
||||
|
||||
- Кто:
|
||||
- Когда:
|
||||
- Где: Teams / устно / сам нашёл
|
||||
|
||||
> Цитата как есть
|
||||
|
||||
## Суть
|
||||
|
||||
Что не так или что нужно, своими словами.
|
||||
|
||||
## Как проверить
|
||||
|
||||
Что сделать руками, чтобы убедиться, что готово.
|
||||
@@ -1 +1 @@
|
||||
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
||||
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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` по манифестам (сначала тест, потом бой).
|
||||
@@ -0,0 +1,37 @@
|
||||
# Журнал архитектурных решений (ADR)
|
||||
|
||||
Одно решение — один файл `NNNN-kratkoe-nazvanie.md`. Файлы не переписываются задним числом:
|
||||
если решение пересмотрели, пишется новый ADR со ссылкой «Заменяет NNNN», а у старого статус
|
||||
меняется на «Заменено NNNN».
|
||||
|
||||
Статусы: **Предложено** → **Принято** → (**Заменено** / **Отменено**).
|
||||
|
||||
| № | Решение | Статус | Дата |
|
||||
| --- | --- | --- | --- |
|
||||
| [0001](0001-isklyuchenie-postavshchikov.md) | Исключение поставщика из сравнения вместо удаления | Принято | 2026-10-27 |
|
||||
|
||||
## Шаблон
|
||||
|
||||
```markdown
|
||||
# NNNN. Название
|
||||
|
||||
- Статус: Предложено
|
||||
- Дата: ГГГГ-ММ-ДД
|
||||
- Задача: #номер
|
||||
- Источник: кто и где попросил
|
||||
|
||||
## Контекст
|
||||
Что происходит сейчас и почему это проблема.
|
||||
|
||||
## Варианты
|
||||
Что рассматривали, плюсы и минусы каждого.
|
||||
|
||||
## Решение
|
||||
Что выбрали.
|
||||
|
||||
## Последствия
|
||||
Что меняется, что придётся сделать, какие риски.
|
||||
|
||||
## Открытые вопросы
|
||||
Что ещё не решено.
|
||||
```
|
||||
Generated
+4
-4
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -345,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",
|
||||
@@ -374,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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -386,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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -101,6 +101,16 @@
|
||||
"title": "Сумма из сметы",
|
||||
"type": "Number",
|
||||
"decimals": 2
|
||||
},
|
||||
{
|
||||
"internalName": "RequestCode",
|
||||
"title": "Номер из заявки",
|
||||
"type": "Text"
|
||||
},
|
||||
{
|
||||
"internalName": "RequestUrl",
|
||||
"title": "Ссылка на заявку",
|
||||
"type": "Text"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -381,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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
background: $surface;
|
||||
|
||||
@@ -9,11 +9,14 @@ import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSe
|
||||
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
|
||||
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
|
||||
import { Step4Approval } from './Step4Approval/Step4Approval';
|
||||
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
|
||||
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';
|
||||
|
||||
@@ -35,6 +38,11 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
ensureHighestStep,
|
||||
} = useCtForm();
|
||||
|
||||
// Открыта существующая СТ — шаги не показываем, пока она не загрузится из SP. Иначе шаг 1 успевал
|
||||
// появиться с пустой сессией и запоминал пустой выбор: «Выберите позиции», хотя позиции в СТ есть.
|
||||
const [isCtLoading, setIsCtLoading] = useState(!!initialCtId);
|
||||
const [ctLoadError, setCtLoadError] = useState<string | undefined>(undefined);
|
||||
|
||||
// Режим формы — по тому, кто открыл и в каком статусе СТ (getCtAccess), а не по ctid/ctId в ссылке.
|
||||
// Считается на каждом рендере из данных СТ — ничего не «прилипает» в сессии: после «Перезапустить
|
||||
// согласование» или возврата на доработку режим меняется сам. astel-k — цены не видит никогда.
|
||||
@@ -75,6 +83,12 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
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 => {
|
||||
@@ -96,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);
|
||||
@@ -108,16 +122,22 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
// КП в разных валютах. Используются, только когда в сессии колонок нет.
|
||||
const currencyCodeOf = (valutaSpId?: number): string =>
|
||||
valutaSpId ? (rateById.get(valutaSpId)?.code ?? 'KZT') : 'KZT';
|
||||
const spSuppliers = buildColumnsFromSpOffers(rawOffers.map(o => ({
|
||||
// Поставщики Шага 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); });
|
||||
// Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из SP.
|
||||
const visibleColumns = formData.suppliers.length > 0 ? formData.suppliers : spSuppliers;
|
||||
// Поставщики Шага 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);
|
||||
@@ -147,6 +167,15 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
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;
|
||||
@@ -161,16 +190,28 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
approvalMatrix,
|
||||
// Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID),
|
||||
// which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3.
|
||||
...(preferSp || 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 in review mode (always fresh).
|
||||
suppliers: withNoOfferSuppliers(formData.suppliers.length === 0 ? spSuppliers : formData.suppliers, noOfferFromSp),
|
||||
...(preferSp || formData.offers.length === 0 ? { offers } : {}),
|
||||
suppliers,
|
||||
...(winnerColumnId !== undefined ? { recommendedSupplierId: winnerColumnId } : {}),
|
||||
...(preferSp || formData.offers.length === 0 ? { offers: [...offers, ...excluded.offers] } : {}),
|
||||
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
|
||||
...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}),
|
||||
...(Object.keys(step3DeliveryKm).length > 0 ? { step3DeliveryKm } : {}),
|
||||
@@ -185,18 +226,25 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
|
||||
// Автор, СТ возвращена на доработку — шаг 4: там видно, кто вернул и что исправить.
|
||||
ensureHighestStep(4);
|
||||
goToStep(4);
|
||||
} else if (approvalMatrix.length > 0) {
|
||||
// Regular reload — make Step 4 accessible; only mark complete if CT is fully resolved
|
||||
ensureHighestStep(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);
|
||||
}
|
||||
}
|
||||
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
|
||||
}, []);
|
||||
@@ -256,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}
|
||||
|
||||
+13
@@ -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>
|
||||
);
|
||||
+39
-17
@@ -5,8 +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 { countSelectedInRequest, planStep1Save, resolveSelectedPositions } from './step1Plan';
|
||||
import {
|
||||
KnownPosition,
|
||||
buildPinnedRequests,
|
||||
countSelectedInRequest,
|
||||
mergeKnownPositions,
|
||||
planStep1Save,
|
||||
resolveProject,
|
||||
resolveSelectedPositions,
|
||||
selectionKey,
|
||||
} from './step1Plan';
|
||||
import styles from './Step1PositionSelection.module.scss';
|
||||
|
||||
interface Props {
|
||||
@@ -20,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,
|
||||
@@ -35,13 +47,24 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
||||
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, loadPositions, loadingPositionIds } = 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 [];
|
||||
@@ -65,6 +88,8 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
||||
}, [isReadOnly, formData.selectedPositions, formData.selectedProject]);
|
||||
|
||||
const filteredRequests = useMemo(() => {
|
||||
// Фильтр в браузере — пока набирают номер (до ответа сервера) и для закреплённых потребностей СТ,
|
||||
// которые добавляются к выдаче без учёта поиска.
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
let filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests;
|
||||
// Режим «Только выбранные» — оставляем заявки, где есть хотя бы одна выбранная позиция,
|
||||
@@ -82,18 +107,14 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
||||
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) => {
|
||||
@@ -166,7 +187,7 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
const selectedProject = selectedPositions.length > 0
|
||||
? positionMap.get(selectedPositions[0].id)?.request.project ?? formData.selectedProject ?? null
|
||||
? positionMap.get(selectedPositions[0].id)?.request.project ?? project ?? null
|
||||
: null;
|
||||
|
||||
const canSave = selectedPositions.length > 0;
|
||||
@@ -197,7 +218,8 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
||||
if (ct.projectName !== selectedProject.name) {
|
||||
await service.updateCTProject(ct.id, selectedProject.name);
|
||||
}
|
||||
const removedIds = new Set(plan.toRemove.map(p => p.id));
|
||||
// Предложения привязаны к позиции либо по id из сессии, либо по SP Id (загружены из SP).
|
||||
const removedIds = new Set(plan.toRemove.flatMap(p => [p.id, String(p.spId)]));
|
||||
onSave({
|
||||
ct: { ...ct, projectName: selectedProject.name },
|
||||
selectedProject,
|
||||
@@ -250,9 +272,9 @@ export const Step1PositionSelection: React.FC<Props> = ({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{formData.selectedProject && (
|
||||
{project && (
|
||||
<Alert variant="info">
|
||||
Выбран проект: <strong>{formData.selectedProject.name}</strong> — позиции из других проектов недоступны
|
||||
Выбран проект: <strong>{project.name}</strong> — позиции из других проектов недоступны
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+144
-3
@@ -1,5 +1,14 @@
|
||||
import { countSelectedInRequest, planStep1Save, resolveSelectedPositions } from './step1Plan';
|
||||
import { Position } from '../types/domain';
|
||||
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 {
|
||||
@@ -54,7 +63,7 @@ describe('planStep1Save (СТ уже создана — повторное со
|
||||
});
|
||||
|
||||
describe('countSelectedInRequest', () => {
|
||||
// Позиции потребности грузятся только при раскрытии — считаем выбранные по id «потребность__номер».
|
||||
// Позиции потребности грузятся только при раскрытии — считаем выбранные по префиксу id «потребность__».
|
||||
it('считает выбранные позиции потребности без загрузки её позиций', () => {
|
||||
const selected = new Set(['req1__0', 'req1__4', 'req2__0']);
|
||||
|
||||
@@ -68,6 +77,46 @@ describe('countSelectedInRequest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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: потребность не раскрыта — выбранные позиции берём из сохранённых,
|
||||
// иначе «Сохранить» неактивна, хотя выбор есть.
|
||||
@@ -83,3 +132,95 @@ describe('resolveSelectedPositions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+76
-11
@@ -1,8 +1,15 @@
|
||||
import { Position } from '../types/domain';
|
||||
import { Position, Project, Request } from '../types/domain';
|
||||
|
||||
// id позиции заявки — `${id потребности}__${номер}` (см. CtService._mapPosition).
|
||||
// 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 всегда создавал новую СТ — предложения оставались в прежней, а новая
|
||||
// открывалась по ссылке пустой.
|
||||
@@ -13,13 +20,13 @@ export interface Step1SavePlan {
|
||||
}
|
||||
|
||||
export function planStep1Save(saved: Position[], selected: Position[]): Step1SavePlan {
|
||||
const selectedIds = new Set(selected.map(p => p.id));
|
||||
const savedById = new Map(saved.map(p => [p.id, p]));
|
||||
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 = savedById.get(p.id);
|
||||
const savedPosition = savedByKey.get(selectionKey(p));
|
||||
if (savedPosition?.spId !== undefined) {
|
||||
toKeep.push(savedPosition);
|
||||
} else {
|
||||
@@ -30,7 +37,7 @@ export function planStep1Save(saved: Position[], selected: Position[]): Step1Sav
|
||||
return {
|
||||
toKeep,
|
||||
toCreate,
|
||||
toRemove: saved.filter(p => p.spId !== undefined && !selectedIds.has(p.id)),
|
||||
toRemove: saved.filter(p => p.spId !== undefined && !selectedKeys.has(selectionKey(p))),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,15 +49,73 @@ export function countSelectedInRequest(selectedIds: Set<string>, requestId: stri
|
||||
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(
|
||||
selectedIds: string[],
|
||||
findLoaded: (id: string) => Position | undefined,
|
||||
selectedKeys: string[],
|
||||
findLoaded: (key: string) => Position | undefined,
|
||||
saved: Position[],
|
||||
): Position[] {
|
||||
const savedById = new Map(saved.map(p => [p.id, p]));
|
||||
return selectedIds
|
||||
.map(id => findLoaded(id) ?? savedById.get(id))
|
||||
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: '' };
|
||||
}
|
||||
|
||||
+3
-2
@@ -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]
|
||||
|
||||
+25
-4
@@ -13,6 +13,8 @@ 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 {
|
||||
@@ -50,6 +52,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -136,12 +139,18 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
kp.handleFileRemove(localId);
|
||||
};
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
// '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 });
|
||||
onSave(patch);
|
||||
if (mode === 'draft') {
|
||||
setDraftSavedAt(new Date());
|
||||
return;
|
||||
}
|
||||
onStepComplete(2);
|
||||
onNext();
|
||||
} catch (err) {
|
||||
@@ -152,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 => ({
|
||||
@@ -193,9 +204,9 @@ 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}
|
||||
currencyOptions={currencyOptions}
|
||||
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
|
||||
@@ -212,9 +223,19 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
<div className={styles.footerRight}>
|
||||
{saveError && <Alert variant="err">{saveError}</Alert>}
|
||||
{canEdit && !isReadOnly && (
|
||||
<Button variant="accent" onClick={handleSave} disabled={!canProceed}>
|
||||
<>
|
||||
<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>
|
||||
|
||||
+8
-2
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -77,7 +78,8 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
|
||||
savedRates = await service.saveCurrencyRates(currencyRates);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -94,7 +96,11 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
|
||||
const offersForSP = newOffers
|
||||
.map(o => {
|
||||
const pos = formData.selectedPositions.find(p => p.id === o.positionId);
|
||||
const supplierSpId = newSuppliersWithOffer.find(s => s.id === o.supplierId)?.spId;
|
||||
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) };
|
||||
})
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from '
|
||||
import { buildKpSuppliersAndOffers } from './step2Persist';
|
||||
import { buildKpOffersToSave } from './kpOfferRecords';
|
||||
import { buildKpStorageName } from '../utils/kpFileName';
|
||||
import { keepExclusions } from '../utils/excludedColumns';
|
||||
|
||||
let localIdCounter = 0;
|
||||
|
||||
@@ -62,7 +63,7 @@ export function useKpProcessing(
|
||||
// Заменяем колонки этих же КП (поставщик + файл), остальные — сохраняем: другой КП того же
|
||||
// поставщика — отдельное предложение, своя колонка.
|
||||
const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.id === s.id));
|
||||
patch.suppliers = [...others, ...kpSuppliers];
|
||||
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];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+69
@@ -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>
|
||||
);
|
||||
};
|
||||
+82
@@ -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>
|
||||
);
|
||||
};
|
||||
+49
-7
@@ -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-переменную,
|
||||
@@ -107,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>
|
||||
</>
|
||||
) : (
|
||||
@@ -128,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 */}
|
||||
@@ -192,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) */}
|
||||
@@ -228,6 +257,7 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{showAddColumn && <td className={styles.addSupplierFillerCell} />}
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@@ -251,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>
|
||||
);
|
||||
}
|
||||
|
||||
+43
@@ -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>
|
||||
);
|
||||
};
|
||||
+37
@@ -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>
|
||||
);
|
||||
};
|
||||
+29
-7
@@ -5,6 +5,7 @@ import { PositionRow } from './matrixTypes';
|
||||
import { Step3Matrix } from './useStep3Matrix';
|
||||
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';
|
||||
|
||||
@@ -24,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);
|
||||
@@ -46,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);
|
||||
@@ -75,6 +91,10 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
||||
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}>
|
||||
@@ -115,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
|
||||
@@ -160,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
|
||||
@@ -180,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
|
||||
@@ -214,6 +234,8 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{/* Под колонкой «+ Добавить поставщика». */}
|
||||
{!isReadOnly && <td className={styles.addSupplierFillerCell} />}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-3
@@ -4,7 +4,9 @@ import { CurrencyRate } from '../types/domain';
|
||||
import { PositionRow } from './matrixTypes';
|
||||
import { Step3Matrix } from './useStep3Matrix';
|
||||
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';
|
||||
|
||||
@@ -55,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>
|
||||
</>
|
||||
@@ -97,6 +99,8 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
||||
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}>
|
||||
@@ -159,7 +163,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
||||
</td>
|
||||
{!isRestrictedView && isForeign && (
|
||||
<>
|
||||
<td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
|
||||
<td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||
{showInput ? (
|
||||
<div className={styles.offerInput}>
|
||||
<TextInput
|
||||
@@ -179,7 +183,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
{!isRestrictedView && <td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}>
|
||||
{!isRestrictedView && <td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
|
||||
{showInput ? (
|
||||
<div className={styles.offerInput}>
|
||||
<TextInput
|
||||
@@ -214,6 +218,8 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{/* Под колонкой «+ Добавить поставщика»: одна ячейка на позицию, включая строки комплекта. */}
|
||||
{rowIdx === 0 && !isReadOnly && <td className={styles.addSupplierFillerCell} rowSpan={posRowSpan} />}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
+116
-14
@@ -675,6 +675,11 @@ $delivery-km-min-width: 76px;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
// Кнопка «Вернуть» — по центру строки с именем и причиной.
|
||||
.excludedSupplierItem {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.criteriaTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
@@ -861,20 +866,6 @@ $delivery-km-min-width: 76px;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.supplierToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 0 8px;
|
||||
max-width: 30%;
|
||||
|
||||
> :first-child {
|
||||
flex: 1 1 0;
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.addRow {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -1344,3 +1335,114 @@ $delivery-km-min-width: 76px;
|
||||
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;
|
||||
}
|
||||
|
||||
+64
-32
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button, Alert, TextInput, Toggle, Spinner, Dialog, Icon } from '@tnsu/ui-kit';
|
||||
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';
|
||||
@@ -18,6 +18,15 @@ 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;
|
||||
@@ -51,15 +60,20 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, onSave, onNext, onStepComplete });
|
||||
const {
|
||||
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
|
||||
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
|
||||
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);
|
||||
@@ -107,35 +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(); }}
|
||||
<AddSupplierToolbar
|
||||
name={newSupplierName}
|
||||
currency={newSupplierCurrency}
|
||||
currencyOptions={currencyOptions}
|
||||
onNameChange={setNewSupplierName}
|
||||
onCurrencyChange={setNewSupplierCurrency}
|
||||
onAdd={handleAddSupplier}
|
||||
onOpenDatabase={() => setShowVendorPicker(true)}
|
||||
/>
|
||||
</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>
|
||||
<Button variant="secondary" onClick={() => setShowVendorPicker(true)}>
|
||||
<Icon name="building" size="sm" />
|
||||
Добавить из базы поставщиков
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Тумблер пересчёта единиц измерения поставщика в ед. изм. заявки */}
|
||||
@@ -175,14 +171,33 @@ 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={formData.uploadedKpFiles ?? []} service={service} />}
|
||||
{/* Заглушки поставщиков, добавленных вручную, — не КП: не показываем. */}
|
||||
{!isRestrictedView && <KpFilesList files={withoutPlaceholders(formData.uploadedKpFiles ?? [])} service={service} />}
|
||||
|
||||
<AiCommentBlock
|
||||
comment={ai.aiComment}
|
||||
@@ -207,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}
|
||||
@@ -224,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 ? (
|
||||
@@ -239,6 +262,15 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddSupplierDialog && (
|
||||
<AddSupplierDialog
|
||||
currencyOptions={currencyOptions}
|
||||
onAddManual={addManualSupplier}
|
||||
onOpenDatabase={() => setShowVendorPicker(true)}
|
||||
onClose={() => setShowAddSupplierDialog(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showVendorPicker && (
|
||||
<VendorPickerDialog
|
||||
vendorDbService={vendorDbService}
|
||||
|
||||
+29
-2
@@ -1,7 +1,34 @@
|
||||
import { applyCreatedIds, buildOfferKey, computeOffer, getSubCols, isMinPositionTotal, round2Str, sumPositionTotals } from './matrixHelpers';
|
||||
import { ExtraPosition } from './matrixTypes';
|
||||
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(
|
||||
|
||||
+60
-2
@@ -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 = [
|
||||
@@ -87,6 +87,64 @@ 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 и не создаст ту же строку ещё раз.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// Типы матрицы сравнения (Шаг 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;
|
||||
|
||||
+55
@@ -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: [] };
|
||||
}
|
||||
+62
@@ -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);
|
||||
});
|
||||
});
|
||||
+42
@@ -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;
|
||||
}
|
||||
+172
-100
@@ -1,10 +1,14 @@
|
||||
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 { applyCreatedIds, buildOfferKey, computeOffer, sumPositionTotals } 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';
|
||||
@@ -92,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);
|
||||
@@ -274,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,
|
||||
@@ -289,7 +301,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const suppliersWithOffer = useMemo(() => [
|
||||
// Все колонки поставщиков с КП, включая исключённых из сравнения (docs/adr/0001).
|
||||
const allColumns = useMemo(() => [
|
||||
// Поставщики без КП с Шага 2 (hasOffer:false) в таблицу сравнения не попадают — сравнивать
|
||||
// у них нечего. Ручной ввод цен остаётся за поставщиками, добавленными на самом Шаге 3 (extras).
|
||||
...suppliers
|
||||
@@ -299,19 +312,29 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
id: es.id, name: es.name, hasOffer: true as const, isExtraSupplier: true as const,
|
||||
// spId/bin известны сразу для поставщиков, добавленных из «База данных поставщиков»
|
||||
// (см. handleAddSupplierFromDatabase) — без этого useAdataCheck не увидит их БИН.
|
||||
spId: es.spId, bin: es.bin,
|
||||
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(suppliersWithOffer, formData.uploadedKpFiles ?? []),
|
||||
[suppliersWithOffer, formData.uploadedKpFiles],
|
||||
() => buildColumnLabels(allColumns, formData.uploadedKpFiles ?? []),
|
||||
[allColumns, formData.uploadedKpFiles],
|
||||
);
|
||||
const columnDisplayNames = useMemo(
|
||||
() => buildColumnDisplayNames(suppliersWithOffer, columnLabels),
|
||||
[suppliersWithOffer, columnLabels],
|
||||
() => buildColumnDisplayNames(allColumns, columnLabels),
|
||||
[allColumns, columnLabels],
|
||||
);
|
||||
|
||||
const allPositions = useMemo((): PositionRow[] => {
|
||||
@@ -348,35 +371,21 @@ 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 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;
|
||||
}, [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]);
|
||||
@@ -401,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) };
|
||||
});
|
||||
},
|
||||
@@ -418,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) };
|
||||
});
|
||||
},
|
||||
@@ -475,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;
|
||||
@@ -575,16 +587,23 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSupplier = (): void => {
|
||||
if (!newSupplierName.trim()) return;
|
||||
// Поставщик, добавленный на Шаге 3 вручную (без КП). Общая точка для обеих кнопок добавления:
|
||||
// блока над таблицей и колонки «+ Добавить поставщика» в самой таблице.
|
||||
const addManualSupplier = (name: string, currency: string): void => {
|
||||
if (isReadOnly || !name.trim()) return;
|
||||
const id = `extra_sup_${++extraIdCounter}`;
|
||||
const newEntry = { id, name: newSupplierName.trim(), currency: newSupplierCurrency };
|
||||
const updated = [...extraSuppliers, newEntry];
|
||||
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');
|
||||
onSave({ step3ExtraSuppliers: updated });
|
||||
};
|
||||
|
||||
// Добавление поставщика из VendorPickerDialog («База данных поставщиков»): та же механика
|
||||
@@ -660,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);
|
||||
@@ -703,15 +733,26 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
onSave({ step3ExtraSuppliers: nextSuppliers, step3ExtraPositions: nextPositions });
|
||||
};
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!ct) return;
|
||||
const deliveryMissingKm = extraPositions.filter(ep =>
|
||||
// Проверка перед «Сохранить и продолжить». Показывает ошибку и говорит, можно ли сохранять.
|
||||
// Вызывается и до диалога «Комментарий от ИИ» — чтобы не гонять пользователя через ИИ к отказу.
|
||||
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}`])
|
||||
);
|
||||
if (deliveryMissingKm.length > 0) {
|
||||
setSaveError('Укажите километраж для каждого потенциального поставщика в строке логистики');
|
||||
return;
|
||||
}
|
||||
),
|
||||
});
|
||||
setSaveError(error);
|
||||
return error === undefined;
|
||||
};
|
||||
|
||||
// Запись Шага 3 в SP. 'next' — с проверками и переходом на Шаг 4; 'draft' — без проверок,
|
||||
// остаёмся на шаге (черновик: сохранить недоделанное, чтобы не потерять при закрытии вкладки).
|
||||
const handleSave = async (mode: Step3SaveMode = 'next'): Promise<void> => {
|
||||
if (!ct) return;
|
||||
if (mode === 'next' && !validateBeforeSave()) return;
|
||||
setIsSaving(true);
|
||||
setSaveError(undefined);
|
||||
|
||||
@@ -737,6 +778,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
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, [{
|
||||
@@ -800,6 +844,32 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
});
|
||||
|
||||
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 видят только то, что распознал ИИ.
|
||||
@@ -826,34 +896,26 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
// 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) => ({
|
||||
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,
|
||||
})),
|
||||
// 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.
|
||||
@@ -862,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();
|
||||
@@ -931,7 +997,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
updateSupplierProductName,
|
||||
addBundleItem,
|
||||
removeBundleItem,
|
||||
handleDeleteSupplier,
|
||||
excludedColumns,
|
||||
handleExcludeSupplier,
|
||||
handleRestoreSupplier,
|
||||
updatePosQty,
|
||||
updateCriteriaValue,
|
||||
updateCustomCriteriaValue,
|
||||
@@ -939,13 +1007,17 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
handleRemoveCustomCriteria,
|
||||
handleNameColResizeMouseDown,
|
||||
handleStatusChange,
|
||||
addManualSupplier,
|
||||
handleAddSupplier,
|
||||
handleAddSupplierFromDatabase,
|
||||
handleAddPosition,
|
||||
handleAddDelivery,
|
||||
handleDeliveryKmChange,
|
||||
handleDeleteExtraPosition,
|
||||
handleExtraPositionUnitChange,
|
||||
validateBeforeSave,
|
||||
handleSave,
|
||||
draftSavedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -72,9 +73,10 @@ export function useIncore(args: UseIncoreArgs) {
|
||||
setCardAttachment(saved);
|
||||
|
||||
// 4. Пакет для INCORE: карточка с ЭЦП + файлы КП — скачиваем пользователю.
|
||||
// Заглушки поставщиков, добавленных вручную, — не КП, в пакет не идут.
|
||||
service.downloadBlob(cardBlob, incoreCardName);
|
||||
|
||||
const kpFiles = formData.uploadedKpFiles ?? [];
|
||||
const kpFiles = withoutPlaceholders(formData.uploadedKpFiles ?? []);
|
||||
for (const f of kpFiles) {
|
||||
await service.downloadKpFile(f.name);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -14,7 +14,27 @@ interface UseRequestsResult {
|
||||
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);
|
||||
@@ -33,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);
|
||||
}
|
||||
})
|
||||
@@ -50,7 +71,7 @@ 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[]> => {
|
||||
|
||||
@@ -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 — наименование позиции
|
||||
@@ -45,6 +48,8 @@ export interface CT {
|
||||
// блока «Выгрузка в INCORE» и т.п.: в новом механизме согласования (approval-route API)
|
||||
// инициатор в саму матрицу согласования не входит, в отличие от прежней цепочки.
|
||||
authorEmail?: string;
|
||||
// Победитель (лукап winSupplier → «Поставщики»), записывается на Шаге 3.
|
||||
winSupplierSpId?: number;
|
||||
}
|
||||
|
||||
export type CTStatus =
|
||||
@@ -70,6 +75,12 @@ export interface Supplier {
|
||||
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) по БИН/ИИН контрагента.
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -4,14 +4,44 @@ describe('buildKpStorageName (имя файла КП в библиотеке)',
|
||||
// КП сохранялись под исходным именем с перезаписью: «КП.pdf» из одной СТ затирал «КП.pdf» другой,
|
||||
// и согласующий первой СТ открывал чужое КП. Префикс СТ делает имя уникальным.
|
||||
it('добавляет префикс СТ', () => {
|
||||
expect(buildKpStorageName('357', 'КП.pdf')).toBe('357_КП.pdf');
|
||||
expect(buildKpStorageName('357', 'KP.pdf')).toBe('357_KP.pdf');
|
||||
});
|
||||
|
||||
it('не добавляет префикс второй раз', () => {
|
||||
expect(buildKpStorageName('357', '357_КП.pdf')).toBe('357_КП.pdf');
|
||||
expect(buildKpStorageName('357', '357_KP.pdf')).toBe('357_KP.pdf');
|
||||
});
|
||||
|
||||
it('без СТ — исходное имя', () => {
|
||||
expect(buildKpStorageName(undefined, 'КП.pdf')).toBe('КП.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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +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 {
|
||||
if (!ctId) return originalName;
|
||||
const safeName = toSafeFileName(originalName);
|
||||
if (!ctId) return safeName;
|
||||
const prefix = `${ctId}_`;
|
||||
return originalName.startsWith(prefix) ? originalName : `${prefix}${originalName}`;
|
||||
return safeName.startsWith(prefix) ? safeName : `${prefix}${safeName}`;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// Файл-заглушка КП: создаётся для поставщика, добавленного на Шаге 3 вручную, — оффер в SP должен
|
||||
// ссылаться на файл КП (CtService._createPlaceholderKpFile). Людям он не нужен: в списках КП и в
|
||||
// пакете INCORE его не показываем. Узнаём по расширению: на Шаге 2 загрузить можно только
|
||||
// .pdf/.docx/.doc (FileUploadArea), а заглушка всегда .txt.
|
||||
const PLACEHOLDER_EXTENSION = '.txt';
|
||||
|
||||
// Подпись колонки поставщика, у которого вместо КП — заглушка.
|
||||
export const PLACEHOLDER_COLUMN_LABEL = 'Добавлен вручную';
|
||||
|
||||
export function isPlaceholderKpFile(fileName: string): boolean {
|
||||
return fileName.toLowerCase().endsWith(PLACEHOLDER_EXTENSION);
|
||||
}
|
||||
|
||||
// Настоящие КП — для показа и выгрузки. Данные формы (uploadedKpFiles) не фильтруем: по ним
|
||||
// сохранение находит файл КП колонки, в том числе заглушку.
|
||||
export function withoutPlaceholders<T extends { name: string }>(files: T[]): T[] {
|
||||
return files.filter(file => !isPlaceholderKpFile(file.name));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { buildPositionKeys, PositionKeySource } from './positionKey';
|
||||
|
||||
function makeSource(materialName: string, overrides: Partial<PositionKeySource> = {}): PositionKeySource {
|
||||
return {
|
||||
materialName,
|
||||
r66Description: `R66 ${materialName}`,
|
||||
agskCode: '',
|
||||
unit: 'Штука',
|
||||
quantity: 10,
|
||||
smrType: 'ГКЛ перегородки',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const REQUEST_ID = 'ace2f40343f5cfe011f1c60cee00e6ca';
|
||||
|
||||
describe('buildPositionKeys (id позиции потребности)', () => {
|
||||
// API /Requests/{oid}/positions отдаёт позиции без id и каждый раз в разном порядке (потребность
|
||||
// 081/26-10/0028: три разных порядка за день, lineNumber — номер после сортировки по алфавиту).
|
||||
// id был `GUID__номер в ответе` → снял «Дюбель» на шаге 1 — удалилась «ЦСП».
|
||||
const dowel = makeSource('Дюбель-гвоздь 6×40', { quantity: 15275 });
|
||||
const csp = makeSource('Цементно-стружечная плита ЦСП толщиной 12 мм', { quantity: 6211, unit: 'Квадратный метр' });
|
||||
const tape = makeSource('Лента армирующая', { quantity: 15669, unit: 'Метр' });
|
||||
|
||||
it('ключ позиции не зависит от порядка в ответе API', () => {
|
||||
const [dowelFirst, cspFirst] = buildPositionKeys(REQUEST_ID, [dowel, csp, tape]);
|
||||
const reordered = buildPositionKeys(REQUEST_ID, [tape, csp, dowel]);
|
||||
|
||||
expect(reordered[2]).toBe(dowelFirst);
|
||||
expect(reordered[1]).toBe(cspFirst);
|
||||
});
|
||||
|
||||
it('у разных позиций — разные ключи', () => {
|
||||
const keys = buildPositionKeys(REQUEST_ID, [dowel, csp, tape]);
|
||||
|
||||
expect(new Set(keys).size).toBe(3);
|
||||
});
|
||||
|
||||
it('ключ начинается с id потребности — по нему считаем выбранные в потребности', () => {
|
||||
const [key] = buildPositionKeys(REQUEST_ID, [dowel]);
|
||||
|
||||
expect(key.startsWith(`${REQUEST_ID}__`)).toBe(true);
|
||||
});
|
||||
|
||||
it('не совпадает со старым ключом `GUID__номер` — старые записи ни с чем не сопоставятся', () => {
|
||||
const [key] = buildPositionKeys(REQUEST_ID, [dowel]);
|
||||
|
||||
const suffix = key.slice(`${REQUEST_ID}__`.length);
|
||||
expect(suffix).toMatch(/^h-[0-9a-f]{8}$/);
|
||||
});
|
||||
|
||||
it('лишние пробелы и регистр не меняют ключ (в API «Саморез … 3.5х25 » с пробелом на конце)', () => {
|
||||
const [plain] = buildPositionKeys(REQUEST_ID, [makeSource('Саморез для гипсокартона, 3.5х25')]);
|
||||
const [spaced] = buildPositionKeys(REQUEST_ID, [makeSource(' Саморез для гипсокартона, 3.5х25 ')]);
|
||||
|
||||
expect(spaced).toBe(plain);
|
||||
});
|
||||
|
||||
it('другое количество — другая позиция', () => {
|
||||
const [a, b] = buildPositionKeys(REQUEST_ID, [dowel, { ...dowel, quantity: 100 }]);
|
||||
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('одинаковые строки — номер совпадения, ключи различаются', () => {
|
||||
const keys = buildPositionKeys(REQUEST_ID, [dowel, csp, dowel]);
|
||||
|
||||
expect(keys[2]).toBe(`${keys[0]}-2`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// Поля позиции потребности из API, по которым строим её ключ. null из API приводим к '' заранее.
|
||||
export interface PositionKeySource {
|
||||
materialName: string;
|
||||
r66Description: string;
|
||||
agskCode: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
smrType: string;
|
||||
}
|
||||
|
||||
// Метка в ключе: отличает его от старого `GUID__номер в ответе` (так id строился до 2026-10-22),
|
||||
// чтобы такие записи гарантированно ни с чем не сопоставлялись.
|
||||
const KEY_MARK = 'h-';
|
||||
|
||||
function normalize(value: string): string {
|
||||
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
// FNV-1a, 32 бита → 8 hex-символов. Криптостойкость не нужна — только короткий стабильный отпечаток.
|
||||
function hash32(text: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function contentFingerprint(item: PositionKeySource): string {
|
||||
return hash32([
|
||||
normalize(item.materialName),
|
||||
normalize(item.r66Description),
|
||||
normalize(item.agskCode),
|
||||
normalize(item.unit),
|
||||
String(item.quantity),
|
||||
normalize(item.smrType),
|
||||
].join('|'));
|
||||
}
|
||||
|
||||
// Ключ позиции потребности: `${GUID потребности}__h-${отпечаток содержимого}`.
|
||||
// API отдаёт позиции без id и каждый раз в разном порядке (lineNumber — номер после сортировки по
|
||||
// наименованию, сдвигается при добавлении позиции), поэтому номер в ответе как id не годится:
|
||||
// снимали на шаге 1 одну позицию — удалялась другая. Ключ из содержимого от порядка не зависит.
|
||||
// Одинаковые строки получают номер совпадения (`-2`, `-3`): они неразличимы, путать их не страшно.
|
||||
// Если позицию в потребности отредактируют, ключ изменится и она просто перестанет быть отмеченной.
|
||||
export function buildPositionKeys(requestId: string, items: PositionKeySource[]): string[] {
|
||||
const seen = new Map<string, number>();
|
||||
return items.map(item => {
|
||||
const base = `${requestId}__${KEY_MARK}${contentFingerprint(item)}`;
|
||||
const occurrence = (seen.get(base) ?? 0) + 1;
|
||||
seen.set(base, occurrence);
|
||||
return occurrence === 1 ? base : `${base}-${occurrence}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { reachableStep } from './reachableStep';
|
||||
|
||||
describe('reachableStep (до какого шага открыт степпер у СТ, загруженной из SP)', () => {
|
||||
// Черновик в новой вкладке: сессии нет → степпер открывал только шаг 1, хотя позиции и КП уже есть,
|
||||
// и до шага 3 приходилось идти через «Сохранить и продолжить» на каждом шаге.
|
||||
it('пустая СТ — только шаг 1', () => {
|
||||
expect(reachableStep({ hasPositions: false, hasKp: false, hasRoute: false })).toBe(1);
|
||||
});
|
||||
|
||||
it('позиции выбраны — открыт шаг 2', () => {
|
||||
expect(reachableStep({ hasPositions: true, hasKp: false, hasRoute: false })).toBe(2);
|
||||
});
|
||||
|
||||
it('есть КП — открыт шаг 3', () => {
|
||||
expect(reachableStep({ hasPositions: true, hasKp: true, hasRoute: false })).toBe(3);
|
||||
});
|
||||
|
||||
it('есть маршрут согласования — открыт шаг 4', () => {
|
||||
expect(reachableStep({ hasPositions: true, hasKp: true, hasRoute: true })).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { FormStep } from '../types/domain';
|
||||
|
||||
// Что уже есть в СТ, загруженной из SP.
|
||||
export interface CtProgress {
|
||||
hasPositions: boolean;
|
||||
hasKp: boolean;
|
||||
hasRoute: boolean;
|
||||
}
|
||||
|
||||
// До какого шага открыт степпер по данным самой СТ, а не по сессии вкладки. Без этого черновик,
|
||||
// открытый в новой вкладке (из реестра), давал кликнуть только шаг 1, хотя позиции и КП уже есть.
|
||||
export function reachableStep(progress: CtProgress): FormStep {
|
||||
if (progress.hasRoute) return 4;
|
||||
if (progress.hasKp) return 3;
|
||||
if (progress.hasPositions) return 2;
|
||||
return 1;
|
||||
}
|
||||
@@ -1,6 +1,52 @@
|
||||
import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds } from './supplierColumns';
|
||||
import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds, findWinnerColumnId, withoutSessionExtras } from './supplierColumns';
|
||||
import { Supplier } from '../types/domain';
|
||||
|
||||
describe('withoutSessionExtras (поставщик Шага 3 после «Сохранить черновик»)', () => {
|
||||
// СТ 375: единственный поставщик «fghfg» добавлен на Шаге 3, сохранён черновиком. При открытии из
|
||||
// реестра колонка собиралась из SP («285_322», без цен) и ещё раз приходила из сессии (с ценами).
|
||||
const fromSp: Supplier[] = [
|
||||
{ id: '285_322', spId: 285, kpFileSpId: 322, name: 'fghfg', hasOffer: true },
|
||||
{ id: '282_320', spId: 282, kpFileSpId: 320, name: 'Каспий Дорс', hasOffer: true },
|
||||
];
|
||||
|
||||
it('колонку поставщика Шага 3 из SP не добавляет — она уже есть в сессии', () => {
|
||||
const result = withoutSessionExtras(fromSp, [{ spId: 285, offerId: 322 }]);
|
||||
|
||||
expect(result.map(c => c.id)).toEqual(['282_320']);
|
||||
});
|
||||
|
||||
it('другой КП того же поставщика — отдельная колонка, остаётся', () => {
|
||||
const result = withoutSessionExtras(fromSp, [{ spId: 285, offerId: 999 }]);
|
||||
|
||||
expect(result.map(c => c.id)).toEqual(['285_322', '282_320']);
|
||||
});
|
||||
|
||||
it('поставщик Шага 3 ещё не сохранён в SP — ничего не убирает', () => {
|
||||
expect(withoutSessionExtras(fromSp, [{}])).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findWinnerColumnId (победитель из шапки СТ при открытии без сессии)', () => {
|
||||
const columns: Supplier[] = [
|
||||
{ id: '281_318', spId: 281, kpFileSpId: 318, name: 'SteelGate', hasOffer: true, exclusion: { reason: 'Дорого' } },
|
||||
{ id: '281_330', spId: 281, kpFileSpId: 330, name: 'SteelGate', hasOffer: true },
|
||||
{ id: '282_320', spId: 282, kpFileSpId: 320, name: 'Каспий Дорс', hasOffer: true },
|
||||
];
|
||||
|
||||
it('колонка победителя по SP Id поставщика', () => {
|
||||
expect(findWinnerColumnId(columns, 282)).toBe('282_320');
|
||||
});
|
||||
|
||||
it('у поставщика с несколькими КП — первая неисключённая колонка', () => {
|
||||
expect(findWinnerColumnId(columns, 281)).toBe('281_330');
|
||||
});
|
||||
|
||||
it('победителя нет или его колонки нет — undefined', () => {
|
||||
expect(findWinnerColumnId(columns, undefined)).toBeUndefined();
|
||||
expect(findWinnerColumnId(columns, 999)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSupplierColumnId', () => {
|
||||
it('колонка = поставщик + файл КП', () => {
|
||||
expect(buildSupplierColumnId(263, 277)).toBe('263_277');
|
||||
@@ -47,6 +93,19 @@ describe('buildColumnsFromSpOffers (открытие по ссылке / без
|
||||
});
|
||||
});
|
||||
|
||||
describe('подпись колонки с файлом-заглушкой', () => {
|
||||
// СТ 375: в шапке колонки показывалось имя заглушки «fghfg_1793100061349.txt».
|
||||
it('вместо имени заглушки — «Добавлен вручную»', () => {
|
||||
const columns: Supplier[] = [
|
||||
{ id: '285_322', spId: 285, kpFileSpId: 322, name: 'fghfg', hasOffer: true },
|
||||
{ id: '285_330', spId: 285, kpFileSpId: 330, name: 'fghfg', hasOffer: true },
|
||||
];
|
||||
const files = [{ spId: 322, name: 'fghfg_1793100061349.txt' }, { spId: 330, name: '375_KP_fghfg.pdf' }];
|
||||
|
||||
expect(buildColumnLabels(columns, files)).toEqual({ '285_322': 'Добавлен вручную', '285_330': '375_KP_fghfg.pdf' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('подписи колонок поставщика с несколькими КП', () => {
|
||||
const ARLAN = 'ТОО «Арлан Офис Снаб»';
|
||||
const columns: Supplier[] = [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Supplier } from '../types/domain';
|
||||
import { PLACEHOLDER_COLUMN_LABEL, isPlaceholderKpFile } from './placeholderKpFile';
|
||||
|
||||
// Колонка таблицы сравнения = поставщик + файл КП: у одного поставщика может быть несколько
|
||||
// предложений (напр. в тенге и в долларах). Разделитель «_», а не «::» — «::» уже служит
|
||||
@@ -44,6 +45,29 @@ export function buildColumnsFromSpOffers(records: SpOfferColumnSource[]): Suppli
|
||||
return Array.from(columnsById.values());
|
||||
}
|
||||
|
||||
// Поставщик, добавленный на Шаге 3, в сессии: SP Id поставщика и его файла-заглушки КП.
|
||||
export interface SessionExtraColumn {
|
||||
spId?: number;
|
||||
offerId?: number;
|
||||
}
|
||||
|
||||
// Колонки из SP без тех, что в сессии уже есть поставщиком Шага 3. «Сохранить черновик» оставляет
|
||||
// такого поставщика в списке Шага 3 (см. buildSuppliersPatch), а в SP он уже лежит колонкой
|
||||
// «поставщик_заглушка». Без этого при открытии СТ, где других поставщиков нет, колонка задваивалась:
|
||||
// одна из SP без цен, вторая из сессии с ценами.
|
||||
export function withoutSessionExtras(spColumns: Supplier[], extras: SessionExtraColumn[]): Supplier[] {
|
||||
return spColumns.filter(column => !extras.some(extra =>
|
||||
extra.spId !== undefined && extra.spId === column.spId && extra.offerId === column.kpFileSpId,
|
||||
));
|
||||
}
|
||||
|
||||
// Колонка победителя по winSupplier из шапки СТ (СТ открыта без сессии). В SP хранится поставщик,
|
||||
// а не колонка: у поставщика с несколькими КП берём первую неисключённую колонку.
|
||||
export function findWinnerColumnId(columns: Supplier[], winSupplierSpId: number | undefined): string | undefined {
|
||||
if (winSupplierSpId === undefined) return undefined;
|
||||
return columns.find(c => c.spId === winSupplierSpId && c.hasOffer !== false && !c.exclusion)?.id;
|
||||
}
|
||||
|
||||
// Колонки той же компании (тот же SP Id поставщика), включая саму колонку. Нужно для данных, которые
|
||||
// принадлежат компании, а не КП: проверка Adata, БИН.
|
||||
export function findSiblingColumnIds(columns: Supplier[], columnId: string): string[] {
|
||||
@@ -68,6 +92,11 @@ export function buildColumnLabels(
|
||||
if (group.length < 2) return;
|
||||
group.forEach((c, index) => {
|
||||
const fileName = uploadedKpFiles.find(f => f.spId === c.kpFileSpId)?.name;
|
||||
// Имя файла-заглушки (поставщик добавлен вручную) людям ничего не говорит.
|
||||
if (fileName !== undefined && isPlaceholderKpFile(fileName)) {
|
||||
labels[c.id] = PLACEHOLDER_COLUMN_LABEL;
|
||||
return;
|
||||
}
|
||||
labels[c.id] = fileName ?? `КП ${index + 1}`;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,8 +30,18 @@ import {
|
||||
keepLatestOfferRecords,
|
||||
parseOfferItemIndex,
|
||||
} from "../../../shared/offers/offerRecords";
|
||||
import {
|
||||
EXCLUDED_OFFER_SELECT_FIELDS,
|
||||
IExcludedOfferItem,
|
||||
IExcludedOfferRecord,
|
||||
fromExcludedOfferItem,
|
||||
toExcludedOfferBody,
|
||||
} from "../../../shared/offers/excludedOffers";
|
||||
import { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria";
|
||||
import { pickRowToNotify } from "../components/utils/approvalAccess";
|
||||
import { buildPositionKeys } from "../components/utils/positionKey";
|
||||
import { mapFlowResult } from "../components/utils/flowResult";
|
||||
import { aiCommentToText } from "../../../shared/aiComment/aiCommentText";
|
||||
import {
|
||||
NoOfferSupplierEntry,
|
||||
NoOfferSupplierRecord,
|
||||
@@ -62,6 +72,9 @@ const LISTS = {
|
||||
SUPPLIERS: "Поставщики",
|
||||
NO_OFFERS: "Не получены КП",
|
||||
OFFERS: "Оценка поставщиков",
|
||||
// Поставщики, исключённые из сравнения на Шаге 3 (docs/adr/0001) — отдельно от OFFERS,
|
||||
// чтобы отчёт СТ, INCORE и потоки их не видели.
|
||||
EXCLUDED_OFFERS: "Исключённые предложения",
|
||||
CURRENCY_RATES: "Курс Валют",
|
||||
LIBRARY_KP: "Коммерческие предложения",
|
||||
CRITERIA: "Критерии",
|
||||
@@ -166,9 +179,12 @@ export class CtService {
|
||||
return tokenProvider.getToken(AAD_RESOURCE);
|
||||
}
|
||||
|
||||
async fetchRequests(days: number): Promise<Request[]> {
|
||||
// requestNumber — поиск на сервере: по части номера, в пределах периода days. Без него —
|
||||
// все потребности периода.
|
||||
async fetchRequests(days: number, requestNumber?: string): Promise<Request[]> {
|
||||
const token = await this._getApiToken();
|
||||
const res = await fetch(`${EXTERNAL_API}/Requests/${days}`, {
|
||||
const query = requestNumber ? `?requestNumber=${encodeURIComponent(requestNumber)}` : "";
|
||||
const res = await fetch(`${EXTERNAL_API}/Requests/${days}${query}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
@@ -209,8 +225,17 @@ export class CtService {
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data: ApiPosition[] = await res.json();
|
||||
// id позиции — из её содержимого, не из места в ответе: API отдаёт позиции в разном порядке.
|
||||
const keys = buildPositionKeys(oid, data.map((item) => ({
|
||||
materialName: String(item.materialName ?? ""),
|
||||
r66Description: String(item.r66Description ?? ""),
|
||||
agskCode: String(item.agskCode ?? ""),
|
||||
unit: String(item.unit ?? ""),
|
||||
quantity: Number(item.quantity ?? 0),
|
||||
smrType: String(item.smrType ?? ""),
|
||||
})));
|
||||
return data.map((item, idx) =>
|
||||
this._mapPosition(item, oid, requestNumber, idx),
|
||||
this._mapPosition(item, keys[idx], oid, requestNumber),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -271,25 +296,28 @@ export class CtService {
|
||||
if (!res.ok) throw new Error(`Ошибка обновления проекта СТ: ${res.status}`);
|
||||
}
|
||||
|
||||
// Позиции, снятые при повторном сохранении шага 1: их предложения и сами позиции — в корзину.
|
||||
// Сначала предложения: без позиции они бы остались висеть в «Оценка поставщиков».
|
||||
// Позиции, снятые при повторном сохранении шага 1: их предложения (в т.ч. исключённых
|
||||
// поставщиков) и сами позиции — в корзину. Сначала предложения: без позиции они бы остались висеть.
|
||||
async removeCTPositions(positionSpIds: number[]): Promise<void> {
|
||||
if (positionSpIds.length === 0) return;
|
||||
const filter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or ");
|
||||
const loadIds = async (listName: string): Promise<number[]> => {
|
||||
const res = await this.http.get(
|
||||
`${this._li(LISTS.OFFERS)}?$select=Id&$filter=${filter}&$top=5000`,
|
||||
`${this._li(listName)}?$select=Id&$filter=${filter}&$top=5000`,
|
||||
SPHttpClient.configurations.v1,
|
||||
);
|
||||
if (!res.ok) throw new Error(`Ошибка чтения предложений позиций: ${res.status}`);
|
||||
const data = await res.json();
|
||||
const offerIds = ((data.value ?? []) as Array<{ Id: number }>).map((item) => item.Id);
|
||||
return ((data.value ?? []) as Array<{ Id: number }>).map((item) => item.Id);
|
||||
};
|
||||
// MERGE/DELETE на списке предложений блокирует ресивер — убираем через recycle(), как в saveOffers.
|
||||
const recycle = (listName: string) => async (id: number): Promise<void> => {
|
||||
await this.http
|
||||
.post(`${this._liById(listName, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
||||
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
||||
};
|
||||
await this._runChunked(offerIds, 20, recycle(LISTS.OFFERS));
|
||||
await this._runChunked(await loadIds(LISTS.OFFERS), 20, recycle(LISTS.OFFERS));
|
||||
await this._runChunked(await loadIds(LISTS.EXCLUDED_OFFERS), 20, recycle(LISTS.EXCLUDED_OFFERS));
|
||||
await this._runChunked(positionSpIds, 20, recycle(LISTS.CT_POSITIONS));
|
||||
}
|
||||
|
||||
@@ -342,9 +370,14 @@ export class CtService {
|
||||
if (positions.length === 0) return [];
|
||||
// Раньше — по одному POST на позицию в цикле await (последовательно): 100 позиций =
|
||||
// 100 запросов и ~30-60 сек + троттлинг 429. Теперь одним $batch (до 100 операций/запрос).
|
||||
// Связь с позицией потребности нужна шагу 1, когда СТ открыта из реестра (см. Position.requestPositionId).
|
||||
// У доп. позиций шага 3 потребности нет — requestId пустой, связь не пишем.
|
||||
const requestPositionIdOf = (pos: Position): string =>
|
||||
pos.requestPositionId || (pos.requestId ? pos.id : "");
|
||||
const bodies = positions.map((pos) => ({
|
||||
Title: pos.materialName,
|
||||
CTId: parseInt(ctId, 10),
|
||||
requestPositionId: requestPositionIdOf(pos),
|
||||
demandNumber: pos.requestNumber,
|
||||
position: pos.materialName,
|
||||
disciplineCode: pos.disciplineCode ?? "",
|
||||
@@ -357,7 +390,26 @@ export class CtService {
|
||||
deadline: pos.deadline ? pos.deadline.replace(/Z?$/, "Z") : undefined,
|
||||
}));
|
||||
const ids = await this.batchCreateItems(LISTS.CT_POSITIONS, bodies);
|
||||
return positions.map((pos, i) => ({ ...pos, spId: ids[i] }));
|
||||
return positions.map((pos, i) => ({
|
||||
...pos,
|
||||
spId: ids[i],
|
||||
requestPositionId: requestPositionIdOf(pos) || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// Единица доп. позиции Шага 3 (логистика, доп. расходы) — её можно поменять после создания.
|
||||
// MERGE на «Позиции СТ» ресивер не блокирует (блокирует только на «Оценка поставщиков»).
|
||||
async updateCTPositionUnit(positionSpId: number, unit: string): Promise<void> {
|
||||
const res = await this.http.fetch(this._liById(LISTS.CT_POSITIONS, positionSpId), SPHttpClient.configurations.v1, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json;odata=nometadata",
|
||||
"IF-MATCH": "*",
|
||||
"X-HTTP-Method": "MERGE",
|
||||
},
|
||||
body: JSON.stringify({ unit }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Ошибка обновления единицы позиции: ${res.status}`);
|
||||
}
|
||||
|
||||
// Пакетное создание элементов списка через SharePoint OData $batch: вместо N POST —
|
||||
@@ -782,7 +834,7 @@ export class CtService {
|
||||
}
|
||||
|
||||
async loadCTById(ctId: string): Promise<CT> {
|
||||
const url = `${this._liById(LISTS.CT, ctId)}?$select=Id,Title,number,project,status,Author/EMail&$expand=Author`;
|
||||
const url = `${this._liById(LISTS.CT, ctId)}?$select=Id,Title,number,project,status,winSupplierId,Author/EMail&$expand=Author`;
|
||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||
if (!res.ok) throw new Error(`Ошибка загрузки СТ: ${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -792,23 +844,28 @@ export class CtService {
|
||||
projectName: String(data.project ?? ""),
|
||||
status: CHOICE_TO_CT_STATUS[String(data.status ?? "")] ?? "formed",
|
||||
authorEmail: String(data.Author?.EMail ?? "") || undefined,
|
||||
winSupplierSpId: data.winSupplierId ? Number(data.winSupplierId) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async loadPositionsForCT(ctId: string): Promise<Position[]> {
|
||||
const url =
|
||||
`${this._li(LISTS.CT_POSITIONS)}` +
|
||||
`?$select=Id,Title,unit,quantity,positionType,demandNumber,disciplineCode,SMR,agskCode,uniqueCode,deadline` +
|
||||
`?$select=Id,Title,unit,quantity,positionType,requestPositionId,demandNumber,disciplineCode,SMR,agskCode,uniqueCode,deadline` +
|
||||
`&$filter=CTId eq ${parseInt(ctId, 10)}` +
|
||||
`&$top=500`;
|
||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.value ?? []).map(
|
||||
(item: Record<string, unknown>): Position => ({
|
||||
return (data.value ?? []).map((item: Record<string, unknown>): Position => {
|
||||
// Ключ позиции потребности (см. utils/positionKey); у СТ, созданных до появления поля, пусто.
|
||||
const requestPositionId = String(item.requestPositionId ?? "");
|
||||
return {
|
||||
// id остаётся SP Id: по нему к позиции привязаны предложения из SP (см. utils/spOffers).
|
||||
id: String(item["Id"]),
|
||||
spId: Number(item["Id"]),
|
||||
requestId: "",
|
||||
requestPositionId: requestPositionId || undefined,
|
||||
requestId: requestPositionId.split("__")[0],
|
||||
requestNumber: String(item["demandNumber"] ?? ""),
|
||||
materialName: String(item["Title"] ?? ""),
|
||||
disciplineCode: String(item["disciplineCode"] ?? ""),
|
||||
@@ -819,8 +876,8 @@ export class CtService {
|
||||
quantity: Number(item["quantity"] ?? 0),
|
||||
positionType: String(item["positionType"] ?? "ТМЦ"),
|
||||
deadline: String(item["deadline"] ?? ""),
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async loadFullOffersForPositions(positionSpIds: number[]): Promise<
|
||||
@@ -1336,6 +1393,40 @@ export class CtService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Исключённые предложения ───────────────────────────────────────────────
|
||||
|
||||
async loadExcludedOffers(ctId: string): Promise<IExcludedOfferItem[]> {
|
||||
const url =
|
||||
`${this._li(LISTS.EXCLUDED_OFFERS)}?$select=${EXCLUDED_OFFER_SELECT_FIELDS.join(",")}` +
|
||||
`&$expand=supplier&$filter=CTId eq ${parseInt(ctId, 10)}&$top=5000`;
|
||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||
if (!res.ok) throw new Error(`Ошибка чтения исключённых поставщиков: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return ((data.value ?? []) as Array<Record<string, unknown>>).map(fromExcludedOfferItem);
|
||||
}
|
||||
|
||||
// Приводит исключённых этой СТ к текущему Шагу 3. Сначала новые строки, потом прежние в корзину:
|
||||
// упади запись посередине — прежние исключённые останутся, а дубли отсеет чтение
|
||||
// (groupExcludedColumns). Вернувшиеся в сравнение поставщики уходят вместе с прежними строками.
|
||||
async replaceExcludedOffers(ctId: string, records: IExcludedOfferRecord[]): Promise<void> {
|
||||
const ctSpId = parseInt(ctId, 10);
|
||||
const previousIds = (await this.loadExcludedOffers(ctId)).map((item) => item.id);
|
||||
if (records.length === 0 && previousIds.length === 0) return;
|
||||
|
||||
await this._runChunked(records, 15, async (record) => {
|
||||
const res = await this.http.post(this._li(LISTS.EXCLUDED_OFFERS), SPHttpClient.configurations.v1, {
|
||||
headers: { "Content-Type": "application/json;odata=nometadata" },
|
||||
body: JSON.stringify(toExcludedOfferBody(ctSpId, record)),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Ошибка сохранения исключённого поставщика: ${res.status}`);
|
||||
});
|
||||
await this._runChunked(previousIds, 20, async (id) => {
|
||||
await this.http
|
||||
.post(`${this._liById(LISTS.EXCLUDED_OFFERS, id)}/recycle()`, SPHttpClient.configurations.v1, {})
|
||||
.catch(() => undefined /* уже в корзине / не найден — игнорируем */);
|
||||
});
|
||||
}
|
||||
|
||||
async getCurrentUserProfile(): Promise<{
|
||||
department: string;
|
||||
company: string;
|
||||
@@ -1399,13 +1490,8 @@ export class CtService {
|
||||
: ((raw as Record<string, string>)?.comment ??
|
||||
(raw as Record<string, Record<string, string>>)?.body?.comment ??
|
||||
(typeof raw === "string" ? raw : undefined));
|
||||
if (!text) return JSON.stringify(raw);
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return (parsed as Record<string, string>)?.comment ?? text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
// Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText.
|
||||
return aiCommentToText(text ?? JSON.stringify(raw));
|
||||
}
|
||||
|
||||
async loadCtComment(ctId: string): Promise<string | undefined> {
|
||||
@@ -1413,7 +1499,8 @@ export class CtService {
|
||||
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||
if (!res.ok) return undefined;
|
||||
const data = await res.json();
|
||||
return (data.comment as string) || undefined;
|
||||
// В SP мог сохраниться сырой JSON потока (до разбора структуры) — показываем текстом.
|
||||
return data.comment ? aiCommentToText(String(data.comment)) : undefined;
|
||||
}
|
||||
|
||||
async updateCtComment(ctId: string, comment: string): Promise<void> {
|
||||
@@ -1589,35 +1676,7 @@ export class CtService {
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
position:
|
||||
parsed.position !== undefined ? String(parsed.position) : undefined,
|
||||
unit: parsed.unit !== undefined ? String(parsed.unit) : undefined,
|
||||
conditions:
|
||||
parsed.conditions !== undefined ? String(parsed.conditions) : undefined,
|
||||
deliveryType:
|
||||
parsed.deliveryType !== undefined
|
||||
? String(parsed.deliveryType)
|
||||
: undefined,
|
||||
deadline:
|
||||
parsed.deadline !== undefined ? String(parsed.deadline) : undefined,
|
||||
sertificates:
|
||||
parsed.sertificates !== undefined
|
||||
? String(parsed.sertificates)
|
||||
: undefined,
|
||||
cost: parsed.cost !== undefined ? Number(parsed.cost) || 0 : undefined,
|
||||
price: parsed.price !== undefined ? Number(parsed.price) || 0 : undefined,
|
||||
quantity:
|
||||
parsed.quantity !== undefined
|
||||
? Number(parsed.quantity) || 0
|
||||
: undefined,
|
||||
supplier:
|
||||
parsed.supplier !== undefined ? String(parsed.supplier) : undefined,
|
||||
supplierBIN:
|
||||
parsed.supplierBIN !== undefined
|
||||
? String(parsed.supplierBIN)
|
||||
: undefined,
|
||||
};
|
||||
return mapFlowResult(parsed);
|
||||
}
|
||||
|
||||
async loadOffersForPositions(positionSpIds: number[]): Promise<
|
||||
@@ -1782,11 +1841,11 @@ export class CtService {
|
||||
|
||||
private _mapPosition = (
|
||||
item: ApiPosition,
|
||||
requestOid = "",
|
||||
requestNumber = "",
|
||||
idx = 0,
|
||||
positionKey: string,
|
||||
requestOid: string,
|
||||
requestNumber: string,
|
||||
): Position => ({
|
||||
id: `${requestOid}__${idx}`,
|
||||
id: positionKey,
|
||||
requestId: requestOid,
|
||||
requestNumber,
|
||||
materialName: String(item.materialName ?? ""),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IMatrixRow
|
||||
} from '../../../shared/approval';
|
||||
import { keepLatestOfferRecords, parseOfferItemIndex } from '../../../shared/offers/offerRecords';
|
||||
import { aiCommentToText } from '../../../shared/aiComment/aiCommentText';
|
||||
import type {
|
||||
CtFormData,
|
||||
CtStatus,
|
||||
@@ -1203,14 +1204,8 @@ export class KpsCtFormService {
|
||||
: ((asRec?.comment as string | undefined) ??
|
||||
(raw as { body?: { comment?: string } })?.body?.comment ??
|
||||
(typeof raw === 'string' ? raw : undefined));
|
||||
if (!text) {
|
||||
return JSON.stringify(raw);
|
||||
}
|
||||
try {
|
||||
return ((JSON.parse(text) as Record<string, string>)?.comment ?? text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
// Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText.
|
||||
return aiCommentToText(text ?? JSON.stringify(raw));
|
||||
}
|
||||
|
||||
// Шапка СТ для комментария ИИ: номер/проект (в payload) + сохранённый ранее комментарий (показать/поллинг).
|
||||
@@ -1219,7 +1214,9 @@ export class KpsCtFormService {
|
||||
LISTS.ct,
|
||||
`$select=${CT_FIELDS.number},${CT_FIELDS.project},${CT_FIELDS.comment}&$filter=Id eq ${ctId}&$top=1`
|
||||
);
|
||||
return rows[0] ?? {};
|
||||
const header = rows[0] ?? {};
|
||||
// В SP мог сохраниться сырой JSON потока (до разбора структуры) — показываем текстом.
|
||||
return header.comment ? { ...header, comment: aiCommentToText(header.comment) } : header;
|
||||
}
|
||||
|
||||
// Сохранение комментария ИИ в колонку `comment` шапки (MERGE — как setWinSupplier). Пустая строка = очистка.
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
writeRoute,
|
||||
} from "../../../shared/approval";
|
||||
import { findSupplierOfferIds } from "../../../shared/offers/offerRecords";
|
||||
import { aiCommentToText } from "../../../shared/aiComment/aiCommentText";
|
||||
|
||||
const SITE_URL = "https://tnsukz.sharepoint.com/sites/sed";
|
||||
|
||||
@@ -1321,13 +1322,8 @@ export class OidService {
|
||||
: ((raw as Record<string, string>)?.comment ??
|
||||
(raw as Record<string, Record<string, string>>)?.body?.comment ??
|
||||
(typeof raw === "string" ? raw : undefined));
|
||||
if (!text) return JSON.stringify(raw);
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, string>;
|
||||
return parsed?.comment ?? text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
// Текст, JSON {comment} или структура {summary, suppliers_evaluation, conclusion} — см. aiCommentToText.
|
||||
return aiCommentToText(text ?? JSON.stringify(raw));
|
||||
}
|
||||
|
||||
async updateOidComment(oidId: number, comment: string): Promise<void> {
|
||||
@@ -1343,7 +1339,8 @@ export class OidService {
|
||||
const res = await this.sp.get(url, SPHttpClient.configurations.v1);
|
||||
if (!res.ok) return undefined;
|
||||
const data = await res.json() as Record<string, unknown>;
|
||||
return (data.comment as string) || undefined;
|
||||
// В SP мог сохраниться сырой JSON потока (до разбора структуры) — показываем текстом.
|
||||
return data.comment ? aiCommentToText(String(data.comment)) : undefined;
|
||||
}
|
||||
|
||||
async generateOidReport(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { aiCommentToText } from './aiCommentText';
|
||||
|
||||
describe('aiCommentToText (ответ потока «Комментарий ИИ» → текст)', () => {
|
||||
// СТ 367: поток стал отвечать структурой {summary, suppliers_evaluation, conclusion} вместо текста —
|
||||
// в шаге 3 и в SP (а значит и в PDF) лежал сырой JSON.
|
||||
const structured = JSON.stringify({
|
||||
summary: 'Анализ поставщиков по заявке 081/26-10/0028.',
|
||||
suppliers_evaluation: [
|
||||
{
|
||||
supplier: 'BuildCore Supply Ltd.',
|
||||
strengths: ['Самые низкие цены.', 'Широкий ассортимент.'],
|
||||
weaknesses: ['Самый длительный срок поставки.'],
|
||||
},
|
||||
{
|
||||
supplier: 'ТОО «СтройЛайн KZ»',
|
||||
strengths: ['Доставка включена в стоимость.'],
|
||||
weaknesses: [],
|
||||
},
|
||||
],
|
||||
conclusion: 'СтройЛайн — оптимальный выбор.',
|
||||
});
|
||||
|
||||
it('структурированный ответ — читаемый текст: вывод, плюсы/минусы по поставщикам, итог', () => {
|
||||
expect(aiCommentToText(structured)).toBe([
|
||||
'Анализ поставщиков по заявке 081/26-10/0028.',
|
||||
'',
|
||||
'BuildCore Supply Ltd.',
|
||||
'Плюсы:',
|
||||
'- Самые низкие цены.',
|
||||
'- Широкий ассортимент.',
|
||||
'Минусы:',
|
||||
'- Самый длительный срок поставки.',
|
||||
'',
|
||||
'ТОО «СтройЛайн KZ»',
|
||||
'Плюсы:',
|
||||
'- Доставка включена в стоимость.',
|
||||
'',
|
||||
'Вывод: СтройЛайн — оптимальный выбор.',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('обычный текст (прежний формат потока) — без изменений', () => {
|
||||
const text = 'Рекомендуем ТОО «СтройЛайн KZ»:\n- доставка включена';
|
||||
expect(aiCommentToText(text)).toBe(text);
|
||||
});
|
||||
|
||||
it('JSON с полем comment — берём comment', () => {
|
||||
expect(aiCommentToText('{"comment":"Готовый текст"}')).toBe('Готовый текст');
|
||||
});
|
||||
|
||||
it('JSON другой формы — без изменений, ничего не теряем', () => {
|
||||
const text = '{"foo":"bar"}';
|
||||
expect(aiCommentToText(text)).toBe(text);
|
||||
});
|
||||
|
||||
// Тот же промпт в другой раз вернул массив, другое имя списка и строки вместо массивов.
|
||||
it('вариант ответа: массив, suppliers_analysis, плюсы/минусы строкой', () => {
|
||||
const variant = JSON.stringify([{
|
||||
summary: 'Анализ поставщиков по критериям цены и условий поставки',
|
||||
suppliers_analysis: [
|
||||
{ supplier: 'BuildCore Supply Ltd.', strengths: 'Самые низкие цены.', weaknesses: 'Самый длительный срок.' },
|
||||
],
|
||||
conclusion: 'ТОО «СтройЛайн KZ» — оптимальный выбор.',
|
||||
}]);
|
||||
|
||||
expect(aiCommentToText(variant)).toBe([
|
||||
'Анализ поставщиков по критериям цены и условий поставки',
|
||||
'',
|
||||
'BuildCore Supply Ltd.',
|
||||
'Плюсы:',
|
||||
'- Самые низкие цены.',
|
||||
'Минусы:',
|
||||
'- Самый длительный срок.',
|
||||
'',
|
||||
'Вывод: ТОО «СтройЛайн KZ» — оптимальный выбор.',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('рекомендация — отдельной строкой', () => {
|
||||
expect(aiCommentToText('{"summary":"Итоги.","recommendation":"Берём СтройЛайн."}'))
|
||||
.toBe('Итоги.\n\nРекомендация: Берём СтройЛайн.');
|
||||
});
|
||||
|
||||
it('массив не из объектов — без изменений', () => {
|
||||
expect(aiCommentToText('["a","b"]')).toBe('["a","b"]');
|
||||
});
|
||||
|
||||
it('частично заполненная структура — только то, что есть', () => {
|
||||
expect(aiCommentToText('{"conclusion":"Берём BuildCore."}')).toBe('Вывод: Берём BuildCore.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
const LABEL_STRENGTHS = 'Плюсы:';
|
||||
const LABEL_WEAKNESSES = 'Минусы:';
|
||||
const LABEL_CONCLUSION = 'Вывод:';
|
||||
const LABEL_RECOMMENDATION = 'Рекомендация:';
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function tryParseJson(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return undefined;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function asText(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
// Плюсы/минусы модель отдаёт то массивом строк, то одной строкой.
|
||||
function asList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map(asText).filter((item): item is string => item !== undefined);
|
||||
const text = asText(value);
|
||||
return text ? [text] : [];
|
||||
}
|
||||
|
||||
// Список поставщиков модель называет по-разному (suppliers_evaluation, suppliers_analysis…) —
|
||||
// ищем первое поле-массив из объектов с полем supplier.
|
||||
function findSupplierList(obj: JsonObject): JsonObject[] {
|
||||
for (const value of Object.values(obj)) {
|
||||
if (Array.isArray(value) && value.length > 0 && value.every(item => isObject(item) && 'supplier' in item)) {
|
||||
return value as JsonObject[];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isStructured(obj: JsonObject): boolean {
|
||||
return 'summary' in obj || 'conclusion' in obj || 'recommendation' in obj || findSupplierList(obj).length > 0;
|
||||
}
|
||||
|
||||
function bulletList(label: string, items: string[]): string[] {
|
||||
return items.length > 0 ? [label, ...items.map(item => `- ${item}`)] : [];
|
||||
}
|
||||
|
||||
function supplierBlock(supplier: JsonObject): string[] {
|
||||
const name = asText(supplier.supplier);
|
||||
return [
|
||||
...(name ? [name] : []),
|
||||
...bulletList(LABEL_STRENGTHS, asList(supplier.strengths)),
|
||||
...bulletList(LABEL_WEAKNESSES, asList(supplier.weaknesses)),
|
||||
];
|
||||
}
|
||||
|
||||
function labeled(label: string, value: unknown): string[] {
|
||||
const text = asText(value);
|
||||
return text ? [`${label} ${text}`] : [];
|
||||
}
|
||||
|
||||
function structuredToText(obj: JsonObject): string {
|
||||
const summary = asText(obj.summary);
|
||||
const blocks: string[][] = [
|
||||
summary ? [summary] : [],
|
||||
...findSupplierList(obj).map(supplierBlock),
|
||||
labeled(LABEL_CONCLUSION, obj.conclusion),
|
||||
labeled(LABEL_RECOMMENDATION, obj.recommendation),
|
||||
];
|
||||
return blocks
|
||||
.filter(block => block.length > 0)
|
||||
.map(block => block.join('\n'))
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
// Ответ потока «Комментарий ИИ» → текст для показа и для SP (из SP его берёт и PDF-отчёт, поэтому
|
||||
// без Markdown-звёздочек: только строки и списки «- »). Поток отвечал по-разному: текстом, JSON
|
||||
// {comment}, а теперь структурой, форму которой модель каждый раз выбирает сама: объект или массив
|
||||
// из одного объекта, список поставщиков под разными именами, плюсы/минусы массивом или строкой.
|
||||
// Всё, что не распознали, возвращаем как есть: лучше показать сырой ответ, чем потерять его.
|
||||
export function aiCommentToText(raw: string): string {
|
||||
const parsed = tryParseJson(raw);
|
||||
const objects: unknown[] = Array.isArray(parsed) ? parsed : [parsed];
|
||||
if (objects.length === 0 || !objects.every(isObject)) return raw;
|
||||
|
||||
const texts = (objects as JsonObject[]).map(obj => {
|
||||
if (typeof obj.comment === 'string') return obj.comment;
|
||||
return isStructured(obj) ? structuredToText(obj) : undefined;
|
||||
});
|
||||
if (texts.some(text => text === undefined)) return raw;
|
||||
return texts.join('\n\n');
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
EXCLUDED_OFFER_SELECT_FIELDS_OID,
|
||||
IExcludedOfferItem,
|
||||
buildExcludedOfferTitle,
|
||||
fromExcludedOfferItem,
|
||||
groupExcludedColumns,
|
||||
toExcludedOfferBody,
|
||||
toExcludedRecords,
|
||||
} from './excludedOffers';
|
||||
import { parseOfferItemIndex } from './offerRecords';
|
||||
|
||||
const CT_SP_ID = 364;
|
||||
const REASON = 'Не прошёл проверку Adata';
|
||||
|
||||
function makeItem(overrides: Partial<IExcludedOfferItem>): IExcludedOfferItem {
|
||||
return {
|
||||
id: 1,
|
||||
supplierSpId: 259,
|
||||
supplierName: 'ООО Альфа',
|
||||
offerFileSpId: 278,
|
||||
reason: REASON,
|
||||
positionSpId: 1476,
|
||||
itemIndex: 0,
|
||||
supplierPosition: 'Кабель ВВГ 3х2,5',
|
||||
unit: 'м',
|
||||
quantity: 100,
|
||||
price: 450,
|
||||
summa: 45000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildExcludedOfferTitle', () => {
|
||||
it('в формате «Оценки поставщиков» — номер строки комплекта разбирается тем же парсером', () => {
|
||||
const title = buildExcludedOfferTitle(makeItem({ itemIndex: 2 }));
|
||||
|
||||
expect(title).toBe('259-1476-2');
|
||||
expect(parseOfferItemIndex(title)).toBe(2);
|
||||
});
|
||||
|
||||
it('первая строка комплекта — без номера', () => {
|
||||
expect(buildExcludedOfferTitle(makeItem({ itemIndex: 0 }))).toBe('259-1476');
|
||||
});
|
||||
|
||||
it('у маркера поставщика без цен позиция 0', () => {
|
||||
expect(buildExcludedOfferTitle(makeItem({ positionSpId: undefined }))).toBe('259-0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toExcludedOfferBody', () => {
|
||||
it('пишет СТ, поставщика, причину и цены, округляя суммы до копеек', () => {
|
||||
const body = toExcludedOfferBody(CT_SP_ID, makeItem({ price: 450.456, summa: 45045.6 }));
|
||||
|
||||
expect(body).toMatchObject({
|
||||
Title: '259-1476',
|
||||
CTId: CT_SP_ID,
|
||||
supplierId: 259,
|
||||
reason: REASON,
|
||||
positionId: 1476,
|
||||
offerFileId: 278,
|
||||
price: 450.46,
|
||||
summa: 45045.6,
|
||||
});
|
||||
});
|
||||
|
||||
it('не пишет поля, которых нет у формы (ОИД без валюты и доставки) — иначе SP отклонит запрос', () => {
|
||||
const body = toExcludedOfferBody(CT_SP_ID, makeItem({}));
|
||||
|
||||
expect(body).not.toHaveProperty('valutaId');
|
||||
expect(body).not.toHaveProperty('priceTenge');
|
||||
expect(body).not.toHaveProperty('summaTenge');
|
||||
expect(body).not.toHaveProperty('deliveryDistanceKm');
|
||||
});
|
||||
|
||||
it('у маркера нет позиции', () => {
|
||||
const body = toExcludedOfferBody(CT_SP_ID, makeItem({ positionSpId: undefined }));
|
||||
|
||||
expect(body).not.toHaveProperty('positionId');
|
||||
});
|
||||
|
||||
it('валюту, цены в тенге и километраж пишет, когда они есть', () => {
|
||||
const body = toExcludedOfferBody(CT_SP_ID, makeItem({
|
||||
valutaSpId: 3, priceTenge: 230000.004, summaTenge: 23000000, deliveryDistanceKm: 120,
|
||||
}));
|
||||
|
||||
expect(body).toMatchObject({ valutaId: 3, priceTenge: 230000, summaTenge: 23000000, deliveryDistanceKm: 120 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromExcludedOfferItem', () => {
|
||||
it('разбирает элемент SP REST, имя поставщика — из лукапа', () => {
|
||||
const record = fromExcludedOfferItem({
|
||||
Id: 10, Title: '259-1476-1', CTId: CT_SP_ID, supplierId: 259, supplier: { Title: 'ООО Альфа' },
|
||||
reason: REASON, positionId: 1476, supplierPosition: 'Кабель', unit: 'м', quantity: 100,
|
||||
price: 450, summa: 45000, valutaId: 3, priceTenge: 450, summaTenge: 45000, offerFileId: 278,
|
||||
deliveryDistanceKm: null,
|
||||
});
|
||||
|
||||
expect(record).toEqual({
|
||||
id: 10, supplierSpId: 259, supplierName: 'ООО Альфа', offerFileSpId: 278, reason: REASON,
|
||||
positionSpId: 1476, itemIndex: 1, supplierPosition: 'Кабель', unit: 'м', quantity: 100,
|
||||
price: 450, summa: 45000, valutaSpId: 3, priceTenge: 450, summaTenge: 45000,
|
||||
deliveryDistanceKm: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('пустые лукапы SP отдаёт как null — это «нет значения», а не 0', () => {
|
||||
const record = fromExcludedOfferItem({
|
||||
Title: '259-0', supplierId: 259, reason: REASON, positionId: null, valutaId: null, offerFileId: null,
|
||||
});
|
||||
|
||||
expect(record.positionSpId).toBeUndefined();
|
||||
expect(record.valutaSpId).toBeUndefined();
|
||||
expect(record.offerFileSpId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupExcludedColumns', () => {
|
||||
it('одна колонка на поставщика и файл КП — два КП одного поставщика остаются разными колонками', () => {
|
||||
const columns = groupExcludedColumns([
|
||||
makeItem({ offerFileSpId: 278, positionSpId: 1476 }),
|
||||
makeItem({ offerFileSpId: 278, positionSpId: 1477 }),
|
||||
makeItem({ offerFileSpId: 285, positionSpId: 1476, reason: 'Дорого' }),
|
||||
]);
|
||||
|
||||
expect(columns).toHaveLength(2);
|
||||
expect(columns[0]).toMatchObject({ supplierSpId: 259, offerFileSpId: 278, reason: REASON });
|
||||
expect(columns[0].offers).toHaveLength(2);
|
||||
expect(columns[1]).toMatchObject({ offerFileSpId: 285, reason: 'Дорого' });
|
||||
});
|
||||
|
||||
it('маркер даёт колонку без цен', () => {
|
||||
const columns = groupExcludedColumns([makeItem({ supplierSpId: 300, positionSpId: undefined })]);
|
||||
|
||||
expect(columns).toHaveLength(1);
|
||||
expect(columns[0].supplierSpId).toBe(300);
|
||||
expect(columns[0].offers).toEqual([]);
|
||||
});
|
||||
|
||||
// Сохранение упало между созданием новых строк и уборкой прежних — в SP обе версии.
|
||||
it('из дублей оставляет последнюю строку, причина — из последнего сохранения', () => {
|
||||
const columns = groupExcludedColumns([
|
||||
makeItem({ id: 10, positionSpId: 1476, price: 400, reason: 'Дорого' }),
|
||||
makeItem({ id: 11, positionSpId: 1477, price: 500, reason: 'Дорого' }),
|
||||
makeItem({ id: 20, positionSpId: 1476, price: 450, reason: REASON }),
|
||||
]);
|
||||
|
||||
expect(columns).toHaveLength(1);
|
||||
expect(columns[0].reason).toBe(REASON);
|
||||
expect(columns[0].offers.map(o => `${o.positionSpId}:${o.price}`)).toEqual(['1476:450', '1477:500']);
|
||||
});
|
||||
|
||||
it('строки упорядочены по позиции и номеру строки комплекта', () => {
|
||||
const columns = groupExcludedColumns([
|
||||
makeItem({ positionSpId: 1477, itemIndex: 0 }),
|
||||
makeItem({ positionSpId: 1476, itemIndex: 1 }),
|
||||
makeItem({ positionSpId: 1476, itemIndex: 0 }),
|
||||
]);
|
||||
|
||||
expect(columns[0].offers.map(o => `${o.positionSpId}/${o.itemIndex}`)).toEqual(['1476/0', '1476/1', '1477/0']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toExcludedRecords', () => {
|
||||
const column = { supplierSpId: 259, offerFileSpId: 278, reason: REASON };
|
||||
|
||||
it('проставляет каждой строке поставщика, файл КП и причину колонки', () => {
|
||||
const records = toExcludedRecords(column, [
|
||||
{ positionSpId: 1476, itemIndex: 0, supplierPosition: 'Кабель', unit: 'м', quantity: 1, price: 2, summa: 2 },
|
||||
]);
|
||||
|
||||
expect(records).toEqual([{
|
||||
supplierSpId: 259, offerFileSpId: 278, reason: REASON,
|
||||
positionSpId: 1476, itemIndex: 0, supplierPosition: 'Кабель', unit: 'м', quantity: 1, price: 2, summa: 2,
|
||||
}]);
|
||||
});
|
||||
|
||||
it('поставщик без цен — одна строка-маркер, чтобы его можно было вернуть после перезагрузки', () => {
|
||||
const records = toExcludedRecords(column, []);
|
||||
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0].positionSpId).toBeUndefined();
|
||||
expect(records[0].reason).toBe(REASON);
|
||||
});
|
||||
|
||||
it('запись → чтение → группировка возвращает ту же колонку', () => {
|
||||
const records = toExcludedRecords(column, [
|
||||
{ positionSpId: 1476, itemIndex: 0, supplierPosition: 'Кабель', unit: 'м', quantity: 100, price: 450, summa: 45000 },
|
||||
]);
|
||||
// Имитируем SP: что записали телом, то и прочитали (+ имя из лукапа).
|
||||
const readBack = records.map(r => fromExcludedOfferItem({
|
||||
...toExcludedOfferBody(CT_SP_ID, r), supplier: { Title: 'ООО Альфа' },
|
||||
}));
|
||||
|
||||
const [restored] = groupExcludedColumns(readBack);
|
||||
|
||||
expect(restored).toMatchObject({ supplierSpId: 259, offerFileSpId: 278, reason: REASON, supplierName: 'ООО Альфа' });
|
||||
expect(restored.offers[0]).toMatchObject({ positionSpId: 1476, price: 450, quantity: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('EXCLUDED_OFFER_SELECT_FIELDS_OID', () => {
|
||||
it('без колонок, которых нет в списке ОИД', () => {
|
||||
expect(EXCLUDED_OFFER_SELECT_FIELDS_OID).not.toContain('valutaId');
|
||||
expect(EXCLUDED_OFFER_SELECT_FIELDS_OID).not.toContain('deliveryDistanceKm');
|
||||
expect(EXCLUDED_OFFER_SELECT_FIELDS_OID).toContain('reason');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
// Записи списков «Исключённые предложения» (СТ / ОИД / КПС): строки КП поставщика, которого
|
||||
// исключили из сравнения на Шаге 3. Хранятся отдельно от «Оценки поставщиков», чтобы отчёт СТ,
|
||||
// INCORE и потоки их не видели (docs/adr/0001). Колонки — как в «Оценке поставщиков» своей формы
|
||||
// плюс СТ и причина. Чистые функции: запросы к SharePoint — в сервисе каждой формы.
|
||||
|
||||
import { parseOfferItemIndex } from './offerRecords';
|
||||
|
||||
// Одна строка КП исключённого поставщика.
|
||||
export interface IExcludedOfferRecord {
|
||||
supplierSpId: number;
|
||||
// SP Id файла КП: у поставщика может быть несколько КП — каждое отдельная колонка.
|
||||
offerFileSpId?: number;
|
||||
reason: string;
|
||||
// Пусто у строки-маркера: поставщик исключён без единой цены, но колонка должна вернуться.
|
||||
positionSpId?: number;
|
||||
itemIndex: number;
|
||||
supplierPosition: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
summa: number;
|
||||
// Есть не во всех формах: у ОИД нет валюты, цен в тенге и доставки.
|
||||
valutaSpId?: number;
|
||||
priceTenge?: number;
|
||||
summaTenge?: number;
|
||||
deliveryDistanceKm?: number;
|
||||
}
|
||||
|
||||
// Строка, прочитанная из SP: дополнительно SP Id и имя поставщика из лукапа.
|
||||
export interface IExcludedOfferItem extends IExcludedOfferRecord {
|
||||
// Чем больше, тем позже создана — по нему отсеиваются дубли (см. groupExcludedColumns).
|
||||
id: number;
|
||||
supplierName: string;
|
||||
}
|
||||
|
||||
// Колонка исключённого поставщика, собранная из его строк.
|
||||
export interface IExcludedSupplierColumn {
|
||||
supplierSpId: number;
|
||||
supplierName: string;
|
||||
offerFileSpId?: number;
|
||||
reason: string;
|
||||
// Только строки с позицией — маркер сюда не попадает.
|
||||
offers: IExcludedOfferItem[];
|
||||
}
|
||||
|
||||
// Поля, которые читаются из SP. supplier/Title — через $expand=supplier.
|
||||
export const EXCLUDED_OFFER_SELECT_FIELDS = [
|
||||
'Id', 'Title', 'CTId', 'supplierId', 'supplier/Title', 'reason', 'positionId',
|
||||
'supplierPosition', 'unit', 'quantity', 'price', 'summa',
|
||||
'valutaId', 'priceTenge', 'summaTenge', 'offerFileId', 'deliveryDistanceKm',
|
||||
];
|
||||
|
||||
// Набор полей ОИД: в его списке нет валюты, цен в тенге и доставки — SP вернул бы 400 на $select.
|
||||
export const EXCLUDED_OFFER_SELECT_FIELDS_OID = EXCLUDED_OFFER_SELECT_FIELDS.filter(
|
||||
field => !['valutaId', 'priceTenge', 'summaTenge', 'deliveryDistanceKm'].includes(field),
|
||||
);
|
||||
|
||||
const MARKER_POSITION_IN_TITLE = 0;
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function optionalNumber(value: unknown): number | undefined {
|
||||
if (value === null || value === undefined || value === '') return undefined;
|
||||
const parsed = Number(value);
|
||||
return isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
// Title — в формате «Оценки поставщиков» («поставщик-позиция[-N]»), чтобы номер строки комплекта
|
||||
// разбирался тем же parseOfferItemIndex. У маркера позиция 0.
|
||||
export function buildExcludedOfferTitle(record: IExcludedOfferRecord): string {
|
||||
const base = `${record.supplierSpId}-${record.positionSpId ?? MARKER_POSITION_IN_TITLE}`;
|
||||
return record.itemIndex > 0 ? `${base}-${record.itemIndex}` : base;
|
||||
}
|
||||
|
||||
// Тело POST для SP. Необязательные поля пишутся, только если заданы: у ОИД этих колонок нет,
|
||||
// и SP отклонил бы запрос с неизвестным полем.
|
||||
export function toExcludedOfferBody(ctSpId: number, record: IExcludedOfferRecord): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
Title: buildExcludedOfferTitle(record),
|
||||
CTId: ctSpId,
|
||||
supplierId: record.supplierSpId,
|
||||
reason: record.reason,
|
||||
supplierPosition: record.supplierPosition,
|
||||
unit: record.unit,
|
||||
quantity: Number(record.quantity) || 0,
|
||||
price: round2(Number(record.price) || 0),
|
||||
summa: round2(Number(record.summa) || 0),
|
||||
};
|
||||
if (record.positionSpId !== undefined) body.positionId = record.positionSpId;
|
||||
if (record.offerFileSpId !== undefined) body.offerFileId = record.offerFileSpId;
|
||||
if (record.valutaSpId !== undefined) body.valutaId = record.valutaSpId;
|
||||
if (record.priceTenge !== undefined) body.priceTenge = round2(record.priceTenge);
|
||||
if (record.summaTenge !== undefined) body.summaTenge = round2(record.summaTenge);
|
||||
if (record.deliveryDistanceKm !== undefined) body.deliveryDistanceKm = record.deliveryDistanceKm;
|
||||
return body;
|
||||
}
|
||||
|
||||
// Элемент списка из SP REST (odata=nometadata) → запись. Пустой лукап SP отдаёт как null.
|
||||
export function fromExcludedOfferItem(item: Record<string, unknown>): IExcludedOfferItem {
|
||||
const supplier = item.supplier as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: Number(item.Id ?? 0),
|
||||
supplierSpId: Number(item.supplierId),
|
||||
supplierName: String(supplier?.Title ?? ''),
|
||||
offerFileSpId: optionalNumber(item.offerFileId),
|
||||
reason: String(item.reason ?? ''),
|
||||
positionSpId: optionalNumber(item.positionId),
|
||||
itemIndex: parseOfferItemIndex(String(item.Title ?? '')),
|
||||
supplierPosition: String(item.supplierPosition ?? ''),
|
||||
unit: String(item.unit ?? ''),
|
||||
quantity: Number(item.quantity ?? 0),
|
||||
price: Number(item.price ?? 0),
|
||||
summa: Number(item.summa ?? 0),
|
||||
valutaSpId: optionalNumber(item.valutaId),
|
||||
priceTenge: optionalNumber(item.priceTenge),
|
||||
summaTenge: optionalNumber(item.summaTenge),
|
||||
deliveryDistanceKm: optionalNumber(item.deliveryDistanceKm),
|
||||
};
|
||||
}
|
||||
|
||||
function columnKey(supplierSpId: number, offerFileSpId?: number): string {
|
||||
return `${supplierSpId}::${offerFileSpId ?? ''}`;
|
||||
}
|
||||
|
||||
// Одна строка на (колонка, позиция, строка комплекта) — последняя по Id. Дубли остаются, если
|
||||
// сохранение упало между созданием новых строк и уборкой прежних (сервис пишет в этом порядке,
|
||||
// чтобы при сбое не потерять исключённых).
|
||||
function keepLatestExcluded(items: IExcludedOfferItem[]): IExcludedOfferItem[] {
|
||||
const latestByKey = new Map<string, IExcludedOfferItem>();
|
||||
for (const item of items) {
|
||||
const key = `${columnKey(item.supplierSpId, item.offerFileSpId)}::${item.positionSpId ?? ''}::${item.itemIndex}`;
|
||||
const previous = latestByKey.get(key);
|
||||
if (!previous || item.id > previous.id) latestByKey.set(key, item);
|
||||
}
|
||||
return Array.from(latestByKey.values());
|
||||
}
|
||||
|
||||
// Строки → колонки: одна колонка на (поставщик, файл КП), как в таблице сравнения.
|
||||
export function groupExcludedColumns(items: IExcludedOfferItem[]): IExcludedSupplierColumn[] {
|
||||
const columns = new Map<string, IExcludedSupplierColumn>();
|
||||
// Новые строки первыми: причина колонки берётся из последнего сохранения.
|
||||
const newestFirst = keepLatestExcluded(items).sort((a, b) => b.id - a.id);
|
||||
for (const item of newestFirst) {
|
||||
const key = columnKey(item.supplierSpId, item.offerFileSpId);
|
||||
let column = columns.get(key);
|
||||
if (!column) {
|
||||
column = {
|
||||
supplierSpId: item.supplierSpId,
|
||||
supplierName: item.supplierName,
|
||||
offerFileSpId: item.offerFileSpId,
|
||||
reason: item.reason,
|
||||
offers: [],
|
||||
};
|
||||
columns.set(key, column);
|
||||
}
|
||||
if (item.positionSpId !== undefined) column.offers.push(item);
|
||||
}
|
||||
columns.forEach(column => column.offers.sort((a, b) =>
|
||||
(a.positionSpId ?? 0) - (b.positionSpId ?? 0) || a.itemIndex - b.itemIndex,
|
||||
));
|
||||
return Array.from(columns.values());
|
||||
}
|
||||
|
||||
// Колонка → строки для записи. Без цен — одна строка-маркер, иначе при открытии СТ поставщик
|
||||
// пропал бы из списка исключённых и вернуть его было бы нельзя.
|
||||
export function toExcludedRecords(
|
||||
column: Pick<IExcludedSupplierColumn, 'supplierSpId' | 'offerFileSpId' | 'reason'>,
|
||||
offers: Array<Omit<IExcludedOfferRecord, 'supplierSpId' | 'offerFileSpId' | 'reason'>>,
|
||||
): IExcludedOfferRecord[] {
|
||||
const base = { supplierSpId: column.supplierSpId, offerFileSpId: column.offerFileSpId, reason: column.reason };
|
||||
if (offers.length === 0) {
|
||||
return [{ ...base, itemIndex: 0, supplierPosition: '', unit: '', quantity: 0, price: 0, summa: 0 }];
|
||||
}
|
||||
return offers.map(offer => ({ ...offer, ...base }));
|
||||
}
|
||||
Reference in New Issue
Block a user