Deals import: imported pipelines keep only Didar stages; remove unused default stages

This commit is contained in:
2026-10-06 16:58:25 +03:30
parent 2a2728e5ee
commit 3de2d0fa67
2 changed files with 71 additions and 23 deletions
+52 -13
View File
@@ -35,7 +35,7 @@ import {
resolveExcelImportChunk,
type ExcelImportLookupCaches,
} from '../common/excel-import-session';
import { PipelinesService } from './pipelines.service';
import { DEFAULT_STAGES, PipelinesService } from './pipelines.service';
const MAX_ROWS_PER_REQUEST = 80;
@@ -97,6 +97,7 @@ type PipelineCacheEntry = {
type RowContext = {
pipelines: Map<string, PipelineCacheEntry>;
customFieldKeys: Map<string, string>;
touchedPipelines: Set<string>;
};
@Injectable()
@@ -290,6 +291,7 @@ export class DealsExcelService {
const ctx: RowContext = {
pipelines: new Map(),
customFieldKeys: await this.loadCustomFieldKeys(actor.organizationId),
touchedPipelines: new Set(),
};
for (let i = 0; i < rows.length; i++) {
@@ -299,7 +301,10 @@ export class DealsExcelService {
if (type && type !== 'deal') continue;
try {
const outcome = await this.processRow(actor, row, rowNum, result, caches, ctx);
if (outcome) affected.set(outcome.pipelineId, outcome.pipelineName);
if (outcome) {
affected.set(outcome.pipelineId, outcome.pipelineName);
ctx.touchedPipelines.add(outcome.pipelineId);
}
} catch (e) {
result.errors.push({
row: rowNum,
@@ -307,6 +312,7 @@ export class DealsExcelService {
});
}
}
await this.removeUnusedDefaultStages(actor.organizationId, ctx.touchedPipelines);
return affected;
}
@@ -342,7 +348,8 @@ export class DealsExcelService {
if (didarId) {
const existingId = await this.findDealByRef(orgId, didarId, caches);
if (existingId) {
await this.mergeIntoExisting(existingId, customFields, lineItem);
const pipelineId = await this.mergeIntoExisting(existingId, customFields, lineItem);
if (pipelineId) ctx.touchedPipelines.add(pipelineId);
result.skipped += 1;
return null;
}
@@ -448,12 +455,13 @@ export class DealsExcelService {
const deal = await this.prisma.deal.findUnique({
where: { id: dealId },
select: {
pipelineId: true,
customFields: true,
currency: true,
lineItems: { select: { name: true, position: true } },
},
});
if (!deal) return;
if (!deal) return null;
const merged = { ...asCustomFields(deal.customFields) };
let changed = false;
for (const [k, v] of Object.entries(customFields)) {
@@ -490,6 +498,33 @@ export class DealsExcelService {
data: { amount: new Prisma.Decimal(total) },
});
}
return deal.pipelineId;
}
/**
* مراحل پیش‌فرض سیستم (سرنخ، تماس اول، …) که خالی مانده‌اند از کاریزهای واردشده حذف می‌شوند
* تا فقط مراحل خود فایل دیده شوند.
*/
private async removeUnusedDefaultStages(organizationId: string, pipelineIds: Set<string>) {
const defaultNames = new Set<string>(DEFAULT_STAGES.map((s) => s.name));
for (const pipelineId of pipelineIds) {
const stages = await this.prisma.pipelineStage.findMany({
where: { pipelineId, pipeline: { organizationId } },
orderBy: { position: 'asc' },
select: { id: true, name: true, _count: { select: { deals: true } } },
});
if (!stages.some((s) => !defaultNames.has(s.name))) continue;
const removable = stages.filter((s) => defaultNames.has(s.name) && s._count.deals === 0);
if (!removable.length) continue;
const removeIds = new Set(removable.map((s) => s.id));
const remaining = stages.filter((s) => !removeIds.has(s.id));
await this.prisma.$transaction([
this.prisma.pipelineStage.deleteMany({ where: { id: { in: [...removeIds] } } }),
...remaining.map((s, index) =>
this.prisma.pipelineStage.update({ where: { id: s.id }, data: { position: index } }),
),
]);
}
}
private async findDealByRef(
@@ -608,7 +643,9 @@ export class DealsExcelService {
include: { stages: { orderBy: { position: 'asc' } } },
});
if (!row) {
const created = await this.pipelines.create(organizationId, name);
const created = await this.pipelines.create(organizationId, name, {
withDefaultStages: false,
});
row = await this.prisma.pipeline.findFirstOrThrow({
where: { id: created.id },
include: { stages: { orderBy: { position: 'asc' } } },
@@ -622,7 +659,15 @@ export class DealsExcelService {
ctx.pipelines.set(cacheKey, pipeline);
}
const wanted = stageName?.trim();
const fallbackName =
status === 'won' ? 'موفق' : status === 'lost' ? 'ناموفق' : 'جاری';
const byStatusExisting =
status === 'won'
? pipeline.stages.find((s) => s.isWon)
: status === 'lost'
? pipeline.stages.find((s) => s.isLost)
: pipeline.stages.find((s) => !s.isWon && !s.isLost);
const wanted = stageName?.trim() || (byStatusExisting ? '' : fallbackName);
if (wanted) {
const existing = pipeline.stages.find(
(s) => s.name.trim().toLowerCase() === wanted.toLowerCase(),
@@ -642,13 +687,7 @@ export class DealsExcelService {
return { pipelineId: pipeline.id, stageId: stage.id, pipelineName: pipeline.name };
}
const byStatus =
status === 'won'
? pipeline.stages.find((s) => s.isWon)
: status === 'lost'
? pipeline.stages.find((s) => s.isLost)
: pipeline.stages.find((s) => !s.isWon && !s.isLost);
const stage = byStatus ?? pipeline.stages[0];
const stage = byStatusExisting ?? pipeline.stages[0];
if (!stage) throw new Error('کاریز مرحله‌ای ندارد');
return { pipelineId: pipeline.id, stageId: stage.id, pipelineName: pipeline.name };
}
+19 -10
View File
@@ -15,7 +15,7 @@ import { asCustomFields } from '../common/custom-fields';
import { type Actor, dealAccessWhere } from '../common/record-scope';
import { decimalToBase, loadOrgRates } from '../common/currency';
const DEFAULT_STAGES = [
export const DEFAULT_STAGES = [
{ name: 'سرنخ', isWon: false, isLost: false },
{ name: 'تماس اول', isWon: false, isLost: false },
{ name: 'پیشنهاد', isWon: false, isLost: false },
@@ -107,21 +107,30 @@ export class PipelinesService {
return rows.map((row) => this.toPipelineDto(row));
}
async create(organizationId: string, name: string): Promise<PipelineDto> {
async create(
organizationId: string,
name: string,
opts: { withDefaultStages?: boolean } = {},
): Promise<PipelineDto> {
await this.ensureDefaultPipeline(organizationId);
const withDefaults = opts.withDefaultStages ?? true;
const created = await this.prisma.pipeline.create({
data: {
organizationId,
name: name.trim(),
isDefault: false,
stages: {
create: DEFAULT_STAGES.map((s, index) => ({
name: s.name,
position: index,
isWon: s.isWon,
isLost: s.isLost,
})),
},
...(withDefaults
? {
stages: {
create: DEFAULT_STAGES.map((s, index) => ({
name: s.name,
position: index,
isWon: s.isWon,
isLost: s.isLost,
})),
},
}
: {}),
},
include: { stages: { orderBy: { position: 'asc' } } },
});