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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-17 11:47:33 +03:30
co-authored by Claude Fable 5
parent db7b0aca1a
commit f0e1f43d51
4 changed files with 274 additions and 1 deletions
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace App\Discount\Entity;
use App\Discount\Repository\DiscountRuleRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* قانون تخفیف عمومی، per-tenant (owner = doctor|clinic). موتور تخفیف
* (DiscountEngine) این قوانین را برای یک پرونده ارزیابی می‌کند.
*/
#[ORM\Entity(repositoryClass: DiscountRuleRepository::class)]
#[ORM\Table(name: 'discount_rules')]
#[ORM\Index(columns: ['owner_type', 'owner_id', 'active'], name: 'idx_discount_rules_owner')]
class DiscountRule
{
public const TYPE_PATIENT_TAG = 'patient_tag';
public const TYPE_INVOICE_AMOUNT = 'invoice_amount';
public const TYPE_SPECIFIC_PATIENT = 'specific_patient';
public const TYPE_OCCASION = 'occasion';
public const TYPE_SERVICE = 'service';
public const TYPE_VISIT_COUNT = 'visit_count';
public const TYPES = [
self::TYPE_PATIENT_TAG,
self::TYPE_INVOICE_AMOUNT,
self::TYPE_SPECIFIC_PATIENT,
self::TYPE_OCCASION,
self::TYPE_SERVICE,
self::TYPE_VISIT_COUNT,
];
public const DISCOUNT_PERCENT = 'percent';
public const DISCOUNT_FIXED = 'fixed';
/** زیرنوع مناسبت: birthday | null (بازه‌ی تاریخی) */
public const OCCASION_BIRTHDAY = 'birthday';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'owner_type', type: 'string', length: 10)]
private string $ownerType;
#[ORM\Column(name: 'owner_id', type: 'integer')]
private int $ownerId;
#[ORM\Column(type: 'string', length: 120)]
private string $name;
#[ORM\Column(type: 'string', length: 20)]
private string $type;
#[ORM\Column(name: 'discount_type', type: 'string', length: 10)]
private string $discountType = self::DISCOUNT_PERCENT;
/** مقدار خام: درصد (۰..۱۰۰) یا ریال، بسته به discountType */
#[ORM\Column(type: 'integer')]
private int $value = 0;
#[ORM\Column(type: 'integer')]
private int $priority = 0;
#[ORM\Column(type: 'boolean', options: ['default' => 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,
];
}
}