Compare commits

..

11 Commits

Author SHA1 Message Date
Тимур Городецкий 0ff17d3293 feat(ct): исключение поставщика из сравнения вместо удаления (#1)
Шаг 3 СТ ОМТС: крестик исключает поставщика с обязательной причиной, цены
хранятся в «Исключённые предложения», «Вернуть» возвращает колонку целиком.
Решение и варианты — docs/adr/0001. Проверено в воркбенче на СТ 372;
открытие в другом браузере и PDF-отчёт — после выкатки.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 10:44:09 +05:00
27 changed files with 1498 additions and 85 deletions
+20
View File
@@ -0,0 +1,20 @@
---
name: Запрос
about: Пункт из чата, устная просьба или найденная проблема
---
## Источник
- Кто:
- Когда:
- Где: Teams / устно / сам нашёл
> Цитата как есть
## Суть
Что не так или что нужно, своими словами.
## Как проверить
Что сделать руками, чтобы убедиться, что готово.
+14
View File
@@ -0,0 +1,14 @@
# Журнал изменений
Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/). Версия = версия решения
в `config/package-solution.json`, на каждый деплой — тег `vX.Y.Z`.
Разделы: **Добавлено**, **Изменено**, **Исправлено**, **Удалено**.
## [Не выпущено]
### Изменено
- СТ ОМТС, Шаг 3: крестик у поставщика не удаляет его, а исключает из сравнения с обязательной
причиной. Цены сохраняются, под таблицей — список исключённых с кнопкой «Вернуть». Согласующим
список пока не показывается. (#1)
+37
View File
@@ -0,0 +1,37 @@
# Как ведётся работа над порталом
Проект ведёт один разработчик. Требования приходят из Teams-чата и устно, поэтому вся
история — что просили, что решили, что выехало — хранится здесь и в Gitea, а не в памяти.
## Где что лежит
| Что | Где | Отвечает на вопрос |
| --- | --- | --- |
| Задачи | Gitea Issues `ct-forms-portal` | Что надо сделать, кто и когда попросил |
| Решения | `docs/adr/` | Почему сделано именно так, какие были варианты |
| Изменения | `CHANGELOG.md` | Что и в какой версии выехало на прод |
| Код | коммиты с `(#номер)` | Каким кодом закрыта задача |
## Путь запроса
1. **Пришло сообщение** (чат, устно) — каждый пункт становится issue. В issue — цитата или
скрин как есть, дата, кто попросил.
2. **Разбор.** Метка по типу: `bug`, `ux`, `feature`, `perf`, `справочники`,
`вопрос-бизнесу`, `смена-модели`.
- Если пункт меняет устройство данных (`смена-модели`) — сначала ADR, потом код.
- Если нужен ответ бизнеса (`вопрос-бизнесу`) — вопрос в чат, issue ждёт ответа.
3. **Работа.** Баг — сначала падающий Jest-тест, потом фикс. Коммиты —
`тип: описание (#номер)`.
- Мелкий фикс (час-два, один-два коммита) — прямо в `main`.
- Ветка `feat/<номер>-кратко` или `fix/<номер>-кратко` — если задача на несколько дней,
зависит от внешнего (новые списки SP, потоки, бэкенд) или рискованная. Цель одна:
`main` в любой момент можно выкатить на прод.
4. **Деплой.** Запись в `CHANGELOG.md`, тег версии `vX.Y.Z` (= версия в
`config/package-solution.json`), issue закрывается, в чат — короткий список изменений.
## Вопрос, который задаём каждому новому пункту
> Это меняет то, как устроены данные, или только как они показываются?
Только показ или поведение — делаем. Меняет данные — сначала проходим весь путь:
где создаётся, кто видит, что уходит в отчёт, INCORE и согласование. Потом ADR.
@@ -0,0 +1,115 @@
# 0001. Исключение поставщика из сравнения вместо удаления
- Статус: Принято
- Дата: 2026-10-27
- Задача: #1 (СТ ОМТС); ОИД — #3, КПС — #4
- Источник: Асиля Сарниязова, Teams, 2026-10-23, п.4: «Я случайно удалила из таблицы
поставщика — и не смогла вернуть его назад, хоть и есть КП»
## Контекст
На Шаге 3 (таблица сравнения) крестик на колонке поставщика удаляет его безвозвратно во всех
трёх формах — СТ, ОИД, КПС:
- колонка убирается из состояния формы, введённые цены стираются;
- при сохранении все офферы по позициям СТ уходят в корзину и заново пишутся только
оставшиеся поставщики (обычная правка записи на списках офферов заблокирована ресивером);
- вернуть поставщика можно только повторной загрузкой КП на Шаге 2, цены, введённые руками,
теряются.
Офферы читает не только портал:
- СТ — бэкенд `api.tnsu.kz/Report/CT` (PDF/Excel-отчёт, уходит в пакет INCORE);
- ОИД — поток ИИ-комментария читает «Оценку поставщиков ОИД» сам;
- потоки уведомлений — внешние, читают ли они офферы, по коду не определить.
В закупочных системах (SAP Ariba, Coupa, Procore и др.) участника не удаляют, а отклоняют
с причиной, и это остаётся в истории.
## Варианты
**А. Флаг `isExcluded` на каждом оффере** в списках «Оценка поставщиков», «… ОИД», «… КПС».
- Плюс: одна колонка, исключённые лежат рядом с обычными.
- Минус: все сторонние читатели начнут видеть исключённых, пока каждый не добавит фильтр —
бэкенд отчёта СТ (иначе исключённый попадёт в PDF и INCORE), поток ИИ ОИД, возможно потоки
уведомлений. Выкатка строго по порядку: колонка → бэкенд и потоки → портал.
**Б. JSON-снимок в шапке СТ** — колонка `excludedSuppliers` (Note) в «Сравнительной таблице»
каждой формы.
- Плюс: «Оценка поставщиков» не меняется, сторонние читатели ничего не замечают.
- Минус: размер. Строка оффера — 200–300 символов; 3 поставщика × 100 позиций = 60–90 тыс.
символов, с комплектами — вдвое больше. Предел многострочной колонки SP — около 64 тыс.
Сохранение упадёт на обычных объёмах.
**В. Отдельный список «Исключённые предложения»** под каждую форму («…», «… ОИД», «… КПС»):
те же колонки, что в «Оценке поставщиков», плюс лукап на СТ.
- Плюс: в «Оценке поставщиков» только активные поставщики — бэкенд, отчёт, INCORE и потоки
не дорабатываются, порядок выкатки не важен.
- Плюс: одна строка на оффер, объём не ограничен.
- Минус: три новых списка, перенос строк между списками при исключении и возврате.
## Решение
Вариант **В**.
- Крестик исключает поставщика: колонка скрывается, итоги, минимумы, рекомендация и ИИ его
не учитывают. Если исключили выбранного — выбор сбрасывается.
- При исключении **причина обязательна**. Она хранится вместе с исключёнными строками.
- Под таблицей у автора — плашка «Исключены из сравнения: поставщик — причина [Вернуть]».
- **Согласующий исключённых пока не видит.** Данные для него грузятся так же, но показ
выключен константой — включить можно без доработки хранения.
- **Повторная загрузка КП** исключённого поставщика на Шаге 2 его не возвращает: он остаётся
исключённым до «Вернуть».
- При сохранении цены исключённых пишутся в «Исключённые предложения», в «Оценку
поставщиков» не попадают. При открытии СТ исключённые читаются по id СТ.
- «Вернуть» → при сохранении строки переезжают обратно в «Оценку поставщиков».
- Поставщик без цен — одна строка-маркер без позиции, чтобы колонка не терялась.
- Позицию сняли на Шаге 1 — её строки в «Исключённых» тоже уходят в корзину.
- Запись офферов и исключённых — в общем модуле для трёх форм, чтобы позже перенести
сохранение на бэкенд без переделки компонентов.
## Устройство списков
Манифесты — `provisioning/manifests/{ct,oid,kps}.provisioning.json`.
- Колонки — копия «Оценки поставщиков» своей формы (чтобы «Вернуть» восстановило оффер
целиком) плюс `CT` (найти исключённых при открытии СТ) и `reason` (причина).
- Ссылки на обычные списки — Lookup, файл КП — сразу `offerFileId: Number`: лукап на
библиотеку документов не читается через REST.
- Номер строки комплекта — в `Title`, как в «Оценке поставщиков» (`поставщик-позиция-N`):
один формат во всех списках, разбор переиспользуется (`parseOfferItemIndex`).
- Причина повторяется в каждой строке поставщика — осознанная денормализация. Отдельный
список «Исключённые поставщики» дал бы 6 списков вместо 3 ради короткого текста.
- Поставщик без цен — строка без `position`, чтобы колонка не терялась.
## Последствия
- Три новых списка на `/sites/sed` — описать в `provisioning/manifests/{ct,oid,kps}`,
создать через `Provision-Lists.ps1` (сначала тест, потом бой).
- Доработка Шага 3 и загрузки СТ во всех трёх формах, Jest-тесты на исключение и возврат.
- Попутно закрываются баги удаления: в ОИД выбор остаётся на удалённом поставщике, в КПС
удалённый поставщик без КП возвращается после перезагрузки.
## Известные ограничения
- **«Дополнительная информация» (условия оплаты, поставки, сроки) исключённого поставщика в SP
не пишется** — «Критерии» читает отчёт СТ, и исключённый попал бы в него. В сессии автора
условия сохраняются; если вернуть поставщика после открытия СТ в другом браузере, их придётся
заполнить заново. Цены возвращаются всегда.
- Если сессия автора уже есть, СТ берёт поставщиков из неё, а не из SP (как и раньше для
обычных поставщиков).
## Объём
Сейчас — только СТ ОМТС (#1). ОИД (#3) и КПС (#4) — позже: списки для них уже созданы, общий
модуль `shared/offers/excludedOffers.ts` готов к переиспользованию.
## Ответы на открытые вопросы (2026-10-27, Тимур)
1. Согласующий исключённых — заложить, но пока не показывать.
2. Повторная загрузка КП не возвращает исключённого поставщика.
3. Причина исключения — обязательная.
4. Списки создаются через `Provision-Lists.ps1` по манифестам (сначала тест, потом бой).
+37
View File
@@ -0,0 +1,37 @@
# Журнал архитектурных решений (ADR)
Одно решение — один файл `NNNN-kratkoe-nazvanie.md`. Файлы не переписываются задним числом:
если решение пересмотрели, пишется новый ADR со ссылкой «Заменяет NNNN», а у старого статус
меняется на «Заменено NNNN».
Статусы: **Предложено** → **Принято** → (**Заменено** / **Отменено**).
| № | Решение | Статус | Дата |
| --- | --- | --- | --- |
| [0001](0001-isklyuchenie-postavshchikov.md) | Исключение поставщика из сравнения вместо удаления | Принято | 2026-10-27 |
## Шаблон
```markdown
# NNNN. Название
- Статус: Предложено
- Дата: ГГГГ-ММ-ДД
- Задача: #номер
- Источник: кто и где попросил
## Контекст
Что происходит сейчас и почему это проблема.
## Варианты
Что рассматривали, плюсы и минусы каждого.
## Решение
Что выбрали.
## Последствия
Что меняется, что придётся сделать, какие риски.
## Открытые вопросы
Что ещё не решено.
```
+39 -4
View File
@@ -34,7 +34,13 @@
Тенант, например tnsukz.onmicrosoft.com Тенант, например tnsukz.onmicrosoft.com
.PARAMETER Thumbprint .PARAMETER Thumbprint
Отпечаток сертификата в хранилище CurrentUser\My. Отпечаток сертификата в хранилище CurrentUser\My. Либо он, либо CertificatePath.
.PARAMETER CertificatePath
Путь к файлу сертификата .pfx — если сертификат не установлен в хранилище.
.PARAMETER CertificatePassword
Пароль к .pfx из CertificatePath.
.PARAMETER ManifestsDir .PARAMETER ManifestsDir
Папка с манифестами. По умолчанию — provisioning/manifests рядом со скриптом. Папка с манифестами. По умолчанию — provisioning/manifests рядом со скриптом.
@@ -42,17 +48,30 @@
.PARAMETER Only .PARAMETER Only
Провижионить только манифесты указанных форм (по полю form). По умолчанию — все. Провижионить только манифесты указанных форм (по полю form). По умолчанию — все.
.PARAMETER Lists
Провижионить только списки с указанными названиями. Нужен для добавления нового списка
на рабочий сайт: полный прогон перезаписывает варианты Choice у живых списков
значениями из манифеста, а манифест мог отстать от живого списка.
.EXAMPLE .EXAMPLE
./Provision-Lists.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/sed" ` ./Provision-Lists.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/sed" `
-ClientId "<id>" -Tenant "tnsukz.onmicrosoft.com" -Thumbprint "<tp>" -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( param(
[Parameter(Mandatory = $true)][string]$SiteUrl, [Parameter(Mandatory = $true)][string]$SiteUrl,
[Parameter(Mandatory = $true)][string]$ClientId, [Parameter(Mandatory = $true)][string]$ClientId,
[Parameter(Mandatory = $true)][string]$Tenant, [Parameter(Mandatory = $true)][string]$Tenant,
[Parameter(Mandatory = $true)][string]$Thumbprint, [string]$Thumbprint,
[string]$CertificatePath,
[string]$CertificatePassword,
[string]$ManifestsDir = "$PSScriptRoot/manifests", [string]$ManifestsDir = "$PSScriptRoot/manifests",
[string[]]$Only [string[]]$Only,
[string[]]$Lists
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -143,7 +162,14 @@ function Add-ListColumn {
} }
Write-Host "Подключение к $SiteUrl (app-only) ..." -ForegroundColor Cyan Write-Host "Подключение к $SiteUrl (app-only) ..." -ForegroundColor Cyan
if ($Thumbprint) {
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $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 $files = Get-ChildItem -Path $ManifestsDir -Filter '*.provisioning.json' -ErrorAction SilentlyContinue
if (-not $files) { throw "Манифесты *.provisioning.json не найдены в $ManifestsDir" } if (-not $files) { throw "Манифесты *.provisioning.json не найдены в $ManifestsDir" }
@@ -152,9 +178,18 @@ $manifests = @()
foreach ($file in $files) { foreach ($file in $files) {
$def = Get-Content -Path $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json $def = Get-Content -Path $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json
if ($Only -and ($Only -notcontains $def.form)) { continue } 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 $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: создать все списки/библиотеки --- # --- Проход A: создать все списки/библиотеки ---
Write-Host "`n=== Проход A: списки ===" -ForegroundColor Cyan Write-Host "`n=== Проход A: списки ===" -ForegroundColor Cyan
@@ -345,6 +345,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": "Поставщики", "title": "Поставщики",
"template": "GenericList", "template": "GenericList",
@@ -386,6 +386,97 @@
"type": "Note" "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"
}
]
} }
] ]
} }
@@ -381,6 +381,73 @@
"type": "Boolean" "type": "Boolean"
} }
] ]
},
{
"title": "Исключённые предложения ОИД",
"template": "GenericList",
"shared": false,
"columns": [
{
"internalName": "CT",
"title": "Номер СТ",
"type": "Lookup",
"lookupList": "Сравнительная таблица ОИД",
"lookupShowField": "number"
},
{
"internalName": "supplier",
"title": "Поставщик",
"type": "Lookup",
"lookupList": "Поставщики",
"lookupShowField": "name"
},
{
"internalName": "reason",
"title": "Причина исключения",
"type": "Note"
},
{
"internalName": "position",
"title": "Позиция",
"type": "Lookup",
"lookupList": "Позиции СТ ОИД",
"lookupShowField": "position"
},
{
"internalName": "supplierPosition",
"title": "Наименование позиции",
"type": "Text"
},
{
"internalName": "unit",
"title": "Ед. изм",
"type": "Text"
},
{
"internalName": "quantity",
"title": "Количество",
"type": "Number",
"decimals": 3
},
{
"internalName": "price",
"title": "Цена",
"type": "Number",
"decimals": 2
},
{
"internalName": "summa",
"title": "Сумма",
"type": "Number",
"decimals": 2
},
{
"internalName": "offerFileId",
"title": "Id файла КП",
"type": "Number",
"decimals": 0
}
]
} }
] ]
} }
+2
View File
@@ -33,6 +33,7 @@
{ "title": "Критерии", "template": "GenericList" }, { "title": "Критерии", "template": "GenericList" },
{ "title": "Согласование СТ", "template": "GenericList" }, { "title": "Согласование СТ", "template": "GenericList" },
{ "title": "Не получены КП", "template": "GenericList" }, { "title": "Не получены КП", "template": "GenericList" },
{ "title": "Исключённые предложения", "template": "GenericList" },
{ "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": "GenericList" }, { "title": "Не получены КП ОИД", "template": "GenericList" },
{ "title": "Исключённые предложения ОИД", "template": "GenericList" },
{ "title": "Коммерческие предложения ОИД", "template": "DocumentLibrary" } { "title": "Коммерческие предложения ОИД", "template": "DocumentLibrary" }
] ]
} }
@@ -11,6 +11,7 @@ import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTabl
import { Step4Approval } from './Step4Approval/Step4Approval'; import { Step4Approval } from './Step4Approval/Step4Approval';
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns'; import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns';
import { buildOffersFromSpRecords } from './utils/spOffers'; import { buildOffersFromSpRecords } from './utils/spOffers';
import { excludedItemsToFormData } from './utils/excludedColumns';
import { isUserInApprovalChain } from './utils/approvalAccess'; import { isUserInApprovalChain } from './utils/approvalAccess';
import { CtAccess, getCtAccess, isCtAuthor } from './utils/ctAccess'; import { CtAccess, getCtAccess, isCtAuthor } from './utils/ctAccess';
import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers'; import { noOfferRecordsToSuppliers, withNoOfferSuppliers } from './utils/noOfferSuppliers';
@@ -82,6 +83,12 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
const preferSp = loadedAccess.isReview; const preferSp = loadedAccess.isReview;
const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined); const positionSpIds = positions.map(p => p.spId).filter((id): id is number => id !== undefined);
const rawOffers = await service.loadFullOffersForPositions(positionSpIds); 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( const noOfferFromSp = noOfferRecordsToSuppliers(
await service.loadNoOfferSuppliers(initialCtId).catch(err => { await service.loadNoOfferSuppliers(initialCtId).catch(err => {
@@ -103,7 +110,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
// Load currency rates for the valutaIds referenced by offers (separate query avoids // 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). // 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) .map(o => o.valutaSpId)
.filter((id): id is number => id !== undefined && id > 0) .filter((id): id is number => id !== undefined && id > 0)
.filter((id, idx, arr) => arr.indexOf(id) === idx); .filter((id, idx, arr) => arr.indexOf(id) === idx);
@@ -154,6 +161,15 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
currencyCodeOf, currencyCodeOf,
fallbackOfferFileId: supplierSpId => offerIdBySupplierSpId.get(supplierSpId), 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) // Reconstruct uploadedKpFiles for users without session (other users opening CT via link)
let uploadedKpFiles = formData.uploadedKpFiles; let uploadedKpFiles = formData.uploadedKpFiles;
@@ -176,8 +192,11 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
...(preferSp || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}), ...(preferSp || formData.selectedPositions.length === 0 ? { selectedPositions: positions } : {}),
// Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits. // 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). // 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), suppliers: withNoOfferSuppliers(
...(preferSp || formData.offers.length === 0 ? { offers } : {}), formData.suppliers.length === 0 ? [...spSuppliers, ...excluded.suppliers] : formData.suppliers,
noOfferFromSp,
),
...(preferSp || formData.offers.length === 0 ? { offers: [...offers, ...excluded.offers] } : {}),
...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}), ...(formData.currencyRates.length === 0 && spCurrencyRates.length > 0 ? { currencyRates: spCurrencyRates } : {}),
...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}), ...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}),
...(Object.keys(step3DeliveryKm).length > 0 ? { step3DeliveryKm } : {}), ...(Object.keys(step3DeliveryKm).length > 0 ? { step3DeliveryKm } : {}),
@@ -196,7 +215,7 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
// Автор открыл свою СТ — открываем шаги, до которых она уже дошла (позиции → 2, КП → 3, маршрут → 4). // Автор открыл свою СТ — открываем шаги, до которых она уже дошла (позиции → 2, КП → 3, маршрут → 4).
ensureHighestStep(reachableStep({ ensureHighestStep(reachableStep({
hasPositions: positions.length > 0, hasPositions: positions.length > 0,
hasKp: rawOffers.length > 0 || uploadedKpFiles.length > 0, hasKp: rawOffers.length > 0 || excludedItems.length > 0 || uploadedKpFiles.length > 0,
hasRoute: approvalMatrix.length > 0, hasRoute: approvalMatrix.length > 0,
})); }));
if (ct.status === 'approved' || ct.status === 'rejected') { if (ct.status === 'approved' || ct.status === 'rejected') {
@@ -4,6 +4,7 @@ import { CTFormData } from '../hooks/useCtForm';
import { KpFile, NoOfferEntry } from './kpTypes'; import { KpFile, NoOfferEntry } from './kpTypes';
import { buildSupplierColumnId } from '../utils/supplierColumns'; import { buildSupplierColumnId } from '../utils/supplierColumns';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers'; import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
import { keepExclusions } from '../utils/excludedColumns';
export interface PersistStep2Args { export interface PersistStep2Args {
service: CtService; service: CtService;
@@ -77,7 +78,8 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
savedRates = await service.saveCurrencyRates(currencyRates); 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 => ({ const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({
id: s.localId, id: s.localId,
@@ -94,7 +96,11 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
const offersForSP = newOffers const offersForSP = newOffers
.map(o => { .map(o => {
const pos = formData.selectedPositions.find(p => p.id === o.positionId); 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; if (pos?.spId === undefined || supplierSpId === undefined) return null;
return { ...o, positionId: String(pos.spId), supplierId: String(supplierSpId) }; return { ...o, positionId: String(pos.spId), supplierId: String(supplierSpId) };
}) })
@@ -5,6 +5,7 @@ import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from '
import { buildKpSuppliersAndOffers } from './step2Persist'; import { buildKpSuppliersAndOffers } from './step2Persist';
import { buildKpOffersToSave } from './kpOfferRecords'; import { buildKpOffersToSave } from './kpOfferRecords';
import { buildKpStorageName } from '../utils/kpFileName'; import { buildKpStorageName } from '../utils/kpFileName';
import { keepExclusions } from '../utils/excludedColumns';
let localIdCounter = 0; let localIdCounter = 0;
@@ -62,7 +63,7 @@ export function useKpProcessing(
// Заменяем колонки этих же КП (поставщик + файл), остальные — сохраняем: другой КП того же // Заменяем колонки этих же КП (поставщик + файл), остальные — сохраняем: другой КП того же
// поставщика — отдельное предложение, своя колонка. // поставщика — отдельное предложение, своя колонка.
const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.id === s.id)); 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 kpSupplierIds = new Set(kpSuppliers.map(s => s.id));
const otherOffers = (fd.offers ?? []).filter(o => !kpSupplierIds.has(o.supplierId)); const otherOffers = (fd.offers ?? []).filter(o => !kpSupplierIds.has(o.supplierId));
patch.offers = [...otherOffers, ...kpOffers]; patch.offers = [...otherOffers, ...kpOffers];
@@ -7,6 +7,7 @@ import { AddExtraRow } from './AddExtraRow';
import { SUPPLIER_STATUS_OPTIONS, getSubCols, formatKzt } from './matrixHelpers'; import { SUPPLIER_STATUS_OPTIONS, getSubCols, formatKzt } from './matrixHelpers';
import { UseAdataCheckResult } from './useAdataCheck'; import { UseAdataCheckResult } from './useAdataCheck';
import { AdataIndicator } from './AdataIndicator'; import { AdataIndicator } from './AdataIndicator';
import { ExcludeSupplierDialog } from './ExcludeSupplierDialog';
import styles from './Step3ComparisonTable.module.scss'; import styles from './Step3ComparisonTable.module.scss';
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
@@ -26,9 +27,13 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
const { const {
isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId, isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId,
supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers, supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers,
setSelectedSupplierId, handleDeleteSupplier, handleStatusChange, handleNameColResizeMouseDown, setSelectedSupplierId, handleExcludeSupplier, handleStatusChange, handleNameColResizeMouseDown,
} = matrix; } = matrix;
// Колонка, для которой открыто окно «Исключить из сравнения».
const [excludingSupplierId, setExcludingSupplierId] = React.useState<string | undefined>(undefined);
const excludingSupplier = suppliersWithOffer.find(s => s.id === excludingSupplierId);
const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0); const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0);
// Высота row1 (шапка поставщика) переменная (теги валют, выбор поставщика, состояние Adata), // Высота row1 (шапка поставщика) переменная (теги валют, выбор поставщика, состояние Adata),
@@ -115,8 +120,8 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</button> </button>
<button <button
className={styles.deleteSupplierBtn} className={styles.deleteSupplierBtn}
onClick={() => handleDeleteSupplier(s.id, s.isExtraSupplier)} onClick={() => setExcludingSupplierId(s.id)}
title="Удалить потенциального поставщика" title="Исключить из сравнения"
>×</button> >×</button>
</> </>
) : ( ) : (
@@ -257,6 +262,17 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
<AddExtraRow matrix={matrix} isRestrictedView={isRestrictedView} colSpan={totalColSpan} /> <AddExtraRow matrix={matrix} isRestrictedView={isRestrictedView} colSpan={totalColSpan} />
</tbody> </tbody>
</table> </table>
{excludingSupplier && (
<ExcludeSupplierDialog
supplierName={matrix.columnDisplayNames[excludingSupplier.id] ?? excludingSupplier.name}
onConfirm={reason => {
handleExcludeSupplier(excludingSupplier.id, reason);
setExcludingSupplierId(undefined);
}}
onCancel={() => setExcludingSupplierId(undefined)}
/>
)}
</div> </div>
); );
} }
@@ -0,0 +1,43 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, Dialog, TextArea } from '@tnsu/ui-kit';
interface Props {
supplierName: string;
onConfirm: (reason: string) => void;
onCancel: () => void;
}
const REASON_ROWS = 3;
// Исключение поставщика из сравнения (docs/adr/0001): причина обязательна — по ней потом видно,
// почему поставщик не рассматривался.
export const ExcludeSupplierDialog: React.FC<Props> = ({ supplierName, onConfirm, onCancel }) => {
const [reason, setReason] = useState('');
const isReasonEmpty = reason.trim().length === 0;
return (
<Dialog
open
title="Исключить из сравнения"
subText={`«${supplierName}» пропадёт из таблицы и итогов. Цены сохранятся — поставщика можно будет вернуть.`}
onDismiss={onCancel}
footer={
<>
<Button variant="secondary" onClick={onCancel}>Отмена</Button>
<Button variant="primary" onClick={() => onConfirm(reason)} disabled={isReasonEmpty}>
Исключить
</Button>
</>
}
>
<TextArea
value={reason}
onChange={setReason}
rows={REASON_ROWS}
placeholder="Причина исключения, например: не прошёл проверку Adata"
ariaLabel="Причина исключения поставщика"
/>
</Dialog>
);
};
@@ -0,0 +1,37 @@
import * as React from 'react';
import { Button } from '@tnsu/ui-kit';
import styles from './Step3ComparisonTable.module.scss';
export interface ExcludedSupplierEntry {
id: string;
name: string;
reason: string;
}
interface Props {
entries: ExcludedSupplierEntry[];
// Без колбэка — только чтение (кнопки «Вернуть» нет).
onRestore?: (id: string) => void;
}
// Поставщики, исключённые из сравнения на Шаге 3 (docs/adr/0001), — под таблицей, рядом со
// списком «не предоставившие КП».
export const ExcludedSuppliersList: React.FC<Props> = ({ entries, onRestore }) => {
if (entries.length === 0) return null;
return (
<div className={styles.criteriaSection}>
<h4 className={styles.criteriaSectionTitle}>Исключены из сравнения</h4>
<ul className={styles.noOfferSupplierList}>
{entries.map(entry => (
<li key={entry.id} className={`${styles.noOfferSupplierItem} ${styles.excludedSupplierItem}`}>
<span className={styles.noOfferSupplierName}>{entry.name}</span>
<span className={styles.noOfferSupplierComment}>{entry.reason}</span>
{onRestore && (
<Button variant="secondary" onClick={() => onRestore(entry.id)}>Вернуть</Button>
)}
</li>
))}
</ul>
</div>
);
};
@@ -675,6 +675,11 @@ $delivery-km-min-width: 76px;
color: $muted; color: $muted;
} }
// Кнопка «Вернуть» — по центру строки с именем и причиной.
.excludedSupplierItem {
align-items: center;
}
.criteriaTable { .criteriaTable {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
@@ -18,6 +18,11 @@ import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
import { useAdataCheck } from './useAdataCheck'; import { useAdataCheck } from './useAdataCheck';
import { useRecommendedPick } from './useRecommendedPick'; import { useRecommendedPick } from './useRecommendedPick';
import { VendorPickerDialog } from './VendorPickerDialog'; import { VendorPickerDialog } from './VendorPickerDialog';
import { ExcludedSuppliersList } from './ExcludedSuppliersList';
// Исключённых поставщиков в режиме просмотра (согласующий, отправленная СТ) пока не показываем —
// решение от 2026-10-27 (docs/adr/0001). Данные грузятся и так: включить — поменять на true.
const SHOW_EXCLUDED_SUPPLIERS_READ_ONLY = false;
interface Props { interface Props {
service: CtService; service: CtService;
@@ -53,7 +58,9 @@ export const Step3ComparisonTable: React.FC<Props> = ({
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency, isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave, handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId, extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
excludedColumns, handleRestoreSupplier,
} = matrix; } = matrix;
const showExcludedSuppliers = !isReadOnly || SHOW_EXCLUDED_SUPPLIERS_READ_ONLY;
// Проверка контрагентов в Adata вынесена в хук. // Проверка контрагентов в Adata вынесена в хук.
const adata = useAdataCheck(service, suppliersWithOffer); const adata = useAdataCheck(service, suppliersWithOffer);
@@ -181,6 +188,17 @@ export const Step3ComparisonTable: React.FC<Props> = ({
<NoOfferSuppliersList entries={getNoOfferSuppliers(formData.suppliers)} /> <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={formData.uploadedKpFiles ?? []} service={service} />}
@@ -1,5 +1,5 @@
import { SupplierOffer } from '../types/domain'; import { CurrencyRate, SupplierOffer } from '../types/domain';
import { CellEdit } from './matrixTypes'; import { CellEdit, EditableOffers, PositionRow } from './matrixTypes';
// Статус потенциального поставщика — фиксированный список для <select> в шапке (не обязателен). // Статус потенциального поставщика — фиксированный список для <select> в шапке (не обязателен).
export const SUPPLIER_STATUS_OPTIONS = [ export const SUPPLIER_STATUS_OPTIONS = [
@@ -87,6 +87,45 @@ export function buildOfferKey(supplierId: string, positionId: string): string {
return `${supplierId}::${positionId}`; return `${supplierId}::${positionId}`;
} }
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), в элементы стейта. // Проставляет id, полученные при создании в SP (доп. строки / поставщики Шага 3), в элементы стейта.
// Результат сохраняем сразу после создания: если дальше сохранение упадёт, повторное «Сохранить» // Результат сохраняем сразу после создания: если дальше сохранение упадёт, повторное «Сохранить»
// увидит spId и не создаст ту же строку ещё раз. // увидит spId и не создаст ту же строку ещё раз.
@@ -1,11 +1,14 @@
// Типы матрицы сравнения (Шаг 3). Перенесено 1:1 из Step3ComparisonTable. // Типы матрицы сравнения (Шаг 3). Перенесено 1:1 из Step3ComparisonTable.
import { SupplierExclusion } from '../types/domain';
export interface ExtraSupplier { export interface ExtraSupplier {
id: string; id: string;
name: string; name: string;
currency: string; currency: string;
spId?: number; spId?: number;
status?: string; status?: string;
exclusion?: SupplierExclusion;
// Заполняются только когда поставщик добавлен из VendorPickerDialog («База данных поставщиков»). // Заполняются только когда поставщик добавлен из VendorPickerDialog («База данных поставщиков»).
offerId?: number; offerId?: number;
bin?: string; bin?: string;
@@ -1,10 +1,11 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useMemo, useCallback, useRef } 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 { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm'; import { CTFormData } from '../hooks/useCtForm';
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes'; import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
import { applyCreatedIds, buildOfferKey, computeOffer, sumPositionTotals } from './matrixHelpers'; import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
import { IVendorRecord } from '../../services/VendorDatabaseService'; import { IVendorRecord } from '../../services/VendorDatabaseService';
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns'; import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers'; import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
@@ -289,7 +290,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const suppliersWithOffer = useMemo(() => [ // Все колонки поставщиков с КП, включая исключённых из сравнения (docs/adr/0001).
const allColumns = useMemo(() => [
// Поставщики без КП с Шага 2 (hasOffer:false) в таблицу сравнения не попадают — сравнивать // Поставщики без КП с Шага 2 (hasOffer:false) в таблицу сравнения не попадают — сравнивать
// у них нечего. Ручной ввод цен остаётся за поставщиками, добавленными на самом Шаге 3 (extras). // у них нечего. Ручной ввод цен остаётся за поставщиками, добавленными на самом Шаге 3 (extras).
...suppliers ...suppliers
@@ -299,19 +301,29 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
id: es.id, name: es.name, hasOffer: true as const, isExtraSupplier: true as const, id: es.id, name: es.name, hasOffer: true as const, isExtraSupplier: true as const,
// spId/bin известны сразу для поставщиков, добавленных из «База данных поставщиков» // spId/bin известны сразу для поставщиков, добавленных из «База данных поставщиков»
// (см. handleAddSupplierFromDatabase) — без этого useAdataCheck не увидит их БИН. // (см. handleAddSupplierFromDatabase) — без этого useAdataCheck не увидит их БИН.
spId: es.spId, bin: es.bin, spId: es.spId, bin: es.bin, exclusion: es.exclusion,
})), })),
], [suppliers, extraSuppliers]); ], [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( const columnLabels = useMemo(
() => buildColumnLabels(suppliersWithOffer, formData.uploadedKpFiles ?? []), () => buildColumnLabels(allColumns, formData.uploadedKpFiles ?? []),
[suppliersWithOffer, formData.uploadedKpFiles], [allColumns, formData.uploadedKpFiles],
); );
const columnDisplayNames = useMemo( const columnDisplayNames = useMemo(
() => buildColumnDisplayNames(suppliersWithOffer, columnLabels), () => buildColumnDisplayNames(allColumns, columnLabels),
[suppliersWithOffer, columnLabels], [allColumns, columnLabels],
); );
const allPositions = useMemo((): PositionRow[] => { const allPositions = useMemo((): PositionRow[] => {
@@ -348,35 +360,21 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
return m; return m;
}, [suppliers, offers, extraSuppliers]); }, [suppliers, offers, extraSuppliers]);
const computedOffers = useMemo((): SupplierOffer[] => { const computedOffers = useMemo(
const result: SupplierOffer[] = []; (): SupplierOffer[] => computeColumnsOffers({
suppliersWithOffer.forEach(s => { columns: suppliersWithOffer, positions: allPositions, edits, supplierCurrencies, currencyRates,
allPositions.forEach(pos => { }),
const key = buildOfferKey(s.id, pos.id); [edits, supplierCurrencies, currencyRates, allPositions, suppliersWithOffer],
const items = edits[key] ?? [{ supplierId: s.id, positionId: pos.id, itemIndex: 0 }]; );
const currency = supplierCurrencies[s.id] ?? 'KZT'; const excludedOffers = useMemo(
const rate = currency !== 'KZT' (): SupplierOffer[] => computeColumnsOffers({
? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1) columns: excludedColumns, positions: allPositions, edits, supplierCurrencies, currencyRates,
: 1; }),
// For extra suppliers quantity may be undefined in edits (user only entered price). [edits, supplierCurrencies, currencyRates, allPositions, excludedColumns],
// 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]);
React.useEffect(() => { computedOffersRef.current = computedOffers; }, [computedOffers]); // F5 до «Сохранить» не должен терять и цены исключённых — иначе «Вернуть» вернёт пустую колонку.
React.useEffect(() => { computedOffersRef.current = [...computedOffers, ...excludedOffers]; }, [computedOffers, excludedOffers]);
// Суммы по (поставщик, позиция) — один источник и для минимума, и для подсветки ячеек (п.13). // Суммы по (поставщик, позиция) — один источник и для минимума, и для подсветки ячеек (п.13).
const positionTotals = useMemo(() => sumPositionTotals(computedOffers), [computedOffers]); const positionTotals = useMemo(() => sumPositionTotals(computedOffers), [computedOffers]);
@@ -475,26 +473,29 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
[isReadOnly] [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 (isReadOnly) return;
if (isExtra) { if (suppliers.some(s => s.id === supplierId)) {
const updated = extraSuppliers.filter(es => es.id !== supplierId); onSave({ suppliers: suppliers.map(s => (s.id === supplierId ? { ...s, exclusion } : s)) });
setExtraSuppliers(updated);
setEdits(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => { if (k.startsWith(`${supplierId}::`)) delete next[k]; });
return next;
});
onSave({ step3ExtraSuppliers: updated });
} else { } else {
setEdits(prev => { const updated = extraSuppliers.map(es => (es.id === supplierId ? { ...es, exclusion } : es));
const next = { ...prev }; setExtraSuppliers(updated);
Object.keys(next).forEach(k => { if (k.startsWith(`${supplierId}::`)) delete next[k]; }); onSave({ step3ExtraSuppliers: updated });
return next;
});
onSave({ suppliers: suppliers.filter(s => s.id !== supplierId) });
} }
}, [isReadOnly, extraSuppliers, suppliers, onSave]); // Исключённый не может остаться выбранным — иначе он уйдёт победителем в шапку СТ.
if (exclusion && selectedSupplierId === supplierId) setSelectedSupplierId(undefined);
}, [isReadOnly, suppliers, extraSuppliers, selectedSupplierId, 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 => { const updatePosQty = useCallback((positionId: string, value: string): void => {
if (isReadOnly) return; if (isReadOnly) return;
@@ -800,6 +801,32 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
}); });
await service.saveOffers(offersToSave, currencyRates); 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 могли пройти крошками, минуя его «Сохранить». // «Не получены КП» — к этой СТ, до согласования: шаг 2 могли пройти крошками, минуя его «Сохранить».
await service.syncNoOfferSuppliers(ct.id, getNoOfferSuppliers(suppliers)); await service.syncNoOfferSuppliers(ct.id, getNoOfferSuppliers(suppliers));
// «Дополнительная информация» — в SP: иначе согласующий и PDF видят только то, что распознал ИИ. // «Дополнительная информация» — в SP: иначе согласующий и PDF видят только то, что распознал ИИ.
@@ -826,10 +853,14 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
// render keys use React IDs, so SP IDs in session cause empty cells on remount. // 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). // Keep offerId from offersToSave (order matches: both derived from same filtered computedOffers).
const filteredComputed = computedOffers.filter(o => o.totalWithExtraKzt > 0); const filteredComputed = computedOffers.filter(o => o.totalWithExtraKzt > 0);
const offersForSession = filteredComputed.map((o, i) => ({ const offersForSession = [
...filteredComputed.map((o, i) => ({
...o, ...o,
offerId: offersToSave[i]?.offerId, offerId: offersToSave[i]?.offerId,
})); })),
// Цены исключённых — тоже в сессию: «Вернуть» после перехода по шагам возвращает их колонку.
...excludedOffers.filter(o => o.totalWithExtraKzt > 0),
];
onSave({ onSave({
offers: offersForSession, offers: offersForSession,
@@ -849,6 +880,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
name: es.name, name: es.name,
hasOffer: true as const, hasOffer: true as const,
status: supplierStatuses[es.id] ?? '', status: supplierStatuses[es.id] ?? '',
exclusion: es.exclusion,
})), })),
], ],
recommendedSupplierId: selectedSupplierId, recommendedSupplierId: selectedSupplierId,
@@ -931,7 +963,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
updateSupplierProductName, updateSupplierProductName,
addBundleItem, addBundleItem,
removeBundleItem, removeBundleItem,
handleDeleteSupplier, excludedColumns,
handleExcludeSupplier,
handleRestoreSupplier,
updatePosQty, updatePosQty,
updateCriteriaValue, updateCriteriaValue,
updateCustomCriteriaValue, updateCustomCriteriaValue,
@@ -73,6 +73,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 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) adataData?: AdataCompanyInfo; // кэш последней проверки Adata — SP-поле 'adataData' (JSON)
adataCheckedAt?: string; // дата последней проверки Adata — SP-поле 'adataCheckedAt' adataCheckedAt?: string; // дата последней проверки Adata — SP-поле 'adataCheckedAt'
exclusion?: SupplierExclusion; // исключён из сравнения на Шаге 3 — колонка скрыта, цены сохранены
}
// Поставщик исключён из сравнения (docs/adr/0001). Хранится в «Исключённые предложения».
export interface SupplierExclusion {
reason: string;
} }
// Ответ Adata (через прокси api.tnsu.kz/Proxy/AData) по БИН/ИИН контрагента. // Ответ 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;
}
@@ -30,6 +30,13 @@ import {
keepLatestOfferRecords, keepLatestOfferRecords,
parseOfferItemIndex, parseOfferItemIndex,
} from "../../../shared/offers/offerRecords"; } 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 { CriteriaRecord, plainTextFromSp } from "../components/utils/criteria";
import { pickRowToNotify } from "../components/utils/approvalAccess"; import { pickRowToNotify } from "../components/utils/approvalAccess";
import { buildPositionKeys } from "../components/utils/positionKey"; import { buildPositionKeys } from "../components/utils/positionKey";
@@ -65,6 +72,9 @@ const LISTS = {
SUPPLIERS: "Поставщики", SUPPLIERS: "Поставщики",
NO_OFFERS: "Не получены КП", NO_OFFERS: "Не получены КП",
OFFERS: "Оценка поставщиков", OFFERS: "Оценка поставщиков",
// Поставщики, исключённые из сравнения на Шаге 3 (docs/adr/0001) — отдельно от OFFERS,
// чтобы отчёт СТ, INCORE и потоки их не видели.
EXCLUDED_OFFERS: "Исключённые предложения",
CURRENCY_RATES: "Курс Валют", CURRENCY_RATES: "Курс Валют",
LIBRARY_KP: "Коммерческие предложения", LIBRARY_KP: "Коммерческие предложения",
CRITERIA: "Критерии", CRITERIA: "Критерии",
@@ -283,25 +293,28 @@ export class CtService {
if (!res.ok) throw new Error(`Ошибка обновления проекта СТ: ${res.status}`); if (!res.ok) throw new Error(`Ошибка обновления проекта СТ: ${res.status}`);
} }
// Позиции, снятые при повторном сохранении шага 1: их предложения и сами позиции — в корзину. // Позиции, снятые при повторном сохранении шага 1: их предложения (в т.ч. исключённых
// Сначала предложения: без позиции они бы остались висеть в «Оценка поставщиков». // поставщиков) и сами позиции — в корзину. Сначала предложения: без позиции они бы остались висеть.
async removeCTPositions(positionSpIds: number[]): Promise<void> { async removeCTPositions(positionSpIds: number[]): Promise<void> {
if (positionSpIds.length === 0) return; if (positionSpIds.length === 0) return;
const filter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or "); const filter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or ");
const loadIds = async (listName: string): Promise<number[]> => {
const res = await this.http.get( 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, SPHttpClient.configurations.v1,
); );
if (!res.ok) throw new Error(`Ошибка чтения предложений позиций: ${res.status}`); if (!res.ok) throw new Error(`Ошибка чтения предложений позиций: ${res.status}`);
const data = await res.json(); 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. // MERGE/DELETE на списке предложений блокирует ресивер — убираем через recycle(), как в saveOffers.
const recycle = (listName: string) => async (id: number): Promise<void> => { const recycle = (listName: string) => async (id: number): Promise<void> => {
await this.http await this.http
.post(`${this._liById(listName, id)}/recycle()`, SPHttpClient.configurations.v1, {}) .post(`${this._liById(listName, id)}/recycle()`, SPHttpClient.configurations.v1, {})
.catch(() => undefined /* уже в корзине / не найден — игнорируем */); .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)); await this._runChunked(positionSpIds, 20, recycle(LISTS.CT_POSITIONS));
} }
@@ -1361,6 +1374,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<{ async getCurrentUserProfile(): Promise<{
department: string; department: string;
company: string; company: string;
@@ -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 }));
}