Import large activity Excel in the browser via JSON chunks to avoid 502.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -19,12 +19,15 @@ import {
|
||||
import {
|
||||
beginExcelImportPrepare,
|
||||
chunkImportMeta,
|
||||
createExcelImportCacheSession,
|
||||
destroyExcelImportSession,
|
||||
getExcelImportPrepareStatus,
|
||||
getExcelImportSessionCaches,
|
||||
resolveExcelImportChunk,
|
||||
type ExcelImportLookupCaches,
|
||||
type ExcelImportSessionStatus,
|
||||
} from '../common/excel-import-session';
|
||||
import type { ImportActivityRowsDto } from './dto';
|
||||
|
||||
const ACTIVITY_COLUMNS = [
|
||||
{ header: '_type', key: '_type', width: 12 },
|
||||
@@ -239,6 +242,71 @@ export class ActivitiesExcelService {
|
||||
}
|
||||
|
||||
const caches = getExcelImportSessionCaches(ctx.sessionId, actor.organizationId);
|
||||
await this.processActivityRows(
|
||||
actor,
|
||||
ctx.slice,
|
||||
ctx.offset,
|
||||
result,
|
||||
caches,
|
||||
{ skipDuplicateCheck: false },
|
||||
);
|
||||
|
||||
return { ...result, ...chunkImportMeta(ctx) };
|
||||
}
|
||||
|
||||
/**
|
||||
* ایمپورت تکهای JSON از مرورگر — فایل اکسل روی سرور آپلود/پارس نمیشود.
|
||||
*/
|
||||
async importRows(
|
||||
actor: Actor,
|
||||
dto: ImportActivityRowsDto,
|
||||
): Promise<ImportContactsResult> {
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
const rows = Array.isArray(dto.rows) ? dto.rows : [];
|
||||
if (!rows.length) {
|
||||
result.errors.push({ row: 1, message: 'ردیف ارسالشده خالی است' });
|
||||
return result;
|
||||
}
|
||||
if (rows.length > 80) {
|
||||
result.errors.push({ row: 1, message: 'حداکثر ۸۰ ردیف در هر درخواست' });
|
||||
return result;
|
||||
}
|
||||
|
||||
const total = Math.max(0, Number(dto.total) || 0);
|
||||
const offset = Math.max(0, Number(dto.offset) || 0);
|
||||
let sessionId = (dto.sessionId ?? '').trim();
|
||||
if (!sessionId) {
|
||||
sessionId = createExcelImportCacheSession(actor.organizationId);
|
||||
}
|
||||
|
||||
const caches = getExcelImportSessionCaches(sessionId, actor.organizationId);
|
||||
await this.processActivityRows(actor, rows, offset, result, caches, {
|
||||
// برای فایلهای خیلی بزرگ، بررسی تکراری هر ردیف درخواست را طولانی و ۵۰۲ میکند
|
||||
skipDuplicateCheck: total > 2000,
|
||||
});
|
||||
|
||||
const processed = offset + rows.length;
|
||||
const hasMore = total > 0 ? processed < total : false;
|
||||
if (!hasMore) destroyExcelImportSession(sessionId);
|
||||
|
||||
return {
|
||||
...result,
|
||||
sessionId: hasMore ? sessionId : undefined,
|
||||
total,
|
||||
processed,
|
||||
hasMore,
|
||||
nextOffset: hasMore ? processed : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async processActivityRows(
|
||||
actor: Actor,
|
||||
slice: Array<Record<string, string>>,
|
||||
offset: number,
|
||||
result: ImportContactsResult,
|
||||
caches: ExcelImportLookupCaches,
|
||||
opts: { skipDuplicateCheck: boolean },
|
||||
) {
|
||||
const resolveUser = async (name?: string) => {
|
||||
const key = (name ?? '').trim().toLowerCase();
|
||||
if (!key) return null;
|
||||
@@ -274,9 +342,9 @@ export class ActivitiesExcelService {
|
||||
return id;
|
||||
};
|
||||
|
||||
for (let i = 0; i < ctx.slice.length; i++) {
|
||||
const row = ctx.slice[i]!;
|
||||
const rowNum = ctx.offset + i + 2;
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
const row = slice[i]!;
|
||||
const rowNum = offset + i + 2;
|
||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'activity') {
|
||||
continue;
|
||||
}
|
||||
@@ -311,20 +379,20 @@ export class ActivitiesExcelService {
|
||||
const creatorId =
|
||||
(await resolveUser(row.creator)) ?? actor.userId;
|
||||
|
||||
const duplicate = dueAt
|
||||
? await this.prisma.activity.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
title,
|
||||
dueAt,
|
||||
personId: personId ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (duplicate) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
if (!opts.skipDuplicateCheck && dueAt) {
|
||||
const duplicate = await this.prisma.activity.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
title,
|
||||
dueAt,
|
||||
personId: personId ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const created = await this.prisma.activity.create({
|
||||
@@ -346,8 +414,7 @@ export class ActivitiesExcelService {
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
// M2M مسئولان — اگر مایگریشن روی سرور نباشد، ایمپورت نباید بترکد
|
||||
if (assigneeIds.length) {
|
||||
if (assigneeIds.length > 1) {
|
||||
try {
|
||||
await this.prisma.activity.update({
|
||||
where: { id: created.id },
|
||||
@@ -367,8 +434,6 @@ export class ActivitiesExcelService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ...result, ...chunkImportMeta(ctx) };
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ActivitiesService } from './activities.service';
|
||||
import { ActivitiesExcelService } from './activities-excel.service';
|
||||
import {
|
||||
CreateActivityDto,
|
||||
ImportActivityRowsDto,
|
||||
ListActivitiesQueryDto,
|
||||
UpdateActivityDto,
|
||||
} from './dto';
|
||||
@@ -121,6 +122,14 @@ export class ActivitiesController {
|
||||
return this.excelService.prepareStatus(user, sessionId);
|
||||
}
|
||||
|
||||
@Post('import/rows')
|
||||
importRows(
|
||||
@CurrentUser() user: Actor,
|
||||
@Body() dto: ImportActivityRowsDto,
|
||||
) {
|
||||
return this.excelService.importRows(user, dto);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(excelUpload)
|
||||
import(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
Allow,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
@@ -14,6 +15,27 @@ import {
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
/** تکههای JSON از ایمپورت مرورگرگرا */
|
||||
export class ImportActivityRowsDto {
|
||||
@IsArray()
|
||||
@Allow()
|
||||
rows!: Array<Record<string, string>>;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset!: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
total!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
const ACTIVITY_TYPES = ['call', 'meeting', 'task', 'note'] as const;
|
||||
const ACTIVITY_STATUSES = ['open', 'done', 'canceled'] as const;
|
||||
const ACTIVITY_PRIORITIES = ['very_low', 'low', 'medium', 'high', 'very_high'] as const;
|
||||
|
||||
@@ -102,6 +102,22 @@ export function createExcelImportSession(
|
||||
return id;
|
||||
}
|
||||
|
||||
/** فقط کش lookup برای ایمپورت JSON تکهای از مرورگر (بدون نگه داشتن کل فایل) */
|
||||
export function createExcelImportCacheSession(organizationId: string): string {
|
||||
purgeExpired();
|
||||
const id = randomBytes(16).toString('hex');
|
||||
sessions.set(id, {
|
||||
organizationId,
|
||||
rows: [],
|
||||
expiresAt: Date.now() + SESSION_TTL_MS,
|
||||
caches: emptyCaches(),
|
||||
status: 'ready',
|
||||
total: 0,
|
||||
parsed: 0,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* آپلود را فوری قبول میکند و پارس را در پسزمینه انجام میدهد
|
||||
* تا پروکسی nginx با ۵۰۲ قطع نکند.
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-echarts": "^8.3.0",
|
||||
"vue-router": "^4.5.0"
|
||||
"vue-router": "^4.5.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
|
||||
@@ -3,10 +3,12 @@ import { computed, reactive, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
ExcelFieldOption,
|
||||
ExcelImportPrepareStatus,
|
||||
ExcelPreviewResult,
|
||||
ImportContactsResult,
|
||||
} from '@mento/shared';
|
||||
import { previewExcelClient, readMappedRowsClient } from '@/lib/excel-client';
|
||||
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -14,9 +16,25 @@ const props = defineProps<{
|
||||
importPath: string;
|
||||
/** اگر باشد: ابتدا فقط فایل خوانده میشود، بعد تکهتکه وارد میشود (برای فایلهای خیلی بزرگ) */
|
||||
preparePath?: string;
|
||||
/**
|
||||
* ایمپورت مرورگرگرا: اکسل در مرورگر خوانده میشود و فقط JSON کوچک به API میرود.
|
||||
* برای فایلهای دهها هزار ردیفی (جلوگیری از ۵۰۲ nginx).
|
||||
*/
|
||||
rowsImportPath?: string;
|
||||
clientAliases?: Record<string, string>;
|
||||
clientFields?: ExcelFieldOption[];
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const useClientImport = computed(
|
||||
() =>
|
||||
Boolean(
|
||||
props.rowsImportPath &&
|
||||
props.clientAliases &&
|
||||
props.clientFields?.length,
|
||||
),
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
start: [];
|
||||
done: [result: ImportContactsResult];
|
||||
@@ -182,7 +200,62 @@ async function waitPrepareReady(sessionId: string) {
|
||||
throw new Error('آمادهسازی فایل بیش از حد طول کشید. دوباره تلاش کنید.');
|
||||
}
|
||||
|
||||
async function runClientRowsImport(file: File, map?: Record<string, string>) {
|
||||
if (!props.rowsImportPath || !props.clientAliases) {
|
||||
throw new Error('پیکربندی ایمپورت مرورگر ناقص است');
|
||||
}
|
||||
phase.value = 'import';
|
||||
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
|
||||
progressPct.value = 0;
|
||||
|
||||
const rows = await readMappedRowsClient(file, props.clientAliases, map);
|
||||
const total = rows.length;
|
||||
if (total > 100_000) {
|
||||
return {
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
errors: [{ row: 1, message: 'فایل بیش از ۱۰۰۰۰۰ ردیف دارد. فایل را تقسیم کنید.' }],
|
||||
total,
|
||||
processed: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
updateImportProgress(0, total);
|
||||
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
let sessionId: string | undefined;
|
||||
const chunkSize = 60;
|
||||
|
||||
for (let offset = 0; offset < total; offset += chunkSize) {
|
||||
const slice = rows.slice(offset, offset + chunkSize);
|
||||
const { data: part } = await api.post<ImportContactsResult>(
|
||||
props.rowsImportPath,
|
||||
{
|
||||
rows: slice,
|
||||
offset,
|
||||
total,
|
||||
sessionId,
|
||||
},
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
acc = mergeImportResults(acc, part);
|
||||
updateImportProgress(part.processed ?? offset + slice.length, total);
|
||||
if (part.errors?.some((e) => e.row === 1 && /حداکثر|بیش از/.test(e.message))) {
|
||||
break;
|
||||
}
|
||||
sessionId = part.sessionId;
|
||||
if (!part.hasMore) break;
|
||||
}
|
||||
|
||||
updateImportProgress(total, total);
|
||||
return acc;
|
||||
}
|
||||
|
||||
async function runChunkedImport(file: File, map?: Record<string, string>) {
|
||||
if (useClientImport.value) {
|
||||
return runClientRowsImport(file, map);
|
||||
}
|
||||
|
||||
phase.value = 'import';
|
||||
progressLabel.value = 'در حال ارسال فایل…';
|
||||
progressPct.value = 0;
|
||||
@@ -229,7 +302,6 @@ async function runChunkedImport(file: File, map?: Record<string, string>) {
|
||||
const processed = part.processed ?? 0;
|
||||
updateImportProgress(processed, total);
|
||||
|
||||
// خطای سراسری (مثلاً سقف ردیف) — ادامه نده
|
||||
if (
|
||||
part.errors?.some((e) => e.row === 1 && /بیش از .* ردیف/.test(e.message))
|
||||
) {
|
||||
@@ -293,12 +365,19 @@ async function onFile(ev: Event) {
|
||||
submitting.value = true;
|
||||
phase.value = 'preview';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data } = await api.post<ExcelPreviewResult>(props.previewPath, form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
timeout: 5 * 60 * 1000,
|
||||
});
|
||||
let data: ExcelPreviewResult;
|
||||
if (useClientImport.value && props.clientAliases && props.clientFields) {
|
||||
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
|
||||
data = await previewExcelClient(file, props.clientAliases, props.clientFields);
|
||||
} else {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await api.post<ExcelPreviewResult>(props.previewPath, form, {
|
||||
headers: { 'Content-Type': undefined },
|
||||
timeout: 5 * 60 * 1000,
|
||||
});
|
||||
data = res.data;
|
||||
}
|
||||
if (!data.needsMapping) {
|
||||
try {
|
||||
emit('done', await runChunkedImport(file));
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ExcelFieldOption } from '@mento/shared';
|
||||
|
||||
/** همتراز با aliases سمت API برای ایمپورت فعالیت */
|
||||
export const ACTIVITY_EXCEL_ALIASES: Record<string, string> = {
|
||||
_type: '_type',
|
||||
type: '_type',
|
||||
نوعفعالیت: 'kind',
|
||||
عنوانفعالیت: 'title',
|
||||
عنوان: 'title',
|
||||
متنفعالیت: 'body',
|
||||
نتیجهفعالیت: 'result',
|
||||
مسئولاجرافعالیت: 'assignee',
|
||||
ثبتکنندهفعالیت: 'creator',
|
||||
تاریخبرنامهریزیشدهاجرافعالیت: 'dueDate',
|
||||
زمانبرنامهریزیشدهاجرافعالیت: 'dueTime',
|
||||
وضعیتاجرایفعالیت: 'status',
|
||||
تاریخانجامشدنفعالیت: 'doneDate',
|
||||
زمانانجامشدنفعالیت: 'doneTime',
|
||||
کدمعاملهفعالیت: 'dealCode',
|
||||
کدکارتفعالیت: 'cardCode',
|
||||
اشخاصفعالیت: 'personName',
|
||||
کداشخاصفعالیت: 'personCode',
|
||||
موبایلاشخاصفعالیت: 'personMobile',
|
||||
شرکتهایفعالیت: 'companyName',
|
||||
کدشرکتهایفعالیت: 'companyCode',
|
||||
معاملهفعالیت1: 'dealTitle',
|
||||
کارتفعالیت1: 'cardTitle',
|
||||
هشتگ: 'hashtag',
|
||||
};
|
||||
|
||||
export const ACTIVITY_EXCEL_FIELDS: ExcelFieldOption[] = [
|
||||
{ key: 'title', label: 'عنوان فعالیت', required: true },
|
||||
{ key: 'kind', label: 'نوع فعالیت' },
|
||||
{ key: 'body', label: 'متن فعالیت' },
|
||||
{ key: 'result', label: 'نتیجه فعالیت' },
|
||||
{ key: 'assignee', label: 'مسئول اجرا فعالیت' },
|
||||
{ key: 'creator', label: 'ثبت کننده فعالیت' },
|
||||
{ key: 'dueDate', label: 'تاریخ برنامه ریزی شده اجرا فعالیت' },
|
||||
{ key: 'dueTime', label: 'زمان برنامه ریزی شده اجرا فعالیت' },
|
||||
{ key: 'status', label: 'وضعیت اجرای فعالیت' },
|
||||
{ key: 'doneDate', label: 'تاریخ انجام شدن فعالیت' },
|
||||
{ key: 'doneTime', label: 'زمان انجام شدن فعالیت' },
|
||||
{ key: 'personName', label: 'اشخاص فعالیت' },
|
||||
{ key: 'personMobile', label: 'موبایل اشخاص فعالیت' },
|
||||
{ key: 'companyName', label: 'شرکت های فعالیت' },
|
||||
{ key: 'dealTitle', label: 'معامله فعالیت_1' },
|
||||
{ key: 'hashtag', label: 'هشتگ' },
|
||||
];
|
||||
@@ -0,0 +1,138 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { ExcelFieldOption, ExcelPreviewResult } from '@mento/shared';
|
||||
|
||||
function normalizeHeader(raw: string) {
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[_-]+/g, '')
|
||||
.replace(//g, '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[()]/g, '');
|
||||
}
|
||||
|
||||
function cellToString(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value).trim();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? '' : value.toISOString();
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function resolveFieldKey(
|
||||
raw: string,
|
||||
aliases: Record<string, string>,
|
||||
mapping?: Record<string, string>,
|
||||
) {
|
||||
if (mapping) {
|
||||
return mapping[raw] || mapping[normalizeHeader(raw)] || '';
|
||||
}
|
||||
const compact = normalizeHeader(raw);
|
||||
return aliases[compact] ?? aliases[raw.trim()] ?? '';
|
||||
}
|
||||
|
||||
/** خواندن اکسل در مرورگر — بدون آپلود فایل سنگین به API */
|
||||
export async function previewExcelClient(
|
||||
file: File,
|
||||
aliases: Record<string, string>,
|
||||
fields: ExcelFieldOption[],
|
||||
): Promise<ExcelPreviewResult> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const wb = XLSX.read(buffer, { type: 'array', cellDates: true });
|
||||
const sheetName = wb.SheetNames[0];
|
||||
if (!sheetName) throw new Error('شیت اکسل پیدا نشد');
|
||||
const sheet = wb.Sheets[sheetName]!;
|
||||
const matrix = XLSX.utils.sheet_to_json<(string | number | boolean | Date | null)[]>(
|
||||
sheet,
|
||||
{ header: 1, defval: '', raw: false },
|
||||
) as unknown[][];
|
||||
|
||||
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
|
||||
const columns: ExcelPreviewResult['columns'] = [];
|
||||
const suggested: Record<string, string> = {};
|
||||
const used = new Set<string>();
|
||||
|
||||
headerRow.forEach((header, idx) => {
|
||||
if (!header) return;
|
||||
const samples: string[] = [];
|
||||
for (let r = 1; r < Math.min(matrix.length, 6); r += 1) {
|
||||
const value = cellToString(matrix[r]?.[idx]);
|
||||
if (value) samples.push(value);
|
||||
if (samples.length >= 3) break;
|
||||
}
|
||||
columns.push({ header, index: idx + 1, samples });
|
||||
const key = resolveFieldKey(header, aliases);
|
||||
if (key && !used.has(key)) {
|
||||
suggested[header] = key;
|
||||
used.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (!columns.length) throw new Error('ستون عنوان در ردیف اول فایل پیدا نشد');
|
||||
|
||||
let rowCount = 0;
|
||||
for (let r = 1; r < matrix.length; r += 1) {
|
||||
const row = matrix[r] ?? [];
|
||||
if (row.some((c) => cellToString(c))) rowCount += 1;
|
||||
}
|
||||
|
||||
const required = fields.filter((f) => f.required).map((f) => f.key);
|
||||
const mapped = new Set(Object.values(suggested));
|
||||
const missingRequired = required.some((key) => !mapped.has(key));
|
||||
const unrecognized = columns.some((col) => !suggested[col.header]);
|
||||
|
||||
return {
|
||||
columns,
|
||||
fields,
|
||||
suggested,
|
||||
needsMapping: unrecognized || missingRequired || !mapped.size,
|
||||
rowCount,
|
||||
largeFile: rowCount >= 80,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readMappedRowsClient(
|
||||
file: File,
|
||||
aliases: Record<string, string>,
|
||||
mapping?: Record<string, string>,
|
||||
): Promise<Record<string, string>[]> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const wb = XLSX.read(buffer, { type: 'array', cellDates: true });
|
||||
const sheetName = wb.SheetNames[0];
|
||||
if (!sheetName) throw new Error('شیت اکسل پیدا نشد');
|
||||
const sheet = wb.Sheets[sheetName]!;
|
||||
const matrix = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
|
||||
header: 1,
|
||||
defval: '',
|
||||
raw: false,
|
||||
}) as unknown[][];
|
||||
|
||||
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
|
||||
const colMap = new Map<number, string>();
|
||||
headerRow.forEach((header, idx) => {
|
||||
if (!header) return;
|
||||
const key = resolveFieldKey(header, aliases, mapping);
|
||||
if (key) colMap.set(idx, key);
|
||||
});
|
||||
if (!colMap.size) {
|
||||
throw new Error('ستونهای فایل شناسایی نشد. ستونهای فایل را با فیلدهای سیستم تطبیق دهید.');
|
||||
}
|
||||
|
||||
const rows: Record<string, string>[] = [];
|
||||
for (let r = 1; r < matrix.length; r += 1) {
|
||||
const line = matrix[r] ?? [];
|
||||
const item: Record<string, string> = {};
|
||||
let any = false;
|
||||
for (const [idx, key] of colMap) {
|
||||
const value = cellToString(line[idx]);
|
||||
if (value) any = true;
|
||||
item[key] = value;
|
||||
}
|
||||
if (any) rows.push(item);
|
||||
}
|
||||
if (!rows.length) throw new Error('هیچ ردیف دادهای در فایل نیست');
|
||||
return rows;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import { ACTIVITY_EXCEL_ALIASES, ACTIVITY_EXCEL_FIELDS } from '@/lib/activity-excel';
|
||||
import ActivityFormView from '@/views/ActivityFormView.vue';
|
||||
import type {
|
||||
ActivityDto,
|
||||
@@ -499,8 +500,10 @@ onUnmounted(() => {
|
||||
</UiButton>
|
||||
<ExcelImportFlow
|
||||
preview-path="/activities/import/preview"
|
||||
prepare-path="/activities/import/prepare"
|
||||
import-path="/activities/import"
|
||||
rows-import-path="/activities/import/rows"
|
||||
:client-aliases="ACTIVITY_EXCEL_ALIASES"
|
||||
:client-fields="ACTIVITY_EXCEL_FIELDS"
|
||||
:disabled="busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
|
||||
Reference in New Issue
Block a user