feat: import reports from Excel
The /Import/StroyBot/Reports endpoint only validates the file, so the check now runs as soon as a file is picked. When there are no errors, "Import" builds the reports from the ids in the response and saves them with the same Dataverse code as the report form: one report per date, company, shift and object; resources nested under their work; idle and repair hours as separate rows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -177,6 +177,21 @@
|
|||||||
- [ ] **T-REP-22** Enter в любом поле не перезагружает страницу.
|
- [ ] **T-REP-22** Enter в любом поле не перезагружает страницу.
|
||||||
- [ ] ✍️ **T-REP-23** Удалить тестовый отчёт из «Отчёты по проекту» → «Удалить отчёт?» → пропал из списка (мягкое удаление).
|
- [ ] ✍️ **T-REP-23** Удалить тестовый отчёт из «Отчёты по проекту» → «Удалить отчёт?» → пропал из списка (мягкое удаление).
|
||||||
|
|
||||||
|
## 9a. Импорт отчёта из Excel — `T-RIM` (новая фича)
|
||||||
|
|
||||||
|
> `POST /Import/StroyBot/Reports?projectId=` **только проверяет** файл и ничего не пишет (проверено 2026-10-22: после ответа без ошибок в `cree9_reports` / `cree9_report_datas` записей нет). Отчёты пишет фронт по `ids` из ответа — тем же `saveReportWithData`, что форма «Отправить отчёт».
|
||||||
|
> Правила сборки: один отчёт = дата + компания + смена + объект (самый глубокий); каждая строка Excel — своя строка-работа; ресурс с «Часы работы» — под работу; простой / ремонт — отдельной строкой вне работы, «Комментарий (данные)» — ей. Вид работы сервер требует в каждой строке. ⚠️ Только проект **51**.
|
||||||
|
> Тестовый файл: `Downloads\ТЕСТ импорт отчета - проект 51 (v2).xlsx` (4 строки без ошибок → 2 отчёта, за 01.10 и 02.10; строка 4 — простой).
|
||||||
|
|
||||||
|
- [ ] **T-RIM-01** Меню: «Импорт отчёта из Excel» есть у роли **Е**; у **Р** (без edit0) — нет; по прямому адресу без прав — «Импорт отчётов недоступен…».
|
||||||
|
- [ ] **T-RIM-02** Выбор файла **сразу запускает проверку**: «Проверяем файл…», в F12 → Network один `POST Reports` (+ preflight). Кнопка выбора файла на время проверки неактивна.
|
||||||
|
- [ ] **T-RIM-03** Файл с ошибками → «С ошибками: N», «Исправьте ошибки в Excel и загрузите файл снова.», кнопки «Импортировать» нет.
|
||||||
|
- [ ] **T-RIM-04** Таблица: все 18 колонок файла; красные ячейки с ошибками; наведение — текст ошибки; справа «Ошибки»; фильтр «Только строки с ошибками».
|
||||||
|
- [ ] **T-RIM-05** Файл не .xlsx → «Нужен файл Excel в формате .xlsx», проверка не запускается.
|
||||||
|
- [ ] **T-RIM-06** v2 → «Ошибок нет — файл готов к импорту.», кнопка «Импортировать (отчётов: 2)» → окно «Будет создано отчётов: 2 в проекте «…»» → «Отмена» — ничего не записано.
|
||||||
|
- [ ] ✍️ **T-RIM-07** То же → «Импортировать» → «Сохраняем отчёты… 1 из 2» → «Импорт завершён. Созданы отчёты: №… за 01.10.2026, №… за 02.10.2026» и ссылка на «Отчёты по проекту»; кнопки больше нет. (Проверено 2026-10-22: №5, №6.)
|
||||||
|
- [ ] **T-RIM-08** Отчёт за 01.10 в форме: три работы «Арматура» (5, 3, 2); под первой техника «Бетонный насос» 1×11, под второй персонал «Бетонщик» 10×11; в «Технике» отдельно насос 1×3 «Часы простоя». За 02.10: «Арматура» 4, под ней «Бетонщик» 5×11.
|
||||||
|
|
||||||
## 10. Адаптивность — `T-RSP`
|
## 10. Адаптивность — `T-RSP`
|
||||||
|
|
||||||
> Как: F12 → кнопка «телефон/планшет» (Ctrl+Shift+M) → сверху выбрать ширину → F5. Только просмотр, ничего не сохранять.
|
> Как: F12 → кнопка «телефон/планшет» (Ctrl+Shift+M) → сверху выбрать ширину → F5. Только просмотр, ничего не сохранять.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ImportWorksPage } from '../pages/import/ImportWorksPage';
|
|||||||
import { ResourcesPage } from '../pages/resources/ResourcesPage';
|
import { ResourcesPage } from '../pages/resources/ResourcesPage';
|
||||||
import { BinderPage } from '../pages/binder/BinderPage';
|
import { BinderPage } from '../pages/binder/BinderPage';
|
||||||
import { WeatherPage } from '../pages/weather/WeatherPage';
|
import { WeatherPage } from '../pages/weather/WeatherPage';
|
||||||
|
import { ReportImportPage } from '../pages/reportImport/ReportImportPage';
|
||||||
|
|
||||||
/** Показывает страницу текущего раздела (раздел берётся из ?page=). */
|
/** Показывает страницу текущего раздела (раздел берётся из ?page=). */
|
||||||
export const PageOutlet: React.FC = () => {
|
export const PageOutlet: React.FC = () => {
|
||||||
@@ -31,6 +32,8 @@ export const PageOutlet: React.FC = () => {
|
|||||||
return <BinderPage />;
|
return <BinderPage />;
|
||||||
case 'weather':
|
case 'weather':
|
||||||
return <WeatherPage />;
|
return <WeatherPage />;
|
||||||
|
case 'reportImport':
|
||||||
|
return <ReportImportPage />;
|
||||||
case 'hub':
|
case 'hub':
|
||||||
default:
|
default:
|
||||||
return <HubPage />;
|
return <HubPage />;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ describe('buildSidebarItems', () => {
|
|||||||
'objects',
|
'objects',
|
||||||
'reports',
|
'reports',
|
||||||
'report',
|
'report',
|
||||||
|
'reportImport',
|
||||||
'import',
|
'import',
|
||||||
'resources',
|
'resources',
|
||||||
'binder'
|
'binder'
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const NAV_ITEMS: INavItem[] = [
|
|||||||
{ page: 'objects', title: 'Объекты', icon: 'folder', needsProject: true },
|
{ page: 'objects', title: 'Объекты', icon: 'folder', needsProject: true },
|
||||||
{ page: 'reports', title: 'Отчёты по проекту', icon: 'clipboard', needsProject: true },
|
{ page: 'reports', title: 'Отчёты по проекту', icon: 'clipboard', needsProject: true },
|
||||||
{ page: 'report', title: 'Отправить отчёт', icon: 'send', needsProject: true },
|
{ page: 'report', title: 'Отправить отчёт', icon: 'send', needsProject: true },
|
||||||
|
{ page: 'reportImport', title: 'Импорт отчёта из Excel', icon: 'upload', needsProject: true },
|
||||||
{ page: 'import', title: 'Загрузить работы из сметы', icon: 'download', needsProject: true },
|
{ page: 'import', title: 'Загрузить работы из сметы', icon: 'download', needsProject: true },
|
||||||
{ page: 'resources', title: 'Ресурсы проекта', icon: 'people', needsProject: true },
|
{ page: 'resources', title: 'Ресурсы проекта', icon: 'people', needsProject: true },
|
||||||
{ page: 'binder', title: 'Привязка к справочнику', icon: 'swap', needsProject: true }
|
{ page: 'binder', title: 'Привязка к справочнику', icon: 'swap', needsProject: true }
|
||||||
|
|||||||
@@ -119,4 +119,10 @@ describe('isSectionAvailable', () => {
|
|||||||
expect(isSectionAvailable('report', current)).toBe(true);
|
expect(isSectionAvailable('report', current)).toBe(true);
|
||||||
expect(isSectionAvailable('binder', current)).toBe(true);
|
expect(isSectionAvailable('binder', current)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('импорт отчёта из Excel — по тем же правилам, что «Отправить отчёт»', () => {
|
||||||
|
expect(isSectionAvailable('reportImport', project({ status: 'текущий', canEdit: true }))).toBe(true);
|
||||||
|
expect(isSectionAvailable('reportImport', project({ status: 'текущий', canEdit: false }))).toBe(false);
|
||||||
|
expect(isSectionAvailable('reportImport', project({ status: 'завершён', canEdit: true }))).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export function getStatusKind(status: string | undefined): ProjectStatusKind {
|
|||||||
* - архивный проект — закрыто всё (кроме архива, он не раздел приложения);
|
* - архивный проект — закрыто всё (кроме архива, он не раздел приложения);
|
||||||
* - завершён — нельзя «Отправить отчёт»;
|
* - завершён — нельзя «Отправить отчёт»;
|
||||||
* - нет права edit0 — нельзя «Отправить отчёт».
|
* - нет права edit0 — нельзя «Отправить отчёт».
|
||||||
|
* Импорт отчёта из Excel создаёт отчёты — те же правила, что у «Отправить отчёт».
|
||||||
* Привязка к справочнику — только с правом редактирования (решение при объединении).
|
* Привязка к справочнику — только с правом редактирования (решение при объединении).
|
||||||
*/
|
*/
|
||||||
export function isSectionAvailable(page: PageId, project: IAccessibleProject): boolean {
|
export function isSectionAvailable(page: PageId, project: IAccessibleProject): boolean {
|
||||||
@@ -80,7 +81,7 @@ export function isSectionAvailable(page: PageId, project: IAccessibleProject): b
|
|||||||
|
|
||||||
const kind = getStatusKind(project.status);
|
const kind = getStatusKind(project.status);
|
||||||
if (kind === 'archive') return false;
|
if (kind === 'archive') return false;
|
||||||
if (page === 'report') return kind !== 'done' && project.canEdit;
|
if (page === 'report' || page === 'reportImport') return kind !== 'done' && project.canEdit;
|
||||||
if (page === 'binder') return project.canEdit;
|
if (page === 'binder') return project.canEdit;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ export const PAGE_IDS = [
|
|||||||
'import',
|
'import',
|
||||||
'resources',
|
'resources',
|
||||||
'binder',
|
'binder',
|
||||||
'weather'
|
'weather',
|
||||||
|
'reportImport'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type PageId = (typeof PAGE_IDS)[number];
|
export type PageId = (typeof PAGE_IDS)[number];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
orderRowsForDeletion
|
orderRowsForDeletion
|
||||||
} from './dataverseReportRepository';
|
} from './dataverseReportRepository';
|
||||||
import { createFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient';
|
import { createFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient';
|
||||||
|
import { IHttpRequest } from '../../services/http/IHttpClient';
|
||||||
import { IReportPayload } from './reportTypes';
|
import { IReportPayload } from './reportTypes';
|
||||||
|
|
||||||
const DV = 'https://tansu.crm5.dynamics.com';
|
const DV = 'https://tansu.crm5.dynamics.com';
|
||||||
@@ -21,7 +22,7 @@ const REPORT: IReportPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Тело запроса как объект — чтобы сравнивать без зависимости от порядка ключей. */
|
/** Тело запроса как объект — чтобы сравнивать без зависимости от порядка ключей. */
|
||||||
const bodyOf = (body: string | ArrayBuffer | undefined): unknown => JSON.parse(String(body));
|
const bodyOf = (body: IHttpRequest['body']): unknown => JSON.parse(String(body));
|
||||||
|
|
||||||
describe('адреса Dataverse (1:1 со stroy-bot-report)', () => {
|
describe('адреса Dataverse (1:1 со stroy-bot-report)', () => {
|
||||||
it('отчёт, счётчик, строки, факт', () => {
|
it('отчёт, счётчик, строки, факт', () => {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button, Icon } from '@tnsu/ui-kit';
|
||||||
|
import { EXCEL_ACCEPT } from './reportImportRules';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ReportImportPage.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedFileName: string | undefined;
|
||||||
|
disabled: boolean;
|
||||||
|
onSelect: (file: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор Excel-файла — только выбор, без отправки. Скрытый input сбрасывается,
|
||||||
|
* чтобы тот же файл можно было выбрать повторно.
|
||||||
|
*/
|
||||||
|
export const ReportFileField: React.FC<Props> = ({ selectedFileName, disabled, onSelect }) => {
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const onChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = '';
|
||||||
|
if (file) onSelect(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.fileField}>
|
||||||
|
<input ref={inputRef} type="file" accept={EXCEL_ACCEPT} className={styles.hiddenInput} onChange={onChange} />
|
||||||
|
<Button variant="secondary" onClick={() => inputRef.current?.click()} disabled={disabled}>
|
||||||
|
<span className={styles.buttonContent}>
|
||||||
|
<Icon name="upload" size="sm" />
|
||||||
|
{selectedFileName ? REPORT_IMPORT_TEXTS.chooseAnotherFile : REPORT_IMPORT_TEXTS.chooseFile}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
{selectedFileName && (
|
||||||
|
<span className={styles.fileName}>{REPORT_IMPORT_TEXTS.selectedFile(selectedFileName)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button, confirmModal, Spinner } from '@tnsu/ui-kit';
|
||||||
|
import { IImportedReport } from './reportImportBuilder';
|
||||||
|
import { ReportImportStatus } from './useReportImport';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
|
const TEXTS = REPORT_IMPORT_TEXTS.import;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reports: IImportedReport[];
|
||||||
|
projectTitle: string;
|
||||||
|
status: ReportImportStatus;
|
||||||
|
onImport: (reports: IImportedReport[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Кнопка «Импортировать» с подтверждением; во время записи — прогресс. После записи кнопки нет. */
|
||||||
|
export const ReportImportActions: React.FC<Props> = ({ reports, projectTitle, status, onImport }) => {
|
||||||
|
if (status.kind === 'saving') {
|
||||||
|
return <Spinner size="small" label={TEXTS.saving(status.savedCount, status.total)} />;
|
||||||
|
}
|
||||||
|
if (status.kind === 'finished') return null;
|
||||||
|
|
||||||
|
const startImport = async (): Promise<void> => {
|
||||||
|
const isConfirmed = await confirmModal({
|
||||||
|
title: TEXTS.confirmTitle,
|
||||||
|
message: TEXTS.confirmMessage(reports.length, projectTitle),
|
||||||
|
confirmText: TEXTS.confirm,
|
||||||
|
cancelText: TEXTS.cancel
|
||||||
|
});
|
||||||
|
if (isConfirmed) await onImport(reports);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button variant="accent" onClick={() => startImport().catch(() => undefined)}>
|
||||||
|
{TEXTS.button(reports.length)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert } from '@tnsu/ui-kit';
|
||||||
|
import { RouteLink } from '../../app/routing/RouteLink';
|
||||||
|
import { formatReportDate } from '../reports/reportsRules';
|
||||||
|
import { IImportSaveResult, ISavedImportReport } from './reportImportSave';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ReportImportPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = REPORT_IMPORT_TEXTS.import;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
result: IImportSaveResult;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function savedList(saved: ISavedImportReport[]): string {
|
||||||
|
return saved.map((report) => TEXTS.savedReport(report.reportNumber, formatReportDate(report.reportDate))).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Итог записи. При ошибке явно говорим, что уже записано, — повторная загрузка файла задвоит отчёты. */
|
||||||
|
export const ReportImportOutcome: React.FC<Props> = ({ result, projectId }) => {
|
||||||
|
const reportsLink = (
|
||||||
|
<RouteLink className={styles.reportsLink} to={{ page: 'reports', projectId }}>
|
||||||
|
{TEXTS.openReports}
|
||||||
|
</RouteLink>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.errorMessage) {
|
||||||
|
return (
|
||||||
|
<Alert variant="ok">
|
||||||
|
{TEXTS.success(savedList(result.saved))} {reportsLink}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedRows = (result.failedRows || []).join(', ');
|
||||||
|
return (
|
||||||
|
<Alert variant="err">
|
||||||
|
<p className={styles.outcomeLine}>{TEXTS.failed(failedRows, result.errorMessage)}</p>
|
||||||
|
{result.saved.length > 0 && <p className={styles.outcomeLine}>{TEXTS.partlySaved(savedList(result.saved))}</p>}
|
||||||
|
<p className={styles.outcomeLine}>{TEXTS.mayBePartial}</p>
|
||||||
|
{reportsLink}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// В файле 18 колонок — таблица прокручивается внутри рамки, а не растягивает страницу.
|
||||||
|
$issues-min-width: 260px;
|
||||||
|
$cell-max-width: 280px;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-4;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
margin: 0;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileField {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonContent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $sp-5;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
margin-right: $sp-2;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-md;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: $sp-2 $sp-3;
|
||||||
|
border-bottom: 1px solid $gray-200;
|
||||||
|
background: $gray-50;
|
||||||
|
font-size: $fs-caption;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
max-width: $cell-max-width;
|
||||||
|
padding: $sp-2 $sp-3;
|
||||||
|
border-bottom: 1px solid $gray-100;
|
||||||
|
vertical-align: top;
|
||||||
|
color: $gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowNumber {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalidRow .rowNumber {
|
||||||
|
border-left: 3px solid $red-500;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalidCell {
|
||||||
|
background: $red-50;
|
||||||
|
box-shadow: inset 0 0 0 1px $red-500;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issues {
|
||||||
|
min-width: $issues-min-width;
|
||||||
|
font-size: $fs-label;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: $red-500;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: $gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-700;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: $sp-2 0 0;
|
||||||
|
padding-left: $sp-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionLabel {
|
||||||
|
margin: 0;
|
||||||
|
font-family: $font-serif;
|
||||||
|
font-size: $fs-section-title;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outcomeLine {
|
||||||
|
margin: 0 0 $sp-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reportsLink {
|
||||||
|
color: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert, Spinner } from '@tnsu/ui-kit';
|
||||||
|
import { useProject } from '../../app/project/ProjectContext';
|
||||||
|
import { isSectionAvailable } from '../../app/project/projectRules';
|
||||||
|
import { TreePageHeader } from '../_tree/TreePageHeader';
|
||||||
|
import { canImportFile, isExcelFileName } from './reportImportRules';
|
||||||
|
import { useReportFileCheck } from './useReportFileCheck';
|
||||||
|
import { useReportImport } from './useReportImport';
|
||||||
|
import { ReportFileField } from './ReportFileField';
|
||||||
|
import { ReportImportResult } from './ReportImportResult';
|
||||||
|
import { ReportImportStep } from './ReportImportStep';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ReportImportPage.module.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Импорт отчёта из Excel: `?page=reportImport&projectId=`.
|
||||||
|
* Файл проверяется на сервере сразу после выбора (сервер ничего не сохраняет); если ошибок нет —
|
||||||
|
* «Импортировать» записывает отчёты в Dataverse тем же кодом, что форма «Отправить отчёт».
|
||||||
|
*/
|
||||||
|
export const ReportImportPage: React.FC = () => {
|
||||||
|
const { currentProject } = useProject();
|
||||||
|
const { status, checkFile, reset } = useReportFileCheck(currentProject?.id);
|
||||||
|
const importer = useReportImport(currentProject?.id);
|
||||||
|
const [selectedFileName, setSelectedFileName] = React.useState<string | undefined>(undefined);
|
||||||
|
const [fileError, setFileError] = React.useState<string>('');
|
||||||
|
const isChecking = status.kind === 'checking';
|
||||||
|
// Пока идёт запись, новый файл не выбрать: иначе результат записи потеряется с экрана.
|
||||||
|
const isBusy = isChecking || importer.status.kind === 'saving';
|
||||||
|
|
||||||
|
const selectFile = (file: File): void => {
|
||||||
|
importer.reset();
|
||||||
|
if (!isExcelFileName(file.name)) {
|
||||||
|
reset();
|
||||||
|
setSelectedFileName(undefined);
|
||||||
|
setFileError(REPORT_IMPORT_TEXTS.wrongExtension);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedFileName(file.name);
|
||||||
|
setFileError('');
|
||||||
|
checkFile(file).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = (): React.ReactNode => {
|
||||||
|
if (!currentProject) return <Alert variant="info">{REPORT_IMPORT_TEXTS.noProject}</Alert>;
|
||||||
|
if (!isSectionAvailable('reportImport', currentProject)) {
|
||||||
|
return <Alert variant="warn">{REPORT_IMPORT_TEXTS.noAccess}</Alert>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className={styles.intro}>{REPORT_IMPORT_TEXTS.intro}</p>
|
||||||
|
<ReportFileField selectedFileName={selectedFileName} disabled={isBusy} onSelect={selectFile} />
|
||||||
|
{fileError && <Alert variant="err">{fileError}</Alert>}
|
||||||
|
{isChecking && <Spinner size="small" label={REPORT_IMPORT_TEXTS.checking} />}
|
||||||
|
{status.kind === 'error' && <Alert variant="err">{status.message}</Alert>}
|
||||||
|
{status.kind === 'done' && (
|
||||||
|
<ReportImportResult
|
||||||
|
check={status.check}
|
||||||
|
actions={
|
||||||
|
canImportFile(status.check) && (
|
||||||
|
<ReportImportStep
|
||||||
|
check={status.check}
|
||||||
|
projectId={currentProject.id}
|
||||||
|
projectTitle={currentProject.title}
|
||||||
|
status={importer.status}
|
||||||
|
onImport={importer.importReports}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<TreePageHeader icon="upload" label={REPORT_IMPORT_TEXTS.headerLabel} title={currentProject?.title ?? ''} />
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { IReportImportCheck } from './reportImportTypes';
|
||||||
|
import { ReportImportSummary } from './ReportImportSummary';
|
||||||
|
import { ReportImportTable } from './ReportImportTable';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ReportImportPage.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
check: IReportImportCheck;
|
||||||
|
/** Импорт — сразу под итогом проверки, чтобы кнопка не терялась под длинной таблицей. */
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Результат проверки файла: итог, импорт, таблица строк и примечания сервера. */
|
||||||
|
export const ReportImportResult: React.FC<Props> = ({ check, actions }) => {
|
||||||
|
const notes = check.notes || [];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReportImportSummary check={check} />
|
||||||
|
{actions}
|
||||||
|
<ReportImportTable key={check.file_name} check={check} />
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<section className={styles.notes}>
|
||||||
|
<h3 className={styles.sectionLabel}>{REPORT_IMPORT_TEXTS.notesTitle}</h3>
|
||||||
|
<ul>
|
||||||
|
{notes.map((note, index) => (
|
||||||
|
<li key={`${index}-${note}`}>{note}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { issuesByColumn } from './reportImportRules';
|
||||||
|
import { IReportImportRow } from './reportImportTypes';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ReportImportPage.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
row: IReportImportRow;
|
||||||
|
columns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка файла: ячейки с ошибкой подсвечены (сообщение — во всплывающей подсказке), справа — список ошибок. */
|
||||||
|
export const ReportImportRow: React.FC<Props> = ({ row, columns }) => {
|
||||||
|
const issues = issuesByColumn(row);
|
||||||
|
return (
|
||||||
|
<tr className={row.valid ? undefined : styles.invalidRow}>
|
||||||
|
<td className={styles.rowNumber}>{row.row}</td>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const messages = issues.get(column);
|
||||||
|
const value = row.values?.[column];
|
||||||
|
return (
|
||||||
|
<td key={column} className={messages ? styles.invalidCell : undefined} title={messages?.join('\n')}>
|
||||||
|
{value || <span className={styles.muted}>{REPORT_IMPORT_TEXTS.empty}</span>}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className={styles.issues}>
|
||||||
|
{(row.issues || []).map((issue, index) => (
|
||||||
|
<div key={`${index}-${issue.column}`}>
|
||||||
|
<strong>{issue.column}:</strong> {issue.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert } from '@tnsu/ui-kit';
|
||||||
|
import { IReportImportCheck } from './reportImportTypes';
|
||||||
|
import { IImportedReport, tryBuildImportedReports } from './reportImportBuilder';
|
||||||
|
import { ReportImportStatus } from './useReportImport';
|
||||||
|
import { ReportImportActions } from './ReportImportActions';
|
||||||
|
import { ReportImportOutcome } from './ReportImportOutcome';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
check: IReportImportCheck;
|
||||||
|
projectId: number;
|
||||||
|
projectTitle: string;
|
||||||
|
status: ReportImportStatus;
|
||||||
|
onImport: (reports: IImportedReport[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Импорт проверенного файла без ошибок: отчёты собираются заранее, чтобы ошибку сборки показать до записи. */
|
||||||
|
export const ReportImportStep: React.FC<Props> = ({ check, projectId, projectTitle, status, onImport }) => {
|
||||||
|
const build = React.useMemo(() => tryBuildImportedReports(check, projectId), [check, projectId]);
|
||||||
|
|
||||||
|
if (build.kind === 'error') return <Alert variant="err">{build.message}</Alert>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReportImportActions reports={build.reports} projectTitle={projectTitle} status={status} onImport={onImport} />
|
||||||
|
{status.kind === 'finished' && <ReportImportOutcome result={status.result} projectId={String(projectId)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Alert, Badge } from '@tnsu/ui-kit';
|
||||||
|
import { canImportFile } from './reportImportRules';
|
||||||
|
import { IReportImportCheck } from './reportImportTypes';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ReportImportPage.module.scss';
|
||||||
|
|
||||||
|
const TEXTS = REPORT_IMPORT_TEXTS.summary;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
check: IReportImportCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Итог проверки: имя файла, счётчики строк и что делать дальше. */
|
||||||
|
export const ReportImportSummary: React.FC<Props> = ({ check }) => {
|
||||||
|
const isEmpty = check.total_rows === 0;
|
||||||
|
return (
|
||||||
|
<div className={styles.summary}>
|
||||||
|
<div className={styles.summaryRow}>
|
||||||
|
<span className={styles.fileName}>{TEXTS.file(check.file_name)}</span>
|
||||||
|
<Badge variant="neutral">{TEXTS.total(check.total_rows)}</Badge>
|
||||||
|
<Badge variant="success">{TEXTS.valid(check.valid_rows)}</Badge>
|
||||||
|
{check.invalid_rows > 0 && <Badge variant="danger">{TEXTS.invalid(check.invalid_rows)}</Badge>}
|
||||||
|
</div>
|
||||||
|
{isEmpty && <Alert variant="warn">{REPORT_IMPORT_TEXTS.emptyFile}</Alert>}
|
||||||
|
{!isEmpty && canImportFile(check) && <Alert variant="ok">{REPORT_IMPORT_TEXTS.readyToImport}</Alert>}
|
||||||
|
{check.invalid_rows > 0 && <Alert variant="warn">{REPORT_IMPORT_TEXTS.fixInExcel}</Alert>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Checkbox } from '@tnsu/ui-kit';
|
||||||
|
import { columnsOf, visibleRows } from './reportImportRules';
|
||||||
|
import { IReportImportCheck } from './reportImportTypes';
|
||||||
|
import { ReportImportRow } from './ReportImportRow';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
import styles from './ReportImportPage.module.scss';
|
||||||
|
|
||||||
|
const COLUMNS = REPORT_IMPORT_TEXTS.columns;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
check: IReportImportCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Таблица строк файла с фильтром «Только строки с ошибками». */
|
||||||
|
export const ReportImportTable: React.FC<Props> = ({ check }) => {
|
||||||
|
const [onlyInvalid, setOnlyInvalid] = React.useState<boolean>(false);
|
||||||
|
const columns = columnsOf(check);
|
||||||
|
const rows = visibleRows(check, onlyInvalid);
|
||||||
|
|
||||||
|
if (check.rows.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tableSection}>
|
||||||
|
{check.invalid_rows > 0 && (
|
||||||
|
<Checkbox label={REPORT_IMPORT_TEXTS.onlyInvalid} checked={onlyInvalid} onChange={setOnlyInvalid} />
|
||||||
|
)}
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{COLUMNS.row}</th>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th key={column}>{column}</th>
|
||||||
|
))}
|
||||||
|
<th>{COLUMNS.issues}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<ReportImportRow key={row.row} row={row} columns={columns} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { buildImportedReports, toIsoDate, toNumber } from './reportImportBuilder';
|
||||||
|
import { IReportImportCheck, IReportImportRow } from './reportImportTypes';
|
||||||
|
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||||
|
|
||||||
|
const JOB ='f4cc373e-77cb-f111-a05c-70a8a5039971';
|
||||||
|
|
||||||
|
/** Строка как в ответе сервера на «ТЕСТ импорт отчета - проект 51.xlsx»; overrides — отличия. */
|
||||||
|
function makeRow(
|
||||||
|
row: number,
|
||||||
|
values: Record<string, string | null>,
|
||||||
|
ids: Record<string, unknown>
|
||||||
|
): IReportImportRow {
|
||||||
|
return {
|
||||||
|
row,
|
||||||
|
valid: true,
|
||||||
|
values: {
|
||||||
|
'Дата': '01.10.2026 5:00:00',
|
||||||
|
'Компания': 'TANSU',
|
||||||
|
'Смена': '1',
|
||||||
|
'Объект 1': 'Тестовый объект',
|
||||||
|
'Объект 2': 'Бетонирование',
|
||||||
|
'Объект 3': null,
|
||||||
|
'Вид работы': 'Арматура',
|
||||||
|
'Факт': null,
|
||||||
|
'Техника': null,
|
||||||
|
'Профессия': null,
|
||||||
|
'Количество': null,
|
||||||
|
'Часы': null,
|
||||||
|
'Тип часов': 'Часы работы',
|
||||||
|
'Комментарий': null,
|
||||||
|
'Комментарий (данные)': null,
|
||||||
|
...values
|
||||||
|
},
|
||||||
|
ids: { 'Объект 1': 1516, 'Объект 2': 1517, 'Вид работы': JOB, 'Компания': 29, 'Смена': 1, ...ids },
|
||||||
|
issues: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCheck(rows: IReportImportRow[]): IReportImportCheck {
|
||||||
|
return {
|
||||||
|
kind: 'reports',
|
||||||
|
file_name: 'test.xlsx',
|
||||||
|
project_id: 51,
|
||||||
|
project_name: 'TEST',
|
||||||
|
total_rows: rows.length,
|
||||||
|
valid_rows: rows.length,
|
||||||
|
invalid_rows: 0,
|
||||||
|
rows,
|
||||||
|
notes: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const technicRow = makeRow(2, { 'Факт': '5', 'Количество': '1', 'Часы': '11' }, { 'Техника': 327 });
|
||||||
|
const professionRow = makeRow(3, { 'Факт': '3', 'Количество': '10', 'Часы': '11' }, { 'Профессия': 181 });
|
||||||
|
|
||||||
|
describe('сборка отчётов из ответа проверки', () => {
|
||||||
|
it('тестовый файл: один отчёт, две работы, ресурсы вложены в работы', () => {
|
||||||
|
const reports = buildImportedReports(makeCheck([technicRow, professionRow]), 51);
|
||||||
|
|
||||||
|
expect(reports).toEqual([
|
||||||
|
{
|
||||||
|
payload: {
|
||||||
|
cree9_report_date: '2026-10-01',
|
||||||
|
cree9_companyid: 29,
|
||||||
|
cree9_shiftid: 1,
|
||||||
|
cree9_objectid: 1517,
|
||||||
|
cree9_projectid: 51,
|
||||||
|
cree9_comment: undefined
|
||||||
|
},
|
||||||
|
rowNumbers: [2, 3],
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
data: { _cree9_jobid_value: JOB, cree9_value: 5, cree9_comment: undefined },
|
||||||
|
children: [{ cree9_technicid: 327, cree9_count: 1, cree9_hours: 11, cree9_hourstype: 'Часы работы' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: { _cree9_jobid_value: JOB, cree9_value: 3, cree9_comment: undefined },
|
||||||
|
children: [{ cree9_professionid: 181, cree9_count: 10, cree9_hours: 11, cree9_hourstype: 'Часы работы' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('простой под работой: работа остаётся, техника — отдельной строкой с типом часов и комментарием', () => {
|
||||||
|
const idleRow = makeRow(
|
||||||
|
2,
|
||||||
|
{ 'Факт': '5', 'Количество': '1', 'Часы': '3', 'Тип часов': 'Часы простоя', 'Комментарий (данные)': 'нет бетона' },
|
||||||
|
{ 'Техника': 327 }
|
||||||
|
);
|
||||||
|
const [report] = buildImportedReports(makeCheck([idleRow]), 51);
|
||||||
|
|
||||||
|
expect(report.nodes).toEqual([
|
||||||
|
{ data: { _cree9_jobid_value: JOB, cree9_value: 5, cree9_comment: undefined }, children: undefined },
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
cree9_technicid: 327,
|
||||||
|
cree9_count: 1,
|
||||||
|
cree9_hours: 3,
|
||||||
|
cree9_hourstype: 'Часы простоя',
|
||||||
|
cree9_comment: 'нет бетона'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('комментарий строки с часами работы остаётся на работе', () => {
|
||||||
|
const row = makeRow(2, { 'Факт': '5', 'Комментарий (данные)': 'залили' }, { 'Техника': 327 });
|
||||||
|
const [report] = buildImportedReports(makeCheck([row]), 51);
|
||||||
|
|
||||||
|
expect(report.nodes[0].data.cree9_comment).toEqual('залили');
|
||||||
|
expect(report.nodes[0].children?.[0].cree9_comment).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отдельные строки идут после работ: сначала персонал, потом техника — как в форме', () => {
|
||||||
|
const technic = makeRow(2, { 'Количество': '1', 'Часы': '2', 'Тип часов': 'Часы ремонта' }, { 'Техника': 327 });
|
||||||
|
const profession = makeRow(3, { 'Количество': '2', 'Часы': '4', 'Тип часов': 'Часы простоя' }, { 'Профессия': 181 });
|
||||||
|
const [report] = buildImportedReports(makeCheck([technic, profession]), 51);
|
||||||
|
|
||||||
|
expect(report.nodes.slice(2).map((node) => node.data)).toEqual([
|
||||||
|
{ cree9_professionid: 181, cree9_count: 2, cree9_hours: 4, cree9_hourstype: 'Часы простоя' },
|
||||||
|
{ cree9_technicid: 327, cree9_count: 1, cree9_hours: 2, cree9_hourstype: 'Часы ремонта' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('разные компании или даты — разные отчёты', () => {
|
||||||
|
const otherCompany = makeRow(3, {}, { 'Компания': 163 });
|
||||||
|
const otherDate = makeRow(4, { 'Дата': '02.10.2026 5:00:00' }, {});
|
||||||
|
const reports = buildImportedReports(makeCheck([technicRow, otherCompany, otherDate]), 51);
|
||||||
|
|
||||||
|
expect(reports.map((report) => report.rowNumbers)).toEqual([[2], [3], [4]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('объект — самый глубокий заполненный уровень', () => {
|
||||||
|
const row = makeRow(2, {}, { 'Объект 3': 1600 });
|
||||||
|
expect(buildImportedReports(makeCheck([row]), 51)[0].payload.cree9_objectid).toEqual(1600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет компании — ошибка с номером строки, ничего не собирается', () => {
|
||||||
|
const row = makeRow(7, {}, { 'Компания': undefined });
|
||||||
|
expect(() => buildImportedReports(makeCheck([row]), 51)).toThrow('Строка 7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('строка без вида работы — ошибка (сервер такие не пропускает, но не пишем молча)', () => {
|
||||||
|
const row = makeRow(5, {}, { 'Вид работы': undefined, 'Профессия': 181 });
|
||||||
|
expect(() => buildImportedReports(makeCheck([row]), 51)).toThrow('Строка 5: не определён вид работы.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('разбор значений Excel', () => {
|
||||||
|
it('дата: дд.мм.гггг с временем → гггг-мм-дд', () => {
|
||||||
|
expect(toIsoDate('01.10.2026 5:00:00')).toEqual('2026-10-01');
|
||||||
|
expect(toIsoDate(null)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('число: запятая как разделитель, пусто → undefined', () => {
|
||||||
|
expect(toNumber('5,5')).toEqual(5.5);
|
||||||
|
expect(toNumber('11')).toEqual(11);
|
||||||
|
expect(toNumber('')).toBeUndefined();
|
||||||
|
expect(toNumber(null)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { IReportDataFields, IReportDataNode, IReportPayload } from '../report/reportTypes';
|
||||||
|
import { HOURS_TYPES } from '../report/reportConstants';
|
||||||
|
import { IReportImportCheck, IReportImportRow } from './reportImportTypes';
|
||||||
|
import { IMPORT_COLUMNS as COL } from './reportImportColumns';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
|
// Ответ проверки → отчёты в том же виде, в каком их сохраняет форма «Отправить отчёт».
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||||
|
|
||||||
|
/** Отчёт к записи. Номер назначается при сохранении (наибольший в проекте + 1). */
|
||||||
|
export interface IImportedReport {
|
||||||
|
payload: Omit<IReportPayload, 'cree9_number'>;
|
||||||
|
nodes: IReportDataNode[];
|
||||||
|
/** Строки Excel, из которых собран отчёт, — для сообщений пользователю. */
|
||||||
|
rowNumbers: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «01.10.2026 5:00:00» → «2026-10-01» (формат даты, который пишет форма). Время отбрасываем. */
|
||||||
|
export function toIsoDate(value: string | null): string | undefined {
|
||||||
|
const match = /^(\d{2})\.(\d{2})\.(\d{4})/.exec(value || '');
|
||||||
|
return match ? `${match[3]}-${match[2]}-${match[1]}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «5», «5,5» → число; пусто или не число → undefined. */
|
||||||
|
export function toNumber(value: string | null | undefined): number | undefined {
|
||||||
|
if (value === null || value === undefined || value.trim() === '') return undefined;
|
||||||
|
const result = Number(value.replace(',', '.'));
|
||||||
|
return isNaN(result) ? undefined : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function idNumber(row: IReportImportRow, column: string): number | undefined {
|
||||||
|
const id = row.ids[column];
|
||||||
|
return typeof id === 'number' ? id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textValue(row: IReportImportRow, column: string): string | undefined {
|
||||||
|
return row.values[column] || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Объект отчёта — самый глубокий заполненный уровень (Объект 3 → 2 → 1). */
|
||||||
|
function deepestObjectId(row: IReportImportRow): number | undefined {
|
||||||
|
const ids = COL.objects.map((column) => idNumber(row, column)).filter((id) => id !== undefined);
|
||||||
|
return ids[ids.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(row: IReportImportRow, projectId: number): IImportedReport['payload'] {
|
||||||
|
const reportDate = toIsoDate(row.values[COL.date]);
|
||||||
|
const companyId = idNumber(row, COL.company);
|
||||||
|
const shiftId = idNumber(row, COL.shift);
|
||||||
|
const objectId = deepestObjectId(row);
|
||||||
|
// Сервер уже проверил строку; если поля всё же нет — не пишем отчёт без шапки.
|
||||||
|
if (!reportDate || !companyId || !shiftId || !objectId) {
|
||||||
|
throw new Error(REPORT_IMPORT_TEXTS.build.missingHeader(row.row));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cree9_report_date: reportDate,
|
||||||
|
cree9_companyid: companyId,
|
||||||
|
cree9_shiftid: shiftId,
|
||||||
|
cree9_objectid: objectId,
|
||||||
|
cree9_projectid: projectId,
|
||||||
|
cree9_comment: textValue(row, COL.reportComment)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Персонал и техника строки (порядок как в форме: сначала персонал). */
|
||||||
|
function buildResources(row: IReportImportRow, hoursType: string): IReportDataFields[] {
|
||||||
|
const common = { cree9_count: toNumber(row.values[COL.count]), cree9_hours: toNumber(row.values[COL.hours]) };
|
||||||
|
const resources: IReportDataFields[] = [];
|
||||||
|
const professionId = idNumber(row, COL.profession);
|
||||||
|
const technicId = idNumber(row, COL.technic);
|
||||||
|
if (professionId) resources.push({ cree9_professionid: professionId, ...common, cree9_hourstype: hoursType });
|
||||||
|
if (technicId) resources.push({ cree9_technicid: technicId, ...common, cree9_hourstype: hoursType });
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Узлы одной строки Excel: своя строка-работа на каждую строку (вид работы сервер требует всегда).
|
||||||
|
* Ресурс с «Часы работы» — под работу, комментарий строки — работе.
|
||||||
|
* Простой / ремонт — отдельной строкой, как в форме (под работой форма хранит только часы работы),
|
||||||
|
* и комментарий строки — ей: в форме это причина простоя. Пустой комментарий импорт не блокирует —
|
||||||
|
* сервер такой файл принимает; обязательность из формы сработает, только когда отчёт будут править.
|
||||||
|
*/
|
||||||
|
function buildRowNodes(row: IReportImportRow): { work: IReportDataNode; general: IReportDataFields[] } {
|
||||||
|
const jobId = row.ids[COL.job];
|
||||||
|
if (typeof jobId !== 'string') throw new Error(REPORT_IMPORT_TEXTS.build.missingJob(row.row));
|
||||||
|
|
||||||
|
const hoursType = textValue(row, COL.hoursType) || HOURS_TYPES.work;
|
||||||
|
const resources = buildResources(row, hoursType);
|
||||||
|
const comment = textValue(row, COL.rowComment);
|
||||||
|
const isWorkHours = hoursType === HOURS_TYPES.work;
|
||||||
|
const work: IReportDataNode = {
|
||||||
|
data: {
|
||||||
|
_cree9_jobid_value: jobId,
|
||||||
|
cree9_value: toNumber(row.values[COL.fact]),
|
||||||
|
cree9_comment: isWorkHours ? comment : undefined
|
||||||
|
},
|
||||||
|
children: isWorkHours && resources.length > 0 ? resources : undefined
|
||||||
|
};
|
||||||
|
const general = isWorkHours ? [] : resources.map((item) => ({ ...item, cree9_comment: comment }));
|
||||||
|
return { work, general };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportBuildResult = { kind: 'ok'; reports: IImportedReport[] } | { kind: 'error'; message: string };
|
||||||
|
|
||||||
|
/** Сборка без исключений — для показа на экране до записи. */
|
||||||
|
export function tryBuildImportedReports(check: IReportImportCheck, projectId: number): ImportBuildResult {
|
||||||
|
try {
|
||||||
|
return { kind: 'ok', reports: buildImportedReports(check, projectId) };
|
||||||
|
} catch (error) {
|
||||||
|
return { kind: 'error', message: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportKey(payload: IImportedReport['payload']): string {
|
||||||
|
return [payload.cree9_report_date, payload.cree9_companyid, payload.cree9_shiftid, payload.cree9_objectid].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IReportDraft {
|
||||||
|
report: IImportedReport;
|
||||||
|
works: IReportDataNode[];
|
||||||
|
general: IReportDataFields[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строки с одинаковыми датой, компанией, сменой и объектом — один отчёт.
|
||||||
|
* Порядок узлов как в форме: работы, затем общий персонал, затем общая техника.
|
||||||
|
*/
|
||||||
|
export function buildImportedReports(check: IReportImportCheck, projectId: number): IImportedReport[] {
|
||||||
|
const drafts = new Map<string, IReportDraft>();
|
||||||
|
check.rows.forEach((row) => {
|
||||||
|
const payload = buildPayload(row, projectId);
|
||||||
|
const key = reportKey(payload);
|
||||||
|
const draft = drafts.get(key) ?? { report: { payload, nodes: [], rowNumbers: [] }, works: [], general: [] };
|
||||||
|
const { work, general } = buildRowNodes(row);
|
||||||
|
draft.works.push(work);
|
||||||
|
draft.general.push(...general);
|
||||||
|
draft.report.rowNumbers.push(row.row);
|
||||||
|
drafts.set(key, draft);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(drafts.values()).map(({ report, works, general }) => ({
|
||||||
|
...report,
|
||||||
|
nodes: [
|
||||||
|
...works,
|
||||||
|
...general.filter((item) => item.cree9_professionid).map((data) => ({ data })),
|
||||||
|
...general.filter((item) => item.cree9_technicid).map((data) => ({ data }))
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Заголовки колонок Excel-формы отчёта — ключи values / ids в ответе проверки.
|
||||||
|
|
||||||
|
export const IMPORT_COLUMNS = {
|
||||||
|
date: 'Дата',
|
||||||
|
company: 'Компания',
|
||||||
|
shift: 'Смена',
|
||||||
|
objects: ['Объект 1', 'Объект 2', 'Объект 3'],
|
||||||
|
job: 'Вид работы',
|
||||||
|
fact: 'Факт',
|
||||||
|
technic: 'Техника',
|
||||||
|
profession: 'Профессия',
|
||||||
|
count: 'Количество',
|
||||||
|
hours: 'Часы',
|
||||||
|
hoursType: 'Тип часов',
|
||||||
|
reportComment: 'Комментарий',
|
||||||
|
rowComment: 'Комментарий (данные)'
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { reportImportUrl, checkReportFile } from './reportImportRepository';
|
||||||
|
import { createFakeHttpClient } from '../../shared/testing/fakeHttpClient';
|
||||||
|
|
||||||
|
const API = 'https://api.tnsu.kz';
|
||||||
|
|
||||||
|
describe('проверка файла отчётов', () => {
|
||||||
|
it('адрес — как в Swagger', () => {
|
||||||
|
expect(reportImportUrl(API, 51)).toEqual('https://api.tnsu.kz/Import/StroyBot/Reports?projectId=51');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST multipart: файл в поле file, Content-Type не задаём (boundary ставит браузер)', async () => {
|
||||||
|
const body = { total_rows: 0, valid_rows: 0, invalid_rows: 0, rows: [], notes: [] };
|
||||||
|
const client = createFakeHttpClient([{ match: '/Import/StroyBot/Reports', response: { body } }]);
|
||||||
|
const file = new File(['xlsx'], 'отчёт.xlsx');
|
||||||
|
|
||||||
|
expect(await checkReportFile(client, API, 51, file)).toEqual(body);
|
||||||
|
|
||||||
|
const sent = client.requests[0].request;
|
||||||
|
expect(sent.method).toEqual('POST');
|
||||||
|
expect(sent.headers).toEqual({ accept: '*/*' });
|
||||||
|
expect(sent.body).toBeInstanceOf(FormData);
|
||||||
|
expect(((sent.body as FormData).get('file') as File).name).toEqual('отчёт.xlsx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ошибка сервера пробрасывается с текстом ответа', async () => {
|
||||||
|
const client = createFakeHttpClient([
|
||||||
|
{ match: '/Import/StroyBot/Reports', response: { status: 400, body: 'неверный формат' } }
|
||||||
|
]);
|
||||||
|
await expect(checkReportFile(client, API, 51, new File(['x'], 'a.xlsx'))).rejects.toThrow('неверный формат');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { ensureOk, IHttpClient } from '../../services/http/IHttpClient';
|
||||||
|
import { IReportImportCheck } from './reportImportTypes';
|
||||||
|
|
||||||
|
/** Имя поля формы с файлом — как в Swagger эндпоинта. */
|
||||||
|
const FILE_FIELD = 'file';
|
||||||
|
|
||||||
|
export function reportImportUrl(apiBaseUrl: string, projectId: number): string {
|
||||||
|
return `${apiBaseUrl}/Import/StroyBot/Reports?projectId=${projectId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка Excel-файла отчётов. Эндпоинт ничего не сохраняет: разбирает строки, сопоставляет значения
|
||||||
|
* со справочниками (ids) и возвращает ошибки по строкам. Отчёты по результату записывает фронт.
|
||||||
|
* Content-Type не задаём: для FormData браузер сам ставит multipart/form-data с boundary.
|
||||||
|
*/
|
||||||
|
export async function checkReportFile(
|
||||||
|
api: IHttpClient,
|
||||||
|
apiBaseUrl: string,
|
||||||
|
projectId: number,
|
||||||
|
file: File
|
||||||
|
): Promise<IReportImportCheck> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append(FILE_FIELD, file, file.name);
|
||||||
|
const response = await ensureOk(
|
||||||
|
await api.fetch(reportImportUrl(apiBaseUrl, projectId), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { accept: '*/*' },
|
||||||
|
body: form
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return (await response.json()) as IReportImportCheck;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { canImportFile, columnsOf, isExcelFileName, issuesByColumn, visibleRows } from './reportImportRules';
|
||||||
|
import { IReportImportCheck, IReportImportRow } from './reportImportTypes';
|
||||||
|
|
||||||
|
// Строки — по ответу сервера на тестовый файл проекта 51 (сокращённо).
|
||||||
|
const row = (
|
||||||
|
num: number,
|
||||||
|
values: IReportImportRow['values'],
|
||||||
|
issues: IReportImportRow['issues'] = []
|
||||||
|
): IReportImportRow => ({ row: num, valid: issues.length === 0, values, ids: {}, issues });
|
||||||
|
|
||||||
|
const check = (rows: IReportImportRow[]): IReportImportCheck => ({
|
||||||
|
kind: 'reports',
|
||||||
|
file_name: 'test.xlsx',
|
||||||
|
project_id: 51,
|
||||||
|
project_name: 'TEST',
|
||||||
|
total_rows: rows.length,
|
||||||
|
valid_rows: rows.filter((item) => item.valid).length,
|
||||||
|
invalid_rows: rows.filter((item) => !item.valid).length,
|
||||||
|
rows,
|
||||||
|
notes: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const technicRow = row(2, { Дата: '01.10.2026', Техника: 'Насос', Профессия: null }, [
|
||||||
|
{ column: 'Профессия', message: 'значение не указано' },
|
||||||
|
{ column: 'Вид профессии', message: 'значение не указано' }
|
||||||
|
]);
|
||||||
|
const validRow = row(3, { Дата: '01.10.2026', Техника: null, Профессия: 'Арматурщик' });
|
||||||
|
|
||||||
|
describe('columnsOf', () => {
|
||||||
|
it('колонки в порядке файла; колонка только из ошибок — в конце', () => {
|
||||||
|
expect(columnsOf(check([technicRow, validRow]))).toEqual(['Дата', 'Техника', 'Профессия', 'Вид профессии']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('issuesByColumn', () => {
|
||||||
|
it('несколько сообщений одной колонки собираются вместе', () => {
|
||||||
|
const issues = issuesByColumn(
|
||||||
|
row(2, {}, [
|
||||||
|
{ column: 'Профессия', message: 'значение не указано' },
|
||||||
|
{ column: 'Профессия', message: 'id неоднозначен' }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(issues.get('Профессия')).toEqual(['значение не указано', 'id неоднозначен']);
|
||||||
|
expect(issues.get('Техника')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('visibleRows / canImportFile', () => {
|
||||||
|
it('фильтр «только с ошибками»', () => {
|
||||||
|
expect(visibleRows(check([technicRow, validRow]), true).map((item) => item.row)).toEqual([2]);
|
||||||
|
expect(visibleRows(check([technicRow, validRow]), false)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('импорт — только при строках без единой ошибки', () => {
|
||||||
|
expect(canImportFile(check([validRow]))).toBe(true);
|
||||||
|
expect(canImportFile(check([technicRow, validRow]))).toBe(false);
|
||||||
|
expect(canImportFile(check([]))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isExcelFileName', () => {
|
||||||
|
it('только .xlsx, без учёта регистра', () => {
|
||||||
|
expect(isExcelFileName('Отчёт.XLSX')).toBe(true);
|
||||||
|
expect(isExcelFileName('отчёт.xls')).toBe(false);
|
||||||
|
expect(isExcelFileName('отчёт.csv')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { IReportImportCheck, IReportImportRow } from './reportImportTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Колонки таблицы — в порядке, в каком их прислал сервер (это порядок колонок файла).
|
||||||
|
* Колонка, которая есть только в ошибках (её нет в values), добавляется в конец — чтобы ошибка не потерялась.
|
||||||
|
*/
|
||||||
|
export function columnsOf(check: IReportImportCheck): string[] {
|
||||||
|
const columns: string[] = [];
|
||||||
|
const add = (column: string): void => {
|
||||||
|
if (columns.indexOf(column) === -1) columns.push(column);
|
||||||
|
};
|
||||||
|
check.rows.forEach((row) => Object.keys(row.values || {}).forEach(add));
|
||||||
|
check.rows.forEach((row) => (row.issues || []).forEach((issue) => add(issue.column)));
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ошибки строки по колонкам: одна колонка может иметь несколько сообщений. */
|
||||||
|
export function issuesByColumn(row: IReportImportRow): Map<string, string[]> {
|
||||||
|
const result = new Map<string, string[]>();
|
||||||
|
(row.issues || []).forEach((issue) => {
|
||||||
|
const messages = result.get(issue.column) ?? [];
|
||||||
|
result.set(issue.column, [...messages, issue.message]);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строки для показа: все или только с ошибками. */
|
||||||
|
export function visibleRows(check: IReportImportCheck, onlyInvalid: boolean): IReportImportRow[] {
|
||||||
|
return onlyInvalid ? check.rows.filter((row) => !row.valid) : check.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Импортировать можно, только если в файле есть строки и ни одной с ошибкой. */
|
||||||
|
export function canImportFile(check: IReportImportCheck): boolean {
|
||||||
|
return check.total_rows > 0 && check.invalid_rows === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Расширения Excel, которые принимает поле выбора файла. */
|
||||||
|
export const EXCEL_ACCEPT = '.xlsx';
|
||||||
|
|
||||||
|
export function isExcelFileName(fileName: string): boolean {
|
||||||
|
return /\.xlsx$/i.test(fileName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { IReportDataNode, IReportPayload } from '../report/reportTypes';
|
||||||
|
import { IImportedReport } from './reportImportBuilder';
|
||||||
|
import { ImportReportWriter, saveImportedReports } from './reportImportSave';
|
||||||
|
|
||||||
|
function makeReport(date: string, rowNumbers: number[]): IImportedReport {
|
||||||
|
return {
|
||||||
|
payload: {
|
||||||
|
cree9_report_date: date,
|
||||||
|
cree9_companyid: 29,
|
||||||
|
cree9_shiftid: 1,
|
||||||
|
cree9_objectid: 1517,
|
||||||
|
cree9_projectid: 51,
|
||||||
|
cree9_comment: undefined
|
||||||
|
},
|
||||||
|
nodes: [{ data: { _cree9_jobid_value: 'job', cree9_value: 5 } }],
|
||||||
|
rowNumbers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writer, который выдаёт номера с 10 и запоминает, что записано. failOnCall — номер вызова сохранения с ошибкой. */
|
||||||
|
function makeWriter(failOnCall?: number): ImportReportWriter & { saved: IReportPayload[]; nodes: IReportDataNode[][] } {
|
||||||
|
let nextNumber = 10;
|
||||||
|
const writer = {
|
||||||
|
saved: [] as IReportPayload[],
|
||||||
|
nodes: [] as IReportDataNode[][],
|
||||||
|
getNextReportNumber: async (): Promise<number> => nextNumber++,
|
||||||
|
saveReportWithData: async (report: IReportPayload, nodes: IReportDataNode[]): Promise<string> => {
|
||||||
|
if (writer.saved.length + 1 === failOnCall) throw new Error('Dataverse недоступен');
|
||||||
|
writer.saved.push(report);
|
||||||
|
writer.nodes.push(nodes);
|
||||||
|
return `id-${report.cree9_number}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return writer;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('запись импортированных отчётов', () => {
|
||||||
|
it('по одному, каждому — свой номер; узлы передаются как есть', async () => {
|
||||||
|
const writer = makeWriter();
|
||||||
|
const progress: number[] = [];
|
||||||
|
const reports = [makeReport('2026-10-01', [2, 3, 4]), makeReport('2026-10-02', [5])];
|
||||||
|
|
||||||
|
const result = await saveImportedReports(writer, 51, reports, (count) => progress.push(count));
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
saved: [
|
||||||
|
{ reportNumber: 10, reportDate: '2026-10-01', rowNumbers: [2, 3, 4] },
|
||||||
|
{ reportNumber: 11, reportDate: '2026-10-02', rowNumbers: [5] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(writer.saved.map((report) => report.cree9_number)).toEqual([10, 11]);
|
||||||
|
expect(writer.nodes[0]).toEqual(reports[0].nodes);
|
||||||
|
expect(progress).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ошибка останавливает запись и возвращает уже записанные отчёты', async () => {
|
||||||
|
const writer = makeWriter(2);
|
||||||
|
const reports = [makeReport('2026-10-01', [2]), makeReport('2026-10-02', [3]), makeReport('2026-10-03', [4])];
|
||||||
|
|
||||||
|
const result = await saveImportedReports(writer, 51, reports, () => undefined);
|
||||||
|
|
||||||
|
expect(result.saved.map((report) => report.reportNumber)).toEqual([10]);
|
||||||
|
expect(result.errorMessage).toEqual('Dataverse недоступен');
|
||||||
|
expect(result.failedRows).toEqual([3]);
|
||||||
|
expect(writer.saved).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { IDataverseReportRepository } from '../report/dataverseReportRepository';
|
||||||
|
import { IImportedReport } from './reportImportBuilder';
|
||||||
|
|
||||||
|
/** Нужная импорту часть репозитория формы отчёта — тот же код записи, что у «Отправить отчёт». */
|
||||||
|
export type ImportReportWriter = Pick<IDataverseReportRepository, 'getNextReportNumber' | 'saveReportWithData'>;
|
||||||
|
|
||||||
|
export interface ISavedImportReport {
|
||||||
|
reportNumber: number;
|
||||||
|
/** «гггг-мм-дд» */
|
||||||
|
reportDate: string;
|
||||||
|
rowNumbers: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IImportSaveResult {
|
||||||
|
saved: ISavedImportReport[];
|
||||||
|
/** Есть — запись остановилась на отчёте из строк failedRows. */
|
||||||
|
errorMessage?: string;
|
||||||
|
failedRows?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Записывает отчёты по одному: номер выдаётся перед каждым (наибольший в проекте + 1),
|
||||||
|
* поэтому параллельно нельзя — номера совпали бы. Ошибка останавливает запись; уже записанные
|
||||||
|
* отчёты возвращаем, чтобы пользователь не загрузил тот же файл целиком повторно.
|
||||||
|
*/
|
||||||
|
export async function saveImportedReports(
|
||||||
|
writer: ImportReportWriter,
|
||||||
|
projectId: number,
|
||||||
|
reports: IImportedReport[],
|
||||||
|
onProgress: (savedCount: number) => void
|
||||||
|
): Promise<IImportSaveResult> {
|
||||||
|
const saved: ISavedImportReport[] = [];
|
||||||
|
for (const report of reports) {
|
||||||
|
try {
|
||||||
|
const reportNumber = await writer.getNextReportNumber(projectId);
|
||||||
|
await writer.saveReportWithData({ ...report.payload, cree9_number: reportNumber }, report.nodes);
|
||||||
|
saved.push({ reportNumber, reportDate: report.payload.cree9_report_date, rowNumbers: report.rowNumbers });
|
||||||
|
onProgress(saved.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка записи импортированного отчёта:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
return { saved, errorMessage, failedRows: report.rowNumbers };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { saved };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Ответ проверки Excel-файла отчётов: POST /Import/StroyBot/Reports?projectId= (multipart, поле file).
|
||||||
|
// Эндпоинт ничего не сохраняет — только разбирает файл и сопоставляет значения со справочниками.
|
||||||
|
// Имена полей — как в ответе API (snake_case), поэтому не переименовываем.
|
||||||
|
/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */
|
||||||
|
|
||||||
|
/** Ошибка в ячейке: колонка — заголовок колонки файла («Профессия», «Техника»…). */
|
||||||
|
export interface IReportImportIssue {
|
||||||
|
column: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка файла после проверки. */
|
||||||
|
export interface IReportImportRow {
|
||||||
|
/** Номер строки в Excel (заголовок — строка 1). */
|
||||||
|
row: number;
|
||||||
|
valid: boolean;
|
||||||
|
/** Значения ячеек по заголовкам колонок, как их прочитал сервер. Пустая ячейка — null. */
|
||||||
|
values: Record<string, string | null>;
|
||||||
|
/** Во что сервер сопоставил значения (id объектов, работы, компании…). Нам нужно только для отладки. */
|
||||||
|
ids: Record<string, unknown>;
|
||||||
|
issues: IReportImportIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReportImportCheck {
|
||||||
|
kind: string;
|
||||||
|
file_name: string;
|
||||||
|
project_id: number;
|
||||||
|
project_name: string;
|
||||||
|
total_rows: number;
|
||||||
|
valid_rows: number;
|
||||||
|
invalid_rows: number;
|
||||||
|
rows: IReportImportRow[];
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Тексты раздела «Импорт отчёта из Excel».
|
||||||
|
|
||||||
|
export const REPORT_IMPORT_TEXTS = {
|
||||||
|
headerLabel: 'Импорт отчёта из Excel',
|
||||||
|
noProject: 'Проект не выбран. Выберите проект на главной.',
|
||||||
|
noAccess: 'Импорт отчётов недоступен: нужно право редактирования, проект не должен быть архивным или завершённым.',
|
||||||
|
intro:
|
||||||
|
'Загрузите заполненную форму ежедневного отчёта (.xlsx) — файл сразу проверится. ' +
|
||||||
|
'Ошибки подсветятся в таблице; если ошибок нет, отчёты можно будет импортировать.',
|
||||||
|
chooseFile: 'Выбрать файл',
|
||||||
|
chooseAnotherFile: 'Выбрать другой файл',
|
||||||
|
selectedFile: (name: string): string => `Выбран файл: ${name}`,
|
||||||
|
checking: 'Проверяем файл…',
|
||||||
|
wrongExtension: 'Нужен файл Excel в формате .xlsx',
|
||||||
|
checkError: (details: string): string => `Не удалось проверить файл: ${details}`,
|
||||||
|
import: {
|
||||||
|
button: (reportCount: number): string => `Импортировать (отчётов: ${reportCount})`,
|
||||||
|
saving: (savedCount: number, total: number): string => `Сохраняем отчёты… ${savedCount} из ${total}`,
|
||||||
|
confirmTitle: 'Импорт отчётов',
|
||||||
|
confirmMessage: (reportCount: number, projectTitle: string): string =>
|
||||||
|
`Будет создано отчётов: ${reportCount} в проекте «${projectTitle}». Продолжить?`,
|
||||||
|
confirm: 'Импортировать',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
savedReport: (reportNumber: number, date: string): string => `№${reportNumber} за ${date}`,
|
||||||
|
success: (list: string): string => `Импорт завершён. Созданы отчёты: ${list}.`,
|
||||||
|
failed: (rows: string, details: string): string => `Не удалось записать отчёт из строк ${rows}: ${details}`,
|
||||||
|
partlySaved: (list: string): string =>
|
||||||
|
`Уже созданы отчёты: ${list}. Не загружайте этот файл повторно целиком — уберите из него эти строки, ` +
|
||||||
|
'иначе отчёты задвоятся.',
|
||||||
|
mayBePartial:
|
||||||
|
'Отчёт, на котором произошла ошибка, мог записаться частично — проверьте «Отчёты по проекту» ' +
|
||||||
|
'перед повторной загрузкой.',
|
||||||
|
openReports: 'Открыть «Отчёты по проекту»'
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
missingHeader: (row: number): string => `Строка ${row}: не определены дата, компания, смена или объект.`,
|
||||||
|
missingJob: (row: number): string => `Строка ${row}: не определён вид работы.`
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
file: (name: string): string => `Файл: ${name}`,
|
||||||
|
total: (count: number): string => `Строк: ${count}`,
|
||||||
|
valid: (count: number): string => `Без ошибок: ${count}`,
|
||||||
|
invalid: (count: number): string => `С ошибками: ${count}`
|
||||||
|
},
|
||||||
|
emptyFile: 'В файле нет строк с данными.',
|
||||||
|
readyToImport: 'Ошибок нет — файл готов к импорту.',
|
||||||
|
fixInExcel: 'Исправьте ошибки в Excel и загрузите файл снова.',
|
||||||
|
onlyInvalid: 'Только строки с ошибками',
|
||||||
|
columns: {
|
||||||
|
row: 'Строка',
|
||||||
|
issues: 'Ошибки'
|
||||||
|
},
|
||||||
|
notesTitle: 'Примечания',
|
||||||
|
empty: '—'
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useAppServices } from '../../app/AppServicesContext';
|
||||||
|
import { checkReportFile } from './reportImportRepository';
|
||||||
|
import { IReportImportCheck } from './reportImportTypes';
|
||||||
|
import { REPORT_IMPORT_TEXTS } from './texts';
|
||||||
|
|
||||||
|
export type ReportFileCheckStatus =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'checking' }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
| { kind: 'done'; check: IReportImportCheck };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка Excel-файла на сервере. Сервер ничего не сохраняет, поэтому проверку можно
|
||||||
|
* запускать сразу при выборе файла. reset — сбросить результат (файл не Excel).
|
||||||
|
*/
|
||||||
|
export function useReportFileCheck(projectId: number | undefined): {
|
||||||
|
status: ReportFileCheckStatus;
|
||||||
|
checkFile: (file: File) => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
} {
|
||||||
|
const services = useAppServices();
|
||||||
|
const [status, setStatus] = React.useState<ReportFileCheckStatus>({ kind: 'idle' });
|
||||||
|
const isMountedRef = React.useRef<boolean>(true);
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
() => () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkFile = async (file: File): Promise<void> => {
|
||||||
|
if (projectId === undefined) return;
|
||||||
|
setStatus({ kind: 'checking' });
|
||||||
|
try {
|
||||||
|
const check = await checkReportFile(services.tnsuApi, services.settings.apiBaseUrl, projectId, file);
|
||||||
|
if (isMountedRef.current) setStatus({ kind: 'done', check });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки файла отчёта:', error);
|
||||||
|
const details = error instanceof Error ? error.message : String(error);
|
||||||
|
if (isMountedRef.current) setStatus({ kind: 'error', message: REPORT_IMPORT_TEXTS.checkError(details) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = React.useCallback((): void => setStatus({ kind: 'idle' }), []);
|
||||||
|
|
||||||
|
return { status, checkFile, reset };
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useAppServices } from '../../app/AppServicesContext';
|
||||||
|
import { createDataverseReportRepository } from '../report/dataverseReportRepository';
|
||||||
|
import { IImportedReport } from './reportImportBuilder';
|
||||||
|
import { IImportSaveResult, saveImportedReports } from './reportImportSave';
|
||||||
|
|
||||||
|
export type ReportImportStatus =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'saving'; savedCount: number; total: number }
|
||||||
|
| { kind: 'finished'; result: IImportSaveResult };
|
||||||
|
|
||||||
|
/** Запись собранных отчётов в Dataverse с прогрессом. reset — при выборе нового файла. */
|
||||||
|
export function useReportImport(projectId: number | undefined): {
|
||||||
|
status: ReportImportStatus;
|
||||||
|
importReports: (reports: IImportedReport[]) => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
} {
|
||||||
|
const services = useAppServices();
|
||||||
|
const [status, setStatus] = React.useState<ReportImportStatus>({ kind: 'idle' });
|
||||||
|
const isMountedRef = React.useRef<boolean>(true);
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
() => () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const finish = (result: IImportSaveResult): void => {
|
||||||
|
if (isMountedRef.current) setStatus({ kind: 'finished', result });
|
||||||
|
};
|
||||||
|
|
||||||
|
const importReports = async (reports: IImportedReport[]): Promise<void> => {
|
||||||
|
if (projectId === undefined) return;
|
||||||
|
const total = reports.length;
|
||||||
|
setStatus({ kind: 'saving', savedCount: 0, total });
|
||||||
|
const writer = createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl);
|
||||||
|
const result = await saveImportedReports(writer, projectId, reports, (savedCount) => {
|
||||||
|
if (isMountedRef.current) setStatus({ kind: 'saving', savedCount, total });
|
||||||
|
});
|
||||||
|
finish(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = React.useCallback((): void => setStatus({ kind: 'idle' }), []);
|
||||||
|
|
||||||
|
return { status, importReports, reset };
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|||||||
export interface IHttpRequest {
|
export interface IHttpRequest {
|
||||||
method?: HttpMethod;
|
method?: HttpMethod;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
body?: string | ArrayBuffer;
|
/** FormData — для загрузки файлов (multipart): Content-Type с boundary браузер ставит сам. */
|
||||||
|
body?: string | ArrayBuffer | FormData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IHttpResponse {
|
export interface IHttpResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user