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