diff --git a/.gitignore b/.gitignore index b9e543e..bea9510 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ test-results/ # Release artifacts artifacts/ + +# agent +plan.md +review.md \ No newline at end of file diff --git a/appinfo/info.xml b/appinfo/info.xml index 98c1e34..31d5821 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ Mitgliederverwaltung Mitgliederverwaltung für Pfadfindervereine - 0.3.1 + 0.3.2 agpl shahondin1624 Mitgliederverwaltung diff --git a/appinfo/routes.php b/appinfo/routes.php index 9a2d301..574075b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -188,5 +188,32 @@ return [ ['name' => 'file#ensureFolder', 'url' => '/api/v1/members/{memberId}/files/ensure-folder', 'verb' => 'POST'], ['name' => 'file#lagerFiles', 'url' => '/api/v1/lager/{lagerId}/files/browse', 'verb' => 'GET'], ['name' => 'file#ensureLagerFolder', 'url' => '/api/v1/lager/{lagerId}/files/ensure-folder', 'verb' => 'POST'], + + // ── Inventory Tracking ────────────────────────────────────── + ['name' => 'inventory#listCategories', 'url' => '/api/v1/inventory/categories', 'verb' => 'GET'], + ['name' => 'inventory#createCategory', 'url' => '/api/v1/inventory/categories', 'verb' => 'POST'], + ['name' => 'inventory#updateCategory', 'url' => '/api/v1/inventory/categories/{id}', 'verb' => 'PUT'], + ['name' => 'inventory#deleteCategory', 'url' => '/api/v1/inventory/categories/{id}', 'verb' => 'DELETE'], + ['name' => 'inventory#listGeneral', 'url' => '/api/v1/inventory/general', 'verb' => 'GET'], + ['name' => 'inventory#showGeneral', 'url' => '/api/v1/inventory/general/{id}', 'verb' => 'GET'], + ['name' => 'inventory#createGeneral', 'url' => '/api/v1/inventory/general', 'verb' => 'POST'], + ['name' => 'inventory#updateGeneral', 'url' => '/api/v1/inventory/general/{id}', 'verb' => 'PUT'], + ['name' => 'inventory#deleteGeneral', 'url' => '/api/v1/inventory/general/{id}', 'verb' => 'DELETE'], + ['name' => 'inventory#listStock', 'url' => '/api/v1/inventory/stock', 'verb' => 'GET'], + ['name' => 'inventory#showStock', 'url' => '/api/v1/inventory/stock/{id}', 'verb' => 'GET'], + ['name' => 'inventory#createStock', 'url' => '/api/v1/inventory/stock', 'verb' => 'POST'], + ['name' => 'inventory#updateStock', 'url' => '/api/v1/inventory/stock/{id}', 'verb' => 'PUT'], + ['name' => 'inventory#deleteStock', 'url' => '/api/v1/inventory/stock/{id}', 'verb' => 'DELETE'], + ['name' => 'inventory#createVariant', 'url' => '/api/v1/inventory/variants', 'verb' => 'POST'], + ['name' => 'inventory#updateVariant', 'url' => '/api/v1/inventory/variants/{id}', 'verb' => 'PUT'], + ['name' => 'inventory#deleteVariant', 'url' => '/api/v1/inventory/variants/{id}', 'verb' => 'DELETE'], + ['name' => 'inventory#listSales', 'url' => '/api/v1/inventory/sales', 'verb' => 'GET'], + ['name' => 'inventory#showSale', 'url' => '/api/v1/inventory/sales/{id}', 'verb' => 'GET'], + ['name' => 'inventory#createSale', 'url' => '/api/v1/inventory/sales', 'verb' => 'POST'], + ['name' => 'inventory#deleteSale', 'url' => '/api/v1/inventory/sales/{id}', 'verb' => 'DELETE'], + + // ── Inventory Reports ───────────────────────────────────── + ['name' => 'inventoryReport#condition', 'url' => '/api/v1/inventory/reports/condition', 'verb' => 'GET'], + ['name' => 'inventoryReport#sales', 'url' => '/api/v1/inventory/reports/sales', 'verb' => 'GET'], ], ]; diff --git a/docker-compose.yml b/docker-compose.yml index c9a94b9..59ac30e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,8 @@ services: env_file: .env environment: MYSQL_HOST: db - NEXTCLOUD_TRUSTED_DOMAINS: "localhost" + NEXTCLOUD_TRUSTED_DOMAINS: "localhost,192.168.2.35" + OVERWRITEHOST: "192.168.2.35" volumes: - nc_data:/var/www/html - ./:/app-src:ro diff --git a/lib/Controller/InventoryController.php b/lib/Controller/InventoryController.php new file mode 100644 index 0000000..cf141fa --- /dev/null +++ b/lib/Controller/InventoryController.php @@ -0,0 +1,510 @@ +inventoryService = $inventoryService; + $this->saleService = $saleService; + $this->userSession = $userSession; + $this->logger = $logger; + } + + // ── Categories ────────────────────────────────────────────────── + + /** + * List all categories. + * + * GET /api/v1/inventory/categories + */ + #[NoCSRFRequired] + public function listCategories(): JSONResponse { + try { + $categories = $this->inventoryService->getCategories(); + return new JSONResponse(['data' => array_map(fn($c) => $c->jsonSerialize(), $categories)]); + } catch (\Exception $e) { + $this->logger->error('Failed to list inventory categories', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Create a category. + * + * POST /api/v1/inventory/categories + */ + public function createCategory(): JSONResponse { + try { + $data = $this->getRequestData(); + if (empty($data['name'])) { + return new JSONResponse(['error' => 'Name ist erforderlich'], Http::STATUS_BAD_REQUEST); + } + $category = $this->inventoryService->createCategory($data); + return new JSONResponse($category->jsonSerialize(), Http::STATUS_CREATED); + } catch (\Exception $e) { + $this->logger->error('Failed to create inventory category', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Update a category. + * + * PUT /api/v1/inventory/categories/{id} + */ + public function updateCategory(int $id): JSONResponse { + try { + $data = $this->getRequestData(); + $category = $this->inventoryService->updateCategory($id, $data); + return new JSONResponse($category->jsonSerialize()); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Kategorie nicht gefunden'], Http::STATUS_NOT_FOUND); + } catch (\Exception $e) { + $this->logger->error('Failed to update inventory category', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Delete a category. + * + * DELETE /api/v1/inventory/categories/{id} + */ + public function deleteCategory(int $id): JSONResponse { + try { + $this->inventoryService->deleteCategory($id); + return new JSONResponse(['status' => 'deleted']); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Kategorie nicht gefunden'], Http::STATUS_NOT_FOUND); + } catch (\Exception $e) { + $this->logger->error('Failed to delete inventory category', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + // ── General Material ──────────────────────────────────────────── + + /** + * List all general material items. + * + * GET /api/v1/inventory/general + */ + #[NoCSRFRequired] + public function listGeneral(): JSONResponse { + try { + $items = $this->inventoryService->getGeneralMaterial(); + $result = []; + foreach ($items as $item) { + $data = $item->jsonSerialize(); + $data['categories'] = $this->inventoryService->getCategoryNamesForItem($item->getId()); + $result[] = $data; + } + return new JSONResponse(['data' => $result]); + } catch (\Exception $e) { + $this->logger->error('Failed to list general material', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Get a single general material item. + * + * GET /api/v1/inventory/general/{id} + */ + #[NoCSRFRequired] + public function showGeneral(int $id): JSONResponse { + try { + $item = $this->inventoryService->getGeneralMaterialById($id); + $data = $item->jsonSerialize(); + $data['categories'] = $this->inventoryService->getCategoryNamesForItem($id); + return new JSONResponse($data); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Material nicht gefunden'], Http::STATUS_NOT_FOUND); + } catch (\Exception $e) { + $this->logger->error('Failed to get general material', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Create a general material item. + * + * POST /api/v1/inventory/general + */ + public function createGeneral(): JSONResponse { + try { + $data = $this->getRequestData(); + if (empty($data['name'])) { + return new JSONResponse(['error' => 'Name ist erforderlich'], Http::STATUS_BAD_REQUEST); + } + $categoryIds = $data['categories'] ?? null; + unset($data['categories']); + $item = $this->inventoryService->createGeneralMaterial($data, $categoryIds); + $result = $item->jsonSerialize(); + $result['categories'] = $this->inventoryService->getCategoryNamesForItem($item->getId()); + return new JSONResponse($result, Http::STATUS_CREATED); + } catch (\Exception $e) { + $this->logger->error('Failed to create general material', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Update a general material item. + * + * PUT /api/v1/inventory/general/{id} + */ + public function updateGeneral(int $id): JSONResponse { + try { + $data = $this->getRequestData(); + $categoryIds = $data['categories'] ?? null; + unset($data['categories']); + $item = $this->inventoryService->updateGeneralMaterial($id, $data, $categoryIds); + $result = $item->jsonSerialize(); + $result['categories'] = $this->inventoryService->getCategoryNamesForItem($id); + return new JSONResponse($result); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Material nicht gefunden'], Http::STATUS_NOT_FOUND); + } catch (\Exception $e) { + $this->logger->error('Failed to update general material', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Delete a general material item. + * + * DELETE /api/v1/inventory/general/{id} + */ + public function deleteGeneral(int $id): JSONResponse { + try { + $this->inventoryService->deleteGeneralMaterial($id); + return new JSONResponse(['status' => 'deleted']); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Material nicht gefunden'], Http::STATUS_NOT_FOUND); + } catch (\Exception $e) { + $this->logger->error('Failed to delete general material', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + // ── Stock Items ───────────────────────────────────────────────── + + /** + * List all stock items with their variants. + * + * GET /api/v1/inventory/stock + */ + #[NoCSRFRequired] + public function listStock(): JSONResponse { + try { + $items = $this->inventoryService->getStockItems(); + $result = []; + foreach ($items as $item) { + $data = $item->jsonSerialize(); + $data['categories'] = $this->inventoryService->getCategoryNamesForItem($item->getId()); + $data['variants'] = array_map( + fn($v) => $v->jsonSerialize(), + $this->inventoryService->getVariantsByStockItem($item->getId()) + ); + + // Calculate total amount from variants + $totalAmount = 0; + foreach ($data['variants'] as $v) { + $totalAmount += (int)($v['amount'] ?? 0); + } + $data['total_amount'] = $totalAmount; + + // Calculate total minimum threshold + $minThreshold = 0; + foreach ($data['variants'] as $v) { + $minThreshold += (int)($v['min_threshold'] ?? 0); + } + $data['min_threshold'] = $minThreshold; + + $result[] = $data; + } + return new JSONResponse(['data' => $result]); + } catch (\Exception $e) { + $this->logger->error('Failed to list stock items', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Get a single stock item with its variants. + * + * GET /api/v1/inventory/stock/{id} + */ + #[NoCSRFRequired] + public function showStock(int $id): JSONResponse { + try { + $item = $this->inventoryService->getStockItemById($id); + $data = $item->jsonSerialize(); + $data['categories'] = $this->inventoryService->getCategoryNamesForItem($id); + $data['variants'] = array_map( + fn($v) => $v->jsonSerialize(), + $this->inventoryService->getVariantsByStockItem($id) + ); + return new JSONResponse($data); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Verkaufsmaterial nicht gefunden'], Http::STATUS_NOT_FOUND); + } catch (\Exception $e) { + $this->logger->error('Failed to get stock item', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Create a stock item. + * + * POST /api/v1/inventory/stock + */ + public function createStock(): JSONResponse { + try { + $data = $this->getRequestData(); + if (empty($data['name'])) { + return new JSONResponse(['error' => 'Name ist erforderlich'], Http::STATUS_BAD_REQUEST); + } + $categoryIds = $data['categories'] ?? null; + $variants = $data['variants'] ?? []; + unset($data['categories'], $data['variants']); + + $item = $this->inventoryService->createStockItem($data, $categoryIds); + + // Create variants if provided + foreach ($variants as $vData) { + $vData['stock_item_id'] = $item->getId(); + $this->inventoryService->createStockVariant($vData); + } + + $result = $item->jsonSerialize(); + $result['categories'] = $this->inventoryService->getCategoryNamesForItem($item->getId()); + return new JSONResponse($result, Http::STATUS_CREATED); + } catch (\Exception $e) { + $this->logger->error('Failed to create stock item', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Update a stock item. + * + * PUT /api/v1/inventory/stock/{id} + */ + public function updateStock(int $id): JSONResponse { + try { + $data = $this->getRequestData(); + $categoryIds = $data['categories'] ?? null; + $variants = $data['variants'] ?? []; + unset($data['categories'], $data['variants']); + + $item = $this->inventoryService->updateStockItem($id, $data, $categoryIds); + + // Update/create/delete variants + foreach ($variants as $vData) { + if (isset($vData['id']) && is_numeric($vData['id'])) { + // Update existing variant + $this->inventoryService->updateStockVariant((int)$vData['id'], $vData); + } else { + // Create new variant + $vData['stock_item_id'] = $id; + $this->inventoryService->createStockVariant($vData); + } + } + + // Delete variants that are no longer present + $existing = $this->inventoryService->getVariantsByStockItem($id); + $presentIds = array_map(fn($v) => (int)($v['id'] ?? 0), $variants); + foreach ($existing as $existingVariant) { + if (!in_array((int)$existingVariant->getId(), $presentIds, true)) { + $this->inventoryService->deleteStockVariant((int)$existingVariant->getId()); + } + } + + $result = $item->jsonSerialize(); + $result['categories'] = $this->inventoryService->getCategoryNamesForItem($id); + return new JSONResponse($result); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Verkaufsmaterial nicht gefunden'], Http::STATUS_NOT_FOUND); + } catch (\Exception $e) { + $this->logger->error('Failed to update stock item', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Delete a stock item. + * + * DELETE /api/v1/inventory/stock/{id} + */ + public function deleteStock(int $id): JSONResponse { + try { + $this->inventoryService->deleteStockItem($id); + return new JSONResponse(['status' => 'deleted']); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Verkaufsmaterial nicht gefunden'], Http::STATUS_NOT_FOUND); + } catch (\Exception $e) { + $this->logger->error('Failed to delete stock item', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + // ── Variants ──────────────────────────────────────────────────── + + /** + * Create a variant (standalone endpoint). + * + * POST /api/v1/inventory/variants + */ + public function createVariant(): JSONResponse { + try { + $data = $this->getRequestData(); + $variant = $this->inventoryService->createStockVariant($data); + return new JSONResponse($variant->jsonSerialize(), Http::STATUS_CREATED); + } catch (\Exception $e) { + $this->logger->error('Failed to create stock variant', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Update a variant. + * + * PUT /api/v1/inventory/variants/{id} + */ + public function updateVariant(int $id): JSONResponse { + try { + $data = $this->getRequestData(); + $variant = $this->inventoryService->updateStockVariant($id, $data); + return new JSONResponse($variant->jsonSerialize()); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Variante nicht gefunden'], Http::STATUS_NOT_FOUND); + } catch (\Exception $e) { + $this->logger->error('Failed to update stock variant', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Delete a variant. + * + * DELETE /api/v1/inventory/variants/{id} + */ + public function deleteVariant(int $id): JSONResponse { + try { + $this->inventoryService->deleteStockVariant($id); + return new JSONResponse(['status' => 'deleted']); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Variante nicht gefunden'], Http::STATUS_NOT_FOUND); + } catch (\Exception $e) { + $this->logger->error('Failed to delete stock variant', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + // ── Sales ─────────────────────────────────────────────────────── + + /** + * List all sales. + * + * GET /api/v1/inventory/sales + */ + #[NoCSRFRequired] + public function listSales(): JSONResponse { + try { + $from = $this->request->getParam('dateFrom'); + $to = $this->request->getParam('dateTo'); + $sales = $this->saleService->getSalesByDateRange($from, $to); + return new JSONResponse(['data' => array_map(fn($s) => $s->jsonSerialize(), $sales)]); + } catch (\Exception $e) { + $this->logger->error('Failed to list sales', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Get a single sale. + * + * GET /api/v1/inventory/sales/{id} + */ + #[NoCSRFRequired] + public function showSale(int $id): JSONResponse { + try { + $sale = $this->saleService->getSaleById($id); + return new JSONResponse($sale->jsonSerialize()); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Verkauf nicht gefunden'], Http::STATUS_NOT_FOUND); + } catch (\Exception $e) { + $this->logger->error('Failed to get sale', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Create a sale record. + * + * POST /api/v1/inventory/sales + */ + public function createSale(): JSONResponse { + try { + $data = $this->getRequestData(); + $sale = $this->saleService->createSale($data); + return new JSONResponse($sale->jsonSerialize(), Http::STATUS_CREATED); + } catch (\Exception $e) { + $this->logger->error('Failed to create sale', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Delete a sale record. + * + * DELETE /api/v1/inventory/sales/{id} + */ + public function deleteSale(int $id): JSONResponse { + try { + $this->saleService->deleteSale($id); + return new JSONResponse(['status' => 'deleted']); + } catch (DoesNotExistException $e) { + return new JSONResponse(['error' => 'Verkauf nicht gefunden'], Http::STATUS_NOT_FOUND); + } catch (\Exception $e) { + $this->logger->error('Failed to delete sale', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/lib/Controller/InventoryReportController.php b/lib/Controller/InventoryReportController.php new file mode 100644 index 0000000..e6d62a7 --- /dev/null +++ b/lib/Controller/InventoryReportController.php @@ -0,0 +1,70 @@ +reportService = $reportService; + $this->logger = $logger; + } + + /** + * Get condition report data. + * + * GET /api/v1/inventory/reports/condition + */ + #[NoCSRFRequired] + public function condition(): JSONResponse { + try { + $report = $this->reportService->generateConditionReport(); + $report['title'] = 'Materialzustand'; + return new JSONResponse($report); + } catch (\Exception $e) { + $this->logger->error('Failed to generate condition report', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Get sales report data. + * + * GET /api/v1/inventory/reports/sales?dateFrom=2026-01-01&dateTo=2026-03-31 + */ + #[NoCSRFRequired] + public function sales(): JSONResponse { + try { + $dateFrom = $this->request->getParam('dateFrom'); + $dateTo = $this->request->getParam('dateTo'); + $report = $this->reportService->generateSalesReport($dateFrom, $dateTo); + return new JSONResponse($report); + } catch (\Exception $e) { + $this->logger->error('Failed to generate sales report', ['exception' => $e, 'app' => 'mitgliederverwaltung']); + return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/lib/Controller/ReportController.php b/lib/Controller/ReportController.php index 4ce717a..161cbff 100644 --- a/lib/Controller/ReportController.php +++ b/lib/Controller/ReportController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace OCA\Mitgliederverwaltung\Controller; use OCA\Mitgliederverwaltung\Service\EncryptedExportService; +use OCA\Mitgliederverwaltung\Service\InventoryReportService; use OCA\Mitgliederverwaltung\Service\PdfService; use OCA\Mitgliederverwaltung\Service\PermissionService; use OCA\Mitgliederverwaltung\Service\ReportService; @@ -31,6 +32,7 @@ use Psr\Log\LoggerInterface; class ReportController extends ApiController { private ReportService $reportService; + private InventoryReportService $inventoryReportService; private PdfService $pdfService; private EncryptedExportService $encryptedService; private PermissionService $permissionService; @@ -41,6 +43,7 @@ class ReportController extends ApiController { string $appName, IRequest $request, ReportService $reportService, + InventoryReportService $inventoryReportService, PdfService $pdfService, EncryptedExportService $encryptedService, PermissionService $permissionService, @@ -49,6 +52,7 @@ class ReportController extends ApiController { ) { parent::__construct($appName, $request); $this->reportService = $reportService; + $this->inventoryReportService = $inventoryReportService; $this->pdfService = $pdfService; $this->encryptedService = $encryptedService; $this->permissionService = $permissionService; @@ -75,6 +79,8 @@ class ReportController extends ApiController { ['id' => 'familienliste', 'name' => 'Familienliste', 'params' => []], ['id' => 'lagerhistorie', 'name' => 'Lagerhistorie', 'params' => ['memberId']], ['id' => 'verletzungsprotokoll', 'name' => 'Verletzungsprotokoll', 'params' => ['dateFrom', 'dateTo', 'memberId']], + ['id' => 'inventur-verkaeufe', 'name' => 'Inventur-Verkaeufe', 'params' => ['dateFrom', 'dateTo']], + ['id' => 'materialzustand', 'name' => 'Materialzustand', 'params' => []], ], ]); } @@ -251,6 +257,11 @@ class ReportController extends ApiController { ? (int)$this->request->getParam('memberId') : null ), + 'inventur-verkaeufe' => $this->inventoryReportService->generateSalesReport( + $this->request->getParam('dateFrom') ?: null, + $this->request->getParam('dateTo') ?: null + ), + 'materialzustand' => $this->inventoryReportService->generateConditionReport(), default => null, }; } diff --git a/lib/Db/GeneralMaterial.php b/lib/Db/GeneralMaterial.php new file mode 100644 index 0000000..8159177 --- /dev/null +++ b/lib/Db/GeneralMaterial.php @@ -0,0 +1,52 @@ +addType('id', 'integer'); + $this->addType('condition', 'integer'); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'condition' => $this->condition, + 'notes' => $this->notes, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ]; + } +} diff --git a/lib/Db/GeneralMaterialMapper.php b/lib/Db/GeneralMaterialMapper.php new file mode 100644 index 0000000..9e566df --- /dev/null +++ b/lib/Db/GeneralMaterialMapper.php @@ -0,0 +1,93 @@ + + */ +class GeneralMaterialMapper extends QBMapper { + + public function __construct(IDBConnection $db) { + parent::__construct($db, 'mv_inventory_general_material', GeneralMaterial::class); + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function findById(int $id): GeneralMaterial { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + return $this->findEntity($qb); + } + + /** + * Find all general material items. + * + * @return GeneralMaterial[] + * @throws Exception + */ + public function findAll(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->orderBy('name', 'ASC'); + + return $this->findEntities($qb); + } + + /** + * Find items whose condition falls within the given range. + * + * @param int|null $min Minimum condition (inclusive), null = unbounded + * @param int|null $max Maximum condition (inclusive), null = unbounded + * @return GeneralMaterial[] + * @throws Exception + */ + public function findByConditionRange(?int $min = null, ?int $max = null): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->isNotNull('condition')); + + if ($min !== null) { + $qb->andWhere($qb->expr()->gte('condition', $qb->createNamedParameter($min, IQueryBuilder::PARAM_INT))); + } + if ($max !== null) { + $qb->andWhere($qb->expr()->lte('condition', $qb->createNamedParameter($max, IQueryBuilder::PARAM_INT))); + } + + return $this->findEntities($qb); + } + + /** + * Find items needing repair (condition ≤ 2 or NULL). + * + * @return GeneralMaterial[] + * @throws Exception + */ + public function findNeedingRepair(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->lte('condition', $qb->createNamedParameter(2, IQueryBuilder::PARAM_INT))) + ->orWhere($qb->expr()->isNull('condition')); + + return $this->findEntities($qb); + } +} diff --git a/lib/Db/InventoryCategory.php b/lib/Db/InventoryCategory.php new file mode 100644 index 0000000..51b3122 --- /dev/null +++ b/lib/Db/InventoryCategory.php @@ -0,0 +1,39 @@ +addType('id', 'integer'); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/lib/Db/InventoryCategoryMapper.php b/lib/Db/InventoryCategoryMapper.php new file mode 100644 index 0000000..9b84c19 --- /dev/null +++ b/lib/Db/InventoryCategoryMapper.php @@ -0,0 +1,70 @@ + + */ +class InventoryCategoryMapper extends QBMapper { + + public function __construct(IDBConnection $db) { + parent::__construct($db, 'mv_inventory_categories', InventoryCategory::class); + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function findById(int $id): InventoryCategory { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + return $this->findEntity($qb); + } + + /** + * Find a category by name. + * + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function findByName(string $name): InventoryCategory { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR))); + + return $this->findEntity($qb); + } + + /** + * Find all categories. + * + * @return InventoryCategory[] + * @throws Exception + */ + public function findAll(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->orderBy('name', 'ASC'); + + return $this->findEntities($qb); + } +} diff --git a/lib/Db/InventoryItemCategory.php b/lib/Db/InventoryItemCategory.php new file mode 100644 index 0000000..0b5a3ee --- /dev/null +++ b/lib/Db/InventoryItemCategory.php @@ -0,0 +1,36 @@ +addType('itemId', 'integer'); + $this->addType('categoryId', 'integer'); + } + + public function jsonSerialize(): array { + return [ + 'item_id' => $this->itemId, + 'category_id' => $this->categoryId, + ]; + } +} diff --git a/lib/Db/InventoryItemCategoryMapper.php b/lib/Db/InventoryItemCategoryMapper.php new file mode 100644 index 0000000..94f21f5 --- /dev/null +++ b/lib/Db/InventoryItemCategoryMapper.php @@ -0,0 +1,67 @@ + + */ +class InventoryItemCategoryMapper extends QBMapper { + + public function __construct(IDBConnection $db) { + parent::__construct($db, 'mv_inventory_item_categories', InventoryItemCategory::class); + } + + /** + * Find all categories linked to a given item. + * + * @return InventoryItemCategory[] + * @throws Exception + */ + public function findByItemId(int $itemId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('item_id', $qb->createNamedParameter($itemId, IQueryBuilder::PARAM_INT))); + + return $this->findEntities($qb); + } + + /** + * Attach a category to an item. + * + * @throws Exception + */ + public function attach(int $itemId, int $categoryId): void { + $qb = $this->db->getQueryBuilder(); + $qb->insert($this->getTableName()) + ->values([ + 'item_id' => $qb->createNamedParameter($itemId, IQueryBuilder::PARAM_INT), + 'category_id' => $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT), + ]); + $qb->executeStatement(); + } + + /** + * Detach a category from an item. + * + * @throws Exception + */ + public function detach(int $itemId, int $categoryId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('item_id', $qb->createNamedParameter($itemId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('category_id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT))); + $qb->executeStatement(); + } +} diff --git a/lib/Db/SaleRecord.php b/lib/Db/SaleRecord.php new file mode 100644 index 0000000..52a3409 --- /dev/null +++ b/lib/Db/SaleRecord.php @@ -0,0 +1,66 @@ +addType('id', 'integer'); + $this->addType('stockItemId', 'integer'); + $this->addType('variantId', 'integer'); + $this->addType('quantity', 'integer'); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'stock_item_id' => $this->stockItemId, + 'variant_id' => $this->variantId, + 'date' => $this->date, + 'quantity' => $this->quantity, + 'unit_price' => $this->unitPrice, + 'total_price' => $this->totalPrice, + 'notes' => $this->notes, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/lib/Db/SaleRecordMapper.php b/lib/Db/SaleRecordMapper.php new file mode 100644 index 0000000..45f1200 --- /dev/null +++ b/lib/Db/SaleRecordMapper.php @@ -0,0 +1,106 @@ + + */ +class SaleRecordMapper extends QBMapper { + + public function __construct(IDBConnection $db) { + parent::__construct($db, 'mv_inventory_sales', SaleRecord::class); + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function findById(int $id): SaleRecord { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + return $this->findEntity($qb); + } + + /** + * Find all sale records. + * + * @return SaleRecord[] + * @throws Exception + */ + public function findAll(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->orderBy('date', 'DESC'); + + return $this->findEntities($qb); + } + + /** + * Find sale records within a date range. + * + * @param string|null $from Start date (inclusive, YYYY-MM-DD) + * @param string|null $to End date (inclusive, YYYY-MM-DD) + * @return SaleRecord[] + * @throws Exception + */ + public function findByDateRange(?string $from = null, ?string $to = null): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->gte('date', $qb->createNamedParameter($from ?? '1970-01-01', IQueryBuilder::PARAM_STR))) + ->addOrderBy('date', 'DESC'); + + if ($to !== null) { + // Override the first where (set up by gte) + $conditions = $qb->expr()->gte('date', $qb->createNamedParameter($from ?? '1970-01-01', IQueryBuilder::PARAM_STR)); + $conditions2 = $qb->expr()->lte('date', $qb->createNamedParameter($to, IQueryBuilder::PARAM_STR)); + $qb2 = $this->db->getQueryBuilder(); + $qb2->select('*') + ->from($this->getTableName()) + ->where($conditions2) + ->andWhere($conditions); + + return $this->findEntities($qb2); + } + + return $this->findEntities($qb); + } + + /** + * Count sale records within a date range. + * + * @throws Exception + */ + public function countByDateRange(?string $from = null, ?string $to = null): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->createFunction('COUNT(*)')) + ->from($this->getTableName()) + ->where($qb->expr()->gte('date', $qb->createNamedParameter($from ?? '1970-01-01', IQueryBuilder::PARAM_STR))); + + if ($to !== null) { + $qb->andWhere($qb->expr()->lte('date', $qb->createNamedParameter($to, IQueryBuilder::PARAM_STR))); + } + + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + + return $count; + } +} diff --git a/lib/Db/StockItem.php b/lib/Db/StockItem.php new file mode 100644 index 0000000..5400aef --- /dev/null +++ b/lib/Db/StockItem.php @@ -0,0 +1,47 @@ +addType('id', 'integer'); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'provider_urls_json' => $this->providerUrlsJson, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ]; + } +} diff --git a/lib/Db/StockItemMapper.php b/lib/Db/StockItemMapper.php new file mode 100644 index 0000000..c80fc78 --- /dev/null +++ b/lib/Db/StockItemMapper.php @@ -0,0 +1,53 @@ + + */ +class StockItemMapper extends QBMapper { + + public function __construct(IDBConnection $db) { + parent::__construct($db, 'mv_inventory_stock_items', StockItem::class); + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function findById(int $id): StockItem { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + return $this->findEntity($qb); + } + + /** + * Find all stock items. + * + * @return StockItem[] + * @throws Exception + */ + public function findAll(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->orderBy('name', 'ASC'); + + return $this->findEntities($qb); + } +} diff --git a/lib/Db/StockVariant.php b/lib/Db/StockVariant.php new file mode 100644 index 0000000..599243a --- /dev/null +++ b/lib/Db/StockVariant.php @@ -0,0 +1,54 @@ +addType('id', 'integer'); + $this->addType('stockItemId', 'integer'); + $this->addType('amount', 'integer'); + $this->addType('minThreshold', 'integer'); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'stock_item_id' => $this->stockItemId, + 'label' => $this->label, + 'amount' => $this->amount, + 'cost' => $this->cost, + 'min_threshold' => $this->minThreshold, + ]; + } +} diff --git a/lib/Db/StockVariantMapper.php b/lib/Db/StockVariantMapper.php new file mode 100644 index 0000000..fb3edc6 --- /dev/null +++ b/lib/Db/StockVariantMapper.php @@ -0,0 +1,54 @@ + + */ +class StockVariantMapper extends QBMapper { + + public function __construct(IDBConnection $db) { + parent::__construct($db, 'mv_inventory_stock_variants', StockVariant::class); + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function findById(int $id): StockVariant { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + return $this->findEntity($qb); + } + + /** + * Find all variants for a given stock item. + * + * @return StockVariant[] + * @throws Exception + */ + public function findByStockItemId(int $stockItemId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('stock_item_id', $qb->createNamedParameter($stockItemId, IQueryBuilder::PARAM_INT))) + ->orderBy('label', 'ASC'); + + return $this->findEntities($qb); + } +} diff --git a/lib/Migration/Version000018Date20260421000000.php b/lib/Migration/Version000018Date20260421000000.php new file mode 100644 index 0000000..79e7b09 --- /dev/null +++ b/lib/Migration/Version000018Date20260421000000.php @@ -0,0 +1,257 @@ +hasTable('mv_inventory_categories')) { + $table = $schema->createTable('mv_inventory_categories'); + $table->addColumn('id', 'bigint', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 8, + ]); + $table->addColumn('name', 'string', [ + 'notnull' => true, + 'length' => 100, + 'default' => '', + ]); + $table->addColumn('created_at', 'datetime', [ + 'notnull' => false, + 'default' => null, + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['name'], 'inventory_cat_name_uindex'); + } else { + $table = $schema->getTable('mv_inventory_categories'); + if (!$table->hasColumn('created_at')) { + $table->addColumn('created_at', 'datetime', [ + 'notnull' => false, + 'default' => null, + ]); + } + if (!$table->hasIndex('inventory_cat_name_uindex')) { + $table->addUniqueIndex(['name'], 'inventory_cat_name_uindex'); + } + } + + // ── mv_inventory_general_material (referenced by item_categories) ── + if (!$schema->hasTable('mv_inventory_general_material')) { + $table = $schema->createTable('mv_inventory_general_material'); + $table->addColumn('id', 'bigint', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 8, + ]); + $table->addColumn('name', 'string', [ + 'notnull' => true, + 'length' => 255, + 'default' => '', + ]); + $table->addColumn('condition', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + 'unsigned' => true, + ]); + $table->addColumn('notes', 'text', [ + 'notnull' => false, + 'default' => null, + 'length' => 65535, + ]); + $table->addColumn('created_at', 'datetime', [ + 'notnull' => true, + 'default' => '1970-01-01 00:00:00', + ]); + $table->addColumn('updated_at', 'datetime', [ + 'notnull' => true, + 'default' => '1970-01-01 00:00:00', + ]); + $table->setPrimaryKey(['id']); + } + + // ── mv_inventory_stock_items (referenced by stock_variants & sales) ── + if (!$schema->hasTable('mv_inventory_stock_items')) { + $table = $schema->createTable('mv_inventory_stock_items'); + $table->addColumn('id', 'bigint', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 8, + ]); + $table->addColumn('name', 'string', [ + 'notnull' => true, + 'length' => 255, + 'default' => '', + ]); + $table->addColumn('provider_urls_json', 'text', [ + 'notnull' => false, + 'default' => null, + 'length' => 65535, + ]); + $table->addColumn('created_at', 'datetime', [ + 'notnull' => true, + 'default' => '1970-01-01 00:00:00', + ]); + $table->addColumn('updated_at', 'datetime', [ + 'notnull' => true, + 'default' => '1970-01-01 00:00:00', + ]); + $table->setPrimaryKey(['id']); + } + + // ── mv_inventory_stock_variants (FK → stock_items) ─────────── + if (!$schema->hasTable('mv_inventory_stock_variants')) { + $table = $schema->createTable('mv_inventory_stock_variants'); + $table->addColumn('id', 'bigint', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 8, + ]); + $table->addColumn('stock_item_id', 'bigint', [ + 'notnull' => true, + 'length' => 8, + 'default' => 0, + ]); + $table->addColumn('label', 'string', [ + 'notnull' => true, + 'length' => 100, + 'default' => '', + ]); + $table->addColumn('amount', Types::INTEGER, [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + $table->addColumn('cost', 'decimal', [ + 'notnull' => true, + 'default' => '0.00', + 'precision' => 10, + 'scale' => 2, + ]); + $table->addColumn('min_threshold', Types::INTEGER, [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + $table->setPrimaryKey(['id']); + $table->addForeignKeyConstraint( + $schema->getTable('mv_inventory_stock_items'), + ['stock_item_id'], + ['id'], + ['onDelete' => 'CASCADE'] + ); + } + + // ── mv_inventory_item_categories (FK → general_material, categories) ── + if (!$schema->hasTable('mv_inventory_item_categories')) { + $table = $schema->createTable('mv_inventory_item_categories'); + $table->addColumn('item_id', 'bigint', [ + 'notnull' => true, + 'length' => 8, + 'default' => 0, + ]); + $table->addColumn('category_id', 'bigint', [ + 'notnull' => true, + 'length' => 8, + 'default' => 0, + ]); + $table->setPrimaryKey(['item_id', 'category_id']); + $table->addForeignKeyConstraint( + $schema->getTable('mv_inventory_general_material'), + ['item_id'], + ['id'], + ['onDelete' => 'CASCADE'] + ); + $table->addForeignKeyConstraint( + $schema->getTable('mv_inventory_categories'), + ['category_id'], + ['id'], + ['onDelete' => 'CASCADE'] + ); + } + + // ── mv_inventory_sales (FK → stock_items, stock_variants) ──── + if (!$schema->hasTable('mv_inventory_sales')) { + $table = $schema->createTable('mv_inventory_sales'); + $table->addColumn('id', 'bigint', [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 8, + ]); + $table->addColumn('stock_item_id', 'bigint', [ + 'notnull' => false, + 'length' => 8, + 'default' => null, + ]); + $table->addColumn('variant_id', 'bigint', [ + 'notnull' => false, + 'length' => 8, + 'default' => null, + ]); + $table->addColumn('date', 'date', [ + 'notnull' => true, + 'default' => '1970-01-01', + ]); + $table->addColumn('quantity', Types::INTEGER, [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + $table->addColumn('unit_price', 'decimal', [ + 'notnull' => true, + 'default' => '0.00', + 'precision' => 10, + 'scale' => 2, + ]); + $table->addColumn('total_price', 'decimal', [ + 'notnull' => true, + 'default' => '0.00', + 'precision' => 10, + 'scale' => 2, + ]); + $table->addColumn('notes', 'text', [ + 'notnull' => false, + 'default' => null, + 'length' => 65535, + ]); + $table->addColumn('created_at', 'datetime', [ + 'notnull' => true, + 'default' => '1970-01-01 00:00:00', + ]); + $table->setPrimaryKey(['id']); + if ($schema->hasTable('mv_inventory_stock_items')) { + $stockTable = $schema->getTable('mv_inventory_stock_items'); + $table->addForeignKeyConstraint($stockTable, ['stock_item_id'], ['id'], ['onDelete' => 'SET NULL']); + } + if ($schema->hasTable('mv_inventory_stock_variants')) { + $varTable = $schema->getTable('mv_inventory_stock_variants'); + $table->addForeignKeyConstraint($varTable, ['variant_id'], ['id'], ['onDelete' => 'SET NULL']); + } + } + + return $schema; + } +} diff --git a/lib/Service/InventoryReportService.php b/lib/Service/InventoryReportService.php new file mode 100644 index 0000000..86a0f9e --- /dev/null +++ b/lib/Service/InventoryReportService.php @@ -0,0 +1,171 @@ +generalMaterialMapper = $generalMaterialMapper; + $this->categoryMapper = $categoryMapper; + $this->itemCategoryMapper = $itemCategoryMapper; + $this->stockItemMapper = $stockItemMapper; + $this->stockVariantMapper = $stockVariantMapper; + $this->saleRecordMapper = $saleRecordMapper; + $this->logger = $logger; + } + + /** + * Generate condition report data. + * + * @return array{summary: array, rows: array, headers: string[]} + */ + public function generateConditionReport(): array { + $items = $this->generalMaterialMapper->findAll(); + + // Build summary by category + $summary = []; + foreach ($items as $item) { + $catIds = $this->itemCategoryMapper->findByItemId($item->getId()); + if (count($catIds) === 0) { + $catName = '(keine Kategorie)'; + } else { + $catNames = []; + foreach ($catIds as $cat) { + try { + $c = $this->categoryMapper->findById((int)$cat->getCategoryId()); + $catNames[] = $c->getName(); + } catch (\Exception $e) { + // ignore + } + } + $catName = implode(', ', $catNames); + } + if (!isset($summary[$catName])) { + $summary[$catName] = 0; + } + $summary[$catName] += 1; + } + + // Build detail rows for items needing repair (condition ≤ 2 or NULL) + $rows = []; + $needingRepair = $this->generalMaterialMapper->findNeedingRepair(); + foreach ($needingRepair as $item) { + $catIds = $this->itemCategoryMapper->findByItemId($item->getId()); + $catNames = []; + foreach ($catIds as $cat) { + try { + $c = $this->categoryMapper->findById((int)$cat->getCategoryId()); + $catNames[] = $c->getName(); + } catch (\Exception $e) { + // ignore + } + } + + $conditionLabel = $item->getCondition() === null + ? 'Nicht bewertet' + : $item->getCondition() . '/5'; + + $rows[] = [ + 'name' => $item->getName(), + 'condition' => $conditionLabel, + 'categories' => implode(', ', $catNames) ?: '–', + 'notes' => $item->getNotes() ?? '–', + ]; + } + + return [ + 'summary' => $summary, + 'rows' => $rows, + 'headers' => ['Artikel', 'Zustand', 'Kategorie', 'Notizen'], + ]; + } + + /** + * Generate sales report data. + * + * @param string|null $dateFrom Start date (YYYY-MM-DD) + * @param string|null $dateTo End date (YYYY-MM-DD) + * @return array{title: string, summary: array, rows: array, headers: string[]} + */ + public function generateSalesReport(?string $dateFrom = null, ?string $dateTo = null): array { + $sales = $this->saleRecordMapper->findByDateRange($dateFrom, $dateTo); + $rows = []; + $totalRevenue = 0.0; + + foreach ($sales as $sale) { + $itemName = 'Unbekannt'; + try { + $stockItem = $this->stockItemMapper->findById((int)$sale->getStockItemId()); + $itemName = $stockItem->getName(); + } catch (\Exception $e) { + // ignore, keep default name + } + + $variantName = ''; + if ($sale->getVariantId() !== null) { + try { + $variant = $this->stockVariantMapper->findById((int)$sale->getVariantId()); + $variantName = ' – ' . $variant->getLabel(); + } catch (\Exception $e) { + // ignore + } + } + + $totalRevenue += (float)$sale->getTotalPrice(); + + $rows[] = [ + 'date' => $sale->getDate(), + 'item' => $itemName . $variantName, + 'quantity' => (string)$sale->getQuantity(), + 'unit_price' => number_format((float)$sale->getUnitPrice(), 2, ',', '.') . ' €', + 'total_price' => number_format((float)$sale->getTotalPrice(), 2, ',', '.') . ' €', + ]; + } + + $title = 'Inventur-Verkäufe'; + if ($dateFrom !== null || $dateTo !== null) { + $title .= ' (' . ($dateFrom ?? 'Anfang') . ' – ' . ($dateTo ?? 'Ende') . ')'; + } + + return [ + 'title' => $title, + 'summary' => [ + 'total_sales' => count($sales), + 'total_revenue' => number_format($totalRevenue, 2, ',', '.') . ' €', + ], + 'rows' => $rows, + 'headers' => ['Datum', 'Artikel', 'Menge', 'Stückpreis', 'Gesamtsumme'], + ]; + } +} diff --git a/lib/Service/InventoryService.php b/lib/Service/InventoryService.php new file mode 100644 index 0000000..e67398c --- /dev/null +++ b/lib/Service/InventoryService.php @@ -0,0 +1,486 @@ +categoryMapper = $categoryMapper; + $this->itemCategoryMapper = $itemCategoryMapper; + $this->generalMaterialMapper = $generalMaterialMapper; + $this->stockItemMapper = $stockItemMapper; + $this->stockVariantMapper = $stockVariantMapper; + $this->auditService = $auditService; + $this->logger = $logger; + } + + // ── Categories ──────────────────────────────────────────────────── + + /** + * @return InventoryCategory[] + * @throws Exception + */ + public function getCategories(): array { + return $this->categoryMapper->findAll(); + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function getCategory(int $id): InventoryCategory { + return $this->categoryMapper->findById($id); + } + + /** + * @throws Exception + */ + public function createCategory(array $data): InventoryCategory { + $category = new InventoryCategory(); + $category->setName($data['name'] ?? ''); + $category->setCreatedAt((new DateTime())->format('Y-m-d H:i:s')); + $this->categoryMapper->insert($category); + + $this->auditService->logCreate([ + 'name' => $category->getName(), + ], 'inventory_category', $category->getId()); + + return $category; + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function updateCategory(int $id, array $data): InventoryCategory { + $category = $this->categoryMapper->findById($id); + $oldData = ['name' => $category->getName()]; + + if (isset($data['name'])) { + $category->setName($data['name']); + } + + $this->categoryMapper->update($category); + + $this->auditService->logUpdate($oldData, [ + 'name' => $category->getName(), + ], 'inventory_category', $id); + + return $category; + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function deleteCategory(int $id): void { + $category = $this->categoryMapper->findById($id); + $this->categoryMapper->delete($category); + + $this->auditService->logDelete('inventory_category', $id); + } + + // ── General Material ────────────────────────────────────────────── + + /** + * @return GeneralMaterial[] + * @throws Exception + */ + public function getGeneralMaterial(): array { + return $this->generalMaterialMapper->findAll(); + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function getGeneralMaterialById(int $id): GeneralMaterial { + return $this->generalMaterialMapper->findById($id); + } + + /** + * @throws Exception + */ + public function createGeneralMaterial(array $data, ?array $categoryIds = null): GeneralMaterial { + $now = (new DateTime())->format('Y-m-d H:i:s'); + $item = new GeneralMaterial(); + $item->setName($data['name'] ?? ''); + $item->setCondition($data['condition'] ?? null); + $item->setNotes($data['notes'] ?? null); + $item->setCreatedAt($now); + $item->setUpdatedAt($now); + $this->generalMaterialMapper->insert($item); + + // Attach categories + if ($categoryIds !== null && is_array($categoryIds)) { + foreach ($categoryIds as $catId) { + try { + $this->itemCategoryMapper->attach($item->getId(), (int)$catId); + } catch (\Exception $e) { + $this->logger->warning('Failed to attach category {catId} to item {itemId}', [ + 'catId' => $catId, + 'itemId' => $item->getId(), + 'app' => 'mitgliederverwaltung', + ]); + } + } + } + + $this->auditService->logCreate([ + 'name' => $item->getName(), + 'condition' => $item->getCondition(), + 'notes' => $item->getNotes(), + ], 'inventory_general_material', $item->getId()); + + return $item; + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function updateGeneralMaterial(int $id, array $data, ?array $categoryIds = null): GeneralMaterial { + $item = $this->generalMaterialMapper->findById($id); + $oldData = [ + 'name' => $item->getName(), + 'condition' => $item->getCondition(), + 'notes' => $item->getNotes(), + ]; + + if (isset($data['name'])) { + $item->setName($data['name']); + } + if (array_key_exists('condition', $data)) { + $item->setCondition($data['condition'] === null ? null : (int)$data['condition']); + } + if (array_key_exists('notes', $data)) { + $item->setNotes($data['notes'] === null ? null : (string)$data['notes']); + } + $item->setUpdatedAt((new DateTime())->format('Y-m-d H:i:s')); + $this->generalMaterialMapper->update($item); + + // Sync categories if provided + if ($categoryIds !== null && is_array($categoryIds)) { + // Detach all existing categories and re-attach + $existing = $this->itemCategoryMapper->findByItemId($id); + foreach ($existing as $existingCat) { + $this->itemCategoryMapper->detach($id, (int)$existingCat->getCategoryId()); + } + foreach ($categoryIds as $catId) { + try { + $this->itemCategoryMapper->attach($id, (int)$catId); + } catch (\Exception $e) { + $this->logger->warning('Failed to attach category {catId} to item {itemId}', [ + 'catId' => $catId, + 'itemId' => $id, + 'app' => 'mitgliederverwaltung', + ]); + } + } + } + + $this->auditService->logUpdate($oldData, [ + 'name' => $item->getName(), + 'condition' => $item->getCondition(), + 'notes' => $item->getNotes(), + ], 'inventory_general_material', $id); + + return $item; + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function deleteGeneralMaterial(int $id): void { + $item = $this->generalMaterialMapper->findById($id); + $this->generalMaterialMapper->delete($item); + + $this->auditService->logDelete('inventory_general_material', $id); + } + + /** + * Get items needing repair (condition ≤ 2 or NULL). + * + * @return GeneralMaterial[] + * @throws Exception + */ + public function getNeedingRepair(): array { + return $this->generalMaterialMapper->findNeedingRepair(); + } + + /** + * @return GeneralMaterial[] + * @throws Exception + */ + public function getGeneralMaterialByConditionRange(?int $min, ?int $max): array { + return $this->generalMaterialMapper->findByConditionRange($min, $max); + } + + /** + * Get category names for an item. + * + * @return string[] + * @throws Exception + */ + public function getCategoryNamesForItem(int $itemId): array { + $cats = $this->itemCategoryMapper->findByItemId($itemId); + $names = []; + foreach ($cats as $cat) { + try { + $c = $this->categoryMapper->findById((int)$cat->getCategoryId()); + $names[] = $c->getName(); + } catch (\Exception $e) { + // ignore + } + } + return $names; + } + + // ── Stock Items ─────────────────────────────────────────────────── + + /** + * @return StockItem[] + * @throws Exception + */ + public function getStockItems(): array { + return $this->stockItemMapper->findAll(); + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function getStockItemById(int $id): StockItem { + return $this->stockItemMapper->findById($id); + } + + /** + * @throws Exception + */ + public function createStockItem(array $data, ?array $categoryIds = null): StockItem { + $now = (new DateTime())->format('Y-m-d H:i:s'); + $item = new StockItem(); + $item->setName($data['name'] ?? ''); + $item->setProviderUrlsJson($data['provider_urls_json'] ?? null); + $item->setCreatedAt($now); + $item->setUpdatedAt($now); + $this->stockItemMapper->insert($item); + + // Attach categories + if ($categoryIds !== null && is_array($categoryIds)) { + foreach ($categoryIds as $catId) { + try { + $this->itemCategoryMapper->attach($item->getId(), (int)$catId); + } catch (\Exception $e) { + $this->logger->warning('Failed to attach category {catId} to stock item {itemId}', [ + 'catId' => $catId, + 'itemId' => $item->getId(), + 'app' => 'mitgliederverwaltung', + ]); + } + } + } + + $this->auditService->logCreate([ + 'name' => $item->getName(), + ], 'inventory_stock_item', $item->getId()); + + return $item; + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function updateStockItem(int $id, array $data, ?array $categoryIds = null): StockItem { + $item = $this->stockItemMapper->findById($id); + $oldData = [ + 'name' => $item->getName(), + 'provider_urls_json' => $item->getProviderUrlsJson(), + ]; + + if (isset($data['name'])) { + $item->setName($data['name']); + } + if (array_key_exists('provider_urls_json', $data)) { + $item->setProviderUrlsJson($data['provider_urls_json'] ?? null); + } + $item->setUpdatedAt((new DateTime())->format('Y-m-d H:i:s')); + $this->stockItemMapper->update($item); + + // Sync categories if provided + if ($categoryIds !== null && is_array($categoryIds)) { + $existing = $this->itemCategoryMapper->findByItemId($id); + foreach ($existing as $existingCat) { + $this->itemCategoryMapper->detach($id, (int)$existingCat->getCategoryId()); + } + foreach ($categoryIds as $catId) { + try { + $this->itemCategoryMapper->attach($id, (int)$catId); + } catch (\Exception $e) { + $this->logger->warning('Failed to attach category {catId} to stock item {itemId}', [ + 'catId' => $catId, + 'itemId' => $id, + 'app' => 'mitgliederverwaltung', + ]); + } + } + } + + $this->auditService->logUpdate($oldData, [ + 'name' => $item->getName(), + 'provider_urls_json' => $item->getProviderUrlsJson(), + ], 'inventory_stock_item', $id); + + return $item; + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function deleteStockItem(int $id): void { + $item = $this->stockItemMapper->findById($id); + $this->stockItemMapper->delete($item); + + $this->auditService->logDelete('inventory_stock_item', $id); + } + + // ── Stock Variants ──────────────────────────────────────────────── + + /** + * @throws Exception + */ + public function createStockVariant(array $data): StockVariant { + $variant = new StockVariant(); + $variant->setStockItemId((int)$data['stock_item_id']); + $variant->setLabel($data['label'] ?? ''); + $variant->setAmount((int)($data['amount'] ?? 0)); + $variant->setCost((string)($data['cost'] ?? '0.00')); + $variant->setMinThreshold((int)($data['min_threshold'] ?? 0)); + $this->stockVariantMapper->insert($variant); + + $this->auditService->logCreate([ + 'label' => $variant->getLabel(), + 'amount' => $variant->getAmount(), + 'cost' => $variant->getCost(), + ], 'inventory_stock_variant', $variant->getId()); + + return $variant; + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function updateStockVariant(int $id, array $data): StockVariant { + $variant = $this->stockVariantMapper->findById($id); + $oldData = [ + 'label' => $variant->getLabel(), + 'amount' => $variant->getAmount(), + 'cost' => $variant->getCost(), + 'min_threshold' => $variant->getMinThreshold(), + ]; + + if (isset($data['label'])) { + $variant->setLabel($data['label']); + } + if (array_key_exists('amount', $data)) { + $variant->setAmount((int)$data['amount']); + } + if (array_key_exists('cost', $data)) { + $variant->setCost((string)$data['cost']); + } + if (array_key_exists('min_threshold', $data)) { + $variant->setMinThreshold((int)$data['min_threshold']); + } + + $this->stockVariantMapper->update($variant); + + $this->auditService->logUpdate($oldData, [ + 'label' => $variant->getLabel(), + 'amount' => $variant->getAmount(), + 'cost' => $variant->getCost(), + 'min_threshold' => $variant->getMinThreshold(), + ], 'inventory_stock_variant', $id); + + return $variant; + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function deleteStockVariant(int $id): void { + $variant = $this->stockVariantMapper->findById($id); + $this->stockVariantMapper->delete($variant); + + $this->auditService->logDelete('inventory_stock_variant', $id); + } + + /** + * @return StockVariant[] + * @throws Exception + */ + public function getVariantsByStockItem(int $stockItemId): array { + return $this->stockVariantMapper->findByStockItemId($stockItemId); + } + + /** + * Check if an item needs repair (condition ≤ 2). + */ + public function needsRepair(?int $condition): bool { + return $condition === null || $condition <= 2; + } + + /** + * Get condition label for display. + */ + public function getConditionLabel(?int $condition): string { + if ($condition === null) { + return 'Nicht bewertet'; + } + return $condition . '/5'; + } +} diff --git a/lib/Service/SaleService.php b/lib/Service/SaleService.php new file mode 100644 index 0000000..730a6ef --- /dev/null +++ b/lib/Service/SaleService.php @@ -0,0 +1,108 @@ +saleRecordMapper = $saleRecordMapper; + $this->auditService = $auditService; + $this->logger = $logger; + } + + /** + * @return SaleRecord[] + * @throws Exception + */ + public function getSales(): array { + return $this->saleRecordMapper->findAll(); + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function getSaleById(int $id): SaleRecord { + return $this->saleRecordMapper->findById($id); + } + + /** + * @throws Exception + */ + public function createSale(array $data): SaleRecord { + $qty = (int)($data['quantity'] ?? 0); + $unitPrice = (string)($data['unit_price'] ?? '0.00'); + // Calculate total price = quantity × unit_price + $totalPrice = number_format((float)$unitPrice * $qty, 2, '.', ''); + + $record = new SaleRecord(); + $record->setStockItemId($data['stock_item_id'] ?? null); + $record->setVariantId($data['variant_id'] ?? null); + $record->setDate($data['date'] ?? (new DateTime())->format('Y-m-d')); + $record->setQuantity($qty); + $record->setUnitPrice($unitPrice); + $record->setTotalPrice($totalPrice); + $record->setNotes($data['notes'] ?? null); + $record->setCreatedAt((new DateTime())->format('Y-m-d H:i:s')); + $this->saleRecordMapper->insert($record); + + $this->auditService->logCreate([ + 'stock_item_id' => $record->getStockItemId(), + 'variant_id' => $record->getVariantId(), + 'date' => $record->getDate(), + 'quantity' => $record->getQuantity(), + 'unit_price' => $record->getUnitPrice(), + 'total_price' => $record->getTotalPrice(), + ], 'inventory_sale', $record->getId()); + + return $record; + } + + /** + * @throws DoesNotExistException + * @throws Exception + */ + public function deleteSale(int $id): void { + $record = $this->saleRecordMapper->findById($id); + $this->saleRecordMapper->delete($record); + + $this->auditService->logDelete('inventory_sale', $id); + } + + /** + * @return SaleRecord[] + * @throws Exception + */ + public function getSalesByDateRange(?string $from, ?string $to): array { + return $this->saleRecordMapper->findByDateRange($from, $to); + } + + /** + * @throws Exception + */ + public function countSalesByDateRange(?string $from, ?string $to): int { + return $this->saleRecordMapper->countByDateRange($from, $to); + } +} diff --git a/src/App.vue b/src/App.vue index 656a46f..3be825a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -30,6 +30,13 @@ + + + + + @@ -103,6 +110,7 @@ import Cog from 'vue-material-design-icons/Cog.vue' import DatabaseSearch from 'vue-material-design-icons/DatabaseSearch.vue' import SwapVertical from 'vue-material-design-icons/SwapVertical.vue' import MedicalBag from 'vue-material-design-icons/MedicalBag.vue' +import PackageVariant from 'vue-material-design-icons/PackageVariant.vue' import Tent from 'vue-material-design-icons/Campfire.vue' import BackupRestore from 'vue-material-design-icons/BackupRestore.vue' import SearchBar from './components/SearchBar.vue' diff --git a/src/components/CategoryPicker.vue b/src/components/CategoryPicker.vue new file mode 100644 index 0000000..fb9ae5d --- /dev/null +++ b/src/components/CategoryPicker.vue @@ -0,0 +1,78 @@ + + + o.value))" + /> + + + + + + + + Erstellen + + + + + + + + diff --git a/src/components/GeneralMaterialForm.vue b/src/components/GeneralMaterialForm.vue new file mode 100644 index 0000000..b43b5f9 --- /dev/null +++ b/src/components/GeneralMaterialForm.vue @@ -0,0 +1,170 @@ + + + + + Name: + + + + + Zustand (0–5): + + + + {{ option.label }} + + + + + + + Kategorien: + o.value)" + /> + + + + Notizen: + + + + + + Abbrechen + + + {{ form.name ? (editMode ? 'Speichern' : 'Erstellen') : 'Speichern' }} + + + + + + + + + diff --git a/src/components/SaleForm.vue b/src/components/SaleForm.vue new file mode 100644 index 0000000..e32bb3b --- /dev/null +++ b/src/components/SaleForm.vue @@ -0,0 +1,191 @@ + + + + + Artikel: + + + + + Datum: + + + + + Variante: + + + + + Menge: + + + + + Stückpreis (€): + + + + + Gesamtsumme: {{ totalPrice }} + + + + Notizen: + + + + + + Abbrechen + + + Verkauf eintragen + + + + + + + + + diff --git a/src/components/StockItemForm.vue b/src/components/StockItemForm.vue new file mode 100644 index 0000000..be5fc23 --- /dev/null +++ b/src/components/StockItemForm.vue @@ -0,0 +1,255 @@ + + + + + Name: + + + + + Kategorien: + o.value)" + /> + + + + Provider-URLs: + + + + + + + + + + + + + + URL hinzufügen + + + + + Varianten: + + + + + Variante hinzufügen + + + + + + + + + + + + + + + + + + Abbrechen + + + {{ form.name ? (editMode ? 'Speichern' : 'Erstellen') : 'Speichern' }} + + + + + + + + + diff --git a/src/components/StockVariantList.vue b/src/components/StockVariantList.vue new file mode 100644 index 0000000..8587161 --- /dev/null +++ b/src/components/StockVariantList.vue @@ -0,0 +1,47 @@ + + + + + + + Keine Varianten vorhanden + + + + + + + diff --git a/src/components/StockVariantRow.vue b/src/components/StockVariantRow.vue new file mode 100644 index 0000000..5241413 --- /dev/null +++ b/src/components/StockVariantRow.vue @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main.js b/src/main.js index 62d0472..0b5a305 100644 --- a/src/main.js +++ b/src/main.js @@ -31,7 +31,7 @@ app.use(router) // @nextcloud/vue v9 reads appName/appVersion via Vue's inject(), // not via webpack DefinePlugin globals. app.provide('appName', 'mitgliederverwaltung') -app.provide('appVersion', '0.3.1') +app.provide('appVersion', '0.3.2') app.mount('#mitgliederverwaltung') diff --git a/src/router.js b/src/router.js index 094bd4a..a1351e3 100644 --- a/src/router.js +++ b/src/router.js @@ -78,6 +78,11 @@ const routes = [ name: 'backup', component: () => import('./views/Backup.vue'), }, + { + path: '/inventory', + name: 'inventory', + component: () => import('./views/Inventory.vue'), + }, ] const router = createRouter({ diff --git a/src/stores/categories.js b/src/stores/categories.js new file mode 100644 index 0000000..689b90a --- /dev/null +++ b/src/stores/categories.js @@ -0,0 +1,86 @@ +/** + * Pinia store for inventory categories. + * + * Handles fetching and CRUD operations for inventory categories. + * + * Part of Issue #165 (Inventory Tracking). + */ +import { defineStore } from 'pinia' +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' + +export const useCategoriesStore = defineStore('inventoryCategories', { + state: () => ({ + /** @type {Array} List of categories */ + categories: [], + /** @type {boolean} Loading state */ + loading: false, + /** @type {string|null} Error message */ + error: null, + }), + + actions: { + /** + * Fetch all categories. + */ + async fetchCategories() { + this.loading = true + this.error = null + + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/categories') + const response = await axios.get(url) + this.categories = response.data.data || [] + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Laden der Kategorien' + console.error('Failed to fetch inventory categories:', err) + } finally { + this.loading = false + } + }, + + /** + * Create a new category. + */ + async createCategory(name) { + this.loading = true + this.error = null + + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/categories') + const response = await axios.post(url, { name }) + this.categories.push(response.data) + this.categories.sort((a, b) => a.name.localeCompare(b.name)) + return response.data + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Erstellen der Kategorie' + throw err + } finally { + this.loading = false + } + }, + + /** + * Delete a category. + */ + async deleteCategory(id) { + this.loading = true + this.error = null + + try { + const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/categories/${id}`) + await axios.delete(url) + this.categories = this.categories.filter(c => c.id !== id) + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Löschen der Kategorie' + throw err + } finally { + this.loading = false + } + }, + + clearError() { + this.error = null + }, + }, +}) diff --git a/src/stores/general.js b/src/stores/general.js new file mode 100644 index 0000000..b5d7af6 --- /dev/null +++ b/src/stores/general.js @@ -0,0 +1,128 @@ +/** + * Pinia store for general material inventory. + * + * Handles fetching and CRUD operations for general material items. + * + * Part of Issue #165 (Inventory Tracking). + */ +import { defineStore } from 'pinia' +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' + +export const useGeneralStore = defineStore('inventoryGeneral', { + state: () => ({ + /** @type {Array} List of general material items */ + items: [], + /** @type {string|null} Current search/filter text */ + searchText: '', + /** @type {boolean} Loading state */ + loading: false, + /** @type {string|null} Error message */ + error: null, + }), + + getters: { + /** + * Filtered items based on search text. + */ + filteredItems: (state) => { + if (!state.searchText) return state.items + const lower = state.searchText.toLowerCase() + return state.items.filter(item => + item.name.toLowerCase().includes(lower) + ) + }, + }, + + actions: { + /** + * Fetch all general material items. + */ + async fetchItems() { + this.loading = true + this.error = null + + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/general') + const response = await axios.get(url) + this.items = response.data.data || [] + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Laden der Allgemeinmaterialien' + console.error('Failed to fetch general material:', err) + } finally { + this.loading = false + } + }, + + /** + * Create a new general material item. + */ + async createItem(data) { + this.loading = true + this.error = null + + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/general') + const response = await axios.post(url, data) + this.items.push(response.data) + this.items.sort((a, b) => a.name.localeCompare(b.name)) + return response.data + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Erstellen des Materials' + throw err + } finally { + this.loading = false + } + }, + + /** + * Update an existing general material item. + */ + async updateItem(id, data) { + this.loading = true + this.error = null + + try { + const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/general/${id}`) + const response = await axios.put(url, data) + const index = this.items.findIndex(i => i.id === id) + if (index !== -1) { + this.items[index] = response.data + } + return response.data + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Aktualisieren des Materials' + throw err + } finally { + this.loading = false + } + }, + + /** + * Delete a general material item. + */ + async deleteItem(id) { + this.loading = true + this.error = null + + try { + const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/general/${id}`) + await axios.delete(url) + this.items = this.items.filter(i => i.id !== id) + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Löschen des Materials' + throw err + } finally { + this.loading = false + } + }, + + setSearchText(text) { + this.searchText = text + }, + + clearError() { + this.error = null + }, + }, +}) diff --git a/src/stores/inventoryReports.js b/src/stores/inventoryReports.js new file mode 100644 index 0000000..dfb23d7 --- /dev/null +++ b/src/stores/inventoryReports.js @@ -0,0 +1,73 @@ +/** + * Pinia store for inventory report data. + * + * Handles fetching condition and sales report data. + * + * Part of Issue #165 (Inventory Tracking). + */ +import { defineStore } from 'pinia' +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' + +export const useInventoryReportsStore = defineStore('inventoryReports', { + state: () => ({ + /** @type {object|null} Condition report data */ + conditionReport: null, + /** @type {object|null} Sales report data */ + salesReport: null, + /** @type {boolean} Loading state for condition report */ + conditionLoading: false, + /** @type {boolean} Loading state for sales report */ + salesLoading: false, + /** @type {string|null} Error message */ + error: null, + }), + + actions: { + /** + * Fetch condition report data. + */ + async fetchConditionReport() { + this.conditionLoading = true + this.error = null + + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/reports/condition') + const response = await axios.get(url) + this.conditionReport = response.data + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Laden des Materialzustandsberichts' + console.error('Failed to fetch condition report:', err) + } finally { + this.conditionLoading = false + } + }, + + /** + * Fetch sales report data with optional date range. + */ + async fetchSalesReport(dateFrom, dateTo) { + this.salesLoading = true + this.error = null + + try { + const params = {} + if (dateFrom) params.dateFrom = dateFrom + if (dateTo) params.dateTo = dateTo + + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/reports/sales') + const response = await axios.get(url, { params }) + this.salesReport = response.data + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Laden des Inventur-Verkaufsberichts' + console.error('Failed to fetch sales report:', err) + } finally { + this.salesLoading = false + } + }, + + clearError() { + this.error = null + }, + }, +}) diff --git a/src/stores/sales.js b/src/stores/sales.js new file mode 100644 index 0000000..37690d5 --- /dev/null +++ b/src/stores/sales.js @@ -0,0 +1,122 @@ +/** + * Pinia store for inventory sales records. + * + * Handles fetching, creating, and filtering sales records. + * + * Part of Issue #165 (Inventory Tracking). + */ +import { defineStore } from 'pinia' +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' + +export const useSalesStore = defineStore('inventorySales', { + state: () => ({ + /** @type {Array} List of sales records */ + sales: [], + /** @type {string|null} Filter from date */ + filterDateFrom: null, + /** @type {string|null} Filter to date */ + filterDateTo: null, + /** @type {boolean} Loading state */ + loading: false, + /** @type {string|null} Error message */ + error: null, + }), + + getters: { + /** + * Filtered sales based on date range. + */ + filteredSales: (state) => { + if (!state.filterDateFrom && !state.filterDateTo) { + return state.sales + } + return state.sales.filter(sale => { + const saleDate = sale.date + if (state.filterDateFrom && saleDate < state.filterDateFrom) { + return false + } + if (state.filterDateTo && saleDate > state.filterDateTo) { + return false + } + return true + }) + }, + }, + + actions: { + /** + * Fetch sales records with optional date filter. + */ + async fetchSales() { + this.loading = true + this.error = null + + try { + const params = {} + if (this.filterDateFrom) params.dateFrom = this.filterDateFrom + if (this.filterDateTo) params.dateTo = this.filterDateTo + + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/sales') + const response = await axios.get(url, { params }) + this.sales = response.data.data || [] + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Laden der Verkäufe' + console.error('Failed to fetch sales:', err) + } finally { + this.loading = false + } + }, + + /** + * Create a new sale record. + */ + async createSale(data) { + this.loading = true + this.error = null + + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/sales') + const response = await axios.post(url, data) + this.sales.unshift(response.data) + return response.data + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Erstellen des Verkaufs' + throw err + } finally { + this.loading = false + } + }, + + /** + * Delete a sale record. + */ + async deleteSale(id) { + this.loading = true + this.error = null + + try { + const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/sales/${id}`) + await axios.delete(url) + this.sales = this.sales.filter(s => s.id !== id) + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Löschen des Verkaufs' + throw err + } finally { + this.loading = false + } + }, + + setFilterDateFrom(date) { + this.filterDateFrom = date + }, + + setFilterDateTo(date) { + this.filterDateTo = date + }, + + clearError() { + this.error = null + }, + }, +}) diff --git a/src/stores/stock.js b/src/stores/stock.js new file mode 100644 index 0000000..7f4ddec --- /dev/null +++ b/src/stores/stock.js @@ -0,0 +1,181 @@ +/** + * Pinia store for stock (sales) items. + * + * Handles fetching and CRUD operations for stock items with variants. + * + * Part of Issue #165 (Inventory Tracking). + */ +import { defineStore } from 'pinia' +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' + +export const useStockStore = defineStore('inventoryStock', { + state: () => ({ + /** @type {Array} List of stock items */ + items: [], + /** @type {string|null} Current search/filter text */ + searchText: '', + /** @type {boolean} Loading state */ + loading: false, + /** @type {string|null} Error message */ + error: null, + }), + + getters: { + /** + * Filtered items based on search text. + */ + filteredItems: (state) => { + if (!state.searchText) return state.items + const lower = state.searchText.toLowerCase() + return state.items.filter(item => + item.name.toLowerCase().includes(lower) + ) + }, + }, + + actions: { + /** + * Fetch all stock items with their variants. + */ + async fetchItems() { + this.loading = true + this.error = null + + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/stock') + const response = await axios.get(url) + this.items = response.data.data || [] + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Laden des Verkaufsmaterials' + console.error('Failed to fetch stock items:', err) + } finally { + this.loading = false + } + }, + + /** + * Create a new stock item with variants. + */ + async createItem(data) { + this.loading = true + this.error = null + + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/stock') + const response = await axios.post(url, data) + this.items.push(response.data) + this.items.sort((a, b) => a.name.localeCompare(b.name)) + return response.data + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Erstellen des Verkaufsmaterials' + throw err + } finally { + this.loading = false + } + }, + + /** + * Update an existing stock item. + */ + async updateItem(id, data) { + this.loading = true + this.error = null + + try { + const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/stock/${id}`) + const response = await axios.put(url, data) + const index = this.items.findIndex(i => i.id === id) + if (index !== -1) { + this.items[index] = response.data + } + return response.data + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Aktualisieren des Verkaufsmaterials' + throw err + } finally { + this.loading = false + } + }, + + /** + * Delete a stock item. + */ + async deleteItem(id) { + this.loading = true + this.error = null + + try { + const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/stock/${id}`) + await axios.delete(url) + this.items = this.items.filter(i => i.id !== id) + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Löschen des Verkaufsmaterials' + throw err + } finally { + this.loading = false + } + }, + + /** + * Add a variant to an item. + */ + async addVariant(stockItemId, variantData) { + this.loading = true + this.error = null + + try { + const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/variants') + const response = await axios.post(url, { + stock_item_id: stockItemId, + ...variantData, + }) + + const item = this.items.find(i => i.id === stockItemId) + if (item) { + if (!item.variants) item.variants = [] + item.variants.push(response.data) + item.total_amount = (item.total_amount || 0) + (response.data.amount || 0) + } + + return response.data + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Erstellen der Variante' + throw err + } finally { + this.loading = false + } + }, + + /** + * Delete a variant. + */ + async deleteVariant(variantId, stockItemId) { + this.loading = true + this.error = null + + try { + const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/variants/${variantId}`) + await axios.delete(url) + + const item = this.items.find(i => i.id === stockItemId) + if (item && item.variants) { + item.variants = item.variants.filter(v => v.id !== variantId) + } + } catch (err) { + this.error = err.response?.data?.error || 'Fehler beim Löschen der Variante' + throw err + } finally { + this.loading = false + } + }, + + setSearchText(text) { + this.searchText = text + }, + + clearError() { + this.error = null + }, + }, +}) diff --git a/tests/Db/GeneralMaterialMapperTest.php b/tests/Db/GeneralMaterialMapperTest.php new file mode 100644 index 0000000..1ee03da --- /dev/null +++ b/tests/Db/GeneralMaterialMapperTest.php @@ -0,0 +1,130 @@ +db = $this->createMock(IDBConnection::class); + $this->qb = $this->createMock(IQueryBuilder::class); + $this->expr = $this->createMock(IExpressionBuilder::class); + $this->result = $this->createMock(IResult::class); + + $this->qb->method('expr')->willReturn($this->expr); + $this->qb->method('select')->willReturnSelf(); + $this->qb->method('from')->willReturnSelf(); + $this->qb->method('where')->willReturnSelf(); + $this->qb->method('andWhere')->willReturnSelf(); + $this->qb->method('orderBy')->willReturnSelf(); + $this->qb->method('gte')->willReturn('col >= :param'); + $this->qb->method('lte')->willReturn('col <= :param'); + $this->qb->method('isNotNull')->willReturn('col IS NOT NULL'); + $this->qb->method('insert')->willReturnSelf(); + $this->qb->method('delete')->willReturnSelf(); + $this->qb->method('values')->willReturnSelf(); + $this->qb->method('createNamedParameter')->willReturn(':param'); + $this->qb->method('createFunction')->willReturnCallback(fn($call) => $call); + $this->qb->method('getSQL')->willReturn('SELECT * FROM test'); + + $this->expr->method('eq')->willReturn('col = :param'); + $this->expr->method('isNull')->willReturn('col IS NULL'); + $this->expr->method('neq')->willReturn('col != :param'); + $this->expr->method('orX')->willReturn('or_expr'); + + $this->db->method('getQueryBuilder')->willReturn($this->qb); + } + + private function configureResultRows(array $rows): void { + $fetchCalls = array_merge($rows, [false]); + $this->result->method('fetch') + ->willReturnOnConsecutiveCalls(...$fetchCalls); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function configureResultSingleRow(array $row): void { + $this->result->method('fetch') + ->willReturnOnConsecutiveCalls($row, false); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function configureResultEmpty(): void { + $this->result->method('fetch')->willReturn(false); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function itemRow(int $id = 1, string $name = 'Zelt A', ?int $condition = 4, ?string $notes = 'Sehr gut'): array { + return [ + 'id' => $id, + 'name' => $name, + 'condition' => $condition, + 'notes' => $notes, + 'created_at' => '2026-04-21 00:00:00', + 'updated_at' => '2026-04-21 00:00:00', + ]; + } + + public function testCreateAndFind(): void { + $this->configureResultSingleRow($this->itemRow(42, 'Zelt A', 4, 'Sehr gut')); + $mapper = new GeneralMaterialMapper($this->db); + $item = $mapper->findById(42); + $this->assertSame(42, $item->getId()); + $this->assertSame('Zelt A', $item->getName()); + $this->assertSame(4, $item->getCondition()); + } + + public function testFindAll(): void { + $this->configureResultRows([ + $this->itemRow(1, 'Zelt A', 4), + $this->itemRow(2, 'Zelt B', 3), + ]); + $mapper = new GeneralMaterialMapper($this->db); + $items = $mapper->findAll(); + $this->assertCount(2, $items); + $this->assertSame('Zelt A', $items[0]->getName()); + } + + public function testFindByConditionRange(): void { + $this->configureResultRows([$this->itemRow(1, 'Zelt A', 1), $this->itemRow(2, 'Zelt B', 2)]); + $mapper = new GeneralMaterialMapper($this->db); + $items = $mapper->findByConditionRange(0, 2); + $this->assertCount(2, $items); + $this->assertLessThanOrEqual(2, $items[0]->getCondition()); + } + + public function testFindByConditionRangeEmpty(): void { + $this->configureResultRows([]); + $mapper = new GeneralMaterialMapper($this->db); + $items = $mapper->findByConditionRange(4, 5); + $this->assertCount(0, $items); + } + + public function testUpdateCondition(): void { + $this->configureResultSingleRow($this->itemRow(1, 'Zelt A', 3, 'Gut')); + $mapper = new GeneralMaterialMapper($this->db); + $item = $mapper->findById(1); + $this->assertSame(3, $item->getCondition()); + $this->assertSame('Gut', $item->getNotes()); + } +} diff --git a/tests/Db/InventoryCategoryMapperTest.php b/tests/Db/InventoryCategoryMapperTest.php new file mode 100644 index 0000000..0dece65 --- /dev/null +++ b/tests/Db/InventoryCategoryMapperTest.php @@ -0,0 +1,121 @@ +db = $this->createMock(IDBConnection::class); + $this->qb = $this->createMock(IQueryBuilder::class); + $this->expr = $this->createMock(IExpressionBuilder::class); + $this->result = $this->createMock(IResult::class); + + $this->qb->method('expr')->willReturn($this->expr); + $this->qb->method('select')->willReturnSelf(); + $this->qb->method('from')->willReturnSelf(); + $this->qb->method('where')->willReturnSelf(); + $this->qb->method('andWhere')->willReturnSelf(); + $this->qb->method('orderBy')->willReturnSelf(); + $this->qb->method('insert')->willReturnSelf(); + $this->qb->method('delete')->willReturnSelf(); + $this->qb->method('values')->willReturnSelf(); + $this->qb->method('createNamedParameter')->willReturn(':param'); + $this->qb->method('createFunction')->willReturnCallback(fn($call) => $call); + $this->qb->method('getSQL')->willReturn('SELECT * FROM test'); + + $this->expr->method('eq')->willReturn('col = :param'); + $this->expr->method('isNull')->willReturn('col IS NULL'); + $this->expr->method('neq')->willReturn('col != :param'); + + $this->db->method('getQueryBuilder')->willReturn($this->qb); + } + + private function configureResultRows(array $rows): void { + $fetchCalls = array_merge($rows, [false]); + $this->result->method('fetch') + ->willReturnOnConsecutiveCalls(...$fetchCalls); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function configureResultSingleRow(array $row): void { + $this->result->method('fetch') + ->willReturnOnConsecutiveCalls($row, false); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function configureResultEmpty(): void { + $this->result->method('fetch')->willReturn(false); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function categoryRow(int $id = 1, string $name = 'Zelte'): array { + return [ + 'id' => $id, + 'name' => $name, + 'created_at' => '2026-04-21 00:00:00', + ]; + } + + public function testCreateAndFindById(): void { + $this->configureResultSingleRow($this->categoryRow(42, 'Zelte')); + $mapper = new InventoryCategoryMapper($this->db); + $category = $mapper->findById(42); + $this->assertSame(42, $category->getId()); + $this->assertSame('Zelte', $category->getName()); + } + + public function testFindByName(): void { + $this->configureResultSingleRow($this->categoryRow(1, 'Kochgeschirr')); + $mapper = new InventoryCategoryMapper($this->db); + $category = $mapper->findByName('Kochgeschirr'); + $this->assertSame('Kochgeschirr', $category->getName()); + } + + public function testFindByNameThrowsWhenNotFound(): void { + $this->configureResultEmpty(); + $mapper = new InventoryCategoryMapper($this->db); + $this->expectException(DoesNotExistException::class); + $mapper->findByName('NichtExistent'); + } + + public function testFindAll(): void { + $this->configureResultRows([ + $this->categoryRow(1, 'Zelte'), + $this->categoryRow(2, 'Kochgeschirr'), + $this->categoryRow(3, 'Seile'), + ]); + $mapper = new InventoryCategoryMapper($this->db); + $categories = $mapper->findAll(); + $this->assertCount(3, $categories); + $this->assertSame('Zelte', $categories[0]->getName()); + } + + public function testFindAllEmpty(): void { + $this->configureResultRows([]); + $mapper = new InventoryCategoryMapper($this->db); + $categories = $mapper->findAll(); + $this->assertCount(0, $categories); + } +} diff --git a/tests/Db/InventoryItemCategoryMapperTest.php b/tests/Db/InventoryItemCategoryMapperTest.php new file mode 100644 index 0000000..9a5fbc7 --- /dev/null +++ b/tests/Db/InventoryItemCategoryMapperTest.php @@ -0,0 +1,96 @@ +db = $this->createMock(IDBConnection::class); + $this->qb = $this->createMock(IQueryBuilder::class); + $this->expr = $this->createMock(IExpressionBuilder::class); + $this->result = $this->createMock(IResult::class); + + $this->qb->method('expr')->willReturn($this->expr); + $this->qb->method('select')->willReturnSelf(); + $this->qb->method('from')->willReturnSelf(); + $this->qb->method('where')->willReturnSelf(); + $this->qb->method('andWhere')->willReturnSelf(); + $this->qb->method('orderBy')->willReturnSelf(); + $this->qb->method('insert')->willReturnSelf(); + $this->qb->method('delete')->willReturnSelf(); + $this->qb->method('values')->willReturnSelf(); + $this->qb->method('createNamedParameter')->willReturn(':param'); + $this->qb->method('createFunction')->willReturnCallback(fn($call) => $call); + $this->qb->method('getSQL')->willReturn('SELECT * FROM test'); + $this->qb->method('executeStatement')->willReturn(1); + + $this->expr->method('eq')->willReturn('col = :param'); + $this->expr->method('isNull')->willReturn('col IS NULL'); + + $this->db->method('getQueryBuilder')->willReturn($this->qb); + } + + private function configureResultRows(array $rows): void { + $fetchCalls = array_merge($rows, [false]); + $this->result->method('fetch') + ->willReturnOnConsecutiveCalls(...$fetchCalls); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function row(int $itemId = 1, int $categoryId = 1): array { + return [ + 'item_id' => $itemId, + 'category_id' => $categoryId, + ]; + } + + public function testAttach_detach(): void { + $mapper = new InventoryItemCategoryMapper($this->db); + $mapper->attach(1, 1); + $this->assertTrue(true); // No exception = success + } + + public function testDetach(): void { + $mapper = new InventoryItemCategoryMapper($this->db); + $mapper->detach(1, 1); + $this->assertTrue(true); + } + + public function testFindByItemIdReturnsCategories(): void { + $this->configureResultRows([ + $this->row(1, 1), + $this->row(1, 2), + ]); + $mapper = new InventoryItemCategoryMapper($this->db); + $cats = $mapper->findByItemId(1); + $this->assertCount(2, $cats); + $this->assertSame(1, $cats[0]->getCategoryId()); + $this->assertSame(2, $cats[1]->getCategoryId()); + } + + public function testFindByItemIdEmpty(): void { + $this->configureResultRows([]); + $mapper = new InventoryItemCategoryMapper($this->db); + $cats = $mapper->findByItemId(999); + $this->assertCount(0, $cats); + } +} diff --git a/tests/Db/SaleRecordMapperTest.php b/tests/Db/SaleRecordMapperTest.php new file mode 100644 index 0000000..60f98fa --- /dev/null +++ b/tests/Db/SaleRecordMapperTest.php @@ -0,0 +1,134 @@ +db = $this->createMock(IDBConnection::class); + $this->qb = $this->createMock(IQueryBuilder::class); + $this->expr = $this->createMock(IExpressionBuilder::class); + $this->result = $this->createMock(IResult::class); + + $this->qb->method('expr')->willReturn($this->expr); + $this->qb->method('select')->willReturnSelf(); + $this->qb->method('selectDistinct')->willReturnSelf(); + $this->qb->method('from')->willReturnSelf(); + $this->qb->method('where')->willReturnSelf(); + $this->qb->method('andWhere')->willReturnSelf(); + $this->qb->method('orderBy')->willReturnSelf(); + $this->qb->method('insert')->willReturnSelf(); + $this->qb->method('delete')->willReturnSelf(); + $this->qb->method('values')->willReturnSelf(); + $this->qb->method('createNamedParameter')->willReturn(':param'); + $this->qb->method('createFunction')->willReturnCallback(fn($call) => $call); + $this->qb->method('getSQL')->willReturn('SELECT * FROM test'); + + $this->expr->method('eq')->willReturn('col = :param'); + $this->expr->method('gte')->willReturn('col >= :param'); + $this->expr->method('lte')->willReturn('col <= :param'); + $this->expr->method('isNull')->willReturn('col IS NULL'); + $this->expr->method('neq')->willReturn('col != :param'); + $this->expr->method('orX')->willReturn('or_expr'); + + $this->db->method('getQueryBuilder')->willReturn($this->qb); + } + + private function configureResultRows(array $rows): void { + $fetchCalls = array_merge($rows, [false]); + $this->result->method('fetch') + ->willReturnOnConsecutiveCalls(...$fetchCalls); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function configureResultSingleRow(array $row): void { + $this->result->method('fetch') + ->willReturnOnConsecutiveCalls($row, false); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function configureResultEmpty(): void { + $this->result->method('fetch')->willReturn(false); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function configureResultCount(int $count): void { + $this->result->method('fetchOne')->willReturn((string)$count); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function saleRow(int $id = 1, ?int $stockItemId = 1, ?int $variantId = 1, string $date = '2026-04-21', int $quantity = 2, string $unitPrice = '25.00', string $totalPrice = '50.00'): array { + return [ + 'id' => $id, + 'stock_item_id' => $stockItemId, + 'variant_id' => $variantId, + 'date' => $date, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'total_price' => $totalPrice, + 'notes' => null, + 'created_at' => '2026-04-21 00:00:00', + ]; + } + + public function testCreateAndFind(): void { + $this->configureResultSingleRow($this->saleRow(42, 1, 1, '2026-04-21', 2, '25.00', '50.00')); + $mapper = new SaleRecordMapper($this->db); + $sale = $mapper->findById(42); + $this->assertSame(42, $sale->getId()); + $this->assertSame(2, $sale->getQuantity()); + $this->assertSame('50.00', $sale->getTotalPrice()); + } + + public function testFindByDateRange(): void { + $this->configureResultRows([ + $this->saleRow(1, 1, null, '2026-03-15', 1, '30.00', '30.00'), + $this->saleRow(2, 1, 2, '2026-04-01', 2, '25.00', '50.00'), + ]); + $mapper = new SaleRecordMapper($this->db); + $sales = $mapper->findByDateRange('2026-03-01', '2026-04-30'); + $this->assertCount(2, $sales); + $this->assertSame('2026-04-01', $sales[0]->getDate()); + } + + public function testFindByDateRangeEmpty(): void { + $this->configureResultRows([]); + $mapper = new SaleRecordMapper($this->db); + $sales = $mapper->findByDateRange('2000-01-01', '2000-12-31'); + $this->assertCount(0, $sales); + } + + public function testTotalPriceCalculation(): void { + $this->configureResultSingleRow($this->saleRow(1, 1, 1, '2026-04-01', 3, '15.00', '45.00')); + $mapper = new SaleRecordMapper($this->db); + $sale = $mapper->findById(1); + $this->assertSame(3, $sale->getQuantity()); + $this->assertSame('15.00', $sale->getUnitPrice()); + $this->assertSame('45.00', $sale->getTotalPrice()); + // Verify: 3 × 15.00 = 45.00 + $this->assertSame((float)$sale->getTotalPrice(), (float)$sale->getQuantity() * (float)$sale->getUnitPrice()); + } +} diff --git a/tests/Db/StockItemMapperTest.php b/tests/Db/StockItemMapperTest.php new file mode 100644 index 0000000..e8652cd --- /dev/null +++ b/tests/Db/StockItemMapperTest.php @@ -0,0 +1,108 @@ +db = $this->createMock(IDBConnection::class); + $this->qb = $this->createMock(IQueryBuilder::class); + $this->expr = $this->createMock(IExpressionBuilder::class); + $this->result = $this->createMock(IResult::class); + + $this->qb->method('expr')->willReturn($this->expr); + $this->qb->method('select')->willReturnSelf(); + $this->qb->method('from')->willReturnSelf(); + $this->qb->method('where')->willReturnSelf(); + $this->qb->method('orderBy')->willReturnSelf(); + $this->qb->method('createNamedParameter')->willReturn(':param'); + $this->qb->method('createFunction')->willReturnCallback(fn($call) => $call); + $this->qb->method('getSQL')->willReturn('SELECT * FROM test'); + + $this->expr->method('eq')->willReturn('col = :param'); + $this->expr->method('isNull')->willReturn('col IS NULL'); + + $this->db->method('getQueryBuilder')->willReturn($this->qb); + } + + private function configureResultRows(array $rows): void { + $fetchCalls = array_merge($rows, [false]); + $this->result->method('fetch') + ->willReturnOnConsecutiveCalls(...$fetchCalls); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function configureResultSingleRow(array $row): void { + $this->result->method('fetch') + ->willReturnOnConsecutiveCalls($row, false); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function configureResultEmpty(): void { + $this->result->method('fetch')->willReturn(false); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function stockRow(int $id = 1, string $name = 'Pfadfinderhemd', ?string $urls = '[{"url":"https://shop.example.com","provider":"shop"}]'): array { + return [ + 'id' => $id, + 'name' => $name, + 'provider_urls_json' => $urls, + 'created_at' => '2026-04-21 00:00:00', + 'updated_at' => '2026-04-21 00:00:00', + ]; + } + + public function testCreateAndFind(): void { + $this->configureResultSingleRow($this->stockRow(42, 'Pfadfinderhemd')); + $mapper = new StockItemMapper($this->db); + $item = $mapper->findById(42); + $this->assertSame(42, $item->getId()); + $this->assertSame('Pfadfinderhemd', $item->getName()); + } + + public function testFindAll(): void { + $this->configureResultRows([ + $this->stockRow(1, 'Pfadfinderhemd'), + $this->stockRow(2, 'Pfadfindershirt'), + ]); + $mapper = new StockItemMapper($this->db); + $items = $mapper->findAll(); + $this->assertCount(2, $items); + $this->assertSame('Pfadfinderhemd', $items[0]->getName()); + } + + public function testProviderUrlsJsonSerialization(): void { + $this->configureResultSingleRow($this->stockRow(1, 'Hemd', '[{"url":"https://shop.example.com","provider":"shop"},{"url":"https://bestell.example.com","provider":"bestell"}]')); + $mapper = new StockItemMapper($this->db); + $item = $mapper->findById(1); + $json = $item->getProviderUrlsJson(); + $this->assertNotNull($json); + $decoded = json_decode($json, true); + $this->assertCount(2, $decoded); + $this->assertSame('https://shop.example.com', $decoded[0]['url']); + $this->assertSame('shop', $decoded[0]['provider']); + } +} diff --git a/tests/Db/StockVariantMapperTest.php b/tests/Db/StockVariantMapperTest.php new file mode 100644 index 0000000..228b36a --- /dev/null +++ b/tests/Db/StockVariantMapperTest.php @@ -0,0 +1,106 @@ +db = $this->createMock(IDBConnection::class); + $this->qb = $this->createMock(IQueryBuilder::class); + $this->expr = $this->createMock(IExpressionBuilder::class); + $this->result = $this->createMock(IResult::class); + + $this->qb->method('expr')->willReturn($this->expr); + $this->qb->method('select')->willReturnSelf(); + $this->qb->method('from')->willReturnSelf(); + $this->qb->method('where')->willReturnSelf(); + $this->qb->method('orderBy')->willReturnSelf(); + $this->qb->method('createNamedParameter')->willReturn(':param'); + $this->qb->method('createFunction')->willReturnCallback(fn($call) => $call); + $this->qb->method('getSQL')->willReturn('SELECT * FROM test'); + + $this->expr->method('eq')->willReturn('col = :param'); + $this->expr->method('isNull')->willReturn('col IS NULL'); + + $this->db->method('getQueryBuilder')->willReturn($this->qb); + } + + private function configureResultRows(array $rows): void { + $fetchCalls = array_merge($rows, [false]); + $this->result->method('fetch') + ->willReturnOnConsecutiveCalls(...$fetchCalls); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function configureResultSingleRow(array $row): void { + $this->result->method('fetch') + ->willReturnOnConsecutiveCalls($row, false); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function configureResultEmpty(): void { + $this->result->method('fetch')->willReturn(false); + $this->result->method('closeCursor')->willReturn(true); + $this->qb->method('executeQuery')->willReturn($this->result); + } + + private function variantRow(int $id = 1, int $stockItemId = 1, string $label = 'Größe M', int $amount = 10, string $cost = '25.00', int $minThreshold = 3): array { + return [ + 'id' => $id, + 'stock_item_id' => $stockItemId, + 'label' => $label, + 'amount' => $amount, + 'cost' => $cost, + 'min_threshold' => $minThreshold, + ]; + } + + public function testCreate_attachToStockItem(): void { + $this->configureResultSingleRow($this->variantRow(1, 1, 'Größe M')); + $mapper = new StockVariantMapper($this->db); + $variant = $mapper->findById(1); + $this->assertSame(1, $variant->getId()); + $this->assertSame(1, $variant->getStockItemId()); + $this->assertSame('Größe M', $variant->getLabel()); + } + + public function testFindByStockItemId(): void { + $this->configureResultRows([ + $this->variantRow(1, 1, 'Größe M'), + $this->variantRow(2, 1, 'Größe L'), + ]); + $mapper = new StockVariantMapper($this->db); + $variants = $mapper->findByStockItemId(1); + $this->assertCount(2, $variants); + $this->assertSame('Größe M', $variants[0]->getLabel()); + $this->assertSame('Größe L', $variants[1]->getLabel()); + } + + public function testFindByStockItemIdEmpty(): void { + $this->configureResultRows([]); + $mapper = new StockVariantMapper($this->db); + $variants = $mapper->findByStockItemId(999); + $this->assertCount(0, $variants); + } +} diff --git a/tests/Integration/ReportControllerTest.php b/tests/Integration/ReportControllerTest.php index 2709a94..efb74e3 100644 --- a/tests/Integration/ReportControllerTest.php +++ b/tests/Integration/ReportControllerTest.php @@ -6,6 +6,7 @@ namespace OCA\Mitgliederverwaltung\Tests\Integration; use OCA\Mitgliederverwaltung\Controller\ReportController; use OCA\Mitgliederverwaltung\Service\EncryptedExportService; +use OCA\Mitgliederverwaltung\Service\InventoryReportService; use OCA\Mitgliederverwaltung\Service\PdfService; use OCA\Mitgliederverwaltung\Service\PermissionService; use OCA\Mitgliederverwaltung\Service\ReportService; @@ -25,6 +26,7 @@ class ReportControllerTest extends TestCase { private ReportController $controller; private ReportService&MockObject $reportService; + private InventoryReportService&MockObject $inventoryReportService; private PdfService&MockObject $pdfService; private EncryptedExportService&MockObject $encryptedService; private PermissionService&MockObject $permissionService; @@ -36,6 +38,7 @@ class ReportControllerTest extends TestCase { parent::setUp(); $this->reportService = $this->createMock(ReportService::class); + $this->inventoryReportService = $this->createMock(InventoryReportService::class); $this->pdfService = $this->createMock(PdfService::class); $this->encryptedService = $this->createMock(EncryptedExportService::class); $this->permissionService = $this->createMock(PermissionService::class); @@ -52,6 +55,7 @@ class ReportControllerTest extends TestCase { 'mitgliederverwaltung', $this->request, $this->reportService, + $this->inventoryReportService, $this->pdfService, $this->encryptedService, $this->permissionService, diff --git a/tests/Service/InventoryReportServiceTest.php b/tests/Service/InventoryReportServiceTest.php new file mode 100644 index 0000000..ffa5404 --- /dev/null +++ b/tests/Service/InventoryReportServiceTest.php @@ -0,0 +1,163 @@ +generalMaterialMapper = $this->createMock(GeneralMaterialMapper::class); + $this->categoryMapper = $this->createMock(InventoryCategoryMapper::class); + $this->itemCategoryMapper = $this->createMock(InventoryItemCategoryMapper::class); + $this->stockItemMapper = $this->createMock(StockItemMapper::class); + $this->stockVariantMapper = $this->createMock(StockVariantMapper::class); + $this->saleRecordMapper = $this->createMock(SaleRecordMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new InventoryReportService( + $this->generalMaterialMapper, + $this->categoryMapper, + $this->itemCategoryMapper, + $this->stockItemMapper, + $this->stockVariantMapper, + $this->saleRecordMapper, + $this->logger + ); + } + + private function generalMaterialEntity(int $id = 1, string $name = 'Zelt A', ?int $condition = 1): GeneralMaterial { + $entity = new GeneralMaterial(); + $entity->setId($id); + $entity->setName($name); + $entity->setCondition($condition); + $entity->setNotes(null); + return $entity; + } + + private function categoryEntity(int $id = 1, string $name = 'Zelte'): InventoryCategory { + $entity = new InventoryCategory(); + $entity->setId($id); + $entity->setName($name); + return $entity; + } + + private function itemCategoryEntity(int $itemId = 1, int $categoryId = 1): InventoryItemCategory { + $entity = new InventoryItemCategory(); + $entity->setItemId($itemId); + $entity->setCategoryId($categoryId); + return $entity; + } + + private function saleEntity(int $id = 1, int $stockItemId = 1, string $quantity = '2', string $unitPrice = '25.00'): SaleRecord { + $entity = new SaleRecord(); + $entity->setId($id); + $entity->setStockItemId($stockItemId); + $entity->setVariantId(null); + $entity->setDate('2026-04-01'); + $entity->setQuantity((int)$quantity); + $entity->setUnitPrice($unitPrice); + $entity->setTotalPrice((string)((float)$quantity * (float)$unitPrice)); + $entity->setNotes(null); + $entity->setCreatedAt('2026-04-01 00:00:00'); + return $entity; + } + + public function testGenerateSalesReport_dateFiltered(): void { + $sales = [ + $this->saleEntity(1, 1, '2', '25.00'), + $this->saleEntity(2, 1, '1', '30.00'), + ]; + + $this->saleRecordMapper->method('findByDateRange') + ->with('2026-01-01', '2026-03-31') + ->willReturn($sales); + + $report = $this->service->generateSalesReport('2026-01-01', '2026-03-31'); + + $this->assertStringContainsString('2026-01-01', $report['title']); + $this->assertCount(2, $report['rows']); + $this->assertArrayHasKey('total_sales', $report['summary']); + $this->assertSame(2, (int)$report['summary']['total_sales']); + } + + public function testGenerateConditionReport_summaryByCategory(): void { + $item1 = $this->generalMaterialEntity(1, 'Zelt A', 3); + $item2 = $this->generalMaterialEntity(2, 'Zelt B', 4); + + $this->generalMaterialMapper->method('findAll') + ->willReturn([$item1, $item2]); + + $itemCat1 = $this->itemCategoryEntity(1, 1); + $this->itemCategoryMapper->method('findByItemId') + ->willReturnCallback(function($itemId) use ($itemCat1) { + if ($itemId === 1) { + return [$itemCat1]; + } + return []; + }); + + $catEntity = $this->categoryEntity(1, 'Zelte'); + $this->categoryMapper->method('findById') + ->willReturn($catEntity); + + // Also need findNeedingRepair to return empty + $this->generalMaterialMapper->method('findNeedingRepair') + ->willReturn([]); + + $report = $this->service->generateConditionReport(); + + $this->assertArrayHasKey('Zelte', $report['summary']); + $this->assertSame(1, $report['summary']['Zelte']); + } + + public function testGenerateConditionReport_needsRepairOnly(): void { + // One good item, one needing repair + $goodItem = $this->generalMaterialEntity(1, 'Zelt A', 4); + $badItem = $this->generalMaterialEntity(2, 'Zelt B', 1); + + $this->generalMaterialMapper->method('findAll') + ->willReturn([$goodItem, $badItem]); + + $this->itemCategoryMapper->method('findByItemId') + ->willReturn([]); + + $this->generalMaterialMapper->method('findNeedingRepair') + ->willReturn([$badItem]); + + $report = $this->service->generateConditionReport(); + + // Only the bad item should be in rows + $this->assertCount(1, $report['rows']); + $this->assertSame('Zelt B', $report['rows'][0]['name']); + $this->assertSame('1/5', $report['rows'][0]['condition']); + } +} diff --git a/tests/Service/InventoryServiceTest.php b/tests/Service/InventoryServiceTest.php new file mode 100644 index 0000000..ac83049 --- /dev/null +++ b/tests/Service/InventoryServiceTest.php @@ -0,0 +1,160 @@ +categoryMapper = $this->createMock(InventoryCategoryMapper::class); + $this->itemCategoryMapper = $this->createMock(InventoryItemCategoryMapper::class); + $this->generalMaterialMapper = $this->createMock(GeneralMaterialMapper::class); + $this->stockItemMapper = $this->createMock(StockItemMapper::class); + $this->stockVariantMapper = $this->getMockBuilder(\OCA\Mitgliederverwaltung\Db\StockVariantMapper::class) + ->disableOriginalConstructor() + ->onlyMethods(['findByStockItemId', 'findById', 'insert', 'update', 'delete']) + ->getMock(); + $this->auditService = $this->createMock(AuditService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new InventoryService( + $this->categoryMapper, + $this->itemCategoryMapper, + $this->generalMaterialMapper, + $this->stockItemMapper, + $this->stockVariantMapper, + $this->auditService, + $this->logger + ); + } + + private function generalMaterialEntity(int $id = 1, string $name = 'Zelt A', ?int $condition = 3): GeneralMaterial { + $entity = new GeneralMaterial(); + $entity->setId($id); + $entity->setName($name); + $entity->setCondition($condition); + $entity->setNotes(null); + $entity->setCreatedAt('2026-04-21 00:00:00'); + $entity->setUpdatedAt('2026-04-21 00:00:00'); + return $entity; + } + + private function categoryEntity(int $id = 1, string $name = 'Zelte'): InventoryCategory { + $entity = new InventoryCategory(); + $entity->setId($id); + $entity->setName($name); + $entity->setCreatedAt('2026-04-21 00:00:00'); + return $entity; + } + + private function itemCategoryEntity(int $itemId = 1, int $categoryId = 1): InventoryItemCategory { + $entity = new InventoryItemCategory(); + $entity->setItemId($itemId); + $entity->setCategoryId($categoryId); + return $entity; + } + + public function testCreateGeneralMaterial_auditLogged(): void { + $gm = $this->generalMaterialEntity(1, 'Zelt A', 4); + $this->generalMaterialMapper->method('insert')->willReturnCallback(function ($entity) use ($gm) { + $entity->setId($gm->getId()); + }); + $this->categoryMapper->method('findById')->willReturn($this->categoryEntity(1, 'Zelte')); + $this->itemCategoryMapper->method('findByItemId')->willReturn([$this->itemCategoryEntity(1, 1)]); + + $this->auditService->method('logCreate')->willReturnCallback(function(array $data, string $type, int $id) { + $this->assertSame('Zelt A', $data['name']); + $this->assertSame(4, $data['condition']); + $this->assertSame('inventory_general_material', $type); + $this->assertSame(1, $id); + }); + + $result = $this->service->createGeneralMaterial([ + 'name' => 'Zelt A', + 'condition' => 4, + 'notes' => null, + 'categories' => [1], + ]); + + $this->assertSame('Zelt A', $result->getName()); + } + + public function testCreateStockItem_auditLogged(): void { + $this->stockItemMapper->method('insert')->willReturnCallback(function ($entity) { + $entity->setId(1); + $entity->setName('Pfadfinderhemd'); + }); + $this->itemCategoryMapper->method('findByItemId')->willReturn([]); + + $this->auditService->method('logCreate')->willReturnCallback(function(array $data, string $type, int $id) { + $this->assertSame('Pfadfinderhemd', $data['name']); + $this->assertSame('inventory_stock_item', $type); + $this->assertSame(1, $id); + }); + + $result = $this->service->createStockItem([ + 'name' => 'Pfadfinderhemd', + 'categories' => null, + ]); + + $this->assertSame('Pfadfinderhemd', $result->getName()); + } + + public function testDelete_auditLogged(): void { + $gm = $this->generalMaterialEntity(1, 'Zelt A'); + $this->generalMaterialMapper->method('findById')->willReturn($gm); + + $this->auditService->method('logDelete')->willReturnCallback(function(string $type, int $id) { + $this->assertSame('inventory_general_material', $type); + $this->assertSame(1, $id); + }); + + $this->service->deleteGeneralMaterial(1); + + $this->assertTrue(true); + } + + public function testConditionAlerts_correctThreshold(): void { + $this->assertFalse($this->service->needsRepair(3)); + $this->assertFalse($this->service->needsRepair(4)); + $this->assertFalse($this->service->needsRepair(5)); + $this->assertTrue($this->service->needsRepair(2)); + $this->assertTrue($this->service->needsRepair(1)); + $this->assertTrue($this->service->needsRepair(0)); + $this->assertTrue($this->service->needsRepair(null)); + } + + public function testGetConditionLabel(): void { + $this->assertSame('Nicht bewertet', $this->service->getConditionLabel(null)); + $this->assertSame('1/5', $this->service->getConditionLabel(1)); + $this->assertSame('3/5', $this->service->getConditionLabel(3)); + $this->assertSame('5/5', $this->service->getConditionLabel(5)); + } +} diff --git a/tests/Service/SaleServiceTest.php b/tests/Service/SaleServiceTest.php new file mode 100644 index 0000000..4f92cd5 --- /dev/null +++ b/tests/Service/SaleServiceTest.php @@ -0,0 +1,86 @@ +saleRecordMapper = $this->createMock(SaleRecordMapper::class); + $this->auditService = $this->createMock(AuditService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new SaleService( + $this->saleRecordMapper, + $this->auditService, + $this->logger + ); + } + + private function saleEntity(int $id = 1, int $stockItemId = 1, int $variantId = 1, int $quantity = 2, string $unitPrice = '25.00'): SaleRecord { + $entity = new SaleRecord(); + $entity->setId($id); + $entity->setStockItemId($stockItemId); + $entity->setVariantId($variantId); + $entity->setDate('2026-04-21'); + $entity->setQuantity($quantity); + $entity->setUnitPrice($unitPrice); + $entity->setTotalPrice((string)((float)$unitPrice * $quantity)); + $entity->setNotes(null); + $entity->setCreatedAt('2026-04-21 00:00:00'); + return $entity; + } + + public function testCreateSale_auditLogged(): void { + $this->saleRecordMapper->method('insert')->willReturnCallback(function ($entity) use ($stockItemId = 1) { + $entity->setId(1); + }); + + $this->auditService->method('logCreate')->willReturnCallback(function(array $data, string $type, int $id) { + $this->assertSame(1, $data['stock_item_id']); + $this->assertSame('inventory_sale', $type); + $this->assertSame(1, $id); + }); + + $result = $this->service->createSale([ + 'stock_item_id' => 1, + 'variant_id' => 1, + 'date' => '2026-04-21', + 'quantity' => 2, + 'unit_price' => '25.00', + 'notes' => null, + ]); + + $this->assertSame('50.00', $result->getTotalPrice()); + } + + public function testSaleRecord_totalCalculation(): void { + $sale = $this->saleEntity(1, 1, 1, 3, '15.00'); + $expectedTotal = (float)$sale->getQuantity() * (float)$sale->getUnitPrice(); + $actualTotal = (float)$sale->getTotalPrice(); + $this->assertEqualsWithDelta($expectedTotal, $actualTotal, 0.01); + } + + public function testSaleRecord_totalCalculationZero(): void { + $sale = $this->saleEntity(1, 1, 1, 0, '0.00'); + $expectedTotal = 0.0; + $actualTotal = (float)$sale->getTotalPrice(); + $this->assertEqualsWithDelta($expectedTotal, $actualTotal, 0.01); + } +} diff --git a/webpack.config.js b/webpack.config.js index b0cbd31..45f8368 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -41,7 +41,7 @@ module.exports = { new VueLoaderPlugin(), new webpack.DefinePlugin({ appName: JSON.stringify('mitgliederverwaltung'), - appVersion: JSON.stringify('0.3.1'), + appVersion: JSON.stringify('0.3.2'), }), new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1,