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

Odoo Community: نظام ERP لشركتك على سيرفرك

كيف تثبت Odoo Community على سيرفرك، وتمر بدورة بيع كاملة حتى الدفع، وترسل فاتورة إلى بيئة الاختبار في منصة فاتورة.

Odoo Community: نظام ERP لشركتك على سيرفرك

في مقال ما هو ERP وCRM؟ تتبعنا دورة البيع من العميل المحتمل إلى استلام المبلغ على الورق، وذكرنا أن Odoo من أشهر الأنظمة المفتوحة التي تغطيها، وأن إصداره المفتوح Community يختلف عن إصداره المدفوع Enterprise. وفي هذا الدليل سوف ننتقل من الورق إلى نظام حقيقي يعمل على Docker، ونمر فيه بالدورة نفسها بأرقام حقيقية، ثم نختبر سؤالاً يهم كل شركة في المملكة: هل يستطيع Odoo Community أن يتصل بمنصة فاتورة في هيئة الزكاة والضريبة والجمارك؟ وهذا هو المقال الثاني في مرحلة أنظمة الأعمال، وسوف نناقش فيه ما يلي:

  • مختبر Odoo 20 Community مع PostgreSQL، وتهيئة قاعدة البيانات بالعربية والتوطين السعودي من سطر الأوامر.
  • خطأ حقيقي واجهناه في Odoo 20 يجعل الموقع لا يفتح من خارج ال Container، وسببه.
  • دورة بيع كاملة: أمر بيع، ثم تسليم ينقص المخزون، ثم فاتورة ضريبية برمز QR، ثم دفع.
  • الربط مع بيئة الاختبار Sandbox في منصة فاتورة: التسجيل Onboarding، ثم إرسال فاتورة وقبولها، والأخطاء التي ظهرت في الطريق.
  • ما الذي لا يوجد في Community، والعربية في الواجهة والفاتورة المطبوعة، والنسخ الاحتياطي والترقية وحجم السيرفر.

التجربة العملية: Odoo Community على Docker

ينشر فريق Odoo Docker Image رسمياً على Docker Hub بوسوم مؤرخة، واخترنا الوسم odoo:20.0-20260926 لأنه يشير إلى بناء محدد لا يتغير، بخلاف الوسم 20.0 الذي يتحرك مع كل بناء جديد. وOdoo 20 يطلب PostgreSQL 16 على الأقل كما في الثابت MIN_PG_VERSION في ملف الإصدار، فاستخدمنا postgres:17.11. والصورة التالية تبين المختبر:

مخطط مختبر Odoo داخل Docker Compose باسم lab-erp: المتصفح على 127.0.0.1:18469 يصل إلى lab-erp-odoo من الصورة odoo:20.0-20260926 على المنفذ 8069 مع http_interface 0.0.0.0، ومنه سهم إلى lab-erp-db وفيه PostgreSQL 17.11 وقاعدة labco، وتحتهما Volume باسم lab-erp-data للملفات والجلسات وVolume لملفات PostgreSQL، وملف الإعداد مركب للقراءة فقط، وسهم متقطع من Odoo إلى سحابة على اليمين مكتوب عليها ZATCA sandbox وgw-fatoora.zatca.gov.sa للتسجيل وإرسال الفواتير
Odoo وقاعدة البيانات وVolume لكل منهما، والاتصال الوحيد إلى الخارج هو بيئة الاختبار في منصة فاتورة

ملفات المختبر

أنشئ مجلداً للمختبر فيه الملف compose.yaml التالي:

name: lab-erp
services:
  db:
    image: postgres:17.11
    container_name: lab-erp-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: postgres
      POSTGRES_USER: odoo
      POSTGRES_PASSWORD: change-me-db
    volumes:
      - db:/var/lib/postgresql/data

  odoo:
    image: odoo:20.0-20260926
    container_name: lab-erp-odoo
    restart: unless-stopped
    depends_on: [db]
    environment:
      HOST: db
      USER: odoo
      PASSWORD: change-me-db
    ports:
      - "127.0.0.1:18469:8069"
    volumes:
      - data:/var/lib/odoo
      - ./config:/etc/odoo:ro

volumes:
  db:
    name: lab-erp-db
  data:
    name: lab-erp-data

ثم ملف الإعداد config/odoo.conf:

