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 مدت و قیمت
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260801172619 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE resource_service_offerings (id INT AUTO_INCREMENT NOT NULL, duration_minutes SMALLINT DEFAULT NULL, price_rials BIGINT DEFAULT NULL, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, resource_id INT NOT NULL, service_item_id INT NOT NULL, INDEX IDX_6D71D02D89329D25 (resource_id), INDEX IDX_6D71D02DDDEB00C2 (service_item_id), INDEX idx_offering_service (service_item_id, active), UNIQUE INDEX uniq_resource_service (resource_id, service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE messenger_messages (id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL, available_at DATETIME NOT NULL, delivered_at DATETIME DEFAULT NULL, INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 (queue_name, available_at, delivered_at, id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE resource_service_offerings ADD CONSTRAINT FK_6D71D02D89329D25 FOREIGN KEY (resource_id) REFERENCES clinic_resources (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE resource_service_offerings ADD CONSTRAINT FK_6D71D02DDDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE resource_service_offerings DROP FOREIGN KEY FK_6D71D02D89329D25');
$this->addSql('ALTER TABLE resource_service_offerings DROP FOREIGN KEY FK_6D71D02DDDEB00C2');
$this->addSql('DROP TABLE resource_service_offerings');
$this->addSql('DROP TABLE messenger_messages');
}
}
+8
View File
@@ -103,6 +103,10 @@ class ClinicResource
#[ORM\OneToMany(targetEntity: ResourceSkill::class, mappedBy: 'resource', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $skills;
/** @var Collection<int, ResourceServiceOffering> سرویس‌هایی که این منبع ارائه می‌دهد */
#[ORM\OneToMany(targetEntity: ResourceServiceOffering::class, mappedBy: 'resource', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $serviceOfferings;
public function __construct(DoctorAddress $address, ResourceType $type, string $name)
{
$this->uuid = Uuid::v4()->toRfc4122();
@@ -112,6 +116,7 @@ class ClinicResource
$this->createdAt = time();
$this->updatedAt = time();
$this->skills = new ArrayCollection();
$this->serviceOfferings = new ArrayCollection();
// جفت از آدرس مشتق می‌شود، نه از بدنهٔ درخواست — پس هیچ نقطهٔ ساختی
// نمی‌تواند فراموشش کند و کلاینت هم نمی‌تواند منبع را به محیط دیگری بچسباند.
@@ -137,6 +142,9 @@ class ClinicResource
/** @return Collection<int, ResourceSkill> */
public function getSkills(): Collection { return $this->skills; }
/** @return Collection<int, ResourceServiceOffering> */
public function getServiceOfferings(): Collection { return $this->serviceOfferings; }
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
public function setSetupMinutes(int $v): self { $this->setupMinutes = $this->assertMinutes($v, 'setup_minutes'); $this->touch(); return $this; }
public function setCleanupMinutes(int $v): self { $this->cleanupMinutes = $this->assertMinutes($v, 'cleanup_minutes'); $this->touch(); return $this; }
@@ -0,0 +1,137 @@
<?php
namespace App\Resource\Entity;
use App\ClinicService\Entity\ServiceItem;
use App\Resource\Repository\ResourceServiceOfferingRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* «این منبع این سرویس را ارائه می‌دهد — با این مدت و این قیمت.»
*
* رابطهٔ چند‌به‌چندِ منبع↔سرویس که سند مالک محصول می‌خواهد: یک سرویس را چند منبع
* می‌دهند و هر منبع چند سرویس دارد، و هر جفت می‌تواند مدت و قیمت خودش را داشته باشد
* («تزریق ژل: دکتر احمدی ۳۰ دقیقه، دکتر رضایی ۴۵ دقیقه»).
*
* چون «گزینهٔ سرویس» در این کدبیس هم یک {@see ServiceItem} است (عضو یک `ItemGroup`)،
* همین یک جدول هر دو سطح سند را پوشش می‌دهد: ردیف با آیتمِ والد یعنی «منبع + سرویس» و
* ردیف با آیتمِ عضو یعنی «منبع + گزینه».
*
* فرزند aggregate با ریشهٔ {@see ClinicResource} است، دقیقاً مثل {@see ResourceSkill}:
* ستون محیط ندارد چون منبع خودش دارد، و کپی‌کردنش یعنی دو جای قابل واگرایی برای یک
* حقیقت. به‌جایش سازنده اجبار می‌کند منبع و سرویس در **یک** محیط باشند.
*/
#[ORM\Entity(repositoryClass: ResourceServiceOfferingRepository::class)]
#[ORM\Table(name: 'resource_service_offerings')]
#[ORM\UniqueConstraint(name: 'uniq_resource_service', columns: ['resource_id', 'service_item_id'])]
#[ORM\Index(columns: ['service_item_id', 'active'], name: 'idx_offering_service')]
class ResourceServiceOffering
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: ClinicResource::class, inversedBy: 'serviceOfferings')]
#[ORM\JoinColumn(name: 'resource_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ClinicResource $resource;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ServiceItem $serviceItem;
/**
* `null` یعنی «ارث از سطح بالاتر»، نه «صفر».
*
* صفرِ صریح مدتی است که وجود ندارد؛ تهی یعنی این منبع حرفی برای گفتن ندارد و
* {@see \App\ClinicService\Service\ResourceServiceResolver} می‌رود سراغ سطح بعد.
*/
#[ORM\Column(name: 'duration_minutes', type: 'smallint', nullable: true)]
private ?int $durationMinutes = null;
#[ORM\Column(name: 'price_rials', type: 'bigint', nullable: true)]
private ?int $priceRials = null;
/**
* غیرفعال یعنی «فعلاً این منبع این سرویس را نمی‌دهد» — ردیف می‌ماند تا مدت و قیمتِ
* تنظیم‌شده با خاموش/روشن کردن از دست نرود.
*/
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
/** @throws \InvalidArgumentException روی منبع و سرویسِ دو محیط متفاوت */
public function __construct(ClinicResource $resource, ServiceItem $serviceItem)
{
// منبع کلینیک الف نباید سرویس کلینیک ب را ارائه دهد. `TenantFilter` این را
// نمی‌گیرد: هر دو با uuid از بدنهٔ درخواست می‌آیند و فیلتر روی فرزند aggregate
// اعمال نمی‌شود.
if ($resource->getEntityType() !== $serviceItem->getEntityType()
|| $resource->getEntityId() !== $serviceItem->getEntityId()) {
throw new \InvalidArgumentException('A resource cannot offer a service from another environment.');
}
$this->resource = $resource;
$this->serviceItem = $serviceItem;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getResource(): ClinicResource { return $this->resource; }
public function getServiceItem(): ServiceItem { return $this->serviceItem; }
public function getDurationMinutes(): ?int { return $this->durationMinutes; }
public function getPriceRials(): ?int { return $this->priceRials; }
public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; }
/** @throws \InvalidArgumentException روی مدت ناممکن */
public function setDurationMinutes(?int $v): self
{
if ($v !== null && $v <= 0) {
throw new \InvalidArgumentException('An offering duration must be positive, or null to inherit.');
}
$this->durationMinutes = $v;
return $this->touch();
}
/** @throws \InvalidArgumentException روی قیمت منفی */
public function setPriceRials(?int $v): self
{
if ($v !== null && $v < 0) {
throw new \InvalidArgumentException('An offering price cannot be negative.');
}
$this->priceRials = $v;
return $this->touch();
}
public function setActive(bool $v): self { $this->active = $v; return $this->touch(); }
private function touch(): self
{
$this->updatedAt = time();
return $this;
}
/** @return array<string, mixed> */
public function toArray(): array
{
return [
'service_uuid' => $this->serviceItem->getUuid(),
'service_name' => $this->serviceItem->getName(),
'duration_minutes' => $this->durationMinutes,
'price_rials' => $this->priceRials,
'active' => $this->active,
];
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Resource\Repository;
use App\ClinicService\Entity\ServiceItem;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceServiceOffering;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ResourceServiceOffering>
*/
class ResourceServiceOfferingRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ResourceServiceOffering::class);
}
public function findOneFor(ClinicResource $resource, ServiceItem $item): ?ResourceServiceOffering
{
return $this->findOneBy(['resource' => $resource, 'serviceItem' => $item]);
}
/** @return ResourceServiceOffering[] سرویس‌های یک منبع، برای تب پنل */
public function findForResource(ClinicResource $resource): array
{
return $this->createQueryBuilder('o')
->join('o.serviceItem', 'i')
->addSelect('i')
->where('o.resource = :resource')
->setParameter('resource', $resource)
->orderBy('i.name', 'ASC')
->getQuery()
->getResult();
}
/**
* آیا برای این سرویس **اصلاً** رابطه‌ای تعریف شده؟
*
* پایهٔ سازگاری عقب‌روِ {@see ClinicResourceRepository::findEligible()}: محیطی که هنوز
* رابطه‌ها را پر نکرده نباید یک‌شبه بدون وقت آزاد شود، پس فیلتر فقط وقتی اعمال
* می‌شود که کلینیک دست‌کم یک ردیف برای آن سرویس ساخته باشد.
*/
public function hasAnyFor(ServiceItem $item): bool
{
return (bool) $this->createQueryBuilder('o')
->select('1')
->where('o.serviceItem = :item')
->setParameter('item', $item)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
/** @return int[] شناسهٔ منابعی که این سرویس را فعال ارائه می‌دهند */
public function activeResourceIdsFor(ServiceItem $item): array
{
$rows = $this->createQueryBuilder('o')
->select('IDENTITY(o.resource) AS resource_id')
->where('o.serviceItem = :item')
->andWhere('o.active = true')
->setParameter('item', $item)
->getQuery()
->getArrayResult();
return array_map(static fn (array $row): int => (int) $row['resource_id'], $rows);
}
public function deleteForResource(ClinicResource $resource): int
{
return (int) $this->createQueryBuilder('o')
->delete()
->where('o.resource = :resource')
->setParameter('resource', $resource)
->getQuery()
->execute();
}
}
+2
View File
@@ -98,6 +98,8 @@ final class GlobalTables
// تسک ۰۱)، پس ارث‌بری اینجا واقعی است. هیچ‌کدام uuid از request نمی‌گیرند:
// تنها راهشان PUT روی /resource/{uuid}/skills و /resource-pool/{uuid}/members است.
\App\Resource\Entity\ResourceSkill::class => \App\Resource\Entity\ClinicResource::class,
// «این منبع این سرویس را می‌دهد» جزئی از تعریف همان منبع است، نه دادهٔ مستقل.
\App\Resource\Entity\ResourceServiceOffering::class => \App\Resource\Entity\ClinicResource::class,
\App\Resource\Entity\ResourceCalendar::class => \App\Resource\Entity\ClinicResource::class,
\App\Resource\Entity\ResourcePoolMember::class => \App\Resource\Entity\ResourcePool::class,
@@ -0,0 +1,162 @@
<?php
namespace App\Tests\Resource;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceServiceOffering;
use App\Resource\Entity\ResourceType;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
/**
* «کدام منبع کدام سرویس را می‌دهد، با چه مدت و چه قیمتی.»
*
* تا پیش از این، انتخاب منبع فقط از نوع و مهارت می‌آمد؛ یعنی دو دستگاه هم‌نوع
* غیرقابل‌تفکیک بودند حتی وقتی فقط یکی‌شان آن سرویس را می‌داد.
*/
class ResourceServiceOfferingTest extends ResourceTestCase
{
private function service(DoctorAddress $address, string $name, int $price = 5_000_000): ServiceItem
{
$section = new ServiceSection($address->tenantEntityType(), $address->tenantEntityId(), 'بخش ' . $name);
$this->em->persist($section);
$this->em->flush();
$item = new ServiceItem($section, $name, $price);
$this->em->persist($item);
$this->em->flush();
return $item;
}
private function resource(DoctorAddress $address, ResourceType $type, string $name): ClinicResource
{
$resource = new ClinicResource($address, $type, $name);
$this->em->persist($resource);
$this->em->flush();
return $resource;
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testAnOfferingCarriesTheDurationAndPriceOfThatResource(): void
{
[, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$resource = $this->resource($address, $type, 'لیزر دایود ۲');
$service = $this->service($address, 'لیزر پا', 8_000_000);
$offering = new ResourceServiceOffering($resource, $service);
$offering->setDurationMinutes(15)->setPriceRials(9_500_000);
$this->em->persist($offering);
$this->em->flush();
$this->em->clear();
$stored = $this->em->getRepository(ResourceServiceOffering::class)->find($offering->getId());
self::assertSame(15, $stored->getDurationMinutes());
self::assertSame(9_500_000, (int) $stored->getPriceRials());
self::assertTrue($stored->isActive());
// قیمت خودِ سرویس دست‌نخورده می‌ماند؛ این ردیف رویش می‌نشیند، جایش را نمی‌گیرد.
self::assertSame(8_000_000, $stored->getServiceItem()->getPriceRials());
}
public function testNullMeansInheritNotZero(): void
{
[, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$resource = $this->resource($address, $type, 'لیزر بدون تنظیم');
$service = $this->service($address, 'لیزر دست');
$offering = new ResourceServiceOffering($resource, $service);
$this->em->persist($offering);
$this->em->flush();
self::assertNull($offering->getDurationMinutes());
self::assertNull($offering->getPriceRials());
// صفر «مدتی که وجود ندارد» است، نه «حرفی برای گفتن ندارم» — پس رد می‌شود.
$this->expectException(\InvalidArgumentException::class);
$offering->setDurationMinutes(0);
}
// ── ❌ خطا ───────────────────────────────────────────────────────────────
public function testTheSamePairCannotBeRegisteredTwice(): void
{
[, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$resource = $this->resource($address, $type, 'لیزر تکراری');
$service = $this->service($address, 'لیزر صورت');
$this->em->persist(new ResourceServiceOffering($resource, $service));
$this->em->flush();
$this->em->persist(new ResourceServiceOffering($resource, $service));
$this->expectException(UniqueConstraintViolationException::class);
$this->em->flush();
}
public function testAResourceCannotOfferAServiceFromAnotherEnvironment(): void
{
[, , $address] = $this->clinicWithAddress();
[, , $otherAddress] = $this->clinicWithAddress('شعبهٔ کلینیک دیگر');
$resource = $this->resource($address, $this->resourceType($address), 'لیزر محیط الف');
$foreign = $this->service($otherAddress, 'سرویس محیط ب');
// `TenantFilter` این را نمی‌گیرد: هر دو uuid از بدنهٔ درخواست می‌آیند و فیلتر
// روی فرزند aggregate اعمال نمی‌شود. پس گارد باید در خودِ سازنده باشد.
$this->expectException(\InvalidArgumentException::class);
new ResourceServiceOffering($resource, $foreign);
}
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
public function testAServiceCanBeOfferedByMoreThanOneResourceWithDifferentNumbers(): void
{
[, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$service = $this->service($address, 'لیزر پا');
$first = $this->resource($address, $type, 'دستگاه شمارهٔ ۱');
$second = $this->resource($address, $type, 'دستگاه شمارهٔ ۲');
$this->em->persist((new ResourceServiceOffering($first, $service))->setDurationMinutes(20)->setPriceRials(8_000_000));
$this->em->persist((new ResourceServiceOffering($second, $service))->setDurationMinutes(15)->setPriceRials(9_500_000));
$this->em->flush();
$repo = $this->em->getRepository(ResourceServiceOffering::class);
self::assertTrue($repo->hasAnyFor($service));
self::assertCount(2, $repo->activeResourceIdsFor($service));
// همان مثال سند: یک سرویس، دو دستگاه، دو عدد متفاوت.
self::assertSame(20, $repo->findOneFor($first, $service)?->getDurationMinutes());
self::assertSame(15, $repo->findOneFor($second, $service)?->getDurationMinutes());
}
public function testDeactivatingKeepsTheRowAndItsNumbers(): void
{
[, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$resource = $this->resource($address, $type, 'دستگاه خاموش');
$service = $this->service($address, 'لیزر بیکینی');
$offering = new ResourceServiceOffering($resource, $service);
$offering->setDurationMinutes(25)->setPriceRials(7_000_000)->setActive(false);
$this->em->persist($offering);
$this->em->flush();
$repo = $this->em->getRepository(ResourceServiceOffering::class);
// رابطه هست ولی فعال نیست: تنظیمات با خاموش/روشن کردن از دست نمی‌رود.
self::assertTrue($repo->hasAnyFor($service));
self::assertSame([], $repo->activeResourceIdsFor($service));
self::assertSame(25, $repo->findOneFor($resource, $service)?->getDurationMinutes());
}
}