From 18c295982011941f70687eb5d73aff513e2dae75 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:35:21 +0500 Subject: [PATCH] feat: add works import from Excel (check and plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Импорт работ из Excel" section checks the planned volumes file against /Import/StroyBot/Works as soon as it is picked. The builder turns checked rows into works and decides per work whether to create it or update the volume and unit of an existing work with the same title on the object. Saving comes next. Co-Authored-By: Claude Opus 5.5 (1M context) --- 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 | 7 ++ .../stroyBot/app/project/projectRules.ts | 3 +- src/webparts/stroyBot/app/routing/routes.ts | 3 +- .../worksImport/WorksImportPage.module.scss | 14 +++ .../pages/worksImport/WorksImportPage.tsx | 44 ++++++++ .../stroyBot/pages/worksImport/texts.ts | 18 +++ .../worksImport/worksImportBuilder.test.ts | 105 ++++++++++++++++++ .../pages/worksImport/worksImportBuilder.ts | 83 ++++++++++++++ .../pages/worksImport/worksImportColumns.ts | 8 ++ .../worksImport/worksImportRepository.test.ts | 7 ++ .../worksImport/worksImportRepository.ts | 3 + 14 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 src/webparts/stroyBot/pages/worksImport/WorksImportPage.module.scss create mode 100644 src/webparts/stroyBot/pages/worksImport/WorksImportPage.tsx create mode 100644 src/webparts/stroyBot/pages/worksImport/texts.ts create mode 100644 src/webparts/stroyBot/pages/worksImport/worksImportBuilder.test.ts create mode 100644 src/webparts/stroyBot/pages/worksImport/worksImportBuilder.ts create mode 100644 src/webparts/stroyBot/pages/worksImport/worksImportColumns.ts create mode 100644 src/webparts/stroyBot/pages/worksImport/worksImportRepository.test.ts create mode 100644 src/webparts/stroyBot/pages/worksImport/worksImportRepository.ts diff --git a/src/webparts/stroyBot/app/PageOutlet.tsx b/src/webparts/stroyBot/app/PageOutlet.tsx index 78ace2b..4bbc10d 100644 --- a/src/webparts/stroyBot/app/PageOutlet.tsx +++ b/src/webparts/stroyBot/app/PageOutlet.tsx @@ -10,6 +10,7 @@ import { ResourcesPage } from '../pages/resources/ResourcesPage'; import { BinderPage } from '../pages/binder/BinderPage'; import { WeatherPage } from '../pages/weather/WeatherPage'; import { ReportImportPage } from '../pages/reportImport/ReportImportPage'; +import { WorksImportPage } from '../pages/worksImport/WorksImportPage'; /** Показывает страницу текущего раздела (раздел берётся из ?page=). */ export const PageOutlet: React.FC = () => { @@ -34,6 +35,8 @@ export const PageOutlet: React.FC = () => { return ; case 'reportImport': return ; + case 'worksImport': + return ; case 'hub': default: return ; diff --git a/src/webparts/stroyBot/app/navigation.test.ts b/src/webparts/stroyBot/app/navigation.test.ts index 43d9d02..26eb35e 100644 --- a/src/webparts/stroyBot/app/navigation.test.ts +++ b/src/webparts/stroyBot/app/navigation.test.ts @@ -13,6 +13,7 @@ describe('buildSidebarItems', () => { 'report', 'reportImport', 'import', + 'worksImport', 'resources', 'binder' ]); diff --git a/src/webparts/stroyBot/app/navigation.ts b/src/webparts/stroyBot/app/navigation.ts index 1da458f..44ff328 100644 --- a/src/webparts/stroyBot/app/navigation.ts +++ b/src/webparts/stroyBot/app/navigation.ts @@ -17,6 +17,7 @@ export const NAV_ITEMS: INavItem[] = [ { page: 'report', title: 'Отправить отчёт', icon: 'send', needsProject: true }, { page: 'reportImport', title: 'Импорт отчёта из Excel', icon: 'upload', needsProject: true }, { page: 'import', title: 'Загрузить работы из сметы', icon: 'download', needsProject: true }, + { page: 'worksImport', title: 'Импорт работ из Excel', icon: 'document', 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 8f20028..8a980cf 100644 --- a/src/webparts/stroyBot/app/project/projectRules.test.ts +++ b/src/webparts/stroyBot/app/project/projectRules.test.ts @@ -125,4 +125,11 @@ describe('isSectionAvailable', () => { expect(isSectionAvailable('reportImport', project({ status: 'текущий', canEdit: false }))).toBe(false); expect(isSectionAvailable('reportImport', project({ status: 'завершён', canEdit: true }))).toBe(false); }); + + it('импорт работ из Excel — только с правом edit0, архив закрыт', () => { + expect(isSectionAvailable('worksImport', project({ status: 'текущий', canEdit: true }))).toBe(true); + expect(isSectionAvailable('worksImport', project({ status: 'завершён', canEdit: true }))).toBe(true); + expect(isSectionAvailable('worksImport', project({ status: 'текущий', canEdit: false }))).toBe(false); + expect(isSectionAvailable('worksImport', 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 5530038..410ae75 100644 --- a/src/webparts/stroyBot/app/project/projectRules.ts +++ b/src/webparts/stroyBot/app/project/projectRules.ts @@ -75,6 +75,7 @@ export function getStatusKind(status: string | undefined): ProjectStatusKind { * - нет права edit0 — нельзя «Отправить отчёт». * Импорт отчёта из Excel создаёт отчёты — те же правила, что у «Отправить отчёт». * Привязка к справочнику — только с правом редактирования (решение при объединении). + * Импорт работ из Excel меняет работы проекта — тоже только с правом редактирования. */ export function isSectionAvailable(page: PageId, project: IAccessibleProject): boolean { if (page === 'hub') return true; @@ -82,7 +83,7 @@ export function isSectionAvailable(page: PageId, project: IAccessibleProject): b const kind = getStatusKind(project.status); if (kind === 'archive') return false; if (page === 'report' || page === 'reportImport') return kind !== 'done' && project.canEdit; - if (page === 'binder') return project.canEdit; + if (page === 'binder' || page === 'worksImport') return project.canEdit; return true; } diff --git a/src/webparts/stroyBot/app/routing/routes.ts b/src/webparts/stroyBot/app/routing/routes.ts index b1caa78..7cb852b 100644 --- a/src/webparts/stroyBot/app/routing/routes.ts +++ b/src/webparts/stroyBot/app/routing/routes.ts @@ -12,7 +12,8 @@ export const PAGE_IDS = [ 'resources', 'binder', 'weather', - 'reportImport' + 'reportImport', + 'worksImport' ] as const; export type PageId = (typeof PAGE_IDS)[number]; diff --git a/src/webparts/stroyBot/pages/worksImport/WorksImportPage.module.scss b/src/webparts/stroyBot/pages/worksImport/WorksImportPage.module.scss new file mode 100644 index 0000000..a38fda4 --- /dev/null +++ b/src/webparts/stroyBot/pages/worksImport/WorksImportPage.module.scss @@ -0,0 +1,14 @@ +@import '../../../../styles/design-tokens'; + +.page { + display: flex; + flex-direction: column; + gap: $sp-4; + width: 100%; +} + +.intro { + margin: 0; + font-size: $fs-sm; + color: $gray-700; +} diff --git a/src/webparts/stroyBot/pages/worksImport/WorksImportPage.tsx b/src/webparts/stroyBot/pages/worksImport/WorksImportPage.tsx new file mode 100644 index 0000000..4358047 --- /dev/null +++ b/src/webparts/stroyBot/pages/worksImport/WorksImportPage.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Alert } from '@tnsu/ui-kit'; +import { useProject } from '../../app/project/ProjectContext'; +import { isSectionAvailable } from '../../app/project/projectRules'; +import { TreePageHeader } from '../_tree/TreePageHeader'; +import { ExcelImportSection } from '../_excelImport/ExcelImportSection'; +import { worksImportUrl } from './worksImportRepository'; +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 renderContent = (): React.ReactNode => { + if (!currentProject) return {WORKS_IMPORT_TEXTS.noProject}; + if (!isSectionAvailable('worksImport', currentProject)) { + return {WORKS_IMPORT_TEXTS.noAccess}; + } + return ( + <> +

{WORKS_IMPORT_TEXTS.intro}

+ undefined} + renderImport={() => undefined} + /> + + ); + }; + + return ( +
+ + {renderContent()} +
+ ); +}; diff --git a/src/webparts/stroyBot/pages/worksImport/texts.ts b/src/webparts/stroyBot/pages/worksImport/texts.ts new file mode 100644 index 0000000..488015e --- /dev/null +++ b/src/webparts/stroyBot/pages/worksImport/texts.ts @@ -0,0 +1,18 @@ +// Тексты раздела «Импорт работ из Excel». + +export const WORKS_IMPORT_TEXTS = { + headerLabel: 'Импорт работ из Excel', + noProject: 'Проект не выбран. Выберите проект на главной.', + noAccess: 'Импорт работ недоступен: нужно право редактирования, проект не должен быть архивным.', + intro: + 'Загрузите форму плановых объёмов (.xlsx): объекты, вид работ, единица и плановый объём — файл сразу проверится. ' + + 'Ошибки подсветятся в таблице.', + build: { + missingFields: (row: number): string => `Строка ${row}: не определены объект, вид работ, единица или плановый объём.`, + duplicateInFile: (firstRow: number, row: number, title: string): string => + `Строки ${firstRow} и ${row}: работа «${title}» на одном объекте указана дважды — оставьте одну строку.`, + ambiguous: (row: number, title: string, count: number): string => + `Строка ${row}: на объекте ${count} работы «${title}» — непонятно, какую обновить. ` + + 'Переименуйте лишние в разделе «Работы».' + } +} as const; diff --git a/src/webparts/stroyBot/pages/worksImport/worksImportBuilder.test.ts b/src/webparts/stroyBot/pages/worksImport/worksImportBuilder.test.ts new file mode 100644 index 0000000..8d1c2ef --- /dev/null +++ b/src/webparts/stroyBot/pages/worksImport/worksImportBuilder.test.ts @@ -0,0 +1,105 @@ +import { buildImportedWorks, normalizeTitle, objectIdsOf, planWorksImport } from './worksImportBuilder'; +import { IExcelImportCheck, IExcelImportRow } from '../_excelImport/excelImportTypes'; +import { IWork } from '../works/worksTypes'; + +/* eslint-disable @rushstack/no-new-null -- API присылает null для пустых ячеек. */ + +/** Строка как в ответе сервера на «ТЕСТ импорт работ - проект 51.xlsx». */ +function makeRow(row: number, title: string, unit: string, volume: string, ids: Record = {}): IExcelImportRow { + return { + row, + valid: true, + values: { + 'Объект 1': 'Тестовый объект', + 'Объект 2': 'Бетонирование', + 'Объект 3': null, + 'Вид работ': title, + 'Ед.изм.': unit, + 'Плановый объем': volume + }, + ids: { 'Объект 2': 1517, 'Ед.изм.': 37, ...ids }, + issues: [] + }; +} + +function makeCheck(rows: IExcelImportRow[]): IExcelImportCheck { + return { + kind: 'works', + file_name: 'test.xlsx', + project_id: 51, + project_name: 'TEST', + total_rows: rows.length, + valid_rows: rows.length, + invalid_rows: 0, + rows, + notes: [] + }; +} + +function makeWork(id: string, title: string, objectId: string = '1517'): IWork { + return { id, title, objectId, unit: 'м', volume: 100 }; +} + +const armature = makeRow(2, 'Арматура', 'м', '100'); +const newWork = makeRow(3, 'Тестовая работа импорта', 'м3', '50', { 'Ед.изм.': 36 }); + +describe('работы из ответа проверки', () => { + it('тестовый файл: объект — самый глубокий id строкой, единица — текстом, объём — числом', () => { + expect(buildImportedWorks(makeCheck([armature, newWork]))).toEqual([ + { rowNumber: 2, objectId: '1517', title: 'Арматура', unit: 'м', volume: 100 }, + { rowNumber: 3, objectId: '1517', title: 'Тестовая работа импорта', unit: 'м3', volume: 50 } + ]); + }); + + it('объём с запятой', () => { + const [work] = buildImportedWorks(makeCheck([makeRow(2, 'Арматура', 'тн', '2,13')])); + expect(work.volume).toEqual(2.13); + }); + + it('одна и та же работа объекта дважды (без учёта регистра и пробелов) — ошибка', () => { + const duplicate = makeRow(5, ' арматура ', 'м', '30'); + expect(() => buildImportedWorks(makeCheck([armature, duplicate]))).toThrow('Строки 2 и 5'); + }); + + it('та же работа на разных объектах — не дубль', () => { + const otherObject = makeRow(3, 'Арматура', 'м', '30', { 'Объект 2': undefined, 'Объект 1': 1600 }); + expect(buildImportedWorks(makeCheck([armature, otherObject]))).toHaveLength(2); + }); + + it('нет объёма — ошибка с номером строки', () => { + expect(() => buildImportedWorks(makeCheck([makeRow(4, 'Опалубка', 'м2', '')]))).toThrow('Строка 4'); + }); + + it('объекты для загрузки работ — без повторов', () => { + const works = buildImportedWorks(makeCheck([armature, newWork])); + expect(objectIdsOf(works)).toEqual(['1517']); + }); +}); + +describe('план импорта: создать или обновить', () => { + const works = buildImportedWorks(makeCheck([armature, newWork])); + + it('«Арматура» уже есть на объекте — обновить её; новой работы нет — создать', () => { + const existing = new Map([['1517', [makeWork('guid-armature', 'Арматура'), makeWork('guid-other', 'Бетон')]]]); + const plan = planWorksImport(works, existing); + + expect(plan.toUpdate).toEqual([{ ...works[0], workId: 'guid-armature' }]); + expect(plan.toCreate).toEqual([works[1]]); + }); + + it('сравнение названий без учёта регистра и лишних пробелов', () => { + const existing = new Map([['1517', [makeWork('guid-armature', ' АРМАТУРА ')]]]); + expect(planWorksImport(works, existing).toUpdate).toHaveLength(1); + expect(normalizeTitle(' Монтаж опалубки ')).toEqual('монтаж опалубки'); + }); + + it('работа с тем же названием на другом объекте — создаём новую', () => { + const existing = new Map([['1600', [makeWork('guid-armature', 'Арматура', '1600')]]]); + expect(planWorksImport(works, existing).toCreate).toHaveLength(2); + }); + + it('на объекте две одноимённые работы — ошибка, ничего не планируем', () => { + const existing = new Map([['1517', [makeWork('guid-1', 'Арматура'), makeWork('guid-2', 'Арматура')]]]); + expect(() => planWorksImport(works, existing)).toThrow('Строка 2: на объекте 2 работы «Арматура»'); + }); +}); diff --git a/src/webparts/stroyBot/pages/worksImport/worksImportBuilder.ts b/src/webparts/stroyBot/pages/worksImport/worksImportBuilder.ts new file mode 100644 index 0000000..f3b11be --- /dev/null +++ b/src/webparts/stroyBot/pages/worksImport/worksImportBuilder.ts @@ -0,0 +1,83 @@ +import { IExcelImportCheck, IExcelImportRow } from '../_excelImport/excelImportTypes'; +import { deepestObjectId, textValue, toNumber } from '../_excelImport/excelImportValues'; +import { IWork } from '../works/worksTypes'; +import { WORKS_IMPORT_COLUMNS as COL } from './worksImportColumns'; +import { WORKS_IMPORT_TEXTS } from './texts'; + +// Ответ проверки → какие работы создать, а у каких обновить плановый объём и единицу. + +/** Работа из строки Excel. Единица — текстом: так её хранят работы (сервер лишь подтвердил, что она есть). */ +export interface IImportedWork { + rowNumber: number; + /** Id объекта строкой — как в колонке cree9_objectid. */ + objectId: string; + title: string; + unit: string; + volume: number; +} + +/** Работа с таким названием на объекте уже есть — обновляем её. */ +export interface IWorkUpdate extends IImportedWork { + workId: string; +} + +export interface IWorksImportPlan { + toCreate: IImportedWork[]; + toUpdate: IWorkUpdate[]; +} + +/** Название для сравнения: без краевых и двойных пробелов, без учёта регистра. */ +export function normalizeTitle(title: string): string { + return title.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +function workKey(objectId: string, title: string): string { + return `${objectId}|${normalizeTitle(title)}`; +} + +function buildWork(row: IExcelImportRow): IImportedWork { + const objectId = deepestObjectId(row, COL.objects); + const title = textValue(row, COL.title); + const unit = textValue(row, COL.unit); + const volume = toNumber(row.values[COL.volume]); + // Сервер уже проверил строку; если поля всё же нет — не пишем работу наполовину. + if (!objectId || !title || !unit || volume === undefined) { + throw new Error(WORKS_IMPORT_TEXTS.build.missingFields(row.row)); + } + return { rowNumber: row.row, objectId: String(objectId), title, unit, volume }; +} + +/** Работы файла. Одна и та же работа объекта дважды — ошибка: непонятно, какой объём верный. */ +export function buildImportedWorks(check: IExcelImportCheck): IImportedWork[] { + const firstRowByKey = new Map(); + return check.rows.map((row) => { + const work = buildWork(row); + const key = workKey(work.objectId, work.title); + const firstRow = firstRowByKey.get(key); + if (firstRow !== undefined) throw new Error(WORKS_IMPORT_TEXTS.build.duplicateInFile(firstRow, row.row, work.title)); + firstRowByKey.set(key, row.row); + return work; + }); +} + +/** Объекты, работы которых нужно загрузить перед сравнением. */ +export function objectIdsOf(works: IImportedWork[]): string[] { + return works.map((work) => work.objectId).filter((id, index, all) => all.indexOf(id) === index); +} + +/** + * Есть на объекте работа с тем же названием — обновляем её, нет — создаём. + * Две и больше одноимённых — ошибка: какую обновлять, не угадать. + */ +export function planWorksImport(works: IImportedWork[], existingByObject: Map): IWorksImportPlan { + const plan: IWorksImportPlan = { toCreate: [], toUpdate: [] }; + works.forEach((work) => { + const matches = (existingByObject.get(work.objectId) || []).filter( + (existing) => normalizeTitle(existing.title) === normalizeTitle(work.title) + ); + if (matches.length > 1) throw new Error(WORKS_IMPORT_TEXTS.build.ambiguous(work.rowNumber, work.title, matches.length)); + if (matches.length === 1) plan.toUpdate.push({ ...work, workId: matches[0].id }); + else plan.toCreate.push(work); + }); + return plan; +} diff --git a/src/webparts/stroyBot/pages/worksImport/worksImportColumns.ts b/src/webparts/stroyBot/pages/worksImport/worksImportColumns.ts new file mode 100644 index 0000000..684ad96 --- /dev/null +++ b/src/webparts/stroyBot/pages/worksImport/worksImportColumns.ts @@ -0,0 +1,8 @@ +// Заголовки колонок Excel-формы плановых объёмов — ключи values / ids в ответе проверки. + +export const WORKS_IMPORT_COLUMNS = { + objects: ['Объект 1', 'Объект 2', 'Объект 3'], + title: 'Вид работ', + unit: 'Ед.изм.', + volume: 'Плановый объем' +} as const; diff --git a/src/webparts/stroyBot/pages/worksImport/worksImportRepository.test.ts b/src/webparts/stroyBot/pages/worksImport/worksImportRepository.test.ts new file mode 100644 index 0000000..336452c --- /dev/null +++ b/src/webparts/stroyBot/pages/worksImport/worksImportRepository.test.ts @@ -0,0 +1,7 @@ +import { worksImportUrl } from './worksImportRepository'; + +describe('импорт работ: адрес проверки', () => { + it('как в Swagger', () => { + expect(worksImportUrl('https://api.tnsu.kz', 51)).toEqual('https://api.tnsu.kz/Import/StroyBot/Works?projectId=51'); + }); +}); diff --git a/src/webparts/stroyBot/pages/worksImport/worksImportRepository.ts b/src/webparts/stroyBot/pages/worksImport/worksImportRepository.ts new file mode 100644 index 0000000..685ea28 --- /dev/null +++ b/src/webparts/stroyBot/pages/worksImport/worksImportRepository.ts @@ -0,0 +1,3 @@ +export function worksImportUrl(apiBaseUrl: string, projectId: number): string { + return `${apiBaseUrl}/Import/StroyBot/Works?projectId=${projectId}`; +}