feat: show current vs new plan before importing works

A file value replaces the planned volume of an existing work. Before the
import the page now lists such works with their current and new volume
and unit, highlighting changes and greying out rows that stay the same,
so the user sees what will be overwritten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-23 09:54:12 +05:00
parent a98162fce7
commit ee282b61b7
8 changed files with 190 additions and 11 deletions
+1
View File
@@ -201,6 +201,7 @@
- [ ] **T-WIM-01** Меню: «Импорт работ из Excel» после «Загрузить работы из сметы»; есть у роли **Е**, у **Р** (без edit0) — нет; в архивном проекте — нет; в завершённом — есть.
- [ ] **T-WIM-02** Файл с несуществующим объектом → «С ошибками: 1», «Объект 2: «…» не найден внутри «…»», кнопки «Импортировать» нет.
- [ ] **T-WIM-03** Тестовый файл → «Сверяем с работами объектов…» → «Будет создано работ: 1, обновлён … у существующих: 1», кнопка «Импортировать (работ: 2)».
- [ ] **T-WIM-03a** У работ, где план уже есть, — таблица «Сейчас → Станет»: изменившийся объём подсвечен со стрелкой ▲/▼ (например, «Арматура» 120 м → 150 м ▲), совпадающий — серым «без изменений». План **заменяется** значением из файла, не складывается. В окне подтверждения — «…плановый объём будет заменён значением из файла». (Проверено 2026-10-23.)
- [ ] ✍️ **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.)
@@ -33,3 +33,51 @@
color: inherit;
font-weight: 600;
}
// «Сейчас → станет»: изменившийся план подсвечен, совпадающий — приглушён.
.updates {
display: flex;
flex-direction: column;
gap: $sp-2;
}
.updatesTitle {
margin: 0;
font-size: $fs-sm;
font-weight: 600;
color: $gold-600;
}
.updatesTable {
border-collapse: collapse;
font-size: $fs-sm;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
th {
padding: $sp-2 $sp-3;
border-bottom: 1px solid $gray-200;
background: $gray-50;
font-size: $fs-caption;
font-weight: 600;
text-align: left;
text-transform: uppercase;
color: $gray-500;
}
td {
padding: $sp-2 $sp-3;
border-bottom: 1px solid $gray-100;
color: $gray-900;
}
}
.changed {
font-weight: 600;
background: $gold-50;
}
.unchanged td {
color: $gray-400;
}
@@ -4,6 +4,7 @@ import { IExcelImportCheck } from '../_excelImport/excelImportTypes';
import { IWorksImportPlan } from './worksImportBuilder';
import { WorksImportStatus } from './useWorksImport';
import { WorksImportOutcome } from './WorksImportOutcome';
import { WorksUpdatesTable } from './WorksUpdatesTable';
import { WORKS_IMPORT_TEXTS } from './texts';
import styles from './WorksImportPage.module.scss';
@@ -47,6 +48,7 @@ export const WorksImportStep: React.FC<Props> = ({ check, projectId, projectTitl
return (
<div className={styles.importBlock}>
<p className={styles.plan}>{TEXTS.plan(plan.toCreate.length, plan.toUpdate.length)}</p>
<WorksUpdatesTable updates={plan.toUpdate} />
<div>
<Button variant="accent" onClick={() => startImport().catch(() => undefined)}>
{TEXTS.button(plan.toCreate.length + plan.toUpdate.length)}
@@ -0,0 +1,54 @@
import * as React from 'react';
import { isUnchanged, IWorkUpdate, volumeChange, VolumeChange } from './worksImportBuilder';
import { WORKS_IMPORT_TEXTS } from './texts';
import styles from './WorksImportPage.module.scss';
const TEXTS = WORKS_IMPORT_TEXTS.updates;
const CHANGE_MARK: Record<VolumeChange, string> = { up: '▲', down: '▼', same: '' };
interface Props {
updates: IWorkUpdate[];
}
function formatVolume(volume: number | undefined, unit: string): string {
return volume === undefined ? TEXTS.noPlan : `${volume} ${unit}`.trim();
}
/** Работы, у которых плановый объём уже есть: что сейчас и что станет после импорта. */
export const WorksUpdatesTable: React.FC<Props> = ({ updates }) => {
if (updates.length === 0) return null;
return (
<div className={styles.updates}>
<p className={styles.updatesTitle}>{TEXTS.title}</p>
<table className={styles.updatesTable}>
<thead>
<tr>
<th>{TEXTS.columns.row}</th>
<th>{TEXTS.columns.work}</th>
<th>{TEXTS.columns.object}</th>
<th>{TEXTS.columns.current}</th>
<th>{TEXTS.columns.next}</th>
</tr>
</thead>
<tbody>
{updates.map((update) => {
const isSame = isUnchanged(update);
return (
<tr key={update.rowNumber} className={isSame ? styles.unchanged : undefined}>
<td>{update.rowNumber}</td>
<td>{update.title}</td>
<td>{update.objectPath}</td>
<td>{formatVolume(update.previousVolume, update.previousUnit)}</td>
<td className={isSame ? undefined : styles.changed}>
{isSame ? TEXTS.noChange : `${formatVolume(update.volume, update.unit)} ${CHANGE_MARK[volumeChange(update)]}`}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
@@ -15,7 +15,9 @@ export const WORKS_IMPORT_TEXTS = {
saving: (savedCount: number, total: number): string => `Сохраняем работы… ${savedCount} из ${total}`,
confirmTitle: 'Импорт работ',
confirmMessage: (createCount: number, updateCount: number, projectTitle: string): string =>
`В проекте «${projectTitle}» будет создано работ: ${createCount}, обновлено: ${updateCount}. Продолжить?`,
`В проекте «${projectTitle}» будет создано работ: ${createCount}, обновлено: ${updateCount}` +
(updateCount > 0 ? ' — у них плановый объём будет заменён значением из файла' : '') +
'. Продолжить?',
confirm: 'Импортировать',
cancel: 'Отмена',
success: (createCount: number, updateCount: number): string =>
@@ -26,6 +28,18 @@ export const WORKS_IMPORT_TEXTS = {
'Файл можно загрузить снова: уже записанные работы обновятся, а не задвоятся.',
openObjects: 'Открыть «Объекты»'
},
updates: {
title: 'У этих работ плановый объём уже есть — он будет заменён значением из файла:',
columns: {
row: 'Строка',
work: 'Работа',
object: 'Объект',
current: 'Сейчас',
next: 'Станет'
},
noChange: 'без изменений',
noPlan: '—'
},
build: {
missingFields: (row: number): string => `Строка ${row}: не определены объект, вид работ, единица или плановый объём.`,
duplicateInFile: (firstRow: number, row: number, title: string): string =>
@@ -1,4 +1,12 @@
import { buildImportedWorks, normalizeTitle, objectIdsOf, planWorksImport } from './worksImportBuilder';
import {
buildImportedWorks,
isUnchanged,
IWorkUpdate,
normalizeTitle,
objectIdsOf,
planWorksImport,
volumeChange
} from './worksImportBuilder';
import { IExcelImportCheck, IExcelImportRow } from '../_excelImport/excelImportTypes';
import { IWork } from '../works/worksTypes';
@@ -45,9 +53,10 @@ const newWork = makeRow(3, 'Тестовая работа импорта', 'м3'
describe('работы из ответа проверки', () => {
it('тестовый файл: объект — самый глубокий id строкой, единица — текстом, объём — числом', () => {
const objectPath = 'Тестовый объект / Бетонирование';
expect(buildImportedWorks(makeCheck([armature, newWork]))).toEqual([
{ rowNumber: 2, objectId: '1517', title: 'Арматура', unit: 'м', volume: 100 },
{ rowNumber: 3, objectId: '1517', title: 'Тестовая работа импорта', unit: 'м3', volume: 50 }
{ rowNumber: 2, objectId: '1517', objectPath, title: 'Арматура', unit: 'м', volume: 100 },
{ rowNumber: 3, objectId: '1517', objectPath, title: 'Тестовая работа импорта', unit: 'м3', volume: 50 }
]);
});
@@ -79,11 +88,11 @@ describe('работы из ответа проверки', () => {
describe('план импорта: создать или обновить', () => {
const works = buildImportedWorks(makeCheck([armature, newWork]));
it('«Арматура» уже есть на объекте — обновить её; новой работы нет — создать', () => {
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.toUpdate).toEqual([{ ...works[0], workId: 'guid-armature', previousVolume: 100, previousUnit: 'м' }]);
expect(plan.toCreate).toEqual([works[1]]);
});
@@ -98,6 +107,28 @@ describe('план импорта: создать или обновить', () =
expect(planWorksImport(works, existing).toCreate).toHaveLength(2);
});
it('сейчас 120, в файле 150 — замена с ростом; 100 — снижение; совпадает — без изменений', () => {
const update = (volume: number, unit: string = 'м'): IWorkUpdate => ({
...works[0],
volume,
unit,
workId: 'guid-armature',
previousVolume: 120,
previousUnit: 'м'
});
expect(volumeChange(update(150))).toEqual('up');
expect(volumeChange(update(100))).toEqual('down');
expect(volumeChange(update(120))).toEqual('same');
expect(isUnchanged(update(120))).toBe(true);
expect(isUnchanged(update(120, 'тн'))).toBe(false);
});
it('у существующей работы не было плана — считаем как 0', () => {
const existing = new Map([['1517', [{ ...makeWork('guid-armature', 'Арматура'), volume: undefined as unknown as number }]]]);
const [update] = planWorksImport(works, existing).toUpdate;
expect(volumeChange(update)).toEqual('up');
});
it('на объекте две одноимённые работы — ошибка, ничего не планируем', () => {
const existing = new Map([['1517', [makeWork('guid-1', 'Арматура'), makeWork('guid-2', 'Арматура')]]]);
expect(() => planWorksImport(works, existing)).toThrow('Строка 2: на объекте 2 работы «Арматура»');
@@ -11,14 +11,34 @@ export interface IImportedWork {
rowNumber: number;
/** Id объекта строкой — как в колонке cree9_objectid. */
objectId: string;
/** «Тестовый объект / Бетонирование» — для показа пользователю. */
objectPath: string;
title: string;
unit: string;
volume: number;
}
/** Работа с таким названием на объекте уже есть — обновляем её. */
/** Работа с таким названием на объекте уже есть — заменяем её плановый объём и единицу. */
export interface IWorkUpdate extends IImportedWork {
workId: string;
/** Что сейчас в системе — показываем «сейчас → станет» до импорта. */
previousVolume: number | undefined;
previousUnit: string;
}
/** Как изменится плановый объём: рост, снижение или без изменений. */
export type VolumeChange = 'up' | 'down' | 'same';
export function volumeChange(update: IWorkUpdate): VolumeChange {
const previous = update.previousVolume ?? 0;
if (update.volume > previous) return 'up';
if (update.volume < previous) return 'down';
return 'same';
}
/** Обновление ничего не меняет — ни объём, ни единицу. */
export function isUnchanged(update: IWorkUpdate): boolean {
return volumeChange(update) === 'same' && normalizeTitle(update.unit) === normalizeTitle(update.previousUnit);
}
export interface IWorksImportPlan {
@@ -44,7 +64,11 @@ function buildWork(row: IExcelImportRow): IImportedWork {
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 };
const objectPath = COL.objects
.map((column) => textValue(row, column))
.filter(Boolean)
.join(' / ');
return { rowNumber: row.row, objectId: String(objectId), objectPath, title, unit, volume };
}
/** Работы файла. Одна и та же работа объекта дважды — ошибка: непонятно, какой объём верный. */
@@ -76,8 +100,12 @@ export function planWorksImport(works: IImportedWork[], existingByObject: Map<st
(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);
if (matches.length === 1) {
const [existing] = matches;
plan.toUpdate.push({ ...work, workId: existing.id, previousVolume: existing.volume, previousUnit: existing.unit });
} else {
plan.toCreate.push(work);
}
});
return plan;
}
@@ -24,6 +24,7 @@ function makeWriter(failOnCall?: number): ImportWorksWriter & { writes: IWrite[]
const work = (rowNumber: number, title: string): IImportedWork => ({
rowNumber,
objectId: '1517',
objectPath: 'Тестовый объект / Бетонирование',
title,
unit: 'м',
volume: 100
@@ -32,7 +33,7 @@ const work = (rowNumber: number, title: string): IImportedWork => ({
// Как в тестовом файле: строка 2 — «Арматура» уже есть, строка 3 — новая работа.
const plan: IWorksImportPlan = {
toCreate: [work(3, 'Тестовая работа импорта')],
toUpdate: [{ ...work(2, 'Арматура'), workId: 'guid-armature' }]
toUpdate: [{ ...work(2, 'Арматура'), workId: 'guid-armature', previousVolume: 120, previousUnit: 'м' }]
};
describe('запись импортированных работ', () => {