[options]
data_dir = /var/lib/odoo
db_host = db
db_port = 5432
db_user = odoo
db_password = change-me-db
admin_passwd = change-me-master
list_db = False
dbfilter = ^labco$
proxy_mode = True
http_interface = 0.0.0.0

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

  • كلمة المرور الرئيسية admin_passwd: هي التي تحمي شاشات إدارة قواعد البيانات من إنشاء وحذف ونسخ، فلا تتركها فارغة أو افتراضية.
  • list_db وdbfilter: دليل النشر الرسمي ينصح بأن تضبط dbfilter بحيث يطابق قاعدة واحدة، ثم تجعل list_db = False حتى لا تظهر قائمة قواعد البيانات ولا شاشات إدارتها للزوار.
  • proxy_mode: لأن Odoo في الإنتاج يكون خلف Reverse Proxy، وهذا الخيار يجعله يثق بترويسات العنوان الحقيقي للزائر، كما شرحنا في تطبيقك خلف Reverse Proxy، ولا تفعله إذا لم يكن أمامه Proxy.
  • http_interface: لهذا السطر قصة في القسم التالي.
  • مستخدم قاعدة البيانات: في المختبر أنشأته صورة PostgreSQL كمستخدم بصلاحيات كاملة Superuser، ودليل النشر يطلب في الإنتاج ألا يكون مستخدم Odoo في PostgreSQL من نوع Superuser، فأنشئ مستخدماً عادياً له قاعدة Odoo فقط.

تهيئة قاعدة البيانات بالعربية

بدلاً من معالج إنشاء قاعدة البيانات في المتصفح، يأتي Odoo 20 بأمر db init الذي ينشئ قاعدة البيانات بلغة ودولة محددتين، ثم أمر module install لتثبيت الوحدات:

docker compose up -d db
docker compose run --rm --name lab-erp-init --entrypoint odoo odoo db -c /etc/odoo/odoo.conf \
  --db_host db -r odoo -w change-me-db \
  init labco --language ar_001 --country SA --username admin --password 'Lab-Admin-2026!'
docker compose run --rm --name lab-erp-init --entrypoint odoo odoo module install -c /etc/odoo/odoo.conf \
  -d labco sale_management stock account l10n_sa l10n_sa_edi
docker compose up -d odoo

ومن مخرج الأمر الأول:

INFO ? odoo: Odoo version 20.0-20260926
INFO ? odoo: Using configuration file at /etc/odoo/odoo.conf
INFO labco odoo.modules.loading: 1 modules loaded in 17.68s, 8673 queries (+8673 extra)
INFO labco odoo.modules.loading: 16 modules loaded in 9.21s, 5411 queries (+5411 extra)
INFO labco odoo.modules.loading: Modules loaded.

ومن آخر مخرج الأمر الثاني، بعد دقيقتين تقريباً:

INFO labco odoo.modules.loading: 74 modules loaded in 0.06s, 0 queries (+0 extra)
INFO labco odoo.modules.loading: Modules loaded.
INFO labco odoo.registry: Registry loaded in 0.380s

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

  • استخدمنا --entrypoint odoo لأن سكربت البداية في الصورة يضيف خيارات الاتصال بقاعدة البيانات في آخر الأمر، فيرفضها الأمر الفرعي db بخطأ unrecognized arguments، وهذا ما حدث لنا في أول محاولة.
  • اللغة ar_001 هي العربية العامة في Odoo، والدولة SA تجعل التوطين السعودي l10n_sa يثبت مع المحاسبة بشجرة الحسابات والضرائب السعودية.
  • الوحدات التي طلبناها هي المبيعات والمخزون والفواتير والتوطين السعودي ووحدة الفوترة الإلكترونية l10n_sa_edi، ومعها اعتمادياتها أصبحت 74 وحدة مثبتة.

نكسر الإعداد: لماذا لا يفتح الموقع؟

في أول تشغيل لم يكن السطر http_interface = 0.0.0.0 في الملف، وكان ال Container يعمل، ولكن المتصفح لا يفتح شيئاً:

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18469/web/login
docker logs lab-erp-odoo 2>&1 | grep 'HTTP service'
000
2026-10-08 20:40:24,461 1 INFO ? odoo.service.server: HTTP service running on 127.0.0.1:8069

الرمز 000 يعني أن curl لم يحصل على أي رد، والسطر الثاني يشرح السبب: Odoo يستمع على 127.0.0.1 داخل ال Container، أي أنه لا يقبل إلا الاتصالات من داخل ال Container نفسه، وتوجيه المنفذ من Docker يأتي من خارجه. وفي كود Odoo 20 القيمة الافتراضية للخيار http_interface هي 127.0.0.1 في الملف odoo/tools/config.py، فإذا لم يكن الخيار في ملف الإعداد الذي تستخدمه داخل Container فأضف السطر، وبعد إضافته وإعادة التشغيل أصبح الرد 200.

دورة بيع كاملة

أجرينا الدورة بسكربت Python يعمل داخل odoo shell، حتى تكون الخطوات والأرقام قابلة للتكرار، وكل خطوة فيه تقابل زراً في الواجهة: إنشاء الصنف، وجرد افتتاحي، وأمر بيع، وتأكيده، وتسليمه، وإصدار الفاتورة، وتسجيل الدفع. والبيانات كلها تجريبية، ورقم ضريبة الشركة هو الرقم المستخدم في أمثلة الهيئة:

