12 Commits

Author SHA1 Message Date
gorbenko.pavel 22e37b6e36 feat: прежняя заявка на оплату открывается в панели справа, а не в новой вкладке
Tests / frontend-test (pull_request) Successful in 1m27s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:17:53 +05:00
gorbenko.pavel f654db9bcf docs: заявка на оплату — прежние оплаты через approval-route
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:01:46 +05:00
gorbenko.pavel 5cfe54f33b feat: прежние оплаты по основанию берутся из approval-route, ссылка — только при правах
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:01:34 +05:00
gorbenko.pavel 0c0104f6c9 feat: прежние заявки на оплату по основанию открываются по ссылке, видно статус
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:45:54 +05:00
gorbenko.pavel f664b468a2 fix: черновик заявки на оплату перечитывает прежние оплаты по основанию
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:42:15 +05:00
gorbenko.pavel 2bf0ad1da7 feat: разряды в полях сумм заявки на оплату
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:14:17 +05:00
gorbenko.pavel 6460a2a22d fix: счёт на оплату кладётся в библиотеку под латинским именем (OCR терял расширение)
Tests / frontend-test (pull_request) Successful in 1m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:49:25 +05:00
gorbenko.pavel edd5008120 fix: поля заявки на оплату занимают всю ширину секции
Тело CollapseSection из ui-kit уже сетка в две колонки, а поля были
завёрнуты ещё в один FormGrid и попадали в половину первой колонки.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:44:46 +05:00
gorbenko.pavel 5107f62e54 fix: таблицы заявки на оплату растягиваются на всю ширину секции
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:42:22 +05:00
gorbenko.pavel 780d92cd3d docs: заявка на оплату — списки, тип и маршрут заведены
Списки «Заявка на оплату 1 — данные / согласование» созданы провижинингом,
в approval-route заведены тип 24 (раздел «Бухгалтерия») и маршрут 25 с одним
шагом согласования на фиксированного человека.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:29:01 +05:00
gorbenko.pavel 023c0913f1 fix: заявка на оплату под реальный API справочников и OCR
Tests / frontend-test (pull_request) Successful in 1m32s
Сверено с api-tnsu-portal (DictionaryController, DocumentController):
- статьи ДДС — CashFlowItems?id=<группа> (было groupId, сервер отвечал 400);
- OCR — Document/extractPDFText, текст в file_content пишется до ответа,
  поэтому колонка читается сразу, без опроса;
- договоры и банковские счета — постраничные, поиск на сервере (search),
  иначе находились только первые 50 записей; фильтра по компании у
  договоров нет;
- счета по проекту с откатом на фильтр только по компании: совпадение Id
  проекта из /Project с bank_accounts.project_id не гарантировано;
- Id-фильтры передаются только в формате GUID, иначе API отвечает 400;
- подписи записей по реальным полям (account_number, bank, valuta,
  counterparty_name); разбор вынесен в oneCRecords.ts с тестами;
