Просмотр исходного кода

Add a VIN lookup button to the vehicle form (#435)

Bernt Christian Egeland 1 день назад
Родитель
Сommit
f19408ccbe

+ 4 - 1
messages/de/vehicles.json

@@ -369,7 +369,10 @@
     "inspectionDue": "Nächste Hauptuntersuchung",
     "inspectionDue": "Nächste Hauptuntersuchung",
     "inspectionDueHint": "Datum eintragen, wenn kein Register es liefert. Erinnerungen und Dashboard nutzen es.",
     "inspectionDueHint": "Datum eintragen, wenn kein Register es liefert. Erinnerungen und Dashboard nutzen es.",
     "inspectionDueFromRegistry": "Aus dem Fahrzeugregister. Hier ändern, um es bis zum nächsten Abgleich zu überschreiben.",
     "inspectionDueFromRegistry": "Aus dem Fahrzeugregister. Hier ändern, um es bis zum nächsten Abgleich zu überschreiben.",
-    "lookupEnterPlateOrVin": "Gib zuerst ein Kennzeichen oder eine FIN ein"
+    "lookupVin": "FIN nachschlagen",
+    "lookupEnterVin": "Geben Sie zuerst eine FIN ein",
+    "lookupVinNotFound": "Zu dieser FIN wurde kein Fahrzeug gefunden",
+    "lookupVinFailed": "FIN konnte nicht nachgeschlagen werden"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Fahrzeug archivieren",
     "title": "Fahrzeug archivieren",

+ 4 - 1
messages/en/vehicles.json

@@ -369,7 +369,10 @@
     "inspectionDue": "Next periodic inspection",
     "inspectionDue": "Next periodic inspection",
     "inspectionDueHint": "Type the date if no registry supplies it. Reminders and the dashboard use it.",
     "inspectionDueHint": "Type the date if no registry supplies it. Reminders and the dashboard use it.",
     "inspectionDueFromRegistry": "Filled from the vehicle registry. Change it here to override until the next sync.",
     "inspectionDueFromRegistry": "Filled from the vehicle registry. Change it here to override until the next sync.",
-    "lookupEnterPlateOrVin": "Type a plate or VIN first"
+    "lookupVin": "Look up VIN",
+    "lookupEnterVin": "Type a VIN first",
+    "lookupVinNotFound": "No vehicle found for that VIN",
+    "lookupVinFailed": "Could not look up the VIN"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Archive Vehicle",
     "title": "Archive Vehicle",

+ 4 - 1
messages/es/vehicles.json

@@ -369,7 +369,10 @@
     "inspectionDue": "Próxima inspección periódica",
     "inspectionDue": "Próxima inspección periódica",
     "inspectionDueHint": "Escribe la fecha si ningún registro la aporta. Los recordatorios y el panel la usan.",
     "inspectionDueHint": "Escribe la fecha si ningún registro la aporta. Los recordatorios y el panel la usan.",
     "inspectionDueFromRegistry": "Tomada del registro de vehículos. Cámbiala aquí para anularla hasta la próxima sincronización.",
     "inspectionDueFromRegistry": "Tomada del registro de vehículos. Cámbiala aquí para anularla hasta la próxima sincronización.",
-    "lookupEnterPlateOrVin": "Escribe primero una matrícula o un VIN"
+    "lookupVin": "Buscar VIN",
+    "lookupEnterVin": "Escribe primero un VIN",
+    "lookupVinNotFound": "No se encontró ningún vehículo con ese VIN",
+    "lookupVinFailed": "No se pudo buscar el VIN"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Archivar vehículo",
     "title": "Archivar vehículo",

+ 4 - 1
messages/fr/vehicles.json

@@ -369,7 +369,10 @@
     "inspectionDue": "Prochain contrôle périodique",
     "inspectionDue": "Prochain contrôle périodique",
     "inspectionDueHint": "Saisissez la date si aucun registre ne la fournit. Les rappels et le tableau de bord l'utilisent.",
     "inspectionDueHint": "Saisissez la date si aucun registre ne la fournit. Les rappels et le tableau de bord l'utilisent.",
     "inspectionDueFromRegistry": "Issue du registre des véhicules. Modifiez-la ici pour la remplacer jusqu'à la prochaine synchronisation.",
     "inspectionDueFromRegistry": "Issue du registre des véhicules. Modifiez-la ici pour la remplacer jusqu'à la prochaine synchronisation.",
-    "lookupEnterPlateOrVin": "Saisissez d'abord une plaque ou un VIN"
+    "lookupVin": "Rechercher le NIV",
+    "lookupEnterVin": "Saisissez d'abord un NIV",
+    "lookupVinNotFound": "Aucun véhicule trouvé pour ce NIV",
+    "lookupVinFailed": "Impossible de rechercher le NIV"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Archiver le véhicule",
     "title": "Archiver le véhicule",

+ 4 - 1
messages/it/vehicles.json

@@ -369,7 +369,10 @@
     "inspectionDue": "Prossima revisione periodica",
     "inspectionDue": "Prossima revisione periodica",
     "inspectionDueHint": "Inserisci la data se nessun registro la fornisce. Promemoria e dashboard la usano.",
     "inspectionDueHint": "Inserisci la data se nessun registro la fornisce. Promemoria e dashboard la usano.",
     "inspectionDueFromRegistry": "Presa dal registro veicoli. Modificala qui per sovrascriverla fino alla prossima sincronizzazione.",
     "inspectionDueFromRegistry": "Presa dal registro veicoli. Modificala qui per sovrascriverla fino alla prossima sincronizzazione.",
-    "lookupEnterPlateOrVin": "Scrivi prima una targa o un VIN"
+    "lookupVin": "Cerca VIN",
+    "lookupEnterVin": "Inserisci prima un VIN",
+    "lookupVinNotFound": "Nessun veicolo trovato per questo VIN",
+    "lookupVinFailed": "Impossibile cercare il VIN"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Archivia Veicolo",
     "title": "Archivia Veicolo",

+ 4 - 1
messages/lt/vehicles.json

@@ -369,7 +369,10 @@
     "inspectionDue": "Kita periodinė apžiūra",
     "inspectionDue": "Kita periodinė apžiūra",
     "inspectionDueHint": "Įveskite datą, jei jos nepateikia registras. Ją naudoja priminimai ir skydelis.",
     "inspectionDueHint": "Įveskite datą, jei jos nepateikia registras. Ją naudoja priminimai ir skydelis.",
     "inspectionDueFromRegistry": "Iš transporto priemonių registro. Pakeiskite čia, kad perrašytumėte iki kito sinchronizavimo.",
     "inspectionDueFromRegistry": "Iš transporto priemonių registro. Pakeiskite čia, kad perrašytumėte iki kito sinchronizavimo.",
-    "lookupEnterPlateOrVin": "Pirmiausia įveskite numerį arba VIN"
+    "lookupVin": "Ieškoti pagal VIN",
+    "lookupEnterVin": "Pirmiausia įveskite VIN",
+    "lookupVinNotFound": "Pagal šį VIN transporto priemonė nerasta",
+    "lookupVinFailed": "Nepavyko rasti VIN"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Archyvuoti transporto priemonę",
     "title": "Archyvuoti transporto priemonę",

+ 4 - 1
messages/nb/vehicles.json

@@ -369,7 +369,10 @@
     "inspectionDue": "Neste EU-kontroll",
     "inspectionDue": "Neste EU-kontroll",
     "inspectionDueHint": "Skriv inn datoen hvis ingen register leverer den. Påminnelser og dashbordet bruker den.",
     "inspectionDueHint": "Skriv inn datoen hvis ingen register leverer den. Påminnelser og dashbordet bruker den.",
     "inspectionDueFromRegistry": "Hentet fra kjøretøyregisteret. Endre her for å overstyre til neste synkronisering.",
     "inspectionDueFromRegistry": "Hentet fra kjøretøyregisteret. Endre her for å overstyre til neste synkronisering.",
-    "lookupEnterPlateOrVin": "Skriv inn et skilt eller VIN først"
+    "lookupVin": "Slå opp VIN",
+    "lookupEnterVin": "Skriv inn et VIN først",
+    "lookupVinNotFound": "Fant ingen kjøretøy med det VIN-nummeret",
+    "lookupVinFailed": "Kunne ikke slå opp VIN-nummeret"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Arkiver kjøretøy",
     "title": "Arkiver kjøretøy",

+ 4 - 1
messages/nl/vehicles.json

@@ -369,7 +369,10 @@
     "inspectionDue": "Volgende periodieke keuring",
     "inspectionDue": "Volgende periodieke keuring",
     "inspectionDueHint": "Vul de datum in als geen register die levert. Herinneringen en het dashboard gebruiken hem.",
     "inspectionDueHint": "Vul de datum in als geen register die levert. Herinneringen en het dashboard gebruiken hem.",
     "inspectionDueFromRegistry": "Uit het voertuigregister. Wijzig hier om te overschrijven tot de volgende synchronisatie.",
     "inspectionDueFromRegistry": "Uit het voertuigregister. Wijzig hier om te overschrijven tot de volgende synchronisatie.",
-    "lookupEnterPlateOrVin": "Typ eerst een kenteken of VIN"
+    "lookupVin": "VIN opzoeken",
+    "lookupEnterVin": "Voer eerst een VIN in",
+    "lookupVinNotFound": "Geen voertuig gevonden voor dit VIN",
+    "lookupVinFailed": "VIN kon niet worden opgezocht"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Voertuig archiveren",
     "title": "Voertuig archiveren",

+ 4 - 1
messages/pl/vehicles.json

@@ -369,7 +369,10 @@
     "inspectionDue": "Następny przegląd okresowy",
     "inspectionDue": "Następny przegląd okresowy",
     "inspectionDueHint": "Wpisz datę, jeśli żaden rejestr jej nie dostarcza. Używają jej przypomnienia i pulpit.",
     "inspectionDueHint": "Wpisz datę, jeśli żaden rejestr jej nie dostarcza. Używają jej przypomnienia i pulpit.",
     "inspectionDueFromRegistry": "Z rejestru pojazdów. Zmień tutaj, aby nadpisać do następnej synchronizacji.",
     "inspectionDueFromRegistry": "Z rejestru pojazdów. Zmień tutaj, aby nadpisać do następnej synchronizacji.",
-    "lookupEnterPlateOrVin": "Najpierw wpisz rejestrację lub VIN"
+    "lookupVin": "Sprawdź VIN",
+    "lookupEnterVin": "Najpierw wpisz numer VIN",
+    "lookupVinNotFound": "Nie znaleziono pojazdu o tym numerze VIN",
+    "lookupVinFailed": "Nie udało się sprawdzić numeru VIN"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Archiwizuj pojazd",
     "title": "Archiwizuj pojazd",

+ 4 - 1
messages/pt-BR/vehicles.json

@@ -369,7 +369,10 @@
     "inspectionDue": "Próxima inspeção periódica",
     "inspectionDue": "Próxima inspeção periódica",
     "inspectionDueHint": "Digite a data se nenhum registro a fornecer. Lembretes e o painel a usam.",
     "inspectionDueHint": "Digite a data se nenhum registro a fornecer. Lembretes e o painel a usam.",
     "inspectionDueFromRegistry": "Obtida do registro de veículos. Altere aqui para substituir até a próxima sincronização.",
     "inspectionDueFromRegistry": "Obtida do registro de veículos. Altere aqui para substituir até a próxima sincronização.",
-    "lookupEnterPlateOrVin": "Digite uma placa ou um VIN primeiro"
+    "lookupVin": "Consultar chassi",
+    "lookupEnterVin": "Digite um chassi primeiro",
+    "lookupVinNotFound": "Nenhum veículo encontrado para esse chassi",
+    "lookupVinFailed": "Não foi possível consultar o chassi"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Arquivar Veículo",
     "title": "Arquivar Veículo",

+ 4 - 1
messages/pt-PT/vehicles.json

@@ -369,7 +369,10 @@
     "inspectionDue": "Próxima inspeção periódica",
     "inspectionDue": "Próxima inspeção periódica",
     "inspectionDueHint": "Introduza a data se nenhum registo a fornecer. Os lembretes e o painel utilizam-na.",
     "inspectionDueHint": "Introduza a data se nenhum registo a fornecer. Os lembretes e o painel utilizam-na.",
     "inspectionDueFromRegistry": "Obtida do registo automóvel. Altere-a aqui para a substituir até à próxima sincronização.",
     "inspectionDueFromRegistry": "Obtida do registo automóvel. Altere-a aqui para a substituir até à próxima sincronização.",
-    "lookupEnterPlateOrVin": "Introduza primeiro uma matrícula ou um VIN"
+    "lookupVin": "Consultar VIN",
+    "lookupEnterVin": "Introduza primeiro um VIN",
+    "lookupVinNotFound": "Nenhuma viatura encontrada para esse VIN",
+    "lookupVinFailed": "Não foi possível consultar o VIN"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Arquivar Viatura",
     "title": "Arquivar Viatura",

+ 4 - 1
messages/ru/vehicles.json

@@ -369,7 +369,10 @@
     "inspectionDue": "Следующий техосмотр",
     "inspectionDue": "Следующий техосмотр",
     "inspectionDueHint": "Введите дату, если её не даёт реестр. Её используют напоминания и панель.",
     "inspectionDueHint": "Введите дату, если её не даёт реестр. Её используют напоминания и панель.",
     "inspectionDueFromRegistry": "Из реестра транспортных средств. Измените здесь, чтобы переопределить до следующей синхронизации.",
     "inspectionDueFromRegistry": "Из реестра транспортных средств. Измените здесь, чтобы переопределить до следующей синхронизации.",
-    "lookupEnterPlateOrVin": "Сначала введите номер или VIN"
+    "lookupVin": "Найти по VIN",
+    "lookupEnterVin": "Сначала введите VIN",
+    "lookupVinNotFound": "Автомобиль с таким VIN не найден",
+    "lookupVinFailed": "Не удалось найти VIN"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Архивировать транспортное средство",
     "title": "Архивировать транспортное средство",

+ 4 - 1
messages/tr/vehicles.json

@@ -369,7 +369,10 @@
     "inspectionDue": "Sonraki periyodik muayene",
     "inspectionDue": "Sonraki periyodik muayene",
     "inspectionDueHint": "Bir sicil sağlamıyorsa tarihi yazın. Hatırlatmalar ve pano bunu kullanır.",
     "inspectionDueHint": "Bir sicil sağlamıyorsa tarihi yazın. Hatırlatmalar ve pano bunu kullanır.",
     "inspectionDueFromRegistry": "Araç sicilinden alındı. Bir sonraki eşitlemeye kadar geçersiz kılmak için burada değiştirin.",
     "inspectionDueFromRegistry": "Araç sicilinden alındı. Bir sonraki eşitlemeye kadar geçersiz kılmak için burada değiştirin.",
-    "lookupEnterPlateOrVin": "Önce bir plaka veya VIN yazın"
+    "lookupVin": "VIN sorgula",
+    "lookupEnterVin": "Önce bir VIN yazın",
+    "lookupVinNotFound": "Bu VIN ile araç bulunamadı",
+    "lookupVinFailed": "VIN sorgulanamadı"
   },
   },
   "archiveDialog": {
   "archiveDialog": {
     "title": "Aracı Arşivle",
     "title": "Aracı Arşivle",

+ 149 - 0
src/__tests__/features/integrations/vin-lookup.test.ts

@@ -0,0 +1,149 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('@/lib/features', () => ({ getFeatures: vi.fn() }))
+vi.mock('@/features/integrations/Lib/inspection-sync', async (importOriginal) => ({
+  ...(await importOriginal<object>()),
+  recordRegistryAnswer: vi.fn(async () => undefined),
+}))
+vi.mock('@/features/integrations/Lib/vehicle-lookup', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('@/features/integrations/Lib/vehicle-lookup')>()
+  return { ...actual, askRegistry: vi.fn() }
+})
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn(async () => ({ isSuperAdmin: false })) },
+    vehicle: { findFirst: vi.fn() },
+    integrationConnection: { findMany: vi.fn() },
+    appSetting: { findUnique: vi.fn() },
+  },
+}))
+
+import {
+  isVehicleLookupAvailable,
+  lookupVehicle,
+} from '@/features/integrations/Actions/vehicleLookupActions'
+import { recordRegistryAnswer } from '@/features/integrations/Lib/inspection-sync'
+import { askRegistry, findLookupConnection } from '@/features/integrations/Lib/vehicle-lookup'
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { getFeatures } from '@/lib/features'
+
+const VIN = '1HGCM82633A004352'
+
+function connected(...connectorIds: string[]) {
+  vi.mocked(db.integrationConnection.findMany).mockResolvedValue(
+    connectorIds.map((connectorId) => ({ id: `conn-${connectorId}`, connectorId })) as never
+  )
+}
+
+function country(code: string) {
+  vi.mocked(db.appSetting.findUnique).mockResolvedValue({ value: code } as never)
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'u1', email: 'a@b.c', name: 'A', isSuperAdmin: false },
+  } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: 'org1',
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as never)
+  vi.mocked(getFeatures).mockResolvedValue({ integrations: true } as never)
+  vi.mocked(db.vehicle.findFirst).mockResolvedValue({ id: 'v1' } as never)
+  country('US')
+})
+
+describe('findLookupConnection', () => {
+  it('sends a VIN to the decoder and a plate to the registry when a US shop has both', async () => {
+    connected('regcheck', 'nhtsa')
+    expect(await findLookupConnection('org1', 'vin')).toMatchObject({ connectorId: 'nhtsa' })
+    expect(await findLookupConnection('org1', 'plate')).toMatchObject({ connectorId: 'regcheck' })
+  })
+
+  it('offers no plate lookup when the only connector decodes VINs', async () => {
+    connected('nhtsa')
+    expect(await findLookupConnection('org1', 'plate')).toBeNull()
+    expect(await findLookupConnection('org1')).toBeNull()
+  })
+
+  it('treats a registry that answers to both as either', async () => {
+    country('NO')
+    connected('vegvesen')
+    expect(await findLookupConnection('org1', 'vin')).toMatchObject({ connectorId: 'vegvesen' })
+    expect(await findLookupConnection('org1', 'plate')).toMatchObject({ connectorId: 'vegvesen' })
+  })
+
+  it('offers no VIN lookup for plate-only registries', async () => {
+    country('NL')
+    connected('rdw', 'openapi-automotive')
+    expect(await findLookupConnection('org1', 'vin')).toBeNull()
+  })
+})
+
+describe('isVehicleLookupAvailable', () => {
+  it('reports plate and VIN separately', async () => {
+    connected('nhtsa')
+    const res = await isVehicleLookupAvailable()
+    expect(res).toMatchObject({ success: true, data: { plate: false, vin: true } })
+  })
+
+  it('offers neither when the plan has no integrations', async () => {
+    vi.mocked(getFeatures).mockResolvedValue({ integrations: false } as never)
+    connected('nhtsa', 'regcheck')
+    const res = await isVehicleLookupAvailable()
+    expect(res).toMatchObject({ success: true, data: { plate: false, vin: false } })
+  })
+})
+
+describe('lookupVehicle by VIN', () => {
+  it('asks the decoder with the VIN alone and records nothing on the vehicle', async () => {
+    connected('regcheck', 'nhtsa')
+    vi.mocked(askRegistry).mockResolvedValue({
+      result: { make: 'Honda', model: 'Accord EX', year: 2003, vin: VIN, engineCode: 'J30A4' },
+      source: 'NHTSA (USA)',
+      connectorId: 'nhtsa',
+    })
+    const res = await lookupVehicle({ by: 'vin', value: VIN, vehicleId: 'v1' })
+    expect(askRegistry).toHaveBeenCalledWith('conn-nhtsa', { vin: VIN })
+    expect(res).toMatchObject({
+      success: true,
+      data: { make: 'Honda', engineCode: 'J30A4', source: 'NHTSA (USA)' },
+    })
+    // A decoder has no registration to record, and would overwrite the registry's.
+    expect(recordRegistryAnswer).not.toHaveBeenCalled()
+  })
+
+  it('still records what a registry says when it was asked by VIN', async () => {
+    country('NO')
+    connected('vegvesen')
+    vi.mocked(askRegistry).mockResolvedValue({
+      result: { make: 'Toyota', vin: VIN, inspectionDue: '2027-04-26' },
+      source: 'Statens vegvesen',
+      connectorId: 'vegvesen',
+    })
+    await lookupVehicle({ by: 'vin', value: VIN, vehicleId: 'v1' })
+    expect(askRegistry).toHaveBeenCalledWith('conn-vegvesen', { vin: VIN })
+    expect(recordRegistryAnswer).toHaveBeenCalledTimes(1)
+  })
+
+  it('says so when nothing connected decodes VINs', async () => {
+    connected('regcheck')
+    const res = await lookupVehicle({ by: 'vin', value: VIN })
+    expect(res).toMatchObject({ success: false })
+    expect(askRegistry).not.toHaveBeenCalled()
+  })
+
+  it('refuses an empty VIN before asking anyone', async () => {
+    connected('nhtsa')
+    const res = await lookupVehicle({ by: 'vin', value: '   ' })
+    expect(res).toMatchObject({ success: false })
+    expect(askRegistry).not.toHaveBeenCalled()
+  })
+})

+ 46 - 22
src/features/integrations/Actions/vehicleLookupActions.ts

@@ -5,8 +5,13 @@ import { getFeatures } from '@/lib/features'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
 import { recordRegistryAnswer } from '../Lib/inspection-sync'
 import { recordRegistryAnswer } from '../Lib/inspection-sync'
-import type { VehicleLookupResult } from '../Lib/types'
-import { askRegistry, findLookupConnection, withinLookupBudget } from '../Lib/vehicle-lookup'
+import type { VehicleLookupKey, VehicleLookupResult } from '../Lib/types'
+import {
+  askRegistry,
+  findLookupConnection,
+  lookupKeys,
+  withinLookupBudget,
+} from '../Lib/vehicle-lookup'
 
 
 /**
 /**
  * The form's plate and VIN lookups. The registry logic lives in
  * The form's plate and VIN lookups. The registry logic lives in
@@ -20,43 +25,62 @@ export interface VehicleLookup extends VehicleLookupResult {
   source: string
   source: string
 }
 }
 
 
-/** Whether the form should offer a lookup at all: plan on, registry connected. */
+/**
+ * Which lookups the form should offer: plan on, and a connected registry that
+ * answers to a plate, a VIN, or both.
+ */
 export async function isVehicleLookupAvailable() {
 export async function isVehicleLookupAvailable() {
   return withAuth(
   return withAuth(
-    async ({ organizationId }) => {
+    async ({ organizationId }): Promise<Record<VehicleLookupKey, boolean>> => {
       const features = await getFeatures(organizationId)
       const features = await getFeatures(organizationId)
-      if (!features.integrations) return false
-      return (await findLookupConnection(organizationId)) !== null
+      if (!features.integrations) return { plate: false, vin: false }
+      const [plate, vin] = await Promise.all([
+        findLookupConnection(organizationId, 'plate'),
+        findLookupConnection(organizationId, 'vin'),
+      ])
+      return { plate: plate !== null, vin: vin !== null }
     },
     },
     { requiredPermissions: READ_VEHICLES }
     { requiredPermissions: READ_VEHICLES }
   )
   )
 }
 }
 
 
 /**
 /**
- * One lookup for the form. When the vehicle already exists and is this
- * organisation's, what the registry said is also recorded on it, so the
- * inspection date lands without waiting for the next scheduled pass.
+ * One lookup for the form, by plate or by VIN, sent to a registry that
+ * answers to it. When the vehicle already exists and is this organisation's,
+ * what the registry said is also recorded on it, so the inspection date
+ * lands without waiting for the next scheduled pass.
  */
  */
-export async function lookupVehicle(query: { plate?: string; vin?: string; vehicleId?: string }) {
+export async function lookupVehicle(query: {
+  by: VehicleLookupKey
+  value: string
+  vehicleId?: string
+}) {
   return withAuth(
   return withAuth(
     async ({ organizationId }): Promise<VehicleLookup | null> => {
     async ({ organizationId }): Promise<VehicleLookup | null> => {
-      const plate = query.plate?.trim() ?? ''
-      const vin = query.vin?.trim() ?? ''
-      if (!plate && !vin) throw new Error('A plate or VIN is required')
-      if (plate.length > 16 || vin.length > 32)
-        throw new Error('That does not look like a plate or VIN')
+      const by: VehicleLookupKey = query.by === 'vin' ? 'vin' : 'plate'
+      const value = query.value?.trim() ?? ''
+      if (!value) throw new Error(by === 'vin' ? 'A VIN is required' : 'A plate is required')
+      if (value.length > (by === 'vin' ? 32 : 16))
+        throw new Error(
+          by === 'vin' ? 'That does not look like a VIN' : 'That does not look like a plate'
+        )
       const features = await getFeatures(organizationId)
       const features = await getFeatures(organizationId)
       if (!features.integrations) throw new Error('Integrations are not included in your plan')
       if (!features.integrations) throw new Error('Integrations are not included in your plan')
-      const target = await findLookupConnection(organizationId)
-      if (!target) throw new Error('No vehicle registry is connected')
+      const target = await findLookupConnection(organizationId, by)
+      if (!target)
+        throw new Error(
+          by === 'vin'
+            ? 'No connected integration decodes VINs'
+            : 'No vehicle registry is connected'
+        )
       if (!withinLookupBudget(organizationId))
       if (!withinLookupBudget(organizationId))
         throw new Error('Too many lookups, wait a minute and try again')
         throw new Error('Too many lookups, wait a minute and try again')
 
 
-      const answer = await askRegistry(target.id, {
-        plate: plate || undefined,
-        vin: vin || undefined,
-      })
-      if (query.vehicleId) {
+      const answer = await askRegistry(target.id, { [by]: value })
+      // A VIN decoder such as NHTSA knows the model, not this vehicle's
+      // registration, so it has no inspection status to record, and must not
+      // replace the one a registry wrote.
+      if (query.vehicleId && lookupKeys(target.connectorId).includes('plate')) {
         const owned = await db.vehicle.findFirst({
         const owned = await db.vehicle.findFirst({
           where: { id: query.vehicleId, organizationId },
           where: { id: query.vehicleId, organizationId },
           select: { id: true },
           select: { id: true },

+ 7 - 0
src/features/integrations/Lib/types.ts

@@ -126,6 +126,11 @@ export interface ConnectorManifest {
   auth: AuthSpec
   auth: AuthSpec
   /** Capability ids such as 'calendar.push'. Shown as badges; used by the app to find a provider. */
   /** Capability ids such as 'calendar.push'. Shown as badges; used by the app to find a provider. */
   capabilities: string[]
   capabilities: string[]
+  /**
+   * What a 'vehicle.lookup' connector answers to. Plate only when left out;
+   * the form's VIN button is offered only by connectors that list 'vin'.
+   */
+  lookupBy?: VehicleLookupKey[]
   /**
   /**
    * The video call product a person can add to a work order from its page,
    * The video call product a person can add to a work order from its page,
    * as a key under integrations.meeting: 'teams', 'google-meet', 'zoom'.
    * as a key under integrations.meeting: 'teams', 'google-meet', 'zoom'.
@@ -237,6 +242,8 @@ export type JobHandler = (
 ) => Promise<JobOutcome | void>
 ) => Promise<JobOutcome | void>
 
 
 /** What a vehicle registry is asked for: a plate, a VIN, or both. */
 /** What a vehicle registry is asked for: a plate, a VIN, or both. */
+export type VehicleLookupKey = 'plate' | 'vin'
+
 export interface VehicleLookupQuery {
 export interface VehicleLookupQuery {
   plate?: string
   plate?: string
   vin?: string
   vin?: string

+ 14 - 6
src/features/integrations/Lib/vehicle-lookup.ts

@@ -2,7 +2,7 @@ import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { getManifest } from '@/integrations/registry'
 import { getManifest } from '@/integrations/registry'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { loadConnection } from './connections'
 import { loadConnection } from './connections'
-import type { VehicleLookupQuery, VehicleLookupResult } from './types'
+import type { VehicleLookupKey, VehicleLookupQuery, VehicleLookupResult } from './types'
 
 
 /**
 /**
  * Plate and VIN lookups against whichever vehicle registry the workshop has
  * Plate and VIN lookups against whichever vehicle registry the workshop has
@@ -33,12 +33,18 @@ export function withinLookupBudget(organizationId: string): boolean {
   return entry.count <= LOOKUPS_PER_MINUTE
   return entry.count <= LOOKUPS_PER_MINUTE
 }
 }
 
 
+/** What the connector answers to; plate only unless its manifest says otherwise. */
+export function lookupKeys(connectorId: string): VehicleLookupKey[] {
+  return getManifest(connectorId)?.lookupBy ?? ['plate']
+}
+
 /**
 /**
- * The active connection that can answer, preferring one for the workshop's
- * own country when more than one registry is connected.
+ * The active connection that can answer a plate or a VIN, preferring one for
+ * the workshop's own country when more than one registry is connected.
  */
  */
 export async function findLookupConnection(
 export async function findLookupConnection(
-  organizationId: string
+  organizationId: string,
+  by: VehicleLookupKey = 'plate'
 ): Promise<{ id: string; connectorId: string } | null> {
 ): Promise<{ id: string; connectorId: string } | null> {
   const [rows, countrySetting] = await Promise.all([
   const [rows, countrySetting] = await Promise.all([
     db.integrationConnection.findMany({
     db.integrationConnection.findMany({
@@ -53,8 +59,10 @@ export async function findLookupConnection(
     }),
     }),
   ])
   ])
   const country = countrySetting?.value?.toUpperCase() ?? null
   const country = countrySetting?.value?.toUpperCase() ?? null
-  const candidates = rows.filter((r) =>
-    getManifest(r.connectorId)?.capabilities.includes(LOOKUP_CAPABILITY)
+  const candidates = rows.filter(
+    (r) =>
+      getManifest(r.connectorId)?.capabilities.includes(LOOKUP_CAPABILITY) &&
+      lookupKeys(r.connectorId).includes(by)
   )
   )
   if (candidates.length === 0) return null
   if (candidates.length === 0) return null
   const local = candidates.find((r) => {
   const local = candidates.find((r) => {

+ 43 - 27
src/features/vehicles/Components/VehicleForm.tsx

@@ -39,7 +39,7 @@ import { inspectionDueInput } from '../Lib/inspectionDueInput'
 import { createVehicle, updateVehicle } from '../Actions/vehicleActions'
 import { createVehicle, updateVehicle } from '../Actions/vehicleActions'
 import type { VehicleDocumentScan } from '../Actions/aiAnalyzeVehicleDocument'
 import type { VehicleDocumentScan } from '../Actions/aiAnalyzeVehicleDocument'
 import { ScanDocumentButton } from './ScanDocumentButton'
 import { ScanDocumentButton } from './ScanDocumentButton'
-import { PlateLookupButton } from './PlateLookupButton'
+import { VehicleLookupButton } from './VehicleLookupButton'
 import type { VehicleLookup } from '@/features/integrations/Actions/vehicleLookupActions'
 import type { VehicleLookup } from '@/features/integrations/Actions/vehicleLookupActions'
 import { nameSimilarity } from '@/lib/name-similarity'
 import { nameSimilarity } from '@/lib/name-similarity'
 import { Camera, Check, ChevronsUpDown, Loader2, Plus, X } from 'lucide-react'
 import { Camera, Check, ChevronsUpDown, Loader2, Plus, X } from 'lucide-react'
@@ -259,11 +259,13 @@ export function VehicleForm({
 
 
   /**
   /**
    * A registry answer fills the same fields as a scanned document, plus the
    * A registry answer fills the same fields as a scanned document, plus the
-   * gearbox, which papers rarely state. The select only moves while it still
+   * gearbox and engine code, which papers rarely state. The select only moves while it still
    * holds the value the form opened with.
    * holds the value the form opened with.
    */
    */
   const applyLookup = (data: VehicleLookup) => {
   const applyLookup = (data: VehicleLookup) => {
     applyScan(data)
     applyScan(data)
+    const engineCode = formRef.current?.elements.namedItem('engineCode') as HTMLInputElement | null
+    if (data.engineCode && engineCode && !engineCode.value) engineCode.value = data.engineCode
     if (data.inspectionDue) {
     if (data.inspectionDue) {
       const input = formRef.current?.elements.namedItem(
       const input = formRef.current?.elements.namedItem(
         'inspectionDueAt'
         'inspectionDueAt'
@@ -625,16 +627,9 @@ export function VehicleForm({
                 </div>
                 </div>
               </div>
               </div>
 
 
-              <div className="grid grid-cols-2 gap-4">
-                <div className="space-y-2">
-                  <Label htmlFor="vin">{isMarine ? t('vinMarine') : t('vin')}</Label>
-                  <Input
-                    id="vin"
-                    name="vin"
-                    placeholder="1HGCM82633A004352"
-                    defaultValue={vehicle?.vin ?? ''}
-                  />
-                </div>
+              {/* Plate and VIN a row each: side by side, with a lookup button in each,
+                  the VIN was too narrow to show all 17 characters. */}
+              <div className="grid gap-4">
                 <div className="space-y-2">
                 <div className="space-y-2">
                   <Label htmlFor="licensePlate">
                   <Label htmlFor="licensePlate">
                     {isMarine ? t('licensePlateMarine') : t('licensePlate')}
                     {isMarine ? t('licensePlateMarine') : t('licensePlate')}
@@ -648,15 +643,35 @@ export function VehicleForm({
                       className="flex-1"
                       className="flex-1"
                     />
                     />
                     {!isMarine && (
                     {!isMarine && (
-                      <PlateLookupButton
-                        getPlate={() =>
+                      <VehicleLookupButton
+                        by="plate"
+                        getValue={() =>
                           (
                           (
                             formRef.current?.elements.namedItem(
                             formRef.current?.elements.namedItem(
                               'licensePlate'
                               'licensePlate'
                             ) as HTMLInputElement | null
                             ) as HTMLInputElement | null
                           )?.value ?? ''
                           )?.value ?? ''
                         }
                         }
-                        getVin={() =>
+                        onFound={applyLookup}
+                        vehicleId={vehicle?.id}
+                      />
+                    )}
+                  </div>
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="vin">{isMarine ? t('vinMarine') : t('vin')}</Label>
+                  <div className="flex gap-2">
+                    <Input
+                      id="vin"
+                      name="vin"
+                      placeholder="1HGCM82633A004352"
+                      defaultValue={vehicle?.vin ?? ''}
+                      className="flex-1"
+                    />
+                    {!isMarine && (
+                      <VehicleLookupButton
+                        by="vin"
+                        getValue={() =>
                           (formRef.current?.elements.namedItem('vin') as HTMLInputElement | null)
                           (formRef.current?.elements.namedItem('vin') as HTMLInputElement | null)
                             ?.value ?? ''
                             ?.value ?? ''
                         }
                         }
@@ -665,19 +680,20 @@ export function VehicleForm({
                       />
                       />
                     )}
                     )}
                   </div>
                   </div>
-                  {lookupNote && (
-                    <p className="text-xs text-muted-foreground">
-                      {lookupNote.inspectionDue
-                        ? t('lookupInspectionDue', {
-                            source: lookupNote.source,
-                            date: format.dateTime(new Date(lookupNote.inspectionDue), {
-                              dateStyle: 'medium',
-                            }),
-                          })
-                        : t('lookupSource', { source: lookupNote.source })}
-                    </p>
-                  )}
                 </div>
                 </div>
+                {/* Under both fields, since either button may have answered */}
+                {lookupNote && (
+                  <p className="-mt-2 text-xs text-muted-foreground">
+                    {lookupNote.inspectionDue
+                      ? t('lookupInspectionDue', {
+                          source: lookupNote.source,
+                          date: format.dateTime(new Date(lookupNote.inspectionDue), {
+                            dateStyle: 'medium',
+                          }),
+                        })
+                      : t('lookupSource', { source: lookupNote.source })}
+                  </p>
+                )}
               </div>
               </div>
 
 
               <div className="grid grid-cols-2 gap-4">
               <div className="grid grid-cols-2 gap-4">

+ 28 - 32
src/features/vehicles/Components/PlateLookupButton.tsx → src/features/vehicles/Components/VehicleLookupButton.tsx

@@ -12,11 +12,11 @@ import {
   type VehicleLookup,
   type VehicleLookup,
 } from '@/features/integrations/Actions/vehicleLookupActions'
 } from '@/features/integrations/Actions/vehicleLookupActions'
 
 
-interface PlateLookupButtonProps {
-  /** The plate as typed right now; read when the button is pressed. */
-  getPlate: () => string
-  /** The VIN as typed right now, for registries that answer to a VIN rather than a plate. */
-  getVin?: () => string
+interface VehicleLookupButtonProps {
+  /** Whether this button asks by the plate or by the VIN. */
+  by: 'plate' | 'vin'
+  /** The plate or VIN as typed right now; read when the button is pressed. */
+  getValue: () => string
   /** Called with what the registry knows, for the form to apply. */
   /** Called with what the registry knows, for the form to apply. */
   onFound: (data: VehicleLookup) => void
   onFound: (data: VehicleLookup) => void
   /** Set when editing, so the answer is also recorded on the vehicle. */
   /** Set when editing, so the answer is also recorded on the vehicle. */
@@ -24,18 +24,16 @@ interface PlateLookupButtonProps {
 }
 }
 
 
 /**
 /**
- * Asks the workshop's connected vehicle registry about the plate beside it.
+ * Asks the workshop's connected vehicle registry about the plate or VIN
+ * beside it.
  *
  *
  * Availability is checked here rather than passed in, like the document
  * Availability is checked here rather than passed in, like the document
  * scanner: three dialogs render this form and none should have to know which
  * scanner: three dialogs render this form and none should have to know which
- * registries exist.
+ * registries exist. The plate button stays visible but disabled without a
+ * registry, so a workshop learns one can be connected; the VIN button only
+ * appears once something that decodes VINs is.
  */
  */
-export function PlateLookupButton({
-  getPlate,
-  getVin,
-  onFound,
-  vehicleId,
-}: PlateLookupButtonProps) {
+export function VehicleLookupButton({ by, getValue, onFound, vehicleId }: VehicleLookupButtonProps) {
   const t = useTranslations('vehicles.form')
   const t = useTranslations('vehicles.form')
   const [busy, setBusy] = useState(false)
   const [busy, setBusy] = useState(false)
   /** null while the availability check is still in flight. */
   /** null while the availability check is still in flight. */
@@ -44,45 +42,45 @@ export function PlateLookupButton({
   useEffect(() => {
   useEffect(() => {
     let active = true
     let active = true
     isVehicleLookupAvailable().then((result) => {
     isVehicleLookupAvailable().then((result) => {
-      if (active) setAvailable(result.success && result.data === true)
+      if (active) setAvailable(result.success && result.data?.[by] === true)
     })
     })
     return () => {
     return () => {
       active = false
       active = false
     }
     }
-  }, [])
+  }, [by])
 
 
   const handleClick = useCallback(async () => {
   const handleClick = useCallback(async () => {
-    const plate = getPlate().trim()
-    const vin = getVin?.().trim() ?? ''
-    if (!plate && !vin) {
-      toast.error(getVin ? t('lookupEnterPlateOrVin') : t('lookupEnterPlate'))
+    const value = getValue().trim()
+    if (!value) {
+      toast.error(by === 'vin' ? t('lookupEnterVin') : t('lookupEnterPlate'))
       return
       return
     }
     }
     setBusy(true)
     setBusy(true)
     const toastId = toast.loading(t('lookingUp'))
     const toastId = toast.loading(t('lookingUp'))
     try {
     try {
-      const result = await lookupVehicle({
-        plate: plate || undefined,
-        vin: vin || undefined,
-        vehicleId,
-      })
+      const result = await lookupVehicle({ by, value, vehicleId })
       if (!result.success) {
       if (!result.success) {
-        toast.error(result.error || t('lookupFailed'), { id: toastId })
+        toast.error(result.error || t(by === 'vin' ? 'lookupVinFailed' : 'lookupFailed'), {
+          id: toastId,
+        })
         return
         return
       }
       }
       if (!result.data) {
       if (!result.data) {
-        toast.error(t('lookupNotFound'), { id: toastId })
+        toast.error(t(by === 'vin' ? 'lookupVinNotFound' : 'lookupNotFound'), { id: toastId })
         return
         return
       }
       }
       onFound(result.data)
       onFound(result.data)
       toast.success(t('lookupSuccess', { source: result.data.source }), { id: toastId })
       toast.success(t('lookupSuccess', { source: result.data.source }), { id: toastId })
     } catch {
     } catch {
-      toast.error(t('lookupFailed'), { id: toastId })
+      toast.error(t(by === 'vin' ? 'lookupVinFailed' : 'lookupFailed'), { id: toastId })
     } finally {
     } finally {
       setBusy(false)
       setBusy(false)
     }
     }
-  }, [getPlate, getVin, onFound, vehicleId, t])
+  }, [by, getValue, onFound, vehicleId, t])
 
 
+  if (by === 'vin' && !available) return null
+
+  const label = by === 'vin' ? t('lookupVin') : t('lookupPlate')
   return (
   return (
     <Tooltip>
     <Tooltip>
       {/* A disabled button swallows pointer events, so the trigger has to be
       {/* A disabled button swallows pointer events, so the trigger has to be
@@ -93,7 +91,7 @@ export function PlateLookupButton({
             type="button"
             type="button"
             variant="outline"
             variant="outline"
             size="icon"
             size="icon"
-            aria-label={t('lookupPlate')}
+            aria-label={label}
             onClick={handleClick}
             onClick={handleClick}
             disabled={busy || !available}
             disabled={busy || !available}
           >
           >
@@ -101,9 +99,7 @@ export function PlateLookupButton({
           </Button>
           </Button>
         </span>
         </span>
       </TooltipTrigger>
       </TooltipTrigger>
-      <TooltipContent>
-        {available === false ? t('lookupUnavailable') : t('lookupPlate')}
-      </TooltipContent>
+      <TooltipContent>{available === false ? t('lookupUnavailable') : label}</TooltipContent>
     </Tooltip>
     </Tooltip>
   )
   )
 }
 }

+ 1 - 0
src/integrations/nhtsa/manifest.ts

@@ -21,6 +21,7 @@ export const manifest: ConnectorManifest = {
   docs: '/docs/integrations/nhtsa',
   docs: '/docs/integrations/nhtsa',
   auth: { type: 'api-key', fields: [] },
   auth: { type: 'api-key', fields: [] },
   capabilities: ['vehicle.lookup', SAFETY_MANIFEST.capability],
   capabilities: ['vehicle.lookup', SAFETY_MANIFEST.capability],
+  lookupBy: ['vin'],
   settings: [SAFETY_MANIFEST.setting],
   settings: [SAFETY_MANIFEST.setting],
   subscriptions: SAFETY_MANIFEST.subscriptions,
   subscriptions: SAFETY_MANIFEST.subscriptions,
   schedules: [SAFETY_MANIFEST.schedule],
   schedules: [SAFETY_MANIFEST.schedule],

+ 1 - 0
src/integrations/vegvesen/manifest.ts

@@ -24,6 +24,7 @@ export const manifest: ConnectorManifest = {
     ],
     ],
   },
   },
   capabilities: ['vehicle.lookup', INSPECTION_MANIFEST.capability],
   capabilities: ['vehicle.lookup', INSPECTION_MANIFEST.capability],
+  lookupBy: ['plate', 'vin'],
   settings: [INSPECTION_MANIFEST.setting],
   settings: [INSPECTION_MANIFEST.setting],
   schedules: [INSPECTION_MANIFEST.schedule],
   schedules: [INSPECTION_MANIFEST.schedule],
 }
 }