الأدلة التقنية

مركز اتصال Call Center على Asterisk: الطابور وتقرير SLA

نبني طابور اتصال بوكلاء وتسجيل للمكالمات، ونولد عليه مكالمات حقيقية، ثم نحسب مستوى الخدمة SLA ونقارن طرق توزيع المكالمات بالأرقام.

مركز اتصال Call Center على Asterisk: الطابور وتقرير SLA

عندما يتصل عميل برقم خدمة العملاء ويكون كل الموظفين في مكالمات، فأمام المقسم ثلاثة خيارات: أن يعطيه نغمة مشغول فيغلق ويتصل بمنافسك، أو أن يحوله إلى البريد الصوتي فلا يرد عليه أحد قبل الغد، أو أن يضعه في طابور انتظار Queue مع موسيقى وإعلان عن ترتيبه، ثم يوصله بأول موظف يصبح متاحاً. والخيار الثالث هو مركز الاتصال Call Center، وهو ليس منتجاً منفصلاً تشتريه، وإنما مجموعة خصائص في المقسم نفسه: الطابور، وتسجيل دخول الموظفين، وتسجيل المكالمات، وتقارير تقول لك كم عميلاً انتظر وكم واحداً يئس وأغلق.

المواضيع في السلسلة:

وقد شرحنا SIP والمقسم وخطة الاتصال Dial Plan في مقال المصطلحات، فلن نعيدها هنا. وفي هذا المقال سوف نبني مركز اتصال صغيراً على Asterisk، ثم نرسل إليه مكالمات حقيقية من أداة اختبار، بعضها لعملاء صبورين وبعضها لعملاء يغلقون بعد 15 ثانية، حتى تخرج لنا أرقام حقيقية نقرأها. وسوف نتناول ما يلي:

  • كيف يعمل الطابور Queue، ومن يرن عندما يتصل عميل.
  • بناء التجربة: الطابور، وموسيقى الانتظار Music on Hold، ووقت التهيئة Wrap-up Time، والتسجيل بالتطبيق MixMonitor.
  • تسجيل دخول الوكلاء Agents وخروجهم وإيقافهم المؤقت من الهاتف.
  • مقارنة طرق التوزيع ringall وleastrecent وfewestcalls على الحمل نفسه، وماذا يحدث للوكيل الأسرع في الرد.
  • سجل queue_log، وسكربت تقرير يحسب متوسط الانتظار ونسبة المكالمات المتروكة ومستوى الخدمة SLA.
  • كم وكيلاً تحتاج لتحقق مستوى خدمة معيناً، بمعادلة Erlang C.

كيف يعمل الطابور Queue؟

الحل البسيط لرقم خدمة العملاء أن تجعله يرن على كل الهواتف معاً، ومن يرد أولاً يأخذ المكالمة، وهذا يعمل في مكتب صغير حتى يصبح كل الموظفين مشغولين، فعندها يسمع العميل نغمة مشغول أو يرن الهاتف دون نهاية، ولا تعرف أنت كم عميلاً ضاع. والطابور يحل المشكلتين: يحفظ العملاء بالترتيب ويسمعهم موسيقى وإعلانات، ويختار لكل مكالمة وكيلاً متاحاً بحسب طريقة توزيع Strategy تحددها، ويكتب كل خطوة في سجل تبني عليه التقارير. والصورة التالية تبين الأجزاء:

ثلاثة متصلين يدخلون طابوراً باسم support بترتيب 1 و2 و3 و4 مع موسيقى انتظار وإعلانات ومهلة 120 ثانية، ثم طريقة التوزيع strategy مثل ringall وleastrecent وfewestcalls وrrmemory تختار وكيلاً، فالوكيل 3001 في مكالمة، والوكيل 3002 في وقت التهيئة ثلاث ثوان، والوكيل 3003 متاح فيرن، وتحتها MixMonitor يسجل المكالمة وqueue_log يسجل الأحداث
الطابور يرن فقط للوكيل المتاح، والسجل يكتب كل خطوة

وفيما يلي المصطلحات التي سوف نستخدمها في بقية المقال:

  • الوكيل Agent أو العضو Member: الهاتف الذي يستقبل مكالمات الطابور، وقد يكون عضواً ثابتاً في ملف الإعداد، أو عضواً يسجل دخوله وخروجه بنفسه Dynamic Member.
  • وقت الانتظار Hold Time: من دخول العميل الطابور حتى رد الوكيل، ومتوسطه يسمى سرعة الرد Average Speed of Answer، واختصاراً ASA.
  • مدة المكالمة Talk Time: من الرد حتى الإغلاق، ومتوسطها مع ما بعدها من عمل يسمى متوسط وقت المعالجة Average Handle Time، واختصاراً AHT.
  • وقت التهيئة Wrap-up Time: ثوان بعد كل مكالمة لا يرن فيها هاتف الوكيل، حتى يكتب ملاحظاته في نظام التذاكر.
  • المكالمة المتروكة Abandoned Call: عميل أغلق وهو ينتظر في الطابور.
  • مستوى الخدمة Service Level: نسبة المكالمات التي رد عليها خلال حد زمني، مثل «80% خلال 20 ثانية»، وهو الرقم الذي تكتبه الإدارة في اتفاقية مستوى الخدمة SLA.

خريطة التجربة

نحتاج مقسماً فيه الطابور، ووكلاء يردون، ومتصلين يتصلون بأعداد وأوقات نتحكم فيها، والهواتف البرمجية العادية لا تصلح لتوليد عشرات المكالمات، لذلك سوف نستخدم SIPp، وهي أداة لاختبار SIP تنفذ سيناريو مكتوباً بصيغة XML لكل مكالمة، فنكتب سيناريو للمتصل وسيناريو للوكيل:

شبكة Docker داخلية باسم ard-cc-net على 10.88.70.0/24 فيها Container المتصلين ard-cc-callers على 10.88.70.50 يتصل بالرقم 700 على المقسم ard-cc-pbx على 10.88.70.10، والمتصلون الصبورون مكالمة كل 10 ثوان والمتعجلون مكالمة كل 30 ثانية ويغلقون بعد 15 ثانية، وثلاثة وكلاء 3001 و3002 و3003 يردون بعد ثانية وثلاث وخمس ثوان ويتحدثون من 8 إلى 20 ثانية
المتصلون والوكلاء برامج SIPp، والأرقام التي تخرج في السجل حقيقية

ولاحظ أننا جعلنا الوكلاء يختلفون في سرعة الرد عمداً: الأول يرد بعد ثانية، والثاني بعد ثلاث، والثالث بعد خمس، كما يحدث في أي فريق حقيقي، وسوف ترى أثر هذا الفرق عندما نقارن طرق التوزيع.

إعداد المقسم: الطابور والتسجيل

نستخدم ال Image نفسها التي بنيناها في مقال الهواتف، فيها Asterisk وSIPp وbaresip وموسيقى الانتظار من الحزمة asterisk-moh-opsound-gsm. وتنبيه الإصدار نفسه يسري هنا: حزمة Ubuntu فيها Asterisk 20.6.0، وهي للتجربة فقط، فاختر للإنتاج فرعاً مدعوماً من جدول الإصدارات.

المتصلون والوكلاء في PJSIP

المتصلون يأتون من «مزود الخدمة»، أي من عنوان واحد معروف، فنعرفهم بالعنوان دون تسجيل كما يعرف أي SIP Trunk، والوكلاء الثلاثة لهم عناوين ثابتة، والوكيل 3009 هاتف حقيقي يسجل بكلمة مرور لنجرب به تسجيل الدخول. والملف asterisk/pjsip.conf كاملاً:

[global]
type=global
user_agent=ArabRoot-Lab-PBX

[transport-udp]
type=transport
protocol=udp
bind=0.0.0.0:5060

