الفرق والمنتج الأدلة التقنية

نظام توظيف على سيرفرك مع Frappe HR: تجربة كاملة

نبني Frappe HR في Docker ونمر بالتوظيف كاملاً مع ثلاثة متقدمين، ونختبر الفرز الآلي على سير ذاتية عربية وإنجليزية.

نظام توظيف على سيرفرك مع Frappe HR: تجربة كاملة

في مقارنة أدوات التوظيف وجدنا أن Frappe HR أكمل الأنظمة مفتوحة المصدر في مسار التوظيف: صفحة وظائف بنموذج تقديم، ومقابلات ببطاقات تقييم للمهارات، وعرض عمل يطبع بالعربية، ثم تحويل المرشح المقبول إلى موظف. ووجدنا أيضاً أنه أثقلها في التشغيل، ولا توجد له Docker Image رسمية جاهزة. وفي هذا الدليل سوف نبنيه ونشغله على سيرفرنا، ثم نمر بالمسار كاملاً كما يمر به فريق توظيف حقيقي، مع ثلاثة متقدمين ببيانات وهمية، أحدهم بسيرة عربية.

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

وفي هذا الدليل سوف نقوم بما يلي:

  • نبني Docker Image فيها Frappe وERPNext وHRMS بإصدارات مثبتة، ونشغلها بملف Compose واحد.
  • ننشر وظيفة مطور Backend في صفحة الوظائف العامة، ونفعل رفع السير الذاتية للزوار بأقل صلاحية ممكنة.
  • نتقدم كثلاثة مرشحين من نموذج التقديم، ثم نحركهم بين الحالات، ونجري مقابلة ببطاقة تقييم، ونصدر عرض عمل PDF.
  • نختبر الفرز الآلي للسير الذاتية بالعربية والإنجليزية، ونرى بالأرقام لماذا أعطى سيرة عربية صفراً.
  • نحذف بيانات متقدمة ونرى أين تبقى نسخها، ثم النسخ الاحتياطي والاستعادة والتحديث.

والصورة التالية تبين ما سوف نبنيه:

المتقدم يفتح صفحة الوظائف ونموذج التقديم، وفريق التوظيف يفتح الواجهة الداخلية desk، وكلاهما يصل إلى Container باسم frontend فيه nginx على المنفذ 18720، ومنه إلى backend وwebsocket وWorkers وscheduler وكلها من Docker Image واحدة اسمها ats-frappe-hrms، وهي تتصل بقاعدة البيانات MariaDB 11.8 وبنسختين من Redis، وفي الأسفل Volume اسمه sites فيه السير الذاتية المرفوعة كملفات خاصة والنسخ الاحتياطية
تسع Containers من Docker Image واحدة، ولا ينشر منفذاً إلا frontend

المتطلبات

  • سيرفر Linux عليه Docker وDocker Compose مع BuildKit، وقد اختبرنا على Docker Engine 29.8.0.
  • مساحة للبناء: الـ Image الناتجة حجمها 5.5 جيجابايت، والبناء يحتاج اتصالاً بالإنترنت لتنزيل الكود والحزم.
  • ذاكرة: بعد انتهاء التجربة كانت التسع Containers تستهلك حوالي 990 ميجابايت من الذاكرة، منها 414 لـ backend و327 لقاعدة البيانات، فخطط لسيرفر بذاكرة 4 جيجابايت على الأقل لكي يبقى هامش للمستخدمين والمهام الخلفية.
  • نطاق فرعي Subdomain مثل hr.example.com يشير إلى السيرفر، وReverse Proxy مثل Caddy يصدر شهادة TLS، إذا أردت أن يصل المتقدمون إلى صفحة الوظائف من الإنترنت.
  • حساب بريد صادر SMTP لرسائل المتقدمين، وسوف نرى في قسم الرسائل ماذا يحدث بدونه.

بناء الـ Docker Image

الحل البسيط الذي يخطر على البال هو تشغيل frappe/erpnext من Docker Hub، ولكن هذه الـ Image فيها Frappe وERPNext فقط، وليس فيها تطبيق HRMS الذي يحتوي التوظيف، ولا يمكن تثبيت تطبيق جديد داخل Container يعمل ثم الاعتماد عليه، لأنه يختفي عند إعادة إنشاء الـ Container. لذلك الطريقة الرسمية في مشروع frappe_docker أن تبني Image خاصة بك فيها التطبيقات التي تريدها، كما في دليل البناء. نبدأ بنسخ المشروع على إصدار محدد:

mkdir -p /opt/hr && cd /opt/hr
git clone --branch v4.1.1 --depth 1 https://github.com/frappe/frappe_docker.git
cd frappe_docker

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

[
  { "url": "https://github.com/frappe/erpnext", "branch": "v16.52.1" },
  { "url": "https://github.com/frappe/hrms", "branch": "v16.52.0" }
]

ثم نبني الـ Image:

