الكتابات/tutorial/2026/08
Tutorial7 أغسطس 2026·30 دقيقة

تكامل Odoo 17 عبر API الخارجي: دليل TypeScript الشامل

دليل عملي على مستوى الكود لربط أي تطبيق TypeScript بنظام أودو 17 عبر XML-RPC وREST API: المصادقة بمفاتيح API، عمليات CRUD الكاملة، ومثال حقيقي لمزامنة المخزون — بدون Python.

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

المشكلة أن توثيق تكامل أودو مكتوب بالكامل تقريباً لـ Python. مطورو TypeScript يجدون أنفسهم أمام منشورات منتديات قديمة وأمثلة ناقصة.

هذا الدليل يسد هذه الفجوة. ستخرج منه بـ OdooClient جاهز للإنتاج، أمان أنواع كامل لنماذج أودو، وأمثلة تعمل على كل عملية شائعة — بحث، قراءة، إنشاء، تعديل، حذف. نفس الأنماط تعمل على أودو SaaS (odoo.com) والنسخ المستضافة ذاتياً.

ما ستبنيه

  • فئة OdooClient قابلة لإعادة الاستخدام تغلّف استدعاءات XML-RPC
  • مصادقة بمفاتيح API (أكثر أماناً من كلمات المرور)
  • عمليات CRUD كاملة على أي نموذج في أودو
  • استخدام REST API في أودو 17+
  • مثال حقيقي لمزامنة مستويات المخزون مع نظام خارجي
  • منطق إعادة المحاولة ومعالجة الأخطاء للإنتاج

المتطلبات المسبقة

تأكد أن لديك:

  • Node.js 20+ مثبتاً (node --version يجب أن يُظهر v20 أو أعلى)
  • TypeScript 5+ و tsx لتشغيل TypeScript مباشرة
  • وصول إلى نسخة أودو 17 — مستضافة ذاتياً أو عبر odoo.com
  • حساب مستخدم في أودو بصلاحية الوصول للنماذج التي تحتاجها

لا يتطلب الأمر معرفة بـ Python.


الخطوة الأولى: فهم API الخارجي في أودو

أودو يوفر طريقتين للتكامل من كود خارجي.

XML-RPC API هو المسار الكلاسيكي، مستقر منذ أودو 6، ويستخدم نقطتين:

  • /xmlrpc/2/common — المصادقة فقط
  • /xmlrpc/2/object — كل عمليات السجلات (بحث، إنشاء، تعديل، حذف)

REST API (مُقدَّم في أودو 16، محسَّن بشكل كبير في أودو 17) يستخدم JSON عبر HTTPS على /api/. هو موجَّه للنماذج: /api/sale.order يجلب أوامر البيع، /api/res.partner يجلب جهات الاتصال.

هذا الدليل يغطي XML-RPC أولاً لأنه يعمل مع كل إصدارات أودو، ثم يُظهر ما يكافئه في REST لأودو 17+.


الخطوة الثانية: إعداد المشروع

أنشئ مجلداً جديداً وثبّت الاعتمادات:

mkdir odoo-ts-client && cd odoo-ts-client
npm init -y
npm install xmlrpc dotenv
npm install -D typescript tsx @types/node @types/xmlrpc
npx tsc --init

حدّث tsconfig.json لاستخدام دقة الوحدات الحديثة:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist",
    "esModuleInterop": true
  }
}

أنشئ ملف .env للبيانات السرية (لا تُضِفه إلى مستودع Git أبداً):

ODOO_URL=https://your-instance.odoo.com
ODOO_DB=اسم-قاعدة-البيانات
ODOO_USER=admin@yourcompany.com
ODOO_API_KEY=مفتاح-api-الخاص-بك

الخطوة الثالثة: إنشاء مفتاح API في أودو

أودو 14+ يدعم مفاتيح API — بيانات اعتماد قابلة للإلغاء ومستقلة عن كلمة المرور، وهي أكثر أماناً لاستخدامها في الكود.

