From 26b937afbacbb03b6a71d45b92289592e18ef57f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Tue, 27 Oct 2026 11:23:04 +0500 Subject: [PATCH 1/8] =?UTF-8?q?chore:=20=D0=BC=D0=B0=D0=BD=D0=B8=D1=84?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20=C2=AB=D0=98=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D1=91=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=C2=BB=20=D0=B4=D0=BB=D1=8F=20=D0=A1?= =?UTF-8?q?=D0=A2,=20=D0=9E=D0=98=D0=94=20=D0=B8=20=D0=9A=D0=9F=D0=A1=20(#?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Исключённые поставщики хранятся отдельно от «Оценки поставщиков», чтобы отчёт СТ, INCORE и потоки их не видели. Колонки — копия офферов своей формы плюс СТ и причина; файл КП — числом, лукап на библиотеку не читается через REST. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0001-isklyuchenie-postavshchikov.md | 14 +++ provisioning/manifests/ct.provisioning.json | 91 ++++++++++++++++++++ provisioning/manifests/kps.provisioning.json | 91 ++++++++++++++++++++ provisioning/manifests/oid.provisioning.json | 67 ++++++++++++++ provisioning/sources.json | 2 + 5 files changed, 265 insertions(+) diff --git a/docs/adr/0001-isklyuchenie-postavshchikov.md b/docs/adr/0001-isklyuchenie-postavshchikov.md index 5e38aa8..44bb57b 100644 --- a/docs/adr/0001-isklyuchenie-postavshchikov.md +++ b/docs/adr/0001-isklyuchenie-postavshchikov.md @@ -71,6 +71,20 @@ - Запись офферов и исключённых — в общем модуле для трёх форм, чтобы позже перенести сохранение на бэкенд без переделки компонентов. +## Устройство списков + +Манифесты — `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}`, diff --git a/provisioning/manifests/ct.provisioning.json b/provisioning/manifests/ct.provisioning.json index a5a3a87..77cbe10 100644 --- a/provisioning/manifests/ct.provisioning.json +++ b/provisioning/manifests/ct.provisioning.json @@ -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": "Поставщики", "template": "GenericList", diff --git a/provisioning/manifests/kps.provisioning.json b/provisioning/manifests/kps.provisioning.json index 04c441f..d85d4db 100644 --- a/provisioning/manifests/kps.provisioning.json +++ b/provisioning/manifests/kps.provisioning.json @@ -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" + } + ] } ] } diff --git a/provisioning/manifests/oid.provisioning.json b/provisioning/manifests/oid.provisioning.json index abcab3f..525d6ca 100644 --- a/provisioning/manifests/oid.provisioning.json +++ b/provisioning/manifests/oid.provisioning.json @@ -381,6 +381,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 + } + ] } ] } diff --git a/provisioning/sources.json b/provisioning/sources.json index 7c4f5c5..ee4b67c 100644 --- a/provisioning/sources.json +++ b/provisioning/sources.json @@ -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" } ] } From f9bed709eaf99a51c21827d80d5fe4adc9ea6bc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Tue, 27 Oct 2026 11:23:58 +0500 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20Provision-Lists.ps1=20=E2=80=94=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20-Lists=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=82=D0=BE=D1=87=D0=B5=D1=87=D0=BD=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=BE=D0=B2=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полный прогон на рабочем сайте перезаписывает варианты Choice живых списков значениями из манифеста, а манифесты отстали от живых списков (#2). Новые списки «Исключённые предложения» создаём точечно, не трогая остальные. Co-Authored-By: Claude Opus 5.5 (1M context) --- provisioning/Provision-Lists.ps1 | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/provisioning/Provision-Lists.ps1 b/provisioning/Provision-Lists.ps1 index 9c4129a..41f0187 100644 --- a/provisioning/Provision-Lists.ps1 +++ b/provisioning/Provision-Lists.ps1 @@ -42,9 +42,19 @@ .PARAMETER Only Провижионить только манифесты указанных форм (по полю form). По умолчанию — все. +.PARAMETER Lists + Провижионить только списки с указанными названиями. Нужен для добавления нового списка + на рабочий сайт: полный прогон перезаписывает варианты Choice у живых списков + значениями из манифеста, а манифест мог отстать от живого списка. + .EXAMPLE ./Provision-Lists.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/sed" ` -ClientId "" -Tenant "tnsukz.onmicrosoft.com" -Thumbprint "" + +.EXAMPLE + ./Provision-Lists.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/sed" ` + -ClientId "" -Tenant "tnsukz.onmicrosoft.com" -Thumbprint "" ` + -Lists "Исключённые предложения", "Исключённые предложения ОИД" #> param( [Parameter(Mandatory = $true)][string]$SiteUrl, @@ -52,7 +62,8 @@ param( [Parameter(Mandatory = $true)][string]$Tenant, [Parameter(Mandatory = $true)][string]$Thumbprint, [string]$ManifestsDir = "$PSScriptRoot/manifests", - [string[]]$Only + [string[]]$Only, + [string[]]$Lists ) $ErrorActionPreference = 'Stop' @@ -152,9 +163,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 From b5d7efc74910915bf786f404c29f1741e0d895ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Tue, 27 Oct 2026 11:26:24 +0500 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20Provision-Lists.ps1=20=E2=80=94=20?= =?UTF-8?q?=D0=B2=D1=85=D0=BE=D0=B4=20=D0=BF=D0=BE=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=D1=83=20=D1=81=D0=B5=D1=80=D1=82=D0=B8=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=20.pfx=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Рабочий app-only сертификат для /sites/sed лежит файлом с паролем, а не в хранилище CurrentUser\My — скрипт умел только по отпечатку. Co-Authored-By: Claude Opus 5.5 (1M context) --- provisioning/Provision-Lists.ps1 | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/provisioning/Provision-Lists.ps1 b/provisioning/Provision-Lists.ps1 index 41f0187..7411ee9 100644 --- a/provisioning/Provision-Lists.ps1 +++ b/provisioning/Provision-Lists.ps1 @@ -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 рядом со скриптом. @@ -60,7 +66,9 @@ 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[]]$Lists @@ -154,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" } From 3907e5a60b28d3a4acfbbc0a8872a5491b3e1645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Tue, 27 Oct 2026 11:35:32 +0500 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=20=C2=AB=D0=98=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D1=91=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=C2=BB=20=D0=B4=D0=BB=D1=8F=20=D1=82?= =?UTF-8?q?=D1=80=D1=91=D1=85=20=D1=84=D0=BE=D1=80=D0=BC=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чистые функции без SharePoint: тело записи, разбор элемента, сборка колонок (поставщик + файл КП) и строка-маркер для поставщика без цен. Один модуль на СТ, ОИД и КПС, чтобы правила хранения не разошлись между формами. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../shared/offers/excludedOffers.test.ts | 194 ++++++++++++++++++ .../shared/offers/excludedOffers.ts | 159 ++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts create mode 100644 src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts diff --git a/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts new file mode 100644 index 0000000..f24ef7f --- /dev/null +++ b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts @@ -0,0 +1,194 @@ +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 { + return { + 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({ + 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([]); + }); + + 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'); + }); +}); diff --git a/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts new file mode 100644 index 0000000..72dae26 --- /dev/null +++ b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts @@ -0,0 +1,159 @@ +// Записи списков «Исключённые предложения» (СТ / ОИД / КПС): строки КП поставщика, которого +// исключили из сравнения на Шаге 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: дополнительно имя поставщика из лукапа. +export interface IExcludedOfferItem extends IExcludedOfferRecord { + 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 { + const body: Record = { + 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): IExcludedOfferItem { + const supplier = item.supplier as Record | undefined; + return { + 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 ?? ''}`; +} + +// Строки → колонки: одна колонка на (поставщик, файл КП), как в таблице сравнения. +export function groupExcludedColumns(items: IExcludedOfferItem[]): IExcludedSupplierColumn[] { + const columns = new Map(); + for (const item of items) { + 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, + offers: Array>, +): 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 })); +} From 373e34b2cf3a0ef87cce6db1401f0363ccecf238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Tue, 27 Oct 2026 11:43:32 +0500 Subject: [PATCH 5/8] =?UTF-8?q?feat(ct):=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B8=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D1=91=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D1=89=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B2=20SP=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CtService: loadExcludedOffers / replaceExcludedOffers — сначала новые строки, потом прежние в корзину, чтобы сбой записи не терял исключённых; дубли отсеивает чтение. removeCTPositions убирает и исключённые строки снятых позиций. Преобразования строк SP ↔ колонки и офферы формы — отдельно, с тестами. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Step3ComparisonTable/matrixTypes.ts | 3 + .../forms/ct/components/types/domain.ts | 6 + .../components/utils/excludedColumns.test.ts | 110 ++++++++++++++++++ .../ct/components/utils/excludedColumns.ts | 107 +++++++++++++++++ .../forms/ct/services/CtService.ts | 67 +++++++++-- .../shared/offers/excludedOffers.test.ts | 16 ++- .../shared/offers/excludedOffers.ts | 22 +++- 7 files changed, 318 insertions(+), 13 deletions(-) create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.test.ts create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.ts diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixTypes.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixTypes.ts index 679f45f..c177f0c 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixTypes.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/matrixTypes.ts @@ -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; diff --git a/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts b/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts index 10e8437..2f60f69 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/types/domain.ts @@ -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 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) по БИН/ИИН контрагента. diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.test.ts new file mode 100644 index 0000000..f5ec41c --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.test.ts @@ -0,0 +1,110 @@ +import { SupplierOffer } from '../types/domain'; +import { IExcludedOfferItem, fromExcludedOfferItem, toExcludedOfferBody } from '../../../../shared/offers/excludedOffers'; +import { ExcludedSaveDeps, buildExcludedRecordsForSave, excludedItemsToFormData } 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 { + 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 { + 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)[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('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 }); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.ts new file mode 100644 index 0000000..c34ff44 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/excludedColumns.ts @@ -0,0 +1,107 @@ +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 }; +} + +// Колонка исключённого поставщика на момент сохранения: 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; +} diff --git a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts index 2857406..7c2f0d6 100644 --- a/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts +++ b/src/webparts/ctFormsPortal/forms/ct/services/CtService.ts @@ -30,6 +30,13 @@ 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"; @@ -65,6 +72,9 @@ const LISTS = { SUPPLIERS: "Поставщики", NO_OFFERS: "Не получены КП", OFFERS: "Оценка поставщиков", + // Поставщики, исключённые из сравнения на Шаге 3 (docs/adr/0001) — отдельно от OFFERS, + // чтобы отчёт СТ, INCORE и потоки их не видели. + EXCLUDED_OFFERS: "Исключённые предложения", CURRENCY_RATES: "Курс Валют", LIBRARY_KP: "Коммерческие предложения", CRITERIA: "Критерии", @@ -283,25 +293,28 @@ export class CtService { if (!res.ok) throw new Error(`Ошибка обновления проекта СТ: ${res.status}`); } - // Позиции, снятые при повторном сохранении шага 1: их предложения и сами позиции — в корзину. - // Сначала предложения: без позиции они бы остались висеть в «Оценка поставщиков». + // Позиции, снятые при повторном сохранении шага 1: их предложения (в т.ч. исключённых + // поставщиков) и сами позиции — в корзину. Сначала предложения: без позиции они бы остались висеть. async removeCTPositions(positionSpIds: number[]): Promise { if (positionSpIds.length === 0) return; const filter = positionSpIds.map((id) => `positionId eq ${id}`).join(" or "); - const res = await this.http.get( - `${this._li(LISTS.OFFERS)}?$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); + const loadIds = async (listName: string): Promise => { + const res = await this.http.get( + `${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(); + return ((data.value ?? []) as Array<{ Id: number }>).map((item) => item.Id); + }; // MERGE/DELETE на списке предложений блокирует ресивер — убираем через recycle(), как в saveOffers. const recycle = (listName: string) => async (id: number): Promise => { 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)); } @@ -1361,6 +1374,40 @@ export class CtService { } } + // ─── Исключённые предложения ─────────────────────────────────────────────── + + async loadExcludedOffers(ctId: string): Promise { + 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>).map(fromExcludedOfferItem); + } + + // Приводит исключённых этой СТ к текущему Шагу 3. Сначала новые строки, потом прежние в корзину: + // упади запись посередине — прежние исключённые останутся, а дубли отсеет чтение + // (groupExcludedColumns). Вернувшиеся в сравнение поставщики уходят вместе с прежними строками. + async replaceExcludedOffers(ctId: string, records: IExcludedOfferRecord[]): Promise { + 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; diff --git a/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts index f24ef7f..8269197 100644 --- a/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts +++ b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.test.ts @@ -14,6 +14,7 @@ const REASON = 'Не прошёл проверку Adata'; function makeItem(overrides: Partial): IExcludedOfferItem { return { + id: 1, supplierSpId: 259, supplierName: 'ООО Альфа', offerFileSpId: 278, @@ -96,7 +97,7 @@ describe('fromExcludedOfferItem', () => { }); expect(record).toEqual({ - supplierSpId: 259, supplierName: 'ООО Альфа', offerFileSpId: 278, reason: REASON, + 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, @@ -136,6 +137,19 @@ describe('groupExcludedColumns', () => { 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 }), diff --git a/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts index 72dae26..700cf0f 100644 --- a/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts +++ b/src/webparts/ctFormsPortal/shared/offers/excludedOffers.ts @@ -26,8 +26,10 @@ export interface IExcludedOfferRecord { deliveryDistanceKm?: number; } -// Строка, прочитанная из SP: дополнительно имя поставщика из лукапа. +// Строка, прочитанная из SP: дополнительно SP Id и имя поставщика из лукапа. export interface IExcludedOfferItem extends IExcludedOfferRecord { + // Чем больше, тем позже создана — по нему отсеиваются дубли (см. groupExcludedColumns). + id: number; supplierName: string; } @@ -99,6 +101,7 @@ export function toExcludedOfferBody(ctSpId: number, record: IExcludedOfferRecord export function fromExcludedOfferItem(item: Record): IExcludedOfferItem { const supplier = item.supplier as Record | undefined; return { + id: Number(item.Id ?? 0), supplierSpId: Number(item.supplierId), supplierName: String(supplier?.Title ?? ''), offerFileSpId: optionalNumber(item.offerFileId), @@ -121,10 +124,25 @@ function columnKey(supplierSpId: number, offerFileSpId?: number): string { return `${supplierSpId}::${offerFileSpId ?? ''}`; } +// Одна строка на (колонка, позиция, строка комплекта) — последняя по Id. Дубли остаются, если +// сохранение упало между созданием новых строк и уборкой прежних (сервис пишет в этом порядке, +// чтобы при сбое не потерять исключённых). +function keepLatestExcluded(items: IExcludedOfferItem[]): IExcludedOfferItem[] { + const latestByKey = new Map(); + 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(); - for (const item of items) { + // Новые строки первыми: причина колонки берётся из последнего сохранения. + 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) { From 74f6d42e4fb3a740d24e0f2f2d78e083c2052c87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Tue, 27 Oct 2026 11:51:45 +0500 Subject: [PATCH 6/8] =?UTF-8?q?feat(ct):=20=D0=BF=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B2=D1=89=D0=B8=D0=BA=D0=B0=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B0=D1=8E=D1=82=20=D0=B8=D0=B7=20=D1=81=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D1=87=D0=B8=D0=BD=D0=BE=D0=B9=20=D0=B8=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B3=D1=83=D1=82=20=D0=B2=D0=B5=D1=80=D0=BD=D1=83=D1=82=D1=8C?= =?UTF-8?q?=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше крестик на Шаге 3 удалял поставщика вместе с ценами, а «Сохранить» стирал его офферы в SP — случайно удалённого было не вернуть. Теперь крестик спрашивает обязательную причину и скрывает колонку: таблица, итоги, рекомендация, ИИ и Adata считаются без него, выбор с исключённого снимается. Цены остаются в правках и сессии, «Сохранить» пишет их в «Исключённые предложения», при открытии СТ они подтягиваются. Под таблицей — список исключённых с кнопкой «Вернуть»; в режиме просмотра скрыт константой. Повторная загрузка КП на Шаге 2 исключение не снимает. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../forms/ct/components/CtCreateForm.tsx | 27 +++- .../Step2CommercialOffers/step2Persist.ts | 10 +- .../Step2CommercialOffers/useKpProcessing.ts | 3 +- .../Step3ComparisonTable/ComparisonMatrix.tsx | 22 ++- .../ExcludeSupplierDialog.tsx | 43 +++++ .../ExcludedSuppliersList.tsx | 37 +++++ .../Step3ComparisonTable.module.scss | 5 + .../Step3ComparisonTable.tsx | 18 +++ .../Step3ComparisonTable/matrixHelpers.ts | 43 ++++- .../Step3ComparisonTable/useStep3Matrix.ts | 150 +++++++++++------- .../components/utils/excludedColumns.test.ts | 20 ++- .../ct/components/utils/excludedColumns.ts | 10 ++ 12 files changed, 317 insertions(+), 71 deletions(-) create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ExcludeSupplierDialog.tsx create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ExcludedSuppliersList.tsx diff --git a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx index 60869e7..653a1df 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx @@ -11,6 +11,7 @@ import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTabl import { Step4Approval } from './Step4Approval/Step4Approval'; import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } 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'; @@ -82,6 +83,12 @@ const CtCreateForm: React.FC = ({ 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 => { @@ -103,7 +110,7 @@ const CtCreateForm: React.FC = ({ 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); @@ -154,6 +161,15 @@ const CtCreateForm: React.FC = ({ 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; @@ -176,8 +192,11 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, ...(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: withNoOfferSuppliers( + 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 } : {}), ...(uploadedKpFiles.length > 0 ? { uploadedKpFiles } : {}), ...(Object.keys(step3DeliveryKm).length > 0 ? { step3DeliveryKm } : {}), @@ -196,7 +215,7 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, // Автор открыл свою СТ — открываем шаги, до которых она уже дошла (позиции → 2, КП → 3, маршрут → 4). ensureHighestStep(reachableStep({ 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, })); if (ct.status === 'approved' || ct.status === 'rejected') { diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts index fbbdb7b..2ff4b8b 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/step2Persist.ts @@ -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 ({ id: s.localId, @@ -94,7 +96,11 @@ export async function persistStep2(args: PersistStep2Args): Promise { 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) }; }) diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts index 4244e7e..ae90a69 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts @@ -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]; diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ComparisonMatrix.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ComparisonMatrix.tsx index 930a10a..04743b7 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ComparisonMatrix.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ComparisonMatrix.tsx @@ -7,6 +7,7 @@ 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 styles from './Step3ComparisonTable.module.scss'; const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во @@ -26,9 +27,13 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat const { isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId, supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers, - setSelectedSupplierId, handleDeleteSupplier, handleStatusChange, handleNameColResizeMouseDown, + setSelectedSupplierId, handleExcludeSupplier, handleStatusChange, handleNameColResizeMouseDown, } = matrix; + // Колонка, для которой открыто окно «Исключить из сравнения». + const [excludingSupplierId, setExcludingSupplierId] = React.useState(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); // Высота row1 (шапка поставщика) переменная (теги валют, выбор поставщика, состояние Adata), @@ -115,8 +120,8 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat ) : ( @@ -257,6 +262,17 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat + + {excludingSupplier && ( + { + handleExcludeSupplier(excludingSupplier.id, reason); + setExcludingSupplierId(undefined); + }} + onCancel={() => setExcludingSupplierId(undefined)} + /> + )} ); } diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ExcludeSupplierDialog.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ExcludeSupplierDialog.tsx new file mode 100644 index 0000000..424c555 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ExcludeSupplierDialog.tsx @@ -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 = ({ supplierName, onConfirm, onCancel }) => { + const [reason, setReason] = useState(''); + const isReasonEmpty = reason.trim().length === 0; + + return ( + + + + + } + > +