docker build \
  --build-arg=FRAPPE_PATH=https://github.com/frappe/frappe \
  --build-arg=FRAPPE_BRANCH=version-16 \
  --secret=id=apps_json,src=apps.json \
  --tag=ats-frappe-hrms:16.52.0 \
  --file=images/layered/Containerfile .

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

  • الملف apps.json يمرر كـ Secret وليس كـ Build Argument، والسبب أن قيم Build Arguments تبقى ظاهرة في تاريخ الـ Image لكل من يملكها، وهذا مهم إذا وضعت فيه لاحقاً رابط مستودع خاص فيه Token.
  • القيمة FRAPPE_BRANCH تحدد أيضاً الـ Images الأساسية frappe/build وfrappe/base التي يبنى عليها الملف، فإذا وضعت فيها رقم إصدار غير موجود كوسم في Docker Hub فشل البناء، لذلك أبقيناها على version-16 وثبتنا التطبيقات بالوسوم في apps.json.
  • البناء يأخذ وقتاً، فقد استغرق في تجربتنا 1100 ثانية، أي حوالي 18 دقيقة، أغلبها في بناء واجهات JavaScript، فابنه على جهاز آخر أو في CI إذا كان السيرفر صغيراً.

التشغيل بملف Compose

ملف Compose الذي يأتي مع المشروع مقسم إلى ملف أساسي وعدة ملفات إضافية Overrides، وجمعناه هنا في ملف واحد مقروء يستخدم نفس الـ Image لكل الخدمات. أنشئ المجلد /opt/hr/stack وفيه الملف compose.yaml:

name: ats-frappe

x-app: &app
  image: ats-frappe-hrms:16.52.0
  restart: unless-stopped
  volumes:
    - sites:/home/frappe/frappe-bench/sites
    - logs:/home/frappe/frappe-bench/logs
  environment:
    FRAPPE_REDIS_CACHE: redis://redis-cache:6379
    FRAPPE_REDIS_QUEUE: redis://redis-queue:6379
  depends_on:
    configurator:
      condition: service_completed_successfully

services:
  db:
    image: mariadb:11.8.9
    restart: unless-stopped
    command: [--character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci, --skip-character-set-client-handshake]
    environment:
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 5s
      retries: 20
    volumes:
      - db-data:/var/lib/mysql

  redis-cache:
    image: redis:7.2.16
    restart: unless-stopped
  redis-queue:
    image: redis:7.2.16
    restart: unless-stopped
    volumes:
      - redis-queue-data:/data

  configurator:
    <<: *app
    restart: "no"
    entrypoint: [bash, -c]
    command:
      - >
        ls -1 apps > sites/apps.txt;
        bench set-config -g db_host db;
        bench set-config -gp db_port 3306;
        bench set-config -g redis_cache redis://redis-cache:6379;
        bench set-config -g redis_queue redis://redis-queue:6379;
        bench set-config -g redis_socketio redis://redis-queue:6379;
        bench set-config -gp socketio_port 9000;
    depends_on:
      db:
        condition: service_healthy

  backend:
    <<: *app
  websocket:
    <<: *app
    command: [node, /home/frappe/frappe-bench/apps/frappe/socketio.js]
  queue-short:
    <<: *app
    command: [bench, worker, --queue, "short,default"]
  queue-long:
    <<: *app
    command: [bench, worker, --queue, "long,default,short"]
  scheduler:
    <<: *app
    command: [bench, schedule]

  frontend:
    <<: *app
    command: [nginx-entrypoint.sh]
    environment:
      BACKEND: backend:8000
      SOCKETIO: websocket:9000
      FRAPPE_SITE_NAME_HEADER: hr.example.com
      UPSTREAM_REAL_IP_ADDRESS: 127.0.0.1
      UPSTREAM_REAL_IP_HEADER: X-Forwarded-For
      UPSTREAM_REAL_IP_RECURSIVE: "off"
      PROXY_READ_TIMEOUT: 120
      CLIENT_MAX_BODY_SIZE: 20m
    depends_on: [backend, websocket]
    ports:
      - "127.0.0.1:18720:8080"

volumes:
  db-data:
  redis-queue-data:
  sites:
  logs:

ثم الملف .env بكلمتي مرور عشوائيتين، واحدة لمستخدم root في قاعدة البيانات وأخرى لمدير الموقع:

cd /opt/hr/stack
printf 'DB_ROOT_PASSWORD=%s\n' "$(openssl rand -hex 16)" > .env
printf 'ADMIN_PASSWORD=%s\n' "$(openssl rand -hex 12)" >> .env
chmod 600 .env
docker compose up -d

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

  • الخدمة configurator تعمل مرة واحدة وتكتب عناوين قاعدة البيانات وRedis في إعدادات Frappe المشتركة، ثم تنتهي، وكل الخدمات الأخرى تنتظر انتهاءها بنجاح.
  • الـ Volume المسمى sites هو أهم شيء في هذا الملف، ففيه إعدادات الموقع والسير الذاتية المرفوعة والنسخ الاحتياطية، والـ Volume المسمى db-data فيه قاعدة البيانات.
  • المنفذ منشور على 127.0.0.1 فقط، والوصول من الخارج يكون عبر الـ Reverse Proxy بالنطاق، كما شرحنا في دليل Caddy، وفي Caddyfile يكفي reverse_proxy 127.0.0.1:18720 للنطاق hr.example.com.
  • القيمة FRAPPE_SITE_NAME_HEADER تجعل nginx يخدم الموقع hr.example.com مهما كان الاسم الذي يصل في الطلب، وهذا مناسب لسيرفر فيه موقع واحد.

إنشاء الموقع وتثبيت HRMS

في Frappe يسمى كل نظام مستقل بقاعدة بياناته موقعاً Site، والآن ننشئ الموقع ونثبت فيه ERPNext ثم HRMS:

cd /opt/hr/stack
set -a; . ./.env; set +a
docker compose exec backend bench new-site hr.example.com \
  --mariadb-user-host-login-scope='%' \
  --db-root-username root --db-root-password "$DB_ROOT_PASSWORD" \
  --admin-password "$ADMIN_PASSWORD" \
  --install-app erpnext --install-app hrms --set-default

وعند تشغيل الأمر سوف تجد أنه يأخذ عدة دقائق، فقد استغرق في تجربتنا 183 ثانية، وآخر المخرج:

Thank you for installing Frappe HR!
Updating Dashboard for hrms
*** Scheduler is disabled ***
Current Site set to hr.example.com

ولاحظ السطر Scheduler is disabled، فالمهام المجدولة معطلة في الموقع الجديد، وهي التي ترسل التذكيرات وتنفذ الأعمال الدورية، لذلك نفعلها ثم نتأكد من الإصدارات:

docker compose exec backend bench --site hr.example.com enable-scheduler
docker compose exec backend bench version
Enabled for hr.example.com
erpnext 16.52.1  ()
frappe 16.53.0  ()
hrms 16.52.0  ()

ولاحظ أن Frappe جاء بالإصدار 16.53.0، لأننا بنينا من الفرع version-16 وليس من وسم، فإذا أردت أن يتكرر نفس الإصدار تماماً فسجل هذا الرقم مع الـ Image التي بنيتها، واحتفظ بها في Registry خاص بدلاً من إعادة بنائها.

بعد ذلك افتح الموقع وأكمل معالج الإعداد Setup Wizard، وفيه نختار اللغة العربية، والدولة Saudi Arabia، والعملة SAR، والمنطقة الزمنية Asia/Riyadh، واسم الشركة، وحساب مدير التوظيف. وتذكر أن هذا الحساب هو مدير النظام System Manager أيضاً، لذلك أنشئ بعده حسابات منفصلة للمقيمين بالدور Interviewer فقط.

نشر الوظيفة في صفحة الوظائف

قبل الوظيفة نحتاج ثلاثة أشياء: المسمى الوظيفي Designation «مطور Backend»، والمهارات Skills التي سوف نقيم بها في المقابلة، ونوع المقابلة Interview Type. أنشأنا خمس مهارات مأخوذة من المتطلبات الأساسية في الإعلان: Python، وقواعد البيانات SQL، وتصميم API، وحل المشكلات، والتواصل، ثم نوع مقابلة باسم «مقابلة تقنية» فيه هذه المهارات مع وصف قصير لكل منها، ومقيم واحد، ومتوسط تقييم متوقع ثلاث نجوم من خمس، كما يشرح توثيق أنواع المقابلات. وهذه الخطوة تطبيق مباشر لقاعدة كتابة بطاقة التقييم قبل قراءة أي سيرة، التي شرحناها في المقال الأول.

ثم أنشأنا الوظيفة الشاغرة Job Opening من التوظيف ثم وظيفة شاغرة، بالوصف المقسم إلى المهام والمتطلبات الأساسية والميزة الإضافية، ونطاق راتب من 12,000 إلى 18,000 ريال شهرياً مع خيار نشر الراتب، وتاريخ إغلاق، وفعلنا خيار النشر Publish. فظهرت في الصفحة العامة /jobs على المسار /jobs/شركة_المثال/مطور-backend، والتفاصيل في توثيق الوظائف الشاغرة.

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

صفحة وظيفة مطور Backend في Frappe HR بواجهة إنجليزية من اليسار إلى اليمين، فيها زر Apply Now ونطاق الراتب والطلبات المستلمة 0 وتاريخ الإغلاق، والوصف العربي محاذى إلى اليسار وعلامات الترقيم في غير مكانها
نفس الصفحة لمتصفح إنجليزي: الواجهة تتبع لغة المتصفح وليس لغة النظام

ثم فتحناها من متصفح لغته العربية، فأصبحت الواجهة مترجمة ومن اليمين إلى اليسار، والوصف بترتيب صحيح:

نفس صفحة الوظيفة بواجهة عربية من اليمين إلى اليسار، فيها جميع الوظائف ونطاق الراتب والطلبات المستلمة ويغلق في 30 نوفمبر 2026، والمهام والمتطلبات الأساسية والميزة الإضافية بترتيب صحيح، والزر مكتوب عليه التطبيق الآن
بمتصفح عربي: الاتجاه صحيح، ولكن ترجمة Apply Now جاءت «التطبيق الآن»

والسبب أن Frappe يختار لغة الزائر غير المسجل من لغة متصفحه، ولغة النظام العربية لا تكفي وحدها. لذلك إذا كان أغلب المتقدمين عرباً فراجع الترجمة في الصفحات التي يراها المتقدم، ويمكنك تصحيح أي نص من الإعدادات بإضافة ترجمة Translation خاصة، مثل «قدم الآن» بدلاً من «التطبيق الآن».

رفع السيرة الذاتية دون حساب

زر التقديم يفتح نموذج طلب التوظيف /job_application/new، وفيه حقل لإرفاق السيرة. ولكن عندما جربنا رفع ملف كزائر رفض السيرفر الطلب بخطأ PermissionError، لأن رفع الملفات للزوار معطل افتراضياً، وهذا قرار أمني صحيح، فكل من على الإنترنت يستطيع أن يرسل ملفات إلى سيرفرك. والحل الأنسب أن تفعله بأضيق نطاق ممكن من إعدادات النظام System Settings:

  • Allow Guests to Upload Files: مفعل.
  • Allowed DocTypes for Guest Uploads: Job Applicant فقط، فلا يرفع الزائر ملفاً لأي نوع مستند آخر.
  • Allowed File Extensions: PDF وDOCX كل منهما في سطر.
  • Max File Size: 5 ميجابايت.

