Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
92 / 92
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
ContactDataSeeder
100.00% covered (success)
100.00%
91 / 91
100.00% covered (success)
100.00%
4 / 4
16
100.00% covered (success)
100.00%
1 / 1
 transliterate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 seed
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 generateSingle
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
1 / 1
9
 insertBatch
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Installer;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use DateTimeImmutable;
12use PDO;
13use 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 */
23final 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}