Get a quote

بناء نظام تقارير أعمال آلي باستخدام Go و PostgreSQL للشركات في MENA

الشركات في لبنان والخليج تحتاج تقارير يومية وأسبوعية وشهرية بشكل آلي. بناء نظام تقارير صحيح يعني معالجة استعلامات التجميع بكفاءة، وجدولة التصدير، وإرسالها تلقائياً. هذا ما نستخدمه في Voxire لمنصة RTYLR.

لماذا التقارير اليدوية تكلّف الشركات وقتاً وأخطاء

معظم منتجات SaaS في لبنان والخليج تُطلق مع نظام تقارير يدوي في البداية. يُصدِر أحدهم ملف CSV، يلصقه في جدول بيانات، يُنسّق الأرقام، ثم يرسله إلى مدير العمليات. هذا يعمل مع خمسة فروع. يتوقف عن العمل مع خمسة عشر.

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

هذا بالضبط ما واجهناه في Voxire مع عملاء RTYLR من سلاسل المطاعم.

بناء جدول report_jobs في PostgreSQL

أساس نظام التقارير في RTYLR هو جدول report_jobs الذي يتحكم في الجدولة ويتتبع حالة التنفيذ.

CREATE TABLE report_jobs (
    id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id    UUID NOT NULL REFERENCES tenants(id),
    report_type  TEXT NOT NULL,
    schedule     TEXT NOT NULL,
    timezone     TEXT NOT NULL DEFAULT 'Asia/Beirut',
    last_run_at  TIMESTAMPTZ,
    next_run_at  TIMESTAMPTZ NOT NULL,
    status       TEXT NOT NULL DEFAULT 'pending',
    recipients   JSONB NOT NULL DEFAULT '[]',
    config       JSONB NOT NULL DEFAULT '{}'
);

حقل timezone بالغ الأهمية في بيئات MENA. سلسلة مطاعم في الرياض تعمل على توقيت AST (+3)، بينما سلسلة في بيروت تعمل على EET مع تعقيدات التوقيت الصيفي. تخزين المنطقة الزمنية لكل وظيفة وإجراء حسابات وقت القطع في طبقة التطبيق يتجنب أخطاء التوقيت الحدودي التي يصعب تصحيحها في الإنتاج.

استعلامات التجميع في PostgreSQL

استعلام التجميع الأساسي لتقرير المبيعات اليومي:

SELECT
    l.id AS location_id,
    l.name AS location_name,
    DATE_TRUNC('day', o.created_at AT TIME ZONE $1) AS report_date,
    COUNT(o.id) AS order_count,
    SUM(o.total_amount) AS gross_sales,
    SUM(o.net_amount) AS net_sales,
    AVG(o.net_amount) AS avg_order_value
FROM orders o
JOIN locations l ON l.id = o.location_id
WHERE
    l.tenant_id = $2
    AND o.status NOT IN ('voided', 'test')
    AND o.created_at >= $3
    AND o.created_at < $4
GROUP BY l.id, l.name, DATE_TRUNC('day', o.created_at AT TIME ZONE $1)
ORDER BY l.name, report_date;

تحويل AT TIME ZONE يُجري في الاستعلام حتى تتجمّع البيانات حسب التاريخ المحلي للمستأجر، لا حسب UTC. هذه التفصيلة الواحدة تحل مشكلة كاملة من أخطاء الحدود الليلية.

لمقاييس الفترات المتداولة مثل مقارنة أسبوع بأسبوع، تكون دوال النوافذ أكثر كفاءة من تشغيل استعلام ثانٍ:

SELECT
    location_id,
    report_week,
    weekly_net_sales,
    LAG(weekly_net_sales) OVER (
        PARTITION BY location_id ORDER BY report_week
    ) AS prev_week_sales
FROM weekly_sales_summary
WHERE tenant_id = $1;

توليد ملفات CSV و PDF في Go

