diff --git a/docs/new_feture/taskes/task-15-resource-first-model/checklist.md b/docs/new_feture/taskes/task-15-resource-first-model/checklist.md index ef92bf02..bed72930 100644 --- a/docs/new_feture/taskes/task-15-resource-first-model/checklist.md +++ b/docs/new_feture/taskes/task-15-resource-first-model/checklist.md @@ -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 مدت و قیمت diff --git a/migrations/Version20260801172619.php b/migrations/Version20260801172619.php new file mode 100644 index 00000000..00416132 --- /dev/null +++ b/migrations/Version20260801172619.php @@ -0,0 +1,37 @@ +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'); + } +} diff --git a/src/Resource/Entity/ClinicResource.php b/src/Resource/Entity/ClinicResource.php index e0df6c71..be49350b 100644 --- a/src/Resource/Entity/ClinicResource.php +++ b/src/Resource/Entity/ClinicResource.php @@ -103,6 +103,10 @@ class ClinicResource #[ORM\OneToMany(targetEntity: ResourceSkill::class, mappedBy: 'resource', cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $skills; + /** @var Collection سرویس‌هایی که این منبع ارائه می‌دهد */ + #[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 */ public function getSkills(): Collection { return $this->skills; } + /** @return Collection */ + 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; } diff --git a/src/Resource/Entity/ResourceServiceOffering.php b/src/Resource/Entity/ResourceServiceOffering.php new file mode 100644 index 00000000..525e3df5 --- /dev/null +++ b/src/Resource/Entity/ResourceServiceOffering.php @@ -0,0 +1,137 @@ + 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 */ + 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, + ]; + } +} diff --git a/src/Resource/Repository/ResourceServiceOfferingRepository.php b/src/Resource/Repository/ResourceServiceOfferingRepository.php new file mode 100644 index 00000000..b9cdab15 --- /dev/null +++ b/src/Resource/Repository/ResourceServiceOfferingRepository.php @@ -0,0 +1,80 @@ + + */ +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(); + } +} diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index 86347ba4..ce8c2ac6 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -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, diff --git a/tests/Resource/ResourceServiceOfferingTest.php b/tests/Resource/ResourceServiceOfferingTest.php new file mode 100644 index 00000000..832f5f8d --- /dev/null +++ b/tests/Resource/ResourceServiceOfferingTest.php @@ -0,0 +1,162 @@ +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()); + } +}