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
+37 -16
View File
@@ -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).
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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);
+15 -3
View File
@@ -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(),
],
]);
}
}
+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();
}
}
+8 -3
View File
@@ -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(