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:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user