لإنشاء مفتاح:

  1. سجّل دخولك في أودو كمدير
  2. اذهب إلى الإعدادات ← التقنية ← مفاتيح API (إذا لم تجد هذا القسم، فعّل وضع المطور أولاً: الإعدادات ← أدوات المطور ← تفعيل وضع المطور)
  3. اضغط جديد، أعطِ المفتاح اسماً مثل typescript-integration
  4. انسخ المفتاح المُولَّد فوراً — أودو يُظهره مرة واحدة فقط

الصقه في ملف .env كـ ODOO_API_KEY.


الخطوة الرابعة: بناء فئة OdooClient

أنشئ src/odoo-client.ts:

import xmlrpc from 'xmlrpc';
 
export interface OdooConfig {
  url: string;
  db: string;
  username: string;
  apiKey: string;
}
 
export class OdooClient {
  private config: OdooConfig;
  private uid: number | null = null;
  private common: xmlrpc.Client;
  private object: xmlrpc.Client;
 
  constructor(config: OdooConfig) {
    this.config = config;
    const base = new URL(config.url);
    const isHttps = base.protocol === 'https:';
    const port = base.port ? parseInt(base.port) : (isHttps ? 443 : 80);
 
    const opts = { host: base.hostname, port };
 
    const createClient = isHttps
      ? xmlrpc.createSecureClient.bind(xmlrpc)
      : xmlrpc.createClient.bind(xmlrpc);
 
    this.common = createClient({ ...opts, path: '/xmlrpc/2/common' });
    this.object = createClient({ ...opts, path: '/xmlrpc/2/object' });
  }
 
  async authenticate(): Promise<number> {
    return new Promise((resolve, reject) => {
      this.common.methodCall(
        'authenticate',
        [this.config.db, this.config.username, this.config.apiKey, {}],
        (err, uid) => {
          if (err) return reject(new Error(`فشلت المصادقة: ${err.message}`));
          if (!uid) return reject(new Error('بيانات اعتماد غير صحيحة — تحقق من اسم المستخدم ومفتاح API'));
          this.uid = uid as number;
          resolve(uid as number);
        }
      );
    });
  }
 
  async call<T>(
    model: string,
    method: string,
    args: unknown[],
    kwargs: Record<string, unknown> = {}
  ): Promise<T> {
    if (!this.uid) await this.authenticate();
 
    return new Promise((resolve, reject) => {
      this.object.methodCall(
        'execute_kw',
        [this.config.db, this.uid, this.config.apiKey, model, method, args, kwargs],
        (err, result) => {
          if (err) return reject(new Error(`فشل استدعاء RPC للنموذج ${model}.${method}: ${err.message}`));
          resolve(result as T);
        }
      );
    });
  }
}

دالة call هي محور العمل. تأخذ:

  • model — اسم نموذج أودو، مثل res.partner أو sale.order أو account.move
  • method — الدالة المطلوب استدعاؤها: search_read أو create أو write أو unlink
  • args — المعاملات الموضعية (دائماً مصفوفة)
  • kwargs — المعاملات المسماة (الحقول، الحد، الترتيب...)

الخطوة الخامسة: البحث وقراءة السجلات

أنشئ src/main.ts:

import 'dotenv/config';
import { OdooClient, OdooConfig } from './odoo-client.js';
 
const config: OdooConfig = {
  url: process.env.ODOO_URL!,
  db: process.env.ODOO_DB!,
  username: process.env.ODOO_USER!,
  apiKey: process.env.ODOO_API_KEY!,
};
 
const client = new OdooClient(config);
 
// واجهة TypeScript لنموذج الشريك في أودو
interface OdooPartner {
  id: number;
  name: string;
  email: string | false;
  phone: string | false;
  country_id: [number, string] | false;
  is_company: boolean;
}
 
