feat: استيراد كود سيرو إلى تريبز (سيرو @ecfe7568) — بلا تعديل

قرار المالك 2026-07-27: باك إند سيرو PHP هو المعتمد، وتطبيقاته المجرّبة
ميدانياً تحل محل إعادة البناء المؤرشفة. سيرو نفسه لم يُمسّ.

الخريطة:
  backend · payment_server · loction_server · ride_server ·
  passenger_server · docker · dashboard · stress_test  → الجذر
  siro_rider  → apps/rider          siro_driver  → apps/driver
  siro_admin  → dashboards/admin    siro_service → dashboards/service
  android_bot → apps/android_bot    socialBot    → apps/socialBot

نُسخ المتعقَّب في git سيرو فقط عبر `git archive` (3,198 ملفاً / ~169 م.ب)
لا `cp -r` — فاستُثنيت مخلفات البناء تلقائياً. بلا أي تعديل محتوى عمداً:
كل ما يلي يصير فرقاً مقروءاً مقابل المصدر.

لم يُستورد وسببه: siromove.com (الموقع التسويقي يبقى marketing/ في تريبز،
سيرو فيه 8 ملفات) · docs و planning (تريبز له docs/ الخاص) · deploy.sh
(ليس نشراً على سيرفر بل `git add . && git push origin --all` — فخّ في
مستودع آخر) · transit_dashboard (بانتظار قرار مصير backend-transit و
dashboards/transit-web).

⚠️ لا يبني بعد — ثلاثة نواقص متوقعة ومقصودة:
1. `.env` و `lib/env/env.g.dart` غير متعقَّبين في سيرو (أسرار لكل مستأجر):
   كل تطبيق فلاتر يحتاج .env خاصاً ثم توليد env.g.dart بـ build_runner.
2. إعدادات Firebase (9 ملفات google-services.json و GoogleService-Info.plist)
   يستبعدها .gitignore تريبز — ولكل مستأجر مشروع Firebase خاص أصلاً.
3. apps/driver في سيرو يشير إلى `../../Intaleq/packages/get` خارج المستودع →
   يجب ضمّ الحزم داخله أسوة بـ apps/rider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-27 05:14:13 +03:00
