Compare commits
2 Commits
9fa4da7de2
...
dcf4383fbf
| Author | SHA1 | Date | |
|---|---|---|---|
| dcf4383fbf | |||
| ff43ba32f3 |
@@ -3,6 +3,9 @@
|
|||||||
namespace App\Domains\Catalog\Services;
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\BundleComponent;
|
use App\Domains\Catalog\Models\BundleComponent;
|
||||||
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
|
use App\Domains\Catalog\Models\StockReservationLine;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Event\Models\EventDate;
|
use App\Domains\Event\Models\EventDate;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -92,11 +95,14 @@ class VariantReplacementService
|
|||||||
/** @param Collection<int, int> $eventDateIds */
|
/** @param Collection<int, int> $eventDateIds */
|
||||||
private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant
|
private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant
|
||||||
{
|
{
|
||||||
|
$replacementInventory = $this->cloneInventory($source);
|
||||||
$replacement = $source->replicate([
|
$replacement = $source->replicate([
|
||||||
'event_date_id',
|
'event_date_id',
|
||||||
|
'inventory_id',
|
||||||
'replaced_by_variant_id',
|
'replaced_by_variant_id',
|
||||||
'sales_disabled_at',
|
'sales_disabled_at',
|
||||||
]);
|
]);
|
||||||
|
$replacement->inventory_id = $replacementInventory->getKey();
|
||||||
$replacement->event_date_id = $eventDateIds->count() === 1
|
$replacement->event_date_id = $eventDateIds->count() === 1
|
||||||
? $eventDateIds->first()
|
? $eventDateIds->first()
|
||||||
: null;
|
: null;
|
||||||
@@ -125,6 +131,41 @@ class VariantReplacementService
|
|||||||
return $replacement->load(['eventDates', 'eventDate', 'definitions', 'allAttachments']);
|
return $replacement->load(['eventDates', 'eventDate', 'definitions', 'allAttachments']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function cloneInventory(Variant $source): Inventory
|
||||||
|
{
|
||||||
|
$activeLines = StockReservationLine::query()
|
||||||
|
->where('inventory_id', $source->inventory_id)
|
||||||
|
->whereHas('reservation', fn ($reservation) => $reservation
|
||||||
|
->where('status', StockReservation::STATUS_ACTIVE))
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
$sourceInventory = Inventory::query()
|
||||||
|
->whereKey($source->inventory_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
$reservedStock = (int) $activeLines->sum('quantity');
|
||||||
|
|
||||||
|
if ($sourceInventory->reserved_stock !== $reservedStock) {
|
||||||
|
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$replacementInventory = Inventory::query()->create([
|
||||||
|
'sold_units' => $sourceInventory->sold_units,
|
||||||
|
'reserved_stock' => $reservedStock,
|
||||||
|
'real_stock' => $sourceInventory->real_stock,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($activeLines->isNotEmpty()) {
|
||||||
|
StockReservationLine::query()
|
||||||
|
->whereKey($activeLines->modelKeys())
|
||||||
|
->update(['inventory_id' => $replacementInventory->getKey()]);
|
||||||
|
}
|
||||||
|
$sourceInventory->update(['reserved_stock' => 0]);
|
||||||
|
|
||||||
|
return $replacementInventory;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
private function definitionSignature(Variant $variant): array
|
private function definitionSignature(Variant $variant): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -122,7 +122,11 @@ class EventDate extends Model
|
|||||||
|
|
||||||
public function endsAt(): CarbonInterface
|
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
|
public function getStatusAttribute(): EventDateStatus
|
||||||
@@ -169,10 +173,6 @@ class EventDate extends Model
|
|||||||
$startsAt = $this->startsAt();
|
$startsAt = $this->startsAt();
|
||||||
$expiresAt = $this->endsAt();
|
$expiresAt = $this->endsAt();
|
||||||
|
|
||||||
if ($expiresAt->lessThanOrEqualTo($startsAt)) {
|
|
||||||
$expiresAt = $expiresAt->addDay();
|
|
||||||
}
|
|
||||||
|
|
||||||
$attributes = [
|
$attributes = [
|
||||||
'type' => ValidityTimeType::FixedWindow,
|
'type' => ValidityTimeType::FixedWindow,
|
||||||
'start_time' => null,
|
'start_time' => null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\FiestaFutbolInfantil\Controllers;
|
namespace App\Domains\FiestaFutbolInfantil\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Requests\UpdateHistoricalFoodStockRequest;
|
||||||
use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest;
|
use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest;
|
||||||
use App\Domains\FiestaFutbolInfantil\Resources\FoodResource;
|
use App\Domains\FiestaFutbolInfantil\Resources\FoodResource;
|
||||||
use App\Domains\FiestaFutbolInfantil\Services\FoodService;
|
use App\Domains\FiestaFutbolInfantil\Services\FoodService;
|
||||||
@@ -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
|
public function destroy(Request $request, int $food): Response
|
||||||
{
|
{
|
||||||
$this->foodService->delete(
|
$this->foodService->delete(
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,14 @@
|
|||||||
namespace App\Domains\FiestaFutbolInfantil\Resources;
|
namespace App\Domains\FiestaFutbolInfantil\Resources;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
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\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
/** @mixin CatalogItem */
|
/** @mixin CatalogItem */
|
||||||
class FoodResource extends JsonResource
|
class FoodResource extends JsonResource
|
||||||
@@ -17,27 +23,134 @@ class FoodResource extends JsonResource
|
|||||||
'id' => null,
|
'id' => null,
|
||||||
'name' => 'Comida',
|
'name' => 'Comida',
|
||||||
'variants' => [],
|
'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 [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'name' => $this->nombre,
|
'name' => $this->nombre,
|
||||||
'variants' => $this->variants->map(function ($variant): array {
|
'variants' => $currentVariants->map($this->variantData(...))->values(),
|
||||||
$values = $variant->selectionValues();
|
'history' => $this->historyData($historicalVariants),
|
||||||
$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(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use App\Domains\Catalog\Models\Inventory;
|
|||||||
use App\Domains\Catalog\Models\ItemAttribute;
|
use App\Domains\Catalog\Models\ItemAttribute;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Services\CatalogService;
|
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 App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -36,8 +38,10 @@ class FoodService
|
|||||||
->with([
|
->with([
|
||||||
'variants.catalogItem',
|
'variants.catalogItem',
|
||||||
'variants.inventory',
|
'variants.inventory',
|
||||||
'variants.eventDate',
|
'variants.eventDate.rescheduledTo',
|
||||||
'variants.eventDates',
|
'variants.eventDate.changeHistory.destinationEventDate',
|
||||||
|
'variants.eventDates.rescheduledTo',
|
||||||
|
'variants.eventDates.changeHistory.destinationEventDate',
|
||||||
'variants.definitions.itemAttribute.attribute',
|
'variants.definitions.itemAttribute.attribute',
|
||||||
])
|
])
|
||||||
->first();
|
->first();
|
||||||
@@ -55,11 +59,16 @@ class FoodService
|
|||||||
|
|
||||||
$food->variants()->whereNull('precio')->update(['precio' => $food->precio]);
|
$food->variants()->whereNull('precio')->update(['precio' => $food->precio]);
|
||||||
$existingVariants = $food->variants()
|
$existingVariants = $food->variants()
|
||||||
|
->whereNull('sales_disabled_at')
|
||||||
|
->whereNull('replaced_by_variant_id')
|
||||||
->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute'])
|
->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute'])
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->get();
|
->get()
|
||||||
|
->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant))
|
||||||
|
->values();
|
||||||
$resolvedVariants = $this->resolveVariants($variants, $attributes);
|
$resolvedVariants = $this->resolveVariants($variants, $attributes);
|
||||||
|
|
||||||
|
$this->validateCurrentEventDates($tenant, $resolvedVariants);
|
||||||
$this->validateCombinations($resolvedVariants, $existingVariants);
|
$this->validateCombinations($resolvedVariants, $existingVariants);
|
||||||
|
|
||||||
foreach ($resolvedVariants as $index => $data) {
|
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) {
|
if ($minimumPrice !== null) {
|
||||||
$food->update(['precio' => $minimumPrice]);
|
$food->update(['precio' => $minimumPrice]);
|
||||||
}
|
}
|
||||||
@@ -88,22 +103,102 @@ class FoodService
|
|||||||
return $food->fresh()->load([
|
return $food->fresh()->load([
|
||||||
'variants.catalogItem',
|
'variants.catalogItem',
|
||||||
'variants.inventory',
|
'variants.inventory',
|
||||||
'variants.eventDate',
|
'variants.eventDate.rescheduledTo',
|
||||||
'variants.eventDates',
|
'variants.eventDate.changeHistory.destinationEventDate',
|
||||||
|
'variants.eventDates.rescheduledTo',
|
||||||
|
'variants.eventDates.changeHistory.destinationEventDate',
|
||||||
'variants.definitions.itemAttribute.attribute',
|
'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)
|
||||||
|
->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 = $this->inventoryForHistoricalStockUpdate($variant);
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function inventoryForHistoricalStockUpdate(Variant $variant): Inventory
|
||||||
|
{
|
||||||
|
$inventory = Inventory::query()
|
||||||
|
->whereKey($variant->inventory_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
$variantsSharingInventory = Variant::query()
|
||||||
|
->where('inventory_id', $inventory->getKey())
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get(['id']);
|
||||||
|
|
||||||
|
if ($variantsSharingInventory->count() === 1) {
|
||||||
|
return $inventory;
|
||||||
|
}
|
||||||
|
|
||||||
|
$historicalInventory = Inventory::query()->create([
|
||||||
|
'sold_units' => $inventory->sold_units,
|
||||||
|
'reserved_stock' => 0,
|
||||||
|
'real_stock' => $inventory->real_stock,
|
||||||
|
]);
|
||||||
|
$variant->update(['inventory_id' => $historicalInventory->getKey()]);
|
||||||
|
|
||||||
|
return $historicalInventory;
|
||||||
|
}
|
||||||
|
|
||||||
public function delete(Tenant $tenant, int $foodId): void
|
public function delete(Tenant $tenant, int $foodId): void
|
||||||
{
|
{
|
||||||
$variant = Variant::query()
|
$variant = Variant::query()
|
||||||
->whereKey($foodId)
|
->whereKey($foodId)
|
||||||
|
->whereNull('sales_disabled_at')
|
||||||
|
->whereNull('replaced_by_variant_id')
|
||||||
|
->with(['eventDate', 'eventDates'])
|
||||||
->whereHas('catalogItem', fn ($query) => $query
|
->whereHas('catalogItem', fn ($query) => $query
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->where('slug', 'comida'))
|
->where('slug', 'comida'))
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
|
abort_unless($this->hasCurrentDate($variant), 404);
|
||||||
|
|
||||||
$this->catalogService->deleteVariant($variant);
|
$this->catalogService->deleteVariant($variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +320,30 @@ class FoodService
|
|||||||
return $option;
|
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 array<int, array<string, mixed>> $incoming
|
||||||
* @param Collection<int, Variant> $existing
|
* @param Collection<int, Variant> $existing
|
||||||
@@ -329,4 +448,25 @@ class FoodService
|
|||||||
mb_strtolower(trim($service)),
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ Route::prefix('v1/adminapp/tenant')
|
|||||||
Route::post('foods', [FoodController::class, 'store'])
|
Route::post('foods', [FoodController::class, 'store'])
|
||||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||||
->name('adminapp.fiesta-futbol-infantil.foods.store');
|
->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'])
|
Route::delete('foods/{food}', [FoodController::class, 'destroy'])
|
||||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||||
->name('adminapp.fiesta-futbol-infantil.foods.destroy');
|
->name('adminapp.fiesta-futbol-infantil.foods.destroy');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Forms\Services;
|
|||||||
|
|
||||||
use App\Domains\Catalog\Models\Attribute;
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
use App\Domains\Catalog\Models\AttributeOption;
|
use App\Domains\Catalog\Models\AttributeOption;
|
||||||
|
use App\Domains\Event\Enums\EventDateStatus;
|
||||||
use App\Domains\Event\Models\EventDate;
|
use App\Domains\Event\Models\EventDate;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
@@ -31,7 +32,13 @@ class FoodFormService
|
|||||||
->whereNull('rescheduled_to_event_date_id')
|
->whereNull('rescheduled_to_event_date_id')
|
||||||
->whereNull('suspended_at')
|
->whereNull('suspended_at')
|
||||||
->with('validityTime')
|
->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,
|
'schedules' => $attributes->get('horario')?->options ?? new Collection,
|
||||||
'services' => $attributes->get('servicio')?->options ?? new Collection,
|
'services' => $attributes->get('servicio')?->options ?? new Collection,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use App\Domains\Auth\Models\User;
|
|||||||
use App\Domains\Authorization\Enums\RoleCode;
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
|
use App\Domains\Catalog\Models\StockReservationLine;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Event\Events\EventDateRescheduled;
|
use App\Domains\Event\Events\EventDateRescheduled;
|
||||||
use App\Domains\Event\Events\EventDateSuspended;
|
use App\Domains\Event\Events\EventDateSuspended;
|
||||||
@@ -249,7 +251,17 @@ class AdminAppEventControllerTest extends TestCase
|
|||||||
'time_end' => '20:00',
|
'time_end' => '20:00',
|
||||||
]);
|
]);
|
||||||
$variant = $this->createVariant($tenant, $original->id);
|
$variant = $this->createVariant($tenant, $original->id);
|
||||||
$variant->inventory()->update(['real_stock' => 5]);
|
$variant->inventory()->update(['real_stock' => 5, 'reserved_stock' => 2]);
|
||||||
|
$reservation = StockReservation::query()->create([
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
'expires_at' => now()->addHour(),
|
||||||
|
]);
|
||||||
|
$reservationLine = StockReservationLine::query()->create([
|
||||||
|
'stock_reservation_id' => $reservation->id,
|
||||||
|
'inventory_id' => $variant->inventory_id,
|
||||||
|
'quantity' => 2,
|
||||||
|
'tracks_inventory' => true,
|
||||||
|
]);
|
||||||
$ticket = $this->createTicket($tenant, $admin, $variant);
|
$ticket = $this->createTicket($tenant, $admin, $variant);
|
||||||
Sanctum::actingAs($admin);
|
Sanctum::actingAs($admin);
|
||||||
|
|
||||||
@@ -283,7 +295,12 @@ class AdminAppEventControllerTest extends TestCase
|
|||||||
$replacement = $variant->replacement()->firstOrFail();
|
$replacement = $variant->replacement()->firstOrFail();
|
||||||
$this->assertSame($original->id, $variant->event_date_id);
|
$this->assertSame($original->id, $variant->event_date_id);
|
||||||
$this->assertSame($destination->id, $replacement->event_date_id);
|
$this->assertSame($destination->id, $replacement->event_date_id);
|
||||||
$this->assertSame($variant->inventory_id, $replacement->inventory_id);
|
$this->assertNotSame($variant->inventory_id, $replacement->inventory_id);
|
||||||
|
$this->assertSame(5, $variant->inventory->fresh()->real_stock);
|
||||||
|
$this->assertSame(0, $variant->inventory->fresh()->reserved_stock);
|
||||||
|
$this->assertSame(5, $replacement->inventory->real_stock);
|
||||||
|
$this->assertSame(2, $replacement->inventory->reserved_stock);
|
||||||
|
$this->assertSame($replacement->inventory_id, $reservationLine->fresh()->inventory_id);
|
||||||
$this->assertNotNull($variant->sales_disabled_at);
|
$this->assertNotNull($variant->sales_disabled_at);
|
||||||
$this->assertSame($replacement->id, $variant->replaced_by_variant_id);
|
$this->assertSame($replacement->id, $variant->replaced_by_variant_id);
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
@@ -350,7 +367,8 @@ class AdminAppEventControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
$replacement->refresh();
|
$replacement->refresh();
|
||||||
$latestReplacement = $replacement->replacement()->firstOrFail();
|
$latestReplacement = $replacement->replacement()->firstOrFail();
|
||||||
$this->assertSame($replacement->inventory_id, $latestReplacement->inventory_id);
|
$this->assertNotSame($replacement->inventory_id, $latestReplacement->inventory_id);
|
||||||
|
$this->assertSame(5, $latestReplacement->inventory->real_stock);
|
||||||
$this->assertSame('2027-10-25', $latestReplacement->eventDate->date->format('Y-m-d'));
|
$this->assertSame('2027-10-25', $latestReplacement->eventDate->date->format('Y-m-d'));
|
||||||
$this->assertFalse($replacement->isSellable());
|
$this->assertFalse($replacement->isSellable());
|
||||||
$this->assertTrue($latestReplacement->isSellable());
|
$this->assertTrue($latestReplacement->isSellable());
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ use App\Domains\Authorization\Enums\RoleCode;
|
|||||||
use App\Domains\Catalog\Models\Attribute;
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Category;
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Event\Services\EventService;
|
||||||
use App\Domains\Menu\Models\Menu;
|
use App\Domains\Menu\Models\Menu;
|
||||||
use App\Domains\Shared\Enums\FieldType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Models\WebsiteType;
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
use Database\Seeders\AuthorizationSeeder;
|
use Database\Seeders\AuthorizationSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -32,6 +35,13 @@ class FoodControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow();
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_authentication_is_required(): void
|
public function test_authentication_is_required(): void
|
||||||
{
|
{
|
||||||
$this->postJson('/api/v1/adminapp/tenant/foods', ['variants' => []])
|
$this->postJson('/api/v1/adminapp/tenant/foods', ['variants' => []])
|
||||||
@@ -142,6 +152,118 @@ 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)->rescheduleDateForTenant(
|
||||||
|
$tenant,
|
||||||
|
$historicalDate,
|
||||||
|
['date' => $activeDate->date->format('Y-m-d')],
|
||||||
|
$admin,
|
||||||
|
);
|
||||||
|
$historicalVariant = Variant::query()->findOrFail($historicalVariantId);
|
||||||
|
$replacementVariant = $historicalVariant->replacement()->firstOrFail();
|
||||||
|
$replacementInventoryId = $replacementVariant->inventory_id;
|
||||||
|
|
||||||
|
// Simula variantes creadas antes de que la reprogramación separara sus inventarios.
|
||||||
|
$replacementVariant->update(['inventory_id' => $historicalVariant->inventory_id]);
|
||||||
|
Inventory::query()->whereKey($replacementInventoryId)->delete();
|
||||||
|
|
||||||
|
$updated = $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);
|
||||||
|
$replacement = collect($updated->json('data.variants'))
|
||||||
|
->firstWhere('schedule', 'Almuerzo');
|
||||||
|
$this->assertSame(100, $replacement['stock']);
|
||||||
|
|
||||||
|
$historicalInventoryId = Variant::query()->findOrFail($historicalVariantId)->inventory_id;
|
||||||
|
$currentInventoryId = $replacementVariant->fresh()->inventory_id;
|
||||||
|
$this->assertNotSame($historicalInventoryId, $currentInventoryId);
|
||||||
|
$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
|
public function test_it_deletes_food_records_and_removes_the_empty_product(): void
|
||||||
{
|
{
|
||||||
[$tenant, $firstDate, $secondDate] = $this->configuredTenant();
|
[$tenant, $firstDate, $secondDate] = $this->configuredTenant();
|
||||||
|
|||||||
@@ -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
|
public function test_tenant_has_many_event_dates(): void
|
||||||
{
|
{
|
||||||
$tenant = new Tenant;
|
$tenant = new Tenant;
|
||||||
|
|||||||
Reference in New Issue
Block a user