Skip to main content
ربط Flutter بـ Odoo باستخدام odoo_rest_ov
10 دقائق قراءة
Flutter

ربط Flutter بـ Odoo باستخدام odoo_rest_ov

حزمة Dart تغلّف واجهة JSON-RPC الخاصة بـ Odoo مع أساليب ORM محددة الأنواع، وبانٍ نطاق سلس، وإدارة جلسات، ومعالجة أخطاء واضحة.

View on pub.dev

مقدمة

يبدو بناء تطبيق Flutter يتحدث مع خادم Odoo ERP أمرًا مباشرًا — إلى أن تصطدم بالواقع. فواجهة JSON-RPC 2.0 في Odoo لها دورة حياة خاصة بكوكي الجلسة، وصياغة غير قياسية لمرشّحات النطاق، وتنسيق استجابة للأخطاء يبدو كأنه نجاح (HTTP 200 مع حمولة خطأ)، وحقل منطقة زمنية يُفسد قيم التاريخ والوقت بصمت إن تجاهلته.

ومعظم المطورين الذين يكتبون أول عميل Odoo لهم ينتهون إلى تشابك من استدعاءات http أو dio الخام، وإدارة جلسات مصنوعة يدويًا، ومجموعة متنامية من كتل try/catch تفحص نصوصًا خامًا لمعرفة ما الذي أخفق. وهذا يعمل — إلى أن يتوقف عن العمل.

odoo_rest_ov حزمة Dart بناها فريق Octivex لحل هذه المشكلة كما ينبغي. فهي تغلّف واجهة JSON-RPC 2.0 الخاصة بـ Odoo في عميل محدد الأنواع ومريح في الاستخدام، مع دعم من الدرجة الأولى لـ Flutter. ويستعرض هذا المقال كل ميزة رئيسية بشيفرة حقيقية، لتتمكن من تقييم مدى ملاءمتها لمشروعك.


التثبيت

أضف الحزمة إلى ملف pubspec.yaml:

yaml
dependencies:
  odoo_rest_ov: ^0.1.0

ثم نفّذ:

bash
dart pub get

تعتمد الحزمة على dio لطلبات HTTP، وcookie_jar لتخزين الكوكيز، وdio_cookie_manager لربطهما معًا. وتُجلب الثلاث تلقائيًا — دون حاجة إلى أي تهيئة إضافية في الإعداد الأساسي.


الاتصال بخادم Odoo

إنشاء العميل يحتاج سطرًا واحدًا:

dart
import 'package:odoo_rest_ov/odoo_rest_ov.dart';

final client = OdooClient(OdooClientOptions(
  baseUrl: 'https://mycompany.odoo.com',
  database: 'mydb',
));

يقبل OdooClientOptions معاملات إضافية لضبط السلوك بدقة — منها دالة استدعاء عند تغيّر الجلسة، وقيم مهلة مخصّصة، وحقن مفتاح API. والعميل نفسه كائن طويل العمر؛ أنشئه مرة واحدة وأعد استخدامه في أنحاء تطبيقك.


المصادقة وإدارة الجلسات

اسم المستخدم / كلمة المرور

dart
final session = await client.authenticate('admin', 'admin');

print(session.name);      // "Administrator"
print(session.timezone);  // "Asia/Riyadh" — auto-detected from Odoo settings
print(session.uid);       // 2

يحمل كائن الجلسة كل ما يعيده Odoo بعد تسجيل دخول ناجح: اسم العرض للمستخدم، وUID، والمنطقة الزمنية، واللغة، ومعرّف الشركة، ونوع المستخدم. وتُقرأ المنطقة الزمنية مباشرة من تفضيلات المستخدم في Odoo، ما يتجنّب الخطأ الشائع الذي تبدو فيه قيم التاريخ والوقت مزاحة لأن العميل والخادم لا يتفقان على المنطقة الزمنية.

مفتاح API (Odoo 14 فما فوق)

لسيناريوهات الخادم إلى الخادم أو CI/CD التي يكون فيها تخزين كلمة المرور غير مقبول:

dart
client.setApiKey('your-odoo-api-key-here');
// Use ORM methods directly — no authenticate() call needed

الإنصات لتغيّرات الجلسة

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

dart
client.sessionStream.listen((session) {
  if (session == null) {
    // Session expired or user logged out
    Navigator.pushReplacementNamed(context, '/login');
  }
});

أو مرّر دالة الاستدعاء onSessionChanged عند الإنشاء:

dart
OdooClientOptions(
  baseUrl: 'https://mycompany.odoo.com',
  database: 'mydb',
  onSessionChanged: (session) {
    if (session == null) _navigateToLogin();
  },
);

فحص الجلسة وتحديثها

dart
final isValid = await client.checkSession();  // non-throwing boolean

if (!isValid) {
  await client.authenticate('admin', 'admin');
}

// Or refresh session data without re-authenticating
final updatedSession = await client.refreshSession();

تسجيل الخروج

dart
await client.logout();
client.close(); // release resources when done

أساليب ORM

كل عملية شائعة في ORM الخاص بـ Odoo متاحة كأسلوب محدد الأنواع. وتوقيعات الأساليب تحاكي ORM في Python عن قرب، فيشعر مطورو Odoo بالألفة فورًا.

searchRead

dart
final partners = await client.searchRead(
  'res.partner',
  [['is_company', '=', true]],
  fields: ['name', 'email', 'phone', 'country_id'],
  limit: 20,
  offset: 0,
  order: 'name asc',
);

search

يعيد قائمة بمعرّفات السجلات المطابقة دون جلب بيانات الحقول:

dart
final ids = await client.search(
  'res.partner',
  [['customer_rank', '>', 0]],
  limit: 100,
);

read

جلب حقول محددة لقائمة معروفة من المعرّفات:

dart
final records = await client.read(
  'res.partner',
  [1, 2, 3],
  fields: ['name', 'email'],
);

searchCount

dart
final total = await client.searchCount(
  'res.partner',
  [['active', '=', true]],
);
print('Total active partners: $total');

create

dart
final newId = await client.create('res.partner', {
  'name': 'Acme Corp',
  'email': 'info@acme.com',
  'is_company': true,
});
print('Created partner with ID: $newId');

createMulti

إنشاء عدة سجلات في رحلة واحدة إلى الخادم:

dart
final newIds = await client.createMulti('res.partner', [
  {'name': 'Partner A', 'email': 'a@example.com'},
  {'name': 'Partner B', 'email': 'b@example.com'},
]);

write

dart
await client.write('res.partner', [newId], {
  'phone': '+966 55 000 0000',
  'website': 'https://acme.com',
});

unlink

dart
await client.unlink('res.partner', [newId]);

fieldsGet

افحص تعريفات حقول النموذج أثناء التشغيل — مفيد لبناء نماذج ديناميكية:

dart
final fields = await client.fieldsGet(
  'res.partner',
  attributes: ['string', 'type', 'required', 'selection'],
);
// fields is a Map<String, dynamic> keyed by field name

nameSearch

dart
final results = await client.nameSearch('res.partner', 'Acme');
// Returns List<[int, String]> — id and display name pairs

callMethod

استدعِ أي أسلوب في النموذج لا تغطيه اختصارات ORM:

dart
final result = await client.callMethod(
  'account.move',
  'action_post',
  args: [[invoiceId]],
);

بانِي النطاق

نطاقات Odoo الخام هي قوائم من الصفوف (tuples) — مقروءة بما يكفي في Python، لكنها غير مريحة في Dart:

dart
// Raw — works, but fragile
final domain = [
  ['is_company', '=', true],
  ['customer_rank', '>', 0],
];

ويمنحك البانِي OdooDomain بديلًا سلسًا وآمن الأنواع:

dart
// AND (default)
final domain = OdooDomain()
    .where('is_company').equals(true)
    .where('customer_rank').greaterThan(0)
    .build();

// OR
final orDomain = OdooDomain()
    .or()
    .where('email').ilike('%@gmail.com')
    .where('email').ilike('%@yahoo.com')
    .build();

// NOT
final notDomain = OdooDomain()
    .not()
    .where('active').equals(false)
    .build();

// Membership check
final inDomain = OdooDomain()
    .where('country_id').isIn([184, 113, 20])
    .build();

ومجموعة المعاملات الكاملة تطابق كل معامل يدعمه Odoo: equals وnotEquals وgreaterThan وgreaterOrEqual وlessThan وlessOrEqual وlike وilike وnotLike وnotIlike وisIn وnotIn وchildOf وparentOf وisSet وisNotSet.

ولا تزال القوائم الخام تعمل إن كنت تفضّلها أو تحتاج إلى توافق رجعي — فأساليب ORM تقبل الصيغتين.


مساعدات السجلات

السجلات التي يعيدها searchRead وread هي كائنات OdooRecord مزوّدة بأساليب امتداد تتعامل مع الأنماط الشائعة للحقول العلائقية:

dart
final partner = partners.first;

// Primitive access
partner.id;    // int
partner.name;  // String
partner['email'];  // dynamic — any field by name

// Many2one fields come back as [id, name] — helpers extract each part
partner.many2oneId('country_id');    // int? — 184
partner.many2oneName('country_id');  // String? — "Saudi Arabia"

// Many2many / One2many fields come back as List<int>
partner.x2manyIds('tag_ids');  // [3, 7, 12]

تُغنيك هذه المساعدات عن الشيفرة التكرارية لتحويل الأنواع وفحص القيم الفارغة في صفوف [dynamic, dynamic] التي يعيدها Odoo للحقول العلائقية.


