mirror of
https://github.com/grocy/grocy.git
synced 2026-04-02 02:49:26 +02: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', [
|
return $this->renderPage($response, 'locationform', [
|
||||||
'mode' => 'create',
|
'mode' => 'create',
|
||||||
|
'locationsHierarchy' => $this->getDatabase()->locations_hierarchy()->where('active = 1')->orderBy('location_path', 'COLLATE NOCASE'),
|
||||||
'userfields' => $this->getUserfieldsService()->GetFields('locations')
|
'userfields' => $this->getUserfieldsService()->GetFields('locations')
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
else
|
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', [
|
return $this->renderPage($response, 'locationform', [
|
||||||
'location' => $this->getDatabase()->locations($args['locationId']),
|
'location' => $this->getDatabase()->locations($args['locationId']),
|
||||||
'mode' => 'edit',
|
'mode' => 'edit',
|
||||||
|
'locationsHierarchy' => $this->getDatabase()->locations_hierarchy()->where('active = 1')->orderBy('location_path', 'COLLATE NOCASE'),
|
||||||
|
'descendantLocationIds' => $descendantIds,
|
||||||
'userfields' => $this->getUserfieldsService()->GetFields('locations')
|
'userfields' => $this->getUserfieldsService()->GetFields('locations')
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -120,11 +133,11 @@ class StockController extends BaseController
|
||||||
{
|
{
|
||||||
if (isset($request->getQueryParams()['include_disabled']))
|
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
|
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', [
|
return $this->renderPage($response, 'locations', [
|
||||||
|
|
|
||||||
|
|
@ -4660,6 +4660,18 @@
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"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": {
|
"row_created_timestamp": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
|
|
@ -4671,8 +4683,11 @@
|
||||||
},
|
},
|
||||||
"example": {
|
"example": {
|
||||||
"id": "2",
|
"id": "2",
|
||||||
"name": "0",
|
"name": "Fridge",
|
||||||
"description": null,
|
"description": "The main refrigerator",
|
||||||
|
"parent_location_id": 1,
|
||||||
|
"is_freezer": 0,
|
||||||
|
"active": 1,
|
||||||
"row_created_timestamp": "2019-05-02 20:12:25",
|
"row_created_timestamp": "2019-05-02 20:12:25",
|
||||||
"userfields": null
|
"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();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|
@ -13,6 +19,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
var jsonData = $('#location-form').serializeJSON();
|
var jsonData = $('#location-form').serializeJSON();
|
||||||
|
jsonData.parent_location_id = jsonData.parent_location_id || null;
|
||||||
Grocy.FrontendHelpers.BeginUiBusy("location-form");
|
Grocy.FrontendHelpers.BeginUiBusy("location-form");
|
||||||
|
|
||||||
if (Grocy.EditMode === 'create')
|
if (Grocy.EditMode === 'create')
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,37 @@
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label for="description">{{ $__t('Description') }}</label>
|
<label for="description">{{ $__t('Description') }}</label>
|
||||||
<textarea class="form-control"
|
<textarea class="form-control"
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@
|
||||||
href="#"><i class="fa-solid fa-eye"></i></a>
|
href="#"><i class="fa-solid fa-eye"></i></a>
|
||||||
</th>
|
</th>
|
||||||
<th>{{ $__t('Name') }}</th>
|
<th>{{ $__t('Name') }}</th>
|
||||||
|
<th class="d-none">{{ $__t('Parent location') }}</th>
|
||||||
<th>{{ $__t('Description') }}</th>
|
<th>{{ $__t('Description') }}</th>
|
||||||
|
|
||||||
@include('components.userfields_thead', array(
|
@include('components.userfields_thead', array(
|
||||||
|
|
@ -117,8 +118,17 @@
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
@if($location->location_depth > 0)
|
||||||
|
<span class="text-muted">{{ str_repeat('— ', $location->location_depth) }}</span>
|
||||||
|
@endif
|
||||||
{{ $location->name }}
|
{{ $location->name }}
|
||||||
</td>
|
</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>
|
<td>
|
||||||
{{ $location->description }}
|
{{ $location->description }}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user