وبعد ذلك جربنا رفع ملف HTML كزائر فرفضه النظام برسالة أنه يقبل صيغ الصور وPDF وTXT وCSV ومستندات Microsoft فقط، وهذا يعني أن للزائر قائمة صيغ محدودة حتى قبل إعدادك، وأن إعدادك يضيقها أكثر. والسير المرفوعة تحفظ كملفات خاصة Private في المجلد private/files داخل الـ Volume، فلا تفتح بالرابط إلا لمن سجل دخوله وله صلاحية.

التقديم كثلاثة متقدمين

جهزنا ثلاث سير ذاتية وهمية بصيغة PDF، وكلها في مجلد التجربة:

  • نورة الأحمد: مطورة بخبرة أربع سنوات في Python وDjango وPostgreSQL وDocker، وسيرتها عربية بالكامل، فتكتب «بايثون» و«دوكر» و«واجهات برمجة التطبيقات».
  • Omar Khaled: مطور بخبرة ثلاث سنوات في Python وFastAPI وPostgreSQL، وسيرته إنجليزية.
  • Lina Haddad: مطورة Frontend بخبرة سنة في React مع قليل من Node.js، وسيرتها إنجليزية.

وعند فتح النموذج بمتصفح عربي ظهرت رسالة خطأ قبل أن نكتب أي شيء:

نافذة فوق نموذج طلب التوظيف عنوانها الطريقة غير مسموح بها، ونصها لا يسمح لك بالوصول إلى هذا المورد سجل الدخول للوصول إليه الدالة frappe.client.get_value غير مدرجة في القائمة البيضاء، وتحتها Not permitted
رسالة «الطريقة غير مسموح بها» تظهر للزائر عند فتح نموذج التقديم

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

نموذج طلب التوظيف بالعربية فيه الوظيفة الشاغرة HR-OPN-2026-0001 والاسم الكامل نورة الأحمد والبريد noura@example.com ورقم الهاتف ومحتويات الرسالة المرفقة بالعربية والسيرة الذاتية private/files/cv-noura-ar.pdf ونطاق الراتب المتوقع شهرياً SAR من 15,000 إلى 17,000 وزر تسجيل
نموذج التقديم بالعربية، والسيرة مرفوعة كملف خاص

وبعد الضغط على تسجيل ظهرت رسالة «شكراً لتقديمك طلبك»، وأنشأ النظام لكل متقدم سجلاً من نوع Job Applicant مصدره Website Listing وحالته Open. ولاحظ أن رقم السجل هو البريد الإلكتروني نفسه، فإذا تقدم نفس الشخص لوظيفتين فسوف يرتبط الطلبان بنفس البريد، ويمنع خيار Prevent Duplicate Applicant في الوظيفة تكرار التقديم.

مسار المتقدمين Hiring Pipeline

حالات المتقدم في Frappe HR ثابتة في ست قيم: Open وReplied وShortlisted وRejected وHold وAccepted، كما في توثيق المتقدمين، ولا تضيف لها مراحل خاصة كما في Odoo، وإنما تعبر عن المراحل التفصيلية بالمقابلات وأنواعها. وفي القائمة الجانبية رابط Hiring Pipeline يفتح المتقدمين في لوحة Kanban بحسب الحالة، فتسحب البطاقة من عمود إلى آخر. وبعد الفرز رفضنا لينا لأن خبرتها في Frontend، وحركنا نورة وعمر إلى Shortlisted، وهذه اللوحة في نهاية التجربة:

لوحة Kanban باسم مسار المتقدمين بأعمدة من اليمين إلى اليسار: Open 0 وReplied 0 وShortlisted 0 وRejected 2 فيها Omar Khaled وLina Haddad وHold 0 وAccepted 1 فيها نورة الأحمد، وفي القائمة الجانبية التوظيف ووظيفة شاغرة ومتقدم للوظيفة ومقابلة وعرض عمل
لوحة المسار من اليمين إلى اليسار، وأسماء الأعمدة بقيت بالإنجليزية

ولاحظ أن تغيير الحالة لا يرسل أي رسالة للمتقدم افتراضياً، وهذا ما سوف نعالجه في قسم الرسائل.

المقابلة وبطاقة التقييم Scorecard

أنشأنا مقابلة Interview لكل من نورة وعمر من نوع «مقابلة تقنية»، فجاءت المهارات الخمس والمقيم تلقائياً من النوع. وعندما حاول المقيم تسجيل تقييمه رفض النظام برسالة «لا يسمح بتسجيل ملاحظات المقابلة قبل تاريخ المقابلة المجدول»، لأننا جدولنا المقابلة في يوم لاحق، وهذا قيد مفيد يمنع تقييماً كتب قبل المقابلة. ثم ظهر قيد آخر: تاريخ المقابلة لا يعدل بعد الحفظ، والتغيير يكون من زر إعادة الجدولة Reschedule.

وبعد المقابلة سجل المقيم بحسابه، بدور Interviewer فقط، ملاحظات المقابلة Interview Feedback لكل مرشح، وهي بطاقة فيها كل مهارة بالنجوم من خمس، ثم النتيجة والملاحظات:

نموذج ملاحظات المقابلة HR-INT-FEED-0001 للمحاور eng@example.com والمقابلة HR-INT-2026-0002 من نوع مقابلة تقنية للمتقدمة noura@example.com، والنتيجة تمت التسوية، وجدول تقييم المهارات: بايثون أربع نجوم وقواعد البيانات SQL أربع وتصميم API خمس وحل المشكلات أربع والتواصل أربع، ومتوسط التقييم، والملاحظات بالعربية
بطاقة التقييم: كل مهارة بنجومها، والمتوسط يحسبه النظام

وكانت النتيجة كما يلي:

المرشحPythonSQLتصميم APIحل المشكلاتالتواصلالمتوسطالنتيجة
نورة الأحمد445444.2Cleared
Omar Khaled333232.8Rejected

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

  • يحفظ النظام الدرجة كنسبة من 1، فمتوسط نورة 0.84 وعمر 0.56، والمتوسط المتوقع الذي وضعناه في نوع المقابلة 0.6، وبالتالي تستطيع أن ترى من وصل إلى الحد ومن لم يصل.
  • تقييم المقيم لم يغير حالة المقابلة، فبقيت Pending حتى غيرها مدير التوظيف إلى Cleared أو Rejected، ولم يغير حالة المتقدم أيضاً، فبقي عمر Shortlisted حتى رفضناه يدوياً. لذلك اجعل مراجعة المقابلات المكتملة جزءاً من اجتماع التوظيف الأسبوعي، وإلا بقيت الحالات قديمة وفسدت المقاييس.
  • الترجمة العربية لكلمة Cleared جاءت «تمت التسوية»، وهي ترجمة مالية لا تعني النجاح في المقابلة، وأسماء المهارات المكتوبة بالإنجليزية مثل Python ظهرت مترجمة «بايثون» في الواجهة العربية.

عرض العمل Job Offer

أنشأنا عرض عمل لنورة بشروط عرض Offer Terms نكتبها مرة واحدة ونعيد استخدامها: الراتب الأساسي الشهري، وبدل السكن، وتاريخ المباشرة، وفترة التجربة، ثم طلبنا ملف PDF من زر الطباعة، فرد السيرفر بخطأ:

OSError: wkhtmltopdf reported an error:
Exit with code 1 due to network error: ConnectionRefusedError

والسبب أن أداة wkhtmltopdf داخل backend تطلب ملفات التنسيق CSS من عنوان الموقع نفسه، والعنوان الذي عرفه Frappe من الطلب لا يصل إليه الـ Container من الداخل. والحل أن تحدد العنوان الذي يستخدمه الموقع في الإعداد host_name، ففي الإنتاج ضع عنوان النطاق الذي يصل إليه السيرفر نفسه مثل https://hr.example.com، وفي تجربتنا المحلية وضعنا عنوان frontend داخل شبكة Docker، ثم أعدنا تشغيل backend لأنه يحتفظ بالإعداد القديم في الذاكرة:

docker compose exec backend bench --site hr.example.com set-config host_name http://frontend:8080
docker compose restart backend

ولاحظ أن الروابط في رسائل البريد تبنى من نفس الإعداد، لذلك لا تترك عنواناً داخلياً مثل frontend:8080 في الإنتاج. وبعد ذلك خرج الملف خلال 1.4 ثانية بحجم 25 كيلوبايت، والعربية فيه صحيحة واتجاهها من اليمين إلى اليسار:

ملف PDF بعنوان عرض عمل HR-OFF-2026-00001 وعليه كلمة مسودة، واسم المتقدم نورة الأحمد وتاريخ العرض 24-10-2026 والبريد noura@example.com وتعيين مطور Backend، وجدول شروط العرض: الراتب الأساسي الشهري 12,500 ريال وبدل السكن 25 بالمئة من الراتب الأساسي وتاريخ المباشرة 1 ديسمبر 2026 وفترة التجربة 90 يوماً، والشروط والأحكام هذا العرض صالح مدة 7 أيام من تاريخه
عرض العمل PDF بالعربية، وعليه «مسودة» حتى يعتمد العرض

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

الرسائل للمتقدمين

بعد تقديم الطلبات الثلاثة كان طابور البريد Email Queue فارغاً، أي أن المتقدم لا يصله تأكيد استلام افتراضياً. لذلك أضفنا تنبيهاً Notification على Job Applicant يرسل رسالة اعتذار عندما تتغير الحالة إلى Rejected، بنص عربي يذكر مدة الاحتفاظ بالطلب وطريقة طلب حذفه. وعندما رفضنا لينا ظهر في سجل الأخطاء:

frappe.exceptions.OutgoingEmailError: يرجى إعداد حساب البريد الإلكتروني الصادر الافتراضي من الأدوات > حساب البريد الإلكتروني

أي أن النظام سجل الرسالة في سجل المراسلات Communication ولم يرسلها، لأنه لا يوجد حساب بريد صادر، فأضف حساب SMTP من إعدادات البريد قبل أن تنشر الوظيفة، وقد شرحنا الفرق بين الإرسال المباشر والترحيل في مقال SMTP والترحيل Relay. ووجدنا أيضاً أن عنوان الرسالة الذي كتبناه بالمتغير {{ doc.job_title }} خرج «بخصوص طلبك لوظيفة HR-OPN-2026-0001»، لأن الحقل job_title في المتقدم هو رابط إلى رقم الوظيفة وليس اسمها، فاستخدم {{ doc.designation }} بدلاً منه.

