Deals Excel import/export aligned with Didar: browser-side import, new deal fields (code, probability, closedAt, creator, lostNote, referrer), Didar custom fields
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "code" TEXT;
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "importSource" TEXT NOT NULL DEFAULT 'manual';
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "sourceRef" TEXT;
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "probability" INTEGER;
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "sourceDetail" TEXT;
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "referrer" TEXT;
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "lostNote" TEXT;
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "closedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "Deal" ADD COLUMN IF NOT EXISTS "createdById" TEXT;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Deal" ADD CONSTRAINT "Deal_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Deal_organizationId_sourceRef_idx" ON "Deal"("organizationId", "sourceRef");
|
||||
|
||||
UPDATE "Deal"
|
||||
SET "importSource" = 'didar', "sourceRef" = "customFields"->>'didarId'
|
||||
WHERE "sourceRef" IS NULL AND COALESCE("customFields"->>'didarId', '') <> '';
|
||||
@@ -240,6 +240,7 @@ model User {
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
ownedDeals Deal[] @relation("DealOwner")
|
||||
assignedDeals Deal[] @relation("DealAssignees")
|
||||
createdDeals Deal[] @relation("DealCreator")
|
||||
ownedQuotes Quote[] @relation("QuoteOwner")
|
||||
ownedInvoices Invoice[] @relation("InvoiceOwner")
|
||||
ownedPeople Person[] @relation("PersonOwner")
|
||||
@@ -557,11 +558,26 @@ model Deal {
|
||||
stageId String
|
||||
stage PipelineStage @relation(fields: [stageId], references: [id], onDelete: Restrict)
|
||||
title String
|
||||
/// کد دستی معامله
|
||||
code String?
|
||||
/// منبع ورود: manual | didar | excel
|
||||
importSource String @default("manual")
|
||||
/// شناسه در مبدأ (کد دیدار معامله)
|
||||
sourceRef String?
|
||||
amount Decimal? @db.Decimal(18, 2)
|
||||
currency String @default("IRR")
|
||||
status DealStatus @default(open)
|
||||
/// احتمال موفقیت (۰ تا ۱۰۰)
|
||||
probability Int?
|
||||
/// شیوه آشنایی
|
||||
source String?
|
||||
/// شیوه آشنایی دلخواه
|
||||
sourceDetail String?
|
||||
referrer String?
|
||||
lostReason String?
|
||||
lostNote String?
|
||||
/// تاریخ انجام (موفق/ناموفق شدن)
|
||||
closedAt DateTime?
|
||||
notes String?
|
||||
tags String[] @default([])
|
||||
isStale Boolean @default(false)
|
||||
@@ -574,6 +590,8 @@ model Deal {
|
||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||
ownerId String?
|
||||
owner User? @relation("DealOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
createdById String?
|
||||
createdBy User? @relation("DealCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
assignees User[] @relation("DealAssignees")
|
||||
position Int @default(0)
|
||||
activities Activity[]
|
||||
@@ -592,6 +610,7 @@ model Deal {
|
||||
@@index([personId])
|
||||
@@index([companyId])
|
||||
@@index([ownerId])
|
||||
@@index([organizationId, sourceRef])
|
||||
}
|
||||
|
||||
model Activity {
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { DealStatus, ImportContactsResult } from '@mento/shared';
|
||||
import { createHash } from 'crypto';
|
||||
import {
|
||||
DEAL_EXCEL_ALIASES,
|
||||
DEAL_EXCEL_COLUMNS,
|
||||
EXCEL_CUSTOM_FIELD_PREFIX,
|
||||
compactExcelHeader,
|
||||
type DealStatus,
|
||||
type ImportContactsResult,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
|
||||
import { toJsonCustomFields } from '../common/custom-fields';
|
||||
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
excelFieldsFromColumns,
|
||||
@@ -14,78 +22,33 @@ import {
|
||||
parseJalaliDateTime,
|
||||
previewExcel,
|
||||
readMappedRows,
|
||||
stripHtml,
|
||||
toAsciiDigits,
|
||||
writeXlsx,
|
||||
type ExcelPreviewResult,
|
||||
} from '../common/excel';
|
||||
import {
|
||||
chunkImportMeta,
|
||||
createExcelImportCacheSession,
|
||||
destroyExcelImportSession,
|
||||
getExcelImportSessionCaches,
|
||||
resolveExcelImportChunk,
|
||||
type ExcelImportLookupCaches,
|
||||
} from '../common/excel-import-session';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
|
||||
const DEAL_COLUMNS = [
|
||||
{ header: '_type', key: '_type', width: 12 },
|
||||
{ header: 'کد دیدار معامله', key: 'didarId', width: 16 },
|
||||
{ header: 'عنوان معامله', key: 'title', width: 28 },
|
||||
{ header: 'مبلغ معامله', key: 'amount', width: 16 },
|
||||
{ header: 'ارز معامله', key: 'currency', width: 12 },
|
||||
{ header: 'وضعیت معامله', key: 'status', width: 14 },
|
||||
{ header: 'مسئول معامله', key: 'ownerName', width: 18 },
|
||||
{ header: 'مجوز مشاهده معامله', key: 'visibility', width: 18 },
|
||||
{ header: 'تاریخ ثبت معامله', key: 'createdAt', width: 16 },
|
||||
{ header: 'نام خانوادگی شخص معامله', key: 'personName', width: 22 },
|
||||
{ header: 'کد دیدار شخص', key: 'personCode', width: 14 },
|
||||
{ header: 'موبایل شخص معامله', key: 'personMobile', width: 16 },
|
||||
{ header: 'نام شرکت معامله', key: 'companyName', width: 22 },
|
||||
{ header: 'کاریز معامله', key: 'pipelineName', width: 20 },
|
||||
{ header: 'مرحله کاریز معامله', key: 'stageName', width: 18 },
|
||||
{ header: 'تاریخ انجام معامله', key: 'expectedCloseAt', width: 16 },
|
||||
{ header: 'منبع معامله', key: 'source', width: 16 },
|
||||
{ header: 'توضیحات معامله', key: 'notes', width: 32 },
|
||||
{ header: 'برچسب', key: 'tags', width: 16 },
|
||||
];
|
||||
|
||||
const DEAL_ALIASES: Record<string, string> = {
|
||||
_type: '_type',
|
||||
type: '_type',
|
||||
کددیدارمعامله: 'didarId',
|
||||
عنوانمعامله: 'title',
|
||||
عنوان: 'title',
|
||||
مبلغمعامله: 'amount',
|
||||
مبلغ: 'amount',
|
||||
ارزمعامله: 'currency',
|
||||
ارز: 'currency',
|
||||
وضعیتمعامله: 'status',
|
||||
مسئولمعامله: 'ownerName',
|
||||
مجوزمشاهدهمعامله: 'visibility',
|
||||
تاریخثبتمعامله: 'createdAt',
|
||||
نامخانوادگیشخصمعامله: 'personName',
|
||||
کددیدارشخص: 'personCode',
|
||||
موبایلشخصمعامله: 'personMobile',
|
||||
نامشرکتمعامله: 'companyName',
|
||||
شرکت: 'companyName',
|
||||
کاریزمعامله: 'pipelineName',
|
||||
کاریز: 'pipelineName',
|
||||
مرحلهکاریزمعامله: 'stageName',
|
||||
مرحله: 'stageName',
|
||||
تاریخانجاممعامله: 'expectedCloseAt',
|
||||
منبعمعامله: 'source',
|
||||
منبع: 'source',
|
||||
توضیحاتمعامله: 'notes',
|
||||
توضیحات: 'notes',
|
||||
برچسب: 'tags',
|
||||
};
|
||||
const MAX_ROWS_PER_REQUEST = 80;
|
||||
|
||||
function statusLabel(status: DealStatus) {
|
||||
if (status === 'won') return 'برنده';
|
||||
if (status === 'lost') return 'از دسترفته';
|
||||
return 'باز';
|
||||
if (status === 'won') return 'موفق';
|
||||
if (status === 'lost') return 'ناموفق';
|
||||
return 'جاری';
|
||||
}
|
||||
|
||||
function parseStatus(raw?: string): DealStatus {
|
||||
const v = (raw ?? '').trim();
|
||||
if (/برنده|won|موفق/i.test(v)) return 'won';
|
||||
if (/از\s*دست|باخت|lost|ناموفق/i.test(v)) return 'lost';
|
||||
if (/ناموفق|از\s*دست|باخت|شکست|lost/i.test(v)) return 'lost';
|
||||
if (/موفق|برنده|won/i.test(v)) return 'won';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
@@ -97,6 +60,45 @@ function parseTags(raw?: string) {
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
/** کدهای دیدار: «0» و خالی یعنی بدون کد */
|
||||
function cleanCode(raw?: string): string {
|
||||
const v = toAsciiDigits(raw ?? '').trim();
|
||||
if (!v || /^0+$/.test(v)) return '';
|
||||
return v;
|
||||
}
|
||||
|
||||
function cleanText(raw?: string, max = 2000): string | null {
|
||||
const v = (raw ?? '').trim();
|
||||
return v ? v.slice(0, max) : null;
|
||||
}
|
||||
|
||||
function parseProbability(raw?: string): number | null {
|
||||
const n = parseAmount(raw);
|
||||
if (n == null) return null;
|
||||
return Math.max(0, Math.min(100, Math.round(n)));
|
||||
}
|
||||
|
||||
function parseDate(raw?: string): Date | null {
|
||||
const v = (raw ?? '').trim();
|
||||
if (!v) return null;
|
||||
return parseJalaliDateTime(v);
|
||||
}
|
||||
|
||||
function customFieldKey(label: string) {
|
||||
return `didar_${createHash('md5').update(label).digest('hex').slice(0, 10)}`;
|
||||
}
|
||||
|
||||
type PipelineCacheEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
stages: Array<{ id: string; name: string; isWon: boolean; isLost: boolean }>;
|
||||
};
|
||||
|
||||
type RowContext = {
|
||||
pipelines: Map<string, PipelineCacheEntry>;
|
||||
customFieldKeys: Map<string, string>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DealsExcelService {
|
||||
constructor(
|
||||
@@ -105,57 +107,111 @@ export class DealsExcelService {
|
||||
) {}
|
||||
|
||||
async export(actor: Actor): Promise<Buffer> {
|
||||
const rows = await this.prisma.deal.findMany({
|
||||
where: { organizationId: actor.organizationId, ...dealAccessWhere(actor) },
|
||||
include: {
|
||||
person: {
|
||||
select: { id: true, firstName: true, lastName: true, mobile: true },
|
||||
const [rows, defs] = await Promise.all([
|
||||
this.prisma.deal.findMany({
|
||||
where: { organizationId: actor.organizationId, ...dealAccessWhere(actor) },
|
||||
include: {
|
||||
person: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
mobile: true,
|
||||
sourceRef: true,
|
||||
},
|
||||
},
|
||||
company: { select: { id: true, name: true, phone: true, sourceRef: true } },
|
||||
owner: { select: { fullName: true } },
|
||||
createdBy: { select: { fullName: true } },
|
||||
pipeline: { select: { name: true } },
|
||||
stage: { select: { name: true } },
|
||||
lineItems: {
|
||||
orderBy: { position: 'asc' },
|
||||
include: { product: { select: { sku: true, category: true } } },
|
||||
},
|
||||
},
|
||||
company: { select: { name: true } },
|
||||
owner: { select: { fullName: true } },
|
||||
pipeline: { select: { name: true } },
|
||||
stage: { select: { name: true } },
|
||||
},
|
||||
orderBy: [{ updatedAt: 'desc' }],
|
||||
take: 10000,
|
||||
});
|
||||
|
||||
return writeXlsx(
|
||||
'Deal',
|
||||
DEAL_COLUMNS,
|
||||
rows.map((row) => {
|
||||
const extra = (row.customFields ?? {}) as Record<string, string>;
|
||||
return {
|
||||
_type: 'Deal',
|
||||
didarId: extra.didarId ?? row.id,
|
||||
title: row.title,
|
||||
amount: row.amount == null ? '' : Number(row.amount),
|
||||
currency: row.currency,
|
||||
status: statusLabel(row.status),
|
||||
ownerName: row.owner?.fullName ?? '',
|
||||
visibility: 'همه افراد شرکت',
|
||||
createdAt: formatJalaliDate(row.createdAt),
|
||||
personName: row.person
|
||||
? `${row.person.firstName} ${row.person.lastName}`.trim()
|
||||
: '',
|
||||
personCode: row.person?.id ?? '',
|
||||
personMobile: row.person?.mobile ?? '',
|
||||
companyName: row.company?.name ?? '',
|
||||
pipelineName: row.pipeline.name,
|
||||
stageName: row.stage.name,
|
||||
expectedCloseAt: formatJalaliDate(row.expectedCloseAt),
|
||||
source: row.source ?? '',
|
||||
notes: row.notes ?? '',
|
||||
tags: (row.tags ?? []).join('، '),
|
||||
};
|
||||
orderBy: [{ updatedAt: 'desc' }],
|
||||
take: 10000,
|
||||
}),
|
||||
);
|
||||
this.prisma.customFieldDefinition.findMany({
|
||||
where: { organizationId: actor.organizationId, entityType: 'deal', isActive: true },
|
||||
orderBy: { position: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const columns = [
|
||||
...DEAL_EXCEL_COLUMNS,
|
||||
...defs.map((d) => ({ header: d.label, key: `${EXCEL_CUSTOM_FIELD_PREFIX}${d.key}`, width: 16 })),
|
||||
];
|
||||
|
||||
const out: Array<Record<string, string | number | null>> = [];
|
||||
for (const row of rows) {
|
||||
const extra = asCustomFields(row.customFields);
|
||||
const base: Record<string, string | number | null> = {
|
||||
_type: 'Deal',
|
||||
didarId: row.sourceRef ?? row.id,
|
||||
code: row.code ?? '',
|
||||
title: row.title,
|
||||
amount: row.amount == null ? 0 : Number(row.amount),
|
||||
currency: row.currency,
|
||||
notes: row.notes ?? '',
|
||||
status: statusLabel(row.status),
|
||||
closedAt: formatJalaliDate(row.closedAt),
|
||||
createdAt: formatJalaliDate(row.createdAt),
|
||||
expectedCloseAt: formatJalaliDate(row.expectedCloseAt),
|
||||
ownerName: row.owner?.fullName ?? '',
|
||||
creatorName: row.createdBy?.fullName ?? '',
|
||||
probability: row.probability ?? '',
|
||||
lostReason: row.lostReason ?? '',
|
||||
lostNote: row.lostNote ?? '',
|
||||
visibility: typeof extra.visibility === 'string' ? extra.visibility : 'همه افراد شرکت',
|
||||
source: row.source ?? '',
|
||||
referrer: row.referrer ?? '',
|
||||
sourceDetail: row.sourceDetail ?? '',
|
||||
tags: (row.tags ?? []).join('، '),
|
||||
pipelineName: row.pipeline.name,
|
||||
stageName: row.stage.name,
|
||||
personFirstName: row.person?.firstName ?? '',
|
||||
personName: row.person?.lastName ?? '',
|
||||
personCode: row.person ? (row.person.sourceRef ?? row.person.id) : '',
|
||||
personMobile: row.person?.mobile ?? '',
|
||||
companyName: row.company?.name ?? '',
|
||||
companyCode: row.company ? (row.company.sourceRef ?? row.company.id) : '',
|
||||
companyPhone: row.company?.phone ?? '',
|
||||
};
|
||||
for (const d of defs) {
|
||||
const v = extra[d.key];
|
||||
base[`${EXCEL_CUSTOM_FIELD_PREFIX}${d.key}`] = v == null ? '' : String(v);
|
||||
}
|
||||
if (!row.lineItems.length) {
|
||||
out.push(base);
|
||||
continue;
|
||||
}
|
||||
for (const item of row.lineItems) {
|
||||
const qty = Number(item.quantity);
|
||||
const price = Number(item.unitPrice);
|
||||
out.push({
|
||||
...base,
|
||||
productName: item.name,
|
||||
productCode: item.product?.sku ?? '',
|
||||
productCategory: item.product?.category ?? '',
|
||||
productQty: qty,
|
||||
productPrice: price,
|
||||
productCurrency: item.currency,
|
||||
productDiscount: 0,
|
||||
productTotal: qty * price,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return writeXlsx('Deal', columns, out);
|
||||
}
|
||||
|
||||
preview(file: Express.Multer.File): Promise<ExcelPreviewResult> {
|
||||
return previewExcel(file, DEAL_ALIASES, excelFieldsFromColumns(DEAL_COLUMNS, ['title']));
|
||||
return previewExcel(file, DEAL_EXCEL_ALIASES, excelFieldsFromColumns(DEAL_EXCEL_COLUMNS, ['title']));
|
||||
}
|
||||
|
||||
/** ایمپورت با آپلود فایل (فایلهای استاندارد؛ خروجی دیدار از مسیر importRows میآید) */
|
||||
async import(
|
||||
actor: Actor,
|
||||
file: Express.Multer.File | undefined,
|
||||
@@ -169,72 +225,81 @@ export class DealsExcelService {
|
||||
offset: chunk?.offset,
|
||||
limit: chunk?.limit,
|
||||
loadRows: () =>
|
||||
readMappedRows(file!, DEAL_ALIASES, parseExcelMapping(mappingRaw)),
|
||||
readMappedRows(file!, DEAL_EXCEL_ALIASES, parseExcelMapping(mappingRaw)),
|
||||
});
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
const caches = getExcelImportSessionCaches(ctx.sessionId, actor.organizationId);
|
||||
const affected = await this.processRows(actor, ctx.slice, ctx.offset, result, caches);
|
||||
this.applyBoards(result, affected);
|
||||
return { ...result, ...chunkImportMeta(ctx) };
|
||||
}
|
||||
|
||||
/** ایمپورت تکهای JSON از مرورگر — فایل اکسل روی سرور پارس نمیشود */
|
||||
async importRows(
|
||||
actor: Actor,
|
||||
dto: { rows: Array<Record<string, string>>; offset: number; total: number; sessionId?: string },
|
||||
): Promise<ImportContactsResult> {
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
const rows = Array.isArray(dto.rows) ? dto.rows : [];
|
||||
if (!rows.length) {
|
||||
result.errors.push({ row: 1, message: 'ردیف ارسالشده خالی است' });
|
||||
return result;
|
||||
}
|
||||
if (rows.length > MAX_ROWS_PER_REQUEST) {
|
||||
result.errors.push({ row: 1, message: `حداکثر ${MAX_ROWS_PER_REQUEST} ردیف در هر درخواست` });
|
||||
return result;
|
||||
}
|
||||
const total = Math.max(0, Number(dto.total) || 0);
|
||||
const offset = Math.max(0, Number(dto.offset) || 0);
|
||||
let sessionId = (dto.sessionId ?? '').trim();
|
||||
if (!sessionId) sessionId = createExcelImportCacheSession(actor.organizationId);
|
||||
|
||||
const caches = getExcelImportSessionCaches(sessionId, actor.organizationId);
|
||||
const affected = await this.processRows(actor, rows, offset, result, caches);
|
||||
this.applyBoards(result, affected);
|
||||
|
||||
const processed = offset + rows.length;
|
||||
const hasMore = total > 0 ? processed < total : false;
|
||||
if (!hasMore) destroyExcelImportSession(sessionId);
|
||||
return {
|
||||
...result,
|
||||
sessionId: hasMore ? sessionId : undefined,
|
||||
total,
|
||||
processed,
|
||||
hasMore,
|
||||
nextOffset: hasMore ? processed : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private applyBoards(result: ImportContactsResult, affected: Map<string, string>) {
|
||||
const entries = [...affected.entries()];
|
||||
if (entries[0]) {
|
||||
result.boardId = entries[0][0];
|
||||
result.boards = entries.map(([, n]) => n);
|
||||
}
|
||||
}
|
||||
|
||||
private async processRows(
|
||||
actor: Actor,
|
||||
rows: Array<Record<string, string>>,
|
||||
offset: number,
|
||||
result: ImportContactsResult,
|
||||
caches: ExcelImportLookupCaches,
|
||||
): Promise<Map<string, string>> {
|
||||
const affected = new Map<string, string>();
|
||||
const ctx: RowContext = {
|
||||
pipelines: new Map(),
|
||||
customFieldKeys: await this.loadCustomFieldKeys(actor.organizationId),
|
||||
};
|
||||
|
||||
for (let i = 0; i < ctx.slice.length; i++) {
|
||||
const row = ctx.slice[i]!;
|
||||
const rowNum = ctx.offset + i + 2;
|
||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'deal') {
|
||||
continue;
|
||||
}
|
||||
const title = (row.title || '').trim();
|
||||
if (title.length < 2) {
|
||||
result.errors.push({ row: rowNum, message: 'عنوان معامله الزامی است' });
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const rowNum = offset + i + 2;
|
||||
const type = (row._type || '').trim().toLowerCase();
|
||||
if (type && type !== 'deal') continue;
|
||||
try {
|
||||
const status = parseStatus(row.status);
|
||||
const { pipelineId, stageId, pipelineName } = await this.resolvePipelineAndStage(
|
||||
actor.organizationId,
|
||||
row.pipelineName,
|
||||
row.stageName,
|
||||
status,
|
||||
);
|
||||
affected.set(pipelineId, pipelineName);
|
||||
|
||||
const duplicate = await this.prisma.deal.findFirst({
|
||||
where: { organizationId: actor.organizationId, pipelineId, title },
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const personId = await this.findPersonId(actor.organizationId, row);
|
||||
const companyId = await this.findCompanyId(actor.organizationId, row.companyName);
|
||||
const ownerId =
|
||||
(await this.findUserId(actor.organizationId, row.ownerName)) ??
|
||||
resolveOwnerId(actor, null);
|
||||
const amount = parseAmount(row.amount);
|
||||
const expectedCloseAt = parseJalaliDateTime(row.expectedCloseAt);
|
||||
const customFields: Record<string, string> = {};
|
||||
if (row.didarId?.trim()) customFields.didarId = row.didarId.trim();
|
||||
|
||||
await this.prisma.deal.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
pipelineId,
|
||||
stageId,
|
||||
title,
|
||||
amount: amount == null ? null : new Prisma.Decimal(amount),
|
||||
currency: (row.currency || 'IRR').trim() || 'IRR',
|
||||
status,
|
||||
source: row.source?.trim() || null,
|
||||
notes: row.notes?.trim() || null,
|
||||
tags: parseTags(row.tags),
|
||||
customFields: toJsonCustomFields(customFields),
|
||||
expectedCloseAt,
|
||||
personId,
|
||||
companyId,
|
||||
ownerId,
|
||||
},
|
||||
});
|
||||
result.created += 1;
|
||||
const outcome = await this.processRow(actor, row, rowNum, result, caches, ctx);
|
||||
if (outcome) affected.set(outcome.pipelineId, outcome.pipelineName);
|
||||
} catch (e) {
|
||||
result.errors.push({
|
||||
row: rowNum,
|
||||
@@ -242,35 +307,322 @@ export class DealsExcelService {
|
||||
});
|
||||
}
|
||||
}
|
||||
return affected;
|
||||
}
|
||||
|
||||
const entries = [...affected.entries()];
|
||||
if (entries[0]) {
|
||||
result.boardId = entries[0][0];
|
||||
result.boards = entries.map(([, n]) => n);
|
||||
private async processRow(
|
||||
actor: Actor,
|
||||
row: Record<string, string>,
|
||||
rowNum: number,
|
||||
result: ImportContactsResult,
|
||||
caches: ExcelImportLookupCaches,
|
||||
ctx: RowContext,
|
||||
): Promise<{ pipelineId: string; pipelineName: string } | null> {
|
||||
const orgId = actor.organizationId;
|
||||
const didarId = cleanCode(row.didarId);
|
||||
const personLabel = [row.personFirstName, row.personName]
|
||||
.map((s) => (s ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
let title = (row.title || '').trim();
|
||||
if (title.length < 2) {
|
||||
title = personLabel ? `معامله ${personLabel}` : didarId ? `معامله ${didarId}` : '';
|
||||
}
|
||||
return { ...result, ...chunkImportMeta(ctx) };
|
||||
if (title.length < 2) {
|
||||
result.errors.push({ row: rowNum, message: 'عنوان معامله الزامی است' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineItem = await this.resolveLineItem(actor, row);
|
||||
const customFields = await this.collectCustomFields(orgId, row, ctx);
|
||||
if (didarId) customFields.didarId = didarId;
|
||||
const visibility = row.visibility?.trim();
|
||||
if (visibility) customFields.visibility = visibility;
|
||||
|
||||
if (didarId) {
|
||||
const existingId = await this.findDealByRef(orgId, didarId, caches);
|
||||
if (existingId) {
|
||||
await this.mergeIntoExisting(existingId, customFields, lineItem);
|
||||
result.skipped += 1;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const status = parseStatus(row.status);
|
||||
const { pipelineId, stageId, pipelineName } = await this.resolvePipelineAndStage(
|
||||
orgId,
|
||||
row.pipelineName,
|
||||
row.stageName,
|
||||
status,
|
||||
ctx,
|
||||
);
|
||||
|
||||
const personId = await this.findOrCreatePerson(actor, row, caches);
|
||||
const companyId = await this.findOrCreateCompany(actor, row, caches);
|
||||
|
||||
if (!didarId) {
|
||||
const duplicate = await this.prisma.deal.findFirst({
|
||||
where: { organizationId: orgId, pipelineId, title, personId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate) {
|
||||
result.skipped += 1;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const ownerName = row.ownerName?.trim() ?? '';
|
||||
const matchedOwner = await this.findUserId(orgId, ownerName, caches);
|
||||
const warnKey = `warn:${ownerName.toLowerCase()}`;
|
||||
if (ownerName && !matchedOwner && !caches.user.has(warnKey)) {
|
||||
caches.user.set(warnKey, null);
|
||||
result.errors.push({
|
||||
row: rowNum,
|
||||
message: `مسئول «${ownerName}» در تیم پیدا نشد؛ معاملههای او به کاربر ایمپورتر اختصاص داده شد`,
|
||||
});
|
||||
}
|
||||
const ownerId = matchedOwner ?? resolveOwnerId(actor, null);
|
||||
const createdById =
|
||||
(await this.findUserId(orgId, row.creatorName?.trim() ?? '', caches)) ?? actor.userId;
|
||||
|
||||
const rawAmount = parseAmount(row.amount);
|
||||
const amount =
|
||||
rawAmount && rawAmount > 0
|
||||
? rawAmount
|
||||
: lineItem
|
||||
? lineItem.quantity * lineItem.unitPrice
|
||||
: null;
|
||||
const createdAt = parseDate(row.createdAt);
|
||||
const closedAt = parseDate(row.closedAt) ?? (status !== 'open' && createdAt ? createdAt : null);
|
||||
const currency = (row.currency || 'IRR').trim() || 'IRR';
|
||||
|
||||
const created = await this.prisma.deal.create({
|
||||
data: {
|
||||
organizationId: orgId,
|
||||
pipelineId,
|
||||
stageId,
|
||||
title: title.slice(0, 200),
|
||||
code: cleanText(row.code, 80),
|
||||
importSource: didarId ? 'didar' : 'excel',
|
||||
sourceRef: didarId || null,
|
||||
amount: amount == null ? null : new Prisma.Decimal(amount),
|
||||
currency,
|
||||
status,
|
||||
probability: parseProbability(row.probability),
|
||||
source: cleanText(row.source, 120),
|
||||
sourceDetail: cleanText(row.sourceDetail, 200),
|
||||
referrer: cleanText(row.referrer, 200),
|
||||
lostReason: cleanText(row.lostReason, 400),
|
||||
lostNote: cleanText(row.lostNote),
|
||||
notes: cleanText(stripHtml(row.notes ?? '')),
|
||||
tags: parseTags(row.tags),
|
||||
customFields: toJsonCustomFields(customFields),
|
||||
expectedCloseAt: parseDate(row.expectedCloseAt),
|
||||
closedAt,
|
||||
personId,
|
||||
companyId,
|
||||
ownerId,
|
||||
createdById,
|
||||
...(createdAt ? { createdAt } : {}),
|
||||
...(lineItem
|
||||
? {
|
||||
lineItems: {
|
||||
create: [{ ...lineItem, quantity: new Prisma.Decimal(lineItem.quantity), unitPrice: new Prisma.Decimal(lineItem.unitPrice), position: 0 }],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (didarId) caches.deal.set(`ref:${didarId}`, created.id);
|
||||
result.created += 1;
|
||||
return { pipelineId, pipelineName };
|
||||
}
|
||||
|
||||
/** ردیف تکراری با همان کد دیدار: محصول جدید اضافه و فیلدهای سفارشی تکمیل میشود */
|
||||
private async mergeIntoExisting(
|
||||
dealId: string,
|
||||
customFields: Record<string, string>,
|
||||
lineItem: LineItemInput | null,
|
||||
) {
|
||||
const deal = await this.prisma.deal.findUnique({
|
||||
where: { id: dealId },
|
||||
select: {
|
||||
customFields: true,
|
||||
currency: true,
|
||||
lineItems: { select: { name: true, position: true } },
|
||||
},
|
||||
});
|
||||
if (!deal) return;
|
||||
const merged = { ...asCustomFields(deal.customFields) };
|
||||
let changed = false;
|
||||
for (const [k, v] of Object.entries(customFields)) {
|
||||
if (v && merged[k] == null) {
|
||||
merged[k] = v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
await this.prisma.deal.update({
|
||||
where: { id: dealId },
|
||||
data: { customFields: toJsonCustomFields(merged) },
|
||||
});
|
||||
}
|
||||
if (lineItem && !deal.lineItems.some((li) => li.name === lineItem.name)) {
|
||||
await this.prisma.dealLineItem.create({
|
||||
data: {
|
||||
dealId,
|
||||
productId: lineItem.productId,
|
||||
name: lineItem.name,
|
||||
quantity: new Prisma.Decimal(lineItem.quantity),
|
||||
unitPrice: new Prisma.Decimal(lineItem.unitPrice),
|
||||
currency: lineItem.currency,
|
||||
position: deal.lineItems.length,
|
||||
},
|
||||
});
|
||||
const items = await this.prisma.dealLineItem.findMany({
|
||||
where: { dealId },
|
||||
select: { quantity: true, unitPrice: true },
|
||||
});
|
||||
const total = items.reduce((s, it) => s + Number(it.quantity) * Number(it.unitPrice), 0);
|
||||
await this.prisma.deal.update({
|
||||
where: { id: dealId },
|
||||
data: { amount: new Prisma.Decimal(total) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async findDealByRef(
|
||||
organizationId: string,
|
||||
ref: string,
|
||||
caches: ExcelImportLookupCaches,
|
||||
): Promise<string | null> {
|
||||
const key = `ref:${ref}`;
|
||||
if (caches.deal.has(key)) return caches.deal.get(key) ?? null;
|
||||
const row = await this.prisma.deal.findFirst({
|
||||
where: { organizationId, sourceRef: ref },
|
||||
select: { id: true },
|
||||
});
|
||||
caches.deal.set(key, row?.id ?? null);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
private async resolveLineItem(actor: Actor, row: Record<string, string>): Promise<LineItemInput | null> {
|
||||
const name = row.productName?.trim();
|
||||
if (!name) return null;
|
||||
const orgId = actor.organizationId;
|
||||
const sku = row.productCode?.trim() || null;
|
||||
const quantity = parseAmount(row.productQty) || 1;
|
||||
const price = parseAmount(row.productPrice);
|
||||
const total = parseAmount(row.productTotal);
|
||||
const unitPrice = total != null && total > 0 ? total / quantity : (price ?? 0);
|
||||
const currency = (row.productCurrency || row.currency || 'IRR').trim() || 'IRR';
|
||||
|
||||
let product = sku
|
||||
? await this.prisma.product.findFirst({
|
||||
where: { organizationId: orgId, sku },
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
product ??= await this.prisma.product.findFirst({
|
||||
where: { organizationId: orgId, name: { equals: name, mode: 'insensitive' } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!product) {
|
||||
product = await this.prisma.product.create({
|
||||
data: {
|
||||
organizationId: orgId,
|
||||
name: name.slice(0, 200),
|
||||
sku,
|
||||
category: row.productCategory?.trim() || row.productGroup?.trim() || null,
|
||||
unitPrice: price == null ? null : new Prisma.Decimal(price),
|
||||
currency,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
return { productId: product.id, name: name.slice(0, 200), quantity, unitPrice, currency };
|
||||
}
|
||||
|
||||
private async loadCustomFieldKeys(organizationId: string) {
|
||||
const defs = await this.prisma.customFieldDefinition.findMany({
|
||||
where: { organizationId, entityType: 'deal' },
|
||||
select: { key: true, label: true },
|
||||
});
|
||||
return new Map(defs.map((d) => [compactExcelHeader(d.label), d.key]));
|
||||
}
|
||||
|
||||
/** ستونهای ناشناخته (cf:عنوان) → فیلد سفارشی معامله؛ اگر تعریف نشده باشد ساخته میشود */
|
||||
private async collectCustomFields(
|
||||
organizationId: string,
|
||||
row: Record<string, string>,
|
||||
ctx: RowContext,
|
||||
): Promise<Record<string, string>> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(row)) {
|
||||
if (!rawKey.startsWith(EXCEL_CUSTOM_FIELD_PREFIX)) continue;
|
||||
const value = (rawValue ?? '').trim();
|
||||
if (!value) continue;
|
||||
const label = rawKey.slice(EXCEL_CUSTOM_FIELD_PREFIX.length).trim();
|
||||
if (!label) continue;
|
||||
const compact = compactExcelHeader(label);
|
||||
let key = ctx.customFieldKeys.get(compact);
|
||||
if (!key) {
|
||||
key = customFieldKey(compact);
|
||||
const maxPos = await this.prisma.customFieldDefinition.aggregate({
|
||||
where: { organizationId, entityType: 'deal' },
|
||||
_max: { position: true },
|
||||
});
|
||||
await this.prisma.customFieldDefinition.upsert({
|
||||
where: { organizationId_entityType_key: { organizationId, entityType: 'deal', key } },
|
||||
create: {
|
||||
organizationId,
|
||||
entityType: 'deal',
|
||||
key,
|
||||
label: label.slice(0, 120),
|
||||
fieldType: 'text',
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
ctx.customFieldKeys.set(compact, key);
|
||||
}
|
||||
out[key] = value.slice(0, 1000);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private async resolvePipelineAndStage(
|
||||
organizationId: string,
|
||||
pipelineName?: string,
|
||||
stageName?: string,
|
||||
status: DealStatus = 'open',
|
||||
pipelineName: string | undefined,
|
||||
stageName: string | undefined,
|
||||
status: DealStatus,
|
||||
ctx: RowContext,
|
||||
) {
|
||||
const name = pipelineName?.trim() || 'کاریز فروش';
|
||||
const wanted = stageName?.trim();
|
||||
let pipeline = await this.prisma.pipeline.findFirst({
|
||||
where: { organizationId, name: { equals: name, mode: 'insensitive' } },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
const cacheKey = name.toLowerCase();
|
||||
let pipeline = ctx.pipelines.get(cacheKey);
|
||||
if (!pipeline) {
|
||||
const created = await this.pipelines.create(organizationId, name);
|
||||
pipeline = await this.prisma.pipeline.findFirstOrThrow({
|
||||
where: { id: created.id },
|
||||
let row = await this.prisma.pipeline.findFirst({
|
||||
where: { organizationId, name: { equals: name, mode: 'insensitive' } },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!row) {
|
||||
const created = await this.pipelines.create(organizationId, name);
|
||||
row = await this.prisma.pipeline.findFirstOrThrow({
|
||||
where: { id: created.id },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
}
|
||||
pipeline = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
stages: row.stages.map((s) => ({ id: s.id, name: s.name, isWon: s.isWon, isLost: s.isLost })),
|
||||
};
|
||||
ctx.pipelines.set(cacheKey, pipeline);
|
||||
}
|
||||
|
||||
const wanted = stageName?.trim();
|
||||
if (wanted) {
|
||||
const existing = pipeline.stages.find(
|
||||
(s) => s.name.trim().toLowerCase() === wanted.toLowerCase(),
|
||||
@@ -281,6 +633,12 @@ export class DealsExcelService {
|
||||
const added = await this.pipelines.createStage(organizationId, pipeline.id, wanted);
|
||||
const stage = added.stages.find((s) => s.name === wanted) ?? added.stages.at(-1);
|
||||
if (!stage) throw new Error('کاریز مرحلهای ندارد');
|
||||
pipeline.stages = added.stages.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
isWon: s.isWon,
|
||||
isLost: s.isLost,
|
||||
}));
|
||||
return { pipelineId: pipeline.id, stageId: stage.id, pipelineName: pipeline.name };
|
||||
}
|
||||
|
||||
@@ -295,46 +653,120 @@ export class DealsExcelService {
|
||||
return { pipelineId: pipeline.id, stageId: stage.id, pipelineName: pipeline.name };
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
/** شخص معامله: کد دیدار ← موبایل ← نام دقیق؛ در غیر این صورت ساخته میشود */
|
||||
private async findOrCreatePerson(
|
||||
actor: Actor,
|
||||
row: Record<string, string>,
|
||||
caches: ExcelImportLookupCaches,
|
||||
): Promise<string | null> {
|
||||
const orgId = actor.organizationId;
|
||||
const code = cleanCode(row.personCode);
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
if (mobile) {
|
||||
const needle = mobile.replace(/^\+98|^98|^0/, '');
|
||||
const byMobile = await this.prisma.person.findFirst({
|
||||
where: { organizationId, mobile: { contains: needle } },
|
||||
const mobileCore = mobile ? mobile.replace(/^\+98|^98|^0/, '') : '';
|
||||
const validMobile = mobileCore.length >= 8 ? mobile : null;
|
||||
const first = (row.personFirstName ?? '').trim();
|
||||
const last = (row.personName ?? '').trim();
|
||||
const fullName = [first, last].filter(Boolean).join(' ');
|
||||
if (!code && !validMobile && !fullName) return null;
|
||||
|
||||
const cacheKey = `p:${code}|${validMobile ?? ''}|${fullName}`;
|
||||
if (caches.person.has(cacheKey)) return caches.person.get(cacheKey) ?? null;
|
||||
|
||||
let found: { id: string } | null = null;
|
||||
if (code) {
|
||||
found = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
OR: [{ sourceRef: code }, { customFields: { path: ['didarId'], equals: code } }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byMobile) return byMobile.id;
|
||||
}
|
||||
const name = (row.personName ?? '').trim();
|
||||
if (name) {
|
||||
const byName = await this.prisma.person.findFirst({
|
||||
if (!found && validMobile) {
|
||||
found = await this.prisma.person.findFirst({
|
||||
where: { organizationId: orgId, mobile: { contains: mobileCore } },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
if (!found && fullName && !code && !validMobile) {
|
||||
found = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
organizationId: orgId,
|
||||
OR: [
|
||||
{ lastName: { contains: name, mode: 'insensitive' } },
|
||||
{ firstName: { contains: name.split(/\s+/)[0]!, mode: 'insensitive' } },
|
||||
{ firstName: first, lastName: last },
|
||||
{ firstName: '', lastName: fullName },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byName) return byName.id;
|
||||
}
|
||||
return null;
|
||||
if (!found) {
|
||||
found = await this.prisma.person.create({
|
||||
data: {
|
||||
organizationId: orgId,
|
||||
firstName: first.slice(0, 120),
|
||||
lastName: (last || (first ? '' : 'بی نام')).slice(0, 120),
|
||||
mobile: validMobile,
|
||||
importSource: code ? 'didar' : 'excel',
|
||||
sourceRef: code || null,
|
||||
customFields: toJsonCustomFields(code ? { didarId: code } : {}),
|
||||
ownerId: resolveOwnerId(actor, null),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
caches.person.set(cacheKey, found.id);
|
||||
return found.id;
|
||||
}
|
||||
|
||||
private async findCompanyId(organizationId: string, name?: string) {
|
||||
const n = name?.trim();
|
||||
if (!n) return null;
|
||||
const row = await this.prisma.company.findFirst({
|
||||
where: { organizationId, name: { equals: n, mode: 'insensitive' } },
|
||||
private async findOrCreateCompany(
|
||||
actor: Actor,
|
||||
row: Record<string, string>,
|
||||
caches: ExcelImportLookupCaches,
|
||||
): Promise<string | null> {
|
||||
const orgId = actor.organizationId;
|
||||
const name = (row.companyName ?? '').trim();
|
||||
const code = cleanCode(row.companyCode);
|
||||
if (!name) return null;
|
||||
const cacheKey = `c:${code}|${name.toLowerCase()}`;
|
||||
if (caches.company.has(cacheKey)) return caches.company.get(cacheKey) ?? null;
|
||||
|
||||
let found = code
|
||||
? await this.prisma.company.findFirst({
|
||||
where: { organizationId: orgId, sourceRef: code },
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
found ??= await this.prisma.company.findFirst({
|
||||
where: { organizationId: orgId, name: { equals: name, mode: 'insensitive' } },
|
||||
select: { id: true },
|
||||
});
|
||||
return row?.id ?? null;
|
||||
if (!found) {
|
||||
found = await this.prisma.company.create({
|
||||
data: {
|
||||
organizationId: orgId,
|
||||
name: name.slice(0, 200),
|
||||
phone: row.companyPhone?.trim() || null,
|
||||
importSource: code ? 'didar' : 'excel',
|
||||
sourceRef: code || null,
|
||||
ownerId: resolveOwnerId(actor, null),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
caches.company.set(cacheKey, found.id);
|
||||
return found.id;
|
||||
}
|
||||
|
||||
private async findUserId(organizationId: string, name?: string) {
|
||||
const n = name?.trim();
|
||||
private async findUserId(
|
||||
organizationId: string,
|
||||
name: string,
|
||||
caches: ExcelImportLookupCaches,
|
||||
): Promise<string | null> {
|
||||
const n = name.trim();
|
||||
if (!n) return null;
|
||||
const key = n.toLowerCase();
|
||||
if (caches.user.has(key)) return caches.user.get(key) ?? null;
|
||||
const row = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
fullName: { contains: n, mode: 'insensitive' },
|
||||
@@ -342,6 +774,15 @@ export class DealsExcelService {
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
caches.user.set(key, row?.id ?? null);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
type LineItemInput = {
|
||||
productId: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto';
|
||||
import { CreateDealDto, ImportDealRowsDto, MoveDealDto, UpdateDealDto } from './dto';
|
||||
import { DealsService } from './deals.service';
|
||||
import { DealsExcelService } from './deals-excel.service';
|
||||
import { brand } from '@mento/shared';
|
||||
@@ -83,6 +83,11 @@ export class DealsController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post('import/rows')
|
||||
importRows(@CurrentUser() user: Actor, @Body() dto: ImportDealRowsDto) {
|
||||
return this.excelService.importRows(user, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.dealsService.get(user.organizationId, id, user);
|
||||
|
||||
@@ -30,6 +30,7 @@ const dealInclude = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
createdBy: { select: { id: true, fullName: true } },
|
||||
assignees: { select: { id: true, fullName: true } },
|
||||
lineItems: { orderBy: { position: 'asc' as const } },
|
||||
} satisfies Prisma.DealInclude;
|
||||
@@ -138,6 +139,18 @@ export class DealsService {
|
||||
expectedCloseAt: dto.expectedCloseAt
|
||||
? new Date(dto.expectedCloseAt)
|
||||
: null,
|
||||
code: emptyToNull(dto.code),
|
||||
probability: normalizeProbability(dto.probability),
|
||||
sourceDetail: emptyToNull(dto.sourceDetail),
|
||||
referrer: emptyToNull(dto.referrer),
|
||||
lostReason: emptyToNull(dto.lostReason),
|
||||
lostNote: emptyToNull(dto.lostNote),
|
||||
closedAt: dto.closedAt
|
||||
? new Date(dto.closedAt)
|
||||
: status !== 'open'
|
||||
? new Date()
|
||||
: null,
|
||||
createdById: actor.userId,
|
||||
personId: emptyToNull(dto.personId ?? null),
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
@@ -220,6 +233,12 @@ export class DealsService {
|
||||
data.customFields = toJsonCustomFields(dto.customFields);
|
||||
}
|
||||
if ('lostReason' in dto) data.lostReason = emptyToNull(dto.lostReason);
|
||||
if ('lostNote' in dto) data.lostNote = emptyToNull(dto.lostNote);
|
||||
if ('code' in dto) data.code = emptyToNull(dto.code);
|
||||
if ('probability' in dto) data.probability = normalizeProbability(dto.probability);
|
||||
if ('sourceDetail' in dto) data.sourceDetail = emptyToNull(dto.sourceDetail);
|
||||
if ('referrer' in dto) data.referrer = emptyToNull(dto.referrer);
|
||||
if ('closedAt' in dto) data.closedAt = dto.closedAt ? new Date(dto.closedAt) : null;
|
||||
if ('expectedCloseAt' in dto) {
|
||||
data.expectedCloseAt = dto.expectedCloseAt
|
||||
? new Date(dto.expectedCloseAt)
|
||||
@@ -338,6 +357,9 @@ export class DealsService {
|
||||
position,
|
||||
status,
|
||||
lostReason: stage.isLost ? undefined : null,
|
||||
...(status !== deal.status
|
||||
? { closedAt: status === 'open' ? null : new Date() }
|
||||
: {}),
|
||||
},
|
||||
include: dealInclude,
|
||||
});
|
||||
@@ -494,6 +516,11 @@ function emptyToNull(value?: string | null): string | null {
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeProbability(value?: number | null): number | null {
|
||||
if (value == null || Number.isNaN(Number(value))) return null;
|
||||
return Math.max(0, Math.min(100, Math.round(Number(value))));
|
||||
}
|
||||
|
||||
function normalizeTags(tags?: string[]): string[] {
|
||||
if (!tags?.length) return [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
Allow,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
@@ -118,6 +121,43 @@ export class CreateDealDto {
|
||||
@IsBoolean()
|
||||
isRotten?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
code?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
probability?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
sourceDetail?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
referrer?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
lostNote?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
closedAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
@@ -152,6 +192,12 @@ export class CreateDealDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => DealLineItemInputDto)
|
||||
lineItems?: DealLineItemInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
lostReason?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateDealDto {
|
||||
@@ -201,6 +247,43 @@ export class UpdateDealDto {
|
||||
@MaxLength(400)
|
||||
lostReason?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
code?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
probability?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
sourceDetail?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
referrer?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
lostNote?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
closedAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
@@ -237,6 +320,27 @@ export class UpdateDealDto {
|
||||
lineItems?: DealLineItemInputDto[];
|
||||
}
|
||||
|
||||
/** تکههای JSON از ایمپورت مرورگرگرا */
|
||||
export class ImportDealRowsDto {
|
||||
@IsArray()
|
||||
@Allow()
|
||||
rows!: Array<Record<string, string>>;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset!: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
total!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export class MoveDealDto {
|
||||
@IsString()
|
||||
stageId!: string;
|
||||
|
||||
@@ -38,6 +38,14 @@ type DealRow = {
|
||||
isRotten: boolean;
|
||||
customFields: unknown;
|
||||
expectedCloseAt: Date | null;
|
||||
code: string | null;
|
||||
importSource: string;
|
||||
sourceRef: string | null;
|
||||
probability: number | null;
|
||||
sourceDetail: string | null;
|
||||
referrer: string | null;
|
||||
lostNote: string | null;
|
||||
closedAt: Date | null;
|
||||
position: number;
|
||||
pipelineId: string;
|
||||
stageId: string;
|
||||
@@ -46,6 +54,7 @@ type DealRow = {
|
||||
person: { id: string; firstName: string; lastName: string } | null;
|
||||
company: { id: string; name: string } | null;
|
||||
owner: { id: string; fullName: string } | null;
|
||||
createdBy?: { id: string; fullName: string } | null;
|
||||
assignees?: Array<{ id: string; fullName: string }>;
|
||||
lineItems?: Array<{
|
||||
id: string;
|
||||
@@ -326,6 +335,15 @@ export class PipelinesService {
|
||||
isRotten: row.isRotten,
|
||||
customFields: asCustomFields(row.customFields),
|
||||
expectedCloseAt: row.expectedCloseAt?.toISOString() ?? null,
|
||||
code: row.code,
|
||||
importSource: row.importSource,
|
||||
sourceRef: row.sourceRef,
|
||||
probability: row.probability,
|
||||
sourceDetail: row.sourceDetail,
|
||||
referrer: row.referrer,
|
||||
lostNote: row.lostNote,
|
||||
closedAt: row.closedAt?.toISOString() ?? null,
|
||||
createdBy: row.createdBy ?? null,
|
||||
position: row.position,
|
||||
pipelineId: row.pipelineId,
|
||||
stageId: row.stageId,
|
||||
|
||||
@@ -39,17 +39,24 @@ export type ClientExcelPositionalColumn = { key: string; label: string };
|
||||
export type ClientExcelReadOptions = {
|
||||
positionalColumns?: ClientExcelPositionalColumn[];
|
||||
detectHeaderless?: (firstRowCells: string[]) => boolean;
|
||||
/**
|
||||
* مقدار خام سلول بهجای متن قالببندیشده؛ خروجی دیدار به کدهای عددی قالب تاریخ میدهد
|
||||
* و بدون این گزینه «4231» بهصورت «8/1/11» خوانده میشود.
|
||||
*/
|
||||
rawValues?: boolean;
|
||||
/** ستونهای ناشناخته با کلید `${prefix}${عنوان ستون}` ارسال میشوند (فیلد سفارشی) */
|
||||
customFieldPrefix?: string;
|
||||
};
|
||||
|
||||
function readMatrix(fileBuffer: ArrayBuffer) {
|
||||
const wb = XLSX.read(fileBuffer, { type: 'array', cellDates: true });
|
||||
function readMatrix(fileBuffer: ArrayBuffer, rawValues = false) {
|
||||
const wb = XLSX.read(fileBuffer, { type: 'array', cellDates: !rawValues });
|
||||
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,
|
||||
raw: rawValues,
|
||||
}) as unknown[][];
|
||||
}
|
||||
|
||||
@@ -60,11 +67,13 @@ export async function previewExcelClient(
|
||||
fields: ExcelFieldOption[],
|
||||
options?: ClientExcelReadOptions,
|
||||
): Promise<ExcelPreviewResult> {
|
||||
const matrix = readMatrix(await file.arrayBuffer());
|
||||
const matrix = readMatrix(await file.arrayBuffer(), options?.rawValues);
|
||||
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
|
||||
const headerless =
|
||||
Boolean(options?.positionalColumns?.length) &&
|
||||
Boolean(options?.detectHeaderless?.(headerRow));
|
||||
const prefix = options?.customFieldPrefix;
|
||||
const allFields = [...fields];
|
||||
|
||||
const columns: ExcelPreviewResult['columns'] = [];
|
||||
const suggested: Record<string, string> = {};
|
||||
@@ -96,6 +105,11 @@ export async function previewExcelClient(
|
||||
if (key && !used.has(key)) {
|
||||
suggested[header] = key;
|
||||
used.add(key);
|
||||
} else if (!key && prefix) {
|
||||
const cfKey = `${prefix}${header}`;
|
||||
allFields.push({ key: cfKey, label: `فیلد سفارشی: ${header}` });
|
||||
suggested[header] = cfKey;
|
||||
used.add(cfKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -116,7 +130,7 @@ export async function previewExcelClient(
|
||||
|
||||
return {
|
||||
columns,
|
||||
fields,
|
||||
fields: allFields,
|
||||
suggested,
|
||||
needsMapping: headerless
|
||||
? false
|
||||
@@ -132,7 +146,7 @@ export async function readMappedRowsClient(
|
||||
mapping?: Record<string, string>,
|
||||
options?: ClientExcelReadOptions,
|
||||
): Promise<Record<string, string>[]> {
|
||||
const matrix = readMatrix(await file.arrayBuffer());
|
||||
const matrix = readMatrix(await file.arrayBuffer(), options?.rawValues);
|
||||
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
|
||||
const headerless =
|
||||
!mapping &&
|
||||
@@ -152,6 +166,9 @@ export async function readMappedRowsClient(
|
||||
if (!header) return;
|
||||
const key = resolveFieldKey(header, aliases, mapping);
|
||||
if (key) colMap.set(idx, key);
|
||||
else if (!mapping && options?.customFieldPrefix) {
|
||||
colMap.set(idx, `${options.customFieldPrefix}${header}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,14 @@ const pipelineId = ref('');
|
||||
const ownerId = ref('');
|
||||
const selectedAssigneeIds = ref<string[]>([]);
|
||||
const lostReason = ref('');
|
||||
const lostNote = ref('');
|
||||
const dealCode = ref('');
|
||||
const probability = ref('');
|
||||
const sourceDetail = ref('');
|
||||
const referrer = ref('');
|
||||
const closedAt = ref('');
|
||||
const createdByName = ref('');
|
||||
const sourceRef = ref('');
|
||||
const status = ref('');
|
||||
const customFields = ref<Record<string, unknown>>({});
|
||||
const lines = ref<LineDraft[]>([]);
|
||||
@@ -251,6 +259,14 @@ async function load() {
|
||||
ownerId.value = data.owner?.id ?? auth.user?.id ?? '';
|
||||
selectedAssigneeIds.value = (data.assignees ?? []).map((a) => a.id);
|
||||
lostReason.value = data.lostReason ?? '';
|
||||
lostNote.value = data.lostNote ?? '';
|
||||
dealCode.value = data.code ?? '';
|
||||
probability.value = data.probability == null ? '' : String(data.probability);
|
||||
sourceDetail.value = data.sourceDetail ?? '';
|
||||
referrer.value = data.referrer ?? '';
|
||||
closedAt.value = data.closedAt ? data.closedAt.slice(0, 10) : '';
|
||||
createdByName.value = data.createdBy?.fullName ?? '';
|
||||
sourceRef.value = data.importSource === 'didar' && data.sourceRef ? data.sourceRef : '';
|
||||
status.value = data.status;
|
||||
customFields.value = { ...(data.customFields ?? {}) };
|
||||
lines.value = (data.lineItems ?? []).map((item) => ({
|
||||
@@ -302,6 +318,10 @@ function payload(): CreateDealRequest {
|
||||
? null
|
||||
: Number(amount.value),
|
||||
source: source.value || undefined,
|
||||
sourceDetail: sourceDetail.value.trim() || null,
|
||||
referrer: referrer.value.trim() || null,
|
||||
code: dealCode.value.trim() || null,
|
||||
probability: probability.value === '' ? null : Number(probability.value),
|
||||
notes: notes.value || undefined,
|
||||
tags: parseTags(tagsRaw.value),
|
||||
isStale: health.value === 'stale',
|
||||
@@ -374,6 +394,8 @@ async function submit() {
|
||||
await api.patch(`/deals/${id.value}`, {
|
||||
...payload(),
|
||||
lostReason: lostReason.value || null,
|
||||
lostNote: lostNote.value.trim() || null,
|
||||
closedAt: closedAt.value || null,
|
||||
});
|
||||
if (stageId.value) {
|
||||
await api.patch(`/deals/${id.value}/move`, { stageId: stageId.value });
|
||||
@@ -679,13 +701,43 @@ onMounted(load);
|
||||
<UiInput v-model="source" placeholder="مثال: وبسایت، معرفی، نمایشگاه…" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="شیوه آشنایی دلخواه">
|
||||
<UiInput v-model="sourceDetail" placeholder="توضیح آزاد" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="معرف">
|
||||
<UiInput v-model="referrer" placeholder="نام معرف" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="کد دستی معامله">
|
||||
<UiInput v-model="dealCode" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="احتمال موفقیت (درصد)">
|
||||
<UiInput v-model="probability" type="number" min="0" max="100" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="برچسبها">
|
||||
<UiInput v-model="tagsRaw" placeholder="سازمانی، فوری (با کاما جدا کنید)" />
|
||||
</UiField>
|
||||
|
||||
<UiField v-if="!isNew" label="تاریخ انجام معامله">
|
||||
<JalaliDateInput v-model="closedAt" placeholder="انتخاب تاریخ" />
|
||||
</UiField>
|
||||
|
||||
<UiField v-if="!isNew" label="دلیل شکست (در صورت باخت معامله)" class="crm-col-span-full">
|
||||
<UiInput id="lostReason" v-model="lostReason" placeholder="علت از دست رفتن معامله را بنویسید…" />
|
||||
</UiField>
|
||||
|
||||
<UiField v-if="!isNew" label="یادداشت شکست" class="crm-col-span-full">
|
||||
<UiInput v-model="lostNote" />
|
||||
</UiField>
|
||||
|
||||
<p v-if="!isNew && (createdByName || sourceRef)" class="hint crm-col-span-full">
|
||||
<span v-if="createdByName">ثبتکننده: {{ createdByName }}</span>
|
||||
<span v-if="createdByName && sourceRef"> · </span>
|
||||
<span v-if="sourceRef">کد دیدار: {{ toFaDigits(sourceRef) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- بخش محصولات / خدمات -->
|
||||
|
||||
@@ -24,7 +24,22 @@ import UiModal from '@/components/ui/UiModal.vue';
|
||||
import DealFormView from '@/views/DealFormView.vue';
|
||||
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
|
||||
import type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
import {
|
||||
DEAL_EXCEL_ALIASES,
|
||||
DEAL_EXCEL_COLUMNS,
|
||||
EXCEL_CUSTOM_FIELD_PREFIX,
|
||||
brand,
|
||||
} from '@mento/shared';
|
||||
|
||||
const dealExcelFields = DEAL_EXCEL_COLUMNS.filter((c) => c.key !== '_type').map((c) => ({
|
||||
key: c.key,
|
||||
label: c.header,
|
||||
required: c.key === 'title',
|
||||
}));
|
||||
const dealClientReadOptions = {
|
||||
rawValues: true,
|
||||
customFieldPrefix: EXCEL_CUSTOM_FIELD_PREFIX,
|
||||
};
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
|
||||
@@ -506,6 +521,10 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
<ExcelImportFlow
|
||||
preview-path="/deals/import/preview"
|
||||
import-path="/deals/import"
|
||||
rows-import-path="/deals/import/rows"
|
||||
:client-aliases="DEAL_EXCEL_ALIASES"
|
||||
:client-fields="dealExcelFields"
|
||||
:client-read-options="dealClientReadOptions"
|
||||
:disabled="loading || busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
|
||||
@@ -901,11 +901,24 @@ export interface PipelineDto {
|
||||
export interface DealDto {
|
||||
id: string;
|
||||
title: string;
|
||||
/** کد دستی معامله */
|
||||
code: string | null;
|
||||
/** منبع ورود: manual | didar | excel */
|
||||
importSource: string;
|
||||
/** شناسه در مبدأ (کد دیدار) */
|
||||
sourceRef: string | null;
|
||||
amount: number | null;
|
||||
currency: string;
|
||||
status: DealStatus;
|
||||
/** احتمال موفقیت ۰ تا ۱۰۰ */
|
||||
probability: number | null;
|
||||
source: string | null;
|
||||
sourceDetail: string | null;
|
||||
referrer: string | null;
|
||||
lostReason: string | null;
|
||||
lostNote: string | null;
|
||||
closedAt: string | null;
|
||||
createdBy: UserSummaryDto | null;
|
||||
notes: string | null;
|
||||
tags: string[];
|
||||
isStale: boolean;
|
||||
@@ -955,14 +968,21 @@ export interface CreateDealRequest {
|
||||
title: string;
|
||||
pipelineId?: string;
|
||||
stageId?: string;
|
||||
code?: string | null;
|
||||
amount?: number | null;
|
||||
currency?: string;
|
||||
probability?: number | null;
|
||||
source?: string;
|
||||
sourceDetail?: string | null;
|
||||
referrer?: string | null;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
isStale?: boolean;
|
||||
isRotten?: boolean;
|
||||
expectedCloseAt?: string | null;
|
||||
lostReason?: string | null;
|
||||
lostNote?: string | null;
|
||||
closedAt?: string | null;
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
ownerId?: string | null;
|
||||
@@ -973,14 +993,20 @@ export interface CreateDealRequest {
|
||||
|
||||
export interface UpdateDealRequest {
|
||||
title?: string;
|
||||
code?: string | null;
|
||||
amount?: number | null;
|
||||
currency?: string;
|
||||
probability?: number | null;
|
||||
source?: string;
|
||||
sourceDetail?: string | null;
|
||||
referrer?: string | null;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
isStale?: boolean;
|
||||
isRotten?: boolean;
|
||||
lostReason?: string | null;
|
||||
lostNote?: string | null;
|
||||
closedAt?: string | null;
|
||||
expectedCloseAt?: string | null;
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
@@ -990,6 +1016,80 @@ export interface UpdateDealRequest {
|
||||
lineItems?: DealLineItemInput[];
|
||||
}
|
||||
|
||||
/** ستونهای اکسل معامله — همترتیب با خروجی معاملات دیدار */
|
||||
export const DEAL_EXCEL_COLUMNS: Array<{ header: string; key: string; width?: number }> = [
|
||||
{ header: "_type", key: "_type", width: 8 },
|
||||
{ header: "کد دیدار معامله", key: "didarId", width: 14 },
|
||||
{ header: "کد دستی معامله", key: "code", width: 14 },
|
||||
{ header: "عنوان معامله", key: "title", width: 30 },
|
||||
{ header: "ارزش معامله", key: "amount", width: 16 },
|
||||
{ header: "ارز معامله", key: "currency", width: 10 },
|
||||
{ header: "توضیحات معامله", key: "notes", width: 32 },
|
||||
{ header: "وضعیت معامله", key: "status", width: 12 },
|
||||
{ header: "تاریخ انجام معامله", key: "closedAt", width: 14 },
|
||||
{ header: "تاریخ ایجاد معامله", key: "createdAt", width: 14 },
|
||||
{ header: "تاریخ احتمالی انجام معامله", key: "expectedCloseAt", width: 16 },
|
||||
{ header: "مسئول معامله", key: "ownerName", width: 18 },
|
||||
{ header: "ثبت کننده معامله", key: "creatorName", width: 18 },
|
||||
{ header: "احتمال موفقیت معامله", key: "probability", width: 12 },
|
||||
{ header: "دلیل شکست معامله", key: "lostReason", width: 18 },
|
||||
{ header: "یادداشت شکست معامله", key: "lostNote", width: 24 },
|
||||
{ header: "مجوز مشاهده معامله", key: "visibility", width: 18 },
|
||||
{ header: "شیوه آشنایی معامله", key: "source", width: 16 },
|
||||
{ header: "معرف معامله", key: "referrer", width: 16 },
|
||||
{ header: "شیوه آشنایی دلخواه", key: "sourceDetail", width: 16 },
|
||||
{ header: "برچسب", key: "tags", width: 16 },
|
||||
{ header: "کاریز معامله", key: "pipelineName", width: 20 },
|
||||
{ header: "مرحله کاریز معامله", key: "stageName", width: 20 },
|
||||
{ header: "نام شخص معامله", key: "personFirstName", width: 16 },
|
||||
{ header: "نام خانوادگی شخص معامله", key: "personName", width: 22 },
|
||||
{ header: "کد دیدار شخص معامله", key: "personCode", width: 14 },
|
||||
{ header: "موبایل شخص معامله", key: "personMobile", width: 14 },
|
||||
{ header: "شرکت معامله", key: "companyName", width: 20 },
|
||||
{ header: "کد دیدار شرکت معامله", key: "companyCode", width: 14 },
|
||||
{ header: "شماره تلفن شرکت", key: "companyPhone", width: 14 },
|
||||
{ header: "عنوان محصول", key: "productName", width: 20 },
|
||||
{ header: "کد محصول", key: "productCode", width: 12 },
|
||||
{ header: "دسته بندی محصول", key: "productCategory", width: 14 },
|
||||
{ header: "تعداد محصول", key: "productQty", width: 10 },
|
||||
{ header: "قیمت", key: "productPrice", width: 14 },
|
||||
{ header: "واحد قیمت محصول", key: "productCurrency", width: 12 },
|
||||
{ header: "میزان تخفیف محصول", key: "productDiscount", width: 12 },
|
||||
{ header: "مبلغ نهایی", key: "productTotal", width: 14 },
|
||||
{ header: "گروه محصول", key: "productGroup", width: 14 },
|
||||
];
|
||||
|
||||
/** کلید ستونهای ناشناخته اکسل که بهعنوان فیلد سفارشی ذخیره میشوند: cf:<عنوان ستون> */
|
||||
export const EXCEL_CUSTOM_FIELD_PREFIX = "cf:";
|
||||
|
||||
export function compactExcelHeader(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[_-]+/g, "")
|
||||
.replace(/\u200c/g, "")
|
||||
.replace(/\s+/g, "")
|
||||
.replace(/[()]/g, "");
|
||||
}
|
||||
|
||||
export const DEAL_EXCEL_ALIASES: Record<string, string> = {
|
||||
...Object.fromEntries(DEAL_EXCEL_COLUMNS.map((c) => [compactExcelHeader(c.header), c.key])),
|
||||
type: "_type",
|
||||
عنوان: "title",
|
||||
مبلغمعامله: "amount",
|
||||
مبلغ: "amount",
|
||||
ارز: "currency",
|
||||
وضعیت: "status",
|
||||
توضیحات: "notes",
|
||||
تاریخثبتمعامله: "createdAt",
|
||||
کددیدارشخص: "personCode",
|
||||
نامشرکتمعامله: "companyName",
|
||||
شرکت: "companyName",
|
||||
منبعمعامله: "source",
|
||||
منبع: "source",
|
||||
کاریز: "pipelineName",
|
||||
مرحله: "stageName",
|
||||
};
|
||||
/** فیلتر ذخیرهشدهٔ لیست اشخاص */
|
||||
export interface PeopleViewFilters {
|
||||
q?: string;
|
||||
|
||||
Reference in New Issue
Block a user