FreePBX を理解し、それを Bitrix24 などと統合する

Bitrix24 は、CRM、ワークフロー、会計、その他管理者が好むものと IT スタッフがあまり好まないものを組み合わせた巨大な複合体です。 このポータルは、小規模なクリニック、メーカー、美容サロンなど、多くの中小企業によって使用されています。 マネージャーが「気に入っている」主な機能は、電話と CRM の統合です。通話はすぐに CRM に記録され、クライアント カードが作成され、着信時にはクライアントに関する情報が表示され、その人が誰なのか、何をしているのかがすぐにわかります。売れるか、そして彼がどれだけ借りているか。 しかし、Bitrix24 の電話サービスと CRM との統合には費用がかかり、場合によっては多額の費用がかかります。 この記事では、オープン ツールと人気の IP PBX との統合の経験について説明します。 FreePBX、そしてさまざまな部分の動作のロジックも考慮します

私は IP テレフォニーの販売と構成、統合を行う会社でアウトソーサーとして働いています。 Bitrix24 を顧客が所有する PBX やさまざまな VDS 会社の仮想 PBX と統合するために、この会社やこの会社に何か提案できないかと尋ねられたとき、私は Google に行きました。 そしてもちろん彼は私にリンクをくれました ハブの記事、説明とgithubがあり、すべてが機能しているようです。 しかし、このソリューションを使用しようとすると、Bitrix24 は以前と同じではなくなり、多くの部分をやり直す必要があることが判明しました。 さらに、FreePBX はあなたにとって単なるアスタリスクではありません。ここでは、構成ファイルで使いやすさと本格的なダイヤルプランを組み合わせる方法を考える必要があります。

私たちは仕事のロジックを学びます

まず、すべてがどのように機能するかについて説明します。 PBX の外部から通話 (プロバイダーからの SIP INVITE イベント) を受信すると、ダイヤルプラン (ダイヤル プラン、ダイヤルプラン) の処理が開始されます。これは、通話をどのような順序で処理するかのルールです。 最初のパケットから多くの情報を取得でき、それをルールで使用できます。 SIP の内部を研究するための優れたツールはアナライザーです。 スングレップ (リンク) これは、apt install/yum install などを介して一般的なディストリビューションに単純にインストールされますが、ソースからビルドすることもできます。 sngrep で通話ログを見てみましょう

FreePBX を理解し、それを Bitrix24 などと統合する

簡略化された形式では、ダイヤルプランは最初のパケットのみを処理し、場合によっては会話中、通話の転送、ボタンの押下 (DTMF)、FollowMe、RingGroup、IVR などのさまざまな興味深い機能を処理します。

招待パックの内容

FreePBX を理解し、それを Bitrix24 などと統合する

実際、ほとんどの単純なダイヤルプランは最初の XNUMX つのフィールドで動作し、ロジック全体は DID と CallerID を中心に展開します。 DID - どこに電話をかけているか、CallerID - 誰が電話をかけているか。

しかし、結局のところ、私たちには会社があり、電話は XNUMX 台ではありません。つまり、PBX には都市番号 (リング グループ)、IVR (こんにちは、電話をかけてきました... ...)、留守番電話 (フレーズ)、時間条件、他の番号またはセルへの転送 (FollowMe、Forward)。 これは、実際に誰が電話を受けるのか、また電話がかかってきたときに誰と会話するのかを明確に判断することが非常に難しいことを意味します。 これは、クライアントの PBX での一般的な通話の開始例です。

FreePBX を理解し、それを Bitrix24 などと統合する

通話が PBX に正常に入力されると、通話はさまざまな「コンテキスト」でダイヤルプランを通過します。 Asterisk の観点から見たコンテキストは番号付きのコマンドのセットであり、各コマンドにはダイヤルされた番号によるフィルターが含まれています (初期段階での外部呼び出しの場合は exten=DID と呼ばれます)。 ダイヤルプラン行のコマンドは、内部関数 (内部加入者への呼び出しなど) であれば何でも可能です。 Dial()、電話を置いて - Hangup())、条件演算子(IF, ELSE, ExecIF など)、このコンテキストの他のルールに移行します (Goto, GotoIF)、関数呼び出し (Gosub、マクロ) の形式で他のコンテキストに遷移します。 別個のディレクティブ include имя_контекста, これにより、別のコンテキストからのコマンドが現在のコンテキストの末尾に追加されます。 include によって組み込まれたコマンドは常に実行されます 後の 現在のコンテキストのコマンド。