co-authored by Claude Opus 5
parent 9909d9b4c1
commit 4d8414c96b
3198 changed files with 766859 additions and 0 deletions
+331
View File
@@ -0,0 +1,331 @@
/**
* Siro Pricing Engine CLI
*
* Usage:
* npm run analyze Full analysis all competitors
* npm run analyze:taxif TaxiF only
* npm run analyze -- --competitor=com.taxif.passenger --country=JO
* npm run dev -- --mode=surge Surge-only analysis
*
* Cron integration: see crontab examples in package.json scripts
*/
import { getMySQL, fetchSamples, saveFormulas, saveFormulaHistory, saveSurgeInsights, saveSurgeZones, closeConnections } from './db/connection';
import { runAnalysis } from './analysis/engine';
import { Pool, RowDataPacket } from 'mysql2/promise';
interface CLIOptions {
mode: 'full' | 'report';
competitor?: string;
country?: string;
hoursBack?: number;
}
function parseArgs(): CLIOptions {
const args = process.argv.slice(2);
const opts: CLIOptions = { mode: 'full' };
for (const arg of args) {
if (arg.startsWith('--mode=')) {
const mode = arg.split('=')[1];
if (mode === 'full' || mode === 'report') {
opts.mode = mode;
}
} else if (arg.startsWith('--competitor=')) {
opts.competitor = arg.split('=')[1];
} else if (arg.startsWith('--country=')) {
opts.country = arg.split('=')[1];
} else if (arg.startsWith('--hours=')) {
opts.hoursBack = parseInt(arg.split('=')[1]);
}
}
return opts;
}
interface CompetitorEntry {
competitor_name: string;
country_code: string;
}
async function main(): Promise<void> {
const opts = parseArgs();
const startTime = Date.now();
console.log(`🚀 Siro Pricing Engine v1.1`);
console.log(` Mode: ${opts.mode}`);
if (opts.competitor) console.log(` Competitor: ${opts.competitor}`);
if (opts.country) console.log(` Country: ${opts.country}`);
console.log('');
try {
const pool = await getMySQL();
const competitors = await fetchCompetitors(pool, opts);
if (competitors.length === 0) {
console.log('❌ No competitors found with sufficient data.');
return;
}
// Process competitors in parallel for speed
const results = await Promise.allSettled(
competitors.map(comp => processCompetitor(pool, comp, opts))
);
const succeeded = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\n✨ Analysis complete in ${elapsed}s (${succeeded} succeeded, ${failed} failed)`);
if (failed > 0) {
console.log('\n❌ Failures:');
results.forEach((r, i) => {
if (r.status === 'rejected') {
console.log(` ${competitors[i].competitor_name} (${competitors[i].country_code}): ${r.reason}`);
}
});
}
} catch (err) {
console.error('❌ Fatal error:', err);
process.exit(1);
} finally {
await closeConnections();
}
}
/**
* Compute the peak hours array from surge pattern data.
* Returns the longest contiguous block of hours where avg multiplier > 1.05.
* Used by both formula saving and surge insight saving.
*/
function computePeakHours(surgePatterns: Array<{ surgePrices: Array<{ time: string; multiplier: number }> }>): {
peakHours: number[];
peakStart: number;
peakEnd: number;
} {
// Aggregate all route multipliers per hour of day
const hourMults = new Map<number, number[]>();
for (const sr of surgePatterns) {
for (const sp of sr.surgePrices) {
const h = parseInt(sp.time.split(':')[0]);
if (isNaN(h)) continue;
if (!hourMults.has(h)) hourMults.set(h, []);
hourMults.get(h)!.push(sp.multiplier);
}
}
// Keep only hours where the average multiplier exceeds 1.05
const peakHours: number[] = [];
for (const [h, mults] of hourMults) {
const avg = mults.reduce((a, b) => a + b, 0) / mults.length;
if (avg > 1.05) peakHours.push(h);
}
peakHours.sort((a, b) => a - b);
// Find the longest contiguous block of peak hours
let bestStart = 0, bestEnd = 0, bestLen = 0;
let curStart = -1, curEnd = -1;
for (let i = 0; i < peakHours.length; i++) {
if (curStart < 0) {
curStart = peakHours[i];
curEnd = peakHours[i];
} else if (peakHours[i] === curEnd + 1) {
curEnd = peakHours[i];
} else {
if (curEnd - curStart > bestLen) {
bestLen = curEnd - curStart;
bestStart = curStart;
bestEnd = curEnd;
}
curStart = peakHours[i];
curEnd = peakHours[i];
}
}
if (curEnd - curStart > bestLen) {
bestLen = curEnd - curStart;
bestStart = curStart;
bestEnd = curEnd;
}
const peakStart = bestLen > 0 ? bestStart : 0;
const peakEnd = bestLen > 0 ? bestEnd : 23;
return { peakHours, peakStart, peakEnd };
}
async function processCompetitor(
pool: Pool,
comp: CompetitorEntry,
opts: CLIOptions
): Promise<void> {
console.log(`\n📥 Fetching data for ${comp.competitor_name} (${comp.country_code})...`);
const rows = await fetchSamples(pool, comp.competitor_name, comp.country_code, opts.hoursBack);
if (rows.length < 10) {
console.log(` ⏩ Only ${rows.length} samples — skipping (need 10+)`);
return;
}
const samples = rows.map((row: RowDataPacket) => ({
distance_km: parseFloat(row.distance_km),
duration_min: parseFloat(row.duration_min),
price: parseFloat(row.price_amount),
ppk: parseFloat(row.price_per_km),
startLat: parseFloat(row.start_lat),
startLng: parseFloat(row.start_lng),
endLat: parseFloat(row.end_lat),
endLng: parseFloat(row.end_lng),
scrapedAt: new Date(row.scraped_at),
competitorName: row.competitor_name,
countryCode: row.country_code,
}));
// Known receipts for formula validation.
// Add real receipts here as they are collected — the engine will print
// predicted vs actual with % error so you can judge formula quality at a glance.
const knownReceipts = comp.competitor_name === 'com.taxif.passenger' ? [
{
label: 'TaxiF receipt 2026-06-11 (Amman)',
distanceKm: 2.17,
durationMin: 6 + 38 / 60, // 6 min 38 sec
actualPrice: 1.15, // 1.18 JOD total − 0.03 BookingFee
},
] : [];
const report = await runAnalysis(samples, {
competitorName: comp.competitor_name,
countryCode: comp.country_code,
cleanOutliers: true,
// surgeThresholdFraction defaults to 0.05 (5% of median price) — currency-agnostic
tierCount: 3,
knownReceipts,
});
// --- Compute peak hours once, reuse in both formulas and surge insights ---
const { peakHours, peakStart, peakEnd } = report.surgePatterns.length > 0
? computePeakHours(report.surgePatterns)
: { peakHours: [], peakStart: 0, peakEnd: 23 };
const peakHoursJson = JSON.stringify(peakHours);
// --- Save tier formulas (includes actual peak hours) ---
const formulas = report.tiers
.filter(t => t.regression !== null && t.regression!.sampleCount >= 5)
.map(tier => ({
competitorName: comp.competitor_name,
countryCode: comp.country_code,
tier: tier.label,
baseFare: tier.regression!.baseFare,
kmRate: tier.regression!.kmRate,
minRate: tier.regression!.minRate,
minFare: tier.regression!.minFare,
rmse: tier.regression!.rmse,
rSquared: tier.regression!.rSquared,
sampleCount: tier.regression!.sampleCount,
surgeMultiplier: 1.0,
// Now populated with real peak hours instead of always '[]'
peakHours: peakHoursJson,
}));
if (formulas.length > 0) {
await saveFormulas(pool, formulas);
console.log(` ✅ Saved ${formulas.length} tier formulas`);
if (peakHours.length > 0) {
console.log(` Peak hours stored: [${peakHours.join(', ')}]`);
}
// لقطة insert-only لمحرك الثبات (drift detection) — لا تُستبدل أبداً
await saveFormulaHistory(pool, formulas.map(f => ({
competitorName: f.competitorName,
countryCode: f.countryCode,
tier: f.tier,
baseFare: f.baseFare,
kmRate: f.kmRate,
minRate: f.minRate,
minFare: f.minFare,
rSquared: f.rSquared,
sampleCount: f.sampleCount,
})));
}
// --- Save surge insights ---
if (opts.mode !== 'report' && report.surgePatterns.length > 0) {
const avgMultiplier = report.surgePatterns
.reduce((sum, sr) => sum + sr.maxMultiplier, 0) / report.surgePatterns.length;
const surgeInsights = [{
competitorName: comp.competitor_name,
countryCode: comp.country_code,
surgeMultiplier: Math.round(avgMultiplier * 1000) / 1000,
peakStartHour: peakStart,
peakEndHour: peakEnd,
sampleCount: report.surgePatterns.length,
}];
await saveSurgeInsights(pool, surgeInsights);
console.log(` ✅ Saved surge insight: avg ${avgMultiplier.toFixed(3)}x, hours ${peakStart}:00-${peakEnd}:00`);
}
// --- Save surge zones ---
if (opts.mode !== 'report' && report.zones.length > 0) {
// Find the standard tier formula to use as a baseline for calculating surge multipliers
const standardTier = formulas.find(f => f.tier === 'standard') || formulas[0];
const baselinePpk = standardTier ? standardTier.kmRate : 0.350;
const surgeZones = report.zones
.filter(z => z.avgPpk > baselinePpk * 1.1) // Only keep zones with > 10% surge
.map(z => ({
competitorName: comp.competitor_name,
countryCode: comp.country_code,
zoneKey: z.zoneKey,
latitude: z.centerLat,
longitude: z.centerLng,
avgPpk: z.avgPpk,
sampleCount: z.samples.length,
surgeMultiplier: parseFloat((z.avgPpk / baselinePpk).toFixed(3)),
}));
if (surgeZones.length > 0) {
await saveSurgeZones(pool, surgeZones);
console.log(` ✅ Saved ${surgeZones.length} surge zones for heatmap`);
}
}
}
async function fetchCompetitors(
pool: Pool,
opts: CLIOptions
): Promise<CompetitorEntry[]> {
if (opts.competitor) {
const countryClause = opts.country ? 'AND country_code = ?' : '';
const params: (string | number)[] = opts.country
? [opts.competitor, opts.country]
: [opts.competitor];
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT DISTINCT competitor_name, country_code
FROM scraped_competitor_prices
WHERE competitor_name = ?
AND distance_km > 0 AND duration_min > 0 AND price_amount > 0
${countryClause}
LIMIT 10`,
params
);
return rows as CompetitorEntry[];
}
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT competitor_name, country_code, COUNT(*) as cnt
FROM scraped_competitor_prices
WHERE distance_km > 0 AND duration_min > 0 AND price_amount > 0
GROUP BY competitor_name, country_code
HAVING cnt >= 10
ORDER BY cnt DESC`
);
return rows as CompetitorEntry[];
}
main();