diff --git a/composer.json b/composer.json index ad91a22..2702a2c 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ }, "require": { "php": ">=7.4", + "asika/simple-console": "^1.0", "kwn/number-to-words": "^1.9", "mpdf/mpdf": "^8.0", diff --git a/config/parameters.yaml.dist b/config/parameters.yaml.dist index 2ba4e7d..58610ae 100644 --- a/config/parameters.yaml.dist +++ b/config/parameters.yaml.dist @@ -1,118 +1,24 @@ configuration: - date_format: - source: '' - target: '' locales: source: ru target: en + currency_code: 'USD' images: - signature: '' - stamp: '' - global_substitutions: - # TODO extract to parameters - '%invoice_number%': '999' - '%invoice_date%': '01.01.1970' + signature: 'images/sign.png' + stamp: 'images/stamp.png' + +contract: + # Must be set in format parsable by DateTime + date_from: '01.01.1970' services: - name: - source: 'Работа' - target: 'Work' - amount: 1000 + source: 'Работа на работе по договору от %contract_date% за %period_end_date%' + target: 'Working on the job for the contract from %contract_date% for %period_end_date%' + amount: 9000 units: - source: 'часа' - target: 'hour' - price: 1000 + source: 'часы' + target: 'hours' + price: 100 -translations: - source: - variables: - title: 'Счёт на оплату №%invoice_number% от %invoice_date%' - currency: 'EUR' - rows: - supplier: 'Поставщик' - buyer: 'Покупатель' - # service table - th_number: '№' - th_name: 'Наименование работ, услуг' - th_amount: 'Кол-во' - th_units: 'Ед.' - th_price: 'Цена' - th_sum: 'Сумма' - tf_total: 'Итого' - # bank data - account_data: 'Банковские реквизиты поставщика' - account_number: 'Номер счёта' - bank: 'Банк' - bank_address: 'Адрес' - swift: 'SWIFT' - corr_bank: 'Банк-посредник' - total_to_pay: 'Всего к оплате' - signature: 'Подпись' - supplier: - title: '123456' - short_title: '123' - address: 'some address' - extra: [] - bank: - account: '9999999999999999999' - name: 'Some Bank of some Country' - address: 'Some address' - swift: 'SOMESWIFT' - corr_bank: - name: 'SOME CORR BANK' - swift: 'SOMESWIFT2' - buyer: - title: '"Some Client" LLC' - address: 'Some Address' - extra: - - 'VAT: EU12313123' - - 'IBAN: EU123131231231232112' - static_substitutions: - '%contract_date%': '01.01.1970' - - target: - variables: - title: 'Счёт на оплату №%invoice_number% от %invoice_date%' - currency: 'EUR' - rows: - supplier: 'Поставщик' - buyer: 'Покупатель' - # service table - th_number: '№' - th_name: 'Наименование работ, услуг' - th_amount: 'Кол-во' - th_units: 'Ед.' - th_price: 'Цена' - th_sum: 'Сумма' - tf_total: 'Итого' - # bank data - account_data: 'Банковские реквизиты поставщика' - account_number: 'Номер счёта' - bank: 'Банк' - bank_address: 'Адрес' - swift: 'SWIFT' - corr_bank: 'Банк-посредник' - total_to_pay: 'Всего к оплате' - signature: 'Подпись' - supplier: - title: '123456' - short_title: '123' - address: 'some address' - extra: [] - bank: - account: '9999999999999999999' - name: 'Some Bank of some Country' - address: 'Some address' - swift: 'SOMESWIFT' - corr_bank: - name: 'SOME CORR BANK' - swift: 'SOMESWIFT2' - buyer: - title: '"Some Client" LLC' - address: 'Some Address' - extra: - - 'VAT: EU12313123' - - 'IBAN: EU123131231231232112' - static_substitutions: - '%contract_date%': '01.01.1970' \ No newline at end of file diff --git a/show.php b/show.php index b494d1c..509f510 100644 --- a/show.php +++ b/show.php @@ -3,17 +3,26 @@ require_once __DIR__.'/vendor/autoload.php'; use App\Generator\InvoiceGenerator; +use App\Invoice\InvoiceData; +use App\Kernel; use Mpdf\Mpdf; use Symfony\Component\Yaml\Yaml; -$config = Yaml::parseFile(__DIR__.'/config/parameters.yaml'); +$config = Yaml::parseFile(Kernel::getProjectRoot().'/config/parameters.yaml'); + +$invoice = new InvoiceData( + 123, + new \DateTime(), + new \DateTime($config['contract']['date_from']), + new \DateTime +); $pdf = $_GET['pdf'] ?? false; if ($pdf) { $mpdf = new Mpdf(); - $mpdf->WriteHTML(InvoiceGenerator::generate($config)); + $mpdf->WriteHTML(InvoiceGenerator::generate($invoice, $config)); $mpdf->Output(); } else { - echo InvoiceGenerator::generate($config); + echo InvoiceGenerator::generate($invoice, $config); } diff --git a/src/Generator/InvoiceGenerator.php b/src/Generator/InvoiceGenerator.php index 9f65bfb..1d7a059 100644 --- a/src/Generator/InvoiceGenerator.php +++ b/src/Generator/InvoiceGenerator.php @@ -2,48 +2,32 @@ namespace App\Generator; +use App\Invoice\InvoiceData; use App\Kernel; -use App\Twig\NumberToWordsExtension; -use App\Util\StringReplacer; +use App\Text\PlaceholderProcessor; +use App\Text\Replacer\{LocalizedDateReplacer, StaticReplacer, LocalizedYearMonthReplacer}; +use App\Twig\{NumberToWordsExtension, TextProcessingExtension}; +use Symfony\Component\Yaml\Yaml; use Twig\Environment as Twig; use Twig\Extra\{Html\HtmlExtension, Intl\IntlExtension}; use Twig\Loader\FilesystemLoader; class InvoiceGenerator { - public static function generate(array $config): string + public static function generate(InvoiceData $invoice, array $config): string { - $twig = static::createTwig(); + $locales = $config['configuration']['locales']; - $sourceStaticSubstitutions = array_merge( - $config['translations']['source']['static_substitutions'], - $config['configuration']['global_substitutions'] - ); - $targetStaticSubstitutions = array_merge( - $config['translations']['target']['static_substitutions'], - $config['configuration']['global_substitutions'] - ); + $postprocessor = static::createPostprocessor($invoice); - $source = StringReplacer::recursiveReplace( - $config['translations']['source']['variables'], - $sourceStaticSubstitutions - ); - $target = StringReplacer::recursiveReplace( - $config['translations']['target']['variables'], - $targetStaticSubstitutions - ); + $twig = static::createTwig($postprocessor, $locales['source'], $locales['target']); - // @TODO fix multilingual substitution depending on the context - $services = StringReplacer::recursiveReplace($config['services'], $sourceStaticSubstitutions); $images = static::getImagesContent($config['configuration']['images']); return $twig->render('invoice.html.twig', [ - 'configuration' => $config['configuration'], - 'trans_data' => [ - 'source' => $source, - 'target' => $target, - ], - 'services' => $services, + 'locales' => $locales, + 'currency' => $config['configuration']['currency_code'], + 'services' => $config['services'], 'images' => $images, ]); } @@ -59,15 +43,48 @@ class InvoiceGenerator return $new; } - private static function createTwig(): Twig - { - $loader = new FilesystemLoader(__DIR__.'/../../templates'); + private static function createTwig( + PlaceholderProcessor $placeholderProcessor, + string $sourceLocale, + string $targetLocale + ): Twig { + $loader = new FilesystemLoader(Kernel::getProjectRoot().'/templates'); $twig = new Twig($loader); $twig->addExtension(new NumberToWordsExtension()); $twig->addExtension(new IntlExtension()); $twig->addExtension(new HtmlExtension()); + $localeData = static::loadLocaleData($sourceLocale, $targetLocale); + $twig->addExtension(new TextProcessingExtension($placeholderProcessor, $localeData)); + return $twig; } -} \ No newline at end of file + + private static function createPostprocessor(InvoiceData $invoice): PlaceholderProcessor + { + $processor = new PlaceholderProcessor(); + + $contractDate = new LocalizedDateReplacer($invoice->getContractStartDate()); + $processor->addReplacer('%contract_date%', $contractDate); + + $invoiceNumber = new StaticReplacer($invoice->getNumber()); + $processor->addReplacer('%invoice_number%', $invoiceNumber); + + $invoiceDate = new LocalizedDateReplacer($invoice->getIssueDate()); + $processor->addReplacer('%invoice_date%', $invoiceDate); + + $periodEnd = new LocalizedYearMonthReplacer($invoice->getAccountedMonthDate()); + $processor->addReplacer('%period_end_date%', $periodEnd); + + return $processor; + } + + private static function loadLocaleData(string $source, string $target): array + { + return [ + $source => Yaml::parseFile(Kernel::getProjectRoot().'/translation/source.yaml'), + $target => Yaml::parseFile(Kernel::getProjectRoot().'/translation/target.yaml'), + ]; + } +} diff --git a/src/Invoice/InvoiceData.php b/src/Invoice/InvoiceData.php new file mode 100644 index 0000000..32b98a8 --- /dev/null +++ b/src/Invoice/InvoiceData.php @@ -0,0 +1,43 @@ +number = $number; + $this->issueDate = $issueDate; + $this->contractStartDate = $contractStartDate; + $this->accountedMonthDate = $accountedMonthDate; + } + + public function getNumber(): int + { + return $this->number; + } + + public function getIssueDate(): \DateTime + { + return $this->issueDate; + } + + public function getContractStartDate(): \DateTime + { + return $this->contractStartDate; + } + + public function getAccountedMonthDate(): \DateTime + { + return $this->accountedMonthDate; + } +} diff --git a/src/Text/PlaceholderProcessor.php b/src/Text/PlaceholderProcessor.php new file mode 100644 index 0000000..1a01a4a --- /dev/null +++ b/src/Text/PlaceholderProcessor.php @@ -0,0 +1,49 @@ +replacers = $replacers; + } + + public function addReplacer(string $placeholder, ReplacerInterface $replacer): void + { + if (array_key_exists($placeholder, $this->replacers)) { + throw new \RuntimeException('Handler for \''.$placeholder.'\' already exists.'); + } + + $this->replacers[$placeholder] = $replacer; + } + + public function process( + string $string, + string $locale + ): string { + foreach ($this->replacers as $placeholder => $replacer) { + if (!isset($this->replaceCache[$locale][$placeholder])) { + $this->replaceCache[$locale][$placeholder] = $replacer->generateReplace($locale); + } + + $string = str_replace( + $placeholder, + $this->replaceCache[$locale][$placeholder], + $string + ); + } + + return $string; + } +} diff --git a/src/Text/Replacer/LocalizedDateReplacer.php b/src/Text/Replacer/LocalizedDateReplacer.php new file mode 100644 index 0000000..8586a11 --- /dev/null +++ b/src/Text/Replacer/LocalizedDateReplacer.php @@ -0,0 +1,21 @@ +date = $date ?? new \DateTime(); + } + + public function generateReplace(string $locale): string + { + $formatter = new \IntlDateFormatter($locale, \IntlDateFormatter::LONG, \IntlDateFormatter::LONG); + $formatter->setPattern('d MMMM yyyy'); + + return $formatter->format($this->date); + } +} diff --git a/src/Text/Replacer/LocalizedYearMonthReplacer.php b/src/Text/Replacer/LocalizedYearMonthReplacer.php new file mode 100644 index 0000000..d00afd9 --- /dev/null +++ b/src/Text/Replacer/LocalizedYearMonthReplacer.php @@ -0,0 +1,21 @@ +date = $dateInMonth ?? new \DateTime(); + } + + public function generateReplace(string $locale): string + { + $formatter = new \IntlDateFormatter($locale, \IntlDateFormatter::LONG, \IntlDateFormatter::LONG); + $formatter->setPattern('LLLL yyyy'); + + return $formatter->format($this->date); + } +} diff --git a/src/Text/Replacer/ReplacerInterface.php b/src/Text/Replacer/ReplacerInterface.php new file mode 100644 index 0000000..deeaee1 --- /dev/null +++ b/src/Text/Replacer/ReplacerInterface.php @@ -0,0 +1,8 @@ +replace = $replace; + } + + public function generateReplace(string $locale): string + { + return $this->replace; + } +} diff --git a/src/Twig/TextProcessingExtension.php b/src/Twig/TextProcessingExtension.php new file mode 100644 index 0000000..4fbe53b --- /dev/null +++ b/src/Twig/TextProcessingExtension.php @@ -0,0 +1,52 @@ + ['find' => 'replace']] + */ + public function __construct(PlaceholderProcessor $replacer, array $translationsByLocale) + { + $this->replacer = $replacer; + $this->translationsByLocale = $translationsByLocale; + } + + + public function getFilters(): array + { + return [ + new TwigFilter('trans', [$this, 'translate']), + new TwigFilter('process', [$this, 'process']), + ]; + } + + /** Translates the message if possible or returns original text. */ + public function translate(string $message, string $locale, bool $postprocess = false): string + { + if (!isset($this->translationsByLocale[$locale][$message])) { + return $message; + } + + $text = $this->translationsByLocale[$locale][$message]; + + if ($postprocess) { + $text = $this->replacer->process($text, $locale); + } + + return $text; + } + + public function process(string $message, string $locale): string + { + return $this->replacer->process($message, $locale); + } +} diff --git a/src/Util/StringReplacer.php b/src/Util/StringReplacer.php deleted file mode 100644 index ee3fdb5..0000000 --- a/src/Util/StringReplacer.php +++ /dev/null @@ -1,54 +0,0 @@ - &$value) { - if (is_string($value)) { - $value = static::replaceString($value, $staticReplaces); - } elseif (is_int($value)) { - continue; - } elseif (is_array($value)) { - $value = static::recursiveReplace($value, $staticReplaces); - } else { - throw new \InvalidArgumentException(sprintf( - 'Invalid value. string/array allowed, %s (%s) given.', - gettype($value), - print_r($value, true) - )); - } - } - - return $variables; - } - - private static function replaceString( - string $string, - array $staticReplaces - ): string { - // Process static replaces - $string = str_replace( - array_keys($staticReplaces), - array_values($staticReplaces), - $string - ); - - // TBI - // ... - - return $string; - } -} diff --git a/templates/invoice.html.twig b/templates/invoice.html.twig index 6ea5347..4f7196b 100644 --- a/templates/invoice.html.twig +++ b/templates/invoice.html.twig @@ -17,20 +17,20 @@ {{ include ('invoice_side.html.twig', { - t: trans_data.source, services: services, images: images, context: 'source', - locale: configuration.locales.source + locale: locales.source, + currency: currency }, with_context = false) }} {{ include ('invoice_side.html.twig', { - t: trans_data.target, services: services, images: images, context: 'target', - locale: configuration.locales.target + locale: locales.target, + currency: currency }, with_context = false) }} diff --git a/templates/invoice_side.html.twig b/templates/invoice_side.html.twig index 6759604..dc1b928 100644 --- a/templates/invoice_side.html.twig +++ b/templates/invoice_side.html.twig @@ -1,4 +1,4 @@ -

{{ t.title }}

+

{{ 'label_title'|trans(locale, true) }}

@@ -6,36 +6,34 @@ - - + + - + - - + + - + - {% for item in t.buyer.extra %} - + - {% endfor %}
 
{{ t.rows.supplier }}:{{ t.supplier.title }}{{ 'label_supplier'|trans(locale) }}:{{ 'supplier_title'|trans(locale) }}
{{ t.supplier.address }}{{ 'supplier_address'|trans(locale) }}
{{ t.rows.buyer }}:{{ t.buyer.title }}{{ 'label_buyer'|trans(locale) }}:{{ 'buyer_title'|trans(locale) }}
{{ t.buyer.address }}{{ 'buyer_address'|trans(locale) }}
{{ item }}{{ 'buyer_extra'|trans(locale)|nl2br }}
- - - - - - + + + + + + @@ -43,20 +41,20 @@ {% for service in services %} - + - + {% set sum = service.amount * service.price %} - + {% set total = total + sum %} {% endfor %} - - + +
{{ t.rows.th_number }}{{ t.rows.th_name }}{{ t.rows.th_amount }}{{ t.rows.th_units }}{{ t.rows.th_price }}{{ t.rows.th_sum }}{{ 'label_th_number'|trans(locale) }}{{ 'label_th_name'|trans(locale) }}{{ 'label_th_amount'|trans(locale) }}{{ 'label_th_units'|trans(locale) }}{{ 'label_th_price'|trans(locale) }}{{ 'label_th_sum'|trans(locale) }}
{{ loop.index }}{{ service.name[context] }}{{ service.name[context]|process(locale) }} {{ service.amount }} {{ service.units[context] }}{{ service.amount }} {{ t.currency }}{{ service.price }} {{ currency }}{{ sum }} {{ t.currency }}{{ sum }} {{ currency }}
{{ t.rows.tf_total }}{{ total }} {{ t.currency }}{{ 'label_tf_total'|trans(locale) }}{{ total }} {{ currency }}
@@ -65,7 +63,7 @@
- {{ t.rows.total_to_pay }}: {{ total|ntw(locale) }} {{ t.currency|currency_name(locale) }}. + {{ 'label_total_to_pay'|trans(locale) }}: {{ total|ntw(locale) }} {{ currency|currency_name(locale) }}.
@@ -73,33 +71,33 @@ - + - - + + - + - + - - + + - - + + - - + + - - + + @@ -111,7 +109,7 @@ - + diff --git a/translation/.gitignore b/translation/.gitignore new file mode 100644 index 0000000..6d2c4b4 --- /dev/null +++ b/translation/.gitignore @@ -0,0 +1,2 @@ +*.yaml +!*.yaml.dist diff --git a/translation/source.yaml.dist b/translation/source.yaml.dist new file mode 100644 index 0000000..32ccdec --- /dev/null +++ b/translation/source.yaml.dist @@ -0,0 +1,41 @@ +# Labels +label_title: 'Счёт на оплату №%invoice_number% от %invoice_date%' +label_supplier: 'Поставщик' +label_buyer: 'Покупатель' +label_th_number: '№' +label_th_name: 'Наименование работ, услуг' +label_th_amount: 'Кол-во' +label_th_units: 'Ед.' +label_th_price: 'Цена' +label_th_sum: 'Сумма' +label_tf_total: 'Итого' +label_total_to_pay: 'Всего к оплате' +label_supplier_account_data: 'Банковские реквизиты поставщика' +label_supplier_account_number: 'Номер счёта' +label_supplier_bank: 'Банк' +label_supplier_bank_address: 'Адрес' +label_swift: 'SWIFT' +label_supplier_corr_bank: 'Банк-посредник' +label_signature: 'Подпись' + +# Generic data + +# Supplier data +supplier_title: 'ИП/ООО XXX' +supplier_short_title: 'ИП/ООО XXX' +supplier_address: 'Россия, г. Мухосранск, ул. Узкая 10' +#supplier_extra: '' +supplier_bank_account: '1234567890123456' +supplier_bank_name: 'BANK OF MOTHER RUSSIA' +supplier_bank_address: 'KREMLIN, RUSSIA' +supplier_bank_swift: 'COOLSWIFT' +supplier_bank_corr_bank_name: 'SOME CORRESPONDENT BANK' +supplier_bank_corr_bank_swift: 'SADSWIFT' + +# Buyer data +buyer_title: '"Buyer" LLC' +buyer_address: 'Washington DC, USA' +buyer_extra: "VAT: US12312312312\n +IBAN: US1234567890123456" + + diff --git a/translation/target.yaml.dist b/translation/target.yaml.dist new file mode 100644 index 0000000..89d556d --- /dev/null +++ b/translation/target.yaml.dist @@ -0,0 +1,39 @@ +# Labels +label_title: 'Invoice %invoice_number% for %invoice_date%' +label_supplier: 'Supplier' +label_buyer: 'Buyer' +label_th_number: '#' +label_th_name: 'Service' +label_th_amount: 'Amount' +label_th_units: 'Units' +label_th_price: 'Price' +label_th_sum: 'Sum' +label_tf_total: 'Total' +label_total_to_pay: 'Total to pay' +label_supplier_account_data: 'Supplier bank account' +label_supplier_account_number: 'Account number' +label_supplier_bank: 'Beneficiary bank' +label_supplier_bank_address: 'Bank address' +label_swift: 'SWIFT' +label_supplier_corr_bank: 'Correspondent Bank' +label_signature: 'Подпис' + +# Generic data + +# Supplier data +supplier_title: 'IP/LLC XXX' +supplier_short_title: 'IP/LLC XXX' +supplier_address: 'Russia, Mukhosransk city, Uzkaya st. 10' +#supplier_extra: '' +supplier_bank_account: '1234567890123456' +supplier_bank_name: 'BANK OF MOTHER RUSSIA' +supplier_bank_address: 'KREMLIN, RUSSIA' +supplier_bank_swift: 'COOLSWIFT' +supplier_bank_corr_bank_name: 'SOME CORRESPONDENT BANK' +supplier_bank_corr_bank_swift: 'SADSWIFT' + +# Buyer data +buyer_title: '"Buyer" LLC' +buyer_address: 'Washington DC, USA' +buyer_extra: "VAT: US12312312312\n +IBAN: US1234567890123456"
{{ t.rows.account_data }}:{{ 'label_supplier_account_data'|trans(locale) }}:
{{ t.rows.account_number }}{{ t.supplier.bank.account }}{{ 'label_supplier_account_number'|trans(locale) }}{{ 'supplier_bank_account'|trans(locale) }}
{{ t.rows.bank }}{{ 'label_supplier_bank'|trans(locale) }}
{{ t.supplier.bank.name }}{{ 'supplier_bank_name'|trans(locale) }}
{{ t.rows.bank_address }}{{ t.supplier.bank.address }}{{ 'label_supplier_bank_address'|trans(locale) }}{{ 'supplier_bank_address'|trans(locale) }}
{{ t.rows.swift }}{{ t.supplier.bank.swift }}{{ 'label_swift'|trans(locale) }}{{ 'supplier_bank_swift'|trans(locale) }}
{{ t.rows.corr_bank }}{{ t.supplier.bank.corr_bank.name }}{{ 'label_supplier_corr_bank'|trans(locale) }}{{ 'supplier_bank_corr_bank_name'|trans(locale) }}
{{ t.rows.swift }}{{ t.supplier.bank.corr_bank.swift }}{{ 'label_swift'|trans(locale) }}{{ 'supplier_bank_corr_bank_swift'|trans(locale) }}
  
{{ t.rows.signature }}{{ 'label_signature'|trans(locale) }} Signature