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}`; +}