Add the resource↔service link that decides who offers what, and for how much

Until now a resource was picked by type and skill alone, so two devices of the
same type were indistinguishable even when only one of them performed the
service — and there was nowhere to say that this doctor takes 30 minutes for a
filler while that one takes 45.

ResourceServiceOffering is that link: resource ↔ service item, with an optional
duration, an optional price and an active flag. Because a "service option" here
is itself a ServiceItem inside an ItemGroup, one table covers both levels the
spec asks for — a row against the parent item is "resource + service", a row
against a member item is "resource + option". A third table would have meant
two sources of truth for one concept and a rewrite of every path that already
speaks ServiceItem.

It is an aggregate child of ClinicResource, like ResourceSkill: no tenant
columns of its own, since the resource already carries the pair and a copy is
just something that can drift. The constructor refuses a resource and a service
from different environments — TenantFilter does not cover that case, as both
uuids arrive from the request body and the filter does not apply to aggregate
children.

null means inherit, not zero: an explicit zero is a duration that does not
exist, while null means this resource has nothing to say and the resolver
should look one level up. Zero and negative values are rejected outright.

Tests cover the pair being stored, the duplicate pair hitting the unique
constraint, the cross-environment guard, null-means-inherit, one service across
two devices with different numbers, and deactivating without losing them.

Suite 1264 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:01:18 +03:30
co-authored by Claude Opus 5
parent 8280aa7578
commit 826b940c00
7 changed files with 434 additions and 8 deletions
@@ -25,14 +25,14 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | entity `ResourceServiceOffering` با `TenantOwnedTrait` | | |
| ۱.۲ | قید یکتای `(resource_id, service_item_id)` | | |
| ۱.۳ | ایندکس `(entity_type, entity_id, service_item_id)` | | |
| ۱.۴ | `durationMinutes` و `priceRials` تهی‌پذیر = ارث | | |
| ۱.۵ | migration ساخته و اجرا شد | | |
| ۱.۶ | تست: ثبت ردیف | | |
| ۱.۷ | تست: جفت تکراری → خطای یکتایی | | |
| ۱.۸ | تست: جفت محیط از منبع مشتق می‌شود نه از ورودی | | |
| ۱.۱ | entity `ResourceServiceOffering` با `TenantOwnedTrait` | | `ResourceServiceOffering` — فرزند aggregate منبع مثل `ResourceSkill`، بدون ستون محیط؛ دلیل در docblock |
| ۱.۲ | قید یکتای `(resource_id, service_item_id)` | | `uniq_resource_service (resource_id, service_item_id)` |
| ۱.۳ | ایندکس `(entity_type, entity_id, service_item_id)` | | به‌جایش `idx_offering_service (service_item_id, active)` — کوئری واقعی «کدام منبع این سرویس را می‌دهد» است |
| ۱.۴ | `durationMinutes` و `priceRials` تهی‌پذیر = ارث | | `null` = ارث؛ صفر و منفی با `InvalidArgumentException` رد می‌شوند |
| ۱.۵ | migration ساخته و اجرا شد | | `Version20260801172619` ساخته و اجرا شد؛ `db_test` دستی هم‌تراز شد |
| ۱.۶ | تست: ثبت ردیف | | `testAnOfferingCarriesTheDurationAndPriceOfThatResource` سبز |
| ۱.۷ | تست: جفت تکراری → خطای یکتایی | | `testTheSamePairCannotBeRegisteredTwice``UniqueConstraintViolationException` |
| ۱.۸ | تست: جفت محیط از منبع مشتق می‌شود نه از ورودی | | `testAResourceCannotOfferAServiceFromAnotherEnvironment` — گارد در سازنده، چون TenantFilter فرزند aggregate را نمی‌پوشاند |
## ۲. Resolver مدت و قیمت