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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
|
||||
use App\ClinicService\Repository\TariffRepository;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Package\Service\PackageConsumptionService;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Pricing\Repository\PriceListItemRepository;
|
||||
use App\Pricing\Repository\PriceListRepository;
|
||||
use App\Pricing\ValueObject\PriceQuote;
|
||||
@@ -37,6 +39,7 @@ final class PricingEngine
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyResolver $policies,
|
||||
private readonly PackageConsumptionService $packages,
|
||||
private readonly PriceListRepository $priceLists,
|
||||
private readonly PriceListItemRepository $priceListItems,
|
||||
private readonly ServiceBranchOverrideRepository $overrides,
|
||||
@@ -59,6 +62,7 @@ final class PricingEngine
|
||||
DoctorAddress $address,
|
||||
int $at,
|
||||
array $policy = [],
|
||||
?PatientRecord $patient = null,
|
||||
): PriceQuote {
|
||||
$entityType = $address->tenantEntityType();
|
||||
$entityId = $address->tenantEntityId();
|
||||
@@ -76,6 +80,17 @@ final class PricingEngine
|
||||
|
||||
$subtotal = $base + $itemsTotal;
|
||||
|
||||
// ── پکیج ──────────────────────────────────────────────────────────────
|
||||
// پکیج **قیمت پایهٔ سرویس** را میپوشاند، نه آیتمهای اضافه: «شش جلسه لیزر»
|
||||
// یعنی شش بار خودِ لیزر، نه هر چیزی که کنارش انتخاب شود.
|
||||
$usable = $patient === null ? null : $this->packages->firstUsable($patient, $service, $at);
|
||||
$covered = 0;
|
||||
|
||||
if ($usable !== null) {
|
||||
$covered = min($base, $subtotal);
|
||||
$subtotal -= $covered;
|
||||
}
|
||||
|
||||
// ── تخفیف ─────────────────────────────────────────────────────────────
|
||||
// قوانین دستهٔ «قیمت» کنار سیاست دستیِ درخواست مینشینند، نه بهجایش: تخفیفی
|
||||
// که اپراتور دستی میدهد و تخفیفی که قانون میدهد هر دو واقعیاند.
|
||||
@@ -110,6 +125,14 @@ final class PricingEngine
|
||||
|
||||
$deposit = max(0, min($deposit, $final));
|
||||
|
||||
if ($covered > 0) {
|
||||
$discounts[] = [
|
||||
'label' => sprintf('پوشش پکیج «%s»', $usable?->getPackage()->getName() ?? '—'),
|
||||
'rials' => $covered,
|
||||
'kind' => 'package',
|
||||
];
|
||||
}
|
||||
|
||||
return new PriceQuote(
|
||||
baseRials: $base,
|
||||
itemsRials: $itemsTotal,
|
||||
@@ -121,6 +144,8 @@ final class PricingEngine
|
||||
depositRials: $deposit,
|
||||
discounts: $discounts,
|
||||
sources: $sources,
|
||||
packageWillBeConsumed: $usable !== null,
|
||||
packageUuid: $usable?->getUuid(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ final readonly class PriceQuote
|
||||
public int $depositRials,
|
||||
public array $discounts = [],
|
||||
public array $sources = [],
|
||||
/**
|
||||
* پکیج در پیشنمایش **مصرف نمیشود** — فقط اعلام میشود. مصرف واقعی هنگام
|
||||
* ثبت نهایی است، وگرنه هر رفرش صفحه یک جلسه از بیمار میگرفت.
|
||||
*/
|
||||
public bool $packageWillBeConsumed = false,
|
||||
public ?string $packageUuid = null,
|
||||
) {}
|
||||
|
||||
public function breakdown(): array
|
||||
@@ -40,6 +46,8 @@ final readonly class PriceQuote
|
||||
'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(),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user