; Calls from the carrier (sipp callers) are identified by IP, no registration
[carrier]
type=endpoint
context=from-carrier
disallow=all
allow=alaw
direct_media=no
aors=carrier

[carrier]
type=aor
contact=sip:10.88.70.50:5060

[carrier]
type=identify
endpoint=carrier
match=10.88.70.50

; Agents' phones
[agent](!)
type=endpoint
context=agents
disallow=all
allow=alaw
direct_media=no

[3001](agent)
aors=3001
[3001]
type=aor
contact=sip:[email protected]:5060

[3002](agent)
aors=3002
[3002]
type=aor
contact=sip:[email protected]:5060

[3003](agent)
aors=3003
[3003]
type=aor
contact=sip:[email protected]:5060

[3009](agent)
auth=3009
aors=3009
[3009]
type=auth
auth_type=userpass
username=3009
password=Agent-3009-k2Lm
[3009]
type=aor
max_contacts=1
remove_existing=yes

الطابور في queues.conf

الطابور نفسه يعرف في asterisk/queues.conf، وكل خيار فيه موثق في ملف المثال الرسمي لفرع 20:

[general]
persistentmembers=yes

[support]
strategy=ringall
musicclass=default
timeout=15
retry=2
wrapuptime=3
servicelevel=20
announce-frequency=30
announce-holdtime=no
joinempty=yes
leavewhenempty=no
ringinuse=no

في الإعداد أعلاه لاحظ التالي:

  • strategy طريقة التوزيع، وسوف نغيرها في التجربة، والقيمة الافتراضية ringall.
  • timeout=15 يعني أن هاتف الوكيل يرن 15 ثانية، فإذا لم يرد ينتقل الطابور إلى غيره بعد retry=2 ثانيتين، ويكتب في السجل الحدث RINGNOANSWER.
  • wrapuptime=3 وقت التهيئة بعد كل مكالمة، وجعلناه ثلاث ثوان فقط لتقصير التجربة، وفي الواقع يحدده نوع العمل الذي يقوم به الوكيل بعد المكالمة.
  • servicelevel=20 الحد الزمني لمستوى الخدمة الذي يحسبه Asterisk ويعرضه في queue show.
  • announce-frequency=30 يعلن للعميل ترتيبه كل 30 ثانية، وmusicclass=default يشغل له موسيقى الانتظار من الإعداد الافتراضي في musiconhold.conf، كما في ملف المثال.
  • ringinuse=no يمنع أن يرن الطابور على وكيل في مكالمة أخرى، وpersistentmembers=yes يحفظ الوكلاء الذين سجلوا دخولهم، فلا يخرجون عند إعادة تشغيل المقسم.

خطة الاتصال: الرقم 700 وأكواد الوكلاء

والملف asterisk/extensions.conf فيه سياقان: الرقم 700 للعملاء، وأكواد الوكلاء:

[from-carrier]
; The support number: answer, record the conversation, wait in the queue
exten => 700,1,Answer()
 same => n,Set(CDR(userfield)=support)
 same => n,MixMonitor(/var/spool/asterisk/monitor/${STRFTIME(,,%Y%m%d-%H%M%S)}-${UNIQUEID}.wav,b)
 same => n,Queue(support,,,,120)
 same => n,Hangup()

[agents]
; *45 logs the agent in, *46 logs out, *47 pause, *48 unpause
exten => *45,1,Answer()
 same => n,AddQueueMember(support,PJSIP/${CALLERID(num)})
 same => n,Playback(agent-loginok)
 same => n,Hangup()
exten => *46,1,Answer()
 same => n,RemoveQueueMember(support,PJSIP/${CALLERID(num)})
 same => n,Playback(agent-loggedoff)
 same => n,Hangup()
exten => *47,1,Answer()
 same => n,PauseQueueMember(support,PJSIP/${CALLERID(num)},,lunch)
 same => n,Playback(beep)
 same => n,Hangup()
exten => *48,1,Answer()
 same => n,UnpauseQueueMember(support,PJSIP/${CALLERID(num)})
 same => n,Playback(beep)
 same => n,Hangup()

في الإعداد أعلاه لاحظ التالي:

  • MixMonitor يسجل المكالمة ويخلط الصوتين في ملف واحد، والخيار b يجعله يحفظ الصوت فقط أثناء اتصال العميل بالوكيل، فلا تملأ موسيقى الانتظار القرص. واسم الملف فيه التاريخ والرقم UNIQUEID، وهو الرقم نفسه الذي يظهر في سجل الطابور، فتربط التسجيل بالمكالمة في التقرير.
  • الرقم 120 في Queue أقصى انتظار بالثواني، وبعده يخرج العميل من الطابور إلى السطر التالي، وفي الإنتاج تضع هنا البريد الصوتي أو عرضاً بمعاودة الاتصال بدل Hangup.
  • أكواد الوكلاء تستخدم AddQueueMember وRemoveQueueMember وPauseQueueMember، والسبب lunch في الإيقاف المؤقت يكتب في السجل، فتعرف في التقرير لماذا توقف الوكيل.
⚠️
تسجيل المكالمات بيانات شخصية للعميل وللموظف، لذلك أخبر المتصل في رسالة الترحيب أن المكالمة مسجلة، وحدد مدة حفظ التسجيلات ومن يصل إليها، وراجع قبل التشغيل الأنظمة التي تنطبق على مؤسستك في حماية البيانات الشخصية.

وفي ملف asterisk/logger.conf نفعل سجل الطابور بالسطر queue_log=yes في قسم [general]، ونطاق RTP في rtp.conf من 10000 إلى 10199.

سيناريو الوكيل وسيناريو المتصل

سيناريو الوكيل sipp/agent.xml يقوم بما يقوم به موظف: يرن هاتفه، فيرد بعد مدة، ويتحدث من 8 إلى 20 ثانية ثم يغلق، فإذا وصله CANCEL قبل أن يرد، لأن وكيلاً آخر سبقه أو انتهت مهلة الرنين، يتوقف عن الرنين:

<?xml version="1.0" encoding="UTF-8" ?>
<!-- An agent's phone: rings ANSWER_DELAY ms, then talks 8 to 20 s and hangs up -->
<scenario name="agent">
  <recv request="INVITE">
    <action><ereg regexp="CSeq: ([0-9]+)" search_in="msg" assign_to="whole,invcseq"/>
      <ereg regexp="sip:[^>;]*" search_in="hdr" header="Contact:" assign_to="peer"/></action>
  </recv>
  <Reference variables="whole"/>
  <send>
    <![CDATA[
      SIP/2.0 180 Ringing
      [last_Via:]
      [last_From:]
      [last_To:];tag=[pid]SIPpTag01[call_number]
      [last_Call-ID:]
      [last_CSeq:]
      Contact: <sip:[local_ip]:[local_port]>
      Content-Length: 0
    ]]>
  </send>
  <!-- another agent answered first (ringall) or the ring timeout ended: Asterisk sends CANCEL -->
  <recv request="CANCEL" optional="false" timeout="ANSWER_DELAY" ontimeout="answer"/>
  <send>
    <![CDATA[
      SIP/2.0 200 OK
      [last_Via:]
      [last_From:]
      [last_To:];tag=[pid]SIPpTag01[call_number]
      [last_Call-ID:]
      [last_CSeq:]
      Content-Length: 0
    ]]>
  </send>
  <send>
    <![CDATA[
      SIP/2.0 487 Request Terminated
      [last_Via:]
      [last_From:]
      [last_To:];tag=[pid]SIPpTag01[call_number]
      [last_Call-ID:]
      CSeq: [$invcseq] INVITE
      Content-Length: 0
    ]]>
  </send>
  <recv request="ACK" next="done"/>
  <label id="answer"/>
  <send retrans="500">
    <![CDATA[
      SIP/2.0 200 OK
      [last_Via:]
      [last_From:]
      [last_To:];tag=[pid]SIPpTag01[call_number]
      [last_Call-ID:]
      [last_CSeq:]
      Contact: <sip:[local_ip]:[local_port]>
      Content-Type: application/sdp
      Content-Length: [len]

      v=0
      o=- 1 1 IN IP[local_ip_type] [local_ip]
      s=-
      c=IN IP[media_ip_type] [media_ip]
      t=0 0
      m=audio [media_port] RTP/AVP 8
      a=rtpmap:8 PCMA/8000
    ]]>
  </send>
  <recv request="ACK">
    <action>
      <ereg regexp=".*" search_in="hdr" header="From:" assign_to="them"/>
      <ereg regexp=".*" search_in="hdr" header="To:" assign_to="us"/>
    </action>
  </recv>
  <nop><action><exec play_pcap_audio="/usr/share/sip-tester/g711a.pcap"/></action></nop>
  <pause distribution="uniform" min="8000" max="20000"/>
  <send retrans="500">
    <![CDATA[
      BYE [$peer] SIP/2.0
      Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
      From: [$us]
      To: [$them]
      [last_Call-ID:]
      CSeq: 1 BYE
      Max-Forwards: 70
      Content-Length: 0
    ]]>
  </send>
  <recv response="200"/>
  <label id="done"/>
