Files
Mitgliederverwaltung/lib/Service/InventoryService.php
T
shahondin1624 53b3fd945a
Database Portability Tests / Integration (mysql) (push) Has been skipped
Database Portability Tests / Integration (postgres) (push) Has been skipped
Database Portability Tests / Integration (sqlite) (push) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (push) Successful in 5s
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 43s
Release v0.3.2: Inventory tracking, test infra, and bugfixes
Inventory:
- General material, stock items, and sales CRUD
- Category management with CategoryPicker component
- Inventory reports service
- Database migrations and mappers

Frontend:
- Inventory view with tabs (Allgemeinmaterial, Verkaufsmaterial, Verkäufe)
- Forms for general material, stock items, and sales
- Search bar fix: flexbox wrapper with inline icon replaces broken NcTextField icon slot

Tests:
- All 1,491 tests pass (zero errors, warnings, deprecations)
- BundleImportServiceTest: fixed ZipArchive empty-file deprecation
- BundleImportService: null-coalescing for targetFields
- PHPUnit test infra: make test target via container

CLAUDE.md:
- Added Testing section as PR gating criterion
2026-04-22 10:15:22 +02:00

487 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service;
use DateTime;
use OCA\Mitgliederverwaltung\Db\GeneralMaterial;
use OCA\Mitgliederverwaltung\Db\GeneralMaterialMapper;
use OCA\Mitgliederverwaltung\Db\InventoryCategory;
use OCA\Mitgliederverwaltung\Db\InventoryCategoryMapper;
use OCA\Mitgliederverwaltung\Db\InventoryItemCategory;
use OCA\Mitgliederverwaltung\Db\InventoryItemCategoryMapper;
use OCA\Mitgliederverwaltung\Db\StockItem;
use OCA\Mitgliederverwaltung\Db\StockItemMapper;
use OCA\Mitgliederverwaltung\Db\StockVariant;
use OCA\Mitgliederverwaltung\Db\StockVariantMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\DB\Exception;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
/**
* Service layer for inventory categories, general material, and stock items.
*
* Part of Issue #165 (Inventory Tracking).
*/
class InventoryService {
private InventoryCategoryMapper $categoryMapper;
private InventoryItemCategoryMapper $itemCategoryMapper;
private GeneralMaterialMapper $generalMaterialMapper;
private StockItemMapper $stockItemMapper;
private StockVariantMapper $stockVariantMapper;
private AuditService $auditService;
private LoggerInterface $logger;
public function __construct(
InventoryCategoryMapper $categoryMapper,
InventoryItemCategoryMapper $itemCategoryMapper,
GeneralMaterialMapper $generalMaterialMapper,
StockItemMapper $stockItemMapper,
StockVariantMapper $stockVariantMapper,
AuditService $auditService,
LoggerInterface $logger
) {
$this->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';
}
}