الفرز الآلي للسير الذاتية بالعربية

لا يحتوي Frappe HR على فرز آلي أو ترتيب للسير الذاتية، والحقل Applicant Rating يملؤه المقيم بيده. وقد يتساءل البعض: لماذا لا نضيف ترتيباً آلياً بالكلمات المفتاحية كما تفعل الخدمات التجارية؟ لنجرب ذلك بالضبط. كتبنا سكربت Python بسيطاً اسمه score.py يقرأ نص كل سيرة PDF بمكتبة pypdf، ويبحث فيه عن كلمات الوظيفة بأوزانها: python بثلاث نقاط، وsql وpostgresql وapi بنقطتين لكل منها، وdocker وbackend بنقطة لكل منهما، والمجموع 11. وله طريقتان: الأولى تبحث في النص كما خرج من الملف، والثانية تطبع النص Normalization أولاً ثم تستبدل الكتابة العربية للمصطلحات بأسمائها الإنجليزية. وهذا الجزء الأساسي من السكربت:

KEYWORDS = {"python": 3, "sql": 2, "postgresql": 2, "api": 2, "docker": 1, "backend": 1}

SYNONYMS = {
    "بايثون": "python", "بايتون": "python",
    "بوستجري": "postgresql", "بوستجريسكل": "postgresql",
    "قواعد البيانات": "sql", "قاعدة البيانات": "sql",
    "واجهات برمجة التطبيقات": "api", "واجهة برمجة التطبيقات": "api",
    "دوكر": "docker", "حاويات": "docker",
    "خلفيات": "backend", "الخلفية": "backend",
}

def normalize(t):
    t = unicodedata.normalize("NFKC", t)                             # presentation forms -> letters
    t = re.sub(r"[‎‏‪-‮⁦-⁩]", "", t)  # bidi marks
    t = re.sub(r"[ً-ْـ]", "", t)                      # diacritics and tatweel
    t = re.sub(r"[آأإ]", "ا", t)                 # alef forms
    t = re.sub(r"([؀-ۿ])([A-Za-z0-9])", r"\1 \2", t)      # Arabic glued to English
    t = re.sub(r"([A-Za-z0-9])([؀-ۿ])", r"\1 \2", t)
    return t.lower()

def score(t):
    found = [k for k in KEYWORDS if re.search(rf"\b{k}\b", t)]
    return sum(KEYWORDS[k] for k in found), found

وأضفنا سيرة رابعة لنورة نفسها بنفس الخبرة، ولكنها تكتب المصطلحات بالإنجليزية داخل النص العربي كما يكتب أغلب المطورين، ثم شغلنا السكربت في Container مؤقت:

docker run --rm -v "$PWD":/lab -w /lab python:3.13.17-slim sh -c \
  "pip install -q pypdf==6.14.2 && python score.py cvs/cv-noura-ar.pdf cvs/cv-noura-mixed.pdf cvs/cv-omar-en.pdf cvs/cv-lina-en.pdf"

والمخرج سوف يكون كما يلي:

cv                      basic  arabic  found (arabic mode)
cv-noura-ar.pdf          0/11   11/11  python, sql, postgresql, api, docker, backend
cv-noura-mixed.pdf       5/11   11/11  python, sql, postgresql, api, docker, backend
cv-omar-en.pdf          11/11   11/11  python, sql, postgresql, api, docker, backend
cv-lina-en.pdf           2/11    2/11  api

والصورة التالية تبين ما حدث:

ملف PDF يتحول إلى نص مستخرج فيه الكلمة العربية ملتصقة بالإنجليزية مثل وأستخدمPython والحروف بأشكال العرض، ثم التطبيع والمرادفات NFKC وإضافة مسافة بين العربية والإنجليزية وبايثون إلى python، وفي الأسفل جدول النتيجة من 11: سيرة نورة العربية 0 قبل التطبيع و11 بعده، وسيرتها المختلطة 5 ثم 11، وعمر 11 و11، ولينا 2 و2
نفس المرشحة: صفر ثم الدرجة الكاملة، والفرق في قراءة النص وليس في الخبرة

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

  • السيرة العربية بالكامل حصلت على صفر في الطريقة الأولى، لأنها لا تحتوي كلمة Python أصلاً وإنما «بايثون»، وأي نظام يطابق كلمات الإعلان الإنجليزية سوف يضعها في آخر القائمة.
  • السيرة المختلطة حصلت على 5 فقط مع أن فيها كلمة Python ثلاث مرات، والسبب في طريقة استخراج النص من PDF، فقد خرجت الكلمات بهذا الشكل: وأﺳﺘﺨﺪمPython وﺑﻠﻐﺔREST API، أي أن الكلمة العربية التصقت بالإنجليزية دون مسافة، فلم تعد Python كلمة مستقلة، وخرجت الحروف العربية بأشكال العرض Presentation Forms بدلاً من الحروف العادية، فلا يطابقها بحث عادي.
  • بعد التطبيع والمرادفات حصلت السيرتان على 11، ولكن قائمة المرادفات يكتبها الفريق بيده، وبعض كلماتها تخطئ، فكلمة «حاويات» قد تعني حاويات الشحن في سيرة شخص يعمل في الخدمات اللوجستية.
  • الدرجة لم تفرق بين نورة وعمر، فكلاهما 11، بينما فرقت المقابلة بينهما بوضوح: 4.2 مقابل 2.8. وهذا هو حد الفرز بالكلمات: يعرف هل ذكرت التقنية، ولا يعرف كيف استخدمتها.

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