</scenario>

في السيناريو أعلاه لاحظ التالي:

  • ANSWER_DELAY نستبدلها عند تشغيل كل وكيل بقيمة مختلفة: 1000 و3000 و5000 ملي ثانية.
  • play_pcap_audio يرسل مقطعاً صوتياً حقيقياً بترميز G.711 من ملف يأتي مع SIPp، حتى يجد التسجيل صوتاً يحفظه.
  • الوكيل يحفظ عنوان المقسم من ترويسة Contact في INVITE ليرسل إليه BYE، ويقلب ترويستي From وTo، لأنه هنا الطرف الذي يغلق المكالمة.

وسيناريو المتصل sipp/caller.xml يتصل بالرقم 700، ثم ينتظر أن يغلق الوكيل المكالمة، فإذا مرت مدة صبره PATIENCE دون ذلك يغلق هو:

<?xml version="1.0" encoding="UTF-8" ?>
<!-- A customer calling 700: waits in the queue up to PATIENCE ms, then gives up -->
<scenario name="caller">
  <send retrans="500">
    <![CDATA[
      INVITE sip:[service]@[remote_ip]:[remote_port] SIP/2.0
      Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
      From: "Customer" <sip:05[call_number]@[local_ip]>;tag=[pid]SIPpTag00[call_number]
      To: <sip:[service]@[remote_ip]:[remote_port]>
      Call-ID: [call_id]
      CSeq: 1 INVITE
      Contact: <sip:customer@[local_ip]:[local_port]>
      Max-Forwards: 70
      Content-Type: application/sdp
      Content-Length: [len]

      v=0
      o=- 1 1 IN IP[local_ip_type] [local_ip]
      s=-
      c=IN IP[media_ip_type] [media_ip]
      t=0 0
      m=audio [media_port] RTP/AVP 8
      a=rtpmap:8 PCMA/8000
    ]]>
  </send>
  <recv response="100" optional="true"/>
  <recv response="180" optional="true"/>
  <recv response="200" rtd="true"/>
  <send>
    <![CDATA[
      ACK sip:[remote_ip]:[remote_port] SIP/2.0
      Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
      [last_From:]
      [last_To:]
      Call-ID: [call_id]
      CSeq: 1 ACK
      Max-Forwards: 70
      Content-Length: 0
    ]]>
  </send>
  <nop><action><exec play_pcap_audio="/usr/share/sip-tester/g711a.pcap"/></action></nop>
  <!-- the agent ends the call, or the customer runs out of patience -->
  <recv request="BYE" timeout="PATIENCE" ontimeout="giveup"/>
  <send next="done">
    <![CDATA[
      SIP/2.0 200 OK
      [last_Via:]
      [last_From:]
      [last_To:]
      [last_Call-ID:]
      [last_CSeq:]
      Content-Length: 0
    ]]>
  </send>
  <label id="giveup"/>
  <send retrans="500">
    <![CDATA[
      BYE sip:[remote_ip]:[remote_port] SIP/2.0
      Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
      [last_From:]
      [last_To:]
      Call-ID: [call_id]
      CSeq: 2 BYE
      Max-Forwards: 70
      Content-Length: 0
    ]]>
  </send>
  <recv response="200"/>
  <label id="done"/>
</scenario>

ومن هذا الملف نصنع نوعين من العملاء: صبوراً ينتظر خمس دقائق، ومتعجلاً يغلق بعد 15 ثانية إذا لم ينته الأمر:

sed 's/PATIENCE/300000/' sipp/caller.xml > sipp/caller-patient.xml
sed 's/PATIENCE/15000/' sipp/caller.xml > sipp/caller-impatient.xml

ولاحظ أن المقسم يرد على المتصل فوراً بالتطبيق Answer قبل الطابور، فيبدأ عداد الصبر من دخول الطابور، والعميل المتعجل الذي يرد عليه وكيل خلال 15 ثانية يغلق أيضاً عند الثانية 15، وهذا يحدث في الواقع أيضاً، ويظهر في السجل مكالمة مكتملة أغلقها العميل COMPLETECALLER.

خطأ في سطر واحد يفسد كل الأرقام

في النسخة الأولى من سيناريو المتصل كتبنا رسالة ACK بالمتغير [next_url] كما في أمثلة كثيرة، وشغلنا التجربة كاملة، فخرج تقرير يبدو منطقياً: كل المكالمات أجيبت، ولا توجد مكالمة متروكة واحدة، رغم أن نصف المتصلين متعجلون. ولكن عند قراءة السجل وجدنا 16 مكالمة من نوع COMPLETECALLER، ومجموع الانتظار والحديث في كل واحدة منها نحو 32 ثانية بالضبط، مثل COMPLETECALLER|28|4 وCOMPLETECALLER|29|3. والتقاط مكالمة واحدة على المقسم كشف السبب:

11:40:29.158617 IP 10.88.70.10.5060 > 10.88.70.50.5060: SIP: SIP/2.0 200 OK
11:40:29.158938 IP 10.88.70.50.5060 > 10.88.70.10.5060: SIP: ACK  SIP/2.0
11:40:29.658733 IP 10.88.70.10.5060 > 10.88.70.50.5060: SIP: SIP/2.0 200 OK
11:40:29.658987 IP 10.88.70.50.5060 > 10.88.70.10.5060: SIP: ACK  SIP/2.0
11:40:30.659097 IP 10.88.70.10.5060 > 10.88.70.50.5060: SIP: SIP/2.0 200 OK
11:40:32.659624 IP 10.88.70.10.5060 > 10.88.70.50.5060: SIP: SIP/2.0 200 OK
11:40:36.660322 IP 10.88.70.10.5060 > 10.88.70.50.5060: SIP: SIP/2.0 200 OK

رسالة ACK خرجت دون عنوان بعد كلمة ACK، لأن [next_url] كان فارغاً، فلم يقبلها المقسم، واستمر يعيد إرسال 200 OK بالفواصل المتضاعفة، ثم أنهى المكالمة بعد 32 ثانية لأنه لم يستلم تأكيداً، وكتبها في السجل كأن العميل هو من أغلق. والعميل المتعجل أغلق فعلاً بعد 15 ثانية، ولكن رسالة BYE التي أرسلها كانت فارغة العنوان أيضاً فتجاهلها المقسم، فبقي العميل في الطابور حتى قطع المقسم المكالمة بعد 32 ثانية، ولم يسجل أي ABANDON. والحل أن نكتب العنوان صراحة: ACK sip:[remote_ip]:[remote_port] SIP/2.0، كما في السيناريو أعلاه. والدرس هنا أكبر من SIPp: لا تقم بالثقة في تقرير لم تقرأ السجل الذي خلفه، والسبب أن خطأ في الإعداد قد يعطيك أرقاماً جميلة، و«صفر مكالمات متروكة» في مركز اتصال مزدحم علامة على خطأ وليس على نجاح.

