Support Didar activity Excel exports without header rows via positional columns.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,34 +29,47 @@ import {
|
||||
} from '../common/excel-import-session';
|
||||
import type { ImportActivityRowsDto } from './dto';
|
||||
|
||||
const ACTIVITY_COLUMNS = [
|
||||
{ header: '_type', key: '_type', width: 12 },
|
||||
{ header: 'نوع فعالیت', key: 'kind', width: 16 },
|
||||
{ header: 'عنوان فعالیت', key: 'title', width: 24 },
|
||||
{ header: 'متن فعالیت', key: 'body', width: 36 },
|
||||
{ header: 'نتیجه فعالیت', key: 'result', width: 20 },
|
||||
{ header: 'مسئول اجرا فعالیت', key: 'assignee', width: 18 },
|
||||
{ header: 'ثبت کننده فعالیت', key: 'creator', width: 18 },
|
||||
{ header: 'تاریخ برنامه ریزی شده اجرا فعالیت', key: 'dueDate', width: 18 },
|
||||
{ header: 'زمان برنامه ریزی شده اجرا فعالیت', key: 'dueTime', width: 14 },
|
||||
{ header: 'وضعیت اجرای فعالیت', key: 'status', width: 16 },
|
||||
{ header: 'تاریخ انجام شدن فعالیت', key: 'doneDate', width: 16 },
|
||||
{ header: 'کد معامله فعالیت', key: 'dealCode', width: 14 },
|
||||
{ header: 'کد کارت فعالیت', key: 'cardCode', width: 14 },
|
||||
{ header: 'زمان انجام شدن فعالیت', key: 'doneTime', width: 14 },
|
||||
{ header: 'اشخاص فعالیت', key: 'personName', width: 18 },
|
||||
{ header: 'کد اشخاص فعالیت', key: 'personCode', width: 14 },
|
||||
{ header: 'موبایل اشخاص فعالیت', key: 'personMobile', width: 16 },
|
||||
{ header: 'شرکت های فعالیت', key: 'companyName', width: 20 },
|
||||
{ header: 'کد شرکت های فعالیت', key: 'companyCode', width: 14 },
|
||||
{ header: 'معامله فعالیت_1', key: 'dealTitle', width: 24 },
|
||||
{ header: 'کارت فعالیت_1', key: 'cardTitle', width: 20 },
|
||||
{ header: 'هشتگ', key: 'hashtag', width: 16 },
|
||||
];
|
||||
/** ترتیب دقیق خروجی فعالیت دیدار — برای فایلهای بدون ردیف هدر */
|
||||
const DIDAR_ACTIVITY_POSITIONAL = [
|
||||
{ key: '_type', label: '_type' },
|
||||
{ key: 'kind', label: 'نوع فعالیت' },
|
||||
{ key: 'title', 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: 'dealCode', label: 'کد معامله فعالیت' },
|
||||
{ key: 'cardCode', label: 'کد کارت فعالیت' },
|
||||
{ key: 'doneTime', label: 'زمان انجام شدن فعالیت' },
|
||||
{ key: 'personName', label: 'اشخاص فعالیت' },
|
||||
{ key: 'personCode', label: 'کد اشخاص فعالیت' },
|
||||
{ key: 'personMobile', label: 'موبایل اشخاص فعالیت' },
|
||||
{ key: 'companyName', label: 'شرکت های فعالیت' },
|
||||
{ key: 'companyCode', label: 'کد شرکت های فعالیت' },
|
||||
{ key: 'dealTitle', label: 'معامله فعالیت_1' },
|
||||
{ key: 'cardTitle', label: 'کارت فعالیت_1' },
|
||||
{ key: 'hashtag', label: 'هشتگ' },
|
||||
] as const;
|
||||
|
||||
const ACTIVITY_COLUMNS = DIDAR_ACTIVITY_POSITIONAL.map((c) => ({
|
||||
header: c.label,
|
||||
key: c.key,
|
||||
width:
|
||||
c.key === 'body' || c.key === 'result'
|
||||
? 36
|
||||
: c.key === 'title' || c.key === 'dealTitle'
|
||||
? 24
|
||||
: 16,
|
||||
}));
|
||||
|
||||
const ACTIVITY_ALIASES: Record<string, string> = {
|
||||
_type: '_type',
|
||||
type: '_type',
|
||||
activity: '_type',
|
||||
نوعفعالیت: 'kind',
|
||||
عنوانفعالیت: 'title',
|
||||
عنوان: 'title',
|
||||
@@ -77,10 +90,27 @@ const ACTIVITY_ALIASES: Record<string, string> = {
|
||||
شرکتهایفعالیت: 'companyName',
|
||||
کدشرکتهایفعالیت: 'companyCode',
|
||||
معاملهفعالیت1: 'dealTitle',
|
||||
معاملهفعالیت_1: 'dealTitle',
|
||||
کارتفعالیت1: 'cardTitle',
|
||||
کارتفعالیت_1: 'cardTitle',
|
||||
هشتگ: 'hashtag',
|
||||
};
|
||||
|
||||
function looksLikeDidarActivityHeaderless(firstRow: string[]) {
|
||||
const first = (firstRow[0] ?? '').trim().toLowerCase();
|
||||
if (first !== 'activity' && first !== '_type') return false;
|
||||
const kind = (firstRow[1] ?? '').trim();
|
||||
return /تماس|پیگیری|جلسه|یادداشت|ملاقات|call|task|meeting|note|پیام/i.test(kind);
|
||||
}
|
||||
|
||||
const ACTIVITY_EXCEL_OPTIONS = {
|
||||
positionalColumns: DIDAR_ACTIVITY_POSITIONAL.map((c) => ({
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
})),
|
||||
detectHeaderless: looksLikeDidarActivityHeaderless,
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<ActivityType, string> = {
|
||||
call: 'تماس با مشتری',
|
||||
meeting: 'جلسه',
|
||||
@@ -163,7 +193,12 @@ export class ActivitiesExcelService {
|
||||
}
|
||||
|
||||
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']));
|
||||
return previewExcel(
|
||||
file,
|
||||
ACTIVITY_ALIASES,
|
||||
excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']),
|
||||
ACTIVITY_EXCEL_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +217,8 @@ export class ActivitiesExcelService {
|
||||
const sessionId = beginExcelImportPrepare(
|
||||
actor.organizationId,
|
||||
file.buffer,
|
||||
(f) => readMappedRows(f, ACTIVITY_ALIASES, mapping),
|
||||
(f) =>
|
||||
readMappedRows(f, ACTIVITY_ALIASES, mapping, ACTIVITY_EXCEL_OPTIONS),
|
||||
);
|
||||
return {
|
||||
created: 0,
|
||||
@@ -221,7 +257,12 @@ export class ActivitiesExcelService {
|
||||
offset: chunk?.offset,
|
||||
limit: chunk?.limit,
|
||||
loadRows: () =>
|
||||
readMappedRows(file!, ACTIVITY_ALIASES, parseExcelMapping(mappingRaw)),
|
||||
readMappedRows(
|
||||
file!,
|
||||
ACTIVITY_ALIASES,
|
||||
parseExcelMapping(mappingRaw),
|
||||
ACTIVITY_EXCEL_OPTIONS,
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof BadRequestException) throw e;
|
||||
|
||||
@@ -281,34 +281,76 @@ function resolveFieldKey(
|
||||
return aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)] ?? '';
|
||||
}
|
||||
|
||||
export type ExcelPositionalColumn = { key: string; label: string };
|
||||
|
||||
export type ExcelReadOptions = {
|
||||
/** اگر ردیف اول هدر نباشد، از این ترتیب ستون استفاده شود */
|
||||
positionalColumns?: ExcelPositionalColumn[];
|
||||
/** تشخیص فایل بدون هدر (مثل خروجی دیدار) */
|
||||
detectHeaderless?: (firstRowCells: string[]) => boolean;
|
||||
};
|
||||
|
||||
function readFirstRowCells(sheet: ExcelJS.Worksheet): string[] {
|
||||
const cells: string[] = [];
|
||||
const row = sheet.getRow(1);
|
||||
const max = Math.max(row.cellCount || 0, sheet.columnCount || 0, 22);
|
||||
for (let col = 1; col <= max; col += 1) {
|
||||
cells.push(cellStr(row.getCell(col).value));
|
||||
}
|
||||
while (cells.length && !cells[cells.length - 1]) cells.pop();
|
||||
return cells;
|
||||
}
|
||||
|
||||
export async function previewExcel(
|
||||
file: Express.Multer.File,
|
||||
aliases: Record<string, string>,
|
||||
fields: ExcelFieldOption[],
|
||||
options?: ExcelReadOptions,
|
||||
): Promise<ExcelPreviewResult> {
|
||||
const sheet = await workbookFromUpload(file);
|
||||
const headerRow = sheet.getRow(1);
|
||||
const firstCells = readFirstRowCells(sheet);
|
||||
const headerless =
|
||||
Boolean(options?.positionalColumns?.length) &&
|
||||
Boolean(options?.detectHeaderless?.(firstCells));
|
||||
|
||||
const columns: ExcelColumnPreview[] = [];
|
||||
const suggested: Record<string, string> = {};
|
||||
const used = new Set<string>();
|
||||
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const header = cellStr(cell.value);
|
||||
if (!header) return;
|
||||
const samples: string[] = [];
|
||||
const last = Math.min(sheet.rowCount, 6);
|
||||
for (let r = 2; r <= last; r += 1) {
|
||||
const value = cellStr(sheet.getRow(r).getCell(col).value);
|
||||
if (value) samples.push(value);
|
||||
if (samples.length >= 3) break;
|
||||
}
|
||||
columns.push({ header, index: col, samples });
|
||||
const key = resolveFieldKey(header, aliases);
|
||||
if (key && !used.has(key)) {
|
||||
suggested[header] = key;
|
||||
used.add(key);
|
||||
}
|
||||
});
|
||||
if (headerless && options?.positionalColumns) {
|
||||
const last = Math.min(sheet.rowCount, 5);
|
||||
options.positionalColumns.forEach((colDef, idx) => {
|
||||
const col = idx + 1;
|
||||
const samples: string[] = [];
|
||||
for (let r = 1; r <= last; r += 1) {
|
||||
const value = cellStr(sheet.getRow(r).getCell(col).value);
|
||||
if (value) samples.push(value);
|
||||
if (samples.length >= 3) break;
|
||||
}
|
||||
columns.push({ header: colDef.label, index: col, samples });
|
||||
suggested[colDef.label] = colDef.key;
|
||||
used.add(colDef.key);
|
||||
});
|
||||
} else {
|
||||
const headerRow = sheet.getRow(1);
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const header = cellStr(cell.value);
|
||||
if (!header) return;
|
||||
const samples: string[] = [];
|
||||
const last = Math.min(sheet.rowCount, 6);
|
||||
for (let r = 2; r <= last; r += 1) {
|
||||
const value = cellStr(sheet.getRow(r).getCell(col).value);
|
||||
if (value) samples.push(value);
|
||||
if (samples.length >= 3) break;
|
||||
}
|
||||
columns.push({ header, index: col, samples });
|
||||
const key = resolveFieldKey(header, aliases);
|
||||
if (key && !used.has(key)) {
|
||||
suggested[header] = key;
|
||||
used.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!columns.length) {
|
||||
throw new BadRequestException('ستون عنوان در ردیف اول فایل پیدا نشد');
|
||||
@@ -319,14 +361,16 @@ export async function previewExcel(
|
||||
const missingRequired = required.some((key) => !mapped.has(key));
|
||||
const unrecognized = columns.some((col) => !suggested[col.header]);
|
||||
|
||||
// برای فایلهای خیلی بزرگ (دهها هزار ردیف) پیمایش کامل کند است؛ rowCount شیت کافی است
|
||||
const rowCount = Math.max(0, (sheet.actualRowCount || sheet.rowCount || 1) - 1);
|
||||
const rawRows = Math.max(0, sheet.actualRowCount || sheet.rowCount || 0);
|
||||
const rowCount = headerless ? rawRows : Math.max(0, rawRows - 1);
|
||||
|
||||
return {
|
||||
columns,
|
||||
fields,
|
||||
suggested,
|
||||
needsMapping: unrecognized || missingRequired || !mapped.size,
|
||||
needsMapping: headerless
|
||||
? false
|
||||
: unrecognized || missingRequired || !mapped.size,
|
||||
rowCount,
|
||||
largeFile: rowCount >= EXCEL_IMPORT_LARGE_THRESHOLD,
|
||||
};
|
||||
@@ -336,16 +380,33 @@ export async function readMappedRows(
|
||||
file: Express.Multer.File,
|
||||
aliases: Record<string, string>,
|
||||
mapping?: Record<string, string>,
|
||||
options?: ExcelReadOptions,
|
||||
): Promise<Record<string, string>[]> {
|
||||
const sheet = await workbookFromUpload(file);
|
||||
const headerRow = sheet.getRow(1);
|
||||
const firstCells = readFirstRowCells(sheet);
|
||||
const headerless =
|
||||
!mapping &&
|
||||
Boolean(options?.positionalColumns?.length) &&
|
||||
Boolean(options?.detectHeaderless?.(firstCells));
|
||||
|
||||
const map = new Map<number, string>();
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const raw = cellStr(cell.value);
|
||||
if (!raw) return;
|
||||
const key = resolveFieldKey(raw, aliases, mapping);
|
||||
if (key) map.set(col, key);
|
||||
});
|
||||
let dataStartRow = 2;
|
||||
|
||||
if (headerless && options?.positionalColumns) {
|
||||
options.positionalColumns.forEach((colDef, idx) => {
|
||||
map.set(idx + 1, colDef.key);
|
||||
});
|
||||
dataStartRow = 1;
|
||||
} else {
|
||||
const headerRow = sheet.getRow(1);
|
||||
headerRow.eachCell((cell, col) => {
|
||||
const raw = cellStr(cell.value);
|
||||
if (!raw) return;
|
||||
const key = resolveFieldKey(raw, aliases, mapping);
|
||||
if (key) map.set(col, key);
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.size) {
|
||||
throw new BadRequestException(
|
||||
'ستونهای فایل شناسایی نشد. ستونهای فایل را با فیلدهای سیستم تطبیق دهید.',
|
||||
@@ -354,7 +415,7 @@ export async function readMappedRows(
|
||||
|
||||
const rows: Record<string, string>[] = [];
|
||||
sheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) return;
|
||||
if (rowNumber < dataStartRow) return;
|
||||
const item: Record<string, string> = {};
|
||||
let any = false;
|
||||
for (const [col, key] of map) {
|
||||
|
||||
@@ -8,7 +8,11 @@ import type {
|
||||
ExcelPreviewResult,
|
||||
ImportContactsResult,
|
||||
} from '@mento/shared';
|
||||
import { previewExcelClient, readMappedRowsClient } from '@/lib/excel-client';
|
||||
import {
|
||||
previewExcelClient,
|
||||
readMappedRowsClient,
|
||||
type ClientExcelReadOptions,
|
||||
} from '@/lib/excel-client';
|
||||
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -23,6 +27,8 @@ const props = defineProps<{
|
||||
rowsImportPath?: string;
|
||||
clientAliases?: Record<string, string>;
|
||||
clientFields?: ExcelFieldOption[];
|
||||
/** تشخیص خروجی بدونهدر (مثل فعالیت دیدار) */
|
||||
clientReadOptions?: ClientExcelReadOptions;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
@@ -208,7 +214,12 @@ async function runClientRowsImport(file: File, map?: Record<string, string>) {
|
||||
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
|
||||
progressPct.value = 0;
|
||||
|
||||
const rows = await readMappedRowsClient(file, props.clientAliases, map);
|
||||
const rows = await readMappedRowsClient(
|
||||
file,
|
||||
props.clientAliases,
|
||||
map,
|
||||
props.clientReadOptions,
|
||||
);
|
||||
const total = rows.length;
|
||||
if (total > 100_000) {
|
||||
return {
|
||||
@@ -368,7 +379,12 @@ async function onFile(ev: Event) {
|
||||
let data: ExcelPreviewResult;
|
||||
if (useClientImport.value && props.clientAliases && props.clientFields) {
|
||||
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
|
||||
data = await previewExcelClient(file, props.clientAliases, props.clientFields);
|
||||
data = await previewExcelClient(
|
||||
file,
|
||||
props.clientAliases,
|
||||
props.clientFields,
|
||||
props.clientReadOptions,
|
||||
);
|
||||
} else {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
import type { ExcelFieldOption } from '@mento/shared';
|
||||
|
||||
/**
|
||||
* ترتیب ستونهای خروجی فعالیت دیدار (۲۲ ستون).
|
||||
* بعضی فایلها بدون ردیف هدر هستند و ردیف اول همان دیتا است.
|
||||
*/
|
||||
export const DIDAR_ACTIVITY_POSITIONAL: Array<{ key: string; label: string }> = [
|
||||
{ key: '_type', label: '_type' },
|
||||
{ key: 'kind', label: 'نوع فعالیت' },
|
||||
{ key: 'title', 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: 'dealCode', label: 'کد معامله فعالیت' },
|
||||
{ key: 'cardCode', label: 'کد کارت فعالیت' },
|
||||
{ key: 'doneTime', label: 'زمان انجام شدن فعالیت' },
|
||||
{ key: 'personName', label: 'اشخاص فعالیت' },
|
||||
{ key: 'personCode', label: 'کد اشخاص فعالیت' },
|
||||
{ key: 'personMobile', label: 'موبایل اشخاص فعالیت' },
|
||||
{ key: 'companyName', label: 'شرکت های فعالیت' },
|
||||
{ key: 'companyCode', label: 'کد شرکت های فعالیت' },
|
||||
{ key: 'dealTitle', label: 'معامله فعالیت_1' },
|
||||
{ key: 'cardTitle', label: 'کارت فعالیت_1' },
|
||||
{ key: 'hashtag', label: 'هشتگ' },
|
||||
];
|
||||
|
||||
/** همتراز با aliases سمت API برای ایمپورت فعالیت */
|
||||
export const ACTIVITY_EXCEL_ALIASES: Record<string, string> = {
|
||||
_type: '_type',
|
||||
type: '_type',
|
||||
activity: '_type',
|
||||
نوعفعالیت: 'kind',
|
||||
عنوانفعالیت: 'title',
|
||||
عنوان: 'title',
|
||||
@@ -24,25 +54,24 @@ export const ACTIVITY_EXCEL_ALIASES: Record<string, string> = {
|
||||
شرکتهایفعالیت: 'companyName',
|
||||
کدشرکتهایفعالیت: 'companyCode',
|
||||
معاملهفعالیت1: 'dealTitle',
|
||||
معاملهفعالیت_1: 'dealTitle',
|
||||
کارتفعالیت1: 'cardTitle',
|
||||
کارتفعالیت_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: 'هشتگ' },
|
||||
];
|
||||
export const ACTIVITY_EXCEL_FIELDS: ExcelFieldOption[] = DIDAR_ACTIVITY_POSITIONAL.filter(
|
||||
(c) => c.key !== '_type',
|
||||
).map((c) => ({
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
required: c.key === 'title',
|
||||
}));
|
||||
|
||||
/** ردیف اول شبیه دیتای دیدار است نه هدر فارسی */
|
||||
export function looksLikeDidarActivityHeaderless(firstRow: string[]): boolean {
|
||||
const first = (firstRow[0] ?? '').trim().toLowerCase();
|
||||
if (first !== 'activity' && first !== '_type') return false;
|
||||
const kind = (firstRow[1] ?? '').trim();
|
||||
return /تماس|پیگیری|جلسه|یادداشت|ملاقات|call|task|meeting|note|پیام/i.test(kind);
|
||||
}
|
||||
|
||||
@@ -34,47 +34,77 @@ function resolveFieldKey(
|
||||
return aliases[compact] ?? aliases[raw.trim()] ?? '';
|
||||
}
|
||||
|
||||
export type ClientExcelPositionalColumn = { key: string; label: string };
|
||||
|
||||
export type ClientExcelReadOptions = {
|
||||
positionalColumns?: ClientExcelPositionalColumn[];
|
||||
detectHeaderless?: (firstRowCells: string[]) => boolean;
|
||||
};
|
||||
|
||||
function readMatrix(fileBuffer: ArrayBuffer) {
|
||||
const wb = XLSX.read(fileBuffer, { type: 'array', cellDates: true });
|
||||
const sheetName = wb.SheetNames[0];
|
||||
if (!sheetName) throw new Error('شیت اکسل پیدا نشد');
|
||||
const sheet = wb.Sheets[sheetName]!;
|
||||
return XLSX.utils.sheet_to_json<unknown[]>(sheet, {
|
||||
header: 1,
|
||||
defval: '',
|
||||
raw: false,
|
||||
}) as unknown[][];
|
||||
}
|
||||
|
||||
/** خواندن اکسل در مرورگر — بدون آپلود فایل سنگین به API */
|
||||
export async function previewExcelClient(
|
||||
file: File,
|
||||
aliases: Record<string, string>,
|
||||
fields: ExcelFieldOption[],
|
||||
options?: ClientExcelReadOptions,
|
||||
): 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 matrix = readMatrix(await file.arrayBuffer());
|
||||
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
|
||||
const headerless =
|
||||
Boolean(options?.positionalColumns?.length) &&
|
||||
Boolean(options?.detectHeaderless?.(headerRow));
|
||||
|
||||
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 (headerless && options?.positionalColumns) {
|
||||
options.positionalColumns.forEach((colDef, idx) => {
|
||||
const samples: string[] = [];
|
||||
for (let r = 0; r < Math.min(matrix.length, 5); r += 1) {
|
||||
const value = cellToString(matrix[r]?.[idx]);
|
||||
if (value) samples.push(value);
|
||||
if (samples.length >= 3) break;
|
||||
}
|
||||
columns.push({ header: colDef.label, index: idx + 1, samples });
|
||||
suggested[colDef.label] = colDef.key;
|
||||
used.add(colDef.key);
|
||||
});
|
||||
} else {
|
||||
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 start = headerless ? 0 : 1;
|
||||
for (let r = start; r < matrix.length; r += 1) {
|
||||
const row = matrix[r] ?? [];
|
||||
if (row.some((c) => cellToString(c))) rowCount += 1;
|
||||
}
|
||||
@@ -88,7 +118,9 @@ export async function previewExcelClient(
|
||||
columns,
|
||||
fields,
|
||||
suggested,
|
||||
needsMapping: unrecognized || missingRequired || !mapped.size,
|
||||
needsMapping: headerless
|
||||
? false
|
||||
: unrecognized || missingRequired || !mapped.size,
|
||||
rowCount,
|
||||
largeFile: rowCount >= 80,
|
||||
};
|
||||
@@ -98,31 +130,37 @@ export async function readMappedRowsClient(
|
||||
file: File,
|
||||
aliases: Record<string, string>,
|
||||
mapping?: Record<string, string>,
|
||||
options?: ClientExcelReadOptions,
|
||||
): 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 matrix = readMatrix(await file.arrayBuffer());
|
||||
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
|
||||
const headerless =
|
||||
!mapping &&
|
||||
Boolean(options?.positionalColumns?.length) &&
|
||||
Boolean(options?.detectHeaderless?.(headerRow));
|
||||
|
||||
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);
|
||||
});
|
||||
let dataStart = 1;
|
||||
|
||||
if (headerless && options?.positionalColumns) {
|
||||
options.positionalColumns.forEach((colDef, idx) => {
|
||||
colMap.set(idx, colDef.key);
|
||||
});
|
||||
dataStart = 0;
|
||||
} else {
|
||||
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) {
|
||||
for (let r = dataStart; r < matrix.length; r += 1) {
|
||||
const line = matrix[r] ?? [];
|
||||
const item: Record<string, string> = {};
|
||||
let any = false;
|
||||
|
||||
@@ -20,7 +20,17 @@ 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 {
|
||||
ACTIVITY_EXCEL_ALIASES,
|
||||
ACTIVITY_EXCEL_FIELDS,
|
||||
DIDAR_ACTIVITY_POSITIONAL,
|
||||
looksLikeDidarActivityHeaderless,
|
||||
} from '@/lib/activity-excel';
|
||||
|
||||
const activityClientReadOptions = {
|
||||
positionalColumns: DIDAR_ACTIVITY_POSITIONAL,
|
||||
detectHeaderless: looksLikeDidarActivityHeaderless,
|
||||
};
|
||||
import ActivityFormView from '@/views/ActivityFormView.vue';
|
||||
import type {
|
||||
ActivityDto,
|
||||
@@ -504,6 +514,7 @@ onUnmounted(() => {
|
||||
rows-import-path="/activities/import/rows"
|
||||
:client-aliases="ACTIVITY_EXCEL_ALIASES"
|
||||
:client-fields="ACTIVITY_EXCEL_FIELDS"
|
||||
:client-read-options="activityClientReadOptions"
|
||||
:disabled="busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
|
||||
Reference in New Issue
Block a user