diff --git a/docs/TEST-PLAN.md b/docs/TEST-PLAN.md index 812ee58..18ceac5 100644 --- a/docs/TEST-PLAN.md +++ b/docs/TEST-PLAN.md @@ -192,6 +192,20 @@ - [ ] ✍️ **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. +## 9b. Импорт работ из Excel — `T-WIM` (новая фича) + +> `POST /Import/StroyBot/Works?projectId=` **только проверяет** файл (как и импорт отчётов). Объекты должны уже быть в проекте — иначе ошибка «… не найден внутри …». В `ids` — id самого глубокого объекта и единицы; id работы сервер не возвращает. +> Пишет фронт теми же `createWork` / `updateWork`, что окно работы. Работа с тем же названием на объекте (без учёта регистра и пробелов, на любом уровне иерархии) — **обновляются только объём и единица**; иначе — создаётся работа верхнего уровня. Две одноимённые на объекте или одна работа дважды в файле — ошибка, ничего не пишется. ⚠️ Только проект **51**. +> Тестовый файл: `Downloads\ТЕСТ импорт работ - проект 51.xlsx` (Тестовый объект / Бетонирование: «Арматура» 120 м — есть, «Тестовая работа импорта» 50 м3 — новая). + +- [ ] **T-WIM-01** Меню: «Импорт работ из Excel» после «Загрузить работы из сметы»; есть у роли **Е**, у **Р** (без edit0) — нет; в архивном проекте — нет; в завершённом — есть. +- [ ] **T-WIM-02** Файл с несуществующим объектом → «С ошибками: 1», «Объект 2: «…» не найден внутри «…»», кнопки «Импортировать» нет. +- [ ] **T-WIM-03** Тестовый файл → «Сверяем с работами объектов…» → «Будет создано работ: 1, обновлён … у существующих: 1», кнопка «Импортировать (работ: 2)». +- [ ] ✍️ **T-WIM-04** «Импортировать» → окно «… будет создано работ: 1, обновлено: 1» → «Сохраняем работы…» → «Импорт завершён: создано работ — 1, обновлено — 1» и ссылка «Открыть «Объекты»». (Проверено 2026-10-22.) +- [ ] **T-WIM-05** «Объекты» → «Бетонирование» → «Работы»: «Арматура» — 120 м, родитель «Бетон B25» и даты не изменились; «Тестовая работа импорта» — 50 м3, верхний уровень. +- [ ] ✍️ **T-WIM-06** Тот же файл ещё раз → «создано: 0, обновлено: 2» — работы не задваиваются. (Проверено 2026-10-22.) +- [ ] **T-WIM-07** Одна работа объекта дважды в файле → «Строки … и …: работа «…» на одном объекте указана дважды», кнопки нет. + ## 10. Адаптивность — `T-RSP` > Как: F12 → кнопка «телефон/планшет» (Ctrl+Shift+M) → сверху выбрать ширину → F5. Только просмотр, ничего не сохранять. diff --git a/src/webparts/stroyBot/pages/worksImport/WorksImportOutcome.tsx b/src/webparts/stroyBot/pages/worksImport/WorksImportOutcome.tsx new file mode 100644 index 0000000..625e5c8 --- /dev/null +++ b/src/webparts/stroyBot/pages/worksImport/WorksImportOutcome.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { Alert } from '@tnsu/ui-kit'; +import { RouteLink } from '../../app/routing/RouteLink'; +import { IWorksSaveResult } from './worksImportSave'; +import { WORKS_IMPORT_TEXTS } from './texts'; +import styles from './WorksImportPage.module.scss'; + +const TEXTS = WORKS_IMPORT_TEXTS.import; + +interface Props { + result: IWorksSaveResult; + projectId: string; +} + +/** Итог записи работ: сколько создано и обновлено; при ошибке — на какой строке остановились. */ +export const WorksImportOutcome: React.FC = ({ result, projectId }) => { + const objectsLink = ( + + {TEXTS.openObjects} + + ); + + if (!result.errorMessage) { + return ( + + {TEXTS.success(result.createdCount, result.updatedCount)} {objectsLink} + + ); + } + + return ( + +

