add set scheduling api

This commit is contained in:
Yisroel Baum 2026-08-10 20:05:53 +03:00
parent bdb266746d
commit 98ae9bf088
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
20 changed files with 935 additions and 1 deletions

View file

@ -0,0 +1,15 @@
<?php
namespace App\Schedule;
use App\Element\Element;
use DateTimeImmutable;
final readonly class CreateScheduleAssignmentDto
{
public function __construct(
public Element $element,
public DateTimeImmutable $scheduledDate,
public int $position,
) {}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Schedule;
use App\Set\Set;
use App\User\User;
use DateTimeImmutable;
final readonly class CreateScheduleDto
{
/**
* @param list<CreateScheduleAssignmentDto> $assignments
*/
public function __construct(
public User $user,
public Set $set,
public string $elementKind,
public DateTimeImmutable $startDate,
public DateTimeImmutable $targetDate,
public array $assignments,
) {}
}

View file

@ -0,0 +1,115 @@
<?php
namespace App\Schedule;
use App\Element\ElementRepository;
use App\Set\SetRepository;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class EloquentScheduleRepository implements ScheduleRepository
{
public function __construct(
private SetRepository $setRepository,
private ElementRepository $elementRepository,
) {}
public function create(CreateScheduleDto $dto): Schedule
{
return DB::transaction(function () use ($dto): Schedule {
$model = ScheduleModel::create([
'user_id' => $dto->user->getId(),
'set_id' => $dto->set->getId(),
'element_kind' => $dto->elementKind,
'start_date' => $dto->startDate->format('Y-m-d'),
'target_date' => $dto->targetDate->format('Y-m-d'),
]);
foreach ($dto->assignments as $assignmentDto) {
ScheduleAssignmentModel::create([
'schedule_id' => $model->id,
'element_id' => $assignmentDto->element->getId(),
'scheduled_date' => $assignmentDto->scheduledDate
->format('Y-m-d'),
'position' => $assignmentDto->position,
]);
}
return $this->toDomain($model, $dto->user);
});
}
public function findForUser(int $id, User $user): ?Schedule
{
$model = ScheduleModel::query()
->where('id', $id)
->where('user_id', $user->getId())
->first();
return $model === null ? null : $this->toDomain($model, $user);
}
public function findAllForUser(User $user): array
{
$models = ScheduleModel::query()
->where('user_id', $user->getId())
->orderByDesc('id')
->get();
$schedules = [];
foreach ($models as $model) {
$schedules[] = $this->toDomain($model, $user);
}
return $schedules;
}
private function toDomain(ScheduleModel $model, User $user): Schedule
{
$set = $this->setRepository->find($model->set_id);
if ($set === null) {
throw new RuntimeException('schedule set not found');
}
$assignmentModels = ScheduleAssignmentModel::query()
->where('schedule_id', $model->id)
->orderBy('position')
->orderBy('id')
->get();
$assignments = [];
foreach ($assignmentModels as $assignmentModel) {
$element = $this->elementRepository->find(
$assignmentModel->element_id,
);
if ($element === null) {
throw new RuntimeException('schedule element not found');
}
$assignments[] = new ScheduleAssignment(
id: $assignmentModel->id,
element: $element,
scheduledDate: $this->date($assignmentModel->scheduled_date),
position: $assignmentModel->position,
);
}
return new Schedule(
id: $model->id,
user: $user,
set: $set,
elementKind: $model->element_kind,
startDate: $this->date($model->start_date),
targetDate: $this->date($model->target_date),
assignments: $assignments,
);
}
private function date(string $value): DateTimeImmutable
{
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Schedule;
use App\Set\Set;
use App\User\User;
use DateTimeImmutable;
final readonly class Schedule
{
/**
* @param list<ScheduleAssignment> $assignments
*/
public function __construct(
private int $id,
private User $user,
private Set $set,
private string $elementKind,
private DateTimeImmutable $startDate,
private DateTimeImmutable $targetDate,
private array $assignments,
) {}
public function getId(): int
{
return $this->id;
}
public function getUser(): User
{
return $this->user;
}
public function getSet(): Set
{
return $this->set;
}
public function getElementKind(): string
{
return $this->elementKind;
}
public function getStartDate(): DateTimeImmutable
{
return $this->startDate;
}
public function getTargetDate(): DateTimeImmutable
{
return $this->targetDate;
}
/**
* @return list<ScheduleAssignment>
*/
public function getAssignments(): array
{
return $this->assignments;
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Schedule;
use App\Element\Element;
use DateTimeImmutable;
final readonly class ScheduleAssignment
{
public function __construct(
private int $id,
private Element $element,
private DateTimeImmutable $scheduledDate,
private int $position,
) {}
public function getId(): int
{
return $this->id;
}
public function getElement(): Element
{
return $this->element;
}
public function getScheduledDate(): DateTimeImmutable
{
return $this->scheduledDate;
}
public function getPosition(): int
{
return $this->position;
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Schedule;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $schedule_id
* @property int $element_id
* @property string $scheduled_date
* @property int $position
*
* @method static Builder<static>|ScheduleAssignmentModel newModelQuery()
* @method static Builder<static>|ScheduleAssignmentModel newQuery()
* @method static Builder<static>|ScheduleAssignmentModel query()
*
* @mixin \Eloquent
*/
#[Fillable([
'schedule_id',
'element_id',
'scheduled_date',
'position',
])]
class ScheduleAssignmentModel extends Model
{
protected $table = 'schedule_assignments';
public $timestamps = false;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'schedule_id' => 'integer',
'element_id' => 'integer',
'position' => 'integer',
];
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Schedule;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $user_id
* @property int $set_id
* @property string $element_kind
* @property string $start_date
* @property string $target_date
*
* @method static Builder<static>|ScheduleModel newModelQuery()
* @method static Builder<static>|ScheduleModel newQuery()
* @method static Builder<static>|ScheduleModel query()
*
* @mixin \Eloquent
*/
#[Fillable([
'user_id',
'set_id',
'element_kind',
'start_date',
'target_date',
])]
class ScheduleModel extends Model
{
protected $table = 'schedules';
public $timestamps = false;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'user_id' => 'integer',
'set_id' => 'integer',
];
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Schedule;
use App\User\User;
interface ScheduleRepository
{
public function create(CreateScheduleDto $dto): Schedule;
public function findForUser(int $id, User $user): ?Schedule;
/**
* @return list<Schedule>
*/
public function findAllForUser(User $user): array;
}

View file

@ -0,0 +1,212 @@
<?php
namespace App\Schedule\UseCases\CreateSchedule;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Schedule\CreateScheduleAssignmentDto;
use App\Schedule\CreateScheduleDto;
use App\Schedule\Schedule;
use App\Schedule\ScheduleRepository;
use App\Set\Set;
use App\Set\SetRepository;
use DateTimeImmutable;
use DateTimeZone;
class CreateSchedule
{
public function __construct(
private SetRepository $setRepository,
private ElementRepository $elementRepository,
private ScheduleRepository $scheduleRepository,
) {}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(CreateScheduleRequest $request): Schedule
{
if ($request->setId === null || $request->setId < 1) {
throw new BadRequestException('setId is required');
}
$set = $this->setRepository->find($request->setId);
if ($set === null) {
throw new NotFoundException('set not found');
}
if ($request->elementKind === null || $request->elementKind === '') {
throw new BadRequestException('elementKind is required');
}
$startDate = $this->parseDate($request->startDate, 'startDate');
$targetDate = $this->parseDate($request->targetDate, 'targetDate');
if ($targetDate < $startDate) {
throw new BadRequestException(
'targetDate must not be before startDate',
);
}
$elements = array_values(array_filter(
$this->orderedElements($set),
function (Element $element) use ($request): bool {
return $element->getKind() === $request->elementKind;
},
));
if ($elements === []) {
throw new BadRequestException(
'elementKind is not available for set',
);
}
$assignments = $this->assignments(
elements: $elements,
startDate: $startDate,
targetDate: $targetDate,
);
return $this->scheduleRepository->create(new CreateScheduleDto(
user: $request->user,
set: $set,
elementKind: $request->elementKind,
startDate: $startDate,
targetDate: $targetDate,
assignments: $assignments,
));
}
/**
* @return list<Element>
*/
private function orderedElements(Set $set): array
{
$childrenByParentId = [];
foreach ($this->elementRepository->findBySet($set) as $element) {
$parentId = $element->getParentElement()?->getId() ?? 0;
$childrenByParentId[$parentId][] = $element;
}
foreach ($childrenByParentId as &$children) {
usort($children, function (Element $first, Element $second): int {
$positionComparison = $first->getPosition()
<=> $second->getPosition();
if ($positionComparison !== 0) {
return $positionComparison;
}
return $first->getId() <=> $second->getId();
});
}
unset($children);
$orderedElements = [];
$this->appendChildren(
parentId: 0,
childrenByParentId: $childrenByParentId,
orderedElements: $orderedElements,
);
return $orderedElements;
}
/**
* @param array<int, list<Element>> $childrenByParentId
* @param list<Element> $orderedElements
*/
private function appendChildren(
int $parentId,
array $childrenByParentId,
array &$orderedElements,
): void {
foreach ($childrenByParentId[$parentId] ?? [] as $element) {
$orderedElements[] = $element;
$this->appendChildren(
parentId: $element->getId(),
childrenByParentId: $childrenByParentId,
orderedElements: $orderedElements,
);
}
}
/**
* @param list<Element> $elements
* @return list<CreateScheduleAssignmentDto>
*/
private function assignments(
array $elements,
DateTimeImmutable $startDate,
DateTimeImmutable $targetDate,
): array {
$differenceInDays = $startDate->diff($targetDate)->days;
$dayCount = $differenceInDays + 1;
$elementCount = count($elements);
$assignments = [];
foreach ($elements as $index => $element) {
$dayIndex = $this->dayIndex(
elementIndex: $index,
elementCount: $elementCount,
dayCount: $dayCount,
);
$assignments[] = new CreateScheduleAssignmentDto(
element: $element,
scheduledDate: $startDate->modify("+{$dayIndex} days"),
position: $index + 1,
);
}
return $assignments;
}
private function dayIndex(
int $elementIndex,
int $elementCount,
int $dayCount,
): int {
if ($elementCount === 1 || $dayCount === 1) {
return 0;
}
if ($elementCount < $dayCount) {
$scaledIndex = $elementIndex * ($dayCount - 1)
/ ($elementCount - 1);
return (int) floor($scaledIndex + 0.5);
}
return intdiv($elementIndex * $dayCount, $elementCount);
}
/**
* @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;
}
}

View file

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

View file

@ -0,0 +1,30 @@
<?php
namespace App\Schedule\UseCases\GetSchedule;
use App\Exceptions\NotFoundException;
use App\Schedule\Schedule;
use App\Schedule\ScheduleRepository;
class GetSchedule
{
public function __construct(
private ScheduleRepository $scheduleRepository,
) {}
/**
* @throws NotFoundException
*/
public function execute(GetScheduleRequest $request): Schedule
{
$schedule = $this->scheduleRepository->findForUser(
$request->scheduleId,
$request->user,
);
if ($schedule === null) {
throw new NotFoundException('schedule not found');
}
return $schedule;
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Schedule\UseCases\GetSchedule;
use App\User\User;
final readonly class GetScheduleRequest
{
public function __construct(
public int $scheduleId,
public User $user,
) {}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Schedule\UseCases\ListSchedules;
use App\Schedule\Schedule;
use App\Schedule\ScheduleRepository;
use App\User\User;
class ListSchedules
{
public function __construct(
private ScheduleRepository $scheduleRepository,
) {}
/**
* @return list<Schedule>
*/
public function execute(User $user): array
{
return $this->scheduleRepository->findAllForUser($user);
}
}