معالجة الأخطاء

هنا تنهار عادةً عملاء Odoo الخام. فـ Odoo يعيد كل الأخطاء استجاباتٍ بالرمز HTTP 200 مع جسم JSON يحتوي على مفتاح error. وبلا غلاف، تجد نفسك تحلّل نصوصًا وتخمّن أنواع الأخطاء.

تربط odoo_rest_ov أنواع أخطاء Odoo بتسلسل هرمي من الاستثناءات محددة الأنواع:

OdooException (base)
├── OdooAccessDeniedException     — invalid credentials or locked account
├── OdooSessionExpiredException   — session cookie has expired
├── OdooAccessErrorException      — permission denied on a record or model
├── OdooValidationException       — field validation failed
├── OdooMissingErrorException     — record not found (deleted or wrong ID)
├── OdooUserErrorException        — business logic rejection
├── OdooNetworkException          — connectivity / timeout
└── OdooProtocolException         — malformed response

ويكشف كل استثناء عن رسالتين:

  • message — خطأ Odoo الخام، مناسب للتسجيل في السجلات
  • userMessage — نص نظيف مقروء للبشر وآمن لعرضه في نافذة حوار أو شريط تنبيه
dart
try {
  await client.write('res.partner', [999999], {'name': 'Ghost'});
} on OdooMissingErrorException catch (e) {
  // Show this in your UI — no internal details leak
  showDialog(context: context, builder: (_) => AlertDialog(
    title: const Text('Error'),
    content: Text(e.userMessage), // "Record does not exist or has been deleted."
  ));
  // Log the raw error separately
  logger.error(e.message);
} on OdooAccessErrorException catch (e) {
  showSnackBar(e.userMessage); // "You do not have permission to perform this action."
} on OdooNetworkException catch (e) {
  showSnackBar(e.userMessage); // "Network error. Please check your connection."
} on OdooException catch (e) {
  // Catch-all for any other Odoo error
  showSnackBar(e.userMessage);
}

الفصل بين message وuserMessage مهم في بيئة الإنتاج. فأخطاء Odoo الداخلية كثيرًا ما تتضمن أسماء نماذج وأسماء حقول وأجزاء SQL لا معنى لها لدى المستخدم النهائي، وتمثّل تسريبًا محتملًا للمعلومات. ومع الاستثناءات محددة الأنواع، لن تكشف تلك النصوص في واجهتك عن طريق الخطأ أبدًا.


استدعاء المتحكمات

ليس كل شيء في Odoo يمر عبر ORM. فالوحدات المخصّصة تُتيح متحكمات HTTP — نقاط نهاية مغلّفة بـ JSON-RPC ونقاط نهاية REST عادية على حد سواء. ويتعامل الأسلوب callController مع النوعين:

dart
// JSON-RPC endpoint (Odoo's standard format)
final response = await client.callController(
  '/web/webclient/version_info',
  params: {},
);
print(response.data);       // decoded result
print(response.isSuccess);  // true
print(response.statusCode); // 200

// REST-style GET endpoint (no JSON-RPC wrapper)
final rest = await client.callController(
  '/api/v1/products',
  method: 'GET',
  params: {'limit': '10', 'offset': '0'},
  isJsonRpc: false,
);

// Access result as list or map
final products = rest.dataAsList;

وتُمرَّر كوكيز المصادقة تلقائيًا مع كل طلب — دون حاجة إلى إدارة يدوية للترويسات.


التقارير والحقول الثنائية

تنزيل تقرير PDF

dart
final pdfBytes = await client.getReport(
  'account.report_invoice',
  [invoiceId],
);

// Save to file, open with a PDF viewer, or upload somewhere
await File('/tmp/invoice_$invoiceId.pdf').writeAsBytes(pdfBytes);

رفع مرفق

dart
final imageBytes = await File('/tmp/logo.png').readAsBytes();

await client.uploadBinary(
  'res.partner',
  partnerId,
  'image_1920',
  imageBytes,
  filename: 'logo.png',
);

تنزيل مرفق

dart
final bytes = await client.downloadBinary(
  'res.partner',
  partnerId,
  'image_1920',
);

إعداد خاص بـ Flutter: الجلسات الدائمة

على الأجهزة المحمولة، تريد لجلسات الدخول أن تصمد بعد إعادة تشغيل التطبيق. وتتضمن الحزمة مساعدًا اسمه OdooFlutter يهيّئ PersistCookieJar مدعومًا بمجلد مستندات التطبيق:

dart
import 'package:odoo_rest_ov/odoo_rest_ov_flutter.dart';
import 'package:path_provider/path_provider.dart';

Future<OdooClient> createOdooClient() async {
  final dir = await getApplicationDocumentsDirectory();
  return OdooFlutter.createClient(
    baseUrl: 'https://mycompany.odoo.com',
    database: 'mydb',
    documentsPath: dir.path,
  );
}

