feat: add multi-city representation support and domain context resolution

- Created migration to add representation_cities table and domain, is_global fields to representations.
- Implemented SiteContextController to resolve domain to site context (city | representation | unknown).
- Developed DomainContext and DomainContextResolver services for domain mapping.
- Added tests for DomainContextResolver and commission logic based on domain ownership.
This commit is contained in:
hamed
2026-07-09 07:26:58 +03:30
parent 59559e2e31
commit 0750bc9812
56 changed files with 4297 additions and 1600 deletions
@@ -4,6 +4,7 @@ namespace App\Representation\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Location\Repository\CityRepository;
use App\Representation\Entity\Representation;
use App\Representation\Repository\RepresentationRepository;
use App\Representation\Service\JalaliDateService;
@@ -25,10 +26,68 @@ class RepresentationController extends BaseController
public function __construct(
private readonly RepresentationRepository $representationRepo,
private readonly UserRepository $userRepo,
private readonly CityRepository $cityRepo,
private readonly EntityManagerInterface $em,
private readonly JalaliDateService $jalali,
) {}
/**
* اعمال فیلدهای مشترک create/update روی نماینده.
* خطا به‌صورت آرایه [code, message, field] برمی‌گردد؛ null یعنی موفق.
* فیلدهای domain و is_global فقط admin ($isAdmin) مجازند.
*/
private function applyRepresentationData(Representation $rep, array $data, bool $isAdmin): ?array
{
// چند-شهری: city_ids (آرایه) یا city_id قدیمی (BC).
$cityIds = null;
if (array_key_exists('city_ids', $data)) {
$cityIds = is_array($data['city_ids']) ? $data['city_ids'] : [];
} elseif (array_key_exists('city_id', $data)) {
$cityIds = $data['city_id'] ? [(int) $data['city_id']] : [];
}
if ($cityIds !== null) {
$cities = [];
foreach ($cityIds as $cid) {
$city = $this->cityRepo->find((int) $cid);
if ($city === null) {
return [ErrorCodes::ERR_VALIDATION_001, "شهر با شناسه {$cid} یافت نشد", 'city_ids'];
}
$cities[] = $city;
}
$rep->setCities($cities);
$rep->setCityId($cities !== [] ? (int) $cities[0]->getId() : null);
}
if (array_key_exists('domain', $data)) {
if (!$isAdmin) {
return [ErrorCodes::ERR_AUTH_006, 'تغییر دامنه فقط توسط مدیر مجاز است', 'domain'];
}
$domain = Representation::normalizeDomain(is_string($data['domain']) ? $data['domain'] : null);
if ($domain !== null) {
if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $domain)) {
return [ErrorCodes::ERR_VALIDATION_001, 'دامنه نامعتبر است', 'domain'];
}
if ($this->cityRepo->findByDomain($domain) !== null) {
return [ErrorCodes::ERR_CONFLICT_001, 'این دامنه متعلق به یکی از شهرهاست', 'domain'];
}
$existing = $this->representationRepo->findOneBy(['domain' => $domain]);
if ($existing !== null && $existing->getId() !== $rep->getId()) {
return [ErrorCodes::ERR_CONFLICT_001, 'این دامنه قبلاً برای نماینده دیگری ثبت شده است', 'domain'];
}
}
$rep->setDomain($domain);
}
if (array_key_exists('is_global', $data)) {
if (!$isAdmin) {
return [ErrorCodes::ERR_AUTH_006, 'تغییر نوع نماینده (سراسری) فقط توسط مدیر مجاز است', 'is_global'];
}
$rep->setIsGlobal((bool) $data['is_global']);
}
return null;
}
// ── CRUD ──────────────────────────────────────────────────────────────────
#[OA\Post(
@@ -115,10 +174,16 @@ class RepresentationController extends BaseController
}
$rep = new Representation($user, $fullName);
if (isset($data['city_id'])) $rep->setCityId($data['city_id'] ? (int)$data['city_id'] : null);
if (!empty($data['commission_percent'])) $rep->setCommissionPercent((string)$data['commission_percent']);
if (!empty($data['bank_account'])) $rep->setBankAccount($data['bank_account']);
// create فقط توسط ادمین انجام می‌شود (IsGranted بالای متد) → isAdmin=true.
if (($err = $this->applyRepresentationData($rep, $data, true)) !== null) {
[$code, $message, $field] = $err;
$status = $code === ErrorCodes::ERR_CONFLICT_001 ? 409 : 422;
return $this->error($code, $message, $status, $field);
}
$this->representationRepo->save($rep);
return $this->success(['data' => $rep->toArray()], 201);
@@ -213,9 +278,18 @@ class RepresentationController extends BaseController
$isAdmin = $user->hasRole('ROLE_ADMIN');
if (array_key_exists('full_name', $data)) $rep->setFullName($data['full_name']);
if (array_key_exists('city_id', $data)) $rep->setCityId($data['city_id'] ? (int)$data['city_id'] : null);
if (array_key_exists('bank_account', $data)) $rep->setBankAccount($data['bank_account']);
if (($err = $this->applyRepresentationData($rep, $data, $isAdmin)) !== null) {
[$code, $message, $field] = $err;
$status = match ($code) {
ErrorCodes::ERR_CONFLICT_001 => 409,
ErrorCodes::ERR_AUTH_006 => 403,
default => 422,
};
return $this->error($code, $message, $status, $field);
}
// commission_percent and active are privileged: a representative must not
// be able to raise their own commission or activate themselves.
if (array_key_exists('commission_percent', $data)) {
@@ -0,0 +1,58 @@
<?php
namespace App\Representation\Controller;
use App\Representation\Service\DomainContextResolver;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
/**
* زمینه‌ی عمومی یک دامنه برای سایت nobat724: شهر است، دامنه‌ی اختصاصی نماینده است، یا ناشناخته.
* سایت عمومی برای دامنه‌های خارج از data/city.json از این endpoint استفاده می‌کند.
*/
#[OA\Tag(name: 'Representations')]
class SiteContextController extends BaseController
{
public function __construct(
private readonly DomainContextResolver $resolver,
) {}
#[OA\Get(
path: '/api/v1/site-context',
summary: 'Resolve a domain to its site context (city | representation | unknown)',
parameters: [
new OA\Parameter(name: 'domain', in: 'query', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'Domain context'),
]
)]
#[Route('/api/v1/site-context', methods: ['GET'])]
public function resolve(Request $request): JsonResponse
{
$ctx = $this->resolver->resolve((string) $request->query->get('domain', ''));
$type = 'unknown';
if ($ctx->city !== null) {
$type = 'city';
} elseif ($ctx->representation !== null) {
$type = 'representation';
}
return $this->success([
'type' => $type,
'city' => $ctx->city === null ? null : [
'id' => $ctx->city->getId(),
'name' => $ctx->city->getName(),
],
'representation' => $ctx->representation === null ? null : [
'uuid' => $ctx->representation->getUuid(),
'full_name' => $ctx->representation->getFullName(),
'is_global' => $ctx->representation->isGlobal(),
],
]);
}
}
+56 -2
View File
@@ -3,6 +3,9 @@
namespace App\Representation\Entity;
use App\Auth\Entity\User;
use App\Location\Entity\City;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use App\Representation\Repository\RepresentationRepository;
use Symfony\Component\Uid\Uuid;
@@ -29,9 +32,22 @@ class Representation
#[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $mobileNumber = null;
/** @deprecated نگاشت تک-شهری قدیمی؛ منبع حقیقت $cities است. فقط برای BC خوانده می‌شود. */
#[ORM\Column(name: 'city_id', type: 'integer', nullable: true)]
private ?int $cityId = null;
/** @var Collection<int, City> شهرهای تحت پوشش نماینده */
#[ORM\ManyToMany(targetEntity: City::class)]
#[ORM\JoinTable(name: 'representation_cities')]
private Collection $cities;
/** دامنه اختصاصی نماینده (نرمال‌شده: بدون scheme/www)؛ مبنای کمیسیون دامنه‌محور. */
#[ORM\Column(type: 'string', length: 255, nullable: true, unique: true)]
private ?string $domain = null;
#[ORM\Column(name: 'is_global', type: 'boolean')]
private bool $isGlobal = false;
#[ORM\Column(name: 'commission_percent', type: 'decimal', precision: 5, scale: 2)]
private string $commissionPercent = '10.00';
@@ -56,16 +72,35 @@ class Representation
$this->uuid = Uuid::v4()->toRfc4122();
$this->user = $user;
$this->fullName = $fullName;
$this->cities = new ArrayCollection();
$this->createdAt = time();
$this->updatedAt = time();
}
/** نرمال‌سازی دامنه: lowercase، حذف scheme/www/پورت/اسلش انتهایی. */
public static function normalizeDomain(?string $domain): ?string
{
if ($domain === null) return null;
$d = strtolower(trim($domain));
$d = preg_replace('~^https?://~', '', $d) ?? $d;
$d = preg_replace('~^www\.~', '', $d) ?? $d;
$d = explode('/', $d)[0];
$d = explode(':', $d)[0];
return $d === '' ? null : $d;
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getUser(): User { return $this->user; }
public function getFullName(): string { return $this->fullName; }
public function getMobileNumber(): ?string { return $this->mobileNumber; }
public function getCityId(): ?int { return $this->cityId; }
public function getCityId(): ?int { return $this->cityIds()[0] ?? $this->cityId; }
/** @return Collection<int, City> */
public function getCities(): Collection { return $this->cities; }
/** @return int[] */
public function cityIds(): array { return array_values(array_map(fn(City $c) => (int) $c->getId(), $this->cities->toArray())); }
public function getDomain(): ?string { return $this->domain; }
public function isGlobal(): bool { return $this->isGlobal; }
public function getCommissionPercent(): string { return $this->commissionPercent; }
public function getBankAccount(): ?array { return $this->bankAccount; }
public function isActive(): bool { return $this->active; }
@@ -73,6 +108,18 @@ class Representation
public function setFullName(string $v): self { $this->fullName = $v; $this->touch(); return $this; }
public function setMobileNumber(?string $v): self { $this->mobileNumber = $v; $this->touch(); return $this; }
public function setCityId(?int $v): self { $this->cityId = $v; $this->touch(); return $this; }
/** @param City[] $cities */
public function setCities(array $cities): self
{
$this->cities->clear();
foreach ($cities as $city) {
if (!$this->cities->contains($city)) $this->cities->add($city);
}
$this->touch();
return $this;
}
public function setDomain(?string $v): self { $this->domain = self::normalizeDomain($v); $this->touch(); return $this; }
public function setIsGlobal(bool $v): self { $this->isGlobal = $v; $this->touch(); return $this; }
public function setCommissionPercent(string $v): self { $this->commissionPercent = $v; $this->touch(); return $this; }
public function setBankAccount(?array $v): self { $this->bankAccount = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
@@ -132,7 +179,14 @@ class Representation
'uuid' => $this->uuid,
'full_name' => $this->fullName,
'mobile_number' => $this->mobileNumber,
'city_id' => $this->cityId,
'city_id' => $this->getCityId(),
'city_ids' => $this->cityIds(),
'cities' => array_values(array_map(
fn(City $c) => ['id' => (int) $c->getId(), 'name' => $c->getName()],
$this->cities->toArray(),
)),
'domain' => $this->domain,
'is_global' => $this->isGlobal,
'commission_percent' => $this->commissionPercent,
'bank_account' => $this->bankAccount,
'active' => $this->active,
@@ -24,9 +24,9 @@ class RepresentationRepository extends ServiceEntityRepository
return $this->findOneBy(['user' => $user]);
}
public function findActiveByCityId(int $cityId): ?Representation
public function findActiveByDomain(string $domain): ?Representation
{
return $this->findOneBy(['cityId' => $cityId, 'active' => true]);
return $this->findOneBy(['domain' => $domain, 'active' => true]);
}
public function save(Representation $entity, bool $flush = true): void
@@ -0,0 +1,26 @@
<?php
namespace App\Representation\Service;
use App\Location\Entity\City;
use App\Representation\Entity\Representation;
/** نتیجه‌ی نگاشت دامنه‌ی درخواست به زمینه‌ی سایت (شهر یا نماینده). */
final class DomainContext
{
public function __construct(
public readonly ?Representation $representation,
public readonly ?City $city,
public readonly bool $isGlobalRepresentation,
) {}
public static function empty(): self
{
return new self(null, null, false);
}
public function representationId(): ?int
{
return $this->representation?->getId();
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Representation\Service;
use App\Location\Repository\CityRepository;
use App\Representation\Entity\Representation;
use App\Representation\Repository\RepresentationRepository;
/**
* تنها نقطه‌ی نگاشت host → context در backend. هیچ سرویس/کنترلر دیگری نباید
* مستقیماً دامنه را parse یا با cities/representations تطبیق دهد.
*
* ورودی هر شکلی از آدرس را می‌پذیرد (host خالص، URL کامل، با www/پورت).
*/
class DomainContextResolver
{
public function __construct(
private readonly CityRepository $cityRepo,
private readonly RepresentationRepository $representationRepo,
) {}
/**
* اعمال scope دامنه روی فیلترهای لیست‌های عمومی (doctors/clinics):
* دامنه‌ی نماینده‌ی سراسری → فقط ردیف‌های همان نماینده؛ فیلتر شهر/استان بی‌اثر.
* دامنه‌ی شهری/ناشناخته → فیلترها دست‌نخورده (فقط کلید domain حذف می‌شود).
*/
public function applyToListFilters(array $filters): array
{
$domain = $filters['domain'] ?? null;
unset($filters['domain']);
if (!is_string($domain) || $domain === '') {
return $filters;
}
$ctx = $this->resolve($domain);
if ($ctx->isGlobalRepresentation) {
$filters['representation_id'] = $ctx->representationId();
unset($filters['city_id'], $filters['state_id'], $filters['city'], $filters['state']);
}
return $filters;
}
public function resolve(?string $hostOrUrl): DomainContext
{
$domain = Representation::normalizeDomain($hostOrUrl);
if ($domain === null) {
return DomainContext::empty();
}
$city = $this->cityRepo->findByDomain($domain);
if ($city !== null) {
// دامنه‌ی شهری؛ نماینده‌ای که دامنه‌ی اختصاصی‌اش این باشد وجود ندارد،
// ولی ممکن است نماینده‌ای دامنه‌ی شهر را به‌عنوان دامنه‌ی خودش ثبت کرده باشد.
$rep = $this->representationRepo->findActiveByDomain($domain);
return new DomainContext($rep, $city, false);
}
$rep = $this->representationRepo->findActiveByDomain($domain);
if ($rep !== null) {
return new DomainContext($rep, null, $rep->isGlobal());
}
return DomainContext::empty();
}
}