diff --git a/.env b/.env index 8d9add8..708b98e 100644 --- a/.env +++ b/.env @@ -68,5 +68,8 @@ XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport" BIN_GS=/usr/bin/gs BIN_CONVERT=/usr/bin/convert +DATEV_EMAIL_RECIPIENT=abc-123-def-4567890@uploadmail.datev.de +DATEV_EMAIL_SENDER=honorar@ep-reisen.de + FEATURE_SANITIZE_UPLOADS=false FEATURE_STAMP_INVOICES=false diff --git a/config/packages/flagception.yaml b/config/packages/flagception.yaml index a2a02d2..4623dad 100644 --- a/config/packages/flagception.yaml +++ b/config/packages/flagception.yaml @@ -7,3 +7,6 @@ flagception: stamp_invoices: default: false env: FEATURE_STAMP_INVOICES + datev_email: + default: false + env: FEATURE_DATEV_EMAIL diff --git a/config/services.yaml b/config/services.yaml index d60edfa..6c746ed 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -153,6 +153,11 @@ services: event: oneup_uploader.validation method: onValidate + App\EventListener\DatevEmailSubscriber: + arguments: + $datevEmailRecipient: '%env(DATEV_EMAIL_RECIPIENT)%' + $datevEmailSender: '%env(DATEV_EMAIL_SENDER)%' + app.upload_namer: class: App\Service\Upload\UploadNamer public: true diff --git a/docs/PLAN-cancellation-statistics.md b/docs/PLAN-cancellation-statistics.md new file mode 100644 index 0000000..eacf0eb --- /dev/null +++ b/docs/PLAN-cancellation-statistics.md @@ -0,0 +1,187 @@ +# Plan: Cancellation Statistics Feature + +## Objective + +Enable stakeholders to see how often dispositions and applications have been canceled by either the teamer or the office, per hotel and for a given date range. + +--- + +## Current State Analysis + +### Dispositions - Already Tracked + +The `Disposition` entity has comprehensive cancellation tracking: + +| Field | Type | Purpose | +|-------|------|---------| +| `status` | string | `'called_off'` when cancelled | +| `calledOffBy` | string (nullable) | `'teamer'` or `'office'` | +| `calledOffReason` | text (nullable) | Reason for cancellation | +| `updatedAt` | datetime | Timestamp of last change | + +**Constants:** +```php +public const STATUS_CALLED_OFF = 'called_off'; +public const CALLED_OFF_BY_TEAMER = 'teamer'; +public const CALLED_OFF_BY_OFFICE = 'office'; +``` + +**Cancellation entry points:** +1. Individual disposition cancellation via `Administrative/Disposition/CallOffController` + - Form requires `calledOffBy` and `calledOffReason` + - Dispatches `DispositionCalledOffEvent` + +2. Full assignment cancellation via `Administrative/Assignment/CallOffController` + - Sets all dispositions to `calledOffBy = 'office'` + - Dispatches `AssignmentCalledOffEvent` + +### Applications - Missing Tracking + +The `Application` entity lacks cancellation tracking: + +| Current Field | Issue | +|---------------|-------| +| `status` | Only `'rejected'` - no distinction between teamer withdrawal and office rejection | +| - | No `cancelledBy` field | +| - | No `cancellationReason` field | + +**Current rejection mechanisms:** +1. Manual rejection by office (no tracking of who) +2. Automatic rejection by `RejectApplicationListener` when disposition slots fill +3. Automatic removal by `InvalidateApplicationsListener` when teamer has overlapping confirmed dispositions + +--- + +## Pending Decision + +**Question for stakeholders:** Should automatic rejections be included in statistics? + +| Option | Description | Impact | +|--------|-------------|--------| +| **Only user-initiated** | Count only manual cancellations by teamer or office | Simpler model, clearer accountability | +| **Include automatic** | Also track system-initiated rejections | Full picture of lost applications, requires `'system'` category | + +**Status:** Awaiting stakeholder response + +--- + +## Proposed Changes + +### Application Entity + +**Option A: If only user-initiated cancellations count** + +Add new status and fields: +```php +public const STATUS_WITHDRAWN = 'withdrawn'; // teamer-initiated +public const STATUS_REJECTED = 'rejected'; // office-initiated (existing) + +private ?string $cancellationReason = null; +``` + +**Option B: If automatic rejections should be tracked** + +Add new status, fields, and constant: +```php +public const STATUS_WITHDRAWN = 'withdrawn'; // teamer-initiated +public const STATUS_REJECTED = 'rejected'; // office or system initiated + +public const CANCELLED_BY_TEAMER = 'teamer'; +public const CANCELLED_BY_OFFICE = 'office'; +public const CANCELLED_BY_SYSTEM = 'system'; + +private ?string $cancelledBy = null; +private ?string $cancellationReason = null; +``` + +### Database Migration + +Add columns to `application` table: +- `cancellation_reason` (LONGTEXT, nullable) +- Possibly `cancelled_by` (VARCHAR(64), nullable) if Option B + +### Repository Methods + +Add to `ApplicationRepository`: +```php +/** + * @return array + */ +public function getCancellationStatsByHotel( + \DateTimeImmutable $startDate, + \DateTimeImmutable $endDate +): array; +``` + +Add to `DispositionRepository`: +```php +/** + * @return array + */ +public function getCancellationStatsByHotel( + \DateTimeImmutable $startDate, + \DateTimeImmutable $endDate +): array; +``` + +**Date filtering:** Based on assignment travel dates (confirmed in discussion). + +### Update Listeners (if Option B) + +Modify `RejectApplicationListener` and `InvalidateApplicationsListener` to set `cancelledBy = 'system'`. + +### UI Changes + +Add withdrawal functionality for teamers (if not already present) that sets `STATUS_WITHDRAWN`. + +--- + +## Implementation Steps + +1. [ ] Finalize decision on automatic rejection tracking +2. [ ] Add new constants and fields to `Application` entity +3. [ ] Create database migration +4. [ ] Update `ApplicationRepository` with statistics methods +5. [ ] Update `DispositionRepository` with statistics methods +6. [ ] Update listeners if tracking automatic rejections +7. [ ] Add/update controllers for teamer withdrawal flow +8. [ ] Write tests for new repository methods +9. [ ] Run php-cs-fixer + +--- + +## Related Files + +### Entities +- `src/Entity/Application.php` +- `src/Entity/Disposition.php` +- `src/Entity/Assignment.php` + +### Repositories +- `src/Repository/ApplicationRepository.php` +- `src/Repository/DispositionRepository.php` + +### Controllers +- `src/Controller/Administrative/Disposition/CallOffController.php` +- `src/Controller/Administrative/Assignment/CallOffController.php` +- `src/Controller/Teamer/Disposition/DetailController.php` + +### Listeners +- `src/EventListener/InvalidateApplicationsListener.php` +- `src/EventListener/RejectApplicationListener.php` + +### Events +- `src/Event/DispositionCalledOffEvent.php` +- `src/Event/AssignmentCalledOffEvent.php` + +### Forms +- `src/Form/DispositionCallOffType.php` + +--- + +## Notes + +- Filter statistics by **assignment travel dates**, not cancellation timestamp +- Disposition tracking already works - only need repository query methods +- Application tracking requires entity changes and migration +- Consider whether teamer self-service withdrawal exists or needs to be added \ No newline at end of file diff --git a/src/Email/EmailAttachmentInterface.php b/src/Email/EmailAttachmentInterface.php new file mode 100644 index 0000000..9a1fb75 --- /dev/null +++ b/src/Email/EmailAttachmentInterface.php @@ -0,0 +1,12 @@ +attach($attachment->getContent(), $attachment->getName(), $attachment->getMimeType()); + /* @var EmailAttachmentInterface $attachment */ + $attachment->attachTo($email); } $this->bodyRenderer->render($email); diff --git a/src/EventListener/DatevEmailSubscriber.php b/src/EventListener/DatevEmailSubscriber.php new file mode 100644 index 0000000..65550b3 --- /dev/null +++ b/src/EventListener/DatevEmailSubscriber.php @@ -0,0 +1,94 @@ + 'onDocumentConfirmed', + ]; + } + + public function onDocumentConfirmed(DocumentConfirmedEvent $event): void + { + if (false === $this->featureManager->isActive('datev_email')) { + return; + } + + $document = $event->getDocument(); + + if (Upload::TYPE_INVOICE !== $document->getType()) { + return; + } + + $disposition = $document->getDisposition(); + + if (null === $disposition) { + $this->logger->warning('DATEV email skipped: document has no disposition', [ + 'document' => $document->getId(), + ]); + + return; + } + + $teamer = $disposition->getTeamer(); + + if (null === $teamer) { + $this->logger->warning('DATEV email skipped: disposition has no teamer', [ + 'document' => $document->getId(), + ]); + + return; + } + + $path = $this->uploadHandler->getUploadFilepath($document); + + if (false === file_exists($path)) { + $this->logger->error('DATEV email skipped: attachment file not found', [ + 'document' => $document->getId(), + 'path' => $path, + ]); + + return; + } + + $filename = (new DownloadNamer())->nameForTeamer($document, $teamer); + $attachment = new EmailPathAttachmentDto($path, $filename); + + $this->mailer->createAndSendEmail([ + 'disposition' => $disposition, + ], [ + 'to' => $this->datevEmailRecipient, + 'from' => $this->datevEmailSender, + 'subject' => 'E&P Team Honorarnote '.$teamer, + 'template' => 'email/datev_invoice.html.twig', + 'attachments' => [ + $attachment, + ], + ]); + } +} diff --git a/src/Model/EmailAttachmentDto.php b/src/Model/EmailAttachmentDto.php index 1de5aee..d217755 100644 --- a/src/Model/EmailAttachmentDto.php +++ b/src/Model/EmailAttachmentDto.php @@ -1,8 +1,13 @@ mimeType; } + + public function attachTo(TemplatedEmail $email): void + { + $email->attach($this->content, $this->name, $this->mimeType); + } } diff --git a/src/Model/EmailPathAttachmentDto.php b/src/Model/EmailPathAttachmentDto.php new file mode 100644 index 0000000..9280905 --- /dev/null +++ b/src/Model/EmailPathAttachmentDto.php @@ -0,0 +1,38 @@ +path; + } + + public function getName(): string + { + return $this->name; + } + + public function getMimeType(): string + { + return $this->mimeType; + } + + public function attachTo(TemplatedEmail $email): void + { + $email->attachFromPath($this->path, $this->name, $this->mimeType); + } +} diff --git a/templates/email/datev_invoice.html.twig b/templates/email/datev_invoice.html.twig new file mode 100644 index 0000000..fa55547 --- /dev/null +++ b/templates/email/datev_invoice.html.twig @@ -0,0 +1,11 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} +

