table name */ public const TABLES = [ 'provinces' => 'provinces', 'cities' => 'cities', 'specialties' => 'specialties', 'doctor_services' => 'doctor_services', 'insurances' => 'insurances', 'tags' => 'tags', ]; public function __construct( private readonly Connection $db, ) {} public function isValidBundle(string $bundle): bool { return isset(self::TABLES[$bundle]); } /** Complete export of a bundle — every row, normalized numeric columns. */ public function exportAll(string $bundle): array { $table = self::TABLES[$bundle]; // cities export must match the shape nobat724_front/data/city.json consumes: // social_media as an object (not the raw JSON string) and province_name included. $sql = $bundle === 'cities' ? 'SELECT c.*, p.name AS province_name FROM cities c LEFT JOIN provinces p ON p.id = c.province_id ORDER BY c.id ASC' : 'SELECT * FROM ' . $table . ' ORDER BY id ASC'; $rows = $this->db->fetchAllAssociative($sql); foreach ($rows as &$r) { foreach (['id', 'status', 'weight', 'parent_id', 'specialty_id', 'province_id', 'representation_id'] as $intCol) { if (array_key_exists($intCol, $r) && $r[$intCol] !== null) { $r[$intCol] = (int) $r[$intCol]; } } if ($bundle === 'cities' && is_string($r['social_media'] ?? null)) { $r['social_media'] = json_decode($r['social_media'], true); } } unset($r); return $rows; } /** * Strict, two-phase validation. Returns a list of * ['row' => int, 'field' => string, 'message' => string]; empty == valid. * On success $normalized is filled with ready-to-insert column maps. */ public function validate(string $bundle, array $payload, array &$normalized): array { $errors = []; $normalized = []; $seenId = []; $seenSlug = []; foreach ($payload as $i => $raw) { $rowNo = $i + 1; $add = function (string $field, string $message) use (&$errors, $rowNo): void { $errors[] = ['row' => $rowNo, 'field' => $field, 'message' => "ردیف {$rowNo}: {$message}"]; }; if (!is_array($raw) || array_keys($raw) === range(0, count($raw) - 1)) { $add('-', 'ساختار رکورد نامعتبر است'); continue; } $id = $raw['id'] ?? null; if (!self::isPositiveInt($id)) { $add('id', 'شناسه (id) باید عدد صحیح مثبت باشد'); } elseif (isset($seenId[(int) $id])) { $add('id', "شناسه {$id} تکراری است (ردیف {$seenId[(int) $id]})"); } else { $seenId[(int) $id] = $rowNo; } $name = $raw['name'] ?? null; if (!is_string($name) || trim($name) === '') { $add('name', 'نام الزامی است'); } elseif (mb_strlen($name) > 255) { $add('name', 'نام نباید بیش از ۲۵۵ نویسه باشد'); } $status = self::normInt($raw['status'] ?? 1); if ($status === null || !in_array($status, [0, 1], true)) { $add('status', 'وضعیت باید ۰ یا ۱ باشد'); } $row = [ 'id' => self::isPositiveInt($id) ? (int) $id : null, 'uuid' => (is_string($raw['uuid'] ?? null) && $raw['uuid'] !== '') ? mb_substr($raw['uuid'], 0, 36) : Uuid::v4()->toRfc4122(), 'name' => is_string($name) ? trim($name) : '', 'status' => $status ?? 1, ]; if (in_array($bundle, ['provinces', 'cities', 'specialties', 'doctor_services'], true)) { $weight = self::normInt($raw['weight'] ?? 0); if ($weight === null || $weight < 0) { $add('weight', 'ترتیب نمایش باید عدد صحیح نامنفی باشد'); } $row['weight'] = $weight ?? 0; } if (in_array($bundle, ['specialties', 'doctor_services', 'tags'], true)) { $slug = $raw['slug'] ?? null; if (!is_string($slug) || trim($slug) === '') { $add('slug', 'slug الزامی است'); } elseif (mb_strlen($slug) > 255) { $add('slug', 'slug نباید بیش از ۲۵۵ نویسه باشد'); } else { $slug = trim($slug); if (isset($seenSlug[$slug])) { $add('slug', "slug «{$slug}» تکراری است (ردیف {$seenSlug[$slug]})"); } else { $seenSlug[$slug] = $rowNo; } $row['slug'] = $slug; } } switch ($bundle) { case 'specialties': $row['parent_id'] = self::nullableId($raw['parent_id'] ?? null, $add, 'parent_id'); break; case 'doctor_services': $row['specialty_id'] = self::nullableId($raw['specialty_id'] ?? null, $add, 'specialty_id'); break; case 'insurances': $type = $raw['type'] ?? null; if (!in_array($type, ['basic', 'supplementary'], true)) { $add('type', 'نوع بیمه باید basic یا supplementary باشد'); } $row['type'] = is_string($type) ? $type : 'basic'; $row['logo_url'] = self::optStr($raw['logo_url'] ?? null); break; case 'cities': $row['site_name'] = self::optStr($raw['site_name'] ?? null); $row['title'] = self::optStr($raw['title'] ?? null); $row['province_id'] = self::nullableId($raw['province_id'] ?? null, $add, 'province_id'); $row['representation_id'] = self::nullableId($raw['representation_id'] ?? null, $add, 'representation_id'); $row['contact_phone'] = self::optStr($raw['contact_phone'] ?? null); $row['email'] = self::optStr($raw['email'] ?? null); $row['description'] = self::optStr($raw['description'] ?? null); $row['slogan'] = self::optStr($raw['slogan'] ?? null); $row['domain'] = self::optStr($raw['domain'] ?? null); $row['keywords'] = self::optStr($raw['keywords'] ?? null); $row['footer_description'] = self::optStr($raw['footer_description'] ?? null); $row['logo_url'] = self::optStr($raw['logo_url'] ?? null); $sm = $raw['social_media'] ?? null; $row['social_media'] = ($sm === null || $sm === '') ? null : (is_string($sm) ? $sm : json_encode($sm, JSON_UNESCAPED_UNICODE)); break; } $normalized[] = $row; } $this->validateReferences($bundle, $normalized, $seenId, $errors); return $errors; } /** Wipe the bundle's table and insert the normalized rows in one transaction. */ public function replace(string $bundle, array $normalized): void { $table = self::TABLES[$bundle]; $this->db->beginTransaction(); try { $this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 0'); $this->db->executeStatement('DELETE FROM ' . $table); foreach ($normalized as $row) { $this->db->insert($table, $row); } $this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1'); $this->db->commit(); } catch (\Throwable $e) { $this->db->rollBack(); try { $this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1'); } catch (\Throwable) {} throw $e; } } private function validateReferences(string $bundle, array $rows, array $seenId, array &$errors): void { $err = function (int $rowNo, string $field, string $message) use (&$errors): void { $errors[] = ['row' => $rowNo, 'field' => $field, 'message' => "ردیف {$rowNo}: {$message}"]; }; if ($bundle === 'specialties') { foreach ($rows as $idx => $r) { $p = $r['parent_id'] ?? null; if ($p !== null && !isset($seenId[$p])) { $err($idx + 1, 'parent_id', "والد با شناسه {$p} در همین فایل وجود ندارد"); } } return; } if ($bundle === 'doctor_services') { $valid = $this->existingIds('specialties'); foreach ($rows as $idx => $r) { $s = $r['specialty_id'] ?? null; if ($s !== null && !isset($valid[$s])) { $err($idx + 1, 'specialty_id', "تخصص با شناسه {$s} در سیستم وجود ندارد"); } } return; } if ($bundle === 'cities') { $validProv = $this->existingIds('provinces'); $validRep = $this->existingIds('representations'); foreach ($rows as $idx => $r) { $pv = $r['province_id'] ?? null; if ($pv !== null && !isset($validProv[$pv])) { $err($idx + 1, 'province_id', "استان با شناسه {$pv} وجود ندارد"); } $rp = $r['representation_id'] ?? null; if ($rp !== null && !isset($validRep[$rp])) { $err($idx + 1, 'representation_id', "نماینده با شناسه {$rp} وجود ندارد"); } } } } /** @return array */ private function existingIds(string $table): array { $set = []; foreach ($this->db->fetchFirstColumn('SELECT id FROM ' . $table) as $id) { $set[(int) $id] = true; } return $set; } private static function isPositiveInt(mixed $v): bool { return (is_int($v) || (is_string($v) && ctype_digit($v))) && (int) $v > 0; } private static function normInt(mixed $v): ?int { if (is_int($v)) return $v; if (is_string($v) && preg_match('/^-?\d+$/', $v)) return (int) $v; return null; } private static function nullableId(mixed $v, callable $add, string $field): ?int { if ($v === null || $v === '' || $v === 0 || $v === '0') return null; if (!self::isPositiveInt($v)) { $add($field, "{$field} باید عدد صحیح مثبت یا خالی باشد"); return null; } return (int) $v; } private static function optStr(mixed $v): ?string { if (!is_string($v)) return null; $v = trim($v); return $v === '' ? null : $v; } }