feat: командировка не дольше 40 дней
От прибытия в первую точку до отъезда из последней включительно; при отправке подсвечивается отъезд последней точки. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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: 'выберите вид транспорта' });
|
||||||
|
|||||||
Reference in New Issue
Block a user