From f0e1f43d51ead27d5e3ca32ac5d76aa46a7be7d2 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 17 Jul 2026 11:47:33 +0330 Subject: [PATCH] feat(discount): add DiscountRule entity and session discount-rule audit columns New Discount domain: DiscountRule (generic per-tenant rule with 6 types, priority, combinable, validity window, and per-type target fields) plus its repository. PatientSession gains applied_discount_rule_id/label audit columns and setDiscount() now records the source rule. Migration creates the table and columns. Co-Authored-By: Claude Fable 5 --- migrations/Version20260717081632.php | 31 +++ src/Discount/Entity/DiscountRule.php | 179 ++++++++++++++++++ .../Repository/DiscountRuleRepository.php | 49 +++++ src/Patient/Entity/PatientSession.php | 16 +- 4 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 migrations/Version20260717081632.php create mode 100644 src/Discount/Entity/DiscountRule.php create mode 100644 src/Discount/Repository/DiscountRuleRepository.php diff --git a/migrations/Version20260717081632.php b/migrations/Version20260717081632.php new file mode 100644 index 00000000..4433b8f5 --- /dev/null +++ b/migrations/Version20260717081632.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE discount_rules (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, owner_type VARCHAR(10) NOT NULL, owner_id INT NOT NULL, name VARCHAR(120) NOT NULL, type VARCHAR(20) NOT NULL, discount_type VARCHAR(10) NOT NULL, value INT NOT NULL, priority INT NOT NULL, combinable TINYINT DEFAULT 0 NOT NULL, active TINYINT DEFAULT 1 NOT NULL, valid_from INT DEFAULT NULL, valid_to INT DEFAULT NULL, target_tag_id INT DEFAULT NULL, target_record_id INT DEFAULT NULL, target_service_item_id INT DEFAULT NULL, min_amount_rials INT DEFAULT NULL, min_visit_count INT DEFAULT NULL, occasion_kind VARCHAR(20) DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_A2B041ABD17F50A6 (uuid), INDEX idx_discount_rules_owner (owner_type, owner_id, active), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE patient_sessions ADD applied_discount_rule_id INT DEFAULT NULL, ADD applied_discount_rule_label VARCHAR(120) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE discount_rules'); + $this->addSql('ALTER TABLE patient_sessions DROP applied_discount_rule_id, DROP applied_discount_rule_label'); + } +} diff --git a/src/Discount/Entity/DiscountRule.php b/src/Discount/Entity/DiscountRule.php new file mode 100644 index 00000000..11d63a88 --- /dev/null +++ b/src/Discount/Entity/DiscountRule.php @@ -0,0 +1,179 @@ + false])] + private bool $combinable = false; + + #[ORM\Column(type: 'boolean', options: ['default' => true])] + private bool $active = true; + + #[ORM\Column(name: 'valid_from', type: 'integer', nullable: true)] + private ?int $validFrom = null; + + #[ORM\Column(name: 'valid_to', type: 'integer', nullable: true)] + private ?int $validTo = null; + + // ── target fields (بسته به type فقط یکی معنی‌دار است) ────────────────────── + #[ORM\Column(name: 'target_tag_id', type: 'integer', nullable: true)] + private ?int $targetTagId = null; + + #[ORM\Column(name: 'target_record_id', type: 'integer', nullable: true)] + private ?int $targetRecordId = null; + + #[ORM\Column(name: 'target_service_item_id', type: 'integer', nullable: true)] + private ?int $targetServiceItemId = null; + + #[ORM\Column(name: 'min_amount_rials', type: 'integer', nullable: true)] + private ?int $minAmountRials = null; + + #[ORM\Column(name: 'min_visit_count', type: 'integer', nullable: true)] + private ?int $minVisitCount = null; + + #[ORM\Column(name: 'occasion_kind', type: 'string', length: 20, nullable: true)] + private ?string $occasionKind = null; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct(string $ownerType, int $ownerId, string $name, string $type) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->ownerType = $ownerType; + $this->ownerId = $ownerId; + $this->name = $name; + $this->type = $type; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getOwnerType(): string { return $this->ownerType; } + public function getOwnerId(): int { return $this->ownerId; } + public function getName(): string { return $this->name; } + public function getType(): string { return $this->type; } + public function getDiscountType(): string { return $this->discountType; } + public function getValue(): int { return $this->value; } + public function getPriority(): int { return $this->priority; } + public function isCombinable(): bool { return $this->combinable; } + public function isActive(): bool { return $this->active; } + public function getValidFrom(): ?int { return $this->validFrom; } + public function getValidTo(): ?int { return $this->validTo; } + public function getTargetTagId(): ?int { return $this->targetTagId; } + public function getTargetRecordId(): ?int { return $this->targetRecordId; } + public function getTargetServiceItemId(): ?int { return $this->targetServiceItemId; } + public function getMinAmountRials(): ?int { return $this->minAmountRials; } + public function getMinVisitCount(): ?int { return $this->minVisitCount; } + public function getOccasionKind(): ?string { return $this->occasionKind; } + + public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; } + public function setType(string $v): self { $this->type = $v; $this->touch(); return $this; } + public function setDiscountType(string $v): self { $this->discountType = $v; $this->touch(); return $this; } + public function setValue(int $v): self { $this->value = $v; $this->touch(); return $this; } + public function setPriority(int $v): self { $this->priority = $v; $this->touch(); return $this; } + public function setCombinable(bool $v): self { $this->combinable = $v; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } + public function setValidFrom(?int $v): self { $this->validFrom = $v; $this->touch(); return $this; } + public function setValidTo(?int $v): self { $this->validTo = $v; $this->touch(); return $this; } + public function setTargetTagId(?int $v): self { $this->targetTagId = $v; $this->touch(); return $this; } + public function setTargetRecordId(?int $v): self { $this->targetRecordId = $v; $this->touch(); return $this; } + public function setTargetServiceItemId(?int $v): self { $this->targetServiceItemId = $v; $this->touch(); return $this; } + public function setMinAmountRials(?int $v): self { $this->minAmountRials = $v; $this->touch(); return $this; } + public function setMinVisitCount(?int $v): self { $this->minVisitCount = $v; $this->touch(); return $this; } + public function setOccasionKind(?string $v): self { $this->occasionKind = $v; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'name' => $this->name, + 'type' => $this->type, + 'discount_type' => $this->discountType, + 'value' => $this->value, + 'priority' => $this->priority, + 'combinable' => $this->combinable, + 'active' => $this->active, + 'valid_from' => $this->validFrom, + 'valid_to' => $this->validTo, + 'target_tag_id' => $this->targetTagId, + 'target_record_id' => $this->targetRecordId, + 'target_service_item_id' => $this->targetServiceItemId, + 'min_amount_rials' => $this->minAmountRials, + 'min_visit_count' => $this->minVisitCount, + 'occasion_kind' => $this->occasionKind, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ]; + } +} diff --git a/src/Discount/Repository/DiscountRuleRepository.php b/src/Discount/Repository/DiscountRuleRepository.php new file mode 100644 index 00000000..8169c3fc --- /dev/null +++ b/src/Discount/Repository/DiscountRuleRepository.php @@ -0,0 +1,49 @@ +getEntityManager()->persist($e); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(DiscountRule $e, bool $flush = true): void + { + $this->getEntityManager()->remove($e); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function findByUuidForOwner(string $uuid, string $ownerType, int $ownerId): ?DiscountRule + { + return $this->findOneBy(['uuid' => $uuid, 'ownerType' => $ownerType, 'ownerId' => $ownerId]); + } + + /** @return DiscountRule[] قوانین فعالِ یک owner (برای موتور). */ + public function findActiveForOwner(string $ownerType, int $ownerId): array + { + return $this->findBy(['ownerType' => $ownerType, 'ownerId' => $ownerId, 'active' => true], ['priority' => 'DESC']); + } + + /** لیست ادمین به‌صورت آرایه (array hydration). */ + public function listForOwner(string $ownerType, int $ownerId): array + { + return $this->createQueryBuilder('r') + ->where('r.ownerType = :t')->setParameter('t', $ownerType) + ->andWhere('r.ownerId = :i')->setParameter('i', $ownerId) + ->orderBy('r.priority', 'DESC')->addOrderBy('r.id', 'DESC') + ->getQuery()->getArrayResult(); + } +} diff --git a/src/Patient/Entity/PatientSession.php b/src/Patient/Entity/PatientSession.php index c6336413..e2db5923 100644 --- a/src/Patient/Entity/PatientSession.php +++ b/src/Patient/Entity/PatientSession.php @@ -76,6 +76,14 @@ class PatientSession #[ORM\Column(name: 'discount_rials', type: 'integer')] private int $discountRials = 0; + /** منبع تخفیف: id قانون تخفیف اعمال‌شده (audit)؛ null اگر دستی یا بدون تخفیف */ + #[ORM\Column(name: 'applied_discount_rule_id', type: 'integer', nullable: true)] + private ?int $appliedDiscountRuleId = null; + + /** کشِ نام قانون اعمال‌شده برای نمایش/گزارش بدون join */ + #[ORM\Column(name: 'applied_discount_rule_label', type: 'string', length: 120, nullable: true)] + private ?string $appliedDiscountRuleLabel = null; + /** زمان تسویه‌ی کامل (unix)؛ تا قبل از صفر شدن بدهی null است */ #[ORM\Column(name: 'paid_at', type: 'integer', nullable: true)] private ?int $paidAt = null; @@ -193,14 +201,18 @@ class PatientSession public function setServicesTotalRials(int $v): self { $this->servicesTotalRials = $v; $this->updatedAt = time(); return $this; } public function setFinalPriceRials(int $v): self { $this->finalPriceRials = $v; $this->updatedAt = time(); return $this; } public function setPaymentMethod(string $v): self { $this->paymentMethod = $v; $this->updatedAt = time(); return $this; } - public function setDiscount(?string $type, int $value, int $rials): self + public function setDiscount(?string $type, int $value, int $rials, ?int $ruleId = null, ?string $ruleLabel = null): self { $this->discountType = $type; $this->discountValue = $type === null ? 0 : $value; $this->discountRials = $type === null ? 0 : $rials; + $this->appliedDiscountRuleId = $type === null ? null : $ruleId; + $this->appliedDiscountRuleLabel = $type === null ? null : $ruleLabel; $this->updatedAt = time(); return $this; } + public function getAppliedDiscountRuleId(): ?int { return $this->appliedDiscountRuleId; } + public function getAppliedDiscountRuleLabel(): ?string { return $this->appliedDiscountRuleLabel; } public function setSessionAt(?int $v): self { $this->sessionAt = $v; $this->updatedAt = time(); return $this; } public function setInventoryPackage(?InventoryPackage $v): self { $this->inventoryPackage = $v; $this->updatedAt = time(); return $this; } public function setPaidAt(?int $v): self { $this->paidAt = $v; $this->updatedAt = time(); return $this; } @@ -226,6 +238,8 @@ class PatientSession 'discount_type' => $this->discountType, 'discount_value' => $this->discountValue, 'discount_rials' => $this->discountRials, + 'applied_discount_rule_id' => $this->appliedDiscountRuleId, + 'applied_discount_rule_label' => $this->appliedDiscountRuleLabel, 'paid_at' => $this->paidAt, 'paid_total_rials' => $this->getPaidTotalRials(), 'payments' => array_map(