diff --git a/app/Domains/Event/Models/EventDate.php b/app/Domains/Event/Models/EventDate.php index c70df51..81b826a 100644 --- a/app/Domains/Event/Models/EventDate.php +++ b/app/Domains/Event/Models/EventDate.php @@ -122,7 +122,11 @@ class EventDate extends Model public function endsAt(): CarbonInterface { - return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end); + $endsAt = Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end); + + return $endsAt->lessThanOrEqualTo($this->startsAt()) + ? $endsAt->addDay() + : $endsAt; } public function getStatusAttribute(): EventDateStatus @@ -169,10 +173,6 @@ class EventDate extends Model $startsAt = $this->startsAt(); $expiresAt = $this->endsAt(); - if ($expiresAt->lessThanOrEqualTo($startsAt)) { - $expiresAt = $expiresAt->addDay(); - } - $attributes = [ 'type' => ValidityTimeType::FixedWindow, 'start_time' => null, diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php b/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php index 109a836..1923540 100644 --- a/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php +++ b/app/Domains/FiestaFutbolInfantil/Controllers/FoodController.php @@ -3,6 +3,7 @@ namespace App\Domains\FiestaFutbolInfantil\Controllers; use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest; +use App\Domains\FiestaFutbolInfantil\Requests\UpdateHistoricalFoodStockRequest; use App\Domains\FiestaFutbolInfantil\Resources\FoodResource; use App\Domains\FiestaFutbolInfantil\Services\FoodService; use App\Http\Controllers\Controller; @@ -32,6 +33,16 @@ class FoodController extends Controller ); } + public function updateHistoricalStock(UpdateHistoricalFoodStockRequest $request): FoodResource + { + return FoodResource::make( + $this->foodService->updateHistoricalStock( + $request->user()->tenant()->firstOrFail(), + $request->validated('variants'), + ) + ); + } + public function destroy(Request $request, int $food): Response { $this->foodService->delete( diff --git a/app/Domains/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php b/app/Domains/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php new file mode 100644 index 0000000..b75832d --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Requests/UpdateHistoricalFoodStockRequest.php @@ -0,0 +1,24 @@ + */ + public function rules(): array + { + return [ + 'variants' => ['required', 'array', 'min:1', 'max:500'], + 'variants.*' => ['required', 'array:id,stock'], + 'variants.*.id' => ['required', 'integer', 'distinct'], + 'variants.*.stock' => ['required', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php b/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php index ad1f800..e3e854c 100644 --- a/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php +++ b/app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php @@ -3,8 +3,14 @@ namespace App\Domains\FiestaFutbolInfantil\Resources; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Enums\EventDateChangeType; +use App\Domains\Event\Enums\EventDateStatus; +use App\Domains\Event\Models\EventDate; +use App\Domains\Event\Models\EventDateChange; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Collection; /** @mixin CatalogItem */ class FoodResource extends JsonResource @@ -17,27 +23,134 @@ class FoodResource extends JsonResource 'id' => null, 'name' => 'Comida', 'variants' => [], + 'history' => [], ]; } + $currentVariants = $this->variants + ->filter(fn (Variant $variant): bool => $this->isCurrent($variant)); + $historicalVariants = $this->variants + ->filter(fn (Variant $variant): bool => $this->isHistorical($variant)); + return [ 'id' => $this->id, 'name' => $this->nombre, - 'variants' => $this->variants->map(function ($variant): array { - $values = $variant->selectionValues(); - $eventDate = $variant->selectedEventDates()->first(); - - return [ - 'id' => $variant->id, - 'event_date_id' => $eventDate?->id, - 'event_date' => $eventDate?->date?->format('Y-m-d'), - 'schedule' => $values->get('horario'), - 'service' => $values->get('servicio'), - 'description' => $variant->descripcion, - 'stock' => $variant->inventory->real_stock, - 'price' => number_format($variant->getPrice(), 2, '.', ''), - ]; - })->values(), + 'variants' => $currentVariants->map($this->variantData(...))->values(), + 'history' => $this->historyData($historicalVariants), ]; } + + private function isCurrent(Variant $variant): bool + { + if ($variant->sales_disabled_at !== null || $variant->replaced_by_variant_id !== null) { + return false; + } + + $status = $variant->selectedEventDates()->first()?->status; + + return $status === null || in_array( + $status, + [EventDateStatus::Scheduled, EventDateStatus::InProgress], + true, + ); + } + + private function isHistorical(Variant $variant): bool + { + return in_array( + $variant->selectedEventDates()->first()?->status, + [ + EventDateStatus::Rescheduled, + EventDateStatus::Suspended, + EventDateStatus::Completed, + ], + true, + ); + } + + /** @return array */ + private function variantData(Variant $variant): array + { + $values = $variant->selectionValues(); + $eventDate = $variant->selectedEventDates()->first(); + + return [ + 'id' => $variant->id, + 'event_date_id' => $eventDate?->id, + 'event_date' => $eventDate?->date?->format('Y-m-d'), + 'schedule' => $values->get('horario'), + 'service' => $values->get('servicio'), + 'description' => $variant->descripcion, + 'stock' => $variant->inventory->real_stock, + 'price' => number_format($variant->getPrice(), 2, '.', ''), + ]; + } + + /** + * @param Collection $variants + * @return Collection> + */ + private function historyData(Collection $variants): Collection + { + return $variants + ->filter(fn (Variant $variant): bool => $variant->selectedEventDates()->first() !== null) + ->groupBy(fn (Variant $variant): int => (int) $variant->selectedEventDates()->first()->id) + ->map(function (Collection $dateVariants): array { + /** @var EventDate $eventDate */ + $eventDate = $dateVariants->first()->selectedEventDates()->first(); + $status = $this->historicalStatus($eventDate); + $change = $this->changeForStatus($eventDate, $status); + + return [ + 'id' => $eventDate->id, + 'change_id' => $change?->id, + 'status' => $status->value, + 'status_text' => match ($status) { + EventDateStatus::Rescheduled => 'REPROGRAMADA', + EventDateStatus::Suspended => 'CANCELADA', + EventDateStatus::Completed => 'FINALIZADA', + default => '', + }, + 'event_date_id' => $eventDate->id, + 'event_date' => $eventDate->date->format('Y-m-d'), + 'replacement_event_date_id' => $status === EventDateStatus::Rescheduled + ? ($change?->destination_event_date_id + ?? $eventDate->rescheduled_to_event_date_id) + : null, + 'replacement_event_date' => $status === EventDateStatus::Rescheduled + ? ($change?->new_date?->format('Y-m-d') + ?? $eventDate->rescheduledTo?->date?->format('Y-m-d')) + : null, + 'occurred_at' => ($change?->created_at ?? $eventDate->endsAt())->toISOString(), + 'variants' => $dateVariants->map($this->variantData(...))->values(), + ]; + }) + ->sortByDesc('occurred_at') + ->values(); + } + + private function historicalStatus(EventDate $eventDate): EventDateStatus + { + return match ($eventDate->status) { + EventDateStatus::Rescheduled => EventDateStatus::Rescheduled, + EventDateStatus::Suspended => EventDateStatus::Suspended, + default => EventDateStatus::Completed, + }; + } + + private function changeForStatus( + EventDate $eventDate, + EventDateStatus $status, + ): ?EventDateChange { + $changeType = match ($status) { + EventDateStatus::Rescheduled => EventDateChangeType::Rescheduled, + EventDateStatus::Suspended => EventDateChangeType::Suspended, + default => null, + }; + + return $changeType === null + ? null + : $eventDate->changeHistory + ->first(fn (EventDateChange $change): bool => $change->change_type === $changeType); + } } diff --git a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php index 7551b69..06caafb 100644 --- a/app/Domains/FiestaFutbolInfantil/Services/FoodService.php +++ b/app/Domains/FiestaFutbolInfantil/Services/FoodService.php @@ -11,6 +11,8 @@ use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogService; +use App\Domains\Event\Enums\EventDateStatus; +use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -36,8 +38,10 @@ class FoodService ->with([ 'variants.catalogItem', 'variants.inventory', - 'variants.eventDate', - 'variants.eventDates', + 'variants.eventDate.rescheduledTo', + 'variants.eventDate.changeHistory.destinationEventDate', + 'variants.eventDates.rescheduledTo', + 'variants.eventDates.changeHistory.destinationEventDate', 'variants.definitions.itemAttribute.attribute', ]) ->first(); @@ -55,11 +59,16 @@ class FoodService $food->variants()->whereNull('precio')->update(['precio' => $food->precio]); $existingVariants = $food->variants() + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') ->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute']) ->lockForUpdate() - ->get(); + ->get() + ->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant)) + ->values(); $resolvedVariants = $this->resolveVariants($variants, $attributes); + $this->validateCurrentEventDates($tenant, $resolvedVariants); $this->validateCombinations($resolvedVariants, $existingVariants); foreach ($resolvedVariants as $index => $data) { @@ -80,7 +89,13 @@ class FoodService } } - $minimumPrice = $food->variants()->min('precio'); + $minimumPrice = $food->variants() + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->with(['eventDate', 'eventDates']) + ->get() + ->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant)) + ->min('precio'); if ($minimumPrice !== null) { $food->update(['precio' => $minimumPrice]); } @@ -88,22 +103,81 @@ class FoodService return $food->fresh()->load([ 'variants.catalogItem', 'variants.inventory', - 'variants.eventDate', - 'variants.eventDates', + 'variants.eventDate.rescheduledTo', + 'variants.eventDate.changeHistory.destinationEventDate', + 'variants.eventDates.rescheduledTo', + 'variants.eventDates.changeHistory.destinationEventDate', 'variants.definitions.itemAttribute.attribute', ]); }); } + /** + * @param array $variants + */ + public function updateHistoricalStock(Tenant $tenant, array $variants): CatalogItem + { + return DB::transaction(function () use ($tenant, $variants): CatalogItem { + $food = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'comida') + ->lockForUpdate() + ->firstOrFail(); + $variantIds = collect($variants)->pluck('id')->map(fn ($id): int => (int) $id); + $historicalVariants = $food->variants() + ->whereIn('id', $variantIds) + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->with(['inventory', 'eventDate', 'eventDates']) + ->lockForUpdate() + ->get() + ->filter(fn (Variant $variant): bool => $this->hasHistoricalDate($variant)) + ->keyBy('id'); + + foreach ($variants as $index => $data) { + $variant = $historicalVariants->get((int) $data['id']); + if ($variant === null) { + throw ValidationException::withMessages([ + "variants.{$index}.id" => [ + 'La variante no pertenece al historial de Comida.', + ], + ]); + } + + $stock = (int) $data['stock']; + $inventory = Inventory::query() + ->whereKey($variant->inventory_id) + ->lockForUpdate() + ->firstOrFail(); + if ($stock < $inventory->reserved_stock) { + throw ValidationException::withMessages([ + "variants.{$index}.stock" => [ + 'El stock no puede ser menor que la cantidad actualmente reservada.', + ], + ]); + } + + $inventory->update(['real_stock' => $stock]); + } + + return $this->current($tenant) ?? $food; + }); + } + public function delete(Tenant $tenant, int $foodId): void { $variant = Variant::query() ->whereKey($foodId) + ->whereNull('sales_disabled_at') + ->whereNull('replaced_by_variant_id') + ->with(['eventDate', 'eventDates']) ->whereHas('catalogItem', fn ($query) => $query ->where('tenant_code', $tenant->codigo) ->where('slug', 'comida')) ->firstOrFail(); + abort_unless($this->hasCurrentDate($variant), 404); + $this->catalogService->deleteVariant($variant); } @@ -225,6 +299,30 @@ class FoodService return $option; } + /** @param array> $variants */ + private function validateCurrentEventDates(Tenant $tenant, array $variants): void + { + $eventDates = EventDate::query() + ->where('tenant_code', $tenant->codigo) + ->whereIn('id', collect($variants)->pluck('event_date_id')->unique()) + ->get() + ->keyBy('id'); + + foreach ($variants as $index => $variant) { + $eventDate = $eventDates->get($variant['event_date_id']); + + if ($eventDate !== null && $this->isCurrentStatus($eventDate->status)) { + continue; + } + + throw ValidationException::withMessages([ + "variants.{$index}.event_date_id" => [ + 'La fecha seleccionada ya no está disponible.', + ], + ]); + } + } + /** * @param array> $incoming * @param Collection $existing @@ -329,4 +427,25 @@ class FoodService mb_strtolower(trim($service)), ]); } + + private function hasCurrentDate(Variant $variant): bool + { + $status = $variant->selectedEventDates()->first()?->status; + + return $status === null || $this->isCurrentStatus($status); + } + + private function hasHistoricalDate(Variant $variant): bool + { + return in_array( + $variant->selectedEventDates()->first()?->status, + [EventDateStatus::Rescheduled, EventDateStatus::Suspended, EventDateStatus::Completed], + true, + ); + } + + private function isCurrentStatus(EventDateStatus $status): bool + { + return in_array($status, [EventDateStatus::Scheduled, EventDateStatus::InProgress], true); + } } diff --git a/app/Domains/FiestaFutbolInfantil/routes/api.php b/app/Domains/FiestaFutbolInfantil/routes/api.php index 39d4a47..242cad0 100644 --- a/app/Domains/FiestaFutbolInfantil/routes/api.php +++ b/app/Domains/FiestaFutbolInfantil/routes/api.php @@ -39,6 +39,9 @@ Route::prefix('v1/adminapp/tenant') Route::post('foods', [FoodController::class, 'store']) ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') ->name('adminapp.fiesta-futbol-infantil.foods.store'); + Route::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock']) + ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') + ->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update'); Route::delete('foods/{food}', [FoodController::class, 'destroy']) ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida') ->name('adminapp.fiesta-futbol-infantil.foods.destroy'); diff --git a/app/Domains/Forms/Services/FoodFormService.php b/app/Domains/Forms/Services/FoodFormService.php index 240ea5d..5ed7636 100644 --- a/app/Domains/Forms/Services/FoodFormService.php +++ b/app/Domains/Forms/Services/FoodFormService.php @@ -4,6 +4,7 @@ namespace App\Domains\Forms\Services; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\AttributeOption; +use App\Domains\Event\Enums\EventDateStatus; use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Collection; @@ -31,7 +32,13 @@ class FoodFormService ->whereNull('rescheduled_to_event_date_id') ->whereNull('suspended_at') ->with('validityTime') - ->get(), + ->get() + ->filter(fn (EventDate $eventDate): bool => in_array( + $eventDate->status, + [EventDateStatus::Scheduled, EventDateStatus::InProgress], + true, + )) + ->values(), 'schedules' => $attributes->get('horario')?->options ?? new Collection, 'services' => $attributes->get('servicio')?->options ?? new Collection, ]; diff --git a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php index f03dac5..d41a7bb 100644 --- a/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/FoodControllerTest.php @@ -8,12 +8,14 @@ use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Variant; +use App\Domains\Event\Services\EventService; use App\Domains\Menu\Models\Menu; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -32,6 +34,13 @@ class FoodControllerTest extends TestCase ]); } + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + public function test_authentication_is_required(): void { $this->postJson('/api/v1/adminapp/tenant/foods', ['variants' => []]) @@ -142,6 +151,101 @@ class FoodControllerTest extends TestCase ]); } + public function test_it_separates_current_and_historical_food_variants(): void + { + Carbon::setTestNow('2026-09-01 12:00:00'); + [$tenant, $rescheduledDate, $suspendedDate] = $this->configuredTenant(); + $completedDate = $tenant->eventDates()->create([ + 'date' => '2026-10-11', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $activeDate = $tenant->eventDates()->create([ + 'date' => '2026-10-21', + 'time_start' => '00:00', + 'time_end' => '23:59', + ]); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $this->variantPayload($rescheduledDate->id, 'Almuerzo', 'Comedor', 100, 10000), + $this->variantPayload($suspendedDate->id, 'Cena', 'Vianda', 90, 9000), + $this->variantPayload($completedDate->id, 'Cena', 'Comedor', 80, 8000), + $this->variantPayload($activeDate->id, 'Almuerzo', 'Vianda', 70, 7000), + ], + ])->assertOk(); + + $eventService = app(EventService::class); + $eventService->rescheduleDateForTenant( + $tenant, + $rescheduledDate, + ['date' => '2026-10-20'], + $admin, + ); + $eventService->suspendDateForTenant($tenant, $suspendedDate, $admin); + + Carbon::setTestNow('2026-10-15 12:00:00'); + $response = $this->getJson('/api/v1/adminapp/tenant/foods')->assertOk(); + + $response->assertJsonCount(2, 'data.variants')->assertJsonCount(3, 'data.history'); + $history = collect($response->json('data.history')); + + $rescheduled = $history->firstWhere('status', 'rescheduled'); + $this->assertSame('REPROGRAMADA', $rescheduled['status_text']); + $this->assertSame('2026-10-09', $rescheduled['event_date']); + $this->assertSame('2026-10-20', $rescheduled['replacement_event_date']); + $this->assertCount(1, $rescheduled['variants']); + + $suspended = $history->firstWhere('status', 'suspended'); + $this->assertSame('CANCELADA', $suspended['status_text']); + $this->assertNull($suspended['replacement_event_date']); + + $completed = $history->firstWhere('status', 'completed'); + $this->assertSame('FINALIZADA', $completed['status_text']); + $this->assertSame('2026-10-11', $completed['event_date']); + $this->assertNull($completed['replacement_event_date']); + + } + + public function test_it_updates_only_the_stock_of_historical_food_variants(): void + { + [$tenant, $historicalDate, $activeDate] = $this->configuredTenant(); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $created = $this->postJson('/api/v1/adminapp/tenant/foods', [ + 'variants' => [ + $this->variantPayload($historicalDate->id, 'Almuerzo', 'Comedor', 100, 10000), + $this->variantPayload($activeDate->id, 'Cena', 'Vianda', 80, 8000), + ], + ])->assertOk(); + $historicalVariantId = $created->json('data.variants.0.id'); + $activeVariantId = $created->json('data.variants.1.id'); + + app(EventService::class)->suspendDateForTenant($tenant, $historicalDate, $admin); + + $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [ + 'variants' => [['id' => $historicalVariantId, 'stock' => 45]], + ]) + ->assertOk() + ->assertJsonPath('data.history.0.variants.0.id', $historicalVariantId) + ->assertJsonPath('data.history.0.variants.0.stock', 45); + + $historicalInventoryId = Variant::query()->findOrFail($historicalVariantId)->inventory_id; + $this->assertDatabaseHas('inventories', [ + 'id' => $historicalInventoryId, + 'real_stock' => 45, + ]); + + $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [ + 'variants' => [['id' => $activeVariantId, 'stock' => 20]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['variants.0.id']); + } + public function test_it_deletes_food_records_and_removes_the_empty_product(): void { [$tenant, $firstDate, $secondDate] = $this->configuredTenant(); diff --git a/tests/Unit/Event/EventModelsTest.php b/tests/Unit/Event/EventModelsTest.php index d05a04b..cb3673c 100644 --- a/tests/Unit/Event/EventModelsTest.php +++ b/tests/Unit/Event/EventModelsTest.php @@ -65,6 +65,24 @@ class EventModelsTest extends TestCase } } + public function test_an_overnight_event_finishes_on_the_following_day(): void + { + Carbon::setTestNow('2026-10-10 01:00:00'); + + try { + $eventDate = new EventDate([ + 'date' => '2026-10-09', + 'time_start' => '20:00:00', + 'time_end' => '02:00:00', + ]); + + $this->assertSame('2026-10-10 02:00:00', $eventDate->endsAt()->format('Y-m-d H:i:s')); + $this->assertSame(EventDateStatus::InProgress, $eventDate->status); + } finally { + Carbon::setTestNow(); + } + } + public function test_tenant_has_many_event_dates(): void { $tenant = new Tenant;