feat: лимиты суточных и проживания, ночи считаются сами
Суточные — не больше 6 МРП в день по Казахстану и 8 за рубежом (по признаку точки). Проживание теперь строится по точкам маршрута, как суточные: ночи из дат точки, человек вводит только цену за ночь — не больше 15 000 ₸ по Казахстану; лимит для заграницы — константа, пока без значения. Превышение видно сразу под полем и блокирует отправку. syncPerDiem → syncRouteItems. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,7 +43,7 @@ import { useStableCallback } from '../../shared/useStableCallback';
|
|||||||
import { submitDateOnOpen } from '../../shared/submitDate';
|
import { submitDateOnOpen } from '../../shared/submitDate';
|
||||||
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { draftIssues, submitIssues } from './validation';
|
import { draftIssues, submitIssues } from './validation';
|
||||||
import { syncPerDiem } from './estimate';
|
import { syncRouteItems } from './estimate';
|
||||||
import { saveBusinessTripRequest, businessTripApprovalService } from './submit';
|
import { saveBusinessTripRequest, businessTripApprovalService } from './submit';
|
||||||
import { loadBusinessTripRequest } from './load';
|
import { loadBusinessTripRequest } from './load';
|
||||||
import { useTripEmployee } from './useTripEmployee';
|
import { useTripEmployee } from './useTripEmployee';
|
||||||
@@ -86,9 +86,9 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
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 employee = useTripEmployee(context, editId, data.Employee, setData);
|
||||||
|
|
||||||
const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]);
|
const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]);
|
||||||
|
|||||||
@@ -69,18 +69,24 @@
|
|||||||
- **Задание** — нумерованный список задач, хранится в `TripTasksJson` (+ читаемая копия
|
- **Задание** — нумерованный список задач, хранится в `TripTasksJson` (+ читаемая копия
|
||||||
`TripTasks` для представлений SP). Пустые строки не сохраняются.
|
`TripTasks` для представлений SP). Пустые строки не сохраняются.
|
||||||
- **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**, кроме
|
- **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**, кроме
|
||||||
суточных:
|
суточных и проживания — они строятся по точкам маршрута (`syncRouteItems`, `itemsFromRoute`
|
||||||
- **Суточные считаются сами** (с 10-09, по просьбе кадров): по позиции на каждую точку
|
в `ESTIMATE_KINDS`), по позиции на точку:
|
||||||
маршрута (`syncPerDiem`, `itemsFromRoute` в `ESTIMATE_KINDS`), город и проект — из точки,
|
- **Суточные считаются сами** (с 10-09, по просьбе кадров): город и проект — из точки,
|
||||||
дни — из её дат включительно, но день переезда считается один раз (`stopPerDiemDays`).
|
дни — из её дат включительно, но день переезда считается один раз (`stopPerDiemDays`).
|
||||||
Человек вводит только кол-во МРП в день; ставка = кол-во × `MRP_VALUE` (захардкожен в
|
Человек вводит только кол-во МРП в день; ставка = кол-во × `MRP_VALUE` (захардкожен в
|
||||||
model.ts вместе с `MRP_YEAR` — обновлять раз в год). Ставка сохраняется в заявке (`price`),
|
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`); точку удалили — позицию нужно привязать заново.
|
подставляется сам (`resolveLinkId`); точку удалили — позицию нужно привязать заново.
|
||||||
|
|||||||
+62
@@ -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<IEstimateItem>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проживание в одной точке маршрута: ночи — из дат точки, человек вводит только
|
||||||
|
* цену за ночь (не выше лимита — см. nightPriceLimit). Цену можно не вводить —
|
||||||
|
* в точке могут жить на объекте, тогда позиция просто 0.
|
||||||
|
*/
|
||||||
|
export const AccommodationFields: React.FC<Props> = ({ 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 (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.place}>
|
||||||
|
<span className={styles.city}>{(stop && stopCityLabel(stop)) || 'Точка маршрута'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.calcRow} ${styles.calcRowShort}`}>
|
||||||
|
<FormField label="Ночей">
|
||||||
|
<div className={styles.readonly} title="Считается по датам точки в маршруте">
|
||||||
|
{hasNights ? item.quantity : '—'}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+6
-6
@@ -7,6 +7,7 @@ import { linkOptions, resolveLinkId } from '../../estimate';
|
|||||||
import { fieldKey } from '../../validation';
|
import { fieldKey } from '../../validation';
|
||||||
import { AmountFields } from './AmountFields';
|
import { AmountFields } from './AmountFields';
|
||||||
import { PerDiemFields } from './PerDiemFields';
|
import { PerDiemFields } from './PerDiemFields';
|
||||||
|
import { AccommodationFields } from './AccommodationFields';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
kind: EstimateKind;
|
kind: EstimateKind;
|
||||||
@@ -21,13 +22,14 @@ interface Props {
|
|||||||
* Позиция статьи: к чему относится (точка маршрута / участок пути), сумма и
|
* Позиция статьи: к чему относится (точка маршрута / участок пути), сумма и
|
||||||
* пояснение. Выбор «к чему относится» показываем, только когда вариантов
|
* пояснение. Выбор «к чему относится» показываем, только когда вариантов
|
||||||
* больше одного — в простой поездке в одну точку выбирать нечего. Суточные
|
* больше одного — в простой поездке в одну точку выбирать нечего. Суточные
|
||||||
* привязаны к точке раз и навсегда (строятся по маршруту) — у них свои поля.
|
* (и проживание) привязаны к точке раз и навсегда (строятся по маршруту) — у них свои поля.
|
||||||
*/
|
*/
|
||||||
export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange, onRemove }) => {
|
export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange, onRemove }) => {
|
||||||
const info = ESTIMATE_KINDS[kind];
|
const info = ESTIMATE_KINDS[kind];
|
||||||
const options: ISelectOption[] = linkOptions(kind, route).map((o) => ({ value: o.id, label: o.label }));
|
const options: ISelectOption[] = linkOptions(kind, route).map((o) => ({ value: o.id, label: o.label }));
|
||||||
const showLink = !info.itemsFromRoute && options.length > 1;
|
const showLink = !info.itemsFromRoute && options.length > 1;
|
||||||
const linkLabel = info.link === 'leg' ? 'Участок пути' : 'Точка маршрута';
|
const linkLabel = info.link === 'leg' ? 'Участок пути' : 'Точка маршрута';
|
||||||
|
const stop = route.stops.find((s) => s.id === item.linkId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
@@ -53,11 +55,9 @@ export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{info.itemsFromRoute ? (
|
{kind === 'perDiem' && <PerDiemFields item={item} stop={stop} onChange={onChange} />}
|
||||||
<PerDiemFields item={item} stop={route.stops.find((s) => s.id === item.linkId)} onChange={onChange} />
|
{kind === 'accommodation' && <AccommodationFields item={item} stop={stop} onChange={onChange} />}
|
||||||
) : (
|
{!info.itemsFromRoute && <AmountFields kind={kind} item={item} onChange={onChange} />}
|
||||||
<AmountFields kind={kind} item={item} onChange={onChange} />
|
|
||||||
)}
|
|
||||||
<TextInput
|
<TextInput
|
||||||
value={item.comment}
|
value={item.comment}
|
||||||
onChange={(v) => onChange({ comment: v })}
|
onChange={(v) => onChange({ comment: v })}
|
||||||
|
|||||||
+13
-5
@@ -1,25 +1,28 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import styles from './PerDiemFields.module.scss';
|
import styles from './StopItemFields.module.scss';
|
||||||
import { FormField, MoneyInput } from '../../../../shared/ui';
|
import { FormField, MoneyInput } from '../../../../shared/ui';
|
||||||
import { IEstimateItem, IRouteStop, MRP_VALUE, MRP_YEAR } from '../../model';
|
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 { stopCityLabel } from '../../route';
|
||||||
import { fieldKey } from '../../validation';
|
import { fieldKey } from '../../validation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: IEstimateItem;
|
item: IEstimateItem;
|
||||||
/** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncPerDiem). */
|
/** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncRouteItems). */
|
||||||
stop: IRouteStop | undefined;
|
stop: IRouteStop | undefined;
|
||||||
onChange: (patch: Partial<IEstimateItem>) => void;
|
onChange: (patch: Partial<IEstimateItem>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Суточные одной точки маршрута: город и проект — из точки, дни — из её дат,
|
* Суточные одной точки маршрута: город и проект — из точки, дни — из её дат,
|
||||||
* человек вводит только количество МРП в день. Итог = дни × МРП × MRP_VALUE.
|
* человек вводит только количество МРП в день (не больше лимита — см. mrpLimit).
|
||||||
|
* Итог = дни × МРП × MRP_VALUE.
|
||||||
*/
|
*/
|
||||||
export const PerDiemFields: React.FC<Props> = ({ item, stop, onChange }) => {
|
export const PerDiemFields: React.FC<Props> = ({ item, stop, onChange }) => {
|
||||||
const hasDays = item.quantity !== '';
|
const hasDays = item.quantity !== '';
|
||||||
const project = stop?.project.trim();
|
const project = stop?.project.trim();
|
||||||
|
const limit = mrpLimit(stop);
|
||||||
|
const isOverLimit = Number(item.mrpCount) > limit;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
@@ -37,7 +40,7 @@ export const PerDiemFields: React.FC<Props> = ({ item, stop, onChange }) => {
|
|||||||
<span className={styles.sign} aria-hidden>
|
<span className={styles.sign} aria-hidden>
|
||||||
×
|
×
|
||||||
</span>
|
</span>
|
||||||
<FormField label="Кол-во МРП в день" htmlFor={fieldKey.itemMrpCount(item)}>
|
<FormField label={`Кол-во МРП в день (до ${limit})`} htmlFor={fieldKey.itemMrpCount(item)}>
|
||||||
<div className={styles.number}>
|
<div className={styles.number}>
|
||||||
<MoneyInput id={fieldKey.itemMrpCount(item)} value={item.mrpCount} onChange={(v) => onChange({ mrpCount: v })} />
|
<MoneyInput id={fieldKey.itemMrpCount(item)} value={item.mrpCount} onChange={(v) => onChange({ mrpCount: v })} />
|
||||||
</div>
|
</div>
|
||||||
@@ -57,6 +60,11 @@ export const PerDiemFields: React.FC<Props> = ({ item, stop, onChange }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isOverLimit && (
|
||||||
|
<div className={styles.limitError}>
|
||||||
|
Не больше {limit} МРП в день {stop?.abroad ? 'за рубежом' : 'по Казахстану'}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!hasDays && <div className={styles.hint}>Укажите даты точки в маршруте — по ним считаются дни.</div>}
|
{!hasDays && <div className={styles.hint}>Укажите даты точки в маршруте — по ним считаются дни.</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+15
@@ -1,5 +1,6 @@
|
|||||||
@import '../../../../../../styles/design-tokens';
|
@import '../../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Общие стили позиций «по точке маршрута»: суточные (PerDiemFields) и проживание (AccommodationFields).
|
||||||
// С этой ширины «дни × МРП × значение = итог» помещается в одну строку.
|
// С этой ширины «дни × МРП × значение = итог» помещается в одну строку.
|
||||||
$calc-inline-breakpoint: 640px;
|
$calc-inline-breakpoint: 640px;
|
||||||
|
|
||||||
@@ -115,3 +116,17 @@ $calc-inline-breakpoint: 640px;
|
|||||||
padding-bottom: 8px;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
import {
|
||||||
addItem,
|
addItem,
|
||||||
articleAmount,
|
articleAmount,
|
||||||
@@ -7,12 +16,14 @@ import {
|
|||||||
estimateTotal,
|
estimateTotal,
|
||||||
formatEstimateText,
|
formatEstimateText,
|
||||||
itemCalculation,
|
itemCalculation,
|
||||||
|
mrpLimit,
|
||||||
|
nightPriceLimit,
|
||||||
parseEstimate,
|
parseEstimate,
|
||||||
projectShares,
|
projectShares,
|
||||||
resolveLinkId,
|
resolveLinkId,
|
||||||
serializeEstimate,
|
serializeEstimate,
|
||||||
stopTotals,
|
stopTotals,
|
||||||
syncPerDiem
|
syncRouteItems
|
||||||
} from './estimate';
|
} from './estimate';
|
||||||
|
|
||||||
const ROUTE: ITripRoute = {
|
const ROUTE: ITripRoute = {
|
||||||
@@ -123,7 +134,7 @@ describe('суточные по маршруту', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it('по позиции на точку; день переезда считается один раз', () => {
|
it('по позиции на точку; день переезда считается один раз', () => {
|
||||||
const [synced] = syncPerDiem([perDiem], ROUTE);
|
const [synced] = syncRouteItems([perDiem], ROUTE);
|
||||||
expect(synced.items.map((i) => [i.linkId, i.quantity])).toEqual([
|
expect(synced.items.map((i) => [i.linkId, i.quantity])).toEqual([
|
||||||
['s1', '4'],
|
['s1', '4'],
|
||||||
['s2', '2']
|
['s2', '2']
|
||||||
@@ -131,32 +142,47 @@ describe('суточные по маршруту', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ставка — кол-во МРП × МРП; введённое за точкой сохраняется', () => {
|
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(synced.items[0]).toMatchObject({ id: 'p1', mrpCount: '6', comment: 'объект', price: String(6 * MRP_VALUE) });
|
||||||
expect(articleAmount(synced)).toBe(4 * 6 * MRP_VALUE);
|
expect(articleAmount(synced)).toBe(4 * 6 * MRP_VALUE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('новая точка — пустая позиция со стабильным id; удалённая точка — позиция уходит', () => {
|
it('новая точка — пустая позиция со стабильным id; удалённая точка — позиция уходит', () => {
|
||||||
const [synced] = syncPerDiem([perDiem], ROUTE);
|
const [synced] = syncRouteItems([perDiem], ROUTE);
|
||||||
expect(synced.items[1]).toMatchObject({ id: 'perDiem-s2', mrpCount: '', price: '' });
|
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']);
|
expect(oneStop.items.map((i) => i.linkId)).toEqual(['s2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('без дат дней нет — и суммы нет', () => {
|
it('без дат дней нет — и суммы нет', () => {
|
||||||
const noDates = { ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo: '' }] };
|
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(synced.items[0].quantity).toBe('');
|
||||||
expect(articleAmount(synced)).toBe(0);
|
expect(articleAmount(synced)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('другие статьи не трогает', () => {
|
it('статьи с ручными позициями не трогает', () => {
|
||||||
expect(syncPerDiem([hotels], ROUTE)[0]).toBe(hotels);
|
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('расчёт в тексте — с МРП, по сохранённой ставке', () => {
|
it('расчёт в тексте — с МРП, по сохранённой ставке', () => {
|
||||||
const NBSP = String.fromCharCode(160);
|
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(
|
expect(itemCalculation('perDiem', synced.items[0]).split(NBSP).join(' ')).toBe(
|
||||||
`4 дня × 6 МРП × ${new Intl.NumberFormat('ru-RU').format(MRP_VALUE).split(NBSP).join(' ')} ₸`
|
`4 дня × 6 МРП × ${new Intl.NumberFormat('ru-RU').format(MRP_VALUE).split(NBSP).join(' ')} ₸`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import {
|
|||||||
EstimateKind,
|
EstimateKind,
|
||||||
IEstimateArticle,
|
IEstimateArticle,
|
||||||
IEstimateItem,
|
IEstimateItem,
|
||||||
|
IRouteStop,
|
||||||
ITripRoute,
|
ITripRoute,
|
||||||
|
MAX_MRP_PER_DAY,
|
||||||
|
MAX_NIGHT_PRICE,
|
||||||
MRP_VALUE
|
MRP_VALUE
|
||||||
} from './model';
|
} from './model';
|
||||||
import { legLabel, newId, routeLegs, stopLabel, stopPerDiemDays } from './route';
|
import { legLabel, newId, routeLegs, stopLabel, stopNights, stopPerDiemDays } from './route';
|
||||||
import { plural } from './tripData';
|
import { plural } from './tripData';
|
||||||
|
|
||||||
/** Цифры из поля ввода числом: '' или мусор — 0. MoneyInput хранит только цифры. */
|
/** Цифры из поля ввода числом: '' или мусор — 0. MoneyInput хранит только цифры. */
|
||||||
@@ -66,7 +69,7 @@ function perDiemCalculation(item: IEstimateItem): string {
|
|||||||
* понятно из статьи. Суточные — исключение: там важно показать МРП.
|
* понятно из статьи. Суточные — исключение: там важно показать МРП.
|
||||||
*/
|
*/
|
||||||
export function itemCalculation(kind: EstimateKind, 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 '';
|
if (item.mode !== 'calc' || !item.quantity || !item.price) return '';
|
||||||
return `${formatAmount(toNumber(item.quantity))} × ${formatAmount(toNumber(item.price))} ₸`;
|
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 {
|
function perDiemRate(mrpCount: string): string {
|
||||||
const count = toNumber(mrpCount);
|
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<IEstimateItem> {
|
||||||
|
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) => {
|
return articles.map((article) => {
|
||||||
if (!ESTIMATE_KINDS[article.kind].itemsFromRoute) return article;
|
if (!ESTIMATE_KINDS[article.kind].itemsFromRoute) return article;
|
||||||
const items = route.stops.map((stop, index): IEstimateItem => {
|
const items = route.stops.map((stop, index): IEstimateItem => {
|
||||||
const existing = article.items.find((item) => item.linkId === stop.id);
|
const existing = article.items.find((item) => item.linkId === stop.id);
|
||||||
// id от точки, а не случайный: иначе строка пересоздавалась бы на каждом рендере.
|
// id от точки, а не случайный: иначе строка пересоздавалась бы на каждом рендере.
|
||||||
const base = existing || { ...createItem(article.kind, stop.id), id: `${article.kind}-${stop.id}` };
|
const base = existing || { ...createItem(article.kind, stop.id), id: `${article.kind}-${stop.id}` };
|
||||||
const days = stopPerDiemDays(route, index);
|
return { ...base, mode: 'calc', ...routeItemFigures(article.kind, base, route, index) };
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
mode: 'calc',
|
|
||||||
quantity: days === undefined ? '' : String(days),
|
|
||||||
price: perDiemRate(base.mrpCount)
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
return { ...article, items };
|
return { ...article, items };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export interface IEstimateItem {
|
|||||||
/**
|
/**
|
||||||
* Только у суточных: количество МРП в день (цифры). Из него и MRP_VALUE
|
* Только у суточных: количество МРП в день (цифры). Из него и MRP_VALUE
|
||||||
* считается ставка (price), дни (quantity) — из дат точки маршрута, см.
|
* считается ставка (price), дни (quantity) — из дат точки маршрута, см.
|
||||||
* estimate.ts/syncPerDiem. У остальных статей — ''.
|
* estimate.ts/syncRouteItems. У остальных статей — ''.
|
||||||
*/
|
*/
|
||||||
mrpCount: string;
|
mrpCount: string;
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ export interface IEstimateKindInfo {
|
|||||||
link: EstimateLinkTarget;
|
link: EstimateLinkTarget;
|
||||||
/**
|
/**
|
||||||
* true — позиции не добавляются вручную, а строятся по точкам маршрута (по
|
* true — позиции не добавляются вручную, а строятся по точкам маршрута (по
|
||||||
* одной на точку) и считаются сами — так устроены суточные.
|
* одной на точку) и считаются сами — так устроены суточные (дни) и проживание (ночи).
|
||||||
*/
|
*/
|
||||||
itemsFromRoute: boolean;
|
itemsFromRoute: boolean;
|
||||||
quantityLabel: string;
|
quantityLabel: string;
|
||||||
@@ -199,7 +199,8 @@ export const ESTIMATE_KINDS: Record<EstimateKind, IEstimateKindInfo> = {
|
|||||||
core: true,
|
core: true,
|
||||||
defaultMode: 'calc',
|
defaultMode: 'calc',
|
||||||
link: 'stop',
|
link: 'stop',
|
||||||
itemsFromRoute: false,
|
// Ночи — из дат точки, человек вводит только цену за ночь (с 10-23, по требованию кадров).
|
||||||
|
itemsFromRoute: true,
|
||||||
quantityLabel: 'Ночей',
|
quantityLabel: 'Ночей',
|
||||||
priceLabel: 'Цена за ночь',
|
priceLabel: 'Цена за ночь',
|
||||||
commentPlaceholder: 'Гостиница, адрес (необязательно)',
|
commentPlaceholder: 'Гостиница, адрес (необязательно)',
|
||||||
@@ -259,6 +260,18 @@ export const ESTIMATE_KIND_ORDER: ReadonlyArray<EstimateKind> = [
|
|||||||
export const MRP_VALUE = 4325;
|
export const MRP_VALUE = 4325;
|
||||||
export const MRP_YEAR = 2026;
|
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<EstimateKind> = ['insurance', 'visa'];
|
export const SINGLE_OPTIONAL_KINDS: ReadonlyArray<EstimateKind> = ['insurance', 'visa'];
|
||||||
|
|
||||||
|
|||||||
@@ -100,9 +100,26 @@ describe('validation: смета', () => {
|
|||||||
const [perDiem, travel, accommodation] = EMPTY_DATA.Estimate;
|
const [perDiem, travel, accommodation] = EMPTY_DATA.Estimate;
|
||||||
|
|
||||||
it('включённая статья без суммы — подсвечивается пустое поле', () => {
|
it('включённая статья без суммы — подсвечивается пустое поле', () => {
|
||||||
const filledQty = { ...accommodation, items: [{ ...accommodation.items[0], linkId: 's1', quantity: '3' }] };
|
const tickets = { ...travel, items: [{ ...travel.items[0], linkId: 'origin>s1', mode: 'calc' as const, quantity: '1' }] };
|
||||||
const data = { ...FILLED, Estimate: [off(perDiem), off(travel), filledQty] };
|
const data = { ...FILLED, Estimate: [off(perDiem), tickets, off(accommodation)] };
|
||||||
expect(fields(submit(data))).toEqual([`item-${accommodation.items[0].id}-price`]);
|
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('суточные: дни из маршрута, от человека — только кол-во МРП', () => {
|
it('суточные: дни из маршрута, от человека — только кол-во МРП', () => {
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import type { IFieldIssue } from '../../shared/ui';
|
import type { IFieldIssue } from '../../shared/ui';
|
||||||
import type { IEmployeeOrganization } from '../../shared/dictionary';
|
import type { IEmployeeOrganization } from '../../shared/dictionary';
|
||||||
import { todaySubmitDate } from '../../shared/submitDate';
|
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 { filledTasks } from './tripData';
|
||||||
import { legLabel, routeLegs, stopCityLabel } from './route';
|
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:
|
* Ключи полей маршрута и сметы — те же, что fieldKey/htmlFor у их FormField:
|
||||||
@@ -90,6 +99,27 @@ function amountIssue(item: IEstimateItem, label: string): IFieldIssue {
|
|||||||
return { field: fieldKey.itemPrice(item), label, message: 'укажите цену' };
|
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) => {
|
article.items.forEach((item) => {
|
||||||
const where = itemLinkLabel(article.kind, item, route);
|
const where = itemLinkLabel(article.kind, item, route);
|
||||||
const label = where ? `Смета: ${articleTitle(article)} (${where})` : `Смета: ${articleTitle(article)}`;
|
const label = where ? `Смета: ${articleTitle(article)} (${where})` : `Смета: ${articleTitle(article)}`;
|
||||||
// Суточные привязаны к точке и считают дни сами (пустые даты подсветит маршрут) —
|
|
||||||
// от человека нужно только кол-во МРП. 0 дней (точка в день переезда) — не ошибка.
|
|
||||||
if (ESTIMATE_KINDS[article.kind].itemsFromRoute) {
|
if (ESTIMATE_KINDS[article.kind].itemsFromRoute) {
|
||||||
if (!Number(item.mrpCount)) {
|
issues.push(...routeItemIssues(article.kind, item, label, route));
|
||||||
issues.push({ field: fieldKey.itemMrpCount(item), label, message: 'укажите количество МРП' });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (needsLink && !resolveLinkId(article.kind, item.linkId, route)) {
|
if (needsLink && !resolveLinkId(article.kind, item.linkId, route)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user