Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
92 / 92 |
|
100.00% |
4 / 4 |
CRAP | |
100.00% |
1 / 1 |
| ContactDataSeeder | |
100.00% |
91 / 91 |
|
100.00% |
4 / 4 |
16 | |
100.00% |
1 / 1 |
| transliterate | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| seed | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
3 | |||
| generateSingle | |
100.00% |
52 / 52 |
|
100.00% |
1 / 1 |
9 | |||
| insertBatch | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Installer; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use DateTimeImmutable; |
| 12 | use PDO; |
| 13 | use Random\Randomizer; |
| 14 | |
| 15 | /** |
| 16 | * High-performance Zero-Dependency Random Data Seeder for Contacts module. |
| 17 | * |
| 18 | * Generates realistic Polish business contacts with gender-consistent names, |
| 19 | * valid contact details, addresses, and status distribution in bulk batches. |
| 20 | * |
| 21 | * @package App\Core\Installer |
| 22 | */ |
| 23 | final readonly class ContactDataSeeder |
| 24 | { |
| 25 | /** @var string Polish country name constant. */ |
| 26 | private const string COUNTRY_POLAND = 'Polska'; |
| 27 | |
| 28 | /** @var array<int, string> Male first names list. */ |
| 29 | private const array MALE_FIRST_NAMES = [ |
| 30 | 'Jan', 'Piotr', 'Krzysztof', 'Tomasz', 'Paweł', 'Michał', 'Marcin', 'Jakub', |
| 31 | 'Adam', 'Mateusz', 'Łukasz', 'Grzegorz', 'Wojciech', 'Dariusz', 'Mariusz', |
| 32 | 'Rafał', 'Kamil', 'Maciej', 'Robert', 'Bartłomiej', 'Artur', 'Szymon', |
| 33 | 'Filip', 'Sebastian', 'Dominik', 'Adrian', 'Karol', 'Damian', 'Przemysław', |
| 34 | 'Radosław', 'Andrzej', 'Marek', 'Tadeusz', 'Jerzy', 'Zbigniew', 'Czesław', |
| 35 | ]; |
| 36 | |
| 37 | /** @var array<int, string> Female first names list. */ |
| 38 | private const array FEMALE_FIRST_NAMES = [ |
| 39 | 'Anna', 'Maria', 'Katarzyna', 'Małgorzata', 'Agnieszka', 'Barbara', 'Ewa', |
| 40 | 'Magdalena', 'Elżbieta', 'Joanna', 'Aleksandra', 'Monika', 'Teresa', 'Danuta', |
| 41 | 'Natalia', 'Karolina', 'Marta', 'Beata', 'Dorota', 'Justyna', 'Paulina', |
| 42 | 'Iwona', 'Patrycja', 'Kamila', 'Weronika', 'Klaudia', 'Kinga', 'Dominika', |
| 43 | 'Sylwia', 'Emilia', 'Zofia', 'Wiktoria', 'Iga', 'Oliwia', 'Julia', 'Alicja', |
| 44 | ]; |
| 45 | |
| 46 | /** @var array<int, string> Male last names list. */ |
| 47 | private const array MALE_LAST_NAMES = [ |
| 48 | 'Nowak', 'Kowalski', 'Wiśniewski', 'Wójcik', 'Kowalczyk', 'Kamiński', |
| 49 | 'Lewandowski', 'Zieliński', 'Szymański', 'Woźniak', 'Kozłowski', 'Jankowski', |
| 50 | 'Mazur', 'Wojciechowski', 'Kwiatkowski', 'Krawczyk', 'Kaczmarek', 'Piotrowski', |
| 51 | 'Grabowski', 'Zając', 'Pawłowski', 'Michalski', 'Król', 'Wieczorek', |
| 52 | 'Jabłoński', 'Wróbel', 'Nowakowski', 'Majewski', 'Olszewski', 'Stępień', |
| 53 | 'Jaworski', 'Malinowski', 'Pawlak', 'Górski', 'Witkowski', 'Sikora', |
| 54 | 'Walczak', 'Rutkowski', 'Michalak', 'Szewczyk', 'Ostrowski', 'Baran', |
| 55 | 'Tomaszewski', 'Pietrzak', 'Zalewski', 'Wróblewski', 'Marciniak', 'Jasiński', |
| 56 | ]; |
| 57 | |
| 58 | /** @var array<int, string> Female last names list. */ |
| 59 | private const array FEMALE_LAST_NAMES = [ |
| 60 | 'Nowak', 'Kowalska', 'Wiśniewska', 'Wójcik', 'Kowalczyk', 'Kamińska', |
| 61 | 'Lewandowska', 'Zielińska', 'Szymańska', 'Woźniak', 'Kozłowska', 'Jankowska', |
| 62 | 'Mazur', 'Wojciechowska', 'Kwiatkowska', 'Krawczyk', 'Kaczmarek', 'Piotrowska', |
| 63 | 'Grabowska', 'Zając', 'Pawłowska', 'Michalska', 'Król', 'Wieczorek', |
| 64 | 'Jabłońska', 'Wróbel', 'Nowakowska', 'Majewska', 'Olszewska', 'Stępień', |
| 65 | 'Jaworska', 'Malinowska', 'Pawlak', 'Górska', 'Witkowska', 'Sikora', |
| 66 | 'Walczak', 'Rutkowska', 'Michalak', 'Szewczyk', 'Ostrowska', 'Baran', |
| 67 | 'Tomaszewska', 'Pietrzak', 'Zalewska', 'Wróblewska', 'Marciniak', 'Jasińska', |
| 68 | ]; |
| 69 | |
| 70 | /** @var array<int, string> Job titles catalog. */ |
| 71 | private const array JOB_TITLES = [ |
| 72 | 'Dyrektor Handlowy', 'Kierownik Projektu', 'Główny Architekt', 'Specjalista ds. Sprzedaży', |
| 73 | 'Key Account Manager', 'Senior PHP Developer', 'Lead DevOps Engineer', 'Product Owner', |
| 74 | 'Scrum Master', 'Dyrektor Finansowy', 'Analityk Biznesowy', 'Koordynator ds. Logistyki', |
| 75 | 'Specjalista HR', 'Architekt Rozwiązań Chmurowych', 'Dyrektor Operacyjny', 'Główny Księgowy', |
| 76 | 'Kierownik Działu Obsługi Klienta', 'Doradca Klienta Biznesowego', 'Specjalista ds. Cyberbezpieczeństwa', |
| 77 | 'Kierownik ds. Zakupów', 'Konsultant ERP', 'Inżynier Jakości QA', 'Dyrektor ds. Marketingu', |
| 78 | ]; |
| 79 | |
| 80 | /** @var array<int, array{city: string, zip_prefix: string}> Cities and zip code prefixes. */ |
| 81 | private const array CITIES = [ |
| 82 | ['city' => 'Warszawa', 'zip_prefix' => '00'], |
| 83 | ['city' => 'Warszawa', 'zip_prefix' => '01'], |
| 84 | ['city' => 'Warszawa', 'zip_prefix' => '02'], |
| 85 | ['city' => 'Kraków', 'zip_prefix' => '30'], |
| 86 | ['city' => 'Kraków', 'zip_prefix' => '31'], |
| 87 | ['city' => 'Wrocław', 'zip_prefix' => '50'], |
| 88 | ['city' => 'Wrocław', 'zip_prefix' => '53'], |
| 89 | ['city' => 'Poznań', 'zip_prefix' => '60'], |
| 90 | ['city' => 'Poznań', 'zip_prefix' => '61'], |
| 91 | ['city' => 'Gdańsk', 'zip_prefix' => '80'], |
| 92 | ['city' => 'Gdynia', 'zip_prefix' => '81'], |
| 93 | ['city' => 'Łódź', 'zip_prefix' => '90'], |
| 94 | ['city' => 'Łódź', 'zip_prefix' => '91'], |
| 95 | ['city' => 'Katowice', 'zip_prefix' => '40'], |
| 96 | ['city' => 'Gliwice', 'zip_prefix' => '44'], |
| 97 | ['city' => 'Szczecin', 'zip_prefix' => '70'], |
| 98 | ['city' => 'Lublin', 'zip_prefix' => '20'], |
| 99 | ['city' => 'Białystok', 'zip_prefix' => '15'], |
| 100 | ['city' => 'Rzeszów', 'zip_prefix' => '35'], |
| 101 | ['city' => 'Bydgoszcz', 'zip_prefix' => '85'], |
| 102 | ['city' => 'Toruń', 'zip_prefix' => '87'], |
| 103 | ['city' => 'Kielce', 'zip_prefix' => '25'], |
| 104 | ['city' => 'Olsztyn', 'zip_prefix' => '10'], |
| 105 | ['city' => 'Opole', 'zip_prefix' => '45'], |
| 106 | ['city' => 'Zielona Góra', 'zip_prefix' => '65'], |
| 107 | ['city' => 'Bielsko-Biała', 'zip_prefix' => '43'], |
| 108 | ]; |
| 109 | |
| 110 | /** @var array<int, string> Street names list. */ |
| 111 | private const array STREETS = [ |
| 112 | 'ul. Marszałkowska', 'ul. Floriańska', 'ul. Legnicka', 'ul. Mickiewicza', |
| 113 | 'ul. Słowackiego', 'ul. Kościuszki', 'ul. Sienkiewicza', 'ul. Piłsudskiego', |
| 114 | 'ul. Długa', 'ul. Królewska', 'ul. Świętokrzyska', 'ul. Grunwaldzka', |
| 115 | 'ul. Polna', 'ul. Leśna', 'ul. Słoneczna', 'ul. Ogrodowa', 'ul. Kolejowa', |
| 116 | 'ul. Spacerowa', 'ul. Fabryczna', 'ul. Przemysłowa', 'ul. Krakowska', |
| 117 | 'ul. Warszawska', 'ul. Wrocławska', 'ul. Poznańska', 'ul. Akacjowa', |
| 118 | 'ul. Brzozowa', 'ul. Dębowa', 'ul. Kwiatowa', 'ul. Sosnowa', 'ul. Parkowa', |
| 119 | 'ul. Lipowa', 'ul. Szkolna', 'ul. Kasztanowa', 'ul. Wierzbowa', 'ul. Klonowa', |
| 120 | ]; |
| 121 | |
| 122 | /** @var array<int, string> Business domains list. */ |
| 123 | private const array DOMAINS = [ |
| 124 | 'ammonly.com', 'techcorp.pl', 'enterprise.com', 'cloudservices.pl', |
| 125 | 'softwarehouse.pl', 'novatech.com', 'finpartners.pl', 'softgroup.eu', |
| 126 | 'devflow.io', 'apexsolutions.pl', 'proconsulting.pl', 'nexusglobal.com', |
| 127 | 'datasynergy.pl', 'venturelogic.eu', 'skyreach.pl', 'coremetrics.io', |
| 128 | 'vortexsoft.pl', 'omnicorp.eu', 'stratos.pl', 'primeconnect.pl', |
| 129 | ]; |
| 130 | |
| 131 | /** @var array<int, string> Description templates list. */ |
| 132 | private const array DESCRIPTIONS = [ |
| 133 | 'Kluczowy kontakt biznesowy i decydent w sprawach wdrożeń SaaS.', |
| 134 | 'Koordynator zespołów deweloperskich i wdrożeniowych CRM.', |
| 135 | 'Konsultant techniczny w zakresie architektury mikroserwisów.', |
| 136 | 'Odpowiedzialny za negocjacje umów SLA i warunki licencyjne.', |
| 137 | 'Współpracuje z działem wsparcia i integracji systemów ERP.', |
| 138 | 'Główny punkt kontaktowy w procesie wdrożenia platformy.', |
| 139 | 'Specjalista dedykowany do obsługi kluczowych klientów enterprise.', |
| 140 | 'Nadzoruje harmonogramy projektowe oraz audyty bezpieczeństwa.', |
| 141 | ]; |
| 142 | |
| 143 | /** |
| 144 | * Transliterates Polish characters to ASCII equivalents for emails/usernames. |
| 145 | * |
| 146 | * @param string $text Source text containing Polish diacritics. |
| 147 | * @return string ASCII normalized lowercase string. |
| 148 | */ |
| 149 | public static function transliterate(string $text): string |
| 150 | { |
| 151 | $map = [ |
| 152 | 'ą' => 'a', 'ć' => 'c', 'ę' => 'e', 'ł' => 'l', 'ń' => 'n', |
| 153 | 'ó' => 'o', 'ś' => 's', 'ź' => 'z', 'ż' => 'z', |
| 154 | 'Ą' => 'a', 'Ć' => 'c', 'Ę' => 'e', 'Ł' => 'l', 'Ń' => 'n', |
| 155 | 'Ó' => 'o', 'Ś' => 's', 'Ź' => 'z', 'Ż' => 'z', |
| 156 | ]; |
| 157 | |
| 158 | return strtolower(strtr($text, $map)); |
| 159 | } |
| 160 | |
| 161 | /** |
| 162 | * Seeds random contacts into the target database table. |
| 163 | * |
| 164 | * @param PDO $pdo Active database connection PDO instance. |
| 165 | * @param int $count Number of contacts to generate (default: 5000). |
| 166 | * @param int $batchSize Number of records per SQL multi-row insert (default: 250). |
| 167 | * @param int $ownerId User ID assigned as record owner (default: 1). |
| 168 | * @return int Total number of successfully inserted contact records. |
| 169 | */ |
| 170 | public function seed(PDO $pdo, int $count = 5000, int $batchSize = 250, int $ownerId = 1): int |
| 171 | { |
| 172 | $inserted = 0; |
| 173 | $randomizer = new Randomizer(); |
| 174 | $baseDate = new DateTimeImmutable('-180 days'); |
| 175 | |
| 176 | $pdo->beginTransaction(); |
| 177 | try { |
| 178 | $batches = (int)ceil($count / $batchSize); |
| 179 | for ($b = 0; $b < $batches; $b++) { |
| 180 | $currentBatchSize = min($batchSize, $count - $inserted); |
| 181 | $this->insertBatch($pdo, $randomizer, $baseDate, $currentBatchSize, $ownerId); |
| 182 | $inserted += $currentBatchSize; |
| 183 | } |
| 184 | $pdo->commit(); |
| 185 | } catch (\Throwable $e) { |
| 186 | $pdo->rollBack(); |
| 187 | throw $e; |
| 188 | } |
| 189 | |
| 190 | return $inserted; |
| 191 | } |
| 192 | |
| 193 | /** |
| 194 | * Generates a single contact record array with randomized realistic data. |
| 195 | * |
| 196 | * @param Randomizer $rnd Randomizer instance. |
| 197 | * @param DateTimeImmutable $baseDate Base date reference for timestamps. |
| 198 | * @param int $ownerId Owner user ID. |
| 199 | * @return array<string, mixed> Associative array representing contact record row. |
| 200 | */ |
| 201 | public function generateSingle(Randomizer $rnd, DateTimeImmutable $baseDate, int $ownerId = 1): array |
| 202 | { |
| 203 | $isFemale = $rnd->getInt(0, 1) === 1; |
| 204 | $gender = $isFemale ? 'female' : 'male'; |
| 205 | $firstName = $isFemale |
| 206 | ? self::FEMALE_FIRST_NAMES[$rnd->getInt(0, count(self::FEMALE_FIRST_NAMES) - 1)] |
| 207 | : self::MALE_FIRST_NAMES[$rnd->getInt(0, count(self::MALE_FIRST_NAMES) - 1)]; |
| 208 | $lastName = $isFemale |
| 209 | ? self::FEMALE_LAST_NAMES[$rnd->getInt(0, count(self::FEMALE_LAST_NAMES) - 1)] |
| 210 | : self::MALE_LAST_NAMES[$rnd->getInt(0, count(self::MALE_LAST_NAMES) - 1)]; |
| 211 | |
| 212 | $domain = self::DOMAINS[$rnd->getInt(0, count(self::DOMAINS) - 1)]; |
| 213 | $emailPrefix = self::transliterate($firstName) . '.' . self::transliterate($lastName); |
| 214 | $randomSuffix = $rnd->getInt(10, 9999); |
| 215 | $email = sprintf('%s%d@%s', $emailPrefix, $randomSuffix, $domain); |
| 216 | |
| 217 | $cityData = self::CITIES[$rnd->getInt(0, count(self::CITIES) - 1)]; |
| 218 | $zipCode = sprintf('%s-%03d', $cityData['zip_prefix'], $rnd->getInt(1, 999)); |
| 219 | $street = self::STREETS[$rnd->getInt(0, count(self::STREETS) - 1)]; |
| 220 | $buildingNo = (string)$rnd->getInt(1, 150); |
| 221 | $apartmentNo = $rnd->getInt(0, 3) > 0 ? (string)$rnd->getInt(1, 80) : null; |
| 222 | |
| 223 | $phone = sprintf('+48%d', $rnd->getInt(500000000, 889999999)); |
| 224 | $secPhone = $rnd->getInt(0, 2) === 0 ? sprintf('+48%d', $rnd->getInt(221000000, 719999999)) : null; |
| 225 | |
| 226 | $createdOffset = $rnd->getInt(0, 180 * 86400); |
| 227 | $createdAt = $baseDate->modify("+{$createdOffset} seconds")->format('Y-m-d H:i:s.u'); |
| 228 | |
| 229 | // Special access distribution: 94% Available (1), 4% Archived (2), 2% Deleted (3) |
| 230 | $statusRoll = $rnd->getInt(1, 100); |
| 231 | $specialAccess = 3; |
| 232 | if ($statusRoll <= 94) { |
| 233 | $specialAccess = 1; |
| 234 | } elseif ($statusRoll <= 98) { |
| 235 | $specialAccess = 2; |
| 236 | } |
| 237 | |
| 238 | return [ |
| 239 | 'first_name' => $firstName, |
| 240 | 'last_name' => $lastName, |
| 241 | 'gender' => $gender, |
| 242 | 'job_title' => self::JOB_TITLES[$rnd->getInt(0, count(self::JOB_TITLES) - 1)], |
| 243 | 'email' => $email, |
| 244 | 'secondary_email' => $rnd->getInt(0, 3) === 0 ? "biuro.{$randomSuffix}@{$domain}" : null, |
| 245 | 'phone' => $phone, |
| 246 | 'secondary_phone' => $secPhone, |
| 247 | 'website' => "https://{$domain}", |
| 248 | 'address_street' => $street, |
| 249 | 'address_building_number' => $buildingNo, |
| 250 | 'address_apartment_number' => $apartmentNo, |
| 251 | 'address_postal_code' => $zipCode, |
| 252 | 'address_city' => $cityData['city'], |
| 253 | 'address_country' => self::COUNTRY_POLAND, |
| 254 | 'parent_id' => null, |
| 255 | 'description' => self::DESCRIPTIONS[$rnd->getInt(0, count(self::DESCRIPTIONS) - 1)], |
| 256 | 'special_access' => $specialAccess, |
| 257 | 'created_at' => $createdAt, |
| 258 | 'updated_at' => $createdAt, |
| 259 | 'created_by' => $ownerId, |
| 260 | 'owner' => $ownerId, |
| 261 | 'co_owners' => null, |
| 262 | ]; |
| 263 | } |
| 264 | |
| 265 | /** |
| 266 | * Executes single multi-row batch insert into c_mod_contacts_records table. |
| 267 | * |
| 268 | * @param PDO $pdo Active PDO connection. |
| 269 | * @param Randomizer $rnd Randomizer instance. |
| 270 | * @param DateTimeImmutable $baseDate Base timestamp. |
| 271 | * @param int $batchSize Batch size count. |
| 272 | * @param int $ownerId Owner ID. |
| 273 | * @return void |
| 274 | */ |
| 275 | private function insertBatch( |
| 276 | PDO $pdo, |
| 277 | Randomizer $rnd, |
| 278 | DateTimeImmutable $baseDate, |
| 279 | int $batchSize, |
| 280 | int $ownerId |
| 281 | ): void { |
| 282 | $columns = [ |
| 283 | 'first_name', 'last_name', 'gender', 'job_title', 'email', 'secondary_email', |
| 284 | 'phone', 'secondary_phone', 'website', 'address_street', 'address_building_number', |
| 285 | 'address_apartment_number', 'address_postal_code', 'address_city', 'address_country', |
| 286 | 'parent_id', |
| 287 | 'description', 'special_access', 'created_at', 'updated_at', |
| 288 | 'created_by', 'owner', 'co_owners', |
| 289 | ]; |
| 290 | |
| 291 | $rowPlaceholders = '(' . implode(', ', array_fill(0, count($columns), '?')) . ')'; |
| 292 | $sql = 'INSERT INTO `c_mod_contacts_records` (`' . implode('`, `', $columns) . '`) VALUES ' |
| 293 | . implode(', ', array_fill(0, $batchSize, $rowPlaceholders)); |
| 294 | |
| 295 | $params = []; |
| 296 | for ($i = 0; $i < $batchSize; $i++) { |
| 297 | $row = $this->generateSingle($rnd, $baseDate, $ownerId); |
| 298 | foreach ($columns as $col) { |
| 299 | $params[] = $row[$col]; |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | $stmt = $pdo->prepare($sql); |
| 304 | $stmt->execute($params); |
| 305 | } |
| 306 | } |