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:
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Tests\Package;
|
||||
use App\Clinic\Entity\Clinic; use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress; use App\Patient\Entity\PatientRecord; use App\Tests\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
#[Group('docs')]
|
||||
class PackageDocsCaptureTest extends ApiTestCase {
|
||||
public function testCapture(): void {
|
||||
if (getenv('PKG_DOCS') !== '1') { self::markTestSkipped('برای تولید خروجی مستندات: PKG_DOCS=1'); }
|
||||
$user = $this->createUser(['ROLE_USER','ROLE_CLINIC']);
|
||||
$clinic = new Clinic($user); $clinic->setName('کلینیک نمونه'); $this->em->persist($clinic); $this->em->flush();
|
||||
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); $this->em->persist($section);
|
||||
$address = DoctorAddress::forClinic($clinic->getId()); $address->setName('شعبهٔ مرکزی'); $this->em->persist($address);
|
||||
$pu = $this->createUser(['ROLE_USER']);
|
||||
$patient = new PatientRecord('clinic', (int)$clinic->getId(), $pu, 'clinic', (int)$clinic->getId());
|
||||
$this->em->persist($patient); $this->em->flush();
|
||||
$item = new ServiceItem($section, 'لیزر فولبادی'); $item->setSoloDurationMinutes(20); $item->setPriceRials(5000000);
|
||||
$this->em->persist($item); $this->em->flush();
|
||||
$d = function(string $l, mixed $b): void { fwrite(STDERR, sprintf("\n===%s %d===\n%s\n", $l, $this->responseCode(), json_encode($b, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT))); };
|
||||
$c = $this->authJson('POST','/api/v1/packages',$user,['name'=>'۶ جلسه لیزر فولبادی','session_count'=>6,'price_rials'=>25000000,'validity_days'=>365,'service_uuids'=>[$item->getUuid()]]);
|
||||
$d('CREATE', $c);
|
||||
$d('INDEX', $this->authJson('GET','/api/v1/packages',$user));
|
||||
$s = $this->authJson('POST',"/api/v1/patient/{$patient->getUuid()}/package",$user,['package_uuid'=>$c['data']['uuid']]);
|
||||
$d('SELL', $s);
|
||||
$d('PATIENT_PACKAGES', $this->authJson('GET',"/api/v1/patient/{$patient->getUuid()}/packages",$user));
|
||||
$this->authJson('POST',"/api/v1/patient-package/{$s['data']['uuid']}/adjust",$user,['delta'=>1,'reason'=>'جبران جلسهٔ لغوشده']);
|
||||
$d('LEDGER', $this->authJson('GET',"/api/v1/patient-package/{$s['data']['uuid']}/ledger",$user));
|
||||
$d('ADJUST_NO_REASON', $this->authJson('POST',"/api/v1/patient-package/{$s['data']['uuid']}/adjust",$user,['delta'=>1]));
|
||||
$d('QUOTE', $this->authJson('POST','/api/v1/pricing/quote',$user,['service_uuid'=>$item->getUuid(),'branch_uuid'=>$address->getUuid(),'patient_uuid'=>$patient->getUuid()]));
|
||||
self::assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Package;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Package\Entity\SessionCreditLedger;
|
||||
use App\Package\Repository\PatientPackageRepository;
|
||||
use App\Package\Service\CreditLedgerService;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* پکیج و دفتر اعتبار جلسات — تسک ۱۱.
|
||||
*
|
||||
* محور همهٔ تستها یک جمله از مستند است: «اعتبار را به صورت دفتر حساب نگه میداریم،
|
||||
* نه یک عدد شمارنده.» پس مانده هیچجا ذخیره نمیشود و هر تغییر یک ردیف است.
|
||||
*/
|
||||
class PackageLedgerTest extends ApiTestCase
|
||||
{
|
||||
private int $slotCursor = 0;
|
||||
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
|
||||
private function clinicWithPatient(): array
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($user);
|
||||
$clinic->setName('کلینیک پکیج');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
|
||||
$this->em->persist($section);
|
||||
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$address->setName('شعبهٔ مرکزی');
|
||||
$this->em->persist($address);
|
||||
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر پکیج');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$patientUser = $this->createUser(['ROLE_USER']);
|
||||
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
|
||||
$this->em->persist($patient);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $section, $address, $doctor, $patient];
|
||||
}
|
||||
|
||||
private function service(ServiceSection $section, string $name, int $price = 5_000_000): ServiceItem
|
||||
{
|
||||
$item = new ServiceItem($section, $name);
|
||||
$item->setSoloDurationMinutes(20);
|
||||
$item->setPriceRials($price);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $extra */
|
||||
private function definePackage(User $user, ServiceItem $service, int $sessions = 6, array $extra = []): array
|
||||
{
|
||||
$body = $this->authJson('POST', '/api/v1/packages', $user, $extra + [
|
||||
'name' => '۶ جلسه لیزر فولبادی',
|
||||
'session_count' => $sessions,
|
||||
'price_rials' => 25_000_000,
|
||||
'service_uuids' => [$service->getUuid()],
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $body['data'];
|
||||
}
|
||||
|
||||
private function sell(User $user, PatientRecord $patient, string $packageUuid): array
|
||||
{
|
||||
$body = $this->authJson('POST', "/api/v1/patient/{$patient->getUuid()}/package", $user, [
|
||||
'package_uuid' => $packageUuid,
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $body['data'];
|
||||
}
|
||||
|
||||
/**
|
||||
* هر درخواست HTTP کرنل را ریبوت میکند و EntityManager تازه میشود، پس entity های
|
||||
* قبلی detached اند و باید دوباره خوانده شوند.
|
||||
*/
|
||||
private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId): Appointment
|
||||
{
|
||||
// یک اسلات یکتا per فراخوانی: پزشک کلید یکتای (doctor, slot_start) دارد.
|
||||
$start = time() + 86400 + (++$this->slotCursor) * 3600;
|
||||
|
||||
$doctor = $this->em->getRepository(Doctor::class)->find($doctor->getId());
|
||||
$patient = $this->em->getRepository(PatientRecord::class)->find($patient->getId());
|
||||
$service = $this->em->getRepository(ServiceItem::class)->find($service->getId());
|
||||
|
||||
$appointment = new Appointment($doctor, $patient->getUser(), $start, $start + 1200);
|
||||
$appointment->assignTenantPair('clinic', $clinicId);
|
||||
$appointment->setServiceItem($service);
|
||||
$appointment->setPatientName('بیمار پکیج');
|
||||
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
private function ledgerService(): CreditLedgerService
|
||||
{
|
||||
return static::getContainer()->get(CreditLedgerService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* سرویسهای کانتینر با EntityManager خودشان کار میکنند؛ entity ساختهشده در تست
|
||||
* باید از همان EM دوباره خوانده شود وگرنه «موجودیت جدیدِ persist نشده» میشود.
|
||||
*/
|
||||
private function reload(Appointment $appointment): Appointment
|
||||
{
|
||||
return static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class)
|
||||
->getRepository(Appointment::class)
|
||||
->find($appointment->getId());
|
||||
}
|
||||
|
||||
private function consumption(): \App\Package\Service\PackageConsumptionService
|
||||
{
|
||||
return static::getContainer()->get(\App\Package\Service\PackageConsumptionService::class);
|
||||
}
|
||||
|
||||
// ── تعریف و فروش ────────────────────────────────────────────────────────
|
||||
|
||||
public function testSellingAPackageOpensTheLedgerWithItsSessionCount(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر فولبادی');
|
||||
|
||||
$package = $this->definePackage($user, $service);
|
||||
$sold = $this->sell($user, $patient, $package['uuid']);
|
||||
|
||||
self::assertSame(6, $sold['balance']);
|
||||
|
||||
$list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user);
|
||||
self::assertSame(6, $list['data'][0]['balance']);
|
||||
|
||||
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
|
||||
|
||||
self::assertCount(1, $ledger['data']['rows']);
|
||||
self::assertSame('purchase', $ledger['data']['rows'][0]['kind']);
|
||||
self::assertSame(6, $ledger['data']['rows'][0]['delta']);
|
||||
self::assertSame(6, $ledger['data']['rows'][0]['running_balance']);
|
||||
}
|
||||
|
||||
/** پکیجی که هیچ سرویسی را پوشش نمیدهد هرگز قابل مصرف نیست. */
|
||||
public function testAPackageWithoutServicesIsRejected(): void
|
||||
{
|
||||
[$user] = $this->clinicWithPatient();
|
||||
|
||||
$this->authJson('POST', '/api/v1/packages', $user, [
|
||||
'name' => 'پکیج بیسرویس',
|
||||
'session_count' => 3,
|
||||
'price_rials' => 1_000_000,
|
||||
'service_uuids' => [],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
/** ⭐ مانده باید محاسبه شود، نه ذخیره — همین جلوی «بهینهسازی» شش ماه بعد را میگیرد. */
|
||||
public function testNoStoredBalanceColumnExists(): void
|
||||
{
|
||||
$columns = $this->em->getConnection()
|
||||
->createSchemaManager()
|
||||
->listTableColumns('patient_packages');
|
||||
|
||||
$names = array_map(static fn ($c): string => strtolower($c->getName()), $columns);
|
||||
|
||||
foreach (['remaining', 'remaining_sessions', 'used_count', 'balance'] as $forbidden) {
|
||||
self::assertNotContains($forbidden, $names, 'مانده باید از دفتر محاسبه شود، نه ذخیره');
|
||||
}
|
||||
}
|
||||
|
||||
// ── مصرف و بازگشت ───────────────────────────────────────────────────────
|
||||
|
||||
public function testConsumingLeavesARowAndCancellingAddsAnotherOne(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$package = $this->definePackage($user, $service);
|
||||
$sold = $this->sell($user, $patient, $package['uuid']);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
|
||||
|
||||
$appointment = $this->reload($appointment);
|
||||
self::assertTrue($this->consumption()->consumeFor($appointment));
|
||||
|
||||
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
|
||||
self::assertSame(5, $this->ledgerService()->balance($entity));
|
||||
|
||||
self::assertTrue($this->ledgerService()->refund($appointment));
|
||||
self::assertSame(6, $this->ledgerService()->balance($entity));
|
||||
|
||||
// ردیف `consume` **حذف نمیشود** — دفتر append-only است.
|
||||
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
|
||||
$kinds = array_column($ledger['data']['rows'], 'kind');
|
||||
|
||||
self::assertSame(['purchase', 'consume', 'refund'], $kinds);
|
||||
self::assertSame([6, 5, 6], array_column($ledger['data']['rows'], 'running_balance'));
|
||||
}
|
||||
|
||||
/** `confirm` idempotent است؛ اجرای دومش نباید جلسهٔ دوم بخورد. */
|
||||
public function testConsumingTwiceForTheSameAppointmentTakesOnlyOneSession(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
|
||||
|
||||
$appointment = $this->reload($appointment);
|
||||
$this->consumption()->consumeFor($appointment);
|
||||
$this->consumption()->consumeFor($appointment);
|
||||
|
||||
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
|
||||
|
||||
self::assertSame(5, $this->ledgerService()->balance($entity));
|
||||
}
|
||||
|
||||
/** ماندهٔ صفر خطا نیست: بیمار نقدی میپردازد. */
|
||||
public function testAnEmptyPackageIsSimplyNotApplied(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service, 1)['uuid']);
|
||||
|
||||
$first = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
|
||||
$second = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
|
||||
|
||||
self::assertTrue($this->consumption()->consumeFor($first));
|
||||
self::assertFalse($this->consumption()->consumeFor($second), 'ماندهٔ صفر باید بیسروصدا رد شود');
|
||||
|
||||
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
|
||||
self::assertSame(0, $this->ledgerService()->balance($entity), 'مانده هرگز منفی نمیشود');
|
||||
}
|
||||
|
||||
// ── قیمت ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testQuoteAnnouncesThePackageWithoutConsumingIt(): void
|
||||
{
|
||||
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر', 5_000_000);
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($quote, JSON_UNESCAPED_UNICODE));
|
||||
self::assertTrue($quote['data']['package_will_be_consumed']);
|
||||
self::assertSame(0, $quote['data']['final_rials']);
|
||||
|
||||
// پیشنمایش هرگز مصرف نمیکند؛ وگرنه هر رفرش یک جلسه میخورد.
|
||||
$this->authJson('POST', '/api/v1/pricing/quote', $user, [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
]);
|
||||
|
||||
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
|
||||
self::assertSame(6, $this->ledgerService()->balance($entity));
|
||||
}
|
||||
|
||||
public function testQuoteWithoutAPatientChargesTheFullPrice(): void
|
||||
{
|
||||
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر', 5_000_000);
|
||||
|
||||
$this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertFalse($quote['data']['package_will_be_consumed']);
|
||||
self::assertSame(5_000_000, $quote['data']['final_rials']);
|
||||
}
|
||||
|
||||
// ── FIFO و انقضا ────────────────────────────────────────────────────────
|
||||
|
||||
/** قدیمیترین اول، چون به انقضا نزدیکتر است. */
|
||||
public function testTheOldestUnexpiredPackageIsUsedFirst(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$definition = $this->definePackage($user, $service);
|
||||
$older = $this->sell($user, $patient, $definition['uuid']);
|
||||
$newer = $this->sell($user, $patient, $definition['uuid']);
|
||||
|
||||
$repo = static::getContainer()->get(PatientPackageRepository::class);
|
||||
$olderE = $repo->findByUuid($older['uuid']);
|
||||
|
||||
// خرید دوم را عمداً تازهتر میکنیم تا ترتیب قطعی باشد.
|
||||
$this->em->getConnection()->executeStatement(
|
||||
'UPDATE patient_packages SET purchased_at = purchased_at + 100 WHERE uuid = ?',
|
||||
[$newer['uuid']],
|
||||
);
|
||||
$this->em->clear();
|
||||
|
||||
$chosen = $this->consumption()->firstUsable(
|
||||
$this->em->getRepository(PatientRecord::class)->find($patient->getId()),
|
||||
$this->em->getRepository(ServiceItem::class)->find($service->getId()),
|
||||
);
|
||||
|
||||
self::assertSame($olderE->getUuid(), $chosen?->getUuid());
|
||||
}
|
||||
|
||||
public function testAnExpiredPackageShowsZeroBalanceButKeepsItsLedger(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$this->em->getConnection()->executeStatement(
|
||||
'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?',
|
||||
[time() - 86400, $sold['uuid']],
|
||||
);
|
||||
$this->em->clear();
|
||||
|
||||
$list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user);
|
||||
|
||||
self::assertTrue($list['data'][0]['expired']);
|
||||
self::assertSame(0, $list['data'][0]['balance']);
|
||||
|
||||
// دفتر دستنخورده است: «۶ جلسهام چه شد؟» هنوز جواب دارد.
|
||||
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
|
||||
self::assertSame(6, $ledger['data']['rows'][0]['running_balance']);
|
||||
}
|
||||
|
||||
public function testExpiryCommandWritesTheClosingRow(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$this->em->getConnection()->executeStatement(
|
||||
'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?',
|
||||
[time() - 86400, $sold['uuid']],
|
||||
);
|
||||
$this->em->clear();
|
||||
|
||||
$command = static::getContainer()->get(\App\Package\Command\ExpirePackagesCommand::class);
|
||||
$tester = new \Symfony\Component\Console\Tester\CommandTester($command);
|
||||
$tester->execute([]);
|
||||
|
||||
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
|
||||
$kinds = array_column($ledger['data']['rows'], 'kind');
|
||||
|
||||
self::assertSame(['purchase', 'expiry'], $kinds);
|
||||
self::assertSame(-6, $ledger['data']['rows'][1]['delta']);
|
||||
self::assertSame(0, $ledger['data']['rows'][1]['running_balance']);
|
||||
}
|
||||
|
||||
// ── اصلاح دستی و جداسازی محیط ───────────────────────────────────────────
|
||||
|
||||
public function testAdjustmentNeedsAReasonAndIsRecorded(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, ['delta' => 1]);
|
||||
self::assertSame(422, $this->responseCode(), 'اصلاح بدون دلیل نباید پذیرفته شود');
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
|
||||
'delta' => 2,
|
||||
'reason' => 'جبران جلسهٔ لغوشده توسط کلینیک',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
|
||||
$row = $ledger['data']['rows'][1];
|
||||
|
||||
self::assertSame('adjustment', $row['kind']);
|
||||
self::assertSame(2, $row['delta']);
|
||||
self::assertSame('جبران جلسهٔ لغوشده توسط کلینیک', $row['reason']);
|
||||
self::assertNotNull($row['created_by']);
|
||||
self::assertSame(8, $row['running_balance']);
|
||||
}
|
||||
|
||||
public function testAdjustmentCannotDriveTheBalanceNegative(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
|
||||
'delta' => -10,
|
||||
'reason' => 'اشتباه اپراتور',
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAnotherClinicCannotSeeOrTouchThePackage(): void
|
||||
{
|
||||
[$owner, $section, , , $patient] = $this->clinicWithPatient();
|
||||
[$other] = $this->clinicWithPatient();
|
||||
|
||||
$service = $this->service($section, 'لیزر');
|
||||
$sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']);
|
||||
|
||||
$this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $other, [
|
||||
'delta' => 5,
|
||||
'reason' => 'تلاش از محیط دیگر',
|
||||
]);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
/** منشی نباید بتواند اعتبار را دستی عوض کند. */
|
||||
public function testASecretaryCannotAdjustTheLedger(): void
|
||||
{
|
||||
[$owner, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
$sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']);
|
||||
|
||||
$secretary = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $secretary, [
|
||||
'delta' => 5,
|
||||
'reason' => 'تلاش منشی',
|
||||
]);
|
||||
|
||||
self::assertContains($this->responseCode(), [403, 404]);
|
||||
}
|
||||
|
||||
public function testLedgerRowsNeverHaveAZeroDelta(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
|
||||
'delta' => 0,
|
||||
'reason' => 'بیاثر',
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
|
||||
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
|
||||
|
||||
self::expectException(\InvalidArgumentException::class);
|
||||
new SessionCreditLedger($entity, SessionCreditLedger::KIND_ADJUSTMENT, 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user