From 1ba67fe4c1be36689828d86d3911a76937be3cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Thu, 22 Oct 2026 17:01:27 +0500 Subject: [PATCH] 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) --- docs/TEST-PLAN.md | 15 ++ src/webparts/stroyBot/app/PageOutlet.tsx | 3 + src/webparts/stroyBot/app/navigation.test.ts | 1 + src/webparts/stroyBot/app/navigation.ts | 1 + .../stroyBot/app/project/projectRules.test.ts | 6 + .../stroyBot/app/project/projectRules.ts | 3 +- src/webparts/stroyBot/app/routing/routes.ts | 3 +- .../report/dataverseReportRepository.test.ts | 3 +- .../pages/reportImport/ReportFileField.tsx | 40 +++++ .../reportImport/ReportImportActions.tsx | 40 +++++ .../reportImport/ReportImportOutcome.tsx | 45 +++++ .../reportImport/ReportImportPage.module.scss | 167 ++++++++++++++++++ .../pages/reportImport/ReportImportPage.tsx | 81 +++++++++ .../pages/reportImport/ReportImportResult.tsx | 34 ++++ .../pages/reportImport/ReportImportRow.tsx | 36 ++++ .../pages/reportImport/ReportImportStep.tsx | 28 +++ .../reportImport/ReportImportSummary.tsx | 30 ++++ .../pages/reportImport/ReportImportTable.tsx | 48 +++++ .../reportImport/reportImportBuilder.test.ts | 163 +++++++++++++++++ .../pages/reportImport/reportImportBuilder.ts | 149 ++++++++++++++++ .../pages/reportImport/reportImportColumns.ts | 17 ++ .../reportImportRepository.test.ts | 31 ++++ .../reportImport/reportImportRepository.ts | 32 ++++ .../reportImport/reportImportRules.test.ts | 67 +++++++ .../pages/reportImport/reportImportRules.ts | 42 +++++ .../reportImport/reportImportSave.test.ts | 67 +++++++ .../pages/reportImport/reportImportSave.ts | 46 +++++ .../pages/reportImport/reportImportTypes.ts | 34 ++++ .../stroyBot/pages/reportImport/texts.ts | 55 ++++++ .../pages/reportImport/useReportFileCheck.ts | 49 +++++ .../pages/reportImport/useReportImport.ts | 47 +++++ .../stroyBot/services/http/IHttpClient.ts | 3 +- 32 files changed, 1382 insertions(+), 4 deletions(-) create mode 100644 src/webparts/stroyBot/pages/reportImport/ReportFileField.tsx create mode 100644 src/webparts/stroyBot/pages/reportImport/ReportImportActions.tsx create mode 100644 src/webparts/stroyBot/pages/reportImport/ReportImportOutcome.tsx create mode 100644 src/webparts/stroyBot/pages/reportImport/ReportImportPage.module.scss create mode 100644 src/webparts/stroyBot/pages/reportImport/ReportImportPage.tsx create mode 100644 src/webparts/stroyBot/pages/reportImport/ReportImportResult.tsx create mode 100644 src/webparts/stroyBot/pages/reportImport/ReportImportRow.tsx create mode 100644 src/webparts/stroyBot/pages/reportImport/ReportImportStep.tsx create mode 100644 src/webparts/stroyBot/pages/reportImport/ReportImportSummary.tsx create mode 100644 src/webparts/stroyBot/pages/reportImport/ReportImportTable.tsx create mode 100644 src/webparts/stroyBot/pages/reportImport/reportImportBuilder.test.ts create mode 100644 src/webparts/stroyBot/pages/reportImport/reportImportBuilder.ts create mode 100644 src/webparts/stroyBot/pages/reportImport/reportImportColumns.ts create mode 100644 src/webparts/stroyBot/pages/reportImport/reportImportRepository.test.ts create mode 100644 src/webparts/stroyBot/pages/reportImport/reportImportRepository.ts create mode 100644 src/webparts/stroyBot/pages/reportImport/reportImportRules.test.ts create mode 100644 src/webparts/stroyBot/pages/reportImport/reportImportRules.ts create mode 100644 src/webparts/stroyBot/pages/reportImport/reportImportSave.test.ts create mode 100644 src/webparts/stroyBot/pages/reportImport/reportImportSave.ts create mode 100644 src/webparts/stroyBot/pages/reportImport/reportImportTypes.ts create mode 100644 src/webparts/stroyBot/pages/reportImport/texts.ts create mode 100644 src/webparts/stroyBot/pages/reportImport/useReportFileCheck.ts create mode 100644 src/webparts/stroyBot/pages/reportImport/useReportImport.ts diff --git a/docs/TEST-PLAN.md b/docs/TEST-PLAN.md index ce07fa0..812ee58 100644 --- a/docs/TEST-PLAN.md +++ b/docs/TEST-PLAN.md @@ -177,6 +177,21 @@ - [ ] **T-REP-22** Enter в любом поле не перезагружает страницу. - [ ] ✍️ **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` > Как: F12 → кнопка «телефон/планшет» (Ctrl+Shift+M) → сверху выбрать ширину → F5. Только просмотр, ничего не сохранять. diff --git a/src/webparts/stroyBot/app/PageOutlet.tsx b/src/webparts/stroyBot/app/PageOutlet.tsx index 4173263..78ace2b 100644 --- a/src/webparts/stroyBot/app/PageOutlet.tsx +++ b/src/webparts/stroyBot/app/PageOutlet.tsx @@ -9,6 +9,7 @@ import { ImportWorksPage } from '../pages/import/ImportWorksPage'; import { ResourcesPage } from '../pages/resources/ResourcesPage'; import { BinderPage } from '../pages/binder/BinderPage'; import { WeatherPage } from '../pages/weather/WeatherPage'; +import { ReportImportPage } from '../pages/reportImport/ReportImportPage'; /** Показывает страницу текущего раздела (раздел берётся из ?page=). */ export const PageOutlet: React.FC = () => { @@ -31,6 +32,8 @@ export const PageOutlet: React.FC = () => { return ; case 'weather': return ; + case 'reportImport': + return ; case 'hub': default: return ; diff --git a/src/webparts/stroyBot/app/navigation.test.ts b/src/webparts/stroyBot/app/navigation.test.ts index 41175dc..43d9d02 100644 --- a/src/webparts/stroyBot/app/navigation.test.ts +++ b/src/webparts/stroyBot/app/navigation.test.ts @@ -11,6 +11,7 @@ describe('buildSidebarItems', () => { 'objects', 'reports', 'report', + 'reportImport', 'import', 'resources', 'binder' diff --git a/src/webparts/stroyBot/app/navigation.ts b/src/webparts/stroyBot/app/navigation.ts index 9411c82..1da458f 100644 --- a/src/webparts/stroyBot/app/navigation.ts +++ b/src/webparts/stroyBot/app/navigation.ts @@ -15,6 +15,7 @@ export const NAV_ITEMS: INavItem[] = [ { page: 'objects', title: 'Объекты', icon: 'folder', needsProject: true }, { page: 'reports', title: 'Отчёты по проекту', icon: 'clipboard', 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: 'resources', title: 'Ресурсы проекта', icon: 'people', needsProject: true }, { page: 'binder', title: 'Привязка к справочнику', icon: 'swap', needsProject: true } diff --git a/src/webparts/stroyBot/app/project/projectRules.test.ts b/src/webparts/stroyBot/app/project/projectRules.test.ts index 628870b..8f20028 100644 --- a/src/webparts/stroyBot/app/project/projectRules.test.ts +++ b/src/webparts/stroyBot/app/project/projectRules.test.ts @@ -119,4 +119,10 @@ describe('isSectionAvailable', () => { expect(isSectionAvailable('report', 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); + }); }); diff --git a/src/webparts/stroyBot/app/project/projectRules.ts b/src/webparts/stroyBot/app/project/projectRules.ts index d78455a..5530038 100644 --- a/src/webparts/stroyBot/app/project/projectRules.ts +++ b/src/webparts/stroyBot/app/project/projectRules.ts @@ -73,6 +73,7 @@ export function getStatusKind(status: string | undefined): ProjectStatusKind { * - архивный проект — закрыто всё (кроме архива, он не раздел приложения); * - завершён — нельзя «Отправить отчёт»; * - нет права edit0 — нельзя «Отправить отчёт». + * Импорт отчёта из Excel создаёт отчёты — те же правила, что у «Отправить отчёт». * Привязка к справочнику — только с правом редактирования (решение при объединении). */ 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); 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; return true; } diff --git a/src/webparts/stroyBot/app/routing/routes.ts b/src/webparts/stroyBot/app/routing/routes.ts index f3e3e3c..b1caa78 100644 --- a/src/webparts/stroyBot/app/routing/routes.ts +++ b/src/webparts/stroyBot/app/routing/routes.ts @@ -11,7 +11,8 @@ export const PAGE_IDS = [ 'import', 'resources', 'binder', - 'weather' + 'weather', + 'reportImport' ] as const; export type PageId = (typeof PAGE_IDS)[number]; diff --git a/src/webparts/stroyBot/pages/report/dataverseReportRepository.test.ts b/src/webparts/stroyBot/pages/report/dataverseReportRepository.test.ts index f14e4f3..949e0b9 100644 --- a/src/webparts/stroyBot/pages/report/dataverseReportRepository.test.ts +++ b/src/webparts/stroyBot/pages/report/dataverseReportRepository.test.ts @@ -5,6 +5,7 @@ import { orderRowsForDeletion } from './dataverseReportRepository'; import { createFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient'; +import { IHttpRequest } from '../../services/http/IHttpClient'; import { IReportPayload } from './reportTypes'; 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)', () => { it('отчёт, счётчик, строки, факт', () => { diff --git a/src/webparts/stroyBot/pages/reportImport/ReportFileField.tsx b/src/webparts/stroyBot/pages/reportImport/ReportFileField.tsx new file mode 100644 index 0000000..4ad6655 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/ReportFileField.tsx @@ -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 = ({ selectedFileName, disabled, onSelect }) => { + const inputRef = React.useRef(null); + + const onChange = (event: React.ChangeEvent): void => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (file) onSelect(file); + }; + + return ( +
+ + + {selectedFileName && ( + {REPORT_IMPORT_TEXTS.selectedFile(selectedFileName)} + )} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportActions.tsx b/src/webparts/stroyBot/pages/reportImport/ReportImportActions.tsx new file mode 100644 index 0000000..c980f93 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/ReportImportActions.tsx @@ -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; +} + +/** Кнопка «Импортировать» с подтверждением; во время записи — прогресс. После записи кнопки нет. */ +export const ReportImportActions: React.FC = ({ reports, projectTitle, status, onImport }) => { + if (status.kind === 'saving') { + return ; + } + if (status.kind === 'finished') return null; + + const startImport = async (): Promise => { + 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 ( +
+ +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportOutcome.tsx b/src/webparts/stroyBot/pages/reportImport/ReportImportOutcome.tsx new file mode 100644 index 0000000..10b720a --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/ReportImportOutcome.tsx @@ -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 = ({ result, projectId }) => { + const reportsLink = ( + + {TEXTS.openReports} + + ); + + if (!result.errorMessage) { + return ( + + {TEXTS.success(savedList(result.saved))} {reportsLink} + + ); + } + + const failedRows = (result.failedRows || []).join(', '); + return ( + +

{TEXTS.failed(failedRows, result.errorMessage)}

+ {result.saved.length > 0 &&

{TEXTS.partlySaved(savedList(result.saved))}

} +

{TEXTS.mayBePartial}

+ {reportsLink} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportPage.module.scss b/src/webparts/stroyBot/pages/reportImport/ReportImportPage.module.scss new file mode 100644 index 0000000..4c65db6 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/ReportImportPage.module.scss @@ -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; +} diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportPage.tsx b/src/webparts/stroyBot/pages/reportImport/ReportImportPage.tsx new file mode 100644 index 0000000..f22e51c --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/ReportImportPage.tsx @@ -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(undefined); + const [fileError, setFileError] = React.useState(''); + 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 {REPORT_IMPORT_TEXTS.noProject}; + if (!isSectionAvailable('reportImport', currentProject)) { + return {REPORT_IMPORT_TEXTS.noAccess}; + } + return ( + <> +

{REPORT_IMPORT_TEXTS.intro}

+ + {fileError && {fileError}} + {isChecking && } + {status.kind === 'error' && {status.message}} + {status.kind === 'done' && ( + + ) + } + /> + )} + + ); + }; + + return ( +
+ + {renderContent()} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportResult.tsx b/src/webparts/stroyBot/pages/reportImport/ReportImportResult.tsx new file mode 100644 index 0000000..ed9ea23 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/ReportImportResult.tsx @@ -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 = ({ check, actions }) => { + const notes = check.notes || []; + return ( + <> + + {actions} + + {notes.length > 0 && ( +
+

{REPORT_IMPORT_TEXTS.notesTitle}

+
    + {notes.map((note, index) => ( +
  • {note}
  • + ))} +
+
+ )} + + ); +}; diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportRow.tsx b/src/webparts/stroyBot/pages/reportImport/ReportImportRow.tsx new file mode 100644 index 0000000..2ff7970 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/ReportImportRow.tsx @@ -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 = ({ row, columns }) => { + const issues = issuesByColumn(row); + return ( + + {row.row} + {columns.map((column) => { + const messages = issues.get(column); + const value = row.values?.[column]; + return ( + + {value || {REPORT_IMPORT_TEXTS.empty}} + + ); + })} + + {(row.issues || []).map((issue, index) => ( +
+ {issue.column}: {issue.message} +
+ ))} + + + ); +}; diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportStep.tsx b/src/webparts/stroyBot/pages/reportImport/ReportImportStep.tsx new file mode 100644 index 0000000..b1118e1 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/ReportImportStep.tsx @@ -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; +} + +/** Импорт проверенного файла без ошибок: отчёты собираются заранее, чтобы ошибку сборки показать до записи. */ +export const ReportImportStep: React.FC = ({ check, projectId, projectTitle, status, onImport }) => { + const build = React.useMemo(() => tryBuildImportedReports(check, projectId), [check, projectId]); + + if (build.kind === 'error') return {build.message}; + return ( + <> + + {status.kind === 'finished' && } + + ); +}; diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportSummary.tsx b/src/webparts/stroyBot/pages/reportImport/ReportImportSummary.tsx new file mode 100644 index 0000000..c9fec60 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/ReportImportSummary.tsx @@ -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 = ({ check }) => { + const isEmpty = check.total_rows === 0; + return ( +
+
+ {TEXTS.file(check.file_name)} + {TEXTS.total(check.total_rows)} + {TEXTS.valid(check.valid_rows)} + {check.invalid_rows > 0 && {TEXTS.invalid(check.invalid_rows)}} +
+ {isEmpty && {REPORT_IMPORT_TEXTS.emptyFile}} + {!isEmpty && canImportFile(check) && {REPORT_IMPORT_TEXTS.readyToImport}} + {check.invalid_rows > 0 && {REPORT_IMPORT_TEXTS.fixInExcel}} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/reportImport/ReportImportTable.tsx b/src/webparts/stroyBot/pages/reportImport/ReportImportTable.tsx new file mode 100644 index 0000000..68edd9f --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/ReportImportTable.tsx @@ -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 = ({ check }) => { + const [onlyInvalid, setOnlyInvalid] = React.useState(false); + const columns = columnsOf(check); + const rows = visibleRows(check, onlyInvalid); + + if (check.rows.length === 0) return null; + + return ( +
+ {check.invalid_rows > 0 && ( + + )} +
+ + + + + {columns.map((column) => ( + + ))} + + + + + {rows.map((row) => ( + + ))} + +
{COLUMNS.row}{column}{COLUMNS.issues}
+
+
+ ); +}; diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.test.ts b/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.test.ts new file mode 100644 index 0000000..e0b6720 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.test.ts @@ -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, + ids: Record +): 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(); + }); +}); diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.ts b/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.ts new file mode 100644 index 0000000..14cabec --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/reportImportBuilder.ts @@ -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; + 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(); + 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 })) + ] + })); +} diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportColumns.ts b/src/webparts/stroyBot/pages/reportImport/reportImportColumns.ts new file mode 100644 index 0000000..5b1cbe6 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/reportImportColumns.ts @@ -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; diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportRepository.test.ts b/src/webparts/stroyBot/pages/reportImport/reportImportRepository.test.ts new file mode 100644 index 0000000..45356c7 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/reportImportRepository.test.ts @@ -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('неверный формат'); + }); +}); diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportRepository.ts b/src/webparts/stroyBot/pages/reportImport/reportImportRepository.ts new file mode 100644 index 0000000..035e399 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/reportImportRepository.ts @@ -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 { + 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; +} diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportRules.test.ts b/src/webparts/stroyBot/pages/reportImport/reportImportRules.test.ts new file mode 100644 index 0000000..d3b0f73 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/reportImportRules.test.ts @@ -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); + }); +}); diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportRules.ts b/src/webparts/stroyBot/pages/reportImport/reportImportRules.ts new file mode 100644 index 0000000..7726d4e --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/reportImportRules.ts @@ -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 { + const result = new Map(); + (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); +} diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportSave.test.ts b/src/webparts/stroyBot/pages/reportImport/reportImportSave.test.ts new file mode 100644 index 0000000..70291e2 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/reportImportSave.test.ts @@ -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 => nextNumber++, + saveReportWithData: async (report: IReportPayload, nodes: IReportDataNode[]): Promise => { + 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); + }); +}); diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportSave.ts b/src/webparts/stroyBot/pages/reportImport/reportImportSave.ts new file mode 100644 index 0000000..fdba7a0 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/reportImportSave.ts @@ -0,0 +1,46 @@ +import { IDataverseReportRepository } from '../report/dataverseReportRepository'; +import { IImportedReport } from './reportImportBuilder'; + +/** Нужная импорту часть репозитория формы отчёта — тот же код записи, что у «Отправить отчёт». */ +export type ImportReportWriter = Pick; + +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 { + 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 }; +} diff --git a/src/webparts/stroyBot/pages/reportImport/reportImportTypes.ts b/src/webparts/stroyBot/pages/reportImport/reportImportTypes.ts new file mode 100644 index 0000000..6781943 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/reportImportTypes.ts @@ -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; + /** Во что сервер сопоставил значения (id объектов, работы, компании…). Нам нужно только для отладки. */ + ids: Record; + 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[]; +} diff --git a/src/webparts/stroyBot/pages/reportImport/texts.ts b/src/webparts/stroyBot/pages/reportImport/texts.ts new file mode 100644 index 0000000..d317e29 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/texts.ts @@ -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; diff --git a/src/webparts/stroyBot/pages/reportImport/useReportFileCheck.ts b/src/webparts/stroyBot/pages/reportImport/useReportFileCheck.ts new file mode 100644 index 0000000..179df77 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/useReportFileCheck.ts @@ -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; + reset: () => void; +} { + const services = useAppServices(); + const [status, setStatus] = React.useState({ kind: 'idle' }); + const isMountedRef = React.useRef(true); + + React.useEffect( + () => () => { + isMountedRef.current = false; + }, + [] + ); + + const checkFile = async (file: File): Promise => { + 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 }; +} diff --git a/src/webparts/stroyBot/pages/reportImport/useReportImport.ts b/src/webparts/stroyBot/pages/reportImport/useReportImport.ts new file mode 100644 index 0000000..97e5698 --- /dev/null +++ b/src/webparts/stroyBot/pages/reportImport/useReportImport.ts @@ -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; + reset: () => void; +} { + const services = useAppServices(); + const [status, setStatus] = React.useState({ kind: 'idle' }); + const isMountedRef = React.useRef(true); + + React.useEffect( + () => () => { + isMountedRef.current = false; + }, + [] + ); + + const finish = (result: IImportSaveResult): void => { + if (isMountedRef.current) setStatus({ kind: 'finished', result }); + }; + + const importReports = async (reports: IImportedReport[]): Promise => { + 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 }; +} diff --git a/src/webparts/stroyBot/services/http/IHttpClient.ts b/src/webparts/stroyBot/services/http/IHttpClient.ts index 25137db..dd5af63 100644 --- a/src/webparts/stroyBot/services/http/IHttpClient.ts +++ b/src/webparts/stroyBot/services/http/IHttpClient.ts @@ -8,7 +8,8 @@ export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; export interface IHttpRequest { method?: HttpMethod; headers?: Record; - body?: string | ArrayBuffer; + /** FormData — для загрузки файлов (multipart): Content-Type с boundary браузер ставит сам. */ + body?: string | ArrayBuffer | FormData; } export interface IHttpResponse {