From d07056937aa81999fb9fd9fb7c1f6e958ad1860c 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, 23 Oct 2026 15:12:50 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=D1=8B=20?= =?UTF-8?q?=D1=81=D1=83=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D1=85=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B6=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F,?= =?UTF-8?q?=20=D0=BD=D0=BE=D1=87=D0=B8=20=D1=81=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F=20=D1=81=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Суточные — не больше 6 МРП в день по Казахстану и 8 за рубежом (по признаку точки). Проживание теперь строится по точкам маршрута, как суточные: ночи из дат точки, человек вводит только цену за ночь — не больше 15 000 ₸ по Казахстану; лимит для заграницы — константа, пока без значения. Превышение видно сразу под полем и блокирует отправку. syncPerDiem → syncRouteItems. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../forms/BusinessTrip/BusinessTripForm.tsx | 6 +- .../forms/BusinessTrip/README.md | 18 ++++-- .../estimate/AccommodationFields.tsx | 62 +++++++++++++++++++ .../components/estimate/EstimateItemRow.tsx | 12 ++-- .../components/estimate/PerDiemFields.tsx | 18 ++++-- ...module.scss => StopItemFields.module.scss} | 15 +++++ .../forms/BusinessTrip/estimate.test.ts | 46 +++++++++++--- .../forms/BusinessTrip/estimate.ts | 48 +++++++++----- .../requestPortal/forms/BusinessTrip/model.ts | 19 +++++- .../forms/BusinessTrip/validation.test.ts | 23 ++++++- .../forms/BusinessTrip/validation.ts | 40 +++++++++--- 11 files changed, 250 insertions(+), 57 deletions(-) create mode 100644 src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AccommodationFields.tsx rename src/webparts/requestPortal/forms/BusinessTrip/components/estimate/{PerDiemFields.module.scss => StopItemFields.module.scss} (79%) diff --git a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx index 7f61a75..7a7eac9 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx @@ -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 { syncPerDiem } from './estimate'; +import { syncRouteItems } from './estimate'; import { saveBusinessTripRequest, businessTripApprovalService } from './submit'; import { loadBusinessTripRequest } from './load'; import { useTripEmployee } from './useTripEmployee'; @@ -86,9 +86,9 @@ const BusinessTripForm: React.FC = ({ const readOnly = !isEditableStatus(requestStatus); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); - // Суточные строятся по маршруту (см. syncPerDiem): секции, проверки и сохранение + // Суточные и проживание строятся по маршруту (см. syncRouteItems): секции, проверки и сохранение // работают со сметой, уже приведённой к текущим точкам и датам. - const formData: IFormData = { ...data, Estimate: syncPerDiem(data.Estimate, data.Route) }; + const formData: IFormData = { ...data, Estimate: syncRouteItems(data.Estimate, data.Route) }; const employee = useTripEmployee(context, editId, data.Employee, setData); const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/README.md b/src/webparts/requestPortal/forms/BusinessTrip/README.md index bc2e29f..0b97f40 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/README.md +++ b/src/webparts/requestPortal/forms/BusinessTrip/README.md @@ -69,18 +69,24 @@ - **Задание** — нумерованный список задач, хранится в `TripTasksJson` (+ читаемая копия `TripTasks` для представлений SP). Пустые строки не сохраняются. - **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**, кроме - суточных: - - **Суточные считаются сами** (с 10-09, по просьбе кадров): по позиции на каждую точку - маршрута (`syncPerDiem`, `itemsFromRoute` в `ESTIMATE_KINDS`), город и проект — из точки, + суточных и проживания — они строятся по точкам маршрута (`syncRouteItems`, `itemsFromRoute` + в `ESTIMATE_KINDS`), по позиции на точку: + - **Суточные считаются сами** (с 10-09, по просьбе кадров): город и проект — из точки, дни — из её дат включительно, но день переезда считается один раз (`stopPerDiemDays`). Человек вводит только кол-во МРП в день; ставка = кол-во × `MRP_VALUE` (захардкожен в model.ts вместе с `MRP_YEAR` — обновлять раз в год). Ставка сохраняется в заявке (`price`), - поэтому просмотр старой заявки не пересчитывается по новому МРП — `syncPerDiem` применяется - только в форме. + поэтому просмотр старой заявки не пересчитывается по новому МРП — `syncRouteItems` + применяется только в форме. **Лимит** (с 10-23): не больше 6 МРП в день по Казахстану и 8 за + рубежом (`MAX_MRP_PER_DAY`, по признаку точки) — подпись у поля, ошибка сразу под строкой + и при отправке. + - **Проживание** (с 10-23): ночи — из дат точки (`stopNights`), человек вводит только цену за + ночь. Цена необязательна (в точке могут жить на объекте). **Лимит** — 15 000 ₸ за ночь по + Казахстану; за рубежом `MAX_NIGHT_PRICE.abroad` пока `undefined` (без лимита) — вписать, + когда кадры назовут сумму. Кнопки «+ Ещё гостиница» и ввода «суммой» у проживания больше нет. - Статьи: основные Суточные / Проезд / Проживание (переключатель «Требуется / Не требуется», выключенная печатается «не требуется»), Страховка и Виза (по одной) и «Другой расход» со своим названием (сколько угодно) — кнопками «+», удаляются корзиной. - - **Внутри статьи — позиции** («+ Ещё гостиница», «+ Ещё участок пути»; у суточных + - **Внутри статьи — позиции** («+ Ещё участок пути»; у суточных и проживания позиции только из маршрута). Позиция привязана к точке маршрута (суточные, проживание, другой расход) или к участку пути (проезд); страховка/виза — без привязки. Вариант один — выбор скрыт и подставляется сам (`resolveLinkId`); точку удалили — позицию нужно привязать заново. diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AccommodationFields.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AccommodationFields.tsx new file mode 100644 index 0000000..f6046c6 --- /dev/null +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/AccommodationFields.tsx @@ -0,0 +1,62 @@ +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 { fieldKey } from '../../validation'; + +interface Props { + item: IEstimateItem; + /** Точка маршрута позиции; проживание строится по точкам (см. syncRouteItems). */ + stop: IRouteStop | undefined; + onChange: (patch: Partial) => void; +} + +/** + * Проживание в одной точке маршрута: ночи — из дат точки, человек вводит только + * цену за ночь (не выше лимита — см. nightPriceLimit). Цену можно не вводить — + * в точке могут жить на объекте, тогда позиция просто 0. + */ +export const AccommodationFields: React.FC = ({ item, stop, onChange }) => { + const hasNights = item.quantity !== ''; + const limit = nightPriceLimit(stop); + const isOverLimit = limit !== undefined && Number(item.price) > limit; + const priceLabel = limit === undefined ? 'Цена за ночь' : `Цена за ночь (до ${formatAmount(limit)} ₸)`; + + return ( +
+
+ {(stop && stopCityLabel(stop)) || 'Точка маршрута'} +
+ +
+ +
+ {hasNights ? item.quantity : '—'} +
+
+ + × + + +
+ onChange({ price: v })} /> +
+
+ + = + +
+ Итого + {formatAmount(itemAmount(item))} ₸ +
+
+ + {isOverLimit && limit !== undefined && ( +
Не больше {formatAmount(limit)} ₸ за ночь по Казахстану.
+ )} + {!hasNights &&
Укажите даты точки в маршруте — по ним считаются ночи.
} +
+ ); +}; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx index e371d2a..03d39df 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/EstimateItemRow.tsx @@ -7,6 +7,7 @@ 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; @@ -21,13 +22,14 @@ 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 = !info.itemsFromRoute && options.length > 1; const linkLabel = info.link === 'leg' ? 'Участок пути' : 'Точка маршрута'; + const stop = route.stops.find((s) => s.id === item.linkId); return (
@@ -53,11 +55,9 @@ export const EstimateItemRow: React.FC = ({ kind, item, route, onChange, )}
)} - {info.itemsFromRoute ? ( - s.id === item.linkId)} onChange={onChange} /> - ) : ( - - )} + {kind === 'perDiem' && } + {kind === 'accommodation' && } + {!info.itemsFromRoute && } onChange({ comment: v })} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.tsx b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.tsx index 76bda01..f74ce45 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.tsx @@ -1,25 +1,28 @@ import * as React from 'react'; -import styles from './PerDiemFields.module.scss'; +import styles from './StopItemFields.module.scss'; import { FormField, MoneyInput } from '../../../../shared/ui'; import { IEstimateItem, IRouteStop, MRP_VALUE, MRP_YEAR } from '../../model'; -import { formatAmount, itemAmount } from '../../estimate'; +import { formatAmount, itemAmount, mrpLimit } from '../../estimate'; import { stopCityLabel } from '../../route'; import { fieldKey } from '../../validation'; interface Props { item: IEstimateItem; - /** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncPerDiem). */ + /** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncRouteItems). */ stop: IRouteStop | undefined; onChange: (patch: Partial) => void; } /** * Суточные одной точки маршрута: город и проект — из точки, дни — из её дат, - * человек вводит только количество МРП в день. Итог = дни × МРП × MRP_VALUE. + * человек вводит только количество МРП в день (не больше лимита — см. mrpLimit). + * Итог = дни × МРП × MRP_VALUE. */ export const PerDiemFields: React.FC = ({ item, stop, onChange }) => { const hasDays = item.quantity !== ''; const project = stop?.project.trim(); + const limit = mrpLimit(stop); + const isOverLimit = Number(item.mrpCount) > limit; return (
@@ -37,7 +40,7 @@ export const PerDiemFields: React.FC = ({ item, stop, onChange }) => { × - +
onChange({ mrpCount: v })} />
@@ -57,6 +60,11 @@ export const PerDiemFields: React.FC = ({ item, stop, onChange }) => {
+ {isOverLimit && ( +
+ Не больше {limit} МРП в день {stop?.abroad ? 'за рубежом' : 'по Казахстану'}. +
+ )} {!hasDays &&
Укажите даты точки в маршруте — по ним считаются дни.
} ); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.module.scss b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/StopItemFields.module.scss similarity index 79% rename from src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.module.scss rename to src/webparts/requestPortal/forms/BusinessTrip/components/estimate/StopItemFields.module.scss index 43dd2e7..e5f5313 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/PerDiemFields.module.scss +++ b/src/webparts/requestPortal/forms/BusinessTrip/components/estimate/StopItemFields.module.scss @@ -1,5 +1,6 @@ @import '../../../../../../styles/design-tokens'; +// Общие стили позиций «по точке маршрута»: суточные (PerDiemFields) и проживание (AccommodationFields). // С этой ширины «дни × МРП × значение = итог» помещается в одну строку. $calc-inline-breakpoint: 640px; @@ -115,3 +116,17 @@ $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); + } +} diff --git a/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts b/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts index eb46caf..987f318 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/estimate.test.ts @@ -1,4 +1,13 @@ -import { EMPTY_ESTIMATE, EMPTY_ROUTE, IEstimateArticle, IEstimateItem, ITripRoute, MRP_VALUE } from './model'; +import { + EMPTY_ESTIMATE, + EMPTY_ROUTE, + IEstimateArticle, + IEstimateItem, + ITripRoute, + MAX_MRP_PER_DAY, + MAX_NIGHT_PRICE, + MRP_VALUE +} from './model'; import { addItem, articleAmount, @@ -7,12 +16,14 @@ import { estimateTotal, formatEstimateText, itemCalculation, + mrpLimit, + nightPriceLimit, parseEstimate, projectShares, resolveLinkId, serializeEstimate, stopTotals, - syncPerDiem + syncRouteItems } from './estimate'; const ROUTE: ITripRoute = { @@ -123,7 +134,7 @@ describe('суточные по маршруту', () => { }; it('по позиции на точку; день переезда считается один раз', () => { - const [synced] = syncPerDiem([perDiem], ROUTE); + const [synced] = syncRouteItems([perDiem], ROUTE); expect(synced.items.map((i) => [i.linkId, i.quantity])).toEqual([ ['s1', '4'], ['s2', '2'] @@ -131,32 +142,47 @@ describe('суточные по маршруту', () => { }); it('ставка — кол-во МРП × МРП; введённое за точкой сохраняется', () => { - const [synced] = syncPerDiem([perDiem], ROUTE); + const [synced] = syncRouteItems([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); + const [synced] = syncRouteItems([perDiem], ROUTE); expect(synced.items[1]).toMatchObject({ id: 'perDiem-s2', mrpCount: '', price: '' }); - const [oneStop] = syncPerDiem([perDiem], { ...ROUTE, stops: [ROUTE.stops[1]] }); + const [oneStop] = syncRouteItems([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); + const [synced] = syncRouteItems([perDiem], noDates); expect(synced.items[0].quantity).toBe(''); expect(articleAmount(synced)).toBe(0); }); - it('другие статьи не трогает', () => { - expect(syncPerDiem([hotels], ROUTE)[0]).toBe(hotels); + 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 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('расчёт в тексте — с МРП, по сохранённой ставке', () => { const NBSP = String.fromCharCode(160); - const [synced] = syncPerDiem([perDiem], ROUTE); + const [synced] = syncRouteItems([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(' ')} ₸` ); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/estimate.ts b/src/webparts/requestPortal/forms/BusinessTrip/estimate.ts index f40e32c..a0326ba 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/estimate.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/estimate.ts @@ -6,10 +6,13 @@ import { EstimateKind, IEstimateArticle, IEstimateItem, + IRouteStop, ITripRoute, + MAX_MRP_PER_DAY, + MAX_NIGHT_PRICE, MRP_VALUE } from './model'; -import { legLabel, newId, routeLegs, stopLabel, stopPerDiemDays } from './route'; +import { legLabel, newId, routeLegs, stopLabel, stopNights, stopPerDiemDays } from './route'; import { plural } from './tripData'; /** Цифры из поля ввода числом: '' или мусор — 0. MoneyInput хранит только цифры. */ @@ -66,7 +69,7 @@ function perDiemCalculation(item: IEstimateItem): string { * понятно из статьи. Суточные — исключение: там важно показать МРП. */ export function itemCalculation(kind: EstimateKind, item: IEstimateItem): string { - if (ESTIMATE_KINDS[kind].itemsFromRoute) return perDiemCalculation(item); + if (kind === 'perDiem') return perDiemCalculation(item); if (item.mode !== 'calc' || !item.quantity || !item.price) return ''; return `${formatAmount(toNumber(item.quantity))} × ${formatAmount(toNumber(item.price))} ₸`; } @@ -132,9 +135,22 @@ 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); @@ -142,26 +158,30 @@ function perDiemRate(mrpCount: string): string { } /** - * Позиции статей «по маршруту» (суточные) приводятся к маршруту: ровно одна - * позиция на точку, дни — из дат точки, ставка — кол-во МРП × MRP_VALUE. - * Человек вводит только кол-во МРП (и пояснение) — они сохраняются за точкой. + * Количество и цена позиции «по маршруту»: суточные — дни точки (день переезда + * один раз) × кол-во МРП × MRP_VALUE; проживание — ночи точки × цена за ночь, + * которую ввёл человек. + */ +function routeItemFigures(kind: EstimateKind, base: IEstimateItem, route: ITripRoute, index: number): Partial { + if (kind === 'accommodation') return { quantity: countText(stopNights(route.stops[index])), price: base.price }; + return { quantity: countText(stopPerDiemDays(route, index)), price: perDiemRate(base.mrpCount) }; +} + +/** + * Позиции статей «по маршруту» (суточные, проживание) приводятся к маршруту: + * ровно одна позиция на точку, дни/ночи — из дат точки. Человек вводит только + * кол-во МРП или цену за ночь (и пояснение) — они сохраняются за точкой. * Применяется в форме при каждом рендере и перед сохранением; просмотр берёт * сохранённое как есть — чтобы не пересчитать старую заявку по новому МРП. */ -export function syncPerDiem(articles: IEstimateArticle[], route: ITripRoute): IEstimateArticle[] { +export function syncRouteItems(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 { ...base, mode: 'calc', ...routeItemFigures(article.kind, base, route, index) }; }); return { ...article, items }; }); diff --git a/src/webparts/requestPortal/forms/BusinessTrip/model.ts b/src/webparts/requestPortal/forms/BusinessTrip/model.ts index 5388a4d..8ba3b75 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/model.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/model.ts @@ -132,7 +132,7 @@ export interface IEstimateItem { /** * Только у суточных: количество МРП в день (цифры). Из него и MRP_VALUE * считается ставка (price), дни (quantity) — из дат точки маршрута, см. - * estimate.ts/syncPerDiem. У остальных статей — ''. + * estimate.ts/syncRouteItems. У остальных статей — ''. */ mrpCount: string; } @@ -161,7 +161,7 @@ export interface IEstimateKindInfo { link: EstimateLinkTarget; /** * true — позиции не добавляются вручную, а строятся по точкам маршрута (по - * одной на точку) и считаются сами — так устроены суточные. + * одной на точку) и считаются сами — так устроены суточные (дни) и проживание (ночи). */ itemsFromRoute: boolean; quantityLabel: string; @@ -199,7 +199,8 @@ export const ESTIMATE_KINDS: Record = { core: true, defaultMode: 'calc', link: 'stop', - itemsFromRoute: false, + // Ночи — из дат точки, человек вводит только цену за ночь (с 10-23, по требованию кадров). + itemsFromRoute: true, quantityLabel: 'Ночей', priceLabel: 'Цена за ночь', commentPlaceholder: 'Гостиница, адрес (необязательно)', @@ -259,6 +260,18 @@ export const ESTIMATE_KIND_ORDER: ReadonlyArray = [ export const MRP_VALUE = 4325; export const MRP_YEAR = 2026; +/** Лимит суточных, МРП в день (требования кадров 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 = ['insurance', 'visa']; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts b/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts index ec4a32b..af285c6 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/validation.test.ts @@ -100,9 +100,26 @@ describe('validation: смета', () => { const [perDiem, travel, accommodation] = EMPTY_DATA.Estimate; it('включённая статья без суммы — подсвечивается пустое поле', () => { - const filledQty = { ...accommodation, items: [{ ...accommodation.items[0], linkId: 's1', quantity: '3' }] }; - const data = { ...FILLED, Estimate: [off(perDiem), off(travel), filledQty] }; - expect(fields(submit(data))).toEqual([`item-${accommodation.items[0].id}-price`]); + 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`]); }); it('суточные: дни из маршрута, от человека — только кол-во МРП', () => { diff --git a/src/webparts/requestPortal/forms/BusinessTrip/validation.ts b/src/webparts/requestPortal/forms/BusinessTrip/validation.ts index 5a96f0e..5a34e1d 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/validation.ts +++ b/src/webparts/requestPortal/forms/BusinessTrip/validation.ts @@ -1,10 +1,19 @@ import type { IFieldIssue } from '../../shared/ui'; import type { IEmployeeOrganization } from '../../shared/dictionary'; import { todaySubmitDate } from '../../shared/submitDate'; -import { ESTIMATE_KINDS, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model'; +import { ESTIMATE_KINDS, EstimateKind, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model'; import { filledTasks } from './tripData'; import { legLabel, routeLegs, stopCityLabel } from './route'; -import { articleTitle, itemAmount, itemLinkLabel, linkOptions, resolveLinkId } from './estimate'; +import { + articleTitle, + formatAmount, + itemAmount, + itemLinkLabel, + linkOptions, + mrpLimit, + nightPriceLimit, + resolveLinkId +} from './estimate'; /** * Ключи полей маршрута и сметы — те же, что fieldKey/htmlFor у их FormField: @@ -90,6 +99,27 @@ 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 []; +} + /** * Смета. Сама не обязательна — выключенные статьи не проверяются; но у включённой * или добавленной статьи каждая позиция должна иметь сумму и (если вариантов @@ -107,12 +137,8 @@ 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: 'укажите количество МРП' }); - } + issues.push(...routeItemIssues(article.kind, item, label, route)); return; } if (needsLink && !resolveLinkId(article.kind, item.linkId, route)) {