feat: send accepted teamer invoices to configurable email inbox
This commit is contained in:
@@ -68,5 +68,8 @@ XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport"
|
|||||||
BIN_GS=/usr/bin/gs
|
BIN_GS=/usr/bin/gs
|
||||||
BIN_CONVERT=/usr/bin/convert
|
BIN_CONVERT=/usr/bin/convert
|
||||||
|
|
||||||
|
DATEV_EMAIL_RECIPIENT=[email protected]
|
||||||
|
DATEV_EMAIL_SENDER=[email protected]
|
||||||
|
|
||||||
FEATURE_SANITIZE_UPLOADS=false
|
FEATURE_SANITIZE_UPLOADS=false
|
||||||
FEATURE_STAMP_INVOICES=false
|
FEATURE_STAMP_INVOICES=false
|
||||||
|
|||||||
@@ -7,3 +7,6 @@ flagception:
|
|||||||
stamp_invoices:
|
stamp_invoices:
|
||||||
default: false
|
default: false
|
||||||
env: FEATURE_STAMP_INVOICES
|
env: FEATURE_STAMP_INVOICES
|
||||||
|
datev_email:
|
||||||
|
default: false
|
||||||
|
env: FEATURE_DATEV_EMAIL
|
||||||
|
|||||||
@@ -153,6 +153,11 @@ services:
|
|||||||
event: oneup_uploader.validation
|
event: oneup_uploader.validation
|
||||||
method: onValidate
|
method: onValidate
|
||||||
|
|
||||||
|
App\EventListener\DatevEmailSubscriber:
|
||||||
|
arguments:
|
||||||
|
$datevEmailRecipient: '%env(DATEV_EMAIL_RECIPIENT)%'
|
||||||
|
$datevEmailSender: '%env(DATEV_EMAIL_SENDER)%'
|
||||||
|
|
||||||
app.upload_namer:
|
app.upload_namer:
|
||||||
class: App\Service\Upload\UploadNamer
|
class: App\Service\Upload\UploadNamer
|
||||||
public: true
|
public: true
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Email;
|
namespace App\Email;
|
||||||
|
|
||||||
use App\Model\EmailAttachmentDto;
|
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||||
@@ -46,8 +45,8 @@ class Mailer
|
|||||||
;
|
;
|
||||||
|
|
||||||
foreach ($config['attachments'] as $attachment) {
|
foreach ($config['attachments'] as $attachment) {
|
||||||
/* @var EmailAttachmentDto $attachment */
|
/* @var EmailAttachmentInterface $attachment */
|
||||||
$email->attach($attachment->getContent(), $attachment->getName(), $attachment->getMimeType());
|
$attachment->attachTo($email);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->bodyRenderer->render($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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Model;
|
namespace App\Model;
|
||||||
|
|
||||||
class EmailAttachmentDto
|
use App\Email\EmailAttachmentInterface;
|
||||||
|
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||||
|
|
||||||
|
class EmailAttachmentDto implements EmailAttachmentInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $name,
|
private readonly string $name,
|
||||||
@@ -25,4 +30,9 @@ class EmailAttachmentDto
|
|||||||
{
|
{
|
||||||
return $this->mimeType;
|
return $this->mimeType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function attachTo(TemplatedEmail $email): void
|
||||||
|
{
|
||||||
|
$email->attach($this->content, $this->name, $this->mimeType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user