feat: add works import from Excel (check and plan)

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) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-22 17:35:21 +05:00
parent db8565b0d3
commit 18c2959820
14 changed files with 298 additions and 2 deletions
+3
View File
@@ -10,6 +10,7 @@ import { ResourcesPage } from '../pages/resources/ResourcesPage';
import { BinderPage } from '../pages/binder/BinderPage'; import { BinderPage } from '../pages/binder/BinderPage';
import { WeatherPage } from '../pages/weather/WeatherPage'; import { WeatherPage } from '../pages/weather/WeatherPage';
import { ReportImportPage } from '../pages/reportImport/ReportImportPage'; import { ReportImportPage } from '../pages/reportImport/ReportImportPage';
import { WorksImportPage } from '../pages/worksImport/WorksImportPage';
/** Показывает страницу текущего раздела (раздел берётся из ?page=). */ /** Показывает страницу текущего раздела (раздел берётся из ?page=). */
export const PageOutlet: React.FC = () => { export const PageOutlet: React.FC = () => {
@@ -34,6 +35,8 @@ export const PageOutlet: React.FC = () => {
return <WeatherPage />; return <WeatherPage />;
case 'reportImport': case 'reportImport':
return <ReportImportPage />; return <ReportImportPage />;
case 'worksImport':
return <WorksImportPage />;
case 'hub': case 'hub':
default: default:
return <HubPage />; return <HubPage />;
@@ -13,6 +13,7 @@ describe('buildSidebarItems', () => {
'report', 'report',
'reportImport', 'reportImport',
'import', 'import',
'worksImport',
'resources', 'resources',
'binder' 'binder'
]); ]);
+1
View File
@@ -17,6 +17,7 @@ export const NAV_ITEMS: INavItem[] = [
{ page: 'report', title: 'Отправить отчёт', icon: 'send', needsProject: true }, { page: 'report', title: 'Отправить отчёт', icon: 'send', needsProject: true },
{ page: 'reportImport', title: 'Импорт отчёта из Excel', icon: 'upload', needsProject: true }, { page: 'reportImport', title: 'Импорт отчёта из Excel', icon: 'upload', needsProject: true },
{ page: 'import', title: 'Загрузить работы из сметы', icon: 'download', 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: 'resources', title: 'Ресурсы проекта', icon: 'people', needsProject: true },
{ page: 'binder', title: 'Привязка к справочнику', icon: 'swap', needsProject: true } { page: 'binder', title: 'Привязка к справочнику', icon: 'swap', needsProject: true }
]; ];
@@ -125,4 +125,11 @@ describe('isSectionAvailable', () => {
expect(isSectionAvailable('reportImport', project({ status: 'текущий', canEdit: false }))).toBe(false); expect(isSectionAvailable('reportImport', project({ status: 'текущий', canEdit: false }))).toBe(false);
expect(isSectionAvailable('reportImport', project({ status: 'завершён', canEdit: true }))).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);
});
}); });
@@ -75,6 +75,7 @@ export function getStatusKind(status: string | undefined): ProjectStatusKind {
* - нет права edit0 — нельзя «Отправить отчёт». * - нет права edit0 — нельзя «Отправить отчёт».
* Импорт отчёта из Excel создаёт отчёты — те же правила, что у «Отправить отчёт». * Импорт отчёта из Excel создаёт отчёты — те же правила, что у «Отправить отчёт».
* Привязка к справочнику — только с правом редактирования (решение при объединении). * Привязка к справочнику — только с правом редактирования (решение при объединении).
* Импорт работ из Excel меняет работы проекта — тоже только с правом редактирования.
*/ */
export function isSectionAvailable(page: PageId, project: IAccessibleProject): boolean { export function isSectionAvailable(page: PageId, project: IAccessibleProject): boolean {
if (page === 'hub') return true; if (page === 'hub') return true;
@@ -82,7 +83,7 @@ export function isSectionAvailable(page: PageId, project: IAccessibleProject): b
const kind = getStatusKind(project.status); const kind = getStatusKind(project.status);
if (kind === 'archive') return false; if (kind === 'archive') return false;
if (page === 'report' || page === 'reportImport') return kind !== 'done' && project.canEdit; 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; return true;
} }
+2 -1
View File
@@ -12,7 +12,8 @@ export const PAGE_IDS = [
'resources', 'resources',
'binder', 'binder',
'weather', 'weather',
'reportImport' 'reportImport',
'worksImport'
] as const; ] as const;
export type PageId = (typeof PAGE_IDS)[number]; export type PageId = (typeof PAGE_IDS)[number];
@@ -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;
}
@@ -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 <Alert variant="info">{WORKS_IMPORT_TEXTS.noProject}</Alert>;
if (!isSectionAvailable('worksImport', currentProject)) {
return <Alert variant="warn">{WORKS_IMPORT_TEXTS.noAccess}</Alert>;
}
return (
<>
<p className={styles.intro}>{WORKS_IMPORT_TEXTS.intro}</p>
<ExcelImportSection
projectId={currentProject.id}
checkUrl={worksImportUrl}
isLocked={false}
onFileChange={() => undefined}
renderImport={() => undefined}
/>
</>
);
};
return (
<div className={styles.page}>
<TreePageHeader icon="document" label={WORKS_IMPORT_TEXTS.headerLabel} title={currentProject?.title ?? ''} />
{renderContent()}
</div>
);
};
@@ -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;
@@ -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<string, unknown> = {}): 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 работы «Арматура»');
});
});
@@ -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<string, number>();
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<string, IWork[]>): 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;
}
@@ -0,0 +1,8 @@
// Заголовки колонок Excel-формы плановых объёмов — ключи values / ids в ответе проверки.
export const WORKS_IMPORT_COLUMNS = {
objects: ['Объект 1', 'Объект 2', 'Объект 3'],
title: 'Вид работ',
unit: 'Ед.изм.',
volume: 'Плановый объем'
} as const;
@@ -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');
});
});
@@ -0,0 +1,3 @@
export function worksImportUrl(apiBaseUrl: string, projectId: number): string {
return `${apiBaseUrl}/Import/StroyBot/Works?projectId=${projectId}`;
}