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:
2026-10-06 16:36:04 +03:30
parent e08f1452ef
commit 2a2728e5ee
11 changed files with 1032 additions and 210 deletions
@@ -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', '') <> '';
+19
View File
@@ -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 {
+643 -202
View File
@@ -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;
};
+6 -1
View File
@@ -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);
+27
View File
@@ -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>();
+104
View File
@@ -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,
+23 -6
View File
@@ -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}`);
}
});
}
+52
View File
@@ -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>
<!-- بخش محصولات / خدمات -->
+20 -1
View File
@@ -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"
+100
View File
@@ -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;