feat: save works imported from Excel
After a clean check the page loads the existing works of each object and shows how many works will be created and updated. "Import" writes them with the same requests as the work dialog: a work with the same title on the object gets only its planned volume and unit updated, so loading the same file again does not duplicate works. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -192,6 +192,20 @@
|
||||
- [ ] ✍️ **T-RIM-07** То же → «Импортировать» → «Сохраняем отчёты… 1 из 2» → «Импорт завершён. Созданы отчёты: №… за 01.10.2026, №… за 02.10.2026» и ссылка на «Отчёты по проекту»; кнопки больше нет. (Проверено 2026-10-22: №5, №6.)
|
||||
- [ ] **T-RIM-08** Отчёт за 01.10 в форме: три работы «Арматура» (5, 3, 2); под первой техника «Бетонный насос» 1×11, под второй персонал «Бетонщик» 10×11; в «Технике» отдельно насос 1×3 «Часы простоя». За 02.10: «Арматура» 4, под ней «Бетонщик» 5×11.
|
||||
|
||||
## 9b. Импорт работ из Excel — `T-WIM` (новая фича)
|
||||
|
||||
> `POST /Import/StroyBot/Works?projectId=` **только проверяет** файл (как и импорт отчётов). Объекты должны уже быть в проекте — иначе ошибка «… не найден внутри …». В `ids` — id самого глубокого объекта и единицы; id работы сервер не возвращает.
|
||||
> Пишет фронт теми же `createWork` / `updateWork`, что окно работы. Работа с тем же названием на объекте (без учёта регистра и пробелов, на любом уровне иерархии) — **обновляются только объём и единица**; иначе — создаётся работа верхнего уровня. Две одноимённые на объекте или одна работа дважды в файле — ошибка, ничего не пишется. ⚠️ Только проект **51**.
|
||||
> Тестовый файл: `Downloads\ТЕСТ импорт работ - проект 51.xlsx` (Тестовый объект / Бетонирование: «Арматура» 120 м — есть, «Тестовая работа импорта» 50 м3 — новая).
|
||||
|
||||
- [ ] **T-WIM-01** Меню: «Импорт работ из Excel» после «Загрузить работы из сметы»; есть у роли **Е**, у **Р** (без edit0) — нет; в архивном проекте — нет; в завершённом — есть.
|
||||
- [ ] **T-WIM-02** Файл с несуществующим объектом → «С ошибками: 1», «Объект 2: «…» не найден внутри «…»», кнопки «Импортировать» нет.
|
||||
- [ ] **T-WIM-03** Тестовый файл → «Сверяем с работами объектов…» → «Будет создано работ: 1, обновлён … у существующих: 1», кнопка «Импортировать (работ: 2)».
|
||||
- [ ] ✍️ **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.)
|
||||
- [ ] **T-WIM-07** Одна работа объекта дважды в файле → «Строки … и …: работа «…» на одном объекте указана дважды», кнопки нет.
|
||||
|
||||
## 10. Адаптивность — `T-RSP`
|
||||
|
||||
> Как: F12 → кнопка «телефон/планшет» (Ctrl+Shift+M) → сверху выбрать ширину → F5. Только просмотр, ничего не сохранять.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import { Alert } from '@tnsu/ui-kit';
|
||||
import { RouteLink } from '../../app/routing/RouteLink';
|
||||
import { IWorksSaveResult } from './worksImportSave';
|
||||
import { WORKS_IMPORT_TEXTS } from './texts';
|
||||
import styles from './WorksImportPage.module.scss';
|
||||
|
||||
const TEXTS = WORKS_IMPORT_TEXTS.import;
|
||||
|
||||
interface Props {
|
||||
result: IWorksSaveResult;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/** Итог записи работ: сколько создано и обновлено; при ошибке — на какой строке остановились. */
|
||||
export const WorksImportOutcome: React.FC<Props> = ({ result, projectId }) => {
|
||||
const objectsLink = (
|
||||
<RouteLink className={styles.link} to={{ page: 'objects', projectId }}>
|
||||
{TEXTS.openObjects}
|
||||
</RouteLink>
|
||||
);
|
||||
|
||||
if (!result.errorMessage) {
|
||||
return (
|
||||
<Alert variant="ok">
|
||||
{TEXTS.success(result.createdCount, result.updatedCount)} {objectsLink}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="err">
|
||||
<p className={styles.outcomeLine}>{TEXTS.failed(result.failedRow ?? 0, result.errorMessage)}</p>
|
||||
<p className={styles.outcomeLine}>{TEXTS.partlySaved(result.createdCount, result.updatedCount)}</p>
|
||||
{objectsLink}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -12,3 +12,24 @@
|
||||
font-size: $fs-sm;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.importBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-3;
|
||||
}
|
||||
|
||||
.plan {
|
||||
margin: 0;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
.outcomeLine {
|
||||
margin: 0 0 $sp-2;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -5,16 +5,20 @@ import { isSectionAvailable } from '../../app/project/projectRules';
|
||||
import { TreePageHeader } from '../_tree/TreePageHeader';
|
||||
import { ExcelImportSection } from '../_excelImport/ExcelImportSection';
|
||||
import { worksImportUrl } from './worksImportRepository';
|
||||
import { useWorksImport } from './useWorksImport';
|
||||
import { WorksImportStep } from './WorksImportStep';
|
||||
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 importer = useWorksImport();
|
||||
|
||||
const renderContent = (): React.ReactNode => {
|
||||
if (!currentProject) return <Alert variant="info">{WORKS_IMPORT_TEXTS.noProject}</Alert>;
|
||||
@@ -27,9 +31,18 @@ export const WorksImportPage: React.FC = () => {
|
||||
<ExcelImportSection
|
||||
projectId={currentProject.id}
|
||||
checkUrl={worksImportUrl}
|
||||
isLocked={false}
|
||||
onFileChange={() => undefined}
|
||||
renderImport={() => undefined}
|
||||
isLocked={importer.status.kind === 'saving'}
|
||||
onFileChange={importer.reset}
|
||||
renderImport={(check) => (
|
||||
<WorksImportStep
|
||||
check={check}
|
||||
projectId={String(currentProject.id)}
|
||||
projectTitle={currentProject.title}
|
||||
status={importer.status}
|
||||
onPrepare={importer.prepare}
|
||||
onImport={importer.importWorks}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from 'react';
|
||||
import { Alert, Button, confirmModal, Spinner } from '@tnsu/ui-kit';
|
||||
import { IExcelImportCheck } from '../_excelImport/excelImportTypes';
|
||||
import { IWorksImportPlan } from './worksImportBuilder';
|
||||
import { WorksImportStatus } from './useWorksImport';
|
||||
import { WorksImportOutcome } from './WorksImportOutcome';
|
||||
import { WORKS_IMPORT_TEXTS } from './texts';
|
||||
import styles from './WorksImportPage.module.scss';
|
||||
|
||||
const TEXTS = WORKS_IMPORT_TEXTS.import;
|
||||
|
||||
interface Props {
|
||||
check: IExcelImportCheck;
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
status: WorksImportStatus;
|
||||
onPrepare: (check: IExcelImportCheck) => Promise<void>;
|
||||
onImport: (plan: IWorksImportPlan) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Импорт проверенного файла без ошибок. План (что создать, что обновить) готовится сразу —
|
||||
* пользователь видит его до нажатия «Импортировать».
|
||||
*/
|
||||
export const WorksImportStep: React.FC<Props> = ({ check, projectId, projectTitle, status, onPrepare, onImport }) => {
|
||||
// Сайд-эффект: загрузка существующих работ объектов для нового файла.
|
||||
React.useEffect(() => {
|
||||
onPrepare(check).catch(() => undefined);
|
||||
}, [check, onPrepare]);
|
||||
|
||||
if (status.kind === 'prepareError') return <Alert variant="err">{status.message}</Alert>;
|
||||
if (status.kind === 'saving') return <Spinner size="small" label={TEXTS.saving(status.savedCount, status.total)} />;
|
||||
if (status.kind === 'finished') return <WorksImportOutcome result={status.result} projectId={projectId} />;
|
||||
if (status.kind !== 'ready') return <Spinner size="small" label={TEXTS.preparing} />;
|
||||
|
||||
const { plan } = status;
|
||||
const startImport = async (): Promise<void> => {
|
||||
const isConfirmed = await confirmModal({
|
||||
title: TEXTS.confirmTitle,
|
||||
message: TEXTS.confirmMessage(plan.toCreate.length, plan.toUpdate.length, projectTitle),
|
||||
confirmText: TEXTS.confirm,
|
||||
cancelText: TEXTS.cancel
|
||||
});
|
||||
if (isConfirmed) await onImport(plan);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.importBlock}>
|
||||
<p className={styles.plan}>{TEXTS.plan(plan.toCreate.length, plan.toUpdate.length)}</p>
|
||||
<div>
|
||||
<Button variant="accent" onClick={() => startImport().catch(() => undefined)}>
|
||||
{TEXTS.button(plan.toCreate.length + plan.toUpdate.length)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,26 @@ export const WORKS_IMPORT_TEXTS = {
|
||||
noAccess: 'Импорт работ недоступен: нужно право редактирования, проект не должен быть архивным.',
|
||||
intro:
|
||||
'Загрузите форму плановых объёмов (.xlsx): объекты, вид работ, единица и плановый объём — файл сразу проверится. ' +
|
||||
'Ошибки подсветятся в таблице.',
|
||||
'Объекты должны уже быть в проекте. Если такая работа на объекте уже есть, у неё обновятся объём и единица.',
|
||||
import: {
|
||||
preparing: 'Сверяем с работами объектов…',
|
||||
plan: (createCount: number, updateCount: number): string =>
|
||||
`Будет создано работ: ${createCount}, обновлён плановый объём и единица у существующих: ${updateCount}.`,
|
||||
button: (total: number): string => `Импортировать (работ: ${total})`,
|
||||
saving: (savedCount: number, total: number): string => `Сохраняем работы… ${savedCount} из ${total}`,
|
||||
confirmTitle: 'Импорт работ',
|
||||
confirmMessage: (createCount: number, updateCount: number, projectTitle: string): string =>
|
||||
`В проекте «${projectTitle}» будет создано работ: ${createCount}, обновлено: ${updateCount}. Продолжить?`,
|
||||
confirm: 'Импортировать',
|
||||
cancel: 'Отмена',
|
||||
success: (createCount: number, updateCount: number): string =>
|
||||
`Импорт завершён: создано работ — ${createCount}, обновлено — ${updateCount}.`,
|
||||
failed: (row: number, details: string): string => `Не удалось записать работу из строки ${row}: ${details}`,
|
||||
partlySaved: (createCount: number, updateCount: number): string =>
|
||||
`До ошибки записано: создано — ${createCount}, обновлено — ${updateCount}. ` +
|
||||
'Файл можно загрузить снова: уже записанные работы обновятся, а не задвоятся.',
|
||||
openObjects: 'Открыть «Объекты»'
|
||||
},
|
||||
build: {
|
||||
missingFields: (row: number): string => `Строка ${row}: не определены объект, вид работ, единица или плановый объём.`,
|
||||
duplicateInFile: (firstRow: number, row: number, title: string): string =>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as React from 'react';
|
||||
import { useAppServices } from '../../app/AppServicesContext';
|
||||
import { createWorksRepository, IWorksRepository } from '../works/worksRepository';
|
||||
import { IWork } from '../works/worksTypes';
|
||||
import { IExcelImportCheck } from '../_excelImport/excelImportTypes';
|
||||
import { buildImportedWorks, IWorksImportPlan, objectIdsOf, planWorksImport } from './worksImportBuilder';
|
||||
import { IWorksSaveResult, saveImportedWorks } from './worksImportSave';
|
||||
|
||||
export type WorksImportStatus =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'preparing' }
|
||||
| { kind: 'prepareError'; message: string }
|
||||
| { kind: 'ready'; plan: IWorksImportPlan }
|
||||
| { kind: 'saving'; savedCount: number; total: number }
|
||||
| { kind: 'finished'; result: IWorksSaveResult };
|
||||
|
||||
const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
/** План: работы файла сравниваются с уже существующими работами их объектов. */
|
||||
async function preparePlan(repository: IWorksRepository, check: IExcelImportCheck): Promise<IWorksImportPlan> {
|
||||
const works = buildImportedWorks(check);
|
||||
const existingByObject = new Map<string, IWork[]>();
|
||||
for (const objectId of objectIdsOf(works)) {
|
||||
existingByObject.set(objectId, await repository.loadWorks(objectId));
|
||||
}
|
||||
return planWorksImport(works, existingByObject);
|
||||
}
|
||||
|
||||
/** Импорт проверенного файла работ: подготовка плана (создать / обновить) и запись. */
|
||||
export function useWorksImport(): {
|
||||
status: WorksImportStatus;
|
||||
prepare: (check: IExcelImportCheck) => Promise<void>;
|
||||
importWorks: (plan: IWorksImportPlan) => Promise<void>;
|
||||
reset: () => void;
|
||||
} {
|
||||
const services = useAppServices();
|
||||
const [status, setStatus] = React.useState<WorksImportStatus>({ kind: 'idle' });
|
||||
const isMountedRef = React.useRef<boolean>(true);
|
||||
const repository = React.useMemo(
|
||||
() =>
|
||||
createWorksRepository({
|
||||
sharePoint: services.sharePoint,
|
||||
dataverse: services.dataverse,
|
||||
siteUrl: services.settings.siteUrl,
|
||||
dataverseUrl: services.settings.dataverseUrl
|
||||
}),
|
||||
[services]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
isMountedRef.current = false;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const update = React.useCallback((next: WorksImportStatus): void => {
|
||||
if (isMountedRef.current) setStatus(next);
|
||||
}, []);
|
||||
|
||||
const prepare = React.useCallback(
|
||||
async (check: IExcelImportCheck): Promise<void> => {
|
||||
update({ kind: 'preparing' });
|
||||
try {
|
||||
update({ kind: 'ready', plan: await preparePlan(repository, check) });
|
||||
} catch (error) {
|
||||
console.error('Ошибка подготовки импорта работ:', error);
|
||||
update({ kind: 'prepareError', message: errorText(error) });
|
||||
}
|
||||
},
|
||||
[repository, update]
|
||||
);
|
||||
|
||||
const importWorks = async (plan: IWorksImportPlan): Promise<void> => {
|
||||
const total = plan.toCreate.length + plan.toUpdate.length;
|
||||
update({ kind: 'saving', savedCount: 0, total });
|
||||
const result = await saveImportedWorks(repository, plan, (savedCount) => update({ kind: 'saving', savedCount, total }));
|
||||
update({ kind: 'finished', result });
|
||||
};
|
||||
|
||||
const reset = React.useCallback((): void => setStatus({ kind: 'idle' }), []);
|
||||
|
||||
return { status, prepare, importWorks, reset };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ImportWorksWriter, saveImportedWorks } from './worksImportSave';
|
||||
import { IImportedWork, IWorksImportPlan } from './worksImportBuilder';
|
||||
|
||||
interface IWrite {
|
||||
method: 'create' | 'update';
|
||||
id?: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Writer, который запоминает запросы. failOnCall — номер вызова (с 1), на котором бросить ошибку. */
|
||||
function makeWriter(failOnCall?: number): ImportWorksWriter & { writes: IWrite[] } {
|
||||
const writes: IWrite[] = [];
|
||||
const record = async (write: IWrite): Promise<void> => {
|
||||
if (writes.length + 1 === failOnCall) throw new Error('Dataverse недоступен');
|
||||
writes.push(write);
|
||||
};
|
||||
return {
|
||||
writes,
|
||||
createWork: (body) => record({ method: 'create', body }),
|
||||
updateWork: (id, body) => record({ method: 'update', id, body })
|
||||
};
|
||||
}
|
||||
|
||||
const work = (rowNumber: number, title: string): IImportedWork => ({
|
||||
rowNumber,
|
||||
objectId: '1517',
|
||||
title,
|
||||
unit: 'м',
|
||||
volume: 100
|
||||
});
|
||||
|
||||
// Как в тестовом файле: строка 2 — «Арматура» уже есть, строка 3 — новая работа.
|
||||
const plan: IWorksImportPlan = {
|
||||
toCreate: [work(3, 'Тестовая работа импорта')],
|
||||
toUpdate: [{ ...work(2, 'Арматура'), workId: 'guid-armature' }]
|
||||
};
|
||||
|
||||
describe('запись импортированных работ', () => {
|
||||
it('в порядке строк файла: обновление — только объём и единица, создание — с объектом', async () => {
|
||||
const writer = makeWriter();
|
||||
const progress: number[] = [];
|
||||
|
||||
const result = await saveImportedWorks(writer, plan, (count) => progress.push(count));
|
||||
|
||||
expect(result).toEqual({ createdCount: 1, updatedCount: 1 });
|
||||
expect(writer.writes).toEqual([
|
||||
{ method: 'update', id: 'guid-armature', body: { cree9_unit: 'м', cree9_volume: 100 } },
|
||||
{
|
||||
method: 'create',
|
||||
body: { cree9_title: 'Тестовая работа импорта', cree9_unit: 'м', cree9_volume: 100, cree9_objectid: '1517' }
|
||||
}
|
||||
]);
|
||||
expect(progress).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('ошибка останавливает запись, итог — что успели и на какой строке упали', async () => {
|
||||
const writer = makeWriter(2);
|
||||
|
||||
const result = await saveImportedWorks(writer, plan, () => undefined);
|
||||
|
||||
expect(result).toEqual({ createdCount: 0, updatedCount: 1, errorMessage: 'Dataverse недоступен', failedRow: 3 });
|
||||
expect(writer.writes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { IWorksRepository, WORK_COLUMNS } from '../works/worksRepository';
|
||||
import { IImportedWork, IWorksImportPlan, IWorkUpdate } from './worksImportBuilder';
|
||||
|
||||
/** Нужная импорту часть репозитория раздела «Работы» — те же запросы, что у окна работы. */
|
||||
export type ImportWorksWriter = Pick<IWorksRepository, 'createWork' | 'updateWork'>;
|
||||
|
||||
export interface IWorksSaveResult {
|
||||
createdCount: number;
|
||||
updatedCount: number;
|
||||
/** Есть — запись остановилась на строке failedRow. */
|
||||
errorMessage?: string;
|
||||
failedRow?: number;
|
||||
}
|
||||
|
||||
/** Новая работа — верхнего уровня объекта; даты не задаём (в файле их нет). */
|
||||
export function createWorkBody(work: IImportedWork): Record<string, unknown> {
|
||||
return {
|
||||
[WORK_COLUMNS.title]: work.title,
|
||||
[WORK_COLUMNS.unit]: work.unit,
|
||||
[WORK_COLUMNS.volume]: work.volume,
|
||||
[WORK_COLUMNS.objectId]: work.objectId
|
||||
};
|
||||
}
|
||||
|
||||
/** У существующей работы меняем только план: название, родителя и даты не трогаем. */
|
||||
export function updateWorkBody(work: IWorkUpdate): Record<string, unknown> {
|
||||
return { [WORK_COLUMNS.unit]: work.unit, [WORK_COLUMNS.volume]: work.volume };
|
||||
}
|
||||
|
||||
type PlannedWrite = { kind: 'create'; work: IImportedWork } | { kind: 'update'; work: IWorkUpdate };
|
||||
|
||||
/** Порядок записи — как строки в файле, чтобы прогресс и ошибка были понятны пользователю. */
|
||||
function writesInFileOrder(plan: IWorksImportPlan): PlannedWrite[] {
|
||||
const writes: PlannedWrite[] = [
|
||||
...plan.toCreate.map((work): PlannedWrite => ({ kind: 'create', work })),
|
||||
...plan.toUpdate.map((work): PlannedWrite => ({ kind: 'update', work }))
|
||||
];
|
||||
return writes.sort((a, b) => a.work.rowNumber - b.work.rowNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Записывает работы по одной. Ошибка останавливает запись; уже записанное остаётся.
|
||||
* Повторная загрузка того же файла безопасна: созданные работы найдутся по названию и обновятся.
|
||||
*/
|
||||
export async function saveImportedWorks(
|
||||
writer: ImportWorksWriter,
|
||||
plan: IWorksImportPlan,
|
||||
onProgress: (savedCount: number) => void
|
||||
): Promise<IWorksSaveResult> {
|
||||
const result: IWorksSaveResult = { createdCount: 0, updatedCount: 0 };
|
||||
for (const write of writesInFileOrder(plan)) {
|
||||
try {
|
||||
if (write.kind === 'create') {
|
||||
await writer.createWork(createWorkBody(write.work));
|
||||
result.createdCount++;
|
||||
} else {
|
||||
await writer.updateWork(write.work.workId, updateWorkBody(write.work));
|
||||
result.updatedCount++;
|
||||
}
|
||||
onProgress(result.createdCount + result.updatedCount);
|
||||
} catch (error) {
|
||||
console.error('Ошибка записи импортированной работы:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { ...result, errorMessage, failedRow: write.work.rowNumber };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user