async function listSaudiCompanies(): Promise<void> {
  // معرف المملكة العربية السعودية في أودو هو 186
  const partners = await client.call<OdooPartner[]>(
    'res.partner',
    'search_read',
    [[['is_company', '=', true], ['country_id', '=', 186]]],
    {
      fields: ['id', 'name', 'email', 'phone', 'country_id'],
      limit: 20,
      order: 'name asc',
    }
  );
 
  console.log(`وُجد ${partners.length} شركة سعودية:`);
  partners.forEach(p => {
    const country = p.country_id ? p.country_id[1] : 'غير محدد';
    console.log(`  [${p.id}] ${p.name} (${country}) — ${p.email || 'لا يوجد بريد إلكتروني'}`);
  });
}
 
listSaudiCompanies().catch(console.error);

شغّل الكود:

npx tsx src/main.ts

صيغة فلتر النطاق (Domain Filter)

فلاتر أودو تستخدم مصفوفات من الثلاثيات: ['الحقل', 'المشغّل', القيمة].

// جهات اتصال من السعودية
[['country_id', '=', 186]]
 
// شركات لديها رقم هاتف
[['is_company', '=', true], ['phone', '!=', false]]
 
// سجلات أُنشئت بعد 2026-01-01
[['create_date', '>=', '2026-01-01 00:00:00']]
 
// شركاء يحتوي اسمهم على "شركة"
[['name', 'ilike', 'شركة']]

الشروط المتعددة في نفس المصفوفة تُجمع بـ AND. استخدم '|' للـ OR:

// سجلات اسمها يبدأ بـ "Al" أو "Al-"
['|', ['name', '=like', 'Al%'], ['name', '=like', 'Al-%']]

الخطوة السادسة: إنشاء السجلات

interface NewPartnerData {
  name: string;
  is_company: boolean;
  country_id?: number;
  email?: string;
  phone?: string;
}
 
async function createPartner(data: NewPartnerData): Promise<number> {
  const newId = await client.call<number>(
    'res.partner',
    'create',
    [[data]]  // create تأخذ قائمة تحتوي على قاموس واحد
  );
  console.log(`تم إنشاء شريك بالمعرف ${newId}`);
  return newId;
}
 
// مثال
const id = await createPartner({
  name: 'شركة الأمانة للتقنية',
  is_company: true,
  country_id: 186,
  email: 'info@amanah-tech.sa',
  phone: '+966500000000',
});

إنشاء سجلات بأسطر مرتبطة (الفواتير)

أودو يستخدم صيغة أوامر خاصة لحقول One2many وMany2many. التنسيق: [كود_الأمر، المعرف، القيم]:

  • [0, 0, values] — إنشاء سجل مرتبط جديد
  • [1, id, values] — تعديل سجل مرتبط موجود
  • [2, id, 0] — حذف سجل مرتبط
  • [4, id, 0] — ربط سجل موجود دون تعديله
interface InvoiceLine {
  product_id: number;
  quantity: number;
  price_unit: number;
  name: string;
}
 
async function createInvoice(
  partnerId: number,
  lines: InvoiceLine[]
): Promise<number> {
  const invoiceId = await client.call<number>(
    'account.move',
    'create',
    [[{
      partner_id: partnerId,
      move_type: 'out_invoice',  // فاتورة للعميل
      invoice_date: new Date().toISOString().split('T')[0],
      invoice_line_ids: lines.map(line => [0, 0, line]),
    }]]
  );
 
  console.log(`تم إنشاء فاتورة رقم ${invoiceId}`);
  return invoiceId;
}
 
await createInvoice(id, [
  { product_id: 1, quantity: 3, price_unit: 500, name: 'خدمات استشارية في الذكاء الاصطناعي' },
  { product_id: 2, quantity: 1, price_unit: 1200, name: 'إعداد تكامل الأنظمة' },
]);

الخطوة السابعة: تعديل السجلات

دالة write تأخذ قائمة معرفات السجلات وقاموساً بالحقول المراد تعديلها:

// تعديل بريد شريك واحد
async function updatePartnerEmail(partnerId: number, email: string): Promise<void> {
  await client.call<boolean>(
    'res.partner',
    'write',
    [[partnerId], { email }]  // المعامل الأول قائمة معرفات
  );
}
 
// تعديل سجلات متعددة دفعة واحدة
async function markPartnersAsCustomer(ids: number[]): Promise<void> {
  await client.call<boolean>(
    'res.partner',
    'write',
    [ids, { customer_rank: 1 }]
  );
}

