feat: суточные в командировке считаются по маршруту
Суточные в смете строятся автоматически по точкам и датам маршрута (в МРП), чтобы инициатор не пересчитывал их вручную; проверки и сохранение работают с уже пересчитанной сметой. Форма командировки заодно подключена к перепланированию маршрута пустого черновика. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ import { useStableCallback } from '../../shared/useStableCallback';
|
|||||||
import { submitDateOnOpen } from '../../shared/submitDate';
|
import { submitDateOnOpen } from '../../shared/submitDate';
|
||||||
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, FORM_TITLE, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { draftIssues, submitIssues } from './validation';
|
import { draftIssues, submitIssues } from './validation';
|
||||||
|
import { syncPerDiem } from './estimate';
|
||||||
import { saveBusinessTripRequest, businessTripApprovalService } from './submit';
|
import { saveBusinessTripRequest, businessTripApprovalService } from './submit';
|
||||||
import { loadBusinessTripRequest } from './load';
|
import { loadBusinessTripRequest } from './load';
|
||||||
import { useTripEmployee } from './useTripEmployee';
|
import { useTripEmployee } from './useTripEmployee';
|
||||||
@@ -83,6 +84,9 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
|||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
// Суточные строятся по маршруту (см. syncPerDiem): секции, проверки и сохранение
|
||||||
|
// работают со сметой, уже приведённой к текущим точкам и датам.
|
||||||
|
const formData: IFormData = { ...data, Estimate: syncPerDiem(data.Estimate, data.Route) };
|
||||||
const handleEmployeeChange = useTripEmployee(context, editId, setData);
|
const handleEmployeeChange = useTripEmployee(context, editId, setData);
|
||||||
|
|
||||||
const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]);
|
const approval = useMemo(() => businessTripApprovalService(editListId), [editListId]);
|
||||||
@@ -115,14 +119,16 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
|||||||
context,
|
context,
|
||||||
approval,
|
approval,
|
||||||
requestId: editId,
|
requestId: editId,
|
||||||
|
replanEmptyDraft: true,
|
||||||
|
requestStatus,
|
||||||
employeeEmail: data.Employee,
|
employeeEmail: data.Employee,
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
editable: !readOnly
|
editable: !readOnly
|
||||||
});
|
});
|
||||||
|
|
||||||
// Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. validation.ts).
|
// Черновику нужен лишь сотрудник, отправке — все обязательные поля (см. validation.ts).
|
||||||
const draftMissing = draftIssues(data);
|
const draftMissing = draftIssues(formData);
|
||||||
const submitMissing = submitIssues(data);
|
const submitMissing = submitIssues(formData);
|
||||||
|
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (draftMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
|
if (draftMissing.length > 0) return `Заполните обязательные поля: ${issueLabels(draftMissing)}.`;
|
||||||
@@ -161,7 +167,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
|||||||
const { id, code } = await saveBusinessTripRequest(
|
const { id, code } = await saveBusinessTripRequest(
|
||||||
requestId,
|
requestId,
|
||||||
rtId,
|
rtId,
|
||||||
data,
|
formData,
|
||||||
toApprovers(matrix.rows),
|
toApprovers(matrix.rows),
|
||||||
toApprovers(executors.rows),
|
toApprovers(executors.rows),
|
||||||
status,
|
status,
|
||||||
@@ -196,7 +202,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
|||||||
onDeleted: onCancel
|
onDeleted: onCancel
|
||||||
});
|
});
|
||||||
|
|
||||||
const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange };
|
const sectionProps = { data: formData, set, context, onEmployeeChange: handleEmployeeChange };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}>
|
<FieldIssuesProvider draftIssues={draftMissing} submitIssues={submitMissing}>
|
||||||
|
|||||||
@@ -38,13 +38,20 @@
|
|||||||
`TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период).
|
`TripProjects` (все объекты), `TripStartDate`/`TripEndDate`/`TripDaysCount` (весь период).
|
||||||
- **Задание** — нумерованный список задач, хранится в `TripTasksJson` (+ читаемая копия
|
- **Задание** — нумерованный список задач, хранится в `TripTasksJson` (+ читаемая копия
|
||||||
`TripTasks` для представлений SP). Пустые строки не сохраняются.
|
`TripTasks` для представлений SP). Пустые строки не сохраняются.
|
||||||
- **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**: форма
|
- **Смета** (`components/estimate`, логика — `estimate.ts`). Всё вводится **вручную**, кроме
|
||||||
ничего не подставляет (решение Тимура — корректно подставлять нечего; про МРП забыть).
|
суточных:
|
||||||
- Статьи: основные Суточные / Проезд / Проживание (переключатель «Нужно / Не требуется»,
|
- **Суточные считаются сами** (с 10-09, по просьбе кадров): по позиции на каждую точку
|
||||||
|
маршрута (`syncPerDiem`, `itemsFromRoute` в `ESTIMATE_KINDS`), город и проект — из точки,
|
||||||
|
дни — из её дат включительно, но день переезда считается один раз (`stopPerDiemDays`).
|
||||||
|
Человек вводит только кол-во МРП в день; ставка = кол-во × `MRP_VALUE` (захардкожен в
|
||||||
|
model.ts вместе с `MRP_YEAR` — обновлять раз в год). Ставка сохраняется в заявке (`price`),
|
||||||
|
поэтому просмотр старой заявки не пересчитывается по новому МРП — `syncPerDiem` применяется
|
||||||
|
только в форме.
|
||||||
|
- Статьи: основные Суточные / Проезд / Проживание (переключатель «Требуется / Не требуется»,
|
||||||
выключенная печатается «не требуется»), Страховка и Виза (по одной) и «Другой расход»
|
выключенная печатается «не требуется»), Страховка и Виза (по одной) и «Другой расход»
|
||||||
со своим названием (сколько угодно) — кнопками «+», удаляются корзиной.
|
со своим названием (сколько угодно) — кнопками «+», удаляются корзиной.
|
||||||
- **Внутри статьи — позиции** («+ Ещё гостиница», «+ Ещё участок пути», «+ Ещё ставка
|
- **Внутри статьи — позиции** («+ Ещё гостиница», «+ Ещё участок пути»; у суточных
|
||||||
суточных»). Позиция привязана к точке маршрута (суточные, проживание, другой расход) или
|
позиции только из маршрута). Позиция привязана к точке маршрута (суточные, проживание, другой расход) или
|
||||||
к участку пути (проезд); страховка/виза — без привязки. Вариант один — выбор скрыт и
|
к участку пути (проезд); страховка/виза — без привязки. Вариант один — выбор скрыт и
|
||||||
подставляется сам (`resolveLinkId`); точку удалили — позицию нужно привязать заново.
|
подставляется сам (`resolveLinkId`); точку удалили — позицию нужно привязать заново.
|
||||||
- Сумма позиции — «Суммой» или «Кол-во × цена» (только перемножение введённого), подписи
|
- Сумма позиции — «Суммой» или «Кол-во × цена» (только перемножение введённого), подписи
|
||||||
@@ -54,7 +61,7 @@
|
|||||||
если объектов несколько, **итог по объектам** (`projectShares`; участок — к объекту
|
если объектов несколько, **итог по объектам** (`projectShares`; участок — к объекту
|
||||||
пункта назначения, обратный — к объекту, откуда уезжают; страховка/виза — «без объекта»).
|
пункта назначения, обратный — к объекту, откуда уезжают; страховка/виза — «без объекта»).
|
||||||
- Смета сама не обязательна, но у включённой статьи каждая позиция — с суммой и
|
- Смета сама не обязательна, но у включённой статьи каждая позиция — с суммой и
|
||||||
привязкой, у «Другого расхода» — название.
|
привязкой, у «Другого расхода» — название, у суточных — кол-во МРП.
|
||||||
- Хранение: `TripEstimateJson` (статьи + total), читаемо — `TripEstimate` (позиция на
|
- Хранение: `TripEstimateJson` (статьи + total), читаемо — `TripEstimate` (позиция на
|
||||||
строку), итог числом — `TripEstimateTotal`. Просмотр/печать — таблица по статьям.
|
строку), итог числом — `TripEstimateTotal`. Просмотр/печать — таблица по статьям.
|
||||||
- **Обязательство** по авансовому отчёту (текст бланка) — флажок, без него не отправить.
|
- **Обязательство** по авансовому отчёту (текст бланка) — флажок, без него не отправить.
|
||||||
|
|||||||
+11
-7
@@ -16,7 +16,7 @@ interface Props {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Статья сметы карточкой с позициями внутри: несколько гостиниц, участков пути,
|
* Статья сметы карточкой с позициями внутри: несколько гостиниц, участков пути,
|
||||||
* ставок суточных. Основная статья включается переключателем «нужно» —
|
* ставок суточных. Основная статья включается переключателем «требуется» —
|
||||||
* выключенная сворачивается в серую строку «не требуется». Добавленная
|
* выключенная сворачивается в серую строку «не требуется». Добавленная
|
||||||
* (страховка, виза, другой расход) всегда включена и удаляется корзиной.
|
* (страховка, виза, другой расход) всегда включена и удаляется корзиной.
|
||||||
*/
|
*/
|
||||||
@@ -24,6 +24,8 @@ export const EstimateArticleCard: React.FC<Props> = ({ article, route, onChange,
|
|||||||
const info = ESTIMATE_KINDS[article.kind];
|
const info = ESTIMATE_KINDS[article.kind];
|
||||||
const amount = articleAmount(article);
|
const amount = articleAmount(article);
|
||||||
const cardClass = article.enabled ? styles.card : `${styles.card} ${styles.off}`;
|
const cardClass = article.enabled ? styles.card : `${styles.card} ${styles.off}`;
|
||||||
|
// Позиции суточных строятся по точкам маршрута — вручную не добавляются и не удаляются.
|
||||||
|
const manualItems = !info.itemsFromRoute;
|
||||||
|
|
||||||
const updateItem = (id: string, patch: Partial<IEstimateItem>): void =>
|
const updateItem = (id: string, patch: Partial<IEstimateItem>): void =>
|
||||||
onChange({ ...article, items: article.items.map((item) => (item.id === id ? { ...item, ...patch } : item)) });
|
onChange({ ...article, items: article.items.map((item) => (item.id === id ? { ...item, ...patch } : item)) });
|
||||||
@@ -53,7 +55,7 @@ export const EstimateArticleCard: React.FC<Props> = ({ article, route, onChange,
|
|||||||
<Toggle
|
<Toggle
|
||||||
checked={article.enabled}
|
checked={article.enabled}
|
||||||
onChange={(enabled) => onChange({ ...article, enabled })}
|
onChange={(enabled) => onChange({ ...article, enabled })}
|
||||||
onLabel={article.enabled ? 'Нужно' : 'Не требуется'}
|
onLabel={article.enabled ? 'Требуется' : 'Не требуется'}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className={styles.remove} title="Убрать статью" onClick={onRemove}>
|
<button type="button" className={styles.remove} title="Убрать статью" onClick={onRemove}>
|
||||||
@@ -73,14 +75,16 @@ export const EstimateArticleCard: React.FC<Props> = ({ article, route, onChange,
|
|||||||
item={item}
|
item={item}
|
||||||
route={route}
|
route={route}
|
||||||
onChange={(patch) => updateItem(item.id, patch)}
|
onChange={(patch) => updateItem(item.id, patch)}
|
||||||
onRemove={article.items.length > 1 ? () => removeItem(item.id) : undefined}
|
onRemove={manualItems && article.items.length > 1 ? () => removeItem(item.id) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className={styles.addItem} onClick={() => onChange(addItem(article, route))}>
|
{manualItems && (
|
||||||
<Icon name="plus" size="sm" />
|
<button type="button" className={styles.addItem} onClick={() => onChange(addItem(article, route))}>
|
||||||
{info.addItemLabel}
|
<Icon name="plus" size="sm" />
|
||||||
</button>
|
{info.addItemLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-3
@@ -6,6 +6,7 @@ import { ESTIMATE_KINDS, EstimateKind, IEstimateItem, ITripRoute } from '../../m
|
|||||||
import { linkOptions, resolveLinkId } from '../../estimate';
|
import { linkOptions, resolveLinkId } from '../../estimate';
|
||||||
import { fieldKey } from '../../validation';
|
import { fieldKey } from '../../validation';
|
||||||
import { AmountFields } from './AmountFields';
|
import { AmountFields } from './AmountFields';
|
||||||
|
import { PerDiemFields } from './PerDiemFields';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
kind: EstimateKind;
|
kind: EstimateKind;
|
||||||
@@ -19,12 +20,13 @@ interface Props {
|
|||||||
/**
|
/**
|
||||||
* Позиция статьи: к чему относится (точка маршрута / участок пути), сумма и
|
* Позиция статьи: к чему относится (точка маршрута / участок пути), сумма и
|
||||||
* пояснение. Выбор «к чему относится» показываем, только когда вариантов
|
* пояснение. Выбор «к чему относится» показываем, только когда вариантов
|
||||||
* больше одного — в простой поездке в одну точку выбирать нечего.
|
* больше одного — в простой поездке в одну точку выбирать нечего. Суточные
|
||||||
|
* привязаны к точке раз и навсегда (строятся по маршруту) — у них свои поля.
|
||||||
*/
|
*/
|
||||||
export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange, onRemove }) => {
|
export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange, onRemove }) => {
|
||||||
const info = ESTIMATE_KINDS[kind];
|
const info = ESTIMATE_KINDS[kind];
|
||||||
const options: ISelectOption[] = linkOptions(kind, route).map((o) => ({ value: o.id, label: o.label }));
|
const options: ISelectOption[] = linkOptions(kind, route).map((o) => ({ value: o.id, label: o.label }));
|
||||||
const showLink = options.length > 1;
|
const showLink = !info.itemsFromRoute && options.length > 1;
|
||||||
const linkLabel = info.link === 'leg' ? 'Участок пути' : 'Точка маршрута';
|
const linkLabel = info.link === 'leg' ? 'Участок пути' : 'Точка маршрута';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -51,7 +53,11 @@ export const EstimateItemRow: React.FC<Props> = ({ kind, item, route, onChange,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<AmountFields kind={kind} item={item} onChange={onChange} />
|
{info.itemsFromRoute ? (
|
||||||
|
<PerDiemFields item={item} stop={route.stops.find((s) => s.id === item.linkId)} onChange={onChange} />
|
||||||
|
) : (
|
||||||
|
<AmountFields kind={kind} item={item} onChange={onChange} />
|
||||||
|
)}
|
||||||
<TextInput
|
<TextInput
|
||||||
value={item.comment}
|
value={item.comment}
|
||||||
onChange={(v) => onChange({ comment: v })}
|
onChange={(v) => onChange({ comment: v })}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const EstimateTable: React.FC<Props> = ({ articles, route }) => (
|
|||||||
<div>{itemLinkLabel(article.kind, item, route) || '—'}</div>
|
<div>{itemLinkLabel(article.kind, item, route) || '—'}</div>
|
||||||
{item.comment.trim() && <div className={styles.details}>{item.comment.trim()}</div>}
|
{item.comment.trim() && <div className={styles.details}>{item.comment.trim()}</div>}
|
||||||
</td>
|
</td>
|
||||||
<td className={styles.calc}>{itemCalculation(item) || '—'}</td>
|
<td className={styles.calc}>{itemCalculation(article.kind, item) || '—'}</td>
|
||||||
<td className={styles.num}>{formatAmount(itemAmount(item))}</td>
|
<td className={styles.num}>{formatAmount(itemAmount(item))}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
@import '../../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// С этой ширины «дни × МРП × значение = итог» помещается в одну строку.
|
||||||
|
$calc-inline-breakpoint: 640px;
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Город и проект точки — подзаголовок позиции.
|
||||||
|
.place {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: $sp-1 $sp-3;
|
||||||
|
font-family: $font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city {
|
||||||
|
font-size: $fs-body;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Числовое поле — как в AmountFields: цифры справа, моноширинные.
|
||||||
|
.number input {
|
||||||
|
text-align: right;
|
||||||
|
font-size: $fs-body;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значение, которое считает форма (дни, МРП): выглядит как поле, но не редактируется.
|
||||||
|
.readonly {
|
||||||
|
// Отступы и рамка — как у поля ввода ui-kit (padding 11px 13px, border 1px):
|
||||||
|
// тогда высота совпадает с «Кол-во МРП» и подписи над полями стоят на одной линии.
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
line-height: normal;
|
||||||
|
background: $gray-100;
|
||||||
|
color: $navy-900;
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-body;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Телефон: поля друг под другом, знаки операций прячем.
|
||||||
|
.calcRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultLabel {
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-label;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultValue {
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-body;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $navy-900;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-family: $font-sans;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: $calc-inline-breakpoint) {
|
||||||
|
.calcRow {
|
||||||
|
grid-template-columns:
|
||||||
|
minmax(0, 0.6fr) auto minmax(0, 0.8fr) auto minmax(0, 0.8fr) auto minmax(120px, auto);
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign {
|
||||||
|
display: block;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: $fs-body;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
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 { fieldKey } from '../../validation';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: IEstimateItem;
|
||||||
|
/** Точка маршрута позиции; суточные всегда строятся по точкам (см. syncPerDiem). */
|
||||||
|
stop: IRouteStop | undefined;
|
||||||
|
onChange: (patch: Partial<IEstimateItem>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Суточные одной точки маршрута: город и проект — из точки, дни — из её дат,
|
||||||
|
* человек вводит только количество МРП в день. Итог = дни × МРП × MRP_VALUE.
|
||||||
|
*/
|
||||||
|
export const PerDiemFields: React.FC<Props> = ({ item, stop, onChange }) => {
|
||||||
|
const hasDays = item.quantity !== '';
|
||||||
|
const project = stop?.project.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.place}>
|
||||||
|
<span className={styles.city}>{stop?.city.trim() || 'Точка маршрута'}</span>
|
||||||
|
<span className={styles.project}>Проект: {project || 'не выбран'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.calcRow}>
|
||||||
|
<FormField label="Дней">
|
||||||
|
<div className={styles.readonly} title="Считается по датам точки в маршруте">
|
||||||
|
{hasDays ? item.quantity : '—'}
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
<span className={styles.sign} aria-hidden>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
<FormField label="Кол-во МРП в день" htmlFor={fieldKey.itemMrpCount(item)}>
|
||||||
|
<div className={styles.number}>
|
||||||
|
<MoneyInput id={fieldKey.itemMrpCount(item)} value={item.mrpCount} onChange={(v) => onChange({ mrpCount: v })} />
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
<span className={styles.sign} aria-hidden>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
<FormField label={`МРП ${MRP_YEAR}`}>
|
||||||
|
<div className={styles.readonly}>{formatAmount(MRP_VALUE)} ₸</div>
|
||||||
|
</FormField>
|
||||||
|
<span className={styles.sign} aria-hidden>
|
||||||
|
=
|
||||||
|
</span>
|
||||||
|
<div className={styles.result}>
|
||||||
|
<span className={styles.resultLabel}>Итого</span>
|
||||||
|
<span className={styles.resultValue}>{formatAmount(itemAmount(item))} ₸</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasDays && <div className={styles.hint}>Укажите даты точки в маршруте — по ним считаются дни.</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { EMPTY_ESTIMATE, EMPTY_ROUTE, IEstimateArticle, IEstimateItem, ITripRoute } from './model';
|
import { EMPTY_ESTIMATE, EMPTY_ROUTE, IEstimateArticle, IEstimateItem, ITripRoute, MRP_VALUE } from './model';
|
||||||
import {
|
import {
|
||||||
addItem,
|
addItem,
|
||||||
articleAmount,
|
articleAmount,
|
||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
estimateShares,
|
estimateShares,
|
||||||
estimateTotal,
|
estimateTotal,
|
||||||
formatEstimateText,
|
formatEstimateText,
|
||||||
|
itemCalculation,
|
||||||
parseEstimate,
|
parseEstimate,
|
||||||
projectShares,
|
projectShares,
|
||||||
resolveLinkId,
|
resolveLinkId,
|
||||||
serializeEstimate,
|
serializeEstimate,
|
||||||
stopTotals
|
stopTotals,
|
||||||
|
syncPerDiem
|
||||||
} from './estimate';
|
} from './estimate';
|
||||||
|
|
||||||
const ROUTE: ITripRoute = {
|
const ROUTE: ITripRoute = {
|
||||||
@@ -31,6 +33,7 @@ const item = (patch: Partial<IEstimateItem>): IEstimateItem => ({
|
|||||||
quantity: '',
|
quantity: '',
|
||||||
price: '',
|
price: '',
|
||||||
comment: '',
|
comment: '',
|
||||||
|
mrpCount: '',
|
||||||
...patch
|
...patch
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,6 +111,56 @@ describe('привязка позиций', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('суточные по маршруту', () => {
|
||||||
|
const perDiem: IEstimateArticle = {
|
||||||
|
id: 'perDiem',
|
||||||
|
kind: 'perDiem',
|
||||||
|
title: '',
|
||||||
|
enabled: true,
|
||||||
|
items: [item({ id: 'p1', linkId: 's1', mrpCount: '6', comment: 'объект' })]
|
||||||
|
};
|
||||||
|
|
||||||
|
it('по позиции на точку; день переезда считается один раз', () => {
|
||||||
|
const [synced] = syncPerDiem([perDiem], ROUTE);
|
||||||
|
expect(synced.items.map((i) => [i.linkId, i.quantity])).toEqual([
|
||||||
|
['s1', '4'],
|
||||||
|
['s2', '2']
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ставка — кол-во МРП × МРП; введённое за точкой сохраняется', () => {
|
||||||
|
const [synced] = syncPerDiem([perDiem], ROUTE);
|
||||||
|
expect(synced.items[0]).toMatchObject({ id: 'p1', mrpCount: '6', comment: 'объект', price: String(6 * MRP_VALUE) });
|
||||||
|
expect(articleAmount(synced)).toBe(4 * 6 * MRP_VALUE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('новая точка — пустая позиция со стабильным id; удалённая точка — позиция уходит', () => {
|
||||||
|
const [synced] = syncPerDiem([perDiem], ROUTE);
|
||||||
|
expect(synced.items[1]).toMatchObject({ id: 'perDiem-s2', mrpCount: '', price: '' });
|
||||||
|
const [oneStop] = syncPerDiem([perDiem], { ...ROUTE, stops: [ROUTE.stops[1]] });
|
||||||
|
expect(oneStop.items.map((i) => i.linkId)).toEqual(['s2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без дат дней нет — и суммы нет', () => {
|
||||||
|
const noDates = { ...ROUTE, stops: [{ ...ROUTE.stops[0], dateTo: '' }] };
|
||||||
|
const [synced] = syncPerDiem([perDiem], noDates);
|
||||||
|
expect(synced.items[0].quantity).toBe('');
|
||||||
|
expect(articleAmount(synced)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('другие статьи не трогает', () => {
|
||||||
|
expect(syncPerDiem([hotels], ROUTE)[0]).toBe(hotels);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('расчёт в тексте — с МРП, по сохранённой ставке', () => {
|
||||||
|
const NBSP = String.fromCharCode(160);
|
||||||
|
const [synced] = syncPerDiem([perDiem], ROUTE);
|
||||||
|
expect(itemCalculation('perDiem', synced.items[0]).split(NBSP).join(' ')).toBe(
|
||||||
|
`4 дня × 6 МРП × ${new Intl.NumberFormat('ru-RU').format(MRP_VALUE).split(NBSP).join(' ')} ₸`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('текст для SP и хранение', () => {
|
describe('текст для SP и хранение', () => {
|
||||||
it('позиция на строку, с привязкой и расчётом', () => {
|
it('позиция на строку, с привязкой и расчётом', () => {
|
||||||
// Intl ru-RU разделяет разряды неразрывным пробелом (код 160) — приводим к обычному для сравнения.
|
// Intl ru-RU разделяет разряды неразрывным пробелом (код 160) — приводим к обычному для сравнения.
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
EstimateKind,
|
EstimateKind,
|
||||||
IEstimateArticle,
|
IEstimateArticle,
|
||||||
IEstimateItem,
|
IEstimateItem,
|
||||||
ITripRoute
|
ITripRoute,
|
||||||
|
MRP_VALUE
|
||||||
} from './model';
|
} from './model';
|
||||||
import { legLabel, newId, routeLegs, stopLabel } from './route';
|
import { legLabel, newId, routeLegs, stopLabel, stopPerDiemDays } from './route';
|
||||||
|
import { plural } from './tripData';
|
||||||
|
|
||||||
/** Цифры из поля ввода числом: '' или мусор — 0. MoneyInput хранит только цифры. */
|
/** Цифры из поля ввода числом: '' или мусор — 0. MoneyInput хранит только цифры. */
|
||||||
function toNumber(raw: string): number {
|
function toNumber(raw: string): number {
|
||||||
@@ -42,12 +44,29 @@ export function formatAmount(value: number): string {
|
|||||||
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(value);
|
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Значение МРП, по которому посчитана позиция суточных: ставка в день / кол-во МРП.
|
||||||
|
* Берём из сохранённой ставки, а не из MRP_VALUE: у старой заявки МРП прошлого года.
|
||||||
|
*/
|
||||||
|
function itemMrpValue(item: IEstimateItem): number {
|
||||||
|
const count = toNumber(item.mrpCount);
|
||||||
|
return count > 0 ? toNumber(item.price) / count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Расчёт суточных: «4 дня × 6 МРП × 4 325 ₸»; дни или МРП не заданы — ''. */
|
||||||
|
function perDiemCalculation(item: IEstimateItem): string {
|
||||||
|
if (!item.quantity || !toNumber(item.mrpCount)) return '';
|
||||||
|
const days = plural(toNumber(item.quantity), ['день', 'дня', 'дней']);
|
||||||
|
return `${days} × ${item.mrpCount} МРП × ${formatAmount(itemMrpValue(item))} ₸`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Расчёт для печати и колонки SP: «3 × 36 000 ₸»; при вводе итогом — ''. Без
|
* Расчёт для печати и колонки SP: «3 × 36 000 ₸»; при вводе итогом — ''. Без
|
||||||
* единиц: склонять «ночь/ночи/ночей» ради одной строки не стоит, а что считали —
|
* единиц: склонять «ночь/ночи/ночей» ради одной строки не стоит, а что считали —
|
||||||
* понятно из статьи.
|
* понятно из статьи. Суточные — исключение: там важно показать МРП.
|
||||||
*/
|
*/
|
||||||
export function itemCalculation(item: IEstimateItem): string {
|
export function itemCalculation(kind: EstimateKind, item: IEstimateItem): string {
|
||||||
|
if (ESTIMATE_KINDS[kind].itemsFromRoute) return perDiemCalculation(item);
|
||||||
if (item.mode !== 'calc' || !item.quantity || !item.price) return '';
|
if (item.mode !== 'calc' || !item.quantity || !item.price) return '';
|
||||||
return `${formatAmount(toNumber(item.quantity))} × ${formatAmount(toNumber(item.price))} ₸`;
|
return `${formatAmount(toNumber(item.quantity))} × ${formatAmount(toNumber(item.price))} ₸`;
|
||||||
}
|
}
|
||||||
@@ -96,7 +115,8 @@ export function createItem(kind: EstimateKind, linkId: string): IEstimateItem {
|
|||||||
amount: '',
|
amount: '',
|
||||||
quantity: '',
|
quantity: '',
|
||||||
price: '',
|
price: '',
|
||||||
comment: ''
|
comment: '',
|
||||||
|
mrpCount: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +131,42 @@ export function createArticle(kind: EstimateKind, route: ITripRoute): IEstimateA
|
|||||||
return addItem(empty, route);
|
return addItem(empty, route);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Суточные — строятся по маршруту
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Ставка суточных в день цифрами: кол-во МРП × МРП. Кол-во не введено — ''. */
|
||||||
|
function perDiemRate(mrpCount: string): string {
|
||||||
|
const count = toNumber(mrpCount);
|
||||||
|
return count > 0 ? String(count * MRP_VALUE) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Позиции статей «по маршруту» (суточные) приводятся к маршруту: ровно одна
|
||||||
|
* позиция на точку, дни — из дат точки, ставка — кол-во МРП × MRP_VALUE.
|
||||||
|
* Человек вводит только кол-во МРП (и пояснение) — они сохраняются за точкой.
|
||||||
|
* Применяется в форме при каждом рендере и перед сохранением; просмотр берёт
|
||||||
|
* сохранённое как есть — чтобы не пересчитать старую заявку по новому МРП.
|
||||||
|
*/
|
||||||
|
export function syncPerDiem(articles: IEstimateArticle[], route: ITripRoute): IEstimateArticle[] {
|
||||||
|
return articles.map((article) => {
|
||||||
|
if (!ESTIMATE_KINDS[article.kind].itemsFromRoute) return article;
|
||||||
|
const items = route.stops.map((stop, index): IEstimateItem => {
|
||||||
|
const existing = article.items.find((item) => item.linkId === stop.id);
|
||||||
|
// id от точки, а не случайный: иначе строка пересоздавалась бы на каждом рендере.
|
||||||
|
const base = existing || { ...createItem(article.kind, stop.id), id: `${article.kind}-${stop.id}` };
|
||||||
|
const days = stopPerDiemDays(route, index);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
mode: 'calc',
|
||||||
|
quantity: days === undefined ? '' : String(days),
|
||||||
|
price: perDiemRate(base.mrpCount)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { ...article, items };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Разрезы итога
|
// Разрезы итога
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -218,7 +274,7 @@ export function formatEstimateText(articles: IEstimateArticle[], route: ITripRou
|
|||||||
.forEach((item) => {
|
.forEach((item) => {
|
||||||
const where = itemLinkLabel(article.kind, item, route);
|
const where = itemLinkLabel(article.kind, item, route);
|
||||||
const title = where ? `${articleTitle(article)} (${where})` : articleTitle(article);
|
const title = where ? `${articleTitle(article)} (${where})` : articleTitle(article);
|
||||||
const calc = itemCalculation(item);
|
const calc = itemCalculation(article.kind, item);
|
||||||
const amount = `${formatAmount(itemAmount(item))} ₸`;
|
const amount = `${formatAmount(itemAmount(item))} ₸`;
|
||||||
lines.push(calc ? `${title}: ${calc} = ${amount}` : `${title}: ${amount}`);
|
lines.push(calc ? `${title}: ${calc} = ${amount}` : `${title}: ${amount}`);
|
||||||
})
|
})
|
||||||
@@ -257,7 +313,8 @@ function toItem(value: unknown): IEstimateItem | undefined {
|
|||||||
amount: text(raw.amount),
|
amount: text(raw.amount),
|
||||||
quantity: text(raw.quantity),
|
quantity: text(raw.quantity),
|
||||||
price: text(raw.price),
|
price: text(raw.price),
|
||||||
comment: text(raw.comment)
|
comment: text(raw.comment),
|
||||||
|
mrpCount: text(raw.mrpCount)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ export interface IEstimateItem {
|
|||||||
price: string;
|
price: string;
|
||||||
/** Гостиница, номер рейса, пояснение — необязательно. */
|
/** Гостиница, номер рейса, пояснение — необязательно. */
|
||||||
comment: string;
|
comment: string;
|
||||||
|
/**
|
||||||
|
* Только у суточных: количество МРП в день (цифры). Из него и MRP_VALUE
|
||||||
|
* считается ставка (price), дни (quantity) — из дат точки маршрута, см.
|
||||||
|
* estimate.ts/syncPerDiem. У остальных статей — ''.
|
||||||
|
*/
|
||||||
|
mrpCount: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Статья сметы — заголовок с позициями внутри. */
|
/** Статья сметы — заголовок с позициями внутри. */
|
||||||
@@ -108,7 +114,7 @@ export interface IEstimateArticle {
|
|||||||
/** Название — только у «Другого расхода»; у остальных подпись из ESTIMATE_KINDS. */
|
/** Название — только у «Другого расхода»; у остальных подпись из ESTIMATE_KINDS. */
|
||||||
title: string;
|
title: string;
|
||||||
/**
|
/**
|
||||||
* «Нужно» — переключатель основных статей (суточные/проезд/проживание).
|
* «Требуется» — переключатель основных статей (суточные/проезд/проживание).
|
||||||
* Добавленные статьи всегда true: ненужную такую статью просто удаляют.
|
* Добавленные статьи всегда true: ненужную такую статью просто удаляют.
|
||||||
*/
|
*/
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -117,11 +123,16 @@ export interface IEstimateArticle {
|
|||||||
|
|
||||||
export interface IEstimateKindInfo {
|
export interface IEstimateKindInfo {
|
||||||
label: string;
|
label: string;
|
||||||
/** true — основная статья (переключатель «нужно»); false — добавляется «+» и удаляется. */
|
/** true — основная статья (переключатель «требуется»); false — добавляется «+» и удаляется. */
|
||||||
core: boolean;
|
core: boolean;
|
||||||
/** Способ ввода по умолчанию — тот, которым такую сумму обычно считают. */
|
/** Способ ввода по умолчанию — тот, которым такую сумму обычно считают. */
|
||||||
defaultMode: AmountMode;
|
defaultMode: AmountMode;
|
||||||
link: EstimateLinkTarget;
|
link: EstimateLinkTarget;
|
||||||
|
/**
|
||||||
|
* true — позиции не добавляются вручную, а строятся по точкам маршрута (по
|
||||||
|
* одной на точку) и считаются сами — так устроены суточные.
|
||||||
|
*/
|
||||||
|
itemsFromRoute: boolean;
|
||||||
quantityLabel: string;
|
quantityLabel: string;
|
||||||
priceLabel: string;
|
priceLabel: string;
|
||||||
commentPlaceholder: string;
|
commentPlaceholder: string;
|
||||||
@@ -135,6 +146,7 @@ export const ESTIMATE_KINDS: Record<EstimateKind, IEstimateKindInfo> = {
|
|||||||
core: true,
|
core: true,
|
||||||
defaultMode: 'calc',
|
defaultMode: 'calc',
|
||||||
link: 'stop',
|
link: 'stop',
|
||||||
|
itemsFromRoute: true,
|
||||||
quantityLabel: 'Дней',
|
quantityLabel: 'Дней',
|
||||||
priceLabel: 'Ставка в день',
|
priceLabel: 'Ставка в день',
|
||||||
commentPlaceholder: 'Пояснение (необязательно)',
|
commentPlaceholder: 'Пояснение (необязательно)',
|
||||||
@@ -145,6 +157,7 @@ export const ESTIMATE_KINDS: Record<EstimateKind, IEstimateKindInfo> = {
|
|||||||
core: true,
|
core: true,
|
||||||
defaultMode: 'total',
|
defaultMode: 'total',
|
||||||
link: 'leg',
|
link: 'leg',
|
||||||
|
itemsFromRoute: false,
|
||||||
quantityLabel: 'Билетов',
|
quantityLabel: 'Билетов',
|
||||||
priceLabel: 'Цена билета',
|
priceLabel: 'Цена билета',
|
||||||
commentPlaceholder: 'Рейс, поезд, пояснение (необязательно)',
|
commentPlaceholder: 'Рейс, поезд, пояснение (необязательно)',
|
||||||
@@ -155,6 +168,7 @@ export const ESTIMATE_KINDS: Record<EstimateKind, IEstimateKindInfo> = {
|
|||||||
core: true,
|
core: true,
|
||||||
defaultMode: 'calc',
|
defaultMode: 'calc',
|
||||||
link: 'stop',
|
link: 'stop',
|
||||||
|
itemsFromRoute: false,
|
||||||
quantityLabel: 'Ночей',
|
quantityLabel: 'Ночей',
|
||||||
priceLabel: 'Цена за ночь',
|
priceLabel: 'Цена за ночь',
|
||||||
commentPlaceholder: 'Гостиница, адрес (необязательно)',
|
commentPlaceholder: 'Гостиница, адрес (необязательно)',
|
||||||
@@ -165,6 +179,7 @@ export const ESTIMATE_KINDS: Record<EstimateKind, IEstimateKindInfo> = {
|
|||||||
core: false,
|
core: false,
|
||||||
defaultMode: 'total',
|
defaultMode: 'total',
|
||||||
link: 'none',
|
link: 'none',
|
||||||
|
itemsFromRoute: false,
|
||||||
quantityLabel: 'Полисов',
|
quantityLabel: 'Полисов',
|
||||||
priceLabel: 'Цена полиса',
|
priceLabel: 'Цена полиса',
|
||||||
commentPlaceholder: 'Страховая компания, полис (необязательно)',
|
commentPlaceholder: 'Страховая компания, полис (необязательно)',
|
||||||
@@ -175,6 +190,7 @@ export const ESTIMATE_KINDS: Record<EstimateKind, IEstimateKindInfo> = {
|
|||||||
core: false,
|
core: false,
|
||||||
defaultMode: 'total',
|
defaultMode: 'total',
|
||||||
link: 'none',
|
link: 'none',
|
||||||
|
itemsFromRoute: false,
|
||||||
quantityLabel: 'Виз',
|
quantityLabel: 'Виз',
|
||||||
priceLabel: 'Сбор за визу',
|
priceLabel: 'Сбор за визу',
|
||||||
commentPlaceholder: 'Страна, тип визы (необязательно)',
|
commentPlaceholder: 'Страна, тип визы (необязательно)',
|
||||||
@@ -185,6 +201,7 @@ export const ESTIMATE_KINDS: Record<EstimateKind, IEstimateKindInfo> = {
|
|||||||
core: false,
|
core: false,
|
||||||
defaultMode: 'total',
|
defaultMode: 'total',
|
||||||
link: 'stop',
|
link: 'stop',
|
||||||
|
itemsFromRoute: false,
|
||||||
quantityLabel: 'Количество',
|
quantityLabel: 'Количество',
|
||||||
priceLabel: 'Цена',
|
priceLabel: 'Цена',
|
||||||
commentPlaceholder: 'Пояснение (необязательно)',
|
commentPlaceholder: 'Пояснение (необязательно)',
|
||||||
@@ -202,6 +219,15 @@ export const ESTIMATE_KIND_ORDER: ReadonlyArray<EstimateKind> = [
|
|||||||
'other'
|
'other'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Месячный расчётный показатель (МРП, Казахстан) — суточные считаются в МРП.
|
||||||
|
* Пока захардкожен (решение Тимура 2026-10-09); обновлять раз в год вместе с
|
||||||
|
* MRP_YEAR. Уже отправленные заявки не пересчитываются: ставка, по которой их
|
||||||
|
* посчитали, сохранена в самой заявке (IEstimateItem.price).
|
||||||
|
*/
|
||||||
|
export const MRP_VALUE = 4325;
|
||||||
|
export const MRP_YEAR = 2026;
|
||||||
|
|
||||||
/** Статьи, которые добавляются кнопкой не больше одного раза (другой расход — сколько угодно). */
|
/** Статьи, которые добавляются кнопкой не больше одного раза (другой расход — сколько угодно). */
|
||||||
export const SINGLE_OPTIONAL_KINDS: ReadonlyArray<EstimateKind> = ['insurance', 'visa'];
|
export const SINGLE_OPTIONAL_KINDS: ReadonlyArray<EstimateKind> = ['insurance', 'visa'];
|
||||||
|
|
||||||
@@ -276,7 +302,8 @@ function emptyItem(kind: EstimateKind, linkId: string): IEstimateItem {
|
|||||||
amount: '',
|
amount: '',
|
||||||
quantity: '',
|
quantity: '',
|
||||||
price: '',
|
price: '',
|
||||||
comment: ''
|
comment: '',
|
||||||
|
mrpCount: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ export function stopNights(stop: IRouteStop): number | undefined {
|
|||||||
return days === undefined ? undefined : days - 1;
|
return days === undefined ? undefined : days - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дней суточных в точке (по индексу): даты включительно, но день переезда
|
||||||
|
* считаем один раз. Атырау 05–08.10, Актау 08–10.10: Атырау — 4 дня, Актау — 2
|
||||||
|
* (08.10 уже учтён в Атырау), всего 6 — как весь период 05–10.10.
|
||||||
|
* Пустые/перепутанные даты — undefined.
|
||||||
|
*/
|
||||||
|
export function stopPerDiemDays(route: ITripRoute, index: number): number | undefined {
|
||||||
|
const stop = route.stops[index];
|
||||||
|
const days = stop ? tripDays(stop.dateFrom, stop.dateTo) : undefined;
|
||||||
|
if (days === undefined) return undefined;
|
||||||
|
const previous = route.stops[index - 1];
|
||||||
|
const transferDayCounted = !!previous && previous.dateTo === stop.dateFrom;
|
||||||
|
return transferDayCounted ? days - 1 : days;
|
||||||
|
}
|
||||||
|
|
||||||
/** Период всей поездки: прибытие в первую точку — отъезд из последней. */
|
/** Период всей поездки: прибытие в первую точку — отъезд из последней. */
|
||||||
export function routePeriod(route: ITripRoute): { start: string; end: string } {
|
export function routePeriod(route: ITripRoute): { start: string; end: string } {
|
||||||
const starts = route.stops.map((s) => s.dateFrom).filter(Boolean).sort();
|
const starts = route.stops.map((s) => s.dateFrom).filter(Boolean).sort();
|
||||||
|
|||||||
@@ -67,9 +67,17 @@ describe('validation: смета', () => {
|
|||||||
const [perDiem, travel, accommodation] = EMPTY_DATA.Estimate;
|
const [perDiem, travel, accommodation] = EMPTY_DATA.Estimate;
|
||||||
|
|
||||||
it('включённая статья без суммы — подсвечивается пустое поле', () => {
|
it('включённая статья без суммы — подсвечивается пустое поле', () => {
|
||||||
const filledQty = { ...perDiem, items: [{ ...perDiem.items[0], linkId: 's1', quantity: '5' }] };
|
const filledQty = { ...accommodation, items: [{ ...accommodation.items[0], linkId: 's1', quantity: '3' }] };
|
||||||
const data = { ...FILLED, Estimate: [filledQty, off(travel), off(accommodation)] };
|
const data = { ...FILLED, Estimate: [off(perDiem), off(travel), filledQty] };
|
||||||
expect(fields(submitIssues(data))).toEqual([`item-${perDiem.items[0].id}-price`]);
|
expect(fields(submitIssues(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([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('при нескольких вариантах позицию нужно привязать', () => {
|
it('при нескольких вариантах позицию нужно привязать', () => {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ export const fieldKey = {
|
|||||||
itemLink: (item: IEstimateItem): string => `item-${item.id}-link`,
|
itemLink: (item: IEstimateItem): string => `item-${item.id}-link`,
|
||||||
itemAmount: (item: IEstimateItem): string => `item-${item.id}-amount`,
|
itemAmount: (item: IEstimateItem): string => `item-${item.id}-amount`,
|
||||||
itemQuantity: (item: IEstimateItem): string => `item-${item.id}-quantity`,
|
itemQuantity: (item: IEstimateItem): string => `item-${item.id}-quantity`,
|
||||||
itemPrice: (item: IEstimateItem): string => `item-${item.id}-price`
|
itemPrice: (item: IEstimateItem): string => `item-${item.id}-price`,
|
||||||
|
itemMrpCount: (item: IEstimateItem): string => `item-${item.id}-mrp`
|
||||||
};
|
};
|
||||||
|
|
||||||
/** «Точка 2 (Актау)» — как точку видно в форме. */
|
/** «Точка 2 (Актау)» — как точку видно в форме. */
|
||||||
@@ -91,6 +92,14 @@ function estimateIssues(articles: IEstimateArticle[], route: ITripRoute): IField
|
|||||||
article.items.forEach((item) => {
|
article.items.forEach((item) => {
|
||||||
const where = itemLinkLabel(article.kind, item, route);
|
const where = itemLinkLabel(article.kind, item, route);
|
||||||
const label = where ? `Смета: ${articleTitle(article)} (${where})` : `Смета: ${articleTitle(article)}`;
|
const label = where ? `Смета: ${articleTitle(article)} (${where})` : `Смета: ${articleTitle(article)}`;
|
||||||
|
// Суточные привязаны к точке и считают дни сами (пустые даты подсветит маршрут) —
|
||||||
|
// от человека нужно только кол-во МРП. 0 дней (точка в день переезда) — не ошибка.
|
||||||
|
if (ESTIMATE_KINDS[article.kind].itemsFromRoute) {
|
||||||
|
if (!Number(item.mrpCount)) {
|
||||||
|
issues.push({ field: fieldKey.itemMrpCount(item), label, message: 'укажите количество МРП' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (needsLink && !resolveLinkId(article.kind, item.linkId, route)) {
|
if (needsLink && !resolveLinkId(article.kind, item.linkId, route)) {
|
||||||
issues.push({ field: fieldKey.itemLink(item), label, message: 'выберите, к чему относится расход' });
|
issues.push({ field: fieldKey.itemLink(item), label, message: 'выберите, к чему относится расход' });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user