Edit import excel

This commit is contained in:
2026-09-19 15:30:31 +03:30
parent 9c7dad3677
commit 4776d1a3c3
22 changed files with 731 additions and 261 deletions
@@ -5,12 +5,16 @@ import type { Actor } from '../common/record-scope';
import { assigneeWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatClock,
formatJalaliDate,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
readMappedRows,
stripHtml,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
const ACTIVITY_COLUMNS = [
@@ -143,8 +147,16 @@ export class ActivitiesExcelService {
);
}
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, ACTIVITY_ALIASES);
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, ACTIVITY_ALIASES, excelFieldsFromColumns(ACTIVITY_COLUMNS, ['title']));
}
async import(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, ACTIVITY_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
@@ -79,6 +79,17 @@ export class ActivitiesController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
importPreview(@UploadedFile() file: Express.Multer.File) {
return this.excelService.preview(file);
}
@Post('import')
@UseInterceptors(
FileInterceptor('file', {
@@ -89,8 +100,9 @@ export class ActivitiesController {
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.import(user, file);
return this.excelService.import(user, file, mapping);
}
@Get(':id')
+114 -3
View File
@@ -188,9 +188,121 @@ export async function workbookFromUpload(file: Express.Multer.File | undefined)
return sheet;
}
export type ExcelFieldOption = {
key: string;
label: string;
required?: boolean;
};
export type ExcelColumnPreview = {
header: string;
index: number;
samples: string[];
};
export type ExcelPreviewResult = {
columns: ExcelColumnPreview[];
fields: ExcelFieldOption[];
suggested: Record<string, string>;
needsMapping: boolean;
};
export function excelFieldsFromColumns(
columns: Array<{ header: string; key: string }>,
requiredKeys: string[] = [],
): ExcelFieldOption[] {
const seen = new Set<string>();
const fields: ExcelFieldOption[] = [];
for (const col of columns) {
if (!col.key || col.key === '_type' || seen.has(col.key)) continue;
seen.add(col.key);
fields.push({
key: col.key,
label: col.header,
required: requiredKeys.includes(col.key),
});
}
return fields;
}
export function parseExcelMapping(raw?: string): Record<string, string> | undefined {
if (raw == null || !String(raw).trim()) return undefined;
try {
const parsed = JSON.parse(String(raw)) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('invalid');
}
const mapping: Record<string, string> = {};
for (const [header, field] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof field === 'string' && field.trim()) mapping[header] = field.trim();
}
return mapping;
} catch {
throw new BadRequestException('نگاشت ستون‌ها نامعتبر است');
}
}
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()] ?? aliases[normalizeHeader(raw)] ?? '';
}
export async function previewExcel(
file: Express.Multer.File,
aliases: Record<string, string>,
fields: ExcelFieldOption[],
): Promise<ExcelPreviewResult> {
const sheet = await workbookFromUpload(file);
const headerRow = sheet.getRow(1);
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 (!columns.length) {
throw new BadRequestException('ستون عنوان در ردیف اول فایل پیدا نشد');
}
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,
};
}
export async function readMappedRows(
file: Express.Multer.File,
aliases: Record<string, string>,
mapping?: Record<string, string>,
): Promise<Record<string, string>[]> {
const sheet = await workbookFromUpload(file);
const headerRow = sheet.getRow(1);
@@ -198,13 +310,12 @@ export async function readMappedRows(
headerRow.eachCell((cell, col) => {
const raw = cellStr(cell.value);
if (!raw) return;
const compact = normalizeHeader(raw);
const key = aliases[compact] ?? aliases[raw.trim()] ?? aliases[normalizeHeader(raw)];
const key = resolveFieldKey(raw, aliases, mapping);
if (key) map.set(col, key);
});
if (!map.size) {
throw new BadRequestException(
'ستون‌های فایل شناسایی نشد. از قالب خروجی همین سیستم یا خروجی دیدار استفاده کنید.',
'ستون‌های فایل شناسایی نشد. ستون‌های فایل را با فیلدهای سیستم تطبیق دهید.',
);
}
@@ -56,13 +56,20 @@ export class CompaniesController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@UseInterceptors(FileInterceptor('file'))
previewImport(@UploadedFile() file: Express.Multer.File) {
return this.excelService.previewCompanies(file);
}
@Post('import')
@UseInterceptors(FileInterceptor('file'))
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.importCompanies(user, file);
return this.excelService.importCompanies(user, file, mapping);
}
@Get(':id')
@@ -6,8 +6,11 @@ import type { Actor } from '../common/record-scope';
import { ownerWhere } from '../common/record-scope';
import {
formatJalaliDate,
parseExcelMapping,
previewExcel,
readMappedRows,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import { CompaniesService } from './companies.service';
import { PeopleService } from './people.service';
@@ -107,6 +110,19 @@ const PERSON_ALIASES: Record<string, string> = {
ستاره: 'star',
};
const PERSON_FIELDS = [
{ key: 'firstName', label: 'نام' },
{ key: 'lastName', label: 'نام خانوادگی' },
{ key: 'title', label: 'نام کامل / عنوان' },
{ key: 'mobile', label: 'موبایل' },
{ key: 'phone', label: 'تلفن' },
{ key: 'email', label: 'ایمیل' },
{ key: 'position', label: 'سمت' },
{ key: 'companyName', label: 'شرکت' },
{ key: 'notes', label: 'یادداشت' },
{ key: 'mood', label: 'وضعیت مشتری' },
];
const COMPANY_ALIASES: Record<string, string> = {
name: 'name',
نام: 'name',
@@ -126,6 +142,16 @@ const COMPANY_ALIASES: Record<string, string> = {
یادداشت: 'notes',
};
const COMPANY_FIELDS = [
{ key: 'name', label: 'نام شرکت', required: true },
{ key: 'nationalId', label: 'شناسه ملی' },
{ key: 'phone', label: 'تلفن' },
{ key: 'email', label: 'ایمیل' },
{ key: 'website', label: 'وب‌سایت' },
{ key: 'address', label: 'آدرس' },
{ key: 'notes', label: 'یادداشت' },
];
const CONTACT_COLUMNS = [
{ header: '_type', key: '_type', width: 12 },
{ header: 'کد دیدار مشتری', key: 'didarId', width: 16 },
@@ -294,11 +320,20 @@ export class ContactsExcelService {
);
}
previewPeople(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, PERSON_ALIASES, PERSON_FIELDS);
}
previewCompanies(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, COMPANY_ALIASES, COMPANY_FIELDS);
}
async importPeople(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, PERSON_ALIASES);
const rows = await readMappedRows(file, PERSON_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
@@ -307,9 +342,18 @@ export class ContactsExcelService {
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'contact') {
continue;
}
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title);
if (!names) {
result.errors.push({ row: rowNum, message: 'نام مشتری الزامی است' });
const names = splitDidarName(row.firstName ?? '', row.lastName ?? '', row.title) ?? {
firstName: (row.firstName ?? '').trim().slice(0, 80),
lastName: (row.lastName ?? '').trim().slice(0, 80),
};
if (
!names.firstName &&
!names.lastName &&
!row.mobile?.trim() &&
!row.email?.trim() &&
!row.phone?.trim()
) {
result.errors.push({ row: rowNum, message: 'حداقل نام، موبایل، تلفن یا ایمیل لازم است' });
continue;
}
@@ -357,8 +401,9 @@ export class ContactsExcelService {
async importCompanies(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, COMPANY_ALIASES);
const rows = await readMappedRows(file, COMPANY_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
+4 -6
View File
@@ -135,15 +135,15 @@ export class UpdateCompanyDto {
}
export class CreatePersonDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
firstName!: string;
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
lastName!: string;
lastName?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@@ -192,13 +192,11 @@ export class CreatePersonDto {
export class UpdatePersonDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
lastName?: string;
+10 -1
View File
@@ -57,13 +57,22 @@ export class PeopleController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@UseInterceptors(FileInterceptor('file'))
previewImport(
@UploadedFile() file: Express.Multer.File,
) {
return this.excelService.previewPeople(file);
}
@Post('import')
@UseInterceptors(FileInterceptor('file'))
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.importPeople(user, file);
return this.excelService.importPeople(user, file, mapping);
}
@Get(':id')
+2 -2
View File
@@ -134,8 +134,8 @@ export class PeopleService {
const data: Prisma.PersonUncheckedCreateInput = {
organizationId: actor.organizationId,
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
firstName: (dto.firstName ?? '').trim(),
lastName: (dto.lastName ?? '').trim(),
email: normalizeEmail(dto.email),
phone: normalizePhone(dto.phone),
mobile: normalizePhone(dto.mobile),
+14 -2
View File
@@ -7,11 +7,15 @@ import { ownerWhere, resolveOwnerId } from '../common/record-scope';
import { toJsonCustomFields } from '../common/custom-fields';
import { normalizePhone } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatJalaliDate,
parseAmount,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
readMappedRows,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import { PipelinesService } from './pipelines.service';
@@ -144,8 +148,16 @@ export class DealsExcelService {
);
}
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, DEAL_ALIASES);
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, DEAL_ALIASES, excelFieldsFromColumns(DEAL_COLUMNS, ['title']));
}
async import(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, DEAL_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const affected = new Map<string, string>();
+15 -1
View File
@@ -45,6 +45,19 @@ export class DealsController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
importPreview(
@UploadedFile() file: Express.Multer.File,
) {
return this.excelService.preview(file);
}
@Post('import')
@UseInterceptors(
FileInterceptor('file', {
@@ -55,8 +68,9 @@ export class DealsController {
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.import(user, file);
return this.excelService.import(user, file, mapping);
}
@Get(':id')
@@ -2,9 +2,13 @@ import { Injectable } from '@nestjs/common';
import type { ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import {
excelFieldsFromColumns,
parseAmount,
parseExcelMapping,
previewExcel,
readMappedRows,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import { ProductsService } from './products.service';
@@ -106,11 +110,16 @@ export class ProductsExcelService {
);
}
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, PRODUCT_ALIASES, excelFieldsFromColumns(PRODUCT_COLUMNS, ['name']));
}
async import(
organizationId: string,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, PRODUCT_ALIASES);
const rows = await readMappedRows(file, PRODUCT_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
+14 -1
View File
@@ -62,6 +62,18 @@ export class ProductsController {
return new StreamableFile(buffer);
}
@Post('import/preview')
@Roles('owner', 'admin')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
importPreview(@UploadedFile() file: Express.Multer.File) {
return this.excelService.preview(file);
}
@Post('import')
@Roles('owner', 'admin')
@UseInterceptors(
@@ -73,8 +85,9 @@ export class ProductsController {
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.import(user.organizationId, file);
return this.excelService.import(user.organizationId, file, mapping);
}
@Get(':id')
@@ -128,6 +128,17 @@ export class ProjectBoardsController {
return new StreamableFile(buffer);
}
@Post('project-cards/import/preview')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15_000_000 },
}),
)
previewCards(@UploadedFile() file: Express.Multer.File) {
return this.excelService.preview(file);
}
@Post('project-cards/import')
@UseInterceptors(
FileInterceptor('file', {
@@ -138,8 +149,9 @@ export class ProjectBoardsController {
importCards(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
@Body('mapping') mapping?: string,
) {
return this.excelService.import(user, file);
return this.excelService.import(user, file, mapping);
}
@Get('project-cards/:id')
@@ -5,11 +5,15 @@ import type { Actor } from '../common/record-scope';
import { ownerWhere } from '../common/record-scope';
import { normalizePhone } from '../contacts/normalize';
import {
excelFieldsFromColumns,
formatJalaliDate,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
readMappedRows,
stripHtml,
writeXlsx,
type ExcelPreviewResult,
} from '../common/excel';
import { ProjectBoardsService } from './project-boards.service';
@@ -105,8 +109,16 @@ export class ProjectCardsExcelService {
);
}
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, CASE_ALIASES);
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
return previewExcel(file, CASE_ALIASES, excelFieldsFromColumns(CASE_COLUMNS, ['title']));
}
async import(
actor: Actor,
file: Express.Multer.File,
mappingRaw?: string,
): Promise<ImportContactsResult> {
const rows = await readMappedRows(file, CASE_ALIASES, parseExcelMapping(mappingRaw));
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
const affected = new Map<string, string>();