Intégration d'un scanner de code-barres en PHP avec Sylius

L'intégration d'un scanner de code-barres dans une application e-commerce permet d'automatiser la saisie des codes produits et d'améliorer l'efficacité des opérations de gestion des stocks. Cet article vous présente une solution complète utilisant Binary Eye (application mobile) et Symfony/Sylius.

Architecture de la solution

Composants principaux

  1. Application mobile : Binary Eye (Android/iOS)
  2. Contrôleur PHP : Gestion des résultats de scan
  3. Interface admin : Formulaire Sylius avec bouton de scan
  4. JavaScript : Communication entre les composants

Flux de fonctionnement

Mobile App → Scan → URL Scheme → PHP Controller → JavaScript → Form Field

Implémentation du contrôleur PHP

1. Création du contrôleur de gestion

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class BarcodeScanController extends AbstractController
{
    #[Route('/admin/barcode-scan-result', name: 'app_barcode_scan_result')]
    public function handleScanResult(Request $request): Response
    {
        $result = $request->query->get('result');
        $format = $request->query->get('format');
        
        // Log pour debug
        $this->addFlash('info', sprintf('Code-barres scanné: %s (format: %s)', $result, $format));
        
        // Retourner une page HTML qui va automatiquement remplir le champ barcode
        $html = <<<HTML
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Résultat du scan</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            text-align: center;
            padding: 20px;
            background-color: #f5f5f5;
        }
        .container {
            background: white;
            padding: 30px;
            border-radius: 10px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            max-width: 400px;
            margin: 50px auto;
        }
        .success {
            color: #28a745;
            font-size: 24px;
            margin-bottom: 20px;
        }
        .barcode {
            font-family: monospace;
            font-size: 18px;
            background: #f8f9fa;
            padding: 10px;
            border-radius: 5px;
            margin: 15px 0;
        }
        .format {
            color: #6c757d;
            font-size: 14px;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="success">✓ Code-barres scanné avec succès</div>
        <div class="barcode">{$result}</div>
        <div class="format">Format: {$format}</div>
        <p>Vous pouvez maintenant fermer cette page et retourner au formulaire.</p>
    </div>
    
    <script>
        // Stocker le résultat dans le localStorage pour le récupérer dans le formulaire
        localStorage.setItem('barcodeScanResult', '{$result}');
        localStorage.setItem('barcodeScanFormat', '{$format}');
        
        // Notifier le parent window si on est dans un iframe
        if (window.parent !== window) {
            window.parent.postMessage({
                type: 'barcodeScanResult',
                result: '{$result}',
                format: '{$format}'
            }, '*');
        }
        
        // Fermer automatiquement après 3 secondes
        setTimeout(function() {
            window.close();
        }, 3000);
    </script>
</body>
</html>
HTML;

        return new Response($html);
    }
}

2. Points clés du contrôleur

Gestion des paramètres

$result = $request->query->get('result');  // Code-barres scanné
$format = $request->query->get('format');  // Format (EAN13, CODE128, etc.)

Stockage temporaire

// Stockage dans localStorage pour communication inter-pages
localStorage.setItem('barcodeScanResult', '{$result}');
localStorage.setItem('barcodeScanFormat', '{$format}');

Communication cross-window

// Notification du parent window
if (window.parent !== window) {
    window.parent.postMessage({
        type: 'barcodeScanResult',
        result: '{$result}',
        format: '{$format}'
    }, '*');
}

Extension du formulaire Sylius

1. Extension du type de formulaire

<?php

namespace App\Form\Extension;

use Sylius\Bundle\ProductBundle\Form\Type\ProductVariantType;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;

class ProductVariantTypeExtension extends AbstractTypeExtension
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('barcode', TextType::class, [
            'label' => 'Code-barres',
            'required' => false,
        ]);
    }

    public static function getExtendedTypes(): iterable
    {
        return [ProductVariantType::class];
    }
}

2. Entité ProductVariant étendue

<?php

namespace App\Entity\Product;

use Doctrine\ORM\Mapping as ORM;
use Sylius\Component\Core\Model\ProductVariant as BaseProductVariant;

#[ORM\Entity]
#[ORM\Table(name: 'sylius_product_variant')]
class ProductVariant extends BaseProductVariant
{
    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    private ?string $barcode = null;

    public function getBarcode(): ?string
    {
        return $this->barcode;
    }

    public function setBarcode(?string $barcode): void
    {
        $this->barcode = $barcode;
    }
}

3. Migration de base de données

<?php

namespace App\Migrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20250619141500 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Add barcode field to ProductVariant entity';
    }

    public function up(Schema $schema): void
    {
        $this->addSql('ALTER TABLE sylius_product_variant ADD barcode VARCHAR(255) DEFAULT NULL');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE sylius_product_variant DROP barcode');
    }
}

