نظام حجز المواعيد يبدو بسيطًا حتى يحاول شخصان حجز نفس الوقت في نفس اللحظة، أو حتى يلغي موظف إجازته في اليوم الأخير. هذا المقال يشرح بنية نظام حجز مواعيد بـ Go يتعامل مع التوفر، والتزامن، والتذكيرات بشكل صحيح للعيادات والصالونات والخدمات في لبنان والمنطقة.
نظام حجز المواعيد يبدو بسيطًا حتى يحاول شخصان حجز نفس الوقت في نفس اللحظة، أو حتى يلغي موظف يومه بعد أن يكون المرضى قد تلقوا تأكيدًا، أو حتى يحتاج العميل إلى رؤية الأوقات المتاحة عبر خمسة أطباء في ثلاثة فروع. هذا المقال يشرح بنية نظام حجز مواعيد بـ Go يتعامل مع هذه التحديات للعيادات والصالونات والخدمات في لبنان ومنطقة الشرق الأوسط وشمال إفريقيا.
ما هي المشكلات الأساسية التي يحلها نظام الحجز؟
معظم عيادات ومراكز خدمات في لبنان تدير المواعيد إما بسجل ورقي أو بـ WhatsApp. عندما يصل عدد المواعيد إلى 20 أو 30 يوميًا، تظهر ثلاثة مشاكل أساسية:
- التعارض في الحجز: شخصان يحجزان نفس الوقت قبل أن يُحدَّث السجل
- إدارة التوفر: تتبع أوقات عمل كل موظف وإجازاته وفترات الاستراحة
- التذكيرات: إرسال تذكيرات تلقائية يقلل نسبة عدم الحضور بشكل كبير
كيف تصمم قاعدة البيانات؟
CREATE TABLE providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
full_name TEXT NOT NULL,
specialty TEXT,
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE service_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name TEXT NOT NULL,
duration_minutes INT NOT NULL,
price_usd NUMERIC(10,2)
);
CREATE TABLE provider_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES providers(id),
day_of_week SMALLINT NOT NULL, -- 0=Sunday..6=Saturday
start_time TIME NOT NULL,
end_time TIME NOT NULL,
valid_from DATE NOT NULL,
valid_until DATE
);
CREATE TABLE appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
provider_id UUID NOT NULL REFERENCES providers(id),
service_type_id UUID REFERENCES service_types(id),
patient_name TEXT NOT NULL,
patient_phone TEXT NOT NULL,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- منع التعارض في الحجز على مستوى قاعدة البيانات
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE INDEX appointments_provider_time_idx
ON appointments (provider_id)
WHERE status NOT IN ('cancelled', 'no_show');
ALTER TABLE appointments
ADD CONSTRAINT no_overlap
EXCLUDE USING gist (
provider_id WITH =,
tstzrange(starts_at, ends_at) WITH &&
) WHERE (status NOT IN ('cancelled', 'no_show'));
القيد EXCLUDE USING gist هو الأهم في المخطط. يمنع PostgreSQL تلقائيًا إدراج موعد يتعارض مع موعد آخر موجود لنفس المزود. بدون هذا القيد، يتطلب منع التعارض قفل صريح على مستوى التطبيق، وهو أكثر تعقيدًا وعرضة للأخطاء.
كيف تحسب الأوقات المتاحة؟
التوفر يُحسب بطرح المواعيد الموجودة من جدول عمل المزود:
type TimeSlot struct {
Start time.Time
End time.Time
}
func (s *AppointmentService) GetAvailableSlots(
ctx context.Context,
providerID uuid.UUID,
date time.Time,
durationMinutes int,
) ([]TimeSlot, error) {
schedule, err := s.db.GetProviderSchedule(ctx, providerID, date.Weekday())
if err != nil || schedule == nil {
return nil, err // لا يعمل في هذا اليوم
}
existing, err := s.db.GetAppointmentsForDay(ctx, providerID, date)
if err != nil {
return nil, err
}
// بناء الفترات المحجوزة
busy := make([]TimeSlot, 0, len(existing))
for _, appt := range existing {
busy = append(busy, TimeSlot{Start: appt.StartsAt, End: appt.EndsAt})
}
// توليد جميع الفترات الممكنة كل X دقيقة
slots := generateSlots(schedule.StartTime, schedule.EndTime, date, durationMinutes)
// إزالة الفترات التي تتعارض مع محجوز
available := filterAvailable(slots, busy)
return available, nil
}
func filterAvailable(slots, busy []TimeSlot) []TimeSlot {
result := make([]TimeSlot, 0)
for _, slot := range slots {
overlaps := false
for _, b := range busy {
if slot.Start.Before(b.End) && slot.End.After(b.Start) {
overlaps = true
break
}
}
if !overlaps {
result = append(result, slot)
}
}
return result
}
كيف تتعامل مع الحجز المتزامن؟
عندما يفتح موقع الحجز لمرضى متعددين في نفس الوقت، يمكن لشخصين أن يريا نفس الوقت متاحًا ويحاولا حجزه في نفس اللحظة. قيد EXCLUDE USING gist في PostgreSQL يضمن أن الثاني سيحصل على خطأ:
func (s *AppointmentService) Book(
ctx context.Context,
req BookAppointmentRequest,
) (*Appointment, error) {
appt := &Appointment{
ID: uuid.New(),
TenantID: req.TenantID,
ProviderID: req.ProviderID,
ServiceTypeID: req.ServiceTypeID,
PatientName: req.PatientName,
PatientPhone: req.PatientPhone,
StartsAt: req.StartsAt,
EndsAt: req.StartsAt.Add(time.Duration(req.DurationMinutes) * time.Minute),
Status: "confirmed",
}
err := s.db.InsertAppointment(ctx, appt)
if err != nil {
if isGistConflict(err) {
return nil, ErrTimeSlotTaken // الوقت محجوز من شخص آخر للتو
}
return nil, err
}
// جدول تذكيرات SMS
s.scheduleReminders(ctx, appt)
return appt, nil
}
على مستوى الواجهة الأمامية، عندما يحصل العميل على ErrTimeSlotTaken، تعيد تحميل الأوقات المتاحة وتطلب منه اختيار وقت آخر. هذه تجربة مستخدم أفضل من القفل المتفائل التقليدي لأنها تفشل بسرعة وتوجّه المستخدم مباشرةً.
كيف تبني نظام التذكيرات؟
تذكيرات SMS قبل 24 ساعة وساعة واحدة تقلل نسبة عدم الحضور بنسبة 30 إلى 40 بالمئة. ننفذ هذا كوظائف مجدولة:
CREATE TABLE reminder_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
appointment_id UUID NOT NULL REFERENCES appointments(id),
type TEXT NOT NULL, -- '24h', '1h'
send_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
sent_at TIMESTAMPTZ,
error TEXT
);
func (s *AppointmentService) scheduleReminders(ctx context.Context, appt *Appointment) {
jobs := []ReminderJob{
{
AppointmentID: appt.ID,
Type: "24h",
SendAt: appt.StartsAt.Add(-24 * time.Hour),
},
{
AppointmentID: appt.ID,
Type: "1h",
SendAt: appt.StartsAt.Add(-1 * time.Hour),
},
}
for _, job := range jobs {
if time.Now().Before(job.SendAt) {
s.db.InsertReminderJob(ctx, job)
}
}
}
وظيفة cron تعمل كل 5 دقائق وتبحث عن وظائف التذكير التي حان وقتها:
func (s *ReminderService) ProcessPendingReminders(ctx context.Context) error {
jobs, err := s.db.GetDueReminders(ctx, time.Now())
if err != nil {
return err
}
for _, job := range jobs {
appt, _ := s.db.GetAppointment(ctx, job.AppointmentID)
if appt.Status == "cancelled" {
s.db.MarkReminderSkipped(ctx, job.ID)
continue
}
msg := fmt.Sprintf(
"تذكير: لديك موعد مع %s في %s. للإلغاء اتصل بنا.",
appt.ProviderName,
appt.StartsAt.Format("02/01 03:04 PM"),
)
if err := s.sms.Send(ctx, appt.PatientPhone, msg); err != nil {
s.db.MarkReminderFailed(ctx, job.ID, err.Error())
continue
}
s.db.MarkReminderSent(ctx, job.ID)
}
return nil
}
ما هي المشاكل الإضافية التي تظهر في الإنتاج؟
تغيير الجدول بأثر رجعي: عندما يغير طبيب مواعيد عمله، يجب أن يؤثر التغيير فقط على المواعيد المستقبلية. الحل هو تخزين valid_from وvalid_until في جدول provider_schedules.
المواعيد المتكررة: بعض العيادات تحجز مواعيد أسبوعية ثابتة. ننفذ هذا بجدولة مسبقة لـ 4 إلى 8 أسابيع وليس بـ recurrence rules معقدة.
تعارض المنطقة الزمنية: لبنان على UTC+3 (UTC+2 شتاءً). جميع الأوقات تُخزّن كـ TIMESTAMPTZ في PostgreSQL. التحويل للعرض يتم في طبقة API بناءً على منطقة المستخدم.
الدروس الرئيسية من الإنتاج
قيد EXCLUDE USING gist في PostgreSQL هو الطريقة الأكثر موثوقية لمنع التعارض في الحجز. التذكيرات المجدولة ليست ميزة إضافية، هي ميزة تؤثر مباشرةً على إيرادات العميل من خلال تقليل عدم الحضور. إدارة التوفر تصبح معقدة بسرعة عند إضافة إجازات وفروع متعددة، ابدأ بسيطًا وأضف التعقيد عند الطلب.
هل تحتاج إلى مساعدة في بناء نظام الحجز؟
فوكسير تبني أنظمة إدارة المواعيد المخصصة للعيادات والصالونات ومراكز الخدمات في لبنان والمنطقة. إذا كنت تحتاج إلى نظام حجز يناسب طبيعة عملك، تواصل معنا.
https://voxire.com/get-a-quote/
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.
Voxire
SaaS Product Development
From idea to launched product - strategy, architecture, full-stack development, and post-launch support.
Learn more