# Runs inside: odoo shell -c /etc/odoo/odoo.conf -d labco < flow.py
# A full sale -> delivery -> invoice -> payment cycle on test data.
company = env.company
company.write({
    'name': 'شركة المختبر للتجارة',
    'vat': '399999999900003',          # test VAT number used in ZATCA's samples
    'street': 'طريق الملك فهد',
    'city': 'الرياض',
    'zip': '12271',
})

Product = env['product.product']
printer = Product.create({
    'name': 'طابعة ليزر',
    'type': 'consu',
    'is_storable': True,
    'list_price': 1200.0,
    'standard_price': 800.0,
})
stock = env.ref('stock.warehouse0').lot_stock_id
env['stock.quant'].with_context(inventory_mode=True).create({
    'product_id': printer.id, 'location_id': stock.id, 'inventory_quantity': 10,
}).action_apply_inventory()
print('on hand before:', printer.qty_available)

customer = env['res.partner'].create({
    'name': 'مؤسسة العميل التجريبي',
    'is_company': True,
    'vat': '300000000000003',
    'country_id': env.ref('base.sa').id,
    'city': 'جدة',
})

order = env['sale.order'].create({
    'partner_id': customer.id,
    'order_line': [(0, 0, {'product_id': printer.id, 'product_uom_qty': 3})],
})
order.action_confirm()
print('order:', order.name, order.state, 'untaxed', order.amount_untaxed, 'tax', order.amount_tax, 'total', order.amount_total)

picking = order.picking_ids
for move in picking.move_ids:
    move.quantity = move.product_uom_qty
picking.button_validate()
print('delivery:', picking.name, picking.state, '| on hand after:', printer.qty_available)

invoice = order._create_invoices()
invoice.action_post()
print('invoice:', invoice.name, invoice.state, invoice.l10n_sa_invoice_type, 'total', invoice.amount_total, 'payment', invoice.payment_state)
print('qr:', invoice.l10n_sa_qr_code_str)

env['account.payment.register'].with_context(
    active_model='account.move', active_ids=invoice.ids,
).create({}).action_create_payments()
print('after payment:', invoice.payment_state, 'residual', invoice.amount_residual)
env.cr.commit()
docker exec -i lab-erp-odoo odoo shell -c /etc/odoo/odoo.conf -d labco --log-level=warn < flow.py

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

on hand before: 10.0
order: S00001 sale untaxed 3600.0 tax 540.0 total 4140.0
delivery: WH/OUT/00001 done | on hand after: 7.0
invoice: الفاتورة/2026/00001 posted tax total 4140.0 payment not_paid
qr: ASbYtNix2YPYqSDYp9mE2YXYrtiq2KjYsSDZhNmE2KrYrNin2LHYqQIPMzk5OTk5OTk5OTAwMDAzAxMyMDI2LTEwLTA4VDIzOjQwOjU1BAc0MTQwLjAwBQY1NDAuMDA=
after payment: paid residual 0.0

والصورة التالية تجمع المستندات التي أنشأها Odoo في هذه البيعة:

أربعة صناديق مرقمة: 1 S00001 أمر بيع لثلاث طابعات، 2 WH/OUT/00001 تسليم نقل المخزون من 10 إلى 7، 3 فاتورة ضريبية 2026/00001 بمبلغ 4,140.00 ريال، 4 دفع مسجل والحالة مدفوعة، وتحتها صندوق المبالغ 3 في 1,200.00 تساوي 3,600.00 وضريبة 15% 540.00 والإجمالي 4,140.00، وصندوق رمز QR للمرحلة الأولى فيه البائع والرقم الضريبي والوقت والإجمالي والضريبة بترميز TLV وBase64
بيعة واحدة في المختبر: أمر بيع، وتسليم، وفاتورة ضريبية برمز QR، ودفع

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

  • الضريبة 15% أضيفت تلقائياً من التوطين السعودي، فالمبلغ قبل الضريبة 3600 والضريبة 540 والإجمالي 4140، ولم نكتب نسبة الضريبة في أي مكان.
  • المخزون نقص من 10 إلى 7 عند تأكيد التسليم، وليس عند تأكيد أمر البيع، وهذا الفرق بين حجز الكمية وخروجها فعلاً من المستودع.
  • نوع الفاتورة tax لأن العميل منشأة لها رقم ضريبي، ولو كان فرداً لكانت فاتورة مبسطة simplified.
  • رقم الفاتورة بدأ بكلمة «الفاتورة» لأن رمز دفتر اليومية Journal ترجم إلى العربية عند إنشاء القاعدة بالعربية، وسوف نرى في قسم فاتورة أن هذا له أثر.

والصورة التالية لأمر البيع كما يظهر في الواجهة العربية بعد التسليم والفوترة:

أمر البيع S00001 في واجهة Odoo العربية: الحالة أمر البيع، وزر التوصيل 1 وفواتير العملاء 1، والعميل مؤسسة العميل التجريبي في جدة مع الرقم الضريبي، ووسم تم التوصيل بالكامل، وسطر طابعة ليزر بالكمية 3 وتم التوصيل 3 ومفوتر 3 وسعر الوحدة 1,200.00 وضريبة 15%، والمبلغ دون الضريبة 3,600.00 والضريبة 540.00 والإجمالي 4,140.00 ريال، وتحت السطر روابط Add Line وAdd Section وAdd Note بالإنجليزية
أمر البيع بعد التسليم والفوترة: الكمية المسلمة والمفوترة ثلاث، والضريبة محسوبة تلقائياً

وسجل حركات المخزون يبين من أين جاء كل تغيير في الكمية، فالجرد الافتتاحي أضاف عشراً، وكل تسليم أخرج كميته إلى العملاء، ومنها تسليمان لفاتورتين أخريين سوف نأتي إليهما في القسم التالي:

سجل الحركات في مخزون Odoo بالعربية: أربعة أسطر لطابعة ليزر، تحديث كمية المنتج بعشر وحدات من Inventory adjustment إلى WH/المخزون، ثم ثلاث حركات تسليم WH/OUT/00001 بثلاث وحدات وWH/OUT/00002 ووحدتين وWH/OUT/00003 بوحدتين من WH/المخزون إلى Customers، وكلها بحالة منتهي
سجل الحركات: عشر وحدات دخلت بالجرد الافتتاحي، وسبع خرجت في ثلاث عمليات تسليم

رمز QR للمرحلة الأولى

النص الطويل في سطر qr هو محتوى رمز QR، وهو مرمز بطريقة TLV، أي نوع ثم طول ثم قيمة، ثم بترميز Base64، ويمكنك فكه بسطرين من Python:

import base64
b = base64.b64decode('ASbYtNix2YPYqSDYp9mE2YXYrtiq2KjYsSDZhNmE2KrYrNin2LHYqQIPMzk5OTk5OTk5OTAwMDAzAxMyMDI2LTEwLTA4VDIzOjQwOjU1BAc0MTQwLjAwBQY1NDAuMDA=')
i = 0
while i < len(b):
    t, l = b[i], b[i + 1]; print(t, l, b[i + 2:i + 2 + l].decode()); i += 2 + l
1 38 شركة المختبر للتجارة
2 15 399999999900003
3 19 2026-10-08T23:40:55
4 7 4140.00
5 6 540.00

الحقول الخمسة هي اسم البائع، ورقمه الضريبي، ووقت الفاتورة بتوقيت الرياض، والإجمالي مع الضريبة، ومبلغ الضريبة، ولاحظ أن طول اسم الشركة 38 بايت مع أنه 20 حرفاً، لأن الحرف العربي يأخذ بايتين في UTF-8، وسوف نحتاج هذه المعلومة بعد قليل. والصورة التالية للفاتورة كما يعرضها تقرير الفاتورة في المتصفح، ورمز QR في أسفلها:

فاتورة ضريبية من Odoo بالعربية: عنوان شركة المختبر للتجارة في الرياض مع الرقم الضريبي 399999999900003 والسجل التجاري، وعنوان العميل في جدة برقمه الضريبي، وعنوان فاتورة ضريبية الفاتورة/2026/00001، وتواريخ الفاتورة والاستحقاق والتوريد 2026-10-08، والمصدر والمرجع S00001، وجدول الأصناف بعمود وصف السلعة أو الخدمة فارغاً والكمية 3.00 وسعر الوحدة 1,200.00 والضريبة 15% والمبالغ، والإجمالي 4,140.00 ريال، وفي الأسفل رمز QR، وفي أعلى اليمين مكان شعار مكتوب فيه Your logo
الفاتورة الضريبية برمز QR، ولاحظ أن عمود وصف السلعة فارغ في هذه النسخة

ولاحظ في الصورة شيئاً لم نتوقعه: عمود «وصف السلعة أو الخدمة» فارغ، مع أن الفاتورة لصنف اسمه «طابعة ليزر». والسبب أن سطر الفاتورة الذي أنشئ من أمر البيع حقل الوصف name فيه فارغ، وقالب الفاتورة الخليجية في الوحدة l10n_gcc_invoice يضيف اسم المنتج بالعربية والإنجليزية فقط عندما تكون الفاتورة ثنائية اللغة، وإلا فهو يعرض حقل الوصف وحده. وأما ملف XML الذي أرسلناه إلى منصة فاتورة في القسم التالي ففيه اسم الصنف كاملاً في الحقل cbc:Name. والقاعدة هنا: اطبع الفاتورة واقرأها كما سيقرؤها العميل قبل أن تبدأ الإصدار الحقيقي، فما يرضي المنصة قد لا يكون ما يتوقعه العميل على الورق.

