feat: add SeedDemoDataCommand for seeding production-like demo data for testing representations, doctors, clinics, users, appointments, and commissions

This commit is contained in:
hamed
2026-07-09 08:07:41 +03:30
parent 0750bc9812
commit 0ad8487aac
16 changed files with 3454 additions and 1155 deletions
+800
View File
@@ -0,0 +1,800 @@
<?php
namespace App\Shared\Command;
use App\Config\Repository\SiteConfigRepository;
use App\Payment\Entity\Payment;
use App\Settlement\Service\CommissionService;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Uid\Uuid;
/**
* دیتای دمو production-like برای تست ماژول نمایندگان و نوبت‌دهی.
*
* Markerهای داده‌ی دمو (مبنای purge — با داده‌ی واقعی تداخل ندارند):
* نماینده‌ها: موبایل کاربر 09124000xxx
* بیماران: موبایل 09125xxxxxx
* پزشکان: موبایل کاربر 09126xxxxxx + medical_system_code از 100000
* کلینیک‌ها: موبایل کاربر 09127xxxxxx
* پرداخت‌ها: metadata JSON دارای "demo":true + order_id با پیشوند DEMO-
*
* درج حجیم با DBAL bulk INSERT (chunkهای ۵۰۰تایی)؛ کمیسیونِ سناریوهای match با همان
* فرمول CommissionService::settle مستقیم درج می‌شود و یک زیرمجموعه از مسیر واقعی
* سرویس رد می‌شود تا parity فرمول تضمین بماند.
*
* ddev exec php bin/console app:seed-demo-data --purge
*/
#[AsCommand(name: 'app:seed-demo-data', description: 'Seed production-like demo data (representations, doctors, clinics, users, appointments, commissions)')]
class SeedDemoDataCommand extends Command
{
private const CHUNK = 500;
private const GLOBAL_REP_DOMAINS = ['x-nobat.ir', 'global-doctor.ir', 'iran-doc.ir', 'salamat-nobat.ir', 'doc24.ir'];
private const FIRST_NAMES = ['علی', 'محمد', 'حسین', 'رضا', 'مهدی', 'امیر', 'سعید', 'حسن', 'مجید', 'احمد', 'مریم', 'زهرا', 'فاطمه', 'سارا', 'نرگس', 'لیلا', 'مینا', 'شیما', 'الهام', 'نسرین', 'کامران', 'بهرام', 'فرهاد', 'پیمان', 'آرش'];
private const LAST_NAMES = ['احمدی', 'محمدی', 'رضایی', 'کریمی', 'حسینی', 'موسوی', 'جعفری', 'صادقی', 'رحیمی', 'نجفی', 'قاسمی', 'هاشمی', 'اکبری', 'امینی', 'شریفی', 'توکلی', 'زارعی', 'مرادی', 'عباسی', 'فلاحی', 'نوری', 'سلطانی', 'کاظمی', 'یوسفی', 'باقری'];
private const SPECIALTY_NAMES = ['قلب', 'داخلی', 'ارتوپدی', 'پوست و مو', 'چشم', 'زنان', 'کودکان', 'دندانپزشکی', 'مغز و اعصاب', 'روانپزشکی'];
/** @var array<int,int> user_id نماینده → موجودی جاری کیف‌پول (برای balance_after) */
private array $walletBalance = [];
public function __construct(
private readonly Connection $db,
private readonly EntityManagerInterface $em,
private readonly SiteConfigRepository $configRepo,
private readonly CommissionService $commissionService,
private readonly string $environment,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('doctors', null, InputOption::VALUE_REQUIRED, 'تعداد پزشک', '500')
->addOption('clinics', null, InputOption::VALUE_REQUIRED, 'تعداد کلینیک', '200')
->addOption('users', null, InputOption::VALUE_REQUIRED, 'تعداد بیمار', '10000')
->addOption('appointments', null, InputOption::VALUE_REQUIRED, 'تعداد نوبت', '20000')
->addOption('purge', null, InputOption::VALUE_NONE, 'حذف داده‌ی دموی قبلی پیش از seed')
->addOption('force', null, InputOption::VALUE_NONE, 'اجازه‌ی اجرا روی prod');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if ($this->environment === 'prod' && !$input->getOption('force')) {
$io->error('این seeder برای محیط تست است؛ روی prod فقط با --force اجرا می‌شود.');
return Command::FAILURE;
}
$nDoctors = max(1, (int) $input->getOption('doctors'));
$nClinics = max(1, (int) $input->getOption('clinics'));
$nUsers = max(1, (int) $input->getOption('users'));
$nAppointments = max(1, (int) $input->getOption('appointments'));
mt_srand(42); // اجراهای تکرارپذیر
if ($input->getOption('purge')) {
$this->purge($io);
}
$cities = $this->db->fetchAllAssociative('SELECT id, name, domain FROM cities WHERE domain IS NOT NULL ORDER BY id');
if (count($cities) < 5) {
$io->error('جدول cities خالی است — اول app:seed-categories را اجرا کنید.');
return Command::FAILURE;
}
$specialtyIds = $this->resolveSpecialtyIds($io);
// کمیسیون‌ها باید فعال باشند تا مسیر واقعی سرویس هم ثبت کند.
$this->configRepo->set('appointment_commission_enabled', '1');
$this->configRepo->set('upgrade_commission_enabled', '1');
if ($this->configRepo->get('upgrade_commission_percent') === null) {
$this->configRepo->set('upgrade_commission_percent', '20');
}
$now = time();
$io->section('۱) نمایندگان');
$reps = $this->seedRepresentations($cities, $now);
$io->text(sprintf('%d شهری + %d سراسری', 20, 5));
$io->section('۲) بیماران');
$patientIds = $this->seedPatients($nUsers, $now, $io);
$io->section('۳) پزشکان + آدرس + برنامه هفتگی');
$doctors = $this->seedDoctors($nDoctors, $reps, $now, $specialtyIds, $io);
$io->section('۴) کلینیک‌ها');
$this->seedClinics($nClinics, $reps, $doctors, $now, $io);
$io->section('۵) نوبت‌ها + پرداخت‌ها + کمیسیون');
$scenarioCounts = $this->seedAppointments($nAppointments, $reps, $doctors, $patientIds, $now, $io);
$io->section('۶) اشتراک‌ها');
$subCounts = $this->seedSubscriptions($reps, $doctors, $now, $io);
$io->section('۷) parity با CommissionService واقعی');
$parityOk = $this->verifyParity($reps, $doctors, $patientIds, $now, $io);
$this->report($io, $scenarioCounts, $subCounts, $parityOk);
return $parityOk ? Command::SUCCESS : Command::FAILURE;
}
// ── purge ────────────────────────────────────────────────────────────────
private function purge(SymfonyStyle $io): void
{
$io->section('purge داده‌ی دموی قبلی');
// پرداخت‌های parity از مسیر واقعی سرویس order_id استاندارد ORD- می‌گیرند → marker اصلی metadata است.
$demoPayments = 'SELECT id FROM payments WHERE order_id LIKE "DEMO-%" OR metadata LIKE \'%"demo":true%\'';
$demoDoctors = 'SELECT id FROM doctors WHERE medical_system_code >= 100000 AND medical_system_code < 101000';
$demoUsers = "SELECT id FROM users WHERE mobile_number LIKE '09124000%' OR mobile_number LIKE '09125%' OR mobile_number LIKE '09126%' OR mobile_number LIKE '09127%'";
$steps = [
"DELETE FROM financial_breakdowns WHERE payment_id IN ($demoPayments)",
"DELETE FROM wallet_transactions WHERE payment_id IN ($demoPayments)",
"DELETE FROM clinic_subscriptions WHERE payment_id IN ($demoPayments)",
"DELETE FROM appointments WHERE doctor_id IN ($demoDoctors)",
"DELETE FROM payments WHERE order_id LIKE 'DEMO-%' OR metadata LIKE '%\"demo\":true%'",
"DELETE FROM weekly_schedules WHERE doctor_id IN ($demoDoctors)",
"DELETE FROM clinic_doctors WHERE doctor_id IN ($demoDoctors)",
"DELETE FROM doctor_specialties WHERE doctor_id IN ($demoDoctors)",
"DELETE FROM doctor_addresses WHERE doctor_id IN ($demoDoctors)",
"DELETE FROM clinics WHERE user_id IN (SELECT id FROM users WHERE mobile_number LIKE '09127%')",
"DELETE FROM doctors WHERE medical_system_code >= 100000 AND medical_system_code < 101000",
"DELETE FROM representations WHERE user_id IN (SELECT id FROM users WHERE mobile_number LIKE '09124000%')",
"DELETE FROM users WHERE id IN ($demoUsers)",
];
foreach ($steps as $sql) {
// MariaDB روی DELETE با subquery از همان جدول خطا می‌دهد → از جدول موقتِ derived استفاده کن.
$sql = preg_replace('/IN \((SELECT id FROM users[^)]*)\)$/', 'IN (SELECT id FROM (\1) AS t)', $sql) ?? $sql;
$this->db->executeStatement($sql);
}
$io->text('purge انجام شد.');
}
// ── سازنده‌های پایه ──────────────────────────────────────────────────────
/** درج bulk با chunk؛ rows = لیست ردیف‌های associative با کلیدهای یکسان. */
private function bulkInsert(string $table, array $rows): void
{
if ($rows === []) return;
$cols = array_keys($rows[0]);
$colSql = implode(', ', array_map(fn($c) => "`$c`", $cols));
foreach (array_chunk($rows, self::CHUNK) as $chunk) {
$placeholders = [];
$params = [];
foreach ($chunk as $row) {
$placeholders[] = '(' . implode(', ', array_fill(0, count($cols), '?')) . ')';
foreach ($cols as $c) $params[] = $row[$c];
}
$this->db->executeStatement(
"INSERT INTO `$table` ($colSql) VALUES " . implode(', ', $placeholders),
$params,
);
}
}
private function insertUser(string $mobile, string $name, array $roles, int $now): int
{
$this->db->insert('users', [
'uuid' => Uuid::v4()->toRfc4122(),
'mobile_number' => $mobile,
'real_name' => $name,
'roles' => json_encode($roles),
'national_code_verified' => 0,
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
return (int) $this->db->lastInsertId();
}
private function persianName(int $i): string
{
return self::FIRST_NAMES[$i % 25] . ' ' . self::LAST_NAMES[intdiv($i, 25) % 25];
}
private function resolveSpecialtyIds(SymfonyStyle $io): array
{
$rows = $this->db->fetchAllAssociative('SELECT id, name FROM specialties');
$byName = array_column($rows, 'id', 'name');
$ids = [];
foreach (self::SPECIALTY_NAMES as $name) {
foreach ($byName as $n => $id) {
if (str_contains((string) $n, $name)) { $ids[] = (int) $id; continue 2; }
}
}
if ($ids === []) {
$ids = array_map('intval', array_slice(array_column($rows, 'id'), 0, 10));
$io->warning('تخصص‌های خواسته‌شده در جدول نبود — از ۱۰ تخصص اول استفاده شد.');
}
return $ids;
}
// ── نمایندگان ────────────────────────────────────────────────────────────
/** @return array{city: array<int,array>, global: array<int,array>} */
private function seedRepresentations(array $cities, int $now): array
{
$cityReps = [];
$cityPool = $cities;
for ($i = 0; $i < 20; $i++) {
$name = 'نماینده ' . $this->persianName($i);
$userId = $this->insertUser(sprintf('09124000%03d', $i + 1), $name, ['ROLE_USER', 'ROLE_REPRESENTATION'], $now);
// ۱ تا ۳ شهر؛ شهر اصلی = دامنه‌ی نماینده (مدل واقعی نماینده‌ی شهری).
$count = 1 + ($i % 3);
$chosen = [];
for ($k = 0; $k < $count && $cityPool !== []; $k++) {
$idx = ($i * 3 + $k) % count($cityPool);
$chosen[] = $cityPool[$idx];
array_splice($cityPool, $idx, 1);
}
// استخر شهرِ اختصاص‌نیافته تمام شد → عضویت از لیست کامل، دامنه مصنوعی یکتا
// (دامنه‌ی شهری فقط وقتی که آن شهر هنوز نماینده‌ی دامنه‌دار ندارد).
$domain = $chosen !== [] ? $chosen[0]['domain'] : sprintf('demo-rep%02d.ir', $i + 1);
if ($chosen === []) $chosen[] = $cities[$i % count($cities)];
$this->db->insert('representations', [
'uuid' => Uuid::v4()->toRfc4122(),
'user_id' => $userId,
'full_name' => $name,
'mobile_number' => sprintf('09124000%03d', $i + 1),
'city_id' => $chosen[0]['id'],
'domain' => $domain,
'is_global' => 0,
'commission_percent' => (string) (8 + ($i % 8)),
'active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
$repId = (int) $this->db->lastInsertId();
foreach ($chosen as $c) {
$this->db->insert('representation_cities', ['representation_id' => $repId, 'city_id' => $c['id']]);
}
$this->walletBalance[$userId] = 0;
$cityReps[] = ['id' => $repId, 'user_id' => $userId, 'domain' => $domain, 'percent' => 8 + ($i % 8), 'cities' => array_column($chosen, 'id')];
}
$globalReps = [];
foreach (self::GLOBAL_REP_DOMAINS as $g => $domain) {
$name = 'نماینده سراسری ' . $this->persianName(20 + $g);
$userId = $this->insertUser(sprintf('09124000%03d', 100 + $g), $name, ['ROLE_USER', 'ROLE_REPRESENTATION'], $now);
$this->db->insert('representations', [
'uuid' => Uuid::v4()->toRfc4122(),
'user_id' => $userId,
'full_name' => $name,
'mobile_number' => sprintf('09124000%03d', 100 + $g),
'city_id' => null,
'domain' => $domain,
'is_global' => 1,
'commission_percent' => (string) (10 + $g),
'active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
$this->walletBalance[$userId] = 0;
$globalReps[] = ['id' => (int) $this->db->lastInsertId(), 'user_id' => $userId, 'domain' => $domain, 'percent' => 10 + $g, 'cities' => [$cities[$g]['id']]];
}
return ['city' => $cityReps, 'global' => $globalReps];
}
// ── بیماران ──────────────────────────────────────────────────────────────
/** @return int[] */
private function seedPatients(int $count, int $now, SymfonyStyle $io): array
{
$rows = [];
for ($i = 0; $i < $count; $i++) {
$rows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'mobile_number' => sprintf('09125%06d', $i),
'real_name' => $this->persianName($i),
'roles' => json_encode(['ROLE_USER']),
'national_code_verified' => 0,
'status' => 1,
'created_at' => $now - mt_rand(0, 180 * 86400),
'updated_at' => $now,
];
}
$this->bulkInsert('users', $rows);
$ids = $this->db->fetchFirstColumn("SELECT id FROM users WHERE mobile_number LIKE '09125%' ORDER BY id");
$io->text(count($ids) . ' بیمار');
return array_map('intval', $ids);
}
// ── پزشکان ───────────────────────────────────────────────────────────────
/** @return array<int,array{id:int,rep:?array,address_id:int,duration:int,user_id:int}> */
private function seedDoctors(int $count, array $reps, int $now, array $specialtyIds, SymfonyStyle $io): array
{
$allReps = array_merge($reps['city'], $reps['global']);
// توزیع مالکیت: ~۱۰٪ بدون نماینده؛ سراسری‌ها هر کدام سهم ثابت؛ بقیه بین شهری‌ها.
$userRows = [];
for ($i = 0; $i < $count; $i++) {
$userRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'mobile_number' => sprintf('09126%06d', $i),
'real_name' => 'دکتر ' . $this->persianName($i),
'roles' => json_encode(['ROLE_USER', 'ROLE_DOCTOR']),
'national_code_verified' => 0,
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
];
}
$this->bulkInsert('users', $userRows);
$doctorUserIds = array_map('intval', $this->db->fetchFirstColumn("SELECT id FROM users WHERE mobile_number LIKE '09126%' ORDER BY id"));
$doctorRows = [];
$ownership = [];
foreach ($doctorUserIds as $i => $userId) {
if ($i % 10 === 9) {
$rep = null; // بدون نماینده
} elseif ($i % 10 >= 7) {
$rep = $reps['global'][intdiv($i, 10) % 5]; // ~۲۰٪ سراسری، چرخشی بین هر ۵
} else {
$rep = $reps['city'][$i % 20]; // بقیه شهری
}
$ownership[$i] = $rep;
$doctorRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'user_id' => $userId,
'name' => 'دکتر ' . $this->persianName($i),
'gender' => $i % 3 === 0 ? 'woman' : 'man',
'medical_system_code' => (string) (100000 + $i),
'mobile_number' => sprintf('09126%06d', $i),
'degree' => ['specialist', 'general', 'subspecialist'][$i % 3],
'active_doctor_appointment' => $i % 7 === 6 ? 0 : 1,
'representation_id' => $rep['id'] ?? null,
'doctor_rate' => mt_rand(30, 50) / 10,
'doctor_rate_percentage' => mt_rand(50, 100),
'created_at' => $now,
'updated_at' => $now,
];
}
$this->bulkInsert('doctors', $doctorRows);
$doctorIds = array_map('intval', $this->db->fetchFirstColumn('SELECT id FROM doctors WHERE medical_system_code >= 100000 AND medical_system_code < 101000 ORDER BY id'));
// تخصص + آدرس + برنامه هفتگی
$specRows = $addrRows = [];
foreach ($doctorIds as $i => $docId) {
$specRows[] = ['doctor_id' => $docId, 'specialty_id' => $specialtyIds[$i % count($specialtyIds)]];
$rep = $ownership[$i];
$cityId = $rep !== null ? $rep['cities'][$i % count($rep['cities'])] : 108; // بدون نماینده → تهران
$addrRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'doctor_id' => $docId,
'name' => 'مطب',
'address' => 'خیابان اصلی، پلاک ' . ($i + 1),
'telephone' => sprintf('0219998%04d', $i),
'city_id' => $cityId,
'type' => 'personal',
'created_at' => $now,
'updated_at' => $now,
];
}
$this->bulkInsert('doctor_specialties', $specRows);
$this->bulkInsert('doctor_addresses', $addrRows);
$addrIds = array_map('intval', $this->db->fetchFirstColumn("SELECT id FROM doctor_addresses WHERE telephone LIKE '0219998%' ORDER BY doctor_id"));
$scheduleRows = [];
$doctors = [];
foreach ($doctorIds as $i => $docId) {
$duration = [15, 20, 30][$i % 3];
$days = 3 + ($i % 4); // ۳ تا ۶ روز کاری
$setting = [];
for ($d = 0; $d < 7; $d++) {
if ($d >= $days) continue;
$sessions = [$this->session('08:00', '14:00', $duration, $addrIds[$i])];
if ($i % 2 === 0) $sessions[] = $this->session('16:00', '21:00', $duration, $addrIds[$i]);
$setting[(string) $d] = ['sessions' => $sessions];
}
$scheduleRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'doctor_id' => $docId,
'setting' => json_encode($setting, JSON_UNESCAPED_UNICODE),
'created_at' => $now,
'updated_at' => $now,
];
$doctors[] = ['id' => $docId, 'rep' => $ownership[$i], 'address_id' => $addrIds[$i], 'duration' => $duration, 'user_id' => $doctorUserIds[$i]];
}
$this->bulkInsert('weekly_schedules', $scheduleRows);
$io->text(count($doctors) . ' پزشک + آدرس + برنامه هفتگی');
return $doctors;
}
private function session(string $start, string $end, int $duration, int $locationId): array
{
return [
'active' => true, 'location_id' => $locationId,
'start_time' => $start, 'end_time' => $end,
'duration_per_patient' => $duration,
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null,
];
}
// ── کلینیک‌ها ────────────────────────────────────────────────────────────
private function seedClinics(int $count, array $reps, array $doctors, int $now, SymfonyStyle $io): void
{
$allReps = array_merge($reps['city'], $reps['global']);
$userRows = [];
for ($i = 0; $i < $count; $i++) {
$userRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'mobile_number' => sprintf('09127%06d', $i),
'real_name' => 'مدیر کلینیک ' . ($i + 1),
'roles' => json_encode(['ROLE_USER', 'ROLE_CLINIC']),
'national_code_verified' => 0,
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
];
}
$this->bulkInsert('users', $userRows);
$clinicUserIds = array_map('intval', $this->db->fetchFirstColumn("SELECT id FROM users WHERE mobile_number LIKE '09127%' ORDER BY id"));
$clinicRows = [];
foreach ($clinicUserIds as $i => $userId) {
$rep = $allReps[$i % count($allReps)];
$clinicRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'user_id' => $userId,
'name' => 'کلینیک ' . self::LAST_NAMES[$i % 25] . ' ' . ($i + 1),
'address' => 'بلوار مرکزی، ساختمان ' . ($i + 1),
'telephone' => sprintf('0219997%04d', $i),
'city_id' => $rep['cities'][0],
'representation_id' => $rep['id'],
'is_active' => 1,
'is_24_7' => 0,
'created_at' => $now,
'updated_at' => $now,
];
}
$this->bulkInsert('clinics', $clinicRows);
$clinicIds = array_map('intval', $this->db->fetchFirstColumn("SELECT id FROM clinics WHERE telephone LIKE '0219997%' ORDER BY id"));
// اتصال ۱–۵ پزشکِ همان نماینده به هر کلینیک.
$byRep = [];
foreach ($doctors as $d) {
if ($d['rep'] !== null) $byRep[$d['rep']['id']][] = $d['id'];
}
$linkRows = [];
foreach ($clinicIds as $i => $clinicId) {
$rep = $allReps[$i % count($allReps)];
$pool = $byRep[$rep['id']] ?? [];
foreach (array_slice($pool, 0, 1 + ($i % 5)) as $docId) {
$linkRows[] = ['clinic_id' => $clinicId, 'doctor_id' => $docId];
}
}
// حذف تکراری‌ها (PK مرکب)
$seen = [];
$linkRows = array_values(array_filter($linkRows, function ($r) use (&$seen) {
$k = $r['clinic_id'] . '-' . $r['doctor_id'];
if (isset($seen[$k])) return false;
return $seen[$k] = true;
}));
$this->bulkInsert('clinic_doctors', $linkRows);
$io->text(count($clinicIds) . ' کلینیک + ' . count($linkRows) . ' اتصال پزشک');
}
// ── نوبت‌ها + پرداخت + کمیسیون ───────────────────────────────────────────
/** @return array<string,int> */
private function seedAppointments(int $count, array $reps, array $doctors, array $patientIds, int $now, SymfonyStyle $io): array
{
$allReps = array_merge($reps['city'], $reps['global']);
$fee = max(100000, (int) ($this->configRepo->get('appointment_fee_rials') ?: 1500000));
$smsFee = (int) ($this->configRepo->get('sms_panel_fee_rials') ?: 0);
$taxOn = $this->configRepo->get('tax_enabled') === '1';
$taxPct = $taxOn ? (float) $this->configRepo->get('tax_percent') : 0.0;
$today = strtotime('today');
$counts = ['match' => 0, 'mismatch' => 0, 'cancelled' => 0, 'pending' => 0];
$apptRows = $payRows = $bdRows = $wtRows = [];
$io->progressStart($count);
for ($i = 0; $i < $count; $i++) {
$doc = $doctors[$i % count($doctors)];
$dur = $doc['duration'] * 60;
$dayOffset = mt_rand(-60, 14);
$base = $today + $dayOffset * 86400 + 8 * 3600;
$slotStart = $base + mt_rand(0, intdiv(6 * 3600, $dur) - 1) * $dur;
$slotEnd = $slotStart + $dur;
$patient = $patientIds[$i % count($patientIds)];
$apptUuid = Uuid::v4()->toRfc4122();
$r = $i % 20;
if ($r < 12 && $doc['rep'] !== null) $scenario = 'match'; // 60%
elseif ($r < 15 && $doc['rep'] !== null) $scenario = 'mismatch'; // 15%
elseif ($r < 18) $scenario = 'cancelled'; // 15%
else $scenario = 'pending'; // 10%
if ($doc['rep'] === null && in_array($scenario, ['match', 'mismatch'], true)) $scenario = 'pending';
$counts[$scenario]++;
$status = match ($scenario) {
'match', 'mismatch' => $slotStart < $now ? 'completed' : 'confirmed',
'cancelled' => 'cancelled_by_user',
'pending' => 'pending',
};
$bookingRepId = null;
$domain = null;
if ($scenario === 'match') {
$domain = $doc['rep']['domain'];
$bookingRepId = $doc['rep']['id'];
} elseif ($scenario === 'mismatch') {
$other = $allReps[($i + 7) % count($allReps)];
if ($other['id'] === $doc['rep']['id']) $other = $allReps[($i + 8) % count($allReps)];
$domain = $other['domain'];
$bookingRepId = $other['id'];
}
$apptRows[] = [
'uuid' => $apptUuid, 'version' => 1,
'slot_start' => $slotStart, 'slot_end' => $slotEnd, 'status' => $status,
'doctor_id' => $doc['id'], 'user_id' => $patient,
'patient_name' => $this->persianName($i), 'patient_mobile' => sprintf('09125%06d', $i % count($patientIds)),
'patient_national_code' => sprintf('%010d', 1000000000 + $i), 'patient_gender' => $i % 2 ? 'man' : 'woman',
'address_id' => $doc['address_id'], 'booking_representation_id' => $bookingRepId,
'created_at' => min($slotStart, $now) - 3600, 'updated_at' => $now,
];
if ($scenario === 'match' || $scenario === 'mismatch') {
$payRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'order_id' => 'DEMO-A' . $i,
'amount_rials' => $fee, 'status' => 'success', 'gateway' => 'mock', 'type' => 'appointment',
'reference_id' => 'DEMOREF-A' . $i,
'frontend_address' => 'https://' . $domain . '/payment/result',
'user_id' => $patient,
'metadata' => json_encode(['demo' => true, 'scenario' => $scenario, 'appt_uuid' => $apptUuid]),
'created_at' => min($slotStart, $now) - 3000, 'updated_at' => $now,
];
if ($scenario === 'match') {
$rep = $doc['rep'];
[$bd, $wt] = $this->breakdownRows('appointment', $fee, $smsFee, $taxOn, $taxPct, (float) $rep['percent'], $rep, $doc['id'], null, $patient, $now, 'DEMO-A' . $i);
$bdRows[] = $bd;
if ($wt !== null) $wtRows[] = $wt;
}
}
$io->progressAdvance();
}
$io->progressFinish();
$this->bulkInsert('appointments', $apptRows);
$this->bulkInsert('payments', $payRows);
// payment_id واقعی را به breakdownها وصل کن (بر اساس order_id).
$payIdByOrder = [];
foreach ($this->db->fetchAllAssociative("SELECT id, order_id FROM payments WHERE order_id LIKE 'DEMO-A%'") as $p) {
$payIdByOrder[$p['order_id']] = (int) $p['id'];
}
foreach ($bdRows as &$bd) { $bd['payment_id'] = $payIdByOrder[$bd['payment_id']]; }
unset($bd);
foreach ($wtRows as &$wt) { $wt['payment_id'] = $payIdByOrder[$wt['payment_id']]; }
unset($wt);
$this->bulkInsert('financial_breakdowns', $bdRows);
$this->bulkInsert('wallet_transactions', $wtRows);
$io->text(sprintf('نوبت: %d | پرداخت: %d | کمیسیون match: %d', count($apptRows), count($payRows), count($bdRows)));
return $counts;
}
/**
* همان فرمول CommissionService::settle — parity در verifyParity() چک می‌شود.
* payment_id موقتاً order_id است و بعد از درج پرداخت‌ها resolve می‌شود.
* @return array{0: array, 1: ?array}
*/
private function breakdownRows(string $source, int $gross, int $smsFee, bool $taxOn, float $taxPct, float $percent, array $rep, ?int $doctorId, ?int $clinicId, int $userId, int $now, string $orderRef): array
{
$afterSms = max(0, $gross - $smsFee);
$taxRials = ($taxOn && $taxPct > 0) ? (int) round($afterSms * $taxPct / (100 + $taxPct)) : 0;
$netAfterTax = $afterSms - $taxRials;
$repShare = (int) round($netAfterTax * $percent / 100);
$bd = [
'uuid' => Uuid::v4()->toRfc4122(), 'source' => $source,
'gross_rials' => $gross, 'sms_fee_rials' => $smsFee,
'tax_percent' => number_format($taxPct, 2, '.', ''), 'tax_rials' => $taxRials,
'net_after_tax_rials' => $netAfterTax,
'commission_percent' => number_format($percent, 2, '.', ''),
'representation_share_rials' => $repShare,
'system_share_rials' => $gross - $smsFee - $taxRials - $repShare,
'representation_id' => $rep['id'], 'doctor_id' => $doctorId, 'clinic_id' => $clinicId,
'user_id' => $userId, 'payment_id' => $orderRef, 'created_at' => $now,
];
$wt = null;
if ($repShare > 0) {
$this->walletBalance[$rep['user_id']] += $repShare;
$wt = [
'uuid' => Uuid::v4()->toRfc4122(),
'amount_rials' => $repShare, 'type' => 'credit',
'description' => 'پورسانت ' . $source . ' ' . $orderRef,
'balance_after' => $this->walletBalance[$rep['user_id']],
'user_id' => $rep['user_id'], 'payment_id' => $orderRef, 'created_at' => $now,
];
}
return [$bd, $wt];
}
// ── اشتراک‌ها ────────────────────────────────────────────────────────────
/** @return array<string,int> */
private function seedSubscriptions(array $reps, array $doctors, int $now, SymfonyStyle $io): array
{
$period = $this->db->fetchAssociative('SELECT id, plan_id FROM subscription_periods ORDER BY id LIMIT 1');
if ($period === false) {
$io->warning('subscription_periods خالی است — اشتراک seed نشد.');
return ['sub_match' => 0, 'sub_mismatch' => 0];
}
$allReps = array_merge($reps['city'], $reps['global']);
$owned = array_values(array_filter($doctors, fn($d) => $d['rep'] !== null));
$smsFee = (int) ($this->configRepo->get('sms_panel_fee_rials') ?: 0);
$taxOn = $this->configRepo->get('tax_enabled') === '1';
$taxPct = $taxOn ? (float) $this->configRepo->get('tax_percent') : 0.0;
$upPct = (float) $this->configRepo->get('upgrade_commission_percent');
$amount = 5000000;
$payRows = $subRows = $bdRows = $wtRows = [];
$counts = ['sub_match' => 0, 'sub_mismatch' => 0];
for ($i = 0; $i < 100 && $i < count($owned); $i++) {
$doc = $owned[$i * 3 % count($owned)];
$match = $i % 2 === 0;
$rep = $doc['rep'];
$domain = $match ? $rep['domain'] : $allReps[($i + 9) % count($allReps)]['domain'];
if (!$match && $domain === $rep['domain']) $domain = $allReps[($i + 10) % count($allReps)]['domain'];
$counts[$match ? 'sub_match' : 'sub_mismatch']++;
$payRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'order_id' => 'DEMO-S' . $i,
'amount_rials' => $amount, 'status' => 'success', 'gateway' => 'mock', 'type' => 'subscription',
'reference_id' => 'DEMOREF-S' . $i,
'frontend_address' => 'https://' . $domain . '/panel',
'user_id' => $doc['user_id'],
'metadata' => json_encode(['demo' => true, 'scenario' => $match ? 'sub_match' : 'sub_mismatch']),
'created_at' => $now - mt_rand(0, 30 * 86400), 'updated_at' => $now,
];
$subRows[] = [
'uuid' => Uuid::v4()->toRfc4122(),
'entity_type' => 'doctor', 'entity_id' => $doc['id'], 'is_trial' => 0,
'starts_at' => $now - 86400, 'expires_at' => $now + 30 * 86400,
'plan_id' => (int) $period['plan_id'], 'period_id' => (int) $period['id'],
'payment_id' => 'DEMO-S' . $i, 'created_at' => $now,
];
if ($match) {
[$bd, $wt] = $this->breakdownRows('subscription', $amount, $smsFee, $taxOn, $taxPct, $upPct, $rep, $doc['id'], null, $doc['user_id'], $now, 'DEMO-S' . $i);
$bdRows[] = $bd;
if ($wt !== null) $wtRows[] = $wt;
}
}
$this->bulkInsert('payments', $payRows);
$payIdByOrder = [];
foreach ($this->db->fetchAllAssociative("SELECT id, order_id FROM payments WHERE order_id LIKE 'DEMO-S%'") as $p) {
$payIdByOrder[$p['order_id']] = (int) $p['id'];
}
foreach ($subRows as &$s) { $s['payment_id'] = $payIdByOrder[$s['payment_id']]; }
unset($s);
foreach ($bdRows as &$bd) { $bd['payment_id'] = $payIdByOrder[$bd['payment_id']]; }
unset($bd);
foreach ($wtRows as &$wt) { $wt['payment_id'] = $payIdByOrder[$wt['payment_id']]; }
unset($wt);
$this->bulkInsert('clinic_subscriptions', $subRows);
$this->bulkInsert('financial_breakdowns', $bdRows);
$this->bulkInsert('wallet_transactions', $wtRows);
$io->text(sprintf('اشتراک: %d (match: %d، mismatch: %d)', count($subRows), $counts['sub_match'], $counts['sub_mismatch']));
return $counts;
}
// ── parity با سرویس واقعی ────────────────────────────────────────────────
private function verifyParity(array $reps, array $doctors, array $patientIds, int $now, SymfonyStyle $io): bool
{
$fee = max(100000, (int) ($this->configRepo->get('appointment_fee_rials') ?: 1500000));
$ok = true;
$sample = array_values(array_filter($doctors, fn($d) => $d['rep'] !== null));
for ($i = 0; $i < 100 && $i < count($sample); $i++) {
$doc = $sample[$i];
$rep = $doc['rep'];
$user = $this->em->getReference(\App\Auth\Entity\User::class, $patientIds[$i]);
$payment = new Payment($user, $fee, 'mock', Payment::TYPE_APPOINTMENT, 'https://' . $rep['domain'] . '/payment/result');
$payment->setMetadata(['demo' => true, 'scenario' => 'match-service']);
$this->em->persist($payment);
$this->em->flush();
$this->commissionService->processAppointment($payment, $rep['id'], $rep['id'], $doc['id']);
$share = $this->db->fetchOne('SELECT representation_share_rials FROM financial_breakdowns WHERE payment_id = ?', [$payment->getId()]);
$expected = $this->expectedShare($fee, (float) $rep['percent']);
if ((int) $share !== $expected) {
$io->error(sprintf('parity شکست: سرویس=%s، فرمول bulk=%d (rep %d)', var_export($share, true), $expected, $rep['id']));
$ok = false;
break;
}
$this->em->clear();
}
if ($ok) $io->text('parity تایید شد — فرمول bulk == CommissionService (۱۰۰ نمونه).');
return $ok;
}
private function expectedShare(int $gross, float $percent): int
{
$smsFee = (int) ($this->configRepo->get('sms_panel_fee_rials') ?: 0);
$afterSms = max(0, $gross - $smsFee);
$taxOn = $this->configRepo->get('tax_enabled') === '1';
$taxPct = $taxOn ? (float) $this->configRepo->get('tax_percent') : 0.0;
$taxRials = ($taxOn && $taxPct > 0) ? (int) round($afterSms * $taxPct / (100 + $taxPct)) : 0;
return (int) round(($afterSms - $taxRials) * $percent / 100);
}
// ── گزارش ────────────────────────────────────────────────────────────────
private function report(SymfonyStyle $io, array $scenarioCounts, array $subCounts, bool $parityOk): void
{
$io->section('گزارش نهایی');
$tables = ['representations', 'representation_cities', 'doctors', 'clinics', 'clinic_doctors', 'weekly_schedules', 'users', 'appointments', 'payments', 'clinic_subscriptions', 'financial_breakdowns', 'wallet_transactions'];
$rows = [];
foreach ($tables as $t) {
$rows[] = [$t, number_format((int) $this->db->fetchOne("SELECT COUNT(*) FROM `$t`"))];
}
$io->table(['جدول', 'تعداد کل'], $rows);
$io->table(['سناریو', 'تعداد'], array_map(fn($k, $v) => [$k, $v], array_keys($scenarioCounts + $subCounts), array_values($scenarioCounts + $subCounts)));
$mismatchLeak = (int) $this->db->fetchOne(
"SELECT COUNT(*) FROM financial_breakdowns b JOIN payments p ON p.id = b.payment_id
WHERE p.metadata LIKE '%\"scenario\":\"mismatch\"%' OR p.metadata LIKE '%\"scenario\":\"sub_mismatch\"%'"
);
$walletParity = $this->db->fetchOne(
'SELECT (SELECT COALESCE(SUM(representation_share_rials),0) FROM financial_breakdowns)
= (SELECT COALESCE(SUM(amount_rials),0) FROM wallet_transactions WHERE type = "credit")'
);
$io->listing([
'کمیسیون نشت‌کرده به سناریوهای mismatch: ' . $mismatchLeak . ' (باید 0 باشد)',
'برابری مجموع سهم نماینده و کیف‌پول: ' . ($walletParity ? 'OK' : 'FAIL'),
'parity فرمول با CommissionService: ' . ($parityOk ? 'OK' : 'FAIL'),
]);
$io->section('سناریوهای تست دستی');
$io->listing([
'GET /api/v1/doctors?domain=x-nobat.ir → فقط پزشکان نماینده سراسری اول',
'GET /api/v1/doctors?domain=<دامنه شهر نماینده اول> → رفتار شهری عادی',
'GET /api/v1/site-context?domain=x-nobat.ir → type=representation و is_global=true',
'ورود با موبایل 09124000001 (نماینده ۱) → داشبورد نماینده: درآمد > 0',
'پنل ادمین → نمایندگان → badge «سراسری» روی ۵ نماینده و ستون شهرهای چندتایی',
]);
}
}