diff --git a/.env b/.env index a31deb625..edac9f0f4 100644 --- a/.env +++ b/.env @@ -100,5 +100,18 @@ INVOICE_ENTRY_ACCOUNTS='{ # Otherwise, all issues will be added to a single invoice per client. INVOICE_ONE_INVOICE_PER_ISSUE=false -# If true, the invoice description is set based on issue name andinvoice entries. -SET_INVOICE_DESCRIPTION_FROM_ENTRIES=false +# If true, the invoice description is set based on issue description +SET_INVOICE_DESCRIPTION_FROM_ISSUE_DESCRIPTION=false +INVOICE_DESCRIPTION_ISSUE_HEADING=Opgavebeskrivelse + +# Replace elements in invoice descriptions (if `SET_INVOICE_DESCRIPTION_FROM_ISSUE_DESCRIPTION` is true) +# +# { +# elementName: [start, end] +# } +# +# INVOICE_DESCRIPTION_ELEMENT_REPLACEMENTS='{ +# "p": ["", "; "] +# "strong": [": ", ""] +# }' +INVOICE_DESCRIPTION_ELEMENT_REPLACEMENTS='{}' diff --git a/composer.json b/composer.json index 97ad9cdf8..0a8073757 100644 --- a/composer.json +++ b/composer.json @@ -6,6 +6,7 @@ "require": { "php": ">=8.1", "ext-ctype": "*", + "ext-dom": "*", "ext-iconv": "*", "doctrine/doctrine-bundle": "^2.7", "doctrine/doctrine-migrations-bundle": "^3.2", diff --git a/composer.lock b/composer.lock index 328398044..0cc093e7a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6271cc27ab9aee98952e86350fe6017c", + "content-hash": "b64ceba6fcd50267561d233ec42afcfd", "packages": [ { "name": "behat/transliterator", @@ -10298,12 +10298,12 @@ "version": "v5.2.13", "source": { "type": "git", - "url": "https://github.com/justinrainbow/json-schema.git", + "url": "https://github.com/jsonrainbow/json-schema.git", "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/fbbe7e5d79f618997bc3332a6f49246036c45793", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/fbbe7e5d79f618997bc3332a6f49246036c45793", "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793", "shasum": "" }, @@ -13220,6 +13220,7 @@ "platform": { "php": ">=8.1", "ext-ctype": "*", + "ext-dom": "*", "ext-iconv": "*" }, "platform-dev": [], diff --git a/config/services.yaml b/config/services.yaml index 8e59848b3..29f8d17d0 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -61,4 +61,6 @@ services: $options: accounts: '%env(json:INVOICE_ENTRY_ACCOUNTS)%' one_invoice_per_issue: '%env(bool:INVOICE_ONE_INVOICE_PER_ISSUE)%' - set_invoice_description_from_entries: '%env(bool:SET_INVOICE_DESCRIPTION_FROM_ENTRIES)%' + set_invoice_description_from_issue_description: '%env(bool:SET_INVOICE_DESCRIPTION_FROM_ISSUE_DESCRIPTION)%' + invoice_description_issue_heading: '%env(INVOICE_DESCRIPTION_ISSUE_HEADING)%' + invoice_description_element_replacements: '%env(json:INVOICE_DESCRIPTION_ELEMENT_REPLACEMENTS)%' diff --git a/src/Entity/Invoice.php b/src/Entity/Invoice.php index 8dc2f582a..be19de8cf 100644 --- a/src/Entity/Invoice.php +++ b/src/Entity/Invoice.php @@ -19,7 +19,6 @@ class Invoice extends AbstractBaseEntity #[ORM\Column(length: 255)] private ?string $name = null; - #[ORM\Column(length: self::DESCRIPTION_MAX_LENGTH, nullable: true)] private ?string $description = null; diff --git a/src/Service/HtmlHelper.php b/src/Service/HtmlHelper.php new file mode 100644 index 000000000..4d3e88f1c --- /dev/null +++ b/src/Service/HtmlHelper.php @@ -0,0 +1,79 @@ +loadHTML('
'.$html.'
'); + /** @var \DOMElement $wrapper */ + $wrapper = $dom->getElementById($wrapperId); + + $headings = $dom->getElementsByTagName($tagName); + + /** @var \DOMElement $element */ + foreach (iterator_to_array($headings) as $index => $element) { + if ($element->textContent === $title + || ($useRegex && !empty($title) && preg_match($title, $element->textContent))) { + // The node list is live, so we must remove trailing content before removing leading content. + if ($nextElement = $headings->item($index + 1)) { + assert(!empty($nextElement->parentNode)); + while ($nextElement->nextSibling) { + $nextElement->parentNode->removeChild($nextElement->nextSibling); + } + $nextElement->parentNode->removeChild($nextElement); + } + + // Remove leading content. + assert(!empty($element->parentNode)); + while ($element->previousSibling) { + $element->parentNode->removeChild($element->previousSibling); + } + if (!$includeHeading) { + $element->parentNode->removeChild($element); + } + + return join( + '', + array_map( + static fn (\DOMNode $node) => $dom->saveHTML($node), + iterator_to_array($wrapper->childNodes) + ) + ); + } + } + + return null; + } + + public function element2separator(string $html, string $elementName, string $before, string $after): string + { + $html = trim($html); + // Remove element start tag at start of string + $html = preg_replace('@^<'.preg_quote($elementName, '@').'[^>]*>@', '', $html); + // Remove element end tag at end of string + $html = preg_replace('@$@', '', $html); + + $placeholder = '[[['.uniqid().']]]'; + + // Replace element start tag + $html = preg_replace('@<'.preg_quote($elementName, '@').'[^>]*>@', $placeholder, $html); + // Replace multiple consecutive placeholders with a single placeholder. + $html = preg_replace('@('.preg_quote($placeholder, '@').'){2,}@', $placeholder, $html); + $html = str_replace($placeholder, $before, $html); + + // Replace element start tag + $html = preg_replace('@@', $placeholder, $html); + // Replace multiple consecutive placeholders with a single placeholder. + $html = preg_replace('@('.preg_quote($placeholder, '@').'){2,}@', $placeholder, $html); + $html = str_replace($placeholder, $after, $html); + + return $html; + } +} diff --git a/src/Service/InvoiceHelper.php b/src/Service/InvoiceHelper.php index 9ed3944ff..ec74492ab 100644 --- a/src/Service/InvoiceHelper.php +++ b/src/Service/InvoiceHelper.php @@ -2,29 +2,66 @@ namespace App\Service; +use App\Entity\Invoice; use App\Entity\InvoiceEntry; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; +use function Symfony\Component\String\s; + class InvoiceHelper { private readonly array $options; public function __construct( - array $options + private readonly HtmlHelper $htmlHelper, + array $options, ) { $this->options = $this->resolveOptions($options); } - public function getOneInvoicePerIssue() + public function getOneInvoicePerIssue(): bool { return $this->options['one_invoice_per_issue']; } - public function getSetInvoiceDescriptionFromEntries() + public function getSetInvoiceDescriptionFromIssueDescription(): bool { - return $this->options['set_invoice_description_from_entries']; + return $this->options['set_invoice_description_from_issue_description']; + } + + public function getInvoiceDescription(?string $description): ?string + { + if (empty($description)) { + return $description; + } + + $heading = $this->getInvoiceDescriptionIssueHeading(); + if ($description = $this->htmlHelper->getSection($description, $heading)) { + foreach ($this->getInvoiceDescriptionElementReplacements() as $elementName => [$before, $after]) { + $description = $this->htmlHelper->element2separator($description, $elementName, $before, $after); + } + + $description = strip_tags($description); + + $description = s($description)->trim()->truncate(Invoice::DESCRIPTION_MAX_LENGTH)->toString(); + + // HACK! Replace some duplicated punctuation. + return preg_replace('/([;:] )\1/', '$1', $description); + } + + return null; + } + + public function getInvoiceDescriptionIssueHeading(): string + { + return $this->options['invoice_description_issue_heading']; + } + + public function getInvoiceDescriptionElementReplacements(): array + { + return $this->options['invoice_description_element_replacements']; } /** @@ -194,8 +231,14 @@ private function resolveOptions(array $options): array ->setDefault('one_invoice_per_issue', false) ->setAllowedTypes('one_invoice_per_issue', 'bool') - ->setDefault('set_invoice_description_from_entries', false) - ->setAllowedTypes('set_invoice_description_from_entries', 'bool') + ->setDefault('set_invoice_description_from_issue_description', false) + ->setAllowedTypes('set_invoice_description_from_issue_description', 'bool') + + ->setDefault('invoice_description_issue_heading', '') + ->setAllowedTypes('invoice_description_issue_heading', 'string') + + ->setDefault('invoice_description_element_replacements', []) + ->setAllowedTypes('invoice_description_element_replacements', 'array') ->resolve($options); } diff --git a/src/Service/ProjectBillingService.php b/src/Service/ProjectBillingService.php index 3a9ca5965..072562af6 100644 --- a/src/Service/ProjectBillingService.php +++ b/src/Service/ProjectBillingService.php @@ -34,6 +34,7 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly TranslatorInterface $translator, private readonly InvoiceHelper $invoiceEntryHelper, + private readonly HtmlHelper $htmlHelper, ) { } @@ -306,24 +307,12 @@ public function createProjectBilling(int $projectBillingId): void } if ($this->invoiceEntryHelper->getOneInvoicePerIssue() - && $this->invoiceEntryHelper->getSetInvoiceDescriptionFromEntries()) { + && $this->invoiceEntryHelper->getSetInvoiceDescriptionFromIssueDescription()) { // We know that we have exactly one issue. $issue = reset($invoiceArray['issues']); - // Generate an invoice description starting with the issue name - // followed by an “Ordrelinjer:” heading - // and a line per invoice entry. - $description = array_merge( - [ - $issue->getName(), - 'Ordrelinjer:', - ], - $invoice->getInvoiceEntries() - ->map( - static fn (InvoiceEntry $entry) => preg_replace('/^[A-Z0-9-]+: /', '', $entry->getDescription() ?? '') - ) - ->toArray() - ); - $invoice->setDescription(join(PHP_EOL, $description)); + if ($description = $this->invoiceEntryHelper->getInvoiceDescription($issue->getDescription())) { + $invoice->setDescription($description); + } } $projectBilling->addInvoice($invoice); diff --git a/tests/Service/HtmlHelperTest.php b/tests/Service/HtmlHelperTest.php new file mode 100644 index 000000000..a4303cf3c --- /dev/null +++ b/tests/Service/HtmlHelperTest.php @@ -0,0 +1,190 @@ +helper = new HtmlHelper(); + } + + /** + * @dataProvider dataGetSection + */ + public function testGetSection( + string $html, + string $title, + ?string $tagName, + ?bool $useRegex, + ?bool $includeHeading, + ?string $expected + ): void { + $actual = $this->helper->getSection($html, $title, tagName: $tagName ?? 'h3', useRegex: $useRegex ?? false, + includeHeading: $includeHeading ?? false); + $this->assertSame($expected, $actual); + } + + public function testElement2separatorMulti(): void + { + $html = '

Titel: Tryk af viftekort LIV guide
Produkt
viftekort- som ledetrådene- lille lommeformat: 200 stk.
Kommentar til opgaven: Fil er uden skæremærker. Efter opsætning hos jer, send gerne retur til godkendelse.
Uploadede filer
ny-liv-viftekort-til-tryk-2.0.pdf (OK)

'; + + $actual = $html; + foreach ([ + 'p' => ['', '; '], + 'strong' => ['; ', ': '], + ] as $elementName => [$start, $end]) { + $actual = $this->helper->element2separator($actual, $elementName, $start, $end); + } + + $expected = 'Titel: : Tryk af viftekort LIV guide
; Produkt:
viftekort- som ledetrådene- lille lommeformat: 200 stk.
; Kommentar til opgaven: : Fil er uden skæremærker. Efter opsætning hos jer, send gerne retur til godkendelse.
; Uploadede filer:
ny-liv-viftekort-til-tryk-2.0.pdf (OK)'; + + $this->assertEquals($expected, $actual); + } + + /** + * @dataProvider dataElement2separator + */ + public function testElement2separator( + string $html, + string $elementName, + string $start, + string $end, + string $expected + ): void { + $actual = $this->helper->element2separator($html, $elementName, $start, $end); + $this->assertSame($expected, $actual); + } + + /** + * Provider for testElement2separator. + */ + public static function dataElement2separator(): iterable + { + yield [ + '

xxxxxx
xxx
xxx
xxx
xxx
xxxxxx

xxx
xxx
xxx

xxx
xxx
xxx
xxx

xxx

xxx
xxx
xxx
xxx
xxx

', + 'strong', + '', + '', + '

xxxxxx
xxx
xxx
xxx
xxx
xxxxxx

xxx
xxx
xxx

xxx
xxx
xxx
xxx

xxx

xxx
xxx
xxx
xxx
xxx

', + ]; + + yield [ + '

xxxxxx
xxx
xxx
xxx
xxx
xxxxxx

xxx
xxx
xxx

xxx
xxx
xxx
xxx

xxx

xxx
xxx
xxx
xxx
xxx

', + 'strong', + '', + ': ', + '

xxx: xxx
xxx:
xxx
xxx
xxx
xxx: xxx

xxx
xxx
xxx

xxx
xxx
xxx
xxx

xxx

xxx
xxx:
xxx
xxx
xxx

', + ]; + + yield [ + '

xxxxxx
xxx
xxx
xxx
xxx
xxxxxx

xxx
xxx
xxx

xxx
xxx
xxx
xxx

xxx

xxx
xxx
xxx
xxx
xxx

', + 'br', + '; ', + '', + '

xxxxxx; xxx; xxx; xxx; xxx; xxxxxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx

', + ]; + + yield [ + '

xxxxxx
xxx
xxx
xxx
xxx
xxxxxx

xxx
xxx
xxx

xxx
xxx
xxx
xxx

xxx

xxx
xxx
xxx
xxx
xxx

', + 'br', + '; ', + '', + '

xxxxxx; xxx; xxx; xxx; xxx; xxxxxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx; xxx

', + ]; + } + + /** + * Provider for testGetSection. + * + * @return iterable + */ + public static function dataGetSection(): iterable + { + yield [ + '

Profil

+

Navn: Test Testersen

+ +

Opgavebeskrivelse

+

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ +

Betaling

+

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ +

Levering

+

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ +

Interne

+

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+', + 'Opgavebeskrivelse', + null, + null, + null, + + ' +

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ +', + ]; + + yield [ + '

Profil

+

Navn: Test Testersen

+ +

Opgavebeskrivelse

+

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ +

Betaling

+

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ +

Levering

+

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ +

Interne

+

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+', + 'Hest', + null, + null, + null, + + null, + ]; + + yield [ + '

Profil

+

Navn: Test Testersen

+ +

Opgavebeskrivelse

+

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ +

Betaling

+

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ +

Levering

+

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ +

Interne

+

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+', + '/beskrivelse/', + null, + true, + null, + + ' +

Titel: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ +', + ]; + } +}