{TEXTS.failed(result.failedRow ?? 0, result.errorMessage)}

+

{TEXTS.partlySaved(result.createdCount, result.updatedCount)}

+ {objectsLink} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/worksImport/WorksImportPage.module.scss b/src/webparts/stroyBot/pages/worksImport/WorksImportPage.module.scss index a38fda4..8a9a69f 100644 --- a/src/webparts/stroyBot/pages/worksImport/WorksImportPage.module.scss +++ b/src/webparts/stroyBot/pages/worksImport/WorksImportPage.module.scss @@ -12,3 +12,24 @@ font-size: $fs-sm; color: $gray-700; } + +.importBlock { + display: flex; + flex-direction: column; + gap: $sp-3; +} + +.plan { + margin: 0; + font-size: $fs-sm; + color: $gray-900; +} + +.outcomeLine { + margin: 0 0 $sp-2; +} + +.link { + color: inherit; + font-weight: 600; +} diff --git a/src/webparts/stroyBot/pages/worksImport/WorksImportPage.tsx b/src/webparts/stroyBot/pages/worksImport/WorksImportPage.tsx index 4358047..30c15f5 100644 --- a/src/webparts/stroyBot/pages/worksImport/WorksImportPage.tsx +++ b/src/webparts/stroyBot/pages/worksImport/WorksImportPage.tsx @@ -5,16 +5,20 @@ import { isSectionAvailable } from '../../app/project/projectRules'; import { TreePageHeader } from '../_tree/TreePageHeader'; import { ExcelImportSection } from '../_excelImport/ExcelImportSection'; import { worksImportUrl } from './worksImportRepository'; +import { useWorksImport } from './useWorksImport'; +import { WorksImportStep } from './WorksImportStep'; import { WORKS_IMPORT_TEXTS } from './texts'; import styles from './WorksImportPage.module.scss'; /** * Импорт работ (плановых объёмов) из Excel: `?page=worksImport&projectId=`. - * Пока только проверка файла на сервере; запись работ в Dataverse — следующим шагом, - * когда по ответу сервера будут решены правила (объекты, дубли, единицы). + * Файл проверяется на сервере сразу после выбора (сервер ничего не сохраняет, объекты должны существовать). + * Работа с тем же названием на объекте — обновляется (объём и единица), новая — создаётся + * теми же запросами, что окно работы в разделе «Работы». */ export const WorksImportPage: React.FC = () => { const { currentProject } = useProject(); + const importer = useWorksImport(); const renderContent = (): React.ReactNode => { if (!currentProject) return {WORKS_IMPORT_TEXTS.noProject}; @@ -27,9 +31,18 @@ export const WorksImportPage: React.FC = () => { undefined} - renderImport={() => undefined} + isLocked={importer.status.kind === 'saving'} + onFileChange={importer.reset} + renderImport={(check) => ( + + )} /> ); diff --git a/src/webparts/stroyBot/pages/worksImport/WorksImportStep.tsx b/src/webparts/stroyBot/pages/worksImport/WorksImportStep.tsx new file mode 100644 index 0000000..d86a24d --- /dev/null +++ b/src/webparts/stroyBot/pages/worksImport/WorksImportStep.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { Alert, Button, confirmModal, Spinner } from '@tnsu/ui-kit'; +import { IExcelImportCheck } from '../_excelImport/excelImportTypes'; +import { IWorksImportPlan } from './worksImportBuilder'; +import { WorksImportStatus } from './useWorksImport'; +import { WorksImportOutcome } from './WorksImportOutcome'; +import { WORKS_IMPORT_TEXTS } from './texts'; +import styles from './WorksImportPage.module.scss'; + +const TEXTS = WORKS_IMPORT_TEXTS.import; + +interface Props { + check: IExcelImportCheck; + projectId: string; + projectTitle: string; + status: WorksImportStatus; + onPrepare: (check: IExcelImportCheck) => Promise; + onImport: (plan: IWorksImportPlan) => Promise; +} + +/** + * Импорт проверенного файла без ошибок. План (что создать, что обновить) готовится сразу — + * пользователь видит его до нажатия «Импортировать». + */ +export const WorksImportStep: React.FC = ({ check, projectId, projectTitle, status, onPrepare, onImport }) => { + // Сайд-эффект: загрузка существующих работ объектов для нового файла. + React.useEffect(() => { + onPrepare(check).catch(() => undefined); + }, [check, onPrepare]); + + if (status.kind === 'prepareError') return {status.message}; + if (status.kind === 'saving') return ; + if (status.kind === 'finished') return ; + if (status.kind !== 'ready') return ; + + const { plan } = status; + const startImport = async (): Promise => { + const isConfirmed = await confirmModal({ + title: TEXTS.confirmTitle, + message: TEXTS.confirmMessage(plan.toCreate.length, plan.toUpdate.length, projectTitle), + confirmText: TEXTS.confirm, + cancelText: TEXTS.cancel + }); + if (isConfirmed) await onImport(plan); + }; + + return ( +
+

{TEXTS.plan(plan.toCreate.length, plan.toUpdate.length)}

+
+ +
+
+ ); +}; diff --git a/src/webparts/stroyBot/pages/worksImport/texts.ts b/src/webparts/stroyBot/pages/worksImport/texts.ts index 488015e..8cb3cff 100644 --- a/src/webparts/stroyBot/pages/worksImport/texts.ts +++ b/src/webparts/stroyBot/pages/worksImport/texts.ts @@ -6,7 +6,26 @@ export const WORKS_IMPORT_TEXTS = { noAccess: 'Импорт работ недоступен: нужно право редактирования, проект не должен быть архивным.', intro: 'Загрузите форму плановых объёмов (.xlsx): объекты, вид работ, единица и плановый объём — файл сразу проверится. ' + - 'Ошибки подсветятся в таблице.', + 'Объекты должны уже быть в проекте. Если такая работа на объекте уже есть, у неё обновятся объём и единица.', + import: { + preparing: 'Сверяем с работами объектов…', + plan: (createCount: number, updateCount: number): string => + `Будет создано работ: ${createCount}, обновлён плановый объём и единица у существующих: ${updateCount}.`, + button: (total: number): string => `Импортировать (работ: ${total})`, + saving: (savedCount: number, total: number): string => `Сохраняем работы… ${savedCount} из ${total}`, + confirmTitle: 'Импорт работ', + confirmMessage: (createCount: number, updateCount: number, projectTitle: string): string => + `В проекте «${projectTitle}» будет создано работ: ${createCount}, обновлено: ${updateCount}. Продолжить?`, + confirm: 'Импортировать', + cancel: 'Отмена', + success: (createCount: number, updateCount: number): string => + `Импорт завершён: создано работ — ${createCount}, обновлено — ${updateCount}.`, + failed: (row: number, details: string): string => `Не удалось записать работу из строки ${row}: ${details}`, + partlySaved: (createCount: number, updateCount: number): string => + `До ошибки записано: создано — ${createCount}, обновлено — ${updateCount}. ` + + 'Файл можно загрузить снова: уже записанные работы обновятся, а не задвоятся.', + openObjects: 'Открыть «Объекты»' + }, build: { missingFields: (row: number): string => `Строка ${row}: не определены объект, вид работ, единица или плановый объём.`, duplicateInFile: (firstRow: number, row: number, title: string): string => diff --git a/src/webparts/stroyBot/pages/worksImport/useWorksImport.ts b/src/webparts/stroyBot/pages/worksImport/useWorksImport.ts new file mode 100644 index 0000000..39a1722 --- /dev/null +++ b/src/webparts/stroyBot/pages/worksImport/useWorksImport.ts @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { useAppServices } from '../../app/AppServicesContext'; +import { createWorksRepository, IWorksRepository } from '../works/worksRepository'; +import { IWork } from '../works/worksTypes'; +import { IExcelImportCheck } from '../_excelImport/excelImportTypes'; +import { buildImportedWorks, IWorksImportPlan, objectIdsOf, planWorksImport } from './worksImportBuilder'; +import { IWorksSaveResult, saveImportedWorks } from './worksImportSave'; + +export type WorksImportStatus = + | { kind: 'idle' } + | { kind: 'preparing' } + | { kind: 'prepareError'; message: string } + | { kind: 'ready'; plan: IWorksImportPlan } + | { kind: 'saving'; savedCount: number; total: number } + | { kind: 'finished'; result: IWorksSaveResult }; + +const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +/** План: работы файла сравниваются с уже существующими работами их объектов. */ +async function preparePlan(repository: IWorksRepository, check: IExcelImportCheck): Promise { + const works = buildImportedWorks(check); + const existingByObject = new Map(); + for (const objectId of objectIdsOf(works)) { + existingByObject.set(objectId, await repository.loadWorks(objectId)); + } + return planWorksImport(works, existingByObject); +} + +/** Импорт проверенного файла работ: подготовка плана (создать / обновить) и запись. */ +export function useWorksImport(): { + status: WorksImportStatus; + prepare: (check: IExcelImportCheck) => Promise; + importWorks: (plan: IWorksImportPlan) => Promise; + reset: () => void; +} { + const services = useAppServices(); + const [status, setStatus] = React.useState({ kind: 'idle' }); + const isMountedRef = React.useRef(true); + const repository = React.useMemo( + () => + createWorksRepository({ + sharePoint: services.sharePoint, + dataverse: services.dataverse, + siteUrl: services.settings.siteUrl, + dataverseUrl: services.settings.dataverseUrl + }), + [services] + ); + + React.useEffect( + () => () => { + isMountedRef.current = false; + }, + [] + ); + + const update = React.useCallback((next: WorksImportStatus): void => { + if (isMountedRef.current) setStatus(next); + }, []); + + const prepare = React.useCallback( + async (check: IExcelImportCheck): Promise => { + update({ kind: 'preparing' }); + try { + update({ kind: 'ready', plan: await preparePlan(repository, check) }); + } catch (error) { + console.error('Ошибка подготовки импорта работ:', error); + update({ kind: 'prepareError', message: errorText(error) }); + } + }, + [repository, update] + ); + + const importWorks = async (plan: IWorksImportPlan): Promise => { + const total = plan.toCreate.length + plan.toUpdate.length; + update({ kind: 'saving', savedCount: 0, total }); + const result = await saveImportedWorks(repository, plan, (savedCount) => update({ kind: 'saving', savedCount, total })); + update({ kind: 'finished', result }); + }; + + const reset = React.useCallback((): void => setStatus({ kind: 'idle' }), []); + + return { status, prepare, importWorks, reset }; +} diff --git a/src/webparts/stroyBot/pages/worksImport/worksImportSave.test.ts b/src/webparts/stroyBot/pages/worksImport/worksImportSave.test.ts new file mode 100644 index 0000000..70c99f4 --- /dev/null +++ b/src/webparts/stroyBot/pages/worksImport/worksImportSave.test.ts @@ -0,0 +1,64 @@ +import { ImportWorksWriter, saveImportedWorks } from './worksImportSave'; +import { IImportedWork, IWorksImportPlan } from './worksImportBuilder'; + +interface IWrite { + method: 'create' | 'update'; + id?: string; + body: Record; +} + +/** Writer, который запоминает запросы. failOnCall — номер вызова (с 1), на котором бросить ошибку. */ +function makeWriter(failOnCall?: number): ImportWorksWriter & { writes: IWrite[] } { + const writes: IWrite[] = []; + const record = async (write: IWrite): Promise => { + if (writes.length + 1 === failOnCall) throw new Error('Dataverse недоступен'); + writes.push(write); + }; + return { + writes, + createWork: (body) => record({ method: 'create', body }), + updateWork: (id, body) => record({ method: 'update', id, body }) + }; +} + +const work = (rowNumber: number, title: string): IImportedWork => ({ + rowNumber, + objectId: '1517', + title, + unit: 'м', + volume: 100 +}); + +// Как в тестовом файле: строка 2 — «Арматура» уже есть, строка 3 — новая работа. +const plan: IWorksImportPlan = { + toCreate: [work(3, 'Тестовая работа импорта')], + toUpdate: [{ ...work(2, 'Арматура'), workId: 'guid-armature' }] +}; + +describe('запись импортированных работ', () => { + it('в порядке строк файла: обновление — только объём и единица, создание — с объектом', async () => { + const writer = makeWriter(); + const progress: number[] = []; + + const result = await saveImportedWorks(writer, plan, (count) => progress.push(count)); + + expect(result).toEqual({ createdCount: 1, updatedCount: 1 }); + expect(writer.writes).toEqual([ + { method: 'update', id: 'guid-armature', body: { cree9_unit: 'м', cree9_volume: 100 } }, + { + method: 'create', + body: { cree9_title: 'Тестовая работа импорта', cree9_unit: 'м', cree9_volume: 100, cree9_objectid: '1517' } + } + ]); + expect(progress).toEqual([1, 2]); + }); + + it('ошибка останавливает запись, итог — что успели и на какой строке упали', async () => { + const writer = makeWriter(2); + + const result = await saveImportedWorks(writer, plan, () => undefined); + + expect(result).toEqual({ createdCount: 0, updatedCount: 1, errorMessage: 'Dataverse недоступен', failedRow: 3 }); + expect(writer.writes).toHaveLength(1); + }); +}); diff --git a/src/webparts/stroyBot/pages/worksImport/worksImportSave.ts b/src/webparts/stroyBot/pages/worksImport/worksImportSave.ts new file mode 100644 index 0000000..be6be76 --- /dev/null +++ b/src/webparts/stroyBot/pages/worksImport/worksImportSave.ts @@ -0,0 +1,68 @@ +import { IWorksRepository, WORK_COLUMNS } from '../works/worksRepository'; +import { IImportedWork, IWorksImportPlan, IWorkUpdate } from './worksImportBuilder'; + +/** Нужная импорту часть репозитория раздела «Работы» — те же запросы, что у окна работы. */ +export type ImportWorksWriter = Pick; + +export interface IWorksSaveResult { + createdCount: number; + updatedCount: number; + /** Есть — запись остановилась на строке failedRow. */ + errorMessage?: string; + failedRow?: number; +} + +/** Новая работа — верхнего уровня объекта; даты не задаём (в файле их нет). */ +export function createWorkBody(work: IImportedWork): Record { + return { + [WORK_COLUMNS.title]: work.title, + [WORK_COLUMNS.unit]: work.unit, + [WORK_COLUMNS.volume]: work.volume, + [WORK_COLUMNS.objectId]: work.objectId + }; +} + +/** У существующей работы меняем только план: название, родителя и даты не трогаем. */ +export function updateWorkBody(work: IWorkUpdate): Record { + return { [WORK_COLUMNS.unit]: work.unit, [WORK_COLUMNS.volume]: work.volume }; +} + +type PlannedWrite = { kind: 'create'; work: IImportedWork } | { kind: 'update'; work: IWorkUpdate }; + +/** Порядок записи — как строки в файле, чтобы прогресс и ошибка были понятны пользователю. */ +function writesInFileOrder(plan: IWorksImportPlan): PlannedWrite[] { + const writes: PlannedWrite[] = [ + ...plan.toCreate.map((work): PlannedWrite => ({ kind: 'create', work })), + ...plan.toUpdate.map((work): PlannedWrite => ({ kind: 'update', work })) + ]; + return writes.sort((a, b) => a.work.rowNumber - b.work.rowNumber); +} + +/** + * Записывает работы по одной. Ошибка останавливает запись; уже записанное остаётся. + * Повторная загрузка того же файла безопасна: созданные работы найдутся по названию и обновятся. + */ +export async function saveImportedWorks( + writer: ImportWorksWriter, + plan: IWorksImportPlan, + onProgress: (savedCount: number) => void +): Promise { + const result: IWorksSaveResult = { createdCount: 0, updatedCount: 0 }; + for (const write of writesInFileOrder(plan)) { + try { + if (write.kind === 'create') { + await writer.createWork(createWorkBody(write.work)); + result.createdCount++; + } else { + await writer.updateWork(write.work.workId, updateWorkBody(write.work)); + result.updatedCount++; + } + onProgress(result.createdCount + result.updatedCount); + } catch (error) { + console.error('Ошибка записи импортированной работы:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + return { ...result, errorMessage, failedRow: write.work.rowNumber }; + } + } + return result; +}