implemented inventur and changed warehousearticle/category
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
413
application/WarehouseStocktake/WarehouseStocktakeController.php
Normal file
413
application/WarehouseStocktake/WarehouseStocktakeController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
74
application/WarehouseStocktake/WarehouseStocktakeModel.php
Normal file
74
application/WarehouseStocktake/WarehouseStocktakeModel.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user