From 8e3161f42501f661e6eef8114ac0f2edb5d10d89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Fri, 9 Oct 2026 12:18:15 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D1=83=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B2=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B5=20=D1=81=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE=20=D0=BC=D0=B0?= =?UTF-8?q?=D1=80=D1=88=D1=80=D1=83=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Суточные в смете строятся автоматически по точкам и датам маршрута (в МРП), чтобы инициатор не пересчитывал их вручную; проверки и сохранение работают с уже пересчитанной сметой. Форма командировки заодно подключена к перепланированию маршрута пустого черновика. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../forms/BusinessTrip/BusinessTripForm.tsx | 14 ++- .../forms/BusinessTrip/README.md | 19 ++- .../estimate/EstimateArticleCard.tsx | 18 +-- .../components/estimate/EstimateItemRow.tsx | 12 +- .../components/estimate/EstimateTable.tsx | 2 +- .../estimate/PerDiemFields.module.scss | 117 ++++++++++++++++++ .../components/estimate/PerDiemFields.tsx | 62 ++++++++++ .../forms/BusinessTrip/estimate.test.ts | 57 ++++++++- .../forms/BusinessTrip/estimate.ts | 71 +++++++++-- .../requestPortal/forms/BusinessTrip/model.ts | 33 ++++- .../requestPortal/forms/BusinessTrip/route.ts | 15 +++ .../forms/BusinessTrip/validation.test.ts | 14 ++- .../forms/BusinessTrip/validation.ts | 11 +- 13 files changed, 408 insertions(+), 37 deletions(-) create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.module.scss create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.tsx diff --git a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx index c9d6b0a..1fe105b 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx @@ -41,6 +41,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 { syncPerDiem } from './estimate'; import { saveBusinessTripRequest, businessTripApprovalService } from './submit'; import { loadBusinessTripRequest } from './load'; import { useTripEmployee } from './useTripEmployee'; @@ -83,6 +84,9 @@ const BusinessTripForm: React.FC = ({ const readOnly = !isEditableStatus(requestStatus); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); + // Суточные строятся по маршруту (см. syncPerDiem): секции, проверки и сохранение + // работают со сметой, уже приведённой к текущим точкам и датам. + const formData: IFormData = { ...data, Estimate: syncPerDiem(data.Estimate, data.Route) }; const handleEmployeeChange = useTripEmployee(context, editId, setData); const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]); @@ -115,14 +119,16 @@ const BusinessTripForm: React.FC = ({ context, approval, requestId: editId, + replanEmptyDraft: true, + requestStatus, employeeEmail: data.Employee, requestTypeId, editable: !readOnly }); // Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. validation.ts). - const draftMissing = draftIssues(data); - const submitMissing = submitIssues(data); + const draftMissing = draftIssues(formData); + const submitMissing = submitIssues(formData); const validate = (): string | undefined => { if (draftMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`; @@ -161,7 +167,7 @@ const BusinessTripForm: React.FC = ({ const { id, code } = await saveBusinessTripRequest( requestId, rtId, - data, + formData, toApprovers(matrix.rows), toApprovers(executors.rows), status, @@ -196,7 +202,7 @@ const BusinessTripForm: React.FC = ({ onDeleted: onCancel }); - const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange }; + const sectionProps = { data: formData, set, context, onEmployeeChange: handleEmployeeChange }; return ( diff --git a/src/webparts/requestPortal/forms/BusinessTrip/README.md b/src/webparts/requestPortal/forms/BusinessTrip/README.md index 6179ae7..0af6c2b 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/README.md +++ b/src/webparts/requestPortal/forms/BusinessTrip/README.md @@ -38,13 +38,20 @@ `TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период). - **Задание** — нумерованный список задач, хранится в `TripTasksJson` (+ читаемая копия `TripTasks` для представлений SP). Пустые строки не сохраняются. -- **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**: форма - ничего не подставляет (решение Тимура — корректно подставлять нечего; про МРП забыть). - - Статьи: основные Суточные / Проезд / Проживание (переключатель «Нужно / Не требуется», +- **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**, кроме + суточных: + - **Суточные считаются сами** (с 10-09, по просьбе кадров): по позиции на каждую точку + маршрута (`syncPerDiem`, `itemsFromRoute` в `ESTIMATE_KINDS`), город и проект — из точки, + дни — из её дат включительно, но день переезда считается один раз (`stopPerDiemDays`). + Человек вводит только кол-во МРП в день; ставка = кол-во × `MRP_VALUE` (захардкожен в + model.ts вместе с `MRP_YEAR` — обновлять раз в год). Ставка сохраняется в заявке (`price`), + поэтому просмотр старой заявки не пересчитывается по новому МРП — `syncPerDiem` применяется + только в форме. + - Статьи: основные Суточные / Проезд / Проживание (переключатель «Требуется / Не требуется», выключенная печатается «не требуется»), Страховка и Виза (по одной) и «Другой расход» со своим названием (сколько угодно) — кнопками «+», удаляются корзиной. - - **Внутри статьи — позиции** («+ Ещё гостиница», «+ Ещё участок пути», «+ Ещё ставка - суточных»). Позиция привязана к точке маршрута (суточные, проживание, другой расход) или + - **Внутри статьи — позиции** («+ Ещё гостиница», «+ Ещё участок пути»; у суточных + позиции только из маршрута). Позиция привязана к точке маршрута (суточные, проживание, другой расход) или к участку пути (проезд); страховка/виза — без привязки. Вариант один — выбор скрыт и подставляется сам (`resolveLinkId`); точку удалили — позицию нужно привязать заново. - Сумма позиции — «Суммой» или «Кол-во × цена» (только перемножение введённого), подписи @@ -54,7 +61,7 @@ если объектов несколько, **итог по объектам** (`projectShares`; участок — к объекту пункта назначения, обратный — к объекту, откуда уезжают; страховка/виза — «без объекта»). - Смета сама не обязательна, но у включённой статьи каждая позиция — с суммой и - привязкой, у «Другого расхода» — название. + привязкой, у «Другого расхода» — название, у суточных — кол-во МРП. - Хранение: `TripEstimateJson` (статьи + total), читаемо — `TripEstimate` (позиция на строку), итог числом — `TripEstimateTotal`. Просмотр/печать — таблица по статьям. - **Обязательство** по авансовому отчёту (текст бланка) — флажок, без него не отправить. diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateArticleCard.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateArticleCard.tsx index 3b0dbf7..3c06072 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateArticleCard.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateArticleCard.tsx @@ -16,7 +16,7 @@ interface Props { /** * Статья сметы карточкой с позициями внутри: несколько гостиниц, участков пути, - * ставок суточных. Основная статья включается переключателем «нужно» — + * ставок суточных. Основная статья включается переключателем «требуется» — * выключенная сворачивается в серую строку «не требуется». Добавленная * (страховка, виза, другой расход) всегда включена и удаляется корзиной. */ @@ -24,6 +24,8 @@ export const EstimateArticleCard: React.FC = ({ article, route, onChange, const info = ESTIMATE_KINDS[article.kind]; const amount = articleAmount(article); const cardClass = article.enabled ? styles.card : `${styles.card} ${styles.off}`; + // Позиции суточных строятся по точкам маршрута — вручную не добавляются и не удаляются. + const manualItems = !info.itemsFromRoute; const updateItem = (id: string, patch: Partial): void => onChange({ ...article, items: article.items.map((item) => (item.id === id ? { ...item, ...patch } : item)) }); @@ -53,7 +55,7 @@ export const EstimateArticleCard: React.FC = ({ article, route, onChange, onChange({ ...article, enabled })} - onLabel={article.enabled ? 'Нужно' : 'Не требуется'} + onLabel={article.enabled ? 'Требуется' : 'Не требуется'} /> ) : ( + {manualItems && ( + + )} )} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx index 104c89d..e371d2a 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx @@ -6,6 +6,7 @@ import { ESTIMATE_KINDS, EstimateKind, IEstimateItem, ITripRoute } from '../../m import { linkOptions, resolveLinkId } from '../../estimate'; import { fieldKey } from '../../validation'; import { AmountFields } from './AmountFields'; +import { PerDiemFields } from './PerDiemFields'; interface Props { kind: EstimateKind; @@ -19,12 +20,13 @@ interface Props { /** * Позиция статьи: к чему относится (точка маршрута / участок пути), сумма и * пояснение. Выбор «к чему относится» показываем, только когда вариантов - * больше одного — в простой поездке в одну точку выбирать нечего. + * больше одного — в простой поездке в одну точку выбирать нечего. Суточные + * привязаны к точке раз и навсегда (строятся по маршруту) — у них свои поля. */ export const EstimateItemRow: React.FC = ({ 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 = options.length > 1; + const showLink = !info.itemsFromRoute && options.length > 1; const linkLabel = info.link === 'leg' ? 'Участок пути' : 'Точка маршрута'; return ( @@ -51,7 +53,11 @@ export const EstimateItemRow: React.FC = ({ kind, item, route, onChange, )} )} - + {info.itemsFromRoute ? ( + s.id === item.linkId)} onChange={onChange} /> + ) : ( + + )} onChange({ comment: v })} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateTable.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateTable.tsx index 220bb90..234503a 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateTable.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateTable.tsx @@ -46,7 +46,7 @@ export const EstimateTable: React.FC = ({ articles, route }) => (
{itemLinkLabel(article.kind, item, route) || '—'}
{item.comment.trim() &&
{item.comment.trim()}
} - {itemCalculation(item) || '—'} + {itemCalculation(article.kind, item) || '—'} {formatAmount(itemAmount(item))} ))} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.module.scss new file mode 100644 index 0000000..43dd2e7 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.module.scss @@ -0,0 +1,117 @@ +@import '../../../../../../styles/design-tokens'; + +// С этой ширины «дни × МРП × значение = итог» помещается в одну строку. +$calc-inline-breakpoint: 640px; + +.wrap { + display: flex; + flex-direction: column; + gap: $sp-3; +} + +// Город и проект точки — подзаголовок позиции. +.place { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: $sp-1 $sp-3; + font-family: $font-sans; +} + +.city { + font-size: $fs-body; + font-weight: 600; + color: $navy-900; +} + +.project { + font-size: $fs-sm; + color: $gray-500; +} + +// Числовое поле — как в AmountFields: цифры справа, моноширинные. +.number input { + text-align: right; + font-size: $fs-body; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +// Значение, которое считает форма (дни, МРП): выглядит как поле, но не редактируется. +.readonly { + // Отступы и рамка — как у поля ввода ui-kit (padding 11px 13px, border 1px): + // тогда высота совпадает с «Кол-во МРП» и подписи над полями стоят на одной линии. + box-sizing: border-box; + padding: 11px 13px; + border: 1px solid transparent; + border-radius: $r-sm; + line-height: normal; + background: $gray-100; + color: $navy-900; + font-family: $font-sans; + font-size: $fs-body; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +} + +// Телефон: поля друг под другом, знаки операций прячем. +.calcRow { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: $sp-3; +} + +.sign { + display: none; +} + +.result { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: $sp-2; +} + +.resultLabel { + font-family: $font-sans; + font-size: $fs-label; + color: $gray-500; +} + +.resultValue { + font-family: $font-sans; + font-size: $fs-body; + font-weight: 700; + color: $navy-900; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.hint { + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-500; +} + +@media (min-width: $calc-inline-breakpoint) { + .calcRow { + grid-template-columns: + minmax(0, 0.6fr) auto minmax(0, 0.8fr) auto minmax(0, 0.8fr) auto minmax(120px, auto); + align-items: end; + } + + .sign { + display: block; + padding-bottom: 10px; + color: $gray-400; + font-size: $fs-body; + } + + .result { + flex-direction: column; + align-items: flex-end; + padding-bottom: 8px; + } +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.tsx new file mode 100644 index 0000000..73a3715 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import styles from './PerDiemFields.module.scss'; +import { FormField, MoneyInput } from '../../../../shared/ui'; +import { IEstimateItem, IRouteStop, MRP_VALUE, MRP_YEAR } from '../../model'; +import { formatAmount, itemAmount } from '../../estimate'; +import { fieldKey } from '../../validation'; + +interface Props { + item: IEstimateItem; + /** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncPerDiem). */ + stop: IRouteStop | undefined; + onChange: (patch: Partial) => void; +} + +/** + * Суточные одной точки маршрута: город и проект — из точки, дни — из её дат, + * человек вводит только количество МРП в день. Итог = дни × МРП × MRP_VALUE. + */ +export const PerDiemFields: React.FC = ({ item, stop, onChange }) => { + const hasDays = item.quantity !== ''; + const project = stop?.project.trim(); + + return ( +
+
+ {stop?.city.trim() || 'Точка маршрута'} + Проект: {project || 'не выбран'} +
+ +
+ +
+ {hasDays ? item.quantity : '—'} +
+
+ + × + + +
+ onChange({ mrpCount: v })} /> +
+
+ + × + + +
{formatAmount(MRP_VALUE)} ₸
+
+ + = + +
+ Итого + {formatAmount(itemAmount(item))} ₸ +
+
+ + {!hasDays &&
Укажите даты точки в маршруте — по ним считаются дни.
} +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts b/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts index 118b784..c576a8c 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts @@ -1,4 +1,4 @@ -import { EMPTY_ESTIMATE, EMPTY_ROUTE, IEstimateArticle, IEstimateItem, ITripRoute } from './model'; +import { EMPTY_ESTIMATE, EMPTY_ROUTE, IEstimateArticle, IEstimateItem, ITripRoute, MRP_VALUE } from './model'; import { addItem, articleAmount, @@ -6,11 +6,13 @@ import { estimateShares, estimateTotal, formatEstimateText, + itemCalculation, parseEstimate, projectShares, resolveLinkId, serializeEstimate, - stopTotals + stopTotals, + syncPerDiem } from './estimate'; const ROUTE: ITripRoute = { @@ -31,6 +33,7 @@ const item = (patch: Partial): IEstimateItem => ({ quantity: '', price: '', comment: '', + mrpCount: '', ...patch }); @@ -108,6 +111,56 @@ describe('привязка позиций', () => { }); }); +describe('суточные по маршруту', () => { + const perDiem: IEstimateArticle = { + id: 'perDiem', + kind: 'perDiem', + title: '', + enabled: true, + items: [item({ id: 'p1', linkId: 's1', mrpCount: '6', comment: 'объект' })] + }; + + it('по позиции на точку; день переезда считается один раз', () => { + const [synced] = syncPerDiem([perDiem], ROUTE); + expect(synced.items.map((i) => [i.linkId, i.quantity])).toEqual([ + ['s1', '4'], + ['s2', '2'] + ]); + }); + + it('ставка — кол-во МРП × МРП; введённое за точкой сохраняется', () => { + 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] = syncPerDiem([perDiem], ROUTE); + expect(synced.items[1]).toMatchObject({ id: 'perDiem-s2', mrpCount: '', price: '' }); + 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] = syncPerDiem([perDiem], noDates); + expect(synced.items[0].quantity).toBe(''); + expect(articleAmount(synced)).toBe(0); + }); + + it('другие статьи не трогает', () => { + expect(syncPerDiem([hotels], ROUTE)[0]).toBe(hotels); + }); + + it('расчёт в тексте — с МРП, по сохранённой ставке', () => { + const NBSP = String.fromCharCode(160); + 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(' ')} ₸` + ); + }); +}); + describe('текст для SP и хранение', () => { it('позиция на строку, с привязкой и расчётом', () => { // Intl ru-RU разделяет разряды неразрывным пробелом (код 160) — приводим к обычному для сравнения. diff --git a/src/webparts/requestPortal/forms/BusinessTrip/estimate.ts b/src/webparts/requestPortal/forms/BusinessTrip/estimate.ts index 58728f5..f40e32c 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/estimate.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/estimate.ts @@ -6,9 +6,11 @@ import { EstimateKind, IEstimateArticle, IEstimateItem, - ITripRoute + ITripRoute, + MRP_VALUE } from './model'; -import { legLabel, newId, routeLegs, stopLabel } from './route'; +import { legLabel, newId, routeLegs, stopLabel, stopPerDiemDays } from './route'; +import { plural } from './tripData'; /** Цифры из поля ввода числом: '' или мусор — 0. MoneyInput хранит только цифры. */ function toNumber(raw: string): number { @@ -42,12 +44,29 @@ export function formatAmount(value: number): string { return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(value); } +/** + * Значение МРП, по которому посчитана позиция суточных: ставка в день / кол-во МРП. + * Берём из сохранённой ставки, а не из MRP_VALUE: у старой заявки МРП прошлого года. + */ +function itemMrpValue(item: IEstimateItem): number { + const count = toNumber(item.mrpCount); + return count > 0 ? toNumber(item.price) / count : 0; +} + +/** Расчёт суточных: «4 дня × 6 МРП × 4 325 ₸»; дни или МРП не заданы — ''. */ +function perDiemCalculation(item: IEstimateItem): string { + if (!item.quantity || !toNumber(item.mrpCount)) return ''; + const days = plural(toNumber(item.quantity), ['день', 'дня', 'дней']); + return `${days} × ${item.mrpCount} МРП × ${formatAmount(itemMrpValue(item))} ₸`; +} + /** * Расчёт для печати и колонки SP: «3 × 36 000 ₸»; при вводе итогом — ''. Без * единиц: склонять «ночь/ночи/ночей» ради одной строки не стоит, а что считали — - * понятно из статьи. + * понятно из статьи. Суточные — исключение: там важно показать МРП. */ -export function itemCalculation(item: IEstimateItem): string { +export function itemCalculation(kind: EstimateKind, item: IEstimateItem): string { + if (ESTIMATE_KINDS[kind].itemsFromRoute) return perDiemCalculation(item); if (item.mode !== 'calc' || !item.quantity || !item.price) return ''; return `${formatAmount(toNumber(item.quantity))} × ${formatAmount(toNumber(item.price))} ₸`; } @@ -96,7 +115,8 @@ export function createItem(kind: EstimateKind, linkId: string): IEstimateItem { amount: '', quantity: '', price: '', - comment: '' + comment: '', + mrpCount: '' }; } @@ -111,6 +131,42 @@ export function createArticle(kind: EstimateKind, route: ITripRoute): IEstimateA return addItem(empty, route); } +// --------------------------------------------------------------------------- +// Суточные — строятся по маршруту +// --------------------------------------------------------------------------- + +/** Ставка суточных в день цифрами: кол-во МРП × МРП. Кол-во не введено — ''. */ +function perDiemRate(mrpCount: string): string { + const count = toNumber(mrpCount); + return count > 0 ? String(count * MRP_VALUE) : ''; +} + +/** + * Позиции статей «по маршруту» (суточные) приводятся к маршруту: ровно одна + * позиция на точку, дни — из дат точки, ставка — кол-во МРП × MRP_VALUE. + * Человек вводит только кол-во МРП (и пояснение) — они сохраняются за точкой. + * Применяется в форме при каждом рендере и перед сохранением; просмотр берёт + * сохранённое как есть — чтобы не пересчитать старую заявку по новому МРП. + */ +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, 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}` }; + const days = stopPerDiemDays(route, index); + return { + ...base, + mode: 'calc', + quantity: days === undefined ? '' : String(days), + price: perDiemRate(base.mrpCount) + }; + }); + return { ...article, items }; + }); +} + // --------------------------------------------------------------------------- // Разрезы итога // --------------------------------------------------------------------------- @@ -218,7 +274,7 @@ export function formatEstimateText(articles: IEstimateArticle[], route: ITripRou .forEach((item) => { const where = itemLinkLabel(article.kind, item, route); const title = where ? `${articleTitle(article)} (${where})` : articleTitle(article); - const calc = itemCalculation(item); + const calc = itemCalculation(article.kind, item); const amount = `${formatAmount(itemAmount(item))} ₸`; lines.push(calc ? `${title}: ${calc} = ${amount}` : `${title}: ${amount}`); }) @@ -257,7 +313,8 @@ function toItem(value: unknown): IEstimateItem | undefined { amount: text(raw.amount), quantity: text(raw.quantity), price: text(raw.price), - comment: text(raw.comment) + comment: text(raw.comment), + mrpCount: text(raw.mrpCount) }; } diff --git a/src/webparts/requestPortal/forms/BusinessTrip/model.ts b/src/webparts/requestPortal/forms/BusinessTrip/model.ts index 8ecfc6f..6f94a9f 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/model.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/model.ts @@ -98,6 +98,12 @@ export interface IEstimateItem { price: string; /** Гостиница, номер рейса, пояснение — необязательно. */ comment: string; + /** + * Только у суточных: количество МРП в день (цифры). Из него и MRP_VALUE + * считается ставка (price), дни (quantity) — из дат точки маршрута, см. + * estimate.ts/syncPerDiem. У остальных статей — ''. + */ + mrpCount: string; } /** Статья сметы — заголовок с позициями внутри. */ @@ -108,7 +114,7 @@ export interface IEstimateArticle { /** Название — только у «Другого расхода»; у остальных подпись из ESTIMATE_KINDS. */ title: string; /** - * «Нужно» — переключатель основных статей (суточные/проезд/проживание). + * «Требуется» — переключатель основных статей (суточные/проезд/проживание). * Добавленные статьи всегда true: ненужную такую статью просто удаляют. */ enabled: boolean; @@ -117,11 +123,16 @@ export interface IEstimateArticle { export interface IEstimateKindInfo { label: string; - /** true — основная статья (переключатель «нужно»); false — добавляется «+» и удаляется. */ + /** true — основная статья (переключатель «требуется»); false — добавляется «+» и удаляется. */ core: boolean; /** Способ ввода по умолчанию — тот, которым такую сумму обычно считают. */ defaultMode: AmountMode; link: EstimateLinkTarget; + /** + * true — позиции не добавляются вручную, а строятся по точкам маршрута (по + * одной на точку) и считаются сами — так устроены суточные. + */ + itemsFromRoute: boolean; quantityLabel: string; priceLabel: string; commentPlaceholder: string; @@ -135,6 +146,7 @@ export const ESTIMATE_KINDS: Record = { core: true, defaultMode: 'calc', link: 'stop', + itemsFromRoute: true, quantityLabel: 'Дней', priceLabel: 'Ставка в день', commentPlaceholder: 'Пояснение (необязательно)', @@ -145,6 +157,7 @@ export const ESTIMATE_KINDS: Record = { core: true, defaultMode: 'total', link: 'leg', + itemsFromRoute: false, quantityLabel: 'Билетов', priceLabel: 'Цена билета', commentPlaceholder: 'Рейс, поезд, пояснение (необязательно)', @@ -155,6 +168,7 @@ export const ESTIMATE_KINDS: Record = { core: true, defaultMode: 'calc', link: 'stop', + itemsFromRoute: false, quantityLabel: 'Ночей', priceLabel: 'Цена за ночь', commentPlaceholder: 'Гостиница, адрес (необязательно)', @@ -165,6 +179,7 @@ export const ESTIMATE_KINDS: Record = { core: false, defaultMode: 'total', link: 'none', + itemsFromRoute: false, quantityLabel: 'Полисов', priceLabel: 'Цена полиса', commentPlaceholder: 'Страховая компания, полис (необязательно)', @@ -175,6 +190,7 @@ export const ESTIMATE_KINDS: Record = { core: false, defaultMode: 'total', link: 'none', + itemsFromRoute: false, quantityLabel: 'Виз', priceLabel: 'Сбор за визу', commentPlaceholder: 'Страна, тип визы (необязательно)', @@ -185,6 +201,7 @@ export const ESTIMATE_KINDS: Record = { core: false, defaultMode: 'total', link: 'stop', + itemsFromRoute: false, quantityLabel: 'Количество', priceLabel: 'Цена', commentPlaceholder: 'Пояснение (необязательно)', @@ -202,6 +219,15 @@ export const ESTIMATE_KIND_ORDER: ReadonlyArray = [ 'other' ]; +/** + * Месячный расчётный показатель (МРП, Казахстан) — суточные считаются в МРП. + * Пока захардкожен (решение Тимура 2026-10-09); обновлять раз в год вместе с + * MRP_YEAR. Уже отправленные заявки не пересчитываются: ставка, по которой их + * посчитали, сохранена в самой заявке (IEstimateItem.price). + */ +export const MRP_VALUE = 4325; +export const MRP_YEAR = 2026; + /** Статьи, которые добавляются кнопкой не больше одного раза (другой расход — сколько угодно). */ export const SINGLE_OPTIONAL_KINDS: ReadonlyArray = ['insurance', 'visa']; @@ -276,7 +302,8 @@ function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem { amount: '', quantity: '', price: '', - comment: '' + comment: '', + mrpCount: '' }; } diff --git a/src/webparts/requestPortal/forms/BusinessTrip/route.ts b/src/webparts/requestPortal/forms/BusinessTrip/route.ts index ce6cf4e..62eb376 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/route.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/route.ts @@ -64,6 +64,21 @@ export function stopNights(stop: IRouteStop): number | undefined { return days === undefined ? undefined : days - 1; } +/** + * Дней суточных в точке (по индексу): даты включительно, но день переезда + * считаем один раз. Атырау 05–08.10, Актау 08–10.10: Атырау — 4 дня, Актау — 2 + * (08.10 уже учтён в Атырау), всего 6 — как весь период 05–10.10. + * Пустые/перепутанные даты — undefined. + */ +export function stopPerDiemDays(route: ITripRoute, index: number): number | undefined { + const stop = route.stops[index]; + const days = stop ? tripDays(stop.dateFrom, stop.dateTo) : undefined; + if (days === undefined) return undefined; + const previous = route.stops[index - 1]; + const transferDayCounted = !!previous && previous.dateTo === stop.dateFrom; + return transferDayCounted ? days - 1 : days; +} + /** Период всей поездки: прибытие в первую точку — отъезд из последней. */ export function routePeriod(route: ITripRoute): { start: string; end: string } { const starts = route.stops.map((s) => s.dateFrom).filter(Boolean).sort(); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts b/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts index f670b47..9413686 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts @@ -67,9 +67,17 @@ describe('validation: смета', () => { const [perDiem, travel, accommodation] = EMPTY_DATA.Estimate; it('включённая статья без суммы — подсвечивается пустое поле', () => { - const filledQty = { ...perDiem, items: [{ ...perDiem.items[0], linkId: 's1', quantity: '5' }] }; - const data = { ...FILLED, Estimate: [filledQty, off(travel), off(accommodation)] }; - expect(fields(submitIssues(data))).toEqual([`item-${perDiem.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(submitIssues({ ...FILLED, Estimate: estimate(noMrp) }))).toEqual([`item-${perDiem.items[0].id}-mrp`]); + expect(submitIssues({ ...FILLED, Estimate: estimate(withMrp) })).toEqual([]); }); it('при нескольких вариантах позицию нужно привязать', () => { diff --git a/src/webparts/requestPortal/forms/BusinessTrip/validation.ts b/src/webparts/requestPortal/forms/BusinessTrip/validation.ts index e5d5012..574c1aa 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/validation.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/validation.ts @@ -19,7 +19,8 @@ export const fieldKey = { itemLink: (item: IEstimateItem): string => `item-${item.id}-link`, itemAmount: (item: IEstimateItem): string => `item-${item.id}-amount`, itemQuantity: (item: IEstimateItem): string => `item-${item.id}-quantity`, - itemPrice: (item: IEstimateItem): string => `item-${item.id}-price` + itemPrice: (item: IEstimateItem): string => `item-${item.id}-price`, + itemMrpCount: (item: IEstimateItem): string => `item-${item.id}-mrp` }; /** «Точка 2 (Актау)» — как точку видно в форме. */ @@ -91,6 +92,14 @@ 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) { + if (!Number(item.mrpCount)) { + issues.push({ field: fieldKey.itemMrpCount(item), label, message: 'укажите количество МРП' }); + } + return; + } if (needsLink && !resolveLinkId(article.kind, item.linkId, route)) { issues.push({ field: fieldKey.itemLink(item), label, message: 'выберите, к чему относится расход' }); }