Add automatic VoIP call summaries and sync progress checklist.
When a PBX webhook includes recordingUrl or transcript, queue STT and AI to create a call_result note; mark API, bulk SMS, WooCommerce, and VoIP as done and defer online payments. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -80,7 +80,7 @@ $env:MENTO_DEV_API="http://127.0.0.1:3000"; npm run dev
|
|||||||
|
|
||||||
## فاز فعلی
|
## فاز فعلی
|
||||||
|
|
||||||
فاز ۱ هسته فروش/پشتیبانی و Smart Notes عملاً کامل است. جزئیات تیکخورده و باقیمانده:
|
فاز ۱ هسته فروش/پشتیبانی، Smart Notes، مالی سبک و اتصالها (API، ووکامرس، VoIP، پیامک گروهی) عملاً کامل است. جزئیات تیکخورده و باقیمانده:
|
||||||
|
|
||||||
- [`docs/وضعیت-پیشرفت.md`](docs/وضعیت-پیشرفت.md) ← **چکلیست زنده**
|
- [`docs/وضعیت-پیشرفت.md`](docs/وضعیت-پیشرفت.md) ← **چکلیست زنده**
|
||||||
- [`docs/قابلیتهای-منتکت.md`](docs/قابلیتهای-منتکت.md) ← فهرست کوتاه قابلیتها
|
- [`docs/قابلیتهای-منتکت.md`](docs/قابلیتهای-منتکت.md) ← فهرست کوتاه قابلیتها
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "recordingUrl" TEXT;
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "transcript" TEXT;
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "summary" TEXT;
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryStatus" TEXT NOT NULL DEFAULT 'none';
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryNoteId" TEXT;
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryError" TEXT;
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryAttempts" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "VoipCallLog" ADD COLUMN "summaryNextAttemptAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoipCallLog_summaryStatus_summaryNextAttemptAt_idx" ON "VoipCallLog"("summaryStatus", "summaryNextAttemptAt");
|
||||||
@@ -1855,6 +1855,18 @@ model VoipCallLog {
|
|||||||
startedAt DateTime @default(now())
|
startedAt DateTime @default(now())
|
||||||
endedAt DateTime?
|
endedAt DateTime?
|
||||||
rawPayload Json?
|
rawPayload Json?
|
||||||
|
/// لینک فایل ضبط از سانترال
|
||||||
|
recordingUrl String?
|
||||||
|
/// متن خام (از وبهوک یا STT)
|
||||||
|
transcript String?
|
||||||
|
/// خلاصه فارسی تولیدشده
|
||||||
|
summary String?
|
||||||
|
/// none | pending | processing | done | failed | skipped
|
||||||
|
summaryStatus String @default("none")
|
||||||
|
summaryNoteId String?
|
||||||
|
summaryError String?
|
||||||
|
summaryAttempts Int @default(0)
|
||||||
|
summaryNextAttemptAt DateTime?
|
||||||
syncedAt DateTime @default(now())
|
syncedAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -1863,6 +1875,7 @@ model VoipCallLog {
|
|||||||
@@index([organizationId, syncedAt])
|
@@index([organizationId, syncedAt])
|
||||||
@@index([personId])
|
@@index([personId])
|
||||||
@@index([activityId])
|
@@index([activityId])
|
||||||
|
@@index([summaryStatus, summaryNextAttemptAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// خدمت قابل رزرو (مثلاً مشاوره ۳۰ دقیقهای)
|
/// خدمت قابل رزرو (مثلاً مشاوره ۳۰ دقیقهای)
|
||||||
|
|||||||
@@ -61,6 +61,44 @@ Rules:
|
|||||||
return this.normalizeExtract(raw);
|
return this.normalizeExtract(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async summarizeCallTranscript(input: {
|
||||||
|
transcript: string;
|
||||||
|
personName?: string | null;
|
||||||
|
direction?: string | null;
|
||||||
|
}): Promise<{ summary: string; nextAction: string | null }> {
|
||||||
|
const system = `You summarize phone call transcripts for a Persian CRM.
|
||||||
|
Return ONLY valid JSON:
|
||||||
|
{
|
||||||
|
"summary": string,
|
||||||
|
"nextAction": string | null
|
||||||
|
}
|
||||||
|
Rules:
|
||||||
|
- summary: 3–8 concise Persian sentences covering purpose, customer needs, outcomes, and commitments.
|
||||||
|
- nextAction: one short Persian follow-up if needed; otherwise null.
|
||||||
|
- Do not invent facts not in the transcript.
|
||||||
|
- Keep names, numbers, and amounts accurate.`;
|
||||||
|
|
||||||
|
const userParts = [
|
||||||
|
input.personName ? `Customer: ${input.personName}` : null,
|
||||||
|
input.direction ? `Direction: ${input.direction}` : null,
|
||||||
|
`Transcript:\n${input.transcript.trim().slice(0, 12000)}`,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const raw = await this.completeJson<{
|
||||||
|
summary?: string;
|
||||||
|
nextAction?: string | null;
|
||||||
|
}>(system, userParts.join('\n'));
|
||||||
|
|
||||||
|
const summary = String(raw.summary ?? '').trim().slice(0, 4000);
|
||||||
|
if (!summary) {
|
||||||
|
throw new BadRequestException('خلاصه تماس خالی بود');
|
||||||
|
}
|
||||||
|
const nextAction = raw.nextAction
|
||||||
|
? String(raw.nextAction).trim().slice(0, 500) || null
|
||||||
|
: null;
|
||||||
|
return { summary, nextAction };
|
||||||
|
}
|
||||||
|
|
||||||
async completeJson<T>(system: string, user: string): Promise<T> {
|
async completeJson<T>(system: string, user: string): Promise<T> {
|
||||||
if (!this.isEnabled()) {
|
if (!this.isEnabled()) {
|
||||||
throw new ServiceUnavailableException('هوش مصنوعی فعال نیست');
|
throw new ServiceUnavailableException('هوش مصنوعی فعال نیست');
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { AiService } from '../../ai/ai.service';
|
||||||
|
import { NotesService } from '../../notes/notes.service';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { SttClientService } from './stt-client.service';
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CallSummaryService {
|
||||||
|
private readonly logger = new Logger(CallSummaryService.name);
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly stt: SttClientService,
|
||||||
|
private readonly ai: AiService,
|
||||||
|
private readonly notes: NotesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Cron('*/1 * * * *')
|
||||||
|
async processDue(): Promise<number> {
|
||||||
|
if (this.running) return 0;
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const rows = await this.prisma.voipCallLog.findMany({
|
||||||
|
where: {
|
||||||
|
summaryStatus: 'pending',
|
||||||
|
OR: [
|
||||||
|
{ summaryNextAttemptAt: null },
|
||||||
|
{ summaryNextAttemptAt: { lte: now } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { syncedAt: 'asc' },
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
await this.processOne(row.id);
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
return processed;
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processOne(id: string): Promise<void> {
|
||||||
|
const claimed = await this.prisma.voipCallLog.updateMany({
|
||||||
|
where: { id, summaryStatus: 'pending' },
|
||||||
|
data: {
|
||||||
|
summaryStatus: 'processing',
|
||||||
|
summaryAttempts: { increment: 1 },
|
||||||
|
summaryError: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!claimed.count) return;
|
||||||
|
|
||||||
|
const row = await this.prisma.voipCallLog.findUnique({ where: { id } });
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let transcript = (row.transcript ?? '').trim();
|
||||||
|
if (!transcript && row.recordingUrl) {
|
||||||
|
if (!this.stt.isEnabled()) {
|
||||||
|
await this.markFailed(
|
||||||
|
id,
|
||||||
|
row.summaryAttempts,
|
||||||
|
'سرویس تبدیل گفتار فعال نیست',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transcript = await this.stt.downloadAndTranscribe(row.recordingUrl);
|
||||||
|
await this.prisma.voipCallLog.update({
|
||||||
|
where: { id },
|
||||||
|
data: { transcript },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transcript) {
|
||||||
|
await this.prisma.voipCallLog.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
summaryStatus: 'skipped',
|
||||||
|
summaryError: 'ضبط یا متن تماس ارسال نشده',
|
||||||
|
summaryNextAttemptAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.ai.isEnabled()) {
|
||||||
|
await this.markFailed(
|
||||||
|
id,
|
||||||
|
row.summaryAttempts,
|
||||||
|
'هوش مصنوعی فعال نیست',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let personName: string | null = null;
|
||||||
|
if (row.personId) {
|
||||||
|
const person = await this.prisma.person.findFirst({
|
||||||
|
where: { id: row.personId },
|
||||||
|
select: { firstName: true, lastName: true },
|
||||||
|
});
|
||||||
|
if (person) {
|
||||||
|
personName = `${person.firstName} ${person.lastName}`.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { summary, nextAction } = await this.ai.summarizeCallTranscript({
|
||||||
|
transcript,
|
||||||
|
personName,
|
||||||
|
direction: row.direction,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authorId = await this.resolveAuthorId(row.organizationId, row.personId);
|
||||||
|
if (!authorId) {
|
||||||
|
await this.markFailed(
|
||||||
|
id,
|
||||||
|
row.summaryAttempts,
|
||||||
|
'کاربر مالک/مدیر برای ثبت یادداشت پیدا نشد',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyParts = [
|
||||||
|
summary,
|
||||||
|
nextAction ? `\nاقدام بعدی پیشنهادی: ${nextAction}` : null,
|
||||||
|
'\n---\nمتن تماس:\n',
|
||||||
|
transcript.slice(0, 8000),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const note = await this.notes.create(row.organizationId, authorId, {
|
||||||
|
title: personName
|
||||||
|
? `خلاصه تماس — ${personName}`
|
||||||
|
: 'خلاصه تماس تلفنی',
|
||||||
|
body: bodyParts.join(''),
|
||||||
|
noteType: 'call_result',
|
||||||
|
tags: ['voip', 'auto-summary'],
|
||||||
|
personId: row.personId,
|
||||||
|
activityId: row.activityId,
|
||||||
|
analyzeEnabled: true,
|
||||||
|
visibility: 'team',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (row.activityId) {
|
||||||
|
const activity = await this.prisma.activity.findFirst({
|
||||||
|
where: { id: row.activityId },
|
||||||
|
select: { description: true },
|
||||||
|
});
|
||||||
|
if (activity) {
|
||||||
|
const base = (activity.description ?? '').trim();
|
||||||
|
const block = `خلاصه خودکار:\n${summary}`;
|
||||||
|
const nextDesc = base.includes('خلاصه خودکار:')
|
||||||
|
? base.replace(/خلاصه خودکار:[\s\S]*$/m, block)
|
||||||
|
: [base, block].filter(Boolean).join('\n\n');
|
||||||
|
await this.prisma.activity.update({
|
||||||
|
where: { id: row.activityId },
|
||||||
|
data: { description: nextDesc.slice(0, 8000) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.voipCallLog.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
summary,
|
||||||
|
summaryStatus: 'done',
|
||||||
|
summaryNoteId: note.id,
|
||||||
|
summaryError: null,
|
||||||
|
summaryNextAttemptAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message.slice(0, 400) : 'خطای ناشناخته';
|
||||||
|
this.logger.warn(`call summary ${id}: ${message}`);
|
||||||
|
await this.markFailed(id, row.summaryAttempts, message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markFailed(
|
||||||
|
id: string,
|
||||||
|
attemptsSoFar: number,
|
||||||
|
error: string,
|
||||||
|
permanent: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const attempts = attemptsSoFar;
|
||||||
|
if (permanent || attempts >= MAX_ATTEMPTS) {
|
||||||
|
await this.prisma.voipCallLog.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
summaryStatus: 'failed',
|
||||||
|
summaryError: error,
|
||||||
|
summaryNextAttemptAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delayMin = Math.min(15, 2 ** Math.max(0, attempts - 1));
|
||||||
|
await this.prisma.voipCallLog.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
summaryStatus: 'pending',
|
||||||
|
summaryError: error,
|
||||||
|
summaryNextAttemptAt: new Date(Date.now() + delayMin * 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAuthorId(
|
||||||
|
organizationId: string,
|
||||||
|
personId: string | null,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (personId) {
|
||||||
|
const person = await this.prisma.person.findFirst({
|
||||||
|
where: { id: personId, organizationId },
|
||||||
|
select: { ownerId: true },
|
||||||
|
});
|
||||||
|
if (person?.ownerId) return person.ownerId;
|
||||||
|
}
|
||||||
|
const actor = await this.prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
isActive: true,
|
||||||
|
role: { in: ['owner', 'admin'] },
|
||||||
|
},
|
||||||
|
select: { userId: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
return actor?.userId ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,12 @@ export class VoipCallsService {
|
|||||||
durationSec: r.durationSec,
|
durationSec: r.durationSec,
|
||||||
startedAt: r.startedAt.toISOString(),
|
startedAt: r.startedAt.toISOString(),
|
||||||
syncedAt: r.syncedAt.toISOString(),
|
syncedAt: r.syncedAt.toISOString(),
|
||||||
|
recordingUrl: r.recordingUrl,
|
||||||
|
transcript: r.transcript,
|
||||||
|
summary: r.summary,
|
||||||
|
summaryStatus: r.summaryStatus,
|
||||||
|
summaryNoteId: r.summaryNoteId,
|
||||||
|
summaryError: r.summaryError,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +139,8 @@ export class VoipCallsService {
|
|||||||
event.durationSec != null
|
event.durationSec != null
|
||||||
? Math.max(0, Math.round(Number(event.durationSec)))
|
? Math.max(0, Math.round(Number(event.durationSec)))
|
||||||
: null;
|
: null;
|
||||||
|
const recordingUrl = String(event.recordingUrl ?? '').trim() || null;
|
||||||
|
const transcript = String(event.transcript ?? '').trim() || null;
|
||||||
|
|
||||||
const existing = await this.prisma.voipCallLog.findUnique({
|
const existing = await this.prisma.voipCallLog.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -158,6 +166,24 @@ export class VoipCallsService {
|
|||||||
activityId = createdId || null;
|
activityId = createdId || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextRecordingUrl = recordingUrl ?? existing?.recordingUrl ?? null;
|
||||||
|
const nextTranscript = transcript ?? existing?.transcript ?? null;
|
||||||
|
const canQueueSummary =
|
||||||
|
['completed', 'answered'].includes(status) &&
|
||||||
|
Boolean(nextRecordingUrl || nextTranscript) &&
|
||||||
|
!['done', 'pending', 'processing'].includes(
|
||||||
|
existing?.summaryStatus ?? 'none',
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryPatch: Prisma.VoipCallLogUpdateInput = {};
|
||||||
|
if (recordingUrl) summaryPatch.recordingUrl = recordingUrl;
|
||||||
|
if (transcript) summaryPatch.transcript = transcript;
|
||||||
|
if (canQueueSummary) {
|
||||||
|
summaryPatch.summaryStatus = 'pending';
|
||||||
|
summaryPatch.summaryError = null;
|
||||||
|
summaryPatch.summaryNextAttemptAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await this.prisma.voipCallLog.update({
|
await this.prisma.voipCallLog.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
@@ -173,6 +199,7 @@ export class VoipCallsService {
|
|||||||
endedAt,
|
endedAt,
|
||||||
rawPayload: event as unknown as Prisma.InputJsonValue,
|
rawPayload: event as unknown as Prisma.InputJsonValue,
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
|
...summaryPatch,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -191,6 +218,10 @@ export class VoipCallsService {
|
|||||||
startedAt,
|
startedAt,
|
||||||
endedAt,
|
endedAt,
|
||||||
rawPayload: event as unknown as Prisma.InputJsonValue,
|
rawPayload: event as unknown as Prisma.InputJsonValue,
|
||||||
|
recordingUrl,
|
||||||
|
transcript,
|
||||||
|
summaryStatus: canQueueSummary ? 'pending' : 'none',
|
||||||
|
summaryNextAttemptAt: canQueueSummary ? new Date() : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SttClientService {
|
||||||
|
private readonly logger = new Logger(SttClientService.name);
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this.config.get<string>('STT_ENABLED') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadAndTranscribe(recordingUrl: string): Promise<string> {
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = this.config.get<string>('STT_URL')?.trim();
|
||||||
|
if (!baseUrl) {
|
||||||
|
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = Number(
|
||||||
|
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
|
||||||
|
);
|
||||||
|
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || '';
|
||||||
|
|
||||||
|
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
|
||||||
|
const form = new FormData();
|
||||||
|
const blob = new Blob([new Uint8Array(audio.buffer)], {
|
||||||
|
type: audio.contentType,
|
||||||
|
});
|
||||||
|
form.append('audio', blob, audio.filename);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers['X-STT-Token'] = token;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: form,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`خطای STT (${res.status})${text ? `: ${text.slice(0, 200)}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { text?: string };
|
||||||
|
const text = String(data.text ?? '').trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new ServiceUnavailableException('متن خالی از STT برگشت');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceUnavailableException) throw err;
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
|
throw new ServiceUnavailableException('زمان تبدیل گفتار تمام شد');
|
||||||
|
}
|
||||||
|
this.logger.warn(
|
||||||
|
`STT failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
throw new ServiceUnavailableException('تبدیل گفتار ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadAudio(
|
||||||
|
url: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<{ buffer: ArrayBuffer; contentType: string; filename: string }> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), Math.min(timeoutMs, 60000));
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { 'User-Agent': 'MontactCRM-VoIP/1.0' },
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`دانلود ضبط ناموفق بود (${res.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const contentLength = Number(res.headers.get('content-length') || 0);
|
||||||
|
if (contentLength > MAX_AUDIO_BYTES) {
|
||||||
|
throw new ServiceUnavailableException('حجم فایل ضبط بیش از حد مجاز است');
|
||||||
|
}
|
||||||
|
const buffer = await res.arrayBuffer();
|
||||||
|
if (buffer.byteLength > MAX_AUDIO_BYTES) {
|
||||||
|
throw new ServiceUnavailableException('حجم فایل ضبط بیش از حد مجاز است');
|
||||||
|
}
|
||||||
|
if (!buffer.byteLength) {
|
||||||
|
throw new ServiceUnavailableException('فایل ضبط خالی است');
|
||||||
|
}
|
||||||
|
const contentType =
|
||||||
|
res.headers.get('content-type')?.split(';')[0]?.trim() ||
|
||||||
|
'audio/wav';
|
||||||
|
const pathPart = url.split('?')[0] ?? url;
|
||||||
|
const baseName = pathPart.split('/').pop() || 'recording.wav';
|
||||||
|
const filename = /\.\w+$/.test(baseName) ? baseName : `${baseName}.wav`;
|
||||||
|
return { buffer, contentType, filename };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceUnavailableException) throw err;
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
|
throw new ServiceUnavailableException('زمان دانلود ضبط تمام شد');
|
||||||
|
}
|
||||||
|
throw new ServiceUnavailableException('دانلود فایل ضبط ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { BillingModule } from '../../billing/billing.module';
|
import { BillingModule } from '../../billing/billing.module';
|
||||||
|
import { NotesModule } from '../../notes/notes.module';
|
||||||
import { NotificationsModule } from '../../notifications/notifications.module';
|
import { NotificationsModule } from '../../notifications/notifications.module';
|
||||||
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
|
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
|
||||||
|
import { CallSummaryService } from './call-summary.service';
|
||||||
import { VoipConnectionsService } from './connections.service';
|
import { VoipConnectionsService } from './connections.service';
|
||||||
import { VoipCallsService } from './calls.service';
|
import { VoipCallsService } from './calls.service';
|
||||||
|
import { SttClientService } from './stt-client.service';
|
||||||
import { VoipController, VoipWebhookController } from './voip.controller';
|
import { VoipController, VoipWebhookController } from './voip.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, BillingModule, NotificationsModule],
|
imports: [AuthModule, BillingModule, NotificationsModule, NotesModule],
|
||||||
controllers: [VoipController, VoipWebhookController],
|
controllers: [VoipController, VoipWebhookController],
|
||||||
providers: [
|
providers: [
|
||||||
TahesabCryptoService,
|
TahesabCryptoService,
|
||||||
VoipConnectionsService,
|
VoipConnectionsService,
|
||||||
VoipCallsService,
|
VoipCallsService,
|
||||||
|
SttClientService,
|
||||||
|
CallSummaryService,
|
||||||
],
|
],
|
||||||
exports: [VoipConnectionsService, VoipCallsService],
|
exports: [VoipConnectionsService, VoipCallsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type {
|
import type {
|
||||||
@@ -46,6 +47,21 @@ const statusLabel: Record<string, string> = {
|
|||||||
failed: 'ناموفق',
|
failed: 'ناموفق',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const summaryLabel: Record<string, string> = {
|
||||||
|
none: '—',
|
||||||
|
pending: 'در صف',
|
||||||
|
processing: 'در حال ساخت',
|
||||||
|
done: 'آماده',
|
||||||
|
failed: 'ناموفق',
|
||||||
|
skipped: 'رد شده',
|
||||||
|
};
|
||||||
|
|
||||||
|
function noteLink(c: VoipCallLogDto): string | null {
|
||||||
|
if (!c.summaryNoteId) return null;
|
||||||
|
if (c.personId) return `/contacts/people/${c.personId}`;
|
||||||
|
return '/notes';
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -123,7 +139,7 @@ onMounted(() => {
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<UiPageHeader
|
<UiPageHeader
|
||||||
title="تلفن سازمانی"
|
title="تلفن سازمانی"
|
||||||
description="ثبت خودکار تماسهای سانترال روی پرونده مشتری و اعلان تماس ورودی"
|
description="ثبت خودکار تماس، خلاصه گفتگو و اعلان تماس ورودی"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<UiButton variant="secondary" :disabled="loading" @click="load">
|
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||||
@@ -145,6 +161,15 @@ onMounted(() => {
|
|||||||
<code>status</code>،
|
<code>status</code>،
|
||||||
<code>from</code>،
|
<code>from</code>،
|
||||||
<code>to</code>.
|
<code>to</code>.
|
||||||
|
برای خلاصه خودکار یکی از
|
||||||
|
<code>recordingUrl</code>
|
||||||
|
(لینک فایل ضبط) یا
|
||||||
|
<code>transcript</code>
|
||||||
|
(متن آماده) را بفرستید؛ پس از
|
||||||
|
<code>completed</code>
|
||||||
|
/
|
||||||
|
<code>answered</code>
|
||||||
|
صف خلاصه ساخته میشود.
|
||||||
امضا اختیاری با هدر
|
امضا اختیاری با هدر
|
||||||
<code>X-Voip-Signature</code>
|
<code>X-Voip-Signature</code>
|
||||||
(HMAC-SHA256 هگز).
|
(HMAC-SHA256 هگز).
|
||||||
@@ -213,6 +238,7 @@ onMounted(() => {
|
|||||||
<th>از / به</th>
|
<th>از / به</th>
|
||||||
<th>مشتری</th>
|
<th>مشتری</th>
|
||||||
<th>مدت</th>
|
<th>مدت</th>
|
||||||
|
<th>خلاصه</th>
|
||||||
<th>زمان</th>
|
<th>زمان</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -229,6 +255,27 @@ onMounted(() => {
|
|||||||
: '—'
|
: '—'
|
||||||
}}
|
}}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="summary-cell">
|
||||||
|
<span
|
||||||
|
class="summary-badge"
|
||||||
|
:data-status="c.summaryStatus || 'none'"
|
||||||
|
>
|
||||||
|
{{ summaryLabel[c.summaryStatus] ?? c.summaryStatus ?? '—' }}
|
||||||
|
</span>
|
||||||
|
<p v-if="c.summary" class="summary-preview">
|
||||||
|
{{ c.summary.slice(0, 120) }}{{ c.summary.length > 120 ? '…' : '' }}
|
||||||
|
</p>
|
||||||
|
<p v-if="c.summaryError && c.summaryStatus === 'failed'" class="warn">
|
||||||
|
{{ c.summaryError }}
|
||||||
|
</p>
|
||||||
|
<RouterLink
|
||||||
|
v-if="noteLink(c)"
|
||||||
|
class="note-link"
|
||||||
|
:to="noteLink(c)!"
|
||||||
|
>
|
||||||
|
مشاهده در پرونده
|
||||||
|
</RouterLink>
|
||||||
|
</td>
|
||||||
<td>{{ formatJalaliNumeric(new Date(c.startedAt)) }}</td>
|
<td>{{ formatJalaliNumeric(new Date(c.startedAt)) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -310,11 +357,48 @@ td {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
padding: 0.55rem;
|
padding: 0.55rem;
|
||||||
border-bottom: 1px solid #eef2f7;
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
th {
|
th {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
.summary-cell {
|
||||||
|
max-width: 16rem;
|
||||||
|
}
|
||||||
|
.summary-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.summary-badge[data-status='done'] {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
.summary-badge[data-status='pending'],
|
||||||
|
.summary-badge[data-status='processing'] {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.summary-badge[data-status='failed'] {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
.summary-preview {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #334155;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.note-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -50,10 +50,10 @@
|
|||||||
| مایلاستون | وضعیت | محتوا |
|
| مایلاستون | وضعیت | محتوا |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **۱-الف** هسته فروش | انجام شده | مخاطب، کاریز، فعالیت، تقویم، اکسل، نقش، چندارزی، UI |
|
| **۱-الف** هسته فروش | انجام شده | مخاطب، کاریز، فعالیت، تقویم، اکسل، نقش، چندارزی، UI |
|
||||||
| **۱-ب** Smart Notes / AI | انجام شده | Composer، Memory، Ask، Voice؛ شخصی/تیمی + تاریخچه ویرایش |
|
| **۱-ب** Smart Notes / AI | انجام شده | Composer، Memory، Ask، Voice؛ شخصی/تیمی + تاریخچه؛ خلاصه خودکار تماس |
|
||||||
| **۱-ج** سرنخ و مالی | انجام شده | فرمساز، پیشفاکتور، فاکتور/اقساط، هدف فروش |
|
| **۱-ج** سرنخ و مالی | انجام شده | فرمساز، پیشفاکتور، فاکتور/اقساط، هدف فروش |
|
||||||
| **۱-د** پسازفروش | انجام شده | تیکت، پرتال OTP، پروژه، باشگاه، تهحساب |
|
| **۱-د** پسازفروش | انجام شده | تیکت، پرتال OTP، پروژه، باشگاه، تهحساب |
|
||||||
| **۱-هـ** گزارش و اتصال | نیمهکاره | گزارش و اتوماسیون و SMS عملیاتی آماده؛ API عمومی/وبهوک/درگاه هنوز نه |
|
| **۱-هـ** گزارش و اتصال | انجام شده | گزارش، اتوماسیون، SMS گروهی، API/وبهوک، ووکامرس، VoIP + خلاصه تماس |
|
||||||
|
|
||||||
جزئیات تیکخورده: [`وضعیت-پیشرفت.md`](وضعیت-پیشرفت.md)
|
جزئیات تیکخورده: [`وضعیت-پیشرفت.md`](وضعیت-پیشرفت.md)
|
||||||
|
|
||||||
@@ -90,15 +90,18 @@
|
|||||||
| گزارش و پیشبینی فروش | آماده |
|
| گزارش و پیشبینی فروش | آماده |
|
||||||
| تنظیمات (تیم، ارز، محصول، فرم سرنخ، …) | آماده |
|
| تنظیمات (تیم، ارز، محصول، فرم سرنخ، …) | آماده |
|
||||||
| تهحساب | آماده (فاز ۱ اتصال) |
|
| تهحساب | آماده (فاز ۱ اتصال) |
|
||||||
| API عمومی / وبهوک | مانده |
|
| API عمومی / وبهوک | آماده |
|
||||||
| درگاه پرداخت داخل CRM | مانده |
|
| پیامک گروهی | آماده |
|
||||||
|
| ووکامرس | آماده |
|
||||||
|
| VoIP + خلاصه خودکار تماس | آماده |
|
||||||
|
| درگاه پرداخت داخل CRM | عمداً نه (فعلاً) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ۶. مسیر بعدی (اولویت پیشنهادی)
|
## ۶. مسیر بعدی (اولویت پیشنهادی)
|
||||||
|
|
||||||
1. **API عمومی + وبهوک** — اتصال سیستمهای بیرونی بدون دستکاری هسته
|
1. **MinIO** برای پیوستها در استقرار بزرگ
|
||||||
2. **پرداخت / پیامک مدیریتی** — اگر مشتری بخواهد از داخل CRM مدیریت کند
|
2. **OpenAPI کامل** برای یکپارچهسازان بیرونی (در صورت نیاز)
|
||||||
3. **لندینگ Nuxt** — بعد از ثبات UI و استقرار
|
3. **لندینگ Nuxt** — بعد از ثبات UI و استقرار
|
||||||
4. **`platform_admin` SaaS** — فقط وقتی فروش حساب چندمستأجری عمومی شروع شود
|
4. **`platform_admin` SaaS** — فقط وقتی فروش حساب چندمستأجری عمومی شروع شود
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ AI پیشنهاد Next Action میدهد؛ اعمال فقط با تأیید
|
|||||||
### دیکته صوتی (Voice-to-Text)
|
### دیکته صوتی (Voice-to-Text)
|
||||||
تبدیل گفتار به متن داخل یادداشت و برخی فرمها.
|
تبدیل گفتار به متن داخل یادداشت و برخی فرمها.
|
||||||
|
|
||||||
|
### خلاصه خودکار تماس تلفنی
|
||||||
|
پس از رویداد VoIP با ضبط یا متن → STT و AI → یادداشت نتیجه تماس روی پرونده.
|
||||||
|
|
||||||
### تایملاین پرونده
|
### تایملاین پرونده
|
||||||
ترتیب زمانی یادداشتها و فعالیتها روی شخص/شرکت/معامله.
|
ترتیب زمانی یادداشتها و فعالیتها روی شخص/شرکت/معامله.
|
||||||
|
|
||||||
@@ -238,10 +241,28 @@ AI پیشنهاد Next Action میدهد؛ اعمال فقط با تأیید
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## اتصال و کانالها
|
||||||
|
|
||||||
|
### API عمومی و وبهوک خروجی
|
||||||
|
کلید API برای خواندن افراد/معاملات؛ تعریف endpoint وبهوک و ارسال رویداد به سرور شما.
|
||||||
|
|
||||||
|
### پیامک گروهی
|
||||||
|
کمپین انبوه به مخاطبان انتخابشده با صف ارسال.
|
||||||
|
|
||||||
|
### ووکامرس
|
||||||
|
اتصال فروشگاه، همگامسازی سفارش به فاکتور و مشتری، وبهوک ورودی.
|
||||||
|
|
||||||
|
### تلفن سازمانی (VoIP)
|
||||||
|
ثبت تماس از سانترال، تطبیق مخاطب، فعالیت تماس، click-to-call.
|
||||||
|
|
||||||
|
### خلاصه خودکار تماس
|
||||||
|
با ارسال `recordingUrl` یا `transcript` در وبهوک → تبدیل گفتار (STT) → خلاصه AI → یادداشت `call_result` روی پرونده.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## عمداً خارج از محصول فعلی
|
## عمداً خارج از محصول فعلی
|
||||||
|
|
||||||
|
- درگاه پرداخت آنلاین داخل پنل (فعلاً نه)
|
||||||
- اپ بومی اندروید/iOS جدا
|
- اپ بومی اندروید/iOS جدا
|
||||||
- حسابداری کامل و انبار ERP داخل خود منتکت
|
- حسابداری کامل و انبار ERP داخل خود منتکت
|
||||||
- API عمومی مستند و وبهوک خروجی (در نقشه راه بعدی)
|
|
||||||
- درگاه پرداخت آنلاین داخل پنل
|
|
||||||
- لندینگ بازاریابی Nuxt (بعد از ثبات)
|
- لندینگ بازاریابی Nuxt (بعد از ثبات)
|
||||||
|
|||||||
+16
-11
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
| تاریخ | مورد |
|
| تاریخ | مورد |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| ۱۴۰۵/۷/۵ | خلاصه خودکار تماس VoIP (ضبط/متن → STT → AI → یادداشت call_result) |
|
||||||
|
| ۱۴۰۵/۷/۵ | همترازی چکلیست: API عمومی/وبهوک، پیامک گروهی، ووکامرس، VoIP؛ درگاه پرداخت عمداً نه |
|
||||||
| ۱۴۰۵/۷/۵ | چند مسئول روی معاملهٔ کاریز + ذخیره فیلتر در اشخاص و کاریز |
|
| ۱۴۰۵/۷/۵ | چند مسئول روی معاملهٔ کاریز + ذخیره فیلتر در اشخاص و کاریز |
|
||||||
| ۱۴۰۵/۷/۵ | صیقل Smart Notes: یادداشت شخصی/تیمی + تاریخچه ویرایش و بازگردانی |
|
| ۱۴۰۵/۷/۵ | صیقل Smart Notes: یادداشت شخصی/تیمی + تاریخچه ویرایش و بازگردانی |
|
||||||
| ۱۴۰۵/۶/۲۰ | پورسانت کارشناس (درصد پیشفرض + تغییر روی فاکتور) و دفتر چک وصل به پرداخت |
|
| ۱۴۰۵/۶/۲۰ | پورسانت کارشناس (درصد پیشفرض + تغییر روی فاکتور) و دفتر چک وصل به پرداخت |
|
||||||
@@ -74,7 +76,9 @@
|
|||||||
| [x] | Voice-to-Text | دکمه دیکته (Pipecat/STT) روی Note و فرمها |
|
| [x] | Voice-to-Text | دکمه دیکته (Pipecat/STT) روی Note و فرمها |
|
||||||
| [x] | Note شخصی در برابر تیمی | visibility=team|personal + فیلتر UI |
|
| [x] | Note شخصی در برابر تیمی | visibility=team|personal + فیلتر UI |
|
||||||
| [x] | تاریخچه ویرایش Note | NoteRevision + بازگردانی |
|
| [x] | تاریخچه ویرایش Note | NoteRevision + بازگردانی |
|
||||||
| [ ] | خلاصه خودکار تماس تلفنی کامل | فراتر از دیکته متن |
|
| [x] | خلاصه خودکار تماس تلفنی کامل | وبهوک VoIP + `recordingUrl`/`transcript` → STT → AI → Note نوع `call_result` |
|
||||||
|
|
||||||
|
**جمع ۱-ب:** کامل از نظر چکلیست فعلی.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -107,15 +111,16 @@
|
|||||||
|
|
||||||
## ۱-هـ — گزارش، اتوماسیون، یکپارچگی
|
## ۱-هـ — گزارش، اتوماسیون، یکپارچگی
|
||||||
|
|
||||||
| وضعیت | مورد |
|
| وضعیت | مورد | توضیح |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| [x] | گزارشات و داشبورد | فروش/قیف/فعالیت/تیم/تیکت + فیلتر تاریخ |
|
| [x] | گزارشات و داشبورد | فروش/قیف/فعالیت/تیم/تیکت + فیلتر تاریخ |
|
||||||
| [x] | اتوماسیون اگر/آنگاه | deal_won / ticket_resolved → فعالیت، اعلان، امتیاز |
|
| [x] | اتوماسیون اگر/آنگاه | deal_won / ticket_resolved → فعالیت، اعلان، امتیاز |
|
||||||
| [x] | SMS عملیاتی | OTP auth/پرتال؛ یادآور فعالیت (ترِز) |
|
| [x] | SMS عملیاتی | OTP auth/پرتال؛ یادآور فعالیت (ترِز) |
|
||||||
| [ ] | API عمومی مستند + وبهوک خروجی | |
|
| [x] | API عمومی + وبهوک خروجی | کلید API؛ `public/v1` افراد/معاملات؛ endpoint وبهوک + تحویل دورهای |
|
||||||
| [ ] | پنل پیامکی کامل داخل CRM | فراتر از ارسال تراکنشی |
|
| [x] | پیامک گروهی | کمپین انبوه به مخاطبان انتخابشده |
|
||||||
| [ ] | درگاه پرداخت آنلاین داخل CRM | |
|
| [x] | ووکامرس | اتصال فروشگاه، همگامسازی سفارش → فاکتور/مشتری، وبهوک |
|
||||||
| [ ] | VoIP / ووکامرس عمیق | |
|
| [x] | VoIP پایه | اتصال سانترال، وبهوک رویداد، لاگ تماس، تطبیق مخاطب، فعالیت، click-to-call |
|
||||||
|
| [x] | خلاصه خودکار تماس | صف پسزمینه روی لاگ تماس؛ وابسته به STT و AI فعال |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,6 +128,7 @@
|
|||||||
|
|
||||||
| وضعیت | مورد | یادداشت |
|
| وضعیت | مورد | یادداشت |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| [ ] | درگاه پرداخت آنلاین داخل CRM | فعلاً نه — پرداخت دستی/فیش/چک کافی است |
|
||||||
| [ ] | `platform_admin` SaaS | وقتی فروش چندمستأجری عمومی شروع شود |
|
| [ ] | `platform_admin` SaaS | وقتی فروش چندمستأجری عمومی شروع شود |
|
||||||
| [ ] | اپ بومی اندروید/iOS | وب RTL + موبایلفرندلی کافی است |
|
| [ ] | اپ بومی اندروید/iOS | وب RTL + موبایلفرندلی کافی است |
|
||||||
| [ ] | حسابداری کامل / انبار ERP | انبار کوچک داخلی هست؛ دفترکل و چندانبار عمداً نه |
|
| [ ] | حسابداری کامل / انبار ERP | انبار کوچک داخلی هست؛ دفترکل و چندانبار عمداً نه |
|
||||||
@@ -132,10 +138,9 @@
|
|||||||
|
|
||||||
## گام بعدی پیشنهادی
|
## گام بعدی پیشنهادی
|
||||||
|
|
||||||
1. API عمومی و وبهوک خروجی
|
1. ارتقای فایل به MinIO در استقرار بزرگ
|
||||||
2. پنل پیامکی / درگاه پرداخت (در صورت نیاز کسبوکار)
|
2. OpenAPI کامل برای API عمومی (در صورت نیاز یکپارچهسازان)
|
||||||
3. ارتقای فایل به MinIO در استقرار بزرگ
|
3. لندینگ Nuxt / `platform_admin` وقتی فروش SaaS شروع شود
|
||||||
4. خلاصه خودکار تماس تلفنی کامل
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3484,6 +3484,14 @@ export interface UpdateVoipConnectionRequest {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VoipSummaryStatus =
|
||||||
|
| "none"
|
||||||
|
| "pending"
|
||||||
|
| "processing"
|
||||||
|
| "done"
|
||||||
|
| "failed"
|
||||||
|
| "skipped";
|
||||||
|
|
||||||
export interface VoipCallLogDto {
|
export interface VoipCallLogDto {
|
||||||
id: string;
|
id: string;
|
||||||
direction: VoipCallDirection;
|
direction: VoipCallDirection;
|
||||||
@@ -3496,6 +3504,12 @@ export interface VoipCallLogDto {
|
|||||||
durationSec: number | null;
|
durationSec: number | null;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
syncedAt: string;
|
syncedAt: string;
|
||||||
|
recordingUrl: string | null;
|
||||||
|
transcript: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
summaryStatus: VoipSummaryStatus | string;
|
||||||
|
summaryNoteId: string | null;
|
||||||
|
summaryError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VoipClickToCallRequest {
|
export interface VoipClickToCallRequest {
|
||||||
@@ -3513,6 +3527,10 @@ export interface VoipWebhookEvent {
|
|||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
endedAt?: string;
|
endedAt?: string;
|
||||||
durationSec?: number;
|
durationSec?: number;
|
||||||
|
/** لینک فایل ضبط (برای خلاصه خودکار) */
|
||||||
|
recordingUrl?: string;
|
||||||
|
/** متن آمادهٔ تماس؛ اگر باشد STT رد میشود */
|
||||||
|
transcript?: string;
|
||||||
/** برای سازگاری با فیلدهای رایج دیگر */
|
/** برای سازگاری با فیلدهای رایج دیگر */
|
||||||
caller?: string;
|
caller?: string;
|
||||||
callee?: string;
|
callee?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user