feat: город в командировке из справочника на русском и казахском

Город отправления и города точек выбираются из справочника КАТО; в маршруте
хранятся оба названия и код (city/cityKz/cityKato, origin/originKz/originKato
внутри TripRouteJson — новых колонок нет). Везде, включая TripCity/TripRoute и
заголовок, город показывается как «Кульсары (Құлсары)». Введённый вручную
город (село, заграница) сохраняется текстом. README дополнен и описанием
командирующей организации.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-23 14:24:10 +05:00
parent c892fb26a9
commit 08dd151c56
13 changed files with 115 additions and 35 deletions
@@ -18,10 +18,26 @@
## Особое поведение
- **Можно подать за другого сотрудника.** По умолчанию — сам инициатор; при выборе другого
подтягиваются его должность и организации (`Employee/ByEmail`, `useTripEmployee`).
- **Командирующая организация** (с 10-23, логика — `tripCompany.ts`). Сотрудник может работать
в нескольких компаниях — выбирают, от какой он едет (`Select` «Компания — должность»).
Одна организация — выбирается сама, поле заблокировано. Несколько — обязательна при отправке,
должность пустая до выбора и подставляется из выбранной компании. Сотрудника нет в
справочнике — поля нет, отправку не блокирует. Смена сотрудника сбрасывает выбор. Хранение:
`TripCompany` (название) + `TripCompanyId` (CompanyId — под будущий маршрут по компании);
`Organization` — по-прежнему все организации сотрудника. Заявки до 10-23 в просмотре
показывают прежний список организаций.
- **Маршрут — лента точек** (`components/route`, логика — `route.ts`; гибрид «маршрут + смета»
по образцу Concur/Navan/TripIt). Город отправления → точки (город + объект из `/Project` +
даты прибытия/отъезда) → (возвращение — переключатель). Простая поездка — одна точка;
«+ Добавить точку маршрута» — для поездок по нескольким объектам.
- **Города — из справочника КАТО** (`shared/dictionary/kato`, поле `CityPicker`): 178 городов
и посёлков РК из официального КАТО (stat.gov.kz), сгенерированы `scripts/Build-KatoCities.ps1`.
Поиск по русскому и казахскому названию с учётом раскладки (Құлсары = Кулсары = Кульсары);
набранное целиком однозначное название подставляется само. Выбор сохраняет оба названия и
код КАТО (`city`/`cityKz`/`cityKato`, у города отправления — `origin`/`originKz`/`originKato`,
всё внутри `TripRouteJson`), показывается везде как «Кульсары (Құлсары)» (`cityLabel`), в том
числе в `TripCity`/`TripRoute` и заголовке. Нет в справочнике (село, заграница) — сохраняется
введённый текст без казахского названия.
- Участки пути не хранятся, а выводятся из точек (`routeLegs`, ключ `откуда>куда`); у
каждого участка — вид транспорта плашками (`legTransport`, `TRANSPORT_TYPES`), обязателен.
- Объект точки — только выбор из справочника (`projectId`): по объектам разносят затраты.
@@ -76,7 +92,7 @@
заголовком (см. грабли коллизий internalName).
## Секции формы
EmployeeSection (сотрудник · должность · дата подачи · организация) → RouteSection (маршрут
EmployeeSection (сотрудник · должность · дата подачи · командирующая организация) → RouteSection (маршрут
лентой + цель) → TasksSection (задачи) → EstimateSection (смета + итог) →
ObligationSection (текст обязательства + согласие).
@@ -3,6 +3,7 @@ import styles from './PerDiemFields.module.scss';
import { FormField, MoneyInput } from '../../../../shared/ui';
import { IEstimateItem, IRouteStop, MRP_VALUE, MRP_YEAR } from '../../model';
import { formatAmount, itemAmount } from '../../estimate';
import { stopCityLabel } from '../../route';
import { fieldKey } from '../../validation';
interface Props {
@@ -23,7 +24,7 @@ export const PerDiemFields: React.FC<Props> = ({ item, stop, onChange }) => {
return (
<div className={styles.wrap}>
<div className={styles.place}>
<span className={styles.city}>{stop?.city.trim() || 'Точка маршрута'}</span>
<span className={styles.city}>{(stop && stopCityLabel(stop)) || 'Точка маршрута'}</span>
<span className={styles.project}>Проект: {project || 'не выбран'}</span>
</div>
@@ -1,9 +1,10 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './RouteEditor.module.scss';
import { FormField, Icon, TextInput, Toggle, useFormReadOnly } from '../../../../shared/ui';
import { IRouteStop, ITripRoute } from '../../model';
import { IRouteLeg, createStop, routeLegs } from '../../route';
import { FormField, Icon, Toggle, useFormReadOnly } from '../../../../shared/ui';
import { CityPicker } from '../../../../shared/dictionary';
import { CITY_PLACEHOLDER, IRouteStop, ITripRoute } from '../../model';
import { IRouteLeg, createStop, originLabel, routeLegs } from '../../route';
import { fieldKey } from '../../validation';
import { RouteStopCard } from './RouteStopCard';
import { RouteLegRow } from './RouteLegRow';
@@ -71,11 +72,11 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
<TimelineRow marker="endpoint">
<div className={styles.origin}>
<FormField label="Откуда (город отправления)" htmlFor={fieldKey.origin} required>
<TextInput
<CityPicker
id={fieldKey.origin}
value={route.origin}
onChange={(v) => onChange({ ...route, origin: v })}
placeholder="Напр.: Алматы"
value={{ name: route.origin, nameKz: route.originKz, kato: route.originKato }}
onChange={(city) => onChange({ ...route, origin: city.name, originKz: city.nameKz, originKato: city.kato })}
placeholder={CITY_PLACEHOLDER}
/>
</FormField>
</div>
@@ -111,13 +112,13 @@ export const RouteEditor: React.FC<Props> = ({ route, onChange, context }) => {
<div className={styles.end}>
{readOnly ? (
<span className={styles.endText}>
{route.returnToOrigin ? `Возвращение: ${route.origin || 'город отправления'}` : 'Без возвращения в город отправления'}
{route.returnToOrigin ? `Возвращение: ${originLabel(route) || 'город отправления'}` : 'Без возвращения в город отправления'}
</span>
) : (
<Toggle
checked={route.returnToOrigin}
onChange={(returnToOrigin) => onChange({ ...route, returnToOrigin })}
onLabel={`Возвращение в ${route.origin.trim() || 'город отправления'}`}
onLabel={`Возвращение в ${originLabel(route) || 'город отправления'}`}
/>
)}
</div>
@@ -1,7 +1,7 @@
import * as React from 'react';
import styles from './RouteScheme.module.scss';
import { IEstimateArticle, ITripRoute } from '../../model';
import { routeLegs, routePeriod, routeProjects } from '../../route';
import { originLabel, routeLegs, routePeriod, routeProjects, stopCityLabel } from '../../route';
import { estimateTotal, formatAmount, stopTotals } from '../../estimate';
import { plural, tripDays } from '../../tripData';
import { ISchemeNode } from './schemeNode';
@@ -18,14 +18,14 @@ interface Props {
/** Узлы схемы по порядку: город отправления → точки → (возвращение). */
function schemeNodes(route: ITripRoute, amounts: Map<string, number>): ISchemeNode[] {
const period = routePeriod(route);
const origin = route.origin.trim();
const origin = originLabel(route);
const withDate = (caption: string, iso: string): string => (iso ? `${caption} ${shortDate(iso)}` : caption);
const nodes: ISchemeNode[] = [
{ key: 'origin', kind: 'endpoint', city: origin, caption: withDate('выезд', period.start), amount: 0 }
];
route.stops.forEach((stop, i) =>
nodes.push({ key: stop.id, kind: 'stop', number: i + 1, stop, city: stop.city.trim(), amount: amounts.get(stop.id) || 0 })
nodes.push({ key: stop.id, kind: 'stop', number: i + 1, stop, city: stopCityLabel(stop), amount: amounts.get(stop.id) || 0 })
);
if (route.returnToOrigin) {
nodes.push({ key: 'return', kind: 'endpoint', city: origin, caption: withDate('возвращение', period.end), amount: 0 });
@@ -2,8 +2,8 @@ import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './RouteStopCard.module.scss';
import { FormField, Icon, TextInput } from '../../../../shared/ui';
import { ProjectPicker } from '../../../../shared/dictionary';
import { IRouteStop, PROJECT_PLACEHOLDER } from '../../model';
import { CityPicker, ProjectPicker } from '../../../../shared/dictionary';
import { CITY_PLACEHOLDER, IRouteStop, PROJECT_PLACEHOLDER } from '../../model';
import { stopNights } from '../../route';
import { plural } from '../../tripData';
import { fieldKey } from '../../validation';
@@ -41,7 +41,12 @@ export const RouteStopCard: React.FC<Props> = ({ stop, index, context, onChange,
</div>
<div className={styles.grid}>
<FormField label="Город, населённый пункт" htmlFor={fieldKey.stopCity(stop)} required>
<TextInput id={fieldKey.stopCity(stop)} value={stop.city} onChange={(v) => onChange({ city: v })} />
<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>
<FormField label="Объект" htmlFor={fieldKey.stopProject(stop)} required>
<ProjectPicker
@@ -39,7 +39,7 @@ export const SchemeNodeLabel: React.FC<Props> = ({ node }) => {
const dates = stopDates(stop);
return (
<span className={styles.text}>
<span className={styles.city}>{stop.city.trim() || `Город ${NOT_SET}`}</span>
<span className={styles.city}>{node.city || `Город ${NOT_SET}`}</span>
{/* Названия объектов в справочнике бывают с полным адресом — режем до 2 строк, полное — в подсказке. */}
<span className={styles.project} title={stop.project.trim() || undefined}>
{stop.project.trim() || 'объект не выбран'}
@@ -8,7 +8,7 @@ export interface ISchemeNode {
number?: number;
/** Сама точка — только у точки маршрута. */
stop?: IRouteStop;
/** Город отправления — у endpoint. */
/** Город на двух языках (см. route.ts/stopCityLabel): отправления — у endpoint, точки — у stop. */
city: string;
/** «выезд 02.10» / «возвращение 05.10» — у endpoint. */
caption?: string;
@@ -17,9 +17,11 @@ import {
const ROUTE: ITripRoute = {
origin: 'Алматы',
originKz: '',
originKato: '',
stops: [
{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', city: 'Актау', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
{ id: 's1', 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' }
],
returnToOrigin: true,
legTransport: {}
@@ -20,7 +20,12 @@ export const FORM_TITLE = 'Заявка на командировку';
export interface IRouteStop {
/** Стабильный ключ точки: на него ссылаются позиции сметы (IEstimateItem.linkId). */
id: string;
/** Город на русском — из справочника КАТО (shared/dictionary/kato) или введён вручную. */
city: string;
/** Город на казахском; '' — введён вручную (в справочнике не нашёлся). */
cityKz: string;
/** Код КАТО города; '' — введён вручную. */
cityKato: string;
/** Объект — проект из справочника /Project (см. shared/dictionary/ProjectPicker). */
project: string;
/** oid проекта; '' — текст введён вручную без выбора из списка (отправка не пропустит). */
@@ -38,8 +43,12 @@ export interface IRouteStop {
* участка по его ключу.
*/
export interface ITripRoute {
/** Город отправления (обычно город работы). */
/** Город отправления (обычно город работы), на русском. */
origin: string;
/** Город отправления на казахском; '' — введён вручную. */
originKz: string;
/** Код КАТО города отправления; '' — введён вручную. */
originKato: string;
stops: IRouteStop[];
/** Обратный путь из последней точки в город отправления. */
returnToOrigin: boolean;
@@ -306,7 +315,9 @@ export const FIRST_STOP_ID = 'stop-1';
export const EMPTY_ROUTE: ITripRoute = {
origin: '',
stops: [{ id: FIRST_STOP_ID, city: '', project: '', projectId: '', dateFrom: '', dateTo: '' }],
originKz: '',
originKato: '',
stops: [{ id: FIRST_STOP_ID, city: '', cityKz: '', cityKato: '', project: '', projectId: '', dateFrom: '', dateTo: '' }],
returnToOrigin: true,
legTransport: {}
};
@@ -364,4 +375,5 @@ export const OBLIGATION_TEXT =
export const OBLIGATION_LABEL = 'Ознакомлен(а) и согласен(на)';
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
export const CITY_PLACEHOLDER = 'Начните вводить город: Атырау, Құлсары…';
export const PROJECT_PLACEHOLDER ='Начните вводить название или код объекта…';
@@ -14,9 +14,11 @@ import {
const ROUTE: ITripRoute = {
origin: 'Алматы',
originKz: '',
originKato: '',
stops: [
{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' },
{ id: 's2', city: 'Актау', project: 'Порт Курык', projectId: 'p2', dateFrom: '2026-10-08', dateTo: '2026-10-10' }
{ id: 's1', 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' }
],
returnToOrigin: true,
legTransport: { 'origin>s1': 'Авиа' }
@@ -48,6 +50,18 @@ describe('сводка маршрута', () => {
);
});
it('город из справочника — на двух языках, если названия различаются', () => {
const route: ITripRoute = {
...ROUTE,
origin: 'Уральск',
originKz: 'Орал',
stops: [{ ...ROUTE.stops[0], city: 'Кульсары', cityKz: 'Құлсары' }, { ...ROUTE.stops[1], cityKz: 'Актау' }],
returnToOrigin: false
};
expect(routeCities(route)).toBe('Кульсары (Құлсары), Актау');
expect(routeLegs(route).map(legLabel)).toEqual(['Уральск (Орал) → Кульсары (Құлсары)', 'Кульсары (Құлсары) → Актау']);
});
it('новая точка начинается с даты отъезда из предыдущей', () => {
expect(createStop(ROUTE.stops[1]).dateFrom).toBe('2026-10-10');
expect(createStop(undefined).dateFrom).toBe('');
@@ -59,6 +73,13 @@ describe('хранение маршрута', () => {
expect(parseRoute(serializeRoute(ROUTE))).toEqual(ROUTE);
});
it('заявка до справочника КАТО — город как был, без казахского названия', () => {
const old = { origin: 'Алматы', stops: [{ id: 's1', city: 'Атырау', project: '', projectId: '', dateFrom: '', dateTo: '' }] };
const route = parseRoute(JSON.stringify(old));
expect([route.originKz, route.stops[0].cityKz, route.stops[0].cityKato]).toEqual(['', '', '']);
expect(routeCities(route)).toBe('Атырау');
});
it('битый JSON или маршрут без точек — маршрут новой заявки', () => {
expect(parseRoute('{oops')).toEqual(EMPTY_ROUTE);
expect(parseRoute(JSON.stringify({ origin: 'Алматы', stops: [] }))).toEqual(EMPTY_ROUTE);
@@ -1,4 +1,6 @@
import { formatIsoDate } from '../../shared/dateUtils';
// Прямой импорт, не через shared/dictionary: индекс тянет сервисы с sp-http, а route.ts тестируется без SPFx.
import { cityLabel } from '../../shared/dictionary/kato/cityLabel';
import { EMPTY_ROUTE, IRouteStop, ITripRoute, ORIGIN_ID } from './model';
import { tripDays } from './tripData';
@@ -24,8 +26,18 @@ export function newId(prefix: string): string {
const ORIGIN_FALLBACK = 'Город отправления';
const STOP_FALLBACK = 'Точка маршрута';
/** Город точки на двух языках: «Кульсары (Құлсары)»; '' — не указан. */
export function stopCityLabel(stop: IRouteStop): string {
return cityLabel(stop.city, stop.cityKz);
}
/** Город отправления на двух языках; '' — не указан. */
export function originLabel(route: ITripRoute): string {
return cityLabel(route.origin, route.originKz);
}
function stopCity(stop: IRouteStop): string {
return stop.city.trim() || STOP_FALLBACK;
return stopCityLabel(stop) || STOP_FALLBACK;
}
/** Подпись точки в выпадающих списках сметы: «Атырау · Нефтебаза-2». */
@@ -40,7 +52,7 @@ export function stopLabel(stop: IRouteStop): string {
* участки пересобрались сами.
*/
export function routeLegs(route: ITripRoute): IRouteLeg[] {
const origin = { id: ORIGIN_ID, name: route.origin.trim() || ORIGIN_FALLBACK };
const origin = { id: ORIGIN_ID, name: originLabel(route) || ORIGIN_FALLBACK };
const points = [origin, ...route.stops.map((s) => ({ id: s.id, name: stopCity(s) }))];
if (route.returnToOrigin) points.push(origin);
@@ -91,7 +103,7 @@ const unique = (values: string[]): string[] =>
/** Города точек через запятую — для колонки TripCity и заголовка элемента. */
export function routeCities(route: ITripRoute): string {
return unique(route.stops.map((s) => s.city)).join(', ');
return unique(route.stops.map(stopCityLabel)).join(', ');
}
/** Объекты точек (без повторов) — для колонки TripProjects. */
@@ -106,12 +118,12 @@ function stopDates(stop: IRouteStop): string {
/** Маршрут читаемым текстом для колонки SP: «Алматы → Атырау (Нефтебаза-2, 05.10.2026–08.10.2026) → Алматы». */
export function routeText(route: ITripRoute): string {
const parts: string[] = [route.origin.trim() || ORIGIN_FALLBACK];
const parts: string[] = [originLabel(route) || ORIGIN_FALLBACK];
route.stops.forEach((stop) => {
const details = [stop.project.trim(), stopDates(stop)].filter(Boolean).join(', ');
parts.push(details ? `${stopCity(stop)} (${details})` : stopCity(stop));
});
if (route.returnToOrigin) parts.push(route.origin.trim() || ORIGIN_FALLBACK);
if (route.returnToOrigin) parts.push(originLabel(route) || ORIGIN_FALLBACK);
return parts.join(' → ');
}
@@ -123,6 +135,8 @@ export function createStop(previous: IRouteStop | undefined): IRouteStop {
return {
id: newId('stop'),
city: '',
cityKz: '',
cityKato: '',
project: '',
projectId: '',
dateFrom: previous?.dateTo || '',
@@ -144,6 +158,9 @@ function toStop(value: unknown): IRouteStop | undefined {
return {
id,
city: text(raw.city),
// Заявки до справочника КАТО — без этих полей: город остаётся введённым вручную.
cityKz: text(raw.cityKz),
cityKato: text(raw.cityKato),
project: text(raw.project),
projectId: text(raw.projectId),
dateFrom: text(raw.dateFrom),
@@ -177,6 +194,8 @@ export function parseRoute(raw: unknown): ITripRoute {
if (stops.length === 0) return EMPTY_ROUTE;
return {
origin: text(rawRoute.origin),
originKz: text(rawRoute.originKz),
originKato: text(rawRoute.originKato),
stops,
returnToOrigin: rawRoute.returnToOrigin !== false,
legTransport: toTransport(rawRoute.legTransport)
@@ -6,7 +6,9 @@ const off = (article: IEstimateArticle): IEstimateArticle => ({ ...article, enab
const ROUTE: ITripRoute = {
origin: 'Алматы',
stops: [{ id: 's1', city: 'Атырау', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }],
originKz: '',
originKato: '',
stops: [{ id: 's1', city: 'Атырау', cityKz: '', cityKato: '', project: 'Нефтебаза-2', projectId: 'p1', dateFrom: '2026-10-05', dateTo: '2026-10-08' }],
returnToOrigin: true,
legTransport: { 'origin>s1': 'Авиа', 's1>origin': 'Авиа' }
};
@@ -64,7 +66,7 @@ describe('validation: маршрут', () => {
});
it('прибытие в следующую точку раньше отъезда из предыдущей', () => {
const second = { id: 's2', city: 'Актау', project: 'Порт', projectId: 'p2', dateFrom: '2026-10-06', dateTo: '2026-10-09' };
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']);
});
@@ -2,7 +2,7 @@ import type { IFieldIssue } from '../../shared/ui';
import type { IEmployeeOrganization } from '../../shared/dictionary';
import { ESTIMATE_KINDS, IEstimateArticle, IEstimateItem, IFormData, IRouteStop, ITripRoute } from './model';
import { filledTasks } from './tripData';
import { legLabel, routeLegs } from './route';
import { legLabel, routeLegs, stopCityLabel } from './route';
import { articleTitle, itemAmount, itemLinkLabel, linkOptions, resolveLinkId } from './estimate';
/**
@@ -26,7 +26,8 @@ export const fieldKey = {
/** «Точка 2 (Актау)» — как точку видно в форме. */
function stopName(stop: IRouteStop, index: number): string {
return stop.city.trim() ? `Точка ${index + 1} (${stop.city.trim()})` : `Точка ${index + 1}`;
const city = stopCityLabel(stop);
return city ? `Точка ${index + 1} (${city})` : `Точка ${index + 1}`;
}
/** Дата окончания раньше начала — блокирует даже черновик: такие даты не сохранить осмысленно. */