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:
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user