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
@@ -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;
+90 -29
View File
@@ -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) {