chore: أرشفة باك إند NestJS إلى backend-archive

قرار المالك 2026-07-27: الباك إند المعتمد صار باك إند سيرو PHP، ويُنقل
إلى هذا المستودع كنسخة جديدة باسم «انطلق» على api.intaleqapp.com.

backend/ → backend-archive/ (305 ملفاً، إعادة تسمية بلا تعديل محتوى)
+ README-ARCHIVE.md يوضّح سبب الأرشفة وسبب عدم الحذف: scripts/e2e-test.mjs
  هو أدق توثيق سلوكي لدورة الرحلة كاملة، ويبقى مرجعاً عند بناء الوحدات
  الناقصة في باك إند PHP (المحفظتان · الاستحقاقات · محرّك التسعير · الوحدات).

مسارات backend/ في docs/*.md لم تُعدَّل عمداً — سجلّ تاريخي.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-27 04:54:35 +03:00
co-authored by Claude Opus 5
parent 75b80bd299
commit fff76c949f
307 changed files with 47 additions and 0 deletions
+395
View File
@@ -0,0 +1,395 @@
// اختبار تسعير عبر API حقيقي — يطلب رحلة لكل فئة خدمة في كل بلد، ويقرأ
// السعر والمسافة والزمن اللذين رجعهما محرّك التعرفة الفعلي (لا محاكاة).
//
// ينشئ مستأجرَي اختبار لمصر وسوريا إن لم يكونا موجودين (الأردن = "siro"
// القائم أصلاً)، ثم يطلب 3 رحلات نموذجية (قصيرة/متوسطة/طويلة) لكل فئة
// خدمة في كل بلد، ويطبع جدول: البلد × الفئة × الرحلة → السعر.
//
// التشغيل (من داخل شبكة الحاوية، بعد نجاح البناء):
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -e PLATFORM_SECRET="$(grep -E '^PLATFORM_SECRET=' .env | cut -d= -f2-)" \
// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/pricing-test.mjs
//
// يتطلّب `PLATFORM_SECRET` فقط — **لا يحتاج `OTP_DEV_MODE`**: يقرأ رمز
// التحقّق من Redis مباشرةً. الوضع التطويري ممنوع على الإنتاج بحارس في
// `main.ts`، وإضعاف ذلك الحارس لأجل اختبار مقايضة خاسرة.
import net from 'node:net';
const BASE = process.env.BASE || 'http://localhost:4010/api';
const PLATFORM_SECRET = process.env.PLATFORM_SECRET || '';
// يقرأ رمز OTP من Redis مباشرةً بدل `OTP_DEV_MODE`. الوضع التطويري ممنوع
// على الإنتاج بحارس في `main.ts` (`OTP_DEV_MODE=true` + `NODE_ENV=production`
// = رفض الإقلاع)، وهو حارس صحيح: رمز ثابت على سيرفر متاح للإنترنت يعني أن
// معرفة رقم هاتف تكفي لدخول أي حساب. فنقرأ الرمز الحقيقي بدل إضعاف الحارس.
const REDIS_HOST = process.env.REDIS_HOST || 'tripz-redis';
const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10);
const REDIS_DB = parseInt(process.env.REDIS_DB || '3', 10);
const REDIS_KEY_PREFIX = process.env.REDIS_KEY_PREFIX || 'tripz:';
if (!PLATFORM_SECRET) {
console.error('❌ PLATFORM_SECRET غير مضبوط — لا يمكن إنشاء مستأجرَي الاختبار.');
process.exit(1);
}
// الفئات المتوقَّع أن تُسعَّر. **`fixed` ليست منها**: أُخرجت من الزرع عمداً
// (سعر ثابت بلا جدول خطوط = رقم واحد لكل الوجهات)، فالمتوقَّع منها رفض 400
// لا سعر — ويُتحقَّق من ذلك صراحةً في `REJECTED_CLASSES`.
const SERVICE_CLASSES = [
'saver', 'delivery', 'economy', 'electric', 'lady', 'comfort', 'vip', 'van',
];
/** فئات يجب أن يرفضها الخادم — الرفض هنا نجاحٌ لا فشل. */
const REJECTED_CLASSES = ['fixed'];
/**
* رحلات نموذجية داخل الزرقاء/عمّان (إحداثيات قابلة لإعادة الاستعمال في
* أي بلد — الفارق بين البلدان هو التعرفة لا الجغرافيا؛ المسافة والزمن
* يأتيان من محرّك المسارات الحقيقي `MapsService.route`، لا من افتراض).
*
* النافذة الزمنية الفعلية تُحدَّد بلحظة تشغيل السكربت فعلاً — لا حقلاً في
* الطلب (`TariffEngine.quote` يستعمل `new Date()` الحقيقي). اختبار الذروة
* مقابل النافذة العادية مغطّى بوحدات على `tariff-seed.spec.ts`؛ هنا الهدف
* إثبات أن **الـAPI الحي** يرجع سعراً معقولاً بمسافة/زمن حقيقيَّين من انطلق.
*/
const RIDES = [
{
label: 'قصيرة (داخل الزرقاء)',
origin: { lat: 32.0728, lng: 36.0876 },
destination: { lat: 32.0895, lng: 36.1046 },
},
{
label: 'متوسطة (الزرقاء → عمّان)',
origin: { lat: 32.0728, lng: 36.0876 },
destination: { lat: 31.9539, lng: 35.9106 },
},
{
label: 'طويلة (الزرقاء → المطار)',
origin: { lat: 32.0728, lng: 36.0876 },
destination: { lat: 31.7226, lng: 35.9932 },
},
];
const TENANTS = [
{ slug: 'siro', countryPack: 'jo', currency: 'JOD', create: false }, // موجود أصلاً
{ slug: 'tripz-test-eg', countryPack: 'eg', currency: 'EGP', create: true, name: 'Tripz Test EG' },
{ slug: 'tripz-test-sy', countryPack: 'sy', currency: 'SYP', create: true, name: 'Tripz Test SY' },
];
// ---- عميل Redis أدنى (RESP على TCP خام) ----
// بلا `ioredis`: حاوية `node:22-alpine` عابرة بلا `node_modules`، وتثبيت
// حزمة لكل تشغيلة تبعية شبكة لا داعي لها لأمرَين اثنين (SELECT ثم GET).
const respCmd = (...args) =>
`*${args.length}\r\n` +
args.map((a) => `$${Buffer.byteLength(a)}\r\n${a}\r\n`).join('');
/** يفكّ ردّاً واحداً بدءاً من `i`؛ يعيد null إن لم يكتمل بعد. */
function parseReply(s, i) {
const type = s[i];
const end = s.indexOf('\r\n', i);
if (end === -1) return null;
const head = s.slice(i + 1, end);
if (type === '+' || type === '-' || type === ':') {
return { value: head, next: end + 2, error: type === '-' };
}
if (type === '$') {
const len = parseInt(head, 10);
if (len === -1) return { value: null, next: end + 2 }; // مفتاح غير موجود
const start = end + 2;
if (s.length < start + len + 2) return null;
return { value: s.slice(start, start + len), next: start + len + 2 };
}
return null;
}
function redisGet(key) {
return new Promise((resolve, reject) => {
const sock = net.createConnection({ host: REDIS_HOST, port: REDIS_PORT });
sock.setEncoding('utf8');
const timer = setTimeout(() => {
sock.destroy();
reject(new Error(`redis timeout (${REDIS_HOST}:${REDIS_PORT})`));
}, 5000);
sock.on('connect', () => {
sock.write(respCmd('SELECT', String(REDIS_DB)));
sock.write(respCmd('GET', key));
});
let buf = '';
sock.on('data', (chunk) => {
buf += chunk;
const first = parseReply(buf, 0); // ردّ SELECT
if (!first) return;
if (first.error) {
clearTimeout(timer);
sock.end();
return reject(new Error(`redis SELECT failed: ${first.value}`));
}
const second = parseReply(buf, first.next); // ردّ GET
if (!second) return;
clearTimeout(timer);
sock.end();
resolve(second.value);
});
sock.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
});
}
async function api(method, path, { token, tenant, body, platform } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
if (tenant) headers['x-tenant-id'] = tenant;
if (platform) headers['x-platform-secret'] = PLATFORM_SECRET;
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
let json = null;
try { json = await res.json(); } catch {}
return { status: res.status, body: json };
}
async function ensureTenant(t) {
if (!t.create) return;
const provisioned = await api('POST', '/admin/tenants/provision', {
platform: true,
body: {
name: t.name,
slug: t.slug,
countryPack: t.countryPack,
plan: 'launch',
},
});
if (provisioned.status >= 200 && provisioned.status < 300) {
console.log(`✅ أُنشئ مستأجر ${t.slug} (${t.countryPack})`);
} else if (/already taken|already exists|موجود/i.test(JSON.stringify(provisioned.body))) {
console.log(`ℹ️ ${t.slug} موجود أصلاً`);
} else {
console.error(`❌ فشل إنشاء ${t.slug}:`, provisioned.status, JSON.stringify(provisioned.body));
}
}
/**
* رقم دولي كامل صالح لكل بلد — **بلا "+"**، تماماً كما يخزّنه
* `PhoneService.normalize` (docs/17 — D1). الطول يجب أن يطابق
* `callingCode + nationalLength` بالضبط وإلا رُفض الرقم:
* الأردن/سوريا 962/963 + 9 أرقام محلية، مصر 20 + 10 أرقام محلية.
*/
function testPhone(countryPack, seed) {
const plans = {
jo: { code: '962', national: 9, prefix: '79' },
sy: { code: '963', national: 9, prefix: '93' },
eg: { code: '20', national: 10, prefix: '10' },
};
const plan = plans[countryPack] ?? plans.jo;
const remaining = plan.national - plan.prefix.length;
const digits = String(1_000_000_000 + seed).slice(-remaining);
return `${plan.code}${plan.prefix}${digits}`;
}
async function loginRider(tenantSlug, tenantId, countryPack, seed) {
const phone = testPhone(countryPack, seed);
// `send-otp` يخزّن الرمز في Redis **قبل** محاولة الإرسال، فحتى لو فشل
// الإرسال لرقم وهمي (503) يكون الرمز محفوظاً — وهو ما نقرأه.
let sent = await api('POST', '/auth/send-otp', { tenant: tenantSlug, body: { phone } });
// 429: `send-otp` محدود المعدّل (10 لكل 5 دقائق). تشغيلتان متتاليتان
// تصطدمان به، فننتظر بدل أن نُبلغ عن فشل ليس في النظام.
for (let wait = 0; sent.status === 429 && wait < 3; wait++) {
console.log(` ⏳ حدّ المعدّل على send-otp — انتظار 20 ثانية…`);
await new Promise((r) => setTimeout(r, 20000));
sent = await api('POST', '/auth/send-otp', { tenant: tenantSlug, body: { phone } });
}
if (sent.body?.dev_code) {
// وضع تطويري مفعّل (بيئة غير إنتاجية) — الرمز في الاستجابة مباشرةً.
return (await verify(tenantSlug, phone, sent.body.dev_code))?.access_token;
}
// المفتاح كما يبنيه `AuthService.otpKey` + `keyPrefix` من إعداد Redis،
// والمعرّف هو **UUID المستأجر** لا الـslug.
const key = `${REDIS_KEY_PREFIX}otp:${tenantId}:${phone}`;
let code;
try {
code = await redisGet(key);
} catch (e) {
console.error(` ❌ تعذّرت قراءة الرمز من Redis: ${e.message}`);
return undefined;
}
if (!code) {
console.error(
` ❌ لا رمز في Redis للمفتاح ${key} — ` +
`استجابة send-otp كانت ${sent.status} ${JSON.stringify(sent.body)}`,
);
return undefined;
}
const r = await verify(tenantSlug, phone, code);
if (!r?.access_token) {
console.error(` ❌ فشل تسجيل الدخول (${phone}): ${JSON.stringify(r)}`);
}
return r?.access_token;
}
async function verify(tenantSlug, phone, code) {
const r = await api('POST', '/auth/verify-otp', { tenant: tenantSlug, body: { phone, code } });
return r.body;
}
async function quoteFor(tenantSlug, token, serviceClass, ride) {
const res = await api('POST', '/trips', {
tenant: tenantSlug,
token,
body: {
origin: ride.origin,
destination: ride.destination,
service_class: serviceClass,
payment_method: 'cash',
},
});
return res;
}
async function main() {
console.log(`\n=== تسعير التعرفة الافتراضية — API حقيقي (${BASE}) ===`);
console.log(`وقت التشغيل: ${new Date().toISOString()} (UTC) — يحدّد أي نافذة تعرفة ستُختار فعلياً.\n`);
for (const t of TENANTS) await ensureTenant(t);
// مفتاح OTP في Redis يستعمل **UUID المستأجر** لا الـslug، فنجلب الخريطة مرة.
const all = await api('GET', '/admin/tenants', { platform: true });
const idBySlug = new Map((all.body ?? []).map((x) => [x.slug, x.id]));
if (!idBySlug.size) {
console.error(`❌ تعذّر جلب المستأجرين: ${all.status} ${JSON.stringify(all.body)}`);
process.exitCode = 1;
return;
}
const rows = [];
let rejectionsOk = true;
for (const t of TENANTS) {
console.log(`\n--- ${t.slug} (${t.countryPack} / ${t.currency}) ---`);
const tenantId = idBySlug.get(t.slug);
if (!tenantId) {
console.error(`❌ لا UUID للمستأجر ${t.slug} — تخطّي`);
continue;
}
let seed = 500 + TENANTS.indexOf(t) * 100;
const token = await loginRider(t.slug, tenantId, t.countryPack, seed++);
if (!token) {
console.error(`❌ تعذّر تسجيل دخول راكب اختبار لـ${t.slug} — تخطّي`);
continue;
}
// الفئات المسحوبة يجب أن تُرفض — تسريب الـ2.5 دينار كان هنا.
for (const serviceClass of REJECTED_CLASSES) {
const res = await quoteFor(t.slug, token, serviceClass, RIDES[2]); // الأطول
if (res.status === 400) {
console.log(` ✅ ${serviceClass} مرفوضة كما يجب (400)`);
} else {
console.error(
` ❌ ${serviceClass} لم تُرفض: ${res.status} — رحلة ${RIDES[2].label} بسعر ثابت تسريب.`,
);
rejectionsOk = false;
}
}
for (const ride of RIDES) {
for (const serviceClass of SERVICE_CLASSES) {
const res = await quoteFor(t.slug, token, serviceClass, ride);
const trip = res.body?.trip;
if (!trip) {
console.error(` ❌ ${serviceClass} / ${ride.label}: ${res.status} ${JSON.stringify(res.body)}`);
continue;
}
rows.push({
tenant: t.slug,
country: t.countryPack,
currency: t.currency,
ride: ride.label,
serviceClass,
distanceKm: trip.distance_km,
durationMin: trip.duration_min,
fare: trip.quoted_fare,
tariffFound: trip.quoted_fare != null,
});
}
}
}
console.log('\n=== النتائج ===\n');
// **حارس العدم**: النسخة الأولى طبعت «الاختبار ناجح» بعد أن فشل تسجيل
// الدخول وجُمع صفر صفّ — لأن كل تحقّق كان على مجموعة فارغة فمرّ فراغاً.
// اختبار يعلن النجاح وهو لم ينفّذ شيئاً أسوأ من اختبار يفشل: الفشل يُرى.
const expected = TENANTS.length * RIDES.length * SERVICE_CLASSES.length;
if (rows.length === 0) {
console.error('❌ لم يُنفَّذ ولا طلب واحد — لا نتيجة تُقرأ. راجع أخطاء تسجيل الدخول أعلاه.');
process.exitCode = 1;
return;
}
if (rows.length < expected) {
console.error(`⚠️ نُفِّذ ${rows.length} من ${expected} طلباً — التغطية ناقصة.`);
}
const noQuote = rows.filter((r) => !r.tariffFound);
if (noQuote.length) {
console.error(`❌ ${noQuote.length} طلب بلا سعر (تعرفة مفقودة) — هذا هو العطل الذي أصلحناه، فوجوده الآن خطأ حرج:`);
for (const r of noQuote) console.error(` ${r.tenant} / ${r.serviceClass} / ${r.ride}`);
} else {
console.log(`✅ كل الطلبات (${rows.length}) رجعت سعراً — لا رحلة مجانية.`);
}
for (const t of TENANTS) {
const forTenant = rows.filter((r) => r.tenant === t.slug);
if (!forTenant.length) continue;
console.log(`\n### ${t.slug} (${t.currency}) ###`);
for (const ride of RIDES) {
console.log(`\n ${ride.label}:`);
const forRide = forTenant.filter((r) => r.ride === ride.label);
if (forRide.length) {
console.log(` مسافة: ${forRide[0].distanceKm} كم · زمن: ${forRide[0].durationMin} دقيقة`);
}
for (const cls of SERVICE_CLASSES) {
const r = forRide.find((x) => x.serviceClass === cls);
console.log(` ${cls.padEnd(10)} ${r ? r.fare + ' ' + t.currency : '—'}`);
}
}
}
console.log(`\n=== التحقق: ترتيب الفئات لا ينعكس ===\n`);
let orderOk = true;
for (const t of TENANTS) {
for (const ride of RIDES) {
const forRide = rows.filter((r) => r.tenant === t.slug && r.ride === ride.label);
const economy = forRide.find((r) => r.serviceClass === 'economy')?.fare;
const van = forRide.find((r) => r.serviceClass === 'van')?.fare;
if (economy != null && van != null && van <= economy) {
orderOk = false;
console.error(`❌ ${t.slug} / ${ride.label}: الفان (${van}) ≤ الاقتصادي (${economy})`);
}
}
}
if (orderOk) console.log('✅ الفان أغلى من الاقتصادي في كل رحلة وكل بلد.');
// المقارنة تحتاج الفئتين معاً؛ غيابهما يعني تحقّقاً لم يقع لا تحقّقاً نجح.
const comparable = rows.filter((r) => ['economy', 'van'].includes(r.serviceClass)).length;
if (comparable === 0) console.error('⚠️ لا بيانات كافية للمقارنة — التحقّق أعلاه لم يقع فعلياً.');
const bad = noQuote.length > 0 || !orderOk || rows.length < expected || !rejectionsOk;
process.exitCode = bad ? 1 : 0;
console.log(bad ? '\n❌ فشل الاختبار.' : `\n✅ الاختبار ناجح (${rows.length} طلباً).`);
}
main().catch((e) => {
console.error('خطأ غير متوقع:', e);
process.exit(1);
});