Interface utilisateur dans l'admin

1. Template Twig personnalisé

{# templates/bundles/SyliusAdminBundle/ProductVariant/Tab/_details.html.twig #}
{% from '@SyliusAdmin/Macro/translationForm.html.twig' import translationForm %}

<div class="ui active tab" data-tab="details">
    <h3 class="ui dividing header">{{ 'sylius.ui.details'|trans }}</h3>

    <div class="ui segments">
        {{ translationForm(form.translations) }}
        <div class="ui hidden divider"></div>
        <div class="ui segment">
            {{ form_row(form.code) }}
            <div class="field">
                <label for="{{ form.barcode.vars.id }}">{{ form.barcode.vars.label|trans }}</label>
                <div class="ui right labeled input">
                    {{ form_widget(form.barcode, {'attr': {'placeholder': form.barcode.vars.attr.placeholder|default('')}}) }}
                    <div class="ui basic label">
                        <a href="binaryeye://scan?ret={{ absolute_url(path('app_barcode_scan_result')) }}?result=%7BRESULT%7D&format=%7BFORMAT%7D">
                            <i class="barcode icon" id="barcode-scanner-icon" style="cursor: pointer;" title="Scanner un code-barres avec Binary Eye"></i>
                        </a>
                    </div>
                </div>
                {% if form.barcode.vars.help is defined and form.barcode.vars.help %}
                    <div class="ui pointing label">
                        {{ form.barcode.vars.help|trans }}
                    </div>
                {% endif %}
                {% if form.barcode.vars.errors|length > 0 %}
                    <div class="ui red pointing label">
                        {% for error in form.barcode.vars.errors %}
                            {{ error.message|trans }}
                        {% endfor %}
                    </div>
                {% endif %}
            </div>
            {{ form_row(form.enabled) }}
        </div>
    </div>
</div>

2. JavaScript pour la communication

document.addEventListener('DOMContentLoaded', function() {
    // Récupérer l'élément du champ barcode
    const barcodeInput = document.getElementById('{{ form.barcode.vars.id }}');
    
    if (barcodeInput) {
        // Vérifier s'il y a un résultat de scan dans le localStorage
        function checkForScanResult() {
            const scanResult = localStorage.getItem('barcodeScanResult');
            const scanFormat = localStorage.getItem('barcodeScanFormat');
            
            if (scanResult) {
                // Insérer le résultat dans le champ
                barcodeInput.value = scanResult;
                barcodeInput.dispatchEvent(new Event('input', { bubbles: true }));
                barcodeInput.dispatchEvent(new Event('change', { bubbles: true }));
                
                // Afficher une notification
                if (typeof $.toast !== 'undefined') {
                    $.toast({
                        message: `Code-barres scanné: ${scanResult} (${scanFormat})`,
                        type: 'success',
                        position: 'top right'
                    });
                } else {
                    console.log(`Code-barres scanné: ${scanResult} (${scanFormat})`);
                }
                
                // Nettoyer le localStorage
                localStorage.removeItem('barcodeScanResult');
                localStorage.removeItem('barcodeScanFormat');
            }
        }
        
        // Vérifier immédiatement
        checkForScanResult();
        
        // Vérifier périodiquement (toutes les 2 secondes)
        setInterval(checkForScanResult, 2000);
    }
});

Configuration de Binary Eye

1. URL Scheme personnalisée

L'application Binary Eye utilise un URL Scheme pour communiquer avec votre application :

binaryeye://scan?ret=URL_ENCODED_RETURN_URL

2. Paramètres de retour

// URL de retour avec paramètres
$returnUrl = $this->generateUrl('app_barcode_scan_result', [
    'result' => '%7BRESULT%7D',  // {RESULT} encodé
    'format' => '%7BFORMAT%7D'   // {FORMAT} encodé
], UrlGeneratorInterface::ABSOLUTE_URL);

3. Formats supportés

Binary Eye supporte de nombreux formats :

  • EAN-13 : Codes-barres produits standard
  • EAN-8 : Codes courts
  • CODE-128 : Codes alphanumériques
  • CODE-39 : Codes industriels
  • QR Code : Codes 2D
  • Data Matrix : Codes 2D industriels

Gestion des erreurs et validation

1. Validation côté serveur

<?php

namespace App\Validator;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

class BarcodeValidator extends ConstraintValidator
{
    public function validate($value, Constraint $constraint): void
    {
        if (null === $value || '' === $value) {
            return;
        }

        // Validation du format EAN-13
        if (!$this->isValidEAN13($value)) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ value }}', $value)
                ->addViolation();
        }
    }

    private function isValidEAN13(string $barcode): bool
    {
        if (strlen($barcode) !== 13 || !ctype_digit($barcode)) {
            return false;
        }

        $sum = 0;
        for ($i = 0; $i < 12; $i++) {
            $sum += (int)$barcode[$i] * ($i % 2 === 0 ? 1 : 3);
        }

        $checkDigit = (10 - ($sum % 10)) % 10;
        return $checkDigit === (int)$barcode[12];
    }
}

2. Gestion des erreurs JavaScript

// Gestion des erreurs de scan
function handleScanError(error) {
    console.error('Erreur de scan:', error);
    
    if (typeof $.toast !== 'undefined') {
        $.toast({
            message: 'Erreur lors du scan du code-barres',
            type: 'error',
            position: 'top right'
        });
    }
}

// Timeout pour éviter les scans bloqués
setTimeout(() => {
    if (!localStorage.getItem('barcodeScanResult')) {
        console.log('Aucun résultat de scan reçu');
    }
}, 10000);

Optimisations et bonnes pratiques

1. Sécurité

// Validation et nettoyage des données
public function handleScanResult(Request $request): Response
{
    $result = trim($request->query->get('result', ''));
    $format = trim($request->query->get('format', ''));
    
    // Validation des données
    if (empty($result) || strlen($result) > 255) {
        throw new BadRequestHttpException('Code-barres invalide');
    }
    
    // Nettoyage des caractères spéciaux
    $result = htmlspecialchars($result, ENT_QUOTES, 'UTF-8');
    $format = htmlspecialchars($format, ENT_QUOTES, 'UTF-8');
    
    // ... reste du code
}

2. Performance

// Debounce pour éviter les scans multiples
let scanTimeout;
function debouncedScanCheck() {
    clearTimeout(scanTimeout);
    scanTimeout = setTimeout(checkForScanResult, 100);
}

// Écouteur d'événements optimisé
window.addEventListener('storage', debouncedScanCheck);

3. Accessibilité

<!-- Bouton de scan accessible -->
<a href="binaryeye://scan?ret=..." 
   role="button" 
   aria-label="Scanner un code-barres"
   tabindex="0">
    <i class="barcode icon"></i>
</a>

Cas d'usage avancés

1. Scan en lot

<?php

namespace App\Controller;

class BatchBarcodeController extends AbstractController
{
    #[Route('/admin/batch-barcode-scan', name: 'app_batch_barcode_scan')]
    public function batchScan(Request $request): Response
    {
        $barcodes = $request->request->get('barcodes', []);
        
        foreach ($barcodes as $barcode) {
            // Traitement en lot des codes-barres
            $this->processBarcode($barcode);
        }
        
        return $this->json(['success' => true]);
    }
}

2. Intégration avec l'inventaire

<?php

namespace App\Service;

class InventoryBarcodeService
{
    public function processScannedBarcode(string $barcode): void
    {
        // Recherche automatique du produit
        $product = $this->productRepository->findByBarcode($barcode);
        
        if ($product) {
            // Mise à jour automatique du stock
            $this->updateInventory($product);
            
            // Notification si stock faible
            if ($product->getOnHand() < 10) {
                $this->notificationService->sendLowStockAlert($product);
            }
        }
    }
}

Déploiement et maintenance

1. Configuration de production

# config/packages/prod/barcode.yaml
services:
    app.barcode_controller:
        arguments:
            $debug: false
            $timeout: 5000

2. Monitoring

// Logging des scans
$this->logger->info('Barcode scan', [
    'barcode' => $result,
    'format' => $format,
    'user' => $this->getUser()->getUsername(),
    'timestamp' => new \DateTime()
]);

3. Tests automatisés

<?php

namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class BarcodeScanControllerTest extends WebTestCase
{
    public function testHandleScanResult(): void
    {
        $client = static::createClient();
        
        $client->request('GET', '/admin/barcode-scan-result', [
            'result' => '1234567890123',
            'format' => 'EAN13'
        ]);
        
        $this->assertResponseIsSuccessful();
        $this->assertStringContainsString('1234567890123', $client->getResponse()->getContent());
    }
}

Conclusion

L'intégration d'un scanner de code-barres dans Sylius offre de nombreux avantages :

Automatisation : Saisie automatique des codes produits
Précision : Élimination des erreurs de saisie manuelle
Efficacité : Accélération des opérations de gestion
Flexibilité : Support de multiples formats de codes
Intégration : Communication transparente avec l'interface admin

Cette solution utilise les standards web modernes (localStorage, postMessage) et s'intègre parfaitement dans l'écosystème Symfony/Sylius existant.

Ressources utiles :