Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0ad92dd47 | |||
| 19adf0ba87 | |||
| bfb57b4e1e | |||
| c4f5f8e7fb | |||
| 0ffc993d3f | |||
| e1d24d4d54 | |||
| 5c67fc763a | |||
| ccda3ee570 | |||
| d28dcd4541 | |||
| d4e25fe739 | |||
| 967bacf231 | |||
| ee569250ad | |||
| b29a268b1d | |||
| 53b3fd945a | |||
| ff60c4088e | |||
| 9a55ef4677 | |||
| a9f3dc82ae | |||
| 38858c8002 | |||
| 7450160e9e | |||
| 05c62d1a21 |
@@ -36,3 +36,6 @@ test-results/
|
||||
|
||||
# Release artifacts
|
||||
artifacts/
|
||||
|
||||
# Plan verification tracking
|
||||
.plans/.verified_plans
|
||||
@@ -0,0 +1,57 @@
|
||||
# Issue #200: N+1 Query Problem in Member List
|
||||
|
||||
## Problem
|
||||
|
||||
The `MemberService::findAll()` method (and similar methods) loads members, then makes 3 additional queries per member to fetch addresses, phones, and emails:
|
||||
|
||||
```php
|
||||
public function findAll(?int $limit = null, ?int $offset = null): array {
|
||||
$members = $this->memberMapper->findAll($limit, $offset);
|
||||
return array_map(function (Member $member) {
|
||||
$id = $member->getId();
|
||||
$addresses = $this->addressMapper->findByMemberId($id); // +1 query
|
||||
$phones = $this->phoneMapper->findByMemberId($id); // +1 query
|
||||
$emails = $this->emailMapper->findByMemberId($id); // +1 query
|
||||
return $this->buildMemberResponse($member, $addresses, $phones, $emails);
|
||||
}, $members);
|
||||
}
|
||||
```
|
||||
|
||||
For a page of 20 members, this results in **1 + 20×3 = 61 queries**.
|
||||
|
||||
## Impact
|
||||
|
||||
- Slow page loads, especially with pagination
|
||||
- Increased database load
|
||||
- Poor performance on larger member lists
|
||||
|
||||
## Solution
|
||||
|
||||
Create a new mapper method that uses JOINs to fetch all data in a single query:
|
||||
|
||||
```sql
|
||||
SELECT m.*, a.*, p.*, e.*
|
||||
FROM mv_members m
|
||||
LEFT JOIN mv_addresses a ON m.id = a.member_id
|
||||
LEFT JOIN mv_phones p ON m.id = p.member_id
|
||||
LEFT JOIN mv_emails e ON m.id = e.member_id
|
||||
WHERE m.deleted_at IS NULL
|
||||
ORDER BY m.nachname, m.vorname
|
||||
```
|
||||
|
||||
Then parse the result set in PHP to group related sub-entities.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Create `MemberMapper::findAllWithRelations(?int $limit, ?int $offset): array` that returns raw joined data
|
||||
- [ ] Create a DTO or use an associative array to represent joined results
|
||||
- [ ] Update `MemberService::findAll()` to use the new method
|
||||
- [ ] Apply same pattern to `findFiltered()`, `findByFamily()`, `search()`, `fullTextSearch()`
|
||||
- [ ] Benchmark before/after query counts
|
||||
|
||||
## Labels
|
||||
|
||||
- enhancement
|
||||
- backend
|
||||
- priority:high
|
||||
- performance
|
||||
@@ -0,0 +1,51 @@
|
||||
# Issue #201: countArchived() Loads All Members into Memory
|
||||
|
||||
## Problem
|
||||
|
||||
The `MemberService::countArchived()` method loads ALL members into memory just to count archived ones:
|
||||
|
||||
```php
|
||||
public function countArchived(): int {
|
||||
$allMembers = $this->memberMapper->findAll(null, null, true);
|
||||
return count(array_filter($allMembers, fn(Member $m) => $m->getDeletedAt() !== null));
|
||||
}
|
||||
```
|
||||
|
||||
This is extremely inefficient for large member databases.
|
||||
|
||||
## Impact
|
||||
|
||||
- Memory exhaustion with large member counts
|
||||
- Slow archive page loads
|
||||
- N+1 query problem (sub-entities loaded unnecessarily)
|
||||
|
||||
## Solution
|
||||
|
||||
Add a COUNT query to the mapper:
|
||||
|
||||
```php
|
||||
public function countArchived(): int {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select($qb->createFunction('COUNT(*)'))
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->isNull('deleted_at'));
|
||||
|
||||
// Add: where status = 'geloescht' or deleted_at IS NOT NULL
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, add a `deleted_at IS NOT NULL` check to the existing `findAll()` method and return only the count.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Add `MemberMapper::countArchived(): int` using `SELECT COUNT(*) WHERE deleted_at IS NOT NULL`
|
||||
- [ ] Update `MemberService::countArchived()` to use the new mapper method
|
||||
- [ ] Verify the count matches the archive list page size
|
||||
|
||||
## Labels
|
||||
|
||||
- bug
|
||||
- backend
|
||||
- priority:high
|
||||
- performance
|
||||
@@ -0,0 +1,52 @@
|
||||
# Issue #202: findArchived() Filters in PHP Instead of SQL
|
||||
|
||||
## Problem
|
||||
|
||||
The `MemberService::findArchived()` method fetches ALL members then filters in PHP:
|
||||
|
||||
```php
|
||||
public function findArchived(?int $limit = null, ?int $offset = null): array {
|
||||
$members = $this->memberMapper->findAll($limit, $offset, true);
|
||||
$archived = array_filter($members, fn(Member $m) => $m->getDeletedAt() !== null);
|
||||
return array_values(array_map(function (Member $member) {
|
||||
// ...
|
||||
}, $archived));
|
||||
}
|
||||
```
|
||||
|
||||
This means pagination is broken — `limit` and `offset` apply to ALL members, not just archived ones.
|
||||
|
||||
## Impact
|
||||
|
||||
- Incorrect pagination on archive page
|
||||
- Unnecessary data transfer (all members, not just archived)
|
||||
- N+1 query problem (sub-entities loaded unnecessarily)
|
||||
|
||||
## Solution
|
||||
|
||||
Add a dedicated mapper method:
|
||||
|
||||
```php
|
||||
public function findArchived(?int $limit = null, ?int $offset = null): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->isNull('deleted_at'))
|
||||
->andWhere($qb->expr()->isNotNull('deleted_at')) // or status check
|
||||
->orderBy('deleted_at', 'DESC')
|
||||
// ... pagination
|
||||
}
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Add `MemberMapper::findArchived(?int $limit, ?int $offset): array`
|
||||
- [ ] Update `MemberService::findArchived()` to use the new mapper method
|
||||
- [ ] Remove the PHP array_filter call
|
||||
- [ ] Test pagination with archived members
|
||||
|
||||
## Labels
|
||||
|
||||
- bug
|
||||
- backend
|
||||
- priority:high
|
||||
@@ -0,0 +1,63 @@
|
||||
# Issue #203: Missing Database Transactions in MemberService
|
||||
|
||||
## Problem
|
||||
|
||||
The `MemberService::create()` method inserts a member and its sub-entities (addresses, phones, emails) in separate queries without a transaction:
|
||||
|
||||
```php
|
||||
public function create(array $data, array $addresses = [], ...): array {
|
||||
// ...
|
||||
$member = $this->memberMapper->insert($member);
|
||||
|
||||
$savedAddresses = $this->saveAddresses($member->getId(), $addresses);
|
||||
$savedPhones = $this->savePhones($member->getId(), $phones);
|
||||
$savedEmails = $this->saveEmails($member->getId(), $emails);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
If `savePhones()` fails, the member is created but phones are not attached.
|
||||
|
||||
Similarly, `softDelete()` deletes sub-entities separately without a transaction.
|
||||
|
||||
## Impact
|
||||
|
||||
- Data inconsistency if partial failures occur
|
||||
- Orphaned sub-entities on failed member creation
|
||||
- Orphaned member on failed sub-entity creation
|
||||
|
||||
## Solution
|
||||
|
||||
Wrap the operation in a transaction:
|
||||
|
||||
```php
|
||||
use OCP\DB\Events\SQLFileEmitter;
|
||||
|
||||
public function create(array $data, ...): array {
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
$member = $this->memberMapper->insert($member);
|
||||
$savedAddresses = $this->saveAddresses($member->getId(), $addresses);
|
||||
// ...
|
||||
$this->db->commit();
|
||||
} catch (\Exception $e) {
|
||||
$this->db->rollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Add transaction wrapping to `MemberService::create()`
|
||||
- [ ] Add transaction wrapping to `MemberService::softDelete()`
|
||||
- [ ] Add transaction wrapping to `MemberService::update()` (sync operations)
|
||||
- [ ] Verify rollback works correctly on failure
|
||||
- [ ] Consider adding a `DbTransactionTrait` for reusability
|
||||
|
||||
## Labels
|
||||
|
||||
- bug
|
||||
- backend
|
||||
- priority:high
|
||||
- data-integrity
|
||||
@@ -0,0 +1,65 @@
|
||||
# Issue #204: Duplicate Sub-Entity Sync Methods
|
||||
|
||||
## Problem
|
||||
|
||||
`MemberService` contains three nearly identical methods for syncing sub-entities:
|
||||
|
||||
- `syncAddresses()`
|
||||
- `syncPhones()`
|
||||
- `syncEmails()`
|
||||
|
||||
They all follow the same pattern:
|
||||
1. Fetch existing entities
|
||||
2. Index by ID
|
||||
3. Update kept IDs, create new ones
|
||||
4. Delete removed IDs
|
||||
|
||||
This violates DRY (Don't Repeat Yourself) and makes maintenance harder.
|
||||
|
||||
## Current Code
|
||||
|
||||
```php
|
||||
private function syncAddresses(int $memberId, array $incoming): void {
|
||||
$existing = $this->addressMapper->findByMemberId($memberId);
|
||||
$existingById = [];
|
||||
foreach ($existing as $addr) {
|
||||
$existingById[$addr->getId()] = $addr;
|
||||
}
|
||||
$keptIds = [];
|
||||
foreach ($incoming as $item) {
|
||||
$id = isset($item['id']) ? (int)$item['id'] : 0;
|
||||
if ($id > 0 && isset($existingById[$id])) {
|
||||
$this->updateAddress($id, $item);
|
||||
$keptIds[$id] = true;
|
||||
} else {
|
||||
$this->addAddress($memberId, $item);
|
||||
}
|
||||
}
|
||||
foreach ($existingById as $id => $addr) {
|
||||
if (!isset($keptIds[$id])) {
|
||||
$this->deleteAddress($id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// syncPhones() and syncEmails() are identical except for Address → Phone/Email
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
Extract the common sync logic into a single private generic method that accepts callables for the mapper-specific operations. Each of the three sync methods becomes a thin 10-line wrapper calling the generic method.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Extract common sync logic into `syncSubEntities()` private generic method
|
||||
- [ ] Refactor `syncAddresses()`, `syncPhones()`, `syncEmails()` to use the new pattern
|
||||
- [ ] Ensure identical behavior is preserved (all existing tests must pass)
|
||||
- [ ] Run `make test` — all must pass
|
||||
- [ ] Commit, push, create PR via tea, merge it
|
||||
- [ ] Move plan file from `.plans/open/` to `.plans/done/`
|
||||
- [ ] Check out main again
|
||||
|
||||
## Labels
|
||||
|
||||
- refactoring
|
||||
- backend
|
||||
- priority:medium
|
||||
@@ -0,0 +1,59 @@
|
||||
# Issue #205: Date Calculations Duplicated in Frontend
|
||||
|
||||
## Problem
|
||||
|
||||
The `MemberList.vue` component contains duplicate date calculation logic for `alter` (age) and `mitgliedsdauer` (membership duration) that also exists in the backend `MemberService`:
|
||||
|
||||
**Backend** (`lib/Service/MemberService.php`):
|
||||
```php
|
||||
private function calculateMitgliedsdauer(Member $member): string {
|
||||
$eintritt = new DateTime($member->getEintritt());
|
||||
$end = $member->getAustritt()
|
||||
? new DateTime($member->getAustritt())
|
||||
: new DateTime();
|
||||
$diff = $eintritt->diff($end);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend** (`src/views/MemberList.vue`):
|
||||
```javascript
|
||||
function calculateAge(geburtsdatum) {
|
||||
const birth = new Date(geburtsdatum)
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birth.getFullYear()
|
||||
// ...
|
||||
}
|
||||
|
||||
function calculateMitgliedsdauer(eintritt) {
|
||||
const start = new Date(eintritt)
|
||||
const today = new Date()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
- Inconsistent calculations if date logic changes
|
||||
- Maintenance burden (two places to update)
|
||||
- Potential for bugs if frontend and backend diverge
|
||||
|
||||
## Solution
|
||||
|
||||
1. **Option A (Recommended)**: Calculate `alter` and `mitgliedsdauer` in the backend and include them in the API response
|
||||
2. **Option B**: Extract to a shared utility in a format both can use (e.g., precompute in migration, or use a build-time code generator)
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Add `alter` and `mitgliedsdauer` to `Member::jsonSerialize()`
|
||||
- [ ] Update `MemberService::buildMemberResponse()` to compute these values
|
||||
- [ ] Remove duplicate calculations from `MemberList.vue`
|
||||
- [ ] Verify frontend displays match backend calculations
|
||||
- [ ] Update any other views using these calculations
|
||||
|
||||
## Labels
|
||||
|
||||
- refactoring
|
||||
- frontend
|
||||
- backend
|
||||
- priority:medium
|
||||
@@ -0,0 +1,64 @@
|
||||
# Issue #206: Missing Unit Tests for Core Services
|
||||
|
||||
## Problem
|
||||
|
||||
The codebase has PHP unit tests (`tests/` directory) but the core services lack comprehensive test coverage. Key untested areas:
|
||||
|
||||
- `MemberService::create()` - no tests for validation, duplicate detection
|
||||
- `MemberService::update()` - no tests for field filtering, partial updates
|
||||
- `MemberService::softDelete()` - no tests for data purge behavior
|
||||
- `MemberService::findFiltered()` - no tests for filter combinations
|
||||
- `FeeCalculationService` - mentioned in docs but tests may be incomplete
|
||||
- `AuditService` - no tests for field masking, diff computation
|
||||
|
||||
## Impact
|
||||
|
||||
- Risk of regressions when modifying services
|
||||
- Harder to refactor with confidence
|
||||
- Potential security issues (e.g., audit log masking) undetected
|
||||
|
||||
## Solution
|
||||
|
||||
Add comprehensive unit tests using PHPUnit:
|
||||
|
||||
```php
|
||||
class MemberServiceTest extends\TestCase {
|
||||
|
||||
/** @var MemberService */
|
||||
private $service;
|
||||
|
||||
protected function setUp(): void {
|
||||
// Mock mappers and services
|
||||
}
|
||||
|
||||
public function testCreateValidatesRequiredFields(): void {
|
||||
$this->expectException(ValidationException::class);
|
||||
$this->service->create([]);
|
||||
}
|
||||
|
||||
public function testCreateDetectsDuplicates(): void {
|
||||
$this->expectException(DuplicateMemberException::class);
|
||||
$this->service->create([...]);
|
||||
}
|
||||
|
||||
// ... more tests
|
||||
}
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Set up PHPUnit with mocks for mappers and services
|
||||
- [ ] Test `MemberService::create()` - required fields, dates, duplicates
|
||||
- [ ] Test `MemberService::update()` - editable fields filter, partial updates
|
||||
- [ ] Test `MemberService::softDelete()` - data purge verification
|
||||
- [ ] Test `MemberService::findFiltered()` - all filter combinations
|
||||
- [ ] Test `AuditService::maskIfEncrypted()` - field masking
|
||||
- [ ] Test `FeeCalculationService::calculateAnnualFee()`
|
||||
- [ ] Achieve >70% code coverage
|
||||
|
||||
## Labels
|
||||
|
||||
- enhancement
|
||||
- testing
|
||||
- backend
|
||||
- priority:high
|
||||
@@ -0,0 +1,74 @@
|
||||
# Issue #207: Authorization Middleware Uses instanceof Chains
|
||||
|
||||
## Problem
|
||||
|
||||
The `AuthorizationMiddleware::getAdminMethods()` method uses a chain of `instanceof` checks to determine admin-only methods:
|
||||
|
||||
```php
|
||||
private function getAdminMethods($controller): ?array {
|
||||
if ($controller instanceof FeeController) {
|
||||
return self::ADMIN_METHODS_FEE;
|
||||
}
|
||||
if ($controller instanceof StufeController) {
|
||||
return self::ADMIN_METHODS_STUFE;
|
||||
}
|
||||
if ($controller instanceof ExportController) {
|
||||
return self::ADMIN_METHODS_EXPORT;
|
||||
}
|
||||
if ($controller instanceof MemberController) {
|
||||
return self::ADMIN_METHODS_MEMBER;
|
||||
}
|
||||
// ... more controllers
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
This pattern violates Open/Closed Principle — adding a new controller requires modifying the middleware.
|
||||
|
||||
## Impact
|
||||
|
||||
- Hard to maintain as controller count grows
|
||||
- Easy to forget to add a new controller to the chain
|
||||
- Mixing of concerns (controller identity vs. permissions)
|
||||
|
||||
## Solution
|
||||
|
||||
Use PHP attributes to declare permissions on controller methods:
|
||||
|
||||
```php
|
||||
#[Attribute]
|
||||
class RequiresAdmin implements \Attribute {}
|
||||
|
||||
#[RequiresAdmin]
|
||||
public function createRule(): JSONResponse { ... }
|
||||
```
|
||||
|
||||
Then scan for attributes in middleware:
|
||||
|
||||
```php
|
||||
public function beforeController($controller, $methodName): void {
|
||||
$method = new \ReflectionMethod($controller, $methodName);
|
||||
if ($method->getAttributes(RequiresAdmin::class)) {
|
||||
if (!$this->permissionService->isAdmin($userId)) {
|
||||
throw new \RuntimeException('Authorization: admin required');
|
||||
}
|
||||
}
|
||||
// ... rest of checks
|
||||
}
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Create `RequiresRead` and `RequiresWrite` attributes
|
||||
- [ ] Create `RequiresAdmin` attribute
|
||||
- [ ] Add attributes to all controller methods
|
||||
- [ ] Refactor `AuthorizationMiddleware` to use reflection + attributes
|
||||
- [ ] Remove `ADMIN_METHODS_*` constants and `getAdminMethods()` method
|
||||
- [ ] Test all permission levels still work correctly
|
||||
|
||||
## Labels
|
||||
|
||||
- refactoring
|
||||
- backend
|
||||
- priority:medium
|
||||
- architecture
|
||||
@@ -0,0 +1,69 @@
|
||||
# Issue #208: MemberController Has Too Many Responsibilities
|
||||
|
||||
## Problem
|
||||
|
||||
`MemberController` handles:
|
||||
- Member CRUD (index, show, create, update, destroy)
|
||||
- Sub-entity CRUD (addresses, phones, emails)
|
||||
- Admin-only operations (revealAllergies, archive)
|
||||
- Search (search, fullTextSearch)
|
||||
|
||||
This violates Single Responsibility Principle. The controller is 500+ lines with 20+ methods.
|
||||
|
||||
## Current Structure
|
||||
|
||||
```
|
||||
MemberController
|
||||
├── search() # Full-text search
|
||||
├── index() # List with filters
|
||||
├── show() # Single member
|
||||
├── create() # Create member
|
||||
├── update() # Update member
|
||||
├── destroy() # Delete member
|
||||
├── revealAllergies() # Admin-only
|
||||
├── archive() # Admin-only
|
||||
├── createAddress() # Sub-entity
|
||||
├── updateAddress() # Sub-entity
|
||||
├── destroyAddress() # Sub-entity
|
||||
├── createPhone() # Sub-entity
|
||||
├── updatePhone() # Sub-entity
|
||||
├── destroyPhone() # Sub-entity
|
||||
├── createEmail() # Sub-entity
|
||||
├── updateEmail() # Sub-entity
|
||||
├── destroyEmail() # Sub-entity
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
Split into focused controllers:
|
||||
|
||||
```
|
||||
Controllers/
|
||||
├── MemberController # Member CRUD only
|
||||
├── MemberAddressController # Address CRUD
|
||||
├── MemberPhoneController # Phone CRUD
|
||||
├── MemberEmailController # Email CRUD
|
||||
├── MemberSearchController # Search operations
|
||||
├── MemberAdminController # Admin-only operations
|
||||
```
|
||||
|
||||
Each controller has 3-5 methods, making them easier to understand and test.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Create `MemberAddressController` for address CRUD
|
||||
- [ ] Create `MemberPhoneController` for phone CRUD
|
||||
- [ ] Create `MemberEmailController` for email CRUD
|
||||
- [ ] Create `MemberSearchController` for search operations
|
||||
- [ ] Create `MemberAdminController` for admin operations
|
||||
- [ ] Move sub-entity and admin methods from MemberController
|
||||
- [ ] Update routes in `AppInfo/Application.php`
|
||||
- [ ] Update frontend API calls to use new endpoints
|
||||
- [ ] Remove old methods from MemberController
|
||||
|
||||
## Labels
|
||||
|
||||
- refactoring
|
||||
- backend
|
||||
- priority:medium
|
||||
- architecture
|
||||
@@ -0,0 +1,60 @@
|
||||
# Issue #209: No API Versioning Strategy
|
||||
|
||||
## Problem
|
||||
|
||||
All API endpoints use `/api/v1/` prefix but there's no formal versioning strategy. When breaking changes are needed (e.g., restructuring the member response), there's no clean migration path.
|
||||
|
||||
Current endpoints:
|
||||
```
|
||||
/apps/mitgliederverwaltung/api/v1/members
|
||||
/apps/mitgliederverwaltung/api/v1/families
|
||||
/apps/mitgliederverwaltung/api/v1/fees
|
||||
...
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
- Breaking frontend changes require immediate backend deployment
|
||||
- No backward compatibility for mobile apps or third-party integrations
|
||||
- Hard to deprecate old formats gracefully
|
||||
|
||||
## Solution
|
||||
|
||||
Implement a proper versioning strategy:
|
||||
|
||||
1. **URL Versioning** (current approach):
|
||||
```
|
||||
/api/v1/members → Current version
|
||||
/api/v2/members → New version with breaking changes
|
||||
```
|
||||
|
||||
2. **Header Versioning** (alternative):
|
||||
```
|
||||
GET /api/members
|
||||
Accept: application/vnd.mitgliederverwaltung.v1+json
|
||||
|
||||
GET /api/members
|
||||
Accept: application/vnd.mitgliederverwaltung.v2+json
|
||||
```
|
||||
|
||||
3. **Deprecation Path**:
|
||||
- v1 marked as deprecated in response headers
|
||||
- v2 available in parallel
|
||||
- v1 removed after 6 months
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Document API versioning strategy in docs/
|
||||
- [ ] Implement v2 endpoints for critical entities (Member first)
|
||||
- [ ] Add deprecation headers to v1 responses
|
||||
- [ ] Update frontend to use v2 endpoints
|
||||
- [ ] Create migration guide for v1 → v2
|
||||
- [ ] Set deprecation timeline (e.g., remove v1 in next major release)
|
||||
|
||||
## Labels
|
||||
|
||||
- enhancement
|
||||
- backend
|
||||
- api
|
||||
- priority:low
|
||||
- architecture
|
||||
@@ -0,0 +1,91 @@
|
||||
# Issue #210: Inconsistent Error Response Format
|
||||
|
||||
## Problem
|
||||
|
||||
Different controllers return errors in different formats:
|
||||
|
||||
```php
|
||||
// MemberController
|
||||
return new JSONResponse(['error' => 'Mitglied nicht gefunden'], Http::STATUS_NOT_FOUND);
|
||||
|
||||
// Some controllers might return
|
||||
return new JSONResponse(['message' => 'Not found'], Http::STATUS_NOT_FOUND);
|
||||
|
||||
// Validation errors sometimes include field-level details:
|
||||
return new JSONResponse(['error' => 'Validation failed', 'fields' => [...]], Http::STATUS_BAD_REQUEST);
|
||||
```
|
||||
|
||||
This makes frontend error handling difficult.
|
||||
|
||||
## Impact
|
||||
|
||||
- Inconsistent frontend error parsing
|
||||
- Difficulty implementing global error handling
|
||||
- Poor developer experience when debugging
|
||||
|
||||
## Solution
|
||||
|
||||
Create a standardized error response format:
|
||||
|
||||
```php
|
||||
class ApiResponse {
|
||||
public static function success(mixed $data, int $status = 200): JSONResponse { ... }
|
||||
|
||||
public static function error(
|
||||
string $message,
|
||||
int $status,
|
||||
?array $details = null,
|
||||
?string $errorCode = null
|
||||
): JSONResponse {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'code' => $errorCode ?? self::getDefaultCode($status),
|
||||
'message' => $message,
|
||||
'details' => $details,
|
||||
]
|
||||
], $status);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Standard response format:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... },
|
||||
"meta": {
|
||||
"total": 42,
|
||||
"limit": 20,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Error response format:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "MEMBER_NOT_FOUND",
|
||||
"message": "Mitglied nicht gefunden",
|
||||
"details": { "memberId": 123 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Create `ApiResponse` utility class
|
||||
- [ ] Define standard error codes (MEMBER_NOT_FOUND, VALIDATION_ERROR, etc.)
|
||||
- [ ] Update all controllers to use `ApiResponse::error()`
|
||||
- [ ] Update frontend stores to handle new error format
|
||||
- [ ] Document the response format in docs/
|
||||
|
||||
## Labels
|
||||
|
||||
- enhancement
|
||||
- backend
|
||||
- frontend
|
||||
- api
|
||||
- priority:medium
|
||||
@@ -0,0 +1,78 @@
|
||||
# Issue #211: Authorization Middleware Maintenance Burden
|
||||
|
||||
## Problem
|
||||
|
||||
The `AuthorizationMiddleware` requires manual maintenance for each new controller and method:
|
||||
|
||||
1. When adding a new admin-only controller, it must be added to the `beforeController` chain:
|
||||
```php
|
||||
if ($controller instanceof ImportController
|
||||
|| $controller instanceof PermissionController
|
||||
|| $controller instanceof AuditController
|
||||
|| $controller instanceof DsgvoController
|
||||
|| $controller instanceof BackupController
|
||||
// NEW: Must add here
|
||||
) { ... }
|
||||
```
|
||||
|
||||
2. When adding an admin-only method, it must be added to a constant:
|
||||
```php
|
||||
private const ADMIN_METHODS_MEMBER = ['revealAllergies', 'archive'];
|
||||
// NEW: Must add here
|
||||
```
|
||||
|
||||
3. When adding a read-only method, it must be added to:
|
||||
```php
|
||||
private const READ_METHODS = [
|
||||
'index', 'show', 'search', 'preview', ...
|
||||
// NEW: Must add here
|
||||
];
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
- Easy to forget to add a new controller/method to the right list
|
||||
- Growing maintenance burden as the app expands
|
||||
- Potential security issues if a write method is accidentally omitted
|
||||
- Violates Open/Closed Principle
|
||||
|
||||
## Solution
|
||||
|
||||
Use PHP 8 attributes to declare permissions directly on controller methods:
|
||||
|
||||
```php
|
||||
#[Attribute]
|
||||
class RequirePermission {
|
||||
public function __construct(string $level) { ... }
|
||||
}
|
||||
|
||||
class MemberController {
|
||||
#[RequirePermission('read')]
|
||||
public function index(): JSONResponse { ... }
|
||||
|
||||
#[RequirePermission('write')]
|
||||
public function create(): JSONResponse { ... }
|
||||
|
||||
#[RequirePermission('admin')]
|
||||
public function revealAllergies(): JSONResponse { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Middleware then reads attributes via reflection.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Create `RequirePermission` attribute class
|
||||
- [ ] Add `#[RequirePermission]` attributes to all controller methods
|
||||
- [ ] Refactor `AuthorizationMiddleware::beforeController()` to use reflection
|
||||
- [ ] Remove hardcoded controller and method lists
|
||||
- [ ] Add tests to verify permission enforcement
|
||||
- [ ] Document the new attribute-based approach
|
||||
|
||||
## Labels
|
||||
|
||||
- refactoring
|
||||
- backend
|
||||
- security
|
||||
- priority:medium
|
||||
- architecture
|
||||
@@ -0,0 +1,75 @@
|
||||
# Issue #212: Inconsistent Error Handling Across Controllers
|
||||
|
||||
## Problem
|
||||
|
||||
Different controllers handle errors differently:
|
||||
|
||||
**Logging inconsistency:**
|
||||
```php
|
||||
// Some controllers log errors:
|
||||
$this->logger->error('Failed to create member', ['exception' => $e, ...]);
|
||||
|
||||
// Some controllers don't log at all
|
||||
```
|
||||
|
||||
**Exception handling inconsistency:**
|
||||
```php
|
||||
// Some controllers catch multiple exception types:
|
||||
} catch (DoesNotExistException $e) {
|
||||
return new JSONResponse(['error' => 'Not found'], 404);
|
||||
} catch (\Exception $e) {
|
||||
return new JSONResponse(['error' => 'Server error'], 500);
|
||||
}
|
||||
|
||||
// Some controllers use generic catches:
|
||||
} catch (Exception $e) { ... }
|
||||
```
|
||||
|
||||
**Error message language:**
|
||||
```php
|
||||
return new JSONResponse(['error' => 'Mitglied nicht gefunden'], 404); // German
|
||||
return new JSONResponse(['error' => 'Member not found'], 404); // English
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
- Inconsistent user experience
|
||||
- Difficult to debug production issues
|
||||
- Potential information leakage (stack traces in logs)
|
||||
- Mixed language in API responses
|
||||
|
||||
## Solution
|
||||
|
||||
1. Create a base controller with standardized error handling:
|
||||
```php
|
||||
abstract class BaseApiController extends ApiController {
|
||||
protected function handleNotFound(DoesNotExistException $e): JSONResponse {
|
||||
$this->logger->warning('Resource not found', ['exception' => $e]);
|
||||
return $this->error('Resource not found', 404);
|
||||
}
|
||||
|
||||
protected function handleError(\Exception $e, string $action): JSONResponse {
|
||||
$this->logger->error("$action failed", ['exception' => $e]);
|
||||
return $this->error('An error occurred', 500);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Define consistent error messages in one place (enum or constants)
|
||||
|
||||
3. Ensure all controllers extend the base class
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Create `BaseApiController` with standardized error methods
|
||||
- [ ] Define `ErrorMessages` enum with all error strings
|
||||
- [ ] Update all controllers to extend `BaseApiController`
|
||||
- [ ] Ensure consistent logging (all errors logged, exceptions include context)
|
||||
- [ ] Standardize on German or bilingual error messages
|
||||
- [ ] Add tests for error handling behavior
|
||||
|
||||
## Labels
|
||||
|
||||
- bug
|
||||
- backend
|
||||
- priority:medium
|
||||
@@ -0,0 +1,66 @@
|
||||
# Issue #213: Frontend State Management Issues
|
||||
|
||||
## Problem
|
||||
|
||||
Several issues in the Pinia stores and Vue components:
|
||||
|
||||
### 1. Store State Not Persisted
|
||||
|
||||
The `members.js` store state (filters, pagination) is lost on page refresh. Users lose their filter settings.
|
||||
|
||||
### 2. No Optimistic UI Updates
|
||||
|
||||
When creating/updating/deleting members, the UI waits for the server response before updating. For better UX, implement optimistic updates:
|
||||
|
||||
```javascript
|
||||
async function deleteMember(id) {
|
||||
// Optimistic: remove from local state immediately
|
||||
this.members = this.members.filter(m => m.id !== id)
|
||||
|
||||
try {
|
||||
await axios.delete(url)
|
||||
} catch (err) {
|
||||
// Revert on failure
|
||||
await this.fetchMembers()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Stale Data After Navigation
|
||||
|
||||
After updating a member in `MemberDetail.vue`, navigating back to `MemberList.vue` may show stale data if the list wasn't refetched.
|
||||
|
||||
### 4. Error State Not Cleared
|
||||
|
||||
`store.clearError()` exists but isn't always called before retry operations, potentially showing old errors alongside new data.
|
||||
|
||||
## Impact
|
||||
|
||||
- Poor UX (page refresh loses context)
|
||||
- Slower perceived performance (no optimistic updates)
|
||||
- Confusing states when data is stale
|
||||
- Confusing error messages
|
||||
|
||||
## Solution
|
||||
|
||||
1. Add `pinia-plugin-persistedstate` to persist store state
|
||||
2. Implement optimistic updates in CRUD operations
|
||||
3. Use `watch` to refetch data when navigating back
|
||||
4. Ensure `clearError()` is called at the start of all fetch operations
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Add `pinia-plugin-persistedstate` dependency
|
||||
- [ ] Configure persistence for filter and pagination state
|
||||
- [ ] Implement optimistic updates in `deleteMember()`, `updateMember()`, `createMember()`
|
||||
- [ ] Add navigation watchers to refetch stale data
|
||||
- [ ] Verify `clearError()` is called before all fetch operations
|
||||
- [ ] Test page refresh preserves filter state
|
||||
|
||||
## Labels
|
||||
|
||||
- enhancement
|
||||
- frontend
|
||||
- ux
|
||||
- priority:medium
|
||||
@@ -0,0 +1,55 @@
|
||||
# Issue #214: Rate Limiting Not Fully Implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The `RateLimitMiddleware` exists but may not be properly configured or may have gaps:
|
||||
|
||||
1. **Bulk operations not rate-limited**: The `revealAllergies()` endpoint iterates over ALL members and decrypts each — this should be rate-limited per admin session.
|
||||
|
||||
2. **No per-endpoint limits**: Search endpoints could be abused for enumeration attacks.
|
||||
|
||||
3. **No limit feedback**: When rate limited, users don't know when they can retry.
|
||||
|
||||
## Impact
|
||||
|
||||
- Potential DoS via expensive operations (bulk decryption)
|
||||
- Enumeration attacks on search endpoints
|
||||
- Poor UX with no retry-after information
|
||||
|
||||
## Solution
|
||||
|
||||
1. Add specific rate limits to expensive endpoints:
|
||||
```php
|
||||
// In middleware configuration
|
||||
'member.allergien.reveal' => ['limit' => 1, 'period' => 3600], // 1 per hour
|
||||
'member.search' => ['limit' => 30, 'period' => 60], // 30 per minute
|
||||
```
|
||||
|
||||
2. Return proper rate limit headers:
|
||||
```php
|
||||
$response->headers->set('X-RateLimit-Remaining', (string)$remaining);
|
||||
$response->headers->set('X-RateLimit-Reset', (string)$resetTime);
|
||||
```
|
||||
|
||||
3. Return 429 with Retry-After header when limited:
|
||||
```php
|
||||
return new JSONResponse(['error' => 'Rate limit exceeded'], 429, [
|
||||
'Retry-After' => $secondsRemaining
|
||||
]);
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Audit all endpoints for rate limit requirements
|
||||
- [ ] Configure per-endpoint rate limits
|
||||
- [ ] Add X-RateLimit-* response headers
|
||||
- [ ] Return 429 with Retry-After when limited
|
||||
- [ ] Document rate limit configuration
|
||||
- [ ] Add tests for rate limiting behavior
|
||||
|
||||
## Labels
|
||||
|
||||
- enhancement
|
||||
- security
|
||||
- backend
|
||||
- priority:medium
|
||||
@@ -0,0 +1,59 @@
|
||||
# Issue #215: Column Rendering Logic Duplicated Across Views
|
||||
|
||||
## Problem
|
||||
|
||||
The `allColumns` definition in `MemberList.vue` contains rendering logic that's duplicated when the same fields appear in other views (e.g., `MemberDetail.vue`, export templates):
|
||||
|
||||
```javascript
|
||||
const allColumns = [
|
||||
{ key: 'name', label: 'Name', render: m => m.nachname + ', ' + m.vorname },
|
||||
{ key: 'stufe', label: 'Stufe', render: m => getStufeNameById(m.stufeId) },
|
||||
{ key: 'status', label: 'Status', render: m => statusMap[m.status] || m.status },
|
||||
// ...
|
||||
]
|
||||
```
|
||||
|
||||
The `statusMap`, `rolleMap`, `geschlechtMap`, `kvTypMap` translations are duplicated in multiple components.
|
||||
|
||||
## Impact
|
||||
|
||||
- Inconsistent rendering if one copy is updated but not others
|
||||
- Maintenance burden (3+ places to update for a field change)
|
||||
- Difficulty adding new columns consistently across views
|
||||
|
||||
## Solution
|
||||
|
||||
Create a shared column definitions module:
|
||||
|
||||
```javascript
|
||||
// src/utils/columnDefinitions.js
|
||||
export const STATUS_MAP = { aktiv: 'Aktiv', inaktiv: 'Inaktiv', geloescht: 'Gelöscht' }
|
||||
export const ROLLE_MAP = { mitglied: 'Mitglied', erziehungsberechtigter: 'Erziehungsberechtigter' }
|
||||
// ...
|
||||
|
||||
export const MEMBER_LIST_COLUMNS = [
|
||||
{ key: 'name', label: 'Name', render: m => `${m.nachname}, ${m.vorname}` },
|
||||
{ key: 'status', label: 'Status', render: m => STATUS_MAP[m.status] || m.status },
|
||||
// ...
|
||||
]
|
||||
```
|
||||
|
||||
Then import in components:
|
||||
```javascript
|
||||
import { MEMBER_LIST_COLUMNS, STATUS_MAP } from '../utils/columnDefinitions.js'
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Create `src/utils/columnDefinitions.js` with all maps and column definitions
|
||||
- [ ] Update `MemberList.vue` to import from the shared module
|
||||
- [ ] Update `MemberDetail.vue` to use shared maps
|
||||
- [ ] Update export templates to use shared definitions
|
||||
- [ ] Update any other views using these maps
|
||||
- [ ] Add TypeScript types for column definitions (if using TS)
|
||||
|
||||
## Labels
|
||||
|
||||
- refactoring
|
||||
- frontend
|
||||
- priority:low
|
||||
@@ -0,0 +1,74 @@
|
||||
# Issue #216: Missing Integration Tests
|
||||
|
||||
## Problem
|
||||
|
||||
While unit tests exist for some services, there are no integration tests that verify the full request-response cycle:
|
||||
|
||||
- No tests for API endpoint behavior
|
||||
- No tests for database operations end-to-end
|
||||
- No tests for authentication/authorization flow
|
||||
- No tests for concurrent access patterns
|
||||
- No tests for data migration scripts
|
||||
|
||||
## Impact
|
||||
|
||||
- Unknown behavior of API contracts
|
||||
- Risk of breaking changes going undetected
|
||||
- Difficult to verify authorization works correctly
|
||||
- Risk of migration failures in production
|
||||
|
||||
## Solution
|
||||
|
||||
Add integration tests using Nextcloud's testing infrastructure:
|
||||
|
||||
```php
|
||||
class MemberApiIntegrationTest extends \PHPUnit\Framework\TestCase {
|
||||
|
||||
use Helper\EmptyContainerTrait;
|
||||
|
||||
private ?AppFrameworkApp $app = null;
|
||||
private ?MemberController $controller = null;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->app = $this->container->get('app');
|
||||
$this->controller = $this->app->getContainer()
|
||||
->get(MemberController::class);
|
||||
}
|
||||
|
||||
public function testCreateMemberRequiresAuthentication(): void {
|
||||
$this->expectException(UnauthorizedException::class);
|
||||
$this->controller->create();
|
||||
}
|
||||
|
||||
public function testCreateMemberValidatesRequiredFields(): void {
|
||||
$this->givenAuthenticatedUser('admin');
|
||||
$response = $this->controller->create([]);
|
||||
$this->assertEquals(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testSoftDeletePurgesSensitiveData(): void {
|
||||
$member = $this->givenMemberWithAllergies();
|
||||
$this->controller->destroy($member->id);
|
||||
|
||||
$deleted = $this->memberMapper->findById($member->id);
|
||||
$this->assertNull($deleted->getAllergienEncrypted());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Set up Nextcloud integration test environment
|
||||
- [ ] Add integration tests for MemberController CRUD
|
||||
- [ ] Add integration tests for authorization (admin vs. user)
|
||||
- [ ] Add integration tests for FamilyController
|
||||
- [ ] Add integration tests for FeeController
|
||||
- [ ] Add integration tests for migration scripts
|
||||
- [ ] Set up CI pipeline for integration tests
|
||||
|
||||
## Labels
|
||||
|
||||
- enhancement
|
||||
- testing
|
||||
- backend
|
||||
- priority:high
|
||||
@@ -0,0 +1,82 @@
|
||||
# Issue #217: No Request Validation Layer
|
||||
|
||||
## Problem
|
||||
|
||||
Input validation is scattered across controllers and services:
|
||||
|
||||
1. **Controller level**: Some controllers validate input exists:
|
||||
```php
|
||||
$data = $this->getRequestData();
|
||||
// Assumes $data is always valid
|
||||
```
|
||||
|
||||
2. **Service level**: `MemberService::validateRequiredFields()` checks for required fields:
|
||||
```php
|
||||
private function validateRequiredFields(array $data): void {
|
||||
$required = ['vorname', 'nachname', 'eintritt'];
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
3. **No type validation**: Phone numbers are validated via `PhoneValidator::validateAndNormalize()`, but other fields have no type checking.
|
||||
|
||||
4. **No schema validation**: There's no declarative validation schema.
|
||||
|
||||
## Impact
|
||||
|
||||
- Inconsistent validation (some fields validated, others not)
|
||||
- Potential security issues (SQL injection mitigated by ORM, but XSS, etc. not checked)
|
||||
- Difficult to know what validation exists for each endpoint
|
||||
- No auto-generated API documentation from validation schemas
|
||||
|
||||
## Solution
|
||||
|
||||
Implement a validation layer using Symfony Validator or similar:
|
||||
|
||||
```php
|
||||
class CreateMemberRequest {
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 1, max: 100)]
|
||||
public string $vorname;
|
||||
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 1, max: 100)]
|
||||
public string $nachname;
|
||||
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Date]
|
||||
public string $eintritt;
|
||||
|
||||
#[Assert\Date]
|
||||
public ?string $geburtsdatum = null;
|
||||
|
||||
#[Assert\Choice(['maennlich', 'weiblich', 'divers'])]
|
||||
public ?string $geschlecht = null;
|
||||
}
|
||||
|
||||
public function create(): JSONResponse {
|
||||
$data = $this->getRequestData();
|
||||
$violations = $this->validator->validate($data, CreateMemberRequest::class);
|
||||
if (count($violations) > 0) {
|
||||
return $this->validationError($violations);
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Add Symfony Validator dependency
|
||||
- [ ] Create validation DTOs for each endpoint
|
||||
- [ ] Add Assert annotations for all fields
|
||||
- [ ] Implement validation middleware or base controller method
|
||||
- [ ] Return structured validation errors (field → message)
|
||||
- [ ] Update services to remove inline validation (keep business logic only)
|
||||
- [ ] Add tests for validation rules
|
||||
|
||||
## Labels
|
||||
|
||||
- enhancement
|
||||
- security
|
||||
- backend
|
||||
- priority:medium
|
||||
@@ -41,6 +41,10 @@ These caused most bugs in this project. Every contributor must know them:
|
||||
- Fetch via `@nextcloud/axios` + `generateUrl()`
|
||||
- Always expose `clearError()` action
|
||||
|
||||
## Testing
|
||||
|
||||
All PHPUnit tests must pass (`make test`) before merging. Runs inside the Nextcloud container — no local PHP needed. Zero errors, warnings, and PHP deprecations is the baseline for a commitable PR.
|
||||
|
||||
## UI Guidelines
|
||||
|
||||
- Buttons must show full text, never truncated. Global fix in `main.js` handles NcButton overflow.
|
||||
@@ -79,3 +83,39 @@ This is a common problem. Nextcloud aggressively caches JS assets. Escalation pa
|
||||
- Repo: `shahondin1624/Mitgliederverwaltung` on `git.shahondin1624.de`
|
||||
- Issues use labels: `enhancement`, `bug`, `backend`, `frontend`, `security`, `epic`, `priority:high/medium/low`
|
||||
- Close issues via commit message: `(Closes #N)`
|
||||
|
||||
### Using `tea` CLI
|
||||
|
||||
Always use the `tea` CLI for Gitea operations (never the web UI or raw API calls):
|
||||
|
||||
```bash
|
||||
# Pull requests
|
||||
teaprclose<PR>
|
||||
teaprclose<PR>
|
||||
teapr create --head <branch> --title "<title>" --labels "<labels>" --description "<body>"
|
||||
teapr list
|
||||
teapr view <PR>
|
||||
teapr diff <PR>
|
||||
teapr merge <PR>
|
||||
|
||||
# Issues
|
||||
tei list --state open
|
||||
tei create --title "<title>" --labels "<labels>"
|
||||
tei view <Issue>
|
||||
tei close <Issue>
|
||||
tei update <Issue> --labels "<new-labels>" --assignees "<user>"
|
||||
|
||||
# Labels
|
||||
tel list
|
||||
tel create <name> --color "<hex>" --description "<desc>"
|
||||
|
||||
# Milestones
|
||||
tems list
|
||||
tms create --title "<title>" --deadline "YYYY-MM-DD"
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Before creating a PR: ensure the branch is pushed to origin first, and verify there are actual commits on the branch (never create a PR from a branch identical to main/base)
|
||||
- Always use `tea pr diff` to verify the PR has the expected changes before merging
|
||||
- Use `tea pr merge` to merge after review
|
||||
- Close issues via commit message `(Closes #N)` — `tea` is only for PR/issue management on the Gitea server
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
.PHONY: build deps up down setup deploy redeploy logs clean package release \
|
||||
up-postgres setup-postgres deploy-postgres clean-postgres \
|
||||
up-sqlite setup-sqlite deploy-sqlite clean-sqlite
|
||||
up-sqlite setup-sqlite deploy-sqlite clean-sqlite \
|
||||
test
|
||||
|
||||
# Install dependencies (composer via Docker since PHP may not be local)
|
||||
deps:
|
||||
npm install --no-audit --no-fund
|
||||
docker run --rm -v "$$(pwd):/app" -w /app composer:2 install --no-dev --optimize-autoloader --no-interaction
|
||||
docker run --rm -v "$$(pwd):/app" -w /app composer:2 install --optimize-autoloader --no-interaction
|
||||
|
||||
# Build frontend
|
||||
build: deps
|
||||
@@ -202,6 +203,38 @@ deploy-sqlite: build up-sqlite setup-sqlite
|
||||
clean-sqlite:
|
||||
docker compose -f docker/sqlite/docker-compose.yml down -v
|
||||
|
||||
# Run all tests inside the Nextcloud container
|
||||
# Supports: make test [suite=Unit] [suite=Integration] [suite=DatabasePortability]
|
||||
test:
|
||||
@if ! docker compose exec -T nextcloud test -f config/config.php 2>/dev/null; then \
|
||||
echo "ERROR: Nextcloud container is not running."; \
|
||||
echo " Start it with: make up && make setup"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@# Ensure app code is up to date inside the container
|
||||
docker compose exec nextcloud cp -a /app-src/appinfo /var/www/html/custom_apps/mitgliederverwaltung/
|
||||
docker compose exec nextcloud cp -a /app-src/lib /var/www/html/custom_apps/mitgliederverwaltung/
|
||||
docker compose exec nextcloud cp -a /app-src/tests /var/www/html/custom_apps/mitgliederverwaltung/
|
||||
docker compose exec nextcloud cp -a /app-src/phpunit.xml /var/www/html/custom_apps/mitgliederverwaltung/
|
||||
docker compose exec nextcloud chown -R www-data:www-data /var/www/html/custom_apps/mitgliederverwaltung
|
||||
@# Copy composer vendor dir with dev deps into app for PHPUnit
|
||||
docker compose exec nextcloud cp -a /app-src/vendor /var/www/html/custom_apps/mitgliederverwaltung/
|
||||
@# Determine which test suite to run (default: all)
|
||||
@if [ "$(suite)" = "" ]; then \
|
||||
echo "Running all tests..."; \
|
||||
docker compose exec -u www-data -T nextcloud \
|
||||
php /var/www/html/custom_apps/mitgliederverwaltung/vendor/bin/phpunit \
|
||||
-c /var/www/html/custom_apps/mitgliederverwaltung/phpunit.xml \
|
||||
--colors=always; \
|
||||
else \
|
||||
echo "Running suite $(suite)..."; \
|
||||
docker compose exec -u www-data -T nextcloud \
|
||||
php /var/www/html/custom_apps/mitgliederverwaltung/vendor/bin/phpunit \
|
||||
-c /var/www/html/custom_apps/mitgliederverwaltung/phpunit.xml \
|
||||
--testsuite $(suite) \
|
||||
--colors=always; \
|
||||
fi
|
||||
|
||||
# Remove volumes (full reset)
|
||||
clean:
|
||||
docker compose down -v
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<name>Mitgliederverwaltung</name>
|
||||
<summary>Mitgliederverwaltung für Pfadfindervereine</summary>
|
||||
<description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description>
|
||||
<version>0.2.11</version>
|
||||
<version>0.3.2</version>
|
||||
<licence>agpl</licence>
|
||||
<author>shahondin1624</author>
|
||||
<namespace>Mitgliederverwaltung</namespace>
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
];
|
||||
|
||||
+1788
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -21,7 +21,7 @@
|
||||
]
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"nextcloud/ocp": "^28",
|
||||
"doctrine/dbal": "^3.0",
|
||||
"sabre/vobject": "^4.5"
|
||||
|
||||
Executable
BIN
Binary file not shown.
+2
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Controller;
|
||||
|
||||
use OCA\Mitgliederverwaltung\Service\InventoryService;
|
||||
use OCA\Mitgliederverwaltung\Service\SaleService;
|
||||
use OCP\AppFramework\ApiController;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* REST API controller for inventory CRUD operations.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*/
|
||||
class InventoryController extends ApiController {
|
||||
|
||||
use ApiControllerTrait;
|
||||
|
||||
private InventoryService $inventoryService;
|
||||
private SaleService $saleService;
|
||||
private IUserSession $userSession;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
InventoryService $inventoryService,
|
||||
SaleService $saleService,
|
||||
IUserSession $userSession,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Controller;
|
||||
|
||||
use OCA\Mitgliederverwaltung\Service\InventoryReportService;
|
||||
use OCP\AppFramework\ApiController;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\IRequest;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* REST API controller for inventory reports.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*/
|
||||
class InventoryReportController extends ApiController {
|
||||
|
||||
private InventoryReportService $reportService;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
InventoryReportService $reportService,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Db;
|
||||
|
||||
use JsonSerializable;
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* General material entity mapping all columns from oc_mv_inventory_general_material.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*
|
||||
* @method int|null getId()
|
||||
* @method void setId(int $id)
|
||||
* @method string getName()
|
||||
* @method void setName(string $name)
|
||||
* @method int|null getCondition()
|
||||
* @method void setCondition(?int $condition)
|
||||
* @method string|null getNotes()
|
||||
* @method void setNotes(?string $notes)
|
||||
* @method string getCreatedAt()
|
||||
* @method void setCreatedAt(string $createdAt)
|
||||
* @method string getUpdatedAt()
|
||||
* @method void setUpdatedAt(string $updatedAt)
|
||||
*/
|
||||
class GeneralMaterial extends Entity implements JsonSerializable {
|
||||
|
||||
protected int $id = 0;
|
||||
protected string $name = '';
|
||||
protected ?int $condition = null;
|
||||
protected ?string $notes = null;
|
||||
protected string $createdAt = '';
|
||||
protected string $updatedAt = '';
|
||||
|
||||
public function __construct() {
|
||||
$this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Db;
|
||||
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* Mapper for the oc_mv_inventory_general_material table.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*
|
||||
* @extends QBMapper<GeneralMaterial>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Db;
|
||||
|
||||
use JsonSerializable;
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* Category entity mapping all columns from oc_mv_inventory_categories.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*
|
||||
* @method int|null getId()
|
||||
* @method void setId(int $id)
|
||||
* @method string getName()
|
||||
* @method void setName(string $name)
|
||||
* @method string|null getCreatedAt()
|
||||
* @method void setCreatedAt(string $createdAt)
|
||||
*/
|
||||
class InventoryCategory extends Entity implements JsonSerializable {
|
||||
|
||||
protected int $id = 0;
|
||||
protected string $name = '';
|
||||
protected ?string $createdAt = null;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('id', 'integer');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Db;
|
||||
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* Mapper for the oc_mv_inventory_categories table.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*
|
||||
* @extends QBMapper<InventoryCategory>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Db;
|
||||
|
||||
use JsonSerializable;
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* Join entity linking a general material item to a category.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*
|
||||
* @method int|null getItemId()
|
||||
* @method void setItemId(int $itemId)
|
||||
* @method int|null getCategoryId()
|
||||
* @method void setCategoryId(int $categoryId)
|
||||
*/
|
||||
class InventoryItemCategory extends Entity implements JsonSerializable {
|
||||
|
||||
protected int $itemId = 0;
|
||||
protected int $categoryId = 0;
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('itemId', 'integer');
|
||||
$this->addType('categoryId', 'integer');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'item_id' => $this->itemId,
|
||||
'category_id' => $this->categoryId,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Db;
|
||||
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* Mapper for the oc_mv_inventory_item_categories join table.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*
|
||||
* @extends QBMapper<InventoryItemCategory>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,47 @@ class MemberMapper extends QBMapper {
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-text search across members, addresses, and notes with all
|
||||
* sub-entities in a single query.
|
||||
*
|
||||
* Searches: vorname, nachname, notizen, zusatz_notizen, and joined
|
||||
* address fields. Returns members with nested addresses, phones, and
|
||||
* emails.
|
||||
*
|
||||
* Part of Issue #33.
|
||||
*
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function fullTextSearchWithRelations(string $query, int $limit = 20): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
|
||||
|
||||
$qb->select('m.*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$qb->where($qb->expr()->isNull('m.deleted_at'))
|
||||
->andWhere(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('m.notizen', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('m.zusatz_notizen', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('a.strasse', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('a.plz', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('a.ort', $qb->createNamedParameter($searchPattern))
|
||||
)
|
||||
)
|
||||
->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC')
|
||||
->setMaxResults($limit);
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a member by exact Vorname, Nachname, and Geburtsdatum.
|
||||
* Used for duplicate detection during import.
|
||||
@@ -330,4 +371,399 @@ class MemberMapper extends QBMapper {
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count all soft-deleted (archived) members.
|
||||
*
|
||||
* Uses a single SQL COUNT query instead of loading all members
|
||||
* into memory.
|
||||
*
|
||||
* Part of Issue #201.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function countArchived(): int {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select($qb->createFunction('COUNT(*)'))
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->isNotNull('deleted_at'));
|
||||
|
||||
$result = $qb->executeQuery();
|
||||
$count = (int)$result->fetchOne();
|
||||
$result->closeCursor();
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all soft-deleted (archived) members with optional pagination.
|
||||
*
|
||||
* Uses a single SQL query with WHERE deleted_at IS NOT NULL instead
|
||||
* of loading all members into memory and filtering in PHP.
|
||||
*
|
||||
* Part of Issue #202.
|
||||
*
|
||||
* @return Member[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findArchived(?int $limit = null, ?int $offset = null): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->isNotNull('deleted_at'))
|
||||
->orderBy('deleted_at', 'DESC');
|
||||
|
||||
if ($limit !== null) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
if ($offset !== null) {
|
||||
$qb->setFirstResult($offset);
|
||||
}
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
// ── Joined fetch methods (N+1 avoidance) ────────────────────────
|
||||
|
||||
/**
|
||||
* Find all members with their addresses, phones, and emails in a
|
||||
* single query using LEFT JOINs.
|
||||
*
|
||||
* Returns an array of associative arrays, each representing one
|
||||
* member with nested 'addresses', 'phones', and 'emails' arrays.
|
||||
*
|
||||
* This replaces the N+1 pattern (1 query for members + 3 queries
|
||||
* per member for sub-entities) with a single query regardless of
|
||||
* the number of members.
|
||||
*
|
||||
* @param int|null $limit Pagination limit
|
||||
* @param int|null $offset Pagination offset
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findAllWithRelations(?int $limit = null, ?int $offset = null): array {
|
||||
return $this->fetchWithRelations(
|
||||
$this->buildBaseQuery(false, $limit, $offset)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members by family ID with all sub-entities in a single query.
|
||||
*
|
||||
* @param int $familyId Family ID
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findByFamilyWithRelations(int $familyId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$qb->where($qb->expr()->eq('m.family_id', $qb->createNamedParameter($familyId, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->isNull('m.deleted_at'))
|
||||
->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC');
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members by status with all sub-entities in a single query.
|
||||
*
|
||||
* @param string $status Status to filter by
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findByStatusWithRelations(string $status): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$qb->where($qb->expr()->eq('m.status', $qb->createNamedParameter($status)))
|
||||
->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC');
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search members by name with all sub-entities in a single query.
|
||||
*
|
||||
* @param string $query Search query (searches Vorname and Nachname)
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function searchWithRelations(string $query): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
|
||||
$qb->where($qb->expr()->isNull('m.deleted_at'))
|
||||
->andWhere(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern))
|
||||
)
|
||||
)
|
||||
->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC');
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members with a birthday in the given month with all
|
||||
* sub-entities in a single query.
|
||||
*
|
||||
* @param int $month Month (1-12)
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findByBirthdayMonthWithRelations(int $month): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$monthExpr = PlatformHelper::getMonthExpression($this->db, 'm.geburtsdatum');
|
||||
$qb->where($qb->expr()->isNull('m.deleted_at'))
|
||||
->andWhere(
|
||||
$qb->expr()->eq(
|
||||
$qb->createFunction($monthExpr),
|
||||
$qb->createNamedParameter($month, IQueryBuilder::PARAM_INT)
|
||||
)
|
||||
)
|
||||
->orderBy('m.geburtsdatum', 'ASC');
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members who have unpaid fee records for a given year with
|
||||
* all sub-entities in a single query.
|
||||
*
|
||||
* @param int $year Year to filter by
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findWithUnpaidFeesWithRelations(int $year): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('m.*')
|
||||
->from($this->getTableName(), 'm')
|
||||
->innerJoin('m', 'mv_fee_records', 'f', $qb->expr()->eq('m.id', 'f.member_id'));
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$qb->where($qb->expr()->isNull('m.deleted_at'))
|
||||
->andWhere($qb->expr()->eq('f.year', $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('f.paid', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
|
||||
->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC');
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members matching a combination of filters with all
|
||||
* sub-entities in a single query.
|
||||
*
|
||||
* @param string|null $status Filter by status (aktiv/inaktiv)
|
||||
* @param string|null $rolle Filter by rolle (mitglied/erziehungsberechtigter)
|
||||
* @param bool $birthdayThisMonth Only members with birthday in current month
|
||||
* @param bool $unpaidFees Only members with unpaid fee records for current year
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findFilteredWithRelations(
|
||||
?string $status = null,
|
||||
?string $rolle = null,
|
||||
bool $birthdayThisMonth = false,
|
||||
bool $unpaidFees = false
|
||||
): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('m.*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$qb->where($qb->expr()->isNull('m.deleted_at'));
|
||||
|
||||
if ($unpaidFees) {
|
||||
$year = (int)(new \DateTime())->format('Y');
|
||||
$qb->innerJoin('m', 'mv_fee_records', 'f', $qb->expr()->eq('m.id', 'f.member_id'))
|
||||
->andWhere($qb->expr()->eq('f.year', $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('f.paid', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)));
|
||||
}
|
||||
|
||||
if ($status !== null) {
|
||||
$qb->andWhere($qb->expr()->eq('m.status', $qb->createNamedParameter($status)));
|
||||
}
|
||||
|
||||
if ($rolle !== null) {
|
||||
$qb->andWhere($qb->expr()->eq('m.rolle', $qb->createNamedParameter($rolle)));
|
||||
}
|
||||
|
||||
if ($birthdayThisMonth) {
|
||||
$currentMonth = (int)(new \DateTime())->format('m');
|
||||
$monthExpr = PlatformHelper::getMonthExpression($this->db, 'm.geburtsdatum');
|
||||
$qb->andWhere(
|
||||
$qb->expr()->eq(
|
||||
$qb->createFunction($monthExpr),
|
||||
$qb->createNamedParameter($currentMonth, IQueryBuilder::PARAM_INT)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$qb->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC');
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the base query for findAllWithRelations.
|
||||
*/
|
||||
private function buildBaseQuery(
|
||||
bool $includeDeleted,
|
||||
?int $limit,
|
||||
?int $offset
|
||||
): \OCP\DB\QueryBuilder\IQueryBuilder {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
if (!$includeDeleted) {
|
||||
$qb->where($qb->expr()->isNull('m.deleted_at'));
|
||||
}
|
||||
|
||||
$qb->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC');
|
||||
|
||||
if ($limit !== null) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
if ($offset !== null) {
|
||||
$qb->setFirstResult($offset);
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the LEFT JOIN clauses for addresses, phones, and emails
|
||||
* to the given query builder.
|
||||
*/
|
||||
private function addJoinClauses(\OCP\DB\QueryBuilder\IQueryBuilder $qb): void {
|
||||
$qb->leftJoin('m', 'mv_addresses', 'a', $qb->expr()->eq('m.id', 'a.member_id'))
|
||||
->leftJoin('m', 'mv_phones', 'p', $qb->expr()->eq('m.id', 'p.member_id'))
|
||||
->leftJoin('m', 'mv_emails', 'e', $qb->expr()->eq('m.id', 'e.member_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query with joined sub-entities and parse the flat
|
||||
* result set into nested member arrays.
|
||||
*
|
||||
* The query must SELECT all columns from the members table plus
|
||||
* columns from mv_addresses, mv_phones, and mv_emails. Column
|
||||
* name collision is handled by prefixing joined columns with
|
||||
* aliases (a.*, p.*, e.*) which Doctrine maps as
|
||||
* a_strasse, p_number_e164, etc.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function fetchWithRelations(\OCP\DB\QueryBuilder\IQueryBuilder $qb): array {
|
||||
$result = $qb->executeQuery();
|
||||
|
||||
/** @var array<int, array> $grouped */
|
||||
$grouped = [];
|
||||
|
||||
while ($row = $result->fetch()) {
|
||||
$id = (int)$row['id'];
|
||||
if (!isset($grouped[$id])) {
|
||||
// Build the base member record from the member columns.
|
||||
$grouped[$id] = [
|
||||
'id' => $id,
|
||||
'vorname' => $row['vorname'] ?? '',
|
||||
'nachname' => $row['nachname'] ?? '',
|
||||
'geburtsdatum' => $row['geburtsdatum'] ?? null,
|
||||
'geschlecht' => $row['geschlecht'] ?? null,
|
||||
'rolle' => $row['rolle'] ?? 'mitglied',
|
||||
'stufeId' => isset($row['stufe_id']) ? (int)$row['stufe_id'] : null,
|
||||
'eintritt' => $row['eintritt'] ?? '',
|
||||
'austritt' => $row['austritt'] ?? null,
|
||||
'status' => $row['status'] ?? 'aktiv',
|
||||
'allergienEncrypted' => $row['allergien_encrypted'] ?? null,
|
||||
'notizen' => $row['notizen'] ?? null,
|
||||
'zusatzNotizen' => $row['zusatz_notizen'] ?? null,
|
||||
'kvTyp' => $row['kv_typ'] ?? null,
|
||||
'kvName' => $row['kv_name'] ?? null,
|
||||
'familyId' => isset($row['family_id']) ? (int)$row['family_id'] : null,
|
||||
'frozenFeeRate' => $row['frozen_fee_rate'] ?? null,
|
||||
'calendarEventUri' => $row['calendar_event_uri'] ?? null,
|
||||
'contactVcardUri' => $row['contact_vcard_uri'] ?? null,
|
||||
'createdAt' => $row['created_at'] ?? '',
|
||||
'updatedAt' => $row['updated_at'] ?? '',
|
||||
'deletedAt' => $row['deleted_at'] ?? null,
|
||||
'einwilligungDatum' => $row['einwilligung_datum'] ?? null,
|
||||
'juleicaNummer' => $row['juleica_nummer'] ?? null,
|
||||
'juleicaAblaufdatum' => $row['juleica_ablaufdatum'] ?? null,
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// Parse sub-entities from the joined columns.
|
||||
// A row with NULLs for all address columns means no address
|
||||
// is joined for this row — we must not create an empty Address.
|
||||
$addrId = $row['a_id'] ?? null;
|
||||
if ($addrId !== null && $addrId !== '') {
|
||||
$grouped[$id]['addresses'][] = [
|
||||
'id' => (int)$addrId,
|
||||
'memberId' => (int)$row['a_member_id'],
|
||||
'label' => $row['a_label'] ?? null,
|
||||
'strasse' => $row['a_strasse'] ?? '',
|
||||
'plz' => $row['a_plz'] ?? '',
|
||||
'ort' => $row['a_ort'] ?? '',
|
||||
'land' => $row['a_land'] ?? 'Deutschland',
|
||||
'isPrimary' => (bool)$row['a_is_primary'],
|
||||
];
|
||||
}
|
||||
|
||||
$phoneId = $row['p_id'] ?? null;
|
||||
if ($phoneId !== null && $phoneId !== '') {
|
||||
$grouped[$id]['phones'][] = [
|
||||
'id' => (int)$phoneId,
|
||||
'memberId' => (int)$row['p_member_id'],
|
||||
'label' => $row['p_label'] ?? null,
|
||||
'numberE164' => $row['p_number_e164'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$emailId = $row['e_id'] ?? null;
|
||||
if ($emailId !== null && $emailId !== '') {
|
||||
$grouped[$id]['emails'][] = [
|
||||
'id' => (int)$emailId,
|
||||
'memberId' => (int)$row['e_member_id'],
|
||||
'label' => $row['e_label'] ?? null,
|
||||
'email' => $row['e_email'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$result->closeCursor();
|
||||
|
||||
// Re-index to a sequential 0-based array while preserving order.
|
||||
return array_values($grouped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Db;
|
||||
|
||||
use JsonSerializable;
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* Sale record entity mapping all columns from oc_mv_inventory_sales.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*
|
||||
* @method int|null getId()
|
||||
* @method void setId(int $id)
|
||||
* @method int|null getStockItemId()
|
||||
* @method void setStockItemId(?int $stockItemId)
|
||||
* @method int|null getVariantId()
|
||||
* @method void setVariantId(?int $variantId)
|
||||
* @method string getDate()
|
||||
* @method void setDate(string $date)
|
||||
* @method int getQuantity()
|
||||
* @method void setQuantity(int $quantity)
|
||||
* @method string getUnitPrice()
|
||||
* @method void setUnitPrice(string $unitPrice)
|
||||
* @method string getTotalPrice()
|
||||
* @method void setTotalPrice(string $totalPrice)
|
||||
* @method string|null getNotes()
|
||||
* @method void setNotes(?string $notes)
|
||||
* @method string getCreatedAt()
|
||||
* @method void setCreatedAt(string $createdAt)
|
||||
*/
|
||||
class SaleRecord extends Entity implements JsonSerializable {
|
||||
|
||||
protected int $id = 0;
|
||||
protected ?int $stockItemId = null;
|
||||
protected ?int $variantId = null;
|
||||
protected string $date = '';
|
||||
protected int $quantity = 0;
|
||||
protected string $unitPrice = '0.00';
|
||||
protected string $totalPrice = '0.00';
|
||||
protected ?string $notes = null;
|
||||
protected string $createdAt = '';
|
||||
|
||||
public function __construct() {
|
||||
$this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Db;
|
||||
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* Mapper for the oc_mv_inventory_sales table.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*
|
||||
* @extends QBMapper<SaleRecord>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Db;
|
||||
|
||||
use JsonSerializable;
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* Stock item entity mapping all columns from oc_mv_inventory_stock_items.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*
|
||||
* @method int|null getId()
|
||||
* @method void setId(int $id)
|
||||
* @method string getName()
|
||||
* @method void setName(string $name)
|
||||
* @method string|null getProviderUrlsJson()
|
||||
* @method void setProviderUrlsJson(?string $providerUrlsJson)
|
||||
* @method string getCreatedAt()
|
||||
* @method void setCreatedAt(string $createdAt)
|
||||
* @method string getUpdatedAt()
|
||||
* @method void setUpdatedAt(string $updatedAt)
|
||||
*/
|
||||
class StockItem extends Entity implements JsonSerializable {
|
||||
|
||||
protected int $id = 0;
|
||||
protected string $name = '';
|
||||
protected ?string $providerUrlsJson = null;
|
||||
protected string $createdAt = '';
|
||||
protected string $updatedAt = '';
|
||||
|
||||
public function __construct() {
|
||||
$this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Db;
|
||||
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* Mapper for the oc_mv_inventory_stock_items table.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*
|
||||
* @extends QBMapper<StockItem>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Db;
|
||||
|
||||
use JsonSerializable;
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* Stock variant entity mapping all columns from oc_mv_inventory_stock_variants.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*
|
||||
* @method int|null getId()
|
||||
* @method void setId(int $id)
|
||||
* @method int getStockItemId()
|
||||
* @method void setStockItemId(int $stockItemId)
|
||||
* @method string getLabel()
|
||||
* @method void setLabel(string $label)
|
||||
* @method int getAmount()
|
||||
* @method void setAmount(int $amount)
|
||||
* @method string getCost()
|
||||
* @method void setCost(string $cost)
|
||||
* @method int getMinThreshold()
|
||||
* @method void setMinThreshold(int $minThreshold)
|
||||
*/
|
||||
class StockVariant extends Entity implements JsonSerializable {
|
||||
|
||||
protected int $id = 0;
|
||||
protected int $stockItemId = 0;
|
||||
protected string $label = '';
|
||||
protected int $amount = 0;
|
||||
protected string $cost = '0.00';
|
||||
protected int $minThreshold = 0;
|
||||
|
||||
public function __construct() {
|
||||
$this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Db;
|
||||
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* Mapper for the oc_mv_inventory_stock_variants table.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*
|
||||
* @extends QBMapper<StockVariant>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\DB\Types;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Migration: Create inventory tracking tables.
|
||||
*
|
||||
* Creates the following tables:
|
||||
* - mv_inventory_categories — Category registry
|
||||
* - mv_inventory_item_categories — Join table (item ↔ category)
|
||||
* - mv_inventory_general_material — General material items
|
||||
* - mv_inventory_stock_items — Stock / sales items
|
||||
* - mv_inventory_stock_variants — Variant options per stock item
|
||||
* - mv_inventory_sales — Sale records (audit trail)
|
||||
*/
|
||||
class Version000018Date20260421000000 extends SimpleMigrationStep {
|
||||
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
// ── mv_inventory_categories (no FKs, create first) ───────────
|
||||
if (!$schema->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;
|
||||
}
|
||||
}
|
||||
@@ -290,7 +290,7 @@ class BundleImportService {
|
||||
'fkWarnings' => $preview['fkWarnings'],
|
||||
'errorCount' => count($preview['errors']),
|
||||
'errors' => array_slice($preview['errors'], 0, 10),
|
||||
'targetFields' => $schema['targetFields'],
|
||||
'targetFields' => $schema['targetFields'] ?? [],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$results[] = [
|
||||
@@ -332,6 +332,11 @@ class BundleImportService {
|
||||
try {
|
||||
file_put_contents($tmpFile, $zipContent);
|
||||
|
||||
$size = @filesize($tmpFile);
|
||||
if ($size === 0 || $size === false) {
|
||||
throw new ValidationException('Die hochgeladene Datei ist leer.');
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
$result = $zip->open($tmpFile);
|
||||
if ($result !== true) {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Service;
|
||||
|
||||
use OCA\Mitgliederverwaltung\Db\GeneralMaterialMapper;
|
||||
use OCA\Mitgliederverwaltung\Db\InventoryCategoryMapper;
|
||||
use OCA\Mitgliederverwaltung\Db\InventoryItemCategoryMapper;
|
||||
use OCA\Mitgliederverwaltung\Db\StockItemMapper;
|
||||
use OCA\Mitgliederverwaltung\Db\StockVariantMapper;
|
||||
use OCA\Mitgliederverwaltung\Db\SaleRecordMapper;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Service for generating inventory reports.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*/
|
||||
class InventoryReportService {
|
||||
|
||||
private GeneralMaterialMapper $generalMaterialMapper;
|
||||
private InventoryCategoryMapper $categoryMapper;
|
||||
private InventoryItemCategoryMapper $itemCategoryMapper;
|
||||
private StockItemMapper $stockItemMapper;
|
||||
private StockVariantMapper $stockVariantMapper;
|
||||
private SaleRecordMapper $saleRecordMapper;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
GeneralMaterialMapper $generalMaterialMapper,
|
||||
InventoryCategoryMapper $categoryMapper,
|
||||
InventoryItemCategoryMapper $itemCategoryMapper,
|
||||
StockItemMapper $stockItemMapper,
|
||||
StockVariantMapper $stockVariantMapper,
|
||||
SaleRecordMapper $saleRecordMapper,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
$this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
+192
-147
@@ -19,6 +19,7 @@ use OCA\Mitgliederverwaltung\Validation\PhoneValidator;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\IDBConnection;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
@@ -37,6 +38,7 @@ class MemberService {
|
||||
private AuditService $auditService;
|
||||
private LoggerInterface $logger;
|
||||
private ?EncryptionService $encryptionService;
|
||||
private IDBConnection $db;
|
||||
|
||||
public function __construct(
|
||||
MemberMapper $memberMapper,
|
||||
@@ -47,7 +49,8 @@ class MemberService {
|
||||
StufeHistoryMapper $stufeHistoryMapper,
|
||||
AuditService $auditService,
|
||||
LoggerInterface $logger,
|
||||
?EncryptionService $encryptionService = null
|
||||
?EncryptionService $encryptionService,
|
||||
IDBConnection $db
|
||||
) {
|
||||
$this->memberMapper = $memberMapper;
|
||||
$this->addressMapper = $addressMapper;
|
||||
@@ -58,6 +61,7 @@ class MemberService {
|
||||
$this->auditService = $auditService;
|
||||
$this->logger = $logger;
|
||||
$this->encryptionService = $encryptionService;
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,6 +157,8 @@ class MemberService {
|
||||
$member->setCreatedAt($now);
|
||||
$member->setUpdatedAt($now);
|
||||
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
/** @var Member $member */
|
||||
$member = $this->memberMapper->insert($member);
|
||||
|
||||
@@ -160,6 +166,12 @@ class MemberService {
|
||||
$savedPhones = $this->savePhones($member->getId(), $phones);
|
||||
$savedEmails = $this->saveEmails($member->getId(), $emails);
|
||||
|
||||
$this->db->commit();
|
||||
} catch (\Exception $e) {
|
||||
$this->db->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->auditService->logCreate($member->jsonSerialize(), 'member', $member->getId());
|
||||
|
||||
return $this->buildMemberResponse($member, $savedAddresses, $savedPhones, $savedEmails);
|
||||
@@ -186,77 +198,56 @@ class MemberService {
|
||||
/**
|
||||
* Find all members with optional pagination.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* @return array[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findAll(?int $limit = null, ?int $offset = null): array {
|
||||
$members = $this->memberMapper->findAll($limit, $offset);
|
||||
return array_map(function (Member $member) {
|
||||
$id = $member->getId();
|
||||
$addresses = $this->addressMapper->findByMemberId($id);
|
||||
$phones = $this->phoneMapper->findByMemberId($id);
|
||||
$emails = $this->emailMapper->findByMemberId($id);
|
||||
return $this->buildMemberResponse($member, $addresses, $phones, $emails);
|
||||
}, $members);
|
||||
return $this->memberMapper->findAllWithRelations($limit, $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members by family ID.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* @return array[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findByFamily(int $familyId): array {
|
||||
$members = $this->memberMapper->findByFamily($familyId);
|
||||
return array_map(function (Member $member) {
|
||||
return $this->buildMemberResponse(
|
||||
$member,
|
||||
$this->addressMapper->findByMemberId($member->getId()),
|
||||
$this->phoneMapper->findByMemberId($member->getId()),
|
||||
$this->emailMapper->findByMemberId($member->getId())
|
||||
);
|
||||
}, $members);
|
||||
return $this->memberMapper->findByFamilyWithRelations($familyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members by status.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* @return array[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findByStatus(string $status): array {
|
||||
$members = $this->memberMapper->findByStatus($status);
|
||||
return array_map(function (Member $member) {
|
||||
return $this->buildMemberResponse(
|
||||
$member,
|
||||
$this->addressMapper->findByMemberId($member->getId()),
|
||||
$this->phoneMapper->findByMemberId($member->getId()),
|
||||
$this->emailMapper->findByMemberId($member->getId())
|
||||
);
|
||||
}, $members);
|
||||
return $this->memberMapper->findByStatusWithRelations($status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search members by name.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* @return array[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function search(string $query): array {
|
||||
$members = $this->memberMapper->search($query);
|
||||
return array_map(function (Member $member) {
|
||||
return $this->buildMemberResponse(
|
||||
$member,
|
||||
$this->addressMapper->findByMemberId($member->getId()),
|
||||
$this->phoneMapper->findByMemberId($member->getId()),
|
||||
$this->emailMapper->findByMemberId($member->getId())
|
||||
);
|
||||
}, $members);
|
||||
return $this->memberMapper->searchWithRelations($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members with a birthday in the current month.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* Part of Issue #34.
|
||||
*
|
||||
* @return array[]
|
||||
@@ -264,20 +255,14 @@ class MemberService {
|
||||
*/
|
||||
public function findByBirthdayThisMonth(): array {
|
||||
$currentMonth = (int)(new DateTime())->format('m');
|
||||
$members = $this->memberMapper->findByBirthdayMonth($currentMonth);
|
||||
return array_map(function (Member $member) {
|
||||
return $this->buildMemberResponse(
|
||||
$member,
|
||||
$this->addressMapper->findByMemberId($member->getId()),
|
||||
$this->phoneMapper->findByMemberId($member->getId()),
|
||||
$this->emailMapper->findByMemberId($member->getId())
|
||||
);
|
||||
}, $members);
|
||||
return $this->memberMapper->findByBirthdayMonthWithRelations($currentMonth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members with unpaid fee records for the current year.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* Part of Issue #34.
|
||||
*
|
||||
* @return array[]
|
||||
@@ -285,20 +270,14 @@ class MemberService {
|
||||
*/
|
||||
public function findWithUnpaidFees(): array {
|
||||
$currentYear = (int)(new DateTime())->format('Y');
|
||||
$members = $this->memberMapper->findWithUnpaidFees($currentYear);
|
||||
return array_map(function (Member $member) {
|
||||
return $this->buildMemberResponse(
|
||||
$member,
|
||||
$this->addressMapper->findByMemberId($member->getId()),
|
||||
$this->phoneMapper->findByMemberId($member->getId()),
|
||||
$this->emailMapper->findByMemberId($member->getId())
|
||||
);
|
||||
}, $members);
|
||||
return $this->memberMapper->findWithUnpaidFeesWithRelations($currentYear);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members matching a combination of filters (additive/AND logic).
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* @return array[]
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -308,43 +287,88 @@ class MemberService {
|
||||
bool $birthdayThisMonth = false,
|
||||
bool $unpaidFees = false
|
||||
): array {
|
||||
$members = $this->memberMapper->findFiltered($status, $rolle, $birthdayThisMonth, $unpaidFees);
|
||||
return array_map(function (Member $member) {
|
||||
return $this->buildMemberResponse(
|
||||
$member,
|
||||
$this->addressMapper->findByMemberId($member->getId()),
|
||||
$this->phoneMapper->findByMemberId($member->getId()),
|
||||
$this->emailMapper->findByMemberId($member->getId())
|
||||
);
|
||||
}, $members);
|
||||
return $this->memberMapper->findFilteredWithRelations($status, $rolle, $birthdayThisMonth, $unpaidFees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-text search across member names, addresses, and notes.
|
||||
* Returns results with match context for display in search dropdown.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* Part of Issue #33.
|
||||
*
|
||||
* @return array[] Members with sub-entities and matchContext field
|
||||
* @throws Exception
|
||||
*/
|
||||
public function fullTextSearch(string $query, int $limit = 20): array {
|
||||
$members = $this->memberMapper->fullTextSearch($query, $limit);
|
||||
$rawResults = $this->memberMapper->fullTextSearchWithRelations($query, $limit);
|
||||
$lowQuery = mb_strtolower($query);
|
||||
|
||||
return array_map(function (Member $member) use ($lowQuery) {
|
||||
$id = $member->getId();
|
||||
$addresses = $this->addressMapper->findByMemberId($id);
|
||||
$phones = $this->phoneMapper->findByMemberId($id);
|
||||
$emails = $this->emailMapper->findByMemberId($id);
|
||||
|
||||
$result = $this->buildMemberResponse($member, $addresses, $phones, $emails);
|
||||
return array_map(function (array $row) use ($lowQuery): array {
|
||||
// buildMatchContext expects an Address[] — the raw result
|
||||
// stores addresses as arrays. Build minimal Address objects
|
||||
// so the existing logic works without rewriting the method.
|
||||
$addresses = array_map(
|
||||
fn(array $a): Address => $this->arrayToAddress($a),
|
||||
$row['addresses'] ?? []
|
||||
);
|
||||
|
||||
// Determine match context — which field matched
|
||||
$result['matchContext'] = $this->buildMatchContext($member, $addresses, $lowQuery);
|
||||
$row['matchContext'] = $this->buildMatchContext(
|
||||
$this->arrayToMember($row),
|
||||
$addresses,
|
||||
$lowQuery
|
||||
);
|
||||
|
||||
return $result;
|
||||
}, $members);
|
||||
return $row;
|
||||
}, $rawResults);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an associative array (from fetchWithRelations) to a Member entity.
|
||||
*/
|
||||
private function arrayToMember(array $row): Member {
|
||||
$m = new Member();
|
||||
$m->setId($row['id']);
|
||||
$m->setVorname($row['vorname']);
|
||||
$m->setNachname($row['nachname']);
|
||||
$m->setGeburtsdatum($row['geburtsdatum']);
|
||||
$m->setGeschlecht($row['geschlecht']);
|
||||
$m->setRolle($row['rolle']);
|
||||
$m->setStufeId($row['stufeId']);
|
||||
$m->setEintritt($row['eintritt']);
|
||||
$m->setAustritt($row['austritt']);
|
||||
$m->setStatus($row['status']);
|
||||
$m->setAllergienEncrypted($row['allergienEncrypted']);
|
||||
$m->setNotizen($row['notizen']);
|
||||
$m->setZusatzNotizen($row['zusatzNotizen']);
|
||||
$m->setKvTyp($row['kvTyp']);
|
||||
$m->setKvName($row['kvName']);
|
||||
$m->setFamilyId($row['familyId']);
|
||||
$m->setFrozenFeeRate($row['frozenFeeRate']);
|
||||
$m->setCalendarEventUri($row['calendarEventUri']);
|
||||
$m->setContactVcardUri($row['contactVcardUri']);
|
||||
$m->setEinwilligungDatum($row['einwilligungDatum']);
|
||||
$m->setJuleicaNummer($row['juleicaNummer']);
|
||||
$m->setJuleicaAblaufdatum($row['juleicaAblaufdatum']);
|
||||
return $m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an associative array (from fetchWithRelations) to an Address entity.
|
||||
*/
|
||||
private function arrayToAddress(array $row): Address {
|
||||
$a = new Address();
|
||||
$a->setId($row['id']);
|
||||
$a->setMemberId($row['memberId']);
|
||||
$a->setLabel($row['label']);
|
||||
$a->setStrasse($row['strasse']);
|
||||
$a->setPlz($row['plz']);
|
||||
$a->setOrt($row['ort']);
|
||||
$a->setLand($row['land']);
|
||||
$a->setIsPrimary($row['isPrimary']);
|
||||
return $a;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -503,6 +527,8 @@ class MemberService {
|
||||
|
||||
$member->setUpdatedAt((new DateTime())->format('Y-m-d H:i:s'));
|
||||
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
/** @var Member $member */
|
||||
$member = $this->memberMapper->update($member);
|
||||
|
||||
@@ -518,11 +544,62 @@ class MemberService {
|
||||
$this->syncEmails($id, $rawData['emails']);
|
||||
}
|
||||
|
||||
$this->db->commit();
|
||||
} catch (\Exception $e) {
|
||||
$this->db->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->auditService->logUpdate($oldData, $member->jsonSerialize(), 'member', $id);
|
||||
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic sub-entity sync: update existing, create new, delete removed.
|
||||
*
|
||||
* @param int $memberId
|
||||
* @param array[] $incoming Payload arrays (each may contain an 'id' key)
|
||||
* @param callable(): list<object> $fetchExisting Fetch current DB entities for this member
|
||||
* @param callable(object): int $getId Extract entity ID
|
||||
* @param callable(int, array): void $updateEntity Update an existing entity by ID
|
||||
* @param callable(int, array): void $createEntity Create a new entity for a member
|
||||
* @param callable(int): void $deleteEntity Delete an entity by ID
|
||||
* @throws Exception
|
||||
*/
|
||||
private function syncSubEntities(
|
||||
int $memberId,
|
||||
array $incoming,
|
||||
callable $fetchExisting,
|
||||
callable $getId,
|
||||
callable $updateEntity,
|
||||
callable $createEntity,
|
||||
callable $deleteEntity
|
||||
): void {
|
||||
$existing = $fetchExisting();
|
||||
$existingById = [];
|
||||
foreach ($existing as $entity) {
|
||||
$existingById[$getId($entity)] = $entity;
|
||||
}
|
||||
|
||||
$keptIds = [];
|
||||
foreach ($incoming as $item) {
|
||||
$id = isset($item['id']) ? (int)$item['id'] : 0;
|
||||
if ($id > 0 && isset($existingById[$id])) {
|
||||
$updateEntity($id, $item);
|
||||
$keptIds[$id] = true;
|
||||
} else {
|
||||
$createEntity($memberId, $item);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_keys($existingById) as $id) {
|
||||
if (!isset($keptIds[$id])) {
|
||||
$deleteEntity($id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a member's addresses against an incoming array.
|
||||
* Rules:
|
||||
@@ -534,28 +611,15 @@ class MemberService {
|
||||
* @throws Exception
|
||||
*/
|
||||
private function syncAddresses(int $memberId, array $incoming): void {
|
||||
$existing = $this->addressMapper->findByMemberId($memberId);
|
||||
$existingById = [];
|
||||
foreach ($existing as $addr) {
|
||||
$existingById[$addr->getId()] = $addr;
|
||||
}
|
||||
|
||||
$keptIds = [];
|
||||
foreach ($incoming as $item) {
|
||||
$id = isset($item['id']) ? (int)$item['id'] : 0;
|
||||
if ($id > 0 && isset($existingById[$id])) {
|
||||
$this->updateAddress($id, $item);
|
||||
$keptIds[$id] = true;
|
||||
} else {
|
||||
$this->addAddress($memberId, $item);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($existingById as $id => $addr) {
|
||||
if (!isset($keptIds[$id])) {
|
||||
$this->deleteAddress($id);
|
||||
}
|
||||
}
|
||||
$this->syncSubEntities(
|
||||
$memberId,
|
||||
$incoming,
|
||||
fn() => $this->addressMapper->findByMemberId($memberId),
|
||||
fn($e) => $e->getId(),
|
||||
fn($id, $data) => $this->updateAddress($id, $data),
|
||||
fn($mid, $data) => $this->addAddress($mid, $data),
|
||||
fn($id) => $this->deleteAddress($id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -565,28 +629,15 @@ class MemberService {
|
||||
* @throws Exception
|
||||
*/
|
||||
private function syncPhones(int $memberId, array $incoming): void {
|
||||
$existing = $this->phoneMapper->findByMemberId($memberId);
|
||||
$existingById = [];
|
||||
foreach ($existing as $p) {
|
||||
$existingById[$p->getId()] = $p;
|
||||
}
|
||||
|
||||
$keptIds = [];
|
||||
foreach ($incoming as $item) {
|
||||
$id = isset($item['id']) ? (int)$item['id'] : 0;
|
||||
if ($id > 0 && isset($existingById[$id])) {
|
||||
$this->updatePhone($id, $item);
|
||||
$keptIds[$id] = true;
|
||||
} else {
|
||||
$this->addPhone($memberId, $item);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($existingById as $id => $p) {
|
||||
if (!isset($keptIds[$id])) {
|
||||
$this->deletePhone($id);
|
||||
}
|
||||
}
|
||||
$this->syncSubEntities(
|
||||
$memberId,
|
||||
$incoming,
|
||||
fn() => $this->phoneMapper->findByMemberId($memberId),
|
||||
fn($e) => $e->getId(),
|
||||
fn($id, $data) => $this->updatePhone($id, $data),
|
||||
fn($mid, $data) => $this->addPhone($mid, $data),
|
||||
fn($id) => $this->deletePhone($id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -596,28 +647,15 @@ class MemberService {
|
||||
* @throws Exception
|
||||
*/
|
||||
private function syncEmails(int $memberId, array $incoming): void {
|
||||
$existing = $this->emailMapper->findByMemberId($memberId);
|
||||
$existingById = [];
|
||||
foreach ($existing as $e) {
|
||||
$existingById[$e->getId()] = $e;
|
||||
}
|
||||
|
||||
$keptIds = [];
|
||||
foreach ($incoming as $item) {
|
||||
$id = isset($item['id']) ? (int)$item['id'] : 0;
|
||||
if ($id > 0 && isset($existingById[$id])) {
|
||||
$this->updateEmail($id, $item);
|
||||
$keptIds[$id] = true;
|
||||
} else {
|
||||
$this->addEmail($memberId, $item);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($existingById as $id => $e) {
|
||||
if (!isset($keptIds[$id])) {
|
||||
$this->deleteEmail($id);
|
||||
}
|
||||
}
|
||||
$this->syncSubEntities(
|
||||
$memberId,
|
||||
$incoming,
|
||||
fn() => $this->emailMapper->findByMemberId($memberId),
|
||||
fn($e) => $e->getId(),
|
||||
fn($id, $data) => $this->updateEmail($id, $data),
|
||||
fn($mid, $data) => $this->addEmail($mid, $data),
|
||||
fn($id) => $this->deleteEmail($id)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Delete (soft) ────────────────────────────────────────────────
|
||||
@@ -673,6 +711,8 @@ class MemberService {
|
||||
$member->setCalendarEventUri(null);
|
||||
$member->setContactVcardUri(null);
|
||||
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
$this->memberMapper->update($member);
|
||||
|
||||
// Hard-delete sub-entities (addresses, phones, emails)
|
||||
@@ -680,6 +720,12 @@ class MemberService {
|
||||
$this->phoneMapper->deleteByMemberId($id);
|
||||
$this->emailMapper->deleteByMemberId($id);
|
||||
|
||||
$this->db->commit();
|
||||
} catch (\Exception $e) {
|
||||
$this->db->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Audit log the soft-delete
|
||||
$this->auditService->logSoftDelete('member', $id);
|
||||
|
||||
@@ -726,10 +772,7 @@ class MemberService {
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findArchived(?int $limit = null, ?int $offset = null): array {
|
||||
$members = $this->memberMapper->findAll($limit, $offset, true);
|
||||
|
||||
// Filter to only soft-deleted members
|
||||
$archived = array_filter($members, fn(Member $m) => $m->getDeletedAt() !== null);
|
||||
$members = $this->memberMapper->findArchived($limit, $offset);
|
||||
|
||||
return array_values(array_map(function (Member $member) {
|
||||
// Only return retained fields
|
||||
@@ -743,17 +786,19 @@ class MemberService {
|
||||
'deletedAt' => $member->getDeletedAt(),
|
||||
'mitgliedsdauer' => $this->calculateMitgliedsdauer($member),
|
||||
];
|
||||
}, $archived));
|
||||
}, $members));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count all soft-deleted (archived) members.
|
||||
*
|
||||
* Part of Issue #201: uses a single SQL COUNT query via the mapper
|
||||
* instead of loading all members into memory.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function countArchived(): int {
|
||||
$allMembers = $this->memberMapper->findAll(null, null, true);
|
||||
return count(array_filter($allMembers, fn(Member $m) => $m->getDeletedAt() !== null));
|
||||
return $this->memberMapper->countArchived();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Service;
|
||||
|
||||
use DateTime;
|
||||
use OCA\Mitgliederverwaltung\Db\SaleRecord;
|
||||
use OCA\Mitgliederverwaltung\Db\SaleRecordMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\DB\Exception;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Service layer for inventory sale records.
|
||||
*
|
||||
* Part of Issue #165 (Inventory Tracking).
|
||||
*/
|
||||
class SaleService {
|
||||
|
||||
private SaleRecordMapper $saleRecordMapper;
|
||||
private AuditService $auditService;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
SaleRecordMapper $saleRecordMapper,
|
||||
AuditService $auditService,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,13 @@
|
||||
<Tent :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Inventar"
|
||||
:to="{ name: 'inventory' }"
|
||||
:active="currentRoute === 'inventory'">
|
||||
<template #icon>
|
||||
<PackageVariant :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Verletzungen"
|
||||
:to="{ name: 'injuries' }"
|
||||
:active="currentRoute === 'injuries'">
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="category-picker">
|
||||
<NcSelect
|
||||
:model-value="selectedCategories"
|
||||
:options="options"
|
||||
multiple
|
||||
:reduce="o => o.value"
|
||||
:close-on-select="false"
|
||||
placeholder="Kategorien auswählen..."
|
||||
@update:model-value="$emit('update:modelValue', $event.map(o => o.value))"
|
||||
/>
|
||||
|
||||
<div class="category-picker__create">
|
||||
<NcTextField
|
||||
:model-value="newCategoryName"
|
||||
placeholder="Neue Kategorie erstellen..."
|
||||
@update:model-value="newCategoryName = $event"
|
||||
/>
|
||||
<NcButton
|
||||
type="primary"
|
||||
:disabled="!newCategoryName"
|
||||
@click="createNewCategory"
|
||||
>
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
</template>
|
||||
Erstellen
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { NcSelect, NcButton } from '@nextcloud/vue'
|
||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'create'])
|
||||
|
||||
const newCategoryName = ref('')
|
||||
|
||||
const selectedCategories = ref([...(props.modelValue || [])])
|
||||
|
||||
// Keep selectedCategories in sync with modelValue
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
selectedCategories.value = [...(newVal || [])]
|
||||
})
|
||||
|
||||
function createNewCategory() {
|
||||
if (!newCategoryName.value) return
|
||||
emit('create', newCategoryName.value.trim())
|
||||
newCategoryName.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.category-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.category-picker__create {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="general-material-form">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="general-material-form__field">
|
||||
<label for="gm-name">Name:</label>
|
||||
<NcTextField
|
||||
id="gm-name"
|
||||
:model-value="form.name"
|
||||
:placeholder="'Name des Materials'"
|
||||
:validate="form.name !== ''"
|
||||
@update:model-value="form.name = $event"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="general-material-form__field">
|
||||
<label for="gm-condition">Zustand (0–5):</label>
|
||||
<NcSelect
|
||||
id="gm-condition"
|
||||
:model-value="conditionOptions.find(o => o.value === form.condition)"
|
||||
:options="conditionOptions"
|
||||
:reduce="o => o.value"
|
||||
:clearable="true"
|
||||
@update:model-value="form.condition = $event"
|
||||
>
|
||||
<template #cell="{ option }">
|
||||
<span class="general-material-form__condition-option">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</template>
|
||||
</NcSelect>
|
||||
</div>
|
||||
|
||||
<div class="general-material-form__field">
|
||||
<label for="gm-categories">Kategorien:</label>
|
||||
<NcSelect
|
||||
id="gm-categories"
|
||||
:model-value="categoryOptions.filter(o => form.categories?.includes(o.value))"
|
||||
:options="categoryOptions"
|
||||
multiple
|
||||
:reduce="o => o.value"
|
||||
:close-on-select="false"
|
||||
@update:model-value="form.categories = $event.map(o => o.value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="general-material-form__field">
|
||||
<label for="gm-notes">Notizen:</label>
|
||||
<NcTextField
|
||||
id="gm-notes"
|
||||
:type="'textarea'"
|
||||
:model-value="form.notes"
|
||||
:placeholder="'Zusätzliche Notizen...'"
|
||||
:multiple-lines="true"
|
||||
@update:model-value="form.notes = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="general-material-form__actions">
|
||||
<NcButton type="tertiary" @click="$emit('close')">
|
||||
Abbrechen
|
||||
</NcButton>
|
||||
<NcButton type="primary" :disabled="!isFormValid" @click="handleSubmit">
|
||||
{{ form.name ? (editMode ? 'Speichern' : 'Erstellen') : 'Speichern' }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { NcTextField, NcSelect, NcButton } from '@nextcloud/vue'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'close'])
|
||||
|
||||
const editMode = computed(() => !!props.item)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
condition: null,
|
||||
categories: [],
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const conditionOptions = computed(() => {
|
||||
const options = []
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
options.push({ value: i, label: i === 0 ? '0 (sehr schlecht)' : i === 5 ? '5 (hervorragend)' : String(i) })
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const categoryOptions = computed(() => {
|
||||
return props.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
}))
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => form.value.name !== '')
|
||||
|
||||
// Load existing item data
|
||||
watch(() => props.item, (newVal) => {
|
||||
if (newVal) {
|
||||
form.value = {
|
||||
name: newVal.name || '',
|
||||
condition: newVal.condition ?? null,
|
||||
categories: newVal.categories || [],
|
||||
notes: newVal.notes || '',
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
const data = {
|
||||
name: form.value.name,
|
||||
condition: form.value.condition,
|
||||
categories: form.value.categories,
|
||||
notes: form.value.notes || null,
|
||||
}
|
||||
|
||||
if (editMode.value) {
|
||||
await emit('update', 'general', 'update', { id: props.item.id, ...data })
|
||||
} else {
|
||||
await emit('update', 'general', 'create', data)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.general-material-form {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.general-material-form__field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.general-material-form__field label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.general-material-form__condition-option {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.general-material-form__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="sale-form">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="sale-form__field">
|
||||
<label for="sale-item">Artikel:</label>
|
||||
<NcSelect
|
||||
id="sale-item"
|
||||
:model-value="stockItemOptions.find(o => o.value === form.stock_item_id)"
|
||||
:options="stockItemOptions"
|
||||
:reduce="o => o.value"
|
||||
:clearable="true"
|
||||
@update:model-value="form.stock_item_id = $event"
|
||||
placeholder="Artikel auswählen..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sale-form__field">
|
||||
<label for="sale-date">Datum:</label>
|
||||
<NcDateTimePicker
|
||||
id="sale-date"
|
||||
:model-value="form.date ? new Date(form.date + 'T00:00:00') : new Date()"
|
||||
@update:model-value="form.date = $event?.toISOString().split('T')[0] || (new Date()).toISOString().split('T')[0]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sale-form__field">
|
||||
<label for="sale-variant">Variante:</label>
|
||||
<NcSelect
|
||||
id="sale-variant"
|
||||
:model-value="variantOptions.find(o => o.value === form.variant_id)"
|
||||
:options="variantOptions"
|
||||
:reduce="o => o.value"
|
||||
:clearable="true"
|
||||
:disabled="!form.stock_item_id"
|
||||
@update:model-value="form.variant_id = $event"
|
||||
placeholder="Variante auswählen (optional)..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sale-form__field">
|
||||
<label for="sale-quantity">Menge:</label>
|
||||
<NcTextField
|
||||
id="sale-quantity"
|
||||
:type="'number'"
|
||||
:model-value="form.quantity"
|
||||
:validate="form.quantity >= 1"
|
||||
@update:model-value="form.quantity = Number($event) || 0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sale-form__field">
|
||||
<label for="sale-unit-price">Stückpreis (€):</label>
|
||||
<NcTextField
|
||||
id="sale-unit-price"
|
||||
:type="'text'"
|
||||
:model-value="form.unit_price"
|
||||
:validate="form.unit_price !== ''"
|
||||
@update:model-value="form.unit_price = $event"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sale-form__field sale-form__field--summary">
|
||||
<strong>Gesamtsumme:</strong> {{ totalPrice }}
|
||||
</div>
|
||||
|
||||
<div class="sale-form__field">
|
||||
<label for="sale-notes">Notizen:</label>
|
||||
<NcTextField
|
||||
id="sale-notes"
|
||||
:type="'textarea'"
|
||||
:model-value="form.notes"
|
||||
:placeholder="'Zusätzliche Notizen...'"
|
||||
:validate="true"
|
||||
@update:model-value="form.notes = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sale-form__actions">
|
||||
<NcButton type="tertiary" @click="$emit('close')">
|
||||
Abbrechen
|
||||
</NcButton>
|
||||
<NcButton type="primary" :disabled="!isFormValid" @click="handleSubmit">
|
||||
Verkauf eintragen
|
||||
</NcButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { NcTextField, NcSelect, NcButton, NcDateTimePicker } from '@nextcloud/vue'
|
||||
|
||||
const props = defineProps({
|
||||
stockItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'create'])
|
||||
|
||||
const form = ref({
|
||||
stock_item_id: null,
|
||||
variant_id: null,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
quantity: 1,
|
||||
unit_price: '0.00',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const stockItemOptions = computed(() => {
|
||||
return props.stockItems.map(item => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
}))
|
||||
})
|
||||
|
||||
const variantOptions = computed(() => {
|
||||
if (!form.value.stock_item_id) return []
|
||||
const item = props.stockItems.find(i => i.id === form.value.stock_item_id)
|
||||
if (!item || !item.variants) return []
|
||||
return item.variants.map(v => ({
|
||||
value: v.id,
|
||||
label: v.label,
|
||||
}))
|
||||
})
|
||||
|
||||
const totalPrice = computed(() => {
|
||||
const qty = Number(form.value.quantity) || 0
|
||||
const price = parseFloat(form.value.unit_price) || 0
|
||||
const total = qty * price
|
||||
return total.toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
})
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return form.value.stock_item_id !== null &&
|
||||
form.value.quantity >= 1 &&
|
||||
form.value.unit_price !== ''
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
const data = {
|
||||
stock_item_id: form.value.stock_item_id,
|
||||
variant_id: form.value.variant_id,
|
||||
date: form.value.date,
|
||||
quantity: Number(form.value.quantity),
|
||||
unit_price: form.value.unit_price,
|
||||
notes: form.value.notes || null,
|
||||
}
|
||||
|
||||
// Create the sale and notify the parent
|
||||
emit('create', data)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sale-form {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.sale-form__field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sale-form__field label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sale-form__field--summary {
|
||||
background: var(--color-background-dark);
|
||||
padding: 12px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.sale-form__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div class="stock-item-form">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="stock-item-form__field">
|
||||
<label for="si-name">Name:</label>
|
||||
<NcTextField
|
||||
id="si-name"
|
||||
:model-value="form.name"
|
||||
:placeholder="'Name des Verkaufsmaterials'"
|
||||
:validate="form.name !== ''"
|
||||
@update:model-value="form.name = $event"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="stock-item-form__field">
|
||||
<label for="si-categories">Kategorien:</label>
|
||||
<NcSelect
|
||||
id="si-categories"
|
||||
:model-value="categoryOptions.filter(o => form.categories?.includes(o.value))"
|
||||
:options="categoryOptions"
|
||||
multiple
|
||||
:reduce="o => o.value"
|
||||
:close-on-select="false"
|
||||
@update:model-value="form.categories = $event.map(o => o.value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="stock-item-form__field">
|
||||
<label>Provider-URLs:</label>
|
||||
<div v-for="(urlEntry, idx) in form.providerUrls" :key="idx" class="stock-item-form__url-row">
|
||||
<NcTextField
|
||||
:model-value="urlEntry.url"
|
||||
:placeholder="'https://...'"
|
||||
@update:model-value="form.providerUrls[idx].url = $event"
|
||||
/>
|
||||
<NcTextField
|
||||
:model-value="urlEntry.provider"
|
||||
:placeholder="'Provider'"
|
||||
@update:model-value="form.providerUrls[idx].provider = $event"
|
||||
/>
|
||||
<NcButton type="tertiary-no-background" @click="removeUrl(idx)">
|
||||
<template #icon>
|
||||
<Close :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
<NcButton type="tertiary" @click="addUrl">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
</template>
|
||||
URL hinzufügen
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<div class="stock-item-form__variants-header">
|
||||
<span>Varianten:</span>
|
||||
<NcButton type="tertiary" @click="addVariant">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
</template>
|
||||
Variante hinzufügen
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<div v-for="(variant, idx) in form.variants" :key="idx" class="stock-item-form__variant-row">
|
||||
<NcTextField
|
||||
:model-value="variant.label"
|
||||
:placeholder="'Größe M, Farbe Blau...'"
|
||||
:validate="variant.label !== ''"
|
||||
@update:model-value="form.variants[idx].label = $event"
|
||||
/>
|
||||
<NcTextField
|
||||
:type="'number'"
|
||||
:model-value="variant.amount"
|
||||
:validate="variant.amount >= 0"
|
||||
@update:model-value="form.variants[idx].amount = Number($event) || 0"
|
||||
/>
|
||||
<NcTextField
|
||||
:type="'text'"
|
||||
:model-value="variant.cost"
|
||||
:placeholder="'Stückpreis (€)'"
|
||||
@update:model-value="variant.cost = $event"
|
||||
/>
|
||||
<NcTextField
|
||||
:type="'number'"
|
||||
:model-value="variant.min_threshold"
|
||||
:validate="variant.min_threshold >= 0"
|
||||
@update:model-value="form.variants[idx].min_threshold = Number($event) || 0"
|
||||
/>
|
||||
<NcButton type="tertiary-no-background" @click="removeVariant(idx)">
|
||||
<template #icon>
|
||||
<Close :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<div class="stock-item-form__actions">
|
||||
<NcButton type="tertiary" @click="$emit('close')">
|
||||
Abbrechen
|
||||
</NcButton>
|
||||
<NcButton type="primary" :disabled="!isFormValid" @click="handleSubmit">
|
||||
{{ form.name ? (editMode ? 'Speichern' : 'Erstellen') : 'Speichern' }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { NcTextField, NcSelect, NcButton } from '@nextcloud/vue'
|
||||
import Close from 'vue-material-design-icons/Close.vue'
|
||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'close'])
|
||||
|
||||
const editMode = computed(() => !!props.item)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
categories: [],
|
||||
providerUrls: [],
|
||||
variants: [],
|
||||
})
|
||||
|
||||
const categoryOptions = computed(() => {
|
||||
return props.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
}))
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => form.value.name !== '')
|
||||
|
||||
// Load existing item data
|
||||
watch(() => props.item, (newVal) => {
|
||||
if (newVal) {
|
||||
let urls = []
|
||||
try {
|
||||
urls = JSON.parse(newVal.provider_urls_json || '[]')
|
||||
} catch {
|
||||
urls = []
|
||||
}
|
||||
|
||||
form.value = {
|
||||
name: newVal.name || '',
|
||||
categories: newVal.categories || [],
|
||||
providerUrls: urls.map(u => ({ url: u.url || '', provider: u.provider || '' })),
|
||||
variants: (newVal.variants || []).map(v => ({
|
||||
id: v.id,
|
||||
label: v.label || '',
|
||||
amount: v.amount || 0,
|
||||
cost: v.cost || '0.00',
|
||||
min_threshold: v.min_threshold || 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function addUrl() {
|
||||
form.value.providerUrls.push({ url: '', provider: '' })
|
||||
}
|
||||
|
||||
function removeUrl(idx) {
|
||||
form.value.providerUrls.splice(idx, 1)
|
||||
}
|
||||
|
||||
function addVariant() {
|
||||
form.value.variants.push({
|
||||
label: '',
|
||||
amount: 0,
|
||||
cost: '0.00',
|
||||
min_threshold: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function removeVariant(idx) {
|
||||
form.value.variants.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
const data = {
|
||||
name: form.value.name,
|
||||
categories: form.value.categories,
|
||||
provider_urls_json: JSON.stringify(form.value.providerUrls),
|
||||
variants: form.value.variants.map(v => ({
|
||||
label: v.label,
|
||||
amount: v.amount || 0,
|
||||
cost: v.cost || '0.00',
|
||||
min_threshold: v.min_threshold || 0,
|
||||
id: v.id,
|
||||
})),
|
||||
}
|
||||
|
||||
if (editMode.value) {
|
||||
await emit('update', 'stock', 'update', { id: props.item.id, ...data })
|
||||
} else {
|
||||
await emit('update', 'stock', 'create', data)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stock-item-form {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.stock-item-form__field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stock-item-form__field label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stock-item-form__url-row,
|
||||
.stock-item-form__variant-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stock-item-form__variants-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.stock-item-form__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="stock-variant-list">
|
||||
<div v-if="variants.length > 0" class="stock-variant-list__variants">
|
||||
<StockVariantRow
|
||||
v-for="variant in variants"
|
||||
:key="variant.id"
|
||||
:variant="variant"
|
||||
:stock-item-id="stockItemId"
|
||||
@update="$emit('update-variant', $event)"
|
||||
@delete="$emit('delete-variant', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="stock-variant-list__empty">
|
||||
Keine Varianten vorhanden
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import StockVariantRow from './StockVariantRow.vue'
|
||||
|
||||
defineProps({
|
||||
variants: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
stockItemId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['update-variant', 'delete-variant'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stock-variant-list__variants {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stock-variant-list__empty {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-style: italic;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="stock-variant-row">
|
||||
<NcTextField
|
||||
:model-value="variant.label"
|
||||
:placeholder="'Größe M, Farbe Blau...'"
|
||||
:validate="variant.label !== ''"
|
||||
@update:model-value="$emit('update-variant', { ...variant, label: $event })"
|
||||
/>
|
||||
<NcTextField
|
||||
:type="'number'"
|
||||
:model-value="variant.amount"
|
||||
:validate="variant.amount >= 0"
|
||||
@update:model-value="$emit('update-variant', { ...variant, amount: Number($event) || 0 })"
|
||||
/>
|
||||
<NcTextField
|
||||
:type="'text'"
|
||||
:model-value="variant.cost"
|
||||
:placeholder="'Stückpreis (€)'"
|
||||
@update:model-value="$emit('update-variant', { ...variant, cost: $event })"
|
||||
/>
|
||||
<NcTextField
|
||||
:type="'number'"
|
||||
:model-value="variant.min_threshold"
|
||||
:validate="variant.min_threshold >= 0"
|
||||
@update:model-value="$emit('update-variant', { ...variant, min_threshold: Number($event) || 0 })"
|
||||
/>
|
||||
<NcButton type="tertiary-no-background" @click="$emit('delete-variant', variant.id)">
|
||||
<template #icon>
|
||||
<Close :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import { NcTextField, NcButton } from '@nextcloud/vue'
|
||||
import Close from 'vue-material-design-icons/Close.vue'
|
||||
|
||||
defineProps({
|
||||
variant: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
stockItemId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['update-variant', 'delete-variant'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stock-variant-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -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.2.11')
|
||||
app.provide('appVersion', '0.3.2')
|
||||
|
||||
app.mount('#mitgliederverwaltung')
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<div class="inventory">
|
||||
<h2>Inventar</h2>
|
||||
|
||||
<!-- Tab buttons -->
|
||||
<div class="inventory__tabs">
|
||||
<NcButton
|
||||
:primary="activeTab === 'general'"
|
||||
@click="activeTab = 'general'"
|
||||
:style="{ backgroundColor: activeTab === 'general' ? 'var(--color-primary-element)' : undefined }"
|
||||
>
|
||||
Allgemeinmaterial
|
||||
</NcButton>
|
||||
<NcButton
|
||||
:primary="activeTab === 'stock'"
|
||||
@click="activeTab = 'stock'"
|
||||
:style="{ backgroundColor: activeTab === 'stock' ? 'var(--color-primary-element)' : undefined }"
|
||||
>
|
||||
Verkaufsmaterial
|
||||
</NcButton>
|
||||
<NcButton
|
||||
:primary="activeTab === 'sales'"
|
||||
@click="activeTab = 'sales'"
|
||||
:style="{ backgroundColor: activeTab === 'sales' ? 'var(--color-primary-element)' : undefined }"
|
||||
>
|
||||
Verkäufe
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Allgemeinmaterial -->
|
||||
<div v-if="activeTab === 'general'" class="inventory__section">
|
||||
<div class="inventory__toolbar">
|
||||
<NcButton @click="showGeneralDialog = true">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
</template>
|
||||
Neues Allgemeinmaterial
|
||||
</NcButton>
|
||||
|
||||
<div class="inventory__search">
|
||||
<div class="inventory__search-field">
|
||||
<Magnify :size="20" class="inventory__search-icon" />
|
||||
<input
|
||||
v-model="generalStore.searchText"
|
||||
type="text"
|
||||
class="inventory__search-input"
|
||||
placeholder="Suchen..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="generalStore.loading" class="inventory__loading">
|
||||
<NcLoadingIcon />
|
||||
<span>Laden...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<NcNoteCard v-if="generalStore.error" type="error" class="inventory__error">
|
||||
{{ generalStore.error }}
|
||||
<NcButton @click="generalStore.clearError()" class="inventory__error-close">
|
||||
Schließen
|
||||
</NcButton>
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!generalStore.loading && generalStore.filteredItems.length === 0 && !generalStore.error" class="inventory__empty">
|
||||
<Inbox :size="48" />
|
||||
<p>Keine Allgemeinmaterialien vorhanden</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-if="!generalStore.loading && generalStore.filteredItems.length > 0" class="inventory__table-wrapper">
|
||||
<table class="inventory__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Zustand</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Notizen</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in generalStore.filteredItems" :key="item.id">
|
||||
<td>{{ item.name }}</td>
|
||||
<td>
|
||||
<span class="inventory__condition"
|
||||
:class="{ 'inventory__condition--warning': needsRepair(item.condition) }">
|
||||
{{ getConditionDisplay(item.condition) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.categories?.join(', ') || '–' }}</td>
|
||||
<td class="inventory__notes-cell">{{ item.notes || '–' }}</td>
|
||||
<td class="inventory__actions">
|
||||
<NcButton icon="edit" @click="editGeneral(item)" />
|
||||
<NcButton icon="delete" @click="deleteGeneral(item.id)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Verkaufsmaterial -->
|
||||
<div v-if="activeTab === 'stock'" class="inventory__section">
|
||||
<div class="inventory__toolbar">
|
||||
<NcButton @click="showStockDialog = true">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
</template>
|
||||
Neues Verkaufsmaterial
|
||||
</NcButton>
|
||||
|
||||
<div class="inventory__search">
|
||||
<div class="inventory__search-field">
|
||||
<Magnify :size="20" class="inventory__search-icon" />
|
||||
<input
|
||||
v-model="stockStore.searchText"
|
||||
type="text"
|
||||
class="inventory__search-input"
|
||||
placeholder="Suchen..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="stockStore.loading" class="inventory__loading">
|
||||
<NcLoadingIcon />
|
||||
<span>Laden...</span>
|
||||
</div>
|
||||
|
||||
<NcNoteCard v-if="stockStore.error" type="error" class="inventory__error">
|
||||
{{ stockStore.error }}
|
||||
<NcButton @click="stockStore.clearError()">Schließen</NcButton>
|
||||
</NcNoteCard>
|
||||
|
||||
<div v-if="!stockStore.loading && stockStore.filteredItems.length === 0 && !stockStore.error" class="inventory__empty">
|
||||
<Inbox :size="48" />
|
||||
<p>Kein Verkaufsmaterial vorhanden</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!stockStore.loading && stockStore.filteredItems.length > 0" class="inventory__table-wrapper">
|
||||
<table class="inventory__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Gesamtbestand</th>
|
||||
<th>Mindestbestand</th>
|
||||
<th>Varianten</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in stockStore.filteredItems" :key="item.id">
|
||||
<td>{{ item.name }}</td>
|
||||
<td>
|
||||
<span :class="{ 'inventory__low-stock': item.total_amount < item.min_threshold }">
|
||||
{{ item.total_amount ?? '–' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.min_threshold ?? '–' }}</td>
|
||||
<td>{{ (item.variants || []).length }} Varianten</td>
|
||||
<td class="inventory__actions">
|
||||
<NcButton icon="edit" @click="editStock(item)" />
|
||||
<NcButton icon="delete" @click="deleteStock(item.id)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Verkäufe -->
|
||||
<div v-if="activeTab === 'sales'" class="inventory__section">
|
||||
<div class="inventory__toolbar">
|
||||
<NcButton @click="showSaleDialog = true">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
</template>
|
||||
Verkauf eintragen
|
||||
</NcButton>
|
||||
|
||||
<div class="inventory__filters">
|
||||
<div class="inventory__filter-item">
|
||||
<label for="sale-date-from">Von:</label>
|
||||
<NcDateTimePicker
|
||||
id="sale-date-from"
|
||||
:model-value="salesStore.filterDateFrom ? new Date(salesStore.filterDateFrom + 'T00:00:00') : null"
|
||||
@update:model-value="salesStore.setFilterDateFrom($event?.toISOString().split('T')[0] || null)"
|
||||
/>
|
||||
</div>
|
||||
<div class="inventory__filter-item">
|
||||
<label for="sale-date-to">Bis:</label>
|
||||
<NcDateTimePicker
|
||||
id="sale-date-to"
|
||||
:model-value="salesStore.filterDateTo ? new Date(salesStore.filterDateTo + 'T00:00:00') : null"
|
||||
@update:model-value="salesStore.setFilterDateTo($event?.toISOString().split('T')[0] || null)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="salesStore.loading" class="inventory__loading">
|
||||
<NcLoadingIcon />
|
||||
<span>Laden...</span>
|
||||
</div>
|
||||
|
||||
<NcNoteCard v-if="salesStore.error" type="error" class="inventory__error">
|
||||
{{ salesStore.error }}
|
||||
<NcButton @click="salesStore.clearError()">Schließen</NcButton>
|
||||
</NcNoteCard>
|
||||
|
||||
<div v-if="!salesStore.loading && salesStore.filteredSales.length === 0 && !salesStore.error" class="inventory__empty">
|
||||
<Inbox :size="48" />
|
||||
<p>Keine Verkäufe vorhanden</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!salesStore.loading && salesStore.filteredSales.length > 0" class="inventory__table-wrapper">
|
||||
<table class="inventory__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Artikel</th>
|
||||
<th>Menge</th>
|
||||
<th>Stückpreis</th>
|
||||
<th>Gesamt</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="sale in salesStore.filteredSales" :key="sale.id">
|
||||
<td>{{ formatDate(sale.date) }}</td>
|
||||
<td>{{ sale.stock_item_id || '–' }}</td>
|
||||
<td>{{ sale.quantity }}</td>
|
||||
<td>{{ formatPrice(sale.unit_price) }}</td>
|
||||
<td>{{ formatPrice(sale.total_price) }}</td>
|
||||
<td class="inventory__actions">
|
||||
<NcButton icon="delete" @click="deleteSale(sale.id)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General Material Dialog -->
|
||||
<NcDialog
|
||||
v-if="showGeneralDialog"
|
||||
name="Allgemeinmaterial"
|
||||
:close-on-outside-click="false"
|
||||
:close-button-label="generalEditItem ? 'Schließen' : 'Abbrechen'"
|
||||
@close="closeGeneralDialog"
|
||||
>
|
||||
<GeneralMaterialForm
|
||||
:item="generalEditItem"
|
||||
:categories="categoriesStore.categories"
|
||||
@update="handleGeneralFormUpdate"
|
||||
@close="closeGeneralDialog"
|
||||
/>
|
||||
</NcDialog>
|
||||
|
||||
<!-- Stock Item Dialog -->
|
||||
<NcDialog
|
||||
v-if="showStockDialog"
|
||||
name="Verkaufsmaterial"
|
||||
:close-on-outside-click="false"
|
||||
:close-button-label="stockEditItem ? 'Schließen' : 'Abbrechen'"
|
||||
@close="closeStockDialog"
|
||||
>
|
||||
<StockItemForm
|
||||
:item="stockEditItem"
|
||||
:categories="categoriesStore.categories"
|
||||
@update="handleStockFormUpdate"
|
||||
@close="closeStockDialog"
|
||||
/>
|
||||
</NcDialog>
|
||||
|
||||
<!-- Sale Dialog -->
|
||||
<NcDialog
|
||||
v-if="showSaleDialog"
|
||||
name="Verkauf eintragen"
|
||||
:close-on-outside-click="false"
|
||||
:close-button-label="'Abbrechen'"
|
||||
@close="closeSaleDialog"
|
||||
>
|
||||
<SaleForm
|
||||
:stock-items="stockStore.items"
|
||||
@close="closeSaleDialog"
|
||||
@create="handleSaleCreate"
|
||||
/>
|
||||
</NcDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NcButton, NcTextField, NcNoteCard, NcLoadingIcon, NcDialog, NcDateTimePicker } from '@nextcloud/vue'
|
||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||
import Inbox from 'vue-material-design-icons/Inbox.vue'
|
||||
import Magnify from 'vue-material-design-icons/Magnify.vue'
|
||||
import { useCategoriesStore } from '../stores/categories.js'
|
||||
import { useGeneralStore } from '../stores/general.js'
|
||||
import { useStockStore } from '../stores/stock.js'
|
||||
import { useSalesStore } from '../stores/sales.js'
|
||||
import GeneralMaterialForm from '../components/GeneralMaterialForm.vue'
|
||||
import StockItemForm from '../components/StockItemForm.vue'
|
||||
import SaleForm from '../components/SaleForm.vue'
|
||||
|
||||
const categoriesStore = useCategoriesStore()
|
||||
const generalStore = useGeneralStore()
|
||||
const stockStore = useStockStore()
|
||||
const salesStore = useSalesStore()
|
||||
|
||||
const activeTab = ref('general')
|
||||
const showGeneralDialog = ref(false)
|
||||
const showStockDialog = ref(false)
|
||||
const showSaleDialog = ref(false)
|
||||
const generalEditItem = ref(null)
|
||||
const stockEditItem = ref(null)
|
||||
|
||||
function needsRepair(condition) {
|
||||
return condition === null || condition <= 2
|
||||
}
|
||||
|
||||
function getConditionDisplay(condition) {
|
||||
if (condition === null) return 'Nicht bewertet'
|
||||
return condition + '/5'
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return '–'
|
||||
return date
|
||||
}
|
||||
|
||||
function formatPrice(value) {
|
||||
if (!value) return '–'
|
||||
return Number(value).toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
})
|
||||
}
|
||||
|
||||
async function editGeneral(item) {
|
||||
generalEditItem.value = item
|
||||
showGeneralDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteGeneral(id) {
|
||||
if (confirm('Allgemeinmaterial wirklich löschen?')) {
|
||||
await generalStore.deleteItem(id)
|
||||
}
|
||||
}
|
||||
|
||||
function closeGeneralDialog() {
|
||||
showGeneralDialog.value = false
|
||||
generalEditItem.value = null
|
||||
}
|
||||
|
||||
async function handleGeneralFormUpdate(field, value) {
|
||||
// Handled by the form component
|
||||
}
|
||||
|
||||
async function editStock(item) {
|
||||
stockEditItem.value = item
|
||||
showStockDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteStock(id) {
|
||||
if (confirm('Verkaufsmaterial wirklich löschen?')) {
|
||||
await stockStore.deleteItem(id)
|
||||
}
|
||||
}
|
||||
|
||||
function closeStockDialog() {
|
||||
showStockDialog.value = false
|
||||
stockEditItem.value = null
|
||||
}
|
||||
|
||||
async function handleStockFormUpdate(field, value) {
|
||||
// Handled by the form component
|
||||
}
|
||||
|
||||
function closeSaleDialog() {
|
||||
showSaleDialog.value = false
|
||||
}
|
||||
|
||||
async function handleSaleCreate(data) {
|
||||
try {
|
||||
await salesStore.createSale(data)
|
||||
closeSaleDialog()
|
||||
} catch (err) {
|
||||
console.error('Failed to create sale:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSale(id) {
|
||||
if (confirm('Verkauf wirklich löschen?')) {
|
||||
await salesStore.deleteSale(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch data on mount
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
categoriesStore.fetchCategories(),
|
||||
generalStore.fetchItems(),
|
||||
stockStore.fetchItems(),
|
||||
salesStore.fetchSales(),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inventory {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.inventory h2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.inventory__tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.inventory__section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.inventory__toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inventory__search {
|
||||
position: relative;
|
||||
min-width: 200px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inventory__search-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--color-main-background);
|
||||
padding: 0 8px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.inventory__search-field:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.inventory__search-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-lighter);
|
||||
}
|
||||
|
||||
.inventory__search-input {
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
padding: 6px 4px;
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
font-size: 0.9em;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.inventory__search-input::placeholder {
|
||||
color: var(--color-text-lighter);
|
||||
}
|
||||
|
||||
.inventory__filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inventory__filter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inventory__filter-item label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inventory__table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.inventory__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.inventory__table th,
|
||||
.inventory__table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.inventory__table th {
|
||||
font-weight: 600;
|
||||
background: var(--color-background-dark);
|
||||
}
|
||||
|
||||
.inventory__condition {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inventory__condition--warning {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.inventory__low-stock {
|
||||
color: var(--color-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inventory__notes-cell {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inventory__actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inventory__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.inventory__empty {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.inventory__empty p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.inventory__error {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inventory__error-close {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Tests\Db;
|
||||
|
||||
use OCA\Mitgliederverwaltung\Db\GeneralMaterial;
|
||||
use OCA\Mitgliederverwaltung\Db\GeneralMaterialMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\DB\QueryBuilder\IExpressionBuilder;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\DB\IResult;
|
||||
use OCP\IDBConnection;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class GeneralMaterialMapperTest extends TestCase {
|
||||
|
||||
private IDBConnection&MockObject $db;
|
||||
private IQueryBuilder&MockObject $qb;
|
||||
private IExpressionBuilder&MockObject $expr;
|
||||
private IResult&MockObject $result;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Tests\Db;
|
||||
|
||||
use OCA\Mitgliederverwaltung\Db\InventoryCategory;
|
||||
use OCA\Mitgliederverwaltung\Db\InventoryCategoryMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\DB\QueryBuilder\IExpressionBuilder;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\DB\IResult;
|
||||
use OCP\IDBConnection;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class InventoryCategoryMapperTest extends TestCase {
|
||||
|
||||
private IDBConnection&MockObject $db;
|
||||
private IQueryBuilder&MockObject $qb;
|
||||
private IExpressionBuilder&MockObject $expr;
|
||||
private IResult&MockObject $result;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Tests\Db;
|
||||
|
||||
use OCA\Mitgliederverwaltung\Db\InventoryItemCategory;
|
||||
use OCA\Mitgliederverwaltung\Db\InventoryItemCategoryMapper;
|
||||
use OCP\DB\QueryBuilder\IExpressionBuilder;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\DB\IResult;
|
||||
use OCP\IDBConnection;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class InventoryItemCategoryMapperTest extends TestCase {
|
||||
|
||||
private IDBConnection&MockObject $db;
|
||||
private IQueryBuilder&MockObject $qb;
|
||||
private IExpressionBuilder&MockObject $expr;
|
||||
private IResult&MockObject $result;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Tests\Db;
|
||||
|
||||
use OCA\Mitgliederverwaltung\Db\SaleRecord;
|
||||
use OCA\Mitgliederverwaltung\Db\SaleRecordMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\DB\QueryBuilder\IExpressionBuilder;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\DB\IResult;
|
||||
use OCP\IDBConnection;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SaleRecordMapperTest extends TestCase {
|
||||
|
||||
private IDBConnection&MockObject $db;
|
||||
private IQueryBuilder&MockObject $qb;
|
||||
private IExpressionBuilder&MockObject $expr;
|
||||
private IResult&MockObject $result;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Tests\Db;
|
||||
|
||||
use OCA\Mitgliederverwaltung\Db\StockItem;
|
||||
use OCA\Mitgliederverwaltung\Db\StockItemMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\DB\QueryBuilder\IExpressionBuilder;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\DB\IResult;
|
||||
use OCP\IDBConnection;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class StockItemMapperTest extends TestCase {
|
||||
|
||||
private IDBConnection&MockObject $db;
|
||||
private IQueryBuilder&MockObject $qb;
|
||||
private IExpressionBuilder&MockObject $expr;
|
||||
private IResult&MockObject $result;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Tests\Db;
|
||||
|
||||
use OCA\Mitgliederverwaltung\Db\StockVariant;
|
||||
use OCA\Mitgliederverwaltung\Db\StockVariantMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\DB\QueryBuilder\IExpressionBuilder;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\DB\IResult;
|
||||
use OCP\IDBConnection;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class StockVariantMapperTest extends TestCase {
|
||||
|
||||
private IDBConnection&MockObject $db;
|
||||
private IQueryBuilder&MockObject $qb;
|
||||
private IExpressionBuilder&MockObject $expr;
|
||||
private IResult&MockObject $result;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Tests\Service;
|
||||
|
||||
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 OCA\Mitgliederverwaltung\Db\SaleRecord;
|
||||
use OCA\Mitgliederverwaltung\Db\SaleRecordMapper;
|
||||
use OCA\Mitgliederverwaltung\Service\InventoryReportService;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class InventoryReportServiceTest extends TestCase {
|
||||
|
||||
private InventoryReportService $service;
|
||||
private MockObject $generalMaterialMapper;
|
||||
private MockObject $categoryMapper;
|
||||
private MockObject $itemCategoryMapper;
|
||||
private MockObject $stockItemMapper;
|
||||
private MockObject $stockVariantMapper;
|
||||
private MockObject $saleRecordMapper;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Tests\Service;
|
||||
|
||||
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\Service\AuditService;
|
||||
use OCA\Mitgliederverwaltung\Service\InventoryService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class InventoryServiceTest extends TestCase {
|
||||
|
||||
private InventoryService $service;
|
||||
private InventoryCategoryMapper&MockObject $categoryMapper;
|
||||
private InventoryItemCategoryMapper&MockObject $itemCategoryMapper;
|
||||
private GeneralMaterialMapper&MockObject $generalMaterialMapper;
|
||||
private StockItemMapper&MockObject $stockItemMapper;
|
||||
private MockObject $stockVariantMapper;
|
||||
private AuditService&MockObject $auditService;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Mitgliederverwaltung\Tests\Service;
|
||||
|
||||
use OCA\Mitgliederverwaltung\Db\SaleRecord;
|
||||
use OCA\Mitgliederverwaltung\Db\SaleRecordMapper;
|
||||
use OCA\Mitgliederverwaltung\Service\AuditService;
|
||||
use OCA\Mitgliederverwaltung\Service\SaleService;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class SaleServiceTest extends TestCase {
|
||||
|
||||
private SaleService $service;
|
||||
private SaleRecordMapper&MockObject $saleRecordMapper;
|
||||
private AuditService&MockObject $auditService;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,14 @@ class BundleImportServiceTest extends TestCase {
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
if (!file_exists($tmpFile)) {
|
||||
return '';
|
||||
}
|
||||
$content = file_get_contents($tmpFile);
|
||||
unlink($tmpFile);
|
||||
if ($content === false) {
|
||||
return '';
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
@@ -543,6 +543,45 @@ class MapperTest extends TestCase {
|
||||
$this->assertSame(0, $count);
|
||||
}
|
||||
|
||||
public function testMemberMapperCountArchived(): void {
|
||||
$this->configureResultCount(7);
|
||||
$mapper = new MemberMapper($this->db);
|
||||
$count = $mapper->countArchived();
|
||||
$this->assertSame(7, $count);
|
||||
}
|
||||
|
||||
public function testMemberMapperCountArchivedZero(): void {
|
||||
$this->configureResultCount(0);
|
||||
$mapper = new MemberMapper($this->db);
|
||||
$count = $mapper->countArchived();
|
||||
$this->assertSame(0, $count);
|
||||
}
|
||||
|
||||
public function testMemberMapperFindArchivedNoParams(): void {
|
||||
$row = $this->memberRow(1);
|
||||
$row['deleted_at'] = '2026-01-01 00:00:00';
|
||||
$this->configureResultRows([$row]);
|
||||
$mapper = new MemberMapper($this->db);
|
||||
$members = $mapper->findArchived();
|
||||
$this->assertCount(1, $members);
|
||||
}
|
||||
|
||||
public function testMemberMapperFindArchivedWithPagination(): void {
|
||||
$row = $this->memberRow(1);
|
||||
$row['deleted_at'] = '2026-01-01 00:00:00';
|
||||
$this->configureResultRows([$row]);
|
||||
$mapper = new MemberMapper($this->db);
|
||||
$members = $mapper->findArchived(10, 0);
|
||||
$this->assertCount(1, $members);
|
||||
}
|
||||
|
||||
public function testMemberMapperFindArchivedEmpty(): void {
|
||||
$this->configureResultRows([]);
|
||||
$mapper = new MemberMapper($this->db);
|
||||
$members = $mapper->findArchived();
|
||||
$this->assertCount(0, $members);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// ── FamilyMapper ─────────────────────────────────────────────────
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -20,6 +20,7 @@ use OCA\Mitgliederverwaltung\Service\EncryptionService;
|
||||
use OCA\Mitgliederverwaltung\Service\MemberService;
|
||||
use OCA\Mitgliederverwaltung\Service\ValidationException;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\IDBConnection;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -35,6 +36,7 @@ class MemberServiceTest extends TestCase {
|
||||
private StufeHistoryMapper&MockObject $stufeHistoryMapper;
|
||||
private AuditService&MockObject $auditService;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private IDBConnection&MockObject $db;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
@@ -59,6 +61,7 @@ class MemberServiceTest extends TestCase {
|
||||
$this->stufeHistoryMapper = $this->createMock(StufeHistoryMapper::class);
|
||||
$this->auditService = $this->createMock(AuditService::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
$this->db = $this->createMock(IDBConnection::class);
|
||||
|
||||
$this->service = new MemberService(
|
||||
$this->memberMapper,
|
||||
@@ -68,7 +71,9 @@ class MemberServiceTest extends TestCase {
|
||||
$this->familyMapper,
|
||||
$this->stufeHistoryMapper,
|
||||
$this->auditService,
|
||||
$this->logger
|
||||
$this->logger,
|
||||
null,
|
||||
$this->db
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,10 +191,10 @@ class MemberServiceTest extends TestCase {
|
||||
$member1 = $this->createMember(1, 'Max');
|
||||
$member2 = $this->createMember(2, 'Anna', 'Schmidt');
|
||||
|
||||
$this->memberMapper->method('findAll')->willReturn([$member1, $member2]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('findAllWithRelations')->willReturn([
|
||||
$member1->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
$member2->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
]);
|
||||
|
||||
$result = $this->service->findAll();
|
||||
|
||||
@@ -200,7 +205,7 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindAllWithPagination(): void {
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findAll')
|
||||
->method('findAllWithRelations')
|
||||
->with(10, 5)
|
||||
->willReturn([]);
|
||||
|
||||
@@ -208,7 +213,7 @@ class MemberServiceTest extends TestCase {
|
||||
}
|
||||
|
||||
public function testFindAllReturnsEmptyArray(): void {
|
||||
$this->memberMapper->method('findAll')->willReturn([]);
|
||||
$this->memberMapper->method('findAllWithRelations')->willReturn([]);
|
||||
|
||||
$result = $this->service->findAll();
|
||||
$this->assertSame([], $result);
|
||||
@@ -225,9 +230,6 @@ class MemberServiceTest extends TestCase {
|
||||
$m->setId(1);
|
||||
return $m;
|
||||
});
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
|
||||
$this->auditService->expects($this->once())->method('logCreate');
|
||||
|
||||
@@ -709,7 +711,8 @@ class MemberServiceTest extends TestCase {
|
||||
$this->stufeHistoryMapper,
|
||||
$this->auditService,
|
||||
$this->logger,
|
||||
$encryption
|
||||
$encryption,
|
||||
$this->db
|
||||
);
|
||||
|
||||
$map = $service->revealAllAllergies('alice');
|
||||
@@ -747,7 +750,8 @@ class MemberServiceTest extends TestCase {
|
||||
$this->stufeHistoryMapper,
|
||||
$this->auditService,
|
||||
$this->logger,
|
||||
$encryption
|
||||
$encryption,
|
||||
$this->db
|
||||
);
|
||||
|
||||
$service->revealAllAllergies('alice');
|
||||
@@ -900,10 +904,9 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testSearchReturnsMatchingMembers(): void {
|
||||
$member = $this->createMember(1);
|
||||
$this->memberMapper->method('search')->with('Max')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('searchWithRelations')->with('Max')->willReturn([
|
||||
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
]);
|
||||
|
||||
$result = $this->service->search('Max');
|
||||
|
||||
@@ -912,7 +915,7 @@ class MemberServiceTest extends TestCase {
|
||||
}
|
||||
|
||||
public function testSearchReturnsEmptyForNoMatch(): void {
|
||||
$this->memberMapper->method('search')->willReturn([]);
|
||||
$this->memberMapper->method('searchWithRelations')->willReturn([]);
|
||||
|
||||
$result = $this->service->search('Nonexistent');
|
||||
$this->assertSame([], $result);
|
||||
@@ -922,14 +925,15 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFullTextSearchReturnsResultsWithMatchContext(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$address = $this->createAddress(1, 1);
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('fullTextSearch')
|
||||
$this->memberMapper->method('fullTextSearchWithRelations')
|
||||
->with('Max', 20)
|
||||
->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([$address]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->fullTextSearch('Max');
|
||||
|
||||
@@ -940,13 +944,14 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFullTextSearchMatchesAddress(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$address = $this->createAddress(1, 1);
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstrasse 1', 'plz' => '12345', 'ort' => 'Musterstadt', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('fullTextSearch')
|
||||
->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([$address]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('fullTextSearchWithRelations')
|
||||
->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->fullTextSearch('Musterstrasse');
|
||||
|
||||
@@ -957,11 +962,13 @@ class MemberServiceTest extends TestCase {
|
||||
public function testFullTextSearchMatchesNotizen(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$member->setNotizen('Hat einen besonderen Wunsch fuer Training');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('fullTextSearch')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('fullTextSearchWithRelations')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->fullTextSearch('Training');
|
||||
|
||||
@@ -972,11 +979,13 @@ class MemberServiceTest extends TestCase {
|
||||
public function testFullTextSearchMatchesZusatzNotizen(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$member->setZusatzNotizen('Zusaetzliche Information');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('fullTextSearch')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('fullTextSearchWithRelations')->willReturn([$memberData]);
|
||||
|
||||
// Search for something not in name or notizen, but in zusatzNotizen
|
||||
$result = $this->service->fullTextSearch('zusaetzliche');
|
||||
@@ -991,7 +1000,7 @@ class MemberServiceTest extends TestCase {
|
||||
$currentMonth = (int)(new \DateTime())->format('m');
|
||||
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findByBirthdayMonth')
|
||||
->method('findByBirthdayMonthWithRelations')
|
||||
->with($currentMonth)
|
||||
->willReturn([]);
|
||||
|
||||
@@ -1000,10 +1009,9 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindByBirthdayThisMonthReturnsMembers(): void {
|
||||
$member = $this->createMember(1);
|
||||
$this->memberMapper->method('findByBirthdayMonth')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('findByBirthdayMonthWithRelations')->willReturn([
|
||||
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
]);
|
||||
|
||||
$result = $this->service->findByBirthdayThisMonth();
|
||||
|
||||
@@ -1016,7 +1024,7 @@ class MemberServiceTest extends TestCase {
|
||||
$currentYear = (int)(new \DateTime())->format('Y');
|
||||
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findWithUnpaidFees')
|
||||
->method('findWithUnpaidFeesWithRelations')
|
||||
->with($currentYear)
|
||||
->willReturn([]);
|
||||
|
||||
@@ -1025,10 +1033,9 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindWithUnpaidFeesReturnsMembers(): void {
|
||||
$member = $this->createMember(1);
|
||||
$this->memberMapper->method('findWithUnpaidFees')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('findWithUnpaidFeesWithRelations')->willReturn([
|
||||
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
]);
|
||||
|
||||
$result = $this->service->findWithUnpaidFees();
|
||||
|
||||
@@ -1039,7 +1046,7 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindFilteredDelegatesToMapper(): void {
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findFiltered')
|
||||
->method('findFilteredWithRelations')
|
||||
->with('aktiv', 'mitglied', true, false)
|
||||
->willReturn([]);
|
||||
|
||||
@@ -1048,7 +1055,7 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindFilteredNoFilters(): void {
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findFiltered')
|
||||
->method('findFilteredWithRelations')
|
||||
->with(null, null, false, false)
|
||||
->willReturn([]);
|
||||
|
||||
@@ -1058,12 +1065,13 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindFilteredReturnsWithSubEntities(): void {
|
||||
$member = $this->createMember(1);
|
||||
$address = $this->createAddress(1, 1);
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findFiltered')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->with(1)->willReturn([$address]);
|
||||
$this->phoneMapper->method('findByMemberId')->with(1)->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->with(1)->willReturn([]);
|
||||
$this->memberMapper->method('findFilteredWithRelations')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findFiltered('aktiv');
|
||||
|
||||
@@ -1075,12 +1083,9 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindFilteredByRolleOnly(): void {
|
||||
$member = $this->createMember(1, rolle: 'erziehungsberechtigter');
|
||||
$this->memberMapper->method('findFiltered')
|
||||
$this->memberMapper->method('findFilteredWithRelations')
|
||||
->with(null, 'erziehungsberechtigter', false, false)
|
||||
->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
->willReturn([$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []]]);
|
||||
|
||||
$result = $this->service->findFiltered(null, 'erziehungsberechtigter');
|
||||
|
||||
@@ -1090,7 +1095,7 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindFilteredCombinedAllParams(): void {
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findFiltered')
|
||||
->method('findFilteredWithRelations')
|
||||
->with('inaktiv', 'mitglied', true, true)
|
||||
->willReturn([]);
|
||||
|
||||
@@ -1252,10 +1257,9 @@ class MemberServiceTest extends TestCase {
|
||||
public function testFindByFamilyReturnsMembers(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv', 'mitglied', 5);
|
||||
|
||||
$this->memberMapper->method('findByFamily')->with(5)->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('findByFamilyWithRelations')->with(5)->willReturn([
|
||||
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
]);
|
||||
|
||||
$result = $this->service->findByFamily(5);
|
||||
|
||||
@@ -1268,10 +1272,9 @@ class MemberServiceTest extends TestCase {
|
||||
public function testFindByStatusReturnsMembers(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv');
|
||||
|
||||
$this->memberMapper->method('findByStatus')->with('aktiv')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('findByStatusWithRelations')->with('aktiv')->willReturn([
|
||||
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
]);
|
||||
|
||||
$result = $this->service->findByStatus('aktiv');
|
||||
|
||||
@@ -1280,17 +1283,20 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
// ── findArchived() ──────────────────────────────────────────────
|
||||
|
||||
public function testFindArchivedReturnsOnlyDeletedMembers(): void {
|
||||
$activeMember = $this->createMember(1);
|
||||
$deletedMember = $this->createMember(2, 'Anna', 'Schmidt');
|
||||
public function testFindArchivedDelegatesToMapper(): void {
|
||||
// Issue #202: findArchived() now delegates to MemberMapper::findArchived()
|
||||
// which uses a SQL WHERE deleted_at IS NOT NULL query instead of
|
||||
// loading all members into memory and filtering in PHP.
|
||||
$deletedMember = $this->createMember(1, 'Anna', 'Schmidt');
|
||||
$deletedMember->setDeletedAt('2026-01-01 00:00:00');
|
||||
$deletedMember->setStatus('geloescht');
|
||||
$deletedMember->setEintritt('2018-01-01');
|
||||
$deletedMember->setAustritt('2025-12-31');
|
||||
|
||||
$this->memberMapper->method('findAll')
|
||||
->with(null, null, true)
|
||||
->willReturn([$activeMember, $deletedMember]);
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findArchived')
|
||||
->with(null, null)
|
||||
->willReturn([$deletedMember]);
|
||||
|
||||
$result = $this->service->findArchived();
|
||||
|
||||
@@ -1300,17 +1306,443 @@ class MemberServiceTest extends TestCase {
|
||||
$this->assertArrayHasKey('deletedAt', $result[0]);
|
||||
}
|
||||
|
||||
public function testFindArchivedPassesPaginationToMapper(): void {
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findArchived')
|
||||
->with(10, 20)
|
||||
->willReturn([]);
|
||||
|
||||
$this->service->findArchived(10, 20);
|
||||
}
|
||||
|
||||
public function testFindArchivedReturnsEmptyArrayWhenNoArchivedMembers(): void {
|
||||
$this->memberMapper->method('findArchived')->willReturn([]);
|
||||
|
||||
$result = $this->service->findArchived();
|
||||
$this->assertSame([], $result);
|
||||
}
|
||||
|
||||
public function testFindArchivedReturnsOnlyRetainedFields(): void {
|
||||
$deletedMember = $this->createMember(1, 'Anna', 'Schmidt');
|
||||
$deletedMember->setDeletedAt('2026-01-01 00:00:00');
|
||||
$deletedMember->setEintritt('2018-01-01');
|
||||
$deletedMember->setAustritt('2025-12-31');
|
||||
|
||||
$this->memberMapper->method('findArchived')->willReturn([$deletedMember]);
|
||||
|
||||
$result = $this->service->findArchived();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$entry = $result[0];
|
||||
// Retained fields should be present
|
||||
$this->assertArrayHasKey('id', $entry);
|
||||
$this->assertArrayHasKey('vorname', $entry);
|
||||
$this->assertArrayHasKey('nachname', $entry);
|
||||
$this->assertArrayHasKey('geburtsdatum', $entry);
|
||||
$this->assertArrayHasKey('eintritt', $entry);
|
||||
$this->assertArrayHasKey('austritt', $entry);
|
||||
$this->assertArrayHasKey('deletedAt', $entry);
|
||||
$this->assertArrayHasKey('mitgliedsdauer', $entry);
|
||||
// Sensitive fields should NOT be present
|
||||
$this->assertArrayNotHasKey('notizen', $entry);
|
||||
$this->assertArrayNotHasKey('allergienEncrypted', $entry);
|
||||
$this->assertArrayNotHasKey('kvTyp', $entry);
|
||||
}
|
||||
|
||||
// ── countArchived() ──────────────────────────────────────────────
|
||||
|
||||
public function testCountArchivedReturnsCorrectCount(): void {
|
||||
$activeMember = $this->createMember(1);
|
||||
$deletedMember = $this->createMember(2);
|
||||
$deletedMember->setDeletedAt('2026-01-01 00:00:00');
|
||||
|
||||
$this->memberMapper->method('findAll')
|
||||
->with(null, null, true)
|
||||
->willReturn([$activeMember, $deletedMember]);
|
||||
// Issue #201: countArchived() now delegates to MemberMapper::countArchived()
|
||||
// which uses a SQL COUNT query instead of loading all members into memory.
|
||||
$this->memberMapper->method('countArchived')->willReturn(1);
|
||||
|
||||
$this->assertSame(1, $this->service->countArchived());
|
||||
}
|
||||
|
||||
public function testCountArchivedReturnsZeroWhenNoArchived(): void {
|
||||
$this->memberMapper->method('countArchived')->willReturn(0);
|
||||
|
||||
$this->assertSame(0, $this->service->countArchived());
|
||||
}
|
||||
|
||||
// ── Joined methods: findAllWithRelations ──────────────────────
|
||||
|
||||
public function testFindAllWithRelationsReturnsMemberWithNestedSubEntities(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [
|
||||
['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true],
|
||||
['id' => 2, 'memberId' => 1, 'label' => 'Arbeit', 'strasse' => 'Büroweg 5', 'plz' => '12346', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => false],
|
||||
],
|
||||
'phones' => [
|
||||
['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678'],
|
||||
],
|
||||
'emails' => [
|
||||
['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com'],
|
||||
],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findAll();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
$this->assertCount(2, $result[0]['addresses']);
|
||||
$this->assertCount(1, $result[0]['phones']);
|
||||
$this->assertCount(1, $result[0]['emails']);
|
||||
$this->assertSame('Hauptstr. 1', $result[0]['addresses'][0]['strasse']);
|
||||
$this->assertSame('+4917612345678', $result[0]['phones'][0]['numberE164']);
|
||||
$this->assertSame('max@example.com', $result[0]['emails'][0]['email']);
|
||||
}
|
||||
|
||||
public function testFindAllWithRelationsHandlesMemberWithoutSubEntities(): void {
|
||||
$member = $this->createMember(1);
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findAll();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertCount(0, $result[0]['addresses']);
|
||||
$this->assertCount(0, $result[0]['phones']);
|
||||
$this->assertCount(0, $result[0]['emails']);
|
||||
}
|
||||
|
||||
// ── Joined methods: searchWithRelations ───────────────────────
|
||||
|
||||
public function testSearchWithRelationsReturnsMemberWithSubEntities(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('searchWithRelations')->with('Max')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->search('Max');
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
$this->assertCount(1, $result[0]['addresses']);
|
||||
}
|
||||
|
||||
// ── Joined methods: findByFamilyWithRelations ─────────────────
|
||||
|
||||
public function testFindByFamilyWithRelationsReturnsMemberWithSubEntities(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv', 'mitglied', 5);
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678']],
|
||||
'emails' => [['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com']],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findByFamilyWithRelations')->with(5)->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findByFamily(5);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
$this->assertCount(1, $result[0]['addresses']);
|
||||
$this->assertCount(1, $result[0]['phones']);
|
||||
$this->assertCount(1, $result[0]['emails']);
|
||||
}
|
||||
|
||||
// ── Joined methods: findByStatusWithRelations ─────────────────
|
||||
|
||||
public function testFindByStatusWithRelationsReturnsMemberWithSubEntities(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findByStatusWithRelations')->with('aktiv')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findByStatus('aktiv');
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
}
|
||||
|
||||
// ── Joined methods: findByBirthdayMonthWithRelations ──────────
|
||||
|
||||
public function testFindByBirthdayMonthWithRelationsReturnsMemberWithSubEntities(): void {
|
||||
$currentMonth = (int)(new \DateTime())->format('m');
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-06-15', '2020-01-01');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findByBirthdayMonthWithRelations')
|
||||
->with($currentMonth)
|
||||
->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findByBirthdayThisMonth();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
}
|
||||
|
||||
// ── Joined methods: findWithUnpaidFeesWithRelations ───────────
|
||||
|
||||
public function testFindWithUnpaidFeesWithRelationsReturnsMemberWithSubEntities(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findWithUnpaidFeesWithRelations')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findWithUnpaidFees();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
}
|
||||
|
||||
// ── Joined methods: findFilteredWithRelations ─────────────────
|
||||
|
||||
public function testFindFilteredWithRelationsReturnsMemberWithSubEntities(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findFilteredWithRelations')
|
||||
->with('aktiv', 'mitglied', false, false)
|
||||
->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findFiltered('aktiv', 'mitglied');
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
$this->assertCount(1, $result[0]['addresses']);
|
||||
}
|
||||
|
||||
// ── Joined methods: fullTextSearchWithRelations ───────────────
|
||||
|
||||
public function testFullTextSearchWithRelationsReturnsResultsWithMatchContext(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstrasse 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('fullTextSearchWithRelations')
|
||||
->with('Musterstrasse', 20)
|
||||
->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->fullTextSearch('Musterstrasse');
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertArrayHasKey('matchContext', $result[0]);
|
||||
$this->assertStringContainsString('Adresse:', $result[0]['matchContext']);
|
||||
$this->assertStringContainsString('Musterstrasse 1', $result[0]['matchContext']);
|
||||
}
|
||||
|
||||
// ── Backward compatibility: findAll shape ─────────────────────
|
||||
|
||||
public function testFindAllReturnsSameShapeAsBefore(): void {
|
||||
// The new method returns the same shape as the old one:
|
||||
// an array of member arrays with addresses/phones/emails sub-arrays
|
||||
$member = $this->createMember(1, 'Max');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678']],
|
||||
'emails' => [['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com']],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findAll();
|
||||
|
||||
// Verify the shape matches what the frontend expects
|
||||
$this->assertIsArray($result[0]);
|
||||
$this->assertArrayHasKey('id', $result[0]);
|
||||
$this->assertArrayHasKey('vorname', $result[0]);
|
||||
$this->assertArrayHasKey('nachname', $result[0]);
|
||||
$this->assertArrayHasKey('addresses', $result[0]);
|
||||
$this->assertArrayHasKey('phones', $result[0]);
|
||||
$this->assertArrayHasKey('emails', $result[0]);
|
||||
}
|
||||
|
||||
// ── Database Transactions ───────────────────────────────────────
|
||||
|
||||
public function testCreateCommitsTransactionOnSuccess(): void {
|
||||
$data = $this->getValidMemberData();
|
||||
|
||||
$this->memberMapper->method('search')->willReturn([]);
|
||||
$this->memberMapper->method('insert')
|
||||
->willReturnCallback(function (Member $m) {
|
||||
$m->setId(1);
|
||||
return $m;
|
||||
});
|
||||
|
||||
$this->db->expects($this->once())->method('beginTransaction');
|
||||
$this->db->expects($this->once())->method('commit');
|
||||
$this->db->expects($this->never())->method('rollback');
|
||||
|
||||
$this->service->create($data);
|
||||
}
|
||||
|
||||
public function testCreateRollbacksTransactionWhenSubEntityInsertFails(): void {
|
||||
$data = $this->getValidMemberData();
|
||||
$phones = [['numberE164' => '+4917612345678']];
|
||||
|
||||
$this->memberMapper->method('search')->willReturn([]);
|
||||
$this->memberMapper->method('insert')
|
||||
->willReturnCallback(function (Member $m) {
|
||||
$m->setId(1);
|
||||
return $m;
|
||||
});
|
||||
$this->phoneMapper->method('insert')->willThrowException(new \RuntimeException('DB error'));
|
||||
|
||||
$this->db->expects($this->once())->method('beginTransaction');
|
||||
$this->db->expects($this->once())->method('rollback');
|
||||
$this->db->expects($this->never())->method('commit');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('DB error');
|
||||
|
||||
$this->service->create($data, [], $phones, []);
|
||||
}
|
||||
|
||||
public function testCreateRollbacksTransactionWhenAddressInsertFails(): void {
|
||||
$data = $this->getValidMemberData();
|
||||
$addresses = [['strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin']];
|
||||
|
||||
$this->memberMapper->method('search')->willReturn([]);
|
||||
$this->memberMapper->method('insert')
|
||||
->willReturnCallback(function (Member $m) {
|
||||
$m->setId(1);
|
||||
return $m;
|
||||
});
|
||||
$this->addressMapper->method('insert')->willThrowException(new \RuntimeException('Constraint violation'));
|
||||
|
||||
$this->db->expects($this->once())->method('beginTransaction');
|
||||
$this->db->expects($this->once())->method('rollback');
|
||||
$this->db->expects($this->never())->method('commit');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$this->service->create($data, $addresses, [], []);
|
||||
}
|
||||
|
||||
public function testSoftDeleteCommitsTransactionOnSuccess(): void {
|
||||
$member = $this->createMember(1);
|
||||
|
||||
$this->memberMapper->method('findById')->with(1)->willReturn($member);
|
||||
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
|
||||
|
||||
$this->db->expects($this->once())->method('beginTransaction');
|
||||
$this->db->expects($this->once())->method('commit');
|
||||
$this->db->expects($this->never())->method('rollback');
|
||||
|
||||
$this->service->softDelete(1);
|
||||
}
|
||||
|
||||
public function testSoftDeleteRollbacksTransactionWhenDeleteFails(): void {
|
||||
$member = $this->createMember(1);
|
||||
|
||||
$this->memberMapper->method('findById')->with(1)->willReturn($member);
|
||||
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
|
||||
$this->phoneMapper->method('deleteByMemberId')->willThrowException(new \RuntimeException('DB error'));
|
||||
|
||||
$this->db->expects($this->once())->method('beginTransaction');
|
||||
$this->db->expects($this->once())->method('rollback');
|
||||
$this->db->expects($this->never())->method('commit');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$this->service->softDelete(1);
|
||||
}
|
||||
|
||||
public function testUpdateCommitsTransactionOnSuccess(): void {
|
||||
$member = $this->createMember(1);
|
||||
|
||||
$this->memberMapper->method('findById')->with(1)->willReturn($member);
|
||||
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
|
||||
$this->db->expects($this->once())->method('beginTransaction');
|
||||
$this->db->expects($this->once())->method('commit');
|
||||
$this->db->expects($this->never())->method('rollback');
|
||||
|
||||
$this->service->update(1, ['notizen' => 'Test']);
|
||||
}
|
||||
|
||||
public function testUpdateRollbacksTransactionWhenSubEntitySyncFails(): void {
|
||||
$member = $this->createMember(1);
|
||||
|
||||
$existing = new Address();
|
||||
$existing->setId(42);
|
||||
$existing->setMemberId(1);
|
||||
$existing->setStrasse('Alt');
|
||||
$existing->setPlz('00000');
|
||||
$existing->setOrt('Alt');
|
||||
$existing->setLand('Deutschland');
|
||||
$existing->setIsPrimary(false);
|
||||
|
||||
$this->memberMapper->method('findById')->with(1)->willReturn($member);
|
||||
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([$existing]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
|
||||
// find() is called by syncAddresses -> updateAddress
|
||||
$this->addressMapper->method('find')->with(42)->willReturn($existing);
|
||||
$this->addressMapper->method('update')->willThrowException(new \RuntimeException('DB error'));
|
||||
|
||||
$this->db->expects($this->once())->method('beginTransaction');
|
||||
$this->db->expects($this->once())->method('rollback');
|
||||
$this->db->expects($this->never())->method('commit');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$this->service->update(1, [
|
||||
'addresses' => [
|
||||
['id' => 42, 'strasse' => 'Neu', 'plz' => '12345', 'ort' => 'Neu'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testUpdateRollbacksTransactionWhenPhoneSyncFails(): void {
|
||||
$member = $this->createMember(1);
|
||||
|
||||
$this->memberMapper->method('findById')->with(1)->willReturn($member);
|
||||
$this->memberMapper->method('update')->willReturnCallback(fn(Member $m) => $m);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
|
||||
// syncPhones will try to insert a new phone
|
||||
$this->phoneMapper->method('insert')->willThrowException(new \RuntimeException('DB error'));
|
||||
|
||||
$this->db->expects($this->once())->method('beginTransaction');
|
||||
$this->db->expects($this->once())->method('rollback');
|
||||
$this->db->expects($this->never())->method('commit');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$this->service->update(1, [
|
||||
'phones' => [['numberE164' => '+4917612345678']],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use OCA\Mitgliederverwaltung\Service\AuditService;
|
||||
use OCA\Mitgliederverwaltung\Service\DuplicateMemberException;
|
||||
use OCA\Mitgliederverwaltung\Service\MemberService;
|
||||
use OCA\Mitgliederverwaltung\Service\ValidationException;
|
||||
use OCP\IDBConnection;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -36,6 +37,7 @@ class MemberValidationTest extends TestCase {
|
||||
private StufeHistoryMapper&MockObject $stufeHistoryMapper;
|
||||
private AuditService&MockObject $auditService;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private IDBConnection&MockObject $db;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
@@ -48,6 +50,7 @@ class MemberValidationTest extends TestCase {
|
||||
$this->stufeHistoryMapper = $this->createMock(StufeHistoryMapper::class);
|
||||
$this->auditService = $this->createMock(AuditService::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
$this->db = $this->createMock(IDBConnection::class);
|
||||
|
||||
$this->service = new MemberService(
|
||||
$this->memberMapper,
|
||||
@@ -57,7 +60,9 @@ class MemberValidationTest extends TestCase {
|
||||
$this->familyMapper,
|
||||
$this->stufeHistoryMapper,
|
||||
$this->auditService,
|
||||
$this->logger
|
||||
$this->logger,
|
||||
null,
|
||||
$this->db
|
||||
);
|
||||
|
||||
// Default: no duplicates found
|
||||
@@ -256,7 +261,9 @@ class MemberValidationTest extends TestCase {
|
||||
$this->familyMapper,
|
||||
$this->stufeHistoryMapper,
|
||||
$this->auditService,
|
||||
$this->logger
|
||||
$this->logger,
|
||||
null,
|
||||
$this->db
|
||||
);
|
||||
|
||||
$this->expectException(DuplicateMemberException::class);
|
||||
@@ -292,7 +299,9 @@ class MemberValidationTest extends TestCase {
|
||||
$this->familyMapper,
|
||||
$this->stufeHistoryMapper,
|
||||
$this->auditService,
|
||||
$this->logger
|
||||
$this->logger,
|
||||
null,
|
||||
$this->db
|
||||
);
|
||||
|
||||
$this->expectException(DuplicateMemberException::class);
|
||||
@@ -338,7 +347,9 @@ class MemberValidationTest extends TestCase {
|
||||
$this->familyMapper,
|
||||
$this->stufeHistoryMapper,
|
||||
$this->auditService,
|
||||
$this->logger
|
||||
$this->logger,
|
||||
null,
|
||||
$this->db
|
||||
);
|
||||
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
@@ -377,7 +388,9 @@ class MemberValidationTest extends TestCase {
|
||||
$this->familyMapper,
|
||||
$this->stufeHistoryMapper,
|
||||
$this->auditService,
|
||||
$this->logger
|
||||
$this->logger,
|
||||
null,
|
||||
$this->db
|
||||
);
|
||||
|
||||
$this->expectException(DuplicateMemberException::class);
|
||||
|
||||
+27
-1
@@ -19,4 +19,30 @@ if (!class_exists('OCA\DAV\CalDAV\CalDavBackend')) {
|
||||
eval('namespace OCA\DAV\CalDAV; class CalDavBackend {}');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
// Try the app's vendor autoload first (full local dev).
|
||||
// Fall back to Nextcloud's root autoloader which has PSR packages.
|
||||
$appAutoload = __DIR__ . '/../vendor/autoload.php';
|
||||
$ncAutoload = '/var/www/html/lib/composer/autoload.php';
|
||||
|
||||
if (file_exists($appAutoload)) {
|
||||
require_once $appAutoload;
|
||||
} elseif (file_exists($ncAutoload)) {
|
||||
require_once $ncAutoload;
|
||||
}
|
||||
|
||||
// Register the app's lib directory for PSR-4 autoloading.
|
||||
// This ensures OCA\Mitgliederverwaltung\* classes are loaded regardless
|
||||
// of which vendor/autoload.php was used (full local dev vs. container).
|
||||
spl_autoload_register(function ($class) {
|
||||
$prefix = 'OCA\\Mitgliederverwaltung\\';
|
||||
$baseDir = __DIR__ . '/../lib/';
|
||||
$len = strlen($prefix);
|
||||
if (strncmp($prefix, $class, $len) !== 0) {
|
||||
return;
|
||||
}
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
}
|
||||
});
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ module.exports = {
|
||||
new VueLoaderPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
appName: JSON.stringify('mitgliederverwaltung'),
|
||||
appVersion: JSON.stringify('0.2.11'),
|
||||
appVersion: JSON.stringify('0.3.2'),
|
||||
}),
|
||||
new webpack.optimize.LimitChunkCountPlugin({
|
||||
maxChunks: 1,
|
||||
|
||||
Reference in New Issue
Block a user