diff --git a/controllers/StockController.php b/controllers/StockController.php index 408c29c7..efd67b87 100644 --- a/controllers/StockController.php +++ b/controllers/StockController.php @@ -103,14 +103,27 @@ class StockController extends BaseController { return $this->renderPage($response, 'locationform', [ 'mode' => 'create', + 'locationsHierarchy' => $this->getDatabase()->locations_hierarchy()->where('active = 1')->orderBy('location_path', 'COLLATE NOCASE'), 'userfields' => $this->getUserfieldsService()->GetFields('locations') ]); } else { + // Get descendant location IDs to exclude from parent picker (prevent circular references) + $descendantIds = []; + foreach ($this->getDatabase()->locations_resolved()->where('ancestor_location_id', $args['locationId']) as $resolved) + { + if ($resolved->location_id != $args['locationId']) + { + $descendantIds[] = $resolved->location_id; + } + } + return $this->renderPage($response, 'locationform', [ 'location' => $this->getDatabase()->locations($args['locationId']), 'mode' => 'edit', + 'locationsHierarchy' => $this->getDatabase()->locations_hierarchy()->where('active = 1')->orderBy('location_path', 'COLLATE NOCASE'), + 'descendantLocationIds' => $descendantIds, 'userfields' => $this->getUserfieldsService()->GetFields('locations') ]); } @@ -120,11 +133,11 @@ class StockController extends BaseController { if (isset($request->getQueryParams()['include_disabled'])) { - $locations = $this->getDatabase()->locations()->orderBy('name', 'COLLATE NOCASE'); + $locations = $this->getDatabase()->locations_hierarchy()->orderBy('location_path', 'COLLATE NOCASE'); } else { - $locations = $this->getDatabase()->locations()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'); + $locations = $this->getDatabase()->locations_hierarchy()->where('active = 1')->orderBy('location_path', 'COLLATE NOCASE'); } return $this->renderPage($response, 'locations', [ diff --git a/grocy.openapi.json b/grocy.openapi.json index 5ce20b44..c6d9281c 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -4660,6 +4660,18 @@ "description": { "type": "string" }, + "parent_location_id": { + "type": "integer", + "description": "Parent location id for hierarchical organization" + }, + "is_freezer": { + "type": "integer", + "description": "1 if this location is a freezer, 0 otherwise" + }, + "active": { + "type": "integer", + "description": "1 if this location is active, 0 otherwise" + }, "row_created_timestamp": { "type": "string", "format": "date-time" @@ -4671,8 +4683,11 @@ }, "example": { "id": "2", - "name": "0", - "description": null, + "name": "Fridge", + "description": "The main refrigerator", + "parent_location_id": 1, + "is_freezer": 0, + "active": 1, "row_created_timestamp": "2019-05-02 20:12:25", "userfields": null } diff --git a/migrations/0255.sql b/migrations/0255.sql new file mode 100644 index 00000000..7f767bd5 --- /dev/null +++ b/migrations/0255.sql @@ -0,0 +1,116 @@ +-- Add parent_location_id column to locations table for hierarchical organization +ALTER TABLE locations ADD parent_location_id INTEGER; + +-- Create recursive view for resolving location hierarchy (ancestor-descendant pairs) +-- Used for circular reference detection and finding all descendants +CREATE VIEW locations_resolved +AS +WITH RECURSIVE location_hierarchy(location_id, ancestor_location_id, level) +AS ( + -- Base case: all locations map to themselves at level 0 + SELECT id, id, 0 + FROM locations + + UNION ALL + + -- Recursive case: find ancestors by following parent_location_id chain + SELECT lh.location_id, l.parent_location_id, lh.level + 1 + FROM location_hierarchy lh + JOIN locations l ON lh.ancestor_location_id = l.id + WHERE l.parent_location_id IS NOT NULL + LIMIT 100 -- Safety limit to prevent infinite loops +) +SELECT + location_id, + ancestor_location_id, + level +FROM location_hierarchy; + +-- Create view for location hierarchy display with computed path and depth +CREATE VIEW locations_hierarchy +AS +WITH RECURSIVE location_tree(id, name, description, parent_location_id, is_freezer, active, row_created_timestamp, path, depth) +AS ( + -- Base case: root locations (no parent) + SELECT id, name, description, parent_location_id, is_freezer, active, row_created_timestamp, name, 0 + FROM locations + WHERE parent_location_id IS NULL + + UNION ALL + + -- Recursive case: child locations + SELECT l.id, l.name, l.description, l.parent_location_id, l.is_freezer, l.active, l.row_created_timestamp, + lt.path || ' > ' || l.name, + lt.depth + 1 + FROM locations l + JOIN location_tree lt ON l.parent_location_id = lt.id + LIMIT 100 -- Safety limit +) +SELECT + id, + name, + description, + parent_location_id, + is_freezer, + active, + row_created_timestamp, + path AS location_path, + depth AS location_depth +FROM location_tree; + +-- Trigger to enforce NULL handling for empty parent_location_id (matching product pattern) +CREATE TRIGGER enforce_parent_location_id_null_when_empty_INS AFTER INSERT ON locations +BEGIN + UPDATE locations + SET parent_location_id = NULL + WHERE id = NEW.id + AND IFNULL(parent_location_id, '') = ''; +END; + +CREATE TRIGGER enforce_parent_location_id_null_when_empty_UPD AFTER UPDATE ON locations +BEGIN + UPDATE locations + SET parent_location_id = NULL + WHERE id = NEW.id + AND IFNULL(parent_location_id, '') = ''; +END; + +-- Trigger to prevent setting self as parent +CREATE TRIGGER prevent_self_parent_location_INS BEFORE INSERT ON locations +BEGIN + SELECT CASE WHEN(( + SELECT 1 + WHERE NEW.parent_location_id IS NOT NULL + AND NEW.parent_location_id = NEW.id + ) NOTNULL) THEN RAISE(ABORT, 'A location cannot be its own parent') END; +END; + +CREATE TRIGGER prevent_self_parent_location_UPD BEFORE UPDATE ON locations +BEGIN + SELECT CASE WHEN(( + SELECT 1 + WHERE NEW.parent_location_id IS NOT NULL + AND NEW.parent_location_id = NEW.id + ) NOTNULL) THEN RAISE(ABORT, 'A location cannot be its own parent') END; +END; + +-- Trigger to prevent circular references in location hierarchy +-- Note: Uses a subquery approach since we can't reference the view during INSERT +CREATE TRIGGER prevent_circular_location_hierarchy_UPD BEFORE UPDATE ON locations +WHEN NEW.parent_location_id IS NOT NULL +BEGIN + SELECT CASE WHEN(( + -- Check if the new parent is a descendant of this location + -- This would create a circular reference + WITH RECURSIVE descendants(id) AS ( + SELECT NEW.id + UNION ALL + SELECT l.id + FROM locations l + JOIN descendants d ON l.parent_location_id = d.id + WHERE l.id != NEW.id + LIMIT 100 + ) + SELECT 1 FROM descendants WHERE id = NEW.parent_location_id + ) NOTNULL) THEN RAISE(ABORT, 'Circular location hierarchy detected') END; +END; diff --git a/public/viewjs/locationform.js b/public/viewjs/locationform.js index 1c41feba..21df2151 100644 --- a/public/viewjs/locationform.js +++ b/public/viewjs/locationform.js @@ -1,4 +1,10 @@ -$('#save-location-button').on('click', function(e) +$('#parent_location_id').combobox({ + appendId: '_text_input', + bsVersion: '4', + clearIfNoMatch: true +}); + +$('#save-location-button').on('click', function(e) { e.preventDefault(); @@ -13,6 +19,7 @@ } var jsonData = $('#location-form').serializeJSON(); + jsonData.parent_location_id = jsonData.parent_location_id || null; Grocy.FrontendHelpers.BeginUiBusy("location-form"); if (Grocy.EditMode === 'create') diff --git a/views/locationform.blade.php b/views/locationform.blade.php index 26a149cc..29b07681 100644 --- a/views/locationform.blade.php +++ b/views/locationform.blade.php @@ -54,6 +54,37 @@ + @php require_frontend_packages(['bootstrap-combobox']); @endphp +