Skip to content

Commit

Permalink
1635: Updated invoice description
Browse files Browse the repository at this point in the history
  • Loading branch information
rimi-itk committed Jul 1, 2024
1 parent 63b5b66 commit 03535f4
Show file tree
Hide file tree
Showing 9 changed files with 346 additions and 29 deletions.
17 changes: 15 additions & 2 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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='{}'
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)%'
1 change: 0 additions & 1 deletion src/Entity/Invoice.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
79 changes: 79 additions & 0 deletions src/Service/HtmlHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

namespace App\Service;

class HtmlHelper
{
/**
* Get section in HTML document identified by heading content.
*/
public function getSection(string $html, string $title, string $tagName = 'h3', bool $useRegex = false, bool $includeHeading = false): ?string
{
$dom = new \DOMDocument();
$wrapperId = 'html-helper-wrapper';
$dom->loadHTML('<div id="'.$wrapperId.'">'.$html.'</div>');
/** @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('@</'.preg_quote($elementName, '@').'>$@', '', $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('@</'.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, $after, $html);

return $html;
}
}
55 changes: 49 additions & 6 deletions src/Service/InvoiceHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
}

/**
Expand Down Expand Up @@ -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);
}
Expand Down
21 changes: 5 additions & 16 deletions src/Service/ProjectBillingService.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TranslatorInterface $translator,
private readonly InvoiceHelper $invoiceEntryHelper,
private readonly HtmlHelper $htmlHelper,
) {
}

Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 03535f4

Please sign in to comment.