feat(catalog): dual durations, item groups, relations and branch overrides
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>
This commit is contained in:
@@ -7,6 +7,7 @@ use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use App\Appointment\ValueObject\ServiceBookingDuration;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\ClinicService\Service\DurationCalculator;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
@@ -27,6 +28,7 @@ final class ServiceBookingCalculator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ServiceItemRepository $itemRepo,
|
||||
private readonly DurationCalculator $durations,
|
||||
private readonly WeeklyScheduleRepository $schedules,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
) {}
|
||||
@@ -69,9 +71,9 @@ final class ServiceBookingCalculator
|
||||
): ServiceBookingDuration {
|
||||
$this->assertBelongsToContext($serviceUuids, $doctor, $clinic);
|
||||
|
||||
$totalMinutes = 0;
|
||||
$resolved = [];
|
||||
$warnings = [];
|
||||
$resolved = [];
|
||||
$warnings = [];
|
||||
$overridden = [];
|
||||
|
||||
foreach ($serviceUuids as $uuid) {
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -87,17 +89,20 @@ final class ServiceBookingCalculator
|
||||
}
|
||||
|
||||
$override = isset($durationOverrides[$uuid]) ? (int) $durationOverrides[$uuid] : 0;
|
||||
$duration = $override > 0 ? $override : (int) ($item->getDurationMinutes() ?? 0);
|
||||
$duration = $override > 0 ? $override : (int) ($item->getSoloDurationMinutes() ?? 0);
|
||||
if ($duration <= 0) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
|
||||
$totalMinutes += $duration;
|
||||
$resolved[] = $item;
|
||||
if ($override > 0) {
|
||||
$overridden[(int) $item->getId()] = $override;
|
||||
}
|
||||
|
||||
$resolved[] = $item;
|
||||
}
|
||||
|
||||
return new ServiceBookingDuration(
|
||||
totalMinutes: $totalMinutes,
|
||||
totalMinutes: $this->durations->totalMinutes($resolved, [], $overridden),
|
||||
bufferMinutes: $this->bufferMinutes($doctor, $clinic),
|
||||
serviceItems: $resolved,
|
||||
warnings: $warnings,
|
||||
|
||||
Reference in New Issue
Block a user