diff --git a/.env b/.env index f7326445..91208e06 100644 --- a/.env +++ b/.env @@ -84,7 +84,9 @@ PRODUCT_QUANTITY_SCALE=2 # }, # "product": { # "label": "The real PSP element", -# "product": true +# "product": true, +# // Optional invoice entry product name prefix +# "invoice_entry_prefix": "Tryk" # } # }' # @@ -93,3 +95,25 @@ INVOICE_ENTRY_ACCOUNTS='{ "label": "Define INVOICE_ENTRY_ACCOUNTS in .env.local" } }' + +# If true, project billing will generate one invoice per issue per client. +# 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 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='{}' + +INVOICE_ISSUE_FAR_PAST_CUTOFF_DATE='' diff --git a/CHANGELOG.md b/CHANGELOG.md index 91c2aea7..67ec3c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 1812: Minor hour report improvements. * [PR-134](https://github.com/itk-dev/economics/pull/134) 1632: Remove team report. +* [PR-130](https://github.com/itk-dev/economics/pull/130) + Reduced product invoice entries to a single entry + Added option to generate one invoice per issue * [PR-133](https://github.com/itk-dev/economics/pull/133) 1742: Simplified hour report form. * [PR-132](https://github.com/itk-dev/economics/pull/132) diff --git a/assets/styles/app.css b/assets/styles/app.css index 711482e0..588015ba 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -405,6 +405,7 @@ content: '▼'; @apply pr-4 text-xs; } + .loading { position: relative; } @@ -419,4 +420,10 @@ z-index: 9999; left: 0; } + + .hours, + .price, + .quantity { + text-align: right; + } } diff --git a/composer.json b/composer.json index 97ad9cdf..cb4cf0d6 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,9 @@ "require": { "php": ">=8.1", "ext-ctype": "*", + "ext-dom": "*", "ext-iconv": "*", + "ext-mbstring": "*", "doctrine/doctrine-bundle": "^2.7", "doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/orm": "^2.13", diff --git a/composer.lock b/composer.lock index 32839804..2aec3567 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": "6f4a16c107f13421a6c5702d65ac5319", "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,7 +13220,9 @@ "platform": { "php": ">=8.1", "ext-ctype": "*", - "ext-iconv": "*" + "ext-dom": "*", + "ext-iconv": "*", + "ext-mbstring": "*" }, "platform-dev": [], "plugin-api-version": "2.6.0" diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index d4661ff3..36863345 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -3,7 +3,7 @@ twig: date: format: 'd.m.Y' globals: - invoice_entry_helper: '@App\Service\InvoiceEntryHelper' + invoice_helper: '@App\Service\InvoiceHelper' when@test: twig: diff --git a/config/services.yaml b/config/services.yaml index 58b7be10..6f45f6b2 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -56,10 +56,15 @@ services: issue_product_type_options: quantity_scale: '%env(int:PRODUCT_QUANTITY_SCALE)%' - App\Service\InvoiceEntryHelper: + App\Service\InvoiceHelper: arguments: $options: accounts: '%env(json:INVOICE_ENTRY_ACCOUNTS)%' + one_invoice_per_issue: '%env(bool:INVOICE_ONE_INVOICE_PER_ISSUE)%' + 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)%' + invoice_issue_far_past_cutoff_date: '%env(INVOICE_ISSUE_FAR_PAST_CUTOFF_DATE)%' App\Service\PlanningService: arguments: diff --git a/migrations/Version20240701080654.php b/migrations/Version20240701080654.php new file mode 100644 index 00000000..203314b1 --- /dev/null +++ b/migrations/Version20240701080654.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE issue ADD description LONGTEXT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE issue DROP description'); + } +} diff --git a/migrations/Version20240701104400.php b/migrations/Version20240701104400.php new file mode 100644 index 00000000..ad04f42a --- /dev/null +++ b/migrations/Version20240701104400.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE invoice CHANGE description description VARCHAR(500) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE invoice CHANGE description description VARCHAR(255) DEFAULT NULL'); + } +} diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php index 9ec40cdc..d14b8377 100644 --- a/src/Controller/InvoiceController.php +++ b/src/Controller/InvoiceController.php @@ -18,7 +18,7 @@ use App\Repository\InvoiceRepository; use App\Service\BillingService; use App\Service\ClientHelper; -use App\Service\InvoiceEntryHelper; +use App\Service\InvoiceHelper; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -37,7 +37,7 @@ class InvoiceController extends AbstractController public function __construct( private readonly BillingService $billingService, private readonly TranslatorInterface $translator, - private readonly InvoiceEntryHelper $invoiceEntryHelper, + private readonly InvoiceHelper $invoiceEntryHelper, ) { } diff --git a/src/Controller/InvoiceEntryController.php b/src/Controller/InvoiceEntryController.php index e5d4c2c2..c087237c 100644 --- a/src/Controller/InvoiceEntryController.php +++ b/src/Controller/InvoiceEntryController.php @@ -11,7 +11,7 @@ use App\Repository\InvoiceEntryRepository; use App\Service\BillingService; use App\Service\ClientHelper; -use App\Service\InvoiceEntryHelper; +use App\Service\InvoiceHelper; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -27,7 +27,7 @@ public function __construct( private readonly BillingService $billingService, private readonly TranslatorInterface $translator, private readonly ClientHelper $clientHelper, - private readonly InvoiceEntryHelper $invoiceEntryHelper, + private readonly InvoiceHelper $invoiceHelper, ) { } @@ -90,7 +90,7 @@ public function edit(Request $request, Invoice $invoice, InvoiceEntry $invoiceEn $options['disabled'] = true; } - $accounts = $this->invoiceEntryHelper->getAccountOptions($invoiceEntry->getAccount()); + $accounts = $this->invoiceHelper->getAccountOptions($invoiceEntry->getAccount()); if (!empty($accounts)) { $options['invoice_entry_accounts'] = $accounts; } diff --git a/src/Controller/ProjectBillingController.php b/src/Controller/ProjectBillingController.php index 88e3e06c..5c800ae2 100644 --- a/src/Controller/ProjectBillingController.php +++ b/src/Controller/ProjectBillingController.php @@ -116,6 +116,7 @@ public function edit(Request $request, ProjectBilling $projectBilling, ProjectBi 'projectBilling' => $projectBilling, 'form' => $form, 'issuesWithoutAccounts' => $issuesWithoutAccounts, + 'issuesInTheFarPast' => $projectBillingService->getIssuesNotIncludedInProjectBillingFromTheFarPast($projectBilling), ]); } diff --git a/src/Entity/Invoice.php b/src/Entity/Invoice.php index e0eb235c..be19de8c 100644 --- a/src/Entity/Invoice.php +++ b/src/Entity/Invoice.php @@ -13,10 +13,13 @@ #[ORM\Entity(repositoryClass: InvoiceRepository::class)] class Invoice extends AbstractBaseEntity { + // Opus allows at most 500 characters (cf. BillingService::exportInvoicesToSpreadsheet). + public const DESCRIPTION_MAX_LENGTH = 500; + #[ORM\Column(length: 255)] private ?string $name = null; - #[ORM\Column(length: 255, nullable: true)] + #[ORM\Column(length: self::DESCRIPTION_MAX_LENGTH, nullable: true)] private ?string $description = null; #[ORM\Column] diff --git a/src/Entity/Issue.php b/src/Entity/Issue.php index 08fc92d3..afb6fe35 100644 --- a/src/Entity/Issue.php +++ b/src/Entity/Issue.php @@ -18,6 +18,9 @@ class Issue extends AbstractBaseEntity #[ORM\Column(length: 255)] private ?string $name = null; + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $description = null; + #[ORM\Column(length: 255)] private ?string $status = null; @@ -89,6 +92,18 @@ public function setName(string $name): self return $this; } + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + public function getStatus(): ?string { return $this->status; diff --git a/src/Model/Invoices/IssueData.php b/src/Model/Invoices/IssueData.php index bc91e8d9..13c2e54d 100644 --- a/src/Model/Invoices/IssueData.php +++ b/src/Model/Invoices/IssueData.php @@ -9,6 +9,7 @@ class IssueData { public \DateTime $started; public string $name; + public string $description; public string $status; public string $projectTrackerId; public string $projectTrackerKey; diff --git a/src/Repository/IssueRepository.php b/src/Repository/IssueRepository.php index 0879700d..14296b9c 100644 --- a/src/Repository/IssueRepository.php +++ b/src/Repository/IssueRepository.php @@ -98,6 +98,19 @@ public function getClosedIssuesFromInterval(Project $project, \DateTimeInterface return $qb->getQuery()->execute(); } + public function getIssuesClosedBefore(Project $project, \DateTimeInterface $date) + { + $qb = $this->createQueryBuilder('issue'); + $qb->andWhere($qb->expr()->eq('issue.project', ':project')); + $qb->setParameter('project', $project); + $qb->andWhere('issue.resolutionDate < :date'); + $qb->setParameter('date', $date); + $qb->andWhere('issue.status IN (:statuses)'); + $qb->setParameter('statuses', $this->getClosedStatuses($project)); + + return $qb->getQuery()->execute(); + } + /** * Get "closed" statuses for a project. * diff --git a/src/Service/BillingService.php b/src/Service/BillingService.php index e0e5ac3e..f7de8ec8 100644 --- a/src/Service/BillingService.php +++ b/src/Service/BillingService.php @@ -168,6 +168,7 @@ public function generateSpreadsheetCsvResponse(array $ids): Response /** @var Csv $writer */ $writer = IOFactory::createWriter($spreadsheet, 'Csv'); $writer->setDelimiter(';'); + // SAP/Opus cannot read CSV! $writer->setEnclosure(''); $writer->setLineEnding("\r\n"); $writer->setSheetIndex(0); @@ -306,8 +307,8 @@ public function exportInvoicesToSpreadsheet(array $invoiceIds): Spreadsheet // 15. "Kunderef.ID" $setCellValue(15, $row, substr('Att: '.$contactName, 0, 35)); // 16. "Toptekst, yderligere spec i det hvide felt på fakturaen" - $description = $invoice->getDescription() ?? ''; - $setCellValue(16, $row, substr($description, 0, 500)); + $description = $this->formatCsvText($invoice->getDescription() ?? ''); + $setCellValue(16, $row, substr($description, 0, Invoice::DESCRIPTION_MAX_LENGTH)); // 17. "Leverandør" if ($internal) { $setCellValue(17, $row, str_pad($this->invoiceSupplierAccount, 10, '0', \STR_PAD_LEFT)); @@ -372,6 +373,22 @@ public function exportInvoicesToSpreadsheet(array $invoiceIds): Spreadsheet return $spreadsheet; } + /** + * Does something to make SAP/Opus accept a text value. + * + * @param string $text + * + * @return string + */ + private function formatCsvText(string $text): string + { + return str_replace( + ["\n", "\r", ';'], + [' ', '', '.'], + $text + ); + } + /** * @throws PhpSpreadsheetException */ diff --git a/src/Service/DataSynchronizationService.php b/src/Service/DataSynchronizationService.php index 0ba6eb4a..f27da4e4 100644 --- a/src/Service/DataSynchronizationService.php +++ b/src/Service/DataSynchronizationService.php @@ -13,6 +13,7 @@ use App\Entity\Worklog; use App\Exception\EconomicsException; use App\Exception\UnsupportedDataProviderException; +use App\Model\Invoices\IssueData; use App\Model\SprintReport\SprintReportVersion; use App\Repository\AccountRepository; use App\Repository\ClientRepository; @@ -214,6 +215,7 @@ public function syncIssuesForProject(int $projectId, callable $progressCallback $pagedIssueData = $service->getIssuesDataForProjectPaged($projectTrackerId, $startAt, self::MAX_RESULTS); $total = $pagedIssueData->total; + /** @var IssueData $issueDatum */ foreach ($pagedIssueData->items as $issueDatum) { $issue = $this->issueRepository->findOneBy(['projectTrackerId' => $issueDatum->projectTrackerId]); @@ -224,6 +226,7 @@ public function syncIssuesForProject(int $projectId, callable $progressCallback $this->entityManager->persist($issue); } $issue->setName($issueDatum->name); + $issue->setDescription($issueDatum->description); $issue->setAccountId($issueDatum->accountId); $issue->setAccountKey($issueDatum->accountKey); $issue->setEpicKey($issueDatum->epicKey); @@ -236,6 +239,7 @@ public function syncIssuesForProject(int $projectId, callable $progressCallback $issue->setPlanHours($issueDatum->planHours); $issue->setHoursRemaining($issueDatum->hourRemaining); $issue->setDueDate($issueDatum->dueDate); + assert(null !== $issueDatum->worker); $issue->setWorker($issueDatum->worker); $issue->setLinkToIssue($issueDatum->linkToIssue); @@ -244,11 +248,13 @@ public function syncIssuesForProject(int $projectId, callable $progressCallback $issue->getVersions()->clear(); } - foreach ($issueDatum->versions as $versionData) { - $version = $this->versionRepository->findOneBy(['projectTrackerId' => $versionData->projectTrackerId]); + if (null !== $issueDatum->versions) { + foreach ($issueDatum->versions as $versionData) { + $version = $this->versionRepository->findOneBy(['projectTrackerId' => $versionData->projectTrackerId]); - if (null !== $version) { - $issue->addVersion($version); + if (null !== $version) { + $issue->addVersion($version); + } } } diff --git a/src/Service/HtmlHelper.php b/src/Service/HtmlHelper.php new file mode 100644 index 00000000..4d3e88f1 --- /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/InvoiceEntryHelper.php b/src/Service/InvoiceHelper.php similarity index 59% rename from src/Service/InvoiceEntryHelper.php rename to src/Service/InvoiceHelper.php index f7586130..ea0a5c54 100644 --- a/src/Service/InvoiceEntryHelper.php +++ b/src/Service/InvoiceHelper.php @@ -2,21 +2,67 @@ namespace App\Service; +use App\Entity\Invoice; use App\Entity\InvoiceEntry; +use Doctrine\DBAL\Driver\Exception; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; -class InvoiceEntryHelper +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(): bool + { + return $this->options['one_invoice_per_issue']; + } + + public function getSetInvoiceDescriptionFromIssueDescription(): bool + { + 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); + + // HACK! Replace some duplicated punctuation. + $description = preg_replace('/([;:] )\1/', '$1', $description); + + return mb_strcut(trim($description), 0, Invoice::DESCRIPTION_MAX_LENGTH); + } + + return null; + } + + public function getInvoiceDescriptionIssueHeading(): string + { + return $this->options['invoice_description_issue_heading']; + } + + public function getInvoiceDescriptionElementReplacements(): array + { + return $this->options['invoice_description_element_replacements']; + } + /** * Get all configured accounts. * @@ -86,10 +132,20 @@ public function getAccountLabel(string $account): string return $accounts[$account]['label'] ?? $account; } + /** + * Get account invoice entry pretix based on configured accounts. + */ + public function getAccountInvoiceEntryPrefix(string $account): ?string + { + $accounts = $this->getAccounts(null); + + return $accounts[$account]['invoice_entry_prefix'] ?? null; + } + /** * Decide if an invoice entry is editable. */ - public function isEditable(InvoiceEntry $entry): bool + public function isEntryEditable(InvoiceEntry $entry): bool { return null === $entry->getInvoice()?->getProjectBilling() || count($this->getAccountOptions(null)) > 1; @@ -114,6 +170,11 @@ private function getAccounts(?string $account): array return $accounts; } + public function getIssueFarPastCutoffDate(): ?\DateTimeInterface + { + return $this->options['invoice_issue_far_past_cutoff_date']; + } + private function resolveOptions(array $options): array { return (new OptionsResolver()) @@ -127,9 +188,11 @@ private function resolveOptions(array $options): array ->setDefaults([ 'default' => false, 'product' => false, + 'invoice_entry_prefix' => null, ]) ->setAllowedTypes('default', 'bool') - ->setAllowedTypes('product', 'bool'); + ->setAllowedTypes('product', 'bool') + ->setAllowedTypes('invoice_entry_prefix', ['null', 'string']); }) ->setAllowedValues('accounts', function (array $values) { if (empty($values)) { @@ -169,6 +232,30 @@ private function resolveOptions(array $options): array return $values; }) + ->setDefault('one_invoice_per_issue', false) + ->setAllowedTypes('one_invoice_per_issue', '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') + + ->setDefault('invoice_issue_far_past_cutoff_date', null) + ->setAllowedTypes('invoice_issue_far_past_cutoff_date', ['null', 'string']) + ->setAllowedValues('invoice_issue_far_past_cutoff_date', static function (?string $value) { + try { + new \DateTimeImmutable($value ?: '0000-00-00'); + + return true; + } catch (Exception) { + return false; + } + }) + ->setNormalizer('invoice_issue_far_past_cutoff_date', static fn (Options $options, ?string $value) => new \DateTimeImmutable($value ?: '0000-00-00')) ->resolve($options); } } diff --git a/src/Service/LeantimeApiService.php b/src/Service/LeantimeApiService.php index 6c069099..11bd6637 100644 --- a/src/Service/LeantimeApiService.php +++ b/src/Service/LeantimeApiService.php @@ -158,6 +158,7 @@ public function getIssuesDataForProjectPaged(string $projectId, $startAt = 0, $m $issueData = new IssueData(); $issueData->name = $issue->headline; + $issueData->description = $issue->description; $issueData->status = $issue->status; $issueData->projectTrackerId = $issue->id; // Leantime does not have a key for each issue. diff --git a/src/Service/ProjectBillingService.php b/src/Service/ProjectBillingService.php index cef1b1c3..2831d533 100644 --- a/src/Service/ProjectBillingService.php +++ b/src/Service/ProjectBillingService.php @@ -6,6 +6,7 @@ use App\Entity\Invoice; use App\Entity\InvoiceEntry; use App\Entity\Issue; +use App\Entity\IssueProduct; use App\Entity\ProjectBilling; use App\Entity\Worklog; use App\Enum\ClientTypeEnum; @@ -32,10 +33,28 @@ public function __construct( private readonly ClientHelper $clientHelper, private readonly EntityManagerInterface $entityManager, private readonly TranslatorInterface $translator, - private readonly InvoiceEntryHelper $invoiceEntryHelper, + private readonly InvoiceHelper $invoiceHelper, ) { } + public function getIssuesNotIncludedInProjectBillingFromTheFarPast(ProjectBilling $projectBilling): array + { + $issues = null; + + if ($cutoffDate = $this->invoiceHelper->getIssueFarPastCutoffDate()) { + $projectBilling = clone $projectBilling; + $projectBilling->setPeriodStart(new \DateTimeImmutable('0001-01-01')); + $projectBilling->setPeriodEnd($cutoffDate); + + $issues = $this->getIssuesNotIncludedInProjectBilling($projectBilling); + } + + return [ + 'cutoff_date' => $cutoffDate, + 'issues' => $issues, + ]; + } + /** * @throws EconomicsException * @throws \Exception @@ -166,30 +185,63 @@ public function createProjectBilling(int $projectBillingId): void continue; } - $clientId = $client->getId(); + $invoiceKey = $client->getId(); + + if (null !== $invoiceKey) { + if ($this->invoiceHelper->getOneInvoicePerIssue()) { + $invoiceKey .= '|||'.$issue->getId(); + } - if (null !== $clientId) { - if (!isset($invoices[$clientId])) { - $invoices[$clientId] = [ + if (!isset($invoices[$invoiceKey])) { + $invoices[$invoiceKey] = [ 'client' => $client, 'issues' => [], ]; } - $invoices[$clientId]['issues'][] = $issue; + $invoices[$invoiceKey]['issues'][] = $issue; } } + $defaultAccount = $this->invoiceHelper->getDefaultAccount(); + $productAccount = $this->invoiceHelper->getProductAccount(); + + // Add invoice entry account prefix, if any, to (product) name. + $prefixWithAccount = function (?string $account, ?string $name): ?string { + if (null === $name) { + return null; + } + + $prefix = $account ? $this->invoiceHelper->getAccountInvoiceEntryPrefix($account) : null; + if (null !== $prefix) { + $name = $prefix.': '.$name; + } + + return $name; + }; + foreach ($invoices as $invoiceArray) { /** @var Client $client */ $client = $invoiceArray['client']; + $invoiceName = sprintf('%s: %s (%s - %s)', + $project->getName() ?? '', + $client->getName() ?? '', + $periodStart->format('d/m/Y'), + $periodEnd->format('d/m/Y') + ); + if ($this->invoiceHelper->getOneInvoicePerIssue()) { + // We know that we have exactly one issue. + $issue = reset($invoiceArray['issues']); + $invoiceName = $issue->getName().': '.$invoiceName; + } + $invoice = new Invoice(); $invoice->setRecorded(false); $invoice->setProject($projectBilling->getProject()); $invoice->setProjectBilling($projectBilling); $invoice->setDescription($projectBilling->getDescription()); - $invoice->setName($project->getName().': '.$client->getName().' ('.$periodStart->format('d/m/Y').' - '.$periodEnd->format('d/m/Y').')'); + $invoice->setName($invoiceName); $invoice->setPeriodFrom($periodStart); $invoice->setPeriodTo($periodEnd); $invoice->setClient($client); @@ -198,7 +250,7 @@ public function createProjectBilling(int $projectBillingId): void // TODO: MaterialNumberEnum::EXTERNAL_WITH_MOMS or MaterialNumberEnum::EXTERNAL_WITHOUT_MOMS? $invoice->setDefaultMaterialNumber($internal ? MaterialNumberEnum::INTERNAL : MaterialNumberEnum::EXTERNAL_WITH_MOMS); - $invoice->setDefaultReceiverAccount($this->invoiceEntryHelper->getDefaultAccount()); + $invoice->setDefaultReceiverAccount($defaultAccount); /** @var Issue $issue */ foreach ($invoiceArray['issues'] as $issue) { @@ -206,7 +258,10 @@ public function createProjectBilling(int $projectBillingId): void $invoiceEntry->setEntryType(InvoiceEntryTypeEnum::WORKLOG); $invoiceEntry->setDescription(''); - $product = $this->getInvoiceEntryProduct($issue); + $product = $prefixWithAccount( + $defaultAccount, + $issue->getName() + ); $price = $this->clientHelper->getStandardPrice($client); $invoiceEntry->setProduct($product); @@ -236,29 +291,26 @@ public function createProjectBilling(int $projectBillingId): void $this->entityManager->persist($invoiceEntry); } - $invoiceEntryProductName = $invoiceEntry->getProduct(); - // Add invoice entries for each product. - foreach ($issue->getProducts() as $productIssue) { - $product = $productIssue->getProduct(); - if (null === $product) { - continue; - } - - $productName = $product->getName() ?? ''; + // Add a single product entry summing all product expenses. + $products = $issue->getProducts(); + if (!$products->isEmpty()) { + $price = $products->reduce(static fn (?float $sum, IssueProduct $product) => $sum + $product->getTotal(), 0.0); + $product = $prefixWithAccount( + $productAccount, + $issue->getName() + ); $productInvoiceEntry = (new InvoiceEntry()) ->setEntryType(InvoiceEntryTypeEnum::PRODUCT) - ->setDescription($productIssue->getDescription()) - ->setProduct(null === $invoiceEntryProductName - ? $productName - : sprintf('%s: %s', $invoiceEntryProductName, $productName) - ) - ->setPrice($product->getPriceAsFloat()) - ->setAmount($productIssue->getQuantity()) - ->setTotalPrice($productIssue->getQuantity() * $product->getPriceAsFloat()) + ->setDescription($issue->getName()) + ->setProduct($product) + ->setPrice($price) + ->setAmount(1) + ->setTotalPrice($price) ->setMaterialNumber($invoice->getDefaultMaterialNumber()) - ->setAccount($this->invoiceEntryHelper->getProductAccount() - ?? $this->invoiceEntryHelper->getDefaultAccount()) - ->addIssueProduct($productIssue); + ->setAccount($productAccount ?? $this->invoiceHelper->getDefaultAccount()); + foreach ($issue->getProducts() as $productIssue) { + $productInvoiceEntry->addIssueProduct($productIssue); + } // We don't add worklogs here, since they're already attached to the main invoice entry // (and only used to detect if an entry has been added to an invoice). @@ -271,6 +323,15 @@ public function createProjectBilling(int $projectBillingId): void continue; } + if ($this->invoiceHelper->getOneInvoicePerIssue() + && $this->invoiceHelper->getSetInvoiceDescriptionFromIssueDescription()) { + // We know that we have exactly one issue. + $issue = reset($invoiceArray['issues']); + if ($description = $this->invoiceHelper->getInvoiceDescription($issue->getDescription())) { + $invoice->setDescription($description); + } + } + $projectBilling->addInvoice($invoice); $this->entityManager->persist($invoice); } diff --git a/src/Twig/Extension/AppExtension.php b/src/Twig/Extension/AppExtension.php new file mode 100644 index 00000000..8574d9cc --- /dev/null +++ b/src/Twig/Extension/AppExtension.php @@ -0,0 +1,22 @@ + ['html']] + // Reference: https://twig.symfony.com/doc/3.x/advanced.html#automatic-escaping + new TwigFilter('format_price', [AppExtensionRuntime::class, 'formatPrice'], ['is_safe' => ['html']]), + new TwigFilter('format_hours', [AppExtensionRuntime::class, 'formatHours'], ['is_safe' => ['html']]), + new TwigFilter('format_quantity', [AppExtensionRuntime::class, 'formatQuantity'], ['is_safe' => ['html']]), + ]; + } +} diff --git a/src/Twig/Runtime/AppExtensionRuntime.php b/src/Twig/Runtime/AppExtensionRuntime.php new file mode 100644 index 00000000..8aa83ac6 --- /dev/null +++ b/src/Twig/Runtime/AppExtensionRuntime.php @@ -0,0 +1,60 @@ +hoursFormatter)) { + $this->hoursFormatter = $this->createNumberFormatter(\NumberFormatter::DECIMAL); + } + + return $this->hoursFormatter->format($value); + } + + public function formatPrice($value) + { + if (!isset($this->priceFormatter)) { + $this->priceFormatter = $this->createNumberFormatter(\NumberFormatter::CURRENCY); + $this->priceFormatter->setSymbol(\NumberFormatter::CURRENCY_SYMBOL, ''); + } + + return $this->priceFormatter->format($value); + } + + public function formatQuantity($value) + { + if (!isset($this->quantityFormatter)) { + $this->quantityFormatter = $this->createNumberFormatter(\NumberFormatter::DECIMAL); + $this->quantityFormatter->setAttribute(\NumberFormatter::MIN_FRACTION_DIGITS, 3); + $this->quantityFormatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, 3); + } + + return $this->quantityFormatter->format($value); + } + + private function createNumberFormatter(int $style): \NumberFormatter + { + $request = $this->requestStack->getCurrentRequest(); + $formatter = new \NumberFormatter($request?->getLocale() ?? 'da', $style); + + // @todo get this from configuration. + $formatter->setAttribute(\NumberFormatter::MIN_FRACTION_DIGITS, 2); + $formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, 2); + + return $formatter; + } +} diff --git a/templates/invoice_entry/edit.html.twig b/templates/invoice_entry/edit.html.twig index 3bc98131..504bfd1a 100644 --- a/templates/invoice_entry/edit.html.twig +++ b/templates/invoice_entry/edit.html.twig @@ -9,7 +9,7 @@
{{ form_widget(form) }} - {% if not invoice.recorded and invoice_entry_helper.isEditable(invoice_entry) %} + {% if not invoice.recorded and invoice_helper.isEntryEditable(invoice_entry) %}
diff --git a/templates/invoices/edit.html.twig b/templates/invoices/edit.html.twig index 6fc36ab6..93ce6bda 100644 --- a/templates/invoices/edit.html.twig +++ b/templates/invoices/edit.html.twig @@ -98,9 +98,9 @@ {{ 'invoices.invoice_entry_receiver_account'|trans }} {{ 'invoices.invoice_entry_material_number'|trans }} {{ 'invoices.invoice_entry_product'|trans }} - {{ 'invoices.invoice_entry_amount'|trans }} - {{ 'invoices.invoice_entry_price'|trans }} - {{ 'invoices.invoice_entry_total_price'|trans }} + {{ 'invoices.invoice_entry_amount'|trans }} + {{ 'invoices.invoice_entry_price'|trans }} + {{ 'invoices.invoice_entry_total_price'|trans }} {{ 'invoices.invoice_entry_type'|trans }} {{ 'invoices.list_actions'|trans }} @@ -113,12 +113,12 @@ } %} {% for index, invoice_entry in invoice.invoiceEntries %} - {{ invoice_entry_helper.getAccountLabel(invoice_entry.account) }} + {{ invoice_helper.getAccountLabel(invoice_entry.account) }} {{ invoice_entry.materialNumber.value ?? '' }} {{ invoice_entry.product }} - {{ invoice_entry.amount }} - {{ invoice_entry.price }} - {{ invoice_entry.totalPrice }} + {{ invoice_entry.amount|format_quantity }} + {{ invoice_entry.price|format_price }} + {{ invoice_entry.totalPrice|format_price }} {{ invoice_entry_type_translations[invoice_entry.entryType.value]|default('') }} @@ -134,9 +134,9 @@ {% elseif invoice.totalPrice %} - {{ invoiceTotalAmount }} + {{ invoiceTotalAmount|format_quantity }} - {{ invoice.totalPrice }} + {{ invoice.totalPrice|format_price }} diff --git a/templates/project_billing/edit.html.twig b/templates/project_billing/edit.html.twig index f646d2f8..6d48bb47 100644 --- a/templates/project_billing/edit.html.twig +++ b/templates/project_billing/edit.html.twig @@ -2,9 +2,44 @@ {% block title %}{{ 'project_billing.edit'|trans }}{% endblock %} +{% macro issue_list(issues) %} + + + + + + + + + + + + {% for index, issue in issues %} + + + + + + + + {% endfor %} + +
{{ 'project_billing.issue.key'|trans }}{{ 'project_billing.issue.name'|trans }}{{ 'project_billing.issue.versions'|trans }}{{ 'project_billing.issue.worklog_time_spent'|trans }}{{ 'project_billing.issue.status'|trans }}
{{ issue.projectTrackerKey }}{{ issue.name }}{{ issue.versions|map(p => p.name)|join(', ') }}{{ issue.worklogs|reduce((carry, v, k) => carry + v.timeSpentSeconds / 3600)|format_hours }}{{ issue.status }}
+ +{% endmacro %} + {% block content %}

{{ 'project_billing.edit'|trans }}

+ {% if issuesInTheFarPast.issues|default([])|length > 0 %} + + {% endif %} + {{ form_start(form) }}
{{ form_rest(form) }} @@ -37,7 +72,7 @@ {{ 'invoices.id'|trans }} {{ 'invoices.name'|trans }} {{ 'invoices.type'|trans }} - {{ 'invoices.list_total_price'|trans }} + {{ 'invoices.list_total_price'|trans }} {{ 'invoices.list_actions'|trans }} @@ -52,7 +87,7 @@ {{ invoice.client.type.value == 'external' ? 'invoices.client_type_external'|trans : '' }} {% endif %} - {{ invoice.totalPrice }} + {{ invoice.totalPrice|format_price }} {{ 'invoices.action_edit'|trans }} @@ -71,28 +106,7 @@ {% include 'components/alert.html.twig' with {level: 'info', text: 'project_billing.issues_not_included_helptext'|trans} %} - - - - - - - - - - - - {% for index, issue in issuesWithoutAccounts %} - - - - - - - - {% endfor %} - -
{{ 'project_billing.issue.key'|trans }}{{ 'project_billing.issue.name'|trans }}{{ 'project_billing.issue.versions'|trans }}{{ 'project_billing.issue.worklog_time_spent'|trans }}{{ 'project_billing.issue.status'|trans }}
{{ issue.projectTrackerKey }}{{ issue.name }}{{ issue.versions|map(p => p.name)|join(', ') }}{{ issue.worklogs|reduce((carry, v, k) => carry + v.timeSpentSeconds / 3600) }}{{ issue.status }}
+ {{ _self.issue_list(issuesWithoutAccounts) }} {% endif %}
@@ -101,4 +115,17 @@ {{ include('project_billing/_delete_form.html.twig') }} {% endif %}
+ + {% if issuesInTheFarPast.issues|default([])|length > 0 %} +
+

{{ 'project_billing.issues_in_the_far_past'|trans }}

+ + {% include 'components/alert.html.twig' with {level: 'warning', text: 'project_billing.issues_in_the_far_past_info'|trans({ + '%count%': issuesInTheFarPast.issues|length, + '%date%': issuesInTheFarPast.cutoff_date|date + })} %} + + {{ _self.issue_list(issuesInTheFarPast.issues) }} +
+ {% endif %} {% endblock %} diff --git a/tests/Service/HtmlHelperTest.php b/tests/Service/HtmlHelperTest.php new file mode 100644 index 00000000..a4303cf3 --- /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.

+ +', + ]; + } +} diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index f235db7e..5049dde2 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -459,6 +459,9 @@ project_billing: on_record_errors_invoice_link: "Link til faktura" action_record_internal: "Eksportér interne" action_record_external: "Eksportér eksterne" + issues_in_the_far_past: 'Opgaver fra fortiden' + issues_in_the_far_past_alert: 'Nogle opgaver er lukket før %date%.' + issues_in_the_far_past_info: '{1}Bemærk: Denne opgave er lukket før %date%.|]1,Inf[Bemærk: Disse opgaver er lukket før %date%.' product: title: "Produkter"