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): прибытие в любую точку — не раньше сегодняшнего дня - **Задним числом нельзя** (с 10-23): прибытие в любую точку — не раньше сегодняшнего дня
(сегодня можно). Прошедшие дни недоступны в календаре (`min`); набранную вручную или (сегодня можно). Прошедшие дни недоступны в календаре (`min`); набранную вручную или
«устаревшую» в черновике дату ловит `validation.ts` при отправке, черновик сохраняется. «устаревшую» в черновике дату ловит `validation.ts` при отправке, черновик сохраняется.
- **Не дольше 40 дней** (с 10-23, `MAX_TRIP_DAYS`): от прибытия в первую точку до отъезда из
последней включительно; при отправке подсвечивается отъезд последней точки.
- Хранение: `TripRouteJson`; для отчётов из маршрута пишутся `TripRoute` (текстом), - Хранение: `TripRouteJson`; для отчётов из маршрута пишутся `TripRoute` (текстом),
`TripCity` (города), `Project`/`ProjectId` (объект ПЕРВОЙ точки — колонка у всех форм одна), `TripCity` (города), `Project`/`ProjectId` (объект ПЕРВОЙ точки — колонка у всех форм одна),
`TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период). `TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период).
@@ -260,6 +260,9 @@ 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_TRIP_DAYS = 40;
/** Лимит суточных, МРП в день (требования кадров 10-23): за рубежом выше. */ /** Лимит суточных, МРП в день (требования кадров 10-23): за рубежом выше. */
export const MAX_MRP_PER_DAY = { domestic: 6, abroad: 8 } as const; export const MAX_MRP_PER_DAY = { domestic: 6, abroad: 8 } as const;
@@ -84,6 +84,13 @@ describe('validation: маршрут', () => {
expect(draftIssues(FILLED)).toEqual([]); expect(draftIssues(FILLED)).toEqual([]);
}); });
it('командировка не дольше 40 дней включительно', () => {
const until = (dateTo: string): ITripRoute => ({ ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo }] });
// 05.10 – 13.11 = 40 дней, 05.10 – 14.11 = 41.
expect(submit({ ...FILLED, Route: until('2026-11-13') })).toEqual([]);
expect(fields(submit({ ...FILLED, Route: until('2026-11-14') }))).toEqual(['stop-s1-to']);
});
it('отъезд раньше прибытия блокирует даже черновик', () => { it('отъезд раньше прибытия блокирует даже черновик', () => {
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo: '2026-10-01' }] }; const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo: '2026-10-01' }] };
expect(fields(draftIssues({ ...FILLED, Route: route }))).toEqual(['stop-s1-to']); expect(fields(draftIssues({ ...FILLED, Route: route }))).toEqual(['stop-s1-to']);
@@ -1,9 +1,18 @@
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, EstimateKind, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model'; import {
import { filledTasks } from './tripData'; ESTIMATE_KINDS,
import { legLabel, routeLegs, stopCityLabel } from './route'; 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 { import {
articleTitle, articleTitle,
formatAmount, formatAmount,
@@ -78,12 +87,31 @@ function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | unde
return issues; return issues;
} }
/**
* Вся поездка — не дольше MAX_TRIP_DAYS дней (от прибытия в первую точку до отъезда
* из последней, включительно). Подсвечиваем отъезд последней точки — его обычно и правят.
*/
function tripLengthIssues(route: ITripRoute): IFieldIssue[] {
const period = routePeriod(route);
const days = tripDays(period.start, period.end);
const last = route.stops[route.stops.length - 1];
if (days === undefined || days <= MAX_TRIP_DAYS || !last) return [];
return [
{
field: fieldKey.stopTo(last),
label: 'Маршрут',
message: `командировка — ${plural(days, ['день', 'дня', 'дней'])}, можно не больше ${MAX_TRIP_DAYS}`
}
];
}
/** Маршрут для отправки: город отправления, все точки, вид транспорта на каждом участке. */ /** Маршрут для отправки: город отправления, все точки, вид транспорта на каждом участке. */
function routeIssues(route: ITripRoute, today: string): IFieldIssue[] { function routeIssues(route: ITripRoute, today: string): IFieldIssue[] {
const issues: IFieldIssue[] = []; const issues: IFieldIssue[] = [];
if (!route.origin.trim()) issues.push({ field: fieldKey.origin, label: 'Маршрут', message: 'укажите город отправления' }); if (!route.origin.trim()) issues.push({ field: fieldKey.origin, label: 'Маршрут', message: 'укажите город отправления' });
route.stops.forEach((stop, i) => issues.push(...stopIssues(stop, i, route.stops[i - 1], today))); route.stops.forEach((stop, i) => issues.push(...stopIssues(stop, i, route.stops[i - 1], today)));
issues.push(...stopDateOrderIssues(route)); issues.push(...stopDateOrderIssues(route));
issues.push(...tripLengthIssues(route));
routeLegs(route).forEach((leg) => { routeLegs(route).forEach((leg) => {
if (!route.legTransport[leg.id]) { if (!route.legTransport[leg.id]) {
issues.push({ field: fieldKey.legTransport(leg.id), label: `Участок ${legLabel(leg)}`, message: 'выберите вид транспорта' }); issues.push({ field: fieldKey.legTransport(leg.id), label: `Участок ${legLabel(leg)}`, message: 'выберите вид транспорта' });