- код позиции потребности — lineNumber;
- пустой ответ Gemini (200 с error) считается ошибкой.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:35:53 +05:00
gorbenko.pavel e976b4e8d4 feat: форма «Заявка на оплату» (СТ/потребность, счёт через OCR и ИИ)
Tests / frontend-test (pull_request) Successful in 2m12s
Новая форма acc-payment-request в категории «Бухгалтерия»: основание —
согласованная СТ (сайт СТ) или потребность; из основания подставляются
получатель, раздел, вид работ, условия, общая сумма и позиции. Счёт (PDF)
сохраняется в библиотеку, уходит на OCR, ИИ достаёт реквизиты, суммы и позиции
и сверяет их с СТ. Подсвечиваются чужой получатель в счёте и превышение
общей суммы СТ всеми оплатами. Справочники 1С — общий DictionaryPicker.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:01:28 +05:00
103 changed files with 3801 additions and 2035 deletions
+2 -1
View File
@@ -49,9 +49,10 @@
| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker) либо создаётся сам по согласованному «Заявлению на увольнение»; условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД); 5 независимых сумм к списанию компактной сеткой (ОС, СИЗ, подотчётная сумма, НМА, ТМЦ — обучение не входит, у него своё поле) — вводит любой активный согласующий, блокируется навсегда после первого ввода; заполнил хоть одну — стандартный комментарий решения (в блоке «Ваше решение») становится обязательным на любое решение, не только возврат/отклонение | ✅ |
| [`hr-dismissal`](../src/webparts/requestPortal/forms/Dismissal/README.md) | Заявление на увольнение | Отдел кадров | да | нет | да | совместители — таблица компаний с удалением строки (снимает и согласующего из матрицы); должность/организация из справочника; **после согласования сам создаёт обходной лист**; печать заблокирована до согласования обходного листа; «Лицо, принимающее ОС/НМА» — заполняет только непосредственный руководитель | ✅ |
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; командирующая организация — выбор из организаций сотрудника; маршрут лентой (точки по Казахстану — город из справочника КАТО на русском и казахском + объект из справочника, за рубежом — страна/город/объект вручную; даты не задним числом, поездка ≤ 40 дней; участки с видом транспорта и временем выезда); суточные (≤ 6 МРП КЗ / ≤ 8 за рубежом) и проживание (ночи сами, ≤ 15 000 ₸ за ночь КЗ) — по точкам; комментарий после итога; задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; маршрут лентой (точки = город + объект из справочника + даты, участки с видом транспорта); задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
| [`acc-payment-request`](../src/webparts/requestPortal/forms/PaymentRequest/README.md) | Заявка на оплату | Бухгалтерия | да | нет | да | основание — согласованная СТ (сайт СТ) или потребность; счёт PDF → библиотека → OCR → ИИ (реквизиты, суммы, позиции); сверка позиций счёта с СТ; подсветка чужого получателя и превышения общей суммы СТ; справочники 1С | ⏳ списки, библиотека, тип (id 24) и маршрут заведены; не проверена на живых данных |
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
**Просмотр** — есть режим согласующего (`<Форма>View`).
-99
View File
@@ -1,99 +0,0 @@
<#
.SYNOPSIS
Собирает справочник городов и посёлков РК для выпадающих списков портала из
официального КАТО (Бюро национальной статистики, stat.gov.kz) и пишет его
TS-модулем src/webparts/requestPortal/shared/dictionary/kato/katoCities.ts.
.DESCRIPTION
Берём только города («г.») и посёлки («п.»): ~180 записей, несколько KB в бандле.
Сёла (~10 тыс.) не берём — тяжело для фронта; их вводят вручную в то же поле.
Административные единицы («Г.А.», «п.а.», «г.а.» — администрации, не населённые
пункты) отбрасываются. КАТО обновляется редко: перезапустить скрипт со ссылкой
на свежий файл со страницы https://stat.gov.kz/ru/classifiers/statistical/21/
и закоммитить сгенерированный модуль.
.EXAMPLE
./scripts/Build-KatoCities.ps1
./scripts/Build-KatoCities.ps1 -XlsxUrl "https://stat.gov.kz/upload/iblock/.../КАТО_ДД.ММ.ГГГГ.xlsx"
#>
param(
[string]$XlsxUrl = 'https://stat.gov.kz/upload/iblock/a7d/eqogm92xc0udumlzfzffhcrutk0fjhb4/КАТО_18.09.2026.xlsx',
[string]$OutFile = (Join-Path $PSScriptRoot '..\src\webparts\requestPortal\shared\dictionary\kato\katoCities.ts')
)
$ErrorActionPreference = 'Stop'
$work = Join-Path ([System.IO.Path]::GetTempPath()) "kato-$([guid]::NewGuid())"
New-Item -ItemType Directory -Path $work | Out-Null
try {
# xlsx — это zip: читаем XML листа напрямую, без Excel и сторонних модулей.
$zip = Join-Path $work 'kato.zip'
Invoke-WebRequest -Uri $XlsxUrl -OutFile $zip -UseBasicParsing
Expand-Archive -Path $zip -DestinationPath $work
[xml]$sharedXml = Get-Content -Raw -Encoding UTF8 (Join-Path $work 'xl\sharedStrings.xml')
$shared = @($sharedXml.sst.si | ForEach-Object { $_.InnerText })
[xml]$sheetXml = Get-Content -Raw -Encoding UTF8 (Join-Path $work 'xl\worksheets\sheet1.xml')
# Колонки листа: A — код КАТО, B — код области (ab), C/D/E — нижние уровни, G/H — название на казахском/русском.
$rows = foreach ($row in $sheetXml.worksheet.sheetData.row) {
$cells = @{}
foreach ($c in $row.c) {
$column = $c.r -replace '\d', ''
$value = if ($c.t -eq 's') { $shared[[int]$c.v] } elseif ($c.t -eq 'inlineStr') { $c.is.InnerText } else { $c.v }
$cells[$column] = "$value".Trim()
}
[pscustomobject]@{ Kato = $cells['A']; Region = $cells['B']; Level2 = $cells['C']; Level3 = $cells['D']; Level4 = $cells['E']; Name = $cells['H']; NameKz = $cells['G'] }
}
# Области — строки без нижних уровней. Города республиканского значения (Астана,
# Алматы, Шымкент) сами себе «область» — для них регион не показываем.
$regions = @{}
$rows | Where-Object { $_.Level2 -eq '00' -and $_.Level3 -eq '00' -and $_.Level4 -eq '000' } | ForEach-Object {
$regions[$_.Region] = if ($_.Name -match '^г\.') { '' } else { $_.Name }
}
# «г.Атырау», «п. Асыката» — населённые пункты; «п.а.», «г.а.» — администрации.
$cities = $rows |
Where-Object { $_.Name -match '^(г|п)\.\s*[^\s.]' -and $_.Name -notmatch '^(г|п)\.а\.' } |
ForEach-Object {
[pscustomobject]@{
Kato = $_.Kato
Name = ($_.Name -replace '^(г|п)\.\s*', '')
# «Құлсары қ.» / «Шаған к.» — тип пункта последним словом с точкой (в источнике
# встречается и опечатка «ќ.»), поэтому отрезаем любое такое слово.
NameKz = ($_.NameKz -replace '\s+\S+\.$', '')
Kind = if ($_.Name -match '^г\.') { 'city' } else { 'settlement' }
Region = $regions[$_.Region]
}
} |
Sort-Object Name, Region
$esc = { param($s) $s -replace '\\', '\\' -replace "'", "\'" }
$lines = $cities | ForEach-Object {
" { kato: '$($_.Kato)', name: '$(& $esc $_.Name)', nameKz: '$(& $esc $_.NameKz)', kind: '$($_.Kind)', region: '$(& $esc $_.Region)' },"
}
$source = [System.Uri]::UnescapeDataString(($XlsxUrl -split '/')[-1])
$content = @"
// СГЕНЕРИРОВАНО scripts/Build-KatoCities.ps1 — руками не править, перезапустить скрипт.
// Источник: КАТО, Бюро национальной статистики РК (stat.gov.kz), файл «$source».
import { IKatoCity } from './IKatoCity';
export const KATO_CITIES: ReadonlyArray<IKatoCity> = [
$($lines -join "`n")
];
"@
$outPath = [System.IO.Path]::GetFullPath($OutFile)
New-Item -ItemType Directory -Force -Path (Split-Path $outPath) | Out-Null
# Без BOM и с LF — как остальные исходники репозитория.
[System.IO.File]::WriteAllText($outPath, $content.Replace("`r`n", "`n") + "`n", (New-Object System.Text.UTF8Encoding $false))
$cityCount = @($cities | Where-Object Kind -eq 'city').Count
Write-Host "Готово: $($cities.Count) записей (городов $cityCount, посёлков $($cities.Count - $cityCount)) → $OutFile"
}
finally {
Remove-Item -Recurse -Force $work
}
@@ -53,6 +53,12 @@ export const REQUEST_TYPES: IRequestType[] = [
title: 'Заявка на услуги',
icon: 'document'
},
{
id: 'acc-payment-request',
categoryId: 'accounting',
title: 'Заявка на оплату',
icon: 'calculator'
},
{
id: 'wh-concrete-request',
categoryId: 'warehouse',
@@ -43,7 +43,7 @@ import { useStableCallback } from '../../shared/useStableCallback';
import { submitDateOnOpen } from '../../shared/submitDate';
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
import { draftIssues, submitIssues } from './validation';
import { syncRouteItems } from './estimate';
import { syncPerDiem } from './estimate';
import { saveBusinessTripRequest, businessTripApprovalService } from './submit';
import { loadBusinessTripRequest } from './load';
import { useTripEmployee } from './useTripEmployee';
@@ -86,10 +86,10 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Суточные и проживание строятся по маршруту (см. syncRouteItems): секции, проверки и сохранение
// Суточные строятся по маршруту (см. syncPerDiem): секции, проверки и сохранение
// работают со сметой, уже приведённой к текущим точкам и датам.
const formData: IFormData = { ...data, Estimate: syncRouteItems(data.Estimate, data.Route) };
const employee = useTripEmployee(context, editId, data.Employee, setData);
const formData: IFormData = { ...data, Estimate: syncPerDiem(data.Estimate, data.Route) };
const handleEmployeeChange = useTripEmployee(context, editId, setData);
const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]);
const requestTypeIdState = useRequestTypeId(context, requestType.id);
@@ -130,7 +130,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
// Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. validation.ts).
const draftMissing = draftIssues(formData);
const submitMissing = submitIssues(formData, employee.organizations);
const submitMissing = submitIssues(formData);
const validate = (): string | undefined => {
if (draftMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
@@ -204,7 +204,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
onDeleted: onCancel
});
const sectionProps = { data: formData, set, context, employee };
const sectionProps = { data: formData, set, context, onEmployeeChange: handleEmployeeChange };
return (
<FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}>
@@ -18,46 +18,13 @@
## Особое поведение
- **Можно подать за другого сотрудника.** По умолчанию — сам инициатор; при выборе другого
подтягиваются его должность и организации (`Employee/ByEmail`, `useTripEmployee`).
- **Командирующая организация** (с 10-23, логика — `tripCompany.ts`). Сотрудник может работать
в нескольких компаниях — выбирают, от какой он едет (`Select` «Компания — должность»).
Одна организация — выбирается сама, поле заблокировано. Несколько — обязательна при отправке,
должность пустая до выбора и подставляется из выбранной компании. Сотрудника нет в
справочнике — поля нет, отправку не блокирует. Смена сотрудника сбрасывает выбор. Хранение:
`TripCompany` (название) + `TripCompanyId` (CompanyId — под будущий маршрут по компании);
`Organization` — по-прежнему все организации сотрудника. Заявки до 10-23 в просмотре
показывают прежний список организаций.
- **Маршрут — лента точек** (`components/route`, логика — `route.ts`; гибрид «маршрут + смета»
по образцу Concur/Navan/TripIt). Город отправления → точки (город + объект из `/Project` +
даты прибытия/отъезда) → (возвращение — переключатель). Простая поездка — одна точка;
«+ Добавить точку маршрута» — для поездок по нескольким объектам.
- **Города — из справочника КАТО** (`shared/dictionary/kato`, поле `CityPicker`): 178 городов
и посёлков РК из официального КАТО (stat.gov.kz), сгенерированы `scripts/Build-KatoCities.ps1`.
Поиск по русскому и казахскому названию с учётом раскладки (Құлсары = Кулсары = Кульсары);
набранное целиком однозначное название подставляется само. Выбор сохраняет оба названия и
код КАТО (`city`/`cityKz`/`cityKato`, у города отправления — `origin`/`originKz`/`originKato`,
всё внутри `TripRouteJson`), показывается везде как «Кульсары (Құлсары)» (`cityLabel`), в том
числе в `TripCity`/`TripRoute` и заголовке. Нет в справочнике (село, заграница) — сохраняется
введённый текст без казахского названия.
- **Казахстан / другая страна — у каждой точки** (с 10-23, `SegmentedSwitch` в карточке точки):
признак у точки, а не у поездки — бывают смешанные маршруты (Алматы → Москва → Атырау).
За рубежом — «Страна» и «Город» вручную (`abroad`/`country` в `TripRouteJson`), подпись
«Китай, Пекин» (страна первой). Смена признака: город остаётся, за рубежом сбрасываются казахское название
и код КАТО, у точки по Казахстану — страна (`stopLocationPatch`). Новая точка — в той же
стране, что предыдущая. Город отправления — всегда из справочника (можно и свой текст).
Колонка `TripKind` («По Казахстану» / «Зарубежная»): хотя бы одна точка за рубежом —
зарубежная; пригодится для маршрута согласования и отчётов. Старые заявки — по Казахстану.
- Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д,
такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен.
Там же **предпочтительное время выезда** (с 10-23): «Утро / День / Вечер / Ночь»
(`legDepartureTime`, `DEPARTURE_TIMES`), тоже обязательно — нужно тревел-координатору под
билеты. На схеме — только в подсказке плашки транспорта, вёрстку схемы не трогали.
- **Переезд — внутри карточки точки** (с 10-23, `LegFields`): блок «Как добраться: Алматы →
Астана» внизу карточки точки, куда едут; обратный — под переключателем «Возвращение».
Раньше участок был отдельной строкой на линии маршрута — его пропускали при заполнении.
- Объект точки по Казахстану — только выбор из справочника (`projectId`): по объектам
разносят затраты. У зарубежной точки поле «Объект / место назначения» принимает и свой
текст (завод, выставка — в справочнике их нет), обязательно лишь непустое значение.
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`), обязателен.
- Объект точки — только выбор из справочника (`projectId`): по объектам разносят затраты.
- **Два вида маршрута** — переключатель «Форма | Схема» (`RouteViewSwitch`). Схема
(`RouteScheme`) — только просмотр, как схема метро: узлы-точки (город, объект, даты, ночи,
расходы по точке из сметы — `stopTotals`), на линиях — вид транспорта, внизу сводка
@@ -66,40 +33,24 @@
узкий — сверху вниз. Сумму под точками Тимур может попросить убрать, если будет громоздко.
- Новая точка начинается с даты отъезда из предыдущей (то, что человек уже ввёл);
прибытие раньше отъезда из предыдущей точки — ошибка.
- **Задним числом нельзя** (с 10-23): прибытие в любую точку — не раньше сегодняшнего дня
(сегодня можно). Прошедшие дни недоступны в календаре (`min`); набранную вручную или
«устаревшую» в черновике дату ловит `validation.ts` при отправке, черновик сохраняется.
- **Не дольше 40 дней** (с 10-23, `MAX_TRIP_DAYS`): от прибытия в первую точку до отъезда из
последней включительно; при отправке подсвечивается отъезд последней точки.
- Хранение: `TripRouteJson`; для отчётов из маршрута пишутся `TripRoute` (текстом),
`TripCity` (города), `Project`/`ProjectId` (объект ПЕРВОЙ точки — колонка у всех форм одна),
`TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период).
- **Задание** — нумерованный список задач, хранится в `TripTasksJson` (+ читаемая копия
`TripTasks` для представлений SP). Пустые строки не сохраняются.
- **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**, кроме
суточных и проживания — они строятся по точкам маршрута (`syncRouteItems`, `itemsFromRoute`
в `ESTIMATE_KINDS`), по позиции на точку:
- **Суточные считаются сами** (с 10-09, по просьбе кадров): город и проект — из точки,
суточных:
- **Суточные считаются сами** (с 10-09, по просьбе кадров): по позиции на каждую точку
маршрута (`syncPerDiem`, `itemsFromRoute` в `ESTIMATE_KINDS`), город и проект — из точки,
дни — из её дат включительно, но день переезда считается один раз (`stopPerDiemDays`).
Человек вводит только кол-во МРП в день; ставка = кол-во × `MRP_VALUE` (захардкожен в
model.ts вместе с `MRP_YEAR` — обновлять раз в год). Ставка сохраняется в заявке (`price`),
поэтому просмотр старой заявки не пересчитывается по новому МРП — `syncRouteItems`
применяется только в форме. **Лимит** (с 10-23): не больше 6 МРП в день по Казахстану и 8 за
рубежом (`MAX_MRP_PER_DAY`, по признаку точки) — подпись у поля, ошибка сразу под строкой
и при отправке.
- **Проживание** (с 10-23): ночи — из дат точки (`stopNights`), человек вводит только цену за
ночь. Цена необязательна (в точке могут жить на объекте). **Лимит** — 15 000 ₸ за ночь по
Казахстану; за рубежом `MAX_NIGHT_PRICE.abroad` пока `undefined` (без лимита) — вписать,
когда кадры назовут сумму. Кнопки «+ Ещё гостиница» и ввода «суммой» у проживания больше нет.
- **Дни/ночи можно поправить вручную** (с 10-23, `AutoQuantityField`): карандаш у поля
открывает ввод, число любое (форс-мажор — задержался в точке). Хранится в
`quantityManual` (`''` — по датам); под полем «вручную · по датам: N · вернуть». Даты
потом поменяли — ручное число остаётся, расхождение видно по подписи. В расчёте для
просмотра/печати/колонки SP — пометка «(вручную)».
поэтому просмотр старой заявки не пересчитывается по новому МРП — `syncPerDiem` применяется
только в форме.
- Статьи: основные Суточные / Проезд / Проживание (переключатель «Требуется / Не требуется»,
выключенная печатается «не требуется»), Страховка и Виза (по одной) и «Другой расход»
со своим названием (сколько угодно) — кнопками «+», удаляются корзиной.
- **Внутри статьи — позиции** («+ Ещё участок пути»; у суточных и проживания
- **Внутри статьи — позиции** («+ Ещё гостиница», «+ Ещё участок пути»; у суточных
позиции только из маршрута). Позиция привязана к точке маршрута (суточные, проживание, другой расход) или
к участку пути (проезд); страховка/виза — без привязки. Вариант один — выбор скрыт и
подставляется сам (`resolveLinkId`); точку удалили — позицию нужно привязать заново.
@@ -113,10 +64,7 @@
привязкой, у «Другого расхода» — название, у суточных — кол-во МРП.
- Хранение: `TripEstimateJson` (статьи + total), читаемо — `TripEstimate` (позиция на
строку), итог числом — `TripEstimateTotal`. Просмотр/печать — таблица по статьям.
- **Комментарий** (с 10-23, `TripComment`, Note) — необязательное поле после итога сметы; в
просмотре пустой не показывается.
- **Обязательство и согласие** (так названа секция с 10-23, по требованию кадров) — обязательство
по авансовому отчёту (текст бланка) и флажок согласия, без него не отправить.
- **Обязательство** по авансовому отчёту (текст бланка) — флажок, без него не отправить.
- **Дата подачи** (`SubmitDate`, общий столбец сайта) — ставится сегодняшней, пока заявку
можно править (см. shared/submitDate.ts).
- **Подсветка незаполненных полей** — `FieldIssuesProvider` + правила в `validation.ts`.
@@ -128,8 +76,8 @@
заголовком (см. грабли коллизий internalName).
## Секции формы
EmployeeSection (сотрудник · должность · дата подачи · командирующая организация) → RouteSection (маршрут
лентой; поле «Цель» убрано 10-23, колонка `TripPurpose` в SP осталась от тестовых заявок) → TasksSection (задачи) → EstimateSection (смета + итог) →
EmployeeSection (сотрудник · должность · дата подачи · организация) → RouteSection (маршрут
лентой + цель) → TasksSection (задачи) → EstimateSection (смета + итог) →
ObligationSection (текст обязательства + согласие).
## Точки входа
@@ -1,45 +0,0 @@
import * as React from 'react';
import styles from './ChoiceChips.module.scss';
import { useFormReadOnly } from '../../../shared/ui';
import { DEPARTURE_TIMES } from '../model';
interface Props {
/** Значение из DEPARTURE_TIMES; '' — не указано. */
value: string;
onChange: (value: string) => void;
/** Подпись группы для экранных дикторов, напр. «Время выезда: Алматы → Атырау». */
ariaLabel: string;
}
/**
* Предпочтительное время выезда плашками (обязательно — см. validation.ts).
* Повторный клик по выбранной плашке снимает выбор. В режиме чтения — только
* выбранная плашка.
*/
export const DepartureTimeChips: React.FC<Props> = ({ value, onChange, ariaLabel }) => {
const readOnly = useFormReadOnly();
if (readOnly) {
return <span className={`${styles.chip} ${styles.selected} ${styles.static}`}>{value || 'не указано'}</span>;
}
return (
<div className={styles.chips} role="radiogroup" aria-label={ariaLabel}>
{DEPARTURE_TIMES.map((time) => {
const selected = time === value;
return (
<button
key={time}
type="button"
role="radio"
aria-checked={selected}
className={selected ? `${styles.chip} ${styles.selected}` : styles.chip}
onClick={() => onChange(selected ? '' : time)}
>
{time}
</button>
);
})}
</div>
);
};
@@ -1,36 +0,0 @@
import * as React from 'react';
import styles from './SegmentedSwitch.module.scss';
export interface ISegmentedOption<T extends string> {
value: T;
label: string;
}
interface Props<T extends string> {
value: T;
options: ReadonlyArray<ISegmentedOption<T>>;
onChange: (value: T) => void;
/** Подпись группы для экранных дикторов. */
ariaLabel: string;
className?: string;
}
/** Сегментный переключатель из 2–3 вариантов: «Форма | Схема», «Казахстан | Другая страна». */
export function SegmentedSwitch<T extends string>({ value, options, onChange, ariaLabel, className }: Props<T>): JSX.Element {
return (
<div className={className ? `${styles.switch} ${className}` : styles.switch} role="radiogroup" aria-label={ariaLabel}>
{options.map((option) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={value === option.value}
className={value === option.value ? `${styles.option} ${styles.active}` : styles.option}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
</div>
);
}
@@ -1,5 +1,4 @@
@import '../../../../../styles/design-tokens';
// Плашки выбора в один клик: вид транспорта (TransportChips) и время выезда (DepartureTimeChips).
.chips {
display: flex;
@@ -1,5 +1,5 @@
import * as React from 'react';
import styles from './ChoiceChips.module.scss';
import styles from './TransportChips.module.scss';
import { useFormReadOnly } from '../../../shared/ui';
import { TRANSPORT_TYPES } from '../model';
import { TransportIcon } from './TransportIcon';
@@ -15,9 +15,8 @@ const PLANE =
'M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z';
const TRAIN =
'M12 2c-4 0-8 .5-8 4v9.5C4 17.43 5.57 19 7.5 19L6 20.5v.5h2.23l2-2H14l2 2h2v-.5L16.5 19c1.93 0 3.5-1.57 3.5-3.5V6c0-3.5-3.58-4-8-4zM7.5 17c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm3.5-7H6V6h5v4zm2 0V6h5v4h-5zm3.5 7c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z';
// local_taxi: та же машина, что CAR, но с шашкой на крыше — чтобы такси отличалось от служебного/личного авто.
const TAXI =
'M18.92 6.01C18.72 5.42 18.16 5 17.5 5H15V3H9v2H6.5c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z';
const BUS =
'M4 16c0 .88.39 1.67 1 2.22V20c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h8v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1.78c.61-.55 1-1.34 1-2.22V6c0-3.5-3.58-4-8-4s-8 .5-8 4v10zm3.5 1c-.83 0-1.5-.67-1.5-1.5S6.67 14 7.5 14s1.5.67 1.5 1.5S8.33 17 7.5 17zm9 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm1.5-6H6V6h12v5z';
const CAR =
'M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z';
@@ -25,7 +24,7 @@ const CAR =
const PATH_BY_TYPE: Record<string, string> = {
Авиа: PLANE,
Железнодорожный: TRAIN,
Такси: TAXI,
Автобус: BUS,
'Служебный автотранспорт': CAR,
'Личный автотранспорт': CAR
};
@@ -1,68 +0,0 @@
import * as React from 'react';
import styles from './StopItemFields.module.scss';
import { FormField, MoneyInput } from '../../../../shared/ui';
import { IEstimateItem, IRouteStop } from '../../model';
import { formatAmount, itemAmount, nightPriceLimit } from '../../estimate';
import { stopCityLabel } from '../../route';
import { AutoQuantityField } from './AutoQuantityField';
import { fieldKey } from '../../validation';
interface Props {
item: IEstimateItem;
/** Точка маршрута позиции; проживание строится по точкам (см. syncRouteItems). */
stop: IRouteStop | undefined;
/** Дни/ночи по датам точки (routeItemAutoQuantity) — рядом с ручным значением. */
autoQuantity: string;
onChange: (patch: Partial<IEstimateItem>) => void;
}
/**
* Проживание в одной точке маршрута: ночи — из дат точки (или вручную — карандаш,
* см. AutoQuantityField), человек вводит цену за ночь (не выше лимита — см.
* nightPriceLimit). Цену можно не вводить — в точке могут жить на объекте, тогда
* позиция просто 0.
*/
export const AccommodationFields: React.FC<Props> = ({ item, stop, autoQuantity, onChange }) => {
const hasNights = item.quantity !== '';
const limit = nightPriceLimit(stop);
const isOverLimit = limit !== undefined && Number(item.price) > limit;
const priceLabel = limit === undefined ? 'Цена за ночь' : `Цена за ночь (до ${formatAmount(limit)} ₸)`;
return (
<div className={styles.wrap}>
<div className={styles.place}>
<span className={styles.city}>{(stop && stopCityLabel(stop)) || 'Точка маршрута'}</span>
</div>
<div className={`${styles.calcRow} ${styles.calcRowShort}`}>
<AutoQuantityField
id={fieldKey.itemQuantity(item)}
label="Ночей"
autoValue={autoQuantity}
manualValue={item.quantityManual}
onManualChange={(v) => onChange({ quantityManual: v })}
/>
<span className={styles.sign} aria-hidden>
×
</span>
<FormField label={priceLabel} htmlFor={fieldKey.itemPrice(item)}>
<div className={styles.number}>
<MoneyInput id={fieldKey.itemPrice(item)} value={item.price} onChange={(v) => onChange({ price: v })} />
</div>
</FormField>
<span className={styles.sign} aria-hidden>
=
</span>
<div className={styles.result}>
<span className={styles.resultLabel}>Итого</span>
<span className={styles.resultValue}>{formatAmount(itemAmount(item))} ₸</span>
</div>
</div>
{isOverLimit && limit !== undefined && (
<div className={styles.limitError}>Не больше {formatAmount(limit)} ₸ за ночь по Казахстану.</div>
)}
{!hasNights && <div className={styles.hint}>Укажите даты точки в маршруте — по ним считаются ночи.</div>}
</div>
);
};
@@ -1,75 +0,0 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import styles from './StopItemFields.module.scss';
import { FormField, Icon, MoneyInput, useFormReadOnly } from '../../../../shared/ui';
interface Props {
id: string;
/** «Дней» / «Ночей». */
label: string;
/** Посчитано по датам точки; '' — даты не заданы. */
autoValue: string;
/** Введено вручную; '' — берём autoValue. */
manualValue: string;
onManualChange: (value: string) => void;
}
/**
* Дни (суточные) или ночи (проживание): по умолчанию считаются по датам точки и
* не редактируются; карандаш открывает ручной ввод — на случай форс-мажора
* (задержался в точке). Ручное число любое; рядом видно, сколько по датам, и
* его можно сбросить обратно к расчёту.
*/
export const AutoQuantityField: React.FC<Props> = ({ id, label, autoValue, manualValue, onManualChange }) => {
const readOnly = useFormReadOnly();
// Отдельно от manualValue: пока человек стёр число и печатает новое, поле не должно схлопываться.
const [editing, setEditing] = useState(false);
const isManual = manualValue !== '';
const showInput = !readOnly && (editing || isManual);
// Карандаш нажали — сразу ставим курсор в поле.
useEffect(() => {
if (editing) document.getElementById(id)?.focus();
}, [editing, id]);
const startEditing = (): void => {
onManualChange(autoValue);
setEditing(true);
};
const resetToAuto = (): void => {
onManualChange('');
setEditing(false);
};
return (
<FormField label={label} htmlFor={id}>
{showInput ? (
<div className={styles.number} onBlur={() => !isManual && setEditing(false)}>
<MoneyInput id={id} value={manualValue} onChange={onManualChange} />
</div>
) : (
<div className={styles.autoValue}>
<div className={styles.readonly} title="Считается по датам точки в маршруте">
{(isManual ? manualValue : autoValue) || '—'}
</div>
{!readOnly && (
<button type="button" className={styles.iconButton} title="Изменить вручную" onClick={startEditing}>
<Icon name="edit" size="sm" />
</button>
)}
</div>
)}
{isManual && (
<span className={styles.manualNote}>
вручную · по датам: {autoValue || '—'}
{!readOnly && (
<button type="button" className={styles.resetButton} onClick={resetToAuto}>
<Icon name="refresh" size="sm" />
вернуть
</button>
)}
</span>
)}
</FormField>
);
};
@@ -3,11 +3,10 @@ import styles from './EstimateItemRow.module.scss';
import { FormField, Icon, Select, TextInput } from '../../../../shared/ui';
import type { ISelectOption } from '../../../../shared/ui';
import { ESTIMATE_KINDS, EstimateKind, IEstimateItem, ITripRoute } from '../../model';
import { linkOptions, resolveLinkId, routeItemAutoQuantity } from '../../estimate';
import { linkOptions, resolveLinkId } from '../../estimate';
import { fieldKey } from '../../validation';
import { AmountFields } from './AmountFields';
import { PerDiemFields } from './PerDiemFields';
import { AccommodationFields } from './AccommodationFields';
interface Props {
kind: EstimateKind;
@@ -22,15 +21,13 @@ interface Props {
* Позиция статьи: к чему относится (точка маршрута / участок пути), сумма и
* пояснение. Выбор «к чему относится» показываем, только когда вариантов
* больше одного — в простой поездке в одну точку выбирать нечего. Суточные
* (и проживание) привязаны к точке раз и навсегда (строятся по маршруту) — у них свои поля.
* привязаны к точке раз и навсегда (строятся по маршруту) — у них свои поля.
*/
export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange, onRemove }) => {
const info = ESTIMATE_KINDS[kind];
const options: ISelectOption[] = linkOptions(kind, route).map((o) => ({ value: o.id, label: o.label }));
const showLink = !info.itemsFromRoute && options.length > 1;
const linkLabel = info.link === 'leg' ? 'Участок пути' : 'Точка маршрута';
const stop = route.stops.find((s) => s.id === item.linkId);
const autoQuantity = routeItemAutoQuantity(kind, route, item.linkId);
return (
<div className={styles.row}>
@@ -56,9 +53,11 @@ export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange,
)}
</div>
)}
{kind === 'perDiem' && <PerDiemFields item={item} stop={stop} autoQuantity={autoQuantity} onChange={onChange} />}
{kind === 'accommodation' && <AccommodationFields item={item} stop={stop} autoQuantity={autoQuantity} onChange={onChange} />}
{!info.itemsFromRoute && <AmountFields kind={kind} item={item} onChange={onChange} />}
{info.itemsFromRoute ? (
<PerDiemFields item={item} stop={route.stops.find((s) => s.id === item.linkId)} onChange={onChange} />
) : (
<AmountFields kind={kind} item={item} onChange={onChange} />
)}
<TextInput
value={item.comment}
onChange={(v) => onChange({ comment: v })}
@@ -1,6 +1,5 @@
@import '../../../../../../styles/design-tokens';
// Общие стили позиций «по точке маршрута»: суточные (PerDiemFields) и проживание (AccommodationFields).
// С этой ширины «дни × МРП × значение = итог» помещается в одну строку.
$calc-inline-breakpoint: 640px;
@@ -116,78 +115,3 @@ $calc-inline-breakpoint: 640px;
padding-bottom: 8px;
}
}
// Превышен лимит (МРП, цена за ночь) — сразу под строкой, не дожидаясь отправки.
.limitError {
font-family: $font-sans;
font-size: $fs-sm;
color: $red-500;
}
// Проживание: «ночи × цена = итог» — на две колонки короче, чем у суточных.
@media (min-width: $calc-inline-breakpoint) {
.calcRowShort {
grid-template-columns: minmax(0, 0.6fr) auto minmax(0, 1fr) auto minmax(120px, auto);
}
}
// Дни/ночи по датам + карандаш «изменить вручную» (AutoQuantityField).
.autoValue {
display: flex;
align-items: center;
gap: $sp-1;
.readonly {
flex: 1;
min-width: 0;
}
}
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 6px;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-500;
cursor: pointer;
transition: .18s;
&:hover {
background: $gray-100;
color: $navy-900;
}
&:focus-visible {
outline: 2px solid $gold-500;
outline-offset: 1px;
}
}
// «вручную · по датам: 4 · вернуть» — под полем, мелко: число правили руками.
.manualNote {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-1 $sp-2;
font-family: $font-sans;
font-size: $fs-caption;
color: $amber-700;
}
.resetButton {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
border: 0;
background: transparent;
color: $navy-700;
font-family: $font-sans;
font-size: $fs-caption;
text-decoration: underline;
cursor: pointer;
}
@@ -1,51 +1,42 @@
import * as React from 'react';
import styles from './StopItemFields.module.scss';
import styles from './PerDiemFields.module.scss';
import { FormField, MoneyInput } from '../../../../shared/ui';
import { IEstimateItem, IRouteStop, MRP_VALUE, MRP_YEAR } from '../../model';
import { formatAmount, itemAmount, mrpLimit } from '../../estimate';
import { stopCityLabel } from '../../route';
import { AutoQuantityField } from './AutoQuantityField';
import { formatAmount, itemAmount } from '../../estimate';
import { fieldKey } from '../../validation';
interface Props {
item: IEstimateItem;
/** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncRouteItems). */
/** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncPerDiem). */
stop: IRouteStop | undefined;
/** Дни/ночи по датам точки (routeItemAutoQuantity) — рядом с ручным значением. */
autoQuantity: string;
onChange: (patch: Partial<IEstimateItem>) => void;
}
/**
* Суточные одной точки маршрута: город и проект — из точки, дни — из её дат (или
* вручную — карандаш, см. AutoQuantityField), человек вводит количество МРП в
* день (не больше лимита — см. mrpLimit). Итог = дни × МРП × MRP_VALUE.
* Суточные одной точки маршрута: город и проект — из точки, дни — из её дат,
* человек вводит только количество МРП в день. Итог = дни × МРП × MRP_VALUE.
*/
export const PerDiemFields: React.FC<Props> = ({ item, stop, autoQuantity, onChange }) => {
export const PerDiemFields: React.FC<Props> = ({ item, stop, onChange }) => {
const hasDays = item.quantity !== '';
const project = stop?.project.trim();
const limit = mrpLimit(stop);
const isOverLimit = Number(item.mrpCount) > limit;
return (
<div className={styles.wrap}>
<div className={styles.place}>
<span className={styles.city}>{(stop && stopCityLabel(stop)) || 'Точка маршрута'}</span>
<span className={styles.city}>{stop?.city.trim() || 'Точка маршрута'}</span>
<span className={styles.project}>Проект: {project || 'не выбран'}</span>
</div>
<div className={styles.calcRow}>
<AutoQuantityField
id={fieldKey.itemQuantity(item)}
label="Дней"
autoValue={autoQuantity}
manualValue={item.quantityManual}
onManualChange={(v) => onChange({ quantityManual: v })}
/>
<FormField label="Дней">
<div className={styles.readonly} title="Считается по датам точки в маршруте">
{hasDays ? item.quantity : '—'}
</div>
</FormField>
<span className={styles.sign} aria-hidden>
×
</span>
<FormField label={`Кол-во МРП в день (до ${limit})`} htmlFor={fieldKey.itemMrpCount(item)}>
<FormField label="Кол-во МРП в день" htmlFor={fieldKey.itemMrpCount(item)}>
<div className={styles.number}>
<MoneyInput id={fieldKey.itemMrpCount(item)} value={item.mrpCount} onChange={(v) => onChange({ mrpCount: v })} />
</div>
@@ -65,11 +56,6 @@ export const PerDiemFields: React.FC<Props> = ({ item, stop, autoQuantity, onCha
</div>
</div>
{isOverLimit && (
<div className={styles.limitError}>
Не больше {limit} МРП в день {stop?.abroad ? 'за рубежом' : 'по Казахстану'}.
</div>
)}
{!hasDays && <div className={styles.hint}>Укажите даты точки в маршруте — по ним считаются дни.</div>}
</div>
);
@@ -1,18 +0,0 @@
@import '../../../../../../styles/design-tokens';
// Переезд внутри карточки точки — отдельная плашка с фоном, чтобы её не пропускали.
.leg {
display: flex;
flex-direction: column;
gap: $sp-3;
padding: $sp-3;
border-radius: $r-sm;
background: $gray-50;
}
.title {
font-family: $font-sans;
font-size: $fs-sm;
font-weight: 600;
color: $navy-900;
}
@@ -1,32 +0,0 @@
import * as React from 'react';
import styles from './LegFields.module.scss';
import { FormField } from '../../../../shared/ui';
import { IRouteLeg, legLabel } from '../../route';
import { fieldKey } from '../../validation';
import { TransportChips } from '../TransportChips';
import { DepartureTimeChips } from '../DepartureTimeChips';
interface Props {
leg: IRouteLeg;
transport: string;
onTransportChange: (value: string) => void;
departureTime: string;
onDepartureTimeChange: (value: string) => void;
}
/**
* Переезд «как добраться»: вид транспорта и время выезда (оба обязательны).
* Живёт внутри карточки точки, куда едут (обратный — в блоке «Возвращение»):
* отдельной строкой на линии маршрута его пропускали при заполнении.
*/
export const LegFields: React.FC<Props> = ({ leg, transport, onTransportChange, departureTime, onDepartureTimeChange }) => (
<div className={styles.leg}>
<span className={styles.title}>Как добраться: {legLabel(leg)}</span>
<FormField label="Вид транспорта" fieldKey={fieldKey.legTransport(leg.id)} required>
<TransportChips value={transport} onChange={onTransportChange} ariaLabel={`Вид транспорта: ${legLabel(leg)}`} />
</FormField>
<FormField label="Время выезда" fieldKey={fieldKey.legDepartureTime(leg.id)} required>
<DepartureTimeChips value={departureTime} onChange={onDepartureTimeChange} ariaLabel={`Время выезда: ${legLabel(leg)}`} />
</FormField>
</div>
);
@@ -108,11 +108,7 @@ $marker-size: 26px;
}
}
// Возвращение: переключатель, а под ним — обратный переезд (транспорт, время выезда).
.end {
display: flex;
flex-direction: column;
gap: $sp-3;
padding-top: $sp-2;
}
@@ -1,13 +1,12 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './RouteEditor.module.scss';
import { FormField, Icon, Toggle, useFormReadOnly } from '../../../../shared/ui';
import { CityPicker } from '../../../../shared/dictionary';
import { CITY_PLACEHOLDER, IRouteStop, ITripRoute } from '../../model';
import { IRouteLeg, createStop, originLabel, routeLegs, withoutStopLegs } from '../../route';
import { FormField, Icon, TextInput, Toggle, useFormReadOnly } from '../../../../shared/ui';
import { IRouteStop, ITripRoute } from '../../model';
import { IRouteLeg, createStop, routeLegs } from '../../route';
import { fieldKey } from '../../validation';
import { RouteStopCard } from './RouteStopCard';
import { LegFields } from './LegFields';
import { RouteLegRow } from './RouteLegRow';
interface Props {
route: ITripRoute;
@@ -33,10 +32,9 @@ const TimelineRow: React.FC<{ marker: MarkerKind; label?: string; children: Reac
/**
* Маршрут командировки лентой: город отправления → точки (город + объект +
* даты) → обратно. Переезд (транспорт, время выезда) — внутри карточки точки, куда
* едут, обратный — в блоке «Возвращение»: отдельной строкой на линии его
* пропускали. Участки не хранятся, а выводятся из точек (route.ts/routeLegs).
* Простая поездка — одна точка; «+ Добавить точку» — для поездок по нескольким объектам.
* даты) → обратно. Между точками — участки пути с видом транспорта; участки
* не хранятся, а выводятся из точек (route.ts/routeLegs). Простая поездка — одна
* точка; «+ Добавить точку» — для поездок по нескольким объектам.
*/
export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
const readOnly = useFormReadOnly();
@@ -48,28 +46,24 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
onChange({ ...route, stops: route.stops.map((s) => (s.id === id ? { ...s, ...patch } : s)) });
const addStop = (): void =>
onChange({ ...route, stops: [...route.stops, createStop(route.stops[route.stops.length - 1])] });
// Транспорт и время выезда участков, которые шли через удалённую точку, больше ни к чему не относятся.
const removeStop = (id: string): void =>
onChange({
...route,
stops: route.stops.filter((s) => s.id !== id),
legTransport: withoutStopLegs(route.legTransport, id),
legDepartureTime: withoutStopLegs(route.legDepartureTime, id)
});
const removeStop = (id: string): void => {
// Виды транспорта участков, которые шли через удалённую точку, больше ни к чему не относятся.
const legTransport: Record<string, string> = {};
Object.keys(route.legTransport)
.filter((key) => key.split('>').indexOf(id) === -1)
.forEach((key) => {
legTransport[key] = route.legTransport[key];
});
onChange({ ...route, stops: route.stops.filter((s) => s.id !== id), legTransport });
};
const setTransport = (id: string, value: string): void =>
onChange({ ...route, legTransport: { ...route.legTransport, [id]: value } });
const setDepartureTime = (id: string, value: string): void =>
onChange({ ...route, legDepartureTime: { ...route.legDepartureTime, [id]: value } });
const legFields = (leg: IRouteLeg | undefined): React.ReactNode =>
const legRow = (leg: IRouteLeg | undefined): React.ReactNode =>
leg && (
<LegFields
leg={leg}
transport={route.legTransport[leg.id] || ''}
onTransportChange={(v) => setTransport(leg.id, v)}
departureTime={route.legDepartureTime[leg.id] || ''}
onDepartureTimeChange={(v) => setDepartureTime(leg.id, v)}
/>
<TimelineRow marker="line">
<RouteLegRow leg={leg} transport={route.legTransport[leg.id] || ''} onTransportChange={(v) => setTransport(leg.id, v)} />
</TimelineRow>
);
return (
@@ -77,27 +71,29 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
<TimelineRow marker="endpoint">
<div className={styles.origin}>
<FormField label="Откуда (город отправления)" htmlFor={fieldKey.origin} required>
<CityPicker
<TextInput
id={fieldKey.origin}
value={{ name: route.origin, nameKz: route.originKz, kato: route.originKato }}
onChange={(city) => onChange({ ...route, origin: city.name, originKz: city.nameKz, originKato: city.kato })}
placeholder={CITY_PLACEHOLDER}
value={route.origin}
onChange={(v) => onChange({ ...route, origin: v })}
placeholder="Напр.: Алматы"
/>
</FormField>
</div>
</TimelineRow>
{route.stops.map((stop, i) => (
<TimelineRow key={stop.id} marker="stop" label={String(i + 1)}>
<RouteStopCard
stop={stop}
index={i}
context={context}
onChange={(patch) => updateStop(stop.id, patch)}
onRemove={!readOnly && route.stops.length > 1 ? () => removeStop(stop.id) : undefined}
inbound={legFields(legTo(stop.id))}
/>
</TimelineRow>
<React.Fragment key={stop.id}>
{legRow(legTo(stop.id))}
<TimelineRow marker="stop" label={String(i + 1)}>
<RouteStopCard
stop={stop}
index={i}
context={context}
onChange={(patch) => updateStop(stop.id, patch)}
onRemove={!readOnly && route.stops.length > 1 ? () => removeStop(stop.id) : undefined}
/>
</TimelineRow>
</React.Fragment>
))}
{!readOnly && (
@@ -109,21 +105,21 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
</TimelineRow>
)}
{legRow(returnLeg)}
<TimelineRow marker="endpoint">
<div className={styles.end}>
{readOnly ? (
<span className={styles.endText}>
{route.returnToOrigin ? `Возвращение: ${originLabel(route) || 'город отправления'}` : 'Без возвращения в город отправления'}
{route.returnToOrigin ? `Возвращение: ${route.origin || 'город отправления'}` : 'Без возвращения в город отправления'}
</span>
) : (
<Toggle
checked={route.returnToOrigin}
onChange={(returnToOrigin) => onChange({ ...route, returnToOrigin })}
onLabel={`Возвращение в ${originLabel(route) || 'город отправления'}`}
onLabel={`Возвращение в ${route.origin.trim() || 'город отправления'}`}
/>
)}
{/* Обратный переезд — здесь: своей карточки точки у него нет. */}
{legFields(returnLeg)}
</div>
</TimelineRow>
</div>
@@ -0,0 +1,6 @@
@import '../../../../../../styles/design-tokens';
// Участок — без карточки: это «переезд» между точками, он лежит прямо на линии маршрута.
.leg {
padding-block: $sp-2;
}
@@ -0,0 +1,25 @@
import * as React from 'react';
import styles from './RouteLegRow.module.scss';
import { FormField } from '../../../../shared/ui';
import { IRouteLeg, legLabel } from '../../route';
import { fieldKey } from '../../validation';
import { TransportChips } from '../TransportChips';
interface Props {
leg: IRouteLeg;
transport: string;
onTransportChange: (value: string) => void;
}
/** Участок пути между точками на линии маршрута: «Алматы → Атырау» и вид транспорта. */
export const RouteLegRow: React.FC<Props> = ({ leg, transport, onTransportChange }) => (
<div className={styles.leg}>
<FormField label={legLabel(leg)} fieldKey={fieldKey.legTransport(leg.id)}>
<TransportChips
value={transport}
onChange={onTransportChange}
ariaLabel={`Вид транспорта: ${legLabel(leg)}`}
/>
</FormField>
</div>
);
@@ -1,7 +1,7 @@
import * as React from 'react';
import styles from './RouteScheme.module.scss';
import { IEstimateArticle, ITripRoute } from '../../model';
import { originLabel, routeLegs, routePeriod, routeProjects, stopCityLabel } from '../../route';
import { routeLegs, routePeriod, routeProjects } from '../../route';
import { estimateTotal, formatAmount, stopTotals } from '../../estimate';
import { plural, tripDays } from '../../tripData';
import { ISchemeNode } from './schemeNode';
@@ -18,14 +18,14 @@ interface Props {
/** Узлы схемы по порядку: город отправления → точки → (возвращение). */
function schemeNodes(route: ITripRoute, amounts: Map<string, number>): ISchemeNode[] {
const period = routePeriod(route);
const origin = originLabel(route);
const origin = route.origin.trim();
const withDate = (caption: string, iso: string): string => (iso ? `${caption} ${shortDate(iso)}` : caption);
const nodes: ISchemeNode[] = [
{ key: 'origin', kind: 'endpoint', city: origin, caption: withDate('выезд', period.start), amount: 0 }
];
route.stops.forEach((stop, i) =>
nodes.push({ key: stop.id, kind: 'stop', number: i + 1, stop, city: stopCityLabel(stop), amount: amounts.get(stop.id) || 0 })
nodes.push({ key: stop.id, kind: 'stop', number: i + 1, stop, city: stop.city.trim(), amount: amounts.get(stop.id) || 0 })
);
if (route.returnToOrigin) {
nodes.push({ key: 'return', kind: 'endpoint', city: origin, caption: withDate('возвращение', period.end), amount: 0 });
@@ -56,7 +56,7 @@ export const RouteScheme: React.FC<Props> = ({ route, articles, onSelectStop })
return (
<div className={styles.scheme}>
<SchemeTrack nodes={nodes} legs={legs} legTransport={route.legTransport} legDepartureTime={route.legDepartureTime} onSelectStop={onSelectStop} />
<SchemeTrack nodes={nodes} legs={legs} legTransport={route.legTransport} onSelectStop={onSelectStop} />
<div className={styles.stats}>{stats.join(' · ')}</div>
</div>
);
@@ -1,14 +1,11 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './RouteStopCard.module.scss';
import { FormField, Icon, TextInput, useFormReadOnly } from '../../../../shared/ui';
import { FormField, Icon, TextInput } from '../../../../shared/ui';
import { ProjectPicker } from '../../../../shared/dictionary';
import { IRouteStop, PROJECT_PLACEHOLDER } from '../../model';
import { stopLocationPatch, stopNights } from '../../route';
import { ISegmentedOption, SegmentedSwitch } from '../SegmentedSwitch';
import { StopPlaceFields } from './StopPlaceFields';
import { stopNights } from '../../route';
import { plural } from '../../tripData';
import { todaySubmitDate } from '../../../../shared/submitDate';
import { fieldKey } from '../../validation';
interface Props {
@@ -18,8 +15,6 @@ interface Props {
onChange: (patch: Partial<IRouteStop>) => void;
/** Не передан — точку удалить нельзя (она единственная). */
onRemove?: () => void;
/** Переезд в эту точку («Как добраться») — собирает RouteEditor, он знает участки. */
inbound?: React.ReactNode;
}
/** id карточки точки в DOM — к ней прокручивает клик по точке на схеме маршрута. */
@@ -27,20 +22,8 @@ export function stopCardDomId(stopId: string): string {
return `route-stop-${stopId}`;
}
type StopLocation = 'domestic' | 'abroad';
const LOCATIONS: ReadonlyArray<ISegmentedOption<StopLocation>> = [
{ value: 'domestic', label: 'Казахстан' },
{ value: 'abroad', label: 'Другая страна' }
];
/**
* Точка маршрута: Казахстан или другая страна, город, объект из справочника, даты
* пребывания и переезд сюда (транспорт, время выезда). В режиме чтения переключатель
* не нужен — страна видна в полях.
*/
export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange, onRemove, inbound }) => {
const readOnly = useFormReadOnly();
/** Точка маршрута: город, объект из справочника и даты пребывания. */
export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange, onRemove }) => {
const nights = stopNights(stop);
return (
@@ -56,23 +39,11 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
</button>
)}
</div>
{!readOnly && (
<SegmentedSwitch
value={stop.abroad ? 'abroad' : 'domestic'}
options={LOCATIONS}
onChange={(location) => onChange(stopLocationPatch(location === 'abroad'))}
ariaLabel={`Где точка ${index + 1}`}
/>
)}
<div className={styles.grid}>
<StopPlaceFields stop={stop} onChange={onChange} />
{/* За рубежом страна и город занимают строку — объект встаёт на всю ширину следующей. */}
<FormField
label={stop.abroad ? 'Объект / место назначения' : 'Объект'}
htmlFor={fieldKey.stopProject(stop)}
required
full={stop.abroad}
>
<FormField label="Город, населённый пункт" htmlFor={fieldKey.stopCity(stop)} required>
<TextInput id={fieldKey.stopCity(stop)} value={stop.city} onChange={(v) => onChange({ city: v })} />
</FormField>
<FormField label="Объект" htmlFor={fieldKey.stopProject(stop)} required>
<ProjectPicker
id={fieldKey.stopProject(stop)}
context={context}
@@ -87,8 +58,6 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
type="date"
value={stop.dateFrom}
onChange={(v) => onChange({ dateFrom: v })}
// Прошедшие дни в календаре недоступны; набранную вручную дату ловит validation.ts.
min={todaySubmitDate()}
/>
</FormField>
<FormField label="Отъезд" htmlFor={fieldKey.stopTo(stop)} required>
@@ -97,12 +66,10 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
type="date"
value={stop.dateTo}
onChange={(v) => onChange({ dateTo: v })}
min={stop.dateFrom || todaySubmitDate()}
min={stop.dateFrom || undefined}
/>
</FormField>
</div>
{/* После города и дат — тогда в заголовке переезда уже видно, куда едут. */}
{inbound}
</div>
);
};
@@ -1,6 +1,6 @@
@import '../../../../../styles/design-tokens';
@import '../../../../../../styles/design-tokens';
// Сегментный переключатель — тот же вид, что «Суммой / Кол-во × цена» в смете (AmountFields).
// Сегментный переключатель — тот же вид, что «Суммой / Кол-во × цена» в смете.
.switch {
display: inline-flex;
align-self: flex-start;
@@ -1,5 +1,5 @@
import * as React from 'react';
import { ISegmentedOption, SegmentedSwitch } from '../SegmentedSwitch';
import styles from './RouteViewSwitch.module.scss';
export type RouteViewMode = 'form' | 'scheme';
@@ -8,12 +8,25 @@ interface Props {
onChange: (mode: RouteViewMode) => void;
}
const MODES: ReadonlyArray<ISegmentedOption<RouteViewMode>> = [
{ value: 'form', label: 'Форма' },
{ value: 'scheme', label: 'Схема' }
const MODES: ReadonlyArray<{ mode: RouteViewMode; label: string }> = [
{ mode: 'form', label: 'Форма' },
{ mode: 'scheme', label: 'Схема' }
];
/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. На печати не нужен. */
/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. */
export const RouteViewSwitch: React.FC<Props> = ({ value, onChange }) => (
<SegmentedSwitch value={value} options={MODES} onChange={onChange} ariaLabel="Вид маршрута" className="rp-no-print" />
<div className={`${styles.switch} rp-no-print`} role="radiogroup" aria-label="Вид маршрута">
{MODES.map(({ mode, label }) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={value === mode}
className={value === mode ? `${styles.option} ${styles.active}` : styles.option}
onClick={() => onChange(mode)}
>
{label}
</button>
))}
</div>
);
@@ -39,7 +39,7 @@ export const SchemeNodeLabel: React.FC<Props> = ({ node }) => {
const dates = stopDates(stop);
return (
<span className={styles.text}>
<span className={styles.city}>{node.city || `Город ${NOT_SET}`}</span>
<span className={styles.city}>{stop.city.trim() || `Город ${NOT_SET}`}</span>
{/* Названия объектов в справочнике бывают с полным адресом — режем до 2 строк, полное — в подсказке. */}
<span className={styles.project} title={stop.project.trim() || undefined}>
{stop.project.trim() || 'объект не выбран'}
@@ -10,8 +10,6 @@ interface Props {
/** Участки между соседними узлами: legs[i] — от nodes[i] к nodes[i + 1]. */
legs: IRouteLeg[];
legTransport: Record<string, string>;
/** Время выезда по ключу участка — только в подсказке плашки, вёрстку схемы не меняет. */
legDepartureTime: Record<string, string>;
onSelectStop?: (stopId: string) => void;
}
@@ -79,7 +77,7 @@ const NodeCell: React.FC<{ node: ISchemeNode; onSelectStop?: (stopId: string) =>
};
/** Широкий экран: ветка слева направо — SVG по координатам + сетка подписей с теми же колонками. */
const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, legDepartureTime, onSelectStop }) => {
const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectStop }) => {
const count = nodes.length;
return (
<div className={styles.hTrack} style={{ minWidth: count * MIN_COLUMN_WIDTH }}>
@@ -118,7 +116,7 @@ const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, legDepart
</svg>
{legs.map((leg, i) => (
<span key={leg.id} className={styles.hBadge} style={{ left: `${((i + 1) / count) * 100}%` }}>
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} departureTime={legDepartureTime[leg.id] || ''} />
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} />
</span>
))}
</div>
@@ -132,7 +130,7 @@ const HorizontalTrack: React.FC<Props> = ({ nodes, legs, legTransport, legDepart
};
/** Телефон: ветка сверху вниз; линия — фоном колонки маркеров, без вычислений геометрии. */
const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, legDepartureTime, onSelectStop }) => (
const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, onSelectStop }) => (
<div className={styles.vTrack}>
{nodes.map((node, i) => {
const leg = legs[i];
@@ -153,7 +151,7 @@ const VerticalTrack: React.FC<Props> = ({ nodes, legs, legTransport, legDepartur
<div className={styles.vRow}>
<span className={styles.vRail} />
<span className={styles.vLeg}>
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} departureTime={legDepartureTime[leg.id] || ''} />
<TransportBadge leg={leg} transport={legTransport[leg.id] || ''} />
</span>
</div>
)}
@@ -1,51 +0,0 @@
import * as React from 'react';
import { FormField, TextInput } from '../../../../shared/ui';
import { CityPicker } from '../../../../shared/dictionary';
import { ABROAD_CITY_PLACEHOLDER, CITY_PLACEHOLDER, COUNTRY_PLACEHOLDER, IRouteStop } from '../../model';
import { fieldKey } from '../../validation';
interface Props {
stop: IRouteStop;
onChange: (patch: Partial<IRouteStop>) => void;
}
/**
* Где находится точка. По Казахстану — город из справочника КАТО (оба языка + код);
* за рубежом — страна и город вручную: справочник только по РК.
* Рендерит ячейки сетки карточки точки (без своей обёртки).
*/
export const StopPlaceFields: React.FC<Props> = ({ stop, onChange }) => {
if (!stop.abroad) {
return (
<FormField label="Город, населённый пункт" htmlFor={fieldKey.stopCity(stop)} required>
<CityPicker
id={fieldKey.stopCity(stop)}
value={{ name: stop.city, nameKz: stop.cityKz, kato: stop.cityKato }}
onChange={(city) => onChange({ city: city.name, cityKz: city.nameKz, cityKato: city.kato })}
placeholder={CITY_PLACEHOLDER}
/>
</FormField>
);
}
return (
<>
<FormField label="Страна" htmlFor={fieldKey.stopCountry(stop)} required>
<TextInput
id={fieldKey.stopCountry(stop)}
value={stop.country}
onChange={(v) => onChange({ country: v })}
placeholder={COUNTRY_PLACEHOLDER}
/>
</FormField>
<FormField label="Город" htmlFor={fieldKey.stopCity(stop)} required>
<TextInput
id={fieldKey.stopCity(stop)}
value={stop.city}
onChange={(v) => onChange({ city: v })}
placeholder={ABROAD_CITY_PLACEHOLDER}
/>
</FormField>
</>
);
};
@@ -7,20 +7,13 @@ import { TransportIcon } from '../TransportIcon';
interface Props {
leg: IRouteLeg;
transport: string;
/** Время выезда; '' — не указано. */
departureTime: string;
}
/**
* Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная и время
* выезда — в подсказке (на линию не помещаются).
*/
export const TransportBadge: React.FC<Props> = ({ leg, transport, departureTime }) => (
/** Плашка вида транспорта на линии схемы: иконка + короткая подпись; полная — в подсказке. */
export const TransportBadge: React.FC<Props> = ({ leg, transport }) => (
<span
className={transport ? styles.transport : `${styles.transport} ${styles.missing}`}
title={[`${leg.from} → ${leg.to}`, transport, departureTime && `выезд: ${departureTime.toLowerCase()}`]
.filter(Boolean)
.join(', ')}
title={`${leg.from} → ${leg.to}${transport ? `: ${transport}` : ''}`}
>
<TransportIcon type={transport} className={styles.transportIcon} />
{transport ? TRANSPORT_SHORT[transport] || transport : 'транспорт?'}
@@ -8,7 +8,7 @@ export interface ISchemeNode {
number?: number;
/** Сама точка — только у точки маршрута. */
stop?: IRouteStop;
/** Город на двух языках (см. route.ts/stopCityLabel): отправления — у endpoint, точки — у stop. */
/** Город отправления — у endpoint. */
city: string;
/** «выезд 02.10» / «возвращение 05.10» — у endpoint. */
caption?: string;
@@ -1,13 +1,4 @@
import {
EMPTY_ESTIMATE,
EMPTY_ROUTE,
IEstimateArticle,
IEstimateItem,
ITripRoute,
MAX_MRP_PER_DAY,
MAX_NIGHT_PRICE,
MRP_VALUE
} from './model';
import { EMPTY_ESTIMATE, EMPTY_ROUTE, IEstimateArticle, IEstimateItem, ITripRoute, MRP_VALUE } from './model';
import {
addItem,
articleAmount,
@@ -16,28 +7,22 @@ import {
estimateTotal,
formatEstimateText,
itemCalculation,
mrpLimit,
nightPriceLimit,
parseEstimate,
projectShares,
resolveLinkId,
routeItemAutoQuantity,
serializeEstimate,
stopTotals,
syncRouteItems
syncPerDiem
} from './estimate';
const ROUTE: ITripRoute = {
origin: 'Алматы',
originKz: '',
originKato: '',
stops: [
{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', city: 'Актау', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
],
returnToOrigin: true,
legTransport: {},
legDepartureTime: {}
legTransport: {}
};
const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({
@@ -49,7 +34,6 @@ const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({
price: '',
comment: '',
mrpCount: '',
quantityManual: '',
...patch
});
@@ -137,7 +121,7 @@ describe('суточные по маршруту', () => {
};
it('по позиции на точку; день переезда считается один раз', () => {
const [synced] = syncRouteItems([perDiem], ROUTE);
const [synced] = syncPerDiem([perDiem], ROUTE);
expect(synced.items.map((i) => [i.linkId, i.quantity])).toEqual([
['s1', '4'],
['s2', '2']
@@ -145,58 +129,32 @@ describe('суточные по маршруту', () => {
});
it('ставка — кол-во МРП × МРП; введённое за точкой сохраняется', () => {
const [synced] = syncRouteItems([perDiem], ROUTE);
const [synced] = syncPerDiem([perDiem], ROUTE);
expect(synced.items[0]).toMatchObject({ id: 'p1', mrpCount: '6', comment: 'объект', price: String(6 * MRP_VALUE) });
expect(articleAmount(synced)).toBe(4 * 6 * MRP_VALUE);
});
it('новая точка — пустая позиция со стабильным id; удалённая точка — позиция уходит', () => {
const [synced] = syncRouteItems([perDiem], ROUTE);
const [synced] = syncPerDiem([perDiem], ROUTE);
expect(synced.items[1]).toMatchObject({ id: 'perDiem-s2', mrpCount: '', price: '' });
const [oneStop] = syncRouteItems([perDiem], { ...ROUTE, stops: [ROUTE.stops[1]] });
const [oneStop] = syncPerDiem([perDiem], { ...ROUTE, stops: [ROUTE.stops[1]] });
expect(oneStop.items.map((i) => i.linkId)).toEqual(['s2']);
});
it('без дат дней нет — и суммы нет', () => {
const noDates = { ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo: '' }] };
const [synced] = syncRouteItems([perDiem], noDates);
const [synced] = syncPerDiem([perDiem], noDates);
expect(synced.items[0].quantity).toBe('');
expect(articleAmount(synced)).toBe(0);
});
it('статьи с ручными позициями не трогает', () => {
expect(syncRouteItems([travel], ROUTE)[0]).toBe(travel);
});
it('проживание: позиция на точку, ночи из дат, введённая цена сохраняется', () => {
const [synced] = syncRouteItems([hotels], ROUTE);
expect(synced.items.map((i) => [i.linkId, i.quantity, i.price])).toEqual([
['s1', '3', '36000'],
['s2', '2', '35000']
]);
expect(synced.items[0]).toMatchObject({ id: 'h1', mode: 'calc', comment: 'Ренессанс' });
});
it('дни/ночи вручную — любое число поверх расчёта по датам; пусто — снова по датам', () => {
const manual = { ...perDiem, items: [{ ...perDiem.items[0], quantityManual: '7' }] };
const [synced] = syncRouteItems([manual], ROUTE);
expect(synced.items[0].quantity).toBe('7');
expect(articleAmount({ ...synced, items: [synced.items[0]] })).toBe(7 * 6 * MRP_VALUE);
expect(itemCalculation('perDiem', synced.items[0])).toContain('7 дней (вручную)');
const [auto] = syncRouteItems([{ ...manual, items: [{ ...manual.items[0], quantityManual: '' }] }], ROUTE);
expect(auto.items[0].quantity).toBe('4');
expect(routeItemAutoQuantity('accommodation', ROUTE, 's1')).toBe('3');
});
it('лимиты зависят от страны точки', () => {
const abroad = { ...ROUTE.stops[0], abroad: true };
expect([mrpLimit(ROUTE.stops[0]), mrpLimit(abroad)]).toEqual([MAX_MRP_PER_DAY.domestic, MAX_MRP_PER_DAY.abroad]);
expect([nightPriceLimit(ROUTE.stops[0]), nightPriceLimit(abroad)]).toEqual([MAX_NIGHT_PRICE.domestic, MAX_NIGHT_PRICE.abroad]);
it('другие статьи не трогает', () => {
expect(syncPerDiem([hotels], ROUTE)[0]).toBe(hotels);
});
it('расчёт в тексте — с МРП, по сохранённой ставке', () => {
const NBSP = String.fromCharCode(160);
const [synced] = syncRouteItems([perDiem], ROUTE);
const [synced] = syncPerDiem([perDiem], ROUTE);
expect(itemCalculation('perDiem', synced.items[0]).split(NBSP).join(' ')).toBe(
`4 дня × 6 МРП × ${new Intl.NumberFormat('ru-RU').format(MRP_VALUE).split(NBSP).join(' ')} ₸`
);
@@ -6,13 +6,10 @@ import {
EstimateKind,
IEstimateArticle,
IEstimateItem,
IRouteStop,
ITripRoute,
MAX_MRP_PER_DAY,
MAX_NIGHT_PRICE,
MRP_VALUE
} from './model';
import { legLabel, newId, routeLegs, stopLabel, stopNights, stopPerDiemDays } from './route';
import { legLabel, newId, routeLegs, stopLabel, stopPerDiemDays } from './route';
import { plural } from './tripData';
/** Цифры из поля ввода числом: '' или мусор — 0. MoneyInput хранит только цифры. */
@@ -57,15 +54,10 @@ function itemMrpValue(item: IEstimateItem): number {
}
/** Расчёт суточных: «4 дня × 6 МРП × 4 325 ₸»; дни или МРП не заданы — ''. */
/** Пометка у дней/ночей, введённых вручную, — согласующему важно видеть, что их правили. */
function manualMark(item: IEstimateItem): string {
return item.quantityManual ? ' (вручную)' : '';
}
function perDiemCalculation(item: IEstimateItem): string {
if (!item.quantity || !toNumber(item.mrpCount)) return '';
const days = plural(toNumber(item.quantity), ['день', 'дня', 'дней']);
return `${days}${manualMark(item)} × ${item.mrpCount} МРП × ${formatAmount(itemMrpValue(item))} ₸`;
return `${days} × ${item.mrpCount} МРП × ${formatAmount(itemMrpValue(item))} ₸`;
}
/**
@@ -74,9 +66,9 @@ function perDiemCalculation(item: IEstimateItem): string {
* понятно из статьи. Суточные — исключение: там важно показать МРП.
*/
export function itemCalculation(kind: EstimateKind, item: IEstimateItem): string {
if (kind === 'perDiem') return perDiemCalculation(item);
if (ESTIMATE_KINDS[kind].itemsFromRoute) return perDiemCalculation(item);
if (item.mode !== 'calc' || !item.quantity || !item.price) return '';
return `${formatAmount(toNumber(item.quantity))}${manualMark(item)} × ${formatAmount(toNumber(item.price))} ₸`;
return `${formatAmount(toNumber(item.quantity))} × ${formatAmount(toNumber(item.price))} ₸`;
}
// ---------------------------------------------------------------------------
@@ -124,8 +116,7 @@ export function createItem(kind: EstimateKind, linkId: string): IEstimateItem {
quantity: '',
price: '',
comment: '',
mrpCount: '',
quantityManual: ''
mrpCount: ''
};
}
@@ -141,22 +132,9 @@ export function createArticle(kind: EstimateKind, route: ITripRoute): IEstimateA
}
// ---------------------------------------------------------------------------
// Суточные и проживание — строятся по маршруту
// Суточные — строятся по маршруту
// ---------------------------------------------------------------------------
/** Сколько МРП в день можно поставить в точке: по Казахстану и за рубежом лимиты разные. */
export function mrpLimit(stop: IRouteStop | undefined): number {
return stop?.abroad ? MAX_MRP_PER_DAY.abroad : MAX_MRP_PER_DAY.domestic;
}
/** Предельная цена ночи в точке, ₸; undefined — без лимита (за рубежом пока не задан). */
export function nightPriceLimit(stop: IRouteStop | undefined): number | undefined {
return stop?.abroad ? MAX_NIGHT_PRICE.abroad : MAX_NIGHT_PRICE.domestic;
}
/** Число или пустая строка для поля: undefined (дат нет) — ''. */
const countText = (count: number | undefined): string => (count === undefined ? '' : String(count));
/** Ставка суточных в день цифрами: кол-во МРП × МРП. Кол-во не введено — ''. */
function perDiemRate(mrpCount: string): string {
const count = toNumber(mrpCount);
@@ -164,39 +142,26 @@ function perDiemRate(mrpCount: string): string {
}
/**
* Дни/ночи позиции «по маршруту» по датам точки: суточные — дни (день переезда
* один раз), проживание — ночи. '' — даты точки не заданы или точки нет.
*/
export function routeItemAutoQuantity(kind: EstimateKind, route: ITripRoute, stopId: string): string {
const index = route.stops.findIndex((s) => s.id === stopId);
if (index === -1) return '';
return kind === 'accommodation' ? countText(stopNights(route.stops[index])) : countText(stopPerDiemDays(route, index));
}
/**
* Количество и цена позиции «по маршруту»: дни/ночи — введённые вручную, иначе по
* датам точки; цена — суточные: кол-во МРП × MRP_VALUE, проживание: что ввёл человек.
*/
function routeItemFigures(kind: EstimateKind, base: IEstimateItem, route: ITripRoute, stopId: string): Partial<IEstimateItem> {
const quantity = base.quantityManual || routeItemAutoQuantity(kind, route, stopId);
return { quantity, price: kind === 'accommodation' ? base.price : perDiemRate(base.mrpCount) };
}
/**
* Позиции статей «по маршруту» (суточные, проживание) приводятся к маршруту:
* ровно одна позиция на точку, дни/ночи — из дат точки. Человек вводит только
* кол-во МРП или цену за ночь (и пояснение) — они сохраняются за точкой.
* Позиции статей «по маршруту» (суточные) приводятся к маршруту: ровно одна
* позиция на точку, дни — из дат точки, ставка — кол-во МРП × MRP_VALUE.
* Человек вводит только кол-во МРП (и пояснение) — они сохраняются за точкой.
* Применяется в форме при каждом рендере и перед сохранением; просмотр берёт
* сохранённое как есть — чтобы не пересчитать старую заявку по новому МРП.
*/
export function syncRouteItems(articles: IEstimateArticle[], route: ITripRoute): IEstimateArticle[] {
export function syncPerDiem(articles: IEstimateArticle[], route: ITripRoute): IEstimateArticle[] {
return articles.map((article) => {
if (!ESTIMATE_KINDS[article.kind].itemsFromRoute) return article;
const items = route.stops.map((stop): IEstimateItem => {
const items = route.stops.map((stop, index): IEstimateItem => {
const existing = article.items.find((item) => item.linkId === stop.id);
// id от точки, а не случайный: иначе строка пересоздавалась бы на каждом рендере.
const base = existing || { ...createItem(article.kind, stop.id), id: `${article.kind}-${stop.id}` };
return { ...base, mode: 'calc', ...routeItemFigures(article.kind, base, route, stop.id) };
const days = stopPerDiemDays(route, index);
return {
...base,
mode: 'calc',
quantity: days === undefined ? '' : String(days),
price: perDiemRate(base.mrpCount)
};
});
return { ...article, items };
});
@@ -349,8 +314,7 @@ function toItem(value: unknown): IEstimateItem | undefined {
quantity: text(raw.quantity),
price: text(raw.price),
comment: text(raw.comment),
mrpCount: text(raw.mrpCount),
quantityManual: text(raw.quantityManual)
mrpCount: text(raw.mrpCount)
};
}
@@ -28,12 +28,10 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
'SubmitDate',
'Position',
'Organization',
'TripCompany',
'TripCompanyId',
'TripRouteJson',
'TripPurpose',
'TripTasksJson',
'TripEstimateJson',
'TripComment',
'TripObligationAccepted',
'RequestStatus',
'RequestUniqueCode',
@@ -49,12 +47,10 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
Employee: employee?.EMail || '',
Position: (item.Position as string) || '',
Organization: (item.Organization as string) || '',
TripCompany: (item.TripCompany as string) || '',
TripCompanyId: (item.TripCompanyId as string) || '',
Route: parseRoute(item.TripRouteJson),
TripPurpose: (item.TripPurpose as string) || '',
Tasks: parseTasks(item.TripTasksJson),
Estimate: parseEstimate(item.TripEstimateJson),
TripComment: (item.TripComment as string) || '',
TripObligationAccepted: item.TripObligationAccepted === true
};
@@ -1,7 +1,6 @@
// import type — чтобы тесты (route/estimate/validation) не тянули SPFx-рантайм через модель.
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import type { RequestTypeId } from '../../models/types';
import type { IEmployeeOrganization } from '../../shared/dictionary';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
@@ -20,26 +19,10 @@ export const FORM_TITLE = 'Заявка на командировку';
export interface IRouteStop {
/** Стабильный ключ точки: на него ссылаются позиции сметы (IEstimateItem.linkId). */
id: string;
/**
* true — точка за рубежом: страна и город вводятся вручную (справочник КАТО только по РК),
* у суточных и проживания свои лимиты. Признак у точки, а не у поездки: бывают смешанные
* маршруты (Алматы → Москва → Атырау).
*/
abroad: boolean;
/** Страна — только у зарубежной точки; у точки по Казахстану ''. */
country: string;
/** Город на русском — из справочника КАТО (shared/dictionary/kato) или введён вручную. */
city: string;
/** Город на казахском; '' — введён вручную (в справочнике не нашёлся). */
cityKz: string;
/** Код КАТО города; '' — введён вручную. */
cityKato: string;
/** Объект — проект из справочника /Project (см. shared/dictionary/ProjectPicker). */
project: string;
/**
* oid проекта; '' — текст введён вручную без выбора из списка. По Казахстану отправка
* такое не пропустит; за рубежом можно — там объект часто чужой (завод, выставка).
*/
/** oid проекта; '' — текст введён вручную без выбора из списка (отправка не пропустит). */
projectId: string;
/** YYYY-MM-DD — прибытие в точку. */
dateFrom: string;
@@ -54,37 +37,18 @@ export interface IRouteStop {
* участка по его ключу.
*/
export interface ITripRoute {
/** Город отправления (обычно город работы), на русском. */
/** Город отправления (обычно город работы). */
origin: string;
/** Город отправления на казахском; '' — введён вручную. */
originKz: string;
/** Код КАТО города отправления; '' — введён вручную. */
originKato: string;
stops: IRouteStop[];
/** Обратный путь из последней точки в город отправления. */
returnToOrigin: boolean;
/** Вид транспорта по ключу участка (ключ — legId из route.ts). */
legTransport: Record<string, string>;
/** Предпочтительное время выезда по ключу участка (DEPARTURE_TIMES); нет ключа — не указано. */
legDepartureTime: Record<string, string>;
}
/** Предпочтительное время выезда на участке (требования кадров 10-23); необязательно. */
export const DEPARTURE_TIMES: ReadonlyArray<string> = ['Утро', 'День', 'Вечер', 'Ночь'];
/** Ключ «точки» города отправления в ключах участков. */
export const ORIGIN_ID = 'origin';
/**
* Вид поездки — значения Choice-колонки TripKind. Выводится из точек: хотя бы одна
* за рубежом — вся поездка зарубежная (по ней будут маршрут согласования и отчёты).
*/
export const TRIP_KIND = {
domestic: 'По Казахстану',
abroad: 'Зарубежная'
} as const;
export type TripKind = (typeof TRIP_KIND)[keyof typeof TRIP_KIND];
/**
* Вид транспорта участка. Предположение: в бланке список не задан — варианты
* взяты типовые; поправить здесь, если кадры используют другие.
@@ -92,7 +56,7 @@ export type TripKind = (typeof TRIP_KIND)[keyof typeof TRIP_KIND];
export const TRANSPORT_TYPES: ReadonlyArray<string> = [
'Авиа',
'Железнодорожный',
'Такси',
'Автобус',
'Служебный автотранспорт',
'Личный автотранспорт'
];
@@ -101,7 +65,7 @@ export const TRANSPORT_TYPES: ReadonlyArray<string> = [
export const TRANSPORT_SHORT: Record<string, string> = {
Авиа: 'Авиа',
Железнодорожный: 'Ж/д',
Такси: 'Такси',
Автобус: 'Автобус',
'Служебный автотранспорт': 'Служебное авто',
'Личный автотранспорт': 'Личное авто'
};
@@ -137,15 +101,9 @@ export interface IEstimateItem {
/**
* Только у суточных: количество МРП в день (цифры). Из него и MRP_VALUE
* считается ставка (price), дни (quantity) — из дат точки маршрута, см.
* estimate.ts/syncRouteItems. У остальных статей — ''.
* estimate.ts/syncPerDiem. У остальных статей — ''.
*/
mrpCount: string;
/**
* Только у позиций «по маршруту» (суточные, проживание): дни/ночи, введённые
* вручную поверх расчёта по датам — бывает форс-мажор (задержался в точке).
* Любое число, без ограничений. '' — считать по датам точки.
*/
quantityManual: string;
}
/** Статья сметы — заголовок с позициями внутри. */
@@ -172,7 +130,7 @@ export interface IEstimateKindInfo {
link: EstimateLinkTarget;
/**
* true — позиции не добавляются вручную, а строятся по точкам маршрута (по
* одной на точку) и считаются сами — так устроены суточные (дни) и проживание (ночи).
* одной на точку) и считаются сами — так устроены суточные.
*/
itemsFromRoute: boolean;
quantityLabel: string;
@@ -210,8 +168,7 @@ export const ESTIMATE_KINDS: Record<EstimateKind, IEstimateKindInfo> = {
core: true,
defaultMode: 'calc',
link: 'stop',
// Ночи — из дат точки, человек вводит только цену за ночь (с 10-23, по требованию кадров).
itemsFromRoute: true,
itemsFromRoute: false,
quantityLabel: 'Ночей',
priceLabel: 'Цена за ночь',
commentPlaceholder: 'Гостиница, адрес (необязательно)',
@@ -271,21 +228,6 @@ export const ESTIMATE_KIND_ORDER: ReadonlyArray<EstimateKind> = [
export const MRP_VALUE = 4325;
export const MRP_YEAR = 2026;
/** Самая длинная командировка, дней включительно (требования кадров 10-23). */
export const MAX_TRIP_DAYS = 40;
/** Лимит суточных, МРП в день (требования кадров 10-23): за рубежом выше. */
export const MAX_MRP_PER_DAY = { domestic: 6, abroad: 8 } as const;
/**
* Лимит цены проживания за ночь, ₸. За рубежом кадры лимит пока не назвали —
* undefined значит «без лимита»; когда назовут, вписать число сюда.
*/
export const MAX_NIGHT_PRICE: { domestic: number; abroad: number | undefined } = {
domestic: 15000,
abroad: undefined
};
/** Статьи, которые добавляются кнопкой не больше одного раза (другой расход — сколько угодно). */
export const SINGLE_OPTIONAL_KINDS: ReadonlyArray<EstimateKind> = ['insurance', 'visa'];
@@ -311,23 +253,15 @@ export interface IFormData {
Position: string;
/** Организации сотрудника (`\n`-joined «Компания — должность»), read-only, см. OrganizationField. */
Organization: string;
/**
* От какой компании едет сотрудник — одна из его организаций (Employee/ByEmail).
* Сотрудник может работать в нескольких компаниях, а командирует только одна.
*/
TripCompany: string;
/** CompanyId выбранной организации из справочника; '' — не выбрана. */
TripCompanyId: string;
Route: ITripRoute;
TripPurpose: string;
/** Задание на командировку — список задач по порядку. */
Tasks: string[];
/** Смета: основные статьи + добавленные (суммы вводятся вручную). */
Estimate: IEstimateArticle[];
/** Комментарий к заявке — после итога сметы, необязательный. */
TripComment: string;
/** Сотрудник принял обязательство по авансовому отчёту (см. OBLIGATION_TEXT). */
TripObligationAccepted: boolean;
@@ -336,40 +270,28 @@ export interface IFormData {
/** Обновление одного поля формы с сохранением типа значения. */
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
/** Сотрудник заявки и выбор организации, от которой он едет (см. useTripEmployee). */
export interface ITripEmployee {
/** Организации текущего сотрудника — варианты «Командирующей организации». */
organizations: IEmployeeOrganization[];
onEmployeeChange: (login: string) => void;
/** key — значение варианта (см. tripCompany.ts/companyKey); '' — сбросить выбор. */
onCompanyChange: (key: string) => void;
}
/** Пропсы секции формы: срез данных + колбэк обновления + контекст (people picker / справочники). */
export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/**
* Выбор сотрудника и организации подтягивает должность и организации — поэтому
* у этих полей свои обработчики. Не передан (режим чтения) — правка через set.
* Выбор сотрудника подтягивает его должность и организации — поэтому у поля
* отдельный обработчик. Не передан (режим чтения) — правка через set.
*/
employee?: ITripEmployee;
onEmployeeChange?: (login: string) => void;
}
export const DEFAULT_PURPOSE = 'Производственная необходимость';
/** Первая точка новой заявки. id фиксированный: у новой заявки на неё ссылаются позиции сметы. */
export const FIRST_STOP_ID = 'stop-1';
export const EMPTY_ROUTE: ITripRoute = {
origin: '',
originKz: '',
originKato: '',
stops: [
{ id: FIRST_STOP_ID, abroad: false, country: '', city: '', cityKz: '', cityKato: '', project: '', projectId: '', dateFrom: '', dateTo: '' }
],
stops: [{ id: FIRST_STOP_ID, city: '', project: '', projectId: '', dateFrom: '', dateTo: '' }],
returnToOrigin: true,
legTransport: {},
legDepartureTime: {}
legTransport: {}
};
function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem {
@@ -381,8 +303,7 @@ function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem {
quantity: '',
price: '',
comment: '',
mrpCount: '',
quantityManual: ''
mrpCount: ''
};
}
@@ -406,13 +327,11 @@ export const EMPTY_DATA: IFormData = {
Employee: '',
Position: '',
Organization: '',
TripCompany: '',
TripCompanyId: '',
Route: EMPTY_ROUTE,
TripPurpose: DEFAULT_PURPOSE,
// Новая заявка сразу показывает одну пустую строку задачи — понятнее, что заполнять.
Tasks: [''],
Estimate: EMPTY_ESTIMATE,
TripComment: '',
TripObligationAccepted: false
};
@@ -426,7 +345,4 @@ export const OBLIGATION_TEXT =
export const OBLIGATION_LABEL = 'Ознакомлен(а) и согласен(на)';
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
export const COUNTRY_PLACEHOLDER = 'Напр.: Китай';
export const ABROAD_CITY_PLACEHOLDER = 'Напр.: Пекин';
export const CITY_PLACEHOLDER ='Начните вводить город: Атырау, Құлсары…';
export const PROJECT_PLACEHOLDER ='Начните вводить название или код объекта…';
export const PROJECT_PLACEHOLDER = 'Начните вводить название или код объекта…';
@@ -8,10 +8,7 @@
{ "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "TripCompany", "title": "Командирующая организация", "type": "Text" },
{ "internalName": "TripCompanyId", "title": "Id командирующей организации (справочник)", "type": "Text" },
{ "internalName": "TripKind", "title": "Вид командировки", "type": "Choice", "choices": ["По Казахстану", "Зарубежная"] },
{ "internalName": "TripRouteJson", "title": "Маршрут (JSON)", "type": "Note" },
{ "internalName": "TripRoute", "title": "Маршрут", "type": "Note" },
{ "internalName": "TripCity", "title": "Города", "type": "Text" },
@@ -21,6 +18,7 @@
{ "internalName": "TripStartDate", "title": "Командировка с", "type": "DateTime", "dateOnly": true },
{ "internalName": "TripEndDate", "title": "Командировка по", "type": "DateTime", "dateOnly": true },
{ "internalName": "TripDaysCount", "title": "Дней командировки", "type": "Number" },
{ "internalName": "TripPurpose", "title": "Цель командировки", "type": "Note" },
{ "internalName": "TripTasks", "title": "Задание на командировку", "type": "Note" },
{ "internalName": "TripTasksJson", "title": "Задание на командировку (JSON)", "type": "Note" },
@@ -28,7 +26,6 @@
{ "internalName": "TripEstimate", "title": "Смета на командировку", "type": "Note" },
{ "internalName": "TripEstimateJson", "title": "Смета на командировку (JSON)", "type": "Note" },
{ "internalName": "TripEstimateTotal", "title": "Итого по смете, тенге", "type": "Number" },
{ "internalName": "TripComment", "title": "Комментарий", "type": "Note" },
{ "internalName": "TripObligationAccepted", "title": "Обязательство по авансовому отчёту принято", "type": "Boolean" }
]
@@ -1,4 +1,4 @@
import { EMPTY_ROUTE, ITripRoute, TRIP_KIND } from './model';
import { EMPTY_ROUTE, ITripRoute } from './model';
import {
createStop,
legLabel,
@@ -9,23 +9,17 @@ import {
routeProjects,
routeText,
serializeRoute,
stopLocationPatch,
stopNights,
tripKind,
withoutStopLegs
stopNights
} from './route';
const ROUTE: ITripRoute = {
origin: 'Алматы',
originKz: '',
originKato: '',
stops: [
{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', city: 'Актау', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
],
returnToOrigin: true,
legTransport: { 'origin>s1': 'Авиа' },
legDepartureTime: {}
legTransport: { 'origin>s1': 'Авиа' }
};
describe('участки пути', () => {
@@ -54,42 +48,10 @@ describe('сводка маршрута', () => {
);
});
it('город из справочника — на двух языках, если названия различаются', () => {
const route: ITripRoute = {
...ROUTE,
origin: 'Уральск',
originKz: 'Орал',
stops: [{ ...ROUTE.stops[0], city: 'Кульсары', cityKz: 'Құлсары' }, { ...ROUTE.stops[1], cityKz: 'Актау' }],
returnToOrigin: false
};
expect(routeCities(route)).toBe('Кульсары (Құлсары), Актау');
expect(routeLegs(route).map(legLabel)).toEqual(['Уральск (Орал) → Кульсары (Құлсары)', 'Кульсары (Құлсары) → Актау']);
});
it('новая точка начинается с даты отъезда из предыдущей', () => {
expect(createStop(ROUTE.stops[1]).dateFrom).toBe('2026-10-10');
expect(createStop(undefined).dateFrom).toBe('');
});
it('зарубежная точка: страна и город, поездка зарубежная', () => {
const beijing = { ...ROUTE.stops[1], abroad: true, country: 'Китай', city: 'Пекин' };
const route: ITripRoute = { ...ROUTE, stops: [ROUTE.stops[0], beijing] };
expect(routeCities(route)).toBe('Атырау, Китай, Пекин');
expect(tripKind(route)).toBe(TRIP_KIND.abroad);
expect(tripKind(ROUTE)).toBe(TRIP_KIND.domestic);
// Следующая точка — в той же стране, что предыдущая.
expect([createStop(beijing).abroad, createStop(beijing).country]).toEqual([true, 'Китай']);
});
it('удалили точку — транспорт и время выезда её участков уходят', () => {
const times = { 'origin>s1': 'Утро', 's1>s2': 'Вечер', 's2>origin': 'Ночь' };
expect(withoutStopLegs(times, 's2')).toEqual({ 'origin>s1': 'Утро' });
});
it('смена «Казахстан / другая страна» сбрасывает чужие поля, город остаётся', () => {
expect(stopLocationPatch(true)).toEqual({ abroad: true, cityKz: '', cityKato: '' });
expect(stopLocationPatch(false)).toEqual({ abroad: false, country: '' });
});
});
describe('хранение маршрута', () => {
@@ -97,15 +59,6 @@ describe('хранение маршрута', () => {
expect(parseRoute(serializeRoute(ROUTE))).toEqual(ROUTE);
});
it('старая заявка (до КАТО и признака страны) — город как был, точка по Казахстану', () => {
const old = { origin: 'Алматы', stops: [{ id: 's1', city: 'Атырау', project: '', projectId: '', dateFrom: '', dateTo: '' }] };
const route = parseRoute(JSON.stringify(old));
expect([route.originKz, route.stops[0].cityKz, route.stops[0].cityKato]).toEqual(['', '', '']);
expect([route.stops[0].abroad, route.stops[0].country]).toEqual([false, '']);
expect(route.legDepartureTime).toEqual({});
expect(routeCities(route)).toBe('Атырау');
});
it('битый JSON или маршрут без точек — маршрут новой заявки', () => {
expect(parseRoute('{oops')).toEqual(EMPTY_ROUTE);
expect(parseRoute(JSON.stringify({ origin: 'Алматы', stops: [] }))).toEqual(EMPTY_ROUTE);
@@ -1,7 +1,5 @@
import { formatIsoDate } from '../../shared/dateUtils';
// Прямой импорт, не через shared/dictionary: индекс тянет сервисы с sp-http, а route.ts тестируется без SPFx.
import { cityLabel } from '../../shared/dictionary/kato/cityLabel';
import { EMPTY_ROUTE, IRouteStop, ITripRoute, ORIGIN_ID, TRIP_KIND, TripKind } from './model';
import { EMPTY_ROUTE, IRouteStop, ITripRoute, ORIGIN_ID } from './model';
import { tripDays } from './tripData';
/** Участок пути (переезд) между двумя соседними точками маршрута. */
@@ -26,36 +24,8 @@ export function newId(prefix: string): string {
const ORIGIN_FALLBACK = 'Город отправления';
const STOP_FALLBACK = 'Точка маршрута';
/**
* Город точки: по Казахстану — на двух языках «Кульсары (Құлсары)», за рубежом —
* страна и город «Китай, Пекин». '' — не указан.
*/
export function stopCityLabel(stop: IRouteStop): string {
if (!stop.abroad) return cityLabel(stop.city, stop.cityKz);
return [stop.country.trim(), stop.city.trim()].filter(Boolean).join(', ');
}
/**
* Поля точки при смене «Казахстан / другая страна». Введённый город остаётся, но
* за рубежом он уже не из справочника КАТО (казахское название и код сбрасываются),
* а у точки по Казахстану страны нет.
*/
export function stopLocationPatch(abroad: boolean): Partial<IRouteStop> {
return abroad ? { abroad: true, cityKz: '', cityKato: '' } : { abroad: false, country: '' };
}
/** Вид поездки для колонки TripKind: хотя бы одна точка за рубежом — зарубежная. */
export function tripKind(route: ITripRoute): TripKind {
return route.stops.some((s) => s.abroad) ? TRIP_KIND.abroad : TRIP_KIND.domestic;
}
/** Город отправления на двух языках; '' — не указан. */
export function originLabel(route: ITripRoute): string {
return cityLabel(route.origin, route.originKz);
}
function stopCity(stop: IRouteStop): string {
return stopCityLabel(stop) || STOP_FALLBACK;
return stop.city.trim() || STOP_FALLBACK;
}
/** Подпись точки в выпадающих списках сметы: «Атырау · Нефтебаза-2». */
@@ -70,7 +40,7 @@ export function stopLabel(stop: IRouteStop): string {
* участки пересобрались сами.
*/
export function routeLegs(route: ITripRoute): IRouteLeg[] {
const origin = { id: ORIGIN_ID, name: originLabel(route) || ORIGIN_FALLBACK };
const origin = { id: ORIGIN_ID, name: route.origin.trim() || ORIGIN_FALLBACK };
const points = [origin, ...route.stops.map((s) => ({ id: s.id, name: stopCity(s) }))];
if (route.returnToOrigin) points.push(origin);
@@ -121,7 +91,7 @@ const unique = (values: string[]): string[] =>
/** Города точек через запятую — для колонки TripCity и заголовка элемента. */
export function routeCities(route: ITripRoute): string {
return unique(route.stops.map(stopCityLabel)).join(', ');
return unique(route.stops.map((s) => s.city)).join(', ');
}
/** Объекты точек (без повторов) — для колонки TripProjects. */
@@ -136,28 +106,23 @@ function stopDates(stop: IRouteStop): string {
/** Маршрут читаемым текстом для колонки SP: «Алматы → Атырау (Нефтебаза-2, 05.10.2026–08.10.2026) → Алматы». */
export function routeText(route: ITripRoute): string {
const parts: string[] = [originLabel(route) || ORIGIN_FALLBACK];
const parts: string[] = [route.origin.trim() || ORIGIN_FALLBACK];
route.stops.forEach((stop) => {
const details = [stop.project.trim(), stopDates(stop)].filter(Boolean).join(', ');
parts.push(details ? `${stopCity(stop)} (${details})` : stopCity(stop));
});
if (route.returnToOrigin) parts.push(originLabel(route) || ORIGIN_FALLBACK);
if (route.returnToOrigin) parts.push(route.origin.trim() || ORIGIN_FALLBACK);
return parts.join(' → ');
}
/**
* Новая точка после последней. Дата прибытия — дата отъезда из предыдущей точки,
* страна (Казахстан / другая) — как у предыдущей: это не догадка, а то, что
* человек сам уже ввёл; поля остаются редактируемыми.
* Новая точка после последней. Дата прибытия — дата отъезда из предыдущей точки:
* это не догадка, а то, что человек сам уже ввёл; поле остаётся редактируемым.
*/
export function createStop(previous: IRouteStop | undefined): IRouteStop {
return {
id: newId('stop'),
abroad: previous?.abroad ?? false,
country: previous?.abroad ? previous.country : '',
city: '',
cityKz: '',
cityKato: '',
project: '',
projectId: '',
dateFrom: previous?.dateTo || '',
@@ -178,13 +143,7 @@ function toStop(value: unknown): IRouteStop | undefined {
if (!id) return undefined;
return {
id,
// Заявки до признака «другая страна» — все точки по Казахстану.
abroad: raw.abroad === true,
country: text(raw.country),
city: text(raw.city),
// Заявки до справочника КАТО — без этих полей: город остаётся введённым вручную.
cityKz: text(raw.cityKz),
cityKato: text(raw.cityKato),
project: text(raw.project),
projectId: text(raw.projectId),
dateFrom: text(raw.dateFrom),
@@ -192,28 +151,16 @@ function toStop(value: unknown): IRouteStop | undefined {
};
}
/** Словарь «ключ участка → значение» (вид транспорта, время выезда); не строки отбрасываются. */
function toLegRecord(value: unknown): Record<string, string> {
function toTransport(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object') return {};
const result: Record<string, string> = {};
Object.keys(value as object).forEach((key) => {
const entry = (value as Record<string, unknown>)[key];
if (typeof entry === 'string') result[key] = entry;
const type = (value as Record<string, unknown>)[key];
if (typeof type === 'string') result[key] = type;
});
return result;
}
/** Убрать из словаря участков те, что шли через точку stopId (её удалили из маршрута). */
export function withoutStopLegs(record: Record<string, string>, stopId: string): Record<string, string> {
const result: Record<string, string> = {};
Object.keys(record)
.filter((key) => key.split('>').indexOf(stopId) === -1)
.forEach((key) => {
result[key] = record[key];
});
return result;
}
/** Разобрать TripRouteJson. Битый JSON или маршрут без точек — маршрут новой заявки. */
export function parseRoute(raw: unknown): ITripRoute {
let parsed: unknown;
@@ -230,12 +177,8 @@ export function parseRoute(raw: unknown): ITripRoute {
if (stops.length === 0) return EMPTY_ROUTE;
return {
origin: text(rawRoute.origin),
originKz: text(rawRoute.originKz),
originKato: text(rawRoute.originKato),
stops,
returnToOrigin: rawRoute.returnToOrigin !== false,
legTransport: toLegRecord(rawRoute.legTransport),
// Заявки до времени выезда — без этого поля: время просто не указано.
legDepartureTime: toLegRecord(rawRoute.legDepartureTime)
legTransport: toTransport(rawRoute.legTransport)
};
}
@@ -1,49 +1,28 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField, Select } from '../../../shared/ui';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model';
import { companyOptions, selectedCompanyKey } from '../tripCompany';
/**
* Командируемый: по умолчанию сам инициатор, но можно выбрать другого
* сотрудника (руководитель оформляет за подчинённого) — должность и
* организации подтянутся по выбранному. Дату подачи ставит форма.
* Сотрудник может работать в нескольких компаниях — тогда выбирают, от какой он едет.
*/
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, employee }) => {
const options = companyOptions(employee?.organizations ?? [], data);
// Заявки, поданные до появления выбора организации, показывают прежний список всех организаций.
const isLegacyRequest = !employee && !data.TripCompany;
return (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
context={context}
value={data.Employee}
onChange={employee?.onEmployeeChange ?? ((v) => set('Employee', v))}
placeholder={EMPLOYEE_PLACEHOLDER}
/>
</FormField>
<FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<FormField label="Дата подачи" htmlFor="SubmitDate">
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
</FormField>
{isLegacyRequest && <OrganizationField value={data.Organization} />}
{!isLegacyRequest && options.length > 0 && (
<FormField label="Командирующая организация" htmlFor="TripCompany" required full>
<Select
id="TripCompany"
value={selectedCompanyKey(data)}
onChange={(key) => employee?.onCompanyChange(key)}
options={options}
placeholder="Выберите, от какой организации командировка"
// Единственная организация выбрана автоматически — выбирать не из чего.
readOnly={options.length === 1 && !!data.TripCompany}
/>
</FormField>
)}
</FormGrid>
);
};
export const EmployeeSection: React.FC<ISectionProps> = ({ data, set, context, onEmployeeChange }) => (
<FormGrid>
<FormField label="Сотрудник" htmlFor="Employee" required full>
<PeopleField
context={context}
value={data.Employee}
onChange={onEmployeeChange ?? ((v) => set('Employee', v))}
placeholder={EMPLOYEE_PLACEHOLDER}
/>
</FormField>
<FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<FormField label="Дата подачи" htmlFor="SubmitDate">
<TextInput id="SubmitDate" type="date" value={data.SubmitDate} onChange={(v) => set('SubmitDate', v)} readOnly />
</FormField>
<OrganizationField value={data.Organization} />
</FormGrid>
);
@@ -1,6 +1,6 @@
import * as React from 'react';
import styles from './EstimateSection.module.scss';
import { CollapseSection, FormField, TextArea, useFormReadOnly } from '../../../shared/ui';
import { CollapseSection, useFormReadOnly } from '../../../shared/ui';
import { ISectionProps } from '../model';
import { EstimateEditor } from '../components/estimate/EstimateEditor';
import { EstimateTable } from '../components/estimate/EstimateTable';
@@ -9,8 +9,7 @@ import { EstimateSummary } from '../components/estimate/EstimateSummary';
/**
* Смета на командировку. Заполнение — карточками статей с позициями, привязанными
* к точкам и участкам маршрута (EstimateEditor); просмотр и печать — таблицей.
* Итог с долями и разбивкой по объектам — в обоих режимах. После итога —
* необязательный комментарий к заявке.
* Итог с долями и разбивкой по объектам — в обоих режимах.
*/
export const EstimateSection: React.FC<ISectionProps> = ({ data, set }) => {
const readOnly = useFormReadOnly();
@@ -27,12 +26,6 @@ export const EstimateSection: React.FC<ISectionProps> = ({ data, set }) => {
<EstimateEditor data={data} set={set} />
)}
</div>
{/* Пустой комментарий в просмотре не показываем — пустое поле только мешает читать. */}
{(!readOnly || data.TripComment.trim()) && (
<FormField label="Комментарий" htmlFor="TripComment" full>
<TextArea id="TripComment" value={data.TripComment} onChange={(v) => set('TripComment', v)} rows={3} />
</FormField>
)}
</CollapseSection>
);
};
@@ -5,7 +5,7 @@ import { ISectionProps, OBLIGATION_LABEL, OBLIGATION_TEXT } from '../model';
/** Обязательство по авансовому отчёту из бланка — без согласия заявку не отправить. */
export const ObligationSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Обязательство и согласие" icon="document">
<CollapseSection title="Обязательство" icon="document">
<p className={styles.text}>{OBLIGATION_TEXT}</p>
<FormField label="Подтверждение" htmlFor="TripObligationAccepted" required full>
<Checkbox
@@ -1,7 +1,7 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import styles from './RouteSection.module.scss';
import { CollapseSection, useFormReadOnly } from '../../../shared/ui';
import { CollapseSection, FormField, TextArea, useFormReadOnly } from '../../../shared/ui';
import { ISectionProps } from '../model';
import { RouteEditor } from '../components/route/RouteEditor';
import { RouteScheme } from '../components/route/RouteScheme';
@@ -9,8 +9,8 @@ import { RouteViewMode, RouteViewSwitch } from '../components/route/RouteViewSwi
import { stopCardDomId } from '../components/route/RouteStopCard';
/**
* Куда едет сотрудник: маршрут (города + объекты + даты) в двух видах — «Форма»
* для заполнения и «Схема» для просмотра. Заявитель
* Куда и зачем: маршрут (города + объекты + даты) и цель командировки. Маршрут —
* в двух видах: «Форма» для заполнения и «Схема» для просмотра. Заявитель
* начинает с формы (он пришёл заполнять), согласующий — со схемы (ему важнее
* увидеть маршрут целиком).
*/
@@ -41,6 +41,9 @@ export const RouteSection: React.FC<ISectionProps> = ({ data, set, context }) =>
<RouteScheme route={data.Route} articles={data.Estimate} onSelectStop={openStopInForm} />
)}
</div>
<FormField label="Цель" htmlFor="TripPurpose" required full>
<TextArea id="TripPurpose" value={data.TripPurpose} onChange={(v) => set('TripPurpose', v)} rows={2} />
</FormField>
</CollapseSection>
);
};
@@ -5,7 +5,7 @@ import { submitDateOnSave } from '../../shared/submitDate';
import { formatIsoDate } from '../../shared/dateUtils';
import { FORM_ID, FORM_TITLE, IFormData } from './model';
import { filledTasks, formatTasks, tripDays } from './tripData';
import { routeCities, routePeriod, routeProjects, routeText, serializeRoute, tripKind } from './route';
import { routeCities, routePeriod, routeProjects, routeText, serializeRoute } from './route';
import { estimateTotal, formatEstimateText, serializeEstimate } from './estimate';
/** Сервис списка согласования этой формы — см. shared/approval/approvalServiceFactory.ts. */
@@ -34,10 +34,7 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
EmployeeId: employeeId,
Position: data.Position || null,
Organization: data.Organization || null,
TripCompany: data.TripCompany || null,
TripCompanyId: data.TripCompanyId || null,
TripKind: tripKind(route),
TripRouteJson: serializeRoute(route),
TripRoute: routeText(route) || null,
TripCity: routeCities(route) || null,
@@ -47,6 +44,7 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
TripStartDate: period.start || null,
TripEndDate: period.end || null,
TripDaysCount: tripDays(period.start, period.end) ?? null,
TripPurpose: data.TripPurpose || null,
// Читаемая строка — для представлений списка; источник для формы — JSON.
TripTasks: formatTasks(data.Tasks) || null,
@@ -56,7 +54,6 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
TripEstimateJson: serializeEstimate(data.Estimate),
// Итог дублируется числовой колонкой — для отчётов и сортировки в SP.
TripEstimateTotal: estimateTotal(data.Estimate),
TripComment: data.TripComment.trim() || null,
TripObligationAccepted: data.TripObligationAccepted
};
@@ -1,57 +0,0 @@
import type { IEmployeeOrganization } from '../../shared/dictionary';
import { EMPTY_DATA, IFormData } from './model';
import { applyCompany, applyEmployeeCompany, companyOptions } from './tripCompany';
const org = (companyId: string, company: string, position: string): IEmployeeOrganization => ({
id: `${companyId}-${position}`,
fio: 'Нурумова Виктория',
email: 'nurumova@tnsu.kz',
iin: '',
company,
companyId,
section: '',
departmentId: '',
position,
mobile: ''
});
const MONTAZHNIK = org('c1', 'ТОО Монтажник-Т', 'Директор');
const ASTEL = org('c2', 'ТОО АСТЕЛ-К', 'Казначей');
describe('tripCompany: варианты', () => {
it('«Компания — должность», дубль компании отбрасывается', () => {
const options = companyOptions([MONTAZHNIK, ASTEL, org('c2', 'ТОО АСТЕЛ-К', 'Бухгалтер')], EMPTY_DATA);
expect(options).toEqual([
{ value: 'c1', label: 'ТОО Монтажник-Т — Директор' },
{ value: 'c2', label: 'ТОО АСТЕЛ-К — Казначей' }
]);
});
it('сохранённая организация видна, даже если справочник её не вернул', () => {
const data: IFormData = { ...EMPTY_DATA, TripCompany: 'ТОО Старая', TripCompanyId: 'old' };
expect(companyOptions([], data)).toEqual([{ value: 'old', label: 'ТОО Старая' }]);
});
});
describe('tripCompany: выбор', () => {
it('выбор организации ставит должность в ней', () => {
const data = applyCompany({ ...EMPTY_DATA, Position: 'Директор' }, ASTEL);
expect([data.TripCompany, data.TripCompanyId, data.Position]).toEqual(['ТОО АСТЕЛ-К', 'c2', 'Казначей']);
});
it('одна организация выбирается сразу', () => {
const data = applyEmployeeCompany(EMPTY_DATA, [MONTAZHNIK]);
expect([data.TripCompanyId, data.Position]).toEqual(['c1', 'Директор']);
});
it('несколько — выбор и должность пустые', () => {
const prev = { ...EMPTY_DATA, TripCompany: 'ТОО Монтажник-Т', TripCompanyId: 'c1', Position: 'Директор' };
const data = applyEmployeeCompany(prev, [MONTAZHNIK, ASTEL]);
expect([data.TripCompanyId, data.Position]).toEqual(['', '']);
});
it('нет в справочнике — должность остаётся (введут руками)', () => {
const data = applyEmployeeCompany({ ...EMPTY_DATA, Position: 'Инженер' }, []);
expect([data.TripCompanyId, data.Position]).toEqual(['', 'Инженер']);
});
});
@@ -1,57 +0,0 @@
// Только типы: модуль чистый и тестируется без SharePoint (сервисы справочника тянут sp-http).
import type { IEmployeeOrganization } from '../../shared/dictionary';
import type { ISelectOption } from '../../shared/ui';
import { IFormData } from './model';
/** Значение варианта в списке: CompanyId из справочника, у записи без id — название. */
export function companyKey(org: IEmployeeOrganization): string {
return org.companyId || org.company;
}
/** Значение выбранной организации в списке ('' — не выбрана). */
export function selectedCompanyKey(data: IFormData): string {
return data.TripCompanyId || data.TripCompany;
}
/**
* Варианты «Командирующей организации» — «Компания — должность». Если в одной
* компании у сотрудника несколько записей, остаётся первая: выбирают компанию,
* а не ставку. Сохранённая организация, которой нет среди вариантов (просмотр
* заявки, справочник недоступен, сотрудник уже ушёл из компании), всё равно
* показывается — иначе поле выглядело бы пустым.
*/
export function companyOptions(organizations: IEmployeeOrganization[], data: IFormData): ISelectOption[] {
const options: ISelectOption[] = [];
organizations.forEach((org) => {
const value = companyKey(org);
if (!value || options.some((o) => o.value === value)) return;
options.push({ value, label: org.position ? `${org.company} — ${org.position}` : org.company });
});
const saved = selectedCompanyKey(data);
if (saved && !options.some((o) => o.value === saved)) {
options.push({ value: saved, label: data.TripCompany });
}
return options;
}
/** Выбрать организацию: компания + должность сотрудника именно в ней. undefined — сбросить выбор. */
export function applyCompany(data: IFormData, org: IEmployeeOrganization | undefined): IFormData {
return {
...data,
TripCompany: org?.company || '',
TripCompanyId: org?.companyId || '',
Position: org?.position || data.Position
};
}
/**
* Организация и должность нового сотрудника заявки. Одна организация —
* выбирается сразу. Несколько — выбор за человеком, а должность пустая до
* выбора: в каждой компании она своя, и угадывать её по первой записи нельзя.
* Сотрудник не найден в справочнике — должность остаётся как была (введут руками).
*/
export function applyEmployeeCompany(data: IFormData, organizations: IEmployeeOrganization[]): IFormData {
if (organizations.length === 1) return applyCompany(data, organizations[0]);
const cleared = applyCompany(data, undefined);
return organizations.length > 1 ? { ...cleared, Position: '' } : cleared;
}
@@ -1,20 +1,22 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import type { IEmployeeOrganization } from '../../shared/dictionary';
import { todaySubmitDate } from '../../shared/submitDate';
import { IFormData, ITripEmployee } from './model';
import { applyCompany, applyEmployeeCompany, companyKey } from './tripCompany';
import { IFormData } from './model';
type SetData = React.Dispatch<React.SetStateAction<IFormData>>;
/** Подставить организации, командирующую организацию и должность сотрудника из Employee/ByEmail. */
/** Подставить должность и организации сотрудника из Employee/ByEmail (должность — из первой записи). */
function fillProfile(context: WebPartContext, email: string, setData: SetData, isAlive: () => boolean): void {
getEmployeeOrganizations(context, email)
.then((records) => {
if (!isAlive()) return;
setData((prev) => applyEmployeeCompany({ ...prev, Organization: formatEmployeeOrganizations(records) }, records));
setData((prev) => ({
...prev,
Position: records[0]?.position || prev.Position,
Organization: formatEmployeeOrganizations(records)
}));
})
// Справочник недоступен — не страшно: должность введут руками, организация просто не покажется.
.catch(() => undefined);
@@ -22,18 +24,10 @@ function fillProfile(context: WebPartContext, email: string, setData: SetData, i
/**
* Сотрудник заявки. Новая заявка — по умолчанию на себя: текущий пользователь,
* его должность, организации и сегодняшняя дата подачи. Выбор другого сотрудника
* подтягивает профиль выбранного. Список организаций грузится и для открытого
* черновика — но там сохранённые должность и организацию не перезаписываем.
* его должность, организации и сегодняшняя дата подачи. Возвращает обработчик
* выбора другого сотрудника — он подтягивает должность и организации выбранного.
*/
export function useTripEmployee(
context: WebPartContext,
editId: number | undefined,
employee: string,
setData: SetData
): ITripEmployee {
const [organizations, setOrganizations] = useState<IEmployeeOrganization[]>([]);
export function useTripEmployee(context: WebPartContext, editId: number | undefined, setData: SetData): (login: string) => void {
useEffect(() => {
if (editId) return;
let alive = true;
@@ -46,36 +40,8 @@ export function useTripEmployee(
// context — единственный экземпляр WebPartContext на всё время жизни веб-части.
}, [editId, context, setData]);
// Ответ справочника кешируется по e-mail (см. employeeOrganizations.service.ts) — повторного запроса нет.
useEffect(() => {
let alive = true;
const load = employee ? getEmployeeOrganizations(context, employee) : Promise.resolve<IEmployeeOrganization[]>([]);
load
.then((records) => {
if (alive) setOrganizations(records);
})
.catch(() => {
if (alive) setOrganizations([]);
});
return () => {
alive = false;
};
}, [context, employee]);
const onEmployeeChange = (login: string): void => {
if (!login) {
// Сотрудник очищен — его организации и выбранная компания к заявке больше не относятся.
setData((prev) => applyCompany({ ...prev, Employee: '', Organization: '' }, undefined));
return;
}
setData((prev) => ({ ...prev, Employee: login }));
fillProfile(context, login, setData, () => true);
return (login: string): void => {
setData((prev) => ({ ...prev, Employee: login, Organization: login ? prev.Organization : '' }));
if (login) fillProfile(context, login, setData, () => true);
};
const onCompanyChange = (key: string): void => {
const org = organizations.find((o) => companyKey(o) === key);
setData((prev) => applyCompany(prev, org));
};
return { organizations, onEmployeeChange, onCompanyChange };
}
@@ -1,19 +1,14 @@
import { EMPTY_DATA, IEstimateArticle, IFormData, ITripRoute } from './model';
import { createArticle } from './estimate';
import type { IEmployeeOrganization } from '../../shared/dictionary';
import type { IFieldIssue } from '../../shared/ui';
import { draftIssues, submitIssues } from './validation';
const off = (article: IEstimateArticle): IEstimateArticle => ({ ...article, enabled: false });
const ROUTE: ITripRoute = {
origin: 'Алматы',
originKz: '',
originKato: '',
stops: [{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }],
stops: [{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }],
returnToOrigin: true,
legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' },
legDepartureTime: { 'origin>s1': 'Утро', 's1>origin': 'Вечер' }
legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' }
};
const FILLED: IFormData = {
@@ -27,74 +22,33 @@ const FILLED: IFormData = {
TripObligationAccepted: true
};
// Фиксированное «сегодня» — раньше дат маршрута в фикстурах, чтобы тесты не зависели от текущей даты.
const TODAY = '2026-10-01';
const submit = (data: IFormData, organizations: IEmployeeOrganization[] = []): IFieldIssue[] => submitIssues(data, organizations, TODAY);
const fields = (issues: { field: string }[]): string[] => issues.map((i) => i.field);
describe('validation: форма', () => {
it('заполненная заявка — без проблем', () => {
expect(submit(FILLED)).toEqual([]);
expect(submitIssues(FILLED)).toEqual([]);
expect(draftIssues(FILLED)).toEqual([]);
});
it('командирующая организация обязательна, только если есть из чего выбрать', () => {
const orgs = [{ id: '1', fio: '', email: '', iin: '', company: 'ТОО А', companyId: 'a', section: '', departmentId: '', position: '', mobile: '' }];
expect(fields(submit(FILLED, orgs))).toEqual(['TripCompany']);
expect(submit({ ...FILLED, TripCompany: 'ТОО А', TripCompanyId: 'a' }, orgs)).toEqual([]);
expect(submit(FILLED, [])).toEqual([]);
});
it('черновику нужен только сотрудник', () => {
expect(fields(draftIssues(EMPTY_DATA))).toEqual(['Employee']);
});
it('пустые задачи и обязательство', () => {
const data = { ...FILLED, Tasks: [' '], TripObligationAccepted: false };
expect(fields(submit(data))).toEqual(['Tasks', 'TripObligationAccepted']);
expect(fields(submitIssues(data))).toEqual(['Tasks', 'TripObligationAccepted']);
});
});
describe('validation: маршрут', () => {
it('объект только из справочника', () => {
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], projectId: '' }] };
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']);
});
it('зарубежная точка — нужна страна', () => {
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], abroad: true, country: ' ' }] };
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s1-country']);
});
it('зарубежная точка — объект можно ввести вручную, но не пустым', () => {
const abroad = { ...ROUTE.stops[0], abroad: true, country: 'Китай', project: 'Завод Китайский', projectId: '' };
expect(submit({ ...FILLED, Route: { ...ROUTE, stops: [abroad] } })).toEqual([]);
const empty = { ...abroad, project: ' ' };
expect(fields(submit({ ...FILLED, Route: { ...ROUTE, stops: [empty] } }))).toEqual(['stop-s1-project']);
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']);
});
it('у каждого участка нужен вид транспорта', () => {
const route = { ...ROUTE, legTransport: { 'origin>s1': 'Авиа' } };
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']);
});
it('у каждого участка нужно время выезда', () => {
const route = { ...ROUTE, legDepartureTime: { 'origin>s1': 'Утро' } };
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin-time']);
});
it('прибытие задним числом не отправить, сегодня — можно; черновик сохраняется', () => {
expect(fields(submitIssues(FILLED, [], '2026-10-06'))).toEqual(['stop-s1-from']);
expect(submitIssues(FILLED, [], '2026-10-05')).toEqual([]);
expect(draftIssues(FILLED)).toEqual([]);
});
it('командировка не дольше 40 дней включительно', () => {
const until = (dateTo: string): ITripRoute => ({ ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo }] });
// 05.10 – 13.11 = 40 дней, 05.10 – 14.11 = 41.
expect(submit({ ...FILLED, Route: until('2026-11-13') })).toEqual([]);
expect(fields(submit({ ...FILLED, Route: until('2026-11-14') }))).toEqual(['stop-s1-to']);
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']);
});
it('отъезд раньше прибытия блокирует даже черновик', () => {
@@ -103,9 +57,9 @@ describe('validation: маршрут', () => {
});
it('прибытие в следующую точку раньше отъезда из предыдущей', () => {
const second = { id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт', projectId: 'p2', dateFrom: '2026-10-06', dateTo: '2026-10-09' };
const route = { ...ROUTE, stops: [...ROUTE.stops, second], legTransport: { 'origin>s1': 'Авиа', 's1>s2': 'Авиа', 's2>origin': 'Авиа' }, legDepartureTime: { 'origin>s1': 'Утро', 's1>s2': 'День', 's2>origin': 'Вечер' } };
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
const second = { id: 's2', city: 'Актау', project: 'Порт', projectId: 'p2', dateFrom: '2026-10-06', dateTo: '2026-10-09' };
const route = { ...ROUTE, stops: [...ROUTE.stops, second], legTransport: { 'origin>s1': 'Авиа', 's1>s2': 'Авиа', 's2>origin': 'Авиа' } };
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
});
});
@@ -113,46 +67,29 @@ describe('validation: смета', () => {
const [perDiem, travel, accommodation] = EMPTY_DATA.Estimate;
it('включённая статья без суммы — подсвечивается пустое поле', () => {
const tickets = { ...travel, items: [{ ...travel.items[0], linkId: 'origin>s1', mode: 'calc' as const, quantity: '1' }] };
const data = { ...FILLED, Estimate: [off(perDiem), tickets, off(accommodation)] };
expect(fields(submit(data))).toEqual([`item-${travel.items[0].id}-price`]);
});
it('лимит суточных: 6 МРП по Казахстану, 8 за рубежом', () => {
const withMrp = (mrpCount: string): IEstimateArticle => ({ ...perDiem, items: [{ ...perDiem.items[0], linkId: 's1', mrpCount }] });
const estimate = (mrpCount: string): IEstimateArticle[] => [withMrp(mrpCount), off(travel), off(accommodation)];
expect(fields(submit({ ...FILLED, Estimate: estimate('7') }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
const abroad = { ...ROUTE, stops: [{ ...ROUTE.stops[0], abroad: true, country: 'Китай' }] };
expect(submit({ ...FILLED, Route: abroad, Estimate: estimate('8') })).toEqual([]);
expect(fields(submit({ ...FILLED, Route: abroad, Estimate: estimate('9') }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
});
it('проживание: цена необязательна, но по Казахстану не больше 15 000 за ночь', () => {
const withPrice = (price: string): IEstimateArticle => ({ ...accommodation, items: [{ ...accommodation.items[0], linkId: 's1', price }] });
const estimate = (price: string): IEstimateArticle[] => [off(perDiem), off(travel), withPrice(price)];
expect(submit({ ...FILLED, Estimate: estimate('') })).toEqual([]);
expect(submit({ ...FILLED, Estimate: estimate('15000') })).toEqual([]);
expect(fields(submit({ ...FILLED, Estimate: estimate('15001') }))).toEqual([`item-${accommodation.items[0].id}-price`]);
const filledQty = { ...accommodation, items: [{ ...accommodation.items[0], linkId: 's1', quantity: '3' }] };
const data = { ...FILLED, Estimate: [off(perDiem), off(travel), filledQty] };
expect(fields(submitIssues(data))).toEqual([`item-${accommodation.items[0].id}-price`]);
});
it('суточные: дни из маршрута, от человека — только кол-во МРП', () => {
const noMrp = { ...perDiem, items: [{ ...perDiem.items[0], linkId: 's1', quantity: '4' }] };
const withMrp = { ...perDiem, items: [{ ...noMrp.items[0], mrpCount: '6' }] };
const estimate = (article: IEstimateArticle): IEstimateArticle[] => [article, off(travel), off(accommodation)];
expect(fields(submit({ ...FILLED, Estimate: estimate(noMrp) }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
expect(submit({ ...FILLED, Estimate: estimate(withMrp) })).toEqual([]);
expect(fields(submitIssues({ ...FILLED, Estimate: estimate(noMrp) }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
expect(submitIssues({ ...FILLED, Estimate: estimate(withMrp) })).toEqual([]);
});
it('при нескольких вариантах позицию нужно привязать', () => {
// В маршруте «туда и обратно» у проезда два участка — без привязки не понять, какой оплачен.
const unlinked = { ...travel, items: [{ ...travel.items[0], linkId: '', amount: '50000' }] };
const data = { ...FILLED, Estimate: [off(perDiem), unlinked, off(accommodation)] };
expect(fields(submit(data))).toEqual([`item-${travel.items[0].id}-link`]);
expect(fields(submitIssues(data))).toEqual([`item-${travel.items[0].id}-link`]);
});
it('другой расход требует название и сумму', () => {
const other = createArticle('other', ROUTE);
const data = { ...FILLED, Estimate: [...FILLED.Estimate, other] };
expect(fields(submit(data))).toEqual([`article-${other.id}-title`, `item-${other.items[0].id}-amount`]);
expect(fields(submitIssues(data))).toEqual([`article-${other.id}-title`, `item-${other.items[0].id}-amount`]);
});
});
@@ -1,28 +1,8 @@
import type { IFieldIssue } from '../../shared/ui';
import type { IEmployeeOrganization } from '../../shared/dictionary';
import { todaySubmitDate } from '../../shared/submitDate';
import {
ESTIMATE_KINDS,
EstimateKind,
IEstimateArticle,
IEstimateItem,
IFormData,
IRouteStop,
ITripRoute,
MAX_TRIP_DAYS
} from './model';
import { filledTasks, plural, tripDays } from './tripData';
import { legLabel, routeLegs, routePeriod, stopCityLabel } from './route';
import {
articleTitle,
formatAmount,
itemAmount,
itemLinkLabel,
linkOptions,
mrpLimit,
nightPriceLimit,
resolveLinkId
} from './estimate';
import { ESTIMATE_KINDS, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model';
import { filledTasks } from './tripData';
import { legLabel, routeLegs } from './route';
import { articleTitle, itemAmount, itemLinkLabel, linkOptions, resolveLinkId } from './estimate';
/**
* Ключи полей маршрута и сметы — те же, что fieldKey/htmlFor у их FormField:
@@ -30,13 +10,11 @@ import {
*/
export const fieldKey = {
origin: 'route-origin',
stopCountry: (stop: IRouteStop): string => `stop-${stop.id}-country`,
stopCity: (stop: IRouteStop): string => `stop-${stop.id}-city`,
stopProject: (stop: IRouteStop): string => `stop-${stop.id}-project`,
stopFrom: (stop: IRouteStop): string => `stop-${stop.id}-from`,
stopTo: (stop: IRouteStop): string => `stop-${stop.id}-to`,
legTransport: (legId: string): string => `leg-${legId}`,
legDepartureTime: (legId: string): string => `leg-${legId}-time`,
articleTitle: (article: IEstimateArticle): string => `article-${article.id}-title`,
itemLink: (item: IEstimateItem): string => `item-${item.id}-link`,
itemAmount: (item: IEstimateItem): string => `item-${item.id}-amount`,
@@ -47,8 +25,7 @@ export const fieldKey = {
/** «Точка 2 (Актау)» — как точку видно в форме. */
function stopName(stop: IRouteStop, index: number): string {
const city = stopCityLabel(stop);
return city ? `Точка ${index + 1} (${city})` : `Точка ${index + 1}`;
return stop.city.trim() ? `Точка ${index + 1} (${stop.city.trim()})` : `Точка ${index + 1}`;
}
/** Дата окончания раньше начала — блокирует даже черновик: такие даты не сохранить осмысленно. */
@@ -63,24 +40,13 @@ function stopDateOrderIssues(route: ITripRoute): IFieldIssue[] {
}
/** Проблемы одной точки маршрута (кроме порядка дат внутри точки — см. stopDateOrderIssues). */
function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined, today: string): IFieldIssue[] {
function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined): IFieldIssue[] {
const label = stopName(stop, index);
const issues: IFieldIssue[] = [];
if (stop.abroad && !stop.country.trim()) issues.push({ field: fieldKey.stopCountry(stop), label, message: 'укажите страну' });
if (!stop.city.trim()) issues.push({ field: fieldKey.stopCity(stop), label, message: 'укажите город' });
// По Казахстану объект — именно из справочника (projectId): по нему разносят затраты по проектам.
// За рубежом едут на чужую площадку (завод, выставка) — её в справочнике нет, достаточно текста.
if (stop.abroad && !stop.project.trim()) {
issues.push({ field: fieldKey.stopProject(stop), label, message: 'укажите объект или место назначения' });
}
if (!stop.abroad && !stop.projectId) {
issues.push({ field: fieldKey.stopProject(stop), label, message: 'выберите объект из списка' });
}
// Объект — именно из справочника (projectId): по нему потом разносят затраты по проектам.
if (!stop.projectId) issues.push({ field: fieldKey.stopProject(stop), label, message: 'выберите объект из списка' });
if (!stop.dateFrom) issues.push({ field: fieldKey.stopFrom(stop), label, message: 'укажите дату прибытия' });
// Задним числом командировку не оформляют. Сегодня — можно: выезд в день подачи.
if (stop.dateFrom && stop.dateFrom < today) {
issues.push({ field: fieldKey.stopFrom(stop), label, message: 'дата прибытия уже прошла' });
}
if (!stop.dateTo) issues.push({ field: fieldKey.stopTo(stop), label, message: 'укажите дату отъезда' });
if (previous?.dateTo && stop.dateFrom && stop.dateFrom < previous.dateTo) {
issues.push({ field: fieldKey.stopFrom(stop), label, message: 'прибытие раньше отъезда из предыдущей точки' });
@@ -88,39 +54,15 @@ function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | unde
return issues;
}
/**
* Вся поездка — не дольше MAX_TRIP_DAYS дней (от прибытия в первую точку до отъезда
* из последней, включительно). Подсвечиваем отъезд последней точки — его обычно и правят.
*/
function tripLengthIssues(route: ITripRoute): IFieldIssue[] {
const period = routePeriod(route);
const days = tripDays(period.start, period.end);
const last = route.stops[route.stops.length - 1];
if (days === undefined || days <= MAX_TRIP_DAYS || !last) return [];
return [
{
field: fieldKey.stopTo(last),
label: 'Маршрут',
message: `командировка — ${plural(days, ['день', 'дня', 'дней'])}, можно не больше ${MAX_TRIP_DAYS}`
}
];
}
/** Маршрут для отправки: город отправления, все точки, вид транспорта и время выезда на каждом участке. */
function routeIssues(route: ITripRoute, today: string): IFieldIssue[] {
/** Маршрут для отправки: город отправления, все точки, вид транспорта на каждом участке. */
function routeIssues(route: ITripRoute): IFieldIssue[] {
const issues: IFieldIssue[] = [];
if (!route.origin.trim()) issues.push({ field: fieldKey.origin, label: 'Маршрут', message: 'укажите город отправления' });
route.stops.forEach((stop, i) => issues.push(...stopIssues(stop, i, route.stops[i - 1], today)));
route.stops.forEach((stop, i) => issues.push(...stopIssues(stop, i, route.stops[i - 1])));
issues.push(...stopDateOrderIssues(route));
issues.push(...tripLengthIssues(route));
routeLegs(route).forEach((leg) => {
const label = `Переезд ${legLabel(leg)}`;
if (!route.legTransport[leg.id]) {
issues.push({ field: fieldKey.legTransport(leg.id), label, message: 'выберите вид транспорта' });
}
// Время выезда нужно тревел-координатору под билеты — без него не отправить.
if (!route.legDepartureTime[leg.id]) {
issues.push({ field: fieldKey.legDepartureTime(leg.id), label, message: 'выберите время выезда' });
issues.push({ field: fieldKey.legTransport(leg.id), label: `Участок ${legLabel(leg)}`, message: 'выберите вид транспорта' });
}
});
return issues;
@@ -133,27 +75,6 @@ function amountIssue(item: IEstimateItem, label: string): IFieldIssue {
return { field: fieldKey.itemPrice(item), label, message: 'укажите цену' };
}
/**
* Позиция «по маршруту» — точку и дни/ночи форма ставит сама (пустые даты подсветит
* маршрут). Суточные: нужно кол-во МРП, не больше лимита (0 дней в день переезда —
* не ошибка). Проживание: цена необязательна — в точке могут жить на объекте, — но
* не выше лимита за ночь.
*/
function routeItemIssues(kind: EstimateKind, item: IEstimateItem, label: string, route: ITripRoute): IFieldIssue[] {
const stop = route.stops.find((s) => s.id === item.linkId);
if (kind === 'accommodation') {
const limit = nightPriceLimit(stop);
if (limit === undefined || Number(item.price) <= limit) return [];
return [{ field: fieldKey.itemPrice(item), label, message: `цена за ночь не больше ${formatAmount(limit)} ₸` }];
}
if (!Number(item.mrpCount)) return [{ field: fieldKey.itemMrpCount(item), label, message: 'укажите количество МРП' }];
const limit = mrpLimit(stop);
if (Number(item.mrpCount) > limit) {
return [{ field: fieldKey.itemMrpCount(item), label, message: `не больше ${limit} МРП в день` }];
}
return [];
}
/**
* Смета. Сама не обязательна — выключенные статьи не проверяются; но у включённой
* или добавленной статьи каждая позиция должна иметь сумму и (если вариантов
@@ -171,8 +92,12 @@ function estimateIssues(articles: IEstimateArticle[], route: ITripRoute): IField
article.items.forEach((item) => {
const where = itemLinkLabel(article.kind, item, route);
const label = where ? `Смета: ${articleTitle(article)} (${where})` : `Смета: ${articleTitle(article)}`;
// Суточные привязаны к точке и считают дни сами (пустые даты подсветит маршрут) —
// от человека нужно только кол-во МРП. 0 дней (точка в день переезда) — не ошибка.
if (ESTIMATE_KINDS[article.kind].itemsFromRoute) {
issues.push(...routeItemIssues(article.kind, item, label, route));
if (!Number(item.mrpCount)) {
issues.push({ field: fieldKey.itemMrpCount(item), label, message: 'укажите количество МРП' });
}
return;
}
if (needsLink && !resolveLinkId(article.kind, item.linkId, route)) {
@@ -191,30 +116,19 @@ export function draftIssues(data: IFormData): IFieldIssue[] {
return [...issues, ...stopDateOrderIssues(data.Route)];
}
/**
* Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно).
* organizations — организации сотрудника (см. useTripEmployee): из них выбирают командирующую.
* today — YYYY-MM-DD; параметром, чтобы тесты не зависели от текущей даты.
*/
export function submitIssues(
data: IFormData,
organizations: IEmployeeOrganization[] = [],
today: string = todaySubmitDate()
): IFieldIssue[] {
/** Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно). */
export function submitIssues(data: IFormData): IFieldIssue[] {
const issues: IFieldIssue[] = [];
if (!data.Employee) issues.push({ field: 'Employee', label: 'Сотрудник' });
if (!data.Position.trim()) issues.push({ field: 'Position', label: 'Должность' });
// Нет организаций (сотрудника нет в справочнике / справочник недоступен) — выбирать не из чего, не блокируем.
if (organizations.length > 0 && !data.TripCompany) {
issues.push({ field: 'TripCompany', label: 'Командирующая организация', message: 'выберите, от какой организации командировка' });
}
issues.push(...routeIssues(data.Route, today));
issues.push(...routeIssues(data.Route));
if (!data.TripPurpose.trim()) issues.push({ field: 'TripPurpose', label: 'Цель' });
if (filledTasks(data.Tasks).length === 0) {
issues.push({ field: 'Tasks', label: 'Задание на командировку', message: 'добавьте хотя бы одну задачу' });
}
issues.push(...estimateIssues(data.Estimate, data.Route));
if (!data.TripObligationAccepted) {
issues.push({ field: 'TripObligationAccepted', label: 'Обязательство и согласие', message: 'подтвердите согласие' });
issues.push({ field: 'TripObligationAccepted', label: 'Обязательство', message: 'подтвердите согласие' });
}
return issues;
}
@@ -0,0 +1,20 @@
@import '../../../../styles/design-tokens';
.back {
display: inline-flex;
align-items: center;
gap: $sp-2;
margin-bottom: $sp-4;
padding: 0;
border: 0;
background: transparent;
color: $navy-700;
font-family: $font-sans;
font-size: $fs-sm;
cursor: pointer;
transition: color .18s;
&:hover {
color: $gold-600;
}
}
@@ -0,0 +1,339 @@
import * as React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import styles from './PaymentRequestForm.module.scss';
import {
Alert,
CollapseSection,
FormCard,
InitiatorLine,
FormModeProvider,
Icon,
RequestNumber,
SubmitActions,
getDraftDeleteHandler,
useRequestSubmit
} from '../../shared/ui';
import {
composeRemoveRowRule,
ApprovalMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
loadRoutePhase,
isOpenedRouteReady,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite,
unassignedStepMessage,
resolveWriteListId,
useFormExecutors,
ExecutorsMatrix,
executorsToWrite,
initialSubmitStatus
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { todayIsoDate } from '../../shared/dateUtils';
import { IRequestFormProps } from '../../models/types';
import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListTitleForListId } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { savePaymentRequest, paymentRequestApprovalService } from './submit';
import { loadPaymentRequest } from './load';
import { loadPreviousPayments } from './services/previousPayments.service';
import { validatePayment } from './validation';
import { useBasisSelection } from './useBasisSelection';
import { useInvoiceFlow } from './useInvoiceFlow';
import { HeaderSection } from './sections/HeaderSection';
import { BasisSection } from './sections/BasisSection';
import { PaymentSection } from './sections/PaymentSection';
import { AmountsSection } from './sections/AmountsSection';
import { InvoiceSection } from './sections/InvoiceSection';
import { PositionsSection } from './sections/PositionsSection';
const PaymentRequestForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
editId,
editListId,
onCodeLoaded
}) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
const submit = useRequestSubmit();
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
const patch = useCallback((changes: Partial<IFormData>): void => setData((prev) => ({ ...prev, ...changes })), []);
const basis = useBasisSelection(context, patch, requestId);
const invoice = useInvoiceFlow(context, data, patch);
// Новая заявка — предзаполняем заявителя текущим пользователем и дату сегодняшней.
useEffect(() => {
if (editId) return undefined;
const requesterEmail = effectiveUserEmail(context);
setData((prev) => ({
...prev,
RequesterEmail: requesterEmail,
RequesterName: context.pageContext.user.displayName || '',
RequestDate: prev.RequestDate || todayIsoDate()
}));
let alive = true;
getEmployeeOrganizations(context, requesterEmail)
.then((records) => {
if (alive) {
setData((prev) => ({
...prev,
Organization: formatEmployeeOrganizations(records),
RequesterJobTitle: prev.RequesterJobTitle || records[0]?.position || '',
Department: prev.Department || records[0]?.section || '',
DepartmentId: prev.DepartmentId || records[0]?.departmentId || ''
}));
}
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [editId, context]);
// Выбор другого заявителя подтягивает его ФИО, должность, подразделение и организации.
const handleRequesterChange = (login: string): void => {
patch({ RequesterEmail: login });
if (!login) {
patch({ Organization: '' });
return;
}
getEmployeeOrganizations(context, login)
.then((records) =>
setData((prev) => ({
...prev,
RequesterName: records[0]?.fio || prev.RequesterName,
RequesterJobTitle: records[0]?.position || prev.RequesterJobTitle,
Department: records[0]?.section || prev.Department,
DepartmentId: records[0]?.departmentId || prev.DepartmentId,
Organization: formatEmployeeOrganizations(records)
}))
)
.catch(() => undefined);
};
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое и статус.
useEffect(() => {
if (!editId) return undefined;
let alive = true;
loadPaymentRequest(editId, editListId)
.then((loaded) => {
if (!alive) return;
setData(loaded.data);
setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
if (loaded.code) onCodeLoaded?.(loaded.code);
// В элементе лежит снимок прежних оплат на момент сохранения. Пока заявку
// ещё правят (черновик, возврат), перечитываем их: по основанию могли
// подать новые, а лимит суммы должен считаться по актуальным.
if (isEditableStatus(loaded.status) && loaded.data.BasisKey) {
loadPreviousPayments(context, loaded.data.BasisKey, editId)
.then((previous) => {
if (alive) patch({ PreviousPayments: previous });
})
.catch(() => undefined); // остаётся снимок — лучше, чем пустая таблица
}
})
.catch((e) => {
if (alive) submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
});
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editId, editListId]);
const approval = useMemo(() => paymentRequestApprovalService(editListId), [editListId]);
const userEmail = data.RequesterEmail;
const requestTypeIdState = useRequestTypeId(context, requestType.id);
const requestTypeId = requestTypeIdState.id;
// Маршрут зависит от проекта — пока он не выбран, resolve вернёт 404.
const projectId = data.ProjectId || null;
const matrixReady =
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!userEmail && !!projectId && !!requestTypeId));
const loadMatrix = useCallback(
() =>
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId)
),
[context, userEmail, approval, editId, requestStatus, projectId, requestTypeId]
);
const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) =>
approval.getJobTitle(login)
);
const executors = useFormExecutors({
readOnly,
context,
approval,
requestTypeId,
userEmail: matrixReady ? userEmail : '',
editId,
requestStatus,
projectId
});
const formError = validatePayment(data);
const isValid = !formError;
const validate = (): string | undefined => {
if (formError) return formError;
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
return undefined;
};
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign, order: r.order }));
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
const rtId = requestTypeId;
if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
}
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
const { id, code } = await savePaymentRequest(requestId, rtId, data, toApprovers(matrix.rows), executorsToWrite(executors.rows), status, writeListId);
setRequestId(id);
setRequestStatus(status);
if (code) setRequestCode(code);
if (onSaved) onSaved();
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
await notifyActiveStep(context, approval, id, {
requestTypeId: rtId,
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
});
}
};
const validateSubmit = (): string | undefined => {
const error = validate();
if (error) return error;
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
}
return (
unassignedStepMessage(
matrix.rows,
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
) ||
unassignedStepMessage(
executors.rows,
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
)
);
};
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> =>
submit.run(
'submit',
save(executors.rows.length > 0 ? initialSubmitStatus(matrix.rows.length, executors.rows.length) : REQUEST_STATUS.onApproval),
validateSubmit
);
const sectionProps = { data, set, patch, context, onRequesterChange: handleRequesterChange };
const handleDelete = getDraftDeleteHandler({
formKey: FORM_ID,
requestId,
requestStatus,
listId: editListId,
code: requestCode,
submit,
onDeleted: onCancel
});
return (
<div>
<button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" />
<span>К списку заявок</span>
</button>
<FormCard
title={requestType.title}
subtitle="Категория: Бухгалтерия"
belowSubtitle={
<InitiatorLine
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
itemId={requestId}
fallback={{ name: data.RequesterName, email: data.RequesterEmail }}
/>
}
headerRight={
requestId || requestStatus ? (
<>
{requestStatus && <StatusBadge status={requestStatus} />}
{requestCode && <RequestNumber code={requestCode} />}
</>
) : undefined
}
>
{submit.savedKind && !submit.error && (
<Alert variant="ok">{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}</Alert>
)}
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<FormModeProvider readOnly={readOnly}>
<HeaderSection {...sectionProps} />
<CollapseSection title="Основание" icon="document">
<BasisSection {...sectionProps} basis={basis} />
</CollapseSection>
<CollapseSection title="Вложения — счёт на оплату" icon="document">
<InvoiceSection data={data} invoice={invoice} />
</CollapseSection>
<CollapseSection title="Реквизиты платежа" icon="calculator">
<PaymentSection {...sectionProps} />
</CollapseSection>
<CollapseSection title="Суммы" icon="calculator">
<AmountsSection {...sectionProps} />
</CollapseSection>
<CollapseSection title="Позиции" icon="clipboard">
<PositionsSection rows={data.Positions} compared={!!data.InvoiceFileName} />
</CollapseSection>
</FormModeProvider>
<ApprovalMatrix
editor={matrix}
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
search={(q) => approval.searchUsers(q)}
waiting={!matrixReady}
waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования."
/>
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
{editId && (
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={editListId || dataListTitle(FORM_ID)} requestId={editId} reloadKey={requestStatus} />
</div>
)}
<SubmitActions
onDelete={handleDelete}
submit={submit}
isValid={isValid}
readOnly={readOnly}
onCancel={onCancel}
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmit}
/>
</FormCard>
</div>
);
};
export default PaymentRequestForm;
@@ -0,0 +1,17 @@
@import '../../../../styles/design-tokens';
.state {
padding: $sp-8;
color: $gray-500;
font-size: $fs-sm;
text-align: center;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $sp-3;
margin-bottom: $sp-4;
}
@@ -0,0 +1,197 @@
import * as React from 'react';
import { useState, useEffect, useMemo } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './PaymentRequestView.module.scss';
import {
Alert,
Button,
CollapseSection,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
Icon
} from '../../shared/ui';
import { navigate } from '../../shared/routing';
import {
ApproverService,
DecisionPanel,
StatusBadge,
ApprovalMatrix,
ApprovalHistory,
useMatrixEditor,
useViewExecutors,
ExecutorsMatrix,
approvalPhaseRows,
notifyActiveStep,
useRequestTypeId,
isEditableStatus
} from '../../shared/approval';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { useStableCallback } from '../../shared/useStableCallback';
import { approvalListTitle, approvalListTitleForListId, dataListTitle, dataListTitleForListId } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadPaymentRequest } from './load';
import { paymentRequestApprovalService } from './submit';
import { HeaderSection } from './sections/HeaderSection';
import { PaymentSection } from './sections/PaymentSection';
import { AmountsSection } from './sections/AmountsSection';
import { InvoiceSection } from './sections/InvoiceSection';
import { PositionsSection } from './sections/PositionsSection';
import { IInvoiceFlow } from './useInvoiceFlow';
interface IPaymentRequestViewProps {
context: WebPartContext;
itemId: number;
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
formKey: string;
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
listId?: string;
/** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */
onCodeLoaded?: (code: string) => void;
}
/** В просмотре счёт не загружается: поток нужен только секции как заглушка пропсов. */
const IDLE_INVOICE: IInvoiceFlow = { stage: 'idle', error: '', attach: () => Promise.resolve() };
// Заявка на оплату только для просмотра (режим согласующего).
const PaymentRequestView: React.FC<IPaymentRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
const { id: requestTypeId } = useRequestTypeId(context, formKey);
const set: SetField = () => undefined;
const patch = (): void => undefined;
const approval = useMemo(() => paymentRequestApprovalService(listId), [listId]);
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
const [decisionSeq, setDecisionSeq] = useState<number>(0);
const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l));
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
useEffect(() => {
let alive = true;
setLoading(true);
loadPaymentRequest(itemId, listId)
.then((loaded) => {
if (!alive) return;
setData(loaded.data);
setStatus(loaded.status);
setCode(loaded.code);
notifyCode(loaded.code);
setError(null);
setLoading(false);
})
.catch((e) => {
if (alive) {
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
setLoading(false);
}
});
return () => {
alive = false;
};
}, [itemId, listId, notifyCode]);
if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>;
}
if (error) {
return (
<div className={styles.state}>
<Alert variant="err">{error}</Alert>
</div>
);
}
const sectionProps = { data, set, patch, context, printOrgIndex };
const notify = (): void => {
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
{isEditableStatus(status) && (
<Button variant="secondary" onClick={() => navigate({ mode: 'edit', nav: 'new', formKey, itemId, listId })}>
<Icon name="edit" size="sm" />
Редактировать
</Button>
)}
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
</div>
<div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard
title="Заявка на оплату"
subtitle={`№${code || itemId}`}
belowSubtitle={<InitiatorLine listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)} itemId={itemId} />}
headerRight={status ? <StatusBadge status={status} /> : undefined}
>
<FormModeProvider readOnly>
<HeaderSection {...sectionProps} />
<CollapseSection title="Основание" icon="document">
<div>
{data.BasisTitle} · {data.ProjectName} · {data.CompanyName}
</div>
</CollapseSection>
<CollapseSection title="Вложения — счёт на оплату" icon="document">
<InvoiceSection data={data} invoice={IDLE_INVOICE} />
</CollapseSection>
<CollapseSection title="Реквизиты платежа" icon="calculator">
<PaymentSection {...sectionProps} />
</CollapseSection>
<CollapseSection title="Суммы" icon="calculator">
<AmountsSection {...sectionProps} />
</CollapseSection>
<CollapseSection title="Позиции" icon="clipboard">
<PositionsSection rows={data.Positions} compared={!!data.InvoiceFileName} />
</CollapseSection>
</FormModeProvider>
</FormCard>
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
</div>
<div className="rp-no-print">
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
</div>
<div className="rp-no-print">
<DecisionPanel
service={approverService}
requestId={itemId}
search={(q) => approval.searchUsers(q)}
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
notify();
}}
/>
</div>
</div>
);
};
export default PaymentRequestView;
@@ -0,0 +1,40 @@
# Заявка на оплату (`acc-payment-request`)
**Назначение:** оплата по согласованной СТ (сравнительной таблице) или по потребности — счёт, получатель, суммы и сверка позиций счёта с СТ.
**Категория:** Бухгалтерия
**Списки SP:** «Заявка на оплату — данные», «Заявка на оплату — согласование»; библиотека «Счета на оплату» (создаётся вручную, колонка `file_content` — многострочный текст)
**RequestTypeId (внешний API маршрутов):** резолвится в рантайме по `pageUrl` (см. `docs/forms.md`); заведён тип 24, раздел «Бухгалтерия», список данных `/sites/requests/Lists/120`
## Маршрут
- Фазы: согласование (исполнителей нет)
- Маршрут зависит от проекта (`ProjectId`) — как у «Заявки на услуги»: пока проект не выбран, матрица не строится.
- Маршрут 25 (`byProject: true`, без компании и подразделения — для любого проекта): один шаг согласования, фиксированный человек `gorbenko.pavel@tnsu.kz`. Форма всегда шлёт `projectId`, поэтому маршрута с `byProject: false` нет.
## Особое поведение
- **Основание** — согласованная СТ (`status = Согласовано` в списке «Сравнительная таблица» сайта СТ, `CT_SITE_URL`) либо потребность (`GET /Requests/365`, позиции — `/Requests/{oid}/positions`; тот же источник, что шаг 1 СТ). Выбор подставляет проект, компанию (если известна), получателя, раздел, вид работ, условия, общую сумму и позиции (`basisPatch.ts`); всё остаётся редактируемым.
- **Получатель** — выигравший поставщик СТ (`winSupplier` → «Поставщики»). Если в счёте указан другой контрагент (БИН, иначе название без формы собственности) — поле подсвечивается (`recipientMatchesInvoice`).
- **Раздел / вид работ** у СТ — из позиций СТ (`disciplineCode` / `SMR`), у потребности — `discipline` / `workType` заявки.
- **Условия оплаты, доставки и срок** — из «Критериев» СТ по победителю.
- **Общая сумма** СТ — сумма предложений победителя в «Оценке поставщиков» (смешанные валюты — в тенге). У потребности её нет, берётся из счёта.
- **Сумма оплаты** может быть меньше общей (транши). Сумма всех оплат по основанию (прежние + эта) не может превышать общую — при превышении поле подсвечивается, отправку это **не** блокирует.
- **Ранее поданные заявки на оплату** — по `BasisKey` (`ct:<Id СТ>` / `need:<oid>`) через approval-route `GET /api/basis-requests` по всем партициям, без черновиков, отклонённых и возвращённых. Сервер читает от имени приложения, поэтому видны и чужие заявки (права на элементы разорваны поштучно); номер становится ссылкой, только если у пользователя есть права открыть заявку. Снимок пишется в `PreviousPaymentsJson`; черновик и возвращённая при открытии перечитывают список заново.
- **Счёт (PDF)**: сначала сохраняется в библиотеку «Счета на оплату», затем `POST /Document/extractPDFText` (OCR записывает текст в `file_content` до ответа, колонка читается сразу), затем ИИ (`/Proxy/Gemini`) достаёт номер счёта, поставщика, БИН, IBAN, валюту, суммы и позиции и подставляет их (назначение платежа — «Оплата по счёту № …»). При сбое OCR/ИИ счёт остаётся приложенным, поля заполняются вручную.
- **Таблица позиций**: код, наименование, ед. изм. и количество — из СТ/потребности; позиция поставщика — из его КП; позиция из счёта. ИИ сопоставляет позиции по смыслу, а количество и единицы сверяет код (`positions.ts`). Статусы: совпало / расхождение / нет в счёте / нет в СТ. Результат пишется в `PositionsJson`.
- **Код позиции потребности** — `lineNumber` (номер строки табличной части 1С, уникален в пределах заявки).
- Справочники 1С (`api-tnsu-portal`, `DictionaryController`) — `DictionaryPicker`; недоступный справочник не блокирует заявку (поле становится текстовым):
- **Банковский счёт** — `GET /Dictionary/BankAccounts?search=&companyId=&projectId=` (постранично, поиск на сервере по названию, коду, номеру счёта, банку). Сначала по компании и проекту; если по проекту пусто — повтор только по компании с пояснением под полем (совпадение Id проекта из `/Project` с `bank_accounts.project_id` не гарантировано).
- **Договор** — `GET /Dictionary/Contracts?search=` (только действующие; фильтра по компании нет, поиск по названию, коду и контрагенту).
- **Группа статьи ДДС** — `GET /Dictionary/CashFlowItemGroups` (целиком); **статья ДДС** — `GET /Dictionary/CashFlowItems?id=<группа>`.
- Фильтры-Id передаются, только если это GUID: иначе API отвечает 400.
## Что не проверено на живом API
- Совпадают ли Id компании из `/Dictionary/Companies` и проекта из `/Project` с `company_id` / `project_id` в `bank_accounts` (на это — откат на фильтр по компании).
- Доступ пользователей портала на чтение списков сайта СТ.
## Секции формы
HeaderSection, BasisSection (BasisPicker), InvoiceSection, PaymentSection, AmountsSection, PositionsSection
## Точки входа
- rptype: `acc-payment-request`
- Режим согласующего (просмотр): есть (`PaymentRequestView`)
- «Создать на основе»: —
@@ -0,0 +1,66 @@
import { formatAmountInput, overLimitAmount, parseAmount, previousPaymentsTotal, recipientMatchesInvoice } from './amounts';
describe('parseAmount', () => {
it('понимает пробелы и запятую', () => {
expect(parseAmount('1 234,50')).toBe(1234.5);
});
it('мусор и пустая строка — 0', () => {
expect(parseAmount('abc')).toBe(0);
expect(parseAmount('')).toBe(0);
});
});
describe('overLimitAmount', () => {
const prev = [
{ code: 'A', amount: 600 },
{ code: 'B', amount: 100 }
];
it('не превышает, пока сумма всех оплат не больше общей', () => {
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 300, PreviousPayments: prev })).toBe(0);
});
it('возвращает величину превышения', () => {
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 400, PreviousPayments: prev })).toBe(100);
});
it('копейки округления не считаются превышением', () => {
expect(overLimitAmount({ TotalAmount: 1000, PayAmount: 300.004, PreviousPayments: prev })).toBe(0);
});
it('без общей суммы сравнивать не с чем', () => {
expect(overLimitAmount({ TotalAmount: 0, PayAmount: 5, PreviousPayments: prev })).toBe(0);
});
it('считает сумму прежних оплат', () => {
expect(previousPaymentsTotal(prev)).toBe(700);
});
});
describe('recipientMatchesInvoice', () => {
it('БИН решает, когда он есть у обеих сторон', () => {
expect(recipientMatchesInvoice({ name: 'А', bin: '1' }, { name: 'Б', bin: '1' })).toBe(true);
expect(recipientMatchesInvoice({ name: 'А', bin: '1' }, { name: 'А', bin: '2' })).toBe(false);
});
it('без БИН сравнивает названия без формы собственности и кавычек', () => {
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа Строй»', bin: '' }, { name: 'Альфа Строй, ТОО', bin: '' })).toBe(true);
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа»', bin: '' }, { name: 'ТОО «Бета»', bin: '' })).toBe(false);
});
it('нет данных по счёту — расхождения нет', () => {
expect(recipientMatchesInvoice({ name: 'ТОО «Альфа»', bin: '1' }, { name: '', bin: '' })).toBe(true);
});
});
describe('formatAmountInput', () => {
it('разбивает на разряды', () => {
expect(formatAmountInput('1234567')).toBe('1 234 567');
expect(formatAmountInput('1 528 642')).toBe('1 528 642');
});
it('держит недописанную дробную часть и режет её до двух знаков', () => {
expect(formatAmountInput('1234,')).toBe('1 234,');
expect(formatAmountInput('1234.567')).toBe('1 234,56');
expect(formatAmountInput(',5')).toBe('0,5');
});
it('выкидывает мусор и лишние разделители', () => {
expect(formatAmountInput('12a3,4,5')).toBe('123,45');
expect(formatAmountInput('')).toBe('');
});
it('согласован с parseAmount', () => {
expect(parseAmount(formatAmountInput('154100,5'))).toBe(154100.5);
});
});
@@ -0,0 +1,75 @@
import { IFormData, IPreviousPayment } from './model';
/** Допуск при сравнении денег: копейки округления не считаем превышением. */
const MONEY_EPSILON = 0.005;
/** Число из текста поля ввода: пробелы и запятая допустимы; мусор → 0. */
export function parseAmount(raw: string): number {
const value = Number(raw.replace(/\s/g, '').replace(',', '.'));
return Number.isFinite(value) ? value : 0;
}
/**
* Текст поля суммы с разрядами («1234567,5» → «1 234 567,5»), как его видно при
* вводе: остаются цифры и один десятичный разделитель (запятая), в дробной
* части не больше двух знаков. Недописанное «12,» сохраняется — иначе запятую
* нельзя было бы ввести.
*/
export function formatAmountInput(raw: string): string {
const cleaned = raw.replace(/[^\d.,]/g, '').replace('.', ',');
const comma = cleaned.indexOf(',');
const intPart = (comma < 0 ? cleaned : cleaned.slice(0, comma)).replace(/,/g, '').replace(/^0+(?=\d)/, '');
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
if (comma < 0) return grouped;
const fraction = cleaned.slice(comma + 1).replace(/,/g, '').slice(0, 2);
return `${grouped || '0'},${fraction}`;
}
/** Сумма ранее поданных оплат по основанию. */
export function previousPaymentsTotal(payments: IPreviousPayment[]): number {
return payments.reduce((sum, p) => sum + p.amount, 0);
}
/**
* На сколько все оплаты по основанию (прежние + эта) превышают общую сумму СТ.
* 0 — не превышают; общая сумма не задана — сравнивать не с чем, тоже 0.
*/
export function overLimitAmount(data: Pick<IFormData, 'TotalAmount' | 'PayAmount' | 'PreviousPayments'>): number {
if (data.TotalAmount <= 0) return 0;
const over = previousPaymentsTotal(data.PreviousPayments) + data.PayAmount - data.TotalAmount;
return over > MONEY_EPSILON ? over : 0;
}
/** Сумма с разрядами для показа («1 234 567,5»). */
export function formatAmount(value: number): string {
return value.toLocaleString('ru-RU', { maximumFractionDigits: 2 });
}
const LEGAL_FORMS = /(^|\s)(тоо|ао|ип|llp|ооо|товарищество с ограниченной ответственностью)(?=\s|$)/gi;
/** Название контрагента для сравнения: без формы собственности, кавычек и регистра. */
export function normalizeCounterparty(name: string): string {
return name
.toLowerCase()
.replace(/[^a-zа-яё0-9]+/gi, ' ')
.replace(LEGAL_FORMS, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Совпадает ли получатель из СТ с поставщиком из счёта. БИН надёжнее названия:
* если он есть у обеих сторон — решает он. Нет данных по счёту — расхождения нет
* (подсвечивать нечего).
*/
export function recipientMatchesInvoice(
recipient: { name: string; bin: string },
invoice: { name: string; bin: string }
): boolean {
if (!invoice.name && !invoice.bin) return true;
if (recipient.bin && invoice.bin) return recipient.bin === invoice.bin;
const a = normalizeCounterparty(recipient.name);
const b = normalizeCounterparty(invoice.name);
if (!a || !b) return true;
return a === b || a.indexOf(b) !== -1 || b.indexOf(a) !== -1;
}
@@ -0,0 +1,17 @@
{
"dataListTitle": "Заявка на оплату — согласование",
"columns": [
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Заявка на оплату — данные", "lookupShowField": "Title" },
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
]
}
@@ -0,0 +1,54 @@
import { ICompany, IProject } from '../../shared/dictionary';
import { normalizeCounterparty } from './amounts';
import { IFormData, IPreviousPayment } from './model';
import { rowsFromBasis } from './positions';
import { IBasisInfo } from './services/basis.types';
/** Проект из справочника по названию: в СТ и потребности проект хранится только текстом. */
function findProject(projects: IProject[], name: string): IProject | undefined {
const wanted = name.trim().toLowerCase();
return wanted ? projects.filter((p) => p.name.trim().toLowerCase() === wanted)[0] : undefined;
}
/** Компания из справочника по подсказке (организация потребности). */
function findCompany(companies: ICompany[], hint: string): ICompany | undefined {
const wanted = normalizeCounterparty(hint);
if (!wanted) return undefined;
return companies.filter((c) => normalizeCounterparty(c.Name) === wanted)[0];
}
/**
* Поля формы, которые подставляет выбранное основание. Всё подставленное остаётся
* редактируемым: справочники не всегда точны, а заявитель лучше знает, что платит.
* Счёт и сумма оплаты основанием не затрагиваются.
*/
export function basisPatch(
info: IBasisInfo,
previous: IPreviousPayment[],
projects: IProject[],
companies: ICompany[]
): Partial<IFormData> {
const project = findProject(projects, info.projectName);
const company = findCompany(companies, info.companyHint);
return {
BasisKind: info.kind,
BasisKey: info.key,
BasisTitle: info.title,
ProjectName: info.projectName,
ProjectId: project ? project.oid : '',
ProjectManager: project ? project.projectManager || '' : '',
CompanyId: company ? company.Id : '',
CompanyName: company ? company.Name : '',
RecipientName: info.recipientName,
RecipientBin: info.recipientBin,
Section: info.section,
WorkKind: info.workKind,
TotalAmount: info.total,
Currency: info.currency,
DeliveryTerms: info.deliveryTerms,
PaymentTerms: info.paymentTerms,
DeliveryTime: info.deliveryTime,
PreviousPayments: previous,
Positions: rowsFromBasis(info.positions)
};
}
@@ -0,0 +1,62 @@
import { IFormData, IPositionRow } from './model';
import { buildRows, IInvoiceItem, IPositionPair } from './positions';
import { IInvoiceData } from './services/invoiceAi.service';
import { IBasisPosition } from './services/basis.types';
/** Назначение платежа по номеру счёта. */
export function purposeForInvoice(invoiceNumber: string): string {
return invoiceNumber ? `Оплата по счёту № ${invoiceNumber}` : '';
}
/**
* Позиции основания из уже собранной таблицы (строки «нет в СТ» — это позиции
* счёта, к основанию они не относятся). Нужны, чтобы повторно сверить таблицу с
* другим счётом, не перезагружая СТ.
*/
export function basisPositionsFromRows(rows: IPositionRow[]): IBasisPosition[] {
return rows
.filter((row) => row.status !== 'extra')
.map((row) => ({
code: row.code,
name: row.name,
unit: row.unit,
qty: row.qty,
supplierName: row.supplierName,
supplierQty: row.supplierQty,
supplierPrice: row.supplierPrice
}));
}
/**
* Что счёт подставляет в форму. Пустое значение из счёта поле не затирает.
* Общая сумма берётся из счёта только когда основание её не дало (потребность):
* у СТ общая сумма — предел для всех оплат, и счёт его менять не должен.
* Получатель у потребности — поставщик из счёта; у СТ — победитель, и расхождение
* со счётом подсвечивается, а не затирается.
*/
export function invoicePatch(
data: IFormData,
invoice: IInvoiceData,
items: IInvoiceItem[],
pairs: IPositionPair[]
): Partial<IFormData> {
const patch: Partial<IFormData> = {
InvoiceNumber: invoice.invoiceNumber,
InvoiceSupplierName: invoice.supplierName,
InvoiceSupplierBin: invoice.supplierBin,
Positions: buildRows(basisPositionsFromRows(data.Positions), items, pairs)
};
if (invoice.recipientAccount) patch.RecipientAccount = invoice.recipientAccount;
if (invoice.currency) patch.Currency = invoice.currency;
if (invoice.invoiceNumber) patch.PaymentPurpose = purposeForInvoice(invoice.invoiceNumber);
if (invoice.vatAmount > 0) patch.VatAmount = invoice.vatAmount;
if (invoice.totalAmount > 0) {
patch.PayAmount = invoice.totalAmount;
if (data.TotalAmount <= 0) patch.TotalAmount = invoice.totalAmount;
}
if (!data.RecipientName && invoice.supplierName) {
patch.RecipientName = invoice.supplierName;
patch.RecipientBin = invoice.supplierBin;
}
return patch;
}
@@ -0,0 +1,110 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { spDateOnlyToIso } from '../../shared/dateUtils';
import { EMPTY_DATA, FORM_ID, IFormData, IPositionRow, IPreviousPayment, BasisKind } from './model';
export interface ILoadedRequest {
data: IFormData;
status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string;
}
/** Текстовые колонки, которые читаются как есть (имя поля формы = имя колонки). */
const TEXT_KEYS = [
'RequesterEmail',
'RequesterName',
'RequesterJobTitle',
'Organization',
'Department',
'DepartmentId',
'BasisKey',
'BasisTitle',
'ProjectName',
'ProjectId',
'ProjectManager',
'CompanyId',
'CompanyName',
'BankAccountId',
'BankAccountName',
'RecipientName',
'RecipientBin',
'RecipientAccount',
'Section',
'WorkKind',
'ContractId',
'ContractName',
'CashFlowGroupId',
'CashFlowGroupName',
'CashFlowItemId',
'CashFlowItemName',
'PaymentPurpose',
'DeliveryTerms',
'PaymentTerms',
'Currency',
'DeliveryTime',
'Comment',
'InvoiceFileName',
'InvoiceUrl',
'InvoiceNumber',
'InvoiceSupplierName',
'InvoiceSupplierBin'
] as const;
const NUMBER_KEYS = ['TotalAmount', 'VatAmount', 'PayAmount'] as const;
const SELECT_FIELDS = [
'Id',
'RequestDate',
'BasisKind',
'PreviousPaymentsJson',
'PositionsJson',
'RequestStatus',
'RequestUniqueCode',
...TEXT_KEYS,
...NUMBER_KEYS
];
/**
* Безопасный разбор JSON-колонки табличной части: пустой массив при пустом
* значении или битом JSON — заявка должна открываться даже с испорченной
* таблицей, иначе её нельзя было бы починить через форму.
*/
function parseRows<T>(raw: unknown): T[] {
if (typeof raw !== 'string' || raw.trim() === '') return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as T[]) : [];
} catch {
return [];
}
}
/**
* Прочитать «Заявку на оплату» по id: модель формы + статус + код.
* listId — партиция, где лежит именно эта заявка; не задан — текущая.
*/
export async function loadPaymentRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
const item = (await list.items.getById(itemId).select(...SELECT_FIELDS)()) as Record<string, unknown>;
const data: IFormData = {
...EMPTY_DATA,
RequestDate: spDateOnlyToIso(item.RequestDate),
BasisKind: item.BasisKind === 'need' ? ('need' as BasisKind) : 'ct',
PreviousPayments: parseRows<IPreviousPayment>(item.PreviousPaymentsJson),
Positions: parseRows<IPositionRow>(item.PositionsJson)
};
TEXT_KEYS.forEach((key) => {
data[key] = (item[key] as string) || '';
});
NUMBER_KEYS.forEach((key) => {
data[key] = Number(item[key] || 0);
});
return {
data,
status: (item.RequestStatus as string) || '',
code: (item.RequestUniqueCode as string) || ''
};
}
@@ -0,0 +1,189 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
* SharePoint выводятся из него, см. shared/listTitles.ts.
*/
export const FORM_ID: RequestTypeId = 'acc-payment-request';
/** На чём основана оплата: согласованная СТ либо потребность (когда СТ не было). */
export type BasisKind = 'ct' | 'need';
export const BASIS_OPTIONS: Array<{ value: BasisKind; label: string }> = [
{ value: 'ct', label: 'Сравнительная таблица (СТ)' },
{ value: 'need', label: 'Потребность' }
];
/** Ранее поданная заявка на оплату по той же СТ/потребности. */
export interface IPreviousPayment {
/** Уникальный код заявки на оплату. */
code: string;
amount: number;
/** Id элемента списка данных — для ссылки на заявку. Нет, если у пользователя нет прав её открыть (или в старом снимке). */
id?: number;
/** Список партиции (серверный относительный URL), где лежит заявка; '' — текущая. */
listId?: string;
/** Статус заявки на момент чтения. */
status?: string;
}
/**
* Результат сверки строки с позициями счёта:
* ok — совпало; diff — позиция та же, но отличается количество/ед. изм.;
* missing — есть в СТ/потребности, в счёте нет; extra — есть в счёте, в СТ нет.
*/
export type RowStatus = 'ok' | 'diff' | 'missing' | 'extra';
/** Строка таблицы позиций: СТ/потребность ↔ поставщик ↔ счёт. */
export interface IPositionRow {
/** Уникальный код позиции потребности. */
code: string;
/** Наименование и ед. изм. из потребности (в СТ они есть). */
name: string;
unit: string;
qty: number;
/** Позиция поставщика из его КП (только при основании «СТ»). */
supplierName: string;
supplierQty: number;
supplierPrice: number;
/** Позиция из счёта. */
invoiceName: string;
invoiceUnit: string;
invoiceQty: number;
invoicePrice: number;
invoiceSum: number;
status: RowStatus;
/** Пояснение к расхождению; '' — нет. */
note: string;
}
/**
* Данные формы «Заявка на оплату». Ключи = internalName колонок SP-списка
* «Заявка на оплату — данные». Табличные части уходят в JSON-колонки
* (PreviousPaymentsJson, PositionsJson), суммы — в числовые колонки (для отчётов).
*/
export interface IFormData {
// --- Заявитель ---
RequesterEmail: string;
RequesterName: string;
RequesterJobTitle: string;
Organization: string;
Department: string;
DepartmentId: string;
RequestDate: string;
// --- Основание ---
BasisKind: BasisKind;
/** Ключ основания для поиска прежних оплат: «ct:<Id СТ>» либо «need:<oid потребности>». */
BasisKey: string;
/** Подпись основания: «СТ №3 — Проект» либо «Потребность 081/26-04/0017». */
BasisTitle: string;
ProjectName: string;
/** oid проекта: по нему бэкенд резолвит маршрут согласования. */
ProjectId: string;
ProjectManager: string;
CompanyId: string;
CompanyName: string;
// --- Платёж ---
BankAccountId: string;
BankAccountName: string;
RecipientName: string;
RecipientBin: string;
/** Счёт получателя (IBAN) — из файла счёта. */
RecipientAccount: string;
Section: string;
WorkKind: string;
ContractId: string;
ContractName: string;
CashFlowGroupId: string;
CashFlowGroupName: string;
CashFlowItemId: string;
CashFlowItemName: string;
PaymentPurpose: string;
DeliveryTerms: string;
PaymentTerms: string;
// --- Суммы ---
/** Суммы хранятся числом: 0 — не заполнено. */
TotalAmount: number;
VatAmount: number;
Currency: string;
PayAmount: number;
DeliveryTime: string;
Comment: string;
// --- Счёт ---
InvoiceFileName: string;
/** Адрес файла в библиотеке счетов (ServerRelativeUrl). */
InvoiceUrl: string;
InvoiceNumber: string;
/** Поставщик, как он назван в самом счёте, — сверяется с получателем из СТ. */
InvoiceSupplierName: string;
InvoiceSupplierBin: string;
// --- Табличные части ---
PreviousPayments: IPreviousPayment[];
Positions: IPositionRow[];
}
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
/** Пропсы каждой секции формы. */
export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/** Подставить сразу несколько полей (выбор основания меняет их пачкой). */
patch: (changes: Partial<IFormData>) => void;
printOrgIndex?: number;
onRequesterChange?: (login: string) => void;
}
export const EMPTY_DATA: IFormData = {
RequesterEmail: '',
RequesterName: '',
RequesterJobTitle: '',
Organization: '',
Department: '',
DepartmentId: '',
RequestDate: '',
BasisKind: 'ct',
BasisKey: '',
BasisTitle: '',
ProjectName: '',
ProjectId: '',
ProjectManager: '',
CompanyId: '',
CompanyName: '',
BankAccountId: '',
BankAccountName: '',
RecipientName: '',
RecipientBin: '',
RecipientAccount: '',
Section: '',
WorkKind: '',
ContractId: '',
ContractName: '',
CashFlowGroupId: '',
CashFlowGroupName: '',
CashFlowItemId: '',
CashFlowItemName: '',
PaymentPurpose: '',
DeliveryTerms: '',
PaymentTerms: '',
TotalAmount: 0,
VatAmount: 0,
Currency: '',
PayAmount: 0,
DeliveryTime: '',
Comment: '',
InvoiceFileName: '',
InvoiceUrl: '',
InvoiceNumber: '',
InvoiceSupplierName: '',
InvoiceSupplierBin: '',
PreviousPayments: [],
Positions: []
};
@@ -0,0 +1,63 @@
import { buildRows, hasPositionIssues, IInvoiceItem } from './positions';
import { IBasisPosition } from './services/basis.types';
const base = (name: string, qty: number, unit = 'шт'): IBasisPosition => ({
code: `K-${name}`,
name,
unit,
qty,
supplierName: '',
supplierQty: 0,
supplierPrice: 0
});
const item = (name: string, quantity: number, unit = 'шт'): IInvoiceItem => ({
name,
unit,
quantity,
price: 10,
sum: 10 * quantity
});
describe('buildRows', () => {
it('совпавшая пара — ok', () => {
const rows = buildRows([base('Цемент', 5)], [item('Цемент М400', 5)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
expect(rows).toHaveLength(1);
expect(rows[0].status).toBe('ok');
expect(rows[0].invoiceName).toBe('Цемент М400');
});
it('разное количество — diff с пояснением', () => {
const rows = buildRows([base('Цемент', 5)], [item('Цемент', 7)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
expect(rows[0].status).toBe('diff');
expect(rows[0].note).toContain('количество');
});
it('единицы измерения сравниваются без регистра и точек', () => {
const rows = buildRows([base('Труба', 2, 'Шт.')], [item('Труба', 2, 'шт')], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
expect(rows[0].status).toBe('ok');
});
it('позиция, которую ИИ не упомянул, не пропадает', () => {
const rows = buildRows([base('А', 1), base('Б', 1)], [item('А', 1), item('В', 1)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]);
expect(rows.map((r) => r.status)).toEqual(['ok', 'missing', 'extra']);
});
it('пары с неверными индексами и повторы отбрасываются', () => {
const rows = buildRows(
[base('А', 1)],
[item('А', 1), item('Б', 1)],
[
{ baseIndex: 0, invoiceIndex: 0, note: '' },
{ baseIndex: 0, invoiceIndex: 1, note: '' },
{ baseIndex: 9, invoiceIndex: 1, note: '' }
]
);
expect(rows.map((r) => r.status)).toEqual(['ok', 'extra']);
});
it('hasPositionIssues видит любое расхождение', () => {
expect(hasPositionIssues(buildRows([base('А', 1)], [], []))).toBe(true);
expect(hasPositionIssues(buildRows([base('А', 1)], [item('А', 1)], [{ baseIndex: 0, invoiceIndex: 0, note: '' }]))).toBe(false);
});
});
@@ -0,0 +1,129 @@
import { IPositionRow } from './model';
import { IBasisPosition } from './services/basis.types';
/** Позиция, распознанная в счёте. */
export interface IInvoiceItem {
name: string;
unit: string;
quantity: number;
price: number;
sum: number;
}
/**
* Пара, которую вернула ИИ: какой позиции основания (baseIndex) соответствует
* какая позиция счёта (invoiceIndex). −1 с одной из сторон — пары нет.
*/
export interface IPositionPair {
baseIndex: number;
invoiceIndex: number;
note: string;
}
const QTY_EPSILON = 0.0001;
function emptyInvoicePart(): Pick<IPositionRow, 'invoiceName' | 'invoiceUnit' | 'invoiceQty' | 'invoicePrice' | 'invoiceSum'> {
return { invoiceName: '', invoiceUnit: '', invoiceQty: 0, invoicePrice: 0, invoiceSum: 0 };
}
function invoicePart(item: IInvoiceItem): ReturnType<typeof emptyInvoicePart> {
return {
invoiceName: item.name,
invoiceUnit: item.unit,
invoiceQty: item.quantity,
invoicePrice: item.price,
invoiceSum: item.sum
};
}
/** Ед. изм. для сравнения: «шт.», «Шт» и «шт» — одно и то же. */
function normalizeUnit(unit: string): string {
return unit.toLowerCase().replace(/[.\s]/g, '');
}
function basisPart(position: IBasisPosition): Pick<IPositionRow, 'code' | 'name' | 'unit' | 'qty' | 'supplierName' | 'supplierQty' | 'supplierPrice'> {
return {
code: position.code,
name: position.name,
unit: position.unit,
qty: position.qty,
supplierName: position.supplierName,
supplierQty: position.supplierQty,
supplierPrice: position.supplierPrice
};
}
/** Строки таблицы до загрузки счёта: только основание, сверять пока не с чем. */
export function rowsFromBasis(base: IBasisPosition[]): IPositionRow[] {
return base.map((position) => ({
...basisPart(position),
...emptyInvoicePart(),
status: 'ok',
note: ''
}));
}
/** Расхождения парной строки: ед. изм. и количество сравниваем сами, не полагаясь на ИИ. */
function diffNote(position: IBasisPosition, item: IInvoiceItem, aiNote: string): string {
const notes: string[] = [];
if (position.unit && item.unit && normalizeUnit(position.unit) !== normalizeUnit(item.unit)) {
notes.push(`ед. изм.: ${position.unit} → ${item.unit}`);
}
if (Math.abs(position.qty - item.quantity) > QTY_EPSILON) {
notes.push(`количество: ${position.qty} → ${item.quantity}`);
}
// Смысловое отличие названия (другая марка, размер) видит только ИИ — его пометка сама по себе расхождение.
if (aiNote) notes.push(aiNote);
return notes.join('; ');
}
/**
* Собрать таблицу сверки из ответа ИИ. Пары с неверными индексами и повторы
* отбрасываются, а всё, что ИИ не упомянул, попадает в таблицу как «нет в счёте» /
* «нет в основании»: ни одна позиция не должна пропасть молча.
*/
export function buildRows(base: IBasisPosition[], invoice: IInvoiceItem[], pairs: IPositionPair[]): IPositionRow[] {
const invoiceByBase = new Map<number, { index: number; note: string }>();
const usedInvoice = new Set<number>();
pairs.forEach((pair) => {
const validBase = pair.baseIndex >= 0 && pair.baseIndex < base.length;
const validInvoice = pair.invoiceIndex >= 0 && pair.invoiceIndex < invoice.length;
if (!validBase || !validInvoice) return;
if (invoiceByBase.has(pair.baseIndex) || usedInvoice.has(pair.invoiceIndex)) return;
invoiceByBase.set(pair.baseIndex, { index: pair.invoiceIndex, note: pair.note });
usedInvoice.add(pair.invoiceIndex);
});
const rows: IPositionRow[] = base.map((position, baseIndex) => {
const match = invoiceByBase.get(baseIndex);
if (!match) {
return { ...basisPart(position), ...emptyInvoicePart(), status: 'missing', note: 'Нет в счёте' };
}
const item = invoice[match.index];
const note = diffNote(position, item, match.note);
return { ...basisPart(position), ...invoicePart(item), status: note ? 'diff' : 'ok', note };
});
invoice.forEach((item, index) => {
if (usedInvoice.has(index)) return;
rows.push({
code: '',
name: '',
unit: '',
qty: 0,
supplierName: '',
supplierQty: 0,
supplierPrice: 0,
...invoicePart(item),
status: 'extra',
note: 'Нет в СТ / потребности'
});
});
return rows;
}
/** Есть ли в таблице хоть одно расхождение со счётом. */
export function hasPositionIssues(rows: IPositionRow[]): boolean {
return rows.some((row) => row.status !== 'ok');
}
@@ -0,0 +1,57 @@
{
"dataListTitle": "Заявка на оплату — данные",
"columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "RequesterEmail", "title": "Заявитель (email)", "type": "Text" },
{ "internalName": "RequesterName", "title": "Заявитель (Ф.И.О.)", "type": "Text" },
{ "internalName": "RequesterJobTitle", "title": "Должность заявителя", "type": "Text" },
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Department", "title": "Подразделение", "type": "Text" },
{ "internalName": "DepartmentId", "title": "Id подразделения (справочник)", "type": "Text" },
{ "internalName": "RequestDate", "title": "Дата заявки", "type": "DateTime", "dateOnly": true },
{ "internalName": "BasisKind", "title": "Основание (ct / need)", "type": "Text" },
{ "internalName": "BasisKey", "title": "Ключ основания (ct:Id / need:oid)", "type": "Text" },
{ "internalName": "BasisTitle", "title": "Основание", "type": "Text" },
{ "internalName": "ProjectName", "title": "Проект", "type": "Text" },
{ "internalName": "ProjectId", "title": "Id проекта", "type": "Text" },
{ "internalName": "ProjectManager", "title": "Руководитель проекта (email)", "type": "Text" },
{ "internalName": "CompanyId", "title": "Id компании", "type": "Text" },
{ "internalName": "CompanyName", "title": "Компания", "type": "Text" },
{ "internalName": "BankAccountId", "title": "Id банковского счёта (1С)", "type": "Text" },
{ "internalName": "BankAccountName", "title": "Банковский счёт", "type": "Text" },
{ "internalName": "RecipientName", "title": "Получатель", "type": "Text" },
{ "internalName": "RecipientBin", "title": "БИН получателя", "type": "Text" },
{ "internalName": "RecipientAccount", "title": "Счёт получателя", "type": "Text" },
{ "internalName": "Section", "title": "Раздел", "type": "Text" },
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
{ "internalName": "ContractId", "title": "Id договора (1С)", "type": "Text" },
{ "internalName": "ContractName", "title": "Договор", "type": "Text" },
{ "internalName": "CashFlowGroupId", "title": "Id группы статьи ДДС", "type": "Text" },
{ "internalName": "CashFlowGroupName", "title": "Группа статьи ДДС", "type": "Text" },
{ "internalName": "CashFlowItemId", "title": "Id статьи ДДС", "type": "Text" },
{ "internalName": "CashFlowItemName", "title": "Статья ДДС", "type": "Text" },
{ "internalName": "PaymentPurpose", "title": "Назначение платежа", "type": "Note" },
{ "internalName": "DeliveryTerms", "title": "Условия доставки", "type": "Note" },
{ "internalName": "PaymentTerms", "title": "Условия оплаты", "type": "Note" },
{ "internalName": "TotalAmount", "title": "Общая сумма", "type": "Number" },
{ "internalName": "VatAmount", "title": "НДС", "type": "Number" },
{ "internalName": "Currency", "title": "Валюта", "type": "Text" },
{ "internalName": "PayAmount", "title": "Сумма оплаты", "type": "Number" },
{ "internalName": "DeliveryTime", "title": "Общий срок поставки", "type": "Text" },
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
{ "internalName": "InvoiceFileName", "title": "Файл счёта", "type": "Text" },
{ "internalName": "InvoiceUrl", "title": "Адрес файла счёта", "type": "Text" },
{ "internalName": "InvoiceNumber", "title": "Номер счёта", "type": "Text" },
{ "internalName": "InvoiceSupplierName", "title": "Поставщик по счёту", "type": "Text" },
{ "internalName": "InvoiceSupplierBin", "title": "БИН поставщика по счёту", "type": "Text" },
{ "internalName": "PreviousPaymentsJson", "title": "Ранее поданные оплаты (JSON)", "type": "Note" },
{ "internalName": "PositionsJson", "title": "Позиции: СТ, поставщик, счёт (JSON)", "type": "Note" }
]
}
@@ -0,0 +1,37 @@
import * as React from 'react';
import { TextInput } from '../../../shared/ui';
import { formatAmountInput, parseAmount } from '../amounts';
interface IAmountInputProps {
id?: string;
value: number;
onChange: (value: number) => void;
}
const toText = (value: number): string => (value ? formatAmountInput(String(value)) : '');
/**
* Поле суммы с десятичной частью и разрядами («1 234 567,50»). Хранит значение числом, но пока пользователь
* печатает, держит собственный текст: иначе «12,» превращалось бы в «12» на
* каждом нажатии и дробную часть ввести было бы нельзя.
*/
export const AmountInput: React.FC<IAmountInputProps> = ({ id, value, onChange }) => {
const [text, setText] = React.useState<string>(toText(value));
// Значение изменили снаружи (подставил счёт, загрузилась заявка) — показываем его.
React.useEffect(() => {
setText((current) => (parseAmount(current) === value ? current : toText(value)));
}, [value]);
return (
<TextInput
id={id}
value={text}
onChange={(next) => {
const formatted = formatAmountInput(next);
setText(formatted);
onChange(parseAmount(formatted));
}}
/>
);
};
@@ -0,0 +1,93 @@
import * as React from 'react';
import styles from './PaymentSections.module.scss';
import { FormField, TextInput, TextArea } from '../../../shared/ui';
import { formatAmount, overLimitAmount, previousPaymentsTotal } from '../amounts';
import { IPreviousPayment, ISectionProps } from '../model';
import { AmountInput } from './AmountInput';
import { RequestDrawer } from './RequestDrawer';
/**
* Суммы платежа. Сумма всех оплат по СТ (прежние заявки + эта) не может превышать
* общую сумму СТ — при превышении сумма оплаты подсвечивается, отправку это не блокирует.
*/
export const AmountsSection: React.FC<ISectionProps> = ({ data, set, context }) => {
// Прежняя заявка, открытая в панели справа; undefined — панель закрыта.
const [opened, setOpened] = React.useState<IPreviousPayment | undefined>(undefined);
const close = React.useCallback(() => setOpened(undefined), []);
const over = overLimitAmount(data);
const previousTotal = previousPaymentsTotal(data.PreviousPayments);
return (
<>
<FormField label="Общая сумма" htmlFor="TotalAmount">
<AmountInput id="TotalAmount" value={data.TotalAmount} onChange={(v) => set('TotalAmount', v)} />
</FormField>
<FormField label="Валюта" htmlFor="Currency">
<TextInput id="Currency" value={data.Currency} onChange={(v) => set('Currency', v.toUpperCase())} placeholder="KZT" />
</FormField>
<FormField label="НДС" htmlFor="VatAmount">
<AmountInput id="VatAmount" value={data.VatAmount} onChange={(v) => set('VatAmount', v)} />
</FormField>
<FormField label="Сумма оплаты" htmlFor="PayAmount" required>
<div className={over > 0 ? styles.warnBox : undefined}>
<AmountInput id="PayAmount" value={data.PayAmount} onChange={(v) => set('PayAmount', v)} />
{over > 0 && (
<div className={styles.warnText}>
Все оплаты по основанию превышают общую сумму на {formatAmount(over)} {data.Currency}.
</div>
)}
</div>
</FormField>
<FormField label="Общий срок поставки" htmlFor="DeliveryTime">
<TextInput id="DeliveryTime" value={data.DeliveryTime} onChange={(v) => set('DeliveryTime', v)} />
</FormField>
<FormField label="Комментарий" htmlFor="Comment" full>
<TextArea id="Comment" value={data.Comment} onChange={(v) => set('Comment', v)} rows={2} />
</FormField>
<h4 className={styles.fullRow}>Заявки на оплату по этому основанию</h4>
{data.PreviousPayments.length === 0 ? (
<div className={`${styles.empty} ${styles.fullRow}`}>Ранее заявок на оплату по этому основанию не было.</div>
) : (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Номер заявки на оплату</th>
<th>Статус</th>
<th className={styles.num}>Сумма оплаты</th>
</tr>
</thead>
<tbody>
{data.PreviousPayments.map((p) => (
<tr key={p.code}>
<td>
{p.id ? (
<button type="button" className={styles.link} onClick={() => setOpened(p)}>
{p.code}
</button>
) : (
p.code
)}
</td>
<td>{p.status || ''}</td>
<td className={styles.num}>{formatAmount(p.amount)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={2}>Итого ранее</td>
<td className={styles.num}>{formatAmount(previousTotal)}</td>
</tr>
</tfoot>
</table>
</div>
)}
{opened && opened.id && (
<RequestDrawer context={context} itemId={opened.id} listId={opened.listId || ''} code={opened.code} onClose={close} />
)}
</>
);
};
@@ -0,0 +1,65 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { IDictionaryItem, ItemPicker } from '../../../shared/dictionary';
import { friendlyErrorMessage } from '../../../shared/diagnostics';
import { BasisKind } from '../model';
import { IBasisOption } from '../services/basis.types';
import { listApprovedCts } from '../services/ctBasis.service';
import { listNeeds } from '../services/needBasis.service';
interface IBasisPickerProps {
context: WebPartContext;
kind: BasisKind;
/** Подпись выбранного основания. */
value: string;
onPick: (id: string) => void;
readOnly?: boolean;
}
function toPickerItem(option: IBasisOption): IDictionaryItem {
return { id: option.id, label: option.title, note: option.note };
}
/**
* Поиск основания: согласованные СТ либо потребности (по выбранному виду основания).
* Выбор запускает загрузку СТ/потребности целиком (см. useBasisSelection), поэтому
* ручной ввод текста основанием не считается — onPick зовётся только с id записи.
*/
export const BasisPicker: React.FC<IBasisPickerProps> = ({ context, kind, value, onPick, readOnly }) => {
const [items, setItems] = React.useState<IDictionaryItem[]>([]);
const [error, setError] = React.useState<string>('');
// null — показываем выбранное основание; строка — пользователь печатает запрос (в т.ч. пустой).
const [query, setQuery] = React.useState<string | null>(null);
React.useEffect(() => {
if (readOnly) return undefined;
let alive = true;
setError('');
setItems([]);
(kind === 'ct' ? listApprovedCts(context) : listNeeds(context))
.then((options) => {
if (alive) setItems(options.map(toPickerItem));
})
.catch((e) => {
if (alive) setError(`Не удалось загрузить список: ${friendlyErrorMessage(e)}`);
});
return () => {
alive = false;
};
}, [context, kind, readOnly]);
return (
<ItemPicker
id="BasisTitle"
items={items}
value={query ?? value}
readOnly={readOnly}
placeholder={kind === 'ct' ? 'Найдите согласованную СТ по номеру или проекту…' : 'Найдите потребность по номеру или проекту…'}
hint={error}
onChange={(label, id) => {
setQuery(id ? null : label);
if (id) onPick(id);
}}
/>
);
};
@@ -0,0 +1,87 @@
import * as React from 'react';
import { Alert, FormField, Select, ISelectOption } from '../../../shared/ui';
import styles from './PaymentSections.module.scss';
import { ICompany, ProjectPicker, getCompanies } from '../../../shared/dictionary';
import { BASIS_OPTIONS, BasisKind, ISectionProps } from '../model';
import { IBasisSelection } from '../useBasisSelection';
import { BasisPicker } from './BasisPicker';
interface IBasisSectionProps extends ISectionProps {
basis: IBasisSelection;
}
const KIND_OPTIONS: ISelectOption[] = BASIS_OPTIONS.map((o) => ({ value: o.value, label: o.label }));
/**
* Основание оплаты: согласованная СТ либо потребность. Выбор основания подставляет
* проект, раздел, вид работ, условия, суммы и позиции (см. basisPatch) — всё это
* остаётся редактируемым. Проект определяет маршрут согласования.
*/
export const BasisSection: React.FC<IBasisSectionProps> = ({ data, patch, context, basis }) => {
const [companies, setCompanies] = React.useState<ICompany[]>([]);
React.useEffect(() => {
let alive = true;
getCompanies(context)
.then((list) => {
if (alive) setCompanies(list);
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [context]);
const companyOptions: ISelectOption[] = companies.map((c) => ({ value: c.Id, label: c.Name }));
// Компания из заявки может отсутствовать в справочнике (его не удалось загрузить) — показываем её как есть.
if (data.CompanyId && !companies.some((c) => c.Id === data.CompanyId)) {
companyOptions.push({ value: data.CompanyId, label: data.CompanyName });
}
const handleKindChange = (value: string): void => {
patch({ BasisKind: value as BasisKind, BasisKey: '', BasisTitle: '', PreviousPayments: [], Positions: [] });
};
return (
<>
<FormField label="Основание" htmlFor="BasisKind" required>
<Select id="BasisKind" value={data.BasisKind} onChange={handleKindChange} options={KIND_OPTIONS} />
</FormField>
<FormField label={data.BasisKind === 'ct' ? 'Согласованная СТ' : 'Потребность'} htmlFor="BasisTitle" required>
<BasisPicker
context={context}
kind={data.BasisKind}
value={data.BasisTitle}
onPick={(id) => {
basis.select(data.BasisKind, id).catch(() => undefined);
}}
/>
</FormField>
<FormField label="Проект" htmlFor="ProjectName" required>
<ProjectPicker
id="ProjectName"
context={context}
value={data.ProjectName}
placeholder="Начните вводить название проекта…"
onChange={(name, id, project) => {
patch({ ProjectName: name, ProjectId: id, ProjectManager: project ? project.projectManager || '' : '' });
}}
/>
</FormField>
<FormField label="Компания" htmlFor="CompanyId" required>
<Select
id="CompanyId"
value={data.CompanyId}
onChange={(id) => {
const company = companies.filter((c) => c.Id === id)[0];
patch({ CompanyId: id, CompanyName: company ? company.Name : '' });
}}
options={companyOptions}
placeholder="Выберите компанию…"
/>
</FormField>
{basis.loading && <div className={styles.fullRow}><Alert variant="info">Загружаем данные основания…</Alert></div>}
{basis.error && <div className={styles.fullRow}><Alert variant="err">{basis.error}</Alert></div>}
</>
);
};
@@ -0,0 +1,37 @@
import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { DepartmentPicker } from '../../../shared/dictionary';
import { ISectionProps } from '../model';
/** Шапка заявки: кто подаёт (по умолчанию текущий пользователь), должность, подразделение, дата. */
export const HeaderSection: React.FC<ISectionProps> = ({ data, set, context, printOrgIndex, onRequesterChange }) => (
<FormGrid>
<FormField label="Заявитель" htmlFor="RequesterEmail" required full>
<PeopleField
context={context}
value={data.RequesterEmail}
onChange={onRequesterChange ?? ((v) => set('RequesterEmail', v))}
placeholder="Начните вводить Ф.И.О. заявителя…"
/>
</FormField>
<OrganizationField value={data.Organization} printIndex={printOrgIndex} />
<FormField label="Должность заявителя" htmlFor="RequesterJobTitle">
<TextInput id="RequesterJobTitle" value={data.RequesterJobTitle} onChange={(v) => set('RequesterJobTitle', v)} />
</FormField>
<FormField label="Подразделение" htmlFor="Department">
<DepartmentPicker
id="Department"
context={context}
value={data.Department}
onChange={(name, id) => {
set('Department', name);
set('DepartmentId', id);
}}
/>
</FormField>
<FormField label="Дата заявки" htmlFor="RequestDate">
<TextInput id="RequestDate" type="date" value={data.RequestDate} onChange={(v) => set('RequestDate', v)} />
</FormField>
</FormGrid>
);
@@ -0,0 +1,70 @@
import * as React from 'react';
import styles from './PaymentSections.module.scss';
import { Alert, Button, Icon, useFormReadOnly } from '../../../shared/ui';
import { DATA_SITE_URL } from '../../../shared/config';
import { IFormData } from '../model';
import { INVOICE_STAGE_TEXT, IInvoiceFlow } from '../useInvoiceFlow';
interface IInvoiceSectionProps {
data: IFormData;
invoice: IInvoiceFlow;
}
/** Ссылка на файл счёта: в данных лежит серверный относительный адрес. */
function invoiceHref(serverRelativeUrl: string): string {
return new URL(serverRelativeUrl, DATA_SITE_URL).href;
}
/**
* Вложение — счёт на оплату (PDF). Файл сохраняется в библиотеку и уходит на OCR;
* ИИ достаёт из текста реквизиты, суммы и позиции и подставляет их в форму.
* Если распознать не вышло, счёт всё равно остаётся приложенным — поля заполняются руками.
*/
export const InvoiceSection: React.FC<IInvoiceSectionProps> = ({ data, invoice }) => {
const readOnly = useFormReadOnly();
const inputRef = React.useRef<HTMLInputElement>(null);
const busy = invoice.stage !== 'idle';
return (
<div>
<div className={styles.invoiceRow}>
{data.InvoiceFileName ? (
<span>
<Icon name="document" size="sm" />{' '}
{data.InvoiceUrl ? (
<a href={invoiceHref(data.InvoiceUrl)} target="_blank" rel="noreferrer">
{data.InvoiceFileName}
</a>
) : (
data.InvoiceFileName
)}
{data.InvoiceNumber && ` — счёт № ${data.InvoiceNumber}`}
</span>
) : (
<span className={styles.hint}>Счёт ещё не приложен.</span>
)}
{!readOnly && (
<>
<input
ref={inputRef}
className={styles.fileInput}
type="file"
accept="application/pdf,.pdf"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) invoice.attach(file).catch(() => undefined);
}}
/>
<Button variant="secondary" disabled={busy} onClick={() => inputRef.current?.click()}>
<Icon name="upload" size="sm" />
{data.InvoiceFileName ? 'Заменить счёт' : 'Приложить счёт (PDF)'}
</Button>
</>
)}
{busy && <span className={styles.stage}>{INVOICE_STAGE_TEXT[invoice.stage]}</span>}
</div>
{invoice.error && <Alert variant="warn">{invoice.error}</Alert>}
</div>
);
};
@@ -0,0 +1,104 @@
import * as React from 'react';
import styles from './PaymentSections.module.scss';
import { FormField, TextInput, TextArea } from '../../../shared/ui';
import {
DictionaryPicker,
getCashFlowGroups,
getCashFlowItems,
searchBankAccounts,
searchContracts
} from '../../../shared/dictionary';
import { recipientMatchesInvoice } from '../amounts';
import { ISectionProps } from '../model';
/**
* Реквизиты платежа. Банковский счёт, договор и статьи ДДС — из справочников 1С
* (счета фильтруются по компании и проекту, договоры ищутся по названию, коду и
* контрагенту — фильтра по компании у API договоров нет). Получатель — победитель
* СТ; если в самом счёте указан другой контрагент, поле подсвечивается.
*/
export const PaymentSection: React.FC<ISectionProps> = ({ data, set, patch, context }) => {
const recipientMatches = recipientMatchesInvoice(
{ name: data.RecipientName, bin: data.RecipientBin },
{ name: data.InvoiceSupplierName, bin: data.InvoiceSupplierBin }
);
const accountFilter = { companyId: data.CompanyId, projectId: data.ProjectId };
return (
<>
<FormField label="Банковский счёт" htmlFor="BankAccountName" required>
<DictionaryPicker
id="BankAccountName"
load={(search) => searchBankAccounts(context, accountFilter, search)}
loadKey={`${data.CompanyId}|${data.ProjectId}`}
serverSearch
value={data.BankAccountName}
placeholder="Счёт компании в 1С…"
onChange={(label, id) => patch({ BankAccountName: label, BankAccountId: id })}
/>
</FormField>
<FormField label="Договор" htmlFor="ContractName">
<DictionaryPicker
id="ContractName"
load={(search) => searchContracts(context, search)}
loadKey="contracts"
serverSearch
value={data.ContractName}
placeholder="Название, код договора или контрагент…"
onChange={(label, id) => patch({ ContractName: label, ContractId: id })}
/>
</FormField>
<FormField label="Получатель" htmlFor="RecipientName" required>
<div className={recipientMatches ? undefined : styles.warnBox}>
<TextInput id="RecipientName" value={data.RecipientName} onChange={(v) => set('RecipientName', v)} />
{!recipientMatches && (
<div className={styles.warnText}>
В счёте указан другой контрагент: {data.InvoiceSupplierName || '—'}
{data.InvoiceSupplierBin ? ` (БИН ${data.InvoiceSupplierBin})` : ''}. Проверьте получателя.
</div>
)}
</div>
</FormField>
<FormField label="Счёт получателя (IBAN)" htmlFor="RecipientAccount" required>
<TextInput id="RecipientAccount" value={data.RecipientAccount} onChange={(v) => set('RecipientAccount', v)} />
</FormField>
<FormField label="Раздел" htmlFor="Section">
<TextInput id="Section" value={data.Section} onChange={(v) => set('Section', v)} />
</FormField>
<FormField label="Вид работ" htmlFor="WorkKind">
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
</FormField>
<FormField label="Группа статьи ДДС" htmlFor="CashFlowGroupName" required>
<DictionaryPicker
id="CashFlowGroupName"
load={() => getCashFlowGroups(context)}
loadKey="cash-flow-groups"
value={data.CashFlowGroupName}
onChange={(label, id) => patch({ CashFlowGroupName: label, CashFlowGroupId: id, CashFlowItemName: '', CashFlowItemId: '' })}
/>
</FormField>
<FormField label="Статья ДДС" htmlFor="CashFlowItemName" required>
<DictionaryPicker
id="CashFlowItemName"
load={() => getCashFlowItems(context, data.CashFlowGroupId)}
loadKey={data.CashFlowGroupId}
value={data.CashFlowItemName}
onChange={(label, id) => patch({ CashFlowItemName: label, CashFlowItemId: id })}
/>
</FormField>
<FormField label="Назначение платежа" htmlFor="PaymentPurpose" required full>
<TextArea id="PaymentPurpose" value={data.PaymentPurpose} onChange={(v) => set('PaymentPurpose', v)} rows={2} />
</FormField>
<FormField label="Условия доставки" htmlFor="DeliveryTerms">
<TextArea id="DeliveryTerms" value={data.DeliveryTerms} onChange={(v) => set('DeliveryTerms', v)} rows={2} />
</FormField>
<FormField label="Условия оплаты" htmlFor="PaymentTerms">
<TextArea id="PaymentTerms" value={data.PaymentTerms} onChange={(v) => set('PaymentTerms', v)} rows={2} />
</FormField>
</>
);
};
@@ -0,0 +1,125 @@
@import '../../../../../styles/design-tokens';
// Подсветка расхождений: поле, значение которого стоит перепроверить, обводится
// красным, а под ним — пояснение. Подсветка не блокирует отправку: решает человек.
.warnBox {
padding: $sp-1;
border: 1px solid $red-500;
border-radius: $r-sm;
background: $red-50;
}
.warnText {
margin-top: $sp-1;
font-size: $fs-caption;
color: $red-500;
}
.hint {
margin-top: $sp-1;
font-size: $fs-caption;
color: $gray-500;
}
.invoiceRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-4;
}
.fileInput {
display: none;
}
.stage {
font-size: $fs-sm;
color: $navy-700;
}
.tableWrap {
// Тело секции — flex/grid из ui-kit: без явной ширины блок ужимается по содержимому.
width: 100%;
min-width: 0;
box-sizing: border-box;
grid-column: 1 / -1;
overflow-x: auto;
border: 1px solid $gray-200;
border-radius: $r-sm;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: $fs-sm;
th,
td {
padding: 8px 10px;
border-bottom: 1px solid $gray-200;
text-align: left;
vertical-align: top;
}
th {
background: $gray-50;
font-size: $fs-caption;
font-weight: 600;
color: $gray-700;
white-space: nowrap;
}
tfoot td {
font-weight: 600;
background: $gray-50;
}
}
.num {
text-align: right !important;
white-space: nowrap;
}
.rowDiff {
background: $gold-50;
}
.rowBad {
background: $red-50;
}
.rowOk {
color: $green-600;
}
.statusBad {
color: $red-500;
font-weight: 600;
}
.empty {
padding: $sp-4;
color: $gray-500;
font-size: $fs-sm;
}
// Тело CollapseSection из ui-kit — сетка в две колонки; всё не-поле растягиваем на обе.
.fullRow {
grid-column: 1 / -1;
min-width: 0;
}
.link {
padding: 0;
border: 0;
background: none;
font: inherit;
cursor: pointer;
color: $navy-700;
font-weight: 600;
text-decoration: underline;
&:hover {
color: $gold-600;
}
}
@@ -0,0 +1,85 @@
import * as React from 'react';
import styles from './PaymentSections.module.scss';
import { Alert } from '../../../shared/ui';
import { formatAmount } from '../amounts';
import { IPositionRow, RowStatus } from '../model';
import { hasPositionIssues } from '../positions';
interface IPositionsSectionProps {
rows: IPositionRow[];
/** Счёт приложен и сверка выполнялась — только тогда статусы строк что-то значат. */
compared: boolean;
}
const STATUS_LABEL: Record<RowStatus, string> = {
ok: 'Совпадает',
diff: 'Расхождение',
missing: 'Нет в счёте',
extra: 'Нет в СТ'
};
function rowClass(row: IPositionRow, compared: boolean): string | undefined {
if (!compared) return undefined;
if (row.status === 'diff') return styles.rowDiff;
if (row.status === 'missing' || row.status === 'extra') return styles.rowBad;
return undefined;
}
const cell = (value: number): string => (value ? formatAmount(value) : '');
/**
* Позиции: из СТ/потребности (код, наименование, ед. изм.), позиции поставщика и
* позиции из счёта. Строки с расхождениями подсвечены; сверку выполняет ИИ при
* загрузке счёта, количество и единицы сверяются кодом.
*/
export const PositionsSection: React.FC<IPositionsSectionProps> = ({ rows, compared }) => {
if (rows.length === 0) {
return <div className={styles.empty}>Позиции появятся после выбора основания.</div>;
}
return (
<>
{compared && hasPositionIssues(rows) && (
<Alert variant="warn">Позиции счёта отличаются от СТ/потребности — расхождения подсвечены.</Alert>
)}
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Код</th>
<th>Наименование (потребность)</th>
<th>Ед. изм.</th>
<th className={styles.num}>Кол-во</th>
<th>Позиция поставщика</th>
<th>Позиция из счёта</th>
<th className={styles.num}>Кол-во в счёте</th>
<th className={styles.num}>Цена</th>
<th className={styles.num}>Сумма</th>
{compared && <th>Сверка</th>}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={`${row.code}-${index}`} className={rowClass(row, compared)}>
<td>{row.code}</td>
<td>{row.name}</td>
<td>{row.unit}</td>
<td className={styles.num}>{cell(row.qty)}</td>
<td>{row.supplierName}</td>
<td>{row.invoiceName}</td>
<td className={styles.num}>{cell(row.invoiceQty)}</td>
<td className={styles.num}>{cell(row.invoicePrice)}</td>
<td className={styles.num}>{cell(row.invoiceSum)}</td>
{compared && (
<td className={row.status === 'ok' ? styles.rowOk : styles.statusBad}>
{STATUS_LABEL[row.status]}
{row.note && row.status !== 'ok' && <div>{row.note}</div>}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</>
);
};
@@ -0,0 +1,41 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { Drawer } from '../../../components/Drawer/Drawer';
import { Spinner } from '@tnsu/ui-kit';
import portalStyles from '../../../components/RequestPortal.module.scss';
import { FORM_ID } from '../model';
// Лениво: ApproveFeature через реестр просмотров тянет PaymentRequestView, а тот —
// эту же секцию. Статический импорт замкнул бы цикл модулей.
const ApproveFeature = React.lazy(() => import(/* webpackChunkName: 'approve-feature' */ '../../../features/approve/ApproveFeature'));
interface IRequestDrawerProps {
context: WebPartContext;
itemId: number;
/** Партиция, где лежит заявка; '' — текущая. */
listId: string;
code: string;
onClose: () => void;
}
/**
* Прежняя заявка на оплату в выезжающей панели — той же, что у таблиц портала.
* Открываем её здесь, а не переходом на rpmode=approve: смена маршрута
* пересоздала бы открытую форму, и всё введённое пропало бы.
*
* Через портал в body: форма сама бывает открыта в панели (черновик), а
* fixed-слой внутри неё ограничивался бы её рамками. Корневой класс портала
* возвращает шрифт и цвет, которые в body не наследуются.
*/
export const RequestDrawer: React.FC<IRequestDrawerProps> = ({ context, itemId, listId, code, onClose }) =>
ReactDOM.createPortal(
<div className={portalStyles.requestPortal}>
<Drawer title={`Заявка №${code}`} onClose={onClose}>
<React.Suspense fallback={<Spinner block label="Загружаем заявку…" />}>
<ApproveFeature context={context} formKey={FORM_ID} itemId={itemId} listId={listId || undefined} />
</React.Suspense>
</Drawer>
</div>,
document.body
);
@@ -0,0 +1,47 @@
import { BasisKind } from '../model';
/** Позиция основания: из СТ (с предложением победителя) либо из потребности. */
export interface IBasisPosition {
/** Уникальный код позиции потребности; '' — у потребности его нет. */
code: string;
name: string;
unit: string;
qty: number;
/** Позиция поставщика из его КП; '' / 0 — у потребности КП нет. */
supplierName: string;
supplierQty: number;
supplierPrice: number;
}
/** Всё, что форма берёт из выбранной СТ или потребности. */
export interface IBasisInfo {
kind: BasisKind;
/** «ct:<Id СТ>» / «need:<oid потребности>» — по нему ищем прежние оплаты. */
key: string;
title: string;
projectName: string;
/** Раздел и вид работ. */
section: string;
workKind: string;
/** Компания-заказчик, если известна (подбирается по справочнику компаний). */
companyHint: string;
/** Выигравший поставщик СТ; у потребности пустой. */
recipientName: string;
recipientBin: string;
/** Общая сумма основания и её валюта; 0 — не определена. */
total: number;
currency: string;
deliveryTerms: string;
paymentTerms: string;
deliveryTime: string;
positions: IBasisPosition[];
}
/** Строка выпадающего списка выбора основания. */
export interface IBasisOption {
/** Id СТ либо oid потребности. */
id: string;
title: string;
/** Пояснение: проект, дата, число позиций. */
note: string;
}
@@ -0,0 +1,210 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { CT_SITE_URL } from '../../../shared/config';
import { spForSite } from '../../../shared/pnp/pnpjs';
import { IBasisInfo, IBasisOption, IBasisPosition } from './basis.types';
// Списки сайта СТ (см. ct-forms-portal, CtService.LISTS) — имена те же.
const CT_LISTS = {
ct: 'Сравнительная таблица',
positions: 'Позиции СТ',
offers: 'Оценка поставщиков',
suppliers: 'Поставщики',
criteria: 'Критерии',
rates: 'Курс Валют'
};
/** Значение Choice «Статус» у согласованной СТ. */
const CT_STATUS_APPROVED = 'Согласовано';
/** Валюта предложений без подстановки «Валюта» — в СТ это тенге. */
const DEFAULT_CURRENCY = 'KZT';
const MAX_CTS = 500;
const MAX_POSITIONS = 500;
/** Сколько позиций кладём в один OR-фильтр: длинная строка запроса SharePoint не принимает. */
const FILTER_CHUNK = 20;
interface ICtRow {
Id: number;
number?: number | string;
project?: string;
winSupplierId?: number;
}
interface IPositionRow {
Id: number;
Title?: string;
unit?: string;
quantity?: number;
disciplineCode?: string;
SMR?: string;
uniqueCode?: string;
}
interface IOfferRow {
positionId: number;
supplierPosition?: string;
quantity?: number;
price?: number;
summa?: number;
summaTenge?: number;
valutaId?: number;
}
interface ICriteriaRow {
paymentType?: string;
deliveryType?: string;
deadline?: string;
criteriaLabel?: string;
}
function ctWeb(context: WebPartContext): ReturnType<typeof spForSite>['web'] {
return spForSite(CT_SITE_URL, context).web;
}
function chunk<T>(items: T[], size: number): T[][] {
const result: T[][] = [];
for (let i = 0; i < items.length; i += size) result.push(items.slice(i, i + size));
return result;
}
function unique(values: Array<string | undefined>): string[] {
const seen: string[] = [];
values.forEach((v) => {
const value = (v || '').trim();
if (value && seen.indexOf(value) === -1) seen.push(value);
});
return seen;
}
function ctTitle(row: ICtRow): string {
return `СТ №${row.number ?? row.Id}${row.project ? ` — ${row.project}` : ''}`;
}
/** Согласованные СТ для выбора основания, новые сверху. */
export async function listApprovedCts(context: WebPartContext): Promise<IBasisOption[]> {
const rows = (await ctWeb(context)
.lists.getByTitle(CT_LISTS.ct)
.items.select('Id', 'number', 'project', 'winSupplierId')
.filter(`status eq '${CT_STATUS_APPROVED}'`)
.orderBy('Id', false)
.top(MAX_CTS)()) as ICtRow[];
return rows.map((row) => ({
id: String(row.Id),
title: ctTitle(row),
note: row.winSupplierId ? '' : 'победитель не выбран'
}));
}
/** Предложения победителя по позициям СТ (по чанкам — см. FILTER_CHUNK). */
async function loadWinnerOffers(
context: WebPartContext,
supplierId: number,
positionIds: number[]
): Promise<IOfferRow[]> {
const lists = ctWeb(context).lists;
const parts = await Promise.all(
chunk(positionIds, FILTER_CHUNK).map((ids) =>
lists
.getByTitle(CT_LISTS.offers)
.items.select('Id', 'positionId', 'supplierPosition', 'quantity', 'price', 'summa', 'summaTenge', 'valutaId')
.filter(`supplierId eq ${supplierId} and (${ids.map((id) => `positionId eq ${id}`).join(' or ')})`)
.top(MAX_POSITIONS)()
)
);
return ([] as IOfferRow[]).concat(...(parts as IOfferRow[][]));
}
/** Код валюты по Id записи «Курс Валют»; пустая подстановка — тенге. */
async function loadCurrencyCodes(context: WebPartContext, ids: number[]): Promise<Map<number, string>> {
const codes = new Map<number, string>();
await Promise.all(
ids.map(async (id) => {
const rate = (await ctWeb(context).lists.getByTitle(CT_LISTS.rates).items.getById(id).select('code')()) as { code?: string };
codes.set(id, rate.code || DEFAULT_CURRENCY);
})
);
return codes;
}
/** Условия победителя из «Критериев» СТ: оплата, доставка, срок. */
async function loadWinnerTerms(context: WebPartContext, ctId: number, supplierId: number): Promise<ICriteriaRow> {
const rows = (await ctWeb(context)
.lists.getByTitle(CT_LISTS.criteria)
.items.select('Id', 'paymentType', 'deliveryType', 'deadline', 'criteriaLabel')
.filter(`CTId eq ${ctId} and supplierId eq ${supplierId}`)
.top(MAX_POSITIONS)()) as ICriteriaRow[];
// Строки с criteriaLabel — произвольные критерии, а не условия поставки.
return rows.filter((r) => !r.criteriaLabel && (r.paymentType || r.deliveryType || r.deadline))[0] || {};
}
function toBasisPosition(position: IPositionRow, offers: IOfferRow[]): IBasisPosition {
return {
code: position.uniqueCode || '',
name: position.Title || '',
unit: position.unit || '',
qty: Number(position.quantity || 0),
supplierName: unique(offers.map((o) => o.supplierPosition)).join(' / '),
supplierQty: offers.reduce((sum, o) => sum + Number(o.quantity || 0), 0),
supplierPrice: Number(offers[0]?.price || 0)
};
}
/**
* Общая сумма предложений победителя. Все в одной валюте — суммируем «Сумма» в
* этой валюте; смешанные — берём «Сумма в тенге», иначе числа несопоставимы.
*/
function offersTotal(offers: IOfferRow[], codes: Map<number, string>): { total: number; currency: string } {
const currencies = unique(offers.map((o) => (o.valutaId ? codes.get(o.valutaId) : DEFAULT_CURRENCY)));
if (currencies.length > 1) {
return { total: offers.reduce((s, o) => s + Number(o.summaTenge || 0), 0), currency: DEFAULT_CURRENCY };
}
return { total: offers.reduce((s, o) => s + Number(o.summa || 0), 0), currency: currencies[0] || DEFAULT_CURRENCY };
}
/** Всё, что форма берёт из согласованной СТ: победитель, позиции, суммы, условия. */
export async function loadCtBasis(context: WebPartContext, ctId: string): Promise<IBasisInfo> {
const web = ctWeb(context);
const id = parseInt(ctId, 10);
const [ct, positions] = await Promise.all([
web.lists.getByTitle(CT_LISTS.ct).items.getById(id).select('Id', 'number', 'project', 'winSupplierId')() as Promise<ICtRow>,
web.lists
.getByTitle(CT_LISTS.positions)
.items.select('Id', 'Title', 'unit', 'quantity', 'disciplineCode', 'SMR', 'uniqueCode')
.filter(`CTId eq ${id}`)
.top(MAX_POSITIONS)() as Promise<IPositionRow[]>
]);
const winnerId = ct.winSupplierId || 0;
const [winner, offers, terms] = await Promise.all([
winnerId
? (web.lists.getByTitle(CT_LISTS.suppliers).items.getById(winnerId).select('name', 'bin')() as Promise<{ name?: string; bin?: string }>)
: Promise.resolve({} as { name?: string; bin?: string }),
winnerId && positions.length > 0 ? loadWinnerOffers(context, winnerId, positions.map((p) => p.Id)) : Promise.resolve([] as IOfferRow[]),
winnerId ? loadWinnerTerms(context, id, winnerId) : Promise.resolve({} as ICriteriaRow)
]);
const currencyIds = unique(offers.map((o) => (o.valutaId ? String(o.valutaId) : ''))).map(Number);
const codes = await loadCurrencyCodes(context, currencyIds);
const { total, currency } = offersTotal(offers, codes);
return {
kind: 'ct',
key: `ct:${ct.Id}`,
title: ctTitle(ct),
projectName: ct.project || '',
section: unique(positions.map((p) => p.disciplineCode)).join(', '),
workKind: unique(positions.map((p) => p.SMR)).join(', '),
companyHint: '',
recipientName: winner.name || '',
recipientBin: winner.bin || '',
total,
currency,
deliveryTerms: terms.deliveryType || '',
paymentTerms: terms.paymentType || '',
deliveryTime: terms.deadline || '',
positions: positions.map((p) =>
toBasisPosition(
p,
offers.filter((o) => o.positionId === p.Id)
)
)
};
}
@@ -0,0 +1,92 @@
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/folders';
import '@pnp/sp/files';
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DATA_SITE_URL, DICTIONARY_API_BASE_URL } from '../../../shared/config';
import { PAYMENT_INVOICES_LIBRARY_TITLE } from '../../../shared/listTitles';
import { getSP } from '../../../shared/pnp/pnpjs';
import { fileStamp } from '../../../shared/chat/chatFileNames';
/** Колонка библиотеки, куда OCR кладёт распознанный текст счёта. */
const FILE_CONTENT_FIELD = 'file_content';
export const MAX_INVOICE_SIZE_BYTES = 15 * 1024 * 1024;
export interface IStoredInvoice {
/** Id элемента библиотеки — по нему сервис OCR находит файл. */
itemId: number;
/** Имя файла, как он назван в библиотеке (с отметкой времени). */
storedName: string;
serverRelativeUrl: string;
}
/** Проверка файла счёта до загрузки. Возвращает текст ошибки или undefined. */
export function validateInvoiceFile(file: File): string | undefined {
if (!/\.pdf$/i.test(file.name)) {
return 'Счёт должен быть в формате PDF — другие форматы сервис распознавания не читает.';
}
if (file.size > MAX_INVOICE_SIZE_BYTES) {
return `Файл больше ${Math.round(MAX_INVOICE_SIZE_BYTES / (1024 * 1024))} МБ.`;
}
return undefined;
}
/**
* Сохранить файл счёта в библиотеку. Делаем это сразу при выборе файла, до
* сохранения самой заявки: OCR работает с элементом библиотеки, а не с вложением.
* Имя в библиотеке — только латиница и цифры: OCR-сервис получает имя в multipart и
* на кириллице теряет расширение («unsupported file type ''»). Исходное имя
* хранится в данных заявки (InvoiceFileName), поэтому в библиотеке оно не нужно.
* Метка времени и случайный хвост исключают перезапись счёта другой заявки.
*/
export async function storeInvoice(file: File): Promise<IStoredInvoice> {
const storedName = `invoice-${fileStamp(new Date())}-${Math.random().toString(36).slice(2, 8)}.pdf`;
const root = getSP().web.lists.getByTitle(PAYMENT_INVOICES_LIBRARY_TITLE).rootFolder;
const added = await root.files.addUsingPath(storedName, file);
// Id элемента библиотеки нужен OCR, а addUsingPath отдаёт только сведения о файле.
const item = (await getSP()
.web.getFileByServerRelativePath(added.ServerRelativeUrl)
.listItemAllFields.select('Id')()) as { Id: number };
return { itemId: item.Id, storedName, serverRelativeUrl: added.ServerRelativeUrl };
}
async function readFileContent(itemId: number): Promise<string> {
const item = (await getSP()
.web.lists.getByTitle(PAYMENT_INVOICES_LIBRARY_TITLE)
.items.getById(itemId)
.select(FILE_CONTENT_FIELD)()) as Record<string, unknown>;
return typeof item[FILE_CONTENT_FIELD] === 'string' ? (item[FILE_CONTENT_FIELD] as string).trim() : '';
}
/**
* Отправить сохранённый счёт на OCR (POST /Document/extractPDFText) и вернуть
* распознанный текст из колонки file_content. Сервис отвечает только после того,
* как записал текст в колонку, поэтому читаем её сразу, без опроса.
*/
export async function recognizeInvoice(context: WebPartContext, itemId: number): Promise<string> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.post(
`${DICTIONARY_API_BASE_URL}/Document/extractPDFText`,
AadHttpClient.configurations.v1,
{
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
siteUrl: DATA_SITE_URL,
libraryName: PAYMENT_INVOICES_LIBRARY_TITLE,
itemId: String(itemId)
})
}
);
if (!response.ok) {
throw new Error(`Не удалось распознать счёт (Document/extractPDFText): ${response.status} ${response.statusText}`);
}
const text = await readFileContent(itemId);
if (!text) {
throw new Error('Сервис распознавания не нашёл в счёте текста.');
}
return text;
}
@@ -0,0 +1,115 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { askGemini, IGeminiSchema } from '../../../shared/ai/gemini.service';
import { IInvoiceItem, IPositionPair } from '../positions';
import { IBasisPosition } from './basis.types';
/** Текст счёта режем: прокси ИИ ограничен по размеру запроса, а хвост счёта — реквизиты и подписи. */
const MAX_INVOICE_TEXT_CHARS = 30000;
/** Что ИИ достаёт из текста счёта. */
export interface IInvoiceData {
invoiceNumber: string;
supplierName: string;
supplierBin: string;
/** Счёт получателя (IBAN). */
recipientAccount: string;
currency: string;
totalAmount: number;
vatAmount: number;
items: IInvoiceItem[];
}
const STRING: IGeminiSchema = { type: 'string' };
const NUMBER: IGeminiSchema = { type: 'number' };
const INVOICE_SCHEMA: IGeminiSchema = {
type: 'object',
properties: {
invoiceNumber: STRING,
supplierName: STRING,
supplierBin: STRING,
recipientAccount: STRING,
currency: STRING,
totalAmount: NUMBER,
vatAmount: NUMBER,
items: {
type: 'array',
items: {
type: 'object',
properties: { name: STRING, unit: STRING, quantity: NUMBER, price: NUMBER, sum: NUMBER },
required: ['name', 'quantity']
}
}
},
required: ['invoiceNumber', 'totalAmount', 'items']
};
const PAIRS_SCHEMA: IGeminiSchema = {
type: 'object',
properties: {
pairs: {
type: 'array',
items: {
type: 'object',
properties: { baseIndex: { type: 'integer' }, invoiceIndex: { type: 'integer' }, note: STRING },
required: ['baseIndex', 'invoiceIndex']
}
}
},
required: ['pairs']
};
/** Разобрать текст счёта: номер, поставщик, реквизиты, суммы, позиции. */
export async function parseInvoiceText(context: WebPartContext, text: string): Promise<IInvoiceData> {
const prompt =
'Ниже распознанный текст счёта на оплату. Извлеки: номер счёта, наименование поставщика (продавца), ' +
'его БИН/ИИН (12 цифр, пустая строка если нет), счёт получателя платежа (IBAN, например KZ…), ' +
'валюту (код: KZT, USD, EUR, RUB, CNY), итоговую сумму к оплате, сумму НДС (0 если нет) и все позиции ' +
'таблицы (наименование, единица измерения, количество, цена за единицу, сумма по строке). ' +
'Числа — без пробелов и валютных знаков. Ничего не выдумывай: чего нет в тексте — пустая строка или 0.\n\n' +
`Текст счёта:\n${text.slice(0, MAX_INVOICE_TEXT_CHARS)}`;
const data = await askGemini<Partial<IInvoiceData>>(context, prompt, INVOICE_SCHEMA);
return {
invoiceNumber: data.invoiceNumber || '',
supplierName: data.supplierName || '',
supplierBin: (data.supplierBin || '').replace(/\D/g, ''),
recipientAccount: (data.recipientAccount || '').replace(/\s/g, ''),
currency: (data.currency || '').toUpperCase(),
totalAmount: Number(data.totalAmount || 0),
vatAmount: Number(data.vatAmount || 0),
items: (data.items || []).map((item) => ({
name: item.name || '',
unit: item.unit || '',
quantity: Number(item.quantity || 0),
price: Number(item.price || 0),
sum: Number(item.sum || 0)
}))
};
}
/**
* Попросить ИИ сопоставить позиции основания (СТ/потребности) с позициями счёта.
* Названия в счёте поставщик пишет по-своему, поэтому сопоставляем по смыслу;
* количества и единицы потом сверяет код (см. buildRows), а не ИИ.
*/
export async function matchPositions(
context: WebPartContext,
base: IBasisPosition[],
invoice: IInvoiceItem[]
): Promise<IPositionPair[]> {
if (base.length === 0 || invoice.length === 0) return [];
const baseLines = base.map((p, i) => `${i}. ${p.name} (${p.unit}, ${p.qty})${p.supplierName ? ` — у поставщика: ${p.supplierName}` : ''}`);
const invoiceLines = invoice.map((p, i) => `${i}. ${p.name} (${p.unit}, ${p.quantity})`);
const prompt =
'Сопоставь позиции заявки с позициями счёта поставщика. Одна позиция заявки соответствует не более чем ' +
'одной позиции счёта и наоборот. Для каждой найденной пары верни baseIndex и invoiceIndex. ' +
'Позиции без пары верни с индексом −1 с отсутствующей стороны. В note коротко (до 10 слов) опиши отличие ' +
'в названии, если оно смысловое (другая марка, размер, материал); если названия по смыслу совпадают — пустая строка.\n\n' +
`Позиции заявки:\n${baseLines.join('\n')}\n\nПозиции счёта:\n${invoiceLines.join('\n')}`;
const result = await askGemini<{ pairs?: IPositionPair[] }>(context, prompt, PAIRS_SCHEMA);
return (result.pairs || []).map((pair) => ({
baseIndex: Number(pair.baseIndex),
invoiceIndex: Number(pair.invoiceIndex),
note: pair.note || ''
}));
}
@@ -0,0 +1,106 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../../../shared/config';
import { IBasisInfo, IBasisOption, IBasisPosition } from './basis.types';
/** За сколько дней показываем потребности — как шаг выбора позиций в СТ. */
const NEEDS_LOOKBACK_DAYS = 365;
type RawRecord = Record<string, unknown>;
interface INeed {
id: string;
requestNumber: string;
project: string;
section: string;
workKind: string;
organization: string;
createdAt: string;
positionsCount: number;
}
async function apiGet(context: WebPartContext, path: string): Promise<unknown> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.get(`${DICTIONARY_API_BASE_URL}${path}`, AadHttpClient.configurations.v1);
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${response.statusText}`);
}
return response.json();
}
function mapNeed(item: RawRecord): INeed {
return {
id: String(item.id ?? ''),
requestNumber: String(item.requestNumber ?? ''),
// В API нет отдельной сущности «проект» — description и есть название проекта (как в шаге 1 СТ).
project: String(item.description ?? ''),
section: String(item.discipline ?? ''),
workKind: String(item.workType ?? ''),
organization: String(item.organization ?? ''),
createdAt: String(item.requestDate ?? ''),
positionsCount: Number(item.positionsCount ?? 0)
};
}
// Список потребностей большой и в рамках сессии не меняется — грузим один раз.
let needsCache: Promise<INeed[]> | undefined;
function getNeeds(context: WebPartContext): Promise<INeed[]> {
if (!needsCache) {
needsCache = (apiGet(context, `/Requests/${NEEDS_LOOKBACK_DAYS}`) as Promise<RawRecord[]>)
.then((rows) => rows.map(mapNeed))
.catch((e) => {
needsCache = undefined;
throw e;
});
}
return needsCache;
}
/** Потребности для выбора основания. */
export async function listNeeds(context: WebPartContext): Promise<IBasisOption[]> {
const needs = await getNeeds(context);
return needs.map((need) => ({
id: need.id,
title: `Потребность ${need.requestNumber}`,
note: [need.project, need.organization].filter(Boolean).join(' · ')
}));
}
/** Основание «Потребность»: проект, раздел, вид работ и позиции без КП и победителя. */
export async function loadNeedBasis(context: WebPartContext, needId: string): Promise<IBasisInfo> {
const needs = await getNeeds(context);
const need = needs.filter((n) => n.id === needId)[0];
if (!need) {
throw new Error('Потребность не найдена в списке — обновите страницу.');
}
const rows = (await apiGet(context, `/Requests/${encodeURIComponent(needId)}/positions`)) as RawRecord[];
const positions: IBasisPosition[] = rows.map((row) => ({
// Отдельного кода у позиции потребности нет; номер строки табличной части 1С
// (lineNumber) уникален в пределах заявки — он и служит кодом позиции.
code: row.lineNumber !== undefined && row.lineNumber !== null ? String(row.lineNumber) : '',
name: String(row.materialName ?? ''),
unit: String(row.unit ?? ''),
qty: Number(row.quantity ?? 0),
supplierName: '',
supplierQty: 0,
supplierPrice: 0
}));
return {
kind: 'need',
key: `need:${need.id}`,
title: `Потребность ${need.requestNumber}`,
projectName: need.project,
section: need.section,
workKind: need.workKind,
companyHint: need.organization,
recipientName: '',
recipientBin: '',
total: 0,
currency: '',
deliveryTerms: '',
paymentTerms: '',
deliveryTime: '',
positions
};
}
@@ -0,0 +1,41 @@
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { REQUEST_STATUS, getBasisRequests, getRequestTypeIdByPageUrl } from '../../../shared/approval';
import { FORM_ID, IPreviousPayment } from '../model';
/**
* Статусы, которые в «уже оплачено/заявлено» не входят: черновик ещё не подан,
* отклонённая и возвращённая заявки платёж не создают.
*/
const NOT_COUNTED_STATUSES: string[] = [REQUEST_STATUS.draft, REQUEST_STATUS.rejected, REQUEST_STATUS.returned];
/**
* Прежние заявки на оплату по тому же основанию (СТ или потребности) — по всем
* партициям списка.
*
* Читаем не список напрямую, а через approval-route (GET /api/basis-requests):
* права на элементы заявок разорваны поштучно, и под своей учёткой заявитель
* видел бы только свои оплаты — сумма по СТ занижалась бы и проверка превышения
* пропускала переплату. Сервер отдаёт все заявки, а canOpen говорит, можно ли
* текущему пользователю открыть каждую (без прав ссылки в таблице нет).
*
* excludeId — сама редактируемая заявка: себя в «ранее поданных» показывать нельзя.
*/
export async function loadPreviousPayments(
context: WebPartContext,
basisKey: string,
excludeId?: number
): Promise<IPreviousPayment[]> {
if (!basisKey) return [];
const requestTypeId = await getRequestTypeIdByPageUrl(context, FORM_ID);
const rows = await getBasisRequests(context, requestTypeId, basisKey);
return rows
.filter((row) => row.id !== excludeId && NOT_COUNTED_STATUSES.indexOf(row.status) === -1)
.map((row) => ({
code: row.uniqueCode || `№${row.id}`,
amount: Number(row.amount || 0),
id: row.canOpen ? row.id : undefined,
listId: row.listId,
status: row.status
}));
}
@@ -0,0 +1,55 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { FORM_ID, IFormData } from './model';
/** Экземпляр сервиса списка согласования этой формы (см. approvalServiceFactory). */
export function paymentRequestApprovalService(listId?: string): ApprovalListService {
return approvalServiceFor(FORM_ID, listId);
}
/** Осмысленный заголовок элемента (системная колонка Title). */
function buildTitle(data: IFormData): string {
const subject = data.RecipientName.trim() || data.BasisTitle;
return ['Заявка на оплату', [subject, data.InvoiceNumber ? `счёт ${data.InvoiceNumber}` : ''].filter(Boolean).join(', ')]
.filter(Boolean)
.join(': ');
}
/** Колонки, которые не копируются как есть: табличные части идут в JSON. */
const JSON_KEYS: Array<keyof IFormData> = ['PreviousPayments', 'Positions'];
/** Поля элемента списка данных из модели формы. Пустая строка → null, чтобы не хранить пустые значения. */
function buildItem(data: IFormData, status: string): Record<string, unknown> {
const item: Record<string, unknown> = { Title: buildTitle(data), RequestStatus: status };
(Object.keys(data) as Array<keyof IFormData>).forEach((key) => {
if (JSON_KEYS.indexOf(key) !== -1) return;
const value = data[key];
item[key] = value === '' ? null : value;
});
item.PreviousPaymentsJson = data.PreviousPayments.length > 0 ? JSON.stringify(data.PreviousPayments) : null;
item.PositionsJson = data.Positions.length > 0 ? JSON.stringify(data.Positions) : null;
return item;
}
export type ISaveResult = ISaveItemResult;
/**
* Сохранить «Заявку на оплату»: контент + маршрут согласования. existingId задан →
* обновляем ту же запись (черновик/доработка), иначе создаём новую.
* listId — партиция, в которую пишем (см. resolveWriteListId).
*/
export async function savePaymentRequest(
existingId: number | undefined,
requestTypeId: number,
data: IFormData,
approvers: IApproverToWrite[],
executors: IApproverToWrite[],
status: string,
listId?: string
): Promise<ISaveResult> {
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, buildItem(data, status));
await writeRoute(paymentRequestApprovalService(listId), result.id, approvers, executors, status);
return result;
}
@@ -0,0 +1,53 @@
import { useCallback, useState } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getCompanies, getProjects } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { basisPatch } from './basisPatch';
import { BasisKind, IFormData } from './model';
import { loadCtBasis } from './services/ctBasis.service';
import { loadNeedBasis } from './services/needBasis.service';
import { loadPreviousPayments } from './services/previousPayments.service';
export interface IBasisSelection {
loading: boolean;
error: string;
/** Загрузить выбранную СТ/потребность и подставить её данные в форму. */
select: (kind: BasisKind, id: string) => Promise<void>;
}
/**
* Выбор основания оплаты. Справочники проектов и компаний нужны только для
* подбора oid/Id по тексту (в СТ проект — строка), поэтому их сбой не мешает:
* без них проект/компания просто выбираются руками.
*/
export function useBasisSelection(
context: WebPartContext,
patch: (changes: Partial<IFormData>) => void,
editId: number | undefined
): IBasisSelection {
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const select = useCallback(
async (kind: BasisKind, id: string): Promise<void> => {
setLoading(true);
setError('');
try {
const info = kind === 'ct' ? await loadCtBasis(context, id) : await loadNeedBasis(context, id);
const [previous, projects, companies] = await Promise.all([
loadPreviousPayments(context, info.key, editId),
getProjects(context).catch(() => []),
getCompanies(context).catch(() => [])
]);
patch(basisPatch(info, previous, projects, companies));
} catch (e) {
setError(`Не удалось загрузить основание: ${friendlyErrorMessage(e)}`);
} finally {
setLoading(false);
}
},
[context, patch, editId]
);
return { loading, error, select };
}
@@ -0,0 +1,68 @@
import { useCallback, useState } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { IFormData } from './model';
import { basisPositionsFromRows, invoicePatch } from './invoicePatch';
import { recognizeInvoice, storeInvoice, validateInvoiceFile } from './services/invoice.service';
import { matchPositions, parseInvoiceText } from './services/invoiceAi.service';
export type InvoiceStage = 'idle' | 'upload' | 'ocr' | 'analyze';
export const INVOICE_STAGE_TEXT: Record<InvoiceStage, string> = {
idle: '',
upload: 'Сохраняем счёт в библиотеку…',
ocr: 'Распознаём текст счёта (OCR)…',
analyze: 'ИИ разбирает счёт и сверяет позиции…'
};
export interface IInvoiceFlow {
stage: InvoiceStage;
/** Ошибка последнего шага; файл при этом уже сохранён, форму можно заполнить руками. */
error: string;
attach: (file: File) => Promise<void>;
}
/**
* Конвейер счёта: библиотека → OCR → ИИ (реквизиты и позиции) → сверка с основанием.
* Каждый шаг после сохранения файла необязателен: при сбое OCR или ИИ счёт остаётся
* приложенным, а поля заполняются вручную — заявку из-за внешнего сервиса не блокируем.
*/
export function useInvoiceFlow(
context: WebPartContext,
data: IFormData,
patch: (changes: Partial<IFormData>) => void
): IInvoiceFlow {
const [stage, setStage] = useState<InvoiceStage>('idle');
const [error, setError] = useState<string>('');
const attach = useCallback(
async (file: File): Promise<void> => {
const invalid = validateInvoiceFile(file);
if (invalid) {
setError(invalid);
return;
}
setError('');
try {
setStage('upload');
const stored = await storeInvoice(file);
patch({ InvoiceFileName: file.name, InvoiceUrl: stored.serverRelativeUrl });
setStage('ocr');
const text = await recognizeInvoice(context, stored.itemId);
setStage('analyze');
const invoice = await parseInvoiceText(context, text);
const pairs = await matchPositions(context, basisPositionsFromRows(data.Positions), invoice.items);
patch(invoicePatch(data, invoice, invoice.items, pairs));
} catch (e) {
setError(`${friendlyErrorMessage(e)} Заполните поля по счёту вручную.`);
} finally {
setStage('idle');
}
},
[context, data, patch]
);
return { stage, error, attach };
}
@@ -0,0 +1,23 @@
import { IFormData } from './model';
/**
* Обязательные поля заявки на оплату. Возвращает текст ошибки или undefined.
* Превышение общей суммы и расхождения со счётом сюда НЕ входят: они только
* подсвечиваются, решение — за согласующими.
*/
export function validatePayment(data: IFormData): string | undefined {
const missing: string[] = [];
if (!data.RequesterEmail) missing.push('заявитель');
if (!data.BasisKey) missing.push('основание (СТ или потребность)');
if (!data.ProjectName.trim()) missing.push('проект');
if (!data.CompanyName.trim()) missing.push('компания');
if (!data.BankAccountName.trim()) missing.push('банковский счёт');
if (!data.RecipientName.trim()) missing.push('получатель');
if (!data.RecipientAccount.trim()) missing.push('счёт получателя');
if (!data.CashFlowGroupName.trim()) missing.push('группа статьи ДДС');
if (!data.CashFlowItemName.trim()) missing.push('статья ДДС');
if (!data.PaymentPurpose.trim()) missing.push('назначение платежа');
if (data.PayAmount <= 0) missing.push('сумма оплаты');
if (!data.InvoiceFileName) missing.push('счёт на оплату');
return missing.length > 0 ? `Заполните обязательные поля: ${missing.join(', ')}.` : undefined;
}
@@ -55,6 +55,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
'hr-transfer': React.lazy(
() => import(/* webpackChunkName: 'form-employee-transfer' */ './EmployeeTransfer/EmployeeTransferForm')
),
'acc-payment-request': React.lazy(
() => import(/* webpackChunkName: 'form-payment-request' */ './PaymentRequest/PaymentRequestForm')
),
'wh-concrete-request': React.lazy(
() => import(/* webpackChunkName: 'form-concrete-request' */ './ConcreteRequest/ConcreteRequestForm')
)
@@ -74,6 +74,9 @@ const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
'hr-transfer': React.lazy(
() => import(/* webpackChunkName: 'view-employee-transfer' */ './EmployeeTransfer/EmployeeTransferView')
),
'acc-payment-request': React.lazy(
() => import(/* webpackChunkName: 'view-payment-request' */ './PaymentRequest/PaymentRequestView')
),
'wh-concrete-request': React.lazy(
() => import(/* webpackChunkName: 'view-concrete-request' */ './ConcreteRequest/ConcreteRequestView')
)
@@ -0,0 +1,37 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
/** JSON-схема ответа в формате, который принимает прокси Gemini (как в СТ). */
export interface IGeminiSchema {
type: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean';
properties?: Record<string, IGeminiSchema>;
items?: IGeminiSchema;
required?: string[];
}
/**
* POST /Proxy/Gemini { prompt, responseSchema } → объект, подогнанный под схему.
* Тот же вызов делает СТ (CtService.scorePaymentTerms); ответ прокси — уже
* разобранный JSON, без обёртки.
*/
export async function askGemini<T>(context: WebPartContext, prompt: string, responseSchema: IGeminiSchema): Promise<T> {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.post(
`${DICTIONARY_API_BASE_URL}/Proxy/Gemini`,
AadHttpClient.configurations.v1,
{
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, responseSchema })
}
);
if (!response.ok) {
throw new Error(`Proxy/Gemini: ${response.status} ${response.statusText}`);
}
const body = (await response.json()) as T & { error?: unknown };
// Пустой ответ модели прокси отдаёт как 200 с { error } — это сбой, а не данные.
if (body && typeof body.error === 'string') {
throw new Error(`Proxy/Gemini: ${body.error}`);
}
return body;
}
@@ -5,6 +5,7 @@ import {
ApprovalStatus,
ICurrentListPartition,
IApiResponse,
IBasisRequest,
IRequestType,
IRequestTypeListSeries,
IResolvedRoute,
@@ -147,6 +148,32 @@ export async function getRequestTypeIdByPageUrl(context: WebPartContext, pageUrl
return found.id;
}
/**
* GET /api/basis-requests — заявки типа requestTypeId, поданные по основанию
* basisKey («ct:<Id>», «need:<oid>»), по всем партициям. Читаются на сервере от
* имени приложения: права на элементы заявок разорваны поштучно, и прямым
* чтением списка пользователь увидел бы только свои.
*/
export async function getBasisRequests(
context: WebPartContext,
requestTypeId: number,
basisKey: string
): Promise<IBasisRequest[]> {
return withNetworkRetry(async () => {
const c = await client(context);
const query = `requestTypeId=${requestTypeId}&basisKey=${encodeURIComponent(basisKey)}`;
const response: HttpClientResponse = await c.get(
`${getApprovalApiBaseUrl()}/api/basis-requests?${query}`,
AadHttpClient.configurations.v1
);
if (!response.ok) {
throw new Error(`basis-requests: ${response.status} ${response.statusText}`);
}
const body: IApiResponse<IBasisRequest[]> = await response.json();
return body.data ?? [];
});
}
// GET /api/request-type-access/{requestTypeId}/emails — email'ы, кому в
// админке approval-route выдан доступ видеть ВСЕ заявки этого типа (не только
// свои). Тот же эндпоинт дёргает и Power Automate — здесь просто ещё один
@@ -19,6 +19,7 @@ export type {
IListPartition,
ICurrentListPartition,
IRequestType,
IBasisRequest,
IRequestTypeListSeries,
IResolvedRoute,
IResolvedRouteStep,
@@ -32,6 +33,7 @@ export {
getListPartitions,
getCurrentListPartition,
getRequestTypeAccessEmails,
getBasisRequests,
resolveRoute
} from './approvalRoute.service';
export { hasRequestTypeAccess, accessibleAllRequestsTypes, emailsForType } from './requestTypeAccess';
@@ -126,3 +126,19 @@ export interface IResolvedRouteStep {
*/
positionCode?: string;
}
/**
* Заявка, поданная по тому же основанию (СТ, потребность) — GET /api/basis-requests.
* Сервер читает их от имени приложения, поэтому видны все, а не только свои;
* canOpen — есть ли у текущего пользователя права открыть саму заявку.
*/
export interface IBasisRequest {
id: number;
/** Серверный относительный URL списка партиции, где лежит заявка. */
listId: string;
/** Номер заявки; пока не присвоен — пусто (сервер шлёт null). */
uniqueCode?: string;
status: string;
amount: number;
canOpen: boolean;
}
@@ -240,3 +240,10 @@ export const DATAVERSE_LZKPOSITION_LZK_LOOKUP_FIELD = 'cree9_lzkid';
export const DATAVERSE_LZK_ENTITY_SET = 'cree9_lzks';
/** Колонка «project id» на LZK — GUID проекта текстом, совпадает по формату с oid из /Project. */
export const DATAVERSE_LZK_PROJECT_FIELD = 'cree9_projectid';
/**
* Сайт сравнительных таблиц (СТ): списки «Сравнительная таблица», «Позиции СТ»,
* «Оценка поставщиков», «Критерии», «Поставщики», «Курс Валют» живут здесь, а не
* на сайте данных портала. Читаем их из «Заявки на оплату» (forms/PaymentRequest).
*/
export const CT_SITE_URL = 'https://tnsukz.sharepoint.com/sites/sed';
@@ -0,0 +1,114 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
import { RawRecord, extractRecords, isGuid, mapBankAccount, mapContract, mapNamed } from './oneCRecords';
/** Запись справочника 1С в том виде, в каком её показывает выпадающий список. */
export interface IDictionaryItem {
id: string;
/** Основная подпись (название, номер счёта). */
label: string;
/** Пояснение под подписью (банк, контрагент и т.п.); '' — нет. */
note: string;
}
/** Результат загрузки для DictionaryPicker. */
export interface IDictionaryLoadResult {
items: IDictionaryItem[];
/** Пояснение под полем (напр. «счета проекта не найдены, показаны все счета компании»). */
hint?: string;
}
/** Сколько записей просим у постраничных справочников (Contracts, BankAccounts): сервер режет до 200. */
const SEARCH_PAGE_SIZE = 50;
// Один промис на адрес запроса в рамках сессии; ошибку не кешируем (следующий вызов попробует снова).
const cache = new Map<string, Promise<IDictionaryItem[]>>();
/** GET {api}/Dictionary/<path>?<params>. Пустые параметры в запрос не попадают. */
function fetchDictionary(
context: WebPartContext,
path: string,
params: Record<string, string>,
map: (record: RawRecord) => IDictionaryItem
): Promise<IDictionaryItem[]> {
const query = Object.keys(params)
.filter((key) => params[key] !== '')
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
.join('&');
const url = `${DICTIONARY_API_BASE_URL}/Dictionary/${path}${query ? `?${query}` : ''}`;
const cached = cache.get(url);
if (cached) return cached;
const request = (async (): Promise<IDictionaryItem[]> => {
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
const response: HttpClientResponse = await client.get(url, AadHttpClient.configurations.v1);
if (!response.ok) {
throw new Error(`Dictionary/${path}: ${response.status} ${response.statusText}`);
}
return extractRecords(await response.json())
.map(map)
.filter((item) => item.label !== '');
})().catch((e) => {
cache.delete(url);
throw e;
});
cache.set(url, request);
return request;
}
function guidOrEmpty(value: string): string {
return isGuid(value) ? value.trim() : '';
}
/**
* Банковские счета компании и проекта (поиск — на сервере: по названию, коду,
* номеру счёта, банку). Совпадают ли Id проекта из /Project с project_id в
* bank_accounts, не гарантировано, поэтому при пустом ответе по проекту
* повторяем запрос только по компании и говорим об этом под полем.
*/
export async function searchBankAccounts(
context: WebPartContext,
filter: { companyId: string; projectId: string },
search: string
): Promise<IDictionaryLoadResult> {
const base = { search: search.trim(), companyId: guidOrEmpty(filter.companyId), pageSize: String(SEARCH_PAGE_SIZE) };
const projectId = guidOrEmpty(filter.projectId);
if (!projectId) {
return { items: await fetchDictionary(context, 'BankAccounts', base, mapBankAccount) };
}
const byProject = await fetchDictionary(context, 'BankAccounts', { ...base, projectId }, mapBankAccount);
if (byProject.length > 0) return { items: byProject };
const byCompany = await fetchDictionary(context, 'BankAccounts', base, mapBankAccount);
return {
items: byCompany,
hint: byCompany.length > 0 ? 'Счетов проекта не найдено — показаны счета компании.' : undefined
};
}
/**
* Действующие договоры 1С. Фильтра по компании у API нет — ищем на сервере по
* названию, коду договора и контрагенту.
*/
export async function searchContracts(context: WebPartContext, search: string): Promise<IDictionaryLoadResult> {
const items = await fetchDictionary(
context,
'Contracts',
{ search: search.trim(), pageSize: String(SEARCH_PAGE_SIZE) },
mapContract
);
return { items };
}
/** Группы статей ДДС — справочник маленький, отдаётся целиком. */
export async function getCashFlowGroups(context: WebPartContext): Promise<IDictionaryLoadResult> {
return { items: await fetchDictionary(context, 'CashFlowItemGroups', {}, mapNamed) };
}
/** Статьи ДДС группы (GET /Dictionary/CashFlowItems?id=<группа>). Без группы список пуст. */
export async function getCashFlowItems(context: WebPartContext, groupId: string): Promise<IDictionaryLoadResult> {
const id = guidOrEmpty(groupId);
if (!id) return { items: [], hint: 'Сначала выберите группу статьи ДДС.' };
return { items: await fetchDictionary(context, 'CashFlowItems', { id }, mapNamed) };
}
@@ -31,7 +31,16 @@ export {
getCompanyDirectorEmail,
getManagerEmail
} from './employeeLookup.service';
export { CityPicker } from './ui/CityPicker';
export type { ICityPickerProps, ICityValue } from './ui/CityPicker';
export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines';
export type { IOrganizationLine } from './organizationLines';
export {
searchBankAccounts,
searchContracts,
getCashFlowGroups,
getCashFlowItems
} from './dictionaryList.service';
export type { IDictionaryItem, IDictionaryLoadResult } from './dictionaryList.service';
export { DictionaryPicker } from './ui/DictionaryPicker';
export type { IDictionaryPickerProps } from './ui/DictionaryPicker';
export { ItemPicker } from './ui/ItemPicker';
export type { IItemPickerProps } from './ui/ItemPicker';
@@ -1,15 +0,0 @@
/** Город или посёлок РК из КАТО (см. scripts/Build-KatoCities.ps1). */
export interface IKatoCity {
/** Код КАТО, 9 цифр. */
kato: string;
/** Название без префикса «г.»/«п.»: «Атырау», «Кульсары». */
name: string;
/** Название на казахском без «қ.»/«к.»: «Атырау», «Құлсары». Часто отличается от русского (Уральск — Орал). */
nameKz: string;
kind: 'city' | 'settlement';
/**
* Область — различает одноимённые пункты (Актау: город в Мангистауской области
* и посёлок в Карагандинской). '' — город республиканского значения.
*/
region: string;
}
@@ -1,10 +0,0 @@
/**
* Город на двух языках: «Кульсары (Құлсары)». Названия совпадают (Атырау) или
* казахского нет (введено вручную) — одно название. Пустая строка — город не указан.
*/
export function cityLabel(name: string, nameKz: string): string {
const ru = name.trim();
const kz = nameKz.trim();
if (!ru) return kz;
return kz && kz !== ru ? `${ru} (${kz})` : ru;
}
@@ -1,184 +0,0 @@
// СГЕНЕРИРОВАНО scripts/Build-KatoCities.ps1 — руками не править, перезапустить скрипт.
// Источник: КАТО, Бюро национальной статистики РК (stat.gov.kz), файл «КАТО_18.09.2026.xlsx».
import { IKatoCity } from './IKatoCity';
export const KATO_CITIES: ReadonlyArray<IKatoCity> = [
{ kato: '353220100', name: 'Абай', nameKz: 'Абай', kind: 'city', region: 'Карагандинская область' },
{ kato: '356431100', name: 'Агадырь', nameKz: 'Ақадыр', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '434430100', name: 'Айтеке би', nameKz: 'Әйтеке би', kind: 'settlement', region: 'Кызылординская область' },
{ kato: '356435100', name: 'Акжал', nameKz: 'Ақжал', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '113220100', name: 'Акколь', nameKz: 'Ақкөл', kind: 'city', region: 'Акмолинская область' },
{ kato: '273620100', name: 'Аксай', nameKz: 'Ақсай', kind: 'city', region: 'Западно-Казахстанская область' },
{ kato: '111833100', name: 'Аксу', nameKz: 'Ақсу', kind: 'settlement', region: 'Акмолинская область' },
{ kato: '551610000', name: 'Аксу', nameKz: 'Ақсу', kind: 'city', region: 'Павлодарская область' },
{ kato: '352231100', name: 'Актас', nameKz: 'Ақтас', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '623831100', name: 'Актас', nameKz: 'Ақтас', kind: 'settlement', region: 'область Ұлытау' },
{ kato: '352431100', name: 'Актау', nameKz: 'Ақтау', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '471010000', name: 'Актау', nameKz: 'Ақтау', kind: 'city', region: 'Мангистауская область' },
{ kato: '151010000', name: 'Актобе', nameKz: 'Ақтөбе', kind: 'city', region: 'Актюбинская область' },
{ kato: '356437100', name: 'Акчатау', nameKz: 'Ақшатау', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '191810000', name: 'Алатау', nameKz: 'Алатау', kind: 'city', region: 'Алматинская область' },
{ kato: '153220100', name: 'Алга', nameKz: 'Алға', kind: 'city', region: 'Актюбинская область' },
{ kato: '115635100', name: 'Алексеевка', nameKz: 'Алексеевка', kind: 'settlement', region: 'Акмолинская область' },
{ kato: '750000000', name: 'Алматы', nameKz: 'Алматы', kind: 'city', region: '' },
{ kato: '634820100', name: 'Алтай', nameKz: 'Алтай', kind: 'city', region: 'Восточно-Казахстанская область' },
{ kato: '433220100', name: 'Аральск', nameKz: 'Арал', kind: 'city', region: 'Кызылординская область' },
{ kato: '391610000', name: 'Аркалык', nameKz: 'Арқалық', kind: 'city', region: 'Костанайская область' },
{ kato: '113430100', name: 'Аршалы', nameKz: 'Аршалы', kind: 'settlement', region: 'Акмолинская область' },
{ kato: '611610000', name: 'Арысь', nameKz: 'Арыс', kind: 'city', region: 'Туркестанская область' },
{ kato: '710000000', name: 'Астана', nameKz: 'Астана', kind: 'city', region: '' },
{ kato: '613843100', name: 'Асыката', nameKz: 'Асықата', kind: 'settlement', region: 'Туркестанская область' },
{ kato: '614459100', name: 'Атакент', nameKz: 'Атакент', kind: 'settlement', region: 'Туркестанская область' },
{ kato: '551045100', name: 'Атамекен', nameKz: 'Атамекен', kind: 'settlement', region: 'Павлодарская область' },
{ kato: '113820100', name: 'Атбасар', nameKz: 'Атбасар', kind: 'city', region: 'Акмолинская область' },
{ kato: '231010000', name: 'Атырау', nameKz: 'Атырау', kind: 'city', region: 'Атырауская область' },
{ kato: '103620100', name: 'Аягоз', nameKz: 'Аягөз', kind: 'city', region: 'область Абай' },
{ kato: '431910000', name: 'Байконыр', nameKz: 'Байқоңыр', kind: 'city', region: 'Кызылординская область' },
{ kato: '351610000', name: 'Балхаш', nameKz: 'Балқаш', kind: 'city', region: 'Карагандинская область' },
{ kato: '431037100', name: 'Белколь', nameKz: 'Белкөл', kind: 'settlement', region: 'Кызылординская область' },
{ kato: '111837100', name: 'Бестобе', nameKz: 'Бестөбе', kind: 'settlement', region: 'Акмолинская область' },
{ kato: '196835100', name: 'Боралдай', nameKz: 'Боралдай', kind: 'settlement', region: 'Алматинская область' },
{ kato: '354030100', name: 'Ботакара', nameKz: 'Ботақара', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '593620100', name: 'Булаево', nameKz: 'Булаев', kind: 'city', region: 'Северо-Казахстанская область' },
{ kato: '117035100', name: 'Бурабай', nameKz: 'Бурабай', kind: 'settlement', region: 'Акмолинская область' },
{ kato: '354081100', name: 'Габидена Мустафина', nameKz: 'Ғабиден Мұстафин', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '314851400', name: 'Гвардейский', nameKz: 'Гвардейский', kind: 'settlement', region: 'Жамбылская область' },
{ kato: '392433100', name: 'Горняцкий', nameKz: 'Горняк', kind: 'settlement', region: 'Костанайская область' },
{ kato: '315441300', name: 'Гранитогорск', nameKz: 'Гранитогорск', kind: 'settlement', region: 'Жамбылская область' },
{ kato: '351635100', name: 'Гульшат', nameKz: 'Гүлшат', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '356447100', name: 'Дарьинский', nameKz: 'Дария', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '115420100', name: 'Державинск', nameKz: 'Державин', kind: 'city', region: 'Акмолинская область' },
{ kato: '271033100', name: 'Деркул', nameKz: 'Дерқұл', kind: 'settlement', region: 'Западно-Казахстанская область' },
{ kato: '352835100', name: 'Долинка', nameKz: 'Долинка', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '235235100', name: 'Доссор', nameKz: 'Доссор', kind: 'settlement', region: 'Атырауская область' },
{ kato: '114620100', name: 'Ерейментау', nameKz: 'Ерейментау', kind: 'city', region: 'Акмолинская область' },
{ kato: '194020100', name: 'Есик', nameKz: 'Есік', kind: 'city', region: 'Алматинская область' },
{ kato: '114820100', name: 'Есиль', nameKz: 'Есіл', kind: 'city', region: 'Акмолинская область' },
{ kato: '621835100', name: 'Жайрем', nameKz: 'Жәйрем', kind: 'settlement', region: 'область Ұлытау' },
{ kato: '433237100', name: 'Жаксыкылыш', nameKz: 'Жақсықылыш', kind: 'settlement', region: 'Кызылординская область' },
{ kato: '433630100', name: 'Жалагаш', nameKz: 'Жалағаш', kind: 'settlement', region: 'Кызылординская область' },
{ kato: '233639100', name: 'Жана Каратон', nameKz: 'Жаңа Қаратон', kind: 'settlement', region: 'Атырауская область' },
{ kato: '434030100', name: 'Жанакорган', nameKz: 'Жаңақорған', kind: 'settlement', region: 'Кызылординская область' },
{ kato: '471810000', name: 'Жанаозен', nameKz: 'Жаңаөзен', kind: 'city', region: 'Мангистауская область' },
{ kato: '316020100', name: 'Жанатас', nameKz: 'Жаңатас', kind: 'city', region: 'Жамбылская область' },
{ kato: '623630100', name: 'Жаңаарқа', nameKz: 'Жаңаарқа', kind: 'settlement', region: 'область Ұлытау' },
{ kato: '334620100', name: 'Жаркент', nameKz: 'Жаркент', kind: 'city', region: 'область Жетісу' },
{ kato: '623843100', name: 'Жезды', nameKz: 'Жезді', kind: 'settlement', region: 'область Ұлытау' },
{ kato: '621010000', name: 'Жезказган', nameKz: 'Жезқазған', kind: 'city', region: 'область Ұлытау' },
{ kato: '154825100', name: 'Жем', nameKz: 'Жем', kind: 'city', region: 'Актюбинская область' },
{ kato: '613820100', name: 'Жетысай', nameKz: 'Жетісай', kind: 'city', region: 'Туркестанская область' },
{ kato: '113432100', name: 'Жибек жолы', nameKz: 'Жібек жолы', kind: 'settlement', region: 'Акмолинская область' },
{ kato: '394420100', name: 'Житикара', nameKz: 'Жітіқара', kind: 'city', region: 'Костанайская область' },
{ kato: '116839100', name: 'Жолымбет', nameKz: 'Жолымбет', kind: 'settlement', region: 'Акмолинская область' },
{ kato: '434630100', name: 'Жосалы', nameKz: 'Жосалы', kind: 'settlement', region: 'Кызылординская область' },
{ kato: '111841100', name: 'Заводской', nameKz: 'Зауыт', kind: 'settlement', region: 'Акмолинская область' },
{ kato: '634620100', name: 'Зайсан', nameKz: 'Зайсан', kind: 'city', region: 'Восточно-Казахстанская область' },
{ kato: '271035100', name: 'Зачаганск', nameKz: 'Зачаганск', kind: 'settlement', region: 'Западно-Казахстанская область' },
{ kato: '234030100', name: 'Индерборский', nameKz: 'Индербор', kind: 'settlement', region: 'Атырауская область' },
{ kato: '434423100', name: 'Казалинск', nameKz: 'Қазалы', kind: 'city', region: 'Кызылординская область' },
{ kato: '154820100', name: 'Кандыагаш', nameKz: 'Қандыағаш', kind: 'city', region: 'Актюбинская область' },
{ kato: '395030100', name: 'Карабалык', nameKz: 'Қарабалық', kind: 'settlement', region: 'Костанайская область' },
{ kato: '353257100', name: 'Карабас', nameKz: 'Қарабас', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '354863100', name: 'Карагайлы', nameKz: 'Қарағайлы', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '351010000', name: 'Караганда', nameKz: 'Қарағанды', kind: 'city', region: 'Карагандинская область' },
{ kato: '621810000', name: 'Каражал', nameKz: 'Қаражал', kind: 'city', region: 'область Ұлытау' },
{ kato: '316220100', name: 'Каратау', nameKz: 'Қаратау', kind: 'city', region: 'Жамбылская область' },
{ kato: '354820100', name: 'Каркаралинск', nameKz: 'Қарқаралы', kind: 'city', region: 'Карагандинская область' },
{ kato: '623851100', name: 'Карсакпай', nameKz: 'Қарсақпай', kind: 'settlement', region: 'область Ұлытау' },
{ kato: '195220100', name: 'Каскелен', nameKz: 'Қаскелең', kind: 'city', region: 'Алматинская область' },
{ kato: '392435100', name: 'Качар', nameKz: 'Қашар', kind: 'settlement', region: 'Костанайская область' },
{ kato: '612010000', name: 'Кентау', nameKz: 'Кентау', kind: 'city', region: 'Туркестанская область' },
{ kato: '615467100', name: 'Коктерек', nameKz: 'Көктерек', kind: 'settlement', region: 'Туркестанская область' },
{ kato: '111010000', name: 'Кокшетау', nameKz: 'Көкшетау', kind: 'city', region: 'Акмолинская область' },
{ kato: '351600200', name: 'Конырат', nameKz: 'Қоңырат', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '391010000', name: 'Костанай', nameKz: 'Қостанай', kind: 'city', region: 'Костанайская область' },
{ kato: '111610000', name: 'Косшы', nameKz: 'Қосшы', kind: 'city', region: 'Акмолинская область' },
{ kato: '114859100', name: 'Красногорский', nameKz: 'Красногор', kind: 'settlement', region: 'Акмолинская область' },
{ kato: '271039100', name: 'Круглоозерное', nameKz: 'Круглоозерное', kind: 'settlement', region: 'Западно-Казахстанская область' },
{ kato: '233620100', name: 'Кульсары', nameKz: 'Құлсары', kind: 'city', region: 'Атырауская область' },
{ kato: '101810000', name: 'Курчатов', nameKz: 'Курчатов', kind: 'city', region: 'область Абай' },
{ kato: '393633100', name: 'Кушмурун', nameKz: 'Құсмұрын', kind: 'settlement', region: 'Костанайская область' },
{ kato: '354061100', name: 'Кушокы', nameKz: 'Қушоқы', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '615645100', name: 'Кыземшек', nameKz: 'Қыземшек', kind: 'settlement', region: 'Туркестанская область' },
{ kato: '623657100', name: 'Кызылжар', nameKz: 'Қызылжар', kind: 'settlement', region: 'область Ұлытау' },
{ kato: '431010000', name: 'Кызылорда', nameKz: 'Қызылорда', kind: 'city', region: 'Кызылординская область' },
{ kato: '191010000', name: 'Қонаев', nameKz: 'Қонаев', kind: 'city', region: 'Алматинская область' },
{ kato: '615820100', name: 'Ленгер', nameKz: 'Леңгір', kind: 'city', region: 'Туркестанская область' },
{ kato: '392010000', name: 'Лисаковск', nameKz: 'Лисаков', kind: 'city', region: 'Костанайская область' },
{ kato: '553655100', name: 'Майкаин', nameKz: 'Майқайың', kind: 'settlement', region: 'Павлодарская область' },
{ kato: '235230100', name: 'Макат', nameKz: 'Мақат', kind: 'settlement', region: 'Атырауская область' },
{ kato: '114020100', name: 'Макинск', nameKz: 'Макинск', kind: 'city', region: 'Акмолинская область' },
{ kato: '595220100', name: 'Мамлютка', nameKz: 'Мамлют', kind: 'city', region: 'Северо-Казахстанская область' },
{ kato: '356471100', name: 'Мойынты', nameKz: 'Мойынты', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '355657100', name: 'Молодежный', nameKz: 'Молодежный', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '614430100', name: 'Мырзакент', nameKz: 'Мырзакент', kind: 'settlement', region: 'Туркестанская область' },
{ kato: '116835400', name: 'Научный', nameKz: 'Научный', kind: 'settlement', region: 'Акмолинская область' },
{ kato: '352839100', name: 'Новодолинский', nameKz: 'Новодолинский', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '355230100', name: 'Нура', nameKz: 'Нұра', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '392035100', name: 'Октябрьский', nameKz: 'Октябрь', kind: 'settlement', region: 'Костанайская область' },
{ kato: '355630100', name: 'Осакаровка', nameKz: 'Осакаровка', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '551010000', name: 'Павлодар', nameKz: 'Павлодар', kind: 'city', region: 'Павлодарская область' },
{ kato: '591010000', name: 'Петропавловск', nameKz: 'Петропавл', kind: 'city', region: 'Северо-Казахстанская область' },
{ kato: '352110000', name: 'Приозерск', nameKz: 'Приозерск', kind: 'city', region: 'Карагандинская область' },
{ kato: '632410000', name: 'Риддер', nameKz: 'Риддер', kind: 'city', region: 'Восточно-Казахстанская область' },
{ kato: '392410000', name: 'Рудный', nameKz: 'Рудный', kind: 'city', region: 'Костанайская область' },
{ kato: '356451100', name: 'С.Сейфуллин', nameKz: 'С.Сейфуллин', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '433257100', name: 'Саксаульск', nameKz: 'Сексеуіл', kind: 'settlement', region: 'Кызылординская область' },
{ kato: '352210000', name: 'Сарань', nameKz: 'Саран', kind: 'city', region: 'Карагандинская область' },
{ kato: '334820100', name: 'Саркан', nameKz: 'Сарқан', kind: 'city', region: 'область Жетісу' },
{ kato: '615420100', name: 'Сарыагаш', nameKz: 'Сарыағаш', kind: 'city', region: 'Туркестанская область' },
{ kato: '396230100', name: 'Сарыколь', nameKz: 'Сарыкөл', kind: 'settlement', region: 'Костанайская область' },
{ kato: '353667100', name: 'Сарышаган', nameKz: 'Сарышаған', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '622010000', name: 'Сатпаев', nameKz: 'Сәтбаев', kind: 'city', region: 'область Ұлытау' },
{ kato: '351645100', name: 'Саяк', nameKz: 'Саяқ', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '101010000', name: 'Семей', nameKz: 'Семей', kind: 'city', region: 'область Абай' },
{ kato: '595620100', name: 'Сергеевка', nameKz: 'Сергеев', kind: 'city', region: 'Северо-Казахстанская область' },
{ kato: '634821100', name: 'Серебрянск', nameKz: 'Серебрянск', kind: 'city', region: 'Восточно-Казахстанская область' },
{ kato: '552253100', name: 'Солнечный', nameKz: 'Солнечный', kind: 'settlement', region: 'Павлодарская область' },
{ kato: '111037100', name: 'Станционный', nameKz: 'Станционный', kind: 'settlement', region: 'Акмолинская область' },
{ kato: '111810000', name: 'Степногорск', nameKz: 'Степногор', kind: 'city', region: 'Акмолинская область' },
{ kato: '114520100', name: 'Степняк', nameKz: 'Степняк', kind: 'city', region: 'Акмолинская область' },
{ kato: '596020100', name: 'Тайынша', nameKz: 'Тайынша', kind: 'city', region: 'Северо-Казахстанская область' },
{ kato: '196220100', name: 'Талгар', nameKz: 'Талғар', kind: 'city', region: 'Алматинская область' },
{ kato: '331010000', name: 'Талдыкорган', nameKz: 'Талдықорған', kind: 'city', region: 'область Жетісу' },
{ kato: '311010000', name: 'Тараз', nameKz: 'Тараз', kind: 'city', region: 'Жамбылская область' },
{ kato: '431047100', name: 'Тасбугет', nameKz: 'Тасбөгет', kind: 'settlement', region: 'Кызылординская область' },
{ kato: '331810000', name: 'Текели', nameKz: 'Текелі', kind: 'city', region: 'область Жетісу' },
{ kato: '155621100', name: 'Темир', nameKz: 'Темір', kind: 'city', region: 'Актюбинская область' },
{ kato: '352410000', name: 'Темиртау', nameKz: 'Теміртау', kind: 'city', region: 'Карагандинская область' },
{ kato: '396473100', name: 'Тобол', nameKz: 'Тобыл', kind: 'settlement', region: 'Костанайская область' },
{ kato: '395420100', name: 'Тобыл', nameKz: 'Тобыл', kind: 'city', region: 'Костанайская область' },
{ kato: '353285100', name: 'Топар', nameKz: 'Топар', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '611010000', name: 'Туркестан', nameKz: 'Түркістан', kind: 'city', region: 'Туркестанская область' },
{ kato: '616063100', name: 'Тюлькубас', nameKz: 'Түлкібас', kind: 'settlement', region: 'Туркестанская область' },
{ kato: '632400200', name: 'Ульба', nameKz: 'Үлбі', kind: 'settlement', region: 'Восточно-Казахстанская область' },
{ kato: '271010000', name: 'Уральск', nameKz: 'Орал', kind: 'city', region: 'Западно-Казахстанская область' },
{ kato: '631010000', name: 'Усть-Каменогорск', nameKz: 'Өскемен', kind: 'city', region: 'Восточно-Казахстанская область' },
{ kato: '333420100', name: 'Ушарал', nameKz: 'Үшарал', kind: 'city', region: 'область Жетісу' },
{ kato: '334420100', name: 'Уштобе', nameKz: 'Үштөбе', kind: 'city', region: 'область Жетісу' },
{ kato: '475220100', name: 'Форт-Шевченко', nameKz: 'Форт-Шевченко', kind: 'city', region: 'Мангистауская область' },
{ kato: '156020100', name: 'Хромтау', nameKz: 'Хромтау', kind: 'city', region: 'Актюбинская область' },
{ kato: '104163100', name: 'Чаган', nameKz: 'Шаған', kind: 'settlement', region: 'область Абай' },
{ kato: '156420100', name: 'Шалкар', nameKz: 'Шалқар', kind: 'city', region: 'Актюбинская область' },
{ kato: '434065100', name: 'Шалкия', nameKz: 'Шалқия', kind: 'settlement', region: 'Кызылординская область' },
{ kato: '111845100', name: 'Шантобе', nameKz: 'Шаңтөбе', kind: 'settlement', region: 'Акмолинская область' },
{ kato: '104221100', name: 'Шар', nameKz: 'Шар', kind: 'city', region: 'область Абай' },
{ kato: '616420100', name: 'Шардара', nameKz: 'Шардара', kind: 'city', region: 'Туркестанская область' },
{ kato: '352845100', name: 'Шахан', nameKz: 'Шақан', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '352810000', name: 'Шахтинск', nameKz: 'Шахтинск', kind: 'city', region: 'Карагандинская область' },
{ kato: '353679100', name: 'Шашубай', nameKz: 'Шәшубай', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '636820100', name: 'Шемонаиха', nameKz: 'Шемонайха', kind: 'city', region: 'Восточно-Казахстанская область' },
{ kato: '552257100', name: 'Шидерты', nameKz: 'Шідерті', kind: 'settlement', region: 'Павлодарская область' },
{ kato: '435230100', name: 'Шиели', nameKz: 'Шиелі', kind: 'settlement', region: 'Кызылординская область' },
{ kato: '116830100', name: 'Шортанды', nameKz: 'Шортанды', kind: 'settlement', region: 'Акмолинская область' },
{ kato: '316621100', name: 'Шу', nameKz: 'Шу', kind: 'city', region: 'Жамбылская область' },
{ kato: '355289100', name: 'Шубарколь', nameKz: 'Шұбаркөл', kind: 'settlement', region: 'Карагандинская область' },
{ kato: '155630100', name: 'Шубаркудук', nameKz: 'Шұбарқұдық', kind: 'settlement', region: 'Актюбинская область' },
{ kato: '155642100', name: 'Шубарши', nameKz: 'Шұбарши', kind: 'settlement', region: 'Актюбинская область' },
{ kato: '104165100', name: 'Шульбинск', nameKz: 'Шүлбі', kind: 'settlement', region: 'область Абай' },
{ kato: '790000000', name: 'Шымкент', nameKz: 'Шымкент', kind: 'city', region: '' },
{ kato: '117020100', name: 'Щучинск', nameKz: 'Щучинск', kind: 'city', region: 'Акмолинская область' },
{ kato: '552210000', name: 'Экибастуз', nameKz: 'Екібастұз', kind: 'city', region: 'Павлодарская область' },
{ kato: '154823100', name: 'Эмба', nameKz: 'Ембі', kind: 'city', region: 'Актюбинская область' },
{ kato: '353289100', name: 'Южный', nameKz: 'Южный', kind: 'settlement', region: 'Карагандинская область' },
];
@@ -1,53 +0,0 @@
import { IKatoCity } from './IKatoCity';
import { findExactCity, normalizeCityText, searchCities } from './searchCities';
const city = (name: string, nameKz: string, kind: IKatoCity['kind'] = 'city', region = ''): IKatoCity => ({
kato: name,
name,
nameKz,
kind,
region
});
const CITIES: IKatoCity[] = [
city('Актау', 'Ақтау', 'settlement', 'Карагандинская область'),
city('Актау', 'Ақтау', 'city', 'Мангистауская область'),
city('Актобе', 'Ақтөбе'),
city('Кульсары', 'Құлсары'),
city('Уральск', 'Орал'),
city('Жана Каратон', 'Жаңа Қаратон', 'settlement')
];
const names = (list: IKatoCity[]): string[] => list.map((c) => `${c.name}/${c.kind}`);
describe('searchCities', () => {
it('казахские буквы и мягкий знак не мешают', () => {
expect(normalizeCityText('Құлсары')).toBe(normalizeCityText('Кульсары'));
expect(names(searchCities(CITIES, 'Кулс', 10))).toEqual(['Кульсары/city']);
expect(names(searchCities(CITIES, 'Құл', 10))).toEqual(['Кульсары/city']);
});
it('по казахскому названию, даже если русское другое', () => {
expect(names(searchCities(CITIES, 'Орал', 10))).toEqual(['Уральск/city']);
});
it('город раньше одноимённого посёлка, начало названия раньше середины', () => {
expect(names(searchCities(CITIES, 'акт', 10))).toEqual(['Актау/city', 'Актобе/city', 'Актау/settlement']);
});
it('по началу второго слова', () => {
expect(names(searchCities(CITIES, 'карат', 10))).toEqual(['Жана Каратон/settlement']);
});
it('название введено целиком — находится, только если однозначно', () => {
expect(findExactCity(CITIES, ' кульсары ')?.name).toBe('Кульсары');
expect(findExactCity(CITIES, 'Орал')?.name).toBe('Уральск');
expect(findExactCity(CITIES, 'Актау')).toBeUndefined();
expect(findExactCity(CITIES, 'Кульс')).toBeUndefined();
});
it('пустой запрос — пусто, лимит соблюдается', () => {
expect(searchCities(CITIES, ' ', 10)).toEqual([]);
expect(searchCities(CITIES, 'а', 2)).toHaveLength(2);
});
});
@@ -1,70 +0,0 @@
import { IKatoCity } from './IKatoCity';
// Казахские буквы → ближайшие русские: «Кулсары» на русской раскладке находит «Құлсары».
const KZ_TO_RU: Record<string, string> = {
ә: 'а',
ғ: 'г',
қ: 'к',
ң: 'н',
ө: 'о',
ұ: 'у',
ү: 'у',
һ: 'х',
і: 'и',
ё: 'е'
};
/**
* Строка для сравнения: нижний регистр, казахские буквы приведены к русским, без
* мягкого/твёрдого знака — «Кульсары», «Кулсары» и «Құлсары» дают одно и то же.
*/
export function normalizeCityText(text: string): string {
return text
.toLowerCase()
.replace(/[әғқңөұүһіё]/g, (ch) => KZ_TO_RU[ch])
.replace(/[ьъ]/g, '')
.trim();
}
/** Насколько хорошо название подходит под запрос; undefined — не подходит. Меньше — лучше. */
function matchRank(name: string, query: string): number | undefined {
const normalized = normalizeCityText(name);
if (normalized.startsWith(query)) return 0;
// Начало второго слова: «Каратон» находит «Жана Каратон».
if (normalized.split(/[\s-]+/).some((word) => word.startsWith(query))) return 1;
if (normalized.includes(query)) return 2;
return undefined;
}
/**
* Города и посёлки под запрос — по русскому и казахскому названию. Порядок: совпало
* начало названия → начало слова → середина; при равенстве города раньше посёлков.
*/
export function searchCities(cities: ReadonlyArray<IKatoCity>, query: string, limit: number): IKatoCity[] {
const q = normalizeCityText(query);
if (!q) return [];
const found: Array<{ city: IKatoCity; rank: number }> = [];
cities.forEach((city) => {
const ranks = [matchRank(city.name, q), matchRank(city.nameKz, q)].filter((r): r is number => r !== undefined);
if (ranks.length > 0) found.push({ city, rank: Math.min(...ranks) });
});
return found
.sort((a, b) => a.rank - b.rank || kindOrder(a.city) - kindOrder(b.city) || a.city.name.localeCompare(b.city.name, 'ru'))
.slice(0, limit)
.map((f) => f.city);
}
/**
* Пункт, название которого введено целиком (на любом языке). Неоднозначно
* (два Актау) или не найдено — undefined: выбирать за человека нельзя.
*/
export function findExactCity(cities: ReadonlyArray<IKatoCity>, text: string): IKatoCity | undefined {
const q = normalizeCityText(text);
if (!q) return undefined;
const matches = cities.filter((c) => normalizeCityText(c.name) === q || normalizeCityText(c.nameKz) === q);
return matches.length === 1 ? matches[0] : undefined;
}
function kindOrder(city: IKatoCity): number {
return city.kind === 'city' ? 0 : 1;
}
@@ -0,0 +1,29 @@
import { isGuid, mapBankAccount, mapContract, mapNamed } from './oneCRecords';
describe('isGuid', () => {
it('принимает GUID с дефисами и без', () => {
expect(isGuid('3f2504e0-4f89-11d3-9a0c-0305e82c3301')).toBe(true);
expect(isGuid('3F2504E04F8911D39A0C0305E82C3301')).toBe(true);
});
it('отвергает пустое и чужие форматы', () => {
expect(isGuid('')).toBe(false);
expect(isGuid('12345')).toBe(false);
});
});
describe('мапперы справочников 1С', () => {
it('банковский счёт: название, под ним номер счёта, банк и валюта', () => {
const item = mapBankAccount({ id: 'a', name: 'Основной', account_number: 'KZ123', bank: 'Halyk', valuta: 'KZT' });
expect(item).toEqual({ id: 'a', label: 'Основной', note: 'KZ123 · Halyk · KZT' });
});
it('банковский счёт без названия подписывается номером счёта', () => {
expect(mapBankAccount({ id: 'a', account_number: 'KZ123', bank: 'Halyk' })).toEqual({ id: 'a', label: 'KZ123', note: 'Halyk' });
});
it('договор: под названием контрагент, код и срок', () => {
const item = mapContract({ id: 'c', code: 'Д-1', name: 'Поставка', counterparty_name: 'ТОО Альфа', deadline: '2027-01-31T00:00:00' });
expect(item).toEqual({ id: 'c', label: 'Поставка', note: 'ТОО Альфа · Д-1 · до 2027-01-31' });
});
it('статья ДДС: только название', () => {
expect(mapNamed({ id: 'g', name: 'Материалы' })).toEqual({ id: 'g', label: 'Материалы', note: '' });
});
});
@@ -0,0 +1,60 @@
import type { IDictionaryItem } from './dictionaryList.service';
/**
* Разбор ответов справочников 1С из api-tnsu-portal (DictionaryController). Отдельно
* от сервиса, чтобы проверять тестами без SPFx-зависимостей (как companiesCache).
*/
export type RawRecord = Record<string, unknown>;
const GUID_PATTERN = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
/**
* Фильтры справочников api.tnsu.kz — Guid: значение не в формате GUID сервер
* отвергает с 400, и весь список пропадает. Такой фильтр лучше не передавать.
*/
export function isGuid(value: string): boolean {
return GUID_PATTERN.test(value.trim());
}
function str(value: unknown): string {
return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : '';
}
function joinNote(parts: string[]): string {
return parts.filter(Boolean).join(' · ');
}
/** Ответ — либо плоский массив, либо страница { total, page, pageSize, items }. */
export function extractRecords(body: unknown): RawRecord[] {
if (Array.isArray(body)) return body as RawRecord[];
const items = (body as { items?: unknown } | null)?.items;
return Array.isArray(items) ? (items as RawRecord[]) : [];
}
// GET /Dictionary/BankAccounts → { id, code, name, account_number, valuta, bank, project, company, … }
export function mapBankAccount(record: RawRecord): IDictionaryItem {
const name = str(record.name);
const account = str(record.account_number);
return {
id: str(record.id),
label: name || account,
note: joinNote([name ? account : '', str(record.bank), str(record.valuta)])
};
}
// GET /Dictionary/Contracts → { id, code, name, deadline, counterparty_id, counterparty_name }
export function mapContract(record: RawRecord): IDictionaryItem {
const deadline = str(record.deadline).slice(0, 10);
return {
id: str(record.id),
label: str(record.name) || str(record.code),
note: joinNote([str(record.counterparty_name), str(record.code), deadline ? `до ${deadline}` : ''])
};
}
// GET /Dictionary/CashFlowItemGroups, /Dictionary/CashFlowItems?id= → [{ id, name }]
export function mapNamed(record: RawRecord): IDictionaryItem {
return { id: str(record.id), label: str(record.name), note: '' };
}
@@ -1,97 +0,0 @@
@import '../../../../../styles/design-tokens';
.wrap {
position: relative;
}
.input {
box-sizing: border-box;
width: 100%;
// справа место под крестик-очистку
padding: 11px 36px 11px 13px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
@include readonly-field(':read-only');
&::placeholder {
color: $gray-400;
}
}
.clear {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $gray-400;
font-size: 18px;
line-height: 1;
cursor: pointer;
transition: .15s;
&:hover {
background: $gray-100;
color: $gray-700;
}
}
.list {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: $sp-1;
max-height: 260px;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-sm;
box-shadow: $sh-2;
}
.item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 9px 12px;
border: 0;
background: transparent;
text-align: left;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
.meta {
font-size: $fs-caption;
color: $gray-500;
}
// Ничего не нашлось — подсказка, что введённый текст сохранится как есть.
.hint {
padding: 9px 12px;
font-size: $fs-caption;
color: $gray-500;
}
@@ -1,120 +0,0 @@
import * as React from 'react';
import styles from './CityPicker.module.scss';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { IKatoCity } from '../kato/IKatoCity';
import { KATO_CITIES } from '../kato/katoCities';
import { cityLabel } from '../kato/cityLabel';
import { findExactCity, searchCities } from '../kato/searchCities';
/** Значение поля: город из справочника КАТО (оба названия + код) или введённый вручную текст. */
export interface ICityValue {
/** На русском — или текст, введённый вручную. */
name: string;
/** На казахском; '' — введено вручную. */
nameKz: string;
/** Код КАТО; '' — введено вручную. */
kato: string;
}
export interface ICityPickerProps {
id?: string;
value: ICityValue;
onChange: (value: ICityValue) => void;
placeholder?: string;
/** Только чтение (режим согласующего). */
readOnly?: boolean;
}
const MAX_SUGGESTIONS = 20;
const EMPTY_CITY: ICityValue = { name: '', nameKz: '', kato: '' };
/** «Атырауская область · город»; у Астаны/Алматы/Шымкента области нет. */
function cityMeta(city: IKatoCity): string {
const kind = city.kind === 'city' ? 'город' : 'посёлок';
return city.region ? `${city.region} · ${kind}` : 'город республиканского значения';
}
/**
* Город / населённый пункт РК со справочником КАТО (shared/dictionary/kato).
* Ищет по русскому и казахскому названию; выбор сохраняет оба названия и код.
* Нет в справочнике (село, заграница) — остаётся текст, введённый вручную.
*/
export const CityPicker: React.FC<ICityPickerProps> = ({ id, value, onChange, placeholder, readOnly }) => {
const contextReadOnly = useFormReadOnly();
const effectiveReadOnly = readOnly ?? contextReadOnly;
const [open, setOpen] = React.useState<boolean>(false);
const isPicked = value.kato !== '';
// Выбранный город показываем на двух языках; пока человек печатает — ровно его текст.
const shownText = isPicked ? cityLabel(value.name, value.nameKz) : value.name;
const suggestions = isPicked ? [] : searchCities(KATO_CITIES, value.name, MAX_SUGGESTIONS);
const handlePick = (city: IKatoCity): void => {
onChange({ name: city.name, nameKz: city.nameKz, kato: city.kato });
setOpen(false);
};
return (
<div className={styles.wrap}>
<input
id={id}
className={styles.input}
value={shownText}
placeholder={placeholder}
readOnly={effectiveReadOnly}
// Любая правка — уже ручной ввод: казахское название и код сбрасываются.
// setOpen(true) и при вводе: после выбора список закрыт, а фокус остаётся на поле.
onChange={(e) => {
onChange({ name: e.target.value, nameKz: '', kato: '' });
setOpen(true);
}}
// Выбранный город выделяем целиком — новый ввод заменяет его, а не дописывается к «(Құлсары)».
onFocus={(e) => {
if (isPicked && !effectiveReadOnly) e.target.select();
setOpen(true);
}}
onBlur={() => {
setOpen(false);
// Название набрано целиком, но не выбрано из списка — подставляем город из справочника.
const exact = isPicked ? undefined : findExactCity(KATO_CITIES, value.name);
if (exact) handlePick(exact);
}}
/>
{/* preventDefault на mousedown — чтобы клик не «съел» фокус до onClick (как в списке ниже). */}
{!effectiveReadOnly && shownText.length > 0 && (
<button
type="button"
className={styles.clear}
aria-label="Очистить"
title="Очистить"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange(EMPTY_CITY);
setOpen(false);
}}
>
×
</button>
)}
{!effectiveReadOnly && open && value.name.trim() !== '' && !isPicked && (
<div className={styles.list}>
{suggestions.map((city) => (
<button
key={city.kato}
type="button"
className={styles.item}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handlePick(city)}
>
{cityLabel(city.name, city.nameKz)}
<span className={styles.meta}>{cityMeta(city)}</span>
</button>
))}
{suggestions.length === 0 && (
<div className={styles.hint}>Нет в справочнике — останется как введено</div>
)}
</div>
)}
</div>
);
};

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