تشغيل المقسم والوكلاء

نعمل في Git Bash على Windows، لذلك نضع MSYS_NO_PATHCONV=1 ونأخذ المسار من pwd -W، ثم ننشئ شبكة داخلية --internal لا تخرج من جهازك، ونشغل المقسم والوكلاء الثلاثة:

export MSYS_NO_PATHCONV=1
P=$(pwd -W)
docker network create --internal --subnet 10.88.70.0/24 ard-cc-net
docker run -d --name ard-cc-pbx --network ard-cc-net --ip 10.88.70.10 \
  -v "$P/asterisk/pjsip.conf:/etc/asterisk/pjsip.conf" \
  -v "$P/asterisk/extensions.conf:/etc/asterisk/extensions.conf" \
  -v "$P/asterisk/queues.conf:/etc/asterisk/queues.conf" \
  -v "$P/asterisk/rtp.conf:/etc/asterisk/rtp.conf" \
  -v "$P/asterisk/logger.conf:/etc/asterisk/logger.conf" \
  -v "$P/queue_report.py:/usr/local/bin/queue_report:ro" \
  ard-voip-tools:1 asterisk -f
for i in 1 2 3; do
  docker run -d --name ard-cc-agent$i --network ard-cc-net --ip 10.88.70.2$i -v "$P/sipp:/sipp:ro" ard-voip-tools:1 \
    sh -c "sed s/ANSWER_DELAY/$((i*2-1))000/ /sipp/agent.xml > /tmp/agent.xml; cd /tmp; exec sipp -sf /tmp/agent.xml -i 10.88.70.2$i -p 5060 -nostdin -trace_err"
done

تسجيل دخول الوكلاء وإيقافهم المؤقت

في مركز الاتصال لا يكون كل الموظفين على الطابور طوال اليوم، فالموظف يسجل دخوله عند بداية ورديته، ويوقف نفسه مؤقتاً للغداء أو لاجتماع، ويخرج في نهاية اليوم، وكل ذلك يجب أن يظهر في التقارير. نجرب تسجيل الدخول من هاتف حقيقي هو baresip بالتحويلة 3009، ونطلب منه الاتصال بالكود *45:

(sleep 4; echo "/dial *45"; sleep 6; echo "q") | docker run -i --rm --name ard-cc-agent9 --network ard-cc-net --ip 10.88.70.29 \
  -v "$P/agent3009:/conf:ro" ard-voip-tools:1 baresip -f /conf 2>&1 | tr '\r' '\n' | grep -E "200 OK|terminated"
docker exec ard-cc-pbx asterisk -rx 'queue show support'
docker exec ard-cc-pbx cat /var/log/asterisk/queue_log

والمجلد agent3009 فيه إعداد baresip بالوحدة stdio وحساب التحويلة 3009، كما في مقال تتبع المكالمة. والمخرج سوف يكون كما يلي:

[email protected]: {0/UDP/v4} 200 OK (ArabRoot-Lab-PBX) [1 binding]
sip:[email protected]: Call with sip:*[email protected] terminated (duration: 2 secs)
support has 0 calls (max unlimited) in 'ringall' strategy (0s holdtime, 0s talktime), W:0, C:0, A:0, SL:0.0%, SL2:0.0% within 20s
   Members:
      PJSIP/3009 (ringinuse disabled) (dynamic) (Unavailable) has taken no calls yet (login was 9 secs ago)
   No Callers

1792496629|NONE|NONE|NONE|QUEUESTART|
1792496642|1792496642.0|support|PJSIP/3009|ADDMEMBER|

الوكيل 3009 أصبح عضواً ديناميكياً Dynamic في الطابور، وسطر ADDMEMBER في السجل يحمل وقت الدخول. وحالته Unavailable لأننا أغلقنا baresip بعد الاتصال مباشرة، والطابور لا يرن على عضو غير متاح. ولاحظ أن السطر الأول QUEUESTART يكتبه Asterisk عند كل تشغيل، فإذا رأيته في منتصف اليوم في سجل حقيقي فالمقسم أعيد تشغيله.

والوكلاء الثلاثة في التجربة برامج لا تتصل بكود، لذلك نضيفهم من سطر أوامر المقسم، وهو ما تفعله أيضاً لوحات الإدارة مثل FreePBX خلف الواجهة، ونخرج الوكيل 3009:

docker exec ard-cc-pbx asterisk -rx 'queue remove member PJSIP/3009 from support'
for i in 1 2 3; do
  docker exec ard-cc-pbx asterisk -rx "queue add member PJSIP/300$i to support penalty 0 as Agent-300$i"
done

والإيقاف المؤقت يعمل بالطريقة نفسها، ونجربه في آخر التجربة على الوكيل 3002 بسبب lunch:

docker exec ard-cc-pbx asterisk -rx 'queue pause member PJSIP/3002 queue support reason lunch'
docker exec ard-cc-pbx asterisk -rx 'queue show support'
docker exec ard-cc-pbx tail -1 /var/log/asterisk/queue_log
paused interface 'PJSIP/3002' in queue 'support' for reason 'lunch'
support has 0 calls (max unlimited) in 'fewestcalls' strategy (9s holdtime, 8s talktime), W:0, C:31, A:1, SL:100.0%, SL2:100.0% within 20s
   Members:
      Agent-3003 (PJSIP/3003) (ringinuse disabled) (dynamic) (Not in use) has taken 10 calls (last was 32 secs ago) (login was 903 secs ago)
      Agent-3001 (PJSIP/3001) (ringinuse disabled) (dynamic) (Not in use) has taken 10 calls (last was 42 secs ago) (login was 906 secs ago)
      Agent-3002 (PJSIP/3002) (ringinuse disabled) (dynamic) (paused:lunch was 0 secs ago) (Not in use) has taken 11 calls (last was 32 secs ago) (login was 904 secs ago)
   No Callers

1792497565|NONE|support|Agent-3002|PAUSE|lunch

الوكيل المتوقف يبقى مسجلاً في الطابور ولكن لا يرن عليه، والسبب مكتوب في السجل مع الوقت، فتعرف من التقرير كم دقيقة قضى كل وكيل متوقفاً ولماذا.

طرق التوزيع: من يأخذ المكالمة؟

طريقة التوزيع Strategy تحدد أي وكيل متاح يرن عليه الطابور، وفيما يلي أشهرها كما يشرحها ملف المثال الرسمي:

الطريقةمن يرن
ringallكل الوكلاء المتاحين معاً، ومن يرد أولاً يأخذ المكالمة، وهي الافتراضية
leastrecentالوكيل الذي مر أطول وقت على آخر مكالمة أنهاها
fewestcallsالوكيل الذي أخذ أقل عدد من المكالمات
rrmemoryبالدور، ويتذكر الطابور آخر من رن عليه
linearبترتيب ثابت كما في الإعداد، فالأول دائماً أولاً
random وwrandomعشوائياً، والثانية تجعل أولوية العضو Penalty وزناً في الاختيار

وقد يتساءل البعض: هل يهم هذا الاختيار فعلاً إذا كانت كل المكالمات سوف يرد عليها في النهاية؟ والإجابة نتركها للأرقام. سوف نرسل الحمل نفسه ثلاث مرات، مع ثلاث طرق مختلفة: 24 عميلاً صبوراً بمعدل مكالمة كل 10 ثوان، ومعهم 8 عملاء متعجلين بمعدل مكالمة كل 30 ثانية، وفي كل مرة نفرغ السجل ونصفر الإحصاءات قبل البدء:

