Support Didar activity Excel exports without header rows via positional columns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-04 10:44:00 +03:30
co-authored by Cursor
parent cbfe470f33
commit 60c16053a4
6 changed files with 318 additions and 122 deletions
+19 -3
View File
@@ -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);
+47 -18
View 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);
}
+82 -44
View File
@@ -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;
+12 -1
View File
@@ -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"