> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# CRM على سيرفرك مع Twenty: مسار بيع كامل
- URL: https://arabroot.io/articles/crm-على-سيرفرك-مع-twenty-مسار-بيع-كامل/
- Published: 2026-10-25T05:30:00.000Z
- Updated: 2026-10-25T13:37:39.000Z
- Description: نشغل Twenty في Docker ونبني مسار بيع بأسماء عربية، ونستورد العملاء ونحسب توقع الإيرادات، ونرسل كل تغيير إلى نظام آخر عبر Webhook.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, التسويق والمبيعات, الاستضافة الذاتية, Twenty

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

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

- المبيعات B2B للتقنيين: من العميل المحتمل إلى العقد
- مسار البيع Sales Pipeline: المراحل والتوقعات Forecast
- إدارة علاقات العملاء CRM: Salesforce وبدائله مفتوحة المصدر
- CRM على سيرفرك مع Twenty: مسار بيع كامل (الموضوع الحالي)

[![](https://arabroot.io/content/images/2026/10/crm-tools-cover-cover.webp)دليلإدارة علاقات العملاء CRM: Salesforce وبدائله مفتوحة المصدرمقارنة أنظمة CRM التجارية ومفتوحة المصدر بالترخيص والصيانة والأسعار، مع اختبار فعلي لمسار البيع بالعربية في أربعة أنظمة.](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%B9%D9%84%D8%A7%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%B9%D9%85%D9%84%D8%A7%D8%A1-crm-salesforce-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84%D9%87-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1/)[![](https://arabroot.io/content/images/2026/10/sales-pipeline-cover-cover.webp)دليلمسار البيع Sales Pipeline: المراحل والتوقعات Forecastكيف تبني مراحل بيع بشروط خروج واضحة، وتحسب نسبة الفوز وسرعة البيع والتوقع الموزون من صفقاتك، مع تجربة عملية في Docker.](https://arabroot.io/articles/%D9%85%D8%B3%D8%A7%D8%B1-%D8%A7%D9%84%D8%A8%D9%8A%D8%B9-sales-pipeline-%D8%A7%D9%84%D9%85%D8%B1%D8%A7%D8%AD%D9%84-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D9%82%D8%B9%D8%A7%D8%AA-forecast/)[![](https://arabroot.io/content/images/2026/10/sales-basics-cover-cover.webp)دليلالمبيعات B2B للتقنيين: من العميل المحتمل إلى العقدكيف تبيع شركة تقنية لشركات أخرى: من يقرر عند العميل، وكيف تؤهل الصفقة، وما الوثائق والتسعير من أول اجتماع إلى الفاتورة.](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%A8%D9%8A%D8%B9%D8%A7%D8%AA-b2b-%D9%84%D9%84%D8%AA%D9%82%D9%86%D9%8A%D9%8A%D9%86-%D9%85%D9%86-%D8%A7%D9%84%D8%B9%D9%85%D9%8A%D9%84-%D8%A7%D9%84%D9%85%D8%AD%D8%AA%D9%85%D9%84-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%B9%D9%82%D8%AF/)

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

- نشغل Twenty بملف Compose واحد فيه server وworker وPostgreSQL وRedis، بإصدارات مثبتة.
- ننشئ مساحة العمل Workspace ونحول الواجهة إلى العربية ونرى ما يعمل وما لا يعمل فيها.
- نغير مراحل الفرص Opportunities إلى مراحل بيع B2B، ونرى ماذا يحدث للوحة Kanban إذا غيرناها من الـ API.
- نستورد ست شركات من ملف CSV، ونضيف جهات الاتصال والصفقات بسكربت عبر REST API.
- نحسب توقعاً موزوناً Weighted Forecast لكل شهر من بيانات الصفقات.
- نرسل تغييرات الصفقات إلى نظام آخر عبر Webhook موقع بتوقيع HMAC، ونرى لماذا لا يصل الطلب في المحاولة الأولى.
- النسخ الاحتياطي والاستعادة والتحديث والمشكلات التي ظهرت لنا.

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

![فريق المبيعات يفتح Twenty من المتصفح، وسكربت التوقعات يصل إلى الـ API بمفتاح API، وكلاهما يصل إلى Container باسم server على المنفذ 3000، وهو يكتب في PostgreSQL 16 ويضع المهام في Redis، والـ worker يسحب المهام ويرسل طلب POST إلى نظام الفوترة على العنوان hook.example.com:8080 الذي يتحقق من توقيع HMAC، والطلب مسموح عبر المتغير OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS](https://arabroot.io/content/images/2026/10/crm-lab-01-lab-map.webp)

أربع Containers لـ Twenty، وخامسة تمثل نظام الفوترة الذي يستقبل الـ Webhook

## المتطلبات

- سيرفر Linux عليه Docker وDocker Compose، وقد اختبرنا على Docker Engine 29.8.0.
- ذاكرة: التوثيق الرسمي يطلب [2 جيجابايت على الأقل](https://docs.twenty.com/developers/self-host/capabilities/docker-compose?ref=arabroot.io)، وبعد انتهاء التجربة كان server يستهلك 922 ميجابايت والـ worker يستهلك 768 ميجابايت وقاعدة البيانات 129 ميجابايت، أي حوالي 1.8 جيجابايت للنظام وحده، لذلك خطط لسيرفر بذاكرة 4 جيجابايت إذا كان عليه شيء آخر.
- مساحة: الـ Docker Image الخاصة بالإصدار v2.51.0 حجمها 1.65 جيجابايت بعد فك الضغط.
- طريقة وصول خاصة للفريق، مثل شبكة [WireGuard](https://arabroot.io/articles/%D8%B4%D8%A8%D9%83%D8%A9-%D8%AE%D8%A7%D8%B5%D8%A9-%D9%85%D8%B9-wireguard-%D9%88-wg-easy/)، أو نطاق فرعي Subdomain مثل `crm.example.com` خلف Reverse Proxy، وسوف نشرح السبب في قسم [الوصول من خارج السيرفر](#access).

## التثبيت بملف Compose

المشروع ينشر [ملف Compose رسمياً](https://github.com/twentyhq/twenty/blob/main/packages/twenty-docker/docker-compose.yml?ref=arabroot.io) يستخدم الوسم `latest` افتراضياً، وهذا غير مناسب لنظام فيه بيانات العملاء، والسبب أن الـ server يشغل ترحيل قاعدة البيانات Migration تلقائياً عند كل تشغيل، فإذا سحبت `latest` بعد شهر فقد ترقى النظام دون أن تأخذ نسخة احتياطية. لذلك بنينا الملف التالي على الملف الرسمي مع تثبيت كل الإصدارات. نبدأ بالمجلد وملف `.env`:

```bash
mkdir -p /opt/crm && cd /opt/crm
cat > .env <<EOF
SERVER_URL=http://localhost:3000
PG_PASSWORD=$(openssl rand -hex 24)
ENCRYPTION_KEY=$(openssl rand -base64 32)
WEBHOOK_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 .env
```

لاحظ أن `PG_PASSWORD` بالصيغة الست عشرية Hex وليس Base64، والسبب أنها تدخل كما هي داخل رابط الاتصال `PG_DATABASE_URL`، والرموز `/` و`+` التي تظهر في Base64 تفسد الرابط. أما `ENCRYPTION_KEY` فهو المفتاح الذي يشفر به Twenty الأسرار المخزنة في قاعدة البيانات، مثل توكنات الحسابات المرتبطة، فاحفظه مع النسخة الاحتياطية، لأن قاعدة بيانات بدون مفتاحها لا تفيدك في الاستعادة. ثم الملف `compose.yaml`:

```yaml
name: crm

x-env: &env
  PG_DATABASE_URL: postgres://twenty:${PG_PASSWORD}@db:5432/twenty
  SERVER_URL: ${SERVER_URL}
  REDIS_URL: redis://redis:6379
  STORAGE_TYPE: local
  ENCRYPTION_KEY: ${ENCRYPTION_KEY}
  # Private hosts that webhooks and workflow HTTP calls may reach (SSRF protection blocks the rest)
  OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS: hook.example.com

services:
  server:
    image: twentycrm/twenty:v2.51.0
    ports:
      - "127.0.0.1:3000:3000"
    volumes:
      - server-data:/app/packages/twenty-server/.local-storage
    environment:
      <<: *env
      NODE_PORT: 3000
    depends_on:
      db: { condition: service_healthy }
      redis: { condition: service_healthy }
    healthcheck:
      test: curl --fail http://localhost:3000/healthz
      start_period: 15m
      interval: 5s
      timeout: 5s
      retries: 30
    restart: unless-stopped

  worker:
    image: twentycrm/twenty:v2.51.0
    command: ["yarn", "worker:prod"]
    volumes:
      - server-data:/app/packages/twenty-server/.local-storage
    environment:
      <<: *env
      DISABLE_DB_MIGRATIONS: "true"
      DISABLE_CRON_JOBS_REGISTRATION: "true"
    depends_on:
      server: { condition: service_healthy }
    restart: unless-stopped

  db:
    image: postgres:16.15
    volumes:
      - db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: twenty
      POSTGRES_USER: twenty
      POSTGRES_PASSWORD: ${PG_PASSWORD}
    healthcheck:
      test: pg_isready -U twenty -d twenty
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped

  redis:
    image: redis:7.2.16
    command: ["--maxmemory-policy", "noeviction"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped

  hook:
    image: node:24.22.1-bookworm
    command: ["node", "/hook.js"]
    volumes:
      - ./hook.js:/hook.js:ro
    environment:
      WEBHOOK_SECRET: ${WEBHOOK_SECRET}
    networks:
      default:
        aliases: [hook.example.com]
    restart: unless-stopped

volumes:
  db-data:
  server-data:
```

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

- الـ server هو الذي يشغل ترحيل قاعدة البيانات عند التشغيل، والـ worker يعطل ذلك بالمتغيرين `DISABLE_DB_MIGRATIONS` و`DISABLE_CRON_JOBS_REGISTRATION`، لكي لا يحاول الاثنان تعديل قاعدة البيانات في نفس اللحظة. والـ worker هو من يرسل الـ Webhooks وينفذ المهام الخلفية، فإذا توقف تتوقف الإشعارات دون أن تلاحظ ذلك في الواجهة.
- Redis بالسياسة `noeviction` كما في الملف الرسمي، والسبب أنه يحمل طابور المهام Queue، ولا تريد أن يحذف Redis مهمة لم تنفذ لأنه احتاج ذاكرة.
- أضفنا `start_period: 15m` إلى فحص الصحة Health Check، وهذا غير موجود في الملف الرسمي، وسوف نرى السبب بعد قليل.
- الخدمة `hook` ليست جزءاً من Twenty، وإنما سيرفر صغير يمثل نظام الفوترة الذي يستقبل الـ Webhook، وأعطيناه اسماً إضافياً Alias هو `hook.example.com` داخل شبكة Docker، وسوف نشرح في [قسم الـ Webhook](#webhook) لماذا يحتاج اسماً فيه نقطة، ولماذا يحتاج المتغير `OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS`.
- المنفذ منشور على `127.0.0.1` فقط، فلا يصل إلى النظام أحد من خارج السيرفر إلا عبر الطريقة التي تختارها.

أنشئ الملف `hook.js` كما في [قسم الـ Webhook](#webhook)، أو احذف الخدمة `hook` إذا لم تكن تحتاجها الآن، ثم شغل النظام وتابع السجلات Logs:

```bash
docker compose up -d
docker compose logs -f server
```

وعند التشغيل الأول سوف تجد أن الـ server يبدأ بالرسالة `Running database setup and migrations...` ثم يمر على مئات أوامر الترحيل، وفي تجربتنا مرت عشر دقائق تقريباً بين بدء الـ Container والرسالة التالية التي تعني أن النظام جاهز:

```text
LOG [NestApplication] Nest application successfully started
```

وهنا يظهر سبب `start_period`، فالملف الرسمي يعطي فحص الصحة 30 محاولة كل خمس ثوان، أي دقيقتين ونصف، فإذا تأخر الـ server أكثر من ذلك وصفه Docker بأنه غير سليم Unhealthy، وفشل `docker compose up` برسالة `dependency failed to start: container ... is unhealthy` ولم يبدأ الـ worker أصلاً. وهذا ما حدث لنا في المرة الأولى، وتكرر عند كل إعادة إنشاء للـ Container، حيث استغرق التشغيل الثاني ثماني دقائق، وأغلب الوقت في إقلاع تطبيق Node.js نفسه أكثر من مرة: مرة لأوامر الترحيل ومرة لأوامر النسخة ومرة للتطبيق. والسرعة تختلف بحسب السيرفر، وكان سيرفرنا Docker Desktop على Windows مشغولاً بتجارب أخرى، لذلك أعط النظام وقتاً كافياً بدلاً من أن تطارد خطأ غير موجود.

## الإعداد الأول واللغة العربية

افتح `http://localhost:3000` واختر Continue with Email، ثم أدخل البريد وكلمة المرور، فيطلب منك النظام اسم مساحة العمل Workspace، ثم ملفك الشخصي، ثم دعوة الفريق ويمكن تخطيها. وأول ما سوف تلاحظه أن مساحة العمل الجديدة ليست فارغة، ففيها خمس شركات وخمسة أشخاص وست فرص تجريبية بأسماء شركات أمريكية معروفة، فاحذفها قبل أن تبدأ لكي لا تدخل في تقاريرك وتوقعاتك، وقد حذفناها بالـ API كما سيأتي.

بعد ذلك اذهب إلى Settings ثم Experience واختر Arabic من قائمة اللغات، وهي واحدة من أكثر من ثلاثين لغة، فتنقلب الواجهة كلها إلى اليمين:

![صفحة الإعدادات في Twenty بالعربية ومن اليمين إلى اليسار، فيها القائمة الجانبية على اليمين بعناوين الملف الشخصي والخبرة والحسابات ونموذج البيانات والمطورون، وفي الوسط المظهر واللغة العربية والمقياس والتنقل، والمنطقة الزمنية Arabian Standard Time - Riyadh](https://arabroot.io/content/images/2026/10/crm-lab-02-arabic-settings.webp)

Twenty v2.51.0 بالعربية: الاتجاه صحيح، وكلمة Experience ترجمت «الخبرة»

والترجمة جيدة في أغلب الشاشات، ولكن فيها ما يحتاج مراجعة: إعدادات Experience ترجمت «الخبرة» والمقصود تجربة الاستخدام، وأسماء العروض الافتراضية مثل All Companies بقيت بالإنجليزية، وتنسيق التاريخ في البطاقات بالإنجليزية مثل Oct 25, 2026 حتى بعد تغيير اللغة. وهذه كلها أمور شكلية لا تمنع العمل، والأهم أن النص العربي في الحقول يحفظ ويعرض بترتيب صحيح.

## مفتاح الـ API

أغلب ما سوف نفعله بعد ذلك يمر عبر الـ API، و[توثيق الـ API](https://docs.twenty.com/developers/extend/api?ref=arabroot.io) يشرح أن Twenty يولد واجهتين REST وGraphQL من نموذج البيانات نفسه، فأي حقل أو كائن Object تضيفه يظهر فيهما مباشرة. أنشئ المفتاح من Settings ثم Developers ثم Create API Key، واختر له اسماً ودوراً Role، فيظهر المفتاح مرة واحدة فقط، فضعه في متغير بيئة ولا تكتبه في ملف داخل المستودع:

```bash
read -s TWENTY_API_KEY && export TWENTY_API_KEY
export TWENTY_URL=http://localhost:3000
```

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

```text
{"statusCode":403,"messages":["Missing authentication token"],"error":"FORBIDDEN_EXCEPTION"}
```

## مراحل البيع Pipeline Stages

الفرص Opportunities في Twenty تأتي بخمس مراحل افتراضية هي New وScreening وMeeting وProposal وCustomer، وهذه أقرب لمسار شركة SaaS تبيع للأفراد، أما البيع للشركات فيحتاج مراحل لها شرط خروج واضح كما شرحنا في مقال مسار البيع. والمرحلة في Twenty حقل من نوع SELECT اسمه `stage`، فنغير خياراته عبر واجهة البيانات الوصفية Metadata API على المسار `/metadata`. نجلب أولاً معرف الحقل:

```bash
curl -s -H "Authorization: Bearer $TWENTY_API_KEY" -H 'Content-Type: application/json' \
  $TWENTY_URL/metadata \
  -d '{"query":"{ objects(paging:{first:100}) { edges { node { nameSingular fields(paging:{first:200}) { edges { node { name id } } } } } } }"}' \
  | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const o=JSON.parse(d).data.objects.edges.map(e=>e.node).find(n=>n.nameSingular==="opportunity");console.log(o.fields.edges.find(f=>f.node.name==="stage").node.id)})'
```

ثم نكتب الخيارات الجديدة في الملف `stages.json`، ونضع الاسم العربي والإنجليزي معاً في التسمية Label، أما القيمة Value فتبقى بالإنجليزية والحروف الكبيرة لأنها ما يخزن في قاعدة البيانات ويستخدمه الـ API:

```json
{"query":"mutation($id: UUID!, $u: UpdateFieldInput!) { updateOneField(input: {id: $id, update: $u}) { id name options } }",
 "variables":{"id":"STAGE_FIELD_ID","u":{"defaultValue":"'QUALIFIED'","options":[
  {"label":"عميل مؤهل Qualified","value":"QUALIFIED","color":"sky","position":0},
  {"label":"اكتشاف الاحتياج Discovery","value":"DISCOVERY","color":"purple","position":1},
  {"label":"عرض فني ومالي Proposal","value":"PROPOSAL","color":"turquoise","position":2},
  {"label":"تفاوض Negotiation","value":"NEGOTIATION","color":"orange","position":3},
  {"label":"فوز Won","value":"WON","color":"green","position":4},
  {"label":"خسارة Lost","value":"LOST","color":"red","position":5}]}}}
```

```bash
curl -s -H "Authorization: Bearer $TWENTY_API_KEY" -H 'Content-Type: application/json' \
  $TWENTY_URL/metadata --data-binary @stages.json
```

نجح الطلب ورجعت الخيارات الست، ولكن عندما فتحنا الفرص في عرض Kanban المسمى «حسب المرحلة» وجدنا الصفحة فارغة تماماً مع أن العداد يقول 8 فرص. والسبب أن كل عمود في لوحة Kanban هو سجل مستقل من نوع مجموعة العرض View Group مرتبط بقيمة من قيم المرحلة، فعندما حذفنا القيم القديمة حذف النظام مجموعاتها، ولم ينشئ مجموعات للقيم الجديدة. لذلك نجلب معرف العرض من نفس الـ Metadata API (الاستعلام `views` ونبحث عن العرض الذي نوعه `KANBAN`)، ثم ننشئ المجموعات:

```json
{"query":"mutation($inputs: [CreateViewGroupInput!]!) { createManyViewGroups(inputs: $inputs) { fieldValue position } }",
 "variables":{"inputs":[
  {"viewId":"KANBAN_VIEW_ID","fieldValue":"QUALIFIED","position":0,"isVisible":true},
  {"viewId":"KANBAN_VIEW_ID","fieldValue":"DISCOVERY","position":1,"isVisible":true},
  {"viewId":"KANBAN_VIEW_ID","fieldValue":"PROPOSAL","position":2,"isVisible":true},
  {"viewId":"KANBAN_VIEW_ID","fieldValue":"NEGOTIATION","position":3,"isVisible":true},
  {"viewId":"KANBAN_VIEW_ID","fieldValue":"WON","position":4,"isVisible":true},
  {"viewId":"KANBAN_VIEW_ID","fieldValue":"LOST","position":5,"isVisible":true}]}}
```

وبعد إعادة تحميل الصفحة ظهرت الأعمدة الست. والقاعدة هنا: إذا غيرت قيم حقل تبنى عليه لوحة Kanban من الـ API، فتحقق من اللوحة بعدها مباشرة، لأن الخطأ لا يظهر في رد الـ API وإنما في شاشة الفريق.

## استيراد الشركات من CSV

أغلب الفرق لا تبدأ من الصفر، وإنما من جدول بيانات فيه العملاء الحاليون. جهزنا الملف `companies.csv` بست شركات وهمية وأسماء نطاقات تحت `example.com`:

```text
Name,Domain,Employees,City
شركة الواحة للتجارة,https://waha.example.com,120,الرياض
مستشفى النخبة التخصصي,https://nukhba-hospital.example.com,850,جدة
جامعة المستقبل الأهلية,https://mustaqbal-uni.example.com,1400,الدمام
مصنع الرمال للبلاستيك,https://rimal-plastic.example.com,300,الجبيل
مكتب السند للمحاماة,https://sanad-law.example.com,25,الرياض
شركة دروب للنقل,https://duroob.example.com,600,مكة المكرمة
```

والاستيراد ليس في قائمة الخيارات Options كما قد تتوقع، وإنما في قائمة الأوامر Command Menu (الزر ذو النقاط الثلاث أعلى الصفحة) باسم «استيراد الشركات». وبعد رفع الملف تأتي خطوة مطابقة الأعمدة Column Mapping، وفيها طابق النظام عمود City تلقائياً مع العنوان والمدينة، وبقيت الأعمدة الأخرى نختارها يدوياً:

![نافذة الاستيراد في Twenty بالعربية في خطوة مطابقة الأعمدة، عمود Name مطابق مع الاسم، وDomain مع اسم النطاق ثم عنوان URL للرابط، وEmployees مع عدم الاستيراد، وCity مع العنوان ثم المدينة](https://arabroot.io/content/images/2026/10/crm-lab-03-import-mapping.webp)

عمود Employees لا يجد حقلاً يطابقه في الإصدار v2.51.0 فاخترنا «عدم الاستيراد»

ولاحظ أمرين: حقل اسم النطاق Domain Name من نوع الروابط Links وله أجزاء، فتختار منها «عنوان URL للرابط»، وعدد الموظفين لا يوجد كحقل في الشركات في هذا الإصدار، فإذا كنت تحتاجه في تقسيم العملاء فأنشئ حقلاً مخصصاً Custom Field من نموذج البيانات قبل الاستيراد. بعد ذلك تأتي خطوة التحقق من البيانات وفيها كل صف قبل الحفظ، ثم تأكيد:

![قائمة الشركات في Twenty بعد الاستيراد، ست شركات بأسماء عربية ونطاقاتها والمدن الرياض وجدة والدمام والجبيل ومكة المكرمة، وعمود تم الإنشاء بواسطة باسم المستخدم](https://arabroot.io/content/images/2026/10/crm-lab-04-companies.webp)

ست شركات بأسماء عربية بعد الاستيراد، والعرض الافتراضي ما زال اسمه All Companies

## جهات الاتصال والصفقات عبر REST API

الآن نضيف لكل شركة جهة اتصال ثم الصفقات، وهذا ما تفعله عادة عندما تنقل البيانات من نظام قديم أو من نموذج تواصل في موقعك. السكربت التالي `seed.mjs` يعمل بـ Node.js 22 أو أحدث دون أي مكتبات، ويقرأ الشركات المستوردة بأسمائها ثم يضيف الأشخاص والفرص:

```javascript
// Adds contacts and deals to Twenty through the REST API.
//   TWENTY_URL=http://localhost:3000 TWENTY_API_KEY=... node seed.mjs
const URL = process.env.TWENTY_URL, KEY = process.env.TWENTY_API_KEY;
const api = async (method, path, body) => {
  const r = await fetch(`${URL}/rest/${path}`, { method,
    headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
    body: body && JSON.stringify(body) });
  const j = await r.json();
  if (!r.ok) throw new Error(`${method} ${path}: ${r.status} ${JSON.stringify(j)}`);
  return j.data;
};

// Companies were imported from companies.csv; find them by name
const { companies } = await api('GET', 'companies?limit=50');
const co = Object.fromEntries(companies.map((c) => [c.name, c.id]));

const people = [
  ['فهد', 'القحطاني', 'مدير تقنية المعلومات', 'شركة الواحة للتجارة', 'fahad@waha.example.com'],
  ['ريم', 'الشهري', 'مديرة التحول الرقمي', 'مستشفى النخبة التخصصي', 'reem@nukhba-hospital.example.com'],
  ['خالد', 'الزهراني', 'عميد تقنية المعلومات', 'جامعة المستقبل الأهلية', 'khalid@mustaqbal-uni.example.com'],
  ['منى', 'الدوسري', 'مديرة المشتريات', 'مصنع الرمال للبلاستيك', 'mona@rimal-plastic.example.com'],
  ['عبدالله', 'السبيعي', 'الشريك المدير', 'مكتب السند للمحاماة', 'abdullah@sanad-law.example.com'],
  ['نوف', 'الحربي', 'مديرة العمليات', 'شركة دروب للنقل', 'nouf@duroob.example.com'],
];
const contact = {};
for (const [first, last, title, company, email] of people) {
  const p = await api('POST', 'people', { name: { firstName: first, lastName: last }, jobTitle: title,
    emails: { primaryEmail: email }, companyId: co[company] });
  contact[company] = p.createPerson.id;
}

// name, company, amount in SAR, stage, expected close date
const deals = [
  ['نظام حجز مواعيد للعيادات', 'مستشفى النخبة التخصصي', 480000, 'NEGOTIATION', '2026-11-20'],
  ['بوابة الطلاب والدفع الإلكتروني', 'جامعة المستقبل الأهلية', 900000, 'PROPOSAL', '2026-12-15'],
  ['ربط المتجر الإلكتروني مع ERP', 'شركة الواحة للتجارة', 150000, 'DISCOVERY', '2026-12-30'],
  ['تطبيق تتبع الشحنات', 'شركة دروب للنقل', 620000, 'QUALIFIED', '2027-01-31'],
  ['نظام إدارة القضايا', 'مكتب السند للمحاماة', 95000, 'WON', '2026-10-15'],
  ['لوحة مراقبة خطوط الإنتاج', 'مصنع الرمال للبلاستيك', 210000, 'LOST', '2026-10-10'],
  ['استضافة وصيانة سنوية', 'شركة الواحة للتجارة', 72000, 'NEGOTIATION', '2026-11-30'],
  ['مرحلة ثانية: تطبيق المرضى', 'مستشفى النخبة التخصصي', 350000, 'DISCOVERY', '2027-02-28'],
];
for (const [name, company, sar, stage, close] of deals) {
  await api('POST', 'opportunities', { name, stage, closeDate: `${close}T09:00:00.000Z`,
    amount: { amountMicros: sar * 1_000_000, currencyCode: 'SAR' },
    companyId: co[company], pointOfContactId: contact[company] });
}
console.log(`people: ${people.length}, opportunities: ${deals.length}`);
```

```bash
node seed.mjs
```

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

```text
people: 6, opportunities: 8
```

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

- المبلغ في Twenty حقل من نوع العملة Currency يخزن بوحدة الجزء من المليون `amountMicros`، فمبلغ 480 ألف ريال يكتب `480000000000`، والسبب أن الأعداد الصحيحة لا تفقد الدقة كما تفقدها الكسور العشرية في الحسابات المالية، وقد شرحنا سبب ذلك في مقال [الأرقام العشرية من الداخل](https://arabroot.io/articles/%D8%A7%D9%84%D8%A3%D8%B1%D9%82%D8%A7%D9%85-%D8%A7%D9%84%D8%B9%D8%B4%D8%B1%D9%8A%D8%A9-floating-point-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84/).
- العملة `SAR` تكتب مع كل مبلغ، فالنظام يسمح بصفقات بعملات مختلفة في نفس المسار، ولكنه لا يحول بينها، فإذا جمعت صفقات بالدولار والريال في تقرير واحد فالتحويل عليك.
- السكربت يبحث عن الشركة بالاسم، والاسم ليس فريداً في Twenty، فإذا كان عندك شركتان بنفس الاسم فاستخدم النطاق أو معرفاً خارجياً تضيفه كحقل مخصص.
- حذف البيانات التجريبية التي جاءت مع مساحة العمل يكون بنفس الطريقة: `GET` على `opportunities` و`people` و`companies` ثم `DELETE` لكل معرف.

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

![لوحة Kanban للفرص في Twenty من اليمين إلى اليسار، ستة أعمدة: عميل مؤهل Qualified بمجموع 620k، واكتشاف الاحتياج Discovery بمجموع 350k، وعرض فني ومالي Proposal بمجموع 180k، وتفاوض Negotiation بمجموع 972k، وفوز Won بمجموع 575k، وخسارة Lost بمجموع 210k، وفي كل بطاقة اسم الصفقة والمبلغ بالريال وتاريخ الإغلاق والشركة وجهة الاتصال](https://arabroot.io/content/images/2026/10/crm-tools-02-twenty-arabic.webp)

مسار البيع بالعربية: الأعمدة من اليمين، والتسميات المختلطة تقص من بدايتها

والاتجاه صحيح في اللوحة، ولكن لاحظ أن التسميات التي تجمع العربية والإنجليزية مثل «اكتشاف الاحتياج Discovery» تقص من جهة الكلمة الإنجليزية عندما يضيق العمود، وكذلك التاريخ في البطاقة يقص من أوله. فإذا كان فريقك يعمل بالعربية فقط فاجعل التسمية عربية قصيرة، وأبق القيمة الإنجليزية في الـ API.

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

![صفحة صفقة بوابة الطلاب والدفع الإلكتروني في Twenty، فيها المبلغ SAR 900k والمرحلة تفاوض Negotiation وتاريخ الإغلاق ديسمبر 15، 2026 والشركة جامعة المستقبل الأهلية وجهة الاتصال خالد الزهراني، وفي الجدول الزمني سطر تم التحديث المرحلة إلى تفاوض Negotiation وسطر الإنشاء](https://arabroot.io/content/images/2026/10/crm-lab-06-deal-record.webp)

الجدول الزمني يسجل تغيير المرحلة، والسهم فيه يشير إلى اليمين في واجهة عربية

## توقع الإيرادات Forecast من الـ API

التوقع الموزون Weighted Forecast يضرب قيمة كل صفقة مفتوحة في احتمال إغلاقها بحسب مرحلتها، ثم يجمعها لكل شهر حسب تاريخ الإغلاق المتوقع، وقد شرحنا طريقته وحدوده في مقال مسار البيع. Twenty يعرض مجموع كل عمود في اللوحة، ولكنه لا يحمل حقلاً للاحتمال في الفرص، لذلك نحسب التوقع خارج النظام بالـ API، وهذا يعطيك حرية تعديل الأوزان دون تغيير نموذج البيانات. السكربت `forecast.mjs`:

```javascript
// Weighted pipeline forecast from Twenty opportunities, grouped by expected close month.
//   TWENTY_URL=http://localhost:3000 TWENTY_API_KEY=... node forecast.mjs
const WEIGHT = { QUALIFIED: 0.1, DISCOVERY: 0.2, PROPOSAL: 0.4, NEGOTIATION: 0.7, WON: 1, LOST: 0 };

const r = await fetch(`${process.env.TWENTY_URL}/rest/opportunities?limit=200`, {
  headers: { Authorization: `Bearer ${process.env.TWENTY_API_KEY}` } });
const { data: { opportunities } } = await r.json();

const months = {};
for (const o of opportunities) {
  const m = o.closeDate.slice(0, 7);
  const sar = o.amount.amountMicros / 1e6;
  const row = (months[m] ??= { open: 0, weighted: 0, won: 0, deals: 0 });
  row.deals += 1;
  if (o.stage === 'WON') row.won += sar;
  else if (o.stage !== 'LOST') { row.open += sar; row.weighted += sar * WEIGHT[o.stage]; }
}
const fmt = (n) => Math.round(n).toLocaleString('en-US').padStart(11);
console.log('month     deals  open pipeline   weighted        won');
let t = { open: 0, weighted: 0, won: 0 };
for (const m of Object.keys(months).sort()) {
  const x = months[m];
  console.log(`${m}  ${String(x.deals).padStart(5)}  ${fmt(x.open)}  ${fmt(x.weighted)}  ${fmt(x.won)}`);
  t.open += x.open; t.weighted += x.weighted; t.won += x.won;
}
console.log(`total           ${fmt(t.open)}  ${fmt(t.weighted)}  ${fmt(t.won)}`);
```

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

```bash
curl -s -X PATCH -H "Authorization: Bearer $TWENTY_API_KEY" -H 'Content-Type: application/json' \
  "$TWENTY_URL/rest/opportunities/OPPORTUNITY_ID" \
  -d '{"stage":"PROPOSAL","amount":{"amountMicros":180000000000,"currencyCode":"SAR"}}'
```

ثم شغلنا التوقع، والمخرج كان كما يلي:

```text
month     deals  open pipeline   weighted        won
2026-10      3            0            0      575,000
2026-11      1       72,000       50,400            0
2026-12      2    1,080,000      702,000            0
2027-01      1      620,000       62,000            0
2027-02      1      350,000       70,000            0
total             2,122,000      884,400      575,000
```

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

- المسار المفتوح Open Pipeline مليونان و122 ألف ريال، ولكن التوقع الموزون 884,400 ريال فقط، أي حوالي 42% من المسار، وهذا الفرق هو ما يجب أن يراه المدير المالي قبل أن يبني ميزانية العام القادم على رقم المسار كاملاً.
- شهر ديسمبر وحده 702 ألف من التوقع، منها 630 ألفاً من صفقة واحدة هي بوابة الطلاب، فإذا تأخرت هذه الصفقة شهراً واحداً تغير شكل الربع كله، وهذا ما يسمى تركز المسار Pipeline Concentration.
- عند الفوز بنظام حجز المواعيد كان تاريخ الإغلاق المتوقع 20 نوفمبر، فظهرت الصفقة الرابحة في نوفمبر حتى عدلنا التاريخ إلى يوم الفوز الفعلي. والقاعدة: عندما تنقل صفقة إلى «فوز» أو «خسارة» فحدث تاريخ الإغلاق إلى اليوم الفعلي، وإلا فإن تقرير الإيرادات يكذب عليك بهدوء.
- الأوزان 10% و20% و40% و70% افتراضات وضعناها لهذا المثال، والصحيح أن تحسبها من تاريخ صفقاتك: كم صفقة وصلت إلى «عرض» ثم فزت بها فعلاً.

## إرسال التغييرات إلى نظام آخر عبر Webhook

عندما يفوز فريق المبيعات بصفقة يحتاج قسم المالية أن يعرف لكي يصدر الفاتورة، والحل البسيط أن يرسل البائع بريداً، وهذا يتأخر وينسى. الحل الأفضل أن يرسل Twenty طلباً إلى نظام الفوترة كلما تغيرت صفقة، وهذا هو الـ Webhook كما في [توثيق الـ Webhooks](https://docs.twenty.com/developers/extend/webhooks?ref=arabroot.io). نبدأ بالمستقبل `hook.js`، وهو يتحقق من التوقيع قبل أن يثق بأي شيء في الطلب:

```javascript
// Minimal webhook receiver: verifies the Twenty signature and logs one line per event.
const http = require('node:http');
const crypto = require('node:crypto');
const SECRET = process.env.WEBHOOK_SECRET || '';

http.createServer((req, res) => {
  let body = '';
  req.on('data', (c) => (body += c));
  req.on('end', () => {
    const ts = req.headers['x-twenty-webhook-timestamp'] || '';
    const sig = req.headers['x-twenty-webhook-signature'] || '';
    const expected = crypto.createHmac('sha256', SECRET).update(`${ts}:${body}`).digest('hex');
    const valid = sig.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'));
    let e = {};
    try { e = JSON.parse(body); } catch {}
    const r = e.record || e.data || {};
    console.log(JSON.stringify({ valid, event: e.eventName, name: r.name, stage: r.stage,
      amountSAR: r.amount && r.amount.amountMicros / 1e6, updatedFields: e.updatedFields }));
    res.writeHead(valid ? 200 : 401).end();
  });
}).listen(8080, () => console.log('listening on 8080'));
```

ولاحظ أن التوقيع يحسب على النص الخام للطلب كما وصل، وليس على كائن JSON بعد تحويله ثم إعادته نصاً كما في مثال التوثيق، والسبب أن أي اختلاف في المسافات أو ترتيب الحقول يغير الهاش فيفشل التحقق.

بعد ذلك ننشئ الـ Webhook من Settings ثم Developers ثم Webhooks، ونضع العنوان والوصف والسر نفسه الموجود في `WEBHOOK_SECRET`، ونختار من الفلاتر الفرص فقط لكي لا يصل إلى نظام الفوترة كل تعديل في جهات الاتصال:

![نموذج Webhook جديد في Twenty بالعربية، فيه عنوان URL للواجهة النهائية http://hook.example.com:8080/twenty والوصف إرسال تغييرات الفرص إلى نظام الفوترة، والفلاتر الفرص والكل، وحقل السر مخفي](https://arabroot.io/content/images/2026/10/crm-lab-07-webhook-form.webp)

نموذج الـ Webhook مع فلتر على الفرص، وأخفينا السر في الصورة

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

1. **زر الحفظ معطل:** كتبنا أولاً العنوان `http://mkg-hook:8080/twenty` باسم الخدمة في Docker، فبقي زر الحفظ رمادياً دون أي رسالة، والسبب أن النموذج لا يقبل عنواناً لا يحتوي اسم نطاق فيه نقطة. لذلك أعطينا الخدمة الاسم الإضافي `hook.example.com` داخل شبكة Docker، فقبل النموذج العنوان.
2. **الطلب لا يصل:** بعد الحفظ غيرنا مرحلة صفقة، فظهر في سجل الـ worker سطر `Outbound HTTP request: POST http://hook.example.com:8080/twenty` وأن المهمة انتهت خلال خمسة أجزاء من الثانية، ولكن سجل المستقبل بقي فارغاً، ولا توجد أي رسالة خطأ أو تحذير. والسبب حماية SSRF في Twenty، فهو يرفض أي اتصال صادر إلى عنوان IP خاص، كما في قسم Outbound Connections to Internal Hosts من [صفحة الإعداد](https://docs.twenty.com/developers/self-host/capabilities/setup?ref=arabroot.io)، ونظام الفوترة داخل شبكة Docker عنوانه خاص بطبيعة الحال. والحل أن تضيف اسم المضيف بالضبط إلى المتغير `OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS` كما في ملف Compose أعلاه، ولا تضع فيه `*` لأن ذلك يلغي الحماية لكل الأنظمة الداخلية.

وبعد إعادة تشغيل الـ server والـ worker بالمتغير الجديد، غيرنا مرحلة صفقة ربط المتجر وقيمتها، فكان سجل المستقبل كما يلي:

```bash
docker compose logs hook
```

```text
listening on 8080
{"valid":true,"event":"opportunity.updated","name":"ربط المتجر الإلكتروني مع ERP","stage":"PROPOSAL","amountSAR":180000,"updatedFields":["amount","stage"]}
```

ووصلت أيضاً الأحداث التي وقعت أثناء توقف الـ worker بعد عودته، لأنها كانت محفوظة في طابور Redis. وفي المخرج أعلاه لاحظ التالي:

- شكل الطلب الفعلي في الإصدار v2.51.0 يختلف عن المثال في التوثيق، فالحدث في الحقل `eventName` وليس `event`، والسجل في `record` وليس `data`، ومعهما `updatedFields` و`objectMetadata` و`workspaceId` و`webhookId` و`eventDate`. لذلك اكتب المستقبل على طلب حقيقي التقطته، لا على مثال التوثيق.
- الحقل `updatedFields` يذكر ما تغير فقط، فيستطيع نظام الفوترة أن يتجاهل كل تحديث لا يغير `stage` إلى `WON`.
- الطلب يحمل السجل كاملاً بما فيه المبلغ والمرحلة، فإذا كان المستقبل خارج شبكتك فأنت ترسل بيانات العملاء إلى طرف آخر، وهذا يدخل في مسؤولياتك بحسب نظام حماية البيانات الشخصية.

والآن ماذا يحدث إذا أرسل أحد طلباً مزوراً إلى المستقبل يدعي أن صفقة فازت؟ جربنا ذلك من داخل الـ Container بتوقيع خاطئ:

```bash
docker compose exec hook curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/twenty \
  -H 'X-Twenty-Webhook-Timestamp: 1793000000000' -H 'X-Twenty-Webhook-Signature: 00ff' \
  -d '{"eventName":"opportunity.updated","record":{"name":"صفقة مزورة","stage":"WON"}}'
```

```text
401
{"valid":false,"event":"opportunity.updated","name":"صفقة مزورة","stage":"WON"}
```

رفض المستقبل الطلب، وهذا هو سبب السر: بدونه يستطيع أي شخص يعرف عنوان نظام الفوترة أن يصدر فاتورة لصفقة لم تحدث. وإذا أردت أن تبني منطقاً أكبر، مثل إنشاء الفاتورة في نظام ERP عند الفوز، فاجعل المستقبل سير عمل في n8n كما في [مقال سير العمل في n8n](https://arabroot.io/articles/%D8%B3%D9%8A%D8%B1-%D8%B9%D9%85%D9%84-%D8%AD%D9%82%D9%8A%D9%82%D9%8A-%D9%81%D9%8A-n8n-%D9%85%D9%86-%D8%A7%D9%84%D8%AA%D8%B0%D9%83%D8%B1%D8%A9-%D8%A7%D9%84%D9%89-%D8%AA%D8%B3%D9%84%D9%8A%D9%85-%D8%A7%D9%84%D8%AC%D9%87%D8%A7%D8%B2/)، وقد شرحنا ربط منصة SaaS بأنظمة العميل بشكل أوسع في [مقال التكامل مع أنظمة العميل](https://arabroot.io/articles/%D8%AA%D9%83%D8%A7%D9%85%D9%84-%D9%85%D9%86%D8%B5%D8%A9-saas-%D9%85%D8%B9-%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D8%B9%D9%85%D9%8A%D9%84/).

## الوصول من خارج السيرفر

صفحة [تأمين النظام](https://docs.twenty.com/developers/self-host/capabilities/security?ref=arabroot.io) في التوثيق الرسمي توصي بأن يبقى Twenty خاصاً بالفريق، والسبب أن الواجهة نفسها تعمل على المسارات `/graphql` و`/rest`، فنشرها للإنترنت ينشر كل شيء. لذلك الأنسب لفريق صغير أن يصل إليه عبر شبكة خاصة مثل [WireGuard](https://arabroot.io/articles/%D8%B4%D8%A8%D9%83%D8%A9-%D8%AE%D8%A7%D8%B5%D8%A9-%D9%85%D8%B9-wireguard-%D9%88-wg-easy/). وإذا احتجت نطاقاً بشهادة TLS داخل هذه الشبكة، فهذا مثال لملف Caddyfile على طريقة [دليل Caddy](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-caddy-reverse-proxy/):

```text
crm.example.com {
    reverse_proxy 127.0.0.1:3000
}
```

وعند تغيير النطاق غير `SERVER_URL` في ملف `.env` إلى `https://crm.example.com` ثم أعد تشغيل الـ server والـ worker، لأن الروابط في الرسائل والدعوات تبنى منه. ولم نجرب إصدار الشهادة في هذا المختبر لأنه يحتاج نطاقاً حقيقياً.

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

- `curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/healthz` يرجع `200`.
- `docker compose ps` يعرض server بحالة `healthy` والـ worker يعمل.
- الأمر التالي يعرض حالة الترحيل لكل مساحة عمل، ويجب أن يكون `Up to date`:

```bash
docker compose exec server yarn command:prod upgrade:status
```

```text
Workspace
    Inferred version: 2.51.0
    Status:           Up to date
    Executed by:      v2.51.0

Summary
  Instance: Up to date
  Workspaces: 1 up to date, 0 behind, 0 failed (1 total)
```

- تغيير مرحلة صفقة يظهر سطراً في سجل المستقبل خلال ثوان، فإذا لم يظهر فراجع [المشكلات الشائعة](#troubleshooting).

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

البيانات في مكانين: قاعدة PostgreSQL وفيها كل السجلات، والـ Volume المسمى `server-data` وفيه الملفات المرفوعة والمرفقات، وكان حجمه 28.5 ميجابايت في تجربتنا. ومعهما ملف `.env` لأنه يحتوي `ENCRYPTION_KEY`. نأخذ نسخة من قاعدة البيانات بصيغة pg\_dump المضغوطة:

```bash
docker compose exec db pg_dump -U twenty -d twenty -Fc -f /tmp/twenty.dump
docker compose cp db:/tmp/twenty.dump ./twenty-$(date +%F).dump
```

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

```bash
docker compose exec db createdb -U twenty twenty_restore
docker compose exec db pg_restore -U twenty -d twenty_restore /tmp/twenty.dump
docker compose exec db psql -U twenty -d twenty_restore -At \
  -c "select schema_name from information_schema.schemata where schema_name like 'workspace_%'"
```

البيانات في Twenty محفوظة في مخطط Schema مستقل لكل مساحة عمل اسمه يبدأ بـ `workspace_`، فنستخدم الاسم الذي يرجعه الأمر أعلاه:

```bash
docker compose exec db psql -U twenty -d twenty_restore -At \
  -c "select count(*), sum(\"amountAmountMicros\")/1000000 from workspace_xxxx.opportunity where \"deletedAt\" is null"
```

```text
8|2907000.000000000000
```

ثمانية فرص بمجموع مليونين و907 آلاف ريال، وهو مجموع المسار المفتوح والرابح والخاسر في التوقع أعلاه، واستغرقت الاستعادة عشر ثوان تقريباً. وقد شرحنا قاعدة 3-2-1 لحفظ النسخ خارج السيرفر في [دليل النسخ الاحتياطي](https://arabroot.io/articles/%D8%A7%D9%84%D9%86%D8%B3%D8%AE-%D8%A7%D9%84%D8%A7%D8%AD%D8%AA%D9%8A%D8%A7%D8%B7%D9%8A-%D8%A8%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-3-2-1-%D9%88%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%B9%D8%A7%D8%AF%D8%A9/).

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

Twenty يصدر إصدارات بسرعة كبيرة، ففي الأسبوع الذي جربنا فيه صدرت v2.48.1 وv2.49.0 وv2.50.0 وv2.50.1 وv2.51.0 كما في [صفحة Docker Hub](https://hub.docker.com/r/twentycrm/twenty?ref=arabroot.io)، و[الإصدار v2.51.0](https://github.com/twentyhq/twenty/releases/tag/twenty/v2.51.0?ref=arabroot.io) صدر في 23 أكتوبر 2026\. و[دليل التحديث](https://docs.twenty.com/developers/self-host/capabilities/upgrade-guide?ref=arabroot.io) الرسمي يقول إن الترحيل يعمل تلقائياً عند التشغيل، وإن الترقية عبر عدة إصدارات مدعومة منذ v1.23، وإن v2.34 وما بعده يحتاج PostgreSQL 15 أو أحدث. والخطوات:

1. خذ نسخة قاعدة البيانات كما في القسم السابق.
2. غير الوسم في سطري `server` و`worker` إلى الإصدار الجديد.
3. شغل `docker compose up -d` وانتظر رسالة `successfully started`، وقد يطول الترحيل الأول.
4. تحقق بالأمر `upgrade:status`، ثم افتح لوحة Kanban وتأكد أن الأعمدة والمراحل كما هي.

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

- `**dependency failed to start: container ... is unhealthy**`: الـ server لم ينته من الترحيل خلال مهلة فحص الصحة، فأضف `start_period` كما في الملف أعلاه، وتابع السجل حتى تظهر `successfully started`.
- **رسالة `column KeyValuePairEntity.applicationId does not exist` في أول التشغيل**: ظهرت لنا قبل انتهاء الترحيل ثم اختفت، فهي من محاولة قراءة الإعدادات قبل إنشاء الجداول، ولا تحتاج تدخلاً إلا إذا بقيت بعد اكتمال الترحيل.
- **لوحة Kanban فارغة والعداد فيه سجلات**: غيرت قيم حقل المرحلة من الـ API فحذفت مجموعات العرض، فأنشئها كما في [قسم المراحل](#stages).
- **زر حفظ الـ Webhook معطل**: العنوان بلا نقطة في اسم المضيف، فاستخدم اسماً كاملاً.
- **الـ Webhook لا يصل ولا يوجد خطأ**: المستقبل على عنوان خاص، فأضف اسمه إلى `OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS` على الـ server والـ worker معاً، وتأكد أن الـ worker يعمل لأنه هو من يرسل.
- `**Missing authentication token**`: الطلب بدون الترويسة `Authorization: Bearer`، أو المتغير فارغ في الجلسة الحالية.
- **أرقام غريبة في المبالغ**: نسيت أن المبلغ بالجزء من المليون، فاضرب في مليون عند الكتابة واقسم عند القراءة.

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

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

```bash
cd /opt/crm
docker compose down -v
docker rmi twentycrm/twenty:v2.51.0
```

## الخلاصة

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

- Twenty يعمل بأربع Containers ويحتاج حوالي 1.8 جيجابايت من الذاكرة، والتشغيل الأول قد يأخذ دقائق طويلة، فأعطه `start_period` كافياً وثبت الإصدار.
- الواجهة العربية تعمل من اليمين إلى اليسار، مع ترجمات تحتاج مراجعة وتسميات مختلطة تقص في الأعمدة الضيقة.
- تغيير مراحل البيع من الـ API يحذف أعمدة لوحة Kanban، فأعد إنشاءها بـ `createManyViewGroups`.
- الاستيراد من CSV يدعم العربية، وما لا يجد حقلاً مقابلاً تنشئ له حقلاً مخصصاً قبل الاستيراد.
- التوقع الموزون من الـ API أعطى 884,400 ريال من مسار مفتوح قيمته 2,122,000 ريال، وأغلبه في صفقة واحدة.
- الـ Webhook موقع بـ HMAC، ولا يصل إلى عنوان خاص إلا إذا أضفته إلى `OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS`، وشكل الطلب الفعلي يختلف عن مثال التوثيق.

### تدريب عملي

- أضف حقلاً مخصصاً اسمه «سبب الخسارة» من نوع SELECT إلى الفرص، واجعل المستقبل يرفض أي حدث ينقل صفقة إلى `LOST` دون أن يكون الحقل معبأً.
- عدل `forecast.mjs` لكي يحسب الأوزان من صفقاتك المغلقة بدلاً من الأرقام الثابتة.

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

[![](https://arabroot.io/content/images/2026/10/crm-tools-cover-cover.webp)دليلإدارة علاقات العملاء CRM: Salesforce وبدائله مفتوحة المصدرمقارنة أنظمة CRM التجارية ومفتوحة المصدر بالترخيص والصيانة والأسعار، مع اختبار فعلي لمسار البيع بالعربية في أربعة أنظمة.](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%B9%D9%84%D8%A7%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%B9%D9%85%D9%84%D8%A7%D8%A1-crm-salesforce-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84%D9%87-%D9%85%D9%81%D8%AA%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D8%B5%D8%AF%D8%B1/)[![](https://arabroot.io/content/images/2026/10/sales-pipeline-cover-cover.webp)دليلمسار البيع Sales Pipeline: المراحل والتوقعات Forecastكيف تبني مراحل بيع بشروط خروج واضحة، وتحسب نسبة الفوز وسرعة البيع والتوقع الموزون من صفقاتك، مع تجربة عملية في Docker.](https://arabroot.io/articles/%D9%85%D8%B3%D8%A7%D8%B1-%D8%A7%D9%84%D8%A8%D9%8A%D8%B9-sales-pipeline-%D8%A7%D9%84%D9%85%D8%B1%D8%A7%D8%AD%D9%84-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D9%82%D8%B9%D8%A7%D8%AA-forecast/)[![](https://arabroot.io/content/images/2026/10/erp-crm-basics-cover-cover.webp)دليلما هو ERP وCRM؟ ومتى تحتاج شركتك إليهماما الذي تفعله أنظمة ERP وCRM، ومتى تحتاجها شركتك فعلاً، ولماذا يتعثر تطبيقها، وما علاقتها بالفاتورة الإلكترونية في المملكة.](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-erp-%D9%88-crm-%D9%88%D9%85%D8%AA%D9%89-%D8%AA%D8%AD%D8%AA%D8%A7%D8%AC%D9%87%D9%85%D8%A7-%D8%B4%D8%B1%D9%83%D8%AA%D9%83/)[![](https://arabroot.io/content/images/2026/10/odoo-cover-cover.webp)دليلOdoo Community: نظام ERP لشركتك على سيرفرككيف تثبت Odoo Community على سيرفرك، وتمر بدورة بيع كاملة حتى الدفع، وترسل فاتورة إلى بيئة الاختبار في منصة فاتورة.](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-odoo-community-%D9%86%D8%B8%D8%A7%D9%85-erp-%D8%B9%D9%84%D9%89-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1%D9%83/)

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

- أكتوبر 2026: كتابة الدليل واختباره على Twenty v2.51.0 مع postgres:16.15 وredis:7.2.16 وnode:24.22.1-bookworm للمستقبل، على Docker Engine 29.8.0.