for s in ringall leastrecent fewestcalls; do
  sed -i "s/^strategy=.*/strategy=$s/" asterisk/queues.conf
  docker exec ard-cc-pbx asterisk -rx 'module reload app_queue.so'
  docker exec ard-cc-pbx sh -c ': > /var/log/asterisk/queue_log; rm -f /var/spool/asterisk/monitor/*'
  docker exec ard-cc-pbx asterisk -rx 'queue reset stats support'
  docker run -d --name ard-cc-callers --network ard-cc-net --ip 10.88.70.50 -v "$P/sipp:/sipp:ro" ard-voip-tools:1 sh -c '
    cd /tmp
    sipp 10.88.70.10 -sf /sipp/caller-patient.xml -s 700 -p 5060 -min_rtp_port 6000 -max_rtp_port 6999 \
      -r 1 -rp 10000 -m 24 -l 100 -nostdin -trace_err > /dev/null 2>&1 &
    sleep 3
    sipp 10.88.70.10 -sf /sipp/caller-impatient.xml -s 700 -p 5070 -min_rtp_port 7000 -max_rtp_port 7999 \
      -r 1 -rp 30000 -m 8 -l 100 -nostdin -trace_err > /dev/null 2>&1
    wait'
  docker wait ard-cc-callers; docker rm ard-cc-callers
  docker exec ard-cc-pbx cat /var/log/asterisk/queue_log > queue_log-$s
  docker exec ard-cc-pbx python3 /usr/local/bin/queue_report /var/log/asterisk/queue_log 20 5
done
💡
الخيار -l 100 يرفع حد المكالمات المتزامنة في SIPp، حتى لا تؤخر الأداة المكالمات الجديدة بانتظار انتهاء القديمة، فإذا بدا لك حمل الاختبار أخف مما طلبت فتحقق من هذا الخيار أولاً.

وسكربت التقرير queue_report.py نشرحه في القسم التالي، والنتيجة في الجدول التالي، بعد أن جمعنا فيه سطر الوكلاء من التقرير في كل مرة:

الطريقةAgent-3001 (يرد في 1 ث)Agent-3002 (3 ث)Agent-3003 (5 ث)ASAالمتروكةSL 20 ث لكل المكالمات
ringall9101011.0 ث3 من 3290.6%
leastrecent9101110.0 ث2 من 3293.8%
fewestcalls10111011.0 ث1 من 3296.9%

تحت هذا الحمل لا يوجد فرق يذكر: كل وكيل أخذ نحو ثلث المكالمات، والفرق في المتروكة مكالمة أو اثنتان، وهو أقل من الفرق الذي تصنعه العشوائية في مدد الحديث بين تشغيل وآخر، فلا تقم ببناء قرار على هذه الأرقام وحدها. والسبب أن الطابور مشغول أغلب الوقت، فالوكيل الأسرع يكون غالباً في مكالمة عندما تصل المكالمة التالية، فيأخذها من هو متاح أياً كانت الطريقة.

ولكن مراكز الاتصال لا تعمل تحت الضغط طوال اليوم، فماذا يحدث في الساعات الهادئة؟ نعيد التجربة بحمل خفيف: 12 عميلاً صبوراً، مكالمة كل 30 ثانية، أي أن الطابور فارغ تقريباً عند وصول كل مكالمة، مرة بطريقة ringall ومرة بطريقة leastrecent:

docker run --rm --name ard-cc-callers --network ard-cc-net --ip 10.88.70.50 -v "$P/sipp:/sipp:ro" ard-voip-tools:1 sh -c   'cd /tmp; sipp 10.88.70.10 -sf /sipp/caller-patient.xml -s 700 -p 5060 -r 1 -rp 30000 -m 12 -l 100 -nostdin > /dev/null 2>&1'
docker exec ard-cc-pbx python3 /usr/local/bin/queue_report /var/log/asterisk/queue_log 20 5

والمخرج سوف يكون كما يلي، وقد أخذنا من كل تقرير سطر سرعة الرد وجدول الوكلاء:

ringall
ASA (avg speed of answer): 1.0s
Agent          Calls  Talk(s)  NoAnswer
Agent-3001        12      169         0

leastrecent
ASA (avg speed of answer): 3.0s
Agent          Calls  Talk(s)  NoAnswer
Agent-3001         4       62         0
Agent-3002         4       51         0
Agent-3003         4       50         0

هنا يظهر الفرق كاملاً: مع ringall أخذ الوكيل الأسرع المكالمات الاثنتي عشرة كلها، والوكيلان الآخران لم يأخذا شيئاً، لأن الثلاثة يرنون معاً، والأول يرد قبل غيره دائماً. ومع leastrecent أخذ كل وكيل أربع مكالمات بالضبط، ولكن سرعة الرد ارتفعت من ثانية إلى ثلاث ثوان، لأن الطابور يرن أحياناً على الوكيل البطيء. وهذه هي المقايضة الحقيقية: ringall تعطي العميل أسرع رد، وتعاقب الموظف المجتهد بأن يعمل وحده في الساعات الهادئة، وزملاؤه يرون في التقرير أرقاماً لا تعكس جهدهم، لذلك في أغلب مراكز الاتصال الأنسب leastrecent أو fewestcalls، مع مراقبة سرعة الرد لكل وكيل في السجل.

سجل queue_log: مصدر كل التقارير

كل ما يحدث في الطابور يكتبه Asterisk في الملف /var/log/asterisk/queue_log، سطراً لكل حدث، بالصيغة التي تشرحها وثائق سجل الطوابير: الوقت بصيغة Unix، ثم الرقم uniqueid للمكالمة، ثم اسم الطابور، ثم الوكيل، ثم الحدث، ثم بيانات الحدث. والصورة التالية تبين مكالمتين كما تظهران فيه:

خط زمني لمكالمة مجابة يبدأ ب ENTERQUEUE ثم وقت الانتظار holdtime حتى CONNECT ثم وقت الحديث calltime حتى COMPLETEAGENT، وتحته حد مستوى الخدمة 20 ثانية، وخط زمني لمكالمة متروكة يبدأ ب ENTERQUEUE ثم waittime حتى ABANDON مع position وorigposition
الرقم uniqueid يربط أسطر المكالمة الواحدة، ومنها تحسب كل الأرقام

وهذه أسطر حقيقية من السجل في تجربة الضغط التي سنأتي لها بعد قليل، لمكالمة أجيبت وأخرى تركها صاحبها:

1792497641|1792497641.334|support|NONE|ENTERQUEUE||0510|5
1792497665|1792497641.334|support|Agent-3001|CONNECT|24|1792497664.347|1
1792497677|1792497641.334|support|Agent-3001|COMPLETEAGENT|24|12|5
1792497633|1792497633.329|support|NONE|ENTERQUEUE||054|3
1792497648|1792497633.329|support|NONE|ABANDON|1|3|15

في السجل أعلاه لاحظ التالي:

  • ENTERQUEUE يحمل رقم المتصل وترتيبه عند الدخول.
  • CONNECT أول رقم فيه وقت الانتظار بالثواني، وآخر رقم وقت رنين هاتف الوكيل قبل أن يرد.
  • COMPLETEAGENT يعني أن الوكيل هو من أغلق، وCOMPLETECALLER أن العميل هو من أغلق، والرقمان فيهما وقت الانتظار ثم مدة الحديث.
  • ABANDON فيه ترتيب العميل عندما أغلق، وترتيبه عندما دخل، ثم كم ثانية انتظر.
  • RINGNOANSWER يظهر عندما يرن هاتف وكيل حتى نهاية timeout دون رد، وهو مؤشر مهم على وكيل ترك مكتبه دون أن يوقف نفسه.

سكربت التقرير

لوحات الإدارة التجارية تقرأ هذا السجل نفسه، ولكنك لا تحتاجها لتعرف أرقامك الأساسية، فالسكربت التالي يقرأ السجل ويحسب ما تحتاجه الإدارة كل صباح، وهو queue_report.py كاملاً:

