mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-10-28 19:18:58 -04:00
Refactor Stock_location model to camelCase naming and introduce
is_default and sort_order fields with full migration, UI, and
item-sync support. Multiple follow-up fixes consolidated.
Database
- Add is_default (TINYINT, backfilled to oldest non-deleted row) and
sort_order (nullable unsigned TINYINT unique, dense 0..N-1) columns
via new migration; MySQL 5.7-compatible backfill using user variables
instead of ROW_NUMBER(); down() drops both columns
Model
- Rename show_locations → showLocations (camelCase convention)
- Add getDefaultLocationId(), setDefaultLocation(), saveSortOrder();
validate location existence before setting as default
- delete() compacts sort_order via two-pass CASE update to avoid
collisions after prior reorders
- Atomic sort_order assignment via explicit SQL COUNT query
Controller / Config
- Modularize save logic into sanitizeSubmittedLocations(),
deleteRemovedLocations(), saveSubmittedLocations(),
saveLocationOrderAndDefault(); wrap in single transaction
- Support stock_location_new inputs for new-row creation
- Resolve new-* ID placeholders to real IDs post-save
- Abort loop on save failure; default missing POST arrays to []
- Validate that at least one location array is non-empty
Views
- Add Default column with center-aligned radio buttons; disable for
unsaved rows; use attr() instead of data() for location IDs
- Escape dynamic location IDs against XSS in all affected partials
- form_count_details.php: fall back to default location when current
is undefined; guard quantity input similarly
- Add aria-label to default radio inputs
Libraries / Items
- Widen Item_lib::get_item_location() and get_stock_destination() to
nullable return types
- Guard null location_id in Items::getRow() and getGenerateBarcodes()
- Item::get_multiple_info(): accept ?int $location_id; short-circuit
with where('item_id', -1) instead of always-false where('1','0')
i18n
- Add stock_location_default, stock_location_default_header,
stock_location_name_header across all locale files
Tests
- Stock_locationTest: getDefaultLocationId, setDefaultLocation,
saveSortOrder, delete compaction, saveValue; rename to
testMethod_Scenario convention
- Add ConfigControllerTest / StockLocationsControllerTest with
AdminAuthTrait covering CRUD, reorder, default assignment, and
malformed/empty/blank payload validation
- DatabaseBootstrapTrait-based setup for ItemTest and Stock_locationTest
Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
301 lines
11 KiB
PHP
301 lines
11 KiB
PHP
<?php
|
|
|
|
namespace Tests\Models;
|
|
|
|
use CodeIgniter\Test\CIUnitTestCase;
|
|
use CodeIgniter\Test\DatabaseTestTrait;
|
|
use App\Models\Stock_location;
|
|
use Tests\Support\DatabaseBootstrapTrait;
|
|
|
|
class Stock_locationTest extends CIUnitTestCase
|
|
{
|
|
use DatabaseTestTrait;
|
|
use DatabaseBootstrapTrait;
|
|
|
|
protected $migrate = true;
|
|
protected $migrateOnce = true;
|
|
protected $seedOnce = true;
|
|
protected $refresh = true;
|
|
protected $namespace = null;
|
|
|
|
protected $stockLocation;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->resetDatabaseOnce();
|
|
|
|
parent::setUp();
|
|
|
|
session()->set('person_id', 1);
|
|
|
|
$this->stockLocation = model(Stock_location::class);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
parent::tearDown();
|
|
}
|
|
|
|
private function nextSortOrder(): int
|
|
{
|
|
return $this->db->table('stock_locations')->where('deleted', 0)->countAllResults();
|
|
}
|
|
|
|
private function insertLocation(string $name, bool $isDefault = false, bool $withPermissions = true): int
|
|
{
|
|
$sortOrder = $this->nextSortOrder();
|
|
|
|
$builder = $this->db->table('stock_locations');
|
|
$builder->insert([
|
|
'location_name' => $name,
|
|
'deleted' => 0,
|
|
'is_default' => $isDefault ? 1 : 0,
|
|
'sort_order' => $sortOrder,
|
|
]);
|
|
$locationId = $this->db->insertID();
|
|
|
|
if ($withPermissions) {
|
|
foreach (['items', 'sales', 'receivings'] as $module) {
|
|
$permissionId = $module . '_' . str_replace(' ', '_', $name);
|
|
|
|
$this->db->table('permissions')->insert([
|
|
'permission_id' => $permissionId,
|
|
'module_id' => $module,
|
|
'location_id' => $locationId,
|
|
]);
|
|
|
|
$this->db->table('grants')->insert([
|
|
'permission_id' => $permissionId,
|
|
'person_id' => 1,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return $locationId;
|
|
}
|
|
|
|
public function testGetDefaultLocationId_ReturnsRowMarkedDefaultRegardlessOfSortOrder(): void
|
|
{
|
|
$this->db->table('stock_locations')->update(['is_default' => 0]);
|
|
|
|
$this->insertLocation('Alpha');
|
|
$defaultId = $this->insertLocation('Beta', true);
|
|
|
|
$result = $this->stockLocation->getDefaultLocationId('items');
|
|
|
|
$this->assertSame($defaultId, $result);
|
|
}
|
|
|
|
public function testGetDefaultLocationId_FallsBackToLowestSortOrderWhenNoneMarkedDefault(): void
|
|
{
|
|
// Remove the seeded base location so the inserted rows below are the only
|
|
// candidates and are unambiguously ordered by sort_order.
|
|
$this->db->table('permissions')->emptyTable();
|
|
$this->db->table('grants')->emptyTable();
|
|
$this->db->table('stock_locations')->emptyTable();
|
|
|
|
$lowestId = $this->insertLocation('Delta');
|
|
$this->insertLocation('Epsilon');
|
|
$this->insertLocation('Gamma');
|
|
|
|
$result = $this->stockLocation->getDefaultLocationId('items');
|
|
|
|
$this->assertSame($lowestId, $result);
|
|
}
|
|
|
|
public function testGetDefaultLocationId_ReturnsNullWhenNoAllowedLocationForModule(): void
|
|
{
|
|
$this->db->table('grants')->emptyTable();
|
|
$this->db->table('permissions')->emptyTable();
|
|
|
|
$result = $this->stockLocation->getDefaultLocationId('items');
|
|
|
|
$this->assertNull($result);
|
|
}
|
|
|
|
public function testSetDefaultLocation_SetsTargetAndClearsOthers(): void
|
|
{
|
|
$firstId = $this->insertLocation('Zeta', true);
|
|
$secondId = $this->insertLocation('Eta');
|
|
|
|
$result = $this->stockLocation->setDefaultLocation($secondId);
|
|
|
|
$this->assertTrue($result);
|
|
|
|
$rows = $this->db->table('stock_locations')->orderBy('location_id')->get()->getResultArray();
|
|
$defaults = array_column($rows, 'is_default', 'location_id');
|
|
|
|
$this->assertEquals(0, $defaults[$firstId]);
|
|
$this->assertEquals(1, $defaults[$secondId]);
|
|
}
|
|
|
|
public function testSaveSortOrder_AppliesNewOrderExactly(): void
|
|
{
|
|
// saveSortOrder() only reassigns sort_order for the ids passed in, starting
|
|
// the new range at 0 — any pre-existing row left out of the batch would
|
|
// collide with that range, so the batch here is every row in the table.
|
|
$idA = $this->insertLocation('Theta', false, false);
|
|
$idB = $this->insertLocation('Iota', false, false);
|
|
$idC = $this->insertLocation('Kappa', false, false);
|
|
|
|
$allIds = array_column($this->db->table('stock_locations')->get()->getResultArray(), 'location_id');
|
|
$ordered = array_values(array_diff($allIds, [$idA, $idB, $idC]));
|
|
array_unshift($ordered, $idC, $idA, $idB);
|
|
|
|
$result = $this->stockLocation->saveSortOrder($ordered);
|
|
|
|
$this->assertTrue($result);
|
|
|
|
$rows = $this->db->table('stock_locations')
|
|
->whereIn('location_id', [$idA, $idB, $idC])
|
|
->get()
|
|
->getResultArray();
|
|
$sortOrders = array_column($rows, 'sort_order', 'location_id');
|
|
|
|
$this->assertLessThan($sortOrders[$idA], $sortOrders[$idC]);
|
|
$this->assertLessThan($sortOrders[$idB], $sortOrders[$idA]);
|
|
}
|
|
|
|
public function testSaveSortOrder_HandlesCollidingRotationWithoutError(): void
|
|
{
|
|
// A reverse rotation is exactly the case naive sequential UPDATEs would
|
|
// collide on against the unique sort_order index.
|
|
$idA = $this->insertLocation('Lambda', false, false);
|
|
$idB = $this->insertLocation('Mu', false, false);
|
|
$idC = $this->insertLocation('Nu', false, false);
|
|
|
|
$allIds = array_column($this->db->table('stock_locations')->get()->getResultArray(), 'location_id');
|
|
$ordered = array_values(array_diff($allIds, [$idA, $idB, $idC]));
|
|
array_unshift($ordered, $idC, $idB, $idA);
|
|
|
|
$result = $this->stockLocation->saveSortOrder($ordered);
|
|
|
|
$this->assertTrue($result);
|
|
|
|
$rows = $this->db->table('stock_locations')
|
|
->whereIn('location_id', [$idA, $idB, $idC])
|
|
->get()
|
|
->getResultArray();
|
|
$sortOrders = array_column($rows, 'sort_order', 'location_id');
|
|
|
|
$this->assertLessThan($sortOrders[$idB], $sortOrders[$idC]);
|
|
$this->assertLessThan($sortOrders[$idA], $sortOrders[$idB]);
|
|
|
|
$distinctCount = $this->db->table('stock_locations')
|
|
->distinct()
|
|
->select('sort_order')
|
|
->where('deleted', 0)
|
|
->get()
|
|
->getNumRows();
|
|
$totalCount = $this->db->table('stock_locations')->where('deleted', 0)->countAllResults();
|
|
$this->assertSame($totalCount, $distinctCount, 'sort_order values must remain unique after rotation');
|
|
}
|
|
|
|
public function testDelete_ClearsSortOrder(): void
|
|
{
|
|
$idA = $this->insertLocation('Xi', false, false);
|
|
$idB = $this->insertLocation('Omicron', false, false);
|
|
|
|
$result = $this->stockLocation->delete($idB);
|
|
|
|
$this->assertTrue($result);
|
|
|
|
$deletedRow = $this->db->table('stock_locations')->where('location_id', $idB)->get()->getRow();
|
|
|
|
$this->assertEquals(1, $deletedRow->deleted);
|
|
$this->assertNull($deletedRow->sort_order);
|
|
}
|
|
|
|
public function testDelete_ExcludesLocationFromGetAll(): void
|
|
{
|
|
$idA = $this->insertLocation('Pi', false, false);
|
|
$idB = $this->insertLocation('Rho', false, false);
|
|
|
|
$this->stockLocation->delete($idB);
|
|
|
|
$remainingIds = array_map('intval', array_column($this->stockLocation->getAll()->getResultArray(), 'location_id'));
|
|
|
|
$this->assertContains($idA, $remainingIds);
|
|
$this->assertNotContains($idB, $remainingIds);
|
|
}
|
|
|
|
public function testDelete_CompactsRemainingSortOrderAndAllowsSubsequentInsert(): void
|
|
{
|
|
$idA = $this->insertLocation('Sigma', false, false);
|
|
$idB = $this->insertLocation('Tau', false, false);
|
|
$idC = $this->insertLocation('Upsilon', false, false);
|
|
|
|
$this->stockLocation->delete($idB);
|
|
|
|
$remainingSortOrders = array_column(
|
|
$this->db->table('stock_locations')->where('deleted', 0)->orderBy('sort_order', 'ASC')->get()->getResultArray(),
|
|
'sort_order'
|
|
);
|
|
$this->assertSame(range(0, count($remainingSortOrders) - 1), array_map('intval', $remainingSortOrders));
|
|
|
|
$locationData = ['location_name' => 'Phi New Location'];
|
|
|
|
$this->assertTrue($this->stockLocation->saveValue($locationData, NEW_ENTRY));
|
|
$this->assertNotEmpty($locationData['location_id']);
|
|
}
|
|
|
|
public function testDelete_CompactsSortOrderAfterReorderWithoutCollision(): void
|
|
{
|
|
// Reordering first puts the surviving rows' sort_order out of insertion
|
|
// order, so delete()'s compaction must re-derive the dense range from
|
|
// the post-reorder state rather than assuming row N keeps position N.
|
|
$idA = $this->insertLocation('Chi', false, false);
|
|
$idB = $this->insertLocation('Psi', false, false);
|
|
$idC = $this->insertLocation('Omega', false, false);
|
|
|
|
// saveSortOrder() requires every active row in the batch (see
|
|
// testSaveSortOrderAppliesNewOrderExactly) or the untouched row collides
|
|
// with the temp range, so include whatever else is already in the table.
|
|
$allIds = array_column($this->db->table('stock_locations')->where('deleted', 0)->get()->getResultArray(), 'location_id');
|
|
$ordered = array_values(array_diff($allIds, [$idA, $idB, $idC]));
|
|
array_unshift($ordered, $idC, $idA, $idB);
|
|
|
|
$this->stockLocation->saveSortOrder($ordered);
|
|
$result = $this->stockLocation->delete($idA);
|
|
|
|
$this->assertTrue($result);
|
|
|
|
$remainingRows = $this->db->table('stock_locations')
|
|
->where('deleted', 0)
|
|
->orderBy('sort_order', 'ASC')
|
|
->get()
|
|
->getResultArray();
|
|
$remainingIds = array_map('intval', array_column($remainingRows, 'location_id'));
|
|
$remainingSortOrders = array_map('intval', array_column($remainingRows, 'sort_order'));
|
|
|
|
$this->assertNotContains($idA, $remainingIds);
|
|
$this->assertSame([$idC, $idB], array_values(array_intersect($remainingIds, [$idC, $idB])));
|
|
$this->assertSame(range(0, count($remainingSortOrders) - 1), $remainingSortOrders);
|
|
|
|
$distinctCount = $this->db->table('stock_locations')
|
|
->distinct()
|
|
->select('sort_order')
|
|
->where('deleted', 0)
|
|
->get()
|
|
->getNumRows();
|
|
$this->assertSame(count($remainingSortOrders), $distinctCount, 'sort_order values must remain unique after delete compaction');
|
|
}
|
|
|
|
public function testSaveValue_NewInsertAssignsNextSortOrder(): void
|
|
{
|
|
$countBefore = $this->db->table('stock_locations')->where('deleted', 0)->countAllResults();
|
|
|
|
$locationData = ['location_name' => 'Sigma New Location'];
|
|
|
|
$this->assertTrue($this->stockLocation->saveValue($locationData, NEW_ENTRY));
|
|
$this->assertNotEmpty($locationData['location_id']);
|
|
|
|
$savedRow = $this->db->table('stock_locations')
|
|
->where('location_id', $locationData['location_id'])
|
|
->get()
|
|
->getRow();
|
|
|
|
$this->assertSame($countBefore, (int) $savedRow->sort_order);
|
|
}
|
|
}
|