الربط مع منصة فاتورة: المرحلة الثانية

وحدة l10n_sa_edi التي تربط Odoo بمنصة فاتورة موجودة في مستودع Community نفسه بترخيص LGPL-3، وصفحة التوطين السعودي تصفها بأنها تفعل الربط مع واجهات المرحلة الثانية. وأما وحدة التقارير المحاسبية السعودية l10n_sa_reports التي تذكرها الصفحة نفسها فغير موجودة في مستودع Community، فهي من وحدات Enterprise. وفي الوحدة ثلاثة أوضاع للربط: بيئة الاختبار Sandbox، والمحاكاة Simulation، والإنتاج Production، والوضع الافتراضي هو بيئة الاختبار، وهي بيئة عامة في بوابة المطورين لدى الهيئة، ولا تحتاج حساباً، ورمز التحقق OTP فيها ثابت ويملؤه Odoo تلقائياً، فاستخدمناها.

تسجيل دفتر المبيعات Onboarding

التسجيل يعني أن Odoo ينشئ مفتاحاً خاصاً وطلب شهادة CSR لدفتر المبيعات، ويرسله مع رمز التحقق إلى المنصة، فيحصل على شهادة امتثال Compliance CSID، ثم يرسل ست فواتير اختبارية للتحقق، ثم يحصل على شهادة الإنتاج Production CSID التي يوقع بها الفواتير. والسكربت التالي يفعل ذلك من سطر الأوامر، وهو يقابل زر التسجيل في إعدادات دفتر اليومية:

# Runs inside: odoo shell -c /etc/odoo/odoo.conf -d labco < onboard.py
# Onboards the sales journal with ZATCA's public sandbox (Developer Portal), using the sandbox OTP that Odoo pre-fills.
company = env.company
company.write({
    'state_id': env['res.country.state'].search([('country_id.code', '=', 'SA'), ('code', '=', 'SA-01')], limit=1).id,
    'l10n_sa_edi_building_number': '1234',
    'l10n_sa_edi_plot_identification': '5678',
    'l10n_sa_edi_additional_identification_scheme': 'CRN',
    'l10n_sa_edi_additional_identification_number': '1010010000',
})
print('api mode:', company.l10n_sa_api_mode, '| state:', company.state_id.name)
journal = env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', company.id)], limit=1)
journal.write({'code': 'INV', 'name': 'مبيعات'})   # the CSR Common Name (code-name-company) must fit in 64 bytes
journal._l10n_sa_api_onboard_journal('123345')
print('journal:', journal.name, journal.code)
print('errors:', journal.l10n_sa_csr_errors)
print('compliance checks passed:', journal.l10n_sa_compliance_checks_passed)
print('production CSID present:', bool(journal.sudo().l10n_sa_production_csid_json))
env.cr.commit()

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

api mode: sandbox | state: False
errors: <p>يُرجى تعيين ما يلي في شركة المختبر للتجارة: Fed. State</p>

وبعد إضافة المنطقة ظهر خطأ أكثر طرافة، وهو أثر مباشر للغة العربية:

api mode: sandbox | state: Riyadh
errors: <p>Please make sure the following fields are shorter than 64 bytes (note that Arabic or special characters take more space):  <br>- Common Name</p>

والسبب أن Odoo يبني الاسم العام Common Name في طلب الشهادة من رمز دفتر اليومية واسمه واسم الشركة، كما في الوحدة l10n_sa_edi/models/certificate.py، وكانت الثلاثة بالعربية: «الفاتورة» و«المبيعات» و«شركة المختبر للتجارة»، والحرف العربي بايتان كما رأينا في رمز QR، فتجاوز الاسم 64 بايت. والحل في السكربت أعلاه أن يكون رمز الدفتر بالإنجليزية INV واسمه قصيراً. وبعدها جاء المخرج كما يلي:

api mode: sandbox | state: Riyadh
journal: مبيعات INV
errors: False
compliance checks passed: True
production CSID present: True

إرسال فاتورة إلى المنصة

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

# Runs inside: odoo shell -c /etc/odoo/odoo.conf -d labco < zatca.py
# A second invoice, this time submitted to ZATCA's sandbox for clearance (Phase 2).
import re
customer = env['res.partner'].search([('name', '=', 'مؤسسة العميل التجريبي')], limit=1)
customer.write({
    'street': 'شارع التحلية',
    'street2': 'حي الروضة',          # the district, required by ZATCA rule BR-KSA-63
    'zip': '23411',
    'state_id': env['res.country.state'].search([('code', '=', 'SA-02')], limit=1).id,
    'l10n_sa_edi_building_number': '4321',
    'l10n_sa_edi_plot_identification': '8765',
})
env.company.street2 = 'حي العليا'   # the seller district, rule BR-KSA-09
printer = env['product.product'].search([('name', '=', 'طابعة ليزر')], limit=1)
order = env['sale.order'].create({
    'partner_id': customer.id,
    'order_line': [(0, 0, {'product_id': printer.id, 'product_uom_qty': 2})],
})
order.action_confirm()
picking = order.picking_ids
for move in picking.move_ids:
    move.quantity = move.product_uom_qty
