mirror of
https://github.com/grocy/grocy.git
synced 2026-03-28 07:39:25 +01:00
#481 - first attempt of implementing sub-locations
This commit is contained in:
parent
471f21e992
commit
8ac2cb2ef5
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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
116
migrations/0255.sql
Normal 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;
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -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('— ', $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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user