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:
@@ -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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user