حذف بيانات متقدمة: أين تبقى النسخ؟

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

  • ملف السيرة: حذف من القرص ومن جدول الملفات، وهذا جيد.
  • سلة المحذوفات Deleted Documents: فيها نسخة كاملة من سجلها: الاسم والبريد والجوال ورسالة التقديم ومسار السيرة، لأن Frappe يحفظ كل مستند محذوف هناك لكي يمكن استرجاعه.
  • سجل المراسلات Communication: بقيت فيه رسالة الاعتذار التي سجلها التنبيه، وفيها بريدها.
  • النسخة الاحتياطية: استعدنا نسخة أخذت قبل الحذف، فعادت لينا إلى قائمة المتقدمين بحالة Rejected، وعاد ملف سيرتها إلى private/files.

لذلك فالحذف الكامل في هذا النظام ثلاث خطوات: حذف المتقدم، ثم حذف سجله من Deleted Documents، ثم حذف مراسلاته من Communication، وقد نفذناها بحساب مدير التوظيف. وأما النسخ الاحتياطية فلا تعدلها، وإنما اجعل مدة الاحتفاظ بها جزءاً من سياسة الخصوصية، فإذا كانت النسخ تحفظ 30 يوماً فالبيانات المحذوفة تختفي نهائياً بعد 30 يوماً، وإذا استعدت نسخة قديمة فأعد تطبيق طلبات الحذف التي وصلت بعدها.

🛑
حساب مدير التوظيف الذي أنشأه معالج الإعداد هو مدير النظام أيضاً، ويستطيع قراءة كل السير والتقييمات وحذفها. فلا تعطه للمقيمين، وأنشئ لكل مقيم حساباً بدور Interviewer فقط، واربط الدخول بنظام الهوية في الشركة كما شرحنا في دليل authentik.

التحقق من النجاح

  • bench version يظهر hrms و erpnext وfrappe بالإصدارات التي بنيتها.
  • صفحة /jobs تعرض الوظيفة المنشورة، ونموذج التقديم يقبل ملف PDF من زائر ويرفض الصيغ الأخرى.
  • السيرة المرفوعة لا تفتح برابطها في نافذة لم تسجل فيها الدخول.
  • المقيم بدور Interviewer يرى المقابلات المسندة إليه ويسجل تقييمه بعد موعدها فقط.
  • عرض العمل يخرج PDF بالعربية، ورسالة الاعتذار تظهر في طابور البريد بحالة Sent بعد إعداد SMTP.

النسخ الاحتياطي Backup والاستعادة Restore

الأداة الرسمية هي أمر bench backup، ومع الخيار --with-files يحفظ الملفات المرفوعة أيضاً، كما في دليل النسخ الاحتياطي:

docker compose exec backend bench --site hr.example.com backup --with-files
Backup Summary for hr.example.com at 2026-10-24 00:12:49.531611
Config  : /home/frappe/frappe-bench/sites/hr.example.com/private/backups/20261024_001246-hr_example_com-site_config_backup.json 224.0B
Database: /home/frappe/frappe-bench/sites/hr.example.com/private/backups/20261024_001246-hr_example_com-database.sql.gz 1.1MiB
Public  : /home/frappe/frappe-bench/sites/hr.example.com/private/backups/20261024_001246-hr_example_com-files.tar 10.0KiB
Private : /home/frappe/frappe-bench/sites/hr.example.com/private/backups/20261024_001246-hr_example_com-private-files.tar 110.0KiB
Backup for Site hr.example.com has been successfully completed with files

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

  • الملفات الأربعة تحفظ داخل الـ Volume نفسه، فإذا ضاع الـ Volume ضاعت معه، لذلك انسخها خارج السيرفر بعد كل نسخة، بأداة مثل restic كما في مقارنة أدوات النسخ الاحتياطي، واتبع قاعدة 3-2-1.
  • ملف Private فيه السير الذاتية، وملف قاعدة البيانات فيه التقييمات والرواتب المتوقعة، فشفر النسخة قبل نقلها، لأن من يملكها يملك بيانات كل المتقدمين.
  • ملف إعداد الموقع فيه مفتاح التشفير encryption_key الذي تشفر به كلمات المرور المحفوظة في النظام، فاحفظه مع النسخة، وإلا لن تفتح كلمات مرور البريد وغيرها بعد الاستعادة.

واختبرنا الاستعادة على نفس الموقع، فاستغرقت 133 ثانية:

docker compose exec backend bench --site hr.example.com restore \
  sites/hr.example.com/private/backups/20261024_001246-hr_example_com-database.sql.gz \
  --with-private-files sites/hr.example.com/private/backups/20261024_001246-hr_example_com-private-files.tar \
  --db-root-username root --db-root-password "$DB_ROOT_PASSWORD"
App frappe already installed
*** Scheduler is enabled ***
Site hr.example.com has been restored with files