picking.button_validate()
invoice = order._create_invoices()
invoice.action_post()
doc = invoice.l10n_sa_edi_document_id
print('invoice:', invoice.name, invoice.l10n_sa_invoice_type, invoice.amount_total, '| document state:', doc.state)
doc._l10n_sa_post_zatca_edi()
print('after submission:', doc.state, '| chain index:', doc.l10n_sa_chain_index)
print('message:', re.sub(r'<[^>]+>', ' ', doc.message or '').strip()[:600])
print('xml attachment:', doc.attachment_id.name, doc.attachment_id.file_size, 'bytes')
xml = doc.attachment_id.raw.decode()
for tag in ('cbc:ProfileID', 'cbc:InvoiceTypeCode', 'cbc:UUID'):
    m = re.search(r'<%s[^>]*>([^<]+)</%s>' % (tag, tag), xml)
    print(tag, '=', m and m.group(1))
print('has signature:', '<ds:SignatureValue>' in xml, '| has QR:', 'QR' in xml)
env.cr.commit()

في المحاولة الأولى لم يكن في السكربت السطران اللذان يضيفان الحي street2 للبائع والمشتري، فقبلت المنصة الفاتورة مع تحذيرات:

invoice: الفاتورة/2026/00002 tax 2760.0 | document state: to_send
after submission: warning | chain index: 1
message: Warning: Invoice accepted by ZATCA with warning(s)
يُرجى التحقق من التفاصيل أدناه:
 [202]   BR-KSA-09  : Seller address must contain street name (BT-35), building number (KSA-17), postal code (BT-38), city (BT-37), district (KSA-3), country code (BT-40). ...  BR-KSA-63  : If the buyer country code (BT-55) is “SA”, then these fields are mandatory: street name (BT-50), building number (KSA-18), postal code (BT-53), city (BT-52), District (KSA-4), country code (BT-55). ...

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

invoice: الفاتورة/2026/00003 tax 2760.0 | document state: to_send
after submission: accepted | chain index: 2
message: تم بنجاح: تم قبول الفاتورة من قبل زاتكا
xml attachment: 399999999900003_20261008T234352_-2026-00003.xml 18758 bytes
cbc:ProfileID = reporting:1.0
cbc:InvoiceTypeCode = 388
cbc:UUID = 9e8f9014-6e05-4144-9a3e-9760201d2d8d
has signature: True | has QR: True

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

  • الحالة accepted، أي أن بيئة الاختبار في المنصة قبلت الفاتورة دون تحذيرات، وفي الملف توقيع رقمي ورمز QR.
  • chain index رقم الفاتورة في سلسلة الدفتر، فكل فاتورة تحمل بصمة Hash الفاتورة التي قبلها، فلا يمكن حذف فاتورة من وسط السلسلة دون أن ينكشف ذلك.
  • اسم ملف XML بدأ بالرقم الضريبي ثم الوقت ثم -2026-00003، فكلمة «الفاتورة» في بداية رقم الفاتورة سقطت من اسم الملف، وهذا سبب إضافي لأن يكون رمز دفتر اليومية بالإنجليزية من اليوم الأول، فالفواتير القديمة تحتفظ بالبادئة القديمة كما رأيت في الفاتورة/2026/00003 رغم أننا غيرنا الرمز إلى INV.
  • الرقم 388 هو رمز الفاتورة الضريبية في معيار UBL، والإشعار الدائن له رمز آخر.

والصورة التالية لتبويب زاتكا في الفاتورة نفسها، وفيه ملف XML والحالة:

فاتورة العميل الفاتورة/2026/00003 في واجهة Odoo العربية بحالة مرحل، وأزرار إرسال وطباعة والدفع ومعاينة وإشعار دائن وإشعار مدين وإعادة التعيين كمسودة، وتبويب زاتكا مفتوح فيه سطر بتاريخ 8 أكتوبر 11:43 م وحالة Accepted بالإنجليزية واسم ملف XML يبدأ بالرقم الضريبي، وحقل Invoice Type غير مترجم
تبويب زاتكا في الفاتورة: الملف المرسل والحالة Accepted، ولاحظ زر إعادة التعيين كمسودة لأننا في بيئة الاختبار

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