#!/usr/bin/env python3
"""Daily-style report from Asterisk queue_log: python3 queue_report.py /var/log/asterisk/queue_log [threshold] [short]"""
import sys
from collections import defaultdict

path = sys.argv[1]
T = int(sys.argv[2]) if len(sys.argv) > 2 else 20      # service level threshold (seconds)
SHORT = int(sys.argv[3]) if len(sys.argv) > 3 else 5   # abandons faster than this are not counted against you

calls = defaultdict(dict)                    # uniqueid -> what happened to the call
agents = defaultdict(lambda: {"calls": 0, "talk": 0, "noanswer": 0})
for line in open(path):
    f = line.rstrip("\n").split("|")
    if len(f) < 5:
        continue
    _, uid, queue, agent, event, *d = f
    c = calls[uid]
    if event == "ENTERQUEUE":
        c["queue"] = queue
    elif event == "CONNECT":
        c["answered"], c["wait"], c["agent"] = True, int(d[0]), agent
    elif event in ("COMPLETECALLER", "COMPLETEAGENT"):
        c["talk"], c["ended_by"] = int(d[1]), event[8:].lower()
        agents[agent]["calls"] += 1
        agents[agent]["talk"] += int(d[1])
    elif event == "ABANDON":
        c["abandoned"], c["wait"] = True, int(d[2])
    elif event == "EXITWITHTIMEOUT":
        c["timeout"], c["wait"] = True, int(d[2])
    elif event == "RINGNOANSWER":
        agents[agent]["noanswer"] += 1

offered = [c for c in calls.values() if "queue" in c]
ans = [c for c in offered if c.get("answered")]
aband = [c for c in offered if c.get("abandoned")]
short = [c for c in aband if c["wait"] < SHORT]
tout = [c for c in offered if c.get("timeout")]
within = [c for c in ans if c["wait"] <= T]
talked = [c for c in ans if "talk" in c]
avg = lambda xs: sum(xs) / len(xs) if xs else 0

print(f"Offered calls            : {len(offered)}")
print(f"Answered                 : {len(ans)}")
print(f"Abandoned                : {len(aband)}  (of them under {SHORT}s: {len(short)})")
print(f"Left on queue timeout    : {len(tout)}")
print(f"Abandon rate             : {100 * len(aband) / max(len(offered), 1):.1f}%")
print(f"ASA (avg speed of answer): {avg([c['wait'] for c in ans]):.1f}s")
print(f"Longest wait answered    : {max([c['wait'] for c in ans], default=0)}s")
print(f"Avg wait before abandon  : {avg([c['wait'] for c in aband]):.1f}s")
print(f"AHT (avg talk time)      : {avg([c['talk'] for c in talked]):.1f}s")
print(f"SL {T}s, answered only    : {100 * len(within) / max(len(ans), 1):.1f}%")
print(f"SL {T}s, all real calls   : {100 * len(within) / max(len(offered) - len(short), 1):.1f}%")
print()
print(f"{'Agent':<14}{'Calls':>6}{'Talk(s)':>9}{'NoAnswer':>10}")
for a, s in sorted(agents.items()):
    if a != "NONE":
        print(f"{a:<14}{s['calls']:>6}{s['talk']:>9}{s['noanswer']:>10}")

في السكربت أعلاه لاحظ التالي:

  • السكربت يجمع أسطر كل مكالمة بالرقم uniqueid، ثم يقرر مصيرها: أجيبت، أو تركت، أو خرجت بانتهاء المهلة.
  • المكالمة المتروكة خلال أقل من 5 ثوان لا تحسب ضدك في مستوى الخدمة، والسبب أن هذا غالباً رقم خاطئ أو عميل غير رأيه، ولا يستطيع أي فريق أن يرد خلال ثانيتين، وهذا الحد قرار تتفق عليه مع الإدارة وتكتبه في الاتفاقية.
  • يحسب السكربت مستوى الخدمة بطريقتين، والفرق بينهما هو أهم ما في هذا القسم كما سنرى.

تجربة الضغط: عندما يزيد المتصلون على الوكلاء

في تجربة طرق التوزيع كان الحمل أقل من طاقة الوكلاء، فكل الأرقام جميلة. والسؤال الحقيقي هو ماذا يحدث يوم الأحد صباحاً عندما يتصل الجميع؟ نرسل الآن 36 عميلاً صبوراً بمعدل مكالمة كل 7 ثوان، و16 عميلاً متعجلاً بمعدل مكالمة كل 15 ثانية، بطريقة leastrecent، أي نحو مرة ونصف من حمل التجربة الأولى على الوكلاء الثلاثة أنفسهم:

docker run -d --name ard-cc-callers --network ard-cc-net --ip 10.88.70.50 -v "$P/sipp:/sipp:ro" ard-voip-tools:1 sh -c '
  cd /tmp
  sipp 10.88.70.10 -sf /sipp/caller-patient.xml -s 700 -p 5060 -min_rtp_port 6000 -max_rtp_port 6999 \
    -r 1 -rp 7000 -m 36 -l 100 -nostdin -trace_err > /dev/null 2>&1 &
  sleep 3
  sipp 10.88.70.10 -sf /sipp/caller-impatient.xml -s 700 -p 5070 -min_rtp_port 7000 -max_rtp_port 7999 \
    -r 1 -rp 15000 -m 16 -l 100 -nostdin -trace_err > /dev/null 2>&1
  wait'
docker exec ard-cc-pbx asterisk -rx 'queue show support'

وهذه حالة الطابور بعد 200 ثانية من البدء:

support has 5 calls (max unlimited) in 'leastrecent' strategy (26s holdtime, 13s talktime), W:0, C:25, A:9, SL:44.0%, SL2:58.8% within 20s
   Members:
      Agent-3003 (PJSIP/3003) (ringinuse disabled) (dynamic) (Not in use) has taken 8 calls (last was 1 secs ago) (login was 1110 secs ago)
      Agent-3001 (PJSIP/3001) (ringinuse disabled) (dynamic) (in call) (In use) has taken 9 calls (last was 20 secs ago) (login was 1113 secs ago)
      Agent-3002 (PJSIP/3002) (ringinuse disabled) (dynamic) (in call) (In use) has taken 8 calls (last was 14 secs ago) (login was 1111 secs ago)
   Callers:
      1. PJSIP/carrier-00000102 (wait: 0:26, prio: 0)
      2. PJSIP/carrier-00000103 (wait: 0:19, prio: 0)
      3. PJSIP/carrier-00000107 (wait: 0:12, prio: 0)
      4. PJSIP/carrier-00000109 (wait: 0:05, prio: 0)
      5. PJSIP/carrier-0000010a (wait: 0:04, prio: 0)

خمسة عملاء ينتظرون، وأقدمهم منذ 26 ثانية، والسطر الأول يقول إن 9 مكالمات تركت حتى الآن (A:9)، ومستوى الخدمة SL هبط إلى 44%. ولاحظ الوكيل 3003: حالته Not in use وفي الطابور خمسة عملاء، ومع ذلك لا يرن هاتفه، والسبب أنه أنهى مكالمته قبل ثانية واحدة، فهو في وقت التهيئة wrapuptime=3، ولوحة المراقبة لا تفرق بين هذه الحالة ووكيل لا يعمل، وهذا سؤال يتكرر من المشرفين في مراكز الاتصال.

وبعد أن تنتهي كل المكالمات نشغل التقرير:

docker exec ard-cc-pbx python3 /usr/local/bin/queue_report /var/log/asterisk/queue_log 20 5
Offered calls            : 52
Answered                 : 39
Abandoned                : 13  (of them under 5s: 0)
Left on queue timeout    : 0
Abandon rate             : 25.0%
ASA (avg speed of answer): 22.4s
Longest wait answered    : 33s
Avg wait before abandon  : 15.0s
AHT (avg talk time)      : 13.2s
SL 20s, answered only    : 28.2%
SL 20s, all real calls   : 21.2%

