Skip to content

Stock Import - Article Matching Engine

Overview

The article matching engine is the core component that maps supplier product identifiers to internal article numbers. It uses multiple identifiers with priority ordering and confidence scoring.

Identifier Priority

Priority Identifier Reliability Notes
1 EAN High 13-digit barcode, globally unique
2 MPN Medium Manufacturer Part Number, can have collisions
3 Article Number High Internal article number
4 Tyre24 ID High Platform reference
5 TopM ID High German software reference

Matching Algorithm

class ArticleMatchingEngine
{
    public function match(SupplierArticle $supplierArticle): MatchResult
    {
        // Try each identifier in priority order
        foreach ($this->getMatchingStrategies() as $strategy) {
            $result = $strategy->match($supplierArticle);

            if ($result->hasMatch()) {
                return $this->validateAndScore($result, $supplierArticle);
            }
        }

        return MatchResult::noMatch($supplierArticle);
    }

    private function getMatchingStrategies(): array
    {
        return [
            new EanMatchingStrategy($this->articleRepository),
            new MpnMatchingStrategy($this->articleRepository),
            new ArticleNumberMatchingStrategy($this->articleRepository),
            new Tyre24IdMatchingStrategy($this->articleRepository),
            new TopMIdMatchingStrategy($this->articleRepository),
        ];
    }

    private function validateAndScore(MatchResult $result, SupplierArticle $supplierArticle): MatchResult
    {
        $article = $result->getArticle();
        $score = 100;
        $warnings = [];

        // Cross-validate with other identifiers if available
        if ($supplierArticle->getEan() && $article->getEan()) {
            if ($supplierArticle->getEan() !== $article->getEan()) {
                $score -= 30;
                $warnings[] = 'EAN mismatch';
            }
        }

        if ($supplierArticle->getMpn() && $article->getMpn()) {
            if ($supplierArticle->getMpn() !== $article->getMpn()) {
                $score -= 20;
                $warnings[] = 'MPN mismatch';
            }
        }

        // Brand name verification
        if ($supplierArticle->getBrandName()) {
            $similarity = similar_text(
                strtolower($supplierArticle->getBrandName()),
                strtolower($article->getBrand()->getName()),
                $percent
            );

            if ($percent < 80) {
                $score -= 15;
                $warnings[] = 'Brand name differs';
            }
        }

        return $result
            ->withConfidenceScore($score)
            ->withWarnings($warnings);
    }
}

Confidence Scoring

Score Action
90-100 Auto-approve, update stock
70-89 Auto-approve with warning logged
50-69 Queue for manual review
< 50 Reject, flag for investigation

Manual Review Queue

#[ORM\Entity]
class MatchingReviewItem
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\ManyToOne(targetEntity: Supplier::class)]
    private Supplier $supplier;

    #[ORM\Column(type: 'json')]
    private array $supplierData; // Original supplier article data

    #[ORM\ManyToOne(targetEntity: Article::class)]
    private ?Article $suggestedMatch;

    #[ORM\Column]
    private int $confidenceScore;

    #[ORM\Column(type: 'json')]
    private array $warnings;

    #[ORM\Column(length: 20)]
    private string $status; // 'pending', 'approved', 'rejected', 'remapped'

    #[ORM\ManyToOne(targetEntity: Article::class)]
    private ?Article $approvedMatch; // Different if manually remapped

    #[ORM\ManyToOne(targetEntity: User::class)]
    private ?User $reviewedBy;

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $reviewedAt;
}

Matching Strategies

EAN Strategy

class EanMatchingStrategy implements MatchingStrategyInterface
{
    public function match(SupplierArticle $supplierArticle): MatchResult
    {
        $ean = $supplierArticle->getEan();

        if (!$ean || strlen($ean) !== 13) {
            return MatchResult::noMatch();
        }

        $article = $this->articleRepository->findByEan($ean);

        if ($article) {
            return MatchResult::found($article, 'EAN');
        }

        return MatchResult::noMatch();
    }
}

MPN Strategy (with extra validation)

class MpnMatchingStrategy implements MatchingStrategyInterface
{
    public function match(SupplierArticle $supplierArticle): MatchResult
    {
        $mpn = $supplierArticle->getMpn();

        if (!$mpn) {
            return MatchResult::noMatch();
        }

        // MPN can have multiple matches - need to disambiguate
        $articles = $this->articleRepository->findByMpn($mpn);

        if (count($articles) === 0) {
            return MatchResult::noMatch();
        }

        if (count($articles) === 1) {
            return MatchResult::found($articles[0], 'MPN');
        }

        // Multiple matches - try to narrow down by brand
        if ($supplierArticle->getBrandName()) {
            foreach ($articles as $article) {
                if ($this->brandMatches($supplierArticle->getBrandName(), $article->getBrand())) {
                    return MatchResult::found($article, 'MPN+Brand');
                }
            }
        }

        // Still ambiguous - flag for review
        return MatchResult::ambiguous($articles, 'MPN');
    }
}

Audit Trail

#[ORM\Entity]
class MatchingAuditLog
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\ManyToOne(targetEntity: Supplier::class)]
    private Supplier $supplier;

    #[ORM\Column(length: 50)]
    private string $supplierArticleId;

    #[ORM\ManyToOne(targetEntity: Article::class)]
    private ?Article $matchedArticle;

    #[ORM\Column(length: 20)]
    private string $matchedBy; // 'EAN', 'MPN', 'manual', etc.

    #[ORM\Column]
    private int $confidenceScore;

    #[ORM\Column(length: 20)]
    private string $action; // 'auto_approved', 'manual_approved', 'rejected'

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $createdAt;
}

Gherkin Scenarios

Feature: Article Matching
  As the stock import system
  I want to accurately match supplier articles
  So that stock levels are correctly assigned

  Scenario: Match by EAN
    Given supplier article with EAN "1234567890123"
    And internal article exists with same EAN
    When I run matching
    Then article should be matched with 100% confidence

  Scenario: Match by MPN with single result
    Given supplier article with MPN "ABC123"
    And only one internal article has MPN "ABC123"
    When I run matching
    Then article should be matched via MPN

  Scenario: Ambiguous MPN match
    Given supplier article with MPN "XYZ789"
    And two internal articles have MPN "XYZ789"
    When I run matching
    Then match should be flagged as ambiguous
    And should be queued for manual review

  Scenario: Cross-validation reduces confidence
    Given supplier article matches by MPN
    But the EAN does not match
    Then confidence score should be reduced
    And a warning should be logged

  Scenario: Low confidence triggers review
    Given a match with confidence score 55%
    Then the match should be queued for manual review
    And stock should not be updated until reviewed

  Scenario: Manual review approval
    Given a pending review item
    When admin approves the suggested match
    Then the match should be recorded
    And stock should be updated
    And audit log should show manual approval

Database Schema

-- Matching review queue
CREATE TABLE matching_review_items (
    id INT PRIMARY KEY AUTO_INCREMENT,
    supplier_id INT NOT NULL,
    supplier_data JSON NOT NULL,
    suggested_match_id VARCHAR(50),
    confidence_score INT NOT NULL,
    warnings JSON,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    approved_match_id VARCHAR(50),
    reviewed_by INT,
    reviewed_at DATETIME,
    created_at DATETIME NOT NULL,
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id),
    FOREIGN KEY (suggested_match_id) REFERENCES articles(article_number),
    FOREIGN KEY (approved_match_id) REFERENCES articles(article_number),
    FOREIGN KEY (reviewed_by) REFERENCES users(id),
    INDEX idx_status (status),
    INDEX idx_supplier (supplier_id)
);

-- Matching audit log
CREATE TABLE matching_audit_log (
    id INT PRIMARY KEY AUTO_INCREMENT,
    supplier_id INT NOT NULL,
    supplier_article_id VARCHAR(50) NOT NULL,
    matched_article_id VARCHAR(50),
    matched_by VARCHAR(20) NOT NULL,
    confidence_score INT NOT NULL,
    action VARCHAR(20) NOT NULL,
    created_at DATETIME NOT NULL,
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id),
    INDEX idx_article (matched_article_id),
    INDEX idx_created (created_at)
);

-- Supplier article identifiers (for faster lookups)
CREATE TABLE supplier_article_mappings (
    id INT PRIMARY KEY AUTO_INCREMENT,
    supplier_id INT NOT NULL,
    supplier_article_id VARCHAR(50) NOT NULL,
    article_number VARCHAR(50) NOT NULL,
    matched_by VARCHAR(20) NOT NULL,
    is_verified BOOLEAN DEFAULT FALSE,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id),
    FOREIGN KEY (article_number) REFERENCES articles(article_number),
    UNIQUE KEY (supplier_id, supplier_article_id)
);