feat: командировку нельзя оформить задним числом

Прибытие в любую точку маршрута — не раньше сегодняшнего дня: прошедшие дни
закрыты в календаре, а набранная вручную или устаревшая в черновике дата
подсвечивается при отправке (черновик сохраняется). «Сегодня» передаётся в
submitIssues параметром — тесты не зависят от текущей даты.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-23 14:25:27 +05:00
parent fa84dd6ad5
commit cd7b27daa2
5 changed files with 48 additions and 20 deletions
@@ -50,6 +50,9 @@
узкий — сверху вниз. Сумму под точками Тимур может попросить убрать, если будет громоздко.
- Новая точка начинается с даты отъезда из предыдущей (то, что человек уже ввёл);
прибытие раньше отъезда из предыдущей точки — ошибка.
- **Задним числом нельзя** (с 10-23): прибытие в любую точку — не раньше сегодняшнего дня
(сегодня можно). Прошедшие дни недоступны в календаре (`min`); набранную вручную или
«устаревшую» в черновике дату ловит `validation.ts` при отправке, черновик сохраняется.
- Хранение: `TripRouteJson`; для отчётов из маршрута пишутся `TripRoute` (текстом),
`TripCity` (города), `Project`/`ProjectId` (объект ПЕРВОЙ точки — колонка у всех форм одна),
`TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период).
@@ -6,6 +6,7 @@ import { CityPicker, ProjectPicker } from '../../../../shared/dictionary';
import { CITY_PLACEHOLDER, IRouteStop, PROJECT_PLACEHOLDER } from '../../model';
import { stopNights } from '../../route';
import { plural } from '../../tripData';
import { todaySubmitDate } from '../../../../shared/submitDate';
import { fieldKey } from '../../validation';
interface Props {
@@ -63,6 +64,8 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
type="date"
value={stop.dateFrom}
onChange={(v) => onChange({ dateFrom: v })}
// Прошедшие дни в календаре недоступны; набранную вручную дату ловит validation.ts.
min={todaySubmitDate()}
/>
</FormField>
<FormField label="Отъезд" htmlFor={fieldKey.stopTo(stop)} required>
@@ -71,7 +74,7 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
type="date"
value={stop.dateTo}
onChange={(v) => onChange({ dateTo: v })}
min={stop.dateFrom || undefined}
min={stop.dateFrom || todaySubmitDate()}
/>
</FormField>
</div>
@@ -1,5 +1,7 @@
import { EMPTY_DATA, IEstimateArticle, IFormData, ITripRoute } from './model';
import { createArticle } from './estimate';
import type { IEmployeeOrganization } from '../../shared/dictionary';
import type { IFieldIssue } from '../../shared/ui';
import { draftIssues, submitIssues } from './validation';
const off = (article: IEstimateArticle): IEstimateArticle => ({ ...article, enabled: false });
@@ -24,19 +26,23 @@ const FILLED: IFormData = {
TripObligationAccepted: true
};
// Фиксированное «сегодня» — раньше дат маршрута в фикстурах, чтобы тесты не зависели от текущей даты.
const TODAY = '2026-10-01';
const submit = (data: IFormData, organizations: IEmployeeOrganization[] = []): IFieldIssue[] => submitIssues(data, organizations, TODAY);
const fields = (issues: { field: string }[]): string[] => issues.map((i) => i.field);
describe('validation: форма', () => {
it('заполненная заявка — без проблем', () => {
expect(submitIssues(FILLED)).toEqual([]);
expect(submit(FILLED)).toEqual([]);
expect(draftIssues(FILLED)).toEqual([]);
});
it('командирующая организация обязательна, только если есть из чего выбрать', () => {
const orgs = [{ id: '1', fio: '', email: '', iin: '', company: 'ТОО А', companyId: 'a', section: '', departmentId: '', position: '', mobile: '' }];
expect(fields(submitIssues(FILLED, orgs))).toEqual(['TripCompany']);
expect(submitIssues({ ...FILLED, TripCompany: 'ТОО А', TripCompanyId: 'a' }, orgs)).toEqual([]);
expect(submitIssues(FILLED, [])).toEqual([]);
expect(fields(submit(FILLED, orgs))).toEqual(['TripCompany']);
expect(submit({ ...FILLED, TripCompany: 'ТОО А', TripCompanyId: 'a' }, orgs)).toEqual([]);
expect(submit(FILLED, [])).toEqual([]);
});
it('черновику нужен только сотрудник', () => {
@@ -45,19 +51,25 @@ describe('validation: форма', () => {
it('пустые задачи и обязательство', () => {
const data = { ...FILLED, Tasks: [' '], TripObligationAccepted: false };
expect(fields(submitIssues(data))).toEqual(['Tasks', 'TripObligationAccepted']);
expect(fields(submit(data))).toEqual(['Tasks', 'TripObligationAccepted']);
});
});
describe('validation: маршрут', () => {
it('объект только из справочника', () => {
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], projectId: '' }] };
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']);
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']);
});
it('у каждого участка нужен вид транспорта', () => {
const route = { ...ROUTE, legTransport: { 'origin>s1': 'Авиа' } };
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']);
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']);
});
it('прибытие задним числом не отправить, сегодня — можно; черновик сохраняется', () => {
expect(fields(submitIssues(FILLED, [], '2026-10-06'))).toEqual(['stop-s1-from']);
expect(submitIssues(FILLED, [], '2026-10-05')).toEqual([]);
expect(draftIssues(FILLED)).toEqual([]);
});
it('отъезд раньше прибытия блокирует даже черновик', () => {
@@ -68,7 +80,7 @@ describe('validation: маршрут', () => {
it('прибытие в следующую точку раньше отъезда из предыдущей', () => {
const second = { id: 's2', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт', projectId: 'p2', dateFrom: '2026-10-06', dateTo: '2026-10-09' };
const route = { ...ROUTE, stops: [...ROUTE.stops, second], legTransport: { 'origin>s1': 'Авиа', 's1>s2': 'Авиа', 's2>origin': 'Авиа' } };
expect(fields(submitIssues({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
});
});
@@ -78,27 +90,27 @@ describe('validation: смета', () => {
it('включённая статья без суммы — подсвечивается пустое поле', () => {
const filledQty = { ...accommodation, items: [{ ...accommodation.items[0], linkId: 's1', quantity: '3' }] };
const data = { ...FILLED, Estimate: [off(perDiem), off(travel), filledQty] };
expect(fields(submitIssues(data))).toEqual([`item-${accommodation.items[0].id}-price`]);
expect(fields(submit(data))).toEqual([`item-${accommodation.items[0].id}-price`]);
});
it('суточные: дни из маршрута, от человека — только кол-во МРП', () => {
const noMrp = { ...perDiem, items: [{ ...perDiem.items[0], linkId: 's1', quantity: '4' }] };
const withMrp = { ...perDiem, items: [{ ...noMrp.items[0], mrpCount: '6' }] };
const estimate = (article: IEstimateArticle): IEstimateArticle[] => [article, off(travel), off(accommodation)];
expect(fields(submitIssues({ ...FILLED, Estimate: estimate(noMrp) }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
expect(submitIssues({ ...FILLED, Estimate: estimate(withMrp) })).toEqual([]);
expect(fields(submit({ ...FILLED, Estimate: estimate(noMrp) }))).toEqual([`item-${perDiem.items[0].id}-mrp`]);
expect(submit({ ...FILLED, Estimate: estimate(withMrp) })).toEqual([]);
});
it('при нескольких вариантах позицию нужно привязать', () => {
// В маршруте «туда и обратно» у проезда два участка — без привязки не понять, какой оплачен.
const unlinked = { ...travel, items: [{ ...travel.items[0], linkId: '', amount: '50000' }] };
const data = { ...FILLED, Estimate: [off(perDiem), unlinked, off(accommodation)] };
expect(fields(submitIssues(data))).toEqual([`item-${travel.items[0].id}-link`]);
expect(fields(submit(data))).toEqual([`item-${travel.items[0].id}-link`]);
});
it('другой расход требует название и сумму', () => {
const other = createArticle('other', ROUTE);
const data = { ...FILLED, Estimate: [...FILLED.Estimate, other] };
expect(fields(submitIssues(data))).toEqual([`article-${other.id}-title`, `item-${other.items[0].id}-amount`]);
expect(fields(submit(data))).toEqual([`article-${other.id}-title`, `item-${other.items[0].id}-amount`]);
});
});
@@ -1,5 +1,6 @@
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 { filledTasks } from './tripData';
import { legLabel, routeLegs, stopCityLabel } from './route';
@@ -42,13 +43,17 @@ function stopDateOrderIssues(route: ITripRoute): IFieldIssue[] {
}
/** Проблемы одной точки маршрута (кроме порядка дат внутри точки — см. stopDateOrderIssues). */
function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined): IFieldIssue[] {
function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined, today: string): IFieldIssue[] {
const label = stopName(stop, index);
const issues: IFieldIssue[] = [];
if (!stop.city.trim()) issues.push({ field: fieldKey.stopCity(stop), label, message: 'укажите город' });
// Объект — именно из справочника (projectId): по нему потом разносят затраты по проектам.
if (!stop.projectId) issues.push({ field: fieldKey.stopProject(stop), label, message: 'выберите объект из списка' });
if (!stop.dateFrom) issues.push({ field: fieldKey.stopFrom(stop), label, message: 'укажите дату прибытия' });
// Задним числом командировку не оформляют. Сегодня — можно: выезд в день подачи.
if (stop.dateFrom && stop.dateFrom < today) {
issues.push({ field: fieldKey.stopFrom(stop), label, message: 'дата прибытия уже прошла' });
}
if (!stop.dateTo) issues.push({ field: fieldKey.stopTo(stop), label, message: 'укажите дату отъезда' });
if (previous?.dateTo && stop.dateFrom && stop.dateFrom < previous.dateTo) {
issues.push({ field: fieldKey.stopFrom(stop), label, message: 'прибытие раньше отъезда из предыдущей точки' });
@@ -57,10 +62,10 @@ function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | unde
}
/** Маршрут для отправки: город отправления, все точки, вид транспорта на каждом участке. */
function routeIssues(route: ITripRoute): IFieldIssue[] {
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])));
route.stops.forEach((stop, i) => issues.push(...stopIssues(stop, i, route.stops[i - 1], today)));
issues.push(...stopDateOrderIssues(route));
routeLegs(route).forEach((leg) => {
if (!route.legTransport[leg.id]) {
@@ -121,8 +126,13 @@ export function draftIssues(data: IFormData): IFieldIssue[] {
/**
* Всё, что мешает отправке на согласование, в порядке формы (пусто — отправлять можно).
* organizations — организации сотрудника (см. useTripEmployee): из них выбирают командирующую.
* today — YYYY-MM-DD; параметром, чтобы тесты не зависели от текущей даты.
*/
export function submitIssues(data: IFormData, organizations: IEmployeeOrganization[] = []): IFieldIssue[] {
export function submitIssues(
data: IFormData,
organizations: IEmployeeOrganization[] = [],
today: string = todaySubmitDate()
): IFieldIssue[] {
const issues: IFieldIssue[] = [];
if (!data.Employee) issues.push({ field: 'Employee', label: 'Сотрудник' });
if (!data.Position.trim()) issues.push({ field: 'Position', label: 'Должность' });
@@ -130,7 +140,7 @@ export function submitIssues(data: IFormData, organizations: IEmployeeOrganizati
if (organizations.length > 0 && !data.TripCompany) {
issues.push({ field: 'TripCompany', label: 'Командирующая организация', message: 'выберите, от какой организации командировка' });
}
issues.push(...routeIssues(data.Route));
issues.push(...routeIssues(data.Route, today));
if (!data.TripPurpose.trim()) issues.push({ field: 'TripPurpose', label: 'Цель' });
if (filledTasks(data.Tasks).length === 0) {
issues.push({ field: 'Tasks', label: 'Задание на командировку', message: 'добавьте хотя бы одну задачу' });