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:
2026-09-27 15:06:41 +03:30
co-authored by Cursor
parent c06c92b333
commit c5c903d4b4
13 changed files with 615 additions and 22 deletions
+1 -1
View File
@@ -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");
+13
View File
@@ -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])
} }
/// خدمت قابل رزرو (مثلاً مشاوره ۳۰ دقیقه‌ای) /// خدمت قابل رزرو (مثلاً مشاوره ۳۰ دقیقه‌ای)
+38
View File
@@ -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],
}) })
+85 -1
View File
@@ -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;
+9 -6
View File
@@ -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** — فقط وقتی فروش حساب چندمستأجری عمومی شروع شود
+23 -2
View File
@@ -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
View File
@@ -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. خلاصه خودکار تماس تلفنی کامل
--- ---
+18
View File
@@ -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;