feat: add CategoryImporter service and SeedCategoriesCommand for seeding category data
- Implemented CategoryImporter service to handle bulk export/import logic for categories. - Created SeedCategoriesCommand to seed category tables from JSON files in data/seed/. - Added validation and normalization for category data during import. - Ensured proper error handling and user feedback during the seeding process.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Category\Command;
|
||||
|
||||
use App\Category\Service\CategoryImporter;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Seeds the category tables from the JSON files in data/seed/.
|
||||
*
|
||||
* The data ships inside this repo (data/seed/) so a from-scratch database can be
|
||||
* populated with the canonical, valid categories without any external source.
|
||||
* Bundles are seeded in dependency order (provinces → cities, specialties →
|
||||
* doctor_services) and each is a strict wipe+replace via {@see CategoryImporter}.
|
||||
*
|
||||
* ddev exec php bin/console app:seed-categories
|
||||
*/
|
||||
#[AsCommand(name: 'app:seed-categories', description: 'Seed category tables (provinces, cities, specialties, doctor services) from data/seed/*.json')]
|
||||
class SeedCategoriesCommand extends Command
|
||||
{
|
||||
/** seed order matters: referenced tables first */
|
||||
private const ORDER = ['provinces', 'cities', 'specialties', 'doctor_services'];
|
||||
|
||||
public function __construct(
|
||||
private readonly CategoryImporter $importer,
|
||||
private readonly string $projectDir,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$seedDir = $this->projectDir . '/data/seed';
|
||||
|
||||
foreach (self::ORDER as $bundle) {
|
||||
$file = $seedDir . '/' . $bundle . '.json';
|
||||
if (!is_file($file)) {
|
||||
$io->error("فایل seed یافت نشد: {$file}");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) file_get_contents($file), true);
|
||||
if (!is_array($payload) || $payload === [] || array_keys($payload) !== range(0, count($payload) - 1)) {
|
||||
$io->error("{$bundle}: فایل باید یک آرایهی غیرخالی باشد");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
$errors = $this->importer->validate($bundle, $payload, $normalized);
|
||||
if ($errors !== []) {
|
||||
$io->error("{$bundle}: اعتبارسنجی ناموفق — " . count($errors) . ' خطا');
|
||||
foreach (array_slice($errors, 0, 10) as $e) {
|
||||
$io->writeln(" • {$e['message']}");
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->importer->replace($bundle, $normalized);
|
||||
$io->success(sprintf('%s: %d رکورد seed شد', $bundle, count($normalized)));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -2,83 +2,54 @@
|
||||
|
||||
namespace App\Category\Controller;
|
||||
|
||||
use App\Category\Service\CategoryImporter;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
/**
|
||||
* Bulk JSON import for the admin "دستهبندیها" page (all six tabs).
|
||||
* Bulk JSON export/import for the admin "دستهبندیها" page (all six tabs).
|
||||
*
|
||||
* Consumes the exact JSON produced by each tab's "خروجی JSON" export
|
||||
* (an array of objects shaped like the entity's toArray()).
|
||||
* Thin HTTP layer over {@see CategoryImporter}, which holds the strict
|
||||
* validation + wipe-and-replace logic shared with the `app:seed-categories`
|
||||
* console command.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Strict two-phase validation: the WHOLE file is validated first; if any
|
||||
* row is invalid, nothing is written and every error is returned (HTTP 422).
|
||||
* - Wipe + full replace: on success the target table is emptied and rebuilt
|
||||
* from the file, preserving the raw `id` of each row so relations
|
||||
* (parent_id / province_id / specialty_id) stay intact.
|
||||
* - The whole replace runs inside one transaction with FK checks disabled.
|
||||
* - Wipe + full replace, preserving raw `id`s so relations stay intact, inside
|
||||
* one transaction with FK checks disabled.
|
||||
*/
|
||||
#[OA\Tag(name: 'Categories')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class CategoryImportController extends BaseController
|
||||
{
|
||||
/** bundle => table name */
|
||||
private const TABLES = [
|
||||
'provinces' => 'provinces',
|
||||
'cities' => 'cities',
|
||||
'specialties' => 'specialties',
|
||||
'doctor_services' => 'doctor_services',
|
||||
'insurances' => 'insurances',
|
||||
'tags' => 'tags',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly Connection $db,
|
||||
private readonly CategoryImporter $importer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Complete export of a bundle — EVERY row, no pagination cap.
|
||||
*
|
||||
* The page's old "خروجی JSON" button pointed at the admin list endpoint,
|
||||
* which silently caps at 100 rows. Feeding that truncated file back into the
|
||||
* wipe+replace import deletes the tail. This endpoint returns the full table
|
||||
* so the export/import round-trip is lossless.
|
||||
* Complete export of a bundle — EVERY row, no pagination cap. The page's old
|
||||
* "خروجی JSON" button pointed at the admin list endpoint (caps at 100 rows);
|
||||
* feeding that truncated file into the wipe+replace import below deletes the
|
||||
* tail. This endpoint returns the full table so the round-trip is lossless.
|
||||
*/
|
||||
#[Route('/api/v1/admin/categories/{bundle}/export', methods: ['GET'])]
|
||||
public function export(string $bundle): JsonResponse
|
||||
{
|
||||
$table = self::TABLES[$bundle] ?? null;
|
||||
if ($table === null) {
|
||||
if (!$this->importer->isValidBundle($bundle)) {
|
||||
return $this->error('ERR_BUNDLE_UNKNOWN', 'نوع دستهبندی نامعتبر است', 404, 'bundle');
|
||||
}
|
||||
|
||||
$rows = $this->db->fetchAllAssociative('SELECT * FROM ' . $table . ' ORDER BY id ASC');
|
||||
|
||||
// normalize numeric columns so the JSON matches the entity shape
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($r);
|
||||
|
||||
return $this->success($rows);
|
||||
return $this->success($this->importer->exportAll($bundle));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/categories/{bundle}/import', methods: ['POST'])]
|
||||
public function import(string $bundle, Request $request): JsonResponse
|
||||
{
|
||||
$table = self::TABLES[$bundle] ?? null;
|
||||
if ($table === null) {
|
||||
if (!$this->importer->isValidBundle($bundle)) {
|
||||
return $this->error('ERR_BUNDLE_UNKNOWN', 'نوع دستهبندی نامعتبر است', 404, 'bundle');
|
||||
}
|
||||
|
||||
@@ -94,233 +65,18 @@ class CategoryImportController extends BaseController
|
||||
return $this->error('ERR_IMPORT_TOO_LARGE', 'حداکثر ۵۰۰۰ رکورد در هر import مجاز است', 422, 'file');
|
||||
}
|
||||
|
||||
// ── Phase 1: strict validation ───────────────────────────────────────
|
||||
$errors = [];
|
||||
$rows = []; // normalized, ready-to-insert column maps
|
||||
$seenId = []; // id => row index (uniqueness within file)
|
||||
$seenSlug = []; // slug => row index
|
||||
|
||||
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 — required, positive int, unique in file (relations rely on it)
|
||||
$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 — required non-empty string ≤255
|
||||
$name = $raw['name'] ?? null;
|
||||
if (!is_string($name) || trim($name) === '') {
|
||||
$add('name', 'نام الزامی است');
|
||||
} elseif (mb_strlen($name) > 255) {
|
||||
$add('name', 'نام نباید بیش از ۲۵۵ نویسه باشد');
|
||||
}
|
||||
|
||||
// status — 0 or 1
|
||||
$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,
|
||||
];
|
||||
|
||||
// weight — int ≥0 (only bundles whose table has a weight column)
|
||||
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;
|
||||
}
|
||||
|
||||
// slug — required for bundles that have it, unique in file
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// bundle-specific columns
|
||||
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['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;
|
||||
}
|
||||
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
// referential integrity (only meaningful once ids are collected)
|
||||
$this->validateReferences($bundle, $rows, $seenId, $errors);
|
||||
|
||||
$normalized = [];
|
||||
$errors = $this->importer->validate($bundle, $payload, $normalized);
|
||||
if ($errors !== []) {
|
||||
return new JsonResponse(['success' => false, 'data' => null, 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
// ── Phase 2: wipe + replace inside a transaction ─────────────────────
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 0');
|
||||
$this->db->executeStatement('DELETE FROM ' . $table);
|
||||
foreach ($rows as $row) {
|
||||
$this->db->insert($table, $row);
|
||||
}
|
||||
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1');
|
||||
$this->db->commit();
|
||||
$this->importer->replace($bundle, $normalized);
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->rollBack();
|
||||
try { $this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1'); } catch (\Throwable) {}
|
||||
return $this->error('ERR_IMPORT_FAILED', 'خطا در ذخیرهسازی: ' . $e->getMessage(), 500);
|
||||
}
|
||||
|
||||
return $this->success(['imported' => count($rows)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-row / cross-table reference checks.
|
||||
* - specialties.parent_id must point to an id present in the same file.
|
||||
* - cities.province_id / cities.representation_id / doctor_services.specialty_id
|
||||
* must exist in their (un-wiped) reference tables.
|
||||
*/
|
||||
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<int,true> set of existing ids in a table */
|
||||
private function existingIds(string $table): array
|
||||
{
|
||||
$ids = $this->db->fetchFirstColumn('SELECT id FROM ' . $table);
|
||||
$set = [];
|
||||
foreach ($ids 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;
|
||||
}
|
||||
|
||||
/** Validate an optional FK-style id: null stays null, otherwise must be positive int. */
|
||||
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 ($v === null) return null;
|
||||
if (!is_string($v)) return null;
|
||||
$v = trim($v);
|
||||
return $v === '' ? null : $v;
|
||||
return $this->success(['imported' => count($normalized)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace App\Category\Service;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* Shared bulk export/import logic for the six category bundles
|
||||
* (provinces, cities, specialties, doctor_services, insurances, tags).
|
||||
*
|
||||
* Used by both the admin HTTP endpoint (CategoryImportController) and the
|
||||
* `app:seed-categories` console command, so validation + the wipe+replace
|
||||
* behaviour live in exactly one place.
|
||||
*/
|
||||
class CategoryImporter
|
||||
{
|
||||
/** bundle => 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];
|
||||
$rows = $this->db->fetchAllAssociative('SELECT * FROM ' . $table . ' ORDER BY id ASC');
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
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['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<int,true> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user