والعميل المُعاد هو OdooClient قياسي — والفارق الوحيد أن حاوية الكوكيز تُحفظ على القرص. فإن كان لدى المستخدم جلسة صالحة من تشغيل سابق للتطبيق، سيعيد استدعاء checkSession() التالي القيمة true دون حاجة إلى إعادة المصادقة.

أما على الويب فيدير المتصفح الكوكيز تلقائيًا مع withCredentials: true — فلا حاجة إلى إعداد إضافي ولا إلى OdooFlutter.


تحديد نوع المستخدم

بعد المصادقة، يمكنك تفريع منطق الواجهة بحسب هوية المستخدم المسجَّل دخوله:

dart
final session = await client.authenticate('admin', 'admin');

// Enum-based check
switch (session.userType) {
  case OdooUserType.internal:
    showFullDashboard();
    break;
  case OdooUserType.portal:
    showPortalView();
    break;
  case OdooUserType.public:
    showGuestView();
    break;
}

// Boolean shortcuts
session.isInternalUser;  // true for employees
session.isPortalUser;    // true for portal customers
session.isPublic;        // true for anonymous users
session.isAdmin;         // true if user has admin access
session.isSystem;        // true if user has system-level access

مثال من الواقع: شاشة قائمة الشركاء

نجمع كل ما سبق — شاشة Flutter تجلب وتعرض قائمة مقسّمة إلى صفحات من الشركاء من نوع شركة:

dart
class PartnerListScreen extends StatefulWidget {
  const PartnerListScreen({super.key});

  @override
  State<PartnerListScreen> createState() => _PartnerListScreenState();
}

class _PartnerListScreenState extends State<PartnerListScreen> {
  final _client = getIt<OdooClient>(); // from your DI container
  List<OdooRecord> _partners = [];
  bool _loading = true;
  String? _error;

  static const _fields = ['name', 'email', 'phone', 'country_id'];

  @override
  void initState() {
    super.initState();
    _fetchPartners();
  }

  Future<void> _fetchPartners() async {
    setState(() { _loading = true; _error = null; });
    try {
      final domain = OdooDomain()
          .where('is_company').equals(true)
          .where('active').equals(true)
          .build();

      final records = await _client.searchRead(
        'res.partner',
        domain,
        fields: _fields,
        limit: 50,
        order: 'name asc',
      );

      setState(() { _partners = records; _loading = false; });
    } on OdooSessionExpiredException {
      setState(() { _error = 'Session expired. Please log in again.'; _loading = false; });
    } on OdooException catch (e) {
      setState(() { _error = e.userMessage; _loading = false; });
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_loading) return const Center(child: CircularProgressIndicator());
    if (_error != null) return Center(child: Text(_error!));

    return ListView.builder(
      itemCount: _partners.length,
      itemBuilder: (context, index) {
        final p = _partners[index];
        return ListTile(
          title: Text(p.name),
          subtitle: Text(p['email'] ?? ''),
          trailing: Text(p.many2oneName('country_id') ?? ''),
        );
      },
    );
  }
}

تُقرأ الشيفرة بوضوح لأن التعقيد دُفع إلى داخل الحزمة: بناء النطاق، ونقل HTTP، وإدارة الكوكيز، وربط الأخطاء، والوصول إلى الحقول العلائقية — كلها تُعالَج بشفافية.


دعم المنصات

المنصةالحالةملاحظات
Androidمدعومةاستخدم OdooFlutter للجلسات الدائمة
iOSمدعومةاستخدم OdooFlutter للجلسات الدائمة
Webمدعومةالمتصفح يدير الكوكيز تلقائيًا
macOSمدعومةOdooClient القياسي
LinuxمدعومةOdooClient القياسي
WindowsمدعومةOdooClient القياسي

الخلاصة

تتولّى odoo_rest_ov الأجزاء التي يصعب فعلًا ضبطها بنفسك في التكامل مع Odoo: غلاف أخطاء JSON-RPC، وتحويل أنواع الحقول العلائقية، ودورة حياة كوكي الجلسة، واختلاف المنطقة الزمنية. والتسلسل الهرمي للاستثناءات محددة الأنواع يعني أن شيفرة واجهتك لن تضطر أبدًا إلى تحليل نصوص أخطاء Odoo الخام، وبانِي النطاق يقلّص مصدرًا شائعًا لأخطاء وقت التشغيل.

الحزمة مرخّصة برخصة MIT، ومنشورة على pub.dev، ومفتوحة المصدر على GitHub. وإن واجهتك مشكلة أو أردت المساهمة، فتوجّه إلى المستودع.

الروابط:

استكشف الحلول ذات الصلة: واجهة REST لأودو، خدمات تطوير تطبيقات الجوال والويب.