feat(food): add endpoint to update historical stock and separate current and historical food variants

This commit is contained in:
ncoronel 2026-09-15 15:03:41 -03:00
parent 9fa4da7de2
commit ff43ba32f3
9 changed files with 426 additions and 27 deletions

View File

@ -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,

View File

@ -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(

View File

@ -0,0 +1,24 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateHistoricalFoodStockRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
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'],
];
}
}

View File

@ -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<string, mixed> */
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<int, Variant> $variants
* @return Collection<int, array<string, mixed>>
*/
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);
}
}

View File

@ -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<int, array{id: int, stock: int}> $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<int, array<string, mixed>> $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<int, array<string, mixed>> $incoming
* @param Collection<int, Variant> $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);
}
}

View File

@ -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');

View File

@ -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,
];

View File

@ -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();

View File

@ -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;