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

سير عمل حقيقي في n8n: من التذكرة إلى تسليم الجهاز

كيف تبني في n8n سير عمل يفتح تذكرة في GLPI وينتظر موافقة المدير ثم يسلم الجهاز في Snipe-IT، ويتعامل مع الفشل والتكرار.

سير عمل حقيقي في n8n: من التذكرة إلى تسليم الجهاز

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

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

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

  • تصميم سير العمل Workflow: من طلب الموظف إلى تذكرة GLPI ثم الموافقة ثم Snipe-IT.
  • بيانات الاعتماد Credentials بحساب خدمة لكل نظام.
  • خطوة الموافقة البشرية بعقدة Wait ورابط موقع Signed.
  • سير العمل الفرعي Sub-workflow، وسير عمل الأخطاء Error Workflow، وإعادة المحاولة Retry.
  • الطلب المكرر، والفشل في منتصف الطريق، وإعادة التشغيل من العقدة التي فشلت.
  • حفظ سير العمل Workflow في Git، واختباره بسكربت، ومراقبته بمقاييس Prometheus.

التصميم قبل العقد Nodes

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

ست خطوات من اليمين إلى اليسار: الموظف يطلب جهازاً، ثم Remove Duplicates يتجاهل الطلب المكرر، ثم تذكرة طلب خدمة في GLPI، ثم Wait برابط موافقة للمدير ومنه فرع رفض يكتب متابعة في التذكرة، ثم Sub-workflow يسلم الجهاز في Snipe-IT، ثم Solution يغلق التذكرة. وفي الأسفل: كل عقدة HTTP تعيد المحاولة ثلاث مرات، ثم Error Workflow يرسل رسالة للفريق، ثم Retry execution من العقدة التي فشلت
التذكرة هي سجل الطلب، وSnipe-IT هو سجل الجهاز، وn8n يربط بينهما ولا يحفظ بيانات خاصة به

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

  • GLPI يملك الطلب: كل ما يحدث للطلب يكتب في التذكرة كمتابعة Follow-up، فيرى فريق الدعم القصة كاملة دون أن يفتح n8n، كما شرحنا في إدارة خدمات تقنية المعلومات ITSM.
  • Snipe-IT يملك الجهاز: سير العمل Workflow لا يقرر أي جهاز متاح من ذاكرته، وإنما يسأل Snipe-IT في لحظة التسليم.
  • الموافقة إنسان: سير العمل Workflow يتوقف ويحفظ حالته، ولا يكمل إلا عندما يضغط المدير رابطاً.
  • الفشل له مسار: إعادة محاولة، ثم رسالة للفريق، ثم إعادة تشغيل بعد الإصلاح.

المختبر

شغلنا GLPI 12.0.0 وSnipe-IT v8.8.0 على قاعدة MariaDB 11.8.9 واحدة بقاعدتين، وn8n 2.43.5، وخدمة صغيرة بمكتبة Python القياسية تقوم مقام قناة الفريق في المحادثة، أي تستقبل الرسائل وتحفظها:

# Lab: the "suite glue" demo. GLPI 12.0.0 + Snipe-IT v8.8.0 (one MariaDB) + n8n 2.43.5 + a notification sink
name: lab-auto2
services:
  lab-auto2-db:
    image: mariadb:11.8.9
    container_name: lab-auto2-db
    environment: { MARIADB_ROOT_PASSWORD: lab-root-pass }
    volumes:
      - lab-auto2-dbdata:/var/lib/mysql
      - ./initdb.sql:/docker-entrypoint-initdb.d/initdb.sql:ro
    healthcheck: { test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"], interval: 5s, retries: 120 }
  lab-auto2-glpi:
    image: glpi/glpi:12.0.0
    container_name: lab-auto2-glpi
    environment:
      GLPI_DB_HOST: lab-auto2-db
      GLPI_DB_PORT: "3306"
      GLPI_DB_NAME: glpi
      GLPI_DB_USER: glpi
      GLPI_DB_PASSWORD: lab-glpi-pass
      TZ: Asia/Riyadh
    volumes: [lab-auto2-glpidata:/var/glpi]
    ports: ["127.0.0.1:18481:80"]
    depends_on: { lab-auto2-db: { condition: service_healthy } }
  lab-auto2-snipe:
    image: snipe/snipe-it:v8.8.0
    container_name: lab-auto2-snipe
    environment:
      APP_ENV: production
      APP_DEBUG: "false"
      APP_KEY: ${SNIPE_APP_KEY}
      APP_URL: http://127.0.0.1:18482
      APP_TIMEZONE: Asia/Riyadh
      DB_CONNECTION: mysql
      DB_HOST: lab-auto2-db
      DB_DATABASE: snipeit
      DB_USERNAME: snipeit
      DB_PASSWORD: lab-snipe-pass
      MAIL_MAILER: log
    volumes: [lab-auto2-snipedata:/var/lib/snipeit]
    ports: ["127.0.0.1:18482:80"]
    depends_on: { lab-auto2-db: { condition: service_healthy } }
  lab-auto2-n8n:
    image: n8nio/n8n:2.43.5
    container_name: lab-auto2-n8n
    environment:
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
      N8N_DIAGNOSTICS_ENABLED: "false"
      N8N_SECURE_COOKIE: "false"
      N8N_METRICS: "true"
      N8N_WEBHOOK_URL: http://127.0.0.1:18483/
      GENERIC_TIMEZONE: Asia/Riyadh
      TZ: Asia/Riyadh
    volumes: [lab-auto2-n8ndata:/home/node/.n8n]
    ports: ["127.0.0.1:18483:5678"]
  lab-auto2-sink:
    image: python:3.13.16-slim
    container_name: lab-auto2-sink
    command: ["python", "-u", "/lab/sink.py"]
    volumes: ["./sink.py:/lab/sink.py:ro"]
volumes:
  lab-auto2-dbdata:
  lab-auto2-glpidata:
  lab-auto2-snipedata:
  lab-auto2-n8ndata:
networks:
  default: { name: lab-auto2-net }

ثم جهزنا الأنظمة كما يلي، وكلها بيانات تجريبية:

  • GLPI: فعلنا ال API بالأوامر config:set كما في مقارنة أنظمة التذاكر، ووسعنا نطاق عناوين عميل ال API ليشمل شبكة Docker الداخلية، ثم أنشأنا مستخدماً اسمه n8n-bot بصلاحية Technician، وموظفة اسمها sara.
  • Snipe-IT: أنشأنا مفاتيح Passport ومفتاح API كما في مختبر دورة حياة الأجهزة، وأكملنا معالج الإعداد الأول، ثم أضفنا عبر ال API موديلاً واحداً هو Latitude 7450، وثلاثة أجهزة جاهزة للتسليم LT-0001 إلى LT-0003، والموظفة نفسها بالبريد [email protected].

بيانات الاعتماد: حساب خدمة لكل نظام

سير العمل Workflow يحتاج الدخول إلى نظامين، فأنشأنا لكل منهما بيانات اعتماد Credentials في n8n، واستوردناها بسطر الأوامر حتى لا تمر بالمحرر:

[
  { "id": "labAuto2CredGlpi", "name": "GLPI n8n-bot", "type": "httpBasicAuth",
    "data": { "user": "n8n-bot", "password": "..." } },
  { "id": "labAuto2CredSnip", "name": "Snipe-IT API", "type": "httpHeaderAuth",
    "data": { "name": "Authorization", "value": "Bearer ..." } }
]
docker exec lab-auto2-n8n n8n import:credentials --input=/home/node/.n8n/lab/cred.json
docker exec lab-auto2-n8n rm /home/node/.n8n/lab/cred.json

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

Successfully imported 2 credentials.

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

  • n8n يشفر بيانات الاعتماد بمفتاح التشفير قبل حفظها، وسير العمل Workflow يشير إليها بالمعرف id فقط، فملف سير العمل Workflow الذي سوف نحفظه في Git لا يحتوي أي كلمة مرور.
  • احذف ملف الاستيراد بعد استخدامه، لأنه نص واضح.
  • n8n-bot حساب خدمة وليس حساب موظف، فلا يتوقف سير العمل Workflow يوم يغادر أحد، وصلاحيته Technician وليست Super-Admin.

وفي أول تشغيل فشل إغلاق التذكرة بهذا الخطأ من GLPI:

ERROR_GLPI_UPDATE | You don't have permission to perform this action.

والسبب أن GLPI أعطى المستخدم الجديد تلقائياً صلاحية Self-Service إضافة إلى Technician، وجعلها صلاحيته الافتراضية، وجلسة ال API تبدأ بالصلاحية الافتراضية، فكان سير العمل Workflow يعمل كموظف عادي وليس كفني. والحل أن تجعل Technician هي الصلاحية الافتراضية لحساب الخدمة. وهذا مثال جيد على أن أغلب أعطال سير العمل Workflow الأولى ليست في n8n، وإنما في صلاحيات حساب الخدمة في النظام الآخر.

سير العمل Workflow الرئيسي

بنينا سير العمل Workflow من العقد Nodes التالية بالترتيب:

  1. Request: Webhook يستقبل الطلب من بوابة الموظفين أو من نموذج، ويرد فوراً بأن الطلب استلم، لأن باقي العمل قد يستغرق أياماً.
  2. Drop repeated request: عقدة Remove Duplicates، وسوف نرى لماذا أضفناها في قسم الطلب المكرر.
  3. GLPI login وCreate ticket: جلسة API ثم تذكرة من نوع طلب خدمة، فيها البريد والسبب ورقم الطلب.
  4. Ask approver: رسالة للمدير فيها رابط موافقة ورابط رفض.
  5. Wait for approval: عقدة Wait تتوقف حتى يفتح أحد الرابطين.
  6. GLPI login again: جلسة جديدة، لأن الانتظار قد يطول أياماً، والجلسة الأولى قد تنتهي.
  7. Approved?: إذا كانت الموافقة يستدعي سير العمل الفرعي Sub-workflow، ثم يكتب متابعة في التذكرة ويضيف حلاً Solution يغلقها، وإذا كان الرفض يكتب متابعة بالرفض.

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

[
  {
    "parameters": {
      "method": "POST",
      "url": "http://lab-auto2-sink:9000/chat",
      "sendBody": true,
      "specifyBody": "json",
      "jsonBody": "={{ JSON.stringify({ text: 'Approve laptop for ' + $('Request').item.json.body.email + '? Ticket ' + $('Create ticket').item.json.id, approve: $execution.resumeUrl + '&decision=approve', reject: $execution.resumeUrl + '&decision=reject' }) }}",
      "options": {}
    },
    "id": "00000000-0000-4000-8000-000000000005",
    "name": "Ask approver",
    "type": "n8n-nodes-base.httpRequest",
    "typeVersion": 4.2,
    "position": [
      1100,
      0
    ]
  },
  {
    "parameters": {
      "resume": "webhook",
      "options": {}
    },
    "id": "00000000-0000-4000-8000-000000000006",
    "name": "Wait for approval",
    "type": "n8n-nodes-base.wait",
    "typeVersion": 1.1,
    "position": [
      1320,
      0
    ],
    "webhookId": "aa000000-0000-4000-8000-000000000002"
  },
  {
    "parameters": {
      "conditions": {
        "options": {
          "caseSensitive": true,
          "leftValue": "",
          "typeValidation": "strict",
          "version": 2
        },
        "conditions": [
          {
            "id": "a1",
            "leftValue": "={{ $('Wait for approval').item.json.query.decision }}",
            "rightValue": "approve",
            "operator": {
              "type": "string",
              "operation": "equals"
            }
          }
        ],
        "combinator": "and"
      },
      "options": {}
    },
    "id": "00000000-0000-4000-8000-000000000008",
    "name": "Approved?",
    "type": "n8n-nodes-base.if",
    "typeVersion": 2.2,
    "position": [
      1760,
      0
    ]
  }
]

رابط الموافقة

عقدة Wait بخيار resume: webhook تعطي كل تشغيل عنواناً خاصاً به في المتغير $execution.resumeUrl، ومن يفتحه يكمل التشغيل، وما يضاف إليه من Query Parameters يصل إلى العقدة التالية. والخطأ الذي وقعنا فيه في المحاولة الأولى أننا أضفنا القرار بهذا الشكل:

$execution.resumeUrl + '?decision=approve'

فوصل إلى المدير رابط بعلامتي استفهام، والسبب أن العنوان في هذا الإصدار يحمل أصلاً توقيعاً ?signature=...، فكان الرد عند فتحه:

{"error":"Invalid token"} 401

وفتحنا أيضاً العنوان دون التوقيع بالقرار وحده، فرفضه بالرسالة نفسها، وهذا جيد، لأن التوقيع هو ما يمنع شخصاً يعرف رقم التشغيل من الموافقة عليه. وقد أصلحت n8n في سبتمبر 2026 ثغرة في هذا التوقيع نفسه في خطوة Send and Wait كما ذكرنا في قسم الأمان في دليل التثبيت. والصحيح أن تضيف القرار بعلامة &:

$execution.resumeUrl + '&decision=approve'

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

سير العمل الفرعي Sub-workflow

تسليم الجهاز في Snipe-IT خطوة تحتاجها أكثر من عملية: موظف جديد، واستبدال جهاز معطل، وجهاز مؤقت لزائر. لذلك وضعناها في سير عمل فرعي Sub-workflow يبدأ بعقدة Execute Sub-workflow Trigger، ويستقبل البريد والموديل ورقم التذكرة، ثم:

  1. يبحث عن المستخدم بالبريد.
  2. يبحث عن أول جهاز من الموديل حالته جاهز للتسليم.
  3. إذا لم يجد جهازاً يوقف التشغيل بخطأ صريح بعقدة Stop and Error، بدلاً من أن يكمل بلا جهاز.
  4. يسلم الجهاز ويكتب رقم التذكرة في ملاحظة التسليم، فيرتبط السجلان.

وسير العمل Workflow الرئيسي يستدعيه بعقدة Execute Sub-workflow ويستلم منه رقم الجهاز. والفائدة أن منطق Snipe-IT في مكان واحد، فإذا تغيرت ال API عدلته مرة واحدة.

سير عمل الأخطاء Error Workflow

سير عمل الأخطاء Error Workflow يبدأ بعقدة Error Trigger، ويرسل رسالة إلى قناة الفريق فيها اسم سير العمل Workflow ورقم التشغيل والعقدة التي فشلت والخطأ:

{
  "id": "labAuto2ErrorWf1",
  "name": "Error handler: post to team chat",
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "nodes": [
    {
      "parameters": {},
      "id": "00000000-0000-4000-8000-000000000001",
      "name": "Error Trigger",
      "type": "n8n-nodes-base.errorTrigger",
      "typeVersion": 1,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "http://lab-auto2-sink:9000/chat",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: 'FAILED: ' + $json.workflow.name + ' | execution ' + $json.execution.id + ' | node ' + $json.execution.lastNodeExecuted + ' | ' + $json.execution.error.message, url: $json.execution.url }) }}",
        "options": {}
      },
      "id": "00000000-0000-4000-8000-000000000002",
      "name": "Post to chat",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        0
      ]
    }
  ],
  "connections": {
    "Error Trigger": {
      "main": [
        [
          {
            "node": "Post to chat",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

وفي تجربتنا الأولى لم تصل أي رسالة رغم أن التشغيل فشل، ووجدنا في سجل n8n هذا السطر:

Calling Error Workflow for "labAuto2Request1". Workflow "labAuto2ErrorWf1" is not active and cannot be executed

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

ولاحظ أيضاً أن الرسالة وصلت إلينا في إحدى المحاولات بالنص العام Bad request - please check your parameters، بينما كان الخطأ الحقيقي في تفاصيل التشغيل هو ERROR_GLPI_ADD | You don't have permission to perform this action.، فأضف إلى الرسالة رابط التشغيل $json.execution.url حتى يصل من يقرؤها إلى التفاصيل بضغطة واحدة.

التشغيل من البداية إلى النهاية

الملف req.py يرسل الطلب كما ترسله بوابة الموظفين، وينتظر رسالة المدير في القناة، ثم يفتح رابط الموافقة أو الرفض:

"""Lab harness: sends a laptop request to n8n, waits for the approver message in the team chat sink,
optionally clicks approve or reject, then prints the state of GLPI and Snipe-IT.
usage: python req.py <request_id> [approve|reject|none] [times]"""
import json, sys, time, urllib.request
rid, decision = sys.argv[1], (sys.argv[2] if len(sys.argv) > 2 else "approve")
times = int(sys.argv[3]) if len(sys.argv) > 3 else 1
N8N, CHAT = "http://lab-auto2-n8n:5678", "http://lab-auto2-sink:9000/"
chat = lambda: json.loads(urllib.request.urlopen(CHAT).read())
before = len(chat())
body = json.dumps({"request_id": rid, "email": "[email protected]", "model": "Latitude 7450", "reason": "New hire"}).encode()
for _ in range(times):
    r = urllib.request.urlopen(urllib.request.Request(N8N + "/webhook/device-request", body, {"Content-Type": "application/json"}))
    print("POST /webhook/device-request ->", r.status, r.read().decode())
for _ in range(60):
    msgs = [m for m in chat()[before:] if "approve" in m]
    if len(msgs) >= times or decision == "none": break
    time.sleep(2)
time.sleep(3)
msgs = chat()[before:]
for m in msgs: print("chat:", m["text"])
if decision in ("approve", "reject"):
    link = [m for m in msgs if "approve" in m][0][decision].replace("http://127.0.0.1:18483", N8N)
    r = urllib.request.urlopen(link); print("GET", decision, "link ->", r.status, r.read().decode())
    time.sleep(25)
    for m in chat()[before + len(msgs):]: print("chat:", m["text"])

والملف state.sh يقرأ التذاكر وحالة الأجهزة من قاعدة البيانات مباشرة:

docker exec lab-auto2-db mariadb -uroot -plab-root-pass -e "select t.id, t.name, t.status, (select count(*) from glpi.glpi_itilfollowups f where f.items_id=t.id) followups from glpi.glpi_tickets t; select a.asset_tag, u.username as assigned_to from snipeit.assets a left join snipeit.users u on u.id=a.assigned_to order by a.asset_tag;"

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

POST /webhook/device-request -> 200 {"message":"Workflow was started"}
chat: Approve laptop for [email protected]? Ticket 4
GET approve link -> 200 {"message":"Workflow was started"}
id	name	status	followups
1	Laptop request: Latitude 7450	1	1
2	Laptop request: Latitude 7450	2	1
3	Laptop request: Latitude 7450	2	1
4	Laptop request: Latitude 7450	5	1
asset_tag	assigned_to
LT-0001	sara
LT-0002	NULL
LT-0003	NULL
ticket	content
4	Approved. Laptop LT-0001 checked out in Snipe-IT.
ticket	content	status
4	Laptop delivered through the automated approval flow.	2

والحالة 5 في GLPI تعني Solved، والحل نفسه بحالة 2 أي بانتظار موافقة صاحب الطلب على الحل، وهذا سلوك GLPI الافتراضي. أما الرفض فالمخرج له:

POST /webhook/device-request -> 200 {"message":"Workflow was started"}
chat: Approve laptop for [email protected]? Ticket 5
GET reject link -> 200 {"message":"Workflow was started"}
ticket	content
5	Rejected by the approver.

والصورة التالية من قائمة التشغيلات Executions في n8n بعد التجارب كلها، وفيها تشغيلات تنتظر الموافقة، وتشغيل فشل بعد 47 ثانية، وتشغيل بعنوان Retry of execution #16 سوف نشرحه بعد قليل:

تبويب Executions في n8n لسير العمل Laptop request: على اليسار قائمة التشغيلات بأوقاتها وحالاتها Succeeded وError وWaiting وواحد منها Retry of execution #16، وعلى اليمين رسم سير العمل بعقده من Request إلى Sub-workflow input
سجل التشغيلات Executions في n8n هو أول مكان تفتحه عندما يسألك موظف عن طلبه

نكسره: الطلب نفسه يصل مرتين

بوابة الموظفين، مثل أي نظام يرسل Webhooks، قد ترسل الطلب نفسه مرتين، إما لأن الموظف ضغط الزر مرتين، أو لأنها لم تستلم الرد في وقته فأعادت الإرسال. ولأن سير العمل Workflow يرد فوراً ثم يعمل، فلا شيء في الإصدار الأول يمنع الطلب الثاني. أرسلنا الطلب R-1006 مرتين دون أن نوافق عليه:

POST /webhook/device-request -> 200 {"message":"Workflow was started"}
POST /webhook/device-request -> 200 {"message":"Workflow was started"}
chat: Approve laptop for [email protected]? Ticket 6
chat: Approve laptop for [email protected]? Ticket 7
id	name	is_r1006
6	Laptop request: Latitude 7450	1
7	Laptop request: Latitude 7450	1

تذكرتان ورسالتا موافقة لطلب واحد، وإذا وافق المدير على الرسالتين سلم للموظف جهازان. والحل أن يحمل كل طلب معرفاً ثابتاً request_id، وأن يتجاهل سير العمل Workflow أي معرف رآه من قبل، وهذا ما تفعله عقدة Remove Duplicates بعملية Remove Items Processed in Previous Executions، فهي تحفظ القيم التي رأتها في تشغيلات سابقة:

{
  "operation": "removeItemsSeenInPreviousExecutions",
  "logic": "removeItemsWithAlreadySeenKeyValues",
  "dedupeValue": "={{ $json.body.request_id }}",
  "options": { "scope": "workflow" }
}

وبعد إضافتها أرسلنا R-1007 مرتين:

POST /webhook/device-request -> 200 {"message":"Workflow was started"}
POST /webhook/device-request -> 200 {"message":"Workflow was started"}
chat: Approve laptop for [email protected]? Ticket 8
id	name
8	Laptop request: Latitude 7450

تذكرة واحدة، والتشغيل الثاني انتهى بنجاح عند عقدة Remove Duplicates دون أن يمر بأي شيء بعدها. وهذا هو منع التكرار Idempotency الذي شرحناه في Idempotency: كيف تجعل العملية آمنة عند تكرارها، ولاحظ أن العقدة تحفظ القيم في قاعدة بيانات n8n، فإذا كان الطلب نفسه قد يصل بعد أشهر فاحرص على أن يبقى في السجل الذي تحتفظ به، أو تحقق من التكرار في GLPI نفسه بالبحث عن رقم الطلب.

نكسره: الفشل في منتصف الطريق

أخطر أنواع الفشل في سير عمل Workflow يمر بعدة أنظمة هو الفشل بعد أن تنجح بعض الخطوات، فتبقى الأنظمة في حالة لا تتفق. أوقفنا Snipe-IT، ثم وافقنا على الطلب R-1007:

== Snipe-IT stopped
GET approve link -> 200 {"message":"Workflow was started"}
chat: 21:40:12 FAILED: Snipe-IT: check out a laptop | execution 18 | node Find user | The connection cannot be established, this usually occurs due to an incorrect host (domain) value
chat: 21:40:12 FAILED: Laptop request: GLPI ticket, approval, Snipe-IT | execution 16 | node Check out in Snipe-IT | The connection cannot be established, this usually occurs due to an incorrect host (domain) value
8	Laptop request: Latitude 7450	2	0
LT-0001	sara
LT-0002	NULL
LT-0003	NULL
== Snipe-IT started again, retry execution 16
retry -> 21 retry retryOf 16 success
8	Laptop request: Latitude 7450	5	1
LT-0001	sara
LT-0002	sara
LT-0003	NULL

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

  • عقدة Find user مضبوطة على ثلاث محاولات بينها ثانيتان، واستغرق التشغيل الفرعي 17 ثانية من بدايته إلى فشله.
  • وصلت رسالتان: واحدة من سير العمل الفرعي Sub-workflow وواحدة من الرئيسي، لأن لكل منهما سير عمل Workflow أخطاء. وهذا مقبول، ولكن إذا كثرت سير العمل الفرعي Sub-workflowة فاجعل الرسالة من الرئيسي فقط حتى لا تغرق القناة.
  • التذكرة 8 بقيت بحالة 2 دون متابعة، أي أن الطلب موافق عليه ولم يسلم أي جهاز. ولو لم يكن هناك سير عمل Workflow أخطاء لبقيت هكذا حتى يسأل الموظف.
  • بعد أن أعدنا تشغيل Snipe-IT، أعدنا التشغيل 16 من العقدة التي فشلت، فأكمل وسلم LT-0002 وأغلق التذكرة، دون أن يفتح تذكرة جديدة أو يطلب موافقة جديدة.

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

error retry ["ERROR_GLPI_ADD | You don't have permission to perform this action."]

والسبب أن إعادة التشغيل استخدمت رمز الجلسة Session-Token القديم من عقدة GLPI login again، وتلك الجلسة بدأت بالصلاحية القديمة. أي أن إصلاح السبب لا يكفي إذا كان السبب محفوظاً في مخرجات عقدة سابقة، وفي هذه الحالة شغل الطلب من جديد بدلاً من إعادته. ولهذا أيضاً نضع خطوة تسليم الجهاز قبل كتابة المتابعة: إذا فشلت المتابعة بعد التسليم فإعادة التشغيل تكمل المتابعة فقط، وإذا كان الترتيب معكوساً فقد تكتب متابعة تقول إن الجهاز سلم وهو لم يسلم.

حفظ سير العمل Workflow في Git

ميزة التحكم بالنسخ عبر Git المدمجة في n8n مدفوعة كما ذكرنا في مقال الأدوات، ولكنك تستطيع في النسخة المجانية تصدير سير العمل Workflow بسطر الأوامر وحفظه في مستودع Git. والملف التالي يصدر كل سير عمل Workflow في ملف منفصل:

# exports every workflow (one pretty JSON file each) from the running n8n into the git repo folder ./repo/workflows
docker exec lab-auto2-n8n sh -c 'rm -rf /home/node/.n8n/export && n8n export:workflow --backup --output=/home/node/.n8n/export/' 2>&1 | tail -1
rm -rf repo/workflows && mkdir -p repo/workflows
docker cp lab-auto2-n8n:/home/node/.n8n/export/. repo/workflows/

ثم أضفنا عقدة Remove Duplicates، ونشرنا الإصدار الجديد، وصدرنا مرة أخرى، وطلبنا من Git ملخص الفرق قبل أن نحفظه:

$ git diff --stat        # before committing v2
 workflows/labAuto2ErrorWf1.json |  6 +--
 workflows/labAuto2Request1.json | 81 ++++++++++++++++++++++++++++-------------
 workflows/labAuto2SnipeOut.json |  6 +--
 3 files changed, 61 insertions(+), 32 deletions(-)

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

  • تغيير عقدة واحدة أنتج 81 سطراً في الفرق، والسبب أن السكربت الذي يولد سير العمل Workflow عندنا يرقم معرفات العقد Nodes بالترتيب، فإضافة عقدة في البداية غيرت معرفات كل ما بعدها ومواقعها. لذلك اجعل معرفات العقد Nodes ثابتة لا تتغير، حتى يبقى الفرق قابلاً للمراجعة.
  • الملفان الآخران تغيرا رغم أننا لم نعدلهما، والسبب حقل activeVersionId الذي يتغير مع كل نشر، فإذا أردت مراجعة نظيفة فاستبعده من الفرق.
  • بعد الحفظ أصبح في المستودع إصداران: 97dfec4 v1: laptop request flow و3e72b57 v2: drop repeated request_id before creating a ticket، فتستطيع الرجوع إلى أي منهما باستيراد ملفه.
  • الملفات لا تحتوي بيانات الاعتماد، وإنما معرفاتها، فالمستودع آمن للمشاركة داخل الفريق.

الاختبار

في المحرر تستطيع تثبيت بيانات Pin Data لعقدة حتى تجرب ما بعدها دون أن تستدعي الأنظمة الحقيقية، كما في توثيق تثبيت البيانات، وهذا مفيد أثناء البناء. ولكن الاختبار الذي يحميك عند كل تعديل هو اختبار من الخارج على نسخة تجريبية Staging فيها GLPI وSnipe-IT تجريبيان، يرسل طلباً حقيقياً ويتحقق من النتيجة في الأنظمة نفسها:

"""End-to-end test of the laptop flow against a staging n8n: a rejected request must leave a follow-up
on its GLPI ticket and must not touch Snipe-IT stock; a repeated request_id must not open a second ticket."""
import base64, json, time, urllib.request, uuid
N8N, CHAT = "http://lab-auto2-n8n:5678", "http://lab-auto2-sink:9000/"
GLPI, SNIPE = "http://lab-auto2-glpi/apirest.php", "http://lab-auto2-snipe/api/v1"
ST = {"Authorization": "Bearer " + open("/lab/snipe-token.txt").read().strip(), "Accept": "application/json"}
get = lambda url, h={}: json.loads(urllib.request.urlopen(urllib.request.Request(url, headers=h)).read())
glpi = {"Session-Token": get(GLPI + "/initSession", {"Authorization": "Basic " + base64.b64encode(b"n8n-bot:Bot-Pass-2026!").decode()})["session_token"]}
ready = lambda: get(SNIPE + "/hardware?status=RTD&limit=50", ST)["total"]
tickets_for = lambda rid: [t for t in get(GLPI + "/Ticket?range=0-200", glpi) if rid in t["content"]]
rid, stock = "T-" + uuid.uuid4().hex[:6], ready()
n_chat = len(get(CHAT))
body = json.dumps({"request_id": rid, "email": "[email protected]", "model": "Latitude 7450", "reason": "test"}).encode()
for _ in range(2):
    urllib.request.urlopen(urllib.request.Request(N8N + "/webhook/device-request", body, {"Content-Type": "application/json"}))
for _ in range(30):
    msgs = [m for m in get(CHAT)[n_chat:] if "reject" in m]
    if msgs: break
    time.sleep(2)
urllib.request.urlopen(msgs[0]["reject"].replace("http://127.0.0.1:18483", N8N)); time.sleep(15)
t = tickets_for(rid)
fups = get(f"{GLPI}/Ticket/{t[0]['id']}/ITILFollowup", glpi)
checks = {"one ticket for a repeated request_id": len(t) == 1,
          "rejection written on the ticket": any("Rejected" in f["content"] for f in fups),
          "Snipe-IT stock unchanged": ready() == stock}
for k, v in checks.items(): print(("PASS " if v else "FAIL ") + k)
raise SystemExit(0 if all(checks.values()) else 1)
PASS one ticket for a repeated request_id
PASS rejection written on the ticket
PASS Snipe-IT stock unchanged

واجعل هذا الاختبار يعمل بعد كل استيراد لإصدار جديد إلى النسخة التجريبية، وقبل الاستيراد إلى الإنتاج.

مراقبة التشغيلات Executions

إذا فعلت المتغير N8N_METRICS=true يعرض n8n المسار /metrics بصيغة Prometheus، كما في التوثيق، والتوثيق نفسه يطلب ألا تنشر هذا المسار على الإنترنت. ومن المقاييس التي ظهرت عندنا عدد التشغيلات Executions ومدتها حسب الحالة والنوع، منذ آخر تشغيل لـ n8n:

n8n_workflow_execution_duration_seconds_count{status="failed",mode="webhook"} 3
n8n_workflow_execution_duration_seconds_count{status="success",mode="webhook"} 1
n8n_workflow_execution_duration_seconds_count{status="failed",mode="integrated"} 1
n8n_workflow_execution_duration_seconds_count{status="success",mode="error"} 2
n8n_workflow_execution_duration_seconds_count{status="success",mode="integrated"} 1
n8n_workflow_execution_duration_seconds_count{status="success",mode="retry"} 1

ومنها تبني التنبيهات التي شرحناها في المقال الأول: نسبة status="failed" إلى المجموع، وغياب التشغيلات Executions من نوع webhook لمدة أطول من المعتاد. ولاحظ أن التشغيلات Executions التي تنتظر الموافقة لا تظهر هنا حتى تنتهي، فراقبها من قائمة التشغيلات Executions بحالة Waiting، لأن طلباً ينتظر أسبوعاً هو أيضاً مشكلة يجب أن يراها أحد.

حذف المختبر

docker compose down -v

أخطاء شائعة

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

الخلاصة

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

  • صمم سير العمل Workflow حول الأنظمة التي تملك البيانات: التذكرة للطلب، ونظام الأصول للجهاز.
  • لكل نظام حساب خدمة بأقل صلاحية تكفي، واختبر صلاحياته قبل الإنتاج.
  • الموافقة البشرية بعقدة Wait ورابط موقع، وجلسة جديدة بعد الانتظار.
  • ضع الخطوات المشتركة في سير عمل فرعي Sub-workflow، وانشر سير عمل الأخطاء Error Workflow واختبره.
  • امنع الطلب المكرر بمعرف ثابت وعقدة Remove Duplicates.
  • عند الفشل في المنتصف، أصلح السبب ثم أعد التشغيل من العقدة التي فشلت، إلا إذا كان السبب محفوظاً في مخرجات عقدة سابقة.
  • صدر سير العمل Workflow إلى Git، واختبره من الخارج، وراقبه بمقاييس Prometheus وبقائمة التشغيلات Executions المنتظرة.

وفي المقال الأخير من السلسلة بإذن الله سوف نضيف إلى هذا النوع من سير العمل Workflow وكيلاً ذكياً AI Agent بنموذج محلي، ونرى أين يساعد وأين يخطئ بثقة.

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

  • أكتوبر 2026: كتابة المقال، وتشغيل المختبر على n8n 2.43.5 وGLPI 12.0.0 وSnipe-IT v8.8.0 وMariaDB 11.8.9.
نشرة عرب رووت | ArabRoot

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

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

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

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