feat: add SeedDemoDataCommand for seeding production-like demo data for testing representations, doctors, clinics, users, appointments, and commissions
This commit is contained in:
@@ -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 «سراسری» روی ۵ نماینده و ستون شهرهای چندتایی',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user