#481 - first attempt of implementing sub-locations

This commit is contained in:
Micha 2026-02-01 20:02:30 +01:00
parent 471f21e992
commit 8ac2cb2ef5
6 changed files with 197 additions and 5 deletions

View File

@ -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', [

View File

@ -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
}

116
migrations/0255.sql Normal file
View File

@ -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;

View File

@ -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')

View File

@ -54,6 +54,37 @@
</div>
</div>
@php require_frontend_packages(['bootstrap-combobox']); @endphp
<div class="form-group">
<label for="parent_location_id">{{ $__t('Parent location') }}
&nbsp;<i class="fa-solid fa-question-circle text-muted"
data-toggle="tooltip"
data-trigger="hover click"
title="{{ $__t('Select a parent location to create a hierarchy (e.g., Building > Floor > Room)') }}"></i>
</label>
<select class="form-control combobox"
id="parent_location_id"
name="parent_location_id">
<option value=""></option>
@foreach($locationsHierarchy as $loc)
@php
$excludeFromPicker = false;
if($mode == 'edit') {
if($loc->id == $location->id) {
$excludeFromPicker = true;
}
if(isset($descendantLocationIds) && in_array($loc->id, $descendantLocationIds)) {
$excludeFromPicker = true;
}
}
@endphp
@if(!$excludeFromPicker)
<option @if($mode=='edit' && $loc->id == $location->parent_location_id) selected="selected" @endif value="{{ $loc->id }}">{{ str_repeat('-- ', $loc->location_depth) }}{{ $loc->name }}</option>
@endif
@endforeach
</select>
</div>
<div class="form-group">
<label for="description">{{ $__t('Description') }}</label>
<textarea class="form-control"

View File

@ -89,6 +89,7 @@
href="#"><i class="fa-solid fa-eye"></i></a>
</th>
<th>{{ $__t('Name') }}</th>
<th class="d-none">{{ $__t('Parent location') }}</th>
<th>{{ $__t('Description') }}</th>
@include('components.userfields_thead', array(
@ -117,8 +118,17 @@
</a>
</td>
<td>
@if($location->location_depth > 0)
<span class="text-muted">{{ str_repeat('&mdash; ', $location->location_depth) }}</span>
@endif
{{ $location->name }}
</td>
<td class="d-none">
@if($location->parent_location_id)
@php $parentLocation = FindObjectInArrayByPropertyValue($locations, 'id', $location->parent_location_id); @endphp
@if($parentLocation) {{ $parentLocation->name }} @endif
@endif
</td>
<td>
{{ $location->description }}
</td>