diff --git a/backend/scripts/e2e-test.mjs b/backend/scripts/e2e-test.mjs new file mode 100644 index 0000000..9a923b2 --- /dev/null +++ b/backend/scripts/e2e-test.mjs @@ -0,0 +1,300 @@ +// اختبار شامل متكامل لكل وظائف الباك إند (docs/17). +// يغطّي: تسجيل الدخول/الحساب · إعداد السائق (ملف + مركبة) · طلب رحلة · +// المطابقة عبر Redis · القبول الذرّي · كل حالات الرحلة · تتبّع WebSocket · +// التسوية المالية (محفظة) · الرصيد التشغيلي والعمولة · التقييم للطرفين · +// نقطة «رحلة معلّقة للتقييم عند الفتح» · السحب بـOTP. +// +// يستخدم fetch المدمج. اختبار الـWebSocket اختياري: يعمل إن توفّر +// socket.io-client، ويُتخطّى بتحذير إن غاب. +// +// التشغيل (مع WebSocket): +// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \ +// -e WS=http://tripz-api:4010 -v /home/tripz-llc/backend/scripts:/s \ +// node:22-alpine sh -c "cd /tmp && npm i socket.io-client --silent 2>/dev/null; \ +// NODE_PATH=/tmp/node_modules node /s/e2e-test.mjs" +// +// التشغيل (REST فقط، بلا WebSocket): +// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \ +// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/e2e-test.mjs +// +// يتطلّب OTP_DEV_MODE=true (الرمز الثابت 1234). + +const BASE = process.env.BASE || 'http://localhost:4010/api'; +const WS = process.env.WS || BASE.replace(/\/api$/, ''); +const TENANT = process.env.TENANT || 'siro'; +const CODE = '1234'; +const ORIGIN = { lat: 31.9539, lng: 35.9106 }; +const DEST = { lat: 31.9800, lng: 35.8700 }; + +let passed = 0; +let failed = 0; +const failures = []; + +function check(name, cond, detail) { + if (cond) { + passed++; + console.log(` ✅ ${name}`); + } else { + failed++; + failures.push(name + (detail ? ` — ${detail}` : '')); + console.log(` ❌ ${name}${detail ? ` — ${detail}` : ''}`); + } +} + +const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) }); + +async function api(method, path, token, body, opts = {}) { + const res = await fetch(`${BASE}${path}`, { + method, + headers: { ...H(token), 'x-tenant-id': TENANT }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text().catch(() => ''); + let json = {}; + try { json = text ? JSON.parse(text) : {}; } catch { /* غير JSON */ } + if (!res.ok && !opts.allowError) { + throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`); + } + return { status: res.status, body: json, ok: res.ok }; +} + +async function login(phone) { + const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE }); + return { token: r.body.access_token, userId: r.body.user?.id, role: r.body.user?.role }; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const uniqPhone = (p) => `${p}${String(Date.now()).slice(-6)}`; + +// ---- اختبار WebSocket اختياري ---- +async function tryWebSocket(riderToken, tripId) { + let ioClient; + try { + ioClient = (await import('socket.io-client')).io; + } catch { + console.log(' ⚠️ socket.io-client غير متوفّر — تخطّي اختبار WebSocket (راجع أمر التشغيل)'); + return null; + } + return new Promise((resolve) => { + const events = { connected: false, tripUpdate: false, driverLocation: false }; + const socket = ioClient(WS, { auth: { token: riderToken }, transports: ['websocket'], timeout: 5000 }); + const done = () => { try { socket.close(); } catch {} resolve(events); }; + + socket.on('connect', () => { + events.connected = true; + socket.emit('trip:join', { tripId }); + }); + socket.on('trip:update', () => { events.tripUpdate = true; }); + socket.on('driver:location', () => { events.driverLocation = true; }); + socket.on('connect_error', () => done()); + setTimeout(done, 8000); // نافذة رصد الأحداث + }); +} + +async function main() { + console.log(`\n🧪 اختبار Tripz الشامل على ${BASE}\n`); + + // ═══ 1. تسجيل الدخول / إنشاء الحساب ═══ + console.log('1) تسجيل الدخول وإنشاء الحساب (هاتف + OTP)'); + const riderPhone = uniqPhone('07900'); + const rider = await login(riderPhone); + check('الراكب سجّل دخوله وحصل على توكن', !!rider.token); + check('حساب جديد بدور rider افتراضياً', rider.role === 'rider', `role=${rider.role}`); + + // نفس الرقم بصيغة مختلفة → نفس الحساب (تطبيع D1) + const riderAgain = await api('POST', '/auth/verify-otp', null, { + phone: '0' + riderPhone.slice(1), code: CODE, + }, { allowError: true }); + check('تطبيع الهاتف: نفس الرقم بصيغ مختلفة = نفس المستخدم', + riderAgain.body.user?.id === rider.userId); + + const driverPhone = uniqPhone('07955'); + let driver = await login(driverPhone); + check('السائق سجّل دخوله', !!driver.token); + + // ═══ 2. إعداد السائق (ملف + مركبة + اعتماد) ═══ + console.log('\n2) إعداد السائق: تقديم + ملف + مركبة + اعتماد'); + const applied = await api('POST', '/drivers/apply', driver.token, { + vehicle_make: 'Toyota', service_class: 'economy', + }); + const driverId = applied.body.id; + check('تقديم السائق أنشأ سجلاً', !!driverId); + + await api('PATCH', '/drivers/profile', driver.token, { + gender: 'male', national_number: uniqPhone('99'), name_arabic: 'سائق تجريبي', + license_type: 'private', + }); + check('تحديث ملف السائق (رقم وطني مشفّر + فهرس أعمى)', true); + + const vehicle = await api('POST', '/vehicles', driver.token, { + make: 'Toyota', model: 'Corolla', year: 2020, color: 'أبيض', color_hex: '#FFFFFF', + plate: uniqPhone('PL'), + }); + check('إنشاء مركبة', !!vehicle.body.id); + check('أول مركبة تصير الافتراضية تلقائياً', vehicle.body.is_default === true); + + await api('PATCH', `/drivers/${driverId}/approve`, driver.token, {}); + // الدور صار driver بعد apply — يجب إعادة الدخول ليحمل التوكن الدور الجديد + driver = await login(driverPhone); + check('بعد الاعتماد وإعادة الدخول: الدور driver في التوكن', driver.role === 'driver', `role=${driver.role}`); + + // ═══ 3. الرصيد التشغيلي (مكافأة التسجيل) ═══ + console.log('\n3) الرصيد التشغيلي للسائق'); + const credit0 = await api('GET', '/credit', driver.token); + check('السائق بدأ برصيد تشغيلي (مكافأة تسجيل > 0)', Number(credit0.body.balance) > 0, + `balance=${credit0.body.balance}`); + check('السائق غير محجوب', credit0.body.blocked === false); + + // ═══ 4. اتصال السائق + رفع الموقع (Redis) ═══ + console.log('\n4) اتصال السائق ورفع الموقع (Redis + المطابقة)'); + await api('PATCH', '/drivers/status', driver.token, { online: true }); + await api('POST', '/drivers/location', driver.token, { ...ORIGIN, speed: 0, heading: 0 }); + check('السائق متصل ورفع موقعه', true); + + // ═══ 5. المحفظة (شحن للتسوية لاحقاً) ═══ + console.log('\n5) المحفظة'); + await api('POST', '/wallet/topup', rider.token, { amount: 50 }); + const wallet0 = await api('GET', '/wallet', rider.token); + check('شحن محفظة الراكب', Number(wallet0.body.balance) >= 50, `balance=${wallet0.body.balance}`); + + // ═══ 6. طلب رحلة (تسعير + مطابقة + عرض) ═══ + console.log('\n6) طلب رحلة (تسعير + مطابقة عبر Redis)'); + const reqTrip = await api('POST', '/trips', rider.token, { + origin: ORIGIN, destination: DEST, service_class: 'economy', payment_method: 'wallet', + }); + const tripId = reqTrip.body.trip?.id; + check('إنشاء الرحلة', !!tripId); + check('الرحلة بحالة searching', reqTrip.body.trip?.status === 'searching'); + check('حُسبت أجرة مقفولة', reqTrip.body.trip?.quoted_fare != null, + `fare=${reqTrip.body.trip?.quoted_fare}`); + check('عُرضت على سائق قريب (مطابقة Redis نجحت)', reqTrip.body.offeredDrivers >= 1, + `offered=${reqTrip.body.offeredDrivers}`); + + // السائق يرى الرحلة في المتاحة + const available = await api('GET', '/trips/available', driver.token); + check('السائق يرى الرحلة في /trips/available', Array.isArray(available.body) && + available.body.some((t) => t.tripId === tripId || t.id === tripId)); + + // ═══ 7. WebSocket (يُبدأ قبل الانتقالات لرصد الأحداث) ═══ + console.log('\n7) تتبّع WebSocket (اختياري)'); + const wsPromise = tryWebSocket(rider.token, tripId); + await sleep(1500); // مهلة ليتّصل السوكت قبل الانتقالات، وإلا فاتته الأحداث + + // ═══ 8. القبول + كل حالات الرحلة ═══ + console.log('\n8) القبول وكل حالات الرحلة'); + const accepted = await api('POST', `/trips/${tripId}/accept`, driver.token, {}); + check('السائق قبل الرحلة', accepted.ok); + const afterAccept = await api('GET', `/trips/${tripId}`, driver.token); + check('الحالة صارت assigned', afterAccept.body.status === 'assigned', `status=${afterAccept.body.status}`); + + const transitions = ['driver_arriving', 'driver_arrived', 'in_progress', 'completed']; + for (const st of transitions) { + // نبضة موقع قبل الوصول (لكشف تزوير الوصول — H/E) + if (st === 'driver_arrived') { + await api('POST', '/drivers/location', driver.token, { ...ORIGIN, speed: 5 }); + } + const r = await api('PATCH', `/trips/${tripId}/status`, driver.token, { status: st }, { allowError: true }); + check(`الانتقال إلى ${st}`, r.ok, r.ok ? '' : JSON.stringify(r.body).slice(0, 120)); + await sleep(150); + } + + const afterComplete = await api('GET', `/trips/${tripId}`, driver.token); + check('الرحلة مكتملة', afterComplete.body.status === 'completed'); + check('طوابع دقيقة: started_at مضبوط', !!afterComplete.body.started_at); + check('السعر مفصول: price_for_passenger و price_for_driver', + afterComplete.body.price_for_passenger != null && afterComplete.body.price_for_driver != null); + + // ═══ 9. العمولة خُصمت من الرصيد التشغيلي عند الإنهاء ═══ + console.log('\n9) العمولة من الرصيد التشغيلي (عند الإنهاء)'); + const credit1 = await api('GET', '/credit', driver.token); + const commission = Number(afterComplete.body.commission_amount ?? 0); + if (commission > 0) { + check('نقص الرصيد التشغيلي بمقدار العمولة', + Math.abs((Number(credit0.body.balance) - Number(credit1.body.balance)) - commission) < 0.01, + `${credit0.body.balance} → ${credit1.body.balance}, عمولة=${commission}`); + } else { + check('لا عمولة على هذه التعرفة (تخطّي)', true); + } + + // ═══ 10. الدفع (تسوية المحفظة عند paid) ═══ + console.log('\n10) الدفع وتسوية المحفظة'); + const paidRes = await api('PATCH', `/trips/${tripId}/status`, driver.token, { status: 'paid' }, { allowError: true }); + check('الانتقال إلى paid', paidRes.ok, paidRes.ok ? '' : JSON.stringify(paidRes.body).slice(0, 120)); + await sleep(200); + const walletAfter = await api('GET', '/wallet', rider.token); + const fare = Number(afterComplete.body.price_for_passenger ?? 0); + check('خُصمت الأجرة من محفظة الراكب', + Math.abs((Number(wallet0.body.balance) - Number(walletAfter.body.balance)) - fare) < 0.01, + `${wallet0.body.balance} → ${walletAfter.body.balance}, أجرة=${fare}`); + + // ═══ 11. التقييم: نقطة «رحلة معلّقة عند الفتح» ═══ + console.log('\n11) التقييم (رحلة معلّقة عند الفتح + تقييم الطرفين)'); + const pendingRider = await api('GET', '/trips/rating/pending', rider.token); + check('الراكب لديه رحلة معلّقة للتقييم عند الفتح', pendingRider.body.pending?.tripId === tripId, + `pending=${JSON.stringify(pendingRider.body.pending)}`); + check('دور الراكب في التقييم = rider', pendingRider.body.pending?.role === 'rider'); + + const pendingDriver = await api('GET', '/trips/rating/pending', driver.token); + check('السائق لديه رحلة معلّقة للتقييم', pendingDriver.body.pending?.tripId === tripId); + check('دور السائق في التقييم = driver', pendingDriver.body.pending?.role === 'driver'); + + await api('POST', `/trips/${tripId}/rate`, rider.token, { stars: 5, comment: 'ممتاز' }); + check('الراكب قيّم السائق', true); + await api('POST', `/trips/${tripId}/rate`, driver.token, { stars: 4 }); + check('السائق قيّم الراكب', true); + + const pendingAfter = await api('GET', '/trips/rating/pending', rider.token); + check('بعد التقييم: لا رحلة معلّقة للراكب', pendingAfter.body.pending === null); + + const dupRate = await api('POST', `/trips/${tripId}/rate`, rider.token, { stars: 3 }, { allowError: true }); + check('منع التقييم المزدوج', !dupRate.ok && dupRate.status === 400); + + // ═══ 12. السحب بـOTP (تدفّق خطوتين) ═══ + console.log('\n12) سحب أرباح السائق (OTP خطوتين)'); + // السائق يشحن محفظة أرباحه أولاً ليكون له رصيد للسحب + await api('POST', '/wallet/topup', driver.token, { amount: 20 }); + const payoutReq = await api('POST', '/payouts/request', driver.token, + { amount: 10, channel: 'cliq' }, { allowError: true }); + check('طلب السحب أرسل رمزاً بلا خصم', payoutReq.ok && payoutReq.body.otp_sent === true, + JSON.stringify(payoutReq.body).slice(0, 120)); + const payoutId = payoutReq.body.payout?.id; + const devCode = payoutReq.body.dev_code; + if (payoutId && devCode) { + const walletBeforeConfirm = await api('GET', '/wallet', driver.token); + const confirm = await api('POST', `/payouts/${payoutId}/confirm`, driver.token, + { code: String(devCode) }, { allowError: true }); + check('تأكيد السحب بالرمز نجح', confirm.ok, JSON.stringify(confirm.body).slice(0, 120)); + const walletAfterConfirm = await api('GET', '/wallet', driver.token); + check('حُجز مبلغ السحب من محفظة السائق عند التأكيد', + Math.abs((Number(walletBeforeConfirm.body.balance) - Number(walletAfterConfirm.body.balance)) - 10) < 0.01, + `${walletBeforeConfirm.body.balance} → ${walletAfterConfirm.body.balance}`); + } else { + check('dev_code متوفّر للتأكيد', false, 'لا يوجد dev_code — تأكّد من OTP_DEV_MODE=true'); + } + + // ═══ نتيجة WebSocket ═══ + console.log('\n13) نتيجة WebSocket'); + const ws = await wsPromise; + if (ws) { + check('اتصال WebSocket بمصادقة JWT', ws.connected); + check('استقبال أحداث trip:update أثناء الرحلة', ws.tripUpdate); + // driver:location قد لا يُبثّ إن لم تكن الحركة ذات دلالة — تحذير لا فشل + if (!ws.driverLocation) console.log(' ⚠️ لم تُرصد driver:location (قد لا تكون حركة ذات دلالة)'); + } + + // ═══ الخلاصة ═══ + console.log('\n' + '═'.repeat(50)); + console.log(`النتيجة: ${passed} نجح · ${failed} فشل`); + if (failed > 0) { + console.log('\nالإخفاقات:'); + for (const f of failures) console.log(` ❌ ${f}`); + } + console.log('═'.repeat(50) + '\n'); + process.exit(failed === 0 ? 0 : 1); +} + +main().catch((e) => { + console.error('\n💥 توقّف الاختبار بخطأ غير متوقّع:', e.message); + console.error(e.stack?.split('\n').slice(0, 4).join('\n')); + process.exit(1); +}); diff --git a/backend/src/modules/ratings/ratings.controller.ts b/backend/src/modules/ratings/ratings.controller.ts index 0918838..f437004 100644 --- a/backend/src/modules/ratings/ratings.controller.ts +++ b/backend/src/modules/ratings/ratings.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { RatingsService } from './ratings.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -11,6 +11,15 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator export class RatingsController { constructor(private readonly ratings: RatingsService) {} + /** + * رحلة منتهية تنتظر تقييم هذا المستخدم (docs/17 — التقييم). يناديها التطبيق + * عند كل فتح ليفرض شاشة التقييم قبل رحلة جديدة. + */ + @Get('rating/pending') + pending(@CurrentUser() user: AuthUser) { + return this.ratings.pendingRating(user.tenantId, user.userId); + } + @Post(':id/rate') rate( @CurrentUser() user: AuthUser, diff --git a/backend/src/modules/ratings/ratings.service.ts b/backend/src/modules/ratings/ratings.service.ts index a24c2e8..09c9869 100644 --- a/backend/src/modules/ratings/ratings.service.ts +++ b/backend/src/modules/ratings/ratings.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { Rating } from './entities/rating.entity'; import { TripsService } from '../trips/trips.service'; import { DriversService } from '../drivers/drivers.service'; @@ -22,6 +22,46 @@ export class RatingsService { private readonly aggregate: RatingAggregateService, ) {} + /** + * الرحلة المنتهية غير المقيَّمة من هذا المستخدم (docs/17 — التقييم). + * + * يستدعيها التطبيق عند كل فتح (سائقاً أو راكباً): إن رجعت رحلة، يفتح شاشة + * التقييم إجبارياً قبل السماح بطلب/قبول رحلة جديدة. يرجع الأحدث + العدد + * الكلّي للمعلَّقات. + */ + async pendingRating(tenantId: string, userId: string) { + const driver = await this.drivers.findByUser(tenantId, userId); + const finished = await this.trips.finishedForParticipant(tenantId, userId, driver?.id ?? null); + if (finished.length === 0) return { pending: null, count: 0 }; + + // ما قيّمه المستخدم من هذه الرحلات — استعلام واحد بدل واحد لكل رحلة. + const rated = await this.repo.find({ + where: { + tenant_id: tenantId, + by_user_id: userId, + trip_id: In(finished.map((t) => t.id)), + }, + select: { trip_id: true }, + }); + const ratedIds = new Set(rated.map((r) => r.trip_id)); + const unrated = finished.filter((t) => !ratedIds.has(t.id)); + if (unrated.length === 0) return { pending: null, count: 0 }; + + const trip = unrated[0]; // الأحدث (مرتَّبة تنازلياً) + const asDriver = !!driver && trip.driver_id === driver.id && trip.rider_id !== userId; + return { + count: unrated.length, + pending: { + tripId: trip.id, + role: asDriver ? 'driver' : 'rider', // مَن يقيّم مَن + fare: trip.final_fare == null ? null : Number(trip.final_fare), + currency: trip.currency ?? null, + completedAt: trip.completed_at, + distanceKm: trip.distance_km == null ? null : Number(trip.distance_km), + }, + }; + } + async rate( tenantId: string, tripId: string, diff --git a/backend/src/modules/trips/trips.service.ts b/backend/src/modules/trips/trips.service.ts index d4a53d8..d8dadc9 100644 --- a/backend/src/modules/trips/trips.service.ts +++ b/backend/src/modules/trips/trips.service.ts @@ -93,6 +93,28 @@ export class TripsService { }); } + /** + * الرحلات المنتهية (completed/paid) التي شارك فيها المستخدم راكباً أو سائقاً + * — أساس كشف الرحلة غير المقيَّمة عند فتح التطبيق (docs/17 — G6/التقييم). + */ + finishedForParticipant( + tenantId: string, + riderId: string, + driverId: string | null, + limit = 20, + ): Promise { + const qb = this.trips + .createQueryBuilder('t') + .where('t.tenant_id = :tenantId', { tenantId }) + .andWhere('t.status IN (:...done)', { done: ['completed', 'paid'] }); + if (driverId) { + qb.andWhere('(t.rider_id = :riderId OR t.driver_id = :driverId)', { riderId, driverId }); + } else { + qb.andWhere('t.rider_id = :riderId', { riderId }); + } + return qb.orderBy('t.completed_at', 'DESC').take(limit).getMany(); + } + listForDriver(tenantId: string, driverId: string): Promise { return this.trips.find({ where: { tenant_id: tenantId, driver_id: driverId }, diff --git a/docs/15-deploy-flow.md b/docs/15-deploy-flow.md index e395354..e4e579e 100644 --- a/docs/15-deploy-flow.md +++ b/docs/15-deploy-flow.md @@ -72,3 +72,16 @@ docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \ docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \ -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/location-test.mjs ``` + +``` +# اختبار شامل متكامل (E2E) — كل وظائف الباك إند من الدخول للتقييم للسحب +# مع WebSocket: +docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \ + -e WS=http://tripz-api:4010 -v /home/tripz-llc/backend/scripts:/s \ + node:22-alpine sh -c "cd /tmp && npm i socket.io-client --silent 2>/dev/null; \ + NODE_PATH=/tmp/node_modules node /s/e2e-test.mjs" +# أو REST فقط (بلا WebSocket): +docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \ + -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/e2e-test.mjs +# يتطلّب OTP_DEV_MODE=true. +``` diff --git a/docs/17-backend-backlog.md b/docs/17-backend-backlog.md index e2da844..1b03e41 100644 --- a/docs/17-backend-backlog.md +++ b/docs/17-backend-backlog.md @@ -127,6 +127,7 @@ | G4 | **`tenant` في Redis** | كاش `resolve` (slug↔UUID) + `config` مع إبطال عند الإنشاء/التعديل. | ✅ `tenant:{slugOrId}` (TTL ساعة) | | G5 | **التعرفة و ride-types في Redis** | كاش مع **إبطال صريح** عند إنشاء تعرفة/نوع جديد. | ✅ `tariff:{t}:{city}:{class}` و `ridetypes:{t}` (TTL 15د) | | G6 | **التقييم بتراكم يومي** | مجموع/عدد في Redis، والقاعدة تُكتب **مرة واحدة يومياً** لكل طرف (حجز الكتابة ذرّي بـLua). التأخير مقصود ليبعد الاحتكاك. **للطرفين معاً**. | ✅ + أُضيف `ratings.target_user_id` و`users.rating` — تقييم السائق للراكب كان يُخزَّن **بلا هدف فلا يُجمَّع أبداً** | +| — | **رحلة معلّقة للتقييم عند الفتح** | `GET /trips/rating/pending` — يرجع أحدث رحلة منتهية لم يقيّمها المستخدم (سائقاً أو راكباً) + العدد. التطبيق يناديها عند كل فتح ليفرض شاشة التقييم قبل رحلة جديدة (طلب المالك). | ✅ | | — | **كاش الخرائط** | route/reverse/geocode في Redis بإحداثيات مقرَّبة 4 خانات (~11م). | ✅ TTL يوم. لا يُخزَّن الرجوع لخط مستقيم ولا استجابة فاشلة | | G7 | **سعة Redis** | رصد مساحة أكبر أو Redis منفصل عند الحاجة (خصوصاً dispatch + المواقع). حالياً DB 3 مشترك — راجع docs/14. | ⏳ قرار تشغيلي — يُراجَع بعد المجموعة H |