الخطوة الثامنة: الأرشفة والحذف

معظم سجلات أودو تدعم الحذف الناعم عبر حقل active:

// الأرشفة (حذف ناعم) — الطريقة المفضلة
async function archiveRecord(model: string, id: number): Promise<void> {
  await client.call<boolean>(model, 'write', [[id], { active: false }]);
}
 
// الحذف الفعلي — استخدمه بحذر، قد يفشل إذا كان للسجل ارتباطات
async function deleteRecord(model: string, id: number): Promise<void> {
  await client.call<boolean>(model, 'unlink', [[id]]);
}

افضّل الأرشفة على الحذف الفعلي. ضبط active: false يُخفي السجل من كل العروض الافتراضية مع الحفاظ على تاريخ المراجعة. الحذف الفعلي (unlink) يرفع خطأ إذا كان للسجل ارتباطات — مثلاً، لا يمكنك حذف شريك لديه فواتير مفتوحة.


الخطوة التاسعة: استخدام REST API في أودو 17+

أودو 17 يُقدم REST API أكثر سهولة للمطورين. يقبل JSON ويعيده، ويستخدم أفعال HTTP القياسية، ويصادق عبر Bearer token:

async function odooRestFetch<T>(
  path: string,
  options: RequestInit = {}
): Promise<T> {
  const url = `${process.env.ODOO_URL}${path}`;
  const response = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.ODOO_API_KEY}`,
      'X-Odoo-Database': process.env.ODOO_DB!,
      ...options.headers,
    },
  });
 
  if (!response.ok) {
    const body = await response.text();
    throw new Error(`خطأ أودو REST ${response.status} على ${path}: ${body}`);
  }
 
  return response.json() as Promise<T>;
}
 
// قائمة أحدث 10 أوامر بيع
const ordersResult = await odooRestFetch<{
  count: number;
  records: Array<{
    id: number;
    name: string;
    partner_id: [number, string];
    amount_total: number;
  }>;
}>(
  '/api/sale.order?fields=name,partner_id,amount_total&limit=10&order=date_order desc'
);
console.log(`إجمالي الطلبات: ${ordersResult.count}`);
ordersResult.records.forEach(o => {
  console.log(`  ${o.name} — ${o.amount_total} ريال`);
});
 
// إنشاء جهة اتصال عبر REST
const newContact = await odooRestFetch<{ id: number }>(
  '/api/res.partner',
  {
    method: 'POST',
    body: JSON.stringify({
      name: 'مؤسسة الفهد للاستشارات',
      is_company: true,
      country_id: 186,
    }),
  }
);
console.log(`تم إنشاء جهة الاتصال بالمعرف: ${newContact.id}`);

متى تستخدم REST مقابل XML-RPC:

  • REST — أودو 16 أو 17، تريد كوداً أنظف بدلالات HTTP قياسية
  • XML-RPC — أودو 15 أو أقدم، أو عند استدعاء دوال عمل غير مكشوفة عبر REST

الخطوة العاشرة: أنماط الإنتاج

إعادة المحاولة عند الأخطاء العابرة

async function withRetry<T>(
  fn: () => Promise<T>,
  maxAttempts = 3,
  baseDelayMs = 500
): Promise<T> {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt === maxAttempts) throw err;
 
      const message = err instanceof Error ? err.message : String(err);
      const isTransient =
        message.includes('could not serialize') ||
        message.includes('ECONNRESET') ||
        message.includes('ETIMEDOUT') ||
        message.includes('ENOTFOUND');
 
      if (!isTransient) throw err;
 
      const delay = baseDelayMs * Math.pow(2, attempt - 1);
      console.warn(`المحاولة ${attempt} فشلت، إعادة المحاولة خلال ${delay}ms...`);
      await new Promise(r => setTimeout(r, delay));
    }
  }
  throw new Error('لن يصل الكود إلى هنا');
}

معالجة مجموعات بيانات كبيرة دفعةً دفعة

أودو يعمل بشكل أفضل عندما تقرأ السجلات في دُفعات بدلاً من جلبها كلها دفعة واحدة:

async function* readAllRecords<T>(
  model: string,
  domain: unknown[][],
  fields: string[],
  chunkSize = 500
): AsyncGenerator<T[]> {
  let offset = 0;
 
  while (true) {
    const chunk = await withRetry(() =>
      client.call<T[]>(model, 'search_read', [domain], {
        fields,
        limit: chunkSize,
        offset,
        order: 'id asc',
      })
    );
 
    if (chunk.length === 0) break;
    yield chunk;
    offset += chunk.length;
    if (chunk.length < chunkSize) break;
  }
}

الخطوة الحادية عشرة: مثال واقعي — مزامنة المخزون

هذا النمط يسحب مستويات المخزون الحالية من أودو ويدفعها إلى كتالوج خارجي:

interface OdooProduct {
  id: number;
  name: string;
  default_code: string | false;  // رمز المنتج / SKU
  qty_available: number;          // الكمية المتاحة
  virtual_available: number;      // الكمية المتوقعة (تشمل الحركات المعلقة)
  list_price: number;
  active: boolean;
}
 
async function syncInventoryToExternalCatalog(): Promise<void> {
  console.log('بدء مزامنة المخزون...');
  let totalSynced = 0;
 
  for await (const batch of readAllRecords<OdooProduct>(
    'product.product',
    [['active', '=', true], ['type', '=', 'product']],
    ['id', 'name', 'default_code', 'qty_available', 'virtual_available', 'list_price']
  )) {
    const toSync = batch.filter(p => p.default_code);
 
    await Promise.all(
      toSync.map(product =>
        updateExternalCatalog({
          sku: product.default_code as string,
          name: product.name,
          inStock: product.qty_available,
          forecasted: product.virtual_available,
          priceRiyal: product.list_price,
        })
      )
    );
 
    totalSynced += toSync.length;
    console.log(`تمت مزامنة ${totalSynced} منتج حتى الآن...`);
  }
 
  console.log(`اكتملت مزامنة المخزون. تم تحديث ${totalSynced} منتج.`);
}
 
async function updateExternalCatalog(data: {
  sku: string;
  name: string;
  inStock: number;
  forecasted: number;
  priceRiyal: number;
}): Promise<void> {
  // استبدل هذا باستدعاء نظامك الخارجي
  console.log(`  تحديث SKU ${data.sku}: ${data.inStock} متاح`);
}
 
syncInventoryToExternalCatalog().catch(console.error);

استكشاف الأخطاء وإصلاحها

"Access Denied" على نموذج معين

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

خطأ "Expected singleton"

مررت قائمة حيث يتوقع أودو سجلاً واحداً. عادةً يعني هذا أن نطاقك أعاد أكثر من سجل عند استدعاء دالة تتوقع واحداً بالضبط. أضف limit: 1 لاستدعاء search_read أو استخدم search ثم read لهدف محدد.

التواريخ بفارق ساعات

أودو يخزن كل التواريخ بتوقيت UTC داخلياً. المملكة العربية السعودية على UTC+3 (توقيت غرب آسيا). عند الفلترة بنطاقات تاريخية، مرر دائماً أوقات UTC وإلا ستفوتك سجلات قرب منتصف الليل.

تحديد معدل الطلبات على أودو SaaS

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


الخطوات التالية


الخلاصة

لديك الآن أساس جاهز للإنتاج لربط أي تطبيق TypeScript خارجي بأودو 17. فئة OdooClient تتعامل مع المصادقة، منطق إعادة المحاولة، وأمان الأنواع — يمكنك توسيعها لأي من نماذج أودو المئات باستخدام نفس الأنماط الموضحة هنا.

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

تحتاج مساعدة في بناء تكامل أودو مخصص؟ فريقنا نفّذ مشاريع تكامل API في السعودية والخليج وشمال أفريقيا — من مزامنات مخزون بسيطة إلى خطوط أتمتة متعددة الأنظمة. تواصل معنا للحصول على تقييم تقني بدون التزام.