Compare commits

21 Commits

Author SHA1 Message Date
gorbenko.pavel 06dff47f70 Реестр пакетов: организация tansu, адреса по https 2026-10-29 12:22:31 +00:00
gorbenko.pavel 842b9ad678 Обновлён @tnsu/ui-kit до 0.14.0, реестр @tnsu — организация Tansu
Все версии ui-kit скопированы в публичный реестр организации Tansu (контрольные суммы совпадают),
токен для установки больше не нужен. Сборка проекта проверена.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 14:48:01 +05:00
39 changed files with 1202 additions and 154 deletions
+1 -1
View File
@@ -1 +1 @@
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/ @tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
+31
View File
@@ -7,8 +7,39 @@
## [Не выпущено] ## [Не выпущено]
### Добавлено
- СТ ОМТС, Шаги 2 и 3: кнопка «Сохранить черновик» — записывает шаг в SharePoint без проверок
и оставляет на шаге, чтобы правки не пропали при закрытии вкладки. (#8)
- СТ ОМТС, Шаг 3: незаполненное подсвечивается сразу. Красная рамка — то, без чего дальше не
пустит (победитель, километраж, условия оплаты); бледная заливка — пустая цена. (#9)
- СТ ОМТС, Шаг 3: у логистики и доп. расходов можно вписать свою единицу измерения — у логистики
вместо «рейс» может быть «тонна», «машина» и т.п. (#11)
- СТ ОМТС, Шаг 3: добавить потенциального поставщика проще найти — блок над таблицей с заголовком
и активной кнопкой, и кнопка «+ Добавить поставщика» прямо в шапке таблицы. (#12)
### Изменено ### Изменено
- СТ ОМТС, Шаг 3: крестик у поставщика не удаляет его, а исключает из сравнения с обязательной - СТ ОМТС, Шаг 3: крестик у поставщика не удаляет его, а исключает из сравнения с обязательной
причиной. Цены сохраняются, под таблицей — список исключённых с кнопкой «Вернуть». Согласующим причиной. Цены сохраняются, под таблицей — список исключённых с кнопкой «Вернуть». Согласующим
список пока не показывается. (#1) список пока не показывается. (#1)
- СТ ОМТС, Шаг 1: поиск по номеру потребности идёт на сервере — по части номера, в пределах
периода. Старую потребность можно найти, не загружая весь период. (#5)
- СТ ОМТС, Шаг 3: без выбранного поставщика-победителя «Сохранить и продолжить» не пускает
дальше. (#7)
- СТ ОМТС, Шаг 3: условия оплаты обязательны для «Сохранить и продолжить» — раньше были помечены
обязательными, но сохранять не мешали. (#9)
### Исправлено
- СТ ОМТС: у поставщика, добавленного на Шаге 3 вручную, не видно служебного файла `.txt` —
ни в списках КП на Шагах 2 и 3, ни в подписи колонки, ни в пакете для INCORE. (#10)
- СТ ОМТС, Шаг 3: поставщик, добавленный на шаге и сохранённый черновиком, не задваивается при
открытии СТ из реестра. (#8)
- СТ ОМТС, Шаг 1: позиции, выбранные до поиска или смены периода, больше не пропадают при
«Сохранить и продолжить». (#5)
- СТ ОМТС: цена, скопированная с пробелами между разрядами («100 000»), считается целиком, а не
до первого пробела. То же для цены в ₸, количества, километража и курса валюты. (#6)
- СТ ОМТС, Шаг 3: выбор победителя снимается повторным нажатием на «Выбран». При открытии СТ
в другом браузере выбранный победитель сохраняется. (#7)
+2 -2
View File
@@ -269,7 +269,7 @@ Info "Подключаем @tnsu/ui-kit..."
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить). # .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём). # Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
$npmrc = @' $npmrc = @'
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/ @tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
'@ '@
$npmrc | Set-Content -Encoding UTF8 ".npmrc" $npmrc | Set-Content -Encoding UTF8 ".npmrc"
Success ".npmrc создан (адрес registry @tnsu)." Success ".npmrc создан (адрес registry @tnsu)."
@@ -282,7 +282,7 @@ try {
} catch { } catch {
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc." Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:" Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
Warn " //git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ТВОЙ_ТОКЕН" Warn " //git.tnsu.kz/api/packages/tansu/npm/:_authToken=ТВОЙ_ТОКЕН"
Warn "Потом выполни: npm install @tnsu/ui-kit" Warn "Потом выполни: npm install @tnsu/ui-kit"
} }
+4 -4
View File
@@ -15,7 +15,7 @@
"@microsoft/sp-office-ui-fabric-core": "1.22.1", "@microsoft/sp-office-ui-fabric-core": "1.22.1",
"@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@tnsu/ui-kit": "^0.11.0", "@tnsu/ui-kit": "^0.14.0",
"ncalayer-js-client": "^1.6.0", "ncalayer-js-client": "^1.6.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
@@ -6269,9 +6269,9 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@tnsu/ui-kit": { "node_modules/@tnsu/ui-kit": {
"version": "0.11.0", "version": "0.14.0",
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.11.0/ui-kit-0.11.0.tgz", "resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz",
"integrity": "sha512-Yv4hUmCsDNvGMj32ygNhBZiurhe5lU0IHvWSZMkG15h+xmCBmsTzgrItmKenKkKiOM2R4cjOo4D2W8SsTNHleQ==", "integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
+1 -1
View File
@@ -19,7 +19,7 @@
"@microsoft/sp-office-ui-fabric-core": "1.22.1", "@microsoft/sp-office-ui-fabric-core": "1.22.1",
"@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@tnsu/ui-kit": "^0.11.0", "@tnsu/ui-kit": "^0.14.0",
"ncalayer-js-client": "^1.6.0", "ncalayer-js-client": "^1.6.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
@@ -9,7 +9,7 @@ import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSe
import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers'; import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers';
import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable'; import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable';
import { Step4Approval } from './Step4Approval/Step4Approval'; import { Step4Approval } from './Step4Approval/Step4Approval';
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns'; import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findWinnerColumnId, withoutSessionExtras } from './utils/supplierColumns';
import { buildOffersFromSpRecords } from './utils/spOffers'; import { buildOffersFromSpRecords } from './utils/spOffers';
import { excludedItemsToFormData } from './utils/excludedColumns'; import { excludedItemsToFormData } from './utils/excludedColumns';
import { isUserInApprovalChain } from './utils/approvalAccess'; import { isUserInApprovalChain } from './utils/approvalAccess';
@@ -122,16 +122,22 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
// КП в разных валютах. Используются, только когда в сессии колонок нет. // КП в разных валютах. Используются, только когда в сессии колонок нет.
const currencyCodeOf = (valutaSpId?: number): string => const currencyCodeOf = (valutaSpId?: number): string =>
valutaSpId ? (rateById.get(valutaSpId)?.code ?? 'KZT') : 'KZT'; valutaSpId ? (rateById.get(valutaSpId)?.code ?? 'KZT') : 'KZT';
const spSuppliers = buildColumnsFromSpOffers(rawOffers.map(o => ({ // Поставщики Шага 3 из сессии (после «Сохранить черновик») уже лежат в SP — их колонки из SP
// не дублируем.
const sessionExtras = formData.step3ExtraSuppliers ?? [];
const spSuppliers = withoutSessionExtras(buildColumnsFromSpOffers(rawOffers.map(o => ({
supplierSpId: o.supplierSpId, supplierSpId: o.supplierSpId,
supplierName: o.supplierName, supplierName: o.supplierName,
offerFileSpId: o.offerFileSpId, offerFileSpId: o.offerFileSpId,
currencyCode: currencyCodeOf(o.valutaSpId), currencyCode: currencyCodeOf(o.valutaSpId),
}))); }))), sessionExtras);
const supplierMap = new Map<number, string>(); const supplierMap = new Map<number, string>();
rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); }); rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); });
// Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из SP. // Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из SP.
const visibleColumns = formData.suppliers.length > 0 ? formData.suppliers : spSuppliers; // Поставщики Шага 3 — тоже колонки: их офферы из SP должны попасть к ним, а не в убранную колонку.
const visibleColumns = formData.suppliers.length > 0
? formData.suppliers
: [...spSuppliers, ...sessionExtras.map(es => ({ id: es.id, spId: es.spId, name: es.name, hasOffer: true }))];
const columnIdOf = (supplierSpId: number, offerFileSpId?: number): string => const columnIdOf = (supplierSpId: number, offerFileSpId?: number): string =>
findColumnForRecord(visibleColumns, supplierSpId, offerFileSpId)?.id findColumnForRecord(visibleColumns, supplierSpId, offerFileSpId)?.id
?? buildSupplierColumnId(supplierSpId, offerFileSpId); ?? buildSupplierColumnId(supplierSpId, offerFileSpId);
@@ -184,18 +190,27 @@ const CtCreateForm: React.FC<ICtCreateFormProps> = ({ context, userDisplayName,
} }
} }
// Preserve session suppliers and offers — SP data may be incomplete or stale vs. unsaved edits.
// Only use SP-derived data when session has none, or in review mode (always fresh).
const suppliers = withNoOfferSuppliers(
formData.suppliers.length === 0 ? [...spSuppliers, ...excluded.suppliers] : formData.suppliers,
noOfferFromSp,
);
// Победитель обязателен для «Сохранить» на Шаге 3: без сессии берём его из шапки СТ, иначе
// в другом браузере пришлось бы выбирать заново. Только когда сессии нет (колонки из SP):
// в сессии «не выбран» может быть осознанно снятым выбором — его SP не перебивает.
const winnerColumnId = formData.suppliers.length === 0
? findWinnerColumnId(suppliers, ct.winSupplierSpId)
: undefined;
updateFormData({ updateFormData({
ct, ct,
approvalMatrix, approvalMatrix,
// Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID), // Preserve session selectedPositions — overwriting changes pos.id (Запросы ID → Позиции СТ ID),
// which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3. // which breaks edits keys built from buildOfferKey(sup.id, pos.id) in Step 3.
...(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. suppliers,
// Only use SP-derived data when session has none, or in review mode (always fresh). ...(winnerColumnId !== undefined ? { recommendedSupplierId: winnerColumnId } : {}),
suppliers: withNoOfferSuppliers(
formData.suppliers.length === 0 ? [...spSuppliers, ...excluded.suppliers] : formData.suppliers,
noOfferFromSp,
),
...(preferSp || formData.offers.length === 0 ? { offers: [...offers, ...excluded.offers] } : {}), ...(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 } : {}),
@@ -0,0 +1,13 @@
@import '../common/theme';
.root {
display: flex;
align-items: center;
gap: 12px;
}
.savedAt {
font-size: 12px;
color: $muted;
white-space: nowrap;
}
@@ -0,0 +1,35 @@
import * as React from 'react';
import { Button, Spinner } from '@tnsu/ui-kit';
import styles from './SaveDraftButton.module.scss';
interface Props {
onSave: () => void;
isSaving: boolean;
disabled?: boolean;
// Подсказка, почему кнопка недоступна.
disabledReason?: string;
// Время последнего сохранения черновика в этой вкладке.
savedAt?: Date;
}
const TIME_FORMAT: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
// «Сохранить черновик» на шагах СТ: записать недоделанный шаг в SharePoint без проверок и остаться
// на шаге — чтобы правки не пропали при закрытии вкладки и были видны на другом компьютере.
export const SaveDraftButton: React.FC<Props> = ({ onSave, isSaving, disabled = false, disabledReason, savedAt }) => (
<div className={styles.root}>
{savedAt && (
<span className={styles.savedAt}>
Черновик сохранён в {savedAt.toLocaleTimeString('ru-RU', TIME_FORMAT)}
</span>
)}
<Button
variant="secondary"
onClick={onSave}
disabled={disabled || isSaving}
title={disabled ? disabledReason : 'Сохранить в SharePoint и остаться на этом шаге'}
>
{isSaving ? <Spinner size="small" /> : 'Сохранить черновик'}
</Button>
</div>
);
@@ -5,10 +5,13 @@ import { Request, Position, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService'; import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm'; import { CTFormData } from '../hooks/useCtForm';
import { useRequests } from '../hooks/useRequests'; import { useRequests } from '../hooks/useRequests';
import { useDebouncedValue } from '../hooks/useDebouncedValue';
import { RequestsTable } from './RequestsTable'; import { RequestsTable } from './RequestsTable';
import { import {
KnownPosition,
buildPinnedRequests, buildPinnedRequests,
countSelectedInRequest, countSelectedInRequest,
mergeKnownPositions,
planStep1Save, planStep1Save,
resolveProject, resolveProject,
resolveSelectedPositions, resolveSelectedPositions,
@@ -27,6 +30,8 @@ interface Props {
const DEFAULT_DAYS = 5; const DEFAULT_DAYS = 5;
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
// Пауза в наборе номера, после которой поиск уходит на сервер.
const SEARCH_DEBOUNCE_MS = 500;
export const Step1PositionSelection: React.FC<Props> = ({ export const Step1PositionSelection: React.FC<Props> = ({
service, service,
@@ -44,7 +49,7 @@ export const Step1PositionSelection: React.FC<Props> = ({
const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>( const [selectedPositionIds, setSelectedPositionIds] = useState<Set<string>>(
new Set(formData.selectedPositions.map(selectionKey)) new Set(formData.selectedPositions.map(selectionKey))
); );
const [positionMap, setPositionMap] = useState<Map<string, { position: Position; request: Request }>>(new Map()); const [positionMap, setPositionMap] = useState<Map<string, KnownPosition>>(new Map());
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined); const [saveError, setSaveError] = useState<string | undefined>(undefined);
@@ -55,8 +60,11 @@ export const Step1PositionSelection: React.FC<Props> = ({
[formData.selectedPositions, ctProjectName], [formData.selectedPositions, ctProjectName],
); );
// Поиск по номеру — на сервере (часть номера, в пределах периода): иначе ради одной старой
// потребности приходилось грузить весь период.
const serverSearch = useDebouncedValue(searchQuery.trim(), SEARCH_DEBOUNCE_MS);
const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } = const { requests, isLoading, error: loadError, reload, loadPositions, loadingPositionIds } =
useRequests(service, days, isReadOnly, pinnedRequests); useRequests(service, days, serverSearch, isReadOnly, pinnedRequests);
const readOnlyRequests = useMemo(() => { const readOnlyRequests = useMemo(() => {
if (!isReadOnly) return []; if (!isReadOnly) return [];
@@ -80,6 +88,8 @@ export const Step1PositionSelection: React.FC<Props> = ({
}, [isReadOnly, formData.selectedPositions, formData.selectedProject]); }, [isReadOnly, formData.selectedPositions, formData.selectedProject]);
const filteredRequests = useMemo(() => { const filteredRequests = useMemo(() => {
// Фильтр в браузере — пока набирают номер (до ответа сервера) и для закреплённых потребностей СТ,
// которые добавляются к выдаче без учёта поиска.
const q = searchQuery.trim().toLowerCase(); const q = searchQuery.trim().toLowerCase();
let filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests; let filtered = q ? requests.filter(r => r.requestNumber.toLowerCase().includes(q)) : requests;
// Режим «Только выбранные» — оставляем заявки, где есть хотя бы одна выбранная позиция, // Режим «Только выбранные» — оставляем заявки, где есть хотя бы одна выбранная позиция,
@@ -97,15 +107,10 @@ export const Step1PositionSelection: React.FC<Props> = ({
const safePage = Math.min(currentPage, totalPages); const safePage = Math.min(currentPage, totalPages);
const pagedRequests = isReadOnly ? readOnlyRequests : filteredRequests.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE); const pagedRequests = isReadOnly ? readOnlyRequests : filteredRequests.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
// Build position→request map for resolving selection metadata // Позиция → потребность для выбора и «Сохранить». Копим, а не пересобираем: поиск и смена периода
// меняют список, а выбранные раньше позиции должны дойти до сохранения.
useEffect(() => { useEffect(() => {
const map = new Map<string, { position: Position; request: Request }>(); setPositionMap(prev => mergeKnownPositions(prev, requests));
requests.forEach(req => {
req.positions.forEach(pos => {
map.set(pos.id, { position: pos, request: req });
});
});
setPositionMap(map);
}, [requests]); }, [requests]);
const project = resolveProject(formData.selectedProject, ctProjectName, selectedPositionIds.size > 0); const project = resolveProject(formData.selectedProject, ctProjectName, selectedPositionIds.size > 0);
@@ -1,12 +1,14 @@
import { import {
KnownPosition,
buildPinnedRequests, buildPinnedRequests,
countSelectedInRequest, countSelectedInRequest,
mergeKnownPositions,
planStep1Save, planStep1Save,
resolveProject, resolveProject,
resolveSelectedPositions, resolveSelectedPositions,
selectionKey, selectionKey,
} from './step1Plan'; } from './step1Plan';
import { Position } from '../types/domain'; import { Position, Request } from '../types/domain';
function makePosition(id: string, overrides: Partial<Position> = {}): Position { function makePosition(id: string, overrides: Partial<Position> = {}): Position {
return { return {
@@ -75,6 +77,46 @@ describe('countSelectedInRequest', () => {
}); });
}); });
describe('mergeKnownPositions', () => {
function makeRequest(id: string, positionIds: string[]): Request {
return {
id, requestNumber: `081/26-04/${id}`, project: { id: 'Проект', name: 'Проект', code: '' },
discipline: '', workType: '', organization: '', createdAt: '',
positionsCount: positionIds.length, positions: positionIds.map(p => makePosition(p)),
};
}
// Выбрали позицию → нашли другую потребность поиском на сервере (первой в выдаче уже нет) →
// «Сохранить» должно сохранить обе. Раньше карта позиций пересобиралась из текущего списка,
// и выбор до поиска молча пропадал (так же — при смене периода).
it('позиции прежнего списка остаются после загрузки нового', () => {
const beforeSearch = mergeKnownPositions(new Map(), [makeRequest('req1', ['req1__0'])]);
const afterSearch = mergeKnownPositions(beforeSearch, [makeRequest('req2', ['req2__0'])]);
expect(Array.from(afterSearch.keys()).sort()).toEqual(['req1__0', 'req2__0']);
expect(afterSearch.get('req1__0')?.request.id).toBe('req1');
});
it('повторная загрузка той же потребности обновляет позицию', () => {
const first = mergeKnownPositions(new Map(), [makeRequest('req1', ['req1__0'])]);
const reloaded = makeRequest('req1', ['req1__0']);
reloaded.positions[0].quantity = 5;
const result = mergeKnownPositions(first, [reloaded]);
expect(result.get('req1__0')?.position.quantity).toBe(5);
});
it('не меняет переданную карту', () => {
const known = new Map<string, KnownPosition>();
mergeKnownPositions(known, [makeRequest('req1', ['req1__0'])]);
expect(known.size).toBe(0);
});
});
describe('resolveSelectedPositions', () => { describe('resolveSelectedPositions', () => {
// Вернулись на шаг 1: потребность не раскрыта — выбранные позиции берём из сохранённых, // Вернулись на шаг 1: потребность не раскрыта — выбранные позиции берём из сохранённых,
// иначе «Сохранить» неактивна, хотя выбор есть. // иначе «Сохранить» неактивна, хотя выбор есть.
@@ -49,6 +49,25 @@ export function countSelectedInRequest(selectedIds: Set<string>, requestId: stri
return count; return count;
} }
export interface KnownPosition {
position: Position;
request: Request;
}
// Все позиции, которые показывались на шаге 1, — копятся, а не заменяются при каждой загрузке.
// Список потребностей меняется (поиск по номеру на сервере, другой период), а выбор остаётся:
// позиция, выбранная до поиска, должна дойти до «Сохранить», хоть её потребности и нет в выдаче.
export function mergeKnownPositions(
known: Map<string, KnownPosition>,
requests: Request[],
): Map<string, KnownPosition> {
const next = new Map(known);
requests.forEach(request => {
request.positions.forEach(position => next.set(position.id, { position, request }));
});
return next;
}
// Выбранные позиции: загруженная версия, а если потребность не раскрыта — сохранённая в форме. // Выбранные позиции: загруженная версия, а если потребность не раскрыта — сохранённая в форме.
// Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна. // Без этого после возврата на шаг 1 выбор есть, а «Сохранить» неактивна.
export function resolveSelectedPositions( export function resolveSelectedPositions(
@@ -2,6 +2,7 @@ import * as React from 'react';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Button, Alert, TextInput, IconButton, Spinner } from '@tnsu/ui-kit'; import { Button, Alert, TextInput, IconButton, Spinner } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain'; import { CurrencyRate } from '../types/domain';
import { parseNumberInput } from '../utils/parseNumberInput';
import styles from './CurrencyRatesForm.module.scss'; import styles from './CurrencyRatesForm.module.scss';
interface Props { interface Props {
@@ -58,9 +59,9 @@ export const CurrencyRatesForm: React.FC<Props> = ({ rates, onChange, readOnly =
const updateRate = useCallback( const updateRate = useCallback(
(code: string, value: string) => { (code: string, value: string) => {
const parsed = parseFloat(value.replace(',', '.')); const parsed = parseNumberInput(value);
onChange( onChange(
rates.map(r => (r.code === code ? { ...r, rateToKzt: isNaN(parsed) ? 0 : parsed } : r)) rates.map(r => (r.code === code ? { ...r, rateToKzt: parsed ?? 0 } : r))
); );
}, },
[rates, onChange] [rates, onChange]
@@ -13,6 +13,8 @@ import { SuppliersWithoutKp } from './SuppliersWithoutKp';
import { UploadedKpList } from './UploadedKpList'; import { UploadedKpList } from './UploadedKpList';
import { removeKpFileFromComparison } from './kpRemoval'; import { removeKpFileFromComparison } from './kpRemoval';
import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency'; import { applySupplierCurrency, getSupplierCurrency } from './kpCurrency';
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
import { withoutPlaceholders } from '../utils/placeholderKpFile';
import styles from './Step2CommercialOffers.module.scss'; import styles from './Step2CommercialOffers.module.scss';
interface Props { interface Props {
@@ -50,6 +52,7 @@ export const Step2CommercialOffers: React.FC<Props> = ({
); );
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined); const [saveError, setSaveError] = useState<string | undefined>(undefined);
const [draftSavedAt, setDraftSavedAt] = useState<Date | undefined>(undefined);
const kp = useKpProcessing(service, formData, onSave); const kp = useKpProcessing(service, formData, onSave);
@@ -136,12 +139,18 @@ export const Step2CommercialOffers: React.FC<Props> = ({
kp.handleFileRemove(localId); kp.handleFileRemove(localId);
}; };
const handleSave = async (): Promise<void> => { // 'draft' — записать шаг в SP и остаться: распознанные КП попадают в SP только здесь, и без
// черновика пропадали при закрытии вкладки.
const handleSave = async (mode: 'next' | 'draft' = 'next'): Promise<void> => {
setIsSaving(true); setIsSaving(true);
setSaveError(undefined); setSaveError(undefined);
try { try {
const patch = await persistStep2({ service, formData, kpFiles: kp.kpFiles, currencyRates, noOfferSuppliers }); const patch = await persistStep2({ service, formData, kpFiles: kp.kpFiles, currencyRates, noOfferSuppliers });
onSave(patch); onSave(patch);
if (mode === 'draft') {
setDraftSavedAt(new Date());
return;
}
onStepComplete(2); onStepComplete(2);
onNext(); onNext();
} catch (err) { } catch (err) {
@@ -152,6 +161,8 @@ export const Step2CommercialOffers: React.FC<Props> = ({
}; };
const canProceed = !isSaving && currencyRates.every(r => r.rateToKzt > 0); const canProceed = !isSaving && currencyRates.every(r => r.rateToKzt > 0);
// Заглушки поставщиков, добавленных на Шаге 3 вручную, — не КП: в списке не показываем.
const realKpFiles = withoutPlaceholders(formData.uploadedKpFiles ?? []);
const currencyOptions = ['KZT', ...currencyRates.filter(r => r.code !== 'KZT').map(r => r.code)]; const currencyOptions = ['KZT', ...currencyRates.filter(r => r.code !== 'KZT').map(r => r.code)];
const uploadedFiles: UploadedFile[] = kp.kpFiles.map(f => ({ const uploadedFiles: UploadedFile[] = kp.kpFiles.map(f => ({
@@ -193,9 +204,9 @@ export const Step2CommercialOffers: React.FC<Props> = ({
<SuppliersWithoutKp items={noOfferSuppliers} onChange={handleNoOfferChange} disabled={isReadOnly} /> <SuppliersWithoutKp items={noOfferSuppliers} onChange={handleNoOfferChange} disabled={isReadOnly} />
{!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && ( {!isRestrictedView && realKpFiles.length > 0 && (
<UploadedKpList <UploadedKpList
files={formData.uploadedKpFiles} files={realKpFiles}
service={service} service={service}
currencyOptions={currencyOptions} currencyOptions={currencyOptions}
getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)} getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)}
@@ -212,9 +223,19 @@ export const Step2CommercialOffers: React.FC<Props> = ({
<div className={styles.footerRight}> <div className={styles.footerRight}>
{saveError && <Alert variant="err">{saveError}</Alert>} {saveError && <Alert variant="err">{saveError}</Alert>}
{canEdit && !isReadOnly && ( {canEdit && !isReadOnly && (
<Button variant="accent" onClick={handleSave} disabled={!canProceed}> <>
<SaveDraftButton
onSave={() => { handleSave('draft').catch(() => undefined /* ошибку показывает handleSave */); }}
isSaving={isSaving}
// Курсы пишутся в общий список «Курс Валют» — нулевой курс туда писать нельзя.
disabled={!canProceed}
disabledReason="Укажите курсы всех валют"
savedAt={draftSavedAt}
/>
<Button variant="accent" onClick={() => { handleSave().catch(() => undefined /* ошибку показывает handleSave */); }} disabled={!canProceed}>
{isSaving ? <Spinner size="small" /> : 'Сохранить и продолжить'} {isSaving ? <Spinner size="small" /> : 'Сохранить и продолжить'}
</Button> </Button>
</>
)} )}
</div> </div>
</div> </div>
@@ -59,6 +59,8 @@ export function AddExtraRow({ matrix, isRestrictedView, colSpan }: Props): JSX.E
{suppliersWithOffer.map(s => ( {suppliersWithOffer.map(s => (
<td key={s.id} colSpan={getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView)} /> <td key={s.id} colSpan={getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView)} />
))} ))}
{/* Под колонкой «+ Добавить поставщика» (видна там же, где эта строка, — не в режиме просмотра). */}
<td className={styles.addSupplierFillerCell} />
</tr> </tr>
); );
} }
@@ -0,0 +1,69 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, Dialog, Icon, TextInput } from '@tnsu/ui-kit';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
currencyOptions: string[];
onAddManual: (name: string, currency: string) => void;
onOpenDatabase: () => void;
onClose: () => void;
}
const DEFAULT_CURRENCY = 'KZT';
// Окно кнопки «+ Добавить поставщика» в шапке таблицы: вручную (название и валюта) или из базы
// поставщиков. Добавление — те же функции, что у блока над таблицей.
export const AddSupplierDialog: React.FC<Props> = ({ currencyOptions, onAddManual, onOpenDatabase, onClose }) => {
const [name, setName] = useState('');
const [currency, setCurrency] = useState(DEFAULT_CURRENCY);
const canAdd = name.trim().length > 0;
const handleAdd = (): void => {
if (!canAdd) return;
onAddManual(name, currency);
onClose();
};
return (
<Dialog
open
title="Добавить потенциального поставщика"
subText="Поставщик, у которого нет загруженного КП: цены вносятся в таблицу вручную."
onDismiss={onClose}
footer={<Button variant="secondary" onClick={onClose}>Отмена</Button>}
>
<div className={styles.addSupplierDialogBody}>
<div className={styles.addSupplierRow}>
<div className={styles.addInput}>
<TextInput
value={name}
onChange={setName}
placeholder="Наименование поставщика"
ariaLabel="Наименование поставщика"
autoFocus
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); }}
/>
</div>
<select
className={styles.currencySelect}
value={currency}
onChange={e => setCurrency(e.target.value)}
aria-label="Валюта поставщика"
>
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<Button variant="accent" onClick={handleAdd} disabled={!canAdd}>
<Icon name="plus" size="sm" />
Добавить
</Button>
</div>
<div className={styles.addSupplierDivider}>или</div>
<Button variant="secondary" onClick={() => { onClose(); onOpenDatabase(); }}>
<Icon name="building" size="sm" />
Выбрать из базы поставщиков
</Button>
</div>
</Dialog>
);
};
@@ -0,0 +1,82 @@
import * as React from 'react';
import { useState } from 'react';
import { Button, Icon, TextInput } from '@tnsu/ui-kit';
import styles from './Step3ComparisonTable.module.scss';
interface Props {
name: string;
currency: string;
currencyOptions: string[];
onNameChange: (value: string) => void;
onCurrencyChange: (value: string) => void;
onAdd: () => void;
onOpenDatabase: () => void;
}
const NAME_INPUT_ID = 'step3-new-supplier-name';
// Блок «Потенциальные поставщики» над таблицей. Раньше это были поле, выпадашка и серая кнопка без
// подписи — их не замечали: заблокированная кнопка читалась как «не работает». Теперь у блока есть
// заголовок, кнопка не блокируется: с пустым названием подсказывает, что ввести.
export const AddSupplierToolbar: React.FC<Props> = ({
name, currency, currencyOptions, onNameChange, onCurrencyChange, onAdd, onOpenDatabase,
}) => {
const [isNameMissing, setIsNameMissing] = useState(false);
const handleAdd = (): void => {
if (!name.trim()) {
setIsNameMissing(true);
// Поле — в ui-kit, ref он не отдаёт; id задаём сами.
document.getElementById(NAME_INPUT_ID)?.focus();
return;
}
setIsNameMissing(false);
onAdd();
};
const handleNameChange = (value: string): void => {
if (value.trim()) setIsNameMissing(false);
onNameChange(value);
};
return (
<section className={styles.addSupplierBlock}>
<div className={styles.addSupplierHeading}>
<h4 className={styles.addSupplierTitle}>Потенциальные поставщики</h4>
<span className={styles.addSupplierHint}>
Добавьте поставщика, у которого нет загруженного КП, — вручную или из базы поставщиков
</span>
</div>
<div className={styles.addSupplierRow}>
<div className={styles.addInput}>
<TextInput
id={NAME_INPUT_ID}
placeholder="Наименование поставщика"
value={name}
onChange={handleNameChange}
ariaLabel="Наименование нового потенциального поставщика"
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); }}
/>
</div>
<select
className={styles.currencySelect}
value={currency}
onChange={e => onCurrencyChange(e.target.value)}
aria-label="Валюта нового потенциального поставщика"
>
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<Button variant="accent" onClick={handleAdd}>
<Icon name="plus" size="sm" />
Добавить
</Button>
<span className={styles.addSupplierOr}>или</span>
<Button variant="secondary" onClick={onOpenDatabase}>
<Icon name="building" size="sm" />
Из базы поставщиков
</Button>
</div>
{isNameMissing && <span className={styles.addSupplierError}>Введите наименование поставщика</span>}
</section>
);
};
@@ -8,6 +8,7 @@ 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 { ExcludeSupplierDialog } from './ExcludeSupplierDialog';
import { toggleWinner } from './step3Validation';
import styles from './Step3ComparisonTable.module.scss'; import styles from './Step3ComparisonTable.module.scss';
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
@@ -18,12 +19,14 @@ interface Props {
isRestrictedView: boolean; isRestrictedView: boolean;
adata: UseAdataCheckResult; adata: UseAdataCheckResult;
recommendedId?: string; // победитель формулы 80% цена / 20% условия оплаты (useRecommendedPick) recommendedId?: string; // победитель формулы 80% цена / 20% условия оплаты (useRecommendedPick)
// Кнопка «+ Добавить поставщика» в последней колонке шапки.
onAddSupplierClick: () => void;
} }
// Таблица сравнения целиком: шапка (фикс-колонки + заголовки поставщиков) + строки позиций // Таблица сравнения целиком: шапка (фикс-колонки + заголовки поставщиков) + строки позиций
// (MatrixRowView/ExtraPositionRow) + итоговые строки + строка добавления (AddExtraRow). // (MatrixRowView/ExtraPositionRow) + итоговые строки + строка добавления (AddExtraRow).
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix). // Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adata, recommendedId }: Props): JSX.Element { export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adata, recommendedId, onAddSupplierClick }: Props): JSX.Element {
const { const {
isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId, isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId,
supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers, supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers,
@@ -33,8 +36,15 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
// Колонка, для которой открыто окно «Исключить из сравнения». // Колонка, для которой открыто окно «Исключить из сравнения».
const [excludingSupplierId, setExcludingSupplierId] = React.useState<string | undefined>(undefined); const [excludingSupplierId, setExcludingSupplierId] = React.useState<string | undefined>(undefined);
const excludingSupplier = suppliersWithOffer.find(s => s.id === excludingSupplierId); const excludingSupplier = suppliersWithOffer.find(s => s.id === excludingSupplierId);
// Как в проверке перед сохранением (getStep3SaveError): выбор на исключённом — не победитель.
const hasWinner = suppliersWithOffer.some(s => s.id === selectedSupplierId);
const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0); // Последняя колонка «+ Добавить поставщика» — там, где ищут, как добавить колонку. Во всех строках
// под ней пустая ячейка (addSupplierFillerCell), иначе строки таблицы были бы разной длины.
const showAddColumn = !isReadOnly;
const totalColSpan = FIXED_COLS
+ suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0)
+ (showAddColumn ? 1 : 0);
// Высота row1 (шапка поставщика) переменная (теги валют, выбор поставщика, состояние Adata), // Высота row1 (шапка поставщика) переменная (теги валют, выбор поставщика, состояние Adata),
// а row2 прибит sticky сразу под ней. Измеряем реальную высоту и отдаём в CSS-переменную, // а row2 прибит sticky сразу под ней. Измеряем реальную высоту и отдаём в CSS-переменную,
@@ -112,9 +122,15 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
{!isReadOnly ? ( {!isReadOnly ? (
<> <>
<button <button
className={[styles.selectSupplierBtn, isSel ? styles.selectSupplierBtnActive : ''].filter(Boolean).join(' ')} className={[
onClick={() => setSelectedSupplierId(s.id)} styles.selectSupplierBtn,
isSel ? styles.selectSupplierBtnActive : '',
// Победитель не выбран — красная рамка у всех «Выбрать»: без него дальше Шага 3 не пускает.
!hasWinner ? styles.selectSupplierBtnRequired : '',
].filter(Boolean).join(' ')}
onClick={() => setSelectedSupplierId(toggleWinner(selectedSupplierId, s.id))}
aria-pressed={isSel} aria-pressed={isSel}
title={isSel ? 'Снять выбор' : 'Выбрать победителем'}
> >
{isSel ? 'Выбран' : 'Выбрать'} {isSel ? 'Выбран' : 'Выбрать'}
</button> </button>
@@ -133,6 +149,13 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</th> </th>
); );
})} })}
{showAddColumn && (
<th rowSpan={2} className={styles.addSupplierHeaderCell}>
<button className={styles.addSupplierHeaderBtn} onClick={onAddSupplierClick} title="Добавить потенциального поставщика">
+ Добавить поставщика
</button>
</th>
)}
</tr> </tr>
{/* Row 2: sub-headers — 4 cols for KZT, 6 cols for foreign currency */} {/* Row 2: sub-headers — 4 cols for KZT, 6 cols for foreign currency */}
@@ -197,6 +220,7 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</React.Fragment> </React.Fragment>
); );
})} })}
{showAddColumn && <td className={styles.addSupplierFillerCell} />}
</tr> </tr>
{/* Доп. расходы positions — single row each (no bundle support needed) */} {/* Доп. расходы positions — single row each (no bundle support needed) */}
@@ -233,6 +257,7 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</React.Fragment> </React.Fragment>
); );
})} })}
{showAddColumn && <td className={styles.addSupplierFillerCell} />}
</tr> </tr>
)} )}
@@ -256,6 +281,7 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
</React.Fragment> </React.Fragment>
); );
})} })}
{showAddColumn && <td className={styles.addSupplierFillerCell} />}
</tr> </tr>
{/* Inline add-position row */} {/* Inline add-position row */}
@@ -5,6 +5,7 @@ import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix'; import { Step3Matrix } from './useStep3Matrix';
import { getUnitStatus } from '../utils/unitConversion'; import { getUnitStatus } from '../utils/unitConversion';
import { unitStatusClass } from './unitStatusStyles'; import { unitStatusClass } from './unitStatusStyles';
import { isPriceMissing } from './step3Validation';
import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers'; import { formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss'; import styles from './Step3ComparisonTable.module.scss';
@@ -24,7 +25,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
isReadOnly, edits, extraPositions, translateUnits, rawValues, deliveryKm, selectedSupplierId, nameColWidth, isReadOnly, edits, extraPositions, translateUnits, rawValues, deliveryKm, selectedSupplierId, nameColWidth,
suppliersWithOffer, supplierCurrencies, computedOffers, minTotalByPosition, suppliersWithOffer, supplierCurrencies, computedOffers, minTotalByPosition,
updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt, handleDeliveryKmChange, updateSupplierProductName, updateUnit, updateEdit, updatePriceKzt, handleDeliveryKmChange,
handleDeleteExtraPosition, handleDeleteExtraPosition, handleExtraPositionUnitChange,
} = matrix; } = matrix;
const ep = extraPositions.find(e => e.id === pos.id); const ep = extraPositions.find(e => e.id === pos.id);
@@ -46,7 +47,22 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
/> />
)} )}
</td> </td>
<td className={styles.unitCell}>{pos.unit}</td> {/* Единица строки — своя (у логистики по умолчанию «рейс»: бывает «тонна», «машина», «услуга»). */}
<td className={styles.unitCell}>
{!isReadOnly ? (
<div className={styles.offerInput}>
<TextInput
value={pos.unit}
onChange={val => handleExtraPositionUnitChange(pos.id, val)}
placeholder="ед. изм."
borderless
ariaLabel={`Ед. изм. строки ${pos.materialName}`}
/>
</div>
) : (
pos.unit
)}
</td>
<td className={styles.qtyCell}>—</td> <td className={styles.qtyCell}>—</td>
{suppliersWithOffer.map(s => { {suppliersWithOffer.map(s => {
const key = key0(s); const key = key0(s);
@@ -75,6 +91,10 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price; const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective; const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
const showInput = !isReadOnly && !translateUnits; const showInput = !isReadOnly && !translateUnits;
// Подсветка сразу, пока заполняют: пустая цена — заливкой (сохранять не мешает),
// пустой километраж логистики — рамкой (не пускает дальше Шага 3).
const priceMissingCls = !isReadOnly && isPriceMissing(price, item?.priceKztOverride) ? styles.missingPriceCell : '';
const kmMissingCls = !isReadOnly && deliveryKm[`${pos.id}::${s.id}`] === undefined ? styles.requiredMissingBox : '';
return ( return (
<React.Fragment key={s.id}> <React.Fragment key={s.id}>
@@ -115,16 +135,16 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
<TextInput <TextInput
value={rawValues[`${s.id}::${pos.id}::0::quantity`] ?? (qty !== undefined ? String(qty) : '')} value={rawValues[`${s.id}::${pos.id}::0::quantity`] ?? (qty !== undefined ? String(qty) : '')}
onChange={val => updateEdit(s.id, pos.id, 0, 'quantity', val)} onChange={val => updateEdit(s.id, pos.id, 0, 'quantity', val)}
placeholder="рейсы" placeholder={pos.unit || 'кол-во'}
borderless borderless
ariaLabel={`Кол-во рейсов ${s.name} ${pos.materialName}`} ariaLabel={`Кол-во (${pos.unit || 'ед.'}) ${s.name} ${pos.materialName}`}
/> />
</div> </div>
) : ( ) : (
<span className={styles.computedValue}>{dispQty !== undefined ? formatQty(dispQty) : '—'}</span> <span className={styles.computedValue}>{dispQty !== undefined ? formatQty(dispQty) : '—'}</span>
)} )}
</div> </div>
<div className={styles.deliveryKmBox}> <div className={[styles.deliveryKmBox, kmMissingCls].filter(Boolean).join(' ')}>
{showInput ? ( {showInput ? (
<div className={styles.offerInput}> <div className={styles.offerInput}>
<TextInput <TextInput
@@ -160,7 +180,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
</td> </td>
{isForeign && ( {isForeign && (
<> <>
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}> <td className={[styles.dataCell, priceMissingCls, selCls].filter(Boolean).join(' ')}>
{showInput ? ( {showInput ? (
<div className={styles.offerInput}> <div className={styles.offerInput}>
<TextInput <TextInput
@@ -180,7 +200,7 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
</td> </td>
</> </>
)} )}
<td className={[styles.dataCell, selCls].filter(Boolean).join(' ')}> <td className={[styles.dataCell, priceMissingCls, selCls].filter(Boolean).join(' ')}>
{showInput ? ( {showInput ? (
<div className={styles.offerInput}> <div className={styles.offerInput}>
<TextInput <TextInput
@@ -214,6 +234,8 @@ export function ExtraPositionRow({ pos, posIndex, regularPositionsCount, matrix,
</React.Fragment> </React.Fragment>
); );
})} })}
{/* Под колонкой «+ Добавить поставщика». */}
{!isReadOnly && <td className={styles.addSupplierFillerCell} />}
</tr> </tr>
); );
} }
@@ -4,7 +4,9 @@ import { CurrencyRate } from '../types/domain';
import { PositionRow } from './matrixTypes'; import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix'; import { Step3Matrix } from './useStep3Matrix';
import { getUnitStatus } from '../utils/unitConversion'; import { getUnitStatus } from '../utils/unitConversion';
import { parseNumberInput } from '../utils/parseNumberInput';
import { unitStatusClass } from './unitStatusStyles'; import { unitStatusClass } from './unitStatusStyles';
import { isPriceMissing } from './step3Validation';
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers'; import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss'; import styles from './Step3ComparisonTable.module.scss';
@@ -55,7 +57,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
/> />
</div> </div>
) : ( ) : (
posQtyOverrides[pos.id] !== undefined ? parseFloat(posQtyOverrides[pos.id]) : pos.quantity (posQtyOverrides[pos.id] !== undefined ? parseNumberInput(posQtyOverrides[pos.id]) : undefined) ?? pos.quantity
)} )}
</td> </td>
</> </>
@@ -97,6 +99,8 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price; const dispPrice = (tRatio !== null && price !== undefined) ? price / tRatio : price;
const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective; const dispPriceKzt = tRatio !== null ? priceKztEffective / tRatio : priceKztEffective;
const showInput = !isReadOnly && !translateUnits; const showInput = !isReadOnly && !translateUnits;
// Пустая цена — подсветка сразу, пока заполняют; сохранению не мешает (см. isPriceMissing).
const priceMissingCls = !isReadOnly && isPriceMissing(price, item.priceKztOverride) ? styles.missingPriceCell : '';
return ( return (
<React.Fragment key={s.id}> <React.Fragment key={s.id}>
@@ -159,7 +163,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
</td> </td>
{!isRestrictedView && isForeign && ( {!isRestrictedView && isForeign && (
<> <>
<td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}> <td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
{showInput ? ( {showInput ? (
<div className={styles.offerInput}> <div className={styles.offerInput}>
<TextInput <TextInput
@@ -179,7 +183,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
</td> </td>
</> </>
)} )}
{!isRestrictedView && <td className={[styles.dataCell, valueCls, selCls].filter(Boolean).join(' ')}> {!isRestrictedView && <td className={[styles.dataCell, valueCls, priceMissingCls, selCls].filter(Boolean).join(' ')}>
{showInput ? ( {showInput ? (
<div className={styles.offerInput}> <div className={styles.offerInput}>
<TextInput <TextInput
@@ -214,6 +218,8 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
</React.Fragment> </React.Fragment>
); );
})} })}
{/* Под колонкой «+ Добавить поставщика»: одна ячейка на позицию, включая строки комплекта. */}
{rowIdx === 0 && !isReadOnly && <td className={styles.addSupplierFillerCell} rowSpan={posRowSpan} />}
</tr> </tr>
); );
} }
@@ -866,20 +866,6 @@ $delivery-km-min-width: 76px;
color: $text; color: $text;
} }
.supplierToolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 4px 0 8px;
max-width: 30%;
> :first-child {
flex: 1 1 0;
min-width: 160px;
}
}
.addRow { .addRow {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -1349,3 +1335,114 @@ $delivery-km-min-width: 76px;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
// ─── Незаполненное (решение от 2026-10-27: подсвечивать сразу, пока заполняют) ─────
// Красная рамка — то, что не пускает дальше Шага 3 (см. step3Validation): победитель, километраж,
// условия оплаты (.criteriaInputRequired). Бледная заливка — пустая цена: сохранять не мешает.
// !important — фон выбранной колонки (.colSelected) тоже с !important, а пустая цена в выбранной
// колонке должна оставаться заметной.
.missingPriceCell {
background: $danger-bg !important;
}
.requiredMissingBox {
box-shadow: inset 0 0 0 1px $danger;
border-radius: 2px;
}
.selectSupplierBtnRequired {
border-color: $danger;
color: $danger;
}
// ─── Добавление потенциального поставщика ───────────────────────────────────
// Блок над таблицей (AddSupplierToolbar): раньше без заголовка и с серой кнопкой — его не замечали.
.addSupplierBlock {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
border: 1px solid $accent-border;
border-radius: 4px;
background: $accent-bg;
}
.addSupplierHeading {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 12px;
}
.addSupplierTitle {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $accent-dark;
}
.addSupplierHint {
font-size: 12px;
color: $muted;
}
.addSupplierRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.addSupplierOr {
font-size: 12px;
color: $muted;
}
.addSupplierError {
font-size: 12px;
color: $danger;
}
.addSupplierDialogBody {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
> .addSupplierRow { align-self: stretch; }
}
.addSupplierDivider {
font-size: 12px;
color: $muted;
}
// Последняя колонка таблицы «+ Добавить поставщика» — там, где ищут, как добавить колонку.
.addSupplierHeaderCell {
min-width: 140px;
vertical-align: middle;
text-align: center;
background: $head-bg;
border-left: 2px dashed $accent-border !important;
}
.addSupplierHeaderBtn {
padding: 8px 12px;
border: 1px dashed $accent;
border-radius: 4px;
background: $surface;
color: $accent;
font-family: inherit;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
&:hover { background: $accent-bg; }
}
.addSupplierFillerCell {
border-left: 2px dashed $accent-border !important;
}
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Button, Alert, TextInput, Toggle, Spinner, Dialog, Icon } from '@tnsu/ui-kit'; import { Button, Alert, Toggle, Spinner, Dialog, Icon } from '@tnsu/ui-kit';
import { FormStep } from '../types/domain'; import { FormStep } from '../types/domain';
import { CtService } from '../../services/CtService'; import { CtService } from '../../services/CtService';
import { VendorDatabaseService, IVendorRecord } from '../../services/VendorDatabaseService'; import { VendorDatabaseService, IVendorRecord } from '../../services/VendorDatabaseService';
@@ -19,6 +19,10 @@ import { useAdataCheck } from './useAdataCheck';
import { useRecommendedPick } from './useRecommendedPick'; import { useRecommendedPick } from './useRecommendedPick';
import { VendorPickerDialog } from './VendorPickerDialog'; import { VendorPickerDialog } from './VendorPickerDialog';
import { ExcludedSuppliersList } from './ExcludedSuppliersList'; import { ExcludedSuppliersList } from './ExcludedSuppliersList';
import { AddSupplierToolbar } from './AddSupplierToolbar';
import { AddSupplierDialog } from './AddSupplierDialog';
import { SaveDraftButton } from '../SaveDraftButton/SaveDraftButton';
import { withoutPlaceholders } from '../utils/placeholderKpFile';
// Исключённых поставщиков в режиме просмотра (согласующий, отправленная СТ) пока не показываем — // Исключённых поставщиков в режиме просмотра (согласующий, отправленная СТ) пока не показываем —
// решение от 2026-10-27 (docs/adr/0001). Данные грузятся и так: включить — поменять на true. // решение от 2026-10-27 (docs/adr/0001). Данные грузятся и так: включить — поменять на true.
@@ -56,7 +60,8 @@ export const Step3ComparisonTable: React.FC<Props> = ({
const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, onSave, onNext, onStepComplete }); const matrix = useStep3Matrix({ service, formData, isReadOnly: !canEdit, onSave, onNext, onStepComplete });
const { const {
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency, isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave, addManualSupplier, handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError,
validateBeforeSave, handleSave, draftSavedAt,
extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId, extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
excludedColumns, handleRestoreSupplier, excludedColumns, handleRestoreSupplier,
} = matrix; } = matrix;
@@ -67,6 +72,8 @@ export const Step3ComparisonTable: React.FC<Props> = ({
// Модалка выбора поставщика из «База данных поставщиков» (см. VendorPickerDialog). // Модалка выбора поставщика из «База данных поставщиков» (см. VendorPickerDialog).
const [showVendorPicker, setShowVendorPicker] = useState(false); const [showVendorPicker, setShowVendorPicker] = useState(false);
// Окно кнопки «+ Добавить поставщика» в шапке таблицы.
const [showAddSupplierDialog, setShowAddSupplierDialog] = useState(false);
const vendorDbService = useMemo(() => new VendorDatabaseService(service.webPartContext), [service]); const vendorDbService = useMemo(() => new VendorDatabaseService(service.webPartContext), [service]);
const handleAddVendorFromDatabase = (vendor: IVendorRecord): void => { const handleAddVendorFromDatabase = (vendor: IVendorRecord): void => {
const id = handleAddSupplierFromDatabase(vendor); const id = handleAddSupplierFromDatabase(vendor);
@@ -114,35 +121,17 @@ export const Step3ComparisonTable: React.FC<Props> = ({
return ( return (
<div className={styles.root}> <div className={styles.root}>
{/* Toolbar: add supplier — always visible above the table */} {/* Добавление потенциального поставщика: блок над таблицей + кнопка в шапке таблицы. */}
{!isReadOnly && ( {!isReadOnly && (
<div className={styles.supplierToolbar}> <AddSupplierToolbar
<div className={styles.addInput}> name={newSupplierName}
<TextInput currency={newSupplierCurrency}
placeholder="Наименование потенциального поставщика" currencyOptions={currencyOptions}
value={newSupplierName} onNameChange={setNewSupplierName}
onChange={setNewSupplierName} onCurrencyChange={setNewSupplierCurrency}
ariaLabel="Наименование нового потенциального поставщика" onAdd={handleAddSupplier}
onKeyDown={e => { if (e.key === 'Enter') handleAddSupplier(); }} onOpenDatabase={() => setShowVendorPicker(true)}
/> />
</div>
<select
className={styles.currencySelect}
value={newSupplierCurrency}
onChange={e => setNewSupplierCurrency(e.target.value)}
aria-label="Валюта нового потенциального поставщика"
>
{currencyOptions.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<Button variant="secondary" onClick={handleAddSupplier} disabled={!newSupplierName.trim()}>
<Icon name="plus" size="sm" />
Добавить потенциального поставщика
</Button>
<Button variant="secondary" onClick={() => setShowVendorPicker(true)}>
<Icon name="building" size="sm" />
Добавить из базы поставщиков
</Button>
</div>
)} )}
{/* Тумблер пересчёта единиц измерения поставщика в ед. изм. заявки */} {/* Тумблер пересчёта единиц измерения поставщика в ед. изм. заявки */}
@@ -182,7 +171,14 @@ export const Step3ComparisonTable: React.FC<Props> = ({
{recPick.pickError && <Alert variant="err">{recPick.pickError}</Alert>} {recPick.pickError && <Alert variant="err">{recPick.pickError}</Alert>}
<ComparisonMatrix matrix={matrix} currencyRates={currencyRates} isRestrictedView={isRestrictedView} adata={adata} recommendedId={recPick.recommendedId} /> <ComparisonMatrix
matrix={matrix}
currencyRates={currencyRates}
isRestrictedView={isRestrictedView}
adata={adata}
recommendedId={recPick.recommendedId}
onAddSupplierClick={() => setShowAddSupplierDialog(true)}
/>
<CriteriaTable matrix={matrix} /> <CriteriaTable matrix={matrix} />
@@ -200,7 +196,8 @@ export const Step3ComparisonTable: React.FC<Props> = ({
)} )}
{/* В КП — цены: показываем тем же, кому видны цены (участникам согласования и автору). */} {/* В КП — цены: показываем тем же, кому видны цены (участникам согласования и автору). */}
{!isRestrictedView && <KpFilesList files={formData.uploadedKpFiles ?? []} service={service} />} {/* Заглушки поставщиков, добавленных вручную, — не КП: не показываем. */}
{!isRestrictedView && <KpFilesList files={withoutPlaceholders(formData.uploadedKpFiles ?? [])} service={service} />}
<AiCommentBlock <AiCommentBlock
comment={ai.aiComment} comment={ai.aiComment}
@@ -225,6 +222,9 @@ export const Step3ComparisonTable: React.FC<Props> = ({
</Button> </Button>
<div className={styles.footerRight}> <div className={styles.footerRight}>
{saveError && <Alert variant="err">{saveError}</Alert>} {saveError && <Alert variant="err">{saveError}</Alert>}
{!isReadOnly && (
<SaveDraftButton onSave={() => { handleSave('draft').catch(() => undefined /* ошибку показывает handleSave */); }} isSaving={isSaving} savedAt={draftSavedAt} />
)}
<Button <Button
variant="secondary" variant="secondary"
onClick={ai.handleGeneratePdfReport} onClick={ai.handleGeneratePdfReport}
@@ -242,7 +242,12 @@ export const Step3ComparisonTable: React.FC<Props> = ({
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
onClick={() => (!ai.aiComment && !ai.isAiLoading) ? ai.setShowAiPromptDialog(true) : handleSave()} onClick={() => {
// Сначала проверка (победитель, километраж), потом предложение комментария ИИ.
if (!validateBeforeSave()) return;
if (!ai.aiComment && !ai.isAiLoading) ai.setShowAiPromptDialog(true);
else handleSave().catch(() => undefined /* ошибку показывает handleSave */);
}}
disabled={isSaving || isReadOnly} disabled={isSaving || isReadOnly}
> >
{isSaving ? ( {isSaving ? (
@@ -257,6 +262,15 @@ export const Step3ComparisonTable: React.FC<Props> = ({
</div> </div>
</div> </div>
{showAddSupplierDialog && (
<AddSupplierDialog
currencyOptions={currencyOptions}
onAddManual={addManualSupplier}
onOpenDatabase={() => setShowVendorPicker(true)}
onClose={() => setShowAddSupplierDialog(false)}
/>
)}
{showVendorPicker && ( {showVendorPicker && (
<VendorPickerDialog <VendorPickerDialog
vendorDbService={vendorDbService} vendorDbService={vendorDbService}
@@ -1,7 +1,34 @@
import { applyCreatedIds, buildOfferKey, computeOffer, getSubCols, isMinPositionTotal, round2Str, sumPositionTotals } from './matrixHelpers'; import { applyCreatedIds, applyPositionUnitChange, buildOfferKey, computeOffer, getSubCols, isMinPositionTotal, round2Str, sumPositionTotals } from './matrixHelpers';
import { ExtraPosition } from './matrixTypes'; import { EditableOffers, ExtraPosition } from './matrixTypes';
import { SupplierOffer } from '../types/domain'; import { SupplierOffer } from '../types/domain';
describe('applyPositionUnitChange (единица доп. позиции стала своей вместо «рейс»)', () => {
const edits: EditableOffers = {
'sup_a::extra_pos_1': [{ supplierId: 'sup_a', positionId: 'extra_pos_1', itemIndex: 0, unit: 'рейс', pricePerUnit: 50000 }],
'sup_b::extra_pos_1': [{ supplierId: 'sup_b', positionId: 'extra_pos_1', itemIndex: 0, unit: 'машина' }],
'sup_c::extra_pos_1': [{ supplierId: 'sup_c', positionId: 'extra_pos_1', itemIndex: 0 }],
'sup_a::pos_1': [{ supplierId: 'sup_a', positionId: 'pos_1', itemIndex: 0, unit: 'рейс' }],
};
const result = applyPositionUnitChange(edits, 'extra_pos_1', 'рейс', 'тонна');
it('у поставщика с прежней единицей позиции — новая, цена не теряется', () => {
expect(result['sup_a::extra_pos_1'][0]).toMatchObject({ unit: 'тонна', pricePerUnit: 50000 });
});
it('у поставщика без своей единицы — новая', () => {
expect(result['sup_c::extra_pos_1'][0].unit).toBe('тонна');
});
it('свою единицу поставщика не трогает', () => {
expect(result['sup_b::extra_pos_1'][0].unit).toBe('машина');
});
it('другие позиции не трогает, даже с той же единицей', () => {
expect(result['sup_a::pos_1'][0].unit).toBe('рейс');
});
});
describe('computeOffer', () => { describe('computeOffer', () => {
it('считает сумму в тенге для КП в тенге', () => { it('считает сумму в тенге для КП в тенге', () => {
const offer = computeOffer( const offer = computeOffer(
@@ -87,6 +87,25 @@ export function buildOfferKey(supplierId: string, positionId: string): string {
return `${supplierId}::${positionId}`; return `${supplierId}::${positionId}`;
} }
// Поменяли единицу доп. позиции (логистика, доп. расходы): у поставщиков, у которых единица та же,
// что была у позиции (её не трогали), — новая. Свою единицу поставщика не меняем. Ячейки без правок
// и так показывают единицу позиции.
export function applyPositionUnitChange(
edits: EditableOffers,
positionId: string,
oldUnit: string,
newUnit: string,
): EditableOffers {
const next: EditableOffers = {};
Object.keys(edits).forEach(key => {
const isThisPosition = key.split('::')[1] === positionId;
next[key] = isThisPosition
? edits[key].map(item => (!item.unit || item.unit === oldUnit ? { ...item, unit: newUnit } : item))
: edits[key];
});
return next;
}
export interface ColumnOffersInput { export interface ColumnOffersInput {
columns: Array<{ id: string; isExtraSupplier: boolean }>; columns: Array<{ id: string; isExtraSupplier: boolean }>;
positions: PositionRow[]; positions: PositionRow[];
@@ -0,0 +1,55 @@
import { Supplier } from '../types/domain';
import { ExtraSupplier } from './matrixTypes';
import { buildSuppliersPatch } from './step3SavePatch';
const kpColumn: Supplier = { id: '282_320', spId: 282, kpFileSpId: 320, name: 'Каспий Дорс', hasOffer: true };
const extra: ExtraSupplier = { id: 'extra_sup_1', name: 'ООО Новый', currency: 'KZT' };
describe('buildSuppliersPatch', () => {
it('статусы поставщиков — в данные формы в обоих режимах', () => {
for (const mode of ['next', 'draft'] as const) {
const patch = buildSuppliersPatch({
mode, suppliers: [kpColumn], extraSuppliers: [], statuses: { '282_320': 'Производитель' }, extraSupplierSpIds: {},
});
expect(patch.suppliers[0].status).toBe('Производитель');
}
});
it('«Сохранить и продолжить»: поставщик Шага 3 переезжает в suppliers с SP Id, список Шага 3 очищается', () => {
const patch = buildSuppliersPatch({
mode: 'next', suppliers: [kpColumn], extraSuppliers: [extra], statuses: {}, extraSupplierSpIds: { extra_sup_1: 501 },
});
expect(patch.suppliers.map(s => s.id)).toEqual(['282_320', 'extra_sup_1']);
expect(patch.suppliers[1]).toMatchObject({ spId: 501, hasOffer: true });
expect(patch.step3ExtraSuppliers).toEqual([]);
});
// Остаёмся на шаге: таблица держит поставщика в своём списке Шага 3 — перенос задвоил бы колонку.
it('черновик: поставщик Шага 3 остаётся в своём списке, suppliers его не получают', () => {
const patch = buildSuppliersPatch({
mode: 'draft', suppliers: [kpColumn], extraSuppliers: [extra], statuses: {}, extraSupplierSpIds: { extra_sup_1: 501 },
});
expect(patch.suppliers.map(s => s.id)).toEqual(['282_320']);
expect(patch).not.toHaveProperty('step3ExtraSuppliers');
});
it('повторное «Сохранить и продолжить» не дублирует уже перенесённого поставщика', () => {
const alreadyMoved: Supplier = { id: 'extra_sup_1', spId: 501, name: 'ООО Новый', hasOffer: true };
const patch = buildSuppliersPatch({
mode: 'next', suppliers: [kpColumn, alreadyMoved], extraSuppliers: [extra], statuses: {}, extraSupplierSpIds: { extra_sup_1: 501 },
});
expect(patch.suppliers.filter(s => s.id === 'extra_sup_1')).toHaveLength(1);
});
it('исключение поставщика Шага 3 переезжает вместе с ним', () => {
const patch = buildSuppliersPatch({
mode: 'next', suppliers: [], extraSuppliers: [{ ...extra, exclusion: { reason: 'Дорого' } }], statuses: {}, extraSupplierSpIds: {},
});
expect(patch.suppliers[0].exclusion).toEqual({ reason: 'Дорого' });
});
});
@@ -0,0 +1,49 @@
import { Supplier } from '../types/domain';
import { ExtraSupplier } from './matrixTypes';
// «Сохранить и продолжить» — переход на Шаг 4; «Сохранить черновик» — запись в SP, остаёмся на Шаге 3.
export type Step3SaveMode = 'next' | 'draft';
export interface SuppliersPatchInput {
mode: Step3SaveMode;
suppliers: Supplier[];
extraSuppliers: ExtraSupplier[];
statuses: Record<string, string>;
// SP Id поставщиков, добавленных на Шаге 3 (созданы при этом сохранении или раньше).
extraSupplierSpIds: Record<string, number>;
}
export interface SuppliersPatch {
suppliers: Supplier[];
// Есть только при переходе на Шаг 4: поставщики Шага 3 переезжают в suppliers.
step3ExtraSuppliers?: ExtraSupplier[];
}
// Поставщики в данных формы после сохранения Шага 3.
//
// При переходе на Шаг 4 поставщики, добавленные на Шаге 3, переезжают в suppliers (с тем же id —
// по нему к ним привязаны цены в сессии), а список Шага 3 очищается: таблица размонтируется и
// при возврате соберётся заново.
//
// Черновик оставляет нас на шаге, и таблица по-прежнему держит их в своём списке Шага 3. Перенос
// задвоил бы колонку (она в обоих списках), а без списка Шага 3 у такого поставщика пропала бы
// подстановка количества из позиции — суммы обнулились бы. Поэтому черновик их не переносит:
// SP Id уже запомнены (rememberCreatedExtras), повторное сохранение дублей в SP не создаст.
export function buildSuppliersPatch(input: SuppliersPatchInput): SuppliersPatch {
const { mode, suppliers, extraSuppliers, statuses, extraSupplierSpIds } = input;
const withStatuses = suppliers.map(s => ({ ...s, status: statuses[s.id] ?? s.status }));
if (mode === 'draft') return { suppliers: withStatuses };
const movedExtras: Supplier[] = extraSuppliers
// Уже в suppliers после прежнего сохранения — не дублируем колонку.
.filter(es => !suppliers.some(s => s.id === es.id))
.map(es => ({
id: es.id,
spId: extraSupplierSpIds[es.id] ?? es.spId,
name: es.name,
hasOffer: true,
status: statuses[es.id] ?? '',
exclusion: es.exclusion,
}));
return { suppliers: [...withStatuses, ...movedExtras], step3ExtraSuppliers: [] };
}
@@ -0,0 +1,62 @@
import { STEP3_SAVE_ERRORS, getStep3SaveError, isPriceMissing, toggleWinner } from './step3Validation';
describe('toggleWinner', () => {
it('нажали «Выбрать» — поставщик выбран', () => {
expect(toggleWinner(undefined, '259_278')).toBe('259_278');
});
// Раньше повторное нажатие ничего не делало — снять выбор было нельзя.
it('повторное нажатие на «Выбран» снимает выбор', () => {
expect(toggleWinner('259_278', '259_278')).toBeUndefined();
});
it('нажали «Выбрать» у другого — выбор переходит к нему', () => {
expect(toggleWinner('259_278', '282_320')).toBe('282_320');
});
});
describe('getStep3SaveError', () => {
const compared = ['259_278', '282_320'];
it('победитель выбран — сохранять можно', () => {
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
.toBeUndefined();
});
it('победитель не выбран — нельзя', () => {
expect(getStep3SaveError({ selectedSupplierId: undefined, comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
.toBe(STEP3_SAVE_ERRORS.noWinner);
});
// Выбор из сессии мог остаться на поставщике, которого исключили или чей КП убрали.
it('выбран поставщик не из сравниваемых — как будто не выбран', () => {
expect(getStep3SaveError({ selectedSupplierId: '300_400', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: false }))
.toBe(STEP3_SAVE_ERRORS.noWinner);
});
it('логистика без километража — нельзя', () => {
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: false, hasDeliveryWithoutKm: true }))
.toBe(STEP3_SAVE_ERRORS.deliveryKmMissing);
});
// Решение от 2026-10-27: «Условия оплаты *» были помечены обязательными, но сохранять не мешали.
it('условия оплаты не у всех — нельзя', () => {
expect(getStep3SaveError({ selectedSupplierId: '259_278', comparedColumnIds: compared, hasMissingPaymentTerms: true, hasDeliveryWithoutKm: false }))
.toBe(STEP3_SAVE_ERRORS.paymentTermsMissing);
});
});
describe('isPriceMissing', () => {
it('цены нет ни в валюте, ни в ₸ — пусто', () => {
expect(isPriceMissing(undefined, undefined)).toBe(true);
expect(isPriceMissing(0, undefined)).toBe(true);
});
it('цена в валюте КП есть — не пусто', () => {
expect(isPriceMissing(635, undefined)).toBe(false);
});
it('цену ввели вручную в ₸ — не пусто', () => {
expect(isPriceMissing(undefined, 286575.5)).toBe(false);
});
});
@@ -0,0 +1,42 @@
// Проверки перед «Сохранить и продолжить» на Шаге 3. Сообщения — для пользователя.
export const STEP3_SAVE_ERRORS = {
noWinner: 'Выберите поставщика-победителя: кнопка «Выбрать» в шапке его колонки.',
paymentTermsMissing: 'Заполните условия оплаты у всех поставщиков в «Дополнительной информации».',
deliveryKmMissing: 'Укажите километраж для каждого потенциального поставщика в строке логистики',
};
export interface Step3SaveCheckInput {
selectedSupplierId: string | undefined;
// Колонки, которые сравниваются (без исключённых).
comparedColumnIds: string[];
hasMissingPaymentTerms: boolean;
hasDeliveryWithoutKm: boolean;
}
// Что не пускает дальше Шага 3 (решения от 2026-10-27). Всё это подсвечено красной рамкой сразу,
// пока заполняют, — подсветка показывает ровно то, что проверяется здесь.
// - Победитель: уходит в шапку СТ (winSupplier) — без него отчёт и согласование не знают, кого
// выбрали. Должен быть среди сравниваемых: исключённый поставщик победителем не считается.
// - Условия оплаты: по ним считается рекомендация (80% цена / 20% условия оплаты).
// - Километраж логистики.
export function getStep3SaveError(input: Step3SaveCheckInput): string | undefined {
const { selectedSupplierId, comparedColumnIds, hasMissingPaymentTerms, hasDeliveryWithoutKm } = input;
if (!selectedSupplierId || !comparedColumnIds.includes(selectedSupplierId)) {
return STEP3_SAVE_ERRORS.noWinner;
}
if (hasMissingPaymentTerms) return STEP3_SAVE_ERRORS.paymentTermsMissing;
if (hasDeliveryWithoutKm) return STEP3_SAVE_ERRORS.deliveryKmMissing;
return undefined;
}
// Цена в ячейке не введена: ни в валюте КП, ни вручную в ₸. Подсвечивается, но сохранению не
// мешает — поставщик мог не предложить позицию, и 0 вместо пустоты исказил бы сравнение.
export function isPriceMissing(price: number | undefined, priceKztOverride: number | undefined): boolean {
return !(price !== undefined && price > 0) && !(priceKztOverride !== undefined && priceKztOverride > 0);
}
// Кнопка «Выбрать» / «Выбран»: повторное нажатие на выбранного снимает выбор.
export function toggleWinner(currentId: string | undefined, clickedId: string): string | undefined {
return currentId === clickedId ? undefined : clickedId;
}
@@ -4,8 +4,11 @@ import { SupplierOffer, CurrencyRate, FormStep, SupplierExclusion } from '../typ
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, computeColumnsOffers, sumPositionTotals } from './matrixHelpers'; import { applyCreatedIds, applyPositionUnitChange, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
import { buildExcludedRecordsForSave } from '../utils/excludedColumns'; import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
import { parseNumberInput } from '../utils/parseNumberInput';
import { getStep3SaveError } from './step3Validation';
import { Step3SaveMode, buildSuppliersPatch } from './step3SavePatch';
import { IVendorRecord } from '../../services/VendorDatabaseService'; import { 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';
@@ -93,11 +96,19 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
return map; return map;
}); });
const [selectedSupplierId, setSelectedSupplierId] = useState<string | undefined>( const [selectedSupplierId, setSelectedSupplierIdState] = useState<string | undefined>(
formData.recommendedSupplierId formData.recommendedSupplierId
); );
// Выбор победителя сразу пишем в formData, как статус и исключение поставщика: иначе снятый
// выбор жил только на экране, а после F5 возвращался из сессии или из шапки СТ.
const setSelectedSupplierId = useCallback((id: string | undefined): void => {
setSelectedSupplierIdState(id);
onSave({ recommendedSupplierId: id });
}, [onSave]);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined); const [saveError, setSaveError] = useState<string | undefined>(undefined);
// Когда последний раз сохранили черновик — показываем рядом с кнопкой.
const [draftSavedAt, setDraftSavedAt] = useState<Date | undefined>(undefined);
// Тумблер «Перевести в ед. изм. заявки»: переводит кол-во и цену поставщика в ед. заявки. // Тумблер «Перевести в ед. изм. заявки»: переводит кол-во и цену поставщика в ед. заявки.
// При включении блоки поставщиков показываются как вычисленные значения (без редактирования). // При включении блоки поставщиков показываются как вычисленные значения (без редактирования).
const [translateUnits, setTranslateUnits] = useState(false); const [translateUnits, setTranslateUnits] = useState(false);
@@ -275,8 +286,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
}); });
const qtyOverrides: Record<string, number> = {}; const qtyOverrides: Record<string, number> = {};
Object.keys(posQtyOverridesRef.current).forEach(id => { Object.keys(posQtyOverridesRef.current).forEach(id => {
const v = parseFloat(posQtyOverridesRef.current[id]); const v = parseNumberInput(posQtyOverridesRef.current[id]);
if (!isNaN(v)) qtyOverrides[id] = v; if (v !== undefined) qtyOverrides[id] = v;
}); });
onSaveRef.current({ onSaveRef.current({
offers: updatedOffers, offers: updatedOffers,
@@ -399,8 +410,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
setEdits(prev => { setEdits(prev => {
const pos = allPositionsRef.current.find(p => p.id === positionId); const pos = allPositionsRef.current.find(p => p.id === positionId);
const items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }]; const items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }];
const parsed = parseFloat(value.replace(',', '.')); const parsed = parseNumberInput(value);
const numVal = isNaN(parsed) ? 0 : Math.round(parsed * 1000) / 1000; const numVal = parsed === undefined ? 0 : Math.round(parsed * 1000) / 1000;
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, [field]: numVal } : it) }; return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, [field]: numVal } : it) };
}); });
}, },
@@ -416,8 +427,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
setEdits(prev => { setEdits(prev => {
const pos = allPositionsRef.current.find(p => p.id === positionId); const pos = allPositionsRef.current.find(p => p.id === positionId);
const items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }]; const items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }];
const parsed = parseFloat(value.replace(',', '.')); const parsed = parseNumberInput(value);
const override = isNaN(parsed) ? undefined : Math.round(parsed * 1000) / 1000; const override = parsed === undefined ? undefined : Math.round(parsed * 1000) / 1000;
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, priceKztOverride: override } : it) }; return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, priceKztOverride: override } : it) };
}); });
}, },
@@ -486,7 +497,7 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
} }
// Исключённый не может остаться выбранным — иначе он уйдёт победителем в шапку СТ. // Исключённый не может остаться выбранным — иначе он уйдёт победителем в шапку СТ.
if (exclusion && selectedSupplierId === supplierId) setSelectedSupplierId(undefined); if (exclusion && selectedSupplierId === supplierId) setSelectedSupplierId(undefined);
}, [isReadOnly, suppliers, extraSuppliers, selectedSupplierId, onSave]); }, [isReadOnly, suppliers, extraSuppliers, selectedSupplierId, setSelectedSupplierId, onSave]);
const handleExcludeSupplier = useCallback((supplierId: string, reason: string): void => { const handleExcludeSupplier = useCallback((supplierId: string, reason: string): void => {
if (!reason.trim()) return; if (!reason.trim()) return;
@@ -576,16 +587,23 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
} }
}; };
const handleAddSupplier = (): void => { // Поставщик, добавленный на Шаге 3 вручную (без КП). Общая точка для обеих кнопок добавления:
if (!newSupplierName.trim()) return; // блока над таблицей и колонки «+ Добавить поставщика» в самой таблице.
const addManualSupplier = (name: string, currency: string): void => {
if (isReadOnly || !name.trim()) return;
const id = `extra_sup_${++extraIdCounter}`; const id = `extra_sup_${++extraIdCounter}`;
const newEntry = { id, name: newSupplierName.trim(), currency: newSupplierCurrency }; const updated = [...extraSuppliers, { id, name: name.trim(), currency }];
const updated = [...extraSuppliers, newEntry];
setExtraSuppliers(updated); setExtraSuppliers(updated);
setSupplierStatuses(prev => ({ ...prev, [id]: '' })); setSupplierStatuses(prev => ({ ...prev, [id]: '' }));
onSave({ step3ExtraSuppliers: updated });
};
// Блок над таблицей: добавить из своих полей и очистить их.
const handleAddSupplier = (): void => {
if (!newSupplierName.trim()) return;
addManualSupplier(newSupplierName, newSupplierCurrency);
setNewSupplierName(''); setNewSupplierName('');
setNewSupplierCurrency('KZT'); setNewSupplierCurrency('KZT');
onSave({ step3ExtraSuppliers: updated });
}; };
// Добавление поставщика из VendorPickerDialog («База данных поставщиков»): та же механика // Добавление поставщика из VendorPickerDialog («База данных поставщиков»): та же механика
@@ -661,15 +679,26 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
}; };
const handleDeliveryKmChange = (posId: string, supId: string, val: string): void => { const handleDeliveryKmChange = (posId: string, supId: string, val: string): void => {
const km = parseFloat(val); const km = parseNumberInput(val);
const key = `${posId}::${supId}`; const key = `${posId}::${supId}`;
const updated = isNaN(km) const updated = km === undefined
? (({ [key]: _, ...rest }) => rest)(deliveryKm) ? (({ [key]: _, ...rest }) => rest)(deliveryKm)
: { ...deliveryKm, [key]: km }; : { ...deliveryKm, [key]: km };
setDeliveryKm(updated); setDeliveryKm(updated);
onSave({ step3DeliveryKm: updated }); onSave({ step3DeliveryKm: updated });
}; };
// Своя единица доп. позиции вместо заданной при добавлении (у логистики — «рейс»). Поставщики, у
// которых единицу не меняли, получают новую. В SP уходит при «Сохранить» (см. handleSave).
const handleExtraPositionUnitChange = (positionId: string, unit: string): void => {
if (isReadOnly) return;
const oldUnit = extraPositions.find(ep => ep.id === positionId)?.unit ?? '';
const updated = extraPositions.map(ep => (ep.id === positionId ? { ...ep, unit } : ep));
setExtraPositions(updated);
setEdits(prev => applyPositionUnitChange(prev, positionId, oldUnit, unit));
onSave({ step3ExtraPositions: updated });
};
const handleDeleteExtraPosition = (positionId: string): void => { const handleDeleteExtraPosition = (positionId: string): void => {
if (isReadOnly) return; if (isReadOnly) return;
const updated = extraPositions.filter(ep => ep.id !== positionId); const updated = extraPositions.filter(ep => ep.id !== positionId);
@@ -704,15 +733,26 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
onSave({ step3ExtraSuppliers: nextSuppliers, step3ExtraPositions: nextPositions }); onSave({ step3ExtraSuppliers: nextSuppliers, step3ExtraPositions: nextPositions });
}; };
const handleSave = async (): Promise<void> => { // Проверка перед «Сохранить и продолжить». Показывает ошибку и говорит, можно ли сохранять.
if (!ct) return; // Вызывается и до диалога «Комментарий от ИИ» — чтобы не гонять пользователя через ИИ к отказу.
const deliveryMissingKm = extraPositions.filter(ep => const validateBeforeSave = (): boolean => {
const error = getStep3SaveError({
selectedSupplierId,
comparedColumnIds: suppliersWithOffer.map(s => s.id),
hasMissingPaymentTerms: suppliersWithOffer.some(s => !(criteriaMap[s.id]?.paymentType ?? '').trim()),
hasDeliveryWithoutKm: extraPositions.some(ep =>
ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`]) ep.isDelivery && suppliersWithOffer.some(s => !deliveryKm[`${ep.id}::${s.id}`])
); ),
if (deliveryMissingKm.length > 0) { });
setSaveError('Укажите километраж для каждого потенциального поставщика в строке логистики'); setSaveError(error);
return; return error === undefined;
} };
// Запись Шага 3 в SP. 'next' — с проверками и переходом на Шаг 4; 'draft' — без проверок,
// остаёмся на шаге (черновик: сохранить недоделанное, чтобы не потерять при закрытии вкладки).
const handleSave = async (mode: Step3SaveMode = 'next'): Promise<void> => {
if (!ct) return;
if (mode === 'next' && !validateBeforeSave()) return;
setIsSaving(true); setIsSaving(true);
setSaveError(undefined); setSaveError(undefined);
@@ -738,6 +778,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
if (ep.spId) { if (ep.spId) {
// Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows. // Already created in SP on a previous save — reuse to avoid duplicate Позиции СТ rows.
extraPosSpIds[ep.id] = ep.spId; extraPosSpIds[ep.id] = ep.spId;
// Единицу могли поменять после создания. Пишем всегда — строк доп. расходов единицы,
// а отслеживать «менялась ли» — лишнее состояние.
await service.updateCTPositionUnit(ep.spId, ep.unit);
continue; continue;
} }
const saved = await service.createCTPositions(ct.id, [{ const saved = await service.createCTPositions(ct.id, [{
@@ -864,28 +907,15 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
onSave({ onSave({
offers: offersForSession, offers: offersForSession,
suppliers: [ // Поставщики Шага 3 переезжают в suppliers только при переходе на Шаг 4 (см. buildSuppliersPatch).
...suppliers.map(s => ({ ...buildSuppliersPatch({
...s, mode,
status: supplierStatuses[s.id] ?? s.status, suppliers,
})), extraSuppliers,
// Move extras into suppliers (with React ID kept as `id` — required so formData.offers statuses: supplierStatuses,
// entries with React IDs still match sup.id on remount). Skip extras that are already in extraSupplierSpIds: extraSupSpIds,
// formData.suppliers from a prior save (avoids duplicate columns). }),
...extraSuppliers
.filter(es => !suppliers.some(s => s.id === es.id))
.map(es => ({
id: es.id,
spId: extraSupSpIds[es.id],
name: es.name,
hasOffer: true as const,
status: supplierStatuses[es.id] ?? '',
exclusion: es.exclusion,
})),
],
recommendedSupplierId: selectedSupplierId, recommendedSupplierId: selectedSupplierId,
// Extras moved into `suppliers` — clear extras state so `suppliersWithOffer` doesn't duplicate columns.
step3ExtraSuppliers: [],
// Positions don't migrate into selectedPositions — keep them in extras state so the // Positions don't migrate into selectedPositions — keep them in extras state so the
// "доп.расходы" rows continue to render after Step 4 → breadcrumb back. Track spId to // "доп.расходы" rows continue to render after Step 4 → breadcrumb back. Track spId to
// skip re-creating SP rows on subsequent saves. // skip re-creating SP rows on subsequent saves.
@@ -894,14 +924,18 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
spId: extraPosSpIds[ep.id] ?? ep.spId, spId: extraPosSpIds[ep.id] ?? ep.spId,
})), })),
step3PositionQtyOverrides: Object.keys(posQtyOverrides).reduce<Record<string, number>>((acc, id) => { step3PositionQtyOverrides: Object.keys(posQtyOverrides).reduce<Record<string, number>>((acc, id) => {
const v = parseFloat(posQtyOverrides[id]); const v = parseNumberInput(posQtyOverrides[id]);
if (!isNaN(v)) acc[id] = v; if (v !== undefined) acc[id] = v;
return acc; return acc;
}, {}), }, {}),
step3DeliveryKm: deliveryKm, step3DeliveryKm: deliveryKm,
step3CriteriaEdits: criteriaMap, step3CriteriaEdits: criteriaMap,
step3CustomCriteria: customCriteria, step3CustomCriteria: customCriteria,
}); });
if (mode === 'draft') {
setDraftSavedAt(new Date());
return;
}
saveCompletedRef.current = true; saveCompletedRef.current = true;
onStepComplete(3); onStepComplete(3);
onNext(); onNext();
@@ -973,13 +1007,17 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
handleRemoveCustomCriteria, handleRemoveCustomCriteria,
handleNameColResizeMouseDown, handleNameColResizeMouseDown,
handleStatusChange, handleStatusChange,
addManualSupplier,
handleAddSupplier, handleAddSupplier,
handleAddSupplierFromDatabase, handleAddSupplierFromDatabase,
handleAddPosition, handleAddPosition,
handleAddDelivery, handleAddDelivery,
handleDeliveryKmChange, handleDeliveryKmChange,
handleDeleteExtraPosition, handleDeleteExtraPosition,
handleExtraPositionUnitChange,
validateBeforeSave,
handleSave, handleSave,
draftSavedAt,
}; };
} }
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { CtService } from '../../services/CtService'; import { CtService } from '../../services/CtService';
import { SigexSignService, SigexSignStep } from '../../services/SigexSignService'; import { SigexSignService, SigexSignStep } from '../../services/SigexSignService';
import { CTFormData } from '../hooks/useCtForm'; import { CTFormData } from '../hooks/useCtForm';
import { withoutPlaceholders } from '../utils/placeholderKpFile';
// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX. // Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX.
export type IncoreStep = 'report' | SigexSignStep; export type IncoreStep = 'report' | SigexSignStep;
@@ -72,9 +73,10 @@ export function useIncore(args: UseIncoreArgs) {
setCardAttachment(saved); setCardAttachment(saved);
// 4. Пакет для INCORE: карточка с ЭЦП + файлы КП — скачиваем пользователю. // 4. Пакет для INCORE: карточка с ЭЦП + файлы КП — скачиваем пользователю.
// Заглушки поставщиков, добавленных вручную, — не КП, в пакет не идут.
service.downloadBlob(cardBlob, incoreCardName); service.downloadBlob(cardBlob, incoreCardName);
const kpFiles = formData.uploadedKpFiles ?? []; const kpFiles = withoutPlaceholders(formData.uploadedKpFiles ?? []);
for (const f of kpFiles) { for (const f of kpFiles) {
await service.downloadKpFile(f.name); await service.downloadKpFile(f.name);
await new Promise<void>((resolve) => setTimeout(resolve, 300)); await new Promise<void>((resolve) => setTimeout(resolve, 300));
@@ -0,0 +1,14 @@
import { useEffect, useState } from 'react';
// Значение, которое обновляется только после паузы delayMs в изменениях — чтобы поиск с запросом
// к серверу уходил, когда пользователь перестал печатать, а не на каждую букву.
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
@@ -27,9 +27,11 @@ async function loadMissingPinned(service: CtService, pinned: Request[], loaded:
); );
} }
// requestNumber — поиск по номеру на сервере (часть номера, в пределах периода); пусто — весь период.
export function useRequests( export function useRequests(
service: CtService, service: CtService,
days: number, days: number,
requestNumber: string,
skip: boolean, skip: boolean,
pinnedRequests: Request[], pinnedRequests: Request[],
): UseRequestsResult { ): UseRequestsResult {
@@ -51,7 +53,7 @@ export function useRequests(
setError(undefined); setError(undefined);
service service
.fetchRequests(days) .fetchRequests(days, requestNumber || undefined)
.then(async data => { .then(async data => {
const pinned = await loadMissingPinned(service, pinnedRequests, data); const pinned = await loadMissingPinned(service, pinnedRequests, data);
if (!cancelled) { if (!cancelled) {
@@ -69,7 +71,7 @@ export function useRequests(
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [service, days, reloadToken, skip, pinnedRequests]); }, [service, days, requestNumber, reloadToken, skip, pinnedRequests]);
const loadPositions = useCallback( const loadPositions = useCallback(
async (requestId: string): Promise<Position[]> => { async (requestId: string): Promise<Position[]> => {
@@ -48,6 +48,8 @@ export interface CT {
// блока «Выгрузка в INCORE» и т.п.: в новом механизме согласования (approval-route API) // блока «Выгрузка в INCORE» и т.п.: в новом механизме согласования (approval-route API)
// инициатор в саму матрицу согласования не входит, в отличие от прежней цепочки. // инициатор в саму матрицу согласования не входит, в отличие от прежней цепочки.
authorEmail?: string; authorEmail?: string;
// Победитель (лукап winSupplier → «Поставщики»), записывается на Шаге 3.
winSupplierSpId?: number;
} }
export type CTStatus = export type CTStatus =
@@ -0,0 +1,52 @@
import { parseNumberInput } from './parseNumberInput';
describe('parseNumberInput', () => {
// Претензия ОМТС от 2026-10-23, п.6: цену скопировали из КП «100 000» — сумма посчиталась от 100.
it('пробелы между разрядами не обрывают число', () => {
expect(parseNumberInput('100 000')).toBe(100000);
expect(parseNumberInput('1 00000')).toBe(100000);
expect(parseNumberInput('10 0 000')).toBe(100000);
});
// Word, Excel и PDF ставят между разрядами неразрывный пробел, а не обычный.
it('неразрывные пробелы — тоже', () => {
expect(parseNumberInput('100 000')).toBe(100000);
expect(parseNumberInput('1 234 567,5')).toBe(1234567.5);
});
it('запятая — десятичный разделитель, как в русских КП', () => {
expect(parseNumberInput('275 293,50')).toBe(275293.5);
expect(parseNumberInput('0,061')).toBe(0.061);
});
it('точка — тоже десятичный разделитель', () => {
expect(parseNumberInput('286575.5')).toBe(286575.5);
});
// КП на английском: «1,234,567.89». Есть и запятая, и точка — десятичный разделитель последний.
it('английский формат с разделителями разрядов', () => {
expect(parseNumberInput('1,234,567.89')).toBe(1234567.89);
expect(parseNumberInput('1.234.567,89')).toBe(1234567.89);
});
it('несколько одинаковых разделителей — это разряды', () => {
expect(parseNumberInput('1,000,000')).toBe(1000000);
expect(parseNumberInput('1.000.000')).toBe(1000000);
});
it('хвост из валюты или единиц не мешает', () => {
expect(parseNumberInput('100 000 ₸')).toBe(100000);
expect(parseNumberInput('1 540 USD')).toBe(1540);
});
it('пусто или не число — undefined, а не 0: «не введено» ≠ «ноль»', () => {
expect(parseNumberInput('')).toBeUndefined();
expect(parseNumberInput(' ')).toBeUndefined();
expect(parseNumberInput('abc')).toBeUndefined();
});
it('обычные числа как раньше', () => {
expect(parseNumberInput('635')).toBe(635);
expect(parseNumberInput('-5')).toBe(-5);
});
});
@@ -0,0 +1,32 @@
// Число из поля ввода. Цены и количества копируют из КП, Excel и PDF: «100 000», «275 293,50»,
// «1,234,567.89», с неразрывными пробелами между разрядами. parseFloat читает до первого пробела —
// «100 000» превращалось в 100, и сумма считалась в тысячу раз меньше.
export function parseNumberInput(value: string): number | undefined {
// \s в JS покрывает и неразрывные пробелы (U+00A0, U+202F).
const compact = value.replace(/\s/g, '');
if (compact === '') return undefined;
const parsed = parseFloat(normalizeSeparators(compact));
return isNaN(parsed) ? undefined : parsed;
}
// Приводит разделители к виду «1234567.89»:
// - есть и «,», и «.» — десятичный тот, что последний, другой — разряды («1,234.5», «1.234,5»);
// - один и тот же знак несколько раз — разряды («1,000,000»);
// - один знак один раз — десятичный (в русских КП это запятая: «0,061»).
function normalizeSeparators(text: string): string {
const lastComma = text.lastIndexOf(',');
const lastDot = text.lastIndexOf('.');
if (lastComma !== -1 && lastDot !== -1) {
const decimal = lastComma > lastDot ? ',' : '.';
const thousands = decimal === ',' ? '.' : ',';
return text.split(thousands).join('').replace(decimal, '.');
}
const separator = lastComma !== -1 ? ',' : lastDot !== -1 ? '.' : undefined;
if (separator === undefined) return text;
const occurrences = text.split(separator).length - 1;
return occurrences > 1 ? text.split(separator).join('') : text.replace(separator, '.');
}
@@ -0,0 +1,27 @@
import { isPlaceholderKpFile, withoutPlaceholders } from './placeholderKpFile';
describe('isPlaceholderKpFile', () => {
// СТ 375: поставщик «fghfg» добавлен на Шаге 3 — заглушка видна в «Коммерческих предложениях».
it('заглушка поставщика, добавленного вручную', () => {
expect(isPlaceholderKpFile('fghfg_1793100061349.txt')).toBe(true);
expect(isPlaceholderKpFile('ООО Альфа_1793100061349.TXT')).toBe(true);
});
it('настоящие КП — не заглушки', () => {
expect(isPlaceholderKpFile('372_KP_SteelGate_Export_USD.pdf')).toBe(false);
expect(isPlaceholderKpFile('372_KP_TOO_Kaspiy_Dors.docx')).toBe(false);
expect(isPlaceholderKpFile('КП.doc')).toBe(false);
});
});
describe('withoutPlaceholders', () => {
it('оставляет только настоящие КП, порядок сохраняется', () => {
const files = [
{ spId: 318, name: '372_KP_SteelGate.pdf' },
{ spId: 322, name: 'fghfg_1793100061349.txt' },
{ spId: 320, name: '372_KP_Kaspiy.docx' },
];
expect(withoutPlaceholders(files).map(f => f.spId)).toEqual([318, 320]);
});
});
@@ -0,0 +1,18 @@
// Файл-заглушка КП: создаётся для поставщика, добавленного на Шаге 3 вручную, — оффер в SP должен
// ссылаться на файл КП (CtService._createPlaceholderKpFile). Людям он не нужен: в списках КП и в
// пакете INCORE его не показываем. Узнаём по расширению: на Шаге 2 загрузить можно только
// .pdf/.docx/.doc (FileUploadArea), а заглушка всегда .txt.
const PLACEHOLDER_EXTENSION = '.txt';
// Подпись колонки поставщика, у которого вместо КП — заглушка.
export const PLACEHOLDER_COLUMN_LABEL = 'Добавлен вручную';
export function isPlaceholderKpFile(fileName: string): boolean {
return fileName.toLowerCase().endsWith(PLACEHOLDER_EXTENSION);
}
// Настоящие КП — для показа и выгрузки. Данные формы (uploadedKpFiles) не фильтруем: по ним
// сохранение находит файл КП колонки, в том числе заглушку.
export function withoutPlaceholders<T extends { name: string }>(files: T[]): T[] {
return files.filter(file => !isPlaceholderKpFile(file.name));
}
@@ -1,6 +1,52 @@
import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds } from './supplierColumns'; import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds, findWinnerColumnId, withoutSessionExtras } from './supplierColumns';
import { Supplier } from '../types/domain'; import { Supplier } from '../types/domain';
describe('withoutSessionExtras (поставщик Шага 3 после «Сохранить черновик»)', () => {
// СТ 375: единственный поставщик «fghfg» добавлен на Шаге 3, сохранён черновиком. При открытии из
// реестра колонка собиралась из SP («285_322», без цен) и ещё раз приходила из сессии (с ценами).
const fromSp: Supplier[] = [
{ id: '285_322', spId: 285, kpFileSpId: 322, name: 'fghfg', hasOffer: true },
{ id: '282_320', spId: 282, kpFileSpId: 320, name: 'Каспий Дорс', hasOffer: true },
];
it('колонку поставщика Шага 3 из SP не добавляет — она уже есть в сессии', () => {
const result = withoutSessionExtras(fromSp, [{ spId: 285, offerId: 322 }]);
expect(result.map(c => c.id)).toEqual(['282_320']);
});
it('другой КП того же поставщика — отдельная колонка, остаётся', () => {
const result = withoutSessionExtras(fromSp, [{ spId: 285, offerId: 999 }]);
expect(result.map(c => c.id)).toEqual(['285_322', '282_320']);
});
it('поставщик Шага 3 ещё не сохранён в SP — ничего не убирает', () => {
expect(withoutSessionExtras(fromSp, [{}])).toHaveLength(2);
});
});
describe('findWinnerColumnId (победитель из шапки СТ при открытии без сессии)', () => {
const columns: Supplier[] = [
{ id: '281_318', spId: 281, kpFileSpId: 318, name: 'SteelGate', hasOffer: true, exclusion: { reason: 'Дорого' } },
{ id: '281_330', spId: 281, kpFileSpId: 330, name: 'SteelGate', hasOffer: true },
{ id: '282_320', spId: 282, kpFileSpId: 320, name: 'Каспий Дорс', hasOffer: true },
];
it('колонка победителя по SP Id поставщика', () => {
expect(findWinnerColumnId(columns, 282)).toBe('282_320');
});
it('у поставщика с несколькими КП — первая неисключённая колонка', () => {
expect(findWinnerColumnId(columns, 281)).toBe('281_330');
});
it('победителя нет или его колонки нет — undefined', () => {
expect(findWinnerColumnId(columns, undefined)).toBeUndefined();
expect(findWinnerColumnId(columns, 999)).toBeUndefined();
});
});
describe('buildSupplierColumnId', () => { describe('buildSupplierColumnId', () => {
it('колонка = поставщик + файл КП', () => { it('колонка = поставщик + файл КП', () => {
expect(buildSupplierColumnId(263, 277)).toBe('263_277'); expect(buildSupplierColumnId(263, 277)).toBe('263_277');
@@ -47,6 +93,19 @@ describe('buildColumnsFromSpOffers (открытие по ссылке / без
}); });
}); });
describe('подпись колонки с файлом-заглушкой', () => {
// СТ 375: в шапке колонки показывалось имя заглушки «fghfg_1793100061349.txt».
it('вместо имени заглушки — «Добавлен вручную»', () => {
const columns: Supplier[] = [
{ id: '285_322', spId: 285, kpFileSpId: 322, name: 'fghfg', hasOffer: true },
{ id: '285_330', spId: 285, kpFileSpId: 330, name: 'fghfg', hasOffer: true },
];
const files = [{ spId: 322, name: 'fghfg_1793100061349.txt' }, { spId: 330, name: '375_KP_fghfg.pdf' }];
expect(buildColumnLabels(columns, files)).toEqual({ '285_322': 'Добавлен вручную', '285_330': '375_KP_fghfg.pdf' });
});
});
describe('подписи колонок поставщика с несколькими КП', () => { describe('подписи колонок поставщика с несколькими КП', () => {
const ARLAN = 'ТОО «Арлан Офис Снаб»'; const ARLAN = 'ТОО «Арлан Офис Снаб»';
const columns: Supplier[] = [ const columns: Supplier[] = [
@@ -1,4 +1,5 @@
import { Supplier } from '../types/domain'; import { Supplier } from '../types/domain';
import { PLACEHOLDER_COLUMN_LABEL, isPlaceholderKpFile } from './placeholderKpFile';
// Колонка таблицы сравнения = поставщик + файл КП: у одного поставщика может быть несколько // Колонка таблицы сравнения = поставщик + файл КП: у одного поставщика может быть несколько
// предложений (напр. в тенге и в долларах). Разделитель «_», а не «::» — «::» уже служит // предложений (напр. в тенге и в долларах). Разделитель «_», а не «::» — «::» уже служит
@@ -44,6 +45,29 @@ export function buildColumnsFromSpOffers(records: SpOfferColumnSource[]): Suppli
return Array.from(columnsById.values()); return Array.from(columnsById.values());
} }
// Поставщик, добавленный на Шаге 3, в сессии: SP Id поставщика и его файла-заглушки КП.
export interface SessionExtraColumn {
spId?: number;
offerId?: number;
}
// Колонки из SP без тех, что в сессии уже есть поставщиком Шага 3. «Сохранить черновик» оставляет
// такого поставщика в списке Шага 3 (см. buildSuppliersPatch), а в SP он уже лежит колонкой
// «поставщик_заглушка». Без этого при открытии СТ, где других поставщиков нет, колонка задваивалась:
// одна из SP без цен, вторая из сессии с ценами.
export function withoutSessionExtras(spColumns: Supplier[], extras: SessionExtraColumn[]): Supplier[] {
return spColumns.filter(column => !extras.some(extra =>
extra.spId !== undefined && extra.spId === column.spId && extra.offerId === column.kpFileSpId,
));
}
// Колонка победителя по winSupplier из шапки СТ (СТ открыта без сессии). В SP хранится поставщик,
// а не колонка: у поставщика с несколькими КП берём первую неисключённую колонку.
export function findWinnerColumnId(columns: Supplier[], winSupplierSpId: number | undefined): string | undefined {
if (winSupplierSpId === undefined) return undefined;
return columns.find(c => c.spId === winSupplierSpId && c.hasOffer !== false && !c.exclusion)?.id;
}
// Колонки той же компании (тот же SP Id поставщика), включая саму колонку. Нужно для данных, которые // Колонки той же компании (тот же SP Id поставщика), включая саму колонку. Нужно для данных, которые
// принадлежат компании, а не КП: проверка Adata, БИН. // принадлежат компании, а не КП: проверка Adata, БИН.
export function findSiblingColumnIds(columns: Supplier[], columnId: string): string[] { export function findSiblingColumnIds(columns: Supplier[], columnId: string): string[] {
@@ -68,6 +92,11 @@ export function buildColumnLabels(
if (group.length < 2) return; if (group.length < 2) return;
group.forEach((c, index) => { group.forEach((c, index) => {
const fileName = uploadedKpFiles.find(f => f.spId === c.kpFileSpId)?.name; const fileName = uploadedKpFiles.find(f => f.spId === c.kpFileSpId)?.name;
// Имя файла-заглушки (поставщик добавлен вручную) людям ничего не говорит.
if (fileName !== undefined && isPlaceholderKpFile(fileName)) {
labels[c.id] = PLACEHOLDER_COLUMN_LABEL;
return;
}
labels[c.id] = fileName ?? `КП ${index + 1}`; labels[c.id] = fileName ?? `КП ${index + 1}`;
}); });
}); });
@@ -179,9 +179,12 @@ export class CtService {
return tokenProvider.getToken(AAD_RESOURCE); return tokenProvider.getToken(AAD_RESOURCE);
} }
async fetchRequests(days: number): Promise<Request[]> { // requestNumber — поиск на сервере: по части номера, в пределах периода days. Без него —
// все потребности периода.
async fetchRequests(days: number, requestNumber?: string): Promise<Request[]> {
const token = await this._getApiToken(); const token = await this._getApiToken();
const res = await fetch(`${EXTERNAL_API}/Requests/${days}`, { const query = requestNumber ? `?requestNumber=${encodeURIComponent(requestNumber)}` : "";
const res = await fetch(`${EXTERNAL_API}/Requests/${days}${query}`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
@@ -394,6 +397,21 @@ export class CtService {
})); }));
} }
// Единица доп. позиции Шага 3 (логистика, доп. расходы) — её можно поменять после создания.
// MERGE на «Позиции СТ» ресивер не блокирует (блокирует только на «Оценка поставщиков»).
async updateCTPositionUnit(positionSpId: number, unit: string): Promise<void> {
const res = await this.http.fetch(this._liById(LISTS.CT_POSITIONS, positionSpId), SPHttpClient.configurations.v1, {
method: "POST",
headers: {
"Content-Type": "application/json;odata=nometadata",
"IF-MATCH": "*",
"X-HTTP-Method": "MERGE",
},
body: JSON.stringify({ unit }),
});
if (!res.ok) throw new Error(`Ошибка обновления единицы позиции: ${res.status}`);
}
// Пакетное создание элементов списка через SharePoint OData $batch: вместо N POST — // Пакетное создание элементов списка через SharePoint OData $batch: вместо N POST —
// один HTTP-запрос (до 100 операций/changeset, при большем числе бьём на чанки). // один HTTP-запрос (до 100 операций/changeset, при большем числе бьём на чанки).
// Возвращает Id созданных элементов В ТОМ ЖЕ порядке, что и bodies. Changeset атомарен: // Возвращает Id созданных элементов В ТОМ ЖЕ порядке, что и bodies. Changeset атомарен:
@@ -816,7 +834,7 @@ export class CtService {
} }
async loadCTById(ctId: string): Promise<CT> { async loadCTById(ctId: string): Promise<CT> {
const url = `${this._liById(LISTS.CT, ctId)}?$select=Id,Title,number,project,status,Author/EMail&$expand=Author`; const url = `${this._liById(LISTS.CT, ctId)}?$select=Id,Title,number,project,status,winSupplierId,Author/EMail&$expand=Author`;
const res = await this.http.get(url, SPHttpClient.configurations.v1); const res = await this.http.get(url, 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();
@@ -826,6 +844,7 @@ export class CtService {
projectName: String(data.project ?? ""), projectName: String(data.project ?? ""),
status: CHOICE_TO_CT_STATUS[String(data.status ?? "")] ?? "formed", status: CHOICE_TO_CT_STATUS[String(data.status ?? "")] ?? "formed",
authorEmail: String(data.Author?.EMail ?? "") || undefined, authorEmail: String(data.Author?.EMail ?? "") || undefined,
winSupplierSpId: data.winSupplierId ? Number(data.winSupplierId) : undefined,
}; };
} }