مقدمة
يبدو بناء تطبيق 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:
dependencies:
odoo_rest_ov: ^0.1.0ثم نفّذ:
dart pub getتعتمد الحزمة على dio لطلبات HTTP، وcookie_jar لتخزين الكوكيز، وdio_cookie_manager لربطهما معًا. وتُجلب الثلاث تلقائيًا — دون حاجة إلى أي تهيئة إضافية في الإعداد الأساسي.
الاتصال بخادم Odoo
إنشاء العميل يحتاج سطرًا واحدًا:
import 'package:odoo_rest_ov/odoo_rest_ov.dart';
final client = OdooClient(OdooClientOptions(
baseUrl: 'https://mycompany.odoo.com',
database: 'mydb',
));يقبل OdooClientOptions معاملات إضافية لضبط السلوك بدقة — منها دالة استدعاء عند تغيّر الجلسة، وقيم مهلة مخصّصة، وحقن مفتاح API. والعميل نفسه كائن طويل العمر؛ أنشئه مرة واحدة وأعد استخدامه في أنحاء تطبيقك.
المصادقة وإدارة الجلسات
اسم المستخدم / كلمة المرور
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 التي يكون فيها تخزين كلمة المرور غير مقبول:
client.setApiKey('your-odoo-api-key-here');
// Use ORM methods directly — no authenticate() call neededالإنصات لتغيّرات الجلسة
في تطبيق Flutter حقيقي، ينبغي أن يؤدي انتهاء الجلسة إلى تحويل المستخدم إلى شاشة تسجيل الدخول. اربط ذلك عبر تدفق الجلسة:
client.sessionStream.listen((session) {
if (session == null) {
// Session expired or user logged out
Navigator.pushReplacementNamed(context, '/login');
}
});أو مرّر دالة الاستدعاء onSessionChanged عند الإنشاء:
OdooClientOptions(
baseUrl: 'https://mycompany.odoo.com',
database: 'mydb',
onSessionChanged: (session) {
if (session == null) _navigateToLogin();
},
);فحص الجلسة وتحديثها
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();تسجيل الخروج
await client.logout();
client.close(); // release resources when doneأساليب ORM
كل عملية شائعة في ORM الخاص بـ Odoo متاحة كأسلوب محدد الأنواع. وتوقيعات الأساليب تحاكي ORM في Python عن قرب، فيشعر مطورو Odoo بالألفة فورًا.
searchRead
final partners = await client.searchRead(
'res.partner',
[['is_company', '=', true]],
fields: ['name', 'email', 'phone', 'country_id'],
limit: 20,
offset: 0,
order: 'name asc',
);search
يعيد قائمة بمعرّفات السجلات المطابقة دون جلب بيانات الحقول:
final ids = await client.search(
'res.partner',
[['customer_rank', '>', 0]],
limit: 100,
);read
جلب حقول محددة لقائمة معروفة من المعرّفات:
final records = await client.read(
'res.partner',
[1, 2, 3],
fields: ['name', 'email'],
);searchCount
final total = await client.searchCount(
'res.partner',
[['active', '=', true]],
);
print('Total active partners: $total');create
final newId = await client.create('res.partner', {
'name': 'Acme Corp',
'email': 'info@acme.com',
'is_company': true,
});
print('Created partner with ID: $newId');createMulti
إنشاء عدة سجلات في رحلة واحدة إلى الخادم:
final newIds = await client.createMulti('res.partner', [
{'name': 'Partner A', 'email': 'a@example.com'},
{'name': 'Partner B', 'email': 'b@example.com'},
]);write
await client.write('res.partner', [newId], {
'phone': '+966 55 000 0000',
'website': 'https://acme.com',
});unlink
await client.unlink('res.partner', [newId]);fieldsGet
افحص تعريفات حقول النموذج أثناء التشغيل — مفيد لبناء نماذج ديناميكية:
final fields = await client.fieldsGet(
'res.partner',
attributes: ['string', 'type', 'required', 'selection'],
);
// fields is a Map<String, dynamic> keyed by field namenameSearch
final results = await client.nameSearch('res.partner', 'Acme');
// Returns List<[int, String]> — id and display name pairscallMethod
استدعِ أي أسلوب في النموذج لا تغطيه اختصارات ORM:
final result = await client.callMethod(
'account.move',
'action_post',
args: [[invoiceId]],
);بانِي النطاق
نطاقات Odoo الخام هي قوائم من الصفوف (tuples) — مقروءة بما يكفي في Python، لكنها غير مريحة في Dart:
// Raw — works, but fragile
final domain = [
['is_company', '=', true],
['customer_rank', '>', 0],
];ويمنحك البانِي OdooDomain بديلًا سلسًا وآمن الأنواع:
// 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 مزوّدة بأساليب امتداد تتعامل مع الأنماط الشائعة للحقول العلائقية:
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— نص نظيف مقروء للبشر وآمن لعرضه في نافذة حوار أو شريط تنبيه
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 مع النوعين:
// 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
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);رفع مرفق
final imageBytes = await File('/tmp/logo.png').readAsBytes();
await client.uploadBinary(
'res.partner',
partnerId,
'image_1920',
imageBytes,
filename: 'logo.png',
);تنزيل مرفق
final bytes = await client.downloadBinary(
'res.partner',
partnerId,
'image_1920',
);إعداد خاص بـ Flutter: الجلسات الدائمة
على الأجهزة المحمولة، تريد لجلسات الدخول أن تصمد بعد إعادة تشغيل التطبيق. وتتضمن الحزمة مساعدًا اسمه OdooFlutter يهيّئ PersistCookieJar مدعومًا بمجلد مستندات التطبيق:
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.
تحديد نوع المستخدم
بعد المصادقة، يمكنك تفريع منطق الواجهة بحسب هوية المستخدم المسجَّل دخوله:
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 تجلب وتعرض قائمة مقسّمة إلى صفحات من الشركاء من نوع شركة:
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 لأودو، خدمات تطوير تطبيقات الجوال والويب.
