mirror of
https://github.com/snipe/snipe-it.git
synced 2026-08-18 03:06:23 +00:00
Fixed #19474 - better handle floor accessories, consumables, etc
This commit is contained in:
@ -55,6 +55,31 @@ return new class extends Migration
|
||||
|
||||
$modelClass::query()->chunkById(500, function ($rows) use ($modelClass, $create, $qtyAdjust) {
|
||||
foreach ($rows as $model) {
|
||||
// Guard against clobbering rows the AdjustsQuantity trait
|
||||
// never touched. A row with zero qty_adjust entries is
|
||||
// definitionally pre-trait: every post-trait qty change
|
||||
// writes a qty_adjust log, so the absence of any means
|
||||
// this row's current qty was maintained by direct writes
|
||||
// that never fed the ledger. The ledger sum for such a
|
||||
// row is whatever legacy code happened to record on the
|
||||
// create entry (often quantity=1 on pre-replenish
|
||||
// deployments), which will not match the true on-hand
|
||||
// count. Trusting the ledger here silently clobbers qty
|
||||
// (see issue #19474: legacy accessories all reset to 1
|
||||
// after v8.7.0 upgrade). Skip the row - qty stays as the
|
||||
// human admin last set it, and legacy_qty (captured by
|
||||
// the snapshot migration) is the rollback lever.
|
||||
$qtyAdjustCount = DB::table('action_logs')
|
||||
->where('item_type', $modelClass)
|
||||
->where('item_id', $model->id)
|
||||
->where('action_type', $qtyAdjust)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
if ($qtyAdjustCount === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$expected = (int) DB::table('action_logs')
|
||||
->where('item_type', $modelClass)
|
||||
->where('item_id', $model->id)
|
||||
@ -68,15 +93,13 @@ return new class extends Migration
|
||||
continue;
|
||||
}
|
||||
|
||||
// Legacy-data safety: pre-replenish deployments never
|
||||
// populated action_logs.quantity for `create` events
|
||||
// (the `qty_adjust` action_type didn't exist), so the
|
||||
// ledger sum comes out at 0 or a stray small value on
|
||||
// every legacy row. Overwriting the real qty with that
|
||||
// stray value drives it below the currently-in-use
|
||||
// count and renders "-N Remaining" in the UI. Skip
|
||||
// when the ledger has no meaningful entries or when
|
||||
// the sum would push qty below in-use.
|
||||
// Redundant with the qty_adjust guard above (a row with
|
||||
// no qty_adjust entries and only a legacy zero-quantity
|
||||
// create event would trip both), but kept as a
|
||||
// belt-and-braces defense for any lingering edge shape
|
||||
// - e.g. a create entry that was soft-deleted and
|
||||
// filtered out of $expected but not out of the trait's
|
||||
// own write path.
|
||||
if ($expected === 0 && $actual > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ActionType;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Component;
|
||||
use App\Models\Consumable;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Data-recovery migration for installs that upgraded to v8.7.0 before
|
||||
* the reconcile guard in 2026_08_03_144000 was tightened.
|
||||
*
|
||||
* The v8.7.0 reconcile migration had a guard for rows whose ledger sum
|
||||
* came out to 0 but nothing for the common legacy pattern of a single
|
||||
* pre-trait `create` action_log entry with quantity=1. Rows matching
|
||||
* that pattern (legacy accessory / consumable / component with any
|
||||
* on-hand count, whose only ledger event was a stray quantity=1 create
|
||||
* log) got their real qty overwritten with 1. The follow-up repair
|
||||
* migration at 2026_08_03_148000 only lifts qty back up when it fell
|
||||
* below the currently-in-use count, so rows with zero units checked out
|
||||
* stayed stuck at 1. Reported in #19474.
|
||||
*
|
||||
* The snapshot migration at 2026_08_03_143500 preserved the pre-
|
||||
* reconcile qty in a companion `legacy_qty` column exactly for this
|
||||
* kind of rollback. This migration restores qty from that snapshot
|
||||
* only for rows where:
|
||||
*
|
||||
* - qty is currently below legacy_qty (evidence of the clobber -
|
||||
* if the admin has since re-adjusted qty back up to or past the
|
||||
* original value, this is a no-op), AND
|
||||
* - the row has zero qty_adjust action_log entries (evidence that
|
||||
* the AdjustsQuantity trait has never been used on this row -
|
||||
* if the admin has been actively managing qty through the
|
||||
* replenish/decrement UI, those adjustments generated qty_adjust
|
||||
* entries and we do not want to roll their intentional changes
|
||||
* back to the snapshot value).
|
||||
*
|
||||
* Idempotent: on installs where the reconcile did no damage, every
|
||||
* row already has qty >= legacy_qty and this is a full no-op. Same
|
||||
* story on installs that ran the tightened reconcile migration and
|
||||
* never had the clobber happen in the first place.
|
||||
*
|
||||
* License is not in scope. License.seats was excluded from both the
|
||||
* snapshot and reconcile migrations, so there's nothing to restore.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private const RESTORE_MODELS = [
|
||||
Accessory::class,
|
||||
Consumable::class,
|
||||
Component::class,
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
foreach (self::RESTORE_MODELS as $modelClass) {
|
||||
$this->restoreFor($modelClass);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// No-op. Reversing the restore would put qty back to the
|
||||
// clobbered value that this migration exists to fix.
|
||||
}
|
||||
|
||||
private function restoreFor(string $modelClass): void
|
||||
{
|
||||
$qtyAdjust = ActionType::QuantityAdjust->value;
|
||||
$table = (new $modelClass)->getTable();
|
||||
|
||||
$modelClass::query()
|
||||
->whereNotNull('legacy_qty')
|
||||
->whereColumn('qty', '<', 'legacy_qty')
|
||||
->chunkById(500, function ($rows) use ($modelClass, $qtyAdjust, $table) {
|
||||
foreach ($rows as $model) {
|
||||
$qtyAdjustCount = DB::table('action_logs')
|
||||
->where('item_type', $modelClass)
|
||||
->where('item_id', $model->id)
|
||||
->where('action_type', $qtyAdjust)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
if ($qtyAdjustCount > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Log::info(sprintf(
|
||||
'Restoring %s#%d qty: %d -> %d (from legacy_qty snapshot, no qty_adjust events since)',
|
||||
$modelClass,
|
||||
$model->id,
|
||||
(int) $model->qty,
|
||||
(int) $model->legacy_qty,
|
||||
));
|
||||
|
||||
DB::table($table)
|
||||
->where('id', $model->id)
|
||||
->update(['qty' => $model->legacy_qty]);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
212
tests/Feature/Migrations/InventoryQtyReconcileAndRestoreTest.php
Normal file
212
tests/Feature/Migrations/InventoryQtyReconcileAndRestoreTest.php
Normal file
@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Migrations;
|
||||
|
||||
use App\Enums\ActionType;
|
||||
use App\Models\Accessory;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Coverage for the pair of qty-safety migrations tied to issue #19474
|
||||
* (legacy accessories getting reset to 1 after the v8.7.0 reconcile).
|
||||
*
|
||||
* The migrations under test are:
|
||||
*
|
||||
* - 2026_08_03_144000_reconcile_inventory_qty_from_action_logs.php
|
||||
* Post-fix version has a `qty_adjust`-presence guard that skips
|
||||
* any row the AdjustsQuantity trait has never touched.
|
||||
*
|
||||
* - 2026_08_13_000000_restore_clobbered_inventory_qty_from_legacy_snapshot.php
|
||||
* New floor migration that undoes the damage on installs which
|
||||
* ran the pre-fix reconcile. Restores qty from the legacy_qty
|
||||
* column captured by 2026_08_03_143500 for rows still showing
|
||||
* the clobber pattern (qty < legacy_qty AND zero qty_adjust
|
||||
* events since).
|
||||
*
|
||||
* Both migrations are one-shot data migrations. The tests here
|
||||
* instantiate the migration class directly and call ->up(), which is
|
||||
* safe against LazilyRefreshDatabase because both are designed to be
|
||||
* idempotent (the reconcile is a no-op on rows that already agree with
|
||||
* the ledger, and the restore is a no-op on rows where qty >= legacy_qty
|
||||
* or where qty_adjust events exist).
|
||||
*/
|
||||
class InventoryQtyReconcileAndRestoreTest extends TestCase
|
||||
{
|
||||
private function runReconcile(): void
|
||||
{
|
||||
$migration = require database_path('migrations/2026_08_03_144000_reconcile_inventory_qty_from_action_logs.php');
|
||||
$migration->up();
|
||||
}
|
||||
|
||||
private function runRestore(): void
|
||||
{
|
||||
$migration = require database_path('migrations/2026_08_13_000000_restore_clobbered_inventory_qty_from_legacy_snapshot.php');
|
||||
$migration->up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a legacy create action_log entry the way pre-trait Snipe-IT
|
||||
* used to: `create` type with a fixed quantity that does not reflect
|
||||
* the parent row's real on-hand count. Bypass Eloquent to avoid the
|
||||
* observer writing its own more-modern entry on top.
|
||||
*/
|
||||
private function insertLegacyCreateLog(Accessory $accessory, int $quantity = 1): void
|
||||
{
|
||||
DB::table('action_logs')->insert([
|
||||
'action_type' => ActionType::Create->value,
|
||||
'item_type' => Accessory::class,
|
||||
'item_id' => $accessory->id,
|
||||
'quantity' => $quantity,
|
||||
'company_id' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function insertQtyAdjustLog(Accessory $accessory, int $delta): void
|
||||
{
|
||||
DB::table('action_logs')->insert([
|
||||
'action_type' => ActionType::QuantityAdjust->value,
|
||||
'item_type' => Accessory::class,
|
||||
'item_id' => $accessory->id,
|
||||
'quantity' => $delta,
|
||||
'company_id' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function factoryAccessoryWithoutObserverLogs(int $qty): Accessory
|
||||
{
|
||||
// Suppress the observer-driven create log so tests control the
|
||||
// ledger shape explicitly. Without this the factory's created
|
||||
// observer would write a matching create+quantity log, which
|
||||
// would defeat the point of a "pre-trait legacy row" fixture.
|
||||
Accessory::withoutEvents(fn () => null);
|
||||
|
||||
$accessory = Accessory::factory()->create(['qty' => $qty]);
|
||||
|
||||
// Delete any log entries the observer chain wrote so we have a
|
||||
// clean slate to add our own legacy fixture entries.
|
||||
DB::table('action_logs')
|
||||
->where('item_type', Accessory::class)
|
||||
->where('item_id', $accessory->id)
|
||||
->delete();
|
||||
|
||||
// Snapshot the current qty into legacy_qty the same way the
|
||||
// 143500 snapshot migration did. Direct UPDATE bypasses casts.
|
||||
DB::table('accessories')
|
||||
->where('id', $accessory->id)
|
||||
->update(['legacy_qty' => $qty]);
|
||||
|
||||
return $accessory->refresh();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Tightened reconcile guard
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function tightened_reconcile_skips_legacy_row_with_only_a_stray_create_log()
|
||||
{
|
||||
// The exact #19474 pattern: pre-trait accessory with real
|
||||
// qty=50, ledger has a single create entry with quantity=1 and
|
||||
// no qty_adjust events. Pre-fix reconcile clobbered these to 1.
|
||||
// Post-fix reconcile must skip them.
|
||||
$accessory = $this->factoryAccessoryWithoutObserverLogs(50);
|
||||
$this->insertLegacyCreateLog($accessory, 1);
|
||||
|
||||
$this->runReconcile();
|
||||
|
||||
$this->assertSame(50, (int) $accessory->fresh()->qty);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function tightened_reconcile_still_reconciles_rows_with_qty_adjust_history()
|
||||
{
|
||||
// Post-trait accessory: create log matches initial qty, then
|
||||
// qty_adjust entries record replenishment. If somehow the qty
|
||||
// column drifted from the ledger sum (direct DB edit, botched
|
||||
// restore) the reconcile should still correct it - qty_adjust
|
||||
// presence is the signal that the ledger is trustworthy for
|
||||
// this row.
|
||||
$accessory = $this->factoryAccessoryWithoutObserverLogs(3);
|
||||
$this->insertLegacyCreateLog($accessory, 5); // ledger says initial was 5
|
||||
$this->insertQtyAdjustLog($accessory, 2); // then +2, ledger sum = 7
|
||||
|
||||
$this->runReconcile();
|
||||
|
||||
$this->assertSame(7, (int) $accessory->fresh()->qty);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Floor migration: restore from legacy_qty snapshot
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[Test]
|
||||
public function restore_lifts_qty_back_to_legacy_snapshot_for_clobbered_row()
|
||||
{
|
||||
// Simulate the post-clobber state: qty was reconciled down to 1,
|
||||
// legacy_qty preserves the pre-clobber value of 50, and there
|
||||
// are no qty_adjust events (which is the whole reason the row
|
||||
// got clobbered in the first place).
|
||||
$accessory = $this->factoryAccessoryWithoutObserverLogs(50);
|
||||
DB::table('accessories')->where('id', $accessory->id)->update(['qty' => 1]);
|
||||
$this->insertLegacyCreateLog($accessory, 1);
|
||||
|
||||
$this->runRestore();
|
||||
|
||||
$this->assertSame(50, (int) $accessory->fresh()->qty);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function restore_leaves_row_alone_when_qty_matches_or_exceeds_legacy_snapshot()
|
||||
{
|
||||
// Post-clobber, admin manually re-adjusted qty back up to a
|
||||
// value at or above legacy_qty (they might have replenished
|
||||
// more than they had before, or just restored it to its old
|
||||
// value). The restore must not touch these.
|
||||
$accessory = $this->factoryAccessoryWithoutObserverLogs(50);
|
||||
DB::table('accessories')->where('id', $accessory->id)->update(['qty' => 60]);
|
||||
|
||||
$this->runRestore();
|
||||
|
||||
$this->assertSame(60, (int) $accessory->fresh()->qty);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function restore_leaves_row_alone_when_admin_has_used_the_replenish_ui_since()
|
||||
{
|
||||
// qty is below legacy_qty AND there's at least one qty_adjust
|
||||
// entry. The admin has been managing this row's stock through
|
||||
// the replenish/decrement UI since the clobber, so their
|
||||
// downward adjustment is intentional and must not be rolled
|
||||
// back to the pre-clobber snapshot.
|
||||
$accessory = $this->factoryAccessoryWithoutObserverLogs(50);
|
||||
DB::table('accessories')->where('id', $accessory->id)->update(['qty' => 30]);
|
||||
$this->insertQtyAdjustLog($accessory, -20); // admin decremented from 50 to 30
|
||||
|
||||
$this->runRestore();
|
||||
|
||||
$this->assertSame(30, (int) $accessory->fresh()->qty);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function restore_is_a_no_op_when_legacy_qty_is_null()
|
||||
{
|
||||
// Rows created after the snapshot migration ran leave
|
||||
// legacy_qty NULL. Restore must skip them - there's nothing
|
||||
// to restore from.
|
||||
$accessory = $this->factoryAccessoryWithoutObserverLogs(10);
|
||||
DB::table('accessories')->where('id', $accessory->id)->update([
|
||||
'qty' => 5,
|
||||
'legacy_qty' => null,
|
||||
]);
|
||||
|
||||
$this->runRestore();
|
||||
|
||||
$this->assertSame(5, (int) $accessory->fresh()->qty);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user