implemented inventur and changed warehousearticle/category

This commit is contained in:
2025-12-15 23:47:16 +01:00
parent b43925d37e
commit 3000c9e2e7
18 changed files with 2711 additions and 13 deletions

View File

@@ -2,7 +2,7 @@
class WarehouseArticleController extends TTCrud {
protected string $headerTitle = 'Artikel';
protected $createText = 'Artikel erstellen';
protected $createText = false;
protected string $singleText = 'Artikel';
protected bool $reopenOnCreate = true;
@@ -12,7 +12,7 @@ class WarehouseArticleController extends TTCrud {
['key' => 'articleNumber', 'text' => 'Nr.', 'required' => true],
['key' => 'description', 'text' => 'Beschreibung', 'required' => true,'modal' => ['type' => 'textarea'], 'table' => ['sortable' => false]],
['key' => 'category_id', 'text' => 'Kategorie', 'required' => true, 'modal' => ['type' => 'select', 'items' => []], 'table' => ['filter' => 'select']],
['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => false],
['key' => 'unit', 'text' => 'Einheit', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]], 'table' => ['filter' => 'select', 'filterOptions' => [['value' => 'Stk.', 'text' => 'Stk.'], ['value' => 'Pau.', 'text' => 'Pau.'], ['value' => 'm.', 'text' => 'm.'], ['value' => 'Std.', 'text' => 'Std.'], ['value' => 'km', 'text' => 'km']]]],
['key' => 'revenueAccount', 'text' => 'Erlöskonto', 'required' => true,'modal' => ['type' => 'select', 'items' => [['value' => 0, 'text' => 'Dienstleistungen'], ['value' => 1, 'text' => 'Handelswaren']]], 'table' => false],
['key' => 'cheapestPurchasePrice', 'text' => 'Einkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
['key' => 'cheapestSellPrice', 'text' => 'Verkauf', 'modal' => false, 'table' => ['class' => 'text-center', 'suffix' => ' €']],
@@ -32,10 +32,13 @@ class WarehouseArticleController extends TTCrud {
protected array $autocompleteColumns = ['articleNumber', 'title', 'description'];
protected array $permissionCheck = ['WarehouseUser'];
protected array $additionalActions = [['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']];
protected array $additionalActions = [
['key' => 'printLabel','title' => 'Label drucken','class' => 'fas fa-print text-secondary'],
['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']
];
// @formatter:on
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true];
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true, 'HIDE_PAGE_TITLE' => true];
protected function prepareCrudConfig() {
$categories = array_map(fn($category) => ['value' => $category->id, 'text' => $category->name], WarehouseCategory::getAll());
@@ -131,6 +134,41 @@ class WarehouseArticleController extends TTCrud {
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
}
protected function getNextArticleNumberAction() {
$categoryId = intval($this->request->categoryId ?? 0);
if (!$categoryId) self::sendError("Kategorie nicht angegeben");
$category = WarehouseCategory::get($categoryId);
if (!$category) self::sendError("Kategorie nicht gefunden");
if (!$category->articleNumberPrefix) self::sendError("Kategorie hat keinen Artikelnummer-Prefix");
$prefix = $category->articleNumberPrefix;
$db = FronkDB::singleton();
// Get all existing article numbers with this prefix, sorted
$result = $db->query("SELECT CAST(articleNumber AS UNSIGNED) as num FROM WarehouseArticle WHERE articleNumber LIKE '{$prefix}%' ORDER BY num ASC");
$existingNumbers = [];
while ($row = $db->fetch_array($result)) {
$existingNumbers[] = intval($row['num']);
}
// Start from prefix * 10000 + 1 (e.g., 1800 -> 18000001)
$startNumber = intval($prefix) * 10000 + 1;
$nextNumber = $startNumber;
// Find first gap
foreach ($existingNumbers as $num) {
if ($num == $nextNumber) {
$nextNumber++;
} else if ($num > $nextNumber) {
// Found a gap
break;
}
}
self::returnJson(['success' => true, 'articleNumber' => str_pad($nextNumber, 8, '0', STR_PAD_LEFT)]);
}
protected function autocompleteAction() {
$textKey = property_exists($this->model, 'name') ? 'name' : 'title';
if (strlen($this->request->searchedID) > 0) {
@@ -163,4 +201,29 @@ class WarehouseArticleController extends TTCrud {
return ['value' => $item->id, 'text' => $item->$textKey];
}, $data));
}
protected function printLabelAction() {
$articleId = $this->request->id;
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::sendError("Artikel nicht gefunden", 404);
}
$pdf_vars = [
'articleId' => $article->id,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title
];
$pdf = new PdfForm("WarehouseArticle/LABEL", $pdf_vars);
$wkhtmltopdfArgs = "--page-height 25mm --page-width 50mm --margin-top 0 --margin-bottom 0 --margin-left 0 --margin-right 0 --disable-smart-shrinking --encoding utf-8 --dpi 96";
$filename = $pdf->render($wkhtmltopdfArgs);
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="label-' . $article->articleNumber . '.pdf"');
readfile($filename);
die();
}
}

