3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-24 22:29:27 +00:00
Files
snipe-it/app/Http/Controllers/Consumables/ConsumableCheckoutController.php
2026-08-21 19:53:01 +01:00

379 lines
14 KiB
PHP

<?php
namespace App\Http\Controllers\Consumables;
use App\Actions\Acceptances\CreateCheckoutAcceptanceAction;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Models\CheckoutAcceptance;
use App\Models\CheckoutRequest;
use App\Models\Consumable;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ConsumableCheckoutController extends Controller
{
/**
* Return a view to checkout a consumable to a user.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @see ConsumableCheckoutController::store() method that stores the data.
* @since [v1.0]
*
* @param int $id
*/
public function create(Request $request, $id): View|RedirectResponse
{
if ($consumable = Consumable::find($id)) {
$this->authorize('checkout', $consumable);
// Make sure the category is valid
if ($consumable->category) {
// Make sure there is at least one available to checkout
if ($consumable->numRemaining() <= 0) {
return redirect()->route('consumables.index')
->with('error', trans('admin/consumables/message.checkout.unavailable', ['requested' => 1, 'remaining' => $consumable->numRemaining()]));
}
// Optional ?request_id hint. Present when the admin
// reached this screen from a /requests row. Drives
// the side-panel context box (who asked + waiting
// list). CheckoutRequest::contextForCheckout handles
// the URL-twiddle guards; a miss returns nulls /
// empty so the panel renders nothing.
$context = CheckoutRequest::contextForCheckout(
$request->integer('request_id') ?: null,
Consumable::class,
$consumable->id,
);
return view('consumables/checkout', compact('consumable'))
->with('checkoutRequest', $context['checkoutRequest'])
->with('otherPendingRequests', $context['otherPendingRequests']);
}
// Invalid category
return redirect()->route('consumables.edit', ['consumable' => $consumable->id])
->with('error', trans('general.invalid_item_category_single', ['type' => trans('general.consumable')]));
}
// Not found
return redirect()->route('consumables.index')->with('error', trans('admin/consumables/message.does_not_exist'));
}
/**
* Saves the checkout information
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @see ConsumableCheckoutController::create() method that returns the form.
* @since [v1.0]
*
* @param int $consumableId
* @return RedirectResponse
*
* @throws AuthorizationException
*/
public function store(Request $request, $consumableId)
{
if (is_null($consumable = Consumable::with('users')->find($consumableId))) {
return redirect()->route('consumables.index')->with('error', trans('admin/consumables/message.not_found'));
}
$this->authorize('checkout', $consumable);
// If the quantity is not present in the request or is not a positive integer, set it to 1
$quantity = $request->input('checkout_qty');
if (! isset($quantity) || ! ctype_digit((string) $quantity) || $quantity <= 0) {
$quantity = 1;
}
// Make sure there is at least one available to checkout
if ($consumable->numRemaining() <= 0 || $quantity > $consumable->numRemaining()) {
return redirect()->route('consumables.index')->with('error', trans('admin/consumables/message.checkout.unavailable', ['requested' => $quantity, 'remaining' => $consumable->numRemaining()]));
}
$admin_user = auth()->user();
$assigned_to = e($request->input('assigned_to'));
// Check if the user exists
if (is_null($user = User::find($assigned_to))) {
// Redirect to the consumable management page with error
return redirect()->route('consumables.checkout.show', $consumable)->with('error', trans('admin/consumables/message.checkout.user_does_not_exist'))->withInput();
}
if (! $consumable->canCheckoutTo($user)) {
return redirect()->back()->with('error', trans('general.error_checkout_company_mismatch', [
'item' => trans('general.consumable').' "'.$consumable->name.'"',
'item_company' => $consumable->company?->name ?? trans('general.unassigned'),
'target' => trans('general.user').' "'.$user->username.'"',
]));
}
// Update the consumable data
$consumable->assigned_to = e($request->input('assigned_to'));
$consumable->checkout_qty = $quantity;
// Concurrency guard. The unlocked numRemaining() check above is
// advisory only — two simultaneous checkout requests could both
// read "1 remaining", both pass the check, both attach a pivot
// row, and land the register at -1. Re-fetch the parent row under
// lockForUpdate INSIDE a transaction, re-check availability
// against the locked snapshot, and only then write. Mirrors the
// License checkout locking pattern.
$overAllocated = false;
DB::transaction(function () use ($consumable, $request, $admin_user, $quantity, &$overAllocated): void {
$locked = Consumable::whereKey($consumable->id)->lockForUpdate()->first();
if (! $locked || $locked->numRemaining() < $quantity) {
$overAllocated = true;
return;
}
for ($i = 0; $i < $quantity; $i++) {
$consumable->users()->attach($consumable->id, [
'consumable_id' => $consumable->id,
'created_by' => $admin_user->id,
'assigned_to' => e($request->input('assigned_to')),
'note' => $request->input('note'),
]);
}
});
if ($overAllocated) {
return redirect()->route('consumables.index')->with('error', trans('admin/consumables/message.checkout.unavailable', [
'requested' => $quantity,
'remaining' => $consumable->fresh()->numRemaining(),
]));
}
event(new CheckoutableCheckedOut(
$consumable,
$user,
auth()->user(),
$request->input('note'),
[],
$consumable->checkout_qty,
$request->boolean('sign_in_place'),
));
$request->request->add(['checkout_to_type' => 'user']);
$request->request->add(['assigned_user' => $user->id]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => $request->input('checkout_to_type'),
'sign_in_place' => $request->boolean('sign_in_place'),
]);
// When sign_in_place is requested, redirect to the acceptance/signature page
// so the user can sign in person. The signature is attributed to the target user.
if ($request->boolean('sign_in_place')) {
$acceptance = CheckoutAcceptance::where('checkoutable_type', Consumable::class)
->where('checkoutable_id', $consumable->id)
->where('assigned_to_id', $user->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = CreateCheckoutAcceptanceAction::run($consumable, $user, $quantity);
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $consumable->id,
'sign_in_place_resource_type' => 'Consumables',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/consumables/message.checkout.success'));
}
// Redirect to the new consumable page
return Helper::getRedirectOption($request, $consumable->id, 'Consumables')
->with('success', trans('admin/consumables/message.checkout.success'));
}
/**
* Bulk-fulfill screen. Mirrors AccessoryCheckoutController::
* bulkFulfillCreate - see there for the design rationale.
*/
public function bulkFulfillCreate(Consumable $consumable)
{
$this->authorize('checkout', $consumable);
if ($consumable->numRemaining() <= 0) {
return redirect()->route('consumables.show', $consumable)
->with('error', trans('admin/consumables/message.checkout.unavailable', [
'requested' => 1,
'remaining' => $consumable->numRemaining(),
]));
}
$pendingRequests = CheckoutRequest::pending()
->where('requestable_type', Consumable::class)
->where('requestable_id', $consumable->id)
->with('user')
->orderBy('created_at')
->orderBy('id')
->get()
->filter(fn (CheckoutRequest $r) => $r->user !== null)
->values();
if ($pendingRequests->isEmpty()) {
return redirect()->route('consumables.show', $consumable)
->with('info', trans('admin/hardware/message.requests.no_active'));
}
return view('checkouts/fulfill-multiple', [
'item' => $consumable,
'pendingRequests' => $pendingRequests,
'formRoute' => route('consumables.fulfill-requests.store', $consumable),
'remaining' => (int) $consumable->numRemaining(),
]);
}
/**
* Iterate + fulfill. See AccessoryCheckoutController::
* bulkFulfillStore for the shared per-row/partial-success
* pattern.
*/
public function bulkFulfillStore(Request $request, Consumable $consumable): RedirectResponse
{
$this->authorize('checkout', $consumable);
// Checkboxes post as enabled_requests[<request_id>]="1",
// keyed by request id (unchecked boxes don't post at all).
$enabledIds = collect(array_keys((array) $request->input('enabled_requests', [])))
->map(fn ($id) => (int) $id)
->filter()
->unique()
->values();
if ($enabledIds->isEmpty()) {
return redirect()->route('consumables.fulfill-requests.create', $consumable)
->with('error', trans('admin/hardware/message.requests.no_selection'));
}
$qtyInputs = (array) $request->input('qty', []);
$userInputs = (array) $request->input('user_id', []);
$noteInputs = (array) $request->input('notes', []);
$requests = CheckoutRequest::pending()
->where('requestable_type', Consumable::class)
->where('requestable_id', $consumable->id)
->whereIn('id', $enabledIds)
->with('user')
->get()
->keyBy('id');
$adminUser = auth()->user();
$fulfilled = 0;
$errors = [];
foreach ($enabledIds as $requestId) {
/** @var CheckoutRequest|null $checkoutRequest */
$checkoutRequest = $requests->get($requestId);
if (! $checkoutRequest) {
$errors[] = trans('admin/hardware/message.requests.row_stale', ['id' => $requestId]);
continue;
}
$targetUserId = (int) ($userInputs[$requestId] ?? $checkoutRequest->user_id);
$qty = (int) ($qtyInputs[$requestId] ?? $checkoutRequest->quantity);
$note = $noteInputs[$requestId] ?? $checkoutRequest->notes;
if ($qty < 1) {
$errors[] = trans('admin/hardware/message.requests.row_qty_invalid', ['id' => $requestId]);
continue;
}
$targetUser = User::find($targetUserId);
if (! $targetUser) {
$errors[] = trans('admin/hardware/message.requests.row_user_missing', ['id' => $requestId]);
continue;
}
if (! $consumable->canCheckoutTo($targetUser)) {
$errors[] = trans('admin/hardware/message.requests.row_company_mismatch', [
'id' => $requestId,
'user' => $targetUser->display_name,
]);
continue;
}
$overAllocated = false;
DB::transaction(function () use ($consumable, $targetUser, $qty, $note, $adminUser, &$overAllocated): void {
$locked = Consumable::whereKey($consumable->id)->lockForUpdate()->first();
if (! $locked || $locked->numRemaining() < $qty) {
$overAllocated = true;
return;
}
for ($i = 0; $i < $qty; $i++) {
$consumable->users()->attach($consumable->id, [
'consumable_id' => $consumable->id,
'created_by' => $adminUser->id,
'assigned_to' => $targetUser->id,
'note' => $note,
]);
}
});
if ($overAllocated) {
$errors[] = trans('admin/hardware/message.requests.row_over_allocated', [
'id' => $requestId,
]);
continue;
}
try {
event(new CheckoutableCheckedOut(
$consumable,
$targetUser,
$adminUser,
$note,
[],
$qty,
false,
));
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('Bulk-fulfill event dispatch failed for request '.$requestId.': '.$e->getMessage());
}
$fulfilled++;
}
$summary = trans('admin/hardware/message.requests.bulk_summary', [
'fulfilled' => $fulfilled,
'total' => $enabledIds->count(),
]);
return redirect()->route('requests.index')
->with($fulfilled > 0 ? 'success' : 'warning', $summary)
->with('multi_error_messages', $errors);
}
}