تُعالج مكتبة encoding/csv القياسية في Go توليد ملفات CSV بشكل نظيف. النمط المستخدم في RTYLR هو محوّل تقارير خفيف يستقبل نتيجة الاستعلام وقائمة بتعريفات الأعمدة:

type ReportColumn struct {
    Header    string
    Field     string
    Formatter func(v interface{}) string
}

func WriteCSV(w io.Writer, rows []map[string]interface{}, columns []ReportColumn) error {
    cw := csv.NewWriter(w)
    // كتابة الرأس ثم الصفوف
    cw.Flush()
    return cw.Error()
}

لتوليد PDF، يستخدم RTYLR مسار من خطوتين: تصيير قالب HTML بـ html/template في Go، ثم التحويل إلى PDF عبر متصفح بلا واجهة. هذا النهج ضروري لأن دعم اللغة العربية والنصوص من اليمين إلى اليسار يتطلب محرك تصيير قوي.

جدولة التقارير وإرسالها تلقائياً

المجدول هو goroutine في Go يستطلع report_jobs بحثاً عن وظائف يكون فيها next_run_at <= NOW(). أقفال PostgreSQL الاستشارية تمنع تنفيذ نفس الوظيفة من نسختين في حالات النشر أو التوسع الأفقي:

func (s *Scheduler) acquireJobLock(ctx context.Context, jobID uuid.UUID) (bool, error) {
    var acquired bool
    err := s.db.QueryRowContext(ctx,
        "SELECT pg_try_advisory_xact_lock($1)",
        jobID.ID(),
    ).Scan(&acquired)
    return acquired, err
}

القفل مرتبط بالمعاملة ويُحرَّر تلقائياً عند اكتمالها. الوظيفة الفاشلة تُعاد المحاولة مع تراجع أسي: next_run_at يُضبط على NOW() + 15 دقيقة للفشل الأول، ثم NOW() + ساعة للفشل الثاني.

إدارة التقارير لمستأجرين متعددين في SaaS

كل استعلام في نظام التقارير يتضمن tenant_id في شرط WHERE. هذا مُطبَّق في طبقة المستودع، لا في طبقة المعالج.

كشبكة أمان إضافية، يستخدم RTYLR أمان مستوى الصفوف في PostgreSQL:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

يُضبط التطبيق SET LOCAL app.current_tenant = $tenant_id في بداية كل معاملة. إذا تجاوز استعلام ما مرشح المستأجر على مستوى التطبيق، يحجبه RLS على مستوى قاعدة البيانات.

خبرة من الإنتاج في RTYLR

بناء هذا النظام لعملاء RTYLR من سلاسل المطاعم في لبنان والخليج كشف عدة دروس مهمة.

معالجة المنطقة الزمنية مطلب أساسي لا يمكن تأجيله. ابنِها في طبقتي الأنماط والاستعلامات منذ البداية. بيئات MENA تمتد على ثلاثة فوارق UTC على الأقل مع قواعد توقيت صيفي تختلف عن الأعراف الأوروبية.

أداء الاستعلام يتدهور بشكل غير خطي مع النمو. استعلام يعمل في 200ms لمستأجر بـ 10 فروع قد يستغرق 8 ثوانٍ لمستأجر بـ 200 فروع إذا كانت الفهارس خاطئة. قِس أوقات استعلامات التجميع لكل مستأجر منذ اليوم الأول.

الوظائف الفاشلة تحتاج إلى وضوح فوري. وظيفة تفشل بصمت أسوأ من وظيفة لم تُبنَ قط. لوحة تحكم RTYLR الداخلية تعرض وظائف التقارير الفاشلة في الوقت الفعلي.

هل تحتاج إلى بناء نظام تقارير آلي لمنصة SaaS الخاصة بك؟ فريق هندسة Voxire يمكنه تصميم وبناء هذا النظام لك. ابدأ المحادثة على https://voxire.com/get-a-quote/

العودة إلى المدونة
Chat on WhatsApp