Finishing first version draft. Entrypoints still needs to be properly implemented.

This commit is contained in:
Alexey Skobkin 2020-02-01 02:26:07 +03:00
parent a5603b7724
commit edf24cd480
No known key found for this signature in database
GPG key ID: 5D5CEF6F221278E7
17 changed files with 407 additions and 236 deletions

View file

@ -15,6 +15,7 @@
},
"require": {
"php": ">=7.4",
"asika/simple-console": "^1.0",
"kwn/number-to-words": "^1.9",
"mpdf/mpdf": "^8.0",

View file

@ -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'

View file

@ -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);
}

View file

@ -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;
}
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'),
];
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Invoice;
class InvoiceData
{
private int $number;
private \DateTime $issueDate;
private \DateTime $contractStartDate;
private \DateTime $accountedMonthDate;
public function __construct(
int $number,
\DateTime $issueDate,
\DateTime $contractStartDate,
\DateTime $accountedMonthDate
) {
$this->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;
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Text;
use App\Text\Replacer\ReplacerInterface;
class PlaceholderProcessor
{
/** @var ReplacerInterface[] */
private array $replacers = [];
private array $replaceCache = [];
/**
* @param array|callable[] $replacers
*/
public function __construct(array $replacers = [])
{
$this->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;
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Text\Replacer;
class LocalizedDateReplacer implements ReplacerInterface
{
private \DateTime $date;
public function __construct(\DateTime $date = null)
{
$this->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);
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Text\Replacer;
class LocalizedYearMonthReplacer implements ReplacerInterface
{
private \DateTime $date;
public function __construct(\DateTime $dateInMonth = null)
{
$this->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);
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Text\Replacer;
interface ReplacerInterface
{
public function generateReplace(string $locale): string;
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Text\Replacer;
class StaticReplacer implements ReplacerInterface
{
private string $replace;
public function __construct(string $replace)
{
$this->replace = $replace;
}
public function generateReplace(string $locale): string
{
return $this->replace;
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Twig;
use App\Text\PlaceholderProcessor;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class TextProcessingExtension extends AbstractExtension
{
private PlaceholderProcessor $replacer;
private array $translationsByLocale = [];
/**
* @param array $translationsByLocale ['en' => ['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);
}
}

View file

@ -1,54 +0,0 @@
<?php
namespace App\Util;
class StringReplacer
{
/**
* @param array $variables
* @param string[]|array $staticReplaces
* @param callable[]|array $dynamicReplaces
*
* @return array
*/
public static function recursiveReplace(
array $variables,
array $staticReplaces = [],
array $dynamicReplaces = []
): array {
foreach ($variables as $key => &$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;
}
}

View file

@ -17,20 +17,20 @@
<tr>
<td width="50%">
{{ 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) }}
</td>
<td width="50%">
{{ 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) }}
</td>
</tr>

View file

@ -1,4 +1,4 @@
<h2 class="title">{{ t.title }}</h2>
<h2 class="title">{{ 'label_title'|trans(locale, true) }}</h2>
<!-- Party attributes -->
<table class="bordered fat">
@ -6,36 +6,34 @@
<td colspan="3">&nbsp;</td>
</tr>
<tr>
<td rowspan="2">{{ t.rows.supplier }}:</td>
<td colspan="2">{{ t.supplier.title }}</td>
<td rowspan="2">{{ 'label_supplier'|trans(locale) }}:</td>
<td colspan="2">{{ 'supplier_title'|trans(locale) }}</td>
</tr>
<tr>
<td colspan="2">{{ t.supplier.address }}</td>
<td colspan="2">{{ 'supplier_address'|trans(locale) }}</td>
</tr>
<tr>
<td rowspan="{{ 2 + t.buyer.extra|length }}">{{ t.rows.buyer }}:</td>
<td colspan="2">{{ t.buyer.title }}</td>
<td rowspan="3">{{ 'label_buyer'|trans(locale) }}:</td>
<td colspan="2">{{ 'buyer_title'|trans(locale) }}</td>
</tr>
<tr>
<td colspan="2">{{ t.buyer.address }}</td>
<td colspan="2">{{ 'buyer_address'|trans(locale) }}</td>
</tr>
{% for item in t.buyer.extra %}
<tr>
<td colspan="2">{{ item }}</td>
<td colspan="2">{{ 'buyer_extra'|trans(locale)|nl2br }}</td>
</tr>
{% endfor %}
</table>
<!-- Services table -->
<table class="bordered bordered-fully fat">
<thead>
<tr>
<th>{{ t.rows.th_number }}</th>
<th>{{ t.rows.th_name }}</th>
<th>{{ t.rows.th_amount }}</th>
<th>{{ t.rows.th_units }}</th>
<th>{{ t.rows.th_price }}</th>
<th>{{ t.rows.th_sum }}</th>
<th>{{ 'label_th_number'|trans(locale) }}</th>
<th>{{ 'label_th_name'|trans(locale) }}</th>
<th>{{ 'label_th_amount'|trans(locale) }}</th>
<th>{{ 'label_th_units'|trans(locale) }}</th>
<th>{{ 'label_th_price'|trans(locale) }}</th>
<th>{{ 'label_th_sum'|trans(locale) }}</th>
</tr>
</thead>
<tbody>
@ -43,20 +41,20 @@
{% for service in services %}
<tr>
<td>{{ loop.index }}</td>
<td width="60%">{{ service.name[context] }}</td>
<td width="60%">{{ service.name[context]|process(locale) }}</td>
<td>{{ service.amount }}</td>
<td>{{ service.units[context] }}</td>
<td>{{ service.amount }} {{ t.currency }}</td>
<td>{{ service.price }} {{ currency }}</td>
{% set sum = service.amount * service.price %}
<td>{{ sum }} {{ t.currency }}</td>
<td>{{ sum }} {{ currency }}</td>
</tr>
{% set total = total + sum %}
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="5" align="right">{{ t.rows.tf_total }}</td>
<td>{{ total }} {{ t.currency }}</td>
<td colspan="5" align="right">{{ 'label_tf_total'|trans(locale) }}</td>
<td>{{ total }} {{ currency }}</td>
</tr>
</tfoot>
</table>
@ -65,7 +63,7 @@
<table class="fat">
<tr>
<td colspan="3">
{{ 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) }}.
</td>
</tr>
</table>
@ -73,33 +71,33 @@
<!-- Bank account data -->
<table id="bank-details" class="fat">
<tr class="bordered">
<td colspan="3"><strong>{{ t.rows.account_data }}:</strong></td>
<td colspan="3"><strong>{{ 'label_supplier_account_data'|trans(locale) }}:</strong></td>
</tr>
<tr>
<td>{{ t.rows.account_number }}</td>
<td colspan="2">{{ t.supplier.bank.account }}</td>
<td>{{ 'label_supplier_account_number'|trans(locale) }}</td>
<td colspan="2">{{ 'supplier_bank_account'|trans(locale) }}</td>
</tr>
<tr>
<td colspan="3">{{ t.rows.bank }}</td>
<td colspan="3">{{ 'label_supplier_bank'|trans(locale) }}</td>
</tr>
<tr>
<td colspan="3">{{ t.supplier.bank.name }}</td>
<td colspan="3">{{ 'supplier_bank_name'|trans(locale) }}</td>
</tr>
<tr>
<td>{{ t.rows.bank_address }}</td>
<td colspan="2">{{ t.supplier.bank.address }}</td>
<td>{{ 'label_supplier_bank_address'|trans(locale) }}</td>
<td colspan="2">{{ 'supplier_bank_address'|trans(locale) }}</td>
</tr>
<tr>
<td>{{ t.rows.swift }}</td>
<td colspan="2">{{ t.supplier.bank.swift }}</td>
<td>{{ 'label_swift'|trans(locale) }}</td>
<td colspan="2">{{ 'supplier_bank_swift'|trans(locale) }}</td>
</tr>
<tr>
<td><strong>{{ t.rows.corr_bank }}</strong></td>
<td colspan="2">{{ t.supplier.bank.corr_bank.name }}</td>
<td><strong>{{ 'label_supplier_corr_bank'|trans(locale) }}</strong></td>
<td colspan="2">{{ 'supplier_bank_corr_bank_name'|trans(locale) }}</td>
</tr>
<tr>
<td>{{ t.rows.swift }}</td>
<td colspan="2">{{ t.supplier.bank.corr_bank.swift }}</td>
<td>{{ 'label_swift'|trans(locale) }}</td>
<td colspan="2">{{ 'supplier_bank_corr_bank_swift'|trans(locale) }}</td>
</tr>
<tr>
<td colspan="3">&nbsp;</td>
@ -111,7 +109,7 @@
<td colspan="3">&nbsp;</td>
</tr>
<tr>
<td height="50px">{{ t.rows.signature }}</td>
<td height="50px">{{ 'label_signature'|trans(locale) }}</td>
<td colspan="2" valign="bottom">
<img src="{{ images.signature|data_uri(mime='image/png') }}" alt="Signature">
</td>

2
translation/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*.yaml
!*.yaml.dist

View file

@ -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"

View file

@ -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"