add schedule rescheduling

This commit is contained in:
Yisroel Baum 2026-08-19 22:44:14 +03:00
parent b8eeb093d3
commit 7576ccaf09
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
9 changed files with 314 additions and 5 deletions

View file

@ -11,9 +11,11 @@ use App\Schedule\UseCases\CreateSchedule\CreateSchedule;
use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest; use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest;
use App\Schedule\UseCases\GetSchedule\GetSchedule; use App\Schedule\UseCases\GetSchedule\GetSchedule;
use App\Schedule\UseCases\GetSchedule\GetScheduleRequest; use App\Schedule\UseCases\GetSchedule\GetScheduleRequest;
use App\Schedule\UseCases\ListSchedules\ListSchedules;
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate; use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate;
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDateRequest; use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDateRequest;
use App\Schedule\UseCases\ListSchedules\ListSchedules;
use App\Schedule\UseCases\RescheduleSchedule\RescheduleSchedule;
use App\Schedule\UseCases\RescheduleSchedule\RescheduleScheduleRequest;
use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletion; use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletion;
use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletionRequest; use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletionRequest;
use App\Shared\Http\RequestInput; use App\Shared\Http\RequestInput;
@ -27,6 +29,7 @@ class ScheduleController extends Controller
private CreateSchedule $createSchedule, private CreateSchedule $createSchedule,
private ListSchedules $listSchedules, private ListSchedules $listSchedules,
private GetSchedule $getSchedule, private GetSchedule $getSchedule,
private RescheduleSchedule $rescheduleSchedule,
private ListAssignmentsForDate $listAssignmentsForDate, private ListAssignmentsForDate $listAssignmentsForDate,
private SetAssignmentCompletion $setAssignmentCompletion, private SetAssignmentCompletion $setAssignmentCompletion,
) {} ) {}
@ -100,6 +103,39 @@ class ScheduleController extends Controller
]); ]);
} }
public function update(Request $request, int $scheduleId): JsonResponse
{
$input = new RequestInput($request);
try {
$schedule = $this->rescheduleSchedule->execute(
new RescheduleScheduleRequest(
scheduleId: $scheduleId,
user: $this->user($request),
startDate: $input->string('startDate'),
targetDate: $input->string('targetDate'),
workloadPlacement: $input->string(
'workloadPlacement',
),
),
);
} catch (BadRequestException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()],
400,
);
} catch (NotFoundException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()],
404,
);
}
return new JsonResponse([
'schedule' => $this->detailPayload($schedule),
]);
}
public function assignments(Request $request): JsonResponse public function assignments(Request $request): JsonResponse
{ {
$input = new RequestInput($request); $input = new RequestInput($request);
@ -206,15 +242,23 @@ class ScheduleController extends Controller
); );
} }
$days = []; $includedDates = [];
$date = $schedule->getStartDate(); $date = $schedule->getStartDate();
while ($date <= $schedule->getTargetDate()) { while ($date <= $schedule->getTargetDate()) {
$formattedDate = $date->format('Y-m-d'); $includedDates[$date->format('Y-m-d')] = true;
$date = $date->modify('+1 day');
}
foreach (array_keys($assignmentsByDate) as $assignmentDate) {
$includedDates[$assignmentDate] = true;
}
ksort($includedDates);
$days = [];
foreach (array_keys($includedDates) as $formattedDate) {
$days[] = [ $days[] = [
'date' => $formattedDate, 'date' => $formattedDate,
'assignments' => $assignmentsByDate[$formattedDate] ?? [], 'assignments' => $assignmentsByDate[$formattedDate] ?? [],
]; ];
$date = $date->modify('+1 day');
} }
return $days; return $days;

View file

@ -38,6 +38,39 @@ class EloquentScheduleRepository implements ScheduleRepository
}); });
} }
public function reschedule(RescheduleScheduleDto $dto): Schedule
{
return DB::transaction(function () use ($dto): Schedule {
$model = ScheduleModel::query()
->where('id', $dto->scheduleId)
->where('user_id', $dto->user->getId())
->lockForUpdate()
->first();
if ($model === null) {
throw new DomainException(
"Schedule with id {$dto->scheduleId} not found",
);
}
$model->start_date = $dto->startDate->format('Y-m-d');
$model->target_date = $dto->targetDate->format('Y-m-d');
$model->save();
foreach ($dto->assignments as $assignmentDto) {
ScheduleAssignmentModel::query()
->where('id', $assignmentDto->id)
->where('schedule_id', $dto->scheduleId)
->whereNull('completed_at')
->update([
'scheduled_date' => $assignmentDto->scheduledDate
->format('Y-m-d'),
]);
}
return $this->toDomain($model, $dto->user);
});
}
public function findForUser(int $id, User $user): ?Schedule public function findForUser(int $id, User $user): ?Schedule
{ {
$model = ScheduleModel::query() $model = ScheduleModel::query()

View file

@ -0,0 +1,13 @@
<?php
namespace App\Schedule;
use DateTimeImmutable;
final readonly class RescheduleScheduleAssignmentDto
{
public function __construct(
public int $id,
public DateTimeImmutable $scheduledDate,
) {}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Schedule;
use App\User\User;
use DateTimeImmutable;
final readonly class RescheduleScheduleDto
{
/**
* @param list<RescheduleScheduleAssignmentDto> $assignments
*/
public function __construct(
public int $scheduleId,
public User $user,
public DateTimeImmutable $startDate,
public DateTimeImmutable $targetDate,
public array $assignments,
) {}
}

View file

@ -9,6 +9,8 @@ interface ScheduleRepository
{ {
public function create(CreateScheduleDto $dto): Schedule; public function create(CreateScheduleDto $dto): Schedule;
public function reschedule(RescheduleScheduleDto $dto): Schedule;
public function findForUser(int $id, User $user): ?Schedule; public function findForUser(int $id, User $user): ?Schedule;
/** /**

View file

@ -0,0 +1,132 @@
<?php
namespace App\Schedule\UseCases\RescheduleSchedule;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Schedule\EvenDistributionScheduler;
use App\Schedule\RescheduleScheduleAssignmentDto;
use App\Schedule\RescheduleScheduleDto;
use App\Schedule\Schedule;
use App\Schedule\ScheduleAssignment;
use App\Schedule\ScheduleRepository;
use App\Schedule\WorkloadPlacement;
use DateTimeImmutable;
use DateTimeZone;
class RescheduleSchedule
{
public function __construct(
private ScheduleRepository $scheduleRepository,
private EvenDistributionScheduler $evenDistributionScheduler,
) {}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(RescheduleScheduleRequest $request): Schedule
{
$schedule = $this->scheduleRepository->findForUser(
$request->scheduleId,
$request->user,
);
if ($schedule === null) {
throw new NotFoundException('schedule not found');
}
$startDate = $this->parseDate($request->startDate, 'startDate');
$targetDate = $this->parseDate($request->targetDate, 'targetDate');
if ($targetDate < $startDate) {
throw new BadRequestException(
'targetDate must not be before startDate',
);
}
$workloadPlacement = $this->workloadPlacement(
$request->workloadPlacement,
);
$unfinishedAssignments = array_values(array_filter(
$schedule->getAssignments(),
function (ScheduleAssignment $assignment): bool {
return $assignment->getCompletedAt() === null;
},
));
if ($unfinishedAssignments === []) {
throw new BadRequestException(
'schedule has no unfinished assignments',
);
}
$scheduledDates = $this->evenDistributionScheduler->scheduledDates(
assignmentCount: count($unfinishedAssignments),
startDate: $startDate,
targetDate: $targetDate,
workloadPlacement: $workloadPlacement,
);
$assignmentDtos = [];
foreach ($unfinishedAssignments as $index => $assignment) {
$assignmentDtos[] = new RescheduleScheduleAssignmentDto(
id: $assignment->getId(),
scheduledDate: $scheduledDates[$index],
);
}
return $this->scheduleRepository->reschedule(
new RescheduleScheduleDto(
scheduleId: $schedule->getId(),
user: $request->user,
startDate: $startDate,
targetDate: $targetDate,
assignments: $assignmentDtos,
),
);
}
/**
* @throws BadRequestException
*/
private function parseDate(?string $value, string $field): DateTimeImmutable
{
if ($value === null || $value === '') {
throw new BadRequestException("{$field} is required");
}
$date = DateTimeImmutable::createFromFormat(
'!Y-m-d',
$value,
new DateTimeZone('UTC'),
);
$errors = DateTimeImmutable::getLastErrors();
if (
$date === false
|| $date->format('Y-m-d') !== $value
|| ($errors !== false
&& ($errors['warning_count'] > 0 || $errors['error_count'] > 0))
) {
throw new BadRequestException(
"{$field} must be a valid date in YYYY-MM-DD format",
);
}
return $date;
}
/**
* @throws BadRequestException
*/
private function workloadPlacement(?string $value): WorkloadPlacement
{
if ($value === null) {
return WorkloadPlacement::Middle;
}
$workloadPlacement = WorkloadPlacement::tryFrom($value);
if ($workloadPlacement === null) {
throw new BadRequestException(
'workloadPlacement must be start, middle, or end',
);
}
return $workloadPlacement;
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Schedule\UseCases\RescheduleSchedule;
use App\User\User;
final readonly class RescheduleScheduleRequest
{
public function __construct(
public int $scheduleId,
public User $user,
public ?string $startDate,
public ?string $targetDate,
public ?string $workloadPlacement,
) {}
}

View file

@ -22,6 +22,10 @@ Route::middleware(AuthMiddleware::class)->group(function (): void {
)->whereNumber('assignmentId'); )->whereNumber('assignmentId');
Route::post('/schedules', [ScheduleController::class, 'store']); Route::post('/schedules', [ScheduleController::class, 'store']);
Route::get('/schedules', [ScheduleController::class, 'index']); Route::get('/schedules', [ScheduleController::class, 'index']);
Route::patch(
'/schedules/{scheduleId}',
[ScheduleController::class, 'update'],
)->whereNumber('scheduleId');
Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show']) Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show'])
->whereNumber('scheduleId'); ->whereNumber('scheduleId');
Route::post('/logout', [AuthController::class, 'logout']); Route::post('/logout', [AuthController::class, 'logout']);

View file

@ -2,8 +2,9 @@
namespace Tests\Fakes; namespace Tests\Fakes;
use App\Schedule\CreateScheduleDto;
use App\Schedule\AssignmentForDate; use App\Schedule\AssignmentForDate;
use App\Schedule\CreateScheduleDto;
use App\Schedule\RescheduleScheduleDto;
use App\Schedule\Schedule; use App\Schedule\Schedule;
use App\Schedule\ScheduleAssignment; use App\Schedule\ScheduleAssignment;
use App\Schedule\ScheduleRepository; use App\Schedule\ScheduleRepository;
@ -51,6 +52,50 @@ class FakeScheduleRepository implements ScheduleRepository
return $this->copy($schedule); return $this->copy($schedule);
} }
public function reschedule(RescheduleScheduleDto $dto): Schedule
{
$schedule = $this->schedules[$dto->scheduleId] ?? null;
if ($schedule === null || $schedule->getUser()->getId()
!== $dto->user->getId()
) {
throw new DomainException(
"Schedule with id {$dto->scheduleId} not found",
);
}
$datesByAssignmentId = [];
foreach ($dto->assignments as $assignmentDto) {
$datesByAssignmentId[$assignmentDto->id] =
$assignmentDto->scheduledDate;
}
$assignments = [];
foreach ($schedule->getAssignments() as $assignment) {
$assignments[] = new ScheduleAssignment(
id: $assignment->getId(),
name: $assignment->getName(),
kind: $assignment->getKind(),
path: $assignment->getPath(),
scheduledDate: $datesByAssignmentId[$assignment->getId()]
?? $assignment->getScheduledDate(),
position: $assignment->getPosition(),
completedAt: $assignment->getCompletedAt(),
);
}
$rescheduled = new Schedule(
id: $schedule->getId(),
user: $schedule->getUser(),
setName: $schedule->getSetName(),
elementKind: $schedule->getElementKind(),
startDate: $dto->startDate,
targetDate: $dto->targetDate,
assignments: $assignments,
);
$this->schedules[$dto->scheduleId] = $rescheduled;
return $this->copy($rescheduled);
}
public function findForUser(int $id, User $user): ?Schedule public function findForUser(int $id, User $user): ?Schedule
{ {
$schedule = $this->schedules[$id] ?? null; $schedule = $this->schedules[$id] ?? null;