3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-02-04 23:05:41 +00:00

Merge pull request #18359 from marcusmoore/17816-qty-in-activity-report

Fixed #17816 - added quantity to activity reports
This commit is contained in:
snipe
2026-01-05 13:03:56 +00:00
committed by GitHub
23 changed files with 476 additions and 29 deletions

View File

@ -0,0 +1,74 @@
<?php
namespace App\Console\Commands;
use App\Enums\ActionType;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class MigrateLicenseSeatQuantitiesInActionLogs extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:migrate-license-seat-quantities-in-action-logs
{--no-interaction: Do not ask any interactive question}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Updates quantity field in action_logs table for license seats that were added or deleted.';
/**
* Execute the console command.
*/
public function handle()
{
$query = DB::table('action_logs')
->whereIn('action_type', [
ActionType::AddSeats->value,
ActionType::DeleteSeats->value,
])
->where('quantity', '=', 1)
->orderBy('id');
$count = $query->count();
if ($count === 0) {
$this->info('Nothing to update');
return 0;
}
$this->info("{$count} logs to update");
if ($this->option('no-interaction') || $this->confirm('Update quantities in the action log?')) {
$query->chunk(50, function ($logs) {
$logs->each(function ($log) {
$quantityFromNote = Str::between($log->note, "ed ", " seats");
if (!is_numeric($quantityFromNote)) {
$this->error('Could not parse quantity from ID: {id}', ['id' => $log->id]);
}
if ($log->quantity !== (int) $quantityFromNote) {
$this->info(vsprintf('Updating id: %s to quantity %s', [
'id' => $log->id,
'new_quantity' => $quantityFromNote,
]));
DB::table('action_logs')->where('id', $log->id)->update(['quantity' => (int) $quantityFromNote]);
}
});
});
}
return 0;
}
}

View File

@ -15,18 +15,20 @@ class CheckoutableCheckedOut
public $checkedOutBy;
public $note;
public $originalValues;
public int $quantity;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [])
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [], $quantity = 1)
{
$this->checkoutable = $checkoutable;
$this->checkedOutTo = $checkedOutTo;
$this->checkedOutBy = $checkedOutBy;
$this->note = $note;
$this->originalValues = $originalValues;
$this->quantity = $quantity;
}
}

View File

@ -67,7 +67,7 @@ class AccessoryCheckoutController extends Controller
*/
public function store(AccessoryCheckoutRequest $request, Accessory $accessory) : RedirectResponse
{
$this->authorize('checkout', $accessory);
$target = $this->determineCheckoutTarget();
@ -89,7 +89,14 @@ class AccessoryCheckoutController extends Controller
$accessory_checkout->save();
}
event(new CheckoutableCheckedOut($accessory, $target, auth()->user(), $request->input('note')));
event(new CheckoutableCheckedOut(
$accessory,
$target,
auth()->user(),
$request->input('note'),
[],
$accessory->checkout_qty,
));
$request->request->add(['checkout_to_type' => request('checkout_to_type')]);
$request->request->add(['assigned_to' => $target->id]);

View File

@ -325,7 +325,14 @@ class AccessoriesController extends Controller
}
// Set this value to be able to pass the qty through to the event
event(new CheckoutableCheckedOut($accessory, $target, auth()->user(), $request->input('note')));
event(new CheckoutableCheckedOut(
$accessory,
$target,
auth()->user(),
$request->input('note'),
[],
$accessory->checkout_qty,
));
return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/accessories/message.checkout.success')));

View File

@ -321,7 +321,7 @@ class ComponentsController extends Controller
'note' => $request->input('note'),
]);
$component->logCheckout($request->input('note'), $asset);
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/components/message.checkout.success')));
}

View File

@ -326,8 +326,14 @@ class ConsumablesController extends Controller
);
}
event(new CheckoutableCheckedOut($consumable, $user, auth()->user(), $request->input('note')));
event(new CheckoutableCheckedOut(
$consumable,
$user,
auth()->user(),
$request->input('note'),
[],
$consumable->checkout_qty,
));
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/consumables/message.checkout.success')));

View File

@ -115,7 +115,14 @@ class ComponentCheckoutController extends Controller
'note' => $request->input('note'),
]);
event(new CheckoutableCheckedOut($component, $asset, auth()->user(), $request->input('note')));
event(new CheckoutableCheckedOut(
$component,
$asset,
auth()->user(),
$request->input('note'),
[],
$component->checkout_qty,
));
$request->request->add(['checkout_to_type' => 'asset']);
$request->request->add(['assigned_asset' => $asset->id]);

View File

@ -102,7 +102,15 @@ class ConsumableCheckoutController extends Controller
}
$consumable->checkout_qty = $quantity;
event(new CheckoutableCheckedOut($consumable, $user, auth()->user(), $request->input('note')));
event(new CheckoutableCheckedOut(
$consumable,
$user,
auth()->user(),
$request->input('note'),
[],
$consumable->checkout_qty,
));
$request->request->add(['checkout_to_type' => 'user']);
$request->request->add(['assigned_user' => $user->id]);

View File

@ -1,6 +1,7 @@
<?php
namespace App\Http\Transformers;
use App\Enums\ActionType;
use App\Helpers\Helper;
use App\Helpers\StorageHelper;
use App\Models\Actionlog;
@ -80,7 +81,7 @@ class ActionlogsTransformer
// this is a custom field
if (str_starts_with($fieldname, '_snipeit_')) {
foreach ($custom_fields as $custom_field) {
if ($custom_field->db_column == $fieldname) {
@ -185,7 +186,7 @@ class ActionlogsTransformer
'name' => e($actionlog->target->display_name) ?? null,
'type' => e($actionlog->targetType()),
] : null,
'quantity' => $this->getQuantity($actionlog),
'note' => ($actionlog->note) ? Helper::parseEscapedMarkedownInline($actionlog->note): null,
'signature_file' => ($actionlog->accept_signature) ? route('log.signature.view', ['filename' => $actionlog->accept_signature ]) : null,
'log_meta' => ((isset($clean_meta)) && (is_array($clean_meta))) ? $clean_meta: null,
@ -336,6 +337,26 @@ class ActionlogsTransformer
}
private function getQuantity(Actionlog $actionlog): ?int
{
if (!$actionlog->quantity) {
return null;
}
// only a few action types will have a quantity we are interested in.
if (!in_array($actionlog->action_type, [
ActionType::Checkout->value,
ActionType::Accepted->value,
ActionType::Declined->value,
ActionType::CheckinFrom->value,
ActionType::AddSeats->value,
ActionType::DeleteSeats->value,
])) {
return null;
}
return (int) $actionlog->quantity;
}
}

View File

@ -47,7 +47,13 @@ class LogListener
*/
public function onCheckoutableCheckedOut(CheckoutableCheckedOut $event)
{
$event->checkoutable->logCheckout($event->note, $event->checkedOutTo, $event->checkoutable->last_checkout, $event->originalValues);
$event->checkoutable->logCheckout(
$event->note,
$event->checkedOutTo,
$event->checkoutable->last_checkout,
$event->originalValues,
$event->quantity
);
}
/**
@ -66,6 +72,7 @@ class LogListener
$logaction->note = $event->acceptance->note;
$logaction->action_type = 'accepted';
$logaction->action_date = $event->acceptance->accepted_at;
$logaction->quantity = $event->acceptance->qty ?? 1;
// TODO: log the actual license seat that was checked out
if ($event->acceptance->checkoutable instanceof LicenseSeat) {
@ -84,6 +91,7 @@ class LogListener
$logaction->note = $event->acceptance->note;
$logaction->action_type = 'declined';
$logaction->action_date = $event->acceptance->declined_at;
$logaction->quantity = $event->acceptance->qty ?? 1;
// TODO: log the actual license seat that was checked out
if ($event->acceptance->checkoutable instanceof LicenseSeat) {

View File

@ -223,6 +223,7 @@ class License extends Depreciable
$logAction->created_by = auth()->id() ?: 1; // We don't have an id while running the importer from CLI.
$logAction->note = "deleted {$change} seats";
$logAction->target_id = null;
$logAction->quantity = $change;
$logAction->logaction('delete seats');
return true;
@ -259,6 +260,7 @@ class License extends Depreciable
$logAction->created_by = auth()->id() ?: 1; // Importer.
$logAction->note = "added {$change} seats";
$logAction->target_id = null;
$logAction->quantity = $change;
$logAction->logaction('add seats');
}

View File

@ -40,7 +40,7 @@ trait Loggable
* @since [v3.4]
* @return \App\Models\Actionlog
*/
public function logCheckout($note, $target, $action_date = null, $originalValues = [])
public function logCheckout($note, $target, $action_date = null, $originalValues = [], $quantity = 1)
{
$log = new Actionlog;
@ -94,7 +94,7 @@ trait Loggable
$log->note = $note;
$log->action_date = $action_date;
$log->quantity = $quantity;
$changed = [];
$array_to_flip = array_keys($fields_array);

View File

@ -117,6 +117,13 @@ class HistoryPresenter extends Presenter
'visible' => true,
'formatter' => 'fileDownloadButtonsFormatter',
],
[
'field' => 'quantity',
'searchable' => false,
'sortable' => true,
'visible' => true,
'title' => trans('general.quantity'),
],
[
'field' => 'note',
'searchable' => true,
@ -169,4 +176,4 @@ class HistoryPresenter extends Presenter
return json_encode($merged);
}
}
}

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('action_logs', function (Blueprint $table) {
$table->integer('quantity')->default(1)->after('expected_checkin');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('action_logs', function (Blueprint $table) {
$table->dropColumn('quantity');
});
}
};

View File

@ -14,6 +14,72 @@ use Tests\TestCase;
class AccessoryAcceptanceTest extends TestCase
{
public function test_can_accept_accessory_checkout()
{
$assignee = User::factory()->create();
$accessory = Accessory::factory()->create();
$checkoutAcceptance = CheckoutAcceptance::factory()
->pending()
->for($assignee, 'assignedTo')
->for($accessory, 'checkoutable')
->create(['qty' => 2]);
$this->actingAs($assignee)
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'accepted',
'note' => 'A note here',
])
->assertRedirect();
$this->assertNotNull($checkoutAcceptance->refresh()->accepted_at);
$this->assertEquals('A note here', $checkoutAcceptance->note);
$assignee->accessories->contains($accessory);
$this->assertDatabaseHas('action_logs', [
'action_type' => 'accepted',
'target_id' => $assignee->id,
'target_type' => User::class,
'item_id' => $accessory->id,
'item_type' => Accessory::class,
'quantity' => 2,
]);
}
public function test_can_decline_accessory_checkout()
{
$assignee = User::factory()->create();
$accessory = Accessory::factory()->create();
$checkoutAcceptance = CheckoutAcceptance::factory()
->pending()
->for($assignee, 'assignedTo')
->for($accessory, 'checkoutable')
->create(['qty' => 2]);
$this->actingAs($assignee)
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'declined',
'note' => 'A note here',
])
->assertRedirect();
$this->assertNotNull($checkoutAcceptance->refresh()->declined_at);
$this->assertEquals('A note here', $checkoutAcceptance->note);
$assignee->accessories->doesntContain($accessory);
$this->assertDatabaseHas('action_logs', [
'action_type' => 'declined',
'target_id' => $assignee->id,
'target_type' => User::class,
'item_id' => $accessory->id,
'item_type' => Accessory::class,
'quantity' => 2,
]);
}
/**
* This can be absorbed into a bigger test
*/

View File

@ -0,0 +1,77 @@
<?php
namespace Tests\Feature\CheckoutAcceptances\Ui;
use App\Models\CheckoutAcceptance;
use App\Models\Consumable;
use App\Models\User;
use Tests\TestCase;
class ConsumableAcceptanceTest extends TestCase
{
public function test_can_accept_consumable_checkout()
{
$assignee = User::factory()->create();
$consumable = Consumable::factory()->create();
$checkoutAcceptance = CheckoutAcceptance::factory()
->pending()
->for($assignee, 'assignedTo')
->for($consumable, 'checkoutable')
->create(['qty' => 2]);
$this->actingAs($assignee)
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'accepted',
'note' => 'A note here',
])
->assertRedirect();
$this->assertNotNull($checkoutAcceptance->refresh()->accepted_at);
$this->assertEquals('A note here', $checkoutAcceptance->note);
$assignee->consumables->contains($consumable);
$this->assertDatabaseHas('action_logs', [
'action_type' => 'accepted',
'target_id' => $assignee->id,
'target_type' => User::class,
'item_id' => $consumable->id,
'item_type' => Consumable::class,
'quantity' => 2,
]);
}
public function test_can_decline_consumable_checkout()
{
$assignee = User::factory()->create();
$consumable = Consumable::factory()->create();
$checkoutAcceptance = CheckoutAcceptance::factory()
->pending()
->for($assignee, 'assignedTo')
->for($consumable, 'checkoutable')
->create(['qty' => 2]);
$this->actingAs($assignee)
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'declined',
'note' => 'A note here',
])
->assertRedirect();
$this->assertNotNull($checkoutAcceptance->refresh()->declined_at);
$this->assertEquals('A note here', $checkoutAcceptance->note);
$assignee->consumables->doesntContain($consumable);
$this->assertDatabaseHas('action_logs', [
'action_type' => 'declined',
'target_id' => $assignee->id,
'target_type' => User::class,
'item_id' => $consumable->id,
'item_type' => Consumable::class,
'quantity' => 2,
]);
}
}

View File

@ -115,20 +115,17 @@ class AccessoryCheckoutTest extends TestCase implements TestsPermissionsRequirem
$this->assertTrue($accessory->checkouts()->where('assigned_type', User::class)->where('assigned_to', $user->id)->count() > 0);
$this->assertEquals(
1,
Actionlog::where([
'action_type' => 'checkout',
'target_id' => $user->id,
'target_type' => User::class,
'item_id' => $accessory->id,
'item_type' => Accessory::class,
'created_by' => $admin->id,
])->count(),
'Log entry either does not exist or there are more than expected'
);
$this->assertHasTheseActionLogs($accessory, ['create', 'checkout']);
$this->assertDatabaseHas('action_logs', [
'action_type' => 'checkout',
'target_id' => $user->id,
'target_type' => User::class,
'item_id' => $accessory->id,
'item_type' => Accessory::class,
'quantity' => 2,
'created_by' => $admin->id,
]);
$this->assertHasTheseActionLogs($accessory, ['create', 'checkout']);
}
public function testAccessoryCannotBeCheckedOutToInvalidUser()

View File

@ -109,7 +109,7 @@ class ComponentCheckoutTest extends TestCase implements TestsFullMultipleCompani
$this->actingAsForApi($user)
->postJson(route('api.components.checkout', $component->id), [
'assigned_to' => $asset->id,
'assigned_qty' => 1,
'assigned_qty' => 2,
]);
$this->assertDatabaseHas('action_logs', [
@ -120,6 +120,7 @@ class ComponentCheckoutTest extends TestCase implements TestsFullMultipleCompani
'location_id' => $location->id,
'item_type' => Component::class,
'item_id' => $component->id,
'quantity' => 2,
]);
}

View File

@ -52,6 +52,27 @@ class ConsumableCheckoutTest extends TestCase
$this->assertHasTheseActionLogs($consumable, ['create', 'checkout']);
}
public function testConsumableCanBeCheckedOutWithQuantity()
{
$consumable = Consumable::factory()->create();
$user = User::factory()->create();
$this->actingAsForApi(User::factory()->checkoutConsumables()->create())
->postJson(route('api.consumables.checkout', $consumable), [
'assigned_to' => $user->id,
'checkout_qty' => 2,
]);
$this->assertDatabaseHas('action_logs', [
'item_type' => Consumable::class,
'item_id' => $consumable->id,
'target_type' => User::class,
'target_id' => $user->id,
'action_type' => 'checkout',
'quantity' => 2,
]);
}
public function testUserSentNotificationUponCheckout()
{
Mail::fake();

View File

@ -81,6 +81,7 @@ class AccessoryCheckoutTest extends TestCase
'target_type' => User::class,
'item_id' => $accessory->id,
'item_type' => Accessory::class,
'quantity' => 1,
'note' => 'oh hi there',
]);
$this->assertHasTheseActionLogs($accessory, ['create', 'checkout']);
@ -108,6 +109,7 @@ class AccessoryCheckoutTest extends TestCase
'target_type' => User::class,
'item_id' => $accessory->id,
'item_type' => Accessory::class,
'quantity' => 3,
'note' => 'oh hi there',
]);
$this->assertHasTheseActionLogs($accessory, ['create', 'checkout']);
@ -135,6 +137,7 @@ class AccessoryCheckoutTest extends TestCase
'target_type' => Location::class,
'item_id' => $accessory->id,
'item_type' => Accessory::class,
'quantity' => 3,
'note' => 'oh hi there',
]);
$this->assertHasTheseActionLogs($accessory, ['create', 'checkout']);
@ -162,6 +165,7 @@ class AccessoryCheckoutTest extends TestCase
'target_type' => Asset::class,
'item_id' => $accessory->id,
'item_type' => Accessory::class,
'quantity' => 3,
'note' => 'oh hi there',
]);
$this->assertHasTheseActionLogs($accessory, ['create', 'checkout']);

View File

@ -97,4 +97,30 @@ class ComponentsCheckoutTest extends TestCase
->assertRedirect(route('hardware.show', $asset));
$this->assertHasTheseActionLogs($component, ['create', 'checkout']);
}
public function test_quantity_stored_in_action_log()
{
$component = Component::factory()->create();
$asset = Asset::factory()->create();
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->from(route('components.index'))
->post(route('components.checkout.store', $component), [
'asset_id' => $asset->id,
'redirect_option' => 'index',
'assigned_qty' => 2,
]);
$this->assertDatabaseHas('action_logs', [
'action_type' => 'checkout',
'target_id' => $asset->id,
'target_type' => Asset::class,
'item_id' => $component->id,
'item_type' => Component::class,
'quantity' => 2,
'created_by' => $admin->id,
]);
}
}

View File

@ -150,4 +150,29 @@ class ConsumableCheckoutTest extends TestCase
->assertRedirect(route('users.show', $user));
}
public function test_quantity_stored_in_action_log()
{
$consumable = Consumable::factory()->create(['qty' => 3]);
$user = User::factory()->create();
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->from(route('components.index'))
->post(route('consumables.checkout.store', $consumable), [
'assigned_to' => $user->id,
'redirect_option' => 'target',
'checkout_qty' => 2,
]);
$this->assertDatabaseHas('action_logs', [
'action_type' => 'checkout',
'target_id' => $user->id,
'target_type' => User::class,
'item_id' => $consumable->id,
'item_type' => Consumable::class,
'quantity' => 2,
'created_by' => $admin->id,
]);
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace Tests\Unit\Models;
use App\Enums\ActionType;
use App\Models\License;
use App\Models\User;
use Tests\TestCase;
class LicenseTest extends TestCase
{
public function test_adding_seats_is_logged_when_updating()
{
$user = User::factory()->create();
$this->actingAs($user);
$license = License::factory()->create(['seats' => 2]);
$license->update(['seats' => 6]);
$this->assertDatabaseHas('action_logs', [
'created_by' => $user->id,
'action_type' => ActionType::AddSeats,
'item_type' => License::class,
'item_id' => $license->id,
'deleted_at' => null,
// relevant for this test:
'quantity' => 4,
'note' => 'added 4 seats',
]);
}
public function test_removing_seats_is_logged_when_updating()
{
$user = User::factory()->create();
$this->actingAs($user);
$license = License::factory()->create(['seats' => 6]);
$license->update(['seats' => 3]);
$this->assertDatabaseHas('action_logs', [
'created_by' => $user->id,
'action_type' => ActionType::DeleteSeats,
'item_type' => License::class,
'item_id' => $license->id,
'deleted_at' => null,
// relevant for this test:
'quantity' => 3,
'note' => 'deleted 3 seats',
]);
}
}