@@ -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 ;
} ;