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
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
487 lines
16 KiB
PHP
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';
|
|
}
|
|
}
|