feat: точки командировки по Казахстану и за рубежом

У каждой точки маршрута — «Казахстан | Другая страна» (бывают смешанные
поездки). За рубежом страна и город вводятся вручную, подпись «Китай, Пекин»,
объект можно вписать своим текстом (завод, выставка — их нет в справочнике).
Новая колонка TripKind («По Казахстану» / «Зарубежная») — хотя бы одна точка
за рубежом делает поездку зарубежной; пригодится для маршрута и отчётов.
Переключатель «Форма | Схема» вынесен в общий SegmentedSwitch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-23 15:02:22 +05:00
parent 6122370bbf
commit 17f448a1c2
14 changed files with 249 additions and 56 deletions
@@ -38,10 +38,20 @@
всё внутри `TripRouteJson`), показывается везде как «Кульсары (Құлсары)» (`cityLabel`), в том всё внутри `TripRouteJson`), показывается везде как «Кульсары (Құлсары)» (`cityLabel`), в том
числе в `TripCity`/`TripRoute` и заголовке. Нет в справочнике (село, заграница) — сохраняется числе в `TripCity`/`TripRoute` и заголовке. Нет в справочнике (село, заграница) — сохраняется
введённый текст без казахского названия. введённый текст без казахского названия.
- **Казахстан / другая страна — у каждой точки** (с 10-23, `SegmentedSwitch` в карточке точки):
признак у точки, а не у поездки — бывают смешанные маршруты (Алматы → Москва → Атырау).
За рубежом — «Страна» и «Город» вручную (`abroad`/`country` в `TripRouteJson`), подпись
«Китай, Пекин» (страна первой). Смена признака: город остаётся, за рубежом сбрасываются казахское название
и код КАТО, у точки по Казахстану — страна (`stopLocationPatch`). Новая точка — в той же
стране, что предыдущая. Город отправления — всегда из справочника (можно и свой текст).
Колонка `TripKind` («По Казахстану» / «Зарубежная»): хотя бы одна точка за рубежом —
зарубежная; пригодится для маршрута согласования и отчётов. Старые заявки — по Казахстану.
- Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у - Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д, каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`: авиа, ж/д,
такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен. такси, служебный и личный автотранспорт; «Автобус» заменён на «Такси» 10-23), обязателен.
- Объект точки — только выбор из справочника (`projectId`): по объектам разносят затраты. - Объект точки по Казахстану — только выбор из справочника (`projectId`): по объектам
разносят затраты. У зарубежной точки поле «Объект / место назначения» принимает и свой
текст (завод, выставка — в справочнике их нет), обязательно лишь непустое значение.
- **Два вида маршрута** — переключатель «Форма | Схема» (`RouteViewSwitch`). Схема - **Два вида маршрута** — переключатель «Форма | Схема» (`RouteViewSwitch`). Схема
(`RouteScheme`) — только просмотр, как схема метро: узлы-точки (город, объект, даты, ночи, (`RouteScheme`) — только просмотр, как схема метро: узлы-точки (город, объект, даты, ночи,
расходы по точке из сметы — `stopTotals`), на линиях — вид транспорта, внизу сводка расходы по точке из сметы — `stopTotals`), на линиях — вид транспорта, внизу сводка
@@ -1,6 +1,6 @@
@import '../../../../../../styles/design-tokens'; @import '../../../../../styles/design-tokens';
// Сегментный переключатель — тот же вид, что «Суммой / Кол-во × цена» в смете. // Сегментный переключатель — тот же вид, что «Суммой / Кол-во × цена» в смете (AmountFields).
.switch { .switch {
display: inline-flex; display: inline-flex;
align-self: flex-start; align-self: flex-start;
@@ -0,0 +1,36 @@
import * as React from 'react';
import styles from './SegmentedSwitch.module.scss';
export interface ISegmentedOption<T extends string> {
value: T;
label: string;
}
interface Props<T extends string> {
value: T;
options: ReadonlyArray<ISegmentedOption<T>>;
onChange: (value: T) => void;
/** Подпись группы для экранных дикторов. */
ariaLabel: string;
className?: string;
}
/** Сегментный переключатель из 2–3 вариантов: «Форма | Схема», «Казахстан | Другая страна». */
export function SegmentedSwitch<T extends string>({ value, options, onChange, ariaLabel, className }: Props<T>): JSX.Element {
return (
<div className={className ? `${styles.switch} ${className}` : styles.switch} role="radiogroup" aria-label={ariaLabel}>
{options.map((option) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={value === option.value}
className={value === option.value ? `${styles.option} ${styles.active}` : styles.option}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
</div>
);
}
@@ -1,10 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './RouteStopCard.module.scss'; import styles from './RouteStopCard.module.scss';
import { FormField, Icon, TextInput } from '../../../../shared/ui'; import { FormField, Icon, TextInput, useFormReadOnly } from '../../../../shared/ui';
import { CityPicker, ProjectPicker } from '../../../../shared/dictionary'; import { ProjectPicker } from '../../../../shared/dictionary';
import { CITY_PLACEHOLDER, IRouteStop, PROJECT_PLACEHOLDER } from '../../model'; import { IRouteStop, PROJECT_PLACEHOLDER } from '../../model';
import { stopNights } from '../../route'; import { stopLocationPatch, stopNights } from '../../route';
import { ISegmentedOption, SegmentedSwitch } from '../SegmentedSwitch';
import { StopPlaceFields } from './StopPlaceFields';
import { plural } from '../../tripData'; import { plural } from '../../tripData';
import { todaySubmitDate } from '../../../../shared/submitDate'; import { todaySubmitDate } from '../../../../shared/submitDate';
import { fieldKey } from '../../validation'; import { fieldKey } from '../../validation';
@@ -23,8 +25,19 @@ export function stopCardDomId(stopId: string): string {
return `route-stop-${stopId}`; return `route-stop-${stopId}`;
} }
/** Точка маршрута: город, объект из справочника и даты пребывания. */ type StopLocation = 'domestic' | 'abroad';
const LOCATIONS: ReadonlyArray<ISegmentedOption<StopLocation>> = [
{ value: 'domestic', label: 'Казахстан' },
{ value: 'abroad', label: 'Другая страна' }
];
/**
* Точка маршрута: Казахстан или другая страна, город, объект из справочника и даты
* пребывания. В режиме чтения переключатель не нужен — страна видна в полях.
*/
export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange, onRemove }) => { export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange, onRemove }) => {
const readOnly = useFormReadOnly();
const nights = stopNights(stop); const nights = stopNights(stop);
return ( return (
@@ -40,16 +53,23 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
</button> </button>
)} )}
</div> </div>
<div className={styles.grid}> {!readOnly && (
<FormField label="Город, населённый пункт" htmlFor={fieldKey.stopCity(stop)} required> <SegmentedSwitch
<CityPicker value={stop.abroad ? 'abroad' : 'domestic'}
id={fieldKey.stopCity(stop)} options={LOCATIONS}
value={{ name: stop.city, nameKz: stop.cityKz, kato: stop.cityKato }} onChange={(location) => onChange(stopLocationPatch(location === 'abroad'))}
onChange={(city) => onChange({ city: city.name, cityKz: city.nameKz, cityKato: city.kato })} ariaLabel={`Где точка ${index + 1}`}
placeholder={CITY_PLACEHOLDER}
/> />
</FormField> )}
<FormField label="Объект" htmlFor={fieldKey.stopProject(stop)} required> <div className={styles.grid}>
<StopPlaceFields stop={stop} onChange={onChange} />
{/* За рубежом страна и город занимают строку — объект встаёт на всю ширину следующей. */}
<FormField
label={stop.abroad ? 'Объект / место назначения' : 'Объект'}
htmlFor={fieldKey.stopProject(stop)}
required
full={stop.abroad}
>
<ProjectPicker <ProjectPicker
id={fieldKey.stopProject(stop)} id={fieldKey.stopProject(stop)}
context={context} context={context}
@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import styles from './RouteViewSwitch.module.scss'; import { ISegmentedOption, SegmentedSwitch } from '../SegmentedSwitch';
export type RouteViewMode = 'form' | 'scheme'; export type RouteViewMode = 'form' | 'scheme';
@@ -8,25 +8,12 @@ interface Props {
onChange: (mode: RouteViewMode) => void; onChange: (mode: RouteViewMode) => void;
} }
const MODES: ReadonlyArray<{ mode: RouteViewMode; label: string }> = [ const MODES: ReadonlyArray<ISegmentedOption<RouteViewMode>> = [
{ mode: 'form', label: 'Форма' }, { value: 'form', label: 'Форма' },
{ mode: 'scheme', label: 'Схема' } { value: 'scheme', label: 'Схема' }
]; ];
/** Переключатель вида маршрута: поля для заполнения или схема для просмотра. */ /** Переключатель вида маршрута: поля для заполнения или схема для просмотра. На печати не нужен. */
export const RouteViewSwitch: React.FC<Props> = ({ value, onChange }) => ( export const RouteViewSwitch: React.FC<Props> = ({ value, onChange }) => (
<div className={`${styles.switch} rp-no-print`} role="radiogroup" aria-label="Вид маршрута"> <SegmentedSwitch value={value} options={MODES} onChange={onChange} ariaLabel="Вид маршрута" className="rp-no-print" />
{MODES.map(({ mode, label }) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={value === mode}
className={value === mode ? `${styles.option} ${styles.active}` : styles.option}
onClick={() => onChange(mode)}
>
{label}
</button>
))}
</div>
); );
@@ -0,0 +1,51 @@
import * as React from 'react';
import { FormField, TextInput } from '../../../../shared/ui';
import { CityPicker } from '../../../../shared/dictionary';
import { ABROAD_CITY_PLACEHOLDER, CITY_PLACEHOLDER, COUNTRY_PLACEHOLDER, IRouteStop } from '../../model';
import { fieldKey } from '../../validation';
interface Props {
stop: IRouteStop;
onChange: (patch: Partial<IRouteStop>) => void;
}
/**
* Где находится точка. По Казахстану — город из справочника КАТО (оба языка + код);
* за рубежом — страна и город вручную: справочник только по РК.
* Рендерит ячейки сетки карточки точки (без своей обёртки).
*/
export const StopPlaceFields: React.FC<Props> = ({ stop, onChange }) => {
if (!stop.abroad) {
return (
<FormField label="Город, населённый пункт" htmlFor={fieldKey.stopCity(stop)} required>
<CityPicker
id={fieldKey.stopCity(stop)}
value={{ name: stop.city, nameKz: stop.cityKz, kato: stop.cityKato }}
onChange={(city) => onChange({ city: city.name, cityKz: city.nameKz, cityKato: city.kato })}
placeholder={CITY_PLACEHOLDER}
/>
</FormField>
);
}
return (
<>
<FormField label="Страна" htmlFor={fieldKey.stopCountry(stop)} required>
<TextInput
id={fieldKey.stopCountry(stop)}
value={stop.country}
onChange={(v) => onChange({ country: v })}
placeholder={COUNTRY_PLACEHOLDER}
/>
</FormField>
<FormField label="Город" htmlFor={fieldKey.stopCity(stop)} required>
<TextInput
id={fieldKey.stopCity(stop)}
value={stop.city}
onChange={(v) => onChange({ city: v })}
placeholder={ABROAD_CITY_PLACEHOLDER}
/>
</FormField>
</>
);
};
@@ -20,8 +20,8 @@ const ROUTE: ITripRoute = {
originKz: '', originKz: '',
originKato: '', originKato: '',
stops: [ stops: [
{ id: 's1', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }, { id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' } { id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
], ],
returnToOrigin: true, returnToOrigin: true,
legTransport: {} legTransport: {}
@@ -20,6 +20,14 @@ export const FORM_TITLE = 'Заявка на командировку';
export interface IRouteStop { export interface IRouteStop {
/** Стабильный ключ точки: на него ссылаются позиции сметы (IEstimateItem.linkId). */ /** Стабильный ключ точки: на него ссылаются позиции сметы (IEstimateItem.linkId). */
id: string; id: string;
/**
* true — точка за рубежом: страна и город вводятся вручную (справочник КАТО только по РК),
* у суточных и проживания свои лимиты. Признак у точки, а не у поездки: бывают смешанные
* маршруты (Алматы → Москва → Атырау).
*/
abroad: boolean;
/** Страна — только у зарубежной точки; у точки по Казахстану ''. */
country: string;
/** Город на русском — из справочника КАТО (shared/dictionary/kato) или введён вручную. */ /** Город на русском — из справочника КАТО (shared/dictionary/kato) или введён вручную. */
city: string; city: string;
/** Город на казахском; '' — введён вручную (в справочнике не нашёлся). */ /** Город на казахском; '' — введён вручную (в справочнике не нашёлся). */
@@ -28,7 +36,10 @@ export interface IRouteStop {
cityKato: string; cityKato: string;
/** Объект — проект из справочника /Project (см. shared/dictionary/ProjectPicker). */ /** Объект — проект из справочника /Project (см. shared/dictionary/ProjectPicker). */
project: string; project: string;
/** oid проекта; '' — текст введён вручную без выбора из списка (отправка не пропустит). */ /**
* oid проекта; '' — текст введён вручную без выбора из списка. По Казахстану отправка
* такое не пропустит; за рубежом можно — там объект часто чужой (завод, выставка).
*/
projectId: string; projectId: string;
/** YYYY-MM-DD — прибытие в точку. */ /** YYYY-MM-DD — прибытие в точку. */
dateFrom: string; dateFrom: string;
@@ -59,6 +70,16 @@ export interface ITripRoute {
/** Ключ «точки» города отправления в ключах участков. */ /** Ключ «точки» города отправления в ключах участков. */
export const ORIGIN_ID = 'origin'; export const ORIGIN_ID = 'origin';
/**
* Вид поездки — значения Choice-колонки TripKind. Выводится из точек: хотя бы одна
* за рубежом — вся поездка зарубежная (по ней будут маршрут согласования и отчёты).
*/
export const TRIP_KIND = {
domestic: 'По Казахстану',
abroad: 'Зарубежная'
} as const;
export type TripKind = (typeof TRIP_KIND)[keyof typeof TRIP_KIND];
/** /**
* Вид транспорта участка. Предположение: в бланке список не задан — варианты * Вид транспорта участка. Предположение: в бланке список не задан — варианты
* взяты типовые; поправить здесь, если кадры используют другие. * взяты типовые; поправить здесь, если кадры используют другие.
@@ -314,7 +335,9 @@ export const EMPTY_ROUTE: ITripRoute = {
origin: '', origin: '',
originKz: '', originKz: '',
originKato: '', originKato: '',
stops: [{ id: FIRST_STOP_ID, city: '', cityKz: '', cityKato: '', project: '', projectId: '', dateFrom: '', dateTo: '' }], stops: [
{ id: FIRST_STOP_ID, abroad: false, country: '', city: '', cityKz: '', cityKato: '', project: '', projectId: '', dateFrom: '', dateTo: '' }
],
returnToOrigin: true, returnToOrigin: true,
legTransport: {} legTransport: {}
}; };
@@ -371,5 +394,7 @@ export const OBLIGATION_TEXT =
export const OBLIGATION_LABEL = 'Ознакомлен(а) и согласен(на)'; export const OBLIGATION_LABEL = 'Ознакомлен(а) и согласен(на)';
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…'; export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
export const CITY_PLACEHOLDER = 'Начните вводить город: Атырау, Құлсары…'; export const COUNTRY_PLACEHOLDER = 'Напр.: Китай';
export const ABROAD_CITY_PLACEHOLDER = 'Напр.: Пекин';
export const CITY_PLACEHOLDER ='Начните вводить город: Атырау, Құлсары…';
export const PROJECT_PLACEHOLDER ='Начните вводить название или код объекта…'; export const PROJECT_PLACEHOLDER ='Начните вводить название или код объекта…';
@@ -11,6 +11,7 @@
{ "internalName": "TripCompany", "title": "Командирующая организация", "type": "Text" }, { "internalName": "TripCompany", "title": "Командирующая организация", "type": "Text" },
{ "internalName": "TripCompanyId", "title": "Id командирующей организации (справочник)", "type": "Text" }, { "internalName": "TripCompanyId", "title": "Id командирующей организации (справочник)", "type": "Text" },
{ "internalName": "TripKind", "title": "Вид командировки", "type": "Choice", "choices": ["По Казахстану", "Зарубежная"] },
{ "internalName": "TripRouteJson", "title": "Маршрут (JSON)", "type": "Note" }, { "internalName": "TripRouteJson", "title": "Маршрут (JSON)", "type": "Note" },
{ "internalName": "TripRoute", "title": "Маршрут", "type": "Note" }, { "internalName": "TripRoute", "title": "Маршрут", "type": "Note" },
{ "internalName": "TripCity", "title": "Города", "type": "Text" }, { "internalName": "TripCity", "title": "Города", "type": "Text" },
@@ -1,4 +1,4 @@
import { EMPTY_ROUTE, ITripRoute } from './model'; import { EMPTY_ROUTE, ITripRoute, TRIP_KIND } from './model';
import { import {
createStop, createStop,
legLabel, legLabel,
@@ -9,7 +9,9 @@ import {
routeProjects, routeProjects,
routeText, routeText,
serializeRoute, serializeRoute,
stopNights stopLocationPatch,
stopNights,
tripKind
} from './route'; } from './route';
const ROUTE: ITripRoute = { const ROUTE: ITripRoute = {
@@ -17,8 +19,8 @@ const ROUTE: ITripRoute = {
originKz: '', originKz: '',
originKato: '', originKato: '',
stops: [ stops: [
{ id: 's1', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }, { id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' } { id: 's2', abroad: false, country: '', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
], ],
returnToOrigin: true, returnToOrigin: true,
legTransport: { 'origin>s1': 'Авиа' } legTransport: { 'origin>s1': 'Авиа' }
@@ -66,6 +68,21 @@ describe('сводка маршрута', () => {
expect(createStop(ROUTE.stops[1]).dateFrom).toBe('2026-10-10'); expect(createStop(ROUTE.stops[1]).dateFrom).toBe('2026-10-10');
expect(createStop(undefined).dateFrom).toBe(''); expect(createStop(undefined).dateFrom).toBe('');
}); });
it('зарубежная точка: страна и город, поездка зарубежная', () => {
const beijing = { ...ROUTE.stops[1], abroad: true, country: 'Китай', city: 'Пекин' };
const route: ITripRoute = { ...ROUTE, stops: [ROUTE.stops[0], beijing] };
expect(routeCities(route)).toBe('Атырау, Китай, Пекин');
expect(tripKind(route)).toBe(TRIP_KIND.abroad);
expect(tripKind(ROUTE)).toBe(TRIP_KIND.domestic);
// Следующая точка — в той же стране, что предыдущая.
expect([createStop(beijing).abroad, createStop(beijing).country]).toEqual([true, 'Китай']);
});
it('смена «Казахстан / другая страна» сбрасывает чужие поля, город остаётся', () => {
expect(stopLocationPatch(true)).toEqual({ abroad: true, cityKz: '', cityKato: '' });
expect(stopLocationPatch(false)).toEqual({ abroad: false, country: '' });
});
}); });
describe('хранение маршрута', () => { describe('хранение маршрута', () => {
@@ -73,10 +90,11 @@ describe('хранение маршрута', () => {
expect(parseRoute(serializeRoute(ROUTE))).toEqual(ROUTE); expect(parseRoute(serializeRoute(ROUTE))).toEqual(ROUTE);
}); });
it('заявка до справочника КАТО — город как был, без казахского названия', () => { it('старая заявка (до КАТО и признака страны) — город как был, точка по Казахстану', () => {
const old = { origin: 'Алматы', stops: [{ id: 's1', city: 'Атырау', project: '', projectId: '', dateFrom: '', dateTo: '' }] }; const old = { origin: 'Алматы', stops: [{ id: 's1', city: 'Атырау', project: '', projectId: '', dateFrom: '', dateTo: '' }] };
const route = parseRoute(JSON.stringify(old)); const route = parseRoute(JSON.stringify(old));
expect([route.originKz, route.stops[0].cityKz, route.stops[0].cityKato]).toEqual(['', '', '']); expect([route.originKz, route.stops[0].cityKz, route.stops[0].cityKato]).toEqual(['', '', '']);
expect([route.stops[0].abroad, route.stops[0].country]).toEqual([false, '']);
expect(routeCities(route)).toBe('Атырау'); expect(routeCities(route)).toBe('Атырау');
}); });
@@ -1,7 +1,7 @@
import { formatIsoDate } from '../../shared/dateUtils'; import { formatIsoDate } from '../../shared/dateUtils';
// Прямой импорт, не через shared/dictionary: индекс тянет сервисы с sp-http, а route.ts тестируется без SPFx. // Прямой импорт, не через shared/dictionary: индекс тянет сервисы с sp-http, а route.ts тестируется без SPFx.
import { cityLabel } from '../../shared/dictionary/kato/cityLabel'; import { cityLabel } from '../../shared/dictionary/kato/cityLabel';
import { EMPTY_ROUTE, IRouteStop, ITripRoute, ORIGIN_ID } from './model'; import { EMPTY_ROUTE, IRouteStop, ITripRoute, ORIGIN_ID, TRIP_KIND, TripKind } from './model';
import { tripDays } from './tripData'; import { tripDays } from './tripData';
/** Участок пути (переезд) между двумя соседними точками маршрута. */ /** Участок пути (переезд) между двумя соседними точками маршрута. */
@@ -26,9 +26,27 @@ export function newId(prefix: string): string {
const ORIGIN_FALLBACK = 'Город отправления'; const ORIGIN_FALLBACK = 'Город отправления';
const STOP_FALLBACK = 'Точка маршрута'; const STOP_FALLBACK = 'Точка маршрута';
/** Город точки на двух языках: «Кульсары (Құлсары)»; '' — не указан. */ /**
* Город точки: по Казахстану — на двух языках «Кульсары (Құлсары)», за рубежом —
* страна и город «Китай, Пекин». '' — не указан.
*/
export function stopCityLabel(stop: IRouteStop): string { export function stopCityLabel(stop: IRouteStop): string {
return cityLabel(stop.city, stop.cityKz); if (!stop.abroad) return cityLabel(stop.city, stop.cityKz);
return [stop.country.trim(), stop.city.trim()].filter(Boolean).join(', ');
}
/**
* Поля точки при смене «Казахстан / другая страна». Введённый город остаётся, но
* за рубежом он уже не из справочника КАТО (казахское название и код сбрасываются),
* а у точки по Казахстану страны нет.
*/
export function stopLocationPatch(abroad: boolean): Partial<IRouteStop> {
return abroad ? { abroad: true, cityKz: '', cityKato: '' } : { abroad: false, country: '' };
}
/** Вид поездки для колонки TripKind: хотя бы одна точка за рубежом — зарубежная. */
export function tripKind(route: ITripRoute): TripKind {
return route.stops.some((s) => s.abroad) ? TRIP_KIND.abroad : TRIP_KIND.domestic;
} }
/** Город отправления на двух языках; '' — не указан. */ /** Город отправления на двух языках; '' — не указан. */
@@ -128,12 +146,15 @@ export function routeText(route: ITripRoute): string {
} }
/** /**
* Новая точка после последней. Дата прибытия — дата отъезда из предыдущей точки: * Новая точка после последней. Дата прибытия — дата отъезда из предыдущей точки,
* это не догадка, а то, что человек сам уже ввёл; поле остаётся редактируемым. * страна (Казахстан / другая) — как у предыдущей: это не догадка, а то, что
* человек сам уже ввёл; поля остаются редактируемыми.
*/ */
export function createStop(previous: IRouteStop | undefined): IRouteStop { export function createStop(previous: IRouteStop | undefined): IRouteStop {
return { return {
id: newId('stop'), id: newId('stop'),
abroad: previous?.abroad ?? false,
country: previous?.abroad ? previous.country : '',
city: '', city: '',
cityKz: '', cityKz: '',
cityKato: '', cityKato: '',
@@ -157,6 +178,9 @@ function toStop(value: unknown): IRouteStop | undefined {
if (!id) return undefined; if (!id) return undefined;
return { return {
id, id,
// Заявки до признака «другая страна» — все точки по Казахстану.
abroad: raw.abroad === true,
country: text(raw.country),
city: text(raw.city), city: text(raw.city),
// Заявки до справочника КАТО — без этих полей: город остаётся введённым вручную. // Заявки до справочника КАТО — без этих полей: город остаётся введённым вручную.
cityKz: text(raw.cityKz), cityKz: text(raw.cityKz),
@@ -5,7 +5,7 @@ import { submitDateOnSave } from '../../shared/submitDate';
import { formatIsoDate } from '../../shared/dateUtils'; import { formatIsoDate } from '../../shared/dateUtils';
import { FORM_ID, FORM_TITLE, IFormData } from './model'; import { FORM_ID, FORM_TITLE, IFormData } from './model';
import { filledTasks, formatTasks, tripDays } from './tripData'; import { filledTasks, formatTasks, tripDays } from './tripData';
import { routeCities, routePeriod, routeProjects, routeText, serializeRoute } from './route'; import { routeCities, routePeriod, routeProjects, routeText, serializeRoute, tripKind } from './route';
import { estimateTotal, formatEstimateText, serializeEstimate } from './estimate'; import { estimateTotal, formatEstimateText, serializeEstimate } from './estimate';
/** Сервис списка согласования этой формы — см. shared/approval/approvalServiceFactory.ts. */ /** Сервис списка согласования этой формы — см. shared/approval/approvalServiceFactory.ts. */
@@ -37,6 +37,7 @@ function buildItem(data: IFormData, status: string, employeeId: number, employee
TripCompany: data.TripCompany || null, TripCompany: data.TripCompany || null,
TripCompanyId: data.TripCompanyId || null, TripCompanyId: data.TripCompanyId || null,
TripKind: tripKind(route),
TripRouteJson: serializeRoute(route), TripRouteJson: serializeRoute(route),
TripRoute: routeText(route) || null, TripRoute: routeText(route) || null,
TripCity: routeCities(route) || null, TripCity: routeCities(route) || null,
@@ -10,7 +10,7 @@ const ROUTE: ITripRoute = {
origin: 'Алматы', origin: 'Алматы',
originKz: '', originKz: '',
originKato: '', originKato: '',
stops: [{ id: 's1', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }], stops: [{ id: 's1', abroad: false, country: '', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }],
returnToOrigin: true, returnToOrigin: true,
legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' } legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' }
}; };
@@ -61,6 +61,18 @@ describe('validation: маршрут', () => {
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']); expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s1-project']);
}); });
it('зарубежная точка — нужна страна', () => {
const route = { ...ROUTE, stops: [{ ...ROUTE.stops[0], abroad: true, country: ' ' }] };
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s1-country']);
});
it('зарубежная точка — объект можно ввести вручную, но не пустым', () => {
const abroad = { ...ROUTE.stops[0], abroad: true, country: 'Китай', project: 'Завод Китайский', projectId: '' };
expect(submit({ ...FILLED, Route: { ...ROUTE, stops: [abroad] } })).toEqual([]);
const empty = { ...abroad, project: ' ' };
expect(fields(submit({ ...FILLED, Route: { ...ROUTE, stops: [empty] } }))).toEqual(['stop-s1-project']);
});
it('у каждого участка нужен вид транспорта', () => { it('у каждого участка нужен вид транспорта', () => {
const route = { ...ROUTE, legTransport: { 'origin>s1': 'Авиа' } }; const route = { ...ROUTE, legTransport: { 'origin>s1': 'Авиа' } };
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']); expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['leg-s1>origin']);
@@ -78,7 +90,7 @@ describe('validation: маршрут', () => {
}); });
it('прибытие в следующую точку раньше отъезда из предыдущей', () => { it('прибытие в следующую точку раньше отъезда из предыдущей', () => {
const second = { id: 's2', city: 'Актау', cityKz: '', cityKato: '', project: 'Порт', projectId: 'p2', dateFrom: '2026-10-06', dateTo: '2026-10-09' }; const second = { id: 's2', abroad: false, country: '', 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': 'Авиа' } }; const route = { ...ROUTE, stops: [...ROUTE.stops, second], legTransport: { 'origin>s1': 'Авиа', 's1>s2': 'Авиа', 's2>origin': 'Авиа' } };
expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']); expect(fields(submit({ ...FILLED, Route: route }))).toEqual(['stop-s2-from']);
}); });
@@ -12,6 +12,7 @@ import { articleTitle, itemAmount, itemLinkLabel, linkOptions, resolveLinkId } f
*/ */
export const fieldKey = { export const fieldKey = {
origin: 'route-origin', origin: 'route-origin',
stopCountry: (stop: IRouteStop): string => `stop-${stop.id}-country`,
stopCity: (stop: IRouteStop): string => `stop-${stop.id}-city`, stopCity: (stop: IRouteStop): string => `stop-${stop.id}-city`,
stopProject: (stop: IRouteStop): string => `stop-${stop.id}-project`, stopProject: (stop: IRouteStop): string => `stop-${stop.id}-project`,
stopFrom: (stop: IRouteStop): string => `stop-${stop.id}-from`, stopFrom: (stop: IRouteStop): string => `stop-${stop.id}-from`,
@@ -46,9 +47,16 @@ function stopDateOrderIssues(route: ITripRoute): IFieldIssue[] {
function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined, today: string): IFieldIssue[] { function stopIssues(stop: IRouteStop, index: number, previous: IRouteStop | undefined, today: string): IFieldIssue[] {
const label = stopName(stop, index); const label = stopName(stop, index);
const issues: IFieldIssue[] = []; const issues: IFieldIssue[] = [];
if (stop.abroad && !stop.country.trim()) issues.push({ field: fieldKey.stopCountry(stop), label, message: 'укажите страну' });
if (!stop.city.trim()) issues.push({ field: fieldKey.stopCity(stop), label, message: 'укажите город' }); if (!stop.city.trim()) issues.push({ field: fieldKey.stopCity(stop), label, message: 'укажите город' });
// Объект — именно из справочника (projectId): по нему потом разносят затраты по проектам. // По Казахстану объект — именно из справочника (projectId): по нему разносят затраты по проектам.
if (!stop.projectId) issues.push({ field: fieldKey.stopProject(stop), label, message: 'выберите объект из списка' }); // За рубежом едут на чужую площадку (завод, выставка) — её в справочнике нет, достаточно текста.
if (stop.abroad && !stop.project.trim()) {
issues.push({ field: fieldKey.stopProject(stop), label, message: 'укажите объект или место назначения' });
}
if (!stop.abroad && !stop.projectId) {
issues.push({ field: fieldKey.stopProject(stop), label, message: 'выберите объект из списка' });
}
if (!stop.dateFrom) issues.push({ field: fieldKey.stopFrom(stop), label, message: 'укажите дату прибытия' }); if (!stop.dateFrom) issues.push({ field: fieldKey.stopFrom(stop), label, message: 'укажите дату прибытия' });
// Задним числом командировку не оформляют. Сегодня — можно: выезд в день подачи. // Задним числом командировку не оформляют. Сегодня — можно: выезд в день подачи.
if (stop.dateFrom && stop.dateFrom < today) { if (stop.dateFrom && stop.dateFrom < today) {