From 36149dd52123b280e788afc37d3a42a569b9b45f Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 10:01:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(back)=20:=20bon=20de=20pes=C3=A9e=20PDF=20?= =?UTF-8?q?via=20template=20Twig=20(ERP-192)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoint API Platform GET /api/weighing_tickets/{id}/print.pdf (provider renvoyant un binaire, pas de controller) sécurisé par logistique.weighing_tickets.view. Rendu d'un template Twig hydraté avec le ticket converti en PDF via Dompdf. Reproduit le modèle fourni : en-tête fixe (logo + identité société, indépendant du site), pesées à vide/plein avec le numéro de pesée affiché comme un DSD, poids net = plein − vide. --- composer.json | 1 + composer.lock | 447 +++++++++++++++++- .../Domain/Entity/WeighingTicket.php | 13 + .../Provider/WeighingTicketPrintProvider.php | 103 ++++ .../Pdf/WeighingTicketPdfRenderer.php | 75 +++ .../Pdf/assets/logo-lpc-liot.png | Bin 0 -> 7169 bytes .../weighing_ticket_print.html.twig | 81 ++++ .../Api/WeighingTicketPrintApiTest.php | 73 +++ 8 files changed, 792 insertions(+), 1 deletion(-) create mode 100644 src/Module/Logistique/Infrastructure/ApiPlatform/State/Provider/WeighingTicketPrintProvider.php create mode 100644 src/Module/Logistique/Infrastructure/Pdf/WeighingTicketPdfRenderer.php create mode 100644 src/Module/Logistique/Infrastructure/Pdf/assets/logo-lpc-liot.png create mode 100644 templates/logistique/weighing_ticket_print.html.twig create mode 100644 tests/Module/Logistique/Api/WeighingTicketPrintApiTest.php diff --git a/composer.json b/composer.json index 0e5eb29..4ce5354 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "doctrine/doctrine-bundle": "^3.2", "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.6", + "dompdf/dompdf": "^3.0", "lexik/jwt-authentication-bundle": "^3.2", "nelmio/cors-bundle": "^2.6", "nyholm/psr7": "^1.8", diff --git a/composer.lock b/composer.lock index f5fba04..ba44978 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": "b029c1484227c926d39dfd3ae5cb0699", + "content-hash": "224bae08ec63f217eabf5b2b611deaa0", "packages": [ { "name": "api-platform/doctrine-common", @@ -2520,6 +2520,161 @@ }, "time": "2026-02-08T16:21:46+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "lcobucci/jwt", "version": "5.6.0", @@ -2894,6 +3049,73 @@ }, "time": "2022-12-02T22:17:43+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.1", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" + }, + "time": "2026-06-23T18:43:15+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -3937,6 +4159,86 @@ }, "time": "2021-10-29T13:26:27+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.4.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.33 || 2.2.2", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.4.6", + "rector/type-perfect": "1.0.0 || 2.1.3", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.5.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" + }, + "time": "2026-06-18T15:10:53+00:00" + }, { "name": "symfony/asset", "version": "v8.0.8", @@ -8779,6 +9081,149 @@ ], "time": "2026-03-30T15:14:47+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "twig/twig", "version": "v3.24.0", diff --git a/src/Module/Logistique/Domain/Entity/WeighingTicket.php b/src/Module/Logistique/Domain/Entity/WeighingTicket.php index 7e6bf1d..7c27491 100644 --- a/src/Module/Logistique/Domain/Entity/WeighingTicket.php +++ b/src/Module/Logistique/Domain/Entity/WeighingTicket.php @@ -12,6 +12,7 @@ use ApiPlatform\Metadata\Post; use App\Module\Commercial\Domain\Entity\Client; // relation ORM partagee (§ 2.1) use App\Module\Commercial\Domain\Entity\Supplier; // relation ORM partagee (§ 2.1) use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor; +use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketPrintProvider; use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider; use App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository; use App\Module\Sites\Domain\Entity\Site; // relation ORM partagee (§ 2.1) @@ -84,6 +85,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; ]], provider: WeighingTicketProvider::class, ), + // Bon de pesee PDF (RG-5.08, spec § 2.12 / § 4.6) : operation dediee qui + // sert un binaire (pas une representation Hydra). Le provider retourne une + // Response -> la serialisation est court-circuitee. Pas de controller + // (decision spec § 4.6). Pas de format API Platform negocie : `.pdf` est + // litteral dans l'URI. + new Get( + uriTemplate: '/weighing_tickets/{id}/print.pdf', + security: "is_granted('logistique.weighing_tickets.view')", + provider: WeighingTicketPrintProvider::class, + output: false, + read: true, + ), new Post( security: "is_granted('logistique.weighing_tickets.manage')", normalizationContext: ['groups' => [ diff --git a/src/Module/Logistique/Infrastructure/ApiPlatform/State/Provider/WeighingTicketPrintProvider.php b/src/Module/Logistique/Infrastructure/ApiPlatform/State/Provider/WeighingTicketPrintProvider.php new file mode 100644 index 0000000..3ad71b6 --- /dev/null +++ b/src/Module/Logistique/Infrastructure/ApiPlatform/State/Provider/WeighingTicketPrintProvider.php @@ -0,0 +1,103 @@ + Dompdf). + * + * Le provider retourne directement une {@see Response} : API Platform court-circuite + * alors la serialisation Hydra (le SerializeListener/RespondListener detectent une + * Response et la renvoient telle quelle). `Content-Type: application/pdf`, + * disposition `inline` (le front ouvre l'apercu — RG-5.08). + * + * Securite & visibilite — miroir de {@see WeighingTicketProvider::provideItem()} : + * - permission `logistique.weighing_tickets.view` portee par l'operation (403) ; + * - 404 si ticket introuvable, soft-delete (non expose au M5 — § 2.13), ou hors + * perimetre du site courant (anti-enumeration, § 2.3 / RG-5.09). + * + * @implements ProviderInterface + */ +final class WeighingTicketPrintProvider implements ProviderInterface +{ + public function __construct( + #[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')] + private readonly WeighingTicketRepositoryInterface $repository, + private readonly WeighingTicketPdfRenderer $renderer, + private readonly CurrentSiteProviderInterface $currentSiteProvider, + private readonly Security $security, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response + { + $ticket = $this->findVisibleTicket($uriVariables['id'] ?? null); + if (null === $ticket) { + throw new NotFoundHttpException('Ticket de pesée introuvable.'); + } + + $pdf = $this->renderer->render($ticket); + + $response = new Response($pdf); + $response->headers->set('Content-Type', 'application/pdf'); + $response->headers->set( + 'Content-Disposition', + sprintf('inline; filename="bon-pesee-%s.pdf"', $ticket->getNumber() ?? (string) $ticket->getId()), + ); + + return $response; + } + + /** + * Charge le ticket visible par l'utilisateur courant, ou null (-> 404) : + * introuvable, soft-delete, ou hors perimetre du site courant. Logique + * identique a WeighingTicketProvider::provideItem() (cloisonnement § 2.3). + */ + private function findVisibleTicket(mixed $id): ?WeighingTicket + { + if (!is_int($id) && !(is_string($id) && ctype_digit($id))) { + return null; + } + + $ticket = $this->repository->findById((int) $id); + if (null === $ticket || null !== $ticket->getDeletedAt()) { + return null; + } + + $scopeSite = $this->currentScopeSite(); + if (null !== $scopeSite && $ticket->getSite()?->getId() !== $scopeSite->getId()) { + return null; + } + + return $ticket; + } + + /** + * Site servant a cloisonner, ou null si aucun cloisonnement ne s'applique + * (user `sites.bypass_scope`, ou pas de site courant). Miroir de + * WeighingTicketProvider::currentScopeSite(). + */ + private function currentScopeSite(): ?Site + { + if ($this->security->isGranted('sites.bypass_scope')) { + return null; + } + + return $this->currentSiteProvider->get(); + } +} diff --git a/src/Module/Logistique/Infrastructure/Pdf/WeighingTicketPdfRenderer.php b/src/Module/Logistique/Infrastructure/Pdf/WeighingTicketPdfRenderer.php new file mode 100644 index 0000000..179125c --- /dev/null +++ b/src/Module/Logistique/Infrastructure/Pdf/WeighingTicketPdfRenderer.php @@ -0,0 +1,75 @@ + accents FR et « ° » corrects). + */ + public function render(WeighingTicket $ticket): string + { + $html = $this->twig->render('logistique/weighing_ticket_print.html.twig', [ + 'ticket' => $ticket, + 'logoSrc' => $this->logoDataUri(), + ]); + + $options = new Options(); + $options->set('isRemoteEnabled', false); + $options->set('defaultFont', 'DejaVu Sans'); + + $dompdf = new Dompdf($options); + $dompdf->loadHtml($html, 'UTF-8'); + $dompdf->setPaper('A4', 'portrait'); + $dompdf->render(); + + return (string) $dompdf->output(); + } + + /** + * Logo societe encode en data-URI base64, ou null s'il est introuvable (le + * template degrade alors sans bloquer la generation du PDF). + */ + private function logoDataUri(): ?string + { + $binary = @file_get_contents(self::LOGO_PATH); + if (false === $binary) { + return null; + } + + return 'data:image/png;base64,'.base64_encode($binary); + } +} diff --git a/src/Module/Logistique/Infrastructure/Pdf/assets/logo-lpc-liot.png b/src/Module/Logistique/Infrastructure/Pdf/assets/logo-lpc-liot.png new file mode 100644 index 0000000000000000000000000000000000000000..5ec692815d684395a551bfbe6feba9f7ce42ccc1 GIT binary patch literal 7169 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 Dae~#V literal 0 HcmV?d00001 diff --git a/templates/logistique/weighing_ticket_print.html.twig b/templates/logistique/weighing_ticket_print.html.twig new file mode 100644 index 0000000..eaf4b84 --- /dev/null +++ b/templates/logistique/weighing_ticket_print.html.twig @@ -0,0 +1,81 @@ +{# + Ticket de pesée (M5 Logistique) — gabarit imprimable hydraté côté serveur puis + converti en PDF par WeighingTicketPdfRenderer (Dompdf). Cf. spec-back M5 § 2.12 + / § 4.6 (RG-5.08). Reproduit fidèlement le modèle fourni (ticket_pesee.pdf). + + En-tête FIXE (logo + identité société) : le ticket ne change pas en fonction du + site (décision Tristan, ERP-192). Le logo est injecté en data-URI par le renderer + (logoSrc) ; l'identité société est en dur ci-dessous. + + Contraintes Dompdf : CSS2.1 (pas de flexbox/grid), mise en page par tableaux. + Police DejaVu Sans (UTF-8 — accents FR et « ° » rendus correctement). +#} + + + + + + + + {% if logoSrc %} + + {% endif %} + +
SA LIOT Châtellerault
+
Email : lpc.contacts@lpc-liot.fr
+
RCS Châtellerault B 339 505 612
+ +
Ticket de pesée
+ + {# + Référence de pesée affichée au client = un seul numéro, présenté comme un + DSD : en pesée MANUELLE c'est le numéro de pesée saisi (manualNumber), en + pesée AUTO c'est le DSD du pont. « N° pesée » et « DSD » sont la même chose + pour le client (RG-5.04) — on n'expose donc pas le compteur interne du pont + quand une pesée manuelle porte son propre numéro. + #} + {% set emptyRef = (ticket.emptyMode == 'MANUAL' and ticket.emptyManualNumber) ? ticket.emptyManualNumber : ticket.emptyDsd %} + {% set fullRef = (ticket.fullMode == 'MANUAL' and ticket.fullManualNumber) ? ticket.fullManualNumber : ticket.fullDsd %} + + + + + + + + + + + + + + +
Poids à vide{{ ticket.emptyWeight is not null ? ticket.emptyWeight ~ ' kg' : '' }}N° pesée à vide{% if emptyRef is not null %}DSD : {{ emptyRef }}{% endif %}{% if ticket.emptyDate %} {{ ticket.emptyDate|date('d/m/Y H:i:s') }}{% endif %}
Poids à plein{{ ticket.fullWeight is not null ? ticket.fullWeight ~ ' kg' : '' }}N° pesée à plein{% if fullRef is not null %}DSD : {{ fullRef }}{% endif %}{% if ticket.fullDate %} {{ ticket.fullDate|date('d/m/Y H:i:s') }}{% endif %}
+ +
Poids : {{ ticket.netWeight is not null ? ticket.netWeight ~ ' kg' : '—' }}
+ + diff --git a/tests/Module/Logistique/Api/WeighingTicketPrintApiTest.php b/tests/Module/Logistique/Api/WeighingTicketPrintApiTest.php new file mode 100644 index 0000000..5eee1ea --- /dev/null +++ b/tests/Module/Logistique/Api/WeighingTicketPrintApiTest.php @@ -0,0 +1,73 @@ +firstSite(); + $http = $this->authManageOnSite($site); + $client = $this->seedTestClient('Print'); + + $created = $this->postTicket($http, $this->validClientTicketPayload($client)); + self::assertResponseStatusCodeSame(201); + $ticketId = $created->toArray()['id']; + + $response = $http->request('GET', sprintf('/api/weighing_tickets/%d/print.pdf', $ticketId)); + + self::assertResponseIsSuccessful(); + + $headers = $response->getHeaders(false); + self::assertStringContainsString('application/pdf', $headers['content-type'][0] ?? ''); + self::assertStringContainsString('inline', $headers['content-disposition'][0] ?? ''); + + // PDF non vide + signature de fichier PDF (« %PDF-1.x »). + $binary = $response->getContent(false); + self::assertNotSame('', $binary, 'Le PDF du bon de pesée ne doit pas être vide.'); + self::assertStringStartsWith('%PDF', $binary); + } + + public function testForbiddenWithoutViewPermission(): void + { + // On seede un ticket reel via un user habilite, puis on tente l'impression + // avec un user depourvu de `logistique.weighing_tickets.view`. + $site = $this->firstSite(); + $manager = $this->authManageOnSite($site); + $client = $this->seedTestClient('Forbidden'); + + $created = $this->postTicket($manager, $this->validClientTicketPayload($client)); + self::assertResponseStatusCodeSame(201); + $ticketId = $created->toArray()['id']; + + $creds = $this->createUserWithPermission('core.users.view'); + $intrus = $this->authenticatedClient($creds['username'], $creds['password']); + + $intrus->request('GET', sprintf('/api/weighing_tickets/%d/print.pdf', $ticketId)); + + self::assertResponseStatusCodeSame(403); + } + + public function testNotFoundForUnknownTicket(): void + { + $http = $this->authManageOnSite($this->firstSite()); + + $http->request('GET', '/api/weighing_tickets/99999999/print.pdf'); + + self::assertResponseStatusCodeSame(404); + } +}