⚠️
قبول الفاتورة في بيئة الاختبار يثبت أن الوحدة تعمل مع واجهات المنصة، ولا يعني أن إعدادك جاهز للإنتاج، والسبب أن الانتقال إلى الإنتاج يحتاج رمز تحقق حقيقياً من بوابة فاتورة لمنشأتك، وبيانات حقيقية لعنوانها وسجلها، ومراجعة لكل نوع فاتورة تصدره، فجرب بيئة المحاكاة Simulation قبل الإنتاج، واطلب من محاسبك مراجعة الفاتورة المطبوعة.

ما الذي لا يوجد في Odoo Community؟

كل ما أجريناه في هذا الدليل يعمل في Community: المبيعات، والمخزون، والفواتير والضريبة، والدفع، والتوطين السعودي، والربط مع منصة فاتورة. وبحسب صفحة المقارنة بين الإصدارين فإن ما يأتي في Enterprise وحده يشمل:

  • المحاسبة الكاملة Accounting: مثل مطابقة كشوف البنك Bank Reconciliation والتقارير المالية والموازنات، وCommunity فيه تطبيق الفواتير Invoicing، ولذلك لا تجد في مستودعه الوحدتين account_accountant وaccount_reports، ولا وحدة التقارير السعودية l10n_sa_reports.
  • الرواتب Payroll وإدارة الجودة Quality وإدارة دورة حياة المنتج PLM وبعض مزايا التصنيع مثل Shop Floor.
  • أداة التخصيص Studio وتطبيقات الهاتف الرسمية.
  • خدمة الترقية بين الإصدارات والاستضافة على Odoo.sh.

وقد يتساءل البعض: هل يكفي Community لشركة صغيرة؟ والإجابة أن ذلك يعتمد على من يعد قوائمها المالية، فإذا كان لديها محاسب خارجي يعد الميزانية وقائمة الدخل في برنامجه، فإن Community يكفي للبيع والمخزون والفواتير المتوافقة، وإذا كانت تريد التقارير المالية والمطابقة البنكية داخل النظام فستحتاج Enterprise أو وحدات من مجتمع OCA بعد أن تتحقق من توافقها مع الإصدار 20.

العربية في الواجهة

الواجهة العربية تعمل من اليمين إلى اليسار في كل الشاشات التي فتحناها، والقوائم والنماذج والجداول مترجمة في أغلبها، ولكن الصور السابقة تبين بقايا إنجليزية في أماكن يراها المستخدم كل يوم: روابط Add Line وAdd Section وAdd Note في أمر البيع، وVAT Total Amount في الإجماليات، وحقل Invoice Type، وحالة Accepted في تبويب زاتكا، وFed. State في رسالة الخطأ. وفي الوقت نفسه يظهر الوقت بالأرقام العربية الهندية مثل «١١:٤٠ م» في أمر البيع، وبالأرقام العادية في الجداول، وكل هذا لا يمنع العمل، ولكن اذكره في التدريب حتى لا يفاجأ به الموظفون.

النسخ الاحتياطي Backup

بيانات Odoo في مكانين: قاعدة البيانات، ومخزن الملفات Filestore الذي فيه المرفقات وملفات XML المرسلة إلى المنصة، ونسخة قاعدة البيانات وحدها تفقدك الملفات. والأمر db dump في Odoo 20 يأخذ الاثنين معاً في ملف واحد:

docker exec lab-erp-odoo odoo db -c /etc/odoo/odoo.conf dump labco /tmp/labco.zip
docker exec lab-erp-odoo sh -c 'ls -la /tmp/labco.zip'
INFO ? odoo.modules.db: DUMP DB: labco format zip with filestore
-rw-r--r-- 1 odoo odoo 5627555 Oct  8 20:52 /tmp/labco.zip

وفي الملف 162 عنصراً: dump.sql وmanifest.json و160 ملفاً من مخزن الملفات. ثم اختبرنا الاستعادة في قاعدة جديدة باسم آخر، وقرأنا الفواتير منها مباشرة:

docker exec lab-erp-odoo odoo db -c /etc/odoo/odoo.conf load labco_restore /tmp/labco.zip
docker exec lab-erp-db psql -U odoo -d labco_restore -tc "select name, state, payment_state, amount_total from account_move where move_type='out_invoice' order by id"
INFO labco_restore odoo.modules.db: RESTORE DB: labco_restore
 الفاتورة/2026/00001 | posted | paid          |      4140.00
 الفاتورة/2026/00002 | posted | not_paid      |      2760.00
 الفاتورة/2026/00003 | posted | not_paid      |      2760.00

الفواتير الثلاث رجعت بحالاتها، ثم حذفنا القاعدة المستعادة بالأمر odoo db drop labco_restore. وانقل ملف النسخة خارج السيرفر واختبر الاستعادة دورياً كما في النسخ الاحتياطي بقاعدة 3-2-1، ولاحظ أن الملف يحتوي كل بيانات الشركة المالية، فخزنه مشفراً.