FreePBX のロジック全体は、Gosub、マクロ、およびハンドラー ハンドラーを介したインクルードおよび呼び出しを通じて、さまざまなコンテキストを相互に取り込むことに基づいて構築されています。 FreePBX 着信のコンテキストを考慮する

FreePBX を理解し、それを Bitrix24 などと統合する

呼び出しは、すべてのコンテキストを上から下に順番に通過します。各コンテキストでは、マクロ (Macro)、関数 (Gosub)、または単なる遷移 (Goto) などの他のコンテキストへの呼び出しが存在する可能性があるため、呼び出されるものの実際のツリーは、ログで追跡できます。

一般的な PBX の一般的なセットアップ図を以下に示します。 電話をかけると、着信ルートで DID が検索され、その一時的な条件がチェックされ、すべてが正常であれば、音声メニューが起動します。 そこから、ボタン 1 を押すかタイムアウトして、ダイヤル オペレータのグループに戻ります。 通話が終了すると、hangupcall マクロが呼び出されます。その後、特別なハンドラー (ハングアップ ハンドラー) を除いて、ダイヤルプランでは何も実行できなくなります。

FreePBX を理解し、それを Bitrix24 などと統合する

この通話アルゴリズムのどこで、通話の開始に関する情報を CRM に提供し、どこで録音を開始し、どこで録音を終了して、通話に関する情報とともに CRM に送信する必要がありますか?

外部システムとの統合

PBXとCRMの統合とは何ですか? これらは、これら XNUMX つのプラットフォーム間でデータとイベントを変換し、相互に送信する設定とプログラムです。 独立したシステムが通信する最も一般的な方法は API を介するもので、API にアクセスする最も一般的な方法は HTTP REST です。 しかし、アスタリスクはそうではありません。

アスタリスク内は次のとおりです。

  • AGI - 外部プログラム/コンポーネントの同期呼び出し。主にダイヤルプランで使用されます。次のようなライブラリがあります。 ファパギ, パギ

  • AMI - イベントのサブスクライブとテキストコマンドの入力の原則に基づいて動作するテキスト TCP ソケットで、内部的には SMTP に似ており、イベントを追跡し、通話を管理できます。ライブラリがあります。 パミ - Asterisk との接続を作成するために最も人気のあるもの

AMIの出力例

イベント: 新しいチャンネル
特権: 電話、すべて
チャンネル: PJSIP/VMS_pjsip-0000078b
チャネル状態: 4
ChannelStateDesc: リング
発信者ID番号: 111222
発信者ID名: 111222
ConnectedLineNum:
接続された回線名:
言語: 英語
口座番号:
コンテキスト: from-pstn
拡張: s
優先度:1
固有 ID: 1599589046.5244
リンクID: 1599589046.5244

  • ARI は両方を組み合わせたもので、すべて REST、WebSocket 経由、JSON 形式ですが、新鮮なライブラリとラッパーを使用しており、あまり良くないことが偶然に見つかりました (ファファリア, パパリ)約 3 年前に開発されました。

通話開始時の ARI 出力の例

{ "変数":"CallMeCallerIDName", "値":"111222", "タイプ":"ChannelVarset", "タイムスタンプ":"2020-09-09T09:38:36.269+0000", "チャネル":{ "id »:»1599644315.5334″、«名前»:»PJSIP/VMSpjsip-000007b6″, "状態":"呼び出し中", "発信者":{ "名前":"111222", "番号":"111222" }, "接続中":{ "名前":"", "番号" :"" }, "accountcode":"", "dialplan":{ "context":"from-pstn", "exten":"s", "priority":2, "app名前":"ステイシス","アプリデータ":"hello-world" }, "作成時間":"2020-09-09T09:38:35.926+0000", "言語":"en" }, "アスタリスクid":"48:5b:aa:aa:aa:aa", "アプリケーション":"hello-world" }

便利か不便か、特定の API を使用できるか不可能かは、解決する必要があるタスクによって決まります。 CRM と統合するためのタスクは次のとおりです。

  • 通話の開始、転送先を追跡し、発信者 ID、DID、開始時刻と終了時刻、場合によってはディレクトリからデータを抽出します (電話と CRM ユーザー間の接続を検索するため)。

  • 通話の録音を開始および終了し、希望の形式で保存し、録音の終了時にファイルの場所を通知します。

  • (プログラムから) 外部イベントで通話を開始し、内部番号と外部番号を呼び出して接続します。

  • オプション: CRM、ダイヤラー グループ、FollowME と統合して、場所がない場合でも通話を自動転送します (CRM による)

これらすべてのタスクは AMI または ARI を通じて解決できますが、ARI が提供する情報ははるかに少なく、イベントも多くなく、AMI がまだ保持している多くの変数 (マクロ呼び出し、通話記録を含むマクロ内の変数の設定など) は追跡されません。 したがって、正しく正確な追跡を行うために、現時点では (完全ではありませんが) AMI を選択しましょう。 さらに(まあ、これがなかったらどうなるでしょう、私たちは怠け者です) - 原作では(ハブの記事) PAMIを使用します。 *次に、ARI への書き換えを試みる必要がありますが、それが機能するかどうかはわかりません。

統合の再発明

FreePBX が通話の開始、終了時刻、番号、録音されたファイルの名前について簡単な方法で AMI にレポートできるようにするには、元の作成者と同じトリックを使用して通話時間を計算するのが最も簡単です。 - 変数を入力し、その変数が存在するかどうか出力を解析します。 PAMI は、単純にフィルター関数を通じてこれを行うことを提案しています。

以下は、通話の開始時刻に独自の変数を設定する例です (s は、DID 検索を開始する前に実行されるダイヤルプラン内の特別な番号です)。

[ext-did-custom]

exten => s,1,Set(CallStart=${STRFTIME(epoch,,%s)})

この行の AMI イベントの例

イベント: 新しいチャンネル

特権: 電話、すべて

チャンネル: PJSIP/VMS_pjsip-0000078b

チャネル状態: 4

ChannelStateDesc: リング

発信者ID番号: 111222

発信者ID名: 111222

ConnectedLineNum:

接続された回線名:

言語: 英語

口座番号:

コンテキスト: from-pstn

拡張: s

優先度:1

固有 ID: 1599589046.5244

リンクID: 1599589046.5244

アプリケーション: AppData を設定:

コールスタート=1599571046

FreePBXはextension.confファイルとextension_ファイルを上書きするためadded.conf、ファイルを使用します 拡張_カスタム.conf

extension_custom.conf の完全なコード

[globals]	
;; Проверьте пути и права на папки - юзер asterisk должен иметь права на запись
;; Сюда будет писаться разговоры
WAV=/var/www/html/callme/records/wav 
MP3=/var/www/html/callme/records/mp3

;; По этим путям будет воспроизводится и скачиваться запись
URLRECORDS=https://www.host.ru/callmeplus/records/mp3

;; Адрес для калбека при исходящем вызове
URLPHP=https://www.host.ru/callmeplus

;; Да пишем разговоры
RECORDING=1

;; Это макрос для записи разговоров в нашу папку. 
;; Можно использовать и системную запись, но пока пусть будет эта - 
;; она работает
[recording]
exten => ~~s~~,1,Set(LOCAL(calling)=${ARG1})
exten => ~~s~~,2,Set(LOCAL(called)=${ARG2})
exten => ~~s~~,3,GotoIf($["${RECORDING}" = "1"]?4:14)
exten => ~~s~~,4,Set(fname=${UNIQUEID}-${STRFTIME(${EPOCH},,%Y-%m-%d-%H_%M)}-${calling}-${called})
exten => ~~s~~,5,Set(datedir=${STRFTIME(${EPOCH},,%Y/%m/%d)})
exten => ~~s~~,6,System(mkdir -p ${MP3}/${datedir})
exten => ~~s~~,7,System(mkdir -p ${WAV}/${datedir})
exten => ~~s~~,8,Set(monopt=nice -n 19 /usr/bin/lame -b 32  --silent "${WAV}/${datedir}/${fname}.wav"  "${MP3}/${datedir}/${fname}.mp3" && rm -f "${WAV}/${fname}.wav" && chmod o+r "${MP3}/${datedir}/${fname}.mp3")
exten => ~~s~~,9,Set(FullFname=${URLRECORDS}/${datedir}/${fname}.mp3)
exten => ~~s~~,10,Set(CDR(filename)=${fname}.mp3)
exten => ~~s~~,11,Set(CDR(recordingfile)=${fname}.wav)
exten => ~~s~~,12,Set(CDR(realdst)=${called})
exten => ~~s~~,13,MixMonitor(${WAV}/${datedir}/${fname}.wav,b,${monopt})
exten => ~~s~~,14,NoOp(Finish if_recording_1)
exten => ~~s~~,15,Return()


;; Это основной контекст для начала разговора
[ext-did-custom]

;; Это хулиганство, делать это так и здесь, но работает - добавляем к номеру '8'
exten =>  s,1,Set(CALLERID(num)=8${CALLERID(num)})

;; Тут всякие переменные для скрипта
exten =>  s,n,Gosub(recording,~~s~~,1(${CALLERID(number)},${EXTEN}))
exten =>  s,n,ExecIF(${CallMeCallerIDName}?Set(CALLERID(name)=${CallMeCallerIDName}):NoOp())
exten =>  s,n,Set(CallStart=${STRFTIME(epoch,,%s)})
exten =>  s,n,Set(CallMeDISPOSITION=${CDR(disposition)})

;; Самое главное! Обработчик окончания разговора. 
;; Обычные пути обработки конца через (exten=>h,1,чтототут) в FreePBX не работают - Macro(hangupcall,) все портит. 
;; Поэтому вешаем Hangup_Handler на окончание звонка
exten => s,n,Set(CHANNEL(hangup_handler_push)=sub-call-from-cid-ended,s,1(${CALLERID(num)},${EXTEN}))

;; Обработчик окончания входящего вызова
[sub-call-from-cid-ended]

;; Сообщаем о значениях при конце звонка
exten => s,1,Set(CDR_PROP(disable)=true)
exten => s,n,Set(CallStop=${STRFTIME(epoch,,%s)})
exten => s,n,Set(CallMeDURATION=${MATH(${CallStop}-${CallStart},int)})

;; Статус вызова - Ответ, не ответ...
exten => s,n,Set(CallMeDISPOSITION=${CDR(disposition)})
exten => s,n,Return


;; Обработчик исходящих вызовов - все аналогичено
[outbound-allroutes-custom]

;; Запись
exten => _.,1,Gosub(recording,~~s~~,1(${CALLERID(number)},${EXTEN}))
;; Переменные
exten => _.,n,Set(__CallIntNum=${CALLERID(num)})
exten => _.,n,Set(CallExtNum=${EXTEN})
exten => _.,n,Set(CallStart=${STRFTIME(epoch,,%s)})
exten => _.,n,Set(CallmeCALLID=${SIPCALLID})

;; Вешаем Hangup_Handler на окончание звонка
exten => _.,n,Set(CHANNEL(hangup_handler_push)=sub-call-internal-ended,s,1(${CALLERID(num)},${EXTEN}))

;; Обработчик окончания исходящего вызова
[sub-call-internal-ended]

;; переменные
exten => s,1,Set(CDR_PROP(disable)=true)
exten => s,n,Set(CallStop=${STRFTIME(epoch,,%s)})
exten => s,n,Set(CallMeDURATION=${MATH(${CallStop}-${CallStart},int)})
exten => s,n,Set(CallMeDISPOSITION=${CDR(disposition)})

;; Вызов скрипта, который сообщит о звонке в CRM - это исходящий, 
;; так что по факту окончания
exten => s,n,System(curl -s ${URLPHP}/CallMeOut.php --data action=sendcall2b24 --data ExtNum=${CallExtNum} --data call_id=${SIPCALLID} --data-urlencode FullFname='${FullFname}' --data CallIntNum=${CallIntNum} --data CallDuration=${CallMeDURATION} --data-urlencode CallDisposition='${CallMeDISPOSITION}')
exten => s,n,Return

元記事執筆者のオリジナルダイヤルプランの特徴と相違点 -

  • FreePBX が望む .conf 形式のダイヤルプラン (はい、.ael は可能ですが、すべてのバージョンではなく、常に便利であるとは限りません)

  • FreePBX ダイヤルプランはそれのみで機能するため、exten=>h を介して終了を処理する代わりに、hangup_handler を介して処理が導入されました。

  • スクリプト呼び出し文字列を修正し、引用符と外部呼び出し番号 ExtNum を追加

  • 処理は _custom contexts に移動され、FreePBX 設定に触れたり編集したりすることができなくなります - [ 経由で受信ext-did-カスタム]、[経由で送信アウトバウンド-オールルート-カスタム]

  • 番号へのバインドなし - ファイルはユニバーサルであり、サーバーへのパスとリンクのみを構成する必要があります。

開始するには、ログインとパスワードを使用して AMI でスクリプトを実行する必要もあります。このため、FreePBX には _custom ファイルもあります。

manager_custom.conf ファイル

;;  это логин
[callmeplus]
;; это пароль
secret = trampampamturlala
deny = 0.0.0.0/0.0.0.0

;; я работаю с локальной машиной - но если надо, можно и другие прописать
permit = 127.0.0.1/255.255.255.255
read = system,call,log,verbose,agent,user,config,dtmf,reporting,cdr,dialplan
write = system,call,agent,log,verbose,user,config,command,reporting,originate

これらのファイルは両方とも /etc/asterisk に配置し、構成を再読み取りする (またはアスタリスクを再起動する) 必要があります。

# astrisk -rv
  Connected to Asterisk 16.6.2 currently running on freepbx (pid = 31629)
#freepbx*CLI> dialplan reload
     Dialplan reloaded.
#freepbx*CLI> exit

それではPHPに移りましょう

スクリプトの初期化とサービスの作成

AMI のサービスである Bitrix 24 と連携するスキームは完全に単純かつ透明ではないため、別途議論する必要があります。 アスタリスクは、AMI がアクティブ化されると、単にポートを開くだけです。 クライアントが参加すると、認証が要求され、その後、クライアントは必要なイベントをサブスクライブします。 イベントはプレーン テキストで提供されます。PAMI はこれを構造化オブジェクトに変換し、対象のイベント、フィールド、数値などに対してのみフィルター機能を設定する機能を提供します。

呼び出しが着信するとすぐに、親 [from-pstn] コンテキストから NewExten イベントが起動され、すべてのイベントがコンテキスト内の行の順序で進みます。 _custom ダイヤルプランで指定された CallMeCallerIDName 変数と CallStart 変数から情報を受信すると、

  1. 電話がかかってきた内線番号に対応するUserIDを要求する機能。 ダイヤルアップ グループの場合はどうなりますか? 質問は政治的なものです。全員への通話を一度に作成する必要がありますか (全員が同時に通話する場合)、それとも順番に通話するときに通話を作成する必要がありますか? ほとんどのクライアントは最初に利用可能な戦略を採用しているため、これには問題はなく、呼び出しは XNUMX 回だけです。 しかし、問題は解決する必要があります。

  2. Bitrix24 の通話登録関数。CallID を返します。CallID は、通話パラメーターと録音へのリンクを報告するために必要です。 内線番号またはユーザーIDのいずれかが必要です

FreePBX を理解し、それを Bitrix24 などと統合する

通話の終了後、レコード ダウンロード機能が呼び出され、同時に通話完了のステータス (話中、応答なし、成功) が報告され、レコード (存在する場合) を含む mp3 ファイルへのリンクもダウンロードされます。

CallMeIn.php モジュールは継続的に実行する必要があるため、SystemD スタートアップ ファイルが作成されています。 電話してください。サービスこれは /etc/systemd/system/callme.service に置く必要があります

[Unit]
Description=CallMe

[Service]
WorkingDirectory=/var/www/html/callmeplus
ExecStart=/usr/bin/php /var/www/html/callmeplus/CallMeIn.php 2>&1 >>/var/log/callmeplus.log
ExecStop=/bin/kill -WINCH ${MAINPID}
KillSignal=SIGKILL

Restart=on-failure
RestartSec=10s

#тут надо смотреть,какие права на папки
#User=www-data  #Ubuntu - debian
#User=nginx #Centos

[Install]
WantedBy=multi-user.target

スクリプトの初期化と起動は systemctl またはサービスを通じて行われます。

# systemctl enable callme
# systemctl start callme

サービスは必要に応じて (クラッシュの場合) 自動的に再起動します。 受信箱追跡サービスには Web サーバーをインストールする必要はありません。php のみが必要です (間違いなく FeePBX サーバー上にあります)。 ただし、Web サーバー (https も使用) 経由で通話記録にアクセスできない場合、通話記録を聞くことはできません。

次に、発信について話しましょう。 CallMeOut.php スクリプトには XNUMX つの機能があります。

  • PHP スクリプトに対するリクエストを受信したときの呼び出しの開始 (Bitrix 自体の「呼び出し」ボタンの使用を含む)。 Web サーバーなしでは機能しません。リクエストは HTTP POST 経由で受信され、リクエストにはトークンが含まれています。

  • 通話、そのパラメーター、および Bitrix 内のレコードに関するメッセージ。 通話終了時に [sub-call-internal-ended] ダイヤルプランのアスタリスクによって起動されます

FreePBX を理解し、それを Bitrix24 などと統合する

Web サーバーは、Bitrix レコード ファイルのダウンロード (HTTPS 経由) と CallMeOut.php スクリプトの呼び出しの XNUMX つの目的でのみ必要です。 組み込みの FreePBX サーバー (ファイルは /var/www/html) を使用できます。別のサーバーをインストールするか、別のパスを指定することもできます。

ウェブサーバー

Web サーバーのセットアップは独立した研究のために残しておきます (タイプ, タイプ, タイプ)。 ドメインをお持ちでない場合は、FreeDomain( https://www.freenom.com/ru/index.html)、これにより、ホワイト IP に無料の名前が付けられます(外部アドレスがルーター上にのみある場合は、ルーター経由でポート 80、443 を転送することを忘れないでください)。 DNS ドメインを作成したばかりの場合は、すべてのサーバーがロードされるまで (15 分から 48 時間) 待つ必要があります。 国内プロバイダーとの作業の経験によると、1時間からXNUMX日までです。

インストールの自動化

インストールをさらに簡単にするために、インストーラーが github 上で開発されました。 しかし、すべてを手動でインストールしている間、紙の上ではスムーズでした。これをすべていじくり回した後、誰が誰と友達で、誰がどこに行き、どのようにデバッグするかが非常に明確になったためです。 インストーラーはまだありません

デッカー

解決策をすぐに試したい場合は、Docker を使用するオプションがあります。コンテナーをすばやく作成し、外部にポートを提供し、設定ファイルを挿入して試してください (証明書をすでに持っている場合、これは LetsEncrypt コンテナーのオプションです) 、リバース プロキシを FreePBX Web サーバーにリダイレクトする必要があるだけです (別のポートを 88 に指定しました)。Docker で LetsEncrypt をベースにします。 この記事

ダウンロードしたプロジェクト フォルダー (git clone 後) でファイルを実行する必要がありますが、まずアスタリスク構成 (アスタリスク フォルダー) に移動し、そこにレコードへのパスとサイトの URL を書き込みます。

version: '3.3'
services:
  nginx:
    image: nginx:1.15-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/ssl_docker.conf:/etc/nginx/conf.d/ssl_docker.conf
  certbot:
    image: certbot/certbot
  freepbx:
    image: flaviostutz/freepbx
    ports:
      - 88:80 # для настройки
      - 5060:5060/udp
      - 5160:5160/udp
      - 127.0.0.1:5038:5038 # для CallMeOut.php
#      - 3306:3306
      - 18000-18100:18000-18100/udp
    restart: always
    environment:
      - ADMIN_PASSWORD=admin123
    volumes:
      - backup:/backup
      - recordings:/var/spool/asterisk/monitor
      - ./callme:/var/www/html/callme
      - ./systemd/callme.service:/etc/systemd/system/callme.conf
      - ./asterisk/manager_custom.conf:/etc/asterisk/manager_custom.conf
      - ./asterisk/extensions_custom.conf:/etc/asterisk/extensions_custom.conf
#      - ./conf/startup.sh:/startup.sh

volumes:
  backup:
  recordings:

この docker-compose.yaml ファイルは次のように実行されます。

docker-compose up -d

nginx が起動しない場合は、nginx/ssl_docker.conf フォルダー内の構成に問題があります。

その他の統合

そして、同時にいくつかの CRM をスクリプトに組み込んではどうだろうかと私たちは考えました。 私たちは他のいくつかの CRM API、特に無料の組み込み PBX、ShugarCRM と Vtiger を調査しました。その通りです。 はい、原理は同じです。 ただし、これは別の話で、後ほど github に個別にアップロードします。

リファレンス

免責事項: 現実との類似点はすべて架空のものであり、それは私によるものではありません。

出所: habr.com

コメントを追加します