fix(representation): pay commission on online bookings from city sites

Three things kept a city-site booking from ever reaching its representative.

The domain never resolved. City sites carry their own domain on cities.domain
while a representative's coverage is a set of cities, and representations.domain
is normally only filled for a global agent. The resolver looked at that column
alone, so bookingRepId was always null and the commission guard rejected every
booking made through a city site. It now falls back to the active representative
covering that city, and stays null when two of them cover it — an ambiguous
money assignment has to be resolved in the data, not guessed.

Commission waited for confirmation. The money has already arrived when the
gateway callback succeeds; confirming the appointment is the doctor's or
secretary's job and may happen days later or never. It is now recorded on
payment, with the appointment still pending. Recording is idempotent, so the
confirmation path stays and creates nothing twice.

The dashboard counted every appointment of the representative's doctors,
including the ones a secretary typed into the panel. It now counts only
bookings that came from the representative's own site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-19 23:14:22 +03:30
co-authored by Claude Opus 5
parent 73a9351f14
commit 15dfbe61fd
7 changed files with 292 additions and 6 deletions
+11
View File
@@ -328,6 +328,17 @@ final class PaymentManager
$this->em->persist($appointment);
$doctor = $appointment->getDoctor();
// تقسیم مالی همین‌جا انجام می‌شود، نه در لحظهٔ تأیید نوبت: پول از سایت آمده و
// سهم نماینده باید همان لحظه ثبت شود. تأیید نوبت کارِ پزشک/منشی است و ممکن
// است روزها بعد یا هرگز انجام نشود. ثبت idempotent است، پس مسیر تأیید
// (`AppointmentConfirmationService`) دوباره چیزی نمی‌سازد.
$this->commissionService->processAppointment(
$payment,
$doctor->getRepresentationId(),
$this->bookingRepresentationIdFor($payment),
$doctor->getId(),
);
$mobile = $appointment->getPatientMobile();
if ($mobile) {
$this->smsService->dispatchTemplate(SmsLog::TAG_PAYMENT, $mobile, [
@@ -493,11 +493,15 @@ class RepresentationController extends BaseController
{
$repId = $rep->getId();
// نوبت‌های پزشکانِ همین نماینده در بازه.
// فقط نوبت‌های **آنلاینِ** همین نماینده: نوبتی که منشی در پنل ثبت می‌کند از
// سایتِ نماینده نیامده و سهمی هم نمی‌سازد، پس در آمار نماینده هم نباید بیاید.
// `bookingRepresentationId` تنها در مسیر رزرو سایت عمومی پر می‌شود.
$totalAppointments = (int) $this->em->createQuery(
'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
JOIN a.doctor d
WHERE d.representationId = :repId AND a.createdAt BETWEEN :start AND :end'
WHERE d.representationId = :repId
AND a.bookingRepresentationId = :repId
AND a.createdAt BETWEEN :start AND :end'
)->setParameters(['repId' => $repId, 'start' => $startTs, 'end' => $endTs])
->getSingleScalarResult();
@@ -29,6 +29,30 @@ class RepresentationRepository extends ServiceEntityRepository
return $this->findOneBy(['domain' => $domain, 'active' => true]);
}
/**
* نمایندهٔ فعالِ یک شهر، از نگاشت چندشهریِ `representation_cities`.
*
* سایت‌های شهری دامنهٔ خودشان را دارند (`cities.domain`) و نماینده معمولاً ستون
* `domain` ندارد؛ بدون این، دامنهٔ شهری به هیچ نماینده‌ای نمی‌رسید و پورسانتِ
* نوبت‌های آن سایت هرگز حساب نمی‌شد.
*
* فقط وقتی نتیجه یکتاست برمی‌گردد: اگر دو نماینده یک شهر را پوشش دهند، انتساب
* پول مبهم است و باید صریح حل شود، نه با حدس.
*/
public function findActiveByCityId(int $cityId): ?Representation
{
$rows = $this->createQueryBuilder('r')
->join('r.cities', 'c')
->where('r.active = true')
->andWhere('c.id = :cityId')
->setParameter('cityId', $cityId)
->setMaxResults(2)
->getQuery()
->getResult();
return count($rows) === 1 ? $rows[0] : null;
}
/**
* تطبیق بر اساس اولین label دامنه (TLD-agnostic) — هم‌راستا با تطبیق subdomainِ
* شهرها؛ لازم برای dev (مثلاً host=`x-nobat.localhost` باید نماینده‌ی `x-nobat.ir`
@@ -50,9 +50,12 @@ class DomainContextResolver
$city = $this->cityRepo->findByDomain($domain);
if ($city !== null) {
// دامنه‌ی شهری؛ نماینده‌ای که دامنه‌ی اختصاصی‌اش این باشد وجود ندارد،
// ولی ممکن است نماینده‌ای دامنه‌ی شهر را به‌عنوان دامنه‌ی خودش ثبت کرده باشد.
$rep = $this->representationRepo->findActiveByDomain($domain);
// دامنه‌ی شهری. اول نماینده‌ای که همین دامنه را به‌عنوان دامنه‌ی خودش ثبت
// کرده، بعد نماینده‌ی خودِ شهر: مدل پوشش نماینده شهری است، نه دامنه‌ای، و
// ستون `domain` معمولاً فقط برای نماینده‌ی سراسری پر می‌شود.
$rep = $this->representationRepo->findActiveByDomain($domain)
?? $this->representationRepo->findActiveByCityId((int) $city->getId());
return new DomainContext($rep, $city, false);
}