1ff888131f
VinDecoder (deterministic, no API): - ISO 3779/3780 parsing: WMI manufacturer (~60 brands), year (cyclical with post-2010 disambiguation via position 7), region, plant, NA checksum - Strip non-VIN chars, accept dashes/spaces, reject I/O/Q per spec AiAssistantService: - Refactored provider HTTP into postClaude/postOpenAI/postGemini so both chat history and one-shot calls share the same transport - singleShot(system, userPrompt, provider?) for fire-and-forget calls - 4 specialized helpers with tight prompts: - suggestDiagnosis(WO) — diagnostician based on complaint + VIN info - suggestParts(WO, task) — OEM parts list for an operation - suggestPrice(Part) — markup recommendation with justification - vinRecommendations(vin, mileage) — scheduled maintenance from decoded VIN - monthlyUsage() — token spend MTD by provider Filament: - VehicleResource: "Decode VIN" + "AI: recomandări" actions - WorkOrderResource Edit: "AI: sugerează diagnostic" header action - PartResource: "AI: preț recomandat" action - Shared views: filament.tenant.ai-reply, filament.tenant.vin-decode - AiAssistant page shows monthly token usage banner Tests (13 new): - 8 VinDecoder unit tests with real VIN samples (Honda 2003, VW 1999, Audi 2014, Dacia, unknown WMI, lowercase/dashes, forbidden chars) - 5 AiHelpers feature tests with Http::fake covering all providers + no-key fallback + token usage aggregation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
172 lines
7.4 KiB
PHP
172 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Ai;
|
|
|
|
/**
|
|
* Deterministic VIN decoder. Extracts year, country and manufacturer from the
|
|
* 17-character Vehicle Identification Number per ISO 3779/3780. No external API.
|
|
*
|
|
* Reliable signals:
|
|
* - position 1 → world region (geographic prefix)
|
|
* - positions 1-3 → WMI (world manufacturer identifier)
|
|
* - position 10 → model year (cyclical 30-year mapping)
|
|
* - position 11 → assembly plant code
|
|
* - position 9 → check digit (NA-spec checksum, optional verification)
|
|
*
|
|
* Granular model/trim info requires a licensed database (TecDoc / NHTSA API);
|
|
* those are out of scope here. We return a "best-effort" identification.
|
|
*/
|
|
class VinDecoder
|
|
{
|
|
private const YEAR_CODES = [
|
|
'A' => [1980, 2010], 'B' => [1981, 2011], 'C' => [1982, 2012], 'D' => [1983, 2013],
|
|
'E' => [1984, 2014], 'F' => [1985, 2015], 'G' => [1986, 2016], 'H' => [1987, 2017],
|
|
'J' => [1988, 2018], 'K' => [1989, 2019], 'L' => [1990, 2020], 'M' => [1991, 2021],
|
|
'N' => [1992, 2022], 'P' => [1993, 2023], 'R' => [1994, 2024], 'S' => [1995, 2025],
|
|
'T' => [1996, 2026], 'V' => [1997, 2027], 'W' => [1998, 2028], 'X' => [1999, 2029],
|
|
'Y' => [2000, 2030], '1' => [2001, 2031], '2' => [2002, 2032], '3' => [2003, 2033],
|
|
'4' => [2004, 2034], '5' => [2005, 2035], '6' => [2006, 2036], '7' => [2007, 2037],
|
|
'8' => [2008, 2038], '9' => [2009, 2039],
|
|
];
|
|
|
|
// Region by first char (ISO 3779 broad regions).
|
|
private const REGIONS = [
|
|
'A' => 'Africa', 'B' => 'Africa', 'C' => 'Africa', 'D' => 'Africa',
|
|
'E' => 'Africa', 'F' => 'Africa', 'G' => 'Africa', 'H' => 'Africa',
|
|
'J' => 'Asia', 'K' => 'Asia', 'L' => 'Asia', 'M' => 'Asia',
|
|
'N' => 'Asia', 'P' => 'Asia', 'R' => 'Asia',
|
|
'S' => 'Europe', 'T' => 'Europe', 'U' => 'Europe', 'V' => 'Europe',
|
|
'W' => 'Europe', 'X' => 'Europe', 'Y' => 'Europe', 'Z' => 'Europe',
|
|
'1' => 'North America', '2' => 'North America', '3' => 'North America',
|
|
'4' => 'North America', '5' => 'North America',
|
|
'6' => 'Oceania', '7' => 'Oceania',
|
|
'8' => 'South America', '9' => 'South America',
|
|
];
|
|
|
|
// Selected WMI → manufacturer/country. Covers most common European/Asian/US
|
|
// brands relevant for a Moldova service shop.
|
|
private const WMI = [
|
|
// Volkswagen group
|
|
'WVW' => ['VW', 'Germany'], 'WV1' => ['VW Commercial', 'Germany'], 'WV2' => ['VW Bus', 'Germany'],
|
|
'WAU' => ['Audi', 'Germany'], 'WA1' => ['Audi SUV', 'Germany'],
|
|
'TRU' => ['Audi', 'Hungary'], 'WUA' => ['Audi Sport', 'Germany'],
|
|
'VWV' => ['VW', 'Spain'], 'VSS' => ['SEAT', 'Spain'], 'TMB' => ['Škoda', 'Czechia'],
|
|
// BMW
|
|
'WBA' => ['BMW', 'Germany'], 'WBS' => ['BMW M', 'Germany'], 'WBY' => ['BMW i', 'Germany'],
|
|
'WBX' => ['BMW X SUV', 'USA'], 'NM0' => ['BMW Mini', 'Turkey'],
|
|
// Mercedes
|
|
'WDB' => ['Mercedes-Benz', 'Germany'], 'WDC' => ['Mercedes-Benz SUV', 'USA'],
|
|
'WDD' => ['Mercedes-Benz', 'Germany'], 'WDF' => ['Mercedes-Benz Van', 'Germany'],
|
|
// Porsche
|
|
'WP0' => ['Porsche', 'Germany'], 'WP1' => ['Porsche SUV', 'Germany'],
|
|
// Opel / Vauxhall
|
|
'W0L' => ['Opel', 'Germany'], 'W0V' => ['Opel/Vauxhall', 'Germany'],
|
|
// Ford
|
|
'1FA' => ['Ford', 'USA'], '1FT' => ['Ford Truck', 'USA'], '1FM' => ['Ford SUV', 'USA'],
|
|
'WF0' => ['Ford Europe', 'Germany'],
|
|
// Honda
|
|
'1HG' => ['Honda', 'USA'], 'JHM' => ['Honda', 'Japan'], 'JHL' => ['Honda SUV', 'Japan'],
|
|
// Toyota
|
|
'JT2' => ['Toyota', 'Japan'], 'JTD' => ['Toyota', 'Japan'], 'JTE' => ['Toyota', 'Japan'],
|
|
'4T1' => ['Toyota', 'USA'], '5TD' => ['Toyota', 'USA'],
|
|
// Hyundai/Kia
|
|
'KMH' => ['Hyundai', 'Korea'], 'KNA' => ['Kia', 'Korea'], 'KND' => ['Kia SUV', 'Korea'],
|
|
// Renault/Dacia
|
|
'VF1' => ['Renault', 'France'], 'VF6' => ['Renault Trucks', 'France'],
|
|
'UU1' => ['Dacia', 'Romania'], 'UU3' => ['Dacia Pickup', 'Romania'],
|
|
// Peugeot/Citroën
|
|
'VF3' => ['Peugeot', 'France'], 'VF7' => ['Citroën', 'France'],
|
|
// Fiat group
|
|
'ZFA' => ['Fiat', 'Italy'], 'ZAR' => ['Alfa Romeo', 'Italy'], 'ZFF' => ['Ferrari', 'Italy'],
|
|
// Volvo
|
|
'YV1' => ['Volvo Cars', 'Sweden'], 'YV4' => ['Volvo SUV', 'Sweden'],
|
|
// Nissan
|
|
'JN1' => ['Nissan', 'Japan'], 'JN8' => ['Nissan SUV', 'Japan'], '1N4' => ['Nissan', 'USA'],
|
|
// Mazda
|
|
'JM1' => ['Mazda', 'Japan'], 'JMZ' => ['Mazda', 'Japan'],
|
|
// Subaru
|
|
'JF1' => ['Subaru', 'Japan'], 'JF2' => ['Subaru SUV', 'Japan'],
|
|
// Mitsubishi
|
|
'JMB' => ['Mitsubishi', 'Japan'], 'JA3' => ['Mitsubishi', 'Japan'],
|
|
// Lada / Russian
|
|
'XTA' => ['Lada/AvtoVAZ', 'Russia'], 'X4X' => ['UAZ', 'Russia'],
|
|
// Tesla
|
|
'5YJ' => ['Tesla', 'USA'], 'LRW' => ['Tesla', 'China'],
|
|
// Chinese brands
|
|
'LGW' => ['Great Wall', 'China'], 'LJV' => ['JAC', 'China'], 'LSJ' => ['MG/SAIC', 'China'],
|
|
'LB1' => ['Geely', 'China'],
|
|
];
|
|
|
|
public function decode(string $raw): array
|
|
{
|
|
$vin = preg_replace('/[^A-HJ-NPR-Z0-9]/', '', strtoupper($raw));
|
|
if (strlen($vin) !== 17) {
|
|
return [
|
|
'vin' => $vin,
|
|
'valid_length' => false,
|
|
'reason' => 'VIN must be exactly 17 characters (no I, O, Q allowed).',
|
|
];
|
|
}
|
|
|
|
$wmi = substr($vin, 0, 3);
|
|
$yearCode = $vin[9];
|
|
$plant = $vin[10];
|
|
|
|
[$manufacturer, $country] = self::WMI[$wmi] ?? [null, null];
|
|
$region = self::REGIONS[$vin[0]] ?? null;
|
|
|
|
$year = $this->resolveYear($yearCode, $vin);
|
|
|
|
return [
|
|
'vin' => $vin,
|
|
'valid_length' => true,
|
|
'wmi' => $wmi,
|
|
'region' => $region,
|
|
'country' => $country,
|
|
'manufacturer' => $manufacturer,
|
|
'year' => $year,
|
|
'plant' => $plant,
|
|
'checksum_valid' => $this->validateChecksum($vin),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Position 10 encodes year cyclically. Use position 7 as A-Z → 2010+, 0-9 → pre-2010
|
|
* disambiguator for new-spec VINs (since 2010 NHTSA spec).
|
|
*/
|
|
private function resolveYear(string $code, string $vin): ?int
|
|
{
|
|
if (! isset(self::YEAR_CODES[$code])) return null;
|
|
[$old, $new] = self::YEAR_CODES[$code];
|
|
|
|
// Position 7 alpha → post-2010 cycle; numeric → pre-2010
|
|
$p7 = $vin[6];
|
|
return ctype_alpha($p7) ? $new : $old;
|
|
}
|
|
|
|
/**
|
|
* ISO 3779 / NA-spec checksum. Position 9 = mod-11 check digit (X = 10).
|
|
* Optional — many European/Asian manufacturers don't follow the spec.
|
|
*/
|
|
private function validateChecksum(string $vin): bool
|
|
{
|
|
$weights = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2];
|
|
$values = [
|
|
'A' => 1, 'B' => 2, 'C' => 3, 'D' => 4, 'E' => 5, 'F' => 6, 'G' => 7, 'H' => 8,
|
|
'J' => 1, 'K' => 2, 'L' => 3, 'M' => 4, 'N' => 5, 'P' => 7, 'R' => 9,
|
|
'S' => 2, 'T' => 3, 'U' => 4, 'V' => 5, 'W' => 6, 'X' => 7, 'Y' => 8, 'Z' => 9,
|
|
];
|
|
|
|
$sum = 0;
|
|
for ($i = 0; $i < 17; $i++) {
|
|
$c = $vin[$i];
|
|
$v = ctype_digit($c) ? (int) $c : ($values[$c] ?? null);
|
|
if ($v === null) return false;
|
|
$sum += $v * $weights[$i];
|
|
}
|
|
$check = $sum % 11;
|
|
$expected = $check === 10 ? 'X' : (string) $check;
|
|
return $vin[8] === $expected;
|
|
}
|
|
}
|