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
+31
View File
@@ -0,0 +1,31 @@
<?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 Version20260717081632 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add discount_rules table + applied_discount_rule audit columns on patient_sessions';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+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,
];
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Discount\Repository;
use App\Discount\Entity\DiscountRule;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class DiscountRuleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry) { parent::__construct($registry, DiscountRule::class); }
public function save(DiscountRule $e, bool $flush = true): void
{
$this->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();
}
}
+15 -1
View File
@@ -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(