feat: send accepted teamer invoices to configurable email inbox

This commit is contained in:
Björn Fromme
2026-03-15 12:42:28 +01:00
parent 3cb5b065f4
commit 3b99757a08
13 changed files with 648 additions and 4 deletions
+3
View File
@@ -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=[email protected]
DATEV_EMAIL_SENDER=[email protected]
FEATURE_SANITIZE_UPLOADS=false
FEATURE_STAMP_INVOICES=false
+3
View File
@@ -7,3 +7,6 @@ flagception:
stamp_invoices:
default: false
env: FEATURE_STAMP_INVOICES
datev_email:
default: false
env: FEATURE_DATEV_EMAIL
+5
View File
@@ -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
+187
View File
@@ -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<string, array{teamer: int, office: int}>
*/
public function getCancellationStatsByHotel(
\DateTimeImmutable $startDate,
\DateTimeImmutable $endDate
): array;
```
Add to `DispositionRepository`:
```php
/**
* @return array<string, array{teamer: int, office: int}>
*/
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
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Email;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
interface EmailAttachmentInterface
{
public function attachTo(TemplatedEmail $email): void;
}
+2 -3
View File
@@ -2,7 +2,6 @@
namespace App\Email;
use App\Model\EmailAttachmentDto;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
@@ -46,8 +45,8 @@ class Mailer
;
foreach ($config['attachments'] as $attachment) {
/* @var EmailAttachmentDto $attachment */
$email->attach($attachment->getContent(), $attachment->getName(), $attachment->getMimeType());
/* @var EmailAttachmentInterface $attachment */
$attachment->attachTo($email);
}
$this->bodyRenderer->render($email);
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Email\Mailer;
use App\Entity\Upload;
use App\Event\DocumentConfirmedEvent;
use App\Model\EmailPathAttachmentDto;
use App\Service\Upload\DownloadNamer;
use App\Service\Upload\UploadHandler;
use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class DatevEmailSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly Mailer $mailer,
private readonly UploadHandler $uploadHandler,
private readonly LoggerInterface $logger,
private readonly string $datevEmailRecipient,
private readonly string $datevEmailSender,
private readonly FeatureManagerInterface $featureManager,
) {
}
public static function getSubscribedEvents(): array
{
return [
DocumentConfirmedEvent::NAME => '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,
],
]);
}
}
+11 -1
View File
@@ -1,8 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Model;
class EmailAttachmentDto
use App\Email\EmailAttachmentInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
class EmailAttachmentDto implements EmailAttachmentInterface
{
public function __construct(
private readonly string $name,
@@ -25,4 +30,9 @@ class EmailAttachmentDto
{
return $this->mimeType;
}
public function attachTo(TemplatedEmail $email): void
{
$email->attach($this->content, $this->name, $this->mimeType);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Model;
use App\Email\EmailAttachmentInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
class EmailPathAttachmentDto implements EmailAttachmentInterface
{
public function __construct(
private readonly string $path,
private readonly string $name,
private readonly string $mimeType = 'application/pdf',
) {
}
public function getPath(): string
{
return $this->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);
}
}
+11
View File
@@ -0,0 +1,11 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Honorarnote
</h1>
<p>
Die angehangene Honorarnote von {{ disposition.teamer }} zum Einsatz {{ disposition.assignment.destination }}
wurde freigegeben.
</p>
{% endblock %}
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace App\Tests\EventListener;
use App\Email\Mailer;
use App\Entity\Disposition;
use App\Entity\Teamer;
use App\Entity\Upload;
use App\Event\DocumentConfirmedEvent;
use App\EventListener\DatevEmailSubscriber;
use App\Model\EmailPathAttachmentDto;
use App\Service\Upload\UploadHandler;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class DatevEmailSubscriberTest extends TestCase
{
private Mailer&MockObject $mailer;
private UploadHandler&MockObject $uploadHandler;
private LoggerInterface&MockObject $logger;
private DatevEmailSubscriber $subscriber;
protected function setUp(): void
{
$this->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,
'[email protected]',
'[email protected]',
);
}
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('[email protected]', $options['to']);
$this->assertSame('[email protected]', $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);
}
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Tests\Model;
use App\Model\EmailAttachmentDto;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
class EmailAttachmentDtoTest extends TestCase
{
public function testAttachToAddsContentAttachment(): void
{
$dto = new EmailAttachmentDto('invoice.pdf', 'pdf-content', 'application/pdf');
$email = new TemplatedEmail();
$dto->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());
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Tests\Model;
use App\Model\EmailPathAttachmentDto;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
class EmailPathAttachmentDtoTest extends TestCase
{
public function testAttachToAddsPathAttachment(): void
{
$tmpFile = tempnam(sys_get_temp_dir(), 'test_attachment_');
file_put_contents($tmpFile, 'dummy-pdf-content');
try {
$dto = new EmailPathAttachmentDto($tmpFile, 'invoice.pdf');
$email = new TemplatedEmail();
$dto->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());
}
}