"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>
55 lines
2.2 KiB
PHP
55 lines
2.2 KiB
PHP
<?php
|
|
|
|
namespace App\Pricing\ValueObject;
|
|
|
|
/**
|
|
* نتیجهٔ زنجیرهٔ قیمتگذاری، پیش از اینکه جایی ذخیره شود.
|
|
*
|
|
* همان اعدادی که `PriceSnapshot` نگه میدارد — عمداً یک شکل، تا «قیمتی که به کاربر
|
|
* نشان دادیم» و «قیمتی که ثبت کردیم» نتوانند واگرا شوند.
|
|
*/
|
|
final readonly class PriceQuote
|
|
{
|
|
/** @param list<array<string, mixed>> $discounts */
|
|
public function __construct(
|
|
public int $baseRials,
|
|
public int $itemsRials,
|
|
public int $discountRials,
|
|
public int $insuranceBaseRials,
|
|
public int $insuranceSupplementaryRials,
|
|
public int $taxRials,
|
|
public int $finalRials,
|
|
public int $depositRials,
|
|
public array $discounts = [],
|
|
public array $sources = [],
|
|
/**
|
|
* پکیج در پیشنمایش **مصرف نمیشود** — فقط اعلام میشود. مصرف واقعی هنگام
|
|
* ثبت نهایی است، وگرنه هر رفرش صفحه یک جلسه از بیمار میگرفت.
|
|
*/
|
|
public bool $packageWillBeConsumed = false,
|
|
public ?string $packageUuid = null,
|
|
) {}
|
|
|
|
public function breakdown(): array
|
|
{
|
|
return ['discounts' => $this->discounts, 'sources' => $this->sources];
|
|
}
|
|
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'base_rials' => $this->baseRials,
|
|
'items_rials' => $this->itemsRials,
|
|
'discount_rials' => $this->discountRials,
|
|
'insurance_base_rials' => $this->insuranceBaseRials,
|
|
'insurance_supplementary_rials' => $this->insuranceSupplementaryRials,
|
|
'tax_rials' => $this->taxRials,
|
|
'final_rials' => $this->finalRials,
|
|
'deposit_rials' => $this->depositRials,
|
|
'package_will_be_consumed' => $this->packageWillBeConsumed,
|
|
'package_uuid' => $this->packageUuid,
|
|
'breakdown' => $this->breakdown(),
|
|
];
|
|
}
|
|
}
|