View File

@@ -3,7 +3,7 @@ class WarehouseCategory extends TTCrudBaseModel {
public int $id;
public string $name;
public string $description;
public ?int $articleNumberPrefix;
public ?string $articleNumberPrefix;
public int $create;
public int $create_by;
public ?int $edit;

View File

@@ -9,7 +9,7 @@ class WarehouseCategoryController extends TTCrud {
protected array $columns = [
['key' => 'name', 'text' => 'Name', 'required' => true,],
['key' => 'description', 'text' => 'Beschreibung', 'required' => true],
['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => true],
['key' => 'articleNumberPrefix', 'text' => 'Artikelnummerprefix', 'required' => false, 'modal' => ['disabled' => true, 'placeholder' => 'Wird automatisch generiert']],
['key' => 'create', 'text' => 'Erstellt am', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
['key' => 'create_by', 'text' => 'Erstellt von', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center', 'priority' => 10]],
@@ -18,11 +18,45 @@ class WarehouseCategoryController extends TTCrud {
protected array $additionalActions = [['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary']];
protected function beforeCreate(): bool {
$this->postData['articleNumberPrefix'] = $this->getNextFreePrefix();
return true;
}
protected function beforeUpdate($postData): bool {
// Preserve existing prefix - don't allow changes
$existing = WarehouseCategory::get($postData['id']);
if ($existing) {
$this->postData['articleNumberPrefix'] = $existing->articleNumberPrefix;
}
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
private function getNextFreePrefix(): string {
$db = FronkDB::singleton();
$result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1");
$row = $db->fetch_array($result);
if ($row && $row['articleNumberPrefix']) {
$lastPrefix = intval($row['articleNumberPrefix']);
// Skip special ranges (9900+)
if ($lastPrefix >= 9900) {
// Find highest non-special prefix
$result = $db->query("SELECT articleNumberPrefix FROM WarehouseCategory WHERE articleNumberPrefix IS NOT NULL AND CAST(articleNumberPrefix AS UNSIGNED) < 9900 ORDER BY CAST(articleNumberPrefix AS UNSIGNED) DESC LIMIT 1");
$row = $db->fetch_array($result);
$lastPrefix = $row ? intval($row['articleNumberPrefix']) : 1800;
}
$nextPrefix = $lastPrefix + 100;
// Skip 9900+ range
if ($nextPrefix >= 9900) $nextPrefix = 9900;
} else {
$nextPrefix = 1900;
}
return str_pad($nextPrefix, 4, '0', STR_PAD_LEFT);
}
protected function getHistoryAction() {
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
}

View File

@@ -0,0 +1,413 @@
<?php
class WarehouseStocktakeController extends TTCrud {
protected string $headerTitle = 'Inventur';
protected string $createText = 'Inventur erstellen';
protected bool $reopenOnCreate = false;
protected array $columns = [
['key' => 'stocktakeNumber', 'text' => 'Inventur-Nr.', 'required' => false,
'modal' => false,
'table' => ['priority' => 10]],
['key' => 'title', 'text' => 'Titel', 'required' => true,
'modal' => ['type' => 'text'],
'table' => ['priority' => 9]],
['key' => 'warehouseLocationId', 'text' => 'Lagerort', 'required' => true,
'modal' => ['type' => 'select', 'items' => []],
'table' => ['priority' => 8, 'filter' => 'select']],
['key' => 'status', 'text' => 'Status', 'required' => false,
'modal' => false,
'table' => ['priority' => 7, 'filter' => 'iconSelect', 'filterOptions' => [
['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary'],
['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary'],
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success'],
['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger'],
]]],
['key' => 'progress', 'text' => 'Fortschritt', 'required' => false,
'modal' => false,
'table' => ['priority' => 6, 'sortable' => false, 'filter' => false]],
['key' => 'startedAt', 'text' => 'Gestartet', 'required' => false,
'modal' => false,
'table' => ['priority' => 5, 'filter' => false]],
['key' => 'description', 'text' => 'Beschreibung', 'required' => false,
'modal' => ['type' => 'textarea'],
'table' => false],
['key' => 'actions', 'text' => 'Aktionen', 'required' => false,
'modal' => false,
'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
];
protected array $additionalActions = [
['key' => 'startStocktake', 'title' => 'Inventur starten', 'class' => 'fas fa-play text-success'],
['key' => 'viewProgress', 'title' => 'Fortschritt anzeigen', 'class' => 'fas fa-chart-line text-primary'],
['key' => 'completeStocktake', 'title' => 'Inventur abschließen', 'class' => 'fas fa-check text-success'],
['key' => 'applyToStock', 'title' => 'Auf Lager anwenden', 'class' => 'fas fa-boxes text-warning'],
['key' => 'exportReport', 'title' => 'Excel Export', 'class' => 'fas fa-download text-secondary'],
['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-secondary'],
];
protected array $additionalJSVariables = [];
protected array $statusOptions = [
['value' => 'planned', 'text' => 'Geplant', 'icon' => 'fas fa-calendar text-secondary', 'color' => 'secondary'],
['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-primary', 'color' => 'primary'],
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-circle text-success', 'color' => 'success'],
['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-times-circle text-danger', 'color' => 'danger'],
];
protected array $permissionCheck = ['WarehouseUser'];
protected array $infoMessages = [
'create' => 'Inventur wurde erstellt',
'update' => 'Inventur wurde aktualisiert',
'delete' => 'Inventur wurde gelöscht',
'noChanges' => 'Keine Änderungen',
];
public function prepareCrudConfig() {
// Populate locations dropdown
$locations = array_map(function($location) {
return ['value' => $location->id, 'text' => $location->title];
}, WarehouseLocationModel::getAll());
foreach ($this->columns as &$col) {
if ($col['key'] === 'warehouseLocationId') {
$col['modal']['items'] = $locations;
$col['table']['filterOptions'] = $locations;
}
}
$this->additionalJSVariables['STATUS_ITEMS'] = $this->statusOptions;
}
protected function beforeCreate(): bool {
// Set default values
$this->postData['status'] = 'planned';
$this->postData['totalItems'] = 0;
$this->postData['totalScannedItems'] = 0;
return true;
}
protected function afterCreate($postData) {
// Generate stocktake number
$stocktake = WarehouseStocktakeModel::get($postData['id']);
if ($stocktake) {
$stocktakeNumber = WarehouseStocktakeModel::generateStocktakeNumber();
$db = FronkDB::singleton();
$db->query("UPDATE WarehouseStocktake SET stocktakeNumber = '{$stocktakeNumber}' WHERE id = {$stocktake->id}");
// Log creation
WarehouseStocktakeLogModel::log($stocktake->id, 'created', null, ['title' => $stocktake->title]);
}
}
protected function beforeUpdate($postData): bool {
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
protected function customRowsHandler($rows) {
return array_map(fn($row) => $this->formatRow((array)$row), $rows);
}
protected function formatRow($row) {
// Keep raw status for frontend conditional logic (don't modify 'status' - table needs raw value for filter)
$row['rawStatus'] = $row['status'];
// Don't modify warehouseLocationId - table uses items to display the text
// Don't modify status - table uses filterOptions to display
// Format progress (no filter on this column)
$row['progress'] = "<span class='badge bg-info'>{$row['totalScannedItems']} Artikel gescannt</span>";
// Format startedAt (no filter on this column)
if ($row['startedAt']) {
$row['startedAt'] = date('d.m.Y H:i', $row['startedAt']);
} else {
$row['startedAt'] = '-';
}
return $row;
}
/**
* Start a stocktake - changes status to in_progress
*/
protected function startStocktakeAction() {
$id = intval($this->postData['id'] ?? 0);
if (!$id) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$stocktake = WarehouseStocktakeModel::get($id);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
if ($stocktake->status !== 'planned') {
self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "Geplant" gestartet werden']);
return;
}
$db = FronkDB::singleton();
$db->query("UPDATE WarehouseStocktake SET
status = 'in_progress',
startedAt = " . time() . ",
startedBy = {$this->user->id}
WHERE id = {$id}");
WarehouseStocktakeLogModel::log($id, 'started', null, ['startedBy' => $this->user->name]);
self::returnJson(['success' => true, 'message' => 'Inventur wurde gestartet']);
}
/**
* Complete a stocktake - changes status to completed
*/
protected function completeStocktakeAction() {
$id = intval($this->postData['id'] ?? 0);
if (!$id) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$stocktake = WarehouseStocktakeModel::get($id);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
if ($stocktake->status !== 'in_progress') {
self::returnJson(['success' => false, 'message' => 'Inventur kann nur im Status "In Bearbeitung" abgeschlossen werden']);
return;
}
$db = FronkDB::singleton();
$db->query("UPDATE WarehouseStocktake SET
status = 'completed',
completedAt = " . time() . ",
completedBy = {$this->user->id}
WHERE id = {$id}");
WarehouseStocktakeLogModel::log($id, 'completed', null, ['completedBy' => $this->user->name]);
self::returnJson(['success' => true, 'message' => 'Inventur wurde abgeschlossen']);
}
/**
* Get progress data for live updates
*/
protected function getProgressAction() {
$id = intval($this->request->id);
if (!$id) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$stocktake = WarehouseStocktakeModel::get($id);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
// Get items via direct SQL to avoid any ORM issues
$db = FronkDB::singleton();
$result = $db->query("SELECT si.*, a.articleNumber, a.title as articleTitle, w.name as scannedByName
FROM WarehouseStocktakeItem si
LEFT JOIN WarehouseArticle a ON si.articleId = a.id
LEFT JOIN Worker w ON si.scannedBy = w.id
WHERE si.stocktakeId = {$id}
ORDER BY si.`create` DESC");
$formattedItems = [];
while ($row = $result->fetch_assoc()) {
$formattedItems[] = [
'id' => (int)$row['id'],
'articleId' => (int)$row['articleId'],
'articleNumber' => $row['articleNumber'] ?? '',
'articleTitle' => $row['articleTitle'] ?? 'Unbekannt',
'countedQuantity' => (float)$row['countedQuantity'],
'rack' => $row['rack'],
'shelf' => $row['shelf'],
'note' => $row['note'],
'scannedAt' => $row['scannedAt'] ? date('d.m.Y H:i:s', $row['scannedAt']) : null,
'scannedBy' => $row['scannedByName'],
];
}
$location = $stocktake->getLocation();
self::returnJson([
'success' => true,
'stocktake' => [
'id' => $stocktake->id,
'stocktakeNumber' => $stocktake->stocktakeNumber,
'title' => $stocktake->title,
'status' => $stocktake->status,
'locationName' => $location ? $location->title : 'Unbekannt',
'totalScannedItems' => $stocktake->totalScannedItems,
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
],
'items' => $formattedItems,
]);
}
/**
* Apply stocktake results to actual warehouse stock
*/
protected function applyToStockAction() {
$id = intval($this->postData['id'] ?? 0);
if (!$id) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$stocktake = WarehouseStocktakeModel::get($id);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
if ($stocktake->status !== 'completed') {
self::returnJson(['success' => false, 'message' => 'Inventur muss abgeschlossen sein, um die Bestände anzupassen']);
return;
}
$db = FronkDB::singleton();
$items = WarehouseStocktakeItemModel::getAll(['stocktakeId' => $id]);
$appliedCount = 0;
$createdCount = 0;
foreach ($items as $item) {
// Check if a WarehouseItem already exists for this article at this location
$existingItems = WarehouseItemModel::getAll([
'articleId' => $item->articleId,
'warehouseLocationId' => $stocktake->warehouseLocationId
]);
if (count($existingItems) > 0) {
// Update existing item
$existingItem = $existingItems[0];
$oldQuantity = $existingItem->quantity;
$db->query("UPDATE WarehouseItem SET
quantity = {$item->countedQuantity},
rack = " . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ",
shelf = " . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . "
WHERE id = {$existingItem->id}");
// Log history
(new WarehouseHistoryController)->create([
'id' => $existingItem->id,
'quantity' => $item->countedQuantity,
'rack' => $item->rack,
'shelf' => $item->shelf,
], 'WarehouseItem');
$appliedCount++;
} else {
// Create new WarehouseItem
$db->query("INSERT INTO WarehouseItem (articleId, warehouseLocationId, quantity, rack, shelf, createBy, `create`)
VALUES ({$item->articleId}, {$stocktake->warehouseLocationId}, {$item->countedQuantity},
" . ($item->rack ? "'{$db->escape($item->rack)}'" : "NULL") . ",
" . ($item->shelf ? "'{$db->escape($item->shelf)}'" : "NULL") . ",
{$this->user->id}, " . time() . ")");
$createdCount++;
}
}
WarehouseStocktakeLogModel::log($id, 'applied_to_stock', null, [
'appliedCount' => $appliedCount,
'createdCount' => $createdCount,
'appliedBy' => $this->user->name
]);
self::returnJson([
'success' => true,
'message' => "Bestände angepasst: {$appliedCount} aktualisiert, {$createdCount} neu erstellt"
]);
}
/**
* Export stocktake report to Excel
*/
protected function exportReportAction() {
$id = intval($this->request->id);
if (!$id) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$stocktake = WarehouseStocktakeModel::get($id);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
$items = WarehouseStocktakeItemModel::getAll(['stocktakeId' => $id]);
$rows = [];
foreach ($items as $item) {
$article = $item->getArticle();
$scannedBy = $item->getScannedByUser();
$rows[] = [
'Artikel-Nr.' => $article ? $article->articleNumber : '',
'Artikel' => $article ? $article->title : 'Unbekannt',
'Menge' => $item->countedQuantity,
'Regal' => $item->rack ?? '',
'Fach' => $item->shelf ?? '',
'Notiz' => $item->note ?? '',
'Gescannt am' => $item->scannedAt ? date('d.m.Y H:i', $item->scannedAt) : '',
'Gescannt von' => $scannedBy ? $scannedBy->name : '',
];
}
$filename = "Inventur_{$stocktake->stocktakeNumber}_" . date('Y-m-d') . ".csv";
$csv = Helper::arrayToCsv($rows);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
echo "\xEF\xBB\xBF"; // UTF-8 BOM
echo $csv;
exit;
}
/**
* Get history for a stocktake
*/
protected function getHistoryAction() {
$this->prepareCrudConfig();
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
}
/**
* Get logs for a stocktake
*/
protected function getLogsAction() {
$id = intval($this->request->id);
if (!$id) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$logs = WarehouseStocktakeLogModel::getLogsForStocktake($id);
$formattedLogs = [];
foreach ($logs as $log) {
$user = UserModel::get($log->userId);
$formattedLogs[] = [
'id' => $log->id,
'action' => $log->action,
'details' => $log->details ? json_decode($log->details, true) : null,
'userName' => $user ? $user->name : 'Unbekannt',
'create' => date('d.m.Y H:i:s', $log->create),
];
}
self::returnJson(['success' => true, 'logs' => $formattedLogs]);
}
}

View File

@@ -0,0 +1,74 @@
<?php
class WarehouseStocktakeModel extends TTCrudBaseModel {
public int $id;
public ?string $stocktakeNumber;
public string $title;
public ?string $description;
public int $warehouseLocationId;
public string $status;
public ?int $startedAt;
public ?int $completedAt;
public ?int $startedBy;
public ?int $completedBy;
public int $totalItems = 0;
public int $totalScannedItems = 0;
public ?string $notes;
public int $createBy;
public int $create;
/**
* Generate next stocktake number (ST-YYYY-NNNN)
*/
public static function generateStocktakeNumber(): string {
$year = date('Y');
$prefix = "IN{$year}-X";
$db = FronkDB::singleton();
$result = $db->query("SELECT stocktakeNumber FROM WarehouseStocktake
WHERE stocktakeNumber LIKE '{$prefix}%'
ORDER BY stocktakeNumber DESC LIMIT 1");
if ($row = $result->fetch_assoc()) {
$lastNumber = intval(substr($row['stocktakeNumber'], -6));
$nextNumber = $lastNumber + 1;
} else {
$nextNumber = 1;
}
return $prefix . str_pad((string)$nextNumber, 6, '0', STR_PAD_LEFT);
}
/**
* Get location object
*/
public function getLocation(): ?WarehouseLocationModel {
return WarehouseLocationModel::get($this->warehouseLocationId);
}
/**
* Get user who started the stocktake
*/
public function getStartedByUser(): ?UserModel {
if (!$this->startedBy) return null;
return UserModel::get($this->startedBy);
}
/**
* Get items for this stocktake
*/
public function getItems(): array {
return WarehouseStocktakeItemModel::getAll(['stocktakeId' => $this->id]);
}
/**
* Update progress counters
*/
public function updateProgress(): void {
$items = $this->getItems();
$this->totalScannedItems = count($items);
$db = FronkDB::singleton();
$db->query("UPDATE WarehouseStocktake SET totalScannedItems = {$this->totalScannedItems} WHERE id = {$this->id}");
}
}

View File

@@ -0,0 +1,194 @@
<?php
class WarehouseStocktakeItemController extends TTCrud {
protected string $headerTitle = 'Inventur-Artikel';
protected string $createText = 'Artikel hinzufügen';
protected array $columns = [
['key' => 'articleId', 'text' => 'Artikel', 'required' => true,
'modal' => ['type' => 'autocomplete', 'apiUrl' => '/WarehouseArticle/autocomplete'],
'table' => ['priority' => 10]],
['key' => 'countedQuantity', 'text' => 'Menge', 'required' => true,
'modal' => ['type' => 'number'],
'table' => ['priority' => 9]],
['key' => 'rack', 'text' => 'Regal', 'required' => false,
'modal' => ['type' => 'text'],
'table' => ['priority' => 8]],
['key' => 'shelf', 'text' => 'Fach', 'required' => false,
'modal' => ['type' => 'text'],
'table' => ['priority' => 7]],
['key' => 'note', 'text' => 'Notiz', 'required' => false,
'modal' => ['type' => 'textarea'],
'table' => ['priority' => 6]],
['key' => 'scannedAt', 'text' => 'Gescannt am', 'required' => false,
'modal' => false,
'table' => ['priority' => 5]],
['key' => 'actions', 'text' => 'Aktionen', 'required' => false,
'modal' => false,
'table' => ['filter' => false, 'sortable' => false]],
];
protected array $permissionCheck = ['WarehouseUser'];
protected function formatRow($row) {
// Format article
if ($row['articleId']) {
$article = WarehouseArticleModel::get($row['articleId']);
$row['articleId'] = $article ? "[{$article->articleNumber}] {$article->title}" : 'Unbekannt';
}
// Format scannedAt
if ($row['scannedAt']) {
$row['scannedAt'] = date('d.m.Y H:i', $row['scannedAt']);
} else {
$row['scannedAt'] = '-';
}
return $row;
}
/**
* Add item via scan (used by PWA)
*/
protected function scanItemAction() {
$stocktakeId = intval($this->request->stocktakeId);
$articleId = intval($this->request->articleId);
$quantity = floatval($this->request->quantity);
$rack = $this->request->rack ?? null;
$shelf = $this->request->shelf ?? null;
$note = $this->request->note ?? null;
if (!$stocktakeId || !$articleId) {
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
return;
}
// Verify stocktake exists and is in progress
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
if ($stocktake->status !== 'in_progress') {
self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']);
return;
}
// Verify article exists
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
// Check if this article was already scanned in this stocktake
$existing = WarehouseStocktakeItemModel::getFirst([
'stocktakeId' => $stocktakeId,
'articleId' => $articleId
]);
$db = FronkDB::singleton();
if ($existing) {
// Update existing entry - add to quantity
$newQuantity = $existing->countedQuantity + $quantity;
$db->query("UPDATE WarehouseStocktakeItem SET
countedQuantity = {$newQuantity},
rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ",
shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ",
scannedAt = " . time() . ",
scannedBy = {$this->me->id}
WHERE id = {$existing->id}");
$itemId = $existing->id;
$message = "Artikel aktualisiert: {$article->title} (Neue Menge: {$newQuantity})";
} else {
// Create new entry
$db->query("INSERT INTO WarehouseStocktakeItem
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
" . time() . ", {$this->me->id}, {$this->me->id}, " . time() . ")");
$itemId = $db->insert_id;
$message = "Artikel hinzugefügt: {$article->title} (Menge: {$quantity})";
}
// Update stocktake progress
$stocktake->updateProgress();
// Log the scan
WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'quantity' => $quantity,
'rack' => $rack,
'shelf' => $shelf,
]);
self::returnJson([
'success' => true,
'message' => $message,
'item' => [
'id' => $itemId,
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'countedQuantity' => $existing ? ($existing->countedQuantity + $quantity) : $quantity,
'rack' => $rack,
'shelf' => $shelf,
],
'totalScanned' => $stocktake->totalScannedItems + 1,
]);
}
/**
* Get article info by QR code or article number
*/
protected function getArticleByCodeAction() {
$code = $this->request->code;
if (!$code) {
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
return;
}
// Try to parse QR code format: WH:articleId:articleNumber
$articleId = null;
if (preg_match('/^WH:(\d+):/', $code, $matches)) {
$articleId = intval($matches[1]);
} else {
// Try to find by article number
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
if ($article) {
$articleId = $article->id;
}
}
if (!$articleId) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
self::returnJson([
'success' => true,
'article' => [
'id' => $article->id,
'articleNumber' => $article->articleNumber,
'title' => $article->title,
'description' => $article->description ?? '',
'unit' => $article->unit ?? 'Stk.',
]
]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
class WarehouseStocktakeItemModel extends TTCrudBaseModel {
public int $id;
public int $stocktakeId;
public int $articleId;
public ?int $warehouseItemId;
public float $countedQuantity;
public ?string $rack;
public ?string $shelf;
public ?string $note;
public ?int $scannedAt;
public ?int $scannedBy;
public int $createBy;
public int $create;
/**
* Get the article object
*/
public function getArticle(): ?WarehouseArticleModel {
return WarehouseArticleModel::get($this->articleId);
}
/**
* Get the stocktake object
*/
public function getStocktake(): ?WarehouseStocktakeModel {
return WarehouseStocktakeModel::get($this->stocktakeId);
}
/**
* Get user who scanned this item
*/
public function getScannedByUser(): ?UserModel {
if (!$this->scannedBy) return null;
return UserModel::get($this->scannedBy);
}
}

View File

@@ -0,0 +1,43 @@
<?php
class WarehouseStocktakeLogModel extends TTCrudBaseModel {
public int $id;
public int $stocktakeId;
public ?int $stocktakeItemId;
public string $action;
public ?string $details;
public int $userId;
public int $create;
/**
* Create a log entry
*/
public static function log(int $stocktakeId, string $action, ?int $stocktakeItemId = null, ?array $details = null, ?int $userId = null): self {
$me = mfValuecache::singleton()->get("me");
$logUserId = $userId ?? ($me ? $me->id : 0);
$log = new self();
$log->stocktakeId = $stocktakeId;
$log->stocktakeItemId = $stocktakeItemId;
$log->action = $action;
$log->details = $details ? json_encode($details) : null;
$log->userId = $logUserId;
$log->create = time();
$db = FronkDB::singleton();
$db->query("INSERT INTO WarehouseStocktakeLog (stocktakeId, stocktakeItemId, action, details, userId, `create`)
VALUES ({$log->stocktakeId}, " . ($log->stocktakeItemId ? $log->stocktakeItemId : "NULL") . ",
'{$db->escape($log->action)}', " . ($log->details ? "'{$db->escape($log->details)}'" : "NULL") . ",
{$log->userId}, {$log->create})");
$log->id = $db->insert_id;
return $log;
}
/**
* Get logs for a stocktake
*/
public static function getLogsForStocktake(int $stocktakeId): array {
return self::getAll(['stocktakeId' => $stocktakeId], 0, 0, ['create' => 'DESC']);
}
}

View File

@@ -0,0 +1,374 @@
<?php
class WarehouseStocktakePWAController extends mfBaseController {
protected $user;
protected function init() {
$this->needlogin = true;
$me = mfValuecache::singleton()->get("me");
if (!$me) {
$me = new User();
$me->loadMe();
mfValuecache::singleton()->set("me", $me);
}
$this->me = $me;
$this->user = $me;
$this->layout()->set("me", $me);
// Check permission
if (!$me->can('WarehouseUser')) {
$this->redirect("Dashboard");
}
}
/**
* Main PWA View
*/
public function indexAction() {
$this->layout()->setTemplate("VueViews/WarehouseStocktakePWA");
$this->layout()->set("JSGlobals", [
'BASE_PATH' => '/WarehouseStocktakePWA',
'USER_ID' => $this->user->id,
'USER_NAME' => $this->user->name,
]);
}
/**
* Logout
*/
protected function logoutAction() {
mfLoginController::staticLogout();
$this->redirect('/WarehouseStocktakePWA');
}
/**
* Get active stocktakes that user can participate in
*/
protected function getActiveStocktakesAction() {
$stocktakes = WarehouseStocktakeModel::getAll(['status' => 'in_progress']);
$result = [];
foreach ($stocktakes as $stocktake) {
$location = $stocktake->getLocation();
$result[] = [
'id' => $stocktake->id,
'stocktakeNumber' => $stocktake->stocktakeNumber,
'title' => $stocktake->title,
'locationName' => $location ? $location->title : 'Unbekannt',
'totalScannedItems' => $stocktake->totalScannedItems,
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
];
}
self::returnJson(['success' => true, 'stocktakes' => $result]);
}
/**
* Get stocktake details
*/
protected function getStocktakeAction() {
$id = intval($this->request->id);
if (!$id) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$stocktake = WarehouseStocktakeModel::get($id);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
$location = $stocktake->getLocation();
self::returnJson([
'success' => true,
'stocktake' => [
'id' => $stocktake->id,
'stocktakeNumber' => $stocktake->stocktakeNumber,
'title' => $stocktake->title,
'status' => $stocktake->status,
'locationId' => $stocktake->warehouseLocationId,
'locationName' => $location ? $location->title : 'Unbekannt',
'totalScannedItems' => $stocktake->totalScannedItems,
'startedAt' => $stocktake->startedAt ? date('d.m.Y H:i', $stocktake->startedAt) : null,
]
]);
}
/**
* Get article by QR code or article number
*/
protected function getArticleAction() {
$code = $this->request->code;
if (!$code) {
self::returnJson(['success' => false, 'message' => 'Kein Code angegeben']);
return;
}
$articleId = null;
// Try to parse QR code format: WH:articleId:articleNumber
if (preg_match('/^WH:(\d+):/', $code, $matches)) {
$articleId = intval($matches[1]);
} else {
// Try to find by article number
$article = WarehouseArticleModel::getFirst(['articleNumber' => $code]);
if ($article) {
$articleId = $article->id;
}
}
if (!$articleId) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
// Get category name
$category = WarehouseCategoryModel::get($article->category_id);
self::returnJson([
'success' => true,
'article' => [
'id' => $article->id,
'articleNumber' => $article->articleNumber,
'title' => $article->title,
'description' => $article->description ?? '',
'unit' => $article->unit ?? 'Stk.',
'categoryName' => $category ? $category->name : '',
]
]);
}
/**
* Search articles by text
*/
protected function searchArticlesAction() {
$query = $this->request->query;
if (!$query || strlen($query) < 2) {
self::returnJson(['success' => true, 'articles' => []]);
return;
}
$db = FronkDB::singleton();
$escapedQuery = $db->escape($query);
$result = $db->query("SELECT id, articleNumber, title, unit
FROM WarehouseArticle
WHERE (articleNumber LIKE '%{$escapedQuery}%' OR title LIKE '%{$escapedQuery}%')
AND (isEndOfLife IS NULL OR isEndOfLife = 0)
LIMIT 20");
$articles = [];
while ($row = $result->fetch_assoc()) {
$articles[] = [
'id' => intval($row['id']),
'articleNumber' => $row['articleNumber'],
'title' => $row['title'],
'unit' => $row['unit'] ?? 'Stk.',
];
}
self::returnJson(['success' => true, 'articles' => $articles]);
}
/**
* Submit a scanned item
*/
protected function submitScanAction() {
$postData = json_decode(file_get_contents('php://input'), true) ?? [];
$stocktakeId = intval($postData['stocktakeId'] ?? 0);
$articleId = intval($postData['articleId'] ?? 0);
$quantity = floatval($postData['quantity'] ?? 0);
$rack = $postData['rack'] ?? null;
$shelf = $postData['shelf'] ?? null;
$note = $postData['note'] ?? null;
if (!$stocktakeId || !$articleId) {
self::returnJson(['success' => false, 'message' => 'Fehlende Parameter']);
return;
}
if ($quantity <= 0) {
self::returnJson(['success' => false, 'message' => 'Menge muss größer als 0 sein']);
return;
}
// Verify stocktake exists and is in progress
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
if ($stocktake->status !== 'in_progress') {
self::returnJson(['success' => false, 'message' => 'Inventur ist nicht aktiv']);
return;
}
// Verify article exists
$article = WarehouseArticleModel::get($articleId);
if (!$article) {
self::returnJson(['success' => false, 'message' => 'Artikel nicht gefunden']);
return;
}
// Check if this article was already scanned in this stocktake
$existing = WarehouseStocktakeItemModel::getFirst([
'stocktakeId' => $stocktakeId,
'articleId' => $articleId
]);
$db = FronkDB::singleton();
if ($existing) {
// Update existing entry - add to quantity
$newQuantity = $existing->countedQuantity + $quantity;
$db->query("UPDATE WarehouseStocktakeItem SET
countedQuantity = {$newQuantity},
rack = " . ($rack ? "'{$db->escape($rack)}'" : "rack") . ",
shelf = " . ($shelf ? "'{$db->escape($shelf)}'" : "shelf") . ",
scannedAt = " . time() . ",
scannedBy = {$this->user->id}
WHERE id = {$existing->id}");
$itemId = $existing->id;
$finalQuantity = $newQuantity;
$isUpdate = true;
} else {
// Create new entry
$db->query("INSERT INTO WarehouseStocktakeItem
(stocktakeId, articleId, countedQuantity, rack, shelf, note, scannedAt, scannedBy, createBy, `create`)
VALUES ({$stocktakeId}, {$articleId}, {$quantity},
" . ($rack ? "'{$db->escape($rack)}'" : "NULL") . ",
" . ($shelf ? "'{$db->escape($shelf)}'" : "NULL") . ",
" . ($note ? "'{$db->escape($note)}'" : "NULL") . ",
" . time() . ", {$this->user->id}, {$this->user->id}, " . time() . ")");
$itemId = $db->insert_id;
$finalQuantity = $quantity;
$isUpdate = false;
}
// Update stocktake progress
$stocktake->updateProgress();
// Log the scan
WarehouseStocktakeLogModel::log($stocktakeId, 'scanned', $itemId, [
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'quantity' => $quantity,
'totalQuantity' => $finalQuantity,
'rack' => $rack,
'shelf' => $shelf,
'isUpdate' => $isUpdate,
]);
self::returnJson([
'success' => true,
'message' => $isUpdate
? "Menge für '{$article->title}' erhöht auf {$finalQuantity}"
: "'{$article->title}' hinzugefügt ({$quantity} {$article->unit})",
'item' => [
'id' => $itemId,
'articleId' => $articleId,
'articleNumber' => $article->articleNumber,
'articleTitle' => $article->title,
'countedQuantity' => $finalQuantity,
'unit' => $article->unit ?? 'Stk.',
'rack' => $rack,
'shelf' => $shelf,
'isUpdate' => $isUpdate,
]
]);
}
/**
* Get recent scans for current user in a stocktake
*/
protected function getMyScansAction() {
$stocktakeId = intval($this->request->stocktakeId);
if (!$stocktakeId) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$db = FronkDB::singleton();
$result = $db->query("SELECT si.*, wa.articleNumber, wa.title as articleTitle, wa.unit
FROM WarehouseStocktakeItem si
JOIN WarehouseArticle wa ON wa.id = si.articleId
WHERE si.stocktakeId = {$stocktakeId}
AND si.scannedBy = {$this->user->id}
ORDER BY si.scannedAt DESC
LIMIT 50");
$items = [];
while ($row = $result->fetch_assoc()) {
$items[] = [
'id' => intval($row['id']),
'articleId' => intval($row['articleId']),
'articleNumber' => $row['articleNumber'],
'articleTitle' => $row['articleTitle'],
'countedQuantity' => floatval($row['countedQuantity']),
'unit' => $row['unit'] ?? 'Stk.',
'rack' => $row['rack'],
'shelf' => $row['shelf'],
'scannedAt' => $row['scannedAt'] ? date('H:i', $row['scannedAt']) : null,
];
}
self::returnJson(['success' => true, 'items' => $items]);
}
/**
* Get progress stats
*/
protected function getProgressAction() {
$stocktakeId = intval($this->request->stocktakeId);
if (!$stocktakeId) {
self::returnJson(['success' => false, 'message' => 'Keine Inventur-ID angegeben']);
return;
}
$stocktake = WarehouseStocktakeModel::get($stocktakeId);
if (!$stocktake) {
self::returnJson(['success' => false, 'message' => 'Inventur nicht gefunden']);
return;
}
$db = FronkDB::singleton();
// Total scanned items
$totalResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId}");
$totalRow = $totalResult->fetch_assoc();
$totalScanned = intval($totalRow['count']);
// My scanned items
$myResult = $db->query("SELECT COUNT(*) as count FROM WarehouseStocktakeItem WHERE stocktakeId = {$stocktakeId} AND scannedBy = {$this->user->id}");
$myRow = $myResult->fetch_assoc();
$myScanned = intval($myRow['count']);
self::returnJson([
'success' => true,
'progress' => [
'totalScanned' => $totalScanned,
'myScanned' => $myScanned,
'status' => $stocktake->status,
]
]);
}
}