التحديث Upgrade إلى إصدار أحدث

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

  1. خذ نسخة احتياطية بالأمر أعلاه وانسخها خارج السيرفر.
  2. غير الوسوم في apps.json إلى الإصدارات الجديدة من صفحة الإصدارات، وابن الـ Image بوسم جديد مثل ats-frappe-hrms:16.53.0.
  3. غير الوسم في compose.yaml وشغل docker compose up -d، فتعاد الـ Containers بالـ Image الجديدة وتبقى البيانات في الـ Volumes.
  4. شغل الترحيل: docker compose exec backend bench --site hr.example.com migrate.
  5. افتح نموذج التقديم كزائر، وأنشئ عرض عمل PDF، فهذان أكثر ما تغير فيه الإصدارات في تجربتنا.

ولا تقفز بين إصدارات رئيسية Major Versions مثل 15 إلى 16 بهذه الطريقة دون قراءة ملاحظات الترقية، لأن الترحيل بينها قد يغير السلوك الذي تعتمد عليه تخصيصاتك (Breaking Change). ولم نختبر في هذا الدليل الترقية من إصدار سابق، فالأرقام أعلاه من تثبيت جديد.

مشكلات شائعة وحلولها

خطأ wkhtmltopdf عند طباعة عرض العمل

رسالة network error: ConnectionRefusedError تعني أن backend لا يصل إلى عنوان الموقع، فحدد host_name بعنوان يصل إليه السيرفر نفسه كما في قسم عرض العمل، ثم أعد تشغيل backend.

المتقدم لا يستطيع إرفاق سيرته

خطأ PermissionError عند الرفع يعني أن رفع الملفات للزوار معطل، وخطأ الصيغة يعني أن الملف ليس من الصيغ المسموحة، فراجع الإعدادات الأربعة في قسم رفع السيرة.

لا يسمح بتسجيل ملاحظات المقابلة

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

المتقدم لا تصله أي رسالة

تأكد من وجود حساب بريد صادر افتراضي، ومن وجود تنبيه Notification للحدث الذي تريده، ثم راجع طابور البريد Email Queue وسجل الأخطاء Error Log، ففيهما سبب عدم الإرسال كما رأينا في قسم الرسائل.

صفحة الوظائف تظهر بالإنجليزية

لغة الزائر تأتي من لغة متصفحه، فإذا كان أغلب جمهورك عرباً فتأكد أن الإعلان نفسه يذكر ذلك، وراجع الصفحة بمتصفح عربي وآخر إنجليزي بعد كل تعديل.

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

إذا كنت جربت على جهازك وتريد إزالة كل شيء، بما فيه قاعدة البيانات والسير المرفوعة:

cd /opt/hr/stack
docker compose down -v
docker rmi ats-frappe-hrms:16.52.0

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • لا توجد Docker Image رسمية فيها HRMS، فتبنيها بنفسك من frappe_docker بإصدارات مثبتة، والبناء أخذ 18 دقيقة والـ Image حجمها 5.5 جيجابايت.
  • تسع Containers تستهلك حوالي 990 ميجابايت من الذاكرة، والبيانات كلها في Volume قاعدة البيانات وVolume المسمى sites.
  • رفع السير للزوار معطل افتراضياً، ففعله لنوع Job Applicant فقط وبصيغ PDF وDOCX.
  • المسار كامل: نشر الوظيفة، والتقديم، ولوحة Kanban، ومقابلة ببطاقة مهارات، وعرض عمل PDF بالعربية، ثم ملف موظف، مع أخطاء ترجمة ورسالة خطأ للزائر في نموذج التقديم تحتاج مراجعة.
  • الفرز بالكلمات أعطى نفس المرشحة صفراً ثم الدرجة الكاملة بحسب طريقة قراءة النص العربي، ولم يفرق بين مرشحين فرقت بينهما المقابلة.
  • حذف المتقدم يحذف سيرته، ولكن نسخاً من بياناته تبقى في سلة المحذوفات والمراسلات والنسخ الاحتياطية، فاجعلها جزءاً من إجراء الحذف ومن سياسة الاحتفاظ.

وأقرب المقالات إلى هذا الموضوع على الموقع:

دليلنظام تتبع المتقدمين ATS: من إعلان الوظيفة إلى العرضكيف تكتب إعلاناً واضحاً وتفرز المتقدمين بعدل وتقيس مسار التوظيف، وكيف تتعامل مع بياناتهم وفق نظام حماية البيانات الشخصية.دليلأدوات التوظيف مفتوحة المصدر ATS: مقارنة عمليةمقارنة أنظمة تتبع المتقدمين مفتوحة المصدر والتجارية بالترخيص والصيانة والأسعار، مع اختبار فعلي لصفحات الوظائف بالعربية.دليلكيف تدير مقابلة تقنية تكشف مستوى المرشح؟كيف تبني مقابلة تقنية منظمة تفرق بين المبتدئ والخبير بأسئلة المتابعة والتقييم بالدرجات بدلاً من الانطباع.دليلما هو ERP وCRM؟ ومتى تحتاج شركتك إليهماما الذي تفعله أنظمة ERP وCRM، ومتى تحتاجها شركتك فعلاً، ولماذا يتعثر تطبيقها، وما علاقتها بالفاتورة الإلكترونية في المملكة.

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

  • أكتوبر 2026: كتابة الدليل واختباره على Frappe HR 16.52.0 مع ERPNext 16.52.1 وFrappe 16.53.0، بـ Image مبنية من frappe_docker v4.1.1، مع mariadb:11.8.9 وredis:7.2.16 على Docker Engine 29.8.0، واختبار الفرز بمكتبة pypdf 6.14.2 وResume Matcher 1.4.0.
نشرة عرب رووت | ArabRoot

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

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

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

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