feat(pipelines): merge stage-map util and shared DTOs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 20:15:11 +03:30
co-authored by Cursor
parent 91eb9be1d4
commit fd8966e13f
4 changed files with 148 additions and 1 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"prisma:seed": "tsx prisma/seed.ts",
"lint": "echo \"api lint placeholder\"",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts"
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts"
},
"dependencies": {
"@mento/shared": "*",
@@ -0,0 +1,41 @@
export type MergeStageLike = {
id: string;
name: string;
isWon: boolean;
isLost: boolean;
};
export function dealStatusForStage(
stage: Pick<MergeStageLike, 'isWon' | 'isLost'>,
): 'open' | 'won' | 'lost' {
if (stage.isWon) return 'won';
if (stage.isLost) return 'lost';
return 'open';
}
export function assertStageMap(
sourceStages: MergeStageLike[],
targetStages: MergeStageLike[],
stageMap: Record<string, string>,
): void {
const targetById = new Map(targetStages.map((s) => [s.id, s]));
for (const src of sourceStages) {
const tid = stageMap[src.id];
if (!tid) {
throw new Error(`همهٔ ستون‌های مبدأ باید نگاشت شوند (ستون «${src.name}»).`);
}
const tgt = targetById.get(tid);
if (!tgt) {
throw new Error(`ستون مقصد برای «${src.name}» معتبر نیست.`);
}
if (src.isWon && !tgt.isWon) {
throw new Error(`ستون برندهٔ «${src.name}» فقط به ستون برندهٔ مقصد نگاشت می‌شود.`);
}
if (src.isLost && !tgt.isLost) {
throw new Error(`ستون از دست‌رفتهٔ «${src.name}» فقط به ستون از دست‌رفتهٔ مقصد نگاشت می‌شود.`);
}
if (!src.isWon && !src.isLost && (tgt.isWon || tgt.isLost)) {
throw new Error(`ستون باز «${src.name}» را به ستون برد/باخت مقصد نگاشت نکنید.`);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
assertStageMap,
dealStatusForStage,
} from '../src/pipelines/pipeline-merge.util';
type S = { id: string; name: string; isWon: boolean; isLost: boolean };
const openA: S = { id: 'a1', name: 'سرنخ', isWon: false, isLost: false };
const wonA: S = { id: 'aW', name: 'برنده', isWon: true, isLost: false };
const lostA: S = { id: 'aL', name: 'از دست‌رفته', isWon: false, isLost: true };
const openB: S = { id: 'b1', name: 'تماس', isWon: false, isLost: false };
const wonB: S = { id: 'bW', name: 'برنده', isWon: true, isLost: false };
const lostB: S = { id: 'bL', name: 'باخت', isWon: false, isLost: true };
describe('pipeline merge stage map', () => {
it('rejects incomplete map', () => {
assert.throws(
() => assertStageMap([openA, wonA], [openB, wonB], { a1: 'b1' }),
/همهٔ ستون/,
);
});
it('rejects won → open', () => {
assert.throws(
() =>
assertStageMap([openA, wonA], [openB, wonB], {
a1: 'b1',
aW: 'b1',
}),
/برنده/,
);
});
it('rejects lost → open', () => {
assert.throws(
() =>
assertStageMap([openA, lostA], [openB, lostB], {
a1: 'b1',
aL: 'b1',
}),
/از دست/,
);
});
it('accepts valid mapping', () => {
assert.doesNotThrow(() =>
assertStageMap([openA, wonA, lostA], [openB, wonB, lostB], {
a1: 'b1',
aW: 'bW',
aL: 'bL',
}),
);
});
it('dealStatusForStage', () => {
assert.equal(dealStatusForStage(wonB), 'won');
assert.equal(dealStatusForStage(lostB), 'lost');
assert.equal(dealStatusForStage(openB), 'open');
});
});
+44
View File
@@ -996,6 +996,50 @@ export interface ReorderPipelineStagesRequest {
stageIds: string[];
}
export type PipelineMergeLeadFormMode = 'retarget_all';
export interface PipelineMergePreviewStageDto {
id: string;
name: string;
isWon: boolean;
isLost: boolean;
dealCount: number;
}
export interface PipelineMergePreviewTargetDto {
id: string;
name: string;
isDefault: boolean;
stages: Array<{
id: string;
name: string;
isWon: boolean;
isLost: boolean;
}>;
}
export interface PipelineMergePreviewDto {
source: { id: string; name: string; isDefault: boolean };
stages: PipelineMergePreviewStageDto[];
dealCount: number;
leadForms: Array<{ id: string; name: string; isActive: boolean }>;
targets: PipelineMergePreviewTargetDto[];
}
export interface MergePipelineRequest {
targetPipelineId: string;
stageMap: Record<string, string>;
leadFormMode?: PipelineMergeLeadFormMode;
}
export interface MergePipelineResultDto {
ok: true;
targetPipelineId: string;
movedDeals: number;
retargetedLeadForms: number;
deletedPipelineId: string;
}
export interface CreateDealRequest {
title: string;
pipelineId?: string;