Resolve a service's duration and price from the resource that performs it

The chain the spec asks for, plus the branch level that already has data:
resource+option, resource+service, branch override, then the item's own value.

Duration and price resolve independently. If they resolved together the first
override would silently swallow the other value — a resource that only differs
in how long it takes would also drop the branch's tariff.

Each resolved value carries where it came from. Without that, the panel cannot
label a number "from the branch" or "service default", and "why this number?"
becomes a four-table investigation.

Two rules worth stating: null means inherit while zero is a real value, so a
free service keeps its zero instead of inheriting the parent's price; and an
inactive offering is skipped whole, since "this resource does not perform this
right now" is not the same as "I have no opinion on the numbers".

The parent service is passed in rather than looked up from the item's group.
The booking flow already holds both, and a reverse query would be a second way
to answer a question that already has an answer in hand.

Eight tests: one per level with the other levels populated so the winner is
provable, plus independent resolution, the inactive skip, zero, and resolving
the service itself without a parent.

Suite 1272 green, phpstan at its 14-error baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 21:17:29 +03:30
co-authored by Claude Opus 5
parent 826b940c00
commit a70a98769b
4 changed files with 346 additions and 8 deletions
@@ -0,0 +1,98 @@
<?php
namespace App\ClinicService\Service;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
use App\ClinicService\ValueObject\ResolvedServiceSpec;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Repository\ResourceServiceOfferingRepository;
/**
* «این سرویس با این منبع چقدر طول می‌کشد و چقدر می‌شود؟»
*
* زنجیرهٔ خاص به عام، طبق سند مالک محصول به‌علاوهٔ سطح شعبه که از قبل داده دارد:
*
* ۱. منبع + گزینه → `ResourceServiceOffering(resource, option)`
* ۲. منبع + سرویس → `ResourceServiceOffering(resource, parent)`
* ۳. شعبه + آیتم → `ServiceBranchOverride(item, address)`
* ۴. پیش‌فرض آیتم → `ServiceItem`
*
* **مدت و قیمت جدا حل می‌شوند.** منبعی که فقط مدتش فرق دارد نباید قیمتش هم از همان
* سطح بیاید؛ اگر با هم حل شوند، اولین override باعث می‌شود تعرفهٔ شعبه بی‌صدا نادیده گرفته شود.
*
* سطحِ والد را **صدازننده** می‌دهد، نه یک کوئری معکوس روی گروه‌ها: جریان رزرو هر دو را
* از قبل در دست دارد (سرویس انتخاب‌شده و گزینه‌اش)، و کوئری معکوس فقط یک راه اضافه برای
* واگرایی می‌سازد.
*/
final class ResourceServiceResolver
{
public function __construct(
private readonly ResourceServiceOfferingRepository $offerings,
private readonly ServiceBranchOverrideRepository $branchOverrides,
) {}
/**
* @param ServiceItem $item آیتمی که قیمت و مدتش را می‌خواهیم (گزینه یا خودِ سرویس)
* @param ServiceItem|null $parentService سرویسِ والد وقتی `$item` یک گزینه است
*/
public function resolve(
ClinicResource $resource,
ServiceItem $item,
DoctorAddress $address,
?ServiceItem $parentService = null,
): ResolvedServiceSpec {
$optionOffering = $this->offerings->findOneFor($resource, $item);
$parentOffering = $parentService !== null && $parentService->getId() !== $item->getId()
? $this->offerings->findOneFor($resource, $parentService)
: null;
// ردیف غیرفعال یعنی «این منبع فعلاً این را نمی‌دهد»، نه «مقدارش را نمی‌دانم» —
// پس اعدادش هم خوانده نمی‌شوند و زنجیره از رویش رد می‌شود.
$optionOffering = $optionOffering?->isActive() === true ? $optionOffering : null;
$parentOffering = $parentOffering?->isActive() === true ? $parentOffering : null;
$branch = $this->branchOverrides->findOneBy(['item' => $item, 'address' => $address]);
[$duration, $durationSource] = $this->first([
[$optionOffering?->getDurationMinutes(), ResolvedServiceSpec::SOURCE_RESOURCE_OPTION],
[$parentOffering?->getDurationMinutes(), ResolvedServiceSpec::SOURCE_RESOURCE_SERVICE],
[$branch?->getSoloDurationMinutes(), ResolvedServiceSpec::SOURCE_BRANCH],
[$item->getSoloDurationMinutes(), ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT],
]);
[$price, $priceSource] = $this->first([
[$optionOffering?->getPriceRials(), ResolvedServiceSpec::SOURCE_RESOURCE_OPTION],
[$parentOffering?->getPriceRials(), ResolvedServiceSpec::SOURCE_RESOURCE_SERVICE],
[$branch?->getPriceRials(), ResolvedServiceSpec::SOURCE_BRANCH],
[$item->getPriceRials(), ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT],
]);
return new ResolvedServiceSpec(
durationMinutes: $duration,
priceRials: (int) ($price ?? 0),
durationSource: $duration === null ? null : $durationSource,
priceSource: $priceSource ?? ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT,
);
}
/**
* اولین سطحی که مقدار دارد. `null` یعنی «حرفی برای گفتن ندارم» و رد می‌شود؛
* صفر یک مقدارِ واقعی است و **رد نمی‌شود** — سرویسِ رایگان قیمتش صفر است، نه ارث‌بر.
*
* @param list<array{0: int|null, 1: string}> $levels
* @return array{0: int|null, 1: string|null}
*/
private function first(array $levels): array
{
foreach ($levels as [$value, $source]) {
if ($value !== null) {
return [$value, $source];
}
}
return [null, null];
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\ClinicService\ValueObject;
/**
* مدت و قیمتِ حل‌شدهٔ یک سرویس برای یک منبع مشخص — به‌همراه اینکه هر عدد **از کجا** آمده.
*
* منبعِ هر مقدار بخشی از نتیجه است نه اطلاعات اضافه: پنل باید بتواند کنار عدد بنویسد
* «از شعبه» یا «پیش‌فرض سرویس»، و وقتی کلینیک می‌پرسد «چرا این عدد؟» جواب بدون خواندن
* چهار جدول در دسترس باشد.
*/
final readonly class ResolvedServiceSpec
{
/** ردیف `resource_service_offerings` برای همان گزینه‌ای که بیمار انتخاب کرده. */
public const SOURCE_RESOURCE_OPTION = 'resource_option';
/** ردیف همان منبع ولی روی سرویسِ والد — وقتی گزینه مقدار خودش را ندارد. */
public const SOURCE_RESOURCE_SERVICE = 'resource_service';
/** `ServiceBranchOverride` — تنظیم این شعبه، مستقل از اینکه کدام منبع کار را می‌کند. */
public const SOURCE_BRANCH = 'branch';
/** مقدار خودِ `ServiceItem`. */
public const SOURCE_SERVICE_DEFAULT = 'service_default';
public function __construct(
public ?int $durationMinutes,
public int $priceRials,
/** یکی از ثابت‌های `SOURCE_*`؛ `null` یعنی هیچ سطحی مدتی تعریف نکرده. */
public ?string $durationSource,
public string $priceSource,
) {}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'duration_minutes' => $this->durationMinutes,
'price_rials' => $this->priceRials,
'duration_source' => $this->durationSource,
'price_source' => $this->priceSource,
];
}
}