feat: командировка не дольше 40 дней

От прибытия в первую точку до отъезда из последней включительно; при
отправке подсвечивается отъезд последней точки.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-23 15:14:45 +05:00
parent d07056937a
commit 50a1ae2ce3
4 changed files with 43 additions and 3 deletions
@@ -63,6 +63,8 @@
- **Задним числом нельзя** (с 10-23): прибытие в любую точку — не раньше сегодняшнего дня
(сегодня можно). Прошедшие дни недоступны в календаре (`min`); набранную вручную или
«устаревшую» в черновике дату ловит `validation.ts` при отправке, черновик сохраняется.
- **Не дольше 40 дней** (с 10-23, `MAX_TRIP_DAYS`): от прибытия в первую точку до отъезда из
последней включительно; при отправке подсвечивается отъезд последней точки.
- Хранение: `TripRouteJson`; для отчётов из маршрута пишутся `TripRoute` (текстом),
`TripCity` (города), `Project`/`ProjectId` (объект ПЕРВОЙ точки — колонка у всех форм одна),
`TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период).
@@ -260,6 +260,9 @@ export const ESTIMATE_KIND_ORDER: ReadonlyArray<EstimateKind> = [
export const MRP_VALUE = 4325;
export const MRP_YEAR = 2026;
/** Самая длинная командировка, дней включительно (требования кадров 10-23). */
export const MAX_TRIP_DAYS = 40;
/** Лимит суточных, МРП в день (требования кадров 10-23): за рубежом выше. */
export const MAX_MRP_PER_DAY = { domestic: 6, abroad: 8 } as const;
@@ -84,6 +84,13 @@ describe('validation: маршрут', () => {
expect(draftIssues(FILLED)).toEqual([]);
});
it('командировка не дольше 40 дней включительно', () => {
const until = (dateTo: string): ITripRoute => ({ ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo }] });
// 05.10 – 13.11 = 40 дней, 05.10 – 14.11 = 41.
expect(submit({ ...FILLED, Route: until('2026-11-13') })).toEqual([]);
expect(fields(submit({ ...FILLED, Route: until('2026-11-14') }))).toEqual(['stop-s1-to']);
});
it('отъезд раньше прибытия блокирует даже черновик', () => {
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo: '2026-10-01' }] };
expect(fields(draftIssues({ ...FILLED, Route: route }))).toEqual(['stop-s1-to']);
@@ -1,9 +1,18 @@
import type { IFieldIssue } from '../../shared/ui';
import type { IEmployeeOrganization } from '../../shared/dictionary';
import { todaySubmitDate } from '../../shared/submitDate';
import { ESTIMATE_KINDS, EstimateKind, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model';
import { filledTasks } from './tripData';
import { legLabel, routeLegs, stopCityLabel } from './route';
import {
ESTIMATE_KINDS,
EstimateKind,
IEstimateArticle,
IEstimateItem,
IFormData,
IRouteStop,
ITripRoute,
MAX_TRIP_DAYS
} from './model';
import { filledTasks, plural, tripDays } from './tripData';
import { legLabel, routeLegs, routePeriod, stopCityLabel } from './route';
import {
articleTitle,
formatAmount,
@@ -78,12 +87,31 @@ function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | unde
return issues;
}
/**
* Вся поездка — не дольше MAX_TRIP_DAYS дней (от прибытия в первую точку до отъезда
* из последней, включительно). Подсвечиваем отъезд последней точки — его обычно и правят.
*/
function tripLengthIssues(route: ITripRoute): IFieldIssue[] {
const period = routePeriod(route);
const days = tripDays(period.start, period.end);
const last = route.stops[route.stops.length - 1];
if (days === undefined || days <= MAX_TRIP_DAYS || !last) return [];
return [
{
field: fieldKey.stopTo(last),
label: 'Маршрут',
message: `командировка — ${plural(days, ['день', 'дня', 'дней'])}, можно не больше ${MAX_TRIP_DAYS}`
}
];
}
/** Маршрут для отправки: город отправления, все точки, вид транспорта на каждом участке. */
function routeIssues(route: ITripRoute, today: string): IFieldIssue[] {
const issues: IFieldIssue[] = [];
if (!route.origin.trim()) issues.push({ field: fieldKey.origin, label: 'Маршрут', message: 'укажите город отправления' });
route.stops.forEach((stop, i) => issues.push(...stopIssues(stop, i, route.stops[i - 1], today)));
issues.push(...stopDateOrderIssues(route));
issues.push(...tripLengthIssues(route));
routeLegs(route).forEach((leg) => {
if (!route.legTransport[leg.id]) {
issues.push({ field: fieldKey.legTransport(leg.id), label: `Участок ${legLabel(leg)}`, message: 'выберите вид транспорта' });