diff --git a/src/webparts/stroyBot/app/SectionPlaceholder.tsx b/src/webparts/stroyBot/app/SectionPlaceholder.tsx
deleted file mode 100644
index d2cbe7e..0000000
--- a/src/webparts/stroyBot/app/SectionPlaceholder.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import * as React from 'react';
-import { EmptyState } from '../shared/components/EmptyState';
-import { APP_TEXTS } from './texts';
-
-/** Временная заглушка для разделов, которые ещё не перенесены. Удаляется вместе с последним разделом. */
-export const SectionPlaceholder: React.FC = () => (
-
-);
diff --git a/src/webparts/stroyBot/pages/report/dataverseReportRepository.test.ts b/src/webparts/stroyBot/pages/report/dataverseReportRepository.test.ts
new file mode 100644
index 0000000..786e321
--- /dev/null
+++ b/src/webparts/stroyBot/pages/report/dataverseReportRepository.test.ts
@@ -0,0 +1,204 @@
+import {
+ buildReportDataPayload,
+ createDataverseReportRepository,
+ DV_PATHS,
+ orderRowsForDeletion
+} from './dataverseReportRepository';
+import { createFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient';
+import { IReportPayload } from './reportTypes';
+
+const DV = 'https://tansu.crm5.dynamics.com';
+const API = `${DV}/api/data/v9.2/`;
+
+const REPORT: IReportPayload = {
+ cree9_objectid: 41,
+ cree9_companyid: 3,
+ cree9_shiftid: 1,
+ cree9_report_date: '2026-10-16',
+ cree9_number: 8,
+ cree9_projectid: 5,
+ cree9_comment: undefined
+};
+
+/** Тело запроса как объект — чтобы сравнивать без зависимости от порядка ключей. */
+const bodyOf = (body: string | ArrayBuffer | undefined): unknown => JSON.parse(String(body));
+
+describe('адреса Dataverse (1:1 со stroy-bot-report)', () => {
+ it('отчёт, счётчик, строки, факт', () => {
+ expect(DV_PATHS.report('r1')).toEqual('cree9_reports(r1)');
+ expect(DV_PATHS.reportCount(5)).toEqual(
+ 'cree9_reports?$filter=cree9_projectid eq 5 and cree9_deleted ne true&$count=true&$top=1&$select=cree9_number'
+ );
+ expect(DV_PATHS.reportData('r1')).toEqual('cree9_report_datas?$filter=_cree9_column1_value eq r1');
+ expect(DV_PATHS.jobTotalFact('j1')).toEqual('cree9_report_datas?$filter=_cree9_jobid_value eq j1&$select=cree9_value');
+ expect(DV_PATHS.jobsByObject(41)).toEqual(
+ "cree9_tables?$filter=cree9_objectid eq '41'" +
+ '&$select=cree9_tableid,cree9_title,cree9_unit,cree9_objectid,cree9_volume,_cree9_parentid_value'
+ );
+ });
+});
+
+describe('buildReportDataPayload', () => {
+ it('работа — через cree9_jobId@odata.bind, всегда привязка к отчёту, пустые поля пропускаются', () => {
+ expect(buildReportDataPayload({ _cree9_jobid_value: 'j1', cree9_value: 2, cree9_comment: undefined }, 'r1')).toEqual({
+ 'cree9_jobId@odata.bind': '/cree9_tables(j1)',
+ cree9_value: 2,
+ 'cree9_Column1@odata.bind': '/cree9_reports(r1)'
+ });
+ });
+
+ it('строка на работе привязана к строке-работе через cree9_parentId', () => {
+ expect(buildReportDataPayload({ cree9_professionid: 7, cree9_count: 2 }, 'r1', 'row-w')).toEqual({
+ cree9_professionid: 7,
+ cree9_count: 2,
+ 'cree9_Column1@odata.bind': '/cree9_reports(r1)',
+ 'cree9_parentId@odata.bind': '/cree9_report_datas(row-w)'
+ });
+ });
+});
+
+describe('orderRowsForDeletion', () => {
+ it('сначала дочерние (с родителем), потом остальные', () => {
+ const ordered = orderRowsForDeletion([
+ { cree9_report_dataid: 'parent' },
+ { cree9_report_dataid: 'child', _cree9_parentid_value: 'parent' },
+ { cree9_report_dataid: 'general' }
+ ]);
+ expect(ordered.map((row) => row.cree9_report_dataid)).toEqual(['child', 'parent', 'general']);
+ });
+});
+
+describe('saveReportWithData', () => {
+ const nodes = [
+ { data: { _cree9_jobid_value: 'j1', cree9_value: 2 }, children: [{ cree9_professionid: 7, cree9_count: 1 }] },
+ { data: { cree9_technicid: 9, cree9_count: 1 } }
+ ];
+
+ /** Каждая созданная строка отчёта получает свой id: row-1, row-2, … */
+ const createdRows: IFakeRoute = {
+ match: 'cree9_report_datas',
+ respond: (callNumber) => ({ status: 201, body: { cree9_report_dataid: `row-${callNumber}` } })
+ };
+
+ it('новый отчёт: POST отчёта, затем строки по порядку, дети — к строке своей работы', async () => {
+ const client = createFakeHttpClient([
+ createdRows,
+ { match: 'cree9_reports', response: { status: 201, body: { cree9_reportid: 'r-new' } } }
+ ]);
+ const repository = createDataverseReportRepository(client, DV);
+
+ expect(await repository.saveReportWithData(REPORT, nodes)).toEqual('r-new');
+
+ const writes = client.requests;
+ expect(writes.map((r) => r.url)).toEqual([
+ `${API}cree9_reports`,
+ `${API}cree9_report_datas`,
+ `${API}cree9_report_datas`,
+ `${API}cree9_report_datas`
+ ]);
+ expect(bodyOf(writes[0].request.body)).toEqual({
+ cree9_objectid: 41,
+ cree9_companyid: 3,
+ cree9_shiftid: 1,
+ cree9_report_date: '2026-10-16',
+ cree9_number: 8,
+ cree9_projectid: 5
+ });
+ // Ребёнок работы (второй POST строки) привязан к строке работы row-1.
+ expect(bodyOf(writes[2].request.body)).toEqual(
+ expect.objectContaining({ 'cree9_parentId@odata.bind': '/cree9_report_datas(row-1)' })
+ );
+ // Общая техника — без родителя.
+ expect(bodyOf(writes[3].request.body)).not.toHaveProperty('cree9_parentId@odata.bind');
+ });
+
+ it('правка: PATCH отчёта → удаление старых строк (дети первыми) → создание новых', async () => {
+ const client = createFakeHttpClient([
+ {
+ match: '?$filter=_cree9_column1_value eq r1',
+ response: {
+ body: {
+ value: [
+ { cree9_report_dataid: 'old-work' },
+ { cree9_report_dataid: 'old-child', _cree9_parentid_value: 'old-work' }
+ ]
+ }
+ }
+ },
+ { match: 'cree9_report_datas(', response: { status: 204 } },
+ createdRows,
+ { match: 'cree9_reports(r1)', response: { status: 204 } }
+ ]);
+ const repository = createDataverseReportRepository(client, DV);
+
+ expect(await repository.saveReportWithData(REPORT, nodes, 'r1')).toEqual('r1');
+
+ const sequence = client.requests.map((r) => `${r.request.method} ${r.url.replace(API, '')}`);
+ expect(sequence).toEqual([
+ 'PATCH cree9_reports(r1)',
+ 'GET cree9_report_datas?$filter=_cree9_column1_value eq r1',
+ 'DELETE cree9_report_datas(old-child)',
+ 'DELETE cree9_report_datas(old-work)',
+ 'POST cree9_report_datas',
+ 'POST cree9_report_datas',
+ 'POST cree9_report_datas'
+ ]);
+ });
+
+ it('если сервер не вернул id отчёта — ошибка, строки не создаются', async () => {
+ const client = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 201, body: {} } }]);
+ const repository = createDataverseReportRepository(client, DV);
+ await expect(repository.saveReportWithData(REPORT, nodes)).rejects.toThrow('reportId is empty');
+ expect(client.requests).toHaveLength(1);
+ });
+
+ it('ошибка удаления старой строки прерывает сохранение', async () => {
+ const client = createFakeHttpClient([
+ { match: '_cree9_column1_value', response: { body: { value: [{ cree9_report_dataid: 'old' }] } } },
+ { match: 'cree9_report_datas(old)', response: { status: 403 } },
+ { match: 'cree9_reports(r1)', response: { status: 204 } }
+ ]);
+ const repository = createDataverseReportRepository(client, DV);
+ await expect(repository.saveReportWithData(REPORT, nodes, 'r1')).rejects.toThrow('403');
+ expect(client.requests.some((r) => r.request.method === 'POST')).toBe(false);
+ });
+});
+
+describe('прочие операции', () => {
+ it('номер нового отчёта = количество неудалённых + 1', async () => {
+ const client = createFakeHttpClient([{ match: '$count=true', response: { body: { '@odata.count': 7, value: [] } } }]);
+ expect(await createDataverseReportRepository(client, DV).getNextReportNumber(5)).toEqual(8);
+ });
+
+ it('утверждение — PATCH cree9_aprooved: true', async () => {
+ const client = createFakeHttpClient([{ match: 'cree9_reports(r1)', response: { status: 204 } }]);
+ await createDataverseReportRepository(client, DV).approveReport('r1');
+ expect(client.requests[0].request.method).toEqual('PATCH');
+ expect(bodyOf(client.requests[0].request.body)).toEqual({ cree9_aprooved: true });
+ });
+
+ it('итоговый факт — сумма cree9_value по всем строкам работы', async () => {
+ const client = createFakeHttpClient([
+ { match: '_cree9_jobid_value', response: { body: { value: [{ cree9_value: 2 }, {}, { cree9_value: 1.5 }] } } }
+ ]);
+ expect(await createDataverseReportRepository(client, DV).getJobTotalFact('j1')).toEqual(3.5);
+ });
+
+ it('новая работа: объём только если > 0, родитель — через найденное навигационное свойство', async () => {
+ const client = createFakeHttpClient([
+ { match: 'EntityDefinitions', response: { body: { value: [{ ReferencingEntityNavigationPropertyName: 'cree9_ParentId' }] } } },
+ { match: 'cree9_tables', response: { status: 201, body: { cree9_tableid: 'new-job' } } }
+ ]);
+ const repository = createDataverseReportRepository(client, DV);
+
+ const job = await repository.createJob({ title: 'Бетон', unit: 'м3', objectId: 41, volume: 0, parentId: 'a' });
+
+ expect(job.cree9_tableid).toEqual('new-job');
+ expect(bodyOf(client.requests[1].request.body)).toEqual({
+ cree9_title: 'Бетон',
+ cree9_unit: 'м3',
+ cree9_objectid: '41',
+ 'cree9_ParentId@odata.bind': '/cree9_tables(a)'
+ });
+ });
+});
diff --git a/src/webparts/stroyBot/pages/report/reportCollections.test.ts b/src/webparts/stroyBot/pages/report/reportCollections.test.ts
new file mode 100644
index 0000000..16d576b
--- /dev/null
+++ b/src/webparts/stroyBot/pages/report/reportCollections.test.ts
@@ -0,0 +1,50 @@
+import { buildFormCollections } from './reportCollections';
+import { IReportServices } from './reportServices';
+import { IReportDataRow } from './reportTypes';
+
+/** Сервисы-заглушки: только то, что использует разбор строк. */
+function fakeServices(): IReportServices {
+ const sp = {
+ getProfessionById: async (id: number) => ({ Id: id, Title: id === 7 ? 'Сварщик' : 'Монтажник' }),
+ getTechnicById: async (id: number) => {
+ if (id === 99) throw new Error('удалена');
+ return { Id: id, Title: 'Кран' };
+ }
+ };
+ const dv = {
+ getJobsByObjectId: async () => [
+ { cree9_tableid: 'j1', cree9_title: 'Бетон', cree9_unit: 'м3', cree9_objectid: '41', cree9_volume: 10, fullPath: 'Бетон' }
+ ],
+ getJobTotalFact: async () => 6
+ };
+ return { sp, dv, siteUrl: '' } as unknown as IReportServices;
+}
+
+describe('buildFormCollections (как buildFormCollections старой формы)', () => {
+ const rows: IReportDataRow[] = [
+ // Ребёнок раньше своей работы — всё равно прикрепляется к ней.
+ { cree9_report_dataid: 'c1', _cree9_parentid_value: 'w1', cree9_professionid: 7, cree9_count: 2, cree9_hours: 8 },
+ { cree9_report_dataid: 'w1', _cree9_jobid_value: 'j1', cree9_value: 3, cree9_comment: 'залили' },
+ // Старый бот писал название в комментарий — такой комментарий не показывается.
+ { cree9_report_dataid: 'p1', cree9_professionid: 8, cree9_comment: 'Монтажник', cree9_hourstype: 'Часы простоя' },
+ // Элемент справочника удалён — без названия, без падения.
+ { cree9_report_dataid: 't1', cree9_technicid: 99, cree9_comment: 'сломался' }
+ ];
+
+ it('работы, дети работ и общие секции', async () => {
+ const result = await buildFormCollections(fakeServices(), rows, 41);
+
+ expect(result.works).toHaveLength(1);
+ expect(result.works[0]).toEqual(
+ expect.objectContaining({ jobId: 'j1', jobTitle: 'Бетон', jobUnit: 'м3', value: 3, plan: 10, totalFact: 6, comment: 'залили' })
+ );
+ expect(result.works[0].professions.map((p) => p.title)).toEqual(['Сварщик']);
+
+ expect(result.professions).toEqual([
+ expect.objectContaining({ resourceId: 8, title: 'Монтажник', comment: '', hoursType: 'Часы простоя' })
+ ]);
+ expect(result.technics).toEqual([
+ expect.objectContaining({ resourceId: 99, title: '', comment: 'сломался', hoursType: 'Часы работы' })
+ ]);
+ });
+});
diff --git a/src/webparts/stroyBot/pages/report/reportPayload.test.ts b/src/webparts/stroyBot/pages/report/reportPayload.test.ts
new file mode 100644
index 0000000..efe5e62
--- /dev/null
+++ b/src/webparts/stroyBot/pages/report/reportPayload.test.ts
@@ -0,0 +1,51 @@
+import { buildReportNodes, buildReportPayload } from './reportPayload';
+import { reportForm, resource, workItem } from './reportTestData';
+
+describe('buildReportPayload', () => {
+ it('поля отчёта как в handleSave, пустой комментарий не отправляется', () => {
+ expect(buildReportPayload(reportForm(), 8, 5)).toEqual({
+ cree9_objectid: 41,
+ cree9_companyid: 3,
+ cree9_shiftid: 1,
+ cree9_report_date: '2026-10-16',
+ cree9_number: 8,
+ cree9_projectid: 5,
+ cree9_comment: undefined
+ });
+ expect(buildReportPayload(reportForm({ comment: 'Дождь' }), 8, 5).cree9_comment).toEqual('Дождь');
+ });
+});
+
+describe('buildReportNodes (порядок строк как в handleSave)', () => {
+ it('работы с детьми → общий персонал → общая техника', () => {
+ const form = reportForm({
+ works: [
+ workItem({
+ comment: 'залили',
+ professions: [resource({ resourceId: 7, hoursType: 'Часы простоя', comment: 'x' })],
+ technics: [resource({ resourceId: 9, count: 1, hours: 4 })]
+ }),
+ workItem({ id: 'w2', jobId: 'job-2', value: 1 })
+ ],
+ professions: [resource({ resourceId: 11, hoursType: 'Часы простоя', comment: 'ждали' })],
+ technics: [resource({ resourceId: 12, hoursType: '' })]
+ });
+
+ expect(buildReportNodes(form)).toEqual([
+ {
+ data: { _cree9_jobid_value: 'job-1', cree9_value: 3.5, cree9_comment: 'залили' },
+ children: [
+ // На работе тип часов всегда «Часы работы», даже если в элементе другой.
+ { cree9_professionid: 7, cree9_count: 2, cree9_hours: 8, cree9_comment: 'x', cree9_hourstype: 'Часы работы' },
+ { cree9_technicid: 9, cree9_count: 1, cree9_hours: 4, cree9_comment: undefined, cree9_hourstype: 'Часы работы' }
+ ]
+ },
+ { data: { _cree9_jobid_value: 'job-2', cree9_value: 1, cree9_comment: undefined }, children: undefined },
+ {
+ data: { cree9_professionid: 11, cree9_count: 2, cree9_hours: 8, cree9_comment: 'ждали', cree9_hourstype: 'Часы простоя' }
+ },
+ // Пустой тип часов — «Часы работы».
+ { data: { cree9_technicid: 12, cree9_count: 2, cree9_hours: 8, cree9_comment: undefined, cree9_hourstype: 'Часы работы' } }
+ ]);
+ });
+});
diff --git a/src/webparts/stroyBot/pages/report/reportRules.test.ts b/src/webparts/stroyBot/pages/report/reportRules.test.ts
new file mode 100644
index 0000000..bcb0879
--- /dev/null
+++ b/src/webparts/stroyBot/pages/report/reportRules.test.ts
@@ -0,0 +1,136 @@
+import {
+ buildJobPaths,
+ buildObjectPaths,
+ canShowBackAfterSave,
+ canShowSaveButton,
+ daysSinceReport,
+ decideExistingReportMode,
+ getStatusBadge,
+ hasAnyAccess,
+ isFormReadOnly,
+ newJobPath,
+ parseReportRoute,
+ sortTechnics,
+ todayIsoDate
+} from './reportRules';
+import { access } from './reportTestData';
+
+const DAY = 24 * 60 * 60 * 1000;
+
+describe('parseReportRoute', () => {
+ it('projectId через parseInt, reportId «null» — нет отчёта', () => {
+ expect(parseReportRoute({ page: 'report', projectId: '12', reportId: 'null' })).toEqual({
+ projectId: 12,
+ reportId: undefined,
+ copyFromId: ''
+ });
+ expect(parseReportRoute({ page: 'report', projectId: '12abc', copyFromId: 'c1' }).projectId).toEqual(12);
+ expect(parseReportRoute({ page: 'report' }).projectId).toEqual(0);
+ });
+});
+
+describe('decideExistingReportMode (как loadExistingReport)', () => {
+ it('утверждающий + неутверждённый — утверждение, даже если есть право правки', () => {
+ expect(decideExistingReportMode(access({ aproove: true, edit: true }), false, 0, 5)).toEqual('approve');
+ });
+
+ it('редактор + неутверждённый + меньше editDays дней — правка', () => {
+ expect(decideExistingReportMode(access({ edit: true }), false, 4, 5)).toEqual('edit');
+ });
+
+ it('прошло editDays дней — только просмотр', () => {
+ expect(decideExistingReportMode(access({ edit: true }), false, 5, 5)).toEqual('readonly');
+ });
+
+ it('утверждённый отчёт — просмотр для всех', () => {
+ expect(decideExistingReportMode(access({ edit: true, aproove: true }), true, 0, 5)).toEqual('readonly');
+ });
+
+ it('только чтение — просмотр', () => {
+ expect(decideExistingReportMode(access({ read: true }), false, 0, 5)).toEqual('readonly');
+ });
+});
+
+describe('доступ и кнопки', () => {
+ it('нет записи или все права false — нет доступа', () => {
+ expect(hasAnyAccess(undefined)).toBe(false);
+ expect(hasAnyAccess(access())).toBe(false);
+ expect(hasAnyAccess(access({ read: true }))).toBe(true);
+ });
+
+ it('просмотр и утверждение — форма только для чтения', () => {
+ expect(isFormReadOnly('readonly')).toBe(true);
+ expect(isFormReadOnly('approve')).toBe(true);
+ expect(isFormReadOnly('edit')).toBe(false);
+ });
+
+ it('бейдж: у нового нет, утверждён, на утверждении — только у утверждающего', () => {
+ expect(getStatusBadge('create', true, access({ aproove: true }))).toBeUndefined();
+ expect(getStatusBadge('readonly', true, access())).toEqual('approved');
+ expect(getStatusBadge('approve', false, access({ aproove: true }))).toEqual('pending');
+ expect(getStatusBadge('edit', false, access({ edit: true }))).toBeUndefined();
+ });
+
+ it('«Сохранить»: 🐞 новый отчёт без строки «Доступы» сохранить можно, edit=false — нельзя', () => {
+ expect(canShowSaveButton('create', false, undefined)).toBe(true);
+ expect(canShowSaveButton('create', false, access({ edit: false }))).toBe(false);
+ expect(canShowSaveButton('edit', false, access({ edit: true }))).toBe(true);
+ expect(canShowSaveButton('edit', true, access({ edit: true }))).toBe(false);
+ expect(canShowSaveButton('readonly', false, access({ edit: true }))).toBe(false);
+ });
+
+ it('после сохранения — «Вернуться назад»', () => {
+ expect(canShowBackAfterSave('create', true)).toBe(true);
+ expect(canShowBackAfterSave('approve', true)).toBe(false);
+ });
+});
+
+describe('даты', () => {
+ it('сегодня — по UTC, как в старом коде', () => {
+ expect(todayIsoDate(new Date('2026-10-16T23:30:00Z'))).toEqual('2026-10-16');
+ });
+
+ it('полные сутки с даты отчёта', () => {
+ const reportDate = '2026-10-10T00:00:00Z';
+ expect(daysSinceReport(reportDate, new Date(reportDate).getTime() + 5 * DAY - 1)).toEqual(4);
+ expect(daysSinceReport(reportDate, new Date(reportDate).getTime() + 5 * DAY)).toEqual(5);
+ });
+});
+
+describe('пути', () => {
+ it('объекты: «Пятно / Раздел», сортировка по пути', () => {
+ const paths = buildObjectPaths([
+ { Id: 2, Title: 'Раздел', parentId: 1 },
+ { Id: 1, Title: 'Пятно' },
+ { Id: 3, Title: 'Арка' }
+ ]);
+ expect(paths.map((p) => p.fullPath)).toEqual(['Арка', 'Пятно', 'Пятно / Раздел']);
+ });
+
+ it('работы: путь через родителей, цикл не зацикливает', () => {
+ const paths = buildJobPaths([
+ { cree9_tableid: 'a', cree9_title: 'Фундамент', cree9_unit: '', cree9_objectid: '41' },
+ { cree9_tableid: 'b', cree9_title: 'Бетон', cree9_unit: 'м3', cree9_objectid: '41', _cree9_parentid_value: 'a' },
+ { cree9_tableid: 'x', cree9_title: 'X', cree9_unit: '', cree9_objectid: '41', _cree9_parentid_value: 'y' },
+ { cree9_tableid: 'y', cree9_title: 'Y', cree9_unit: '', cree9_objectid: '41', _cree9_parentid_value: 'x' }
+ ]);
+ expect(paths.filter((p) => p.cree9_tableid === 'b')[0].fullPath).toEqual('Фундамент / Бетон');
+ expect(paths.filter((p) => p.cree9_tableid === 'x')[0].fullPath).toEqual('Y / X');
+ });
+
+ it('путь новой работы — от найденного родителя', () => {
+ const jobs = buildJobPaths([{ cree9_tableid: 'a', cree9_title: 'Фундамент', cree9_unit: '', cree9_objectid: '41' }]);
+ expect(newJobPath('Бетон', 'a', jobs)).toEqual('Фундамент / Бетон');
+ expect(newJobPath('Бетон', 'нет', jobs)).toEqual('Бетон');
+ expect(newJobPath('Бетон', undefined, jobs)).toEqual('Бетон');
+ });
+
+ it('техника: по группе, затем по названию', () => {
+ const sorted = sortTechnics([
+ { Id: 1, Title: 'Экскаватор', group: 'Земляные' },
+ { Id: 2, Title: 'Автокран', group: 'Краны' },
+ { Id: 3, Title: 'Бульдозер', group: 'Земляные' }
+ ]);
+ expect(sorted.map((t) => t.Title)).toEqual(['Бульдозер', 'Экскаватор', 'Автокран']);
+ });
+});
diff --git a/src/webparts/stroyBot/pages/report/reportTestData.ts b/src/webparts/stroyBot/pages/report/reportTestData.ts
new file mode 100644
index 0000000..9c2b199
--- /dev/null
+++ b/src/webparts/stroyBot/pages/report/reportTestData.ts
@@ -0,0 +1,56 @@
+import { IReportAccess, IReportFormState, IResourceItem, IWorkItem } from './reportTypes';
+
+// Заготовки данных для тестов формы отчёта (используются только в *.test.ts).
+
+export const access = (overrides: Partial = {}): IReportAccess => ({
+ id: 1,
+ userId: 42,
+ project: '5',
+ read: false,
+ edit: false,
+ aproove: false,
+ ...overrides
+});
+
+export const resource = (overrides: Partial = {}): IResourceItem => ({
+ id: 'r1',
+ resourceId: 7,
+ title: 'Сварщик',
+ count: 2,
+ hours: 8,
+ hoursType: 'Часы работы',
+ comment: '',
+ ...overrides
+});
+
+export const workItem = (overrides: Partial = {}): IWorkItem => ({
+ id: 'w1',
+ jobId: 'job-1',
+ jobTitle: 'Бетон',
+ jobUnit: 'м3',
+ value: 3.5,
+ plan: 10,
+ totalFact: 4,
+ comment: '',
+ professions: [],
+ technics: [],
+ ...overrides
+});
+
+export const reportForm = (overrides: Partial = {}): IReportFormState => ({
+ objectId: 41,
+ objectPath: 'Пятно / Раздел',
+ companyId: 3,
+ companyName: 'ТОО Строй',
+ shiftId: 1,
+ shiftName: 'Дневная',
+ reportDate: '2026-10-16',
+ reportNumber: 0,
+ comment: '',
+ works: [],
+ professions: [],
+ technics: [],
+ photos: [],
+ photoFolderUrl: undefined,
+ ...overrides
+});
diff --git a/src/webparts/stroyBot/pages/report/reportValidation.test.ts b/src/webparts/stroyBot/pages/report/reportValidation.test.ts
new file mode 100644
index 0000000..771d372
--- /dev/null
+++ b/src/webparts/stroyBot/pages/report/reportValidation.test.ts
@@ -0,0 +1,43 @@
+import { validateReportForm } from './reportValidation';
+import { reportForm, resource, workItem } from './reportTestData';
+
+describe('validateReportForm (тексты и порядок как в старом validate)', () => {
+ it('пустая форма — все обязательные поля по порядку', () => {
+ expect(
+ validateReportForm(reportForm({ objectId: undefined, companyId: undefined, shiftId: undefined, reportDate: '' }))
+ ).toEqual([
+ 'Выберите объект',
+ 'Выберите компанию',
+ 'Выберите смену',
+ 'Укажите дату отчета',
+ 'Добавьте хотя бы одну работу, профессию или технику'
+ ]);
+ });
+
+ it('достаточно одной работы', () => {
+ expect(validateReportForm(reportForm({ works: [workItem()] }))).toEqual([]);
+ });
+
+ it('простой персонала без комментария', () => {
+ const form = reportForm({ professions: [resource({ hoursType: 'Часы простоя', comment: ' ' })] });
+ expect(validateReportForm(form)).toEqual(['Персонал #1 (Сварщик): укажите комментарий для типа "Часы простоя"']);
+ });
+
+ it('простой и ремонт техники без комментария, номер — позиция в списке', () => {
+ const form = reportForm({
+ technics: [
+ resource({ title: 'Кран', hoursType: 'Часы работы' }),
+ resource({ title: 'Экскаватор', hoursType: 'Часы ремонта' }),
+ resource({ title: 'Бульдозер', hoursType: 'Часы простоя', comment: 'ждали бетон' })
+ ]
+ });
+ expect(validateReportForm(form)).toEqual(['Техника #2 (Экскаватор): укажите комментарий для типа "Часы ремонта"']);
+ });
+
+ it('персонал и техника внутри работы не проверяются (у них нет типа часов)', () => {
+ const form = reportForm({
+ works: [workItem({ professions: [resource({ hoursType: 'Часы простоя' })] })]
+ });
+ expect(validateReportForm(form)).toEqual([]);
+ });
+});
diff --git a/src/webparts/stroyBot/pages/report/sharePointReportRepository.test.ts b/src/webparts/stroyBot/pages/report/sharePointReportRepository.test.ts
new file mode 100644
index 0000000..cbab647
--- /dev/null
+++ b/src/webparts/stroyBot/pages/report/sharePointReportRepository.test.ts
@@ -0,0 +1,106 @@
+import {
+ createSharePointReportRepository,
+ fileAddUrl,
+ itemsQueryUrl,
+ photoFolderServerRelativeUrl,
+ pickAccess
+} from './sharePointReportRepository';
+import { createFakeHttpClient, IFakeRoute } from '../../shared/testing/fakeHttpClient';
+
+const SITE = 'https://tnsukz.sharepoint.com/sites/stroy';
+
+const repositoryWith = (routes: IFakeRoute[]): {
+ client: ReturnType;
+ repository: ReturnType;
+} => {
+ const client = createFakeHttpClient(routes);
+ const repository = createSharePointReportRepository({
+ client,
+ siteUrl: SITE,
+ userId: 42,
+ webServerRelativeUrl: '/sites/stroy/'
+ });
+ return { client, repository };
+};
+
+describe('адреса SharePoint (1:1 со stroy-bot-report)', () => {
+ it('$top=5000 первым, затем $filter и $select', () => {
+ expect(itemsQueryUrl(SITE, 'Объекты', { filter: "project eq 'Сункар'", select: 'Id,Title,parentId' })).toEqual(
+ `${SITE}/_api/web/lists/getbytitle('Объекты')/items?$top=5000&$filter=project eq 'Сункар'&$select=Id,Title,parentId`
+ );
+ });
+
+ it('папка фото DocLib/{projectId}_{номер}, апостроф в имени файла удваивается', () => {
+ const folder = photoFolderServerRelativeUrl('/sites/stroy/', 5, 8);
+ expect(folder).toEqual('/sites/stroy/DocLib/5_8');
+ expect(fileAddUrl(SITE, folder, "it's.jpg")).toEqual(
+ `${SITE}/_api/web/GetFolderByServerRelativeUrl('/sites/stroy/DocLib/5_8')/Files/add(url='it''s.jpg',overwrite=true)`
+ );
+ });
+});
+
+describe('pickAccess', () => {
+ const row = { Id: 1, projectId: 5, read: true, edit0: false, aproove: false };
+
+ it('поле пользователя ищется под разными именами', () => {
+ expect(pickAccess([{ ...row, userIdId: 42 }], 42)?.read).toBe(true);
+ expect(pickAccess([{ ...row, UserId: 42 }], 42)?.read).toBe(true);
+ expect(pickAccess([{ ...row, userId: 7 }], 42)).toBeUndefined();
+ });
+
+ it('edit0 → edit', () => {
+ expect(pickAccess([{ ...row, userId: 42, edit0: true }], 42)).toEqual({
+ id: 1,
+ userId: 42,
+ project: '5',
+ read: true,
+ edit: true,
+ aproove: false
+ });
+ });
+});
+
+describe('справочники «по проекту»', () => {
+ const allCompanies = { match: "getbytitle('Компании')", response: { body: { value: [{ Id: 1, Title: 'А' }, { Id: 2, Title: 'Б' }] } } };
+
+ it('есть привязки — только привязанные компании', async () => {
+ const { repository } = repositoryWith([
+ { match: 'Компании по проекту', response: { body: { value: [{ companyIdId: 2 }] } } },
+ allCompanies
+ ]);
+ expect((await repository.getCompaniesWithFallback(5)).map((c) => c.Id)).toEqual([2]);
+ });
+
+ it('привязок нет или список недоступен — все компании', async () => {
+ const empty = repositoryWith([{ match: 'Компании по проекту', response: { body: { value: [] } } }, allCompanies]);
+ const broken = repositoryWith([{ match: 'Компании по проекту', response: { status: 404 } }, allCompanies]);
+ expect(await empty.repository.getCompaniesWithFallback(5)).toHaveLength(2);
+ expect(await broken.repository.getCompaniesWithFallback(5)).toHaveLength(2);
+ });
+});
+
+describe('uploadPhotos', () => {
+ const photo = { name: 'a.jpg', preview: '', buffer: new ArrayBuffer(4) };
+
+ it('папки нет — создаётся, затем файлы; возвращается ссылка на папку', async () => {
+ const { client, repository } = repositoryWith([
+ { match: '/Files/add', response: { status: 200 } },
+ { match: 'GetFolderByServerRelativeUrl', response: { status: 404 } },
+ { match: '/_api/web/folders', response: { status: 201 } }
+ ]);
+
+ expect(await repository.uploadPhotos(5, 8, [photo])).toEqual(`${SITE}/DocLib/5_8`);
+ expect(client.requests.map((r) => r.request.method ?? 'GET')).toEqual(['GET', 'POST', 'POST']);
+ expect(JSON.parse(String(client.requests[1].request.body))).toEqual({ ServerRelativeUrl: '/sites/stroy/DocLib/5_8' });
+ expect(client.requests[2].request.headers).toEqual(expect.objectContaining({ 'Content-Type': 'application/octet-stream' }));
+ });
+
+ it('папка уже есть — не создаётся', async () => {
+ const { client, repository } = repositoryWith([
+ { match: '/Files/add', response: { status: 200 } },
+ { match: 'GetFolderByServerRelativeUrl', response: { status: 200 } }
+ ]);
+ await repository.uploadPhotos(5, 8, [photo]);
+ expect(client.requests.some((r) => r.url.endsWith('/_api/web/folders'))).toBe(false);
+ });
+});
diff --git a/src/webparts/stroyBot/shared/testing/fakeHttpClient.ts b/src/webparts/stroyBot/shared/testing/fakeHttpClient.ts
index 6fb67d0..d5857b4 100644
--- a/src/webparts/stroyBot/shared/testing/fakeHttpClient.ts
+++ b/src/webparts/stroyBot/shared/testing/fakeHttpClient.ts
@@ -13,10 +13,15 @@ export interface IFakeResponse {
body?: unknown;
}
-/** Правило ответа: если URL содержит `match` — вернуть `response`. Проверяются по порядку. */
+/**
+ * Правило ответа: если URL содержит `match` — вернуть `response`. Правила проверяются по порядку.
+ * Вместо `response` можно передать `respond(callNumber)` — ответ, который зависит от номера вызова
+ * этого правила (1, 2, 3…), например чтобы каждая созданная строка получала свой id.
+ */
export interface IFakeRoute {
match: string;
- response: IFakeResponse;
+ response?: IFakeResponse;
+ respond?: (callNumber: number) => IFakeResponse;
}
export interface IFakeHttpClient extends IHttpClient {
@@ -37,6 +42,7 @@ function toResponse({ status = 200, body }: IFakeResponse): IHttpResponse {
export function createFakeHttpClient(routes: IFakeRoute[] = []): IFakeHttpClient {
const requests: IRecordedRequest[] = [];
+ const callCounts = new Map();
return {
requests,
fetch: async (url: string, request: IHttpRequest = {}): Promise => {
@@ -45,7 +51,9 @@ export function createFakeHttpClient(routes: IFakeRoute[] = []): IFakeHttpClient
if (!route) {
throw new Error(`fakeHttpClient: нет ответа для ${url}`);
}
- return toResponse(route.response);
+ const callNumber = (callCounts.get(route) ?? 0) + 1;
+ callCounts.set(route, callNumber);
+ return toResponse(route.respond ? route.respond(callNumber) : route.response ?? {});
}
};
}