التحديث والترقية Upgrade

  • التحديثات داخل الإصدار نفسه: الوسوم المؤرخة مثل 20.0-20260926 تحمل إصلاحات الإصدار 20، فغير الوسم إلى أحدث منه، ثم شغل الأمر odoo module upgrade للوحدات المثبتة بعد نسخة احتياطية.
  • الترقية إلى إصدار رئيسي جديد: خدمة الترقية الرسمية لمشتركي Enterprise، وصفحة الترقية تذكر أن كل إصدار رئيسي مدعوم ثلاث سنوات. وفي Community الأداة المفتوحة هي OpenUpgrade، والفرع الافتراضي في مستودعها وقت كتابة المقال هو 19.0، أي أن نصوص الترقية إلى 20 ما زالت قيد العمل، فخطط لترقيتك بعد أن تكتمل، وجربها على نسخة قبل الإنتاج.

حجم السيرفر Sizing

في المختبر الخامل كان Odoo يستخدم 327MB من الذاكرة وPostgreSQL يستخدم 223MB، وحجم Docker Image نفسه 3.43GB بعد التحميل، وقاعدة البيانات بعد تجربتنا 67MB. وفي الإنتاج يعمل Odoo بعدة عمليات Workers، ودليل النشر يعطي القواعد التالية: عدد العمليات (#CPU * 2) + 1، والعملية الواحدة تخدم نحو ستة مستخدمين في الوقت نفسه، ويفترض أن 20% من الطلبات ثقيلة تستهلك نحو 1GB و80% خفيفة تستهلك نحو 150MB. فلسيرفر بأربعة معالجات يكون العدد 9 عمليات، والذاكرة المطلوبة 9 × ((0.8 × 150MB) + (0.2 × 1024MB)) أي 2923MB تقريباً، وهذا حساب من معادلة الدليل وليس قياساً على سيرفر حقيقي، فأضف إليه ذاكرة PostgreSQL والنظام.

تنظيف المختبر

docker compose down -v
 Container lab-erp-odoo Stopping
 Container lab-erp-odoo Stopped
 Container lab-erp-odoo Removing
 Container lab-erp-odoo Removed
 Container lab-erp-db Stopping
 Container lab-erp-db Stopped
 Container lab-erp-db Removing
 Container lab-erp-db Removed
 Network lab-erp_default Removing
 Volume lab-erp-data Removing
 Volume lab-erp-db Removing
 Volume lab-erp-data Removed
 Volume lab-erp-db Removed
 Network lab-erp_default Removed

ثم احذف ال Docker Image الذي حملته للمختبر بأمر docker image rm odoo:20.0-20260926، ولا تستخدم أوامر الحذف الجماعي Prune على سيرفر فيه خدمات أخرى.

تدريب عملي

  • أصدر فاتورة مبسطة لعميل فرد بلا رقم ضريبي، وأرسلها إلى بيئة الاختبار، ثم قارن نوعها ورمز QR فيها بالفاتورة الضريبية.
  • ألغ الفاتورة الثالثة بإشعار دائن Credit Note من زر «إشعار دائن»، وأرسل الإشعار إلى المنصة، ولاحظ رقمه في السلسلة.

الخلاصة

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

  • Odoo 20 Community يعمل على Docker من الصورة الرسمية بوسم مؤرخ، مع PostgreSQL 16 أو أحدث، وفي ملف الإعداد اضبط http_interface وlist_db وdbfilter وكلمة المرور الرئيسية.
  • دورة البيع كاملة في Community: أمر البيع ثم التسليم الذي ينقص المخزون ثم الفاتورة بالضريبة السعودية ورمز QR ثم الدفع.
  • الربط مع منصة فاتورة في الوحدة المفتوحة l10n_sa_edi، وقد سجلنا دفتر المبيعات في بيئة الاختبار وقبلت المنصة فاتورتنا بعد إكمال العنوان.
  • العربية تحتاج انتباهاً: رمز دفتر اليومية بالعربية يكبر الاسم في طلب الشهادة عن 64 بايت ويسقط من اسم ملف XML، والفاتورة المطبوعة قد تخرج بعمود وصف فارغ.
  • المحاسبة الكاملة والتقارير المالية والترقية الرسمية في Enterprise، وفي Community تبقى الترقية الرئيسية مسؤوليتك عبر OpenUpgrade.
  • النسخة الصحيحة تجمع قاعدة البيانات ومخزن الملفات، والأمر odoo db dump يأخذهما معاً، واختبر الاستعادة.

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

  • أكتوبر 2026: كتابة المقال وتجربته على Odoo 20.0 من الصورة odoo:20.0-20260926 وPostgreSQL 17.11، والربط مع بيئة الاختبار في بوابة المطورين لدى هيئة الزكاة والضريبة والجمارك في 8 أكتوبر 2026.
نشرة عرب رووت | ArabRoot

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

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

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

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