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