3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00

Allow zero qty for audit

This commit is contained in:
snipe
2026-08-03 13:25:33 +01:00
parent 91318416a4
commit 0a4bc4be13
7 changed files with 65 additions and 33 deletions

View File

@ -23,9 +23,12 @@ class AdjustQuantityRequest extends FormRequest
{
return [
// Signed delta: positive to replenish, negative to decrement.
// The trait rejects the actual below-in-use case; here we
// just guard against zero (nothing to do) and non-integer.
'amount' => ['required', 'integer', 'not_in:0'],
// Zero is intentionally allowed so users can record an audit
// (physical count against the current DB qty). The trait
// writes a QuantityAdjust log entry with quantity=0 in that
// case without touching the on-hand column. The trait itself
// rejects the actual below-in-use case.
'amount' => ['required', 'integer'],
'note' => ['required', 'string', 'max:65535'],
'order_number' => ['nullable', 'string', 'max:191'],
// Optional receipt/invoice/PO scan. Attaches to the same

View File

@ -54,6 +54,11 @@ trait AdjustsQuantity
* PHP-side read-modify-write). Wrapped in a transaction so the log
* entry and the quantity change either both happen or both roll back.
*
* A delta of zero is a valid audit-only submission: no qty change
* happens but the log entry still writes, so a user can record a
* physical count that confirms the DB value without also logging a
* spurious increment or decrement.
*
* Rejects any adjustment that would leave the on-hand quantity below
* the number of units currently in use — decrementing below what's
* already checked out to users/assets would leave the DB inconsistent
@ -69,10 +74,6 @@ trait AdjustsQuantity
*/
public function adjustQuantity(int $delta, string $note, ?string $orderNumber = null, ?string $filename = null): void
{
if ($delta === 0) {
return;
}
$column = $this->getAdjustableQuantityColumn();
$current = (int) ($this->{$column} ?? 0);
$inUse = max(0, (int) $this->currentlyInUseCount());
@ -91,12 +92,17 @@ trait AdjustsQuantity
// {qty:{old,new}}) alongside our QuantityAdjust log. Keep
// the in-memory attribute in sync so any downstream code
// reading $this->qty after the call sees the new value.
$delta > 0
? $this->newQuery()->where('id', $this->id)->increment($column, $delta)
: $this->newQuery()->where('id', $this->id)->decrement($column, abs($delta));
$this->{$column} = (int) $this->{$column} + $delta;
$this->syncOriginalAttribute($column);
// Gate the actual UPDATE on delta !== 0 so audit-only
// submissions (delta = 0) skip the round-trip.
if ($delta > 0) {
$this->newQuery()->where('id', $this->id)->increment($column, $delta);
$this->{$column} = (int) $this->{$column} + $delta;
$this->syncOriginalAttribute($column);
} elseif ($delta < 0) {
$this->newQuery()->where('id', $this->id)->decrement($column, abs($delta));
$this->{$column} = (int) $this->{$column} + $delta;
$this->syncOriginalAttribute($column);
}
$log = new Actionlog;
$log->item_type = static::class;

View File

@ -16,7 +16,7 @@ return [
'adjust_quantity' => 'Adjust Quantity',
'adjust_quantity_success' => 'Quantity adjusted successfully.',
'adjust_quantity_amount' => 'Amount to add or remove',
'adjust_quantity_amount_help' => 'Positive to replenish (e.g. 5), negative to decrease (e.g. -3). Must not be zero.',
'adjust_quantity_amount_help' => 'Positive to replenish (e.g. 5), negative to decrease (e.g. -3). Zero records an audit entry without changing the on-hand quantity.',
'adjust_quantity_note' => 'Reason / note',
'adjust_quantity_below_zero' => 'That adjustment would take the on-hand quantity below what is currently checked out.',
'adjusted_quantity' => 'adjusted quantity',

View File

@ -21,8 +21,11 @@
<label for="adjustQuantityAmount">{{ trans('general.adjust_quantity_amount') }}</label>
{{-- min is populated by snipeit.js when the modal opens (available). The
browser stepper then refuses to go below and the built-in
constraint-validation message surfaces if a user types past it. --}}
<input type="number" class="form-control" id="adjustQuantityAmount" name="amount" step="1" data-rule-notzero="true" required>
constraint-validation message surfaces if a user types past it.
Zero is a valid input: it produces an audit-only QuantityAdjust
log entry with no qty change so users can record a physical
count against the current DB value. --}}
<input type="number" class="form-control" id="adjustQuantityAmount" name="amount" step="1" required>
<p class="help-block">{{ trans('general.adjust_quantity_amount_help') }}</p>
</div>

View File

@ -1138,17 +1138,6 @@
return param.test(value);
}, '{{ trans('validation.generic.invalid_value_in_field') }}');
// Opt-in via data-rule-not-zero="true" on any numeric input.
// Used by the adjust-quantity modal: the server-side rule is
// not_in:0 (zero delta = nothing to adjust), and HTML5 has no
// native way to exclude a single value from a numeric range.
$.validator.addMethod('notZero', function (value, element) {
if (this.optional(element)) {
return true;
}
return parseFloat(value) !== 0;
}, '{{ trans('general.adjust_quantity_amount_help') }}');
// Generic radio-toggles-required-select handler. Any form pattern
// where a radio group hides/shows sibling <select>s (checkout-to
// type in checkout forms today; any future similar toggle) can

View File

@ -83,17 +83,32 @@ class AdjustAccessoryQuantityApiTest extends TestCase
->assertJsonPath('messages.note.0', 'The note field is required.');
}
public function test_zero_amount_is_rejected()
public function test_zero_amount_writes_audit_log_without_changing_qty()
{
// Zero delta is an audit-only submission: user counted the shelf
// and confirmed it still matches the DB, so we record the
// QuantityAdjust log entry (with quantity=0) for provenance but
// do not touch the on-hand column.
$accessory = Accessory::factory()->create(['qty' => 5]);
$this->actingAsForApi(User::factory()->editAccessories()->create())
->postJson(route('api.accessories.adjust-quantity', $accessory), [
'amount' => 0,
'note' => 'nope',
'note' => 'shelf count matches',
])
->assertOk()
->assertJsonPath('status', 'error');
->assertJsonPath('status', 'success');
$this->assertSame(5, (int) $accessory->fresh()->qty);
$log = Actionlog::where('item_type', Accessory::class)
->where('item_id', $accessory->id)
->where('action_type', ActionType::QuantityAdjust->value)
->latest('id')
->firstOrFail();
$this->assertSame(0, (int) $log->quantity);
$this->assertSame('shelf count matches', $log->note);
}
public function test_decrement_below_currently_checked_out_returns_422()

View File

@ -136,17 +136,33 @@ class AdjustAccessoryQuantityTest extends TestCase
$this->assertSame(5, (int) $accessory->fresh()->qty);
}
public function test_amount_cannot_be_zero()
public function test_zero_amount_writes_audit_log_without_changing_qty()
{
// Zero delta is an audit-only submission: the user counted the
// shelf, it matches the DB, and they recorded that fact. We
// write a QuantityAdjust log entry (with quantity=0) for
// provenance but do not touch the on-hand column.
$actor = User::factory()->editAccessories()->create();
$accessory = Accessory::factory()->create(['qty' => 5]);
$this->actingAs($actor)
->post(route('accessories.adjust-quantity', $accessory), [
'amount' => 0,
'note' => 'nope',
'note' => 'shelf count matches',
])
->assertSessionHasErrors('amount');
->assertSessionHasNoErrors()
->assertSessionHas('success');
$this->assertSame(5, (int) $accessory->fresh()->qty);
$log = \App\Models\Actionlog::where('item_type', Accessory::class)
->where('item_id', $accessory->id)
->where('action_type', \App\Enums\ActionType::QuantityAdjust->value)
->latest('id')
->firstOrFail();
$this->assertSame(0, (int) $log->quantity);
$this->assertSame('shelf count matches', $log->note);
}
public function test_uploaded_receipt_attaches_to_the_same_log_row_and_surfaces_in_files_tab()