#481 - add locations hierarchy to stock overview reports

This commit is contained in:
Micha 2026-02-01 21:28:27 +01:00
parent ea9234eaaf
commit d5878dbb47
4 changed files with 97 additions and 6 deletions

View File

@ -92,8 +92,10 @@ class StockController extends BaseController
return $this->renderPage($response, 'locationcontentsheet', [
'products' => $this->getDatabase()->products()->where('active = 1')->orderBy('name', 'COLLATE NOCASE'),
'quantityunits' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'),
'locations' => $this->getDatabase()->locations()->orderBy('name', 'COLLATE NOCASE'),
'currentStockLocationContent' => $this->getStockService()->GetCurrentStockLocationContent(isset($request->getQueryParams()['include_out_of_stock']))
'locations' => $this->getDatabase()->locations_hierarchy()->orderBy('location_path', 'COLLATE NOCASE'),
'locationsResolved' => $this->getDatabase()->locations_resolved(),
'currentStockLocationContent' => $this->getStockService()->GetCurrentStockLocationContent(isset($request->getQueryParams()['include_out_of_stock'])),
'showLeafLocationsOnly' => isset($request->getQueryParams()['leaf_locations_only'])
]);
}

View File

@ -2476,3 +2476,9 @@ msgstr ""
msgid "List actions"
msgstr ""
msgid "Show only leaf locations"
msgstr ""
msgid "When enabled, only locations without sub-locations are shown. When disabled, parent locations show aggregated content from all sub-locations."
msgstr ""

View File

@ -31,3 +31,17 @@ if (GetUriParam("include_out_of_stock"))
{
$("#include-out-of-stock").prop("checked", false);
}
$(document).on("change", "#leaf-locations-only", function()
{
if (this.checked)
{
UpdateUriParam("leaf_locations_only", true);
}
else
{
RemoveUriParam("leaf_locations_only");
}
window.location.reload();
});

View File

@ -50,6 +50,20 @@
title="{{ $__t('Out of stock items will be shown at the products default location') }}"></i>
</label>
</div>
<div class="form-check custom-control custom-checkbox">
<input class="form-check-input custom-control-input"
type="checkbox"
id="leaf-locations-only"
@if($showLeafLocationsOnly) checked @endif>
<label class="form-check-label custom-control-label"
for="leaf-locations-only">
{{ $__t('Show only leaf locations') }}
<i class="fa-solid fa-question-circle text-muted"
data-toggle="tooltip"
data-trigger="hover click"
title="{{ $__t('When enabled, only locations without sub-locations are shown. When disabled, parent locations show aggregated content from all sub-locations.') }}"></i>
</label>
</div>
<div class="float-right">
<button class="btn btn-outline-dark d-md-none mt-2 order-1 order-md-3"
type="button"
@ -69,8 +83,64 @@
<hr class="my-2 d-print-none">
@foreach($locations as $location)
@if(FindAllObjectsInArrayByPropertyValue($currentStockLocationContent, 'location_id', $location->id) == null)
@php
// Convert iterators to arrays once for reuse
$locationsArray = iterator_to_array($locations);
$locationsResolvedArray = iterator_to_array($locationsResolved);
$stockContentArray = iterator_to_array($currentStockLocationContent);
@endphp
@foreach($locationsArray as $location)
@php
// Determine if this location has children
$hasChildren = count(array_filter($locationsArray, function($loc) use ($location) {
return $loc->parent_location_id == $location->id;
})) > 0;
// Flag to skip this location
$skipLocation = $showLeafLocationsOnly && $hasChildren;
$currentStockEntriesForLocation = [];
if (!$skipLocation) {
// Get stock entries for this location
if ($showLeafLocationsOnly) {
// Only show products directly at this location
$currentStockEntriesForLocation = array_filter($stockContentArray, function($entry) use ($location) {
return $entry->location_id == $location->id;
});
} else {
// Aggregate products from this location and all descendant locations
$descendantLocationIds = array_map(function($r) {
return $r->location_id;
}, array_filter($locationsResolvedArray, function($r) use ($location) {
return $r->ancestor_location_id == $location->id;
}));
$currentStockEntriesForLocation = array_filter($stockContentArray, function($entry) use ($descendantLocationIds) {
return in_array($entry->location_id, $descendantLocationIds);
});
// Aggregate amounts by product_id
$aggregatedEntries = [];
foreach ($currentStockEntriesForLocation as $entry) {
$productId = $entry->product_id;
if (!isset($aggregatedEntries[$productId])) {
$aggregatedEntries[$productId] = (object)[
'product_id' => $productId,
'location_id' => $location->id,
'amount' => 0,
'amount_opened' => 0
];
}
$aggregatedEntries[$productId]->amount += $entry->amount;
$aggregatedEntries[$productId]->amount_opened += $entry->amount_opened;
}
$currentStockEntriesForLocation = array_values($aggregatedEntries);
}
}
@endphp
@if($skipLocation || count($currentStockEntriesForLocation) == 0)
@continue
@endif
<div class="page">
@ -79,7 +149,7 @@
width="114"
height="30"
class="d-none d-print-flex mx-auto">
{{ $location->name }}
{{ $location->location_path }}
<a class="btn btn-outline-dark btn-sm responsive-button print-single-location-button d-print-none"
href="#">
{{ $__t('Print') . ' (' . $__t('this location') . ')' }}
@ -100,7 +170,6 @@
</tr>
</thead>
<tbody>
@php $currentStockEntriesForLocation = FindAllObjectsInArrayByPropertyValue($currentStockLocationContent, 'location_id', $location->id); @endphp
@foreach($currentStockEntriesForLocation as $currentStockEntry)
<tr>
<td class="fit-content">