diff --git a/.gitignore b/.gitignore index a356abd5c..978a4f8a5 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,6 @@ js_dist /compose.override.yml /composer.phar /data +drivers +chromedriver.log diff --git a/Makefile b/Makefile index 16ad02938..8142974e9 100644 --- a/Makefile +++ b/Makefile @@ -106,6 +106,7 @@ test-functional: data config htdocs/uploads tmp $(DOCKER_COMP) stop dbtest apachephptest mailcatcher $(DOCKER_COMP) up -d dbtest apachephptest mailcatcher make clean-test-deprecated-log + $(DOCKER_COMP) run --no-deps --rm -u localUser apachephp ./bin/bdi detect drivers $(DOCKER_COMP) run --no-deps --rm -u localUser apachephp ./bin/behat make var/logs/test.deprecations_grouped.log $(DOCKER_COMP) stop dbtest apachephptest mailcatcher diff --git a/app/config/routing/admin_accounting.yml b/app/config/routing/admin_accounting.yml index b9c022566..9cd57d8b2 100644 --- a/app/config/routing/admin_accounting.yml +++ b/app/config/routing/admin_accounting.yml @@ -14,6 +14,14 @@ admin_accounting_quotations_list: path: /quotations/list defaults: {_controller: AppBundle\Controller\Admin\Accounting\Quotation\ListQuotationAction} +admin_accounting_quotations_add: + path: /quotations/add + defaults: {_controller: AppBundle\Controller\Admin\Accounting\Quotation\AddQuotationAction} + +admin_accounting_quotations_edit: + path: /quotations/edit + defaults: {_controller: AppBundle\Controller\Admin\Accounting\Quotation\EditQuotationAction} + admin_accounting_quotations_download: path: /quotations/download defaults: {_controller: AppBundle\Controller\Admin\Accounting\Quotation\DownloadQuotationAction} diff --git a/behat.yml b/behat.yml index 308835e10..4b5741532 100644 --- a/behat.yml +++ b/behat.yml @@ -9,10 +9,34 @@ default: - FeatureContext - Behat\MinkExtension\Context\MinkContext extensions: + Robertfausk\Behat\PantherExtension: ~ Behat\MinkExtension: base_url: 'https://apachephptest:80' files_path: '%paths.base%/tests/behat/files' - browserkit_http: - http_client_parameters: - verify_peer: false - verify_host: false + default_session: browserkit_http + javascript_session: panther + sessions: + browserkit_http: + browserkit_http: + http_client_parameters: + verify_peer: false + verify_host: false + panther: + panther: + options: + browser: 'chrome' + webServerDir: '%paths.base%/htdocs' + external_base_uri: 'https://apachephptest:80' + manager_options: + chromedriver_arguments: + - '--headless' + - '--disable-gpu' + - '--no-sandbox' + - '--disable-dev-shm-usage' + - '--disable-extensions' + - '--log-path=/var/www/html/chromedriver.log' + capabilities: + goog:chromeOptions: + args: + - --ignore-certificate-errors + external_base_uri: 'https://apachephptest:80' diff --git a/compose.yml b/compose.yml index 3b0990e04..d5831cfd0 100644 --- a/compose.yml +++ b/compose.yml @@ -35,6 +35,8 @@ services: SYMFONY_ENV: "dev" HOST_PWD: ${PWD} SYMFONY_IDE: "%env(IDE_USED)%://open?url=file://%%f&line=%%l&/var/www/html/>%env(HOST_PWD)%/" + PANTHER_NO_SANDBOX: 1 + PANTHER_CHROME_ARGUMENTS: '--disable-dev-shm-usage --disable-features=IsolateOrigins,site-per-process,TrackingProtection3pcd' env_file: .env volumes: diff --git a/composer.json b/composer.json index e94f8b041..a55c61c1d 100644 --- a/composer.json +++ b/composer.json @@ -133,6 +133,7 @@ "require-dev": { "behat/behat": "^3.15", "behat/mink-browserkit-driver": "^2.2", + "dbrekelmans/bdi": "^1.4", "fakerphp/faker": "^1.24", "friends-of-behat/mink-extension": "^2.7", "friendsofphp/php-cs-fixer": "^3.75", @@ -144,6 +145,7 @@ "phpstan/phpstan-symfony": "^2.0", "phpunit/phpunit": "11.*", "rector/rector": "^2.0", + "robertfausk/behat-panther-extension": "^1.2", "smalot/pdfparser": "^0.19.0", "symfony/debug-bundle": "7.3.*", "symfony/json-path": "7.3.*", diff --git a/composer.lock b/composer.lock index 2bf8f5166..5416be2b0 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": "68baf43f99dadc0cef165264170e6131", + "content-hash": "32e08d35f61ea1d7cc3c74f47754e7e7", "packages": [ { "name": "algolia/algoliasearch-client-php", @@ -10288,6 +10288,55 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "dbrekelmans/bdi", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/dbrekelmans/bdi.git", + "reference": "c2b77127d7aa3fad25d57575c207b54b108ab300" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dbrekelmans/bdi/zipball/c2b77127d7aa3fad25d57575c207b54b108ab300", + "reference": "c2b77127d7aa3fad25d57575c207b54b108ab300", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-zip": "*", + "ext-zlib": "*", + "php": "^8.1" + }, + "bin": [ + "bdi", + "bdi.phar" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniël Brekelmans", + "homepage": "https://github.com/dbrekelmans" + }, + { + "name": "Contributors", + "homepage": "https://github.com/dbrekelmans/bdi/graphs/contributors" + } + ], + "description": "PHAR distribution of dbrekelmans/browser-driver-installer.", + "homepage": "https://github.com/dbrekelmans/bdi", + "keywords": [ + "browser-driver-installer" + ], + "support": { + "source": "https://github.com/dbrekelmans/bdi/tree/1.4.1" + }, + "time": "2025-11-03T11:32:28+00:00" + }, { "name": "evenement/evenement", "version": "v3.0.2", @@ -11132,6 +11181,72 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "php-webdriver/webdriver", + "version": "1.15.2", + "source": { + "type": "git", + "url": "https://github.com/php-webdriver/php-webdriver.git", + "reference": "998e499b786805568deaf8cbf06f4044f05d91bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/998e499b786805568deaf8cbf06f4044f05d91bf", + "reference": "998e499b786805568deaf8cbf06f4044f05d91bf", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-zip": "*", + "php": "^7.3 || ^8.0", + "symfony/polyfill-mbstring": "^1.12", + "symfony/process": "^5.0 || ^6.0 || ^7.0" + }, + "replace": { + "facebook/webdriver": "*" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.20.0", + "ondram/ci-detector": "^4.0", + "php-coveralls/php-coveralls": "^2.4", + "php-mock/php-mock-phpunit": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "^3.5", + "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "ext-SimpleXML": "For Firefox profile creation" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Exception/TimeoutException.php" + ], + "psr-4": { + "Facebook\\WebDriver\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", + "homepage": "https://github.com/php-webdriver/php-webdriver", + "keywords": [ + "Chromedriver", + "geckodriver", + "php", + "selenium", + "webdriver" + ], + "support": { + "issues": "https://github.com/php-webdriver/php-webdriver/issues", + "source": "https://github.com/php-webdriver/php-webdriver/tree/1.15.2" + }, + "time": "2024-11-21T15:12:59+00:00" + }, { "name": "phpstan/extension-installer", "version": "1.4.3", @@ -12383,6 +12498,171 @@ ], "time": "2025-10-11T21:50:23+00:00" }, + { + "name": "robertfausk/behat-panther-extension", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/robertfausk/behat-panther-extension.git", + "reference": "838984a60cd53d950382bee321f8b670c3a5d120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/robertfausk/behat-panther-extension/zipball/838984a60cd53d950382bee321f8b670c3a5d120", + "reference": "838984a60cd53d950382bee321f8b670c3a5d120", + "shasum": "" + }, + "require": { + "behat/behat": "^3.0.5", + "friends-of-behat/mink-extension": "^2.3.0", + "php": ">=7.2", + "robertfausk/mink-panther-driver": "^1.0", + "symfony/config": "^3.4|^4.0|^5.0|^6.0|^7.0" + }, + "require-dev": { + "g1a/composer-test-scenarios": "^3.0", + "matthiasnoback/symfony-config-test": "^4.1|^5.1", + "phpunit/phpunit": "~7.5|~9.3", + "roave/security-advisories": "dev-master" + }, + "type": "behat-extension", + "extra": { + "scenarios": { + "symfony3": { + "require": { + "symfony/config": "^3.4" + } + }, + "symfony4": { + "require": { + "symfony/config": "^4.0" + } + }, + "symfony5": { + "require": { + "symfony/config": "^5.0" + } + }, + "symfony6": { + "require": { + "symfony/config": "^6.0" + } + }, + "symfony7": { + "require": { + "symfony/config": "^7.0" + } + } + }, + "branch-alias": { + "dev-main": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Robertfausk\\Behat\\PantherExtension\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Robert Freigang", + "email": "robertfreigang@gmx.de" + } + ], + "description": "Symfony Panther extension for Behat", + "keywords": [ + "Behat", + "Cucumber", + "Panther", + "browser", + "chrome", + "firefox", + "gherkin", + "gui", + "symfony", + "test", + "web" + ], + "support": { + "issues": "https://github.com/robertfausk/behat-panther-extension/issues", + "source": "https://github.com/robertfausk/behat-panther-extension/tree/v1.2.0" + }, + "time": "2025-04-04T10:09:14+00:00" + }, + { + "name": "robertfausk/mink-panther-driver", + "version": "v1.1.2", + "source": { + "type": "git", + "url": "https://github.com/robertfausk/mink-panther-driver.git", + "reference": "ac95116505015a43af687220a8e00cefabd34dc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/robertfausk/mink-panther-driver/zipball/ac95116505015a43af687220a8e00cefabd34dc0", + "reference": "ac95116505015a43af687220a8e00cefabd34dc0", + "shasum": "" + }, + "require": { + "behat/mink": "~1.8", + "ext-dom": "*", + "php": ">=7.2", + "symfony/panther": "~0.7|~1.0|~2.0" + }, + "require-dev": { + "dbrekelmans/bdi": "^1.0", + "mink/driver-testsuite": "dev-master", + "phpunit/phpunit": "~8.5|~9.3", + "symfony/dom-crawler": "~4.0|~5.0|~6.0", + "symfony/http-kernel": "~4.0|~5.0|~6.0" + }, + "suggest": { + "ext-gd": "*" + }, + "type": "mink-driver", + "extra": { + "branch-alias": { + "dev-main": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Mink\\Driver\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Robert Freigang", + "email": "robertfreigang@gmx.de" + } + ], + "description": "Symfony Panther driver for Mink framework", + "homepage": "http://mink.behat.org/", + "keywords": [ + "Mink", + "Panther", + "browser", + "chrome", + "chromium", + "firefox", + "headless", + "symfony", + "testing" + ], + "support": { + "issues": "https://github.com/robertfausk/mink-panther-driver/issues", + "source": "https://github.com/robertfausk/mink-panther-driver/tree/v1.1.2" + }, + "time": "2025-04-03T21:55:18+00:00" + }, { "name": "sebastian/cli-parser", "version": "3.0.2", @@ -13831,6 +14111,95 @@ ], "time": "2025-09-27T15:48:31+00:00" }, + { + "name": "symfony/panther", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/panther.git", + "reference": "7d96ff386394ffc02ff320253e7fb6585e3cb76e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/panther/zipball/7d96ff386394ffc02ff320253e7fb6585e3cb76e", + "reference": "7d96ff386394ffc02ff320253e7fb6585e3cb76e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": ">=8.1", + "php-webdriver/webdriver": "^1.8.2", + "symfony/browser-kit": "^6.4 || ^7.3 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.3 || ^8.0", + "symfony/deprecation-contracts": "^2.4 || ^3", + "symfony/dom-crawler": "^6.4 || ^7.3 || ^8.0", + "symfony/http-client": "^6.4 || ^7.0", + "symfony/http-kernel": "^6.4 || ^7.3 || ^8.0", + "symfony/process": "^6.4 || ^7.3 || ^8.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4 || ^7.3 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.3 || ^8.0", + "symfony/mime": "^6.4 || ^7.3 || ^8.0", + "symfony/phpunit-bridge": ">=7.3.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Panther\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.dev", + "homepage": "https://dunglas.dev" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A browser testing and web scraping library for PHP and Symfony.", + "homepage": "https://symfony.com/packages/Panther", + "keywords": [ + "e2e", + "scraping", + "selenium", + "symfony", + "testing", + "webdriver" + ], + "support": { + "issues": "https://github.com/symfony/panther/issues", + "source": "https://github.com/symfony/panther/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://www.panthera.org/donate", + "type": "custom" + }, + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/panther", + "type": "tidelift" + } + ], + "time": "2025-11-21T14:10:51+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.33.0", diff --git a/docker/dockerfiles/apachephp/Dockerfile b/docker/dockerfiles/apachephp/Dockerfile index 1a861a74a..b549cd5fc 100644 --- a/docker/dockerfiles/apachephp/Dockerfile +++ b/docker/dockerfiles/apachephp/Dockerfile @@ -29,6 +29,7 @@ RUN apt-get update && \ libmcrypt4 \ libicu-dev \ nodejs \ + chromium \ && \ docker-php-ext-configure gd --with-freetype --with-jpeg \ && \ diff --git a/sources/AppBundle/Accounting/Form/InvoicingRowType.php b/sources/AppBundle/Accounting/Form/InvoicingRowType.php new file mode 100644 index 000000000..b588a2757 --- /dev/null +++ b/sources/AppBundle/Accounting/Form/InvoicingRowType.php @@ -0,0 +1,58 @@ +add('reference', TextType::class, [ + 'label' => 'Référence', + 'required' => false, + 'constraints' => [ + new Assert\Type('string'), + new Assert\Length(max: 20), + ], + ])->add('designation', TextareaType::class, [ + 'label' => 'Désignation', + 'required' => false, + 'constraints' => [ + new Assert\Type('string'), + new Assert\Length(max: 100), + ], + ])->add('quantity', NumberType::class, [ + 'label' => 'Quantité', + 'required' => false, + 'scale' => 2, + ])->add('unitPrice', NumberType::class, [ + 'label' => 'Prix unitaire HT', + 'required' => false, + 'scale' => 2, + ])->add('tva', ChoiceType::class, [ + 'label' => 'Taux de TVA', + 'required' => true, + 'placeholder' => false, + 'choices' => ['Non soumis' => 0, '5.5%' => 5.50, '10%' => 10.00, '20%' => 20.00], + 'help' => 'Rappel : sponsoring 20%, place supplémentaire 10%.', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => InvoicingDetail::class, + ]); + } +} diff --git a/sources/AppBundle/Accounting/Form/QuotationType.php b/sources/AppBundle/Accounting/Form/QuotationType.php new file mode 100644 index 000000000..b63c2753e --- /dev/null +++ b/sources/AppBundle/Accounting/Form/QuotationType.php @@ -0,0 +1,187 @@ +add('quotationDate', DateType::class, [ + 'label' => 'Date devis', + 'required' => true, + 'widget' => 'single_text', + ])->add('company', TextType::class, [ + 'label' => 'Société', + 'empty_data' => '', + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Type('string'), + new Assert\Length(max: 50), + ], + ])->add('service', TextType::class, [ + 'label' => 'Service', + 'required' => false, + 'empty_data' => '', + 'constraints' => [ + new Assert\Type('string'), + new Assert\Length(max: 50), + ], + ])->add('address', TextareaType::class, [ + 'label' => 'Adresse', + 'empty_data' => '', + 'constraints' => [ + new Assert\Type('string'), + ], + ])->add('zipcode', TextType::class, [ + 'label' => 'Code postal', + 'empty_data' => '', + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Type('string'), + new Assert\Length(max: 10), + ], + ])->add('city', TextType::class, [ + 'label' => 'Ville', + 'empty_data' => '', + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Type('string'), + new Assert\Length(max: 50), + ], + ])->add('countryId', ChoiceType::class, [ + 'label' => 'Pays', + 'choices' => array_flip($this->pays->obtenirPays()), + ])->add('lastname', TextType::class, [ + 'label' => 'Nom', + 'required' => false, + 'empty_data' => '', + 'constraints' => [ + new Assert\Type('string'), + new Assert\Length(max: 50), + ], + ])->add('firstname', TextType::class, [ + 'label' => 'Prénom', + 'required' => false, + 'empty_data' => '', + 'constraints' => [ + new Assert\Type('string'), + new Assert\Length(max: 50), + ], + ])->add('phone', TextType::class, [ + 'label' => 'Tel', + 'required' => false, + 'empty_data' => '', + 'constraints' => [ + new Assert\Type('string'), + new Assert\Length(max: 30), + ], + ])->add('email', EmailType::class, [ + 'label' => 'Email (facture)', + 'required' => true, + 'empty_data' => '', + 'constraints' => [ + new Assert\NotBlank(), + new Assert\Type('string'), + new Assert\Length(max: 100), + ], + ])->add('tvaIntra', TextType::class, [ + 'label' => 'TVA intracommunautaire (facture)', + 'required' => false, + 'constraints' => [ + new Assert\Type('string'), + new Assert\Length(max: 20), + ], + ])->add('refClt1', TextType::class, [ + 'label' => 'Référence client', + 'required' => false, + 'empty_data' => '', + 'constraints' => [ + new Assert\Type('string'), + new Assert\Length(max: 50), + ], + ])->add('refClt2', TextType::class, [ + 'label' => 'Référence client 2', + 'required' => false, + 'empty_data' => '', + 'constraints' => [ + new Assert\Type('string'), + new Assert\Length(max: 50), + ], + ])->add('refClt3', TextType::class, [ + 'label' => 'Référence client 3', + 'required' => false, + 'empty_data' => '', + 'constraints' => [ + new Assert\Type('string'), + new Assert\Length(max: 50), + ], + ])->add('observation', TextareaType::class, [ + 'required' => false, + 'empty_data' => '', + 'label' => 'Observation', + ])->add('currency', EnumType::class, [ + 'required' => false, + 'class' => InvoicingCurrency::class, + 'attr' => ['size' => count(InvoicingCurrency::cases())], + 'label' => 'Monnaie de la facture', + 'placeholder' => false, + ])->add('details', CollectionType::class, [ + 'entry_type' => InvoicingRowType::class, + 'keep_as_list' => true, + 'allow_add' => true, + 'allow_delete' => true, + 'delete_empty' => $this->validate(), + ]); + + if ($options['actionType'] === 'edit') { + $builder->add('quotationNumber', TextType::class, [ + 'label' => 'Numéro de devis', + 'required' => false, + 'constraints' => [ + new Assert\Type('string'), + new Assert\Length(max: 50), + ], + ]); + } + } + + private function validate(?InvoicingDetail $detail = null): bool + { + return null === $detail || (empty($detail->getUnitPrice()) && empty($detail->getQuantity())); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'actionType' => 'add', + ]); + + $resolver->addAllowedTypes('actionType', 'string'); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['actionType'] = $options['actionType']; + } +} diff --git a/sources/AppBundle/Accounting/Model/Invoicing.php b/sources/AppBundle/Accounting/Model/Invoicing.php index 8f764a6a5..64b5c5d3b 100644 --- a/sources/AppBundle/Accounting/Model/Invoicing.php +++ b/sources/AppBundle/Accounting/Model/Invoicing.php @@ -19,24 +19,26 @@ class Invoicing implements NotifyPropertyInterface private ?string $quotationNumber = null; private ?DateTime $invoiceDate = null; private ?string $invoiceNumber = null; - private ?string $company = null; - private ?string $service = null; - private ?string $address = null; - private ?string $zipcode = null; - private ?string $city = null; - private ?string $countryId = null; - private ?string $email = null; + private string $company = ''; + private string $service = ''; + private string $address = ''; + private string $zipcode = ''; + private string $city = ''; + private string $countryId = ''; + private string $email = ''; private ?string $tvaIntra = null; - private ?string $observation = null; - private ?string $refClt1 = null; - private ?string $refClt2 = null; - private ?string $refClt3 = null; - private ?string $lastname = null; - private ?string $firstname = null; - private ?string $phone = null; + private string $observation = ''; + private string $refClt1 = ''; + private string $refClt2 = ''; + private string $refClt3 = ''; + private string $lastname = ''; + private string $firstname = ''; + private string $phone = ''; private int $paymentStatus = 0; private ?DateTime $paymentDate = null; private ?InvoicingCurrency $currency = null; + /** @var InvoicingDetail[] */ + private array $details = []; private ?float $price = null; @@ -104,12 +106,12 @@ public function setInvoiceNumber(?string $invoiceNumber): self return $this; } - public function getCompany(): ?string + public function getCompany(): string { return $this->company; } - public function setCompany(?string $company): self + public function setCompany(string $company): self { $this->propertyChanged('company', $this->company, $company); $this->company = $company; @@ -117,12 +119,12 @@ public function setCompany(?string $company): self return $this; } - public function getService(): ?string + public function getService(): string { return $this->service; } - public function setService(?string $service): self + public function setService(string $service): self { $this->propertyChanged('service', $this->service, $service); $this->service = $service; @@ -130,12 +132,12 @@ public function setService(?string $service): self return $this; } - public function getAddress(): ?string + public function getAddress(): string { return $this->address; } - public function setAddress(?string $address): self + public function setAddress(string $address): self { $this->propertyChanged('address', $this->address, $address); $this->address = $address; @@ -143,12 +145,12 @@ public function setAddress(?string $address): self return $this; } - public function getZipcode(): ?string + public function getZipcode(): string { return $this->zipcode; } - public function setZipcode(?string $zipcode): self + public function setZipcode(string $zipcode): self { $this->propertyChanged('zipcode', $this->zipcode, $zipcode); $this->zipcode = $zipcode; @@ -156,12 +158,12 @@ public function setZipcode(?string $zipcode): self return $this; } - public function getCity(): ?string + public function getCity(): string { return $this->city; } - public function setCity(?string $city): self + public function setCity(string $city): self { $this->propertyChanged('city', $this->city, $city); $this->city = $city; @@ -182,12 +184,12 @@ public function setCountryId(?string $countryId): self return $this; } - public function getEmail(): ?string + public function getEmail(): string { return $this->email; } - public function setEmail(?string $email): self + public function setEmail(string $email): self { $this->propertyChanged('email', $this->email, $email); $this->email = $email; @@ -208,12 +210,12 @@ public function setTvaIntra(?string $tvaIntra): self return $this; } - public function getObservation(): ?string + public function getObservation(): string { return $this->observation; } - public function setObservation(?string $observation): self + public function setObservation(string $observation): self { $this->propertyChanged('observation', $this->observation, $observation); $this->observation = $observation; @@ -221,12 +223,12 @@ public function setObservation(?string $observation): self return $this; } - public function getRefClt1(): ?string + public function getRefClt1(): string { return $this->refClt1; } - public function setRefClt1(?string $refClt1): self + public function setRefClt1(string $refClt1): self { $this->propertyChanged('refClt1', $this->refClt1, $refClt1); $this->refClt1 = $refClt1; @@ -234,12 +236,12 @@ public function setRefClt1(?string $refClt1): self return $this; } - public function getRefClt2(): ?string + public function getRefClt2(): string { return $this->refClt2; } - public function setRefClt2(?string $refClt2): self + public function setRefClt2(string $refClt2): self { $this->propertyChanged('refClt2', $this->refClt2, $refClt2); $this->refClt2 = $refClt2; @@ -247,12 +249,12 @@ public function setRefClt2(?string $refClt2): self return $this; } - public function getRefClt3(): ?string + public function getRefClt3(): string { return $this->refClt3; } - public function setRefClt3(?string $refClt3): self + public function setRefClt3(string $refClt3): self { $this->propertyChanged('refClt3', $this->refClt3, $refClt3); $this->refClt3 = $refClt3; @@ -260,12 +262,12 @@ public function setRefClt3(?string $refClt3): self return $this; } - public function getLastname(): ?string + public function getLastname(): string { return $this->lastname; } - public function setLastname(?string $lastname): self + public function setLastname(string $lastname): self { $this->propertyChanged('lastname', $this->lastname, $lastname); $this->lastname = $lastname; @@ -273,12 +275,12 @@ public function setLastname(?string $lastname): self return $this; } - public function getFirstname(): ?string + public function getFirstname(): string { return $this->firstname; } - public function setFirstname(?string $firstname): self + public function setFirstname(string $firstname): self { $this->propertyChanged('firstname', $this->firstname, $firstname); $this->firstname = $firstname; @@ -286,12 +288,12 @@ public function setFirstname(?string $firstname): self return $this; } - public function getPhone(): ?string + public function getPhone(): string { return $this->phone; } - public function setPhone(?string $phone): self + public function setPhone(string $phone): self { $this->propertyChanged('phone', $this->phone, $phone); $this->phone = $phone; @@ -358,4 +360,18 @@ public function getPaymentUrlRef(): string return urlencode(Utils::cryptFromText($this->getId())); } + + /** @return InvoicingDetail[] */ + public function getDetails(): array + { + return $this->details; + } + + public function setDetails(array $details) + { + $this->details = $details; + + return $this; + } + } diff --git a/sources/AppBundle/Accounting/Model/InvoicingDetail.php b/sources/AppBundle/Accounting/Model/InvoicingDetail.php new file mode 100644 index 000000000..2db47ecaf --- /dev/null +++ b/sources/AppBundle/Accounting/Model/InvoicingDetail.php @@ -0,0 +1,120 @@ +id; + } + + public function setId(int $id): self + { + $this->propertyChanged('id', $this->id, $id); + $this->id = $id; + return $this; + } + + public function getInvoicingId(): ?int + { + return $this->invoicingId; + } + + public function setInvoicingId(?int $invoicingId): self + { + $this->propertyChanged('invoicingId', $this->invoicingId, $invoicingId); + $this->invoicingId = $invoicingId; + + return $this; + } + + public function getReference(): ?string + { + return $this->reference; + } + + public function setReference(?string $ref): self + { + $this->propertyChanged('reference', $this->reference, $ref); + $this->reference = $ref; + + return $this; + } + + public function getDesignation(): ?string + { + return $this->designation; + } + + public function setDesignation(?string $designation): self + { + $this->propertyChanged('designation', $this->designation, $designation); + $this->designation = $designation; + + return $this; + } + + public function getQuantity(): ?float + { + return $this->quantity; + } + + public function setQuantity(?float $quantity): self + { + $this->propertyChanged('quantity', $this->quantity, $quantity); + $this->quantity = $quantity; + + return $this; + } + + public function getUnitPrice(): ?float + { + return $this->unitPrice; + } + + public function setUnitPrice(?float $unitPrice): self + { + $this->propertyChanged('unitPrice', $this->unitPrice, $unitPrice); + $this->unitPrice = $unitPrice; + + return $this; + } + + public function getTva(): ?float + { + return $this->tva; + } + + public function setTva(?float $tva): self + { + $this->propertyChanged('tva', $this->tva, $tva); + $this->tva = $tva; + + return $this; + } + + public function isValid(): bool + { + if (empty($this->getQuantity()) || empty($this->getDesignation()) || empty($this->getReference()) || empty($this->getUnitPrice())) { + return false; + } + + return true; + } +} diff --git a/sources/AppBundle/Accounting/Model/Repository/InvoicingDetailRepository.php b/sources/AppBundle/Accounting/Model/Repository/InvoicingDetailRepository.php new file mode 100644 index 000000000..61a87dacb --- /dev/null +++ b/sources/AppBundle/Accounting/Model/Repository/InvoicingDetailRepository.php @@ -0,0 +1,101 @@ + + */ +class InvoicingDetailRepository extends Repository implements MetadataInitializer +{ + public function getRowsIdsPerInvoicingId(int $invoicingId): array + { + $query = $this->getQuery( + 'SELECT id FROM afup_compta_facture_details WHERE idafup_compta_facture = :invoicingId', + )->setParams(['invoicingId' => $invoicingId]); + + $result = []; + foreach ($query->query($this->getCollection(new HydratorArray())) as $row) { + $result[] = $row['id']; + } + + return $result; + } + + /** + * @param int[] $ids + * @return void + */ + public function removeRowsPerIds(array $ids): void + { + /** + * @var DeleteInterface $builder + */ + $builder = $this->getQueryBuilder('delete'); + $builder->from($this->getMetadata()->getTable()) + ->where('id IN (:ids)'); + + $this->getQuery($builder)->setParams(['ids' => implode(', ', $ids)])->execute(); + } + + public static function initMetadata(SerializerFactoryInterface $serializerFactory, array $options = []) + { + $metadata = new Metadata($serializerFactory); + + $metadata->setEntity(InvoicingDetail::class); + $metadata->setConnectionName('main'); + $metadata->setDatabase($options['database']); + $metadata->setTable('afup_compta_facture_details'); + + $metadata + ->addField([ + 'columnName' => 'id', + 'fieldName' => 'id', + 'primary' => true, + 'autoincrement' => true, + 'type' => 'int', + ]) + ->addField([ + 'columnName' => 'idafup_compta_facture', + 'fieldName' => 'invoicingId', + 'type' => 'int', + ]) + ->addField([ + 'columnName' => 'ref', + 'fieldName' => 'reference', + 'type' => 'string', + ]) + ->addField([ + 'columnName' => 'designation', + 'fieldName' => 'designation', + 'type' => 'string', + ]) + ->addField([ + 'columnName' => 'quantite', + 'fieldName' => 'quantity', + 'type' => 'float', + ]) + ->addField([ + 'columnName' => 'pu', + 'fieldName' => 'unitPrice', + 'type' => 'float', + ]) + ->addField([ + 'columnName' => 'tva', + 'fieldName' => 'tva', + 'type' => 'float', + ]) + ; + + return $metadata; + } +} diff --git a/sources/AppBundle/Accounting/Model/Repository/InvoicingRepository.php b/sources/AppBundle/Accounting/Model/Repository/InvoicingRepository.php index e371b0c11..831b27b4e 100644 --- a/sources/AppBundle/Accounting/Model/Repository/InvoicingRepository.php +++ b/sources/AppBundle/Accounting/Model/Repository/InvoicingRepository.php @@ -4,6 +4,10 @@ namespace AppBundle\Accounting\Model\Repository; +use CCMBenchmark\Ting\Repository\Hydrator\AggregateFrom; +use CCMBenchmark\Ting\Repository\Hydrator\AggregateTo; +use CCMBenchmark\Ting\Repository\Hydrator\RelationMany; +use CCMBenchmark\Ting\Repository\HydratorRelational; use CCMBenchmark\Ting\Serializer\DateTime; use AppBundle\Accounting\InvoicingCurrency; use AppBundle\Accounting\Model\Invoicing; @@ -21,6 +25,35 @@ */ class InvoicingRepository extends Repository implements MetadataInitializer { + public function getQuotationById(int $periodId): ?Invoicing + { + /** @var Select $builder */ + $builder = $this->getQueryBuilder(self::QUERY_SELECT); + $builder->cols(['acf.*', 'acfd.*']) + ->from('afup_compta_facture acf') + ->leftJoin('afup_compta_facture_details acfd', 'acfd.idafup_compta_facture = acf.id') + ->where('acf.id = :periodId'); + + $hydrator = new HydratorRelational(); + $hydrator->addRelation(new RelationMany(new AggregateFrom('acfd'), new AggregateTo('acf'), 'setDetails')); + $hydrator->callableFinalizeAggregate(fn(array $row) => $row['acf']); + + $collection = $this->getQuery($builder->getStatement()) + ->setParams(['periodId' => $periodId]) + ->query($this->getCollection($hydrator)); + + if ($collection->count() === 0) { + return null; + } + + /** @var Invoicing $entity */ + $entity = $collection->first(); + $entity->setDetails(array_values($entity->getDetails())); + ; + + return $entity; + } + public function getQuotationsByPeriodId(?int $periodId = null, string $sort = 'date', string $direction = 'desc'): CollectionInterface { $filter = 'acf.date_devis'; diff --git a/sources/AppBundle/Controller/Admin/Accounting/Quotation/AddQuotationAction.php b/sources/AppBundle/Controller/Admin/Accounting/Quotation/AddQuotationAction.php new file mode 100644 index 000000000..02c336849 --- /dev/null +++ b/sources/AppBundle/Controller/Admin/Accounting/Quotation/AddQuotationAction.php @@ -0,0 +1,60 @@ +setQuotationDate(new \DateTime()); + $quotation->setCountryId('FR'); + $quotation->setDetails([new InvoicingDetail()]); + $form = $this->createForm(QuotationType::class, $quotation); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + try { + $this->invoicingRepository->startTransaction(); + $quotation->setQuotationNumber($this->facture->genererNumeroDevis()); + $this->invoicingRepository->save($quotation); + foreach ($quotation->getDetails() as $detail) { + if ($detail->isValid() === false) { + continue; + } + $detail->setInvoicingId($quotation->getId()); + $this->invoicingDetailRepository->save($detail); + } + $this->invoicingRepository->commit(); + $this->addFlash('success', 'L\'écriture a été ajoutée'); + return $this->redirectToRoute('admin_accounting_quotations_list'); + } catch (\Exception $e) { + $this->invoicingRepository->rollback(); + $this->addFlash('error', 'L\'écriture n\'a pas pu être enregistrée'); + } + } + + return $this->render('admin/accounting/quotation/add.html.twig', [ + 'quotation' => $quotation, + 'form' => $form->createView(), + 'submitLabel' => 'Ajouter', + ]); + } +} diff --git a/sources/AppBundle/Controller/Admin/Accounting/Quotation/EditQuotationAction.php b/sources/AppBundle/Controller/Admin/Accounting/Quotation/EditQuotationAction.php new file mode 100644 index 000000000..2f9855afa --- /dev/null +++ b/sources/AppBundle/Controller/Admin/Accounting/Quotation/EditQuotationAction.php @@ -0,0 +1,69 @@ +query->getInt('quotationId'); + $quotation = $this->invoicingRepository->getQuotationById($quotationId); + if ($quotation === null) { + throw new InvalidArgumentException("Ce devis n'existe pas"); + } + + $form = $this->createForm(QuotationType::class, $quotation, ['actionType' => 'edit']); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + try { + $idsToRemove = $this->invoicingDetailRepository->getRowsIdsPerInvoicingId($quotation->getId()); + $existingIds = []; + $this->invoicingRepository->startTransaction(); + $this->unitOfWork->pushSave($quotation); + foreach ($quotation->getDetails() as $detail) { + if ($detail->getId() !== null) { + $existingIds[] = $detail->getId(); + } + $detail->setInvoicingId($quotation->getId()); +// $this->unitOfWork->pushSave($detail); + $this->invoicingDetailRepository->save($detail); + } + + $idsToRemove = array_diff($idsToRemove, $existingIds); + if ($idsToRemove) { + $this->invoicingDetailRepository->removeRowsPerIds($idsToRemove); + } + $this->invoicingRepository->save($quotation); + $this->invoicingRepository->commit(); + $this->addFlash('success', 'L\'écriture a été modifiée'); + return $this->redirectToRoute('admin_accounting_quotations_list'); + } catch (\Exception $e) { + $this->invoicingRepository->rollback(); + $this->addFlash('error', 'L\'écriture n\'a pas pu être enregistrée'); + } + } + + return $this->render('admin/accounting/quotation/edit.html.twig', [ + 'quotation' => $quotation, + 'form' => $form->createView(), + 'submitLabel' => 'Modifier', + ]); + } +} diff --git a/templates/admin/accounting/quotation/_javascript.html.twig b/templates/admin/accounting/quotation/_javascript.html.twig new file mode 100644 index 000000000..250c52679 --- /dev/null +++ b/templates/admin/accounting/quotation/_javascript.html.twig @@ -0,0 +1,52 @@ + diff --git a/templates/admin/accounting/quotation/_prototype.html.twig b/templates/admin/accounting/quotation/_prototype.html.twig new file mode 100644 index 000000000..d819ddff2 --- /dev/null +++ b/templates/admin/accounting/quotation/_prototype.html.twig @@ -0,0 +1,15 @@ +
+
+
+
Ligne __reference__
+
+
+{{ form_row(form.details.vars.prototype.reference) }} +
+
+
Rappel : sponsoring 20%, place supplémentaire 10%.
+
+{{ form_row(form.details.vars.prototype.tva) }} +{{ form_row(form.details.vars.prototype.designation) }} +{{ form_row(form.details.vars.prototype.quantity) }} +{{ form_row(form.details.vars.prototype.unitPrice) }} diff --git a/templates/admin/accounting/quotation/add.html.twig b/templates/admin/accounting/quotation/add.html.twig new file mode 100644 index 000000000..c27779081 --- /dev/null +++ b/templates/admin/accounting/quotation/add.html.twig @@ -0,0 +1,13 @@ +{% extends 'admin/base_with_header.html.twig' %} + +{% block content %} +

Ajouter un devis

+ + {% include 'admin/accounting/quotation/form.html.twig' %} + +{% endblock %} + +{% block javascript %} +{{ parent() }} +{% include 'admin/accounting/quotation/_javascript.html.twig' %} +{% endblock %} diff --git a/templates/admin/accounting/quotation/edit.html.twig b/templates/admin/accounting/quotation/edit.html.twig new file mode 100644 index 000000000..fff52d070 --- /dev/null +++ b/templates/admin/accounting/quotation/edit.html.twig @@ -0,0 +1,19 @@ +{% extends 'admin/base_with_header.html.twig' %} + +{% block content %} +

Modifier un devis

+ + + + {% include 'admin/accounting/quotation/form.html.twig' %} + +{% endblock %} + +{% block javascript %} +{{ parent() }} +{% include 'admin/accounting/quotation/_javascript.html.twig' %} +{% endblock %} diff --git a/templates/admin/accounting/quotation/form.html.twig b/templates/admin/accounting/quotation/form.html.twig new file mode 100644 index 000000000..968a4f4fd --- /dev/null +++ b/templates/admin/accounting/quotation/form.html.twig @@ -0,0 +1,170 @@ +{% form_theme form 'form_theme_admin.html.twig' %} + +{{ form_start(form) }} +
+

Détail devis

+
+
+
+ {{ form_row(form.quotationDate) }} +
+
+
+ +
+

Facturation

+
+
+
+
+
+
+
+ Ces informations concernent la personne ou la société qui sera facturée

+
+
+ {{ form_row(form.company) }} + {{ form_row(form.service) }} + {{ form_row(form.address) }} + {{ form_row(form.zipcode) }} + {{ form_row(form.city) }} + {{ form_row(form.countryId) }} +
+
+
+ +
+

Contact

+
+
+
+ {{ form_row(form.lastname) }} + {{ form_row(form.firstname) }} + {{ form_row(form.phone) }} + {{ form_row(form.email) }} + {{ form_row(form.tvaIntra) }} +
+
+
+ +
+

Réservé à l'administration

+
+
+
+
+
+
+
+ Numéro généré automatiquement et affiché en automatique

+
+
+ {% if form.vars.actionType == 'edit' %} + {{ form_row(form.quotationNumber) }} + {% endif %} +
+
+
+ +
+

Référence client

+
+
+
+
+
+
+
+ Possible d'avoir plusieurs références à mettre (obligation client)

+
+
+ {{ form_row(form.refClt1) }} + {{ form_row(form.refClt2) }} + {{ form_row(form.refClt3) }} +
+
+
+ +
+

Observation

+
+
+
+
+
+
+
+ Ces informations seront écrites à la fin du document

+
+
+ {{ form_row(form.observation) }} +
+
+
+ +
+

Devise

+
+
+
+ {{ form_row(form.currency) }} +
+
+
+ +
+
+
+

Contenu

+
+
+ {% set index = form.details|length > 0 ? (form.details|length) : 0 %} +
+ {% for detail in form.details %} +
+
+
+
+
Ligne {{ loop.index }}
+
+
+ {{ form_row(detail.reference) }} +
+
+
Rappel : sponsoring 20%, place supplémentaire 10%.
+
+ {{ form_row(detail.tva) }} + {{ form_row(detail.designation) }} + {{ form_row(detail.quantity) }} + {{ form_row(detail.unitPrice) }} +
+ {% endfor %} +
+
+
+ +
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+

+ * indique un champ obligatoire +

+
+ +{{ form_end(form) }} + diff --git a/templates/admin/accounting/quotation/list.html.twig b/templates/admin/accounting/quotation/list.html.twig index 9a112d3d0..316b56c1e 100644 --- a/templates/admin/accounting/quotation/list.html.twig +++ b/templates/admin/accounting/quotation/list.html.twig @@ -4,7 +4,7 @@

Liste des devis