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:
@@ -1013,10 +1013,9 @@ class AdminApiController extends BaseController
|
||||
$cityId = $request->query->get('city_id');
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select('r.id, r.uuid, r.fullName, r.mobileNumber, u.mobileNumber as user_mobile, r.cityId, r.commissionPercent, r.active, r.createdAt, c.name as city_name')
|
||||
->select('r.id, r.uuid, r.fullName, r.mobileNumber, u.mobileNumber as user_mobile, r.cityId, r.domain, r.isGlobal, r.commissionPercent, r.active, r.createdAt')
|
||||
->from(Representation::class, 'r')
|
||||
->join('r.user', 'u')
|
||||
->leftJoin(City::class, 'c', 'WITH', 'c.id = r.cityId')
|
||||
->orderBy('r.createdAt', 'DESC');
|
||||
|
||||
if ($search !== '') {
|
||||
@@ -1025,7 +1024,7 @@ class AdminApiController extends BaseController
|
||||
}
|
||||
|
||||
if ($cityId !== null && $cityId !== '') {
|
||||
$qb->andWhere('r.cityId = :cityId')
|
||||
$qb->andWhere(':cityId MEMBER OF r.cities')
|
||||
->setParameter('cityId', (int) $cityId);
|
||||
}
|
||||
|
||||
@@ -1034,19 +1033,41 @@ class AdminApiController extends BaseController
|
||||
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||
->getQuery()->getArrayResult();
|
||||
|
||||
$items = array_map(fn(array $r) => [
|
||||
'id' => (int) $r['id'],
|
||||
'uuid' => $r['uuid'],
|
||||
'domain' => $r['fullName'],
|
||||
'full_name' => $r['fullName'],
|
||||
'mobile_number' => $r['mobileNumber'] ?: ($r['user_mobile'] ?? null),
|
||||
'city_id' => $r['cityId'],
|
||||
'city' => $r['city_name'] ?? null,
|
||||
'commission_percent' => (float) $r['commissionPercent'],
|
||||
'wallet_balance' => 0,
|
||||
'is_active' => (bool) $r['active'],
|
||||
'created_at' => date('c', (int) $r['createdAt']),
|
||||
], $rows);
|
||||
// نام شهرهای هر نماینده (چند-شهری) در یک کوئری برای صفحهی جاری.
|
||||
$repIds = array_map(fn(array $r) => (int) $r['id'], $rows);
|
||||
$cityNames = [];
|
||||
if ($repIds !== []) {
|
||||
$cityRows = $this->em->getConnection()->fetchAllAssociative(
|
||||
'SELECT rc.representation_id, c.id AS city_id, c.name
|
||||
FROM representation_cities rc JOIN cities c ON c.id = rc.city_id
|
||||
WHERE rc.representation_id IN (?) ORDER BY c.name',
|
||||
[$repIds],
|
||||
[\Doctrine\DBAL\ArrayParameterType::INTEGER],
|
||||
);
|
||||
foreach ($cityRows as $cr) {
|
||||
$cityNames[(int) $cr['representation_id']][] = ['id' => (int) $cr['city_id'], 'name' => $cr['name']];
|
||||
}
|
||||
}
|
||||
|
||||
$items = array_map(function (array $r) use ($cityNames) {
|
||||
$cities = $cityNames[(int) $r['id']] ?? [];
|
||||
return [
|
||||
'id' => (int) $r['id'],
|
||||
'uuid' => $r['uuid'],
|
||||
'domain' => $r['domain'],
|
||||
'is_global' => (bool) $r['isGlobal'],
|
||||
'full_name' => $r['fullName'],
|
||||
'mobile_number' => $r['mobileNumber'] ?: ($r['user_mobile'] ?? null),
|
||||
'city_id' => $cities[0]['id'] ?? $r['cityId'],
|
||||
'city_ids' => array_column($cities, 'id'),
|
||||
'cities' => $cities,
|
||||
'city' => $cities !== [] ? implode('، ', array_column($cities, 'name')) : null,
|
||||
'commission_percent' => (float) $r['commissionPercent'],
|
||||
'wallet_balance' => 0,
|
||||
'is_active' => (bool) $r['active'],
|
||||
'created_at' => date('c', (int) $r['createdAt']),
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class AppointmentController extends BaseController
|
||||
private readonly SlotCalculatorService $slotCalculator,
|
||||
private readonly PatientService $patientService,
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly \App\Representation\Repository\RepresentationRepository $representationRepo,
|
||||
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
||||
) {}
|
||||
|
||||
// ── Public: available slots ───────────────────────────────────────────────
|
||||
@@ -260,13 +260,11 @@ class AppointmentController extends BaseController
|
||||
$appointment->setPatientGender($gender);
|
||||
if (isset($data['note'])) $appointment->setNote($data['note']);
|
||||
|
||||
// نمایندهی دامنهی جاری (city_id از سایت)؛ برای گاردِ پورسانت.
|
||||
$cityId = (int) ($data['city_id'] ?? 0);
|
||||
if ($cityId > 0) {
|
||||
$bookingRep = $this->representationRepo->findActiveByCityId($cityId);
|
||||
if ($bookingRep !== null) {
|
||||
$appointment->setBookingRepresentationId($bookingRep->getId());
|
||||
}
|
||||
// نمایندهی دامنهی مبدأ رزرو (از Origin مرورگر)؛ گاردِ نهایی پورسانت در لحظهی
|
||||
// پرداخت دوباره از payment.frontend_address محاسبه میشود — این فقط ثبتِ لحظهی رزرو است.
|
||||
$bookingCtx = $this->domainResolver->resolve($request->headers->get('origin'));
|
||||
if ($bookingCtx->representationId() !== null) {
|
||||
$appointment->setBookingRepresentationId($bookingCtx->representationId());
|
||||
}
|
||||
|
||||
// آدرس نوبت از روی session متناظر در برنامهی هفتگی تعیین میشود (location_id).
|
||||
|
||||
@@ -44,6 +44,7 @@ class ClinicController extends BaseController
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
@@ -259,7 +260,7 @@ class ClinicController extends BaseController
|
||||
#[Route('/api/v1/clinics', methods: ['GET'])]
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$filters = $request->query->all();
|
||||
$filters = $this->domainResolver->applyToListFilters($request->query->all());
|
||||
$result = $this->clinicRepo->findWithFilters($filters);
|
||||
|
||||
$clinicIds = array_map(fn(Clinic $c) => $c->getId(), $result['items']);
|
||||
|
||||
@@ -65,6 +65,10 @@ class ClinicRepository extends ServiceEntityRepository
|
||||
if (!empty($filters['specialty'])) {
|
||||
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']);
|
||||
}
|
||||
// Scope دامنهی نمایندهی سراسری (تزریقشده توسط DomainContextResolver در کنترلر).
|
||||
if (!empty($filters['representation_id'])) {
|
||||
$qb->andWhere('c.representationId = :repId')->setParameter('repId', (int) $filters['representation_id']);
|
||||
}
|
||||
|
||||
$qb->orderBy('c.id', $sort);
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ class DoctorController extends BaseController
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly TenantInsuranceCleanupService $insuranceCleanup,
|
||||
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
@@ -241,7 +242,7 @@ class DoctorController extends BaseController
|
||||
#[Route('/api/v1/doctors', methods: ['GET'])]
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$filters = $request->query->all();
|
||||
$filters = $this->domainResolver->applyToListFilters($request->query->all());
|
||||
$result = $this->doctorRepo->findWithFilters($filters);
|
||||
|
||||
$scheduleMap = [];
|
||||
|
||||
@@ -75,6 +75,10 @@ class DoctorRepository extends ServiceEntityRepository
|
||||
if (!empty($filters['specialty_id'])) {
|
||||
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty_id']);
|
||||
}
|
||||
// Scope دامنهی نمایندهی سراسری (تزریقشده توسط DomainContextResolver در کنترلر).
|
||||
if (!empty($filters['representation_id'])) {
|
||||
$qb->andWhere('d.representationId = :repId')->setParameter('repId', (int) $filters['representation_id']);
|
||||
}
|
||||
if (!empty($filters['gender'])) {
|
||||
$qb->andWhere('d.gender = :gender')->setParameter('gender', $filters['gender']);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ class CityRepository extends ServiceEntityRepository
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function findByDomain(string $domain): ?City
|
||||
{
|
||||
return $this->findOneBy(['domain' => $domain]);
|
||||
}
|
||||
|
||||
public function save(City $city, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($city);
|
||||
|
||||
@@ -40,10 +40,20 @@ final class PaymentManager
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly CommissionService $commissionService,
|
||||
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
||||
private readonly JalaliDateService $jalali,
|
||||
private readonly string $appBaseUrl,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* نمایندهی مالکِ دامنهای که خرید از آن انجام شده — مبنای کمیسیون دامنهمحور.
|
||||
* frontend_address هنگام initiate با allow-list دامنهها validate شده است.
|
||||
*/
|
||||
private function bookingRepresentationIdFor(Payment $payment): ?int
|
||||
{
|
||||
return $this->domainResolver->resolve($payment->getFrontendAddress())->representationId();
|
||||
}
|
||||
|
||||
/**
|
||||
* درگاه را برای یک پرداخت pending init میکند (ارتباط با بانک).
|
||||
* موفق → PaymentInitResult؛ ناموفق → false (پرداخت failed و ذخیرهشده).
|
||||
@@ -306,7 +316,7 @@ final class PaymentManager
|
||||
$this->commissionService->processAppointment(
|
||||
$payment,
|
||||
$doctor->getRepresentationId(),
|
||||
$appointment->getBookingRepresentationId(),
|
||||
$this->bookingRepresentationIdFor($payment),
|
||||
$doctor->getId(),
|
||||
);
|
||||
|
||||
@@ -339,16 +349,18 @@ final class PaymentManager
|
||||
|
||||
$user = $payment->getUser();
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
$bookingRepId = $this->bookingRepresentationIdFor($payment);
|
||||
|
||||
if ($doctor !== null) {
|
||||
$this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid);
|
||||
$this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $doctor->getId(), null);
|
||||
$this->commissionService->processSubscription($payment, $doctor->getRepresentationId(), $bookingRepId, $doctor->getId(), null);
|
||||
return;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
if ($clinic !== null) {
|
||||
$this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid);
|
||||
$this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), null, $clinic->getId());
|
||||
$this->commissionService->processSubscription($payment, $clinic->getRepresentationId(), $bookingRepId, null, $clinic->getId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -52,12 +52,17 @@ class CommissionService
|
||||
);
|
||||
}
|
||||
|
||||
/** پورسانت ارتقاء اشتراک: درصد سراسری = upgrade_commission_percent. */
|
||||
public function processSubscription(Payment $payment, ?int $representationId, ?int $doctorId, ?int $clinicId): void
|
||||
/**
|
||||
* پورسانت ارتقاء اشتراک: درصد سراسری = upgrade_commission_percent.
|
||||
* گاردِ دامنه (مثل نوبت): مالک پزشک/کلینیک و نمایندهی دامنهی خرید باید یکی باشند.
|
||||
*/
|
||||
public function processSubscription(Payment $payment, ?int $ownerRepId, ?int $bookingRepId, ?int $doctorId, ?int $clinicId): void
|
||||
{
|
||||
if ($this->configRepo->get('upgrade_commission_enabled') !== '1') return;
|
||||
|
||||
$rep = $this->resolveRep($representationId);
|
||||
if ($ownerRepId === null || $bookingRepId === null || $ownerRepId !== $bookingRepId) return;
|
||||
|
||||
$rep = $this->resolveRep($ownerRepId);
|
||||
if ($rep === null) return;
|
||||
|
||||
$this->settle(
|
||||
|
||||
Reference in New Issue
Block a user