"Six laser sessions" is the common case in an aesthetics clinic: the patient pays once and books the sessions later. Credit is a ledger, not a counter. No table has a remaining/used_count column and a schema test enforces that — the balance is always SUM(delta) over append-only rows, so every number a patient sees has a full history behind it. Corrections are new rows, never edits. - purchase / consume / refund / adjustment / expiry, each with a reason, an author and the appointment it belongs to - consume happens in confirm(), never in quote(): if the preview consumed, a page refresh would cost the patient a session - cancelling adds a refund row; the consume row stays - FIFO across a patient's packages — the oldest is closest to expiring - an empty package is not an error, it just does not apply and the patient pays - adjust/expire need a doctor or clinic role, and adjust always needs a reason - app:package:expire writes the closing row so "where did my 3 sessions go?" always has an answer Consume takes a pessimistic lock on the one package row. That is the opposite of task 07's slot buckets, and docs/api/package.md carries the table explaining why, so nobody unifies them later. Idempotency checks for an existing consume row before inserting rather than catching the unique violation: in Doctrine that exception closes the EntityManager and burns the rest of the request. The unique key stays as the last line of defence. Admin: PackagesPage, a packages tab on the patient record, and a ledger page whose running-balance column shows where the final number came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
143 lines
5.4 KiB
PHP
143 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Package\Entity;
|
|
|
|
use App\Package\Repository\PackageRepository;
|
|
use App\Shared\Tenant\TenantOwnedTrait;
|
|
use Doctrine\Common\Collections\ArrayCollection;
|
|
use Doctrine\Common\Collections\Collection;
|
|
use Doctrine\ORM\Mapping as ORM;
|
|
use Symfony\Component\Uid\Uuid;
|
|
|
|
/**
|
|
* تعریف پکیج — «۶ جلسه لیزر فولبادی».
|
|
*
|
|
* خودِ این ردیف چیزی نمیفروشد؛ {@see PatientPackage} نمونهٔ خریداریشده است و
|
|
* تعداد و قیمت را از اینجا **کپی** میکند. تغییر تعریف فردا، پکیج فروختهشدهٔ دیروز را
|
|
* عوض نمیکند (قانون پنجم مستند).
|
|
*/
|
|
#[ORM\Entity(repositoryClass: PackageRepository::class)]
|
|
#[ORM\Table(name: 'packages')]
|
|
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_packages_tenant')]
|
|
class Package
|
|
{
|
|
use TenantOwnedTrait;
|
|
|
|
#[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(type: 'string', length: 200)]
|
|
private string $name;
|
|
|
|
#[ORM\Column(name: 'session_count', type: 'smallint')]
|
|
private int $sessionCount;
|
|
|
|
/** ریال در `bigint`: پکیج بزرگ از سقف `int` عبور میکند. */
|
|
#[ORM\Column(name: 'price_rials', type: 'bigint')]
|
|
private string|int $priceRials = 0;
|
|
|
|
/** `null` یعنی بیپایان. */
|
|
#[ORM\Column(name: 'validity_days', type: 'smallint', nullable: true)]
|
|
private ?int $validityDays = null;
|
|
|
|
#[ORM\Column(type: 'boolean', options: ['default' => true])]
|
|
private bool $active = true;
|
|
|
|
/** @var Collection<int, PackageService> */
|
|
#[ORM\OneToMany(targetEntity: PackageService::class, mappedBy: 'package', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
|
private Collection $services;
|
|
|
|
#[ORM\Column(name: 'created_at', type: 'integer')]
|
|
private int $createdAt;
|
|
|
|
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
|
private int $updatedAt;
|
|
|
|
public function __construct(string $entityType, int $entityId, string $name, int $sessionCount)
|
|
{
|
|
$this->uuid = Uuid::v4()->toRfc4122();
|
|
$this->name = $name;
|
|
$this->sessionCount = max(1, $sessionCount);
|
|
$this->services = new ArrayCollection();
|
|
$this->createdAt = time();
|
|
$this->updatedAt = time();
|
|
|
|
$this->assignTenantPair($entityType, $entityId);
|
|
}
|
|
|
|
public function getId(): ?int { return $this->id; }
|
|
public function getUuid(): string { return $this->uuid; }
|
|
public function getName(): string { return $this->name; }
|
|
public function getSessionCount(): int { return $this->sessionCount; }
|
|
public function getPriceRials(): int { return (int) $this->priceRials; }
|
|
public function getValidityDays(): ?int { return $this->validityDays; }
|
|
public function isActive(): bool { return $this->active; }
|
|
public function getCreatedAt(): int { return $this->createdAt; }
|
|
|
|
/** @return Collection<int, PackageService> */
|
|
public function getServices(): Collection { return $this->services; }
|
|
|
|
public function setName(string $v): self { $this->name = $v; return $this->touch(); }
|
|
public function setSessionCount(int $v): self { $this->sessionCount = max(1, $v); return $this->touch(); }
|
|
public function setPriceRials(int $v): self { $this->priceRials = max(0, $v); return $this->touch(); }
|
|
public function setValidityDays(?int $v): self { $this->validityDays = $v === null ? null : max(1, $v); return $this->touch(); }
|
|
public function setActive(bool $v): self { $this->active = $v; return $this->touch(); }
|
|
|
|
public function addService(PackageService $service): self
|
|
{
|
|
if (!$this->services->contains($service)) {
|
|
$this->services->add($service);
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/** تاریخ انقضای یک خرید در این لحظه — `null` یعنی بیپایان. */
|
|
public function expiryFor(int $purchasedAt): ?int
|
|
{
|
|
return $this->validityDays === null ? null : $purchasedAt + $this->validityDays * 86400;
|
|
}
|
|
|
|
/** @return list<int> شناسهٔ سرویسهای پوششدادهشده */
|
|
public function serviceIds(): array
|
|
{
|
|
return array_values(array_map(
|
|
static fn (PackageService $s): int => (int) $s->getServiceItem()->getId(),
|
|
$this->services->toArray(),
|
|
));
|
|
}
|
|
|
|
private function touch(): self
|
|
{
|
|
$this->updatedAt = time();
|
|
|
|
return $this;
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'uuid' => $this->uuid,
|
|
'name' => $this->name,
|
|
'session_count' => $this->sessionCount,
|
|
'price_rials' => (int) $this->priceRials,
|
|
'validity_days' => $this->validityDays,
|
|
'active' => $this->active,
|
|
'services' => array_values(array_map(
|
|
static fn (PackageService $s): array => [
|
|
'uuid' => $s->getServiceItem()->getUuid(),
|
|
'name' => $s->getServiceItem()->getName(),
|
|
],
|
|
$this->services->toArray(),
|
|
)),
|
|
'created_at' => $this->createdAt,
|
|
];
|
|
}
|
|
}
|