295 lines
11 KiB
PHP
295 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Actions\CreateBucketAction;
|
|
use App\Enums\BucketAllocationTypeEnum;
|
|
use App\Enums\BucketTypeEnum;
|
|
use App\Models\Bucket;
|
|
use App\Models\Scenario;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use InvalidArgumentException;
|
|
|
|
class BucketController extends Controller
|
|
{
|
|
public function index(Scenario $scenario): JsonResponse
|
|
{
|
|
$buckets = $scenario->buckets()
|
|
->orderedBySortOrder()
|
|
->get()
|
|
->map(fn ($bucket) => $this->formatBucketResponse($bucket));
|
|
|
|
return response()->json([
|
|
'buckets' => $buckets,
|
|
]);
|
|
}
|
|
|
|
public function store(Request $request, Scenario $scenario): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'name' => 'required|string|max:255',
|
|
'type' => 'required|in:'.implode(',', BucketTypeEnum::values()),
|
|
'allocation_type' => 'required|in:'.implode(',', BucketAllocationTypeEnum::values()),
|
|
'allocation_value' => 'nullable|integer',
|
|
'buffer_multiplier' => 'sometimes|numeric|min:0',
|
|
'priority' => 'nullable|integer|min:1',
|
|
]);
|
|
|
|
$type = BucketTypeEnum::from($validated['type']);
|
|
$allocationType = BucketAllocationTypeEnum::from($validated['allocation_type']);
|
|
|
|
$constraintError = $this->validateBucketTypeConstraints($type, $allocationType, $scenario);
|
|
if ($constraintError) {
|
|
return $constraintError;
|
|
}
|
|
|
|
try {
|
|
$createBucketAction = new CreateBucketAction;
|
|
$bucket = $createBucketAction->execute(
|
|
$scenario,
|
|
$validated['name'],
|
|
$allocationType,
|
|
$type,
|
|
$validated['allocation_value'] ?? null,
|
|
$validated['priority'] ?? null,
|
|
isset($validated['buffer_multiplier']) ? (float) $validated['buffer_multiplier'] : null,
|
|
);
|
|
|
|
return response()->json([
|
|
'bucket' => $this->formatBucketResponse($bucket),
|
|
'message' => 'Bucket created successfully.',
|
|
], 201);
|
|
} catch (InvalidArgumentException $e) {
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => ['allocation_value' => [$e->getMessage()]],
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
public function update(Request $request, Bucket $bucket): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'name' => 'sometimes|required|string|max:255',
|
|
'type' => 'sometimes|required|in:'.implode(',', BucketTypeEnum::values()),
|
|
'allocation_type' => 'sometimes|required|in:'.implode(',', BucketAllocationTypeEnum::values()),
|
|
'allocation_value' => 'sometimes|nullable|integer',
|
|
'buffer_multiplier' => 'sometimes|numeric|min:0',
|
|
'starting_amount' => 'sometimes|integer|min:0',
|
|
'priority' => 'sometimes|nullable|integer|min:1',
|
|
]);
|
|
|
|
$type = isset($validated['type']) ? BucketTypeEnum::from($validated['type']) : $bucket->type;
|
|
$allocationType = isset($validated['allocation_type']) ? BucketAllocationTypeEnum::from($validated['allocation_type']) : $bucket->allocation_type;
|
|
|
|
// Prevent changing overflow bucket's type away from overflow
|
|
if (isset($validated['type']) && $bucket->type === BucketTypeEnum::OVERFLOW && $type !== BucketTypeEnum::OVERFLOW) {
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => ['type' => ['The overflow bucket\'s type cannot be changed.']],
|
|
], 422);
|
|
}
|
|
|
|
// Run type/allocation constraint validation when relevant fields change
|
|
if (isset($validated['type']) || isset($validated['allocation_type'])) {
|
|
$constraintError = $this->validateBucketTypeConstraints($type, $allocationType, $bucket->scenario, $bucket);
|
|
if ($constraintError) {
|
|
return $constraintError;
|
|
}
|
|
|
|
// Set allocation_value to null for unlimited buckets
|
|
if ($allocationType === BucketAllocationTypeEnum::UNLIMITED) {
|
|
$validated['allocation_value'] = null;
|
|
}
|
|
|
|
// Buffer only applies to fixed_limit buckets — always reset on type change
|
|
if ($allocationType !== BucketAllocationTypeEnum::FIXED_LIMIT) {
|
|
$validated['buffer_multiplier'] = 0.0;
|
|
}
|
|
}
|
|
|
|
// Validate allocation_value when it or allocation_type changes
|
|
if (array_key_exists('allocation_value', $validated) || isset($validated['allocation_type'])) {
|
|
$allocationValueRules = Bucket::allocationValueRules($allocationType);
|
|
$request->validate([
|
|
'allocation_value' => $allocationValueRules,
|
|
]);
|
|
}
|
|
|
|
// Handle priority change if needed
|
|
if (isset($validated['priority']) && $validated['priority'] !== $bucket->priority) {
|
|
$this->updateBucketPriority($bucket, $validated['priority']);
|
|
$validated['sort_order'] = $validated['priority'];
|
|
}
|
|
|
|
$bucket->update($validated);
|
|
|
|
return response()->json([
|
|
'bucket' => $this->formatBucketResponse($bucket),
|
|
'message' => 'Bucket updated successfully.',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Remove the specified bucket.
|
|
*/
|
|
public function destroy(Bucket $bucket): JsonResponse
|
|
{
|
|
if ($bucket->type === BucketTypeEnum::OVERFLOW) {
|
|
return response()->json([
|
|
'message' => 'The overflow bucket cannot be deleted.',
|
|
], 422);
|
|
}
|
|
|
|
$scenarioId = $bucket->scenario_id;
|
|
$deletedPriority = $bucket->priority;
|
|
|
|
$bucket->delete();
|
|
|
|
// Shift remaining priorities down to fill the gap
|
|
$this->shiftPrioritiesDown($scenarioId, $deletedPriority);
|
|
|
|
return response()->json([
|
|
'message' => 'Bucket deleted successfully.',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Update bucket priorities (for drag-and-drop reordering).
|
|
*/
|
|
public function updatePriorities(Request $request, Scenario $scenario): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'bucket_priorities' => 'required|array',
|
|
'bucket_priorities.*.id' => 'required|exists:buckets,uuid',
|
|
'bucket_priorities.*.priority' => 'required|integer|min:1',
|
|
]);
|
|
|
|
foreach ($validated['bucket_priorities'] as $bucketData) {
|
|
$bucket = Bucket::where('uuid', $bucketData['id'])->first();
|
|
if ($bucket && $bucket->scenario_id === $scenario->id) {
|
|
$bucket->update([
|
|
'priority' => $bucketData['priority'],
|
|
'sort_order' => $bucketData['priority'],
|
|
]);
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'message' => 'Bucket priorities updated successfully.',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Validate bucket type constraints (type+allocation compatibility, one overflow per scenario).
|
|
*/
|
|
private function validateBucketTypeConstraints(
|
|
BucketTypeEnum $type,
|
|
BucketAllocationTypeEnum $allocationType,
|
|
Scenario $scenario,
|
|
?Bucket $excludeBucket = null
|
|
): ?JsonResponse {
|
|
$allowedTypes = $type->getAllowedAllocationTypes();
|
|
if (! in_array($allocationType, $allowedTypes, true)) {
|
|
$allowed = implode(', ', array_map(fn ($t) => $t->getLabel(), $allowedTypes));
|
|
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => ['allocation_type' => ["{$type->getLabel()} buckets only support: {$allowed}."]],
|
|
], 422);
|
|
}
|
|
|
|
if ($type === BucketTypeEnum::OVERFLOW) {
|
|
$query = $scenario->buckets()->where('type', BucketTypeEnum::OVERFLOW->value);
|
|
|
|
if ($excludeBucket) {
|
|
$query->where('id', '!=', $excludeBucket->id);
|
|
}
|
|
|
|
if ($query->exists()) {
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => ['type' => ['A scenario can only have one overflow bucket.']],
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Format bucket data for JSON response.
|
|
* All amounts in storage units (cents for currency, basis points for percentages).
|
|
* Frontend handles conversion to display units.
|
|
*/
|
|
private function formatBucketResponse(Bucket $bucket): array
|
|
{
|
|
return [
|
|
'id' => $bucket->uuid,
|
|
'name' => $bucket->name,
|
|
'type' => $bucket->type,
|
|
'type_label' => $bucket->type->getLabel(),
|
|
'priority' => $bucket->priority,
|
|
'sort_order' => $bucket->sort_order,
|
|
'allocation_type' => $bucket->allocation_type,
|
|
'allocation_value' => $bucket->allocation_value,
|
|
'allocation_type_label' => $bucket->getAllocationTypeLabel(),
|
|
'buffer_multiplier' => (float) $bucket->buffer_multiplier,
|
|
'effective_capacity' => $bucket->hasFiniteCapacity() ? $bucket->getEffectiveCapacity() : null,
|
|
'starting_amount' => $bucket->starting_amount,
|
|
'current_balance' => $bucket->getCurrentBalance(),
|
|
'has_available_space' => $bucket->hasAvailableSpace(),
|
|
'available_space' => $bucket->hasFiniteCapacity() ? $bucket->getAvailableSpace() : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Shift priorities down to fill gap after deletion.
|
|
*/
|
|
private function shiftPrioritiesDown(int $scenarioId, int $deletedPriority): void
|
|
{
|
|
Bucket::query()
|
|
->where('scenario_id', $scenarioId)
|
|
->where('priority', '>', $deletedPriority)
|
|
->decrement('priority');
|
|
}
|
|
|
|
/**
|
|
* Update a bucket's priority and adjust other buckets accordingly.
|
|
*/
|
|
private function updateBucketPriority(Bucket $bucket, int $newPriority): void
|
|
{
|
|
$oldPriority = $bucket->priority;
|
|
$scenario = $bucket->scenario;
|
|
|
|
if ($newPriority === $oldPriority) {
|
|
return;
|
|
}
|
|
|
|
// Use database transaction to handle constraint conflicts
|
|
DB::transaction(function () use ($bucket, $scenario, $oldPriority, $newPriority) {
|
|
// Temporarily set the moving bucket to a high priority to avoid conflicts
|
|
$tempPriority = $scenario->buckets()->max('priority') + 100;
|
|
$bucket->update(['priority' => $tempPriority]);
|
|
|
|
if ($newPriority < $oldPriority) {
|
|
// Moving up - shift others down
|
|
$scenario->buckets()
|
|
->where('id', '!=', $bucket->id)
|
|
->whereBetween('priority', [$newPriority, $oldPriority - 1])
|
|
->increment('priority');
|
|
} else {
|
|
// Moving down - shift others up
|
|
$scenario->buckets()
|
|
->where('id', '!=', $bucket->id)
|
|
->whereBetween('priority', [$oldPriority + 1, $newPriority])
|
|
->decrement('priority');
|
|
}
|
|
|
|
// Finally, set the bucket to its new priority
|
|
$bucket->update(['priority' => $newPriority]);
|
|
});
|
|
}
|
|
}
|