From 60c16053a4314003a90017c3f602574d372311f9 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 4 Oct 2026 10:44:00 +0330 Subject: [PATCH] Support Didar activity Excel exports without header rows via positional columns. Co-authored-by: Cursor --- .../activities/activities-excel.service.ts | 95 +++++++++---- apps/api/src/common/excel.ts | 119 +++++++++++++---- apps/web/src/components/ExcelImportFlow.vue | 22 ++- apps/web/src/lib/activity-excel.ts | 65 ++++++--- apps/web/src/lib/excel-client.ts | 126 ++++++++++++------ apps/web/src/views/ActivitiesView.vue | 13 +- 6 files changed, 318 insertions(+), 122 deletions(-) diff --git a/apps/api/src/activities/activities-excel.service.ts b/apps/api/src/activities/activities-excel.service.ts index 0782179..13e38d2 100644 --- a/apps/api/src/activities/activities-excel.service.ts +++ b/apps/api/src/activities/activities-excel.service.ts @@ -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 = { _type: '_type', type: '_type', + activity: '_type', نوعفعالیت: 'kind', عنوانفعالیت: 'title', عنوان: 'title', @@ -77,10 +90,27 @@ const ACTIVITY_ALIASES: Record = { شرکتهایفعالیت: '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 = { call: 'تماس با مشتری', meeting: 'جلسه', @@ -163,7 +193,12 @@ export class ActivitiesExcelService { } preview(file: Express.Multer.File): Promise { - 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; diff --git a/apps/api/src/common/excel.ts b/apps/api/src/common/excel.ts index 98216aa..f5c6b6c 100644 --- a/apps/api/src/common/excel.ts +++ b/apps/api/src/common/excel.ts @@ -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, fields: ExcelFieldOption[], + options?: ExcelReadOptions, ): Promise { 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 = {}; const used = new Set(); - 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, mapping?: Record, + options?: ExcelReadOptions, ): Promise[]> { 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(); - 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[] = []; sheet.eachRow((row, rowNumber) => { - if (rowNumber === 1) return; + if (rowNumber < dataStartRow) return; const item: Record = {}; let any = false; for (const [col, key] of map) { diff --git a/apps/web/src/components/ExcelImportFlow.vue b/apps/web/src/components/ExcelImportFlow.vue index b2e02d1..c36d647 100644 --- a/apps/web/src/components/ExcelImportFlow.vue +++ b/apps/web/src/components/ExcelImportFlow.vue @@ -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; clientFields?: ExcelFieldOption[]; + /** تشخیص خروجی بدون‌هدر (مثل فعالیت دیدار) */ + clientReadOptions?: ClientExcelReadOptions; disabled?: boolean; }>(); @@ -208,7 +214,12 @@ async function runClientRowsImport(file: File, map?: Record) { 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); diff --git a/apps/web/src/lib/activity-excel.ts b/apps/web/src/lib/activity-excel.ts index ad0f829..af2984c 100644 --- a/apps/web/src/lib/activity-excel.ts +++ b/apps/web/src/lib/activity-excel.ts @@ -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 = { _type: '_type', type: '_type', + activity: '_type', نوعفعالیت: 'kind', عنوانفعالیت: 'title', عنوان: 'title', @@ -24,25 +54,24 @@ export const ACTIVITY_EXCEL_ALIASES: Record = { شرکتهایفعالیت: '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); +} diff --git a/apps/web/src/lib/excel-client.ts b/apps/web/src/lib/excel-client.ts index 04afb41..a9c5551 100644 --- a/apps/web/src/lib/excel-client.ts +++ b/apps/web/src/lib/excel-client.ts @@ -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(sheet, { + header: 1, + defval: '', + raw: false, + }) as unknown[][]; +} + /** خواندن اکسل در مرورگر — بدون آپلود فایل سنگین به API */ export async function previewExcelClient( file: File, aliases: Record, fields: ExcelFieldOption[], + options?: ClientExcelReadOptions, ): Promise { - 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 = {}; const used = new Set(); - 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, mapping?: Record, + options?: ClientExcelReadOptions, ): Promise[]> { - 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(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(); - 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[] = []; - 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 = {}; let any = false; diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index f1378a6..8ed2480 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -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"