الشركات في لبنان والخليج تحتاج تقارير يومية وأسبوعية وشهرية بشكل آلي. بناء نظام تقارير صحيح يعني معالجة استعلامات التجميع بكفاءة، وجدولة التصدير، وإرسالها تلقائياً. هذا ما نستخدمه في 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/