Agent          Calls  Talk(s)  NoAnswer
Agent-3001        14      187         0
Agent-3002        13      177         0
Agent-3003        12      152         0

ربع المتصلين تركوا الطابور، وكلهم من المتعجلين، فكل سطر ABANDON في السجل وقت انتظاره 15 ثانية بالضبط، وترتيبهم عند الدخول بين الثالث والسادس، أما الصبورون فقد أجيب عليهم كلهم، وأطول انتظار 33 ثانية، فلم يصل أحد إلى مهلة 120 ثانية. ولاحظ أن الوكلاء الثلاثة أخذوا 14 و13 و12 مكالمة، فالتوزيع عادل، والمشكلة ليست في الوكلاء ولا في طريقة التوزيع، وإنما في العدد: الحمل أكبر من طاقة ثلاثة وكلاء، كما سنحسب بعد قليل.

التسجيلات

كل مكالمة أجيبت لها ملف في /var/spool/asterisk/monitor، واسمه يحمل الرقم uniqueid نفسه الذي في السجل، فمن سطر CONNECT في التقرير تصل إلى تسجيل المكالمة مباشرة:

docker exec ard-cc-pbx sh -c 'ls /var/spool/asterisk/monitor | wc -l; ls -l /var/spool/asterisk/monitor | head -4'
docker exec ard-cc-pbx sh -c 'f=$(ls /var/spool/asterisk/monitor/*.wav | head -1); soxi "$f"'
32
total 344
-rw-r--r-- 1 root root 64684 Oct 20 11:55 20261020-115445-1792497285.207.wav
-rw-r--r-- 1 root root 97004 Oct 20 11:55 20261020-115455-1792497295.209.wav
-rw-r--r-- 1 root root 33004 Oct 20 11:55 20261020-115505-1792497305.211.wav

Input File     : '/var/spool/asterisk/monitor/20261020-115445-1792497285.207.wav'
Channels       : 1
Sample Rate    : 8000
Precision      : 16-bit
Duration       : 00:00:04.04 = 32320 samples ~ 303 CDDA sectors
File Size      : 64.7k
Bit Rate       : 128k
Sample Encoding: 16-bit Signed Integer PCM

ولاحظ أن عدد الملفات 32 بينما أجيبت 31 مكالمة في هذا التشغيل، والسبب أن MixMonitor ينشئ الملف لحظة دخول العميل، فالمكالمة المتروكة تترك ملفاً حجمه 44 بايت، وهو ترويسة WAV دون أي صوت، لذلك احذف هذه الملفات الفارغة دورياً، ولا تحسب عدد المكالمات المجابة من عدد الملفات.

والملف بصيغة WAV بدقة 8000 عينة في الثانية و16 بت، أي 16 KB لكل ثانية من الصوت، وساعة واحدة من المكالمات تساوي نحو 57 MB. ولاحظ أن طول الملف أربع ثوان بينما استمرت المكالمة أطول. والسبب في أداة الاختبار: المتصل يرسل مقطعاً صوتياً طوله سبع ثوان من لحظة دخوله الطابور ثم يتوقف عن إرسال RTP، وقد وجدنا بعد مقارنة الملفات أن طول كل تسجيل يساوي ما بقي من هذا المقطع بعد الرد: ست ثوان تقريباً إذا رد الوكيل بعد ثانية، وأربع إذا رد بعد ثلاث، وفي تجربة الضغط حيث زاد الانتظار على سبع ثوان خرجت أغلب التسجيلات فارغة، رغم أن الوكيل كان يرسل صوته. أي أن MixMonitor في هذا الإعداد كتب الصوت فقط عندما وصل RTP من جهة المتصل. والهاتف الحقيقي يرسل RTP طوال المكالمة فلا تظهر هذه المشكلة عادة، ولكن إذا رأيت في الإنتاج تسجيلات أقصر من مدة المكالمة فابحث عن طرف توقف عن إرسال الصوت، ولا تقم بحساب مدة المكالمات من طول الملفات، وإنما من السجل. واحسب حجم التخزين مسبقاً: مركز فيه 20 وكيلاً يتحدثون ست ساعات في اليوم ينتج نحو 6.9 GB يومياً بهذه الصيغة، لذلك اختر مدة حفظ واضحة، أو اضغط التسجيلات القديمة إلى صيغة أصغر.

مستوى الخدمة SLA: رقم واحد بطريقتين

ضع الأرقام الثلاثة من تجربة الضغط جنباً إلى جنب: Asterisk يقول في queue show إن SL هو 28.2% وSL2 هو 46.2%، وسكربتنا يقول 28.2% للمكالمات المجابة و21.2% لكل المكالمات الحقيقية. والأوضح من ذلك تجربة ringall في القسم السابق، فقد تركت فيها ثلاث مكالمات بعد 15 ثانية، ومع ذلك عرض Asterisk الرقمين SL وSL2 بنسبة 100%، بينما قال سكربتنا 90.6%. والسبب في التعريف: SL يقسم على المكالمات المجابة فقط، فالعميل الذي يئس وأغلق غير موجود في الحساب أصلاً، وSL2 يعد المكالمة المتروكة قبل 20 ثانية نجاحاً، لأنها «انتهت» داخل الحد، أما سكربتنا فيقسم على كل المكالمات ما عدا المتروكة في أقل من 5 ثوان، فالعميل الذي انتظر 15 ثانية ثم أغلق يحسب فشلاً، وهو فعلاً عميل لم تخدمه.

وAsterisk نفسه يحسب في queue show رقمين، كما يشرح ملف المثال: SL وهو نسبة المكالمات المجابة خلال الحد من كل المكالمات المجابة، وSL2 الذي يضيف المكالمات المتروكة خلال الحد إلى البسط والمقام. وكلا الرقمين لا يعاقب المكالمة التي تركها صاحبها بعد انتظار طويل في البسط، لذلك عندما تتفق مع الإدارة على «80% خلال 20 ثانية» اكتب المعادلة نفسها في الاتفاقية: ما الذي في المقام، وهل تحسب المكالمات المتروكة، ومن أي ثانية.

كم وكيلاً تحتاج؟ معادلة Erlang C

بعد أن تعرف أرقامك من السجل، يأتي السؤال الذي يسأله كل مدير: كم موظفاً نحتاج حتى نصل إلى 80% خلال 20 ثانية؟ والحل البسيط أن تقسم الحمل على عدد الموظفين، فإذا كان الحمل يعادل 6.7 موظف مشغول طوال الساعة فسبعة موظفين تكفي، وهذا غير صحيح إطلاقاً، والسبب أن المكالمات لا تأتي منتظمة، وإنما تتجمع، فيتشكل طابور حتى لو كان المتوسط أقل من الطاقة. والمعادلة التقليدية لهذا هي Erlang C من نظرية الطوابير، وتفترض أن المكالمات تصل عشوائياً، وأن أحداً لا يترك الطابور، والسكربت التالي erlangc.py يحسبها:

#!/usr/bin/env python3
"""How many agents for a service level: python3 erlangc.py calls_per_hour aht_seconds target_seconds"""
import sys, math

calls, aht, target = float(sys.argv[1]), float(sys.argv[2]), float(sys.argv[3])
A = calls * aht / 3600                     # offered traffic in Erlangs

def erlang_c(n, A):                         # probability that a call has to wait
    top = A**n / math.factorial(n) * n / (n - A)
    return top / (sum(A**k / math.factorial(k) for k in range(n)) + top)

print(f"Traffic: {A:.2f} Erlang")
print(f"{'Agents':>6} {'P(wait)':>8} {'ASA(s)':>7} {'SL':>6} {'Occupancy':>10}")
for n in range(math.floor(A) + 1, math.floor(A) + 6):
    pw = erlang_c(n, A)
    asa = pw * aht / (n - A)
    sl = 1 - pw * math.exp(-(n - A) * target / aht)
    print(f"{n:>6} {pw:>8.1%} {asa:>7.1f} {sl:>6.1%} {A / n:>10.1%}")

لنفرض أن لديك 100 مكالمة في ساعة الذروة، ومتوسط وقت المعالجة 4 دقائق، أي 240 ثانية، والهدف 20 ثانية:

python3 erlangc.py 100 240 20
Traffic: 6.67 Erlang
Agents  P(wait)  ASA(s)     SL  Occupancy
     7    86.1%   620.0  16.2%      95.2%
     8    53.3%    95.9  52.3%      83.3%
     9    31.3%    32.2  74.2%      74.1%
    10    17.5%    12.6  86.8%      66.7%
    11     9.2%     5.1  93.6%      60.6%

في المخرج أعلاه لاحظ التالي:

  • الحمل 6.67 Erlang، أي ما يعادل 6.67 موظف مشغول طوال الساعة، ومع سبعة موظفين ينتظر 86% من العملاء، ومتوسط الانتظار عشر دقائق، ومستوى الخدمة 16% فقط، وهذا هو الحل البسيط.
  • الوصول إلى 80% خلال 20 ثانية يحتاج عشرة موظفين، أي ثلاثة فوق المتوسط، ونسبة انشغال كل موظف تنخفض إلى 67%.
  • الموظف الحادي عشر يرفع مستوى الخدمة من 87% إلى 94% فقط، فكل موظف إضافي بعد نقطة معينة يعطي تحسناً أقل، وهذا ما يجب أن تريه للإدارة عندما تطلب 95%.

ولنطبق المعادلة على تجربة الضغط نفسها: وصلت 52 مكالمة خلال 245 ثانية، أي نحو 764 مكالمة في الساعة، وكل مكالمة تشغل الوكيل 13.2 ثانية حديثاً، و3 ثوان تهيئة، ونحو 2.8 ثانية رنيناً كما في متوسط آخر رقم في أسطر CONNECT، أي نحو 19 ثانية:

python3 erlangc.py 764 19 20
Traffic: 4.03 Erlang
Agents  P(wait)  ASA(s)     SL  Occupancy
     5    56.7%    11.1  79.5%      80.6%
     6    29.3%     2.8  96.3%      67.2%
     7    14.0%     0.9  99.4%      57.6%
     8     6.1%     0.3  99.9%      50.4%
     9     2.5%     0.1 100.0%      44.8%

الحمل 4.03 Erlang، أي أكثر من أربعة وكلاء مشغولين طوال الوقت، ونحن نملك ثلاثة، لذلك لا يبدأ الجدول إلا من خمسة: مع ثلاثة وكلاء يكبر الطابور دون حد في نظر المعادلة، وفي التجربة لم يكبر دون حد لسبب واحد هو أن ربع العملاء يئسوا وأغلقوا، فخففوا الحمل على حسابهم. والمعادلة تقول إن خمسة وكلاء يقتربون من 80% خلال 20 ثانية، وستة يتجاوزون 96%. وهذا هو حد Erlang C الذي يجب أن تعرفه: هي تفترض أن لا أحد يترك الطابور، فتعطي أرقاماً أسوأ من الواقع عندما يكون الانتظار طويلاً، ولكنها نقطة بداية جيدة، ثم تصحح بالأرقام الحقيقية من سجلك.

والعدد الذي تخرجه المعادلة هو عدد الموظفين على الهواتف في الوقت نفسه، وليس عدد الموظفين في الوردية، فأضف إليه الاستراحات والإجازات والتدريب، وهي غالباً نسبة كبيرة.

قبل أن تنقل هذا إلى الإنتاج

  • ضع بعد Queue في خطة الاتصال مخرجاً حقيقياً: بريداً صوتياً أو تذكرة معاودة اتصال، ولا تقم بإغلاق المكالمة بعد انتهاء المهلة، والسبب أن العميل الذي انتظر دقيقتين ثم سمع الإغلاق لن يتصل مرة أخرى.
  • اجمع queue_log في قاعدة بيانات إذا كبر المركز، وAsterisk يستطيع أن يكتبه مباشرة في قاعدة بيانات بدل الملف، فتبني عليه لوحة مثل Grafana أو Metabase.
  • راقب RINGNOANSWER لكل وكيل، والخيار autopause يوقف الوكيل الذي لا يرد تلقائياً، حتى لا يضيع وقت العملاء في الرنين على مكتب فارغ.
  • التسجيلات بيانات حساسة، فاحفظها على قرص مشفر، وحدد صلاحيات الوصول إليها، وامسحها بعد المدة المتفق عليها.
  • إذا كان المتصلون يأتون عبر SIP Trunk من الإنترنت، فأمن المقسم أولاً، وسوف نشرح ذلك في مقال تأمين المقسم في هذه السلسلة.

إزالة التجربة

docker rm -f ard-cc-pbx ard-cc-agent1 ard-cc-agent2 ard-cc-agent3 ard-cc-callers
docker network rm ard-cc-net
docker rmi ard-voip-tools:1

تدريب عملي

  • غير wrapuptime إلى 15 ثانية، وأعد تجربة الضغط، وقارن متوسط الانتظار ونسبة المكالمات المتروكة بما رأيته هنا.
  • اجعل الوكيل 3003 لا يرد أبداً بزيادة ANSWER_DELAY عنده إلى 60000، ثم ابحث في السجل عن RINGNOANSWER، وجرب الخيار autopause=yes.
  • أضف إلى سكربت التقرير توزيع المكالمات على ساعات اليوم، فهو الرقم الذي تبني عليه جدول الورديات.

الخلاصة

  • مركز الاتصال في Asterisk هو الطابور app_queue، مع أكواد للوكلاء في خطة الاتصال، وMixMonitor للتسجيل، وqueue_log للتقارير.
  • طريقة التوزيع لا تظهر تحت الضغط، وإنما في الساعات الهادئة: ringall أعطت الوكيل الأسرع كل المكالمات الاثنتي عشرة، وleastrecent وزعتها أربعاً أربعاً مقابل ثانيتين إضافيتين في سرعة الرد.
  • كل الأرقام المهمة، أي ASA وAHT ونسبة المتروكة ومستوى الخدمة، تحسب من queue_log بسكربت صغير يربط الأسطر بالرقم uniqueid.
  • مستوى الخدمة نفسه يعطي أرقاماً مختلفة جداً بحسب المقام، فاكتب المعادلة في الاتفاقية وليس الرقم فقط.
  • عدد الوكلاء لا يحسب بقسمة الحمل، وإنما بمعادلة مثل Erlang C، وتذكر أنها تتجاهل العملاء الذين يتركون الطابور.

وفي المقال القادم بإذن الله سوف نتناول تأمين المقسم: الاحتيال على المكالمات Toll Fraud، وfail2ban، والتشفير بال TLS وSRTP.

دليلمصطلحات الاتصالات الهاتفية VoIP للمبتدئينشرح مبسط لمصطلحات الهاتف عبر الإنترنت، وما يمر في الشبكة بين ضغط زر الاتصال ووصول الصوت إلى الطرف الآخر.دليلتتبع مكالمة SIP عبر الشبكة: لماذا يصل الصوت في اتجاه واحد؟نبني مقسماً وهاتفين خلف NAT في تجربة عملية، ونتتبع المكالمة حزمة بحزمة حتى نعرف لماذا يضيع الصوت وكيف نصلحه.دليلهواتف SIP المكتبية والتطبيقات: لماذا لا يرن الجوال النائم؟نجهز الهواتف المكتبية تلقائياً من سيرفر واحد ونؤمن ملفاتها، ونرى أزرار BLF من الداخل، ولماذا يحتاج تطبيق الجوال إشعارات Push حتى يرن.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وبناء التجربة على Asterisk 20.6.0 وSIPp 3.7.2 وbaresip 1.0.0 من حزم Ubuntu 24.04 في Docker.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.