+ Honorarnote +

+

+ Die angehangene Honorarnote von {{ disposition.teamer }} zum Einsatz {{ disposition.assignment.destination }} + wurde freigegeben. +

+{% endblock %} diff --git a/tests/EventListener/DatevEmailSubscriberTest.php b/tests/EventListener/DatevEmailSubscriberTest.php new file mode 100644 index 0000000..3fb6bd5 --- /dev/null +++ b/tests/EventListener/DatevEmailSubscriberTest.php @@ -0,0 +1,190 @@ +mailer = $this->createMock(Mailer::class); + $this->uploadHandler = $this->createMock(UploadHandler::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->subscriber = new DatevEmailSubscriber( + $this->mailer, + $this->uploadHandler, + $this->logger, + 'datev@example.com', + 'sender@example.com', + ); + } + + public function testGetSubscribedEvents(): void + { + $events = DatevEmailSubscriber::getSubscribedEvents(); + + $this->assertArrayHasKey(DocumentConfirmedEvent::NAME, $events); + $this->assertSame('onDocumentConfirmed', $events[DocumentConfirmedEvent::NAME]); + } + + public function testSkipsNonInvoiceDocuments(): void + { + $document = $this->createMock(Upload::class); + $document->method('getType')->willReturn(Upload::TYPE_CONTRACT); + + $this->mailer + ->expects($this->never()) + ->method('createAndSendEmail'); + + $event = new DocumentConfirmedEvent($document); + $this->subscriber->onDocumentConfirmed($event); + } + + public function testSkipsDocumentWithoutDisposition(): void + { + $document = $this->createMock(Upload::class); + $document->method('getType')->willReturn(Upload::TYPE_INVOICE); + $document->method('getDisposition')->willReturn(null); + $document->method('getId')->willReturn(1); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('DATEV email skipped: document has no disposition', ['document' => 1]); + + $this->mailer + ->expects($this->never()) + ->method('createAndSendEmail'); + + $event = new DocumentConfirmedEvent($document); + $this->subscriber->onDocumentConfirmed($event); + } + + public function testSkipsDispositionWithoutTeamer(): void + { + $disposition = $this->createMock(Disposition::class); + $disposition->method('getTeamer')->willReturn(null); + + $document = $this->createMock(Upload::class); + $document->method('getType')->willReturn(Upload::TYPE_INVOICE); + $document->method('getDisposition')->willReturn($disposition); + $document->method('getId')->willReturn(2); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with('DATEV email skipped: disposition has no teamer', ['document' => 2]); + + $this->mailer + ->expects($this->never()) + ->method('createAndSendEmail'); + + $event = new DocumentConfirmedEvent($document); + $this->subscriber->onDocumentConfirmed($event); + } + + public function testSkipsMissingAttachmentFile(): void + { + $teamer = $this->createMock(Teamer::class); + $teamer->method('getFullName')->willReturn('Doe, John'); + + $disposition = $this->createMock(Disposition::class); + $disposition->method('getTeamer')->willReturn($teamer); + + $document = $this->createMock(Upload::class); + $document->method('getType')->willReturn(Upload::TYPE_INVOICE); + $document->method('getDisposition')->willReturn($disposition); + $document->method('getId')->willReturn(3); + + $nonExistentPath = '/tmp/non_existent_file_'.uniqid().'.pdf'; + + $this->uploadHandler + ->method('getUploadFilepath') + ->with($document) + ->willReturn($nonExistentPath); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with('DATEV email skipped: attachment file not found', [ + 'document' => 3, + 'path' => $nonExistentPath, + ]); + + $this->mailer + ->expects($this->never()) + ->method('createAndSendEmail'); + + $event = new DocumentConfirmedEvent($document); + $this->subscriber->onDocumentConfirmed($event); + } + + public function testSendsEmailWithAttachment(): void + { + $teamer = $this->createMock(Teamer::class); + $teamer->method('getFullName')->willReturn('Doe, John'); + $teamer->method('__toString')->willReturn('Doe, John'); + + $disposition = $this->createMock(Disposition::class); + $disposition->method('getTeamer')->willReturn($teamer); + + $document = $this->createMock(Upload::class); + $document->method('getDisposition')->willReturn($disposition); + $document->method('getType')->willReturn(Upload::TYPE_INVOICE); + $document->method('getId')->willReturn(42); + $document->method('getOriginalFilename')->willReturn('original.pdf'); + $document->method('getCreatedAt')->willReturn(new \DateTimeImmutable('2026-01-15')); + $document->method('getFilename')->willReturn('abc123.pdf'); + + $tmpFile = tempnam(sys_get_temp_dir(), 'datev_test_'); + + try { + $this->uploadHandler + ->expects($this->once()) + ->method('getUploadFilepath') + ->with($document) + ->willReturn($tmpFile); + + $this->mailer + ->expects($this->once()) + ->method('createAndSendEmail') + ->willReturnCallback(function (array $context, array $options): void { + $this->assertSame('datev@example.com', $options['to']); + $this->assertSame('sender@example.com', $options['from']); + $this->assertSame('email/datev_invoice.html.twig', $options['template']); + $this->assertStringContainsString('Honorarnote', $options['subject']); + + $this->assertArrayHasKey('attachments', $options); + $this->assertCount(1, $options['attachments']); + + $attachment = $options['attachments'][0]; + $this->assertInstanceOf(EmailPathAttachmentDto::class, $attachment); + $this->assertSame('hn-42-doe_john-20260115.pdf', $attachment->getName()); + }); + + $event = new DocumentConfirmedEvent($document); + $this->subscriber->onDocumentConfirmed($event); + } finally { + unlink($tmpFile); + } + } +} diff --git a/tests/Model/EmailAttachmentDtoTest.php b/tests/Model/EmailAttachmentDtoTest.php new file mode 100644 index 0000000..88c2e57 --- /dev/null +++ b/tests/Model/EmailAttachmentDtoTest.php @@ -0,0 +1,37 @@ +attachTo($email); + + $attachments = $email->getAttachments(); + $this->assertCount(1, $attachments); + + $body = $attachments[0]->getBody(); + $this->assertSame('pdf-content', $body); + $this->assertSame('invoice.pdf', $attachments[0]->getFilename()); + $this->assertSame('application/pdf', $attachments[0]->getMediaType().'/'.$attachments[0]->getMediaSubtype()); + } + + public function testGettersReturnConstructorValues(): void + { + $dto = new EmailAttachmentDto('test.csv', 'csv-data', 'text/csv'); + + $this->assertSame('test.csv', $dto->getName()); + $this->assertSame('csv-data', $dto->getContent()); + $this->assertSame('text/csv', $dto->getMimeType()); + } +} diff --git a/tests/Model/EmailPathAttachmentDtoTest.php b/tests/Model/EmailPathAttachmentDtoTest.php new file mode 100644 index 0000000..79ede36 --- /dev/null +++ b/tests/Model/EmailPathAttachmentDtoTest.php @@ -0,0 +1,55 @@ +attachTo($email); + + $attachments = $email->getAttachments(); + $this->assertCount(1, $attachments); + $this->assertSame('invoice.pdf', $attachments[0]->getFilename()); + $this->assertSame('application/pdf', $attachments[0]->getMediaType().'/'.$attachments[0]->getMediaSubtype()); + } finally { + unlink($tmpFile); + } + } + + public function testDefaultMimeTypeIsApplicationPdf(): void + { + $dto = new EmailPathAttachmentDto('/tmp/test.pdf', 'test.pdf'); + + $this->assertSame('application/pdf', $dto->getMimeType()); + } + + public function testCustomMimeType(): void + { + $dto = new EmailPathAttachmentDto('/tmp/test.csv', 'test.csv', 'text/csv'); + + $this->assertSame('text/csv', $dto->getMimeType()); + } + + public function testGettersReturnConstructorValues(): void + { + $dto = new EmailPathAttachmentDto('/var/uploads/file.pdf', 'document.pdf', 'application/pdf'); + + $this->assertSame('/var/uploads/file.pdf', $dto->getPath()); + $this->assertSame('document.pdf', $dto->getName()); + $this->assertSame('application/pdf', $dto->getMimeType()); + } +}