add schedule rescheduling
This commit is contained in:
parent
b8eeb093d3
commit
7576ccaf09
9 changed files with 314 additions and 5 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
13
backend/app/Schedule/RescheduleScheduleAssignmentDto.php
Normal file
13
backend/app/Schedule/RescheduleScheduleAssignmentDto.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class RescheduleScheduleAssignmentDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $id,
|
||||||
|
public DateTimeImmutable $scheduledDate,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
20
backend/app/Schedule/RescheduleScheduleDto.php
Normal file
20
backend/app/Schedule/RescheduleScheduleDto.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue