feat(package): session packages backed by a credit ledger

"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>
This commit is contained in:
hamed
2026-07-31 11:11:03 +03:30
co-authored by Claude Opus 5
parent d6294242b7
commit ca9648732d
35 changed files with 3205 additions and 78 deletions
+23 -1
View File
@@ -12,6 +12,7 @@ use App\Pricing\Entity\PriceListItem;
use App\Pricing\Repository\PriceListItemRepository;
use App\Pricing\Repository\PriceListRepository;
use App\Pricing\Repository\PriceSnapshotRepository;
use App\Patient\Repository\PatientRecordRepository;
use App\Pricing\Service\PricingEngine;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
@@ -35,6 +36,7 @@ class PricingController extends BaseController
private readonly PriceSnapshotRepository $snapshots,
private readonly ServiceItemRepository $items,
private readonly PricingEngine $engine,
private readonly PatientRecordRepository $patients,
private readonly BranchResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
@@ -210,7 +212,27 @@ class PricingController extends BaseController
$at = is_numeric($data['at'] ?? null) ? (int) $data['at'] : time();
$policy = is_array($data['policy'] ?? null) ? $data['policy'] : [];
return $this->success($this->engine->quote($service, $items, $address, $at, $policy)->toArray());
// بیمار اختیاری است: بدون او پکیج معنا ندارد و قیمت همان قیمت کامل است.
$patient = is_string($data['patient_uuid'] ?? null)
? $this->requirePatient($user, $data['patient_uuid'])
: null;
return $this->success($this->engine->quote($service, $items, $address, $at, $policy, $patient)->toArray());
}
private function requirePatient(User $user, string $uuid): \App\Patient\Entity\PatientRecord
{
$patient = $this->patients->findOneBy(['uuid' => $uuid]);
[$entityType, $entityId] = $this->branches->pair($user);
if ($patient === null
|| $patient->getEntityType() !== $entityType
|| $patient->getEntityId() !== $entityId
) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404);
}
return $patient;
}
/**