Section 5 of the design document rejects summing service durations. "Face + bikini" is not 15+12=27 minutes but 15+8=23 — preparation and settling the patient do not happen twice. Seven wasted minutes times twenty appointments a day is an hour of capacity lost daily, and AppointmentController was doing exactly that plain sum. Each item now carries a solo duration and an additional duration. One item counts at its solo duration and the rest at their additional; the anchor is the item with the *largest* solo duration rather than the first one selected. Anchoring on selection order would have let the same basket cost different amounts depending on click order, so a patient could buy a shorter appointment by reordering. Largest-first is also conservative: no combination is ever under-estimated, and under-estimating pushes the next appointment on top of this one. additional_duration_minutes stays NULL by default and the entity reads NULL as "same as solo", so every existing service keeps behaving exactly as before — the 236 appointment-domain tests pass unchanged. The old duration_minutes column is kept and written in step rather than renamed, because other consumers still read it. ServiceBookingCalculator now delegates to DurationCalculator, which is the one-line change task 00 predicted when it deliberately preserved the naive sum. Selection rules are data, not policy: min/max per group is a number, and "bikini does not combine with full body" is a relation. Putting either in a rules engine means several rules per service and nobody able to explain a rejection. Validation returns *all* errors at once rather than the first, since a user with three problems should not make three round trips. Prerequisite cycles are rejected at write time — storing both "A requires B" and "B requires A" would make every selection permanently invalid. Named CatalogCategory, not ServiceCategory: that name is already an insurance enum (outpatient/inpatient) living on ServiceItem itself, so the two would have collided in the same file's imports. Also fixed a defect the tests caught: breakdown() used $overrides[$id]?->… on a key that may not exist, which warns instead of yielding null. 1175 tests / 3289 assertions. phpstan measured at 14 errors both with and without this change (verified by stashing). Slot-mode frozen contract green. The admin UI tab for groups and relations is not built; the checklist records it as outstanding with a target. The backend is complete and POST /service-selection/validate is consumable without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
123 lines
4.5 KiB
PHP
123 lines
4.5 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\ClinicService;
|
|
|
|
use App\ClinicService\Service\DurationCalculator;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* فرمول مدت — واحد و بدون دیتابیس.
|
|
*
|
|
* بند ۵ مستند جمع ساده را رد میکند. این تست همان مثال مستند را قفل میکند و
|
|
* مهمتر: تضمین میکند نتیجه به **ترتیب انتخاب** وابسته نباشد.
|
|
*/
|
|
class DurationCalculatorTest extends TestCase
|
|
{
|
|
private DurationCalculator $calculator;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->calculator = new DurationCalculator();
|
|
}
|
|
|
|
/** آیتم ساختگی بدون دیتابیس: فقط چیزی که فرمول میخواند. */
|
|
private function item(int $id, ?int $solo, ?int $additional, int $price = 0): object
|
|
{
|
|
return new class ($id, $solo, $additional, $price) extends \App\ClinicService\Entity\ServiceItem {
|
|
public function __construct(
|
|
private readonly int $fakeId,
|
|
private readonly ?int $solo,
|
|
private readonly ?int $additional,
|
|
private readonly int $price,
|
|
) {}
|
|
|
|
public function getId(): ?int { return $this->fakeId; }
|
|
public function getUuid(): string { return 'item-' . $this->fakeId; }
|
|
public function getName(): string { return 'آیتم ' . $this->fakeId; }
|
|
public function getPriceRials(): int { return $this->price; }
|
|
public function getSoloDurationMinutes(): ?int { return $this->solo; }
|
|
public function effectiveAdditionalMinutes(): ?int { return $this->additional ?? $this->solo; }
|
|
};
|
|
}
|
|
|
|
/** مثال خودِ مستند: صورت (۱۵/۸) + بیکینی (۱۲/۸) = ۲۳، نه ۲۷. */
|
|
public function testDocumentExample(): void
|
|
{
|
|
$total = $this->calculator->totalMinutes([
|
|
$this->item(1, 15, 8),
|
|
$this->item(2, 12, 8),
|
|
]);
|
|
|
|
self::assertSame(23, $total);
|
|
}
|
|
|
|
public function testASingleItemUsesItsSoloDuration(): void
|
|
{
|
|
self::assertSame(12, $this->calculator->totalMinutes([$this->item(2, 12, 8)]));
|
|
}
|
|
|
|
/**
|
|
* ترتیب انتخاب نباید مدت را عوض کند — وگرنه بیمار با جابهجا کردن کلیکها وقت
|
|
* کوتاهتر میخرید.
|
|
*/
|
|
public function testResultIsIndependentOfSelectionOrder(): void
|
|
{
|
|
$a = $this->calculator->totalMinutes([$this->item(1, 15, 8), $this->item(2, 12, 8)]);
|
|
$b = $this->calculator->totalMinutes([$this->item(2, 12, 8), $this->item(1, 15, 8)]);
|
|
|
|
self::assertSame($a, $b);
|
|
self::assertSame(23, $b);
|
|
}
|
|
|
|
/** آیتم بدون «مدت اضافه» همان مدت تنها را میگیرد — رفتار دادهٔ موجود. */
|
|
public function testMissingAdditionalFallsBackToSoloAndBehavesLikeAPlainSum(): void
|
|
{
|
|
$total = $this->calculator->totalMinutes([
|
|
$this->item(1, 15, null),
|
|
$this->item(2, 12, null),
|
|
]);
|
|
|
|
self::assertSame(27, $total, 'دادهٔ قدیمی دقیقاً مثل قبل جمع ساده میشود');
|
|
}
|
|
|
|
public function testEmptySelectionIsZero(): void
|
|
{
|
|
self::assertSame(0, $this->calculator->totalMinutes([]));
|
|
}
|
|
|
|
/** مدت دستیِ کاربر بر فرمول مقدم است و پایهای برای آن نمیشود. */
|
|
public function testExplicitOverrideWins(): void
|
|
{
|
|
$total = $this->calculator->totalMinutes(
|
|
[$this->item(1, 15, 8), $this->item(2, 12, 8)],
|
|
[],
|
|
[1 => 40],
|
|
);
|
|
|
|
self::assertSame(48, $total, '۴۰ دستی بهعنوان لنگر + ۸ اضافهٔ دومی');
|
|
}
|
|
|
|
public function testBreakdownNamesTheAnchor(): void
|
|
{
|
|
$rows = $this->calculator->breakdown([
|
|
$this->item(2, 12, 8),
|
|
$this->item(1, 15, 8),
|
|
]);
|
|
|
|
$byName = array_column($rows, 'counted_as', 'item_uuid');
|
|
|
|
self::assertSame('solo', $byName['item-1'], 'بزرگترین مدت تنها لنگر است');
|
|
self::assertSame('additional', $byName['item-2']);
|
|
}
|
|
|
|
public function testPriceIsAPlainSum(): void
|
|
{
|
|
$total = $this->calculator->totalPriceRials([
|
|
$this->item(1, 15, 8, 500_000),
|
|
$this->item(2, 12, 8, 300_000),
|
|
]);
|
|
|
|
self::assertSame(800_000, $total, 'قیمت برخلاف مدت جمع ساده است');
|
|
}
|
|
}
|