feat(discount): admin Discount Management tab + uuid-based rule targets

Switch rule target references from int ids to uuids (frontend-friendly; the
engine compares uuids directly) via a migration. Add a Discount Management
tab to the subscription page with a full CRUD UI (DiscountTab): table + modal
form with per-type dynamic target fields (tenant tag / service cascade /
amount / visit count / specific patient / occasion + validity window),
priority and combinable/active toggles. Verified TSC + build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-17 12:02:03 +03:30
co-authored by Claude Fable 5
parent b3b1dad832
commit 18d9cc9812
8 changed files with 451 additions and 39 deletions
@@ -161,9 +161,9 @@ class DiscountController extends BaseController
if (array_key_exists('active', $data)) { $rule->setActive((bool) $data['active']); }
if (array_key_exists('valid_from', $data)) { $rule->setValidFrom($data['valid_from'] !== null ? (int) $data['valid_from'] : null); }
if (array_key_exists('valid_to', $data)) { $rule->setValidTo($data['valid_to'] !== null ? (int) $data['valid_to'] : null); }
if (array_key_exists('target_tag_id', $data)) { $rule->setTargetTagId($data['target_tag_id'] !== null ? (int) $data['target_tag_id'] : null); }
if (array_key_exists('target_record_id', $data)) { $rule->setTargetRecordId($data['target_record_id'] !== null ? (int) $data['target_record_id'] : null); }
if (array_key_exists('target_service_item_id', $data)) { $rule->setTargetServiceItemId($data['target_service_item_id'] !== null ? (int) $data['target_service_item_id'] : null); }
if (array_key_exists('target_tag_uuid', $data)) { $rule->setTargetTagUuid($data['target_tag_uuid'] !== null ? (string) $data['target_tag_uuid'] : null); }
if (array_key_exists('target_record_uuid', $data)) { $rule->setTargetRecordUuid($data['target_record_uuid'] !== null ? (string) $data['target_record_uuid'] : null); }
if (array_key_exists('target_service_item_uuid', $data)) { $rule->setTargetServiceItemUuid($data['target_service_item_uuid'] !== null ? (string) $data['target_service_item_uuid'] : null); }
if (array_key_exists('min_amount_rials', $data)) { $rule->setMinAmountRials($data['min_amount_rials'] !== null ? (int) $data['min_amount_rials'] : null); }
if (array_key_exists('min_visit_count', $data)) { $rule->setMinVisitCount($data['min_visit_count'] !== null ? (int) $data['min_visit_count'] : null); }
if (array_key_exists('occasion_kind', $data)) { $rule->setOccasionKind($data['occasion_kind'] !== null ? (string) $data['occasion_kind'] : null); }
+17 -17
View File
@@ -79,15 +79,15 @@ class DiscountRule
#[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;
// ── target fields (بسته به type فقط یکی معنی‌دار است؛ uuid برای سازگاری با UI) ──
#[ORM\Column(name: 'target_tag_uuid', type: 'string', length: 36, nullable: true)]
private ?string $targetTagUuid = null;
#[ORM\Column(name: 'target_record_id', type: 'integer', nullable: true)]
private ?int $targetRecordId = null;
#[ORM\Column(name: 'target_record_uuid', type: 'string', length: 36, nullable: true)]
private ?string $targetRecordUuid = null;
#[ORM\Column(name: 'target_service_item_id', type: 'integer', nullable: true)]
private ?int $targetServiceItemId = null;
#[ORM\Column(name: 'target_service_item_uuid', type: 'string', length: 36, nullable: true)]
private ?string $targetServiceItemUuid = null;
#[ORM\Column(name: 'min_amount_rials', type: 'integer', nullable: true)]
private ?int $minAmountRials = null;
@@ -128,9 +128,9 @@ class DiscountRule
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 getTargetTagUuid(): ?string { return $this->targetTagUuid; }
public function getTargetRecordUuid(): ?string { return $this->targetRecordUuid; }
public function getTargetServiceItemUuid(): ?string { return $this->targetServiceItemUuid; }
public function getMinAmountRials(): ?int { return $this->minAmountRials; }
public function getMinVisitCount(): ?int { return $this->minVisitCount; }
public function getOccasionKind(): ?string { return $this->occasionKind; }
@@ -144,9 +144,9 @@ class DiscountRule
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 setTargetTagUuid(?string $v): self { $this->targetTagUuid = $v; $this->touch(); return $this; }
public function setTargetRecordUuid(?string $v): self { $this->targetRecordUuid = $v; $this->touch(); return $this; }
public function setTargetServiceItemUuid(?string $v): self { $this->targetServiceItemUuid = $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; }
@@ -166,10 +166,10 @@ class DiscountRule
'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,
'target_tag_uuid' => $this->targetTagUuid,
'target_record_uuid' => $this->targetRecordUuid,
'target_service_item_uuid' => $this->targetServiceItemUuid,
'min_amount_rials' => $this->minAmountRials,
'min_visit_count' => $this->minVisitCount,
'occasion_kind' => $this->occasionKind,
'created_at' => $this->createdAt,
+9 -9
View File
@@ -91,15 +91,15 @@ class DiscountEngine
$record = $session->getRecord();
return match ($rule->getType()) {
DiscountRule::TYPE_PATIENT_TAG => $this->hasTag($session, $rule->getTargetTagId()) ? $final : null,
DiscountRule::TYPE_PATIENT_TAG => $this->hasTag($session, $rule->getTargetTagUuid()) ? $final : null,
DiscountRule::TYPE_INVOICE_AMOUNT => ($rule->getMinAmountRials() !== null && $final >= $rule->getMinAmountRials()) ? $final : null,
DiscountRule::TYPE_SPECIFIC_PATIENT => ($rule->getTargetRecordId() !== null && $record->getId() === $rule->getTargetRecordId()) ? $final : null,
DiscountRule::TYPE_SPECIFIC_PATIENT => ($rule->getTargetRecordUuid() !== null && $record->getUuid() === $rule->getTargetRecordUuid()) ? $final : null,
DiscountRule::TYPE_OCCASION => $this->occasionMatches($rule, $session, $now) ? $final : null,
DiscountRule::TYPE_SERVICE => $this->serviceBase($session, $rule->getTargetServiceItemId()),
DiscountRule::TYPE_SERVICE => $this->serviceBase($session, $rule->getTargetServiceItemUuid()),
DiscountRule::TYPE_VISIT_COUNT => ($rule->getMinVisitCount() !== null
&& $this->sessionRepo->countByRecord($record) >= $rule->getMinVisitCount()) ? $final : null,
@@ -108,13 +108,13 @@ class DiscountEngine
};
}
private function hasTag(PatientSession $session, ?int $tagId): bool
private function hasTag(PatientSession $session, ?string $tagUuid): bool
{
if ($tagId === null) {
if ($tagUuid === null) {
return false;
}
foreach ($session->getRecord()->getTags() as $tag) {
if ($tag->getId() === $tagId) {
if ($tag->getUuid() === $tagUuid) {
return true;
}
}
@@ -122,15 +122,15 @@ class DiscountEngine
}
/** مبنای تخفیف سرویس = جمع خطوطِ همان سرویس؛ null اگر سرویس در پرونده نباشد. */
private function serviceBase(PatientSession $session, ?int $serviceItemId): ?int
private function serviceBase(PatientSession $session, ?string $serviceItemUuid): ?int
{
if ($serviceItemId === null) {
if ($serviceItemUuid === null) {
return null;
}
$sum = 0;
foreach ($session->getServices() as $line) {
/** @var SessionService $line */
if ($line->getServiceItem()->getId() === $serviceItemId) {
if ($line->getServiceItem()->getUuid() === $serviceItemUuid